mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-02 10:52:22 +00:00
Merge pull request #527 from Vaso73/fix/lxc-update-session-resilience
Keep LXC updates running when the browser session expires
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { Button } from "./ui/button"
|
||||
import { fetchApi } from "../lib/api-config"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
import { viewedUpdateJobs, type LxcUpdateJob } from "../lib/lxc-update-jobs"
|
||||
|
||||
export function LxcUpdateJobs({ activeRunId, onResume }: {
|
||||
activeRunId?: string
|
||||
onResume: (job: LxcUpdateJob) => void
|
||||
}) {
|
||||
const t = useT()
|
||||
const [jobs, setJobs] = useState<LxcUpdateJob[]>([])
|
||||
const [seen, setSeen] = useState<string[]>([])
|
||||
useEffect(() => {
|
||||
let closed = false
|
||||
const read = () => {
|
||||
void fetchApi<{ jobs: LxcUpdateJob[] }>("/api/lxc-update-jobs")
|
||||
.then((data) => { if (!closed) setJobs(Array.isArray(data.jobs) ? data.jobs : []) })
|
||||
.catch(() => {}) // Auth recovery belongs to fetchApi, never to a job retry.
|
||||
}
|
||||
const viewed = () => setSeen(viewedUpdateJobs())
|
||||
viewed()
|
||||
read()
|
||||
const timer = setInterval(read, 5000)
|
||||
window.addEventListener("proxmenux-update-jobs-viewed", viewed)
|
||||
return () => {
|
||||
closed = true
|
||||
clearInterval(timer)
|
||||
window.removeEventListener("proxmenux-update-jobs-viewed", viewed)
|
||||
}
|
||||
}, [])
|
||||
const visible = jobs.filter((job) => job.id !== activeRunId
|
||||
&& (job.state !== "complete" || !seen.includes(job.id)))
|
||||
if (!visible.length) return null
|
||||
return <div className="space-y-2" role="status">
|
||||
{visible.map((job) => <div key={job.id}
|
||||
className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-violet-500/30 bg-violet-500/5 p-3">
|
||||
<p className="text-sm">{t(job.state === "complete"
|
||||
? "vmLxc.updates.backgroundFinished" : "vmLxc.updates.backgroundRunning",
|
||||
{ vmid: job.vmid, name: job.ct_name || `CT-${job.vmid}` })}</p>
|
||||
<Button variant="outline" size="sm" onClick={() => onResume(job)}>
|
||||
{t("vmLxc.updates.viewBackgroundJob")}
|
||||
</Button>
|
||||
</div>)}
|
||||
</div>
|
||||
}
|
||||
@@ -54,6 +54,8 @@ interface ScriptTerminalModalProps {
|
||||
completedSuccessfullyMessage?: string
|
||||
completedWithErrorMessage?: (exitCode: number) => string
|
||||
cancelledExitCode?: number
|
||||
// Only LXC Updates opts into a separate server-owned job transport.
|
||||
backgroundUpdate?: { runId: string; vmid: number; resume: boolean }
|
||||
// Optional callback fired when the script's WebSocket closes
|
||||
// (script_runner sends an exit code and then closes). Lets the
|
||||
// parent auto-dismiss the modal — used by host-backup's Restore
|
||||
@@ -72,6 +74,7 @@ export function ScriptTerminalModal({
|
||||
completedSuccessfullyMessage,
|
||||
completedWithErrorMessage,
|
||||
cancelledExitCode,
|
||||
backgroundUpdate,
|
||||
onComplete,
|
||||
}: ScriptTerminalModalProps) {
|
||||
const t = useT()
|
||||
@@ -81,6 +84,7 @@ export function ScriptTerminalModal({
|
||||
// after dynamic imports resolve — captures the latest value without
|
||||
// re-binding the closure.
|
||||
const isOpenRef = useRef<boolean>(false)
|
||||
const openGenerationRef = useRef(0)
|
||||
const fitAddonRef = useRef<any>(null)
|
||||
const sessionIdRef = useRef<string>(Math.random().toString(36).substring(2, 8))
|
||||
|
||||
@@ -93,6 +97,7 @@ export function ScriptTerminalModal({
|
||||
const reconnectAttemptsRef = useRef(0)
|
||||
const keepAliveIntervalRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const completionReceivedRef = useRef(false)
|
||||
const outputOffsetRef = useRef(0)
|
||||
const [isMobile, setIsMobile] = useState(false)
|
||||
const [isTablet, setIsTablet] = useState(false)
|
||||
|
||||
@@ -130,7 +135,7 @@ export function ScriptTerminalModal({
|
||||
}, [onComplete])
|
||||
|
||||
const attemptReconnect = useCallback(() => {
|
||||
if (!isOpen || isComplete || reconnectAttemptsRef.current >= 3) {
|
||||
if (!isOpenRef.current || isComplete || reconnectAttemptsRef.current >= 3) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -154,7 +159,10 @@ export function ScriptTerminalModal({
|
||||
|
||||
const wsUrl = getScriptWebSocketUrl(sessionIdRef.current)
|
||||
// Single-use auth ticket appended as ?ticket=... — see lib/terminal-ws.ts.
|
||||
const ws = new WebSocket(await getTicketedWsUrl(wsUrl))
|
||||
const generation = openGenerationRef.current
|
||||
const ticketedUrl = await getTicketedWsUrl(wsUrl)
|
||||
if (!isOpenRef.current || !termRef.current || generation !== openGenerationRef.current) return
|
||||
const ws = new WebSocket(ticketedUrl)
|
||||
wsRef.current = ws
|
||||
|
||||
ws.onopen = () => {
|
||||
@@ -170,9 +178,11 @@ export function ScriptTerminalModal({
|
||||
}
|
||||
}, 30000)
|
||||
|
||||
const initMessage = {
|
||||
const initMessage = {
|
||||
script_path: scriptPath,
|
||||
params: paramsRef.current,
|
||||
...(backgroundUpdate ? { resume: true, vmid: backgroundUpdate.vmid,
|
||||
offset: outputOffsetRef.current } : {}),
|
||||
}
|
||||
ws.send(JSON.stringify(initMessage))
|
||||
|
||||
@@ -195,7 +205,7 @@ const initMessage = {
|
||||
// The PTY worker always emits this final line. Treat it as a
|
||||
// completion fallback because some WebSocket servers tear down the
|
||||
// connection before the following structured message is flushed.
|
||||
const exitMatch = typeof event.data === "string"
|
||||
const exitMatch = !backgroundUpdate && typeof event.data === "string"
|
||||
? event.data.match(/\[Script exited with code (-?\d+)\]/)
|
||||
: null
|
||||
if (exitMatch) {
|
||||
@@ -211,6 +221,31 @@ const initMessage = {
|
||||
|
||||
try {
|
||||
const msg = JSON.parse(event.data)
|
||||
if (msg.type === "job_output") {
|
||||
setIsWaitingNextInteraction(false)
|
||||
if (waitingTimeoutRef.current) clearTimeout(waitingTimeoutRef.current)
|
||||
if (msg.truncated) termRef.current?.writeln(t("vmLxc.updates.backgroundOutputTrimmed"))
|
||||
termRef.current?.write(msg.text)
|
||||
outputOffsetRef.current = msg.offset
|
||||
return
|
||||
}
|
||||
if (msg.type === "job_unavailable") {
|
||||
setCurrentInteraction(null)
|
||||
setIsWaitingNextInteraction(false)
|
||||
if (waitingTimeoutRef.current) clearTimeout(waitingTimeoutRef.current)
|
||||
termRef.current?.writeln(`\x1b[31m${msg.message}\x1b[0m`)
|
||||
completionReceivedRef.current = true
|
||||
setIsComplete(true)
|
||||
ws.close()
|
||||
return
|
||||
}
|
||||
if (backgroundUpdate && msg.type === "web_interaction" && msg.interaction === null) {
|
||||
setCurrentInteraction(null)
|
||||
setInteractionInput("")
|
||||
setIsWaitingNextInteraction(false)
|
||||
if (waitingTimeoutRef.current) clearTimeout(waitingTimeoutRef.current)
|
||||
return
|
||||
}
|
||||
if (msg.type === "web_interaction" && msg.interaction) {
|
||||
setIsWaitingNextInteraction(false)
|
||||
if (waitingTimeoutRef.current) {
|
||||
@@ -231,6 +266,11 @@ const initMessage = {
|
||||
return
|
||||
}
|
||||
if (msg.type === "script_complete") {
|
||||
if (backgroundUpdate) {
|
||||
setCurrentInteraction(null)
|
||||
setIsWaitingNextInteraction(false)
|
||||
if (waitingTimeoutRef.current) clearTimeout(waitingTimeoutRef.current)
|
||||
}
|
||||
const exitCode = Number(msg.exit_code ?? 1)
|
||||
completionReceivedRef.current = true
|
||||
setIsComplete(true)
|
||||
@@ -255,6 +295,7 @@ const initMessage = {
|
||||
}
|
||||
|
||||
ws.onclose = (event) => {
|
||||
if (!isOpenRef.current) return
|
||||
setConnectionStatus("offline")
|
||||
if (keepAliveIntervalRef.current) {
|
||||
clearInterval(keepAliveIntervalRef.current)
|
||||
@@ -266,13 +307,17 @@ const initMessage = {
|
||||
if (!isComplete && reconnectAttemptsRef.current < 3) {
|
||||
reconnectTimeoutRef.current = setTimeout(attemptReconnect, 2000)
|
||||
} else {
|
||||
setIsComplete(true)
|
||||
onCompleteRef.current?.(-1)
|
||||
if (backgroundUpdate) {
|
||||
termRef.current?.writeln(t("vmLxc.updates.backgroundDetached"))
|
||||
} else {
|
||||
setIsComplete(true)
|
||||
onCompleteRef.current?.(-1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 1000)
|
||||
}, [isOpen, isComplete, scriptPath, getCompletionMessage, cancelledExitCode, t])
|
||||
}, [isOpen, isComplete, scriptPath, getCompletionMessage, cancelledExitCode, t, backgroundUpdate])
|
||||
|
||||
const sendKey = useCallback((key: string) => {
|
||||
if (!termRef.current) return
|
||||
@@ -300,12 +345,13 @@ const initMessage = {
|
||||
// otherwise we attach a Terminal to a stale ref and open a WS that
|
||||
// nobody reads.
|
||||
const wasOpenAtCall = isOpenRef.current
|
||||
const generation = ++openGenerationRef.current
|
||||
const [TerminalClass, FitAddonClass] = await Promise.all([
|
||||
import("xterm").then((mod) => mod.Terminal),
|
||||
import("xterm-addon-fit").then((mod) => mod.FitAddon),
|
||||
import("xterm/css/xterm.css"),
|
||||
])
|
||||
if (!wasOpenAtCall || !isOpenRef.current) return
|
||||
if (!wasOpenAtCall || !isOpenRef.current || generation !== openGenerationRef.current) return
|
||||
|
||||
const fontSize = window.innerWidth < 768 ? 12 : 16
|
||||
|
||||
@@ -366,7 +412,9 @@ const initMessage = {
|
||||
|
||||
const wsUrl = getScriptWebSocketUrl(sessionIdRef.current)
|
||||
// Single-use auth ticket appended as ?ticket=... — see lib/terminal-ws.ts.
|
||||
const ws = new WebSocket(await getTicketedWsUrl(wsUrl))
|
||||
const ticketedUrl = await getTicketedWsUrl(wsUrl)
|
||||
if (!isOpenRef.current || termRef.current !== term) return
|
||||
const ws = new WebSocket(ticketedUrl)
|
||||
wsRef.current = ws
|
||||
|
||||
ws.onopen = () => {
|
||||
@@ -384,6 +432,8 @@ const initMessage = {
|
||||
const initMessage = {
|
||||
script_path: scriptPath,
|
||||
params: paramsRef.current,
|
||||
...(backgroundUpdate ? { resume: backgroundUpdate.resume,
|
||||
vmid: backgroundUpdate.vmid, offset: outputOffsetRef.current } : {}),
|
||||
}
|
||||
ws.send(JSON.stringify(initMessage))
|
||||
|
||||
@@ -412,7 +462,7 @@ const initMessage = {
|
||||
// See the reconnect handler above. The exit line is guaranteed to be
|
||||
// sent with the PTY output and is therefore a robust fallback when a
|
||||
// final JSON frame is lost during server-side socket teardown.
|
||||
const exitMatch = typeof event.data === "string"
|
||||
const exitMatch = !backgroundUpdate && typeof event.data === "string"
|
||||
? event.data.match(/\[Script exited with code (-?\d+)\]/)
|
||||
: null
|
||||
if (exitMatch) {
|
||||
@@ -428,7 +478,32 @@ const initMessage = {
|
||||
|
||||
try {
|
||||
const msg = JSON.parse(event.data)
|
||||
if (msg.type === "job_output") {
|
||||
setIsWaitingNextInteraction(false)
|
||||
if (waitingTimeoutRef.current) clearTimeout(waitingTimeoutRef.current)
|
||||
if (msg.truncated) term.writeln(t("vmLxc.updates.backgroundOutputTrimmed"))
|
||||
term.write(msg.text)
|
||||
outputOffsetRef.current = msg.offset
|
||||
return
|
||||
}
|
||||
if (msg.type === "job_unavailable") {
|
||||
setCurrentInteraction(null)
|
||||
setIsWaitingNextInteraction(false)
|
||||
if (waitingTimeoutRef.current) clearTimeout(waitingTimeoutRef.current)
|
||||
term.writeln(`\x1b[31m${msg.message}\x1b[0m`)
|
||||
completionReceivedRef.current = true
|
||||
setIsComplete(true)
|
||||
ws.close()
|
||||
return
|
||||
}
|
||||
|
||||
if (backgroundUpdate && msg.type === "web_interaction" && msg.interaction === null) {
|
||||
setCurrentInteraction(null)
|
||||
setInteractionInput("")
|
||||
setIsWaitingNextInteraction(false)
|
||||
if (waitingTimeoutRef.current) clearTimeout(waitingTimeoutRef.current)
|
||||
return
|
||||
}
|
||||
if (msg.type === "web_interaction" && msg.interaction) {
|
||||
setIsWaitingNextInteraction(false)
|
||||
if (waitingTimeoutRef.current) {
|
||||
@@ -450,6 +525,11 @@ const initMessage = {
|
||||
return
|
||||
}
|
||||
if (msg.type === "script_complete") {
|
||||
if (backgroundUpdate) {
|
||||
setCurrentInteraction(null)
|
||||
setIsWaitingNextInteraction(false)
|
||||
if (waitingTimeoutRef.current) clearTimeout(waitingTimeoutRef.current)
|
||||
}
|
||||
const exitCode = Number(msg.exit_code ?? 1)
|
||||
completionReceivedRef.current = true
|
||||
setIsComplete(true)
|
||||
@@ -478,6 +558,7 @@ const initMessage = {
|
||||
}
|
||||
|
||||
ws.onclose = (event) => {
|
||||
if (!isOpenRef.current) return
|
||||
setConnectionStatus("offline")
|
||||
if (!completionReceivedRef.current) {
|
||||
term.writeln(`\x1b[33m${t("scriptTerminal.connectionClosed")}\x1b[0m`)
|
||||
@@ -489,8 +570,13 @@ const initMessage = {
|
||||
}
|
||||
|
||||
if (!completionReceivedRef.current && !isComplete) {
|
||||
setIsComplete(true)
|
||||
onCompleteRef.current?.(-1)
|
||||
if (backgroundUpdate) {
|
||||
term.writeln(t("vmLxc.updates.backgroundDetached"))
|
||||
attemptReconnect()
|
||||
} else {
|
||||
setIsComplete(true)
|
||||
onCompleteRef.current?.(-1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -552,6 +638,7 @@ const initMessage = {
|
||||
if (isOpen) {
|
||||
initializeTerminal()
|
||||
} else {
|
||||
openGenerationRef.current++
|
||||
if (checkConnectionInterval.current) {
|
||||
clearInterval(checkConnectionInterval.current)
|
||||
}
|
||||
@@ -578,6 +665,7 @@ const initMessage = {
|
||||
sessionIdRef.current = Math.random().toString(36).substring(2, 8)
|
||||
reconnectAttemptsRef.current = 0
|
||||
completionReceivedRef.current = false
|
||||
outputOffsetRef.current = 0
|
||||
setIsComplete(false)
|
||||
setInteractionInput("")
|
||||
setCurrentInteraction(null)
|
||||
@@ -586,6 +674,28 @@ const initMessage = {
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
useEffect(() => () => {
|
||||
if (!backgroundUpdate) return // Transport mode is fixed for this instance.
|
||||
// Conditional parents can unmount an open modal. Detach that viewer too;
|
||||
// only the background job owns its process and completion result.
|
||||
isOpenRef.current = false
|
||||
openGenerationRef.current++
|
||||
for (const ref of [checkConnectionInterval, keepAliveIntervalRef]) {
|
||||
if (ref.current) clearInterval(ref.current)
|
||||
}
|
||||
for (const ref of [waitingTimeoutRef, reconnectTimeoutRef]) {
|
||||
if (ref.current) clearTimeout(ref.current)
|
||||
}
|
||||
if (wsRef.current) {
|
||||
wsRef.current.onclose = null
|
||||
wsRef.current.onmessage = null
|
||||
wsRef.current.close()
|
||||
wsRef.current = null
|
||||
}
|
||||
termRef.current?.dispose()
|
||||
termRef.current = null
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const updateDeviceType = () => {
|
||||
const width = window.innerWidth
|
||||
@@ -641,8 +751,11 @@ const initMessage = {
|
||||
}, [isOpen, isComplete, attemptReconnect])
|
||||
|
||||
const getScriptWebSocketUrl = (sid: string): string => {
|
||||
const route = backgroundUpdate
|
||||
? `/ws/lxc-update/${encodeURIComponent(backgroundUpdate.runId)}`
|
||||
: `/ws/script/${sid}`
|
||||
if (typeof window === "undefined") {
|
||||
return `ws://localhost:${API_PORT}/ws/script/${sid}`
|
||||
return `ws://localhost:${API_PORT}${route}`
|
||||
}
|
||||
|
||||
const { protocol, hostname, port } = window.location
|
||||
@@ -650,9 +763,9 @@ const initMessage = {
|
||||
const wsProtocol = protocol === "https:" ? "wss:" : "ws:"
|
||||
|
||||
if (isStandardPort) {
|
||||
return `${wsProtocol}//${hostname}/ws/script/${sid}`
|
||||
return `${wsProtocol}//${hostname}${route}`
|
||||
} else {
|
||||
return `${wsProtocol}//${hostname}:${API_PORT}/ws/script/${sid}`
|
||||
return `${wsProtocol}//${hostname}:${API_PORT}${route}`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -664,6 +777,10 @@ const initMessage = {
|
||||
if (value === "cancel" || value === "") {
|
||||
setCurrentInteraction(null)
|
||||
setInteractionInput("")
|
||||
if (backgroundUpdate) {
|
||||
wsRef.current.send(JSON.stringify({ type: "cancel" }))
|
||||
return
|
||||
}
|
||||
handleCloseModal()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ import useSWR from "swr"
|
||||
import { MetricsView } from "./metrics-dialog"
|
||||
import { LxcTerminalModal } from "./lxc-terminal-modal"
|
||||
import { ScriptTerminalModal } from "./script-terminal-modal"
|
||||
import { LxcUpdateJobs } from "./lxc-update-jobs"
|
||||
import { markUpdateJobViewed, type LxcUpdateJob } from "../lib/lxc-update-jobs"
|
||||
import { OCI_CANCELLED_EXIT_CODE, shouldRefreshOciAfterCompletion } from "@/lib/script-completion"
|
||||
import { LxcAppPanel, ThemeAwareLogo, ociImageLabel } from "./lxc-app-panel"
|
||||
import { GuestBackupList } from "./guest-backup-list"
|
||||
@@ -1998,6 +2000,7 @@ export function VirtualMachines() {
|
||||
const [applyRestart, setApplyRestart] = useState(false)
|
||||
const [applyStartedAt, setApplyStartedAt] = useState<number>(0)
|
||||
const [applyRunId, setApplyRunId] = useState<string>("")
|
||||
const [applyResume, setApplyResume] = useState(false)
|
||||
const [applyCheckedOs, setApplyCheckedOs] = useState(false)
|
||||
const [applyTargetIds, setApplyTargetIds] = useState<string[]>([])
|
||||
const [applyTargetLabels, setApplyTargetLabels] = useState<string[]>([])
|
||||
@@ -2639,6 +2642,7 @@ export function VirtualMachines() {
|
||||
setApplyVmid(vmid)
|
||||
setApplyTarget(target)
|
||||
setApplyRunId(runId)
|
||||
setApplyResume(false)
|
||||
setApplyCheckedOs(opts?.checkedOs === true)
|
||||
setApplyTargetIds(opts?.targetIds?.length ? opts.targetIds : fallbackTargets)
|
||||
setApplyTargetLabels(opts?.targetLabels?.length ? opts.targetLabels : fallbackLabels)
|
||||
@@ -2659,27 +2663,37 @@ export function VirtualMachines() {
|
||||
setApplyStartedAt(Date.now())
|
||||
setApplyOpen(true)
|
||||
}
|
||||
const handleApplyComplete = async (exitCode = 0) => {
|
||||
const resumeApplyJob = (job: LxcUpdateJob) => {
|
||||
setApplyVmid(job.vmid)
|
||||
setApplyTarget(job.target)
|
||||
setApplyRunId(job.id)
|
||||
setApplyCheckedOs(job.script_path.endsWith("/install_checked_os_updates.sh"))
|
||||
setApplyResume(true)
|
||||
setApplyStartedAt(job.created_at * 1000)
|
||||
setApplyOpen(true)
|
||||
}
|
||||
const handleApplyComplete = async (exitCode = 0, notifyBackend = true) => {
|
||||
if (applyVmid == null) return
|
||||
const duration = Math.max(0, Math.round((Date.now() - applyStartedAt) / 1000))
|
||||
// The modal fires onComplete on any WS close (success or user cancel).
|
||||
// Report the same run ID so the backend can collapse this compatibility
|
||||
// callback with its authoritative process-completion hook.
|
||||
// Legacy callers may report the same run ID to the compatibility route.
|
||||
// Background Updates only refresh the view after the server's real exit.
|
||||
try {
|
||||
await fetchApi(`/api/lxc-updates/${applyVmid}/applied`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
success: exitCode === 0,
|
||||
run_id: applyRunId,
|
||||
target: applyTarget,
|
||||
requested_targets: applyTargetIds,
|
||||
target_labels: applyTargetLabels,
|
||||
duration_seconds: duration,
|
||||
ct_name: selectedVM?.name || `CT-${applyVmid}`,
|
||||
refresh_docker_inventory: applyDockerRefresh,
|
||||
}),
|
||||
})
|
||||
if (notifyBackend) {
|
||||
await fetchApi(`/api/lxc-updates/${applyVmid}/applied`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
success: exitCode === 0,
|
||||
run_id: applyRunId,
|
||||
target: applyTarget,
|
||||
requested_targets: applyTargetIds,
|
||||
target_labels: applyTargetLabels,
|
||||
duration_seconds: duration,
|
||||
ct_name: selectedVM?.name || `CT-${applyVmid}`,
|
||||
refresh_docker_inventory: applyDockerRefresh,
|
||||
}),
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal — the backend process hook owns finalization.
|
||||
}
|
||||
@@ -2697,6 +2711,12 @@ export function VirtualMachines() {
|
||||
// performs the requests (the data map is only a render seed), so no
|
||||
// invalidation is needed and there is no "Loading applications" flash.
|
||||
void fetchLxcApps(applyVmid)
|
||||
// Recovery can be opened from the job banner while another CT is selected.
|
||||
// Refresh the operated guest without changing that other guest's result UI.
|
||||
if (selectedVMRef.current?.vmid !== applyVmid) {
|
||||
void mutate()
|
||||
return
|
||||
}
|
||||
// Enter the "Comprobando resultado…" state on the Updates tab.
|
||||
// Baseline snapshot lets a downstream useEffect detect when the
|
||||
// SWR poll actually delivers the post-apply counts (as opposed
|
||||
@@ -7960,6 +7980,8 @@ export function VirtualMachines() {
|
||||
/>
|
||||
)}
|
||||
|
||||
<LxcUpdateJobs activeRunId={applyOpen ? applyRunId : undefined} onResume={resumeApplyJob} />
|
||||
|
||||
{/* LXC Update Apply Terminal — streams the apply_updates.sh
|
||||
script over WS/PTY (same wiring as the hardware installers).
|
||||
The backend finalizes on process exit; the close callback only
|
||||
@@ -7971,7 +7993,13 @@ export function VirtualMachines() {
|
||||
setApplyOpen(false)
|
||||
setApplyVmid(null)
|
||||
}}
|
||||
onComplete={handleApplyComplete}
|
||||
onComplete={(exitCode) => {
|
||||
markUpdateJobViewed(applyRunId)
|
||||
// The server-owned job is the sole completion authority.
|
||||
void handleApplyComplete(exitCode ?? 1, false)
|
||||
}}
|
||||
backgroundUpdate={{ runId: applyRunId, vmid: applyVmid, resume: applyResume }}
|
||||
cancelledExitCode={130}
|
||||
scriptPath={applyCheckedOs
|
||||
? "/usr/local/share/proxmenux/scripts/lxc/install_checked_os_updates.sh"
|
||||
: "/usr/local/share/proxmenux/scripts/lxc/apply_updates.sh"}
|
||||
@@ -7981,13 +8009,7 @@ export function VirtualMachines() {
|
||||
completedWithErrorMessage={(exitCode) =>
|
||||
t("vmLxc.updates.terminalCompletedWithError", { code: exitCode })
|
||||
}
|
||||
description={
|
||||
applyTarget === "os"
|
||||
? t("vmLxc.updates.terminalDescriptionOs")
|
||||
: applyTarget === "app"
|
||||
? t("vmLxc.updates.terminalDescriptionApp")
|
||||
: t("vmLxc.updates.terminalDescriptionBoth")
|
||||
}
|
||||
description={t("vmLxc.updates.backgroundDescription")}
|
||||
params={{
|
||||
VMID: String(applyVmid),
|
||||
RUN_ID: applyRunId,
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/** Opaque viewer metadata only; updater commands and credentials stay server-side. */
|
||||
export type LxcUpdateJob = {
|
||||
id: string
|
||||
vmid: number
|
||||
state: "running" | "finalizing" | "complete"
|
||||
target: "os" | "app" | "both"
|
||||
ct_name: string
|
||||
script_path: string
|
||||
created_at: number
|
||||
exit_code: number | null
|
||||
}
|
||||
|
||||
const SEEN = "proxmenux-viewed-update-jobs"
|
||||
|
||||
export function viewedUpdateJobs(): string[] {
|
||||
try {
|
||||
const value = JSON.parse(localStorage.getItem(SEEN) || "[]")
|
||||
return Array.isArray(value) ? value.filter((id) => typeof id === "string").slice(-128) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function markUpdateJobViewed(id: string) {
|
||||
try {
|
||||
localStorage.setItem(SEEN, JSON.stringify([...new Set([...viewedUpdateJobs(), id])].slice(-128)))
|
||||
window.dispatchEvent(new Event("proxmenux-update-jobs-viewed"))
|
||||
} catch {
|
||||
// Unavailable browser storage never controls or restarts server work.
|
||||
}
|
||||
}
|
||||
@@ -1551,6 +1551,12 @@
|
||||
"installedVersionUnverified": "Installed version is unverified. Update availability could not be confirmed.",
|
||||
"versionStatusUnknown": "Update availability is unverified. Version information is unavailable or incomplete.",
|
||||
"terminalTitle": "Apply updates — CT {vmid}",
|
||||
"backgroundRunning": "{name} (CT {vmid}): an update is running on the server.",
|
||||
"backgroundOutputTrimmed": "Earlier output was trimmed; the update continues normally.",
|
||||
"backgroundFinished": "{name} (CT {vmid}): an update has finished. View its result.",
|
||||
"viewBackgroundJob": "View update",
|
||||
"backgroundDetached": "The viewer disconnected. The update continues on the server; reconnect after logging in.",
|
||||
"backgroundDescription": "Closing this window leaves the update running. Use Ctrl+C to cancel it.",
|
||||
"terminalDescriptionOs": "Applying OS package updates inside the container...",
|
||||
"terminalDescriptionApp": "Running the application updater inside the container...",
|
||||
"terminalDescriptionBoth": "Applying OS package and application updates inside the container...",
|
||||
|
||||
@@ -1425,6 +1425,12 @@
|
||||
"installedVersionUnverified": "Nainštalovaná verzia nie je overená. Dostupnosť aktualizácie sa nepodarilo potvrdiť.",
|
||||
"versionStatusUnknown": "Dostupnosť aktualizácie nie je overená. Údaje o verzii chýbajú alebo sú neúplné.",
|
||||
"terminalTitle": "Aktualizácia — CT {vmid}",
|
||||
"backgroundRunning": "{name} (CT {vmid}): na serveri prebieha aktualizácia.",
|
||||
"backgroundOutputTrimmed": "Starší výpis bol skrátený; aktualizácia pokračuje normálne.",
|
||||
"backgroundFinished": "{name} (CT {vmid}): aktualizácia sa skončila. Pozrite si výsledok.",
|
||||
"viewBackgroundJob": "Zobraziť aktualizáciu",
|
||||
"backgroundDetached": "Spojenie s terminálom sa prerušilo. Aktualizácia na serveri pokračuje; po prihlásení sa znova pripojte.",
|
||||
"backgroundDescription": "Zatvorením okna sa aktualizácia nezastaví. Zrušiť ju môžete cez Ctrl+C.",
|
||||
"terminalDescriptionOs": "Aktualizujem balíky systému v kontajneri...",
|
||||
"terminalDescriptionApp": "Spúšťam aktualizáciu aplikácie v kontajneri...",
|
||||
"terminalDescriptionBoth": "Aktualizujem systém aj aplikácie v kontajneri...",
|
||||
|
||||
@@ -148,6 +148,7 @@ cp "$SCRIPT_DIR/health_thresholds.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "
|
||||
cp "$SCRIPT_DIR/managed_installs.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ managed_installs.py not found"
|
||||
cp "$SCRIPT_DIR/lxc_update_observation.py" "$APP_DIR/usr/bin/"
|
||||
cp "$SCRIPT_DIR/lxc_update_finalization.py" "$APP_DIR/usr/bin/"
|
||||
cp "$SCRIPT_DIR/lxc_update_jobs.py" "$APP_DIR/usr/bin/"
|
||||
cp "$SCRIPT_DIR/lxc_check_job.py" "$APP_DIR/usr/bin/"
|
||||
cp "$SCRIPT_DIR/native_app_discovery.py" "$APP_DIR/usr/bin/"
|
||||
cp "$SCRIPT_DIR/local_app_icons.py" "$APP_DIR/usr/bin/"
|
||||
|
||||
@@ -134,6 +134,72 @@ def _run_script_completion_hook(script_path, params, exit_code, duration_seconds
|
||||
except Exception as exc:
|
||||
print(f"[ProxMenux] script completion hook failed: {exc}", flush=True)
|
||||
|
||||
|
||||
@terminal_bp.route('/api/lxc-update-jobs', methods=['GET'])
|
||||
@require_admin_scope
|
||||
def lxc_update_jobs_status():
|
||||
"""Read-only recovery after login. This never creates or restarts a job."""
|
||||
from lxc_update_jobs import list_jobs
|
||||
return jsonify({'jobs': list_jobs()})
|
||||
|
||||
|
||||
@sock.route('/ws/lxc-update/<run_id>')
|
||||
def lxc_update_viewer(ws, run_id):
|
||||
"""An authenticated viewer, deliberately not the owner of the update."""
|
||||
if not _ws_auth_check():
|
||||
ws.close(reason=1008, message="Authentication required")
|
||||
return
|
||||
from lxc_update_jobs import get, start
|
||||
try:
|
||||
init = json.loads(ws.receive(timeout=10))
|
||||
if not isinstance(init, dict) or type(init.get('resume')) is not bool:
|
||||
raise ValueError("Invalid update job request")
|
||||
offset = init.get('offset', 0)
|
||||
if type(offset) is not int or offset < 0:
|
||||
raise ValueError("Invalid update output cursor")
|
||||
if init['resume']:
|
||||
job = get(run_id, init.get('vmid'))
|
||||
else:
|
||||
job = start(run_id, init.get('script_path'), init.get('params'),
|
||||
_run_script_completion_hook)
|
||||
revision = -1
|
||||
while True:
|
||||
view = job.read(offset)
|
||||
if view['text'] or view['offset'] != offset:
|
||||
ws.send(json.dumps({'type': 'job_output', 'text': view['text'],
|
||||
'offset': view['offset'], 'truncated': view['truncated']}))
|
||||
offset = view['offset']
|
||||
if view['revision'] != revision:
|
||||
revision = view['revision']
|
||||
# A second viewer must also dismiss a question already
|
||||
# answered elsewhere, or cleared when the job ends.
|
||||
ws.send(json.dumps({'type': 'web_interaction',
|
||||
'interaction': view['interaction']}))
|
||||
if view['state'] == 'complete':
|
||||
ws.send(json.dumps({'type': 'script_complete', 'exit_code': view['exit_code']}))
|
||||
break
|
||||
data = ws.receive(timeout=0.2)
|
||||
if data is None:
|
||||
continue
|
||||
try:
|
||||
message = json.loads(data)
|
||||
except (TypeError, ValueError):
|
||||
message = data
|
||||
if isinstance(message, dict) and message.get('type') == 'ping':
|
||||
ws.send('{"type":"pong"}')
|
||||
else:
|
||||
job.input(message if isinstance(message, dict) else data)
|
||||
except (ValueError, KeyError, OSError, TypeError) as exc:
|
||||
try:
|
||||
ws.send(json.dumps({'type': 'job_unavailable', 'message': str(exc)}))
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
# Viewer loss, including auth expiry/reload, has no job side effects.
|
||||
pass
|
||||
# No process termination, PTY close or completion callback here. The job's
|
||||
# worker alone owns those, even while no viewer is authenticated/connected.
|
||||
|
||||
@terminal_bp.route('/api/terminal/health', methods=['GET'])
|
||||
def terminal_health():
|
||||
"""Health check for terminal service"""
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
"""Browser-independent owners for explicitly confirmed LXC Updates runs.
|
||||
|
||||
Only the two Updates entrypoints are allowed. Import/list/attach never runs
|
||||
anything. Interactive /ws/script consumers retain their original lifecycle.
|
||||
Jobs survive viewer loss, not a Monitor/host restart; missing resume IDs fail
|
||||
closed. The update shell still owns the shared scheduler/control/check flock.
|
||||
"""
|
||||
import codecs
|
||||
import errno
|
||||
import json
|
||||
import os
|
||||
import pty
|
||||
import re
|
||||
import select
|
||||
import signal
|
||||
import struct
|
||||
import subprocess
|
||||
import tempfile
|
||||
import termios
|
||||
import fcntl
|
||||
import threading
|
||||
import time
|
||||
|
||||
SCRIPTS = {
|
||||
"/usr/local/share/proxmenux/scripts/lxc/apply_updates.sh",
|
||||
"/usr/local/share/proxmenux/scripts/lxc/install_checked_os_updates.sh",
|
||||
}
|
||||
PARAMS = {
|
||||
"VMID", "RUN_ID", "CT_NAME", "TARGET", "REQUESTED_TARGETS_JSON",
|
||||
"TARGET_LABELS_JSON", "BACKUP", "BACKUP_STORAGE", "RESTART",
|
||||
"UPDATE_COMMAND", "APP_NAME", "RUN_HELPER", "ALLOW_HELPER_WITH_CUSTOM",
|
||||
"DOCKER_STANDALONE_TARGETS", "UPDATE_DOCKER_ENGINE",
|
||||
"REFRESH_DOCKER_INVENTORY",
|
||||
}
|
||||
RUN_ID = re.compile(r"^[A-Za-z0-9_-]{1,128}$")
|
||||
INTERACTION_ID = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
|
||||
MAX_RUNTIME = 4 * 3600
|
||||
# The shared runner restores a guest it started in its EXIT trap. Its package
|
||||
# observation and `pct shutdown --timeout 60` need time after SIGTERM, too.
|
||||
STOP_GRACE = 300
|
||||
RESULT_TTL = 6 * 3600
|
||||
MAX_OUTPUT = 256 * 1024
|
||||
MAX_INPUT = 64 * 1024
|
||||
MAX_JOBS = 32
|
||||
_lock = threading.RLock()
|
||||
_jobs = {}
|
||||
|
||||
|
||||
class JobConflict(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def _validate(run_id, script, params):
|
||||
if not isinstance(run_id, str) or not RUN_ID.fullmatch(run_id):
|
||||
raise ValueError("Invalid update job ID")
|
||||
if script not in SCRIPTS or os.path.realpath(script) != script:
|
||||
raise ValueError("Only the LXC Updates entrypoints support background runs")
|
||||
if not isinstance(params, dict) or set(params) - PARAMS:
|
||||
raise ValueError("Invalid update parameters")
|
||||
if any(not isinstance(v, str) or len(v) > 65536 for v in params.values()):
|
||||
raise ValueError("Invalid update parameter value")
|
||||
if len(json.dumps(params)) > 128 * 1024:
|
||||
raise ValueError("Update parameters are too large")
|
||||
if params.get("RUN_ID") != run_id:
|
||||
raise ValueError("Update run ID does not match")
|
||||
vmid = params.get("VMID", "")
|
||||
if not re.fullmatch(r"[1-9][0-9]{0,8}", vmid):
|
||||
raise ValueError("Invalid container ID")
|
||||
if params.get("TARGET") not in ("os", "app", "both"):
|
||||
raise ValueError("Invalid update target")
|
||||
return int(vmid)
|
||||
|
||||
|
||||
class UpdateJob:
|
||||
def __init__(self, run_id, script, params, callback):
|
||||
self.id, self.script, self.params = run_id, script, dict(params)
|
||||
self.vmid = int(params["VMID"])
|
||||
self.created_at = time.time()
|
||||
self.started = time.monotonic()
|
||||
self.finished_at = None
|
||||
self.exit_code = None
|
||||
self.state = "running"
|
||||
self.output = ""
|
||||
self.offset = 0
|
||||
self.interaction = None
|
||||
self.revision = 0
|
||||
self.lock = threading.RLock()
|
||||
self.callback = callback
|
||||
self.stop_reason = None
|
||||
self.stop_at = None
|
||||
self.master = None
|
||||
self.process = None
|
||||
self.log_path = None
|
||||
self._interaction_buffer = ""
|
||||
self._input = bytearray()
|
||||
|
||||
def launch(self):
|
||||
master, slave = pty.openpty()
|
||||
self.master = master
|
||||
try:
|
||||
# A viewer's paste must never block the worker's output, timeout
|
||||
# or cancellation when the updater does not read its stdin.
|
||||
os.set_blocking(master, False)
|
||||
fd, self.log_path = tempfile.mkstemp(prefix="proxmenux_update_", suffix=".log")
|
||||
os.close(fd)
|
||||
fcntl.ioctl(master, termios.TIOCSWINSZ, struct.pack("HHHH", 30, 120, 0, 0))
|
||||
env = dict(os.environ, **self.params, EXECUTION_MODE="web",
|
||||
WEB_LOG=self.log_path, TERM="xterm-256color", PYTHONUNBUFFERED="1")
|
||||
self.process = subprocess.Popen(
|
||||
["/bin/bash", self.script], stdin=slave, stdout=slave, stderr=slave,
|
||||
start_new_session=True, env=env,
|
||||
)
|
||||
except BaseException:
|
||||
os.close(master)
|
||||
self.master = None
|
||||
if self.log_path:
|
||||
os.unlink(self.log_path)
|
||||
raise
|
||||
finally:
|
||||
os.close(slave)
|
||||
try:
|
||||
threading.Thread(target=self._run, daemon=True,
|
||||
name=f"lxc-update-{self.id}").start()
|
||||
except BaseException:
|
||||
# A failed worker start must not leave an untracked updater alive.
|
||||
try:
|
||||
os.killpg(self.process.pid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
self.process.wait()
|
||||
os.close(master)
|
||||
self.master = None
|
||||
os.unlink(self.log_path)
|
||||
raise
|
||||
|
||||
def _append(self, text):
|
||||
with self.lock:
|
||||
self.output = (self.output + text)[-MAX_OUTPUT:]
|
||||
self.offset += len(text)
|
||||
|
||||
def _interactions(self, stream):
|
||||
self._interaction_buffer += stream.read(16384)
|
||||
if len(self._interaction_buffer) > 65536:
|
||||
self._interaction_buffer = ""
|
||||
return
|
||||
lines = self._interaction_buffer.split("\n")
|
||||
self._interaction_buffer = lines.pop()
|
||||
for line in lines:
|
||||
if not line.startswith("WEB_INTERACTION:"):
|
||||
continue
|
||||
try:
|
||||
import base64
|
||||
parts = line.rstrip("\n")[16:].split(":", 4)
|
||||
kind, identity, title, message = parts[:4]
|
||||
if not INTERACTION_ID.fullmatch(identity):
|
||||
continue
|
||||
value = dict(type=kind, id=identity,
|
||||
title=base64.b64decode(title).decode(),
|
||||
message=base64.b64decode(message).decode())
|
||||
if len(parts) > 4:
|
||||
if kind == "menu":
|
||||
value["options"] = json.loads(parts[4])
|
||||
elif kind in ("input", "inputbox"):
|
||||
value["default"] = base64.b64decode(parts[4]).decode()
|
||||
with self.lock:
|
||||
if not self.stop_reason:
|
||||
self.interaction = value
|
||||
self.revision += 1
|
||||
except (ValueError, UnicodeError, IndexError):
|
||||
continue
|
||||
|
||||
def _signal(self, reason):
|
||||
with self.lock:
|
||||
if self.process.poll() is not None or self.stop_reason:
|
||||
return
|
||||
self.stop_reason, self.stop_at = reason, time.monotonic()
|
||||
self._input.clear()
|
||||
self.interaction = None
|
||||
self.revision += 1
|
||||
try:
|
||||
os.killpg(self.process.pid, signal.SIGTERM)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
|
||||
def cancel(self):
|
||||
self._signal("cancelled")
|
||||
|
||||
def _write_input(self):
|
||||
# The descriptor is nonblocking, so even a full PTY cannot hold this
|
||||
# lock. Retain unwritten bytes in order until the child can read them.
|
||||
with self.lock:
|
||||
if not self._input or self.stop_reason:
|
||||
return
|
||||
try:
|
||||
written = os.write(self.master, self._input[:16384])
|
||||
except BlockingIOError:
|
||||
return
|
||||
except OSError as exc:
|
||||
if exc.errno != errno.EIO:
|
||||
raise
|
||||
# The child can close its terminal and keep working. Losing
|
||||
# stdin must not turn that update into a viewer failure.
|
||||
self._input.clear()
|
||||
return
|
||||
del self._input[:written]
|
||||
|
||||
def _run(self):
|
||||
decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
|
||||
eof, exited_at = False, None
|
||||
try:
|
||||
with open(self.log_path, encoding="utf-8", errors="replace") as interactions:
|
||||
while True:
|
||||
now = time.monotonic()
|
||||
running = self.process.poll() is None
|
||||
if not running:
|
||||
exited_at = exited_at or now
|
||||
if eof or now - exited_at >= 1:
|
||||
break
|
||||
if time.monotonic() - self.started >= MAX_RUNTIME:
|
||||
self._signal("timeout")
|
||||
if running and self.stop_at and now - self.stop_at >= STOP_GRACE:
|
||||
try:
|
||||
os.killpg(self.process.pid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
self._interactions(interactions)
|
||||
with self.lock:
|
||||
pending_input = bool(self._input) and running and not self.stop_reason
|
||||
readable, writable, _ = select.select(
|
||||
[] if eof else [self.master],
|
||||
[self.master] if pending_input else [], [], 0.05)
|
||||
if writable:
|
||||
self._write_input()
|
||||
if readable:
|
||||
try:
|
||||
data = os.read(self.master, 16384)
|
||||
except BlockingIOError:
|
||||
continue
|
||||
except OSError as exc:
|
||||
if exc.errno != errno.EIO:
|
||||
raise
|
||||
data = b""
|
||||
if data:
|
||||
self._append(decoder.decode(data))
|
||||
continue
|
||||
eof = True
|
||||
code = self.process.wait()
|
||||
self._append(decoder.decode(b"", final=True))
|
||||
if self.stop_reason:
|
||||
code = 124 if self.stop_reason == "timeout" else 130
|
||||
except BaseException as exc:
|
||||
self._append(f"\r\nUpdate viewer failed: {type(exc).__name__}\r\n")
|
||||
self._signal("viewer failure")
|
||||
try:
|
||||
self.process.wait(timeout=STOP_GRACE)
|
||||
except subprocess.TimeoutExpired:
|
||||
os.killpg(self.process.pid, signal.SIGKILL)
|
||||
self.process.wait()
|
||||
code = 1
|
||||
finally:
|
||||
os.close(self.master)
|
||||
self.master = None
|
||||
try:
|
||||
os.unlink(self.log_path)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
with self.lock:
|
||||
self.exit_code = code
|
||||
self.state = "finalizing"
|
||||
self.interaction = None
|
||||
self._input.clear()
|
||||
self.revision += 1
|
||||
try:
|
||||
self.callback(self.script, self.params, code, time.monotonic() - self.started)
|
||||
finally:
|
||||
with self.lock:
|
||||
self.state = "complete"
|
||||
self.finished_at = time.time()
|
||||
self.revision += 1
|
||||
|
||||
def view(self):
|
||||
with self.lock:
|
||||
return dict(id=self.id, vmid=self.vmid, state=self.state,
|
||||
target=self.params["TARGET"], ct_name=self.params.get("CT_NAME", ""),
|
||||
created_at=self.created_at, finished_at=self.finished_at,
|
||||
exit_code=self.exit_code, script_path=self.script)
|
||||
|
||||
def read(self, offset):
|
||||
with self.lock:
|
||||
begin = self.offset - len(self.output)
|
||||
cursor = max(begin, min(offset, self.offset))
|
||||
return dict(text=self.output[cursor - begin:], offset=self.offset,
|
||||
truncated=offset < begin, interaction=self.interaction,
|
||||
revision=self.revision, state=self.state, exit_code=self.exit_code)
|
||||
|
||||
def input(self, message):
|
||||
with self.lock:
|
||||
if self.state != "running" or self.master is None:
|
||||
return
|
||||
if isinstance(message, str):
|
||||
if len(message) > 16384:
|
||||
raise ValueError("Terminal input is too large")
|
||||
if message == "\x03":
|
||||
self.cancel()
|
||||
else:
|
||||
data = message.encode()
|
||||
if len(self._input) + len(data) > MAX_INPUT:
|
||||
raise ValueError("Terminal input buffer is full")
|
||||
if not self.stop_reason:
|
||||
self._input.extend(data)
|
||||
elif message.get("type") == "cancel":
|
||||
self.cancel()
|
||||
elif message.get("type") == "resize":
|
||||
cols = max(20, min(400, int(message.get("cols", 120))))
|
||||
rows = max(5, min(200, int(message.get("rows", 30))))
|
||||
fcntl.ioctl(self.master, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0))
|
||||
elif message.get("type") == "interaction_response":
|
||||
identity, value = message.get("id"), message.get("value")
|
||||
if (not self.interaction or identity != self.interaction["id"]
|
||||
or not isinstance(value, str) or len(value) > 16384):
|
||||
raise ValueError("No matching pending interaction")
|
||||
path = f"/tmp/proxmenux_response_{identity}"
|
||||
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
|
||||
with os.fdopen(fd, "w") as response:
|
||||
response.write(value)
|
||||
self.interaction = None
|
||||
self.revision += 1
|
||||
|
||||
|
||||
def start(run_id, script, params, callback):
|
||||
vmid = _validate(run_id, script, params)
|
||||
with _lock:
|
||||
existing = _jobs.get(run_id)
|
||||
if existing:
|
||||
if existing.script != script or existing.params != params:
|
||||
raise JobConflict("Update job ID already belongs to another request")
|
||||
return existing
|
||||
_prune()
|
||||
if any(j.vmid == vmid and j.state != "complete" for j in _jobs.values()):
|
||||
raise JobConflict("An update is already running for this container")
|
||||
if len(_jobs) >= MAX_JOBS:
|
||||
completed = [j for j in _jobs.values() if j.state == "complete"]
|
||||
if not completed:
|
||||
raise JobConflict("Too many active update jobs; try again later")
|
||||
oldest = min(completed, key=lambda j: j.finished_at)
|
||||
del _jobs[oldest.id]
|
||||
job = UpdateJob(run_id, script, params, callback)
|
||||
_jobs[run_id] = job
|
||||
try:
|
||||
job.launch()
|
||||
except BaseException:
|
||||
_jobs.pop(run_id, None)
|
||||
raise
|
||||
return job
|
||||
|
||||
|
||||
def _prune():
|
||||
now = time.time()
|
||||
for identity, job in list(_jobs.items()):
|
||||
if job.finished_at and now - job.finished_at > RESULT_TTL:
|
||||
del _jobs[identity]
|
||||
|
||||
|
||||
def get(run_id, vmid):
|
||||
with _lock:
|
||||
job = _jobs.get(run_id)
|
||||
if job is None or job.vmid != int(vmid):
|
||||
raise KeyError("Update job is unavailable; it was not restarted")
|
||||
return job
|
||||
|
||||
|
||||
def list_jobs():
|
||||
with _lock:
|
||||
_prune()
|
||||
return [j.view() for j in sorted(_jobs.values(), key=lambda j: j.created_at, reverse=True)]
|
||||
Reference in New Issue
Block a user