Merge pull request #551 from Vaso73/fix/host-update-recovery

Keep Proxmox updates running across sign-out and show pending reboots
This commit is contained in:
MacRimi
2026-10-28 23:24:38 +01:00
committed by GitHub
40 changed files with 3448 additions and 53 deletions
+14
View File
@@ -13,6 +13,15 @@ import sys
import tempfile import tempfile
PYTHON_FILES = ( PYTHON_FILES = (
"tests/test_host_update_jobs.py",
"tests/test_host_update_transport.py",
"tests/test_host_update_shell.py",
"tests/test_host_update_auth.py",
"tests/test_kernel_reboot.py",
"tests/test_reboot_health_refresh.py",
"tests/test_host_update_controls.py",
"tests/test_host_update_packaging.py",
"tests/test_host_update_version.py",
"tests/test_lxc_update_jobs.py", "tests/test_lxc_update_jobs.py",
"tests/test_post_install_i18n.py", "tests/test_post_install_i18n.py",
"tests/test_remaining_cli_i18n.py", "tests/test_remaining_cli_i18n.py",
@@ -34,6 +43,11 @@ PYTHON_FILES = (
"AppImage/scripts/tests/test_health_persistence_recurrence.py", "AppImage/scripts/tests/test_health_persistence_recurrence.py",
) )
NODE_FILES = ( NODE_FILES = (
"tests/test_host_update_ui.cjs",
"tests/test_reboot_health_refresh.cjs",
"tests/test_host_update_controls.cjs",
"tests/test_reboot_recovery.cjs",
"tests/test_proxmenux_version_row.cjs",
"tests/test_lxc_update_jobs.cjs", "tests/test_lxc_update_jobs.cjs",
"tests/test_api_import_boundaries.cjs", "tests/test_api_import_boundaries.cjs",
"tests/test_api_errors.cjs", "tests/test_api_errors.cjs",
@@ -69,13 +69,22 @@ class TerminalCatalogsTests(unittest.TestCase):
def test_shipped_active_catalogs_keep_literal_contracts(self): def test_shipped_active_catalogs_keep_literal_contracts(self):
result = subprocess.run( result = subprocess.run(
[sys.executable, '-B', str(SCRIPTS / 'check_terminal_catalogs.py'), [sys.executable, '-B', str(SCRIPTS / 'check_terminal_catalogs.py'),
'--root', str(ROOT), '--require-complete'], '--root', str(ROOT)],
capture_output=True, text=True, timeout=60, capture_output=True, text=True, timeout=60,
) )
self.assertEqual(result.returncode, 0, result.stdout + result.stderr) self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
for locale in MODULE.DEFAULT_LANGUAGES: for locale in MODULE.DEFAULT_LANGUAGES:
self.assertIn(f'[{locale}] active=', result.stdout) self.assertIn(f'[{locale}] active=', result.stdout)
self.assertNotIn('missing=1', result.stdout) # New strings are reviewed in Slovak. Other locales keep the runtime
# English fallback until the translation bot supplies their values;
# still validate every translation already present in those locales.
result = subprocess.run(
[sys.executable, '-B', str(SCRIPTS / 'check_terminal_catalogs.py'),
'--root', str(ROOT), '--languages', 'sk', '--require-complete'],
capture_output=True, text=True, timeout=60,
)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertIn('missing=0', result.stdout)
def test_optional_completeness_still_rejects_invalid_present_translations(self): def test_optional_completeness_still_rejects_invalid_present_translations(self):
with tempfile.TemporaryDirectory() as temporary: with tempfile.TemporaryDirectory() as temporary:
+1 -1
View File
@@ -69,7 +69,7 @@ jobs:
with: with:
python-version: '3.11' python-version: '3.11'
- name: Install native discovery route fixture dependency - name: Install native discovery route fixture dependency
run: python3 -m pip install 'Flask>=3,<4' 'Pillow==12.3.0' 'defusedxml==0.7.1' run: python3 -m pip install 'Flask>=3,<4' 'Pillow==12.3.0' 'defusedxml==0.7.1' 'PyJWT>=2,<3' 'gevent>=25,<27' 'flask-sock==0.7.0' requests
- name: Run qualified Python files - name: Run qualified Python files
env: env:
PYTHONDONTWRITEBYTECODE: '1' PYTHONDONTWRITEBYTECODE: '1'
+132 -21
View File
@@ -2,7 +2,7 @@
import type React from "react" import type React from "react"
import { useState, useEffect, useCallback } from "react" import { useState, useEffect, useCallback, useRef } from "react"
import { getAuthToken } from "@/lib/api-config" import { getAuthToken } from "@/lib/api-config"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog" import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
@@ -41,6 +41,9 @@ import {
HelpCircle, HelpCircle,
} from "lucide-react" } from "lucide-react"
import { ScriptTerminalModal } from "./script-terminal-modal" import { ScriptTerminalModal } from "./script-terminal-modal"
import { HostUpdateJobs, type HostUpdateJob } from "./host-update-jobs"
import { HostUpdateResult } from "./host-update-result"
import { RestartAfterUpdate, ProxMenuxVersionCard } from "./host-update-status"
import { useT } from "@/lib/i18n/provider" import { useT } from "@/lib/i18n/provider"
import { useIsPbs } from "@/lib/use-platform" import { useIsPbs } from "@/lib/use-platform"
@@ -126,12 +129,23 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
const [dismissedItems, setDismissedItems] = useState<DismissedError[]>([]) const [dismissedItems, setDismissedItems] = useState<DismissedError[]>([])
const [customSuppressions, setCustomSuppressions] = useState<CustomSuppression[]>([]) const [customSuppressions, setCustomSuppressions] = useState<CustomSuppression[]>([])
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [snapshotUnavailable, setSnapshotUnavailable] = useState(false)
const [dismissingKey, setDismissingKey] = useState<string | null>(null) const [dismissingKey, setDismissingKey] = useState<string | null>(null)
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set()) const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set())
const [showUpdateTerminal, setShowUpdateTerminal] = useState(false) const [showUpdateTerminal, setShowUpdateTerminal] = useState(false)
const [hostJob, setHostJob] = useState<{ runId: string; resume: boolean; kind: "host" } | null>(null)
const [hostJobs, setHostJobs] = useState<HostUpdateJob[]>([])
const [resultJobId, setResultJobId] = useState<string | null>(null)
const healthRequest = useRef<AbortController | null>(null)
const previousAutoExpanded = useRef<Set<string>>(new Set())
const fetchHealthDetails = useCallback(async (force = false) => { const fetchHealthDetails = useCallback(async (force = false, cachedOnly = false) => {
setLoading(true) if (cachedOnly && healthRequest.current) return
healthRequest.current?.abort()
const controller = new AbortController()
healthRequest.current = controller
const timeout = cachedOnly ? setTimeout(() => controller.abort(), 10000) : undefined
if (!cachedOnly) setLoading(true)
setError(null) setError(null)
try { try {
@@ -144,14 +158,26 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
authHeaders["Authorization"] = `Bearer ${token}` authHeaders["Authorization"] = `Bearer ${token}`
} }
const response = await fetch(getApiUrl(force ? "/api/health/full?refresh=1" : "/api/health/full"), { headers: authHeaders }) const path = cachedOnly ? "/api/health/full?cached=1" : force ? "/api/health/full?refresh=1" : "/api/health/full"
const response = await fetch(getApiUrl(path), { headers: authHeaders, signal: controller.signal })
if (healthRequest.current !== controller) return
let infoCount = 0 let infoCount = 0
if (!response.ok) { if (!response.ok) {
if (cachedOnly && response.status === 503) {
// Completion invalidates the snapshot before the collector refills it.
// Keep the dated result, explicitly unavailable; never start an audit.
setSnapshotUnavailable(true)
return
}
// Polling cannot fall back to a legacy endpoint that runs a full audit.
if (cachedOnly) throw new Error(t("healthStatus.errors.fetchFailed"))
// Fallback to legacy endpoint // Fallback to legacy endpoint
const legacyResponse = await fetch(getApiUrl("/api/health/details"), { headers: authHeaders }) const legacyResponse = await fetch(getApiUrl("/api/health/details"), { headers: authHeaders, signal: controller.signal })
if (!legacyResponse.ok) throw new Error(t("healthStatus.errors.fetchFailed")) if (!legacyResponse.ok) throw new Error(t("healthStatus.errors.fetchFailed"))
const data = await legacyResponse.json() const data = await legacyResponse.json()
if (healthRequest.current !== controller) return
setSnapshotUnavailable(false)
setHealthData(data) setHealthData(data)
setDismissedItems([]) setDismissedItems([])
setCustomSuppressions([]) setCustomSuppressions([])
@@ -168,6 +194,9 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
} }
} else { } else {
const fullData: FullHealthData = await response.json() const fullData: FullHealthData = await response.json()
if (healthRequest.current !== controller) return
if (!fullData?.health?.details) throw new Error(t("healthStatus.errors.fetchFailed"))
setSnapshotUnavailable(false)
setHealthData(fullData.health) setHealthData(fullData.health)
setDismissedItems(fullData.dismissed || []) setDismissedItems(fullData.dismissed || [])
setCustomSuppressions(fullData.custom_suppressions || []) setCustomSuppressions(fullData.custom_suppressions || [])
@@ -207,9 +236,16 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
}) })
window.dispatchEvent(event) window.dispatchEvent(event)
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : t("healthStatus.errors.unknown")) if (healthRequest.current === controller) {
setSnapshotUnavailable(false)
setError(err instanceof Error ? err.message : t("healthStatus.errors.unknown"))
}
} finally { } finally {
setLoading(false) if (timeout !== undefined) clearTimeout(timeout)
if (healthRequest.current === controller) {
healthRequest.current = null
setLoading(false)
}
} }
}, [getApiUrl, t]) }, [getApiUrl, t])
@@ -225,9 +261,21 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
useEffect(() => { useEffect(() => {
if (open) { if (open) {
fetchHealthDetails() fetchHealthDetails()
// Auto-refresh every 5 minutes while modal is open // Recheck reboot evidence promptly without running APT/full health checks.
const refreshInterval = setInterval(() => fetchHealthDetails(), 300000) const refresh = () => {
return () => clearInterval(refreshInterval) if (document.visibilityState === "visible") void fetchHealthDetails(false, true)
}
const refreshInterval = setInterval(refresh, 30000)
window.addEventListener("focus", refresh)
document.addEventListener("visibilitychange", refresh)
return () => {
clearInterval(refreshInterval)
window.removeEventListener("focus", refresh)
document.removeEventListener("visibilitychange", refresh)
const controller = healthRequest.current
healthRequest.current = null
controller?.abort()
}
} }
}, [open, fetchHealthDetails]) }, [open, fetchHealthDetails])
@@ -245,7 +293,11 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
nonOkCategories.add(key) nonOkCategories.add(key)
} }
}) })
setExpandedCategories(nonOkCategories) const newlyDegraded = [...nonOkCategories].filter(key => !previousAutoExpanded.current.has(key))
previousAutoExpanded.current = nonOkCategories
if (newlyDegraded.length) {
setExpandedCategories(previous => new Set([...previous, ...newlyDegraded]))
}
} }
}, [healthData]) }, [healthData])
@@ -300,6 +352,10 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
const translateHealthText = (value?: string): string => { const translateHealthText = (value?: string): string => {
if (!value) return "" if (!value) return ""
// Health summaries include the category before the same detail text.
if (value.startsWith("Updates: ")) {
return `${t("healthStatus.categories.updates")}: ${translateHealthText(value.slice(9))}`
}
const exact: Record<string, string> = { const exact: Record<string, string> = {
"No temperature sensor detected - install lm-sensors if hardware supports it": t("healthStatus.details.noTemperatureSensor"), "No temperature sensor detected - install lm-sensors if hardware supports it": t("healthStatus.details.noTemperatureSensor"),
"Sustained high CPU usage": t("healthStatus.details.cpuHighSustained"), "Sustained high CPU usage": t("healthStatus.details.cpuHighSustained"),
@@ -330,6 +386,10 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
"Active": t("healthStatus.details.active"), "Active": t("healthStatus.details.active"),
"UP": t("healthStatus.details.up"), "UP": t("healthStatus.details.up"),
"Kernel/PVE up to date": t("healthStatus.details.kernelUpToDate"), "Kernel/PVE up to date": t("healthStatus.details.kernelUpToDate"),
"No reboot required": t("healthStatus.hostUpdate.noReboot"),
"No pending updates": t("healthStatus.details.noPendingUpdates"),
"Reboot required by the operating system": t("healthStatus.hostUpdate.rebootMarker"),
"Could not determine whether a reboot is required": t("healthStatus.hostUpdate.rebootUnknown"),
"Proxmox VE is up to date": t("healthStatus.details.proxmoxUpToDate"), "Proxmox VE is up to date": t("healthStatus.details.proxmoxUpToDate"),
"No security updates pending": t("healthStatus.details.noSecurityUpdates"), "No security updates pending": t("healthStatus.details.noSecurityUpdates"),
"No container startup errors": t("healthStatus.details.noContainerErrors"), "No container startup errors": t("healthStatus.details.noContainerErrors"),
@@ -341,6 +401,8 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
if (Object.prototype.hasOwnProperty.call(exact, value)) return exact[value] if (Object.prototype.hasOwnProperty.call(exact, value)) return exact[value]
let match = value.match(/^Latency ([\d.]+)ms to gateway$/) let match = value.match(/^Latency ([\d.]+)ms to gateway$/)
const reboot = value.match(/^Reboot required: running (.+); installed (.+)$/)
if (reboot) return t("healthStatus.hostUpdate.rebootKernel", { running: reboot[1], installed: reboot[2] })
if (match) return t("healthStatus.details.gatewayLatency", { latency: match[1] }) if (match) return t("healthStatus.details.gatewayLatency", { latency: match[1] })
match = value.match(/^(\d+) failed login attempts in 24h$/) match = value.match(/^(\d+) failed login attempts in 24h$/)
if (match) return t("healthStatus.details.failedLogins", { count: match[1] }) if (match) return t("healthStatus.details.failedLogins", { count: match[1] })
@@ -623,7 +685,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
"smart_health", "io_errors", "zfs_pools", "lvm_volumes", "lvm_check", "connectivity", "smart_health", "io_errors", "zfs_pools", "lvm_volumes", "lvm_check", "connectivity",
"qmp_communication", "container_startup", "vm_startup", "oom_killer", "cluster_mode", "qmp_communication", "container_startup", "vm_startup", "oom_killer", "cluster_mode",
"log_error_cascade", "log_error_spike", "log_persistent_errors", "log_critical_errors", "log_error_cascade", "log_error_spike", "log_persistent_errors", "log_critical_errors",
"pve_version", "security_updates", "system_age", "pending_updates", "kernel_pve", "uptime", "pve_version", "security_updates", "system_age", "pending_updates", "kernel_pve", "uptime", "reboot_required",
"certificates", "login_attempts", "fail2ban", "proxmox_storages", "certificates", "login_attempts", "fail2ban", "proxmox_storages",
]) ])
if (knownKeys.has(key)) return t(`healthStatus.checks.${key}`) if (knownKeys.has(key)) return t(`healthStatus.checks.${key}`)
@@ -634,6 +696,17 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
.replace(/\b\w/g, (c) => c.toUpperCase()) .replace(/\b\w/g, (c) => c.toUpperCase())
} }
const translateHealthSummary = (snapshot: HealthDetails): string => {
if (snapshot.summary === "Updates") {
const reboot = snapshot.details?.updates?.checks?.reboot_required
if (reboot?.status === "INFO" && typeof reboot.detail === "string" && reboot.detail) {
return translateHealthText(reboot.detail)
}
return t("healthStatus.categories.updates")
}
return translateHealthText(snapshot.summary)
}
const renderChecks = ( const renderChecks = (
checks: Record<string, { status: string; detail: string; dismissable?: boolean; [key: string]: any }>, checks: Record<string, { status: string; detail: string; dismissable?: boolean; [key: string]: any }>,
categoryKey: string categoryKey: string
@@ -645,6 +718,12 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
{Object.entries(checks) {Object.entries(checks)
.filter(([, checkData]) => checkData.installed !== false) .filter(([, checkData]) => checkData.installed !== false)
.map(([checkKey, checkData]) => { .map(([checkKey, checkData]) => {
if (categoryKey === "updates" && checkKey === "reboot_required" && checkData.status === "INFO") {
return <RestartAfterUpdate key={checkKey} getApiUrl={getApiUrl}
unavailable={loading || snapshotUnavailable || !!error}
disabled={hostJobs.some(job => job.state !== "complete")} label={formatCheckLabel(checkKey)}
detail={translateHealthText(checkData.detail)} icon={getStatusIcon(checkData.status, "sm")} />
}
const isDismissable = checkData.dismissable === true const isDismissable = checkData.dismissable === true
const checkStatus = checkData.status?.toUpperCase() || "OK" const checkStatus = checkData.status?.toUpperCase() || "OK"
@@ -711,6 +790,14 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<HostUpdateJobs open={open} activeId={showUpdateTerminal ? hostJob?.runId : undefined}
onJobs={setHostJobs} onSettled={() => { void fetchHealthDetails(true) }}
onResume={job => {
if (job.state === "complete") { setResultJobId(job.id); return }
setHostJob({ runId: job.id, resume: true, kind: "host" })
setShowUpdateTerminal(true)
}} />
{loading && ( {loading && (
<div className="flex items-center justify-center py-8"> <div className="flex items-center justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin text-primary" /> <Loader2 className="h-8 w-8 animate-spin text-primary" />
@@ -724,8 +811,15 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
</div> </div>
)} )}
{healthData && !loading && ( {snapshotUnavailable && (
<div className="space-y-4"> <div role="status" className="p-3 rounded-lg border border-amber-500/30 bg-amber-500/5 text-xs text-amber-600 dark:text-amber-400">
<p>{t("healthStatus.snapshotUnavailable")}</p>
{healthData && <p className="mt-1">{t("healthStatus.lastKnownResults")}</p>}
</div>
)}
{healthData && (
<fieldset disabled={loading} className="space-y-4 min-w-0">
{/* Overall Stats Summary */} {/* Overall Stats Summary */}
<div className={`grid gap-2 sm:gap-3 p-3 sm:p-4 rounded-lg bg-muted/30 border ${stats.info > 0 ? "grid-cols-5" : "grid-cols-4"}`}> <div className={`grid gap-2 sm:gap-3 p-3 sm:p-4 rounded-lg bg-muted/30 border ${stats.info > 0 ? "grid-cols-5" : "grid-cols-4"}`}>
<div className="text-center"> <div className="text-center">
@@ -760,7 +854,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
{healthData.summary && healthData.summary !== "All systems operational" && ( {healthData.summary && healthData.summary !== "All systems operational" && (
<div className="text-xs sm:text-sm p-3 rounded-lg bg-muted/20 border overflow-hidden max-w-full"> <div className="text-xs sm:text-sm p-3 rounded-lg bg-muted/20 border overflow-hidden max-w-full">
<p className="font-medium text-foreground break-words whitespace-pre-wrap">{translateHealthText(healthData.summary)}</p> <p className="font-medium text-foreground break-words whitespace-pre-wrap">{translateHealthSummary(healthData)}</p>
</div> </div>
)} )}
@@ -845,18 +939,30 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
pending prevents the operator from spawning a pending prevents the operator from spawning a
terminal that would only report "System is terminal that would only report "System is
already up to date". */} already up to date". */}
{key === "updates" && status?.toUpperCase() !== "OK" && ( {key === "updates" && Number(categoryData?.count ?? 0) > 0 && (
<div className="flex justify-end px-3 py-2 pt-1"> <p className="px-2 sm:px-3 mt-2 text-[10px] sm:text-xs text-muted-foreground">
{t("healthStatus.packagePlanNote")}
</p>
)}
{key === "updates" && Number(categoryData?.count ?? 0) > 0 && (
<div className="flex justify-end px-2 sm:px-3 pt-3 pb-1">
<Button <Button
size="sm" size="sm"
onClick={() => setShowUpdateTerminal(true)} disabled={hostJobs.some(job => job.state !== "complete")}
className="bg-purple-600/15 hover:bg-purple-600/25 border border-purple-500/40 text-purple-300 hover:text-purple-200" onClick={() => {
const runId = window.crypto?.randomUUID?.()
|| `host-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`
setHostJob({ runId, resume: false, kind: "host" })
setShowUpdateTerminal(true)
}}
className="h-10 sm:h-[34px] bg-purple-600/15 hover:bg-purple-600/25 border border-purple-500/40 text-purple-300 hover:text-purple-200"
> >
<ArrowUpCircle className="h-4 w-4 mr-1.5" /> <ArrowUpCircle className="h-4 w-4 mr-1.5" />
{t("healthStatus.updateNow")} {t("healthStatus.updateSystem")}
</Button> </Button>
</div> </div>
)} )}
{key === "updates" && <ProxMenuxVersionCard getApiUrl={getApiUrl} />}
</div> </div>
)} )}
</div> </div>
@@ -964,11 +1070,16 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
{t("healthStatus.lastUpdated", { date: new Date(healthData.timestamp).toLocaleString(document.documentElement.lang) })} {t("healthStatus.lastUpdated", { date: new Date(healthData.timestamp).toLocaleString(document.documentElement.lang) })}
</div> </div>
)} )}
</div> </fieldset>
)} )}
</DialogContent> </DialogContent>
<HostUpdateResult jobId={resultJobId} getApiUrl={getApiUrl} onClose={() => setResultJobId(null)} />
<ScriptTerminalModal <ScriptTerminalModal
key={hostJob?.runId || "host-update"}
open={showUpdateTerminal} open={showUpdateTerminal}
backgroundUpdate={hostJob || undefined}
cancelledExitCode={130}
onComplete={() => { void fetchHealthDetails(true) }}
onClose={() => { onClose={() => {
setShowUpdateTerminal(false) setShowUpdateTerminal(false)
// Force a fresh read (cache-busting via ?refresh=1) so the // Force a fresh read (cache-busting via ?refresh=1) so the
+67
View File
@@ -0,0 +1,67 @@
"use client"
import { useEffect, useRef, useState } from "react"
import { Button } from "./ui/button"
import { fetchApi } from "../lib/api-config"
import { useT } from "../lib/i18n/provider"
export type HostUpdateJob = {
id: string
state: "starting" | "running" | "attention" | "finalizing" | "complete"
exit_code: number | null
}
export function HostUpdateJobs({ open, activeId, onResume, onJobs, onSettled }: {
open: boolean
activeId?: string
onResume: (job: HostUpdateJob) => void
onJobs: (jobs: HostUpdateJob[]) => void
onSettled: () => void
}) {
const t = useT()
const [jobs, setJobs] = useState<HostUpdateJob[]>([])
const [hidden, setHidden] = useState<Set<string>>(new Set())
const callbacks = useRef({ onJobs, onSettled })
callbacks.current = { onJobs, onSettled }
const observed = useRef(new Set<string>())
useEffect(() => {
if (!open) return
let closed = false
const read = async () => {
try {
const data = await fetchApi<{ jobs: HostUpdateJob[] }>("/api/host-update-jobs")
if (closed) return
const next = Array.isArray(data.jobs) ? data.jobs : []
setJobs(next)
callbacks.current.onJobs(next)
for (const job of next) {
if (job.state === "complete" && !observed.current.has(job.id)) {
observed.current.add(job.id)
callbacks.current.onSettled()
}
}
} catch {
// Authentication recovery belongs to fetchApi. Never retry a start.
}
}
void read()
const timer = setInterval(() => { void read() }, 5000)
return () => { closed = true; clearInterval(timer) }
}, [open])
const visible = jobs.filter(job => job.id !== activeId && !(job.state === "complete" && hidden.has(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(`healthStatus.hostUpdate.${job.state === "complete" ? "finished" : job.state === "attention" ? "attention" : "running"}`)}</p>
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" onClick={() => onResume(job)}>
{t(job.state === "complete" ? "healthStatus.hostUpdate.viewResult" : "healthStatus.hostUpdate.view")}
</Button>
{job.state === "complete" && <Button size="sm" variant="ghost" onClick={() => setHidden(previous => new Set([...previous, job.id]))}>
{t("healthStatus.hostUpdate.hideResult")}
</Button>}
</div>
</div>)}
</div>
}
@@ -0,0 +1,77 @@
"use client"
import { useEffect, useRef, useState } from "react"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "./ui/dialog"
import { Button } from "./ui/button"
import { getAuthToken } from "../lib/api-config"
import { scriptCompletion } from "../lib/script-completion"
import { useT } from "../lib/i18n/provider"
import "xterm/css/xterm.css"
export function HostUpdateResult({ jobId, getApiUrl, onClose }: {
jobId: string | null
getApiUrl: (path: string) => string
onClose: () => void
}) {
const t = useT()
const container = useRef<HTMLDivElement>(null)
const [revision, setRevision] = useState(0)
const [exitCode, setExitCode] = useState<number | null>(null)
const [failed, setFailed] = useState(false)
useEffect(() => {
if (!jobId) return
const controller = new AbortController()
let terminal: import("xterm").Terminal | undefined
let observer: ResizeObserver | undefined
let disposed = false
setExitCode(null); setFailed(false)
const timeout = setTimeout(() => controller.abort(), 10000)
void (async () => {
try {
const token = getAuthToken()
const response = await fetch(getApiUrl(`/api/host-update-jobs/${encodeURIComponent(jobId)}/result`), {
headers: token ? { Authorization: `Bearer ${token}` } : {}, signal: controller.signal,
})
if (!response.ok) throw Error("unavailable")
const value = await response.json()
if (value.state !== "complete" || !Number.isSafeInteger(value.exit_code)
|| typeof value.text !== "string" || value.text.length > 256 * 1024
|| typeof value.truncated !== "boolean") throw Error("invalid result")
const [{ Terminal }, { FitAddon }] = await Promise.all([import("xterm"), import("xterm-addon-fit")])
if (controller.signal.aborted || !container.current) return
terminal = new Terminal({ disableStdin: true, convertEol: true, fontSize: 12,
screenReaderMode: true, theme: { background: "#000000", foreground: "#dddddd" } })
const fit = new FitAddon()
terminal.loadAddon(fit); terminal.open(container.current); fit.fit()
if (value.truncated) terminal.writeln(t("vmLxc.updates.backgroundOutputTrimmed"))
terminal.write(value.text)
observer = new ResizeObserver(() => { if (!controller.signal.aborted) fit.fit() })
observer.observe(container.current)
setExitCode(value.exit_code)
} catch {
if (!disposed) setFailed(true)
} finally { clearTimeout(timeout) }
})()
return () => { disposed = true; controller.abort(); clearTimeout(timeout); observer?.disconnect(); terminal?.dispose() }
}, [jobId, getApiUrl, revision, t])
const kind = exitCode === null ? null : scriptCompletion(exitCode, 130).kind
return <Dialog open={jobId !== null} onOpenChange={open => { if (!open) onClose() }}>
<DialogContent className="max-w-4xl w-[95vw] max-h-[85vh] overflow-y-auto p-4 sm:p-5">
<DialogHeader><DialogTitle>{t("healthStatus.hostUpdate.resultTitle")}</DialogTitle>
<DialogDescription>{t("healthStatus.hostUpdate.resultReadOnly")}</DialogDescription></DialogHeader>
{failed && <div role="alert" className="text-sm text-amber-500">
<p>{t("healthStatus.hostUpdate.resultUnavailable")}</p>
<Button size="sm" variant="outline" className="mt-3" onClick={() => setRevision(value => value + 1)}>{t("healthStatus.hostUpdate.retryResult")}</Button>
</div>}
<div ref={container} className="h-[45vh] min-h-[180px] bg-black rounded overflow-hidden" />
<div className="flex items-center justify-between gap-3">
<p role="status" className={`text-xs ${exitCode === 0 ? "text-green-500" : "text-muted-foreground"}`}>
{kind === null ? t(failed ? "healthStatus.hostUpdate.resultUnavailable" : "healthStatus.hostUpdate.loadingResult")
: kind === "cancelled" ? t("scriptTerminal.cancelled") : exitCode === 0
? t("scriptTerminal.completedSuccessfully") : t("scriptTerminal.completedWithError", { code: exitCode })}
</p>
<Button size="sm" variant="outline" onClick={onClose}>{t("healthStatus.hostUpdate.closeResult")}</Button>
</div>
</DialogContent>
</Dialog>
}
+177
View File
@@ -0,0 +1,177 @@
"use client"
import { useEffect, useRef, useState, type ReactNode } from "react"
import * as Confirm from "@radix-ui/react-alert-dialog"
import { Button } from "./ui/button"
import { getAuthToken } from "@/lib/api-config"
import { useT } from "@/lib/i18n/provider"
import { CheckCircle2, AlertTriangle, Info, ChevronRight } from "lucide-react"
function headers() {
const token = getAuthToken()
return { "Content-Type": "application/json", ...(token ? { Authorization: `Bearer ${token}` } : {}) }
}
type Url = (path: string) => string
type Context = { hostname: string; boot_id: string; allowed: boolean; reason: string }
const compact = "h-10 sm:h-[34px] px-3 text-xs"
export function RestartAfterUpdate({ getApiUrl, disabled, unavailable = false, label, detail, icon }: {
getApiUrl: Url; disabled: boolean; unavailable?: boolean; label: string; detail: string; icon: ReactNode
}) {
const t = useT()
const [open, setOpen] = useState(false)
const [context, setContext] = useState<Context | null>(null)
const [submitted, setSubmitted] = useState(false)
const [pending, setPending] = useState(false)
const [accepted, setAccepted] = useState(false)
const [error, setError] = useState(false)
const sending = useRef(false)
const alive = useRef(true)
const postRequest = useRef<AbortController | null>(null)
useEffect(() => { alive.current = true; return () => { alive.current = false; postRequest.current?.abort() } }, [])
useEffect(() => {
let disposed = false, reading = false
let active: AbortController | undefined
setContext(null); setSubmitted(false); setError(false); sending.current = false
const read = async () => {
if (reading) return
reading = true
const controller = new AbortController(); active = controller
const timeout = setTimeout(() => controller.abort(), 6000)
try {
const response = await fetch(getApiUrl("/api/health/reboot-context"), { headers: headers(), signal: controller.signal })
if (!response.ok) throw Error("unavailable")
const value: Context = await response.json()
if (!value || typeof value.hostname !== "string" || typeof value.allowed !== "boolean" || typeof value.boot_id !== "string" || !value.boot_id) throw Error("invalid")
if (!disposed) { setContext(value); setError(false) }
} catch { if (!disposed) { setContext(null); setError(true) } }
finally { clearTimeout(timeout); reading = false }
}
void read()
// A retained health snapshot must never grant permission after reconnect
// or remount. This cheap read does not run APT or a full health check.
const interval = setInterval(read, open ? 2000 : 30000)
return () => { disposed = true; clearInterval(interval); active?.abort() }
}, [open, getApiUrl])
useEffect(() => {
if (!accepted) return
let disposed = false, reading = false
const controller = new AbortController()
const interval = setInterval(async () => {
if (reading) return
reading = true
try {
const response = await fetch(getApiUrl("/api/health/reboot-after-update/status"), { headers: headers(), signal: controller.signal })
if (!response.ok) return
const value = await response.json()
if (!disposed && (value.state === "failed" || value.state === "cancelled")) { setAccepted(false); setError(true) }
} catch { /* Loss of connection does not prove reboot completion. */ }
finally { reading = false }
}, 2000)
const timeout = setTimeout(() => { clearInterval(interval); controller.abort() }, 10000)
return () => { disposed = true; controller.abort(); clearInterval(interval); clearTimeout(timeout) }
}, [accepted, getApiUrl])
const confirm = async () => {
if (disabled || unavailable || !context?.allowed || sending.current || submitted) return
sending.current = true; setSubmitted(true); setPending(true); setError(false)
const controller = new AbortController(); postRequest.current = controller
const timeout = setTimeout(() => controller.abort(), 15000)
try {
const response = await fetch(getApiUrl("/api/health/reboot-after-update"), {
method: "POST", headers: headers(), body: JSON.stringify({ confirm: true, hostname: context.hostname, boot_id: context.boot_id }), signal: controller.signal,
})
if (response.status !== 202 || (await response.json()).accepted !== true) throw Error("unconfirmed")
if (alive.current) { setAccepted(true); setOpen(false) }
} catch { if (alive.current) setError(true) }
finally { clearTimeout(timeout); postRequest.current = null; if (alive.current) setPending(false) }
}
return <section className="py-1.5 px-2 sm:px-3">
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<div className="flex items-start gap-2 flex-1 min-w-0 text-[10px] sm:text-xs"><span className="shrink-0">{icon}</span>
<div><span className="font-medium mr-2">{label}</span><span className="text-muted-foreground break-words">{detail}</span></div></div>
<Button variant="outline" className={`${compact} self-start sm:shrink-0 border-amber-500/40 text-amber-500`}
disabled={disabled || unavailable || !context?.allowed || pending || accepted} onClick={() => setOpen(true)}>{t("healthStatus.restart.button")}</Button>
</div>
{disabled && <p className="mt-2 text-xs text-muted-foreground">{t("healthStatus.restart.blocked")}</p>}
{accepted && <p role="status" className="mt-2 text-xs text-blue-500">{t("healthStatus.restart.accepted")}</p>}
{error && !open && <p role="alert" className="mt-2 text-xs text-amber-500">{t("healthStatus.restart.unconfirmed")}</p>}
<Confirm.Root open={open} onOpenChange={setOpen}><Confirm.Portal>
<Confirm.Overlay className="fixed inset-0 bg-black/75 z-[70]" />
<Confirm.Content className="fixed z-[80] w-[92vw] max-w-[520px] rounded-lg border bg-card text-foreground p-5 shadow-lg" style={{ top: "50%", left: "50%", transform: "translate(-50%,-50%)" }}>
<Confirm.Title className="text-base font-semibold">{t("healthStatus.restart.title", { server: context?.hostname ?? "…" })}</Confirm.Title>
<Confirm.Description className="mt-3 text-sm text-muted-foreground">{t("healthStatus.restart.impact")}</Confirm.Description>
{(disabled || context?.reason === "busy") && <p className="mt-3 text-xs text-amber-500">{t("healthStatus.restart.blocked")}</p>}
{context?.reason === "current" && <p className="mt-3 text-xs text-muted-foreground">{t("healthStatus.hostUpdate.noReboot")}</p>}
{(error || context?.reason === "unknown") && <p role="alert" className="mt-3 text-xs text-amber-500">{t("healthStatus.restart.unconfirmed")}</p>}
<div className="mt-5 flex justify-end gap-2.5">
<Confirm.Cancel asChild><Button variant="outline" className={compact} disabled={pending}>{t("healthStatus.restart.cancel")}</Button></Confirm.Cancel>
<Confirm.Action asChild><Button variant="destructive" className={compact} disabled={disabled || unavailable || !context?.allowed || pending || submitted}
onClick={event => { event.preventDefault(); void confirm() }}>{t("healthStatus.restart.confirm")}</Button></Confirm.Action>
</div>
</Confirm.Content>
</Confirm.Portal></Confirm.Root>
</section>
}
type Versions = { installed: string | null; available: string | null; state: "available" | "current" | "unknown"; checked_at: string | null; channel: "stable" | "beta" }
export function ProxMenuxVersionCard({ getApiUrl }: { getApiUrl: Url }) {
const t = useT()
const [value, setValue] = useState<Versions | null>(null)
useEffect(() => {
let disposed = false, reading = false
let active: AbortController | undefined
const read = async () => {
if (reading || document.visibilityState !== "visible") return
reading = true
const controller = new AbortController(); active = controller
const timeout = setTimeout(() => controller.abort(), 6000)
try {
const response = await fetch(getApiUrl("/api/health/proxmenux-version"), { headers: headers(), signal: controller.signal })
if (!response.ok) throw Error("unavailable")
const data: Versions = await response.json()
if (!["available", "current", "unknown"].includes(data?.state)) throw Error("invalid")
const validVersion = (version: unknown) => typeof version === "string" && /^\d+(?:\.\d+){1,5}$/.test(version)
if (!["stable", "beta"].includes(data.channel) ||
(data.installed !== null && !validVersion(data.installed)) ||
(data.available !== null && !validVersion(data.available)) ||
(data.state !== "unknown" && (!validVersion(data.installed) || !validVersion(data.available) || !data.checked_at)) ||
(data.checked_at !== null && !Number.isFinite(Date.parse(data.checked_at)))) throw Error("invalid")
if (!disposed) setValue(data)
} catch { if (!disposed) setValue(previous => previous ? { ...previous, state: "unknown" } : null) }
finally { clearTimeout(timeout); reading = false }
}
void read()
const interval = setInterval(read, 300000)
window.addEventListener("focus", read); document.addEventListener("visibilitychange", read)
return () => { disposed = true; active?.abort(); clearInterval(interval); window.removeEventListener("focus", read); document.removeEventListener("visibilitychange", read) }
}, [getApiUrl])
const state = value?.state ?? "unknown"
const versions = <dl className="grid grid-cols-2 gap-4">
<div><dt className="text-muted-foreground mb-1">{t("healthStatus.versions.installed")}</dt><dd className="font-mono font-semibold">{value?.installed ?? t("healthStatus.versions.unverified")}</dd></div>
<div><dt className="text-muted-foreground mb-1">{t(state === "unknown" && value?.available ? "healthStatus.versions.lastKnown" : "healthStatus.versions.latest")}</dt><dd className="font-mono font-semibold">{value?.available ?? t("healthStatus.versions.unverified")}</dd></div>
</dl>
const metadata = <>
{value && <p className="mt-3 text-muted-foreground">{t(`healthStatus.versions.${value.channel}`)}</p>}
{value?.checked_at && <p className="mt-1 text-muted-foreground">{t("healthStatus.versions.checked", { time: new Date(value.checked_at).toLocaleString() })}</p>}
</>
if (state !== "available") return <details aria-label={t("healthStatus.versions.title")} className="group text-[10px] sm:text-xs">
<summary className="flex items-start gap-2 py-1.5 px-2 sm:px-3 rounded-md hover:bg-muted/40 cursor-pointer list-none [&::-webkit-details-marker]:hidden">
{state === "current" ? <CheckCircle2 aria-hidden="true" className="h-3 w-3 sm:h-3.5 sm:w-3.5 shrink-0 text-green-500" /> : <AlertTriangle aria-hidden="true" className="h-3 w-3 sm:h-3.5 sm:w-3.5 shrink-0 text-amber-500" />}
<span className="font-medium shrink-0">ProxMenux</span>
<span className={`flex-1 min-w-0 break-words ${state === "current" ? "text-muted-foreground" : "text-amber-500"}`}>
{state === "current" ? t("healthStatus.versions.currentVersion", { version: value!.installed! }) : t("healthStatus.versions.unknown")}
</span>
<ChevronRight aria-hidden="true" className="h-3 w-3 shrink-0 text-muted-foreground transition-transform group-open:rotate-90" />
</summary>
<div className="ml-7 mr-2 sm:mr-3 pb-2">
{state === "unknown" && versions}
{metadata}
</div>
</details>
return <section aria-label={t("healthStatus.versions.title")} className="mt-3 mx-2 sm:mx-3 rounded-lg border bg-muted/10 p-3 text-[10px] sm:text-xs">
<div className="flex flex-wrap items-center justify-between gap-2 mb-3"><h3 className="font-semibold">{t("healthStatus.versions.title")}</h3>
<span className="flex items-center gap-1.5 text-blue-500"><Info aria-hidden="true" className="h-3.5 w-3.5" />{t("healthStatus.versions.available")}</span></div>
{versions}
{metadata}
</section>
}
+28 -7
View File
@@ -1,6 +1,6 @@
"use client" "use client"
import React, { useState, useEffect, useMemo, useCallback } from "react" import React, { useState, useEffect, useMemo, useCallback, useRef } from "react"
import useSWR from "swr" import useSWR from "swr"
import { Badge } from "./ui/badge" import { Badge } from "./ui/badge"
import { Button } from "./ui/button" import { Button } from "./ui/button"
@@ -185,10 +185,18 @@ function MonitorDashboard({ product }: { product: Product }) {
} }
}, []) }, [])
const healthRequest = useRef<AbortController | null>(null)
// Fetch health info count independently (for initial load and refresh) // Fetch health info count independently (for initial load and refresh)
const fetchHealthInfoCount = useCallback(async () => { const fetchHealthInfoCount = useCallback(async (cachedOnly = false) => {
if (cachedOnly && healthRequest.current) return
healthRequest.current?.abort()
const controller = new AbortController()
healthRequest.current = controller
const timeout = cachedOnly ? setTimeout(() => controller.abort(), 10000) : undefined
try { try {
const response = await fetchApi("/api/health/full") const response = await fetchApi(cachedOnly ? "/api/health/full?cached=1" : "/api/health/full", { signal: controller.signal })
if (healthRequest.current !== controller) return
if (!response?.health?.details) throw new Error("Invalid health snapshot")
let calculatedInfoCount = 0 let calculatedInfoCount = 0
if (response && response.health?.details) { if (response && response.health?.details) {
@@ -218,7 +226,10 @@ function MonitorDashboard({ product }: { product: Product }) {
setInfoCount(calculatedInfoCount) setInfoCount(calculatedInfoCount)
} catch (error) { } catch (error) {
// Silently fail - infoCount will remain at 0 // Preserve the last known count; a failed poll is not a healthy result.
} finally {
if (timeout !== undefined) clearTimeout(timeout)
if (healthRequest.current === controller) healthRequest.current = null
} }
}, []) }, [])
@@ -269,20 +280,29 @@ function MonitorDashboard({ product }: { product: Product }) {
fetchHealthInfoCount() fetchHealthInfoCount()
fetchUpdateStatus() fetchUpdateStatus()
// Overview polls health every 30 s; the other tabs every 60 s. // Header reboot information stays current on every tab, without full audits.
const refreshHealth = () => {
if (document.visibilityState === "visible") void fetchHealthInfoCount(true)
}
let interval: ReturnType<typeof setInterval> | null = null let interval: ReturnType<typeof setInterval> | null = null
let healthInterval: ReturnType<typeof setInterval> | null = null let healthInterval: ReturnType<typeof setInterval> | null = null
if (activeTab === "overview") { if (activeTab === "overview") {
interval = setInterval(fetchSystemData, 30000) // 30 segundos interval = setInterval(fetchSystemData, 30000) // 30 segundos
healthInterval = setInterval(fetchHealthInfoCount, 30000) // Also refresh info count
} else { } else {
interval = setInterval(fetchSystemData, 60000) // 60 segundos interval = setInterval(fetchSystemData, 60000) // 60 segundos
healthInterval = setInterval(fetchHealthInfoCount, 60000) // Also refresh info count
} }
healthInterval = setInterval(refreshHealth, 30000)
window.addEventListener("focus", refreshHealth)
document.addEventListener("visibilitychange", refreshHealth)
return () => { return () => {
if (interval) clearInterval(interval) if (interval) clearInterval(interval)
if (healthInterval) clearInterval(healthInterval) if (healthInterval) clearInterval(healthInterval)
window.removeEventListener("focus", refreshHealth)
document.removeEventListener("visibilitychange", refreshHealth)
const controller = healthRequest.current
healthRequest.current = null
controller?.abort()
} }
}, [fetchSystemData, fetchHealthInfoCount, fetchUpdateStatus, activeTab]) }, [fetchSystemData, fetchHealthInfoCount, fetchUpdateStatus, activeTab])
@@ -391,6 +411,7 @@ function MonitorDashboard({ product }: { product: Product }) {
const refreshData = async () => { const refreshData = async () => {
setIsRefreshing(true) setIsRefreshing(true)
await fetchSystemData() await fetchSystemData()
await fetchHealthInfoCount()
setComponentKey((prev) => prev + 1) setComponentKey((prev) => prev + 1)
await new Promise((resolve) => setTimeout(resolve, 500)) await new Promise((resolve) => setTimeout(resolve, 500))
setIsRefreshing(false) setIsRefreshing(false)
+17 -5
View File
@@ -54,8 +54,8 @@ interface ScriptTerminalModalProps {
completedSuccessfullyMessage?: string completedSuccessfullyMessage?: string
completedWithErrorMessage?: (exitCode: number) => string completedWithErrorMessage?: (exitCode: number) => string
cancelledExitCode?: number cancelledExitCode?: number
// Only LXC Updates opts into a separate server-owned job transport. // Explicit server-owned transports; ordinary /ws/script keeps its contract.
backgroundUpdate?: { runId: string; vmid: number; resume: boolean } backgroundUpdate?: { runId: string; vmid?: number; resume: boolean; kind?: "host" }
// Optional callback fired when the script's WebSocket closes // Optional callback fired when the script's WebSocket closes
// (script_runner sends an exit code and then closes). Lets the // (script_runner sends an exit code and then closes). Lets the
// parent auto-dismiss the modal — used by host-backup's Restore // parent auto-dismiss the modal — used by host-backup's Restore
@@ -178,7 +178,9 @@ export function ScriptTerminalModal({
} }
}, 30000) }, 30000)
const initMessage = { const initMessage = backgroundUpdate?.kind === "host" ? {
resume: true, offset: outputOffsetRef.current,
} : {
script_path: scriptPath, script_path: scriptPath,
params: paramsRef.current, params: paramsRef.current,
...(backgroundUpdate ? { resume: true, vmid: backgroundUpdate.vmid, ...(backgroundUpdate ? { resume: true, vmid: backgroundUpdate.vmid,
@@ -221,6 +223,10 @@ export function ScriptTerminalModal({
try { try {
const msg = JSON.parse(event.data) const msg = JSON.parse(event.data)
if (msg.type === "host_update_state") {
if (msg.state === "attention") termRef.current?.writeln(t("healthStatus.hostUpdate.attention"))
return
}
if (msg.type === "job_output") { if (msg.type === "job_output") {
setIsWaitingNextInteraction(false) setIsWaitingNextInteraction(false)
if (waitingTimeoutRef.current) clearTimeout(waitingTimeoutRef.current) if (waitingTimeoutRef.current) clearTimeout(waitingTimeoutRef.current)
@@ -429,7 +435,9 @@ export function ScriptTerminalModal({
} }
}, 30000) }, 30000)
const initMessage = { const initMessage = backgroundUpdate?.kind === "host" ? {
resume: backgroundUpdate.resume, offset: outputOffsetRef.current,
} : {
script_path: scriptPath, script_path: scriptPath,
params: paramsRef.current, params: paramsRef.current,
...(backgroundUpdate ? { resume: backgroundUpdate.resume, ...(backgroundUpdate ? { resume: backgroundUpdate.resume,
@@ -478,6 +486,10 @@ export function ScriptTerminalModal({
try { try {
const msg = JSON.parse(event.data) const msg = JSON.parse(event.data)
if (msg.type === "host_update_state") {
if (msg.state === "attention") term.writeln(t("healthStatus.hostUpdate.attention"))
return
}
if (msg.type === "job_output") { if (msg.type === "job_output") {
setIsWaitingNextInteraction(false) setIsWaitingNextInteraction(false)
if (waitingTimeoutRef.current) clearTimeout(waitingTimeoutRef.current) if (waitingTimeoutRef.current) clearTimeout(waitingTimeoutRef.current)
@@ -752,7 +764,7 @@ export function ScriptTerminalModal({
const getScriptWebSocketUrl = (sid: string): string => { const getScriptWebSocketUrl = (sid: string): string => {
const route = backgroundUpdate const route = backgroundUpdate
? `/ws/lxc-update/${encodeURIComponent(backgroundUpdate.runId)}` ? `/ws/${backgroundUpdate.kind === "host" ? "host-update" : "lxc-update"}/${encodeURIComponent(backgroundUpdate.runId)}`
: `/ws/script/${sid}` : `/ws/script/${sid}`
if (typeof window === "undefined") { if (typeof window === "undefined") {
return `ws://localhost:${API_PORT}${route}` return `ws://localhost:${API_PORT}${route}`
+49 -1
View File
@@ -5309,6 +5309,48 @@
} }
}, },
"healthStatus": { "healthStatus": {
"restart": {
"button": "Restart server",
"title": "Restart server {server}?",
"impact": "Restarting temporarily interrupts the server, virtual machines, containers and their services.",
"cancel": "Cancel",
"confirm": "Confirm restart",
"blocked": "Restart will be available after the host update finishes.",
"accepted": "Restart request accepted. Check that the server returns online.",
"unconfirmed": "The restart request could not be verified. Check the server before trying again."
},
"versions": {
"title": "ProxMenux update",
"installed": "Installed version",
"latest": "Available version",
"lastKnown": "Last known available version",
"available": "Update available",
"current": "ProxMenux is up to date",
"currentVersion": "Version {version} is up to date",
"unknown": "Could not verify the available version",
"unverified": "Unverified",
"stable": "Stable channel",
"beta": "Beta channel",
"checked": "Last successful check: {time}"
},
"hostUpdate": {
"running": "The host update continues even if you sign out. Reopen it after signing in.",
"finished": "The last host update has finished. Its result is available.",
"attention": "The host update needs attention. It has not been stopped; reconnect to inspect it.",
"view": "View update",
"viewResult": "View result",
"hideResult": "Hide result",
"resultTitle": "Host update result",
"resultReadOnly": "Saved output of a completed update. Opening it does not run another update.",
"resultUnavailable": "The saved result could not be loaded. No update was started.",
"retryResult": "Reload result",
"loadingResult": "Loading saved result…",
"closeResult": "Close result",
"noReboot": "No reboot required",
"rebootMarker": "The operating system requires a reboot.",
"rebootUnknown": "Could not determine whether a reboot is required.",
"rebootKernel": "A reboot is required to activate {installed}. Currently running: {running}."
},
"title": "System health status", "title": "System health status",
"description": "Detailed checks of all system components", "description": "Detailed checks of all system components",
"status": { "status": {
@@ -5365,6 +5407,7 @@
"system_age": "System age", "system_age": "System age",
"pending_updates": "Pending updates", "pending_updates": "Pending updates",
"kernel_pve": "Kernel / PVE", "kernel_pve": "Kernel / PVE",
"reboot_required": "Reboot required",
"uptime": "Uptime", "uptime": "Uptime",
"certificates": "Certificates", "certificates": "Certificates",
"login_attempts": "Login attempts", "login_attempts": "Login attempts",
@@ -5392,6 +5435,7 @@
"runningKernelUpdate": "Kernel update available for running {kernel}", "runningKernelUpdate": "Kernel update available for running {kernel}",
"kernelPveUpdates": "Kernel updates available: {kernelCount}; Proxmox updates available: {pveCount}", "kernelPveUpdates": "Kernel updates available: {kernelCount}; Proxmox updates available: {pveCount}",
"pendingPackageUpdates": "Package updates pending: {count}", "pendingPackageUpdates": "Package updates pending: {count}",
"noPendingUpdates": "No pending updates",
"updatesAptError": "Failed to check for updates (apt-get error)", "updatesAptError": "Failed to check for updates (apt-get error)",
"updatesAptTimeout": "apt-get timed out - repository may be unreachable", "updatesAptTimeout": "apt-get timed out - repository may be unreachable",
"kernelPackagesMore": "{technicalIdentity} (+{count} more)", "kernelPackagesMore": "{technicalIdentity} (+{count} more)",
@@ -5435,7 +5479,7 @@
"failedLogins": "{count} failed login attempts in 24h", "failedLogins": "{count} failed login attempts in 24h",
"fail2banBannedIps": "Fail2Ban is currently blocking {count} IP addresses (jails: {jails})", "fail2banBannedIps": "Fail2Ban is currently blocking {count} IP addresses (jails: {jails})",
"uptimeDays": "Uptime: {count} days", "uptimeDays": "Uptime: {count} days",
"pendingPackages": "{count} packages pending", "pendingPackages": "Planned packages: {count} (including new packages)",
"updatedDaysAgo": "Last updated {count} days ago", "updatedDaysAgo": "Last updated {count} days ago",
"startupStoragesChecking": "Checking Proxmox storage after startup: {storages}", "startupStoragesChecking": "Checking Proxmox storage after startup: {storages}",
"startupStorageChecking": "Checking Proxmox storage after startup: {storage}", "startupStorageChecking": "Checking Proxmox storage after startup: {storage}",
@@ -5463,6 +5507,10 @@
}, },
"noIssues": "No issues detected", "noIssues": "No issues detected",
"updateNow": "Update now", "updateNow": "Update now",
"updateSystem": "Update system",
"packagePlanNote": "The plan also includes new packages, such as a new kernel. The overview counts updates to existing packages only.",
"snapshotUnavailable": "New health results are not available yet.",
"lastKnownResults": "Showing the last available results with their original check time.",
"dismissedItems": "Dismissed items ({count})", "dismissedItems": "Dismissed items ({count})",
"permanent": "Permanent", "permanent": "Permanent",
"dismissed": "Dismissed", "dismissed": "Dismissed",
+50 -2
View File
@@ -5181,6 +5181,48 @@
} }
}, },
"healthStatus": { "healthStatus": {
"restart": {
"button": "Reštartovať server",
"title": "Reštartovať server {server}?",
"impact": "Reštart dočasne preruší dostupnosť servera, virtuálnych strojov, kontajnerov a ich služieb.",
"cancel": "Zrušiť",
"confirm": "Potvrdiť reštart",
"blocked": "Reštart bude dostupný po dokončení aktualizácie servera.",
"accepted": "Požiadavka na reštart bola prijatá. Over, že sa server znova sprístupní.",
"unconfirmed": "Požiadavku na reštart sa nepodarilo overiť. Pred ďalším pokusom skontroluj server."
},
"versions": {
"title": "Aktualizácia ProxMenuxu",
"installed": "Nainštalovaná verzia",
"latest": "Dostupná verzia",
"lastKnown": "Posledná známa dostupná verzia",
"available": "Dostupná aktualizácia",
"current": "ProxMenux je aktuálny",
"currentVersion": "Verzia {version} je aktuálna",
"unknown": "Dostupnú verziu sa nepodarilo overiť",
"unverified": "Neoverené",
"stable": "Stabilný kanál",
"beta": "Beta kanál",
"checked": "Posledná úspešná kontrola: {time}"
},
"hostUpdate": {
"running": "Aktualizácia servera pokračuje aj po odhlásení. Po prihlásení ju môžeš znova otvoriť.",
"finished": "Posledná aktualizácia servera sa skončila. Jej výsledok je k dispozícii.",
"attention": "Aktualizácia servera potrebuje kontrolu. Nebola zastavená; otvor ju a skontroluj priebeh.",
"view": "Zobraziť aktualizáciu",
"viewResult": "Zobraziť výsledok",
"hideResult": "Skryť výsledok",
"resultTitle": "Výsledok aktualizácie servera",
"resultReadOnly": "Uložený výpis dokončenej aktualizácie. Jeho otvorenie nespustí ďalšiu aktualizáciu.",
"resultUnavailable": "Uložený výsledok sa nepodarilo načítať. Žiadna aktualizácia sa nespustila.",
"retryResult": "Znova načítať výsledok",
"loadingResult": "Načítava sa uložený výsledok…",
"closeResult": "Zavrieť výsledok",
"noReboot": "Reštart nie je potrebný",
"rebootMarker": "Operačný systém vyžaduje reštart.",
"rebootUnknown": "Nepodarilo sa zistiť, či je potrebný reštart.",
"rebootKernel": "Na aktiváciu jadra {installed} je potrebný reštart. Teraz beží: {running}."
},
"title": "Stav systému", "title": "Stav systému",
"description": "Podrobná kontrola všetkých častí systému", "description": "Podrobná kontrola všetkých častí systému",
"status": { "status": {
@@ -5237,6 +5279,7 @@
"system_age": "Aktuálnosť systému", "system_age": "Aktuálnosť systému",
"pending_updates": "Čakajúce aktualizácie", "pending_updates": "Čakajúce aktualizácie",
"kernel_pve": "Jadro / PVE", "kernel_pve": "Jadro / PVE",
"reboot_required": "Potrebný reštart",
"uptime": "Doba prevádzky", "uptime": "Doba prevádzky",
"certificates": "Certifikáty", "certificates": "Certifikáty",
"login_attempts": "Pokusy o prihlásenie", "login_attempts": "Pokusy o prihlásenie",
@@ -5269,7 +5312,7 @@
"failedLogins": "Neúspešné prihlásenia za 24 h: {count}", "failedLogins": "Neúspešné prihlásenia za 24 h: {count}",
"fail2banBannedIps": "Fail2Ban momentálne blokuje IP adresy: {count} (pravidlá: {jails})", "fail2banBannedIps": "Fail2Ban momentálne blokuje IP adresy: {count} (pravidlá: {jails})",
"uptimeDays": "Beží {count} dní", "uptimeDays": "Beží {count} dní",
"pendingPackages": "Čakajúce balíky: {count}", "pendingPackages": "Plánované balíky: {count} (vrátane nových)",
"updatedDaysAgo": "Naposledy aktualizované pred {count} dňami", "updatedDaysAgo": "Naposledy aktualizované pred {count} dňami",
"startupStoragesChecking": "Po štarte sa ešte overuje stav Proxmox úložísk: {storages}", "startupStoragesChecking": "Po štarte sa ešte overuje stav Proxmox úložísk: {storages}",
"startupStorageChecking": "Po štarte sa ešte overuje stav Proxmox úložiska: {storage}", "startupStorageChecking": "Po štarte sa ešte overuje stav Proxmox úložiska: {storage}",
@@ -5300,9 +5343,10 @@
"pendingSecurityUpdatesDays": "Čakajúce aktualizácie zabezpečenia: {count} (dni čakania: {days})", "pendingSecurityUpdatesDays": "Čakajúce aktualizácie zabezpečenia: {count} (dni čakania: {days})",
"systemNotUpdatedMonths": "Systém nebol aktualizovaný za {days} dní (>18 mesiacov)", "systemNotUpdatedMonths": "Systém nebol aktualizovaný za {days} dní (>18 mesiacov)",
"systemNotUpdatedYear": "Systém nebol aktualizovaný za {days} dní (>1 rok)", "systemNotUpdatedYear": "Systém nebol aktualizovaný za {days} dní (>1 rok)",
"runningKernelUpdate": "K dispozícii je aktualizácia jadra pre spustenie {kernel}", "runningKernelUpdate": "K dispozícii je aktualizácia pre bežiace jadro {kernel}",
"kernelPveUpdates": "Dostupné aktualizácie jadra: {kernelCount}; dostupné aktualizácie Proxmox: {pveCount}", "kernelPveUpdates": "Dostupné aktualizácie jadra: {kernelCount}; dostupné aktualizácie Proxmox: {pveCount}",
"pendingPackageUpdates": "Čakajúce aktualizácie balíka: {count}", "pendingPackageUpdates": "Čakajúce aktualizácie balíka: {count}",
"noPendingUpdates": "Žiadne čakajúce aktualizácie",
"updatesAptError": "Nepodarilo sa skontrolovať aktualizácie (chyba apt-get)", "updatesAptError": "Nepodarilo sa skontrolovať aktualizácie (chyba apt-get)",
"updatesAptTimeout": "Časový limit apt-get vypršal - úložisko môže byť nedostupné", "updatesAptTimeout": "Časový limit apt-get vypršal - úložisko môže byť nedostupné",
"kernelPackagesMore": "{technicalIdentity} (+{count} viac)", "kernelPackagesMore": "{technicalIdentity} (+{count} viac)",
@@ -5335,6 +5379,10 @@
}, },
"noIssues": "Neboli zistené žiadne problémy", "noIssues": "Neboli zistené žiadne problémy",
"updateNow": "Aktualizovať teraz", "updateNow": "Aktualizovať teraz",
"updateSystem": "Aktualizovať systém",
"packagePlanNote": "Plán zahŕňa aj nové balíky, napríklad nové jadro. Prehľad počíta iba aktualizácie existujúcich balíkov.",
"snapshotUnavailable": "Nový výsledok kontroly zatiaľ nie je k dispozícii.",
"lastKnownResults": "Zobrazené sú posledné dostupné údaje s pôvodným časom kontroly.",
"dismissedItems": "Skryté upozornenia ({count})", "dismissedItems": "Skryté upozornenia ({count})",
"permanent": "Natrvalo", "permanent": "Natrvalo",
"dismissed": "Skryté", "dismissed": "Skryté",
+6 -2
View File
@@ -394,10 +394,12 @@ def verify_token_full(token):
API JWT 365 días sin scope. API JWT 365 días sin scope.
""" """
if not JWT_AVAILABLE or not token: if not JWT_AVAILABLE or not token:
_log_auth_failure_throttled("Session rejected: missing token or verifier unavailable")
return None, None return None, None
try: try:
token_hash = hashlib.sha256(token.encode()).hexdigest() token_hash = hashlib.sha256(token.encode()).hexdigest()
if token_hash in _get_revoked_tokens_cached(): if token_hash in _get_revoked_tokens_cached():
_log_auth_failure_throttled("Session rejected: revoked")
return None, None return None, None
try: try:
payload = jwt.decode( payload = jwt.decode(
@@ -409,8 +411,10 @@ def verify_token_full(token):
payload = jwt.decode(token, _get_jwt_secret(), algorithms=[JWT_ALGORITHM]) payload = jwt.decode(token, _get_jwt_secret(), algorithms=[JWT_ALGORITHM])
return payload.get('username'), payload.get('scope', 'full_admin') return payload.get('username'), payload.get('scope', 'full_admin')
except jwt.ExpiredSignatureError: except jwt.ExpiredSignatureError:
_log_auth_failure_throttled("Session rejected: expired")
return None, None return None, None
except jwt.InvalidTokenError: except jwt.InvalidTokenError:
_log_auth_failure_throttled("Session rejected: invalid")
return None, None return None, None
@@ -437,9 +441,9 @@ def _log_auth_failure_throttled(msg):
if _AUTH_LOG_RATE['suppressed']: if _AUTH_LOG_RATE['suppressed']:
print(f"[auth] {_AUTH_LOG_RATE['last_msg']} " print(f"[auth] {_AUTH_LOG_RATE['last_msg']} "
f"(+{_AUTH_LOG_RATE['suppressed']} more in last " f"(+{_AUTH_LOG_RATE['suppressed']} more in last "
f"{int(elapsed)}s)") f"{int(elapsed)}s)", flush=True)
else: else:
print(f"[auth] {msg}") print(f"[auth] {msg}", flush=True)
_AUTH_LOG_RATE['last_ts'] = now _AUTH_LOG_RATE['last_ts'] = now
_AUTH_LOG_RATE['suppressed'] = 0 _AUTH_LOG_RATE['suppressed'] = 0
_AUTH_LOG_RATE['last_msg'] = msg _AUTH_LOG_RATE['last_msg'] = msg
+4
View File
@@ -149,6 +149,10 @@ cp "$SCRIPT_DIR/managed_installs.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "
cp "$SCRIPT_DIR/lxc_update_observation.py" "$APP_DIR/usr/bin/" 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_finalization.py" "$APP_DIR/usr/bin/"
cp "$SCRIPT_DIR/lxc_update_jobs.py" "$APP_DIR/usr/bin/" cp "$SCRIPT_DIR/lxc_update_jobs.py" "$APP_DIR/usr/bin/"
cp "$SCRIPT_DIR/host_update_jobs.py" "$APP_DIR/usr/bin/"
cp "$SCRIPT_DIR/kernel_reboot.py" "$APP_DIR/usr/bin/"
cp "$SCRIPT_DIR/host_update_controls.py" "$APP_DIR/usr/bin/"
cp "$SCRIPT_DIR/proxmenux_version_status.py" "$APP_DIR/usr/bin/"
cp "$SCRIPT_DIR/lxc_check_job.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/native_app_discovery.py" "$APP_DIR/usr/bin/"
cp "$SCRIPT_DIR/local_app_icons.py" "$APP_DIR/usr/bin/" cp "$SCRIPT_DIR/local_app_icons.py" "$APP_DIR/usr/bin/"
+83 -4
View File
@@ -2,7 +2,7 @@
Flask routes for health monitoring with persistence support Flask routes for health monitoring with persistence support
""" """
from flask import Blueprint, jsonify, request from flask import Blueprint, jsonify, request, current_app
from health_monitor import health_monitor from health_monitor import health_monitor
from health_persistence import health_persistence from health_persistence import health_persistence
from jwt_middleware import require_auth, require_admin_scope from jwt_middleware import require_auth, require_admin_scope
@@ -17,6 +17,53 @@ except ImportError:
health_bp = Blueprint('health', __name__) health_bp = Blueprint('health', __name__)
@health_bp.route('/api/health/proxmenux-version', methods=['GET'])
@require_auth
def get_proxmenux_version():
from proxmenux_version_status import version_status
return jsonify(version_status())
@health_bp.route('/api/health/reboot-context', methods=['GET'])
@require_admin_scope
def get_reboot_context():
from host_update_controls import reboot_context
return jsonify(reboot_context())
@health_bp.route('/api/health/reboot-after-update', methods=['POST'])
@require_admin_scope
def reboot_after_update():
import socket
data = request.get_json(silent=True)
if not isinstance(data, dict) or data.get('confirm') is not True or data.get('hostname') != socket.gethostname() or not isinstance(data.get('boot_id'), str) or not data['boot_id']:
return jsonify({'reason': 'confirmation'}), 400
power = current_app.extensions.get('host_update_power')
if not power:
return jsonify({'reason': 'unavailable'}), 503
from host_update_controls import reboot_context, COMMAND, UNIT
context = reboot_context()
if context.get('boot_id') != data['boot_id']:
return jsonify({'reason': 'confirmation'}), 409
if not context['allowed']:
return jsonify({'reason': context['reason']}), 409
ok, error = power['run'](UNIT, list(COMMAND), description='ProxMenux: reboot after host update',
extra_properties=['RuntimeMaxSec=75'])
if not ok:
return jsonify({'reason': 'unavailable'}), error.get('status', 500)
return jsonify({'accepted': True}), 202
@health_bp.route('/api/health/reboot-after-update/status', methods=['GET'])
@require_admin_scope
def reboot_after_update_status():
power = current_app.extensions.get('host_update_power')
if not power:
return jsonify({'state': 'unknown'}), 503
from host_update_controls import UNIT
return jsonify(power['state'](UNIT))
@health_bp.route('/api/health/status', methods=['GET']) @health_bp.route('/api/health/status', methods=['GET'])
@require_auth @require_auth
def get_health_status(): def get_health_status():
@@ -243,10 +290,14 @@ def get_full_health():
security before returning, so an event that just changed underlying state security before returning, so an event that just changed underlying state
(Update Now finished, dismiss action) sees the new value immediately (Update Now finished, dismiss action) sees the new value immediately
instead of waiting for the next polling tick. instead of waiting for the next polling tick.
?cached=1 is a strictly read-only poll: no full checks or cache clearing,
even with refresh=1. Unavailable/stale snapshots return 503. Every valid
response refreshes reboot evidence, while keeping the full audit's date.
""" """
import time as _time import time as _time
try: try:
if request.args.get('refresh') == '1': cached_only = request.args.get('cached') == '1'
if not cached_only and request.args.get('refresh') == '1':
for ck in ('updates_check', 'pve_services', 'security_check', for ck in ('updates_check', 'pve_services', 'security_check',
'_bg_detailed', '_bg_overall', 'overall_health'): '_bg_detailed', '_bg_overall', 'overall_health'):
health_monitor.last_check_times.pop(ck, None) health_monitor.last_check_times.pop(ck, None)
@@ -255,14 +306,42 @@ def get_full_health():
# Try to use the background-cached detailed result for instant response # Try to use the background-cached detailed result for instant response
bg_key = '_bg_detailed' bg_key = '_bg_detailed'
bg_last = health_monitor.last_check_times.get(bg_key, 0) bg_last = health_monitor.last_check_times.get(bg_key, 0)
bg_age = _time.time() - bg_last bg_age = _time.time() - bg_last if type(bg_last) in (int, float) else float('inf')
publish_live = False
if bg_age < 360 and bg_key in health_monitor.cached_results: if 0 <= bg_age < 360 and bg_key in health_monitor.cached_results:
# Use cached result (at most ~5 min old) # Use cached result (at most ~5 min old)
details = health_monitor.cached_results[bg_key] details = health_monitor.cached_results[bg_key]
elif cached_only:
# Automatic polling must never start APT or a full audit, even
# during startup, collector failure, expiry or a conflicting flag.
return jsonify({'error': 'Health snapshot is not available yet'}), 503
else: else:
# No fresh cache, run live (first load or cache expired) # No fresh cache, run live (first load or cache expired)
details = health_monitor.get_detailed_status() details = health_monitor.get_detailed_status()
if (not isinstance(details, dict) or not details.get('timestamp')
or details.get('overall') not in ('OK', 'INFO', 'WARNING', 'CRITICAL', 'UNKNOWN')
or not isinstance(details.get('summary'), str)
or not isinstance(details.get('details'), dict)
or not isinstance(details['details'].get('updates'), dict)):
raise ValueError('Health snapshot is incomplete')
# Publish an explicit check too: its caller may have invalidated
# the background cache after an update. A following read-only poll
# must see this result instead of waiting for another collector.
publish_live = True
# Installed/running kernel state changes outside Monitor jobs too.
# Overlay fresh read-only evidence without refreshing other checks or
# rewriting the background collector's snapshot/timestamp.
from kernel_reboot import refresh_health_reboot
details = refresh_health_reboot(details)
if publish_live:
checked_at = _time.time()
health_monitor.cached_results[bg_key] = details
health_monitor.cached_results['_bg_overall'] = {
'status': details['overall'], 'summary': details['summary']}
health_monitor.last_check_times[bg_key] = checked_at
health_monitor.last_check_times['_bg_overall'] = checked_at
active_errors = health_persistence.get_active_errors() active_errors = health_persistence.get_active_errors()
dismissed = health_persistence.get_dismissed_errors() dismissed = health_persistence.get_dismissed_errors()
+12 -3
View File
@@ -1752,6 +1752,7 @@ def get_uptime():
_system_info_cache = { _system_info_cache = {
'proxmox_version': None, 'proxmox_version': None,
'proxmox_version_time': 0, 'proxmox_version_time': 0,
'proxmox_version_stamp': 0.0,
'available_updates': 0, 'available_updates': 0,
'available_updates_time': 0, 'available_updates_time': 0,
'available_updates_stamp': 0.0, 'available_updates_stamp': 0.0,
@@ -2748,6 +2749,9 @@ def _action_state(unit_name):
return result_dict return result_dict
app.extensions['host_update_power'] = {'run': _action_run, 'state': _action_state}
def _action_cancel(unit_name): def _action_cancel(unit_name):
"""Stop a running transient action unit. Idempotent — succeeds on """Stop a running transient action unit. Idempotent — succeeds on
units that are not running (or don't exist). units that are not running (or don't exist).
@@ -3504,14 +3508,16 @@ def get_coral_info():
def get_proxmox_version(): def get_proxmox_version():
"""Get Proxmox version if available. Cached for 6 hours.""" """Cache the installed PVE version until package state changes."""
global _system_info_cache global _system_info_cache
if is_pbs_host(): if is_pbs_host():
return get_platform().get('version') return get_platform().get('version')
now = time.time() now = time.time()
if _system_info_cache['proxmox_version'] is not None and \ stamp = _apt_state_stamp()
now - _system_info_cache['proxmox_version_time'] < _SYSTEM_INFO_CACHE_TTL: age = now - _system_info_cache['proxmox_version_time']
if _system_info_cache['proxmox_version'] is not None and (0 <= age < _AVAILABLE_UPDATES_MIN_INTERVAL or
(stamp == _system_info_cache.get('proxmox_version_stamp') and 0 <= age < _SYSTEM_INFO_CACHE_TTL)):
return _system_info_cache['proxmox_version'] return _system_info_cache['proxmox_version']
proxmox_version = None proxmox_version = None
@@ -3527,8 +3533,11 @@ def get_proxmox_version():
except Exception as e: except Exception as e:
pass pass
if proxmox_version is None:
return None # Keep last-known cache intact, but do not present it as fresh.
_system_info_cache['proxmox_version'] = proxmox_version _system_info_cache['proxmox_version'] = proxmox_version
_system_info_cache['proxmox_version_time'] = now _system_info_cache['proxmox_version_time'] = now
_system_info_cache['proxmox_version_stamp'] = stamp
return proxmox_version return proxmox_version
def _apt_state_stamp(): def _apt_state_stamp():
+84
View File
@@ -200,6 +200,90 @@ def lxc_update_viewer(ws, run_id):
# No process termination, PTY close or completion callback here. The job's # No process termination, PTY close or completion callback here. The job's
# worker alone owns those, even while no viewer is authenticated/connected. # worker alone owns those, even while no viewer is authenticated/connected.
def _host_update_completed(script_path, params, exit_code, duration_seconds):
"""Invalidate health even when no viewer is left to close its modal."""
from health_monitor import health_monitor
for key in ('updates_check', 'overall_health', '_bg_overall', '_bg_detailed'):
health_monitor.last_check_times.pop(key, None)
health_monitor.cached_results.pop(key, None)
@terminal_bp.route('/api/host-update-jobs', methods=['GET'])
@require_admin_scope
def host_update_jobs_status():
from host_update_jobs import list_jobs
return jsonify({'jobs': list_jobs()})
@terminal_bp.route('/api/host-update-jobs/<run_id>/result', methods=['GET'])
@require_admin_scope
def host_update_result(run_id):
"""Read a completed owner's retained output; never create or resume a job."""
from host_update_jobs import get
try:
view = get(run_id).read(0)
except (ValueError, KeyError):
return jsonify({'error': 'Host update result is unavailable'}), 404
if view['state'] != 'complete' or type(view['exit_code']) is not int:
return jsonify({'error': 'Host update has not completed'}), 409
response = jsonify({key: view[key] for key in ('state', 'exit_code', 'text', 'truncated')})
response.headers['Cache-Control'] = 'no-store'
return response
@sock.route('/ws/host-update/<run_id>')
def host_update_viewer(ws, run_id):
"""New fixed-script contract; existing script/LXC routes stay intact."""
if not _ws_auth_check():
ws.close(reason=1008, message="Authentication required")
return
from host_update_jobs import get, start
try:
init = json.loads(ws.receive(timeout=10))
if (not isinstance(init, dict) or set(init) - {'resume', 'offset'}
or type(init.get('resume')) is not bool):
raise ValueError("Invalid host update request")
offset = init.get('offset', 0)
if type(offset) is not int or offset < 0:
raise ValueError("Invalid host update cursor")
job = get(run_id) if init['resume'] else start(run_id, _host_update_completed)
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']
ws.send(json.dumps({'type': 'web_interaction', 'interaction': view['interaction']}))
ws.send(json.dumps({'type': 'host_update_state', 'state': view['state'],
'attention_reason': view['attention_reason']}))
if view['state'] == 'complete':
ws.send(json.dumps({'type': 'script_complete', 'exit_code': view['exit_code']}))
break
data = ws.receive(timeout=.2)
if data is None:
if getattr(ws, 'connected', True) is False or getattr(ws, 'closed', False) is True:
break
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)
except (ValueError, KeyError, OSError, TypeError) as exc:
try:
ws.send(json.dumps({'type': 'job_unavailable', 'message': str(exc)}))
except Exception:
pass
except Exception:
pass # An observer disconnect never signals the owner or its PTY.
@terminal_bp.route('/api/terminal/health', methods=['GET']) @terminal_bp.route('/api/terminal/health', methods=['GET'])
def terminal_health(): def terminal_health():
"""Health check for terminal service""" """Health check for terminal service"""
+9 -5
View File
@@ -4751,6 +4751,10 @@ class HealthMonitor:
Inst lines and exposes them as an INFO sub-check. Inst lines and exposes them as an INFO sub-check.
""" """
cache_key = 'updates_check' cache_key = 'updates_check'
# Kernel activation is independent of pending APT packages. Check it
# even when the package result is cached; this performs no APT call.
from kernel_reboot import reboot_status, with_reboot_status
reboot = reboot_status()
current_time = time.time() current_time = time.time()
apt_history_path = '/var/log/apt/history.log' apt_history_path = '/var/log/apt/history.log'
@@ -4773,7 +4777,7 @@ class HealthMonitor:
cache_ts = self.last_check_times[cache_key] cache_ts = self.last_check_times[cache_key]
history_changed = (history_mtime is not None and history_mtime > cache_ts) history_changed = (history_mtime is not None and history_mtime > cache_ts)
if not history_changed and current_time - cache_ts < 600: if not history_changed and current_time - cache_ts < 600:
return self.cached_results.get(cache_key) return with_reboot_status(self.cached_results.get(cache_key), reboot)
try: try:
last_update_days = None last_update_days = None
@@ -4803,11 +4807,11 @@ class HealthMonitor:
) )
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
print("[HealthMonitor] apt-get upgrade --dry-run timed out") print("[HealthMonitor] apt-get upgrade --dry-run timed out")
return { return with_reboot_status({
'status': 'UNKNOWN', 'status': 'UNKNOWN',
'reason': 'apt-get timed out - repository may be unreachable', 'reason': 'apt-get timed out - repository may be unreachable',
'count': 0, 'checks': {} 'count': 0, 'checks': {}
} }, reboot)
status = 'OK' status = 'OK'
reason = None reason = None
@@ -5124,11 +5128,11 @@ class HealthMonitor:
self.cached_results[cache_key] = update_result self.cached_results[cache_key] = update_result
self.last_check_times[cache_key] = current_time self.last_check_times[cache_key] = current_time
return update_result return with_reboot_status(update_result, reboot)
except Exception as e: except Exception as e:
print(f"[HealthMonitor] Updates check failed: {e}") print(f"[HealthMonitor] Updates check failed: {e}")
return {'status': 'UNKNOWN', 'reason': f'Updates check unavailable: {str(e)}', 'count': 0, 'checks': {}, 'dismissable': True} return with_reboot_status({'status': 'UNKNOWN', 'reason': f'Updates check unavailable: {str(e)}', 'count': 0, 'checks': {}, 'dismissable': True}, reboot)
def _check_fail2ban_bans(self) -> Dict[str, Any]: def _check_fail2ban_bans(self) -> Dict[str, Any]:
""" """
+121
View File
@@ -0,0 +1,121 @@
"""Guarded reboot-after-update context; existing power API is unchanged."""
import fcntl
import os
from pathlib import Path
import socket
import stat
import subprocess
import sys
import time
from kernel_reboot import reboot_status
from host_update_jobs import list_jobs
LOCK = '/run/proxmenux-host-update.lock'
BOOT_ID = Path('/proc/sys/kernel/random/boot_id')
PROC = Path('/proc')
PACKAGE_LOCKS = ('/var/lib/dpkg/lock-frontend', '/var/lib/dpkg/lock')
UNIT = 'proxmenux-action-reboot-after-update'
# The unit obtains the SAME lock as CLI/legacy/web updates. Holding it for
# the shutdown transition closes the precheck-to-launch race. If an updater
# wins that race, flock exits75 and systemctl reboot is never invoked.
COMMAND = ['/usr/bin/flock', '-n', '-E', '75', LOCK, '/usr/bin/python3',
'/usr/local/share/proxmenux/monitor-app/usr/bin/host_update_controls.py', '--execute-reboot']
def package_process_active():
try:
for path in PROC.iterdir():
if not path.name.isdigit():
continue
try:
name = (path/'comm').read_text().strip()
if name == 'unattended-upgr':
# Linux comm truncates both the updater and its persistent
# shutdown helper to 15 bytes. Exempt only the exact idle
# service command (also with its system Python shebang).
argv = (path/'cmdline').read_bytes().split(b'\0')
helper = b'/usr/share/unattended-upgrades/unattended-upgrade-shutdown'
if argv in ([helper, b'--wait-for-signal', b''],
[b'/usr/bin/python3', helper, b'--wait-for-signal', b'']):
continue
return True
if name in ('apt', 'apt-get', 'dpkg'):
return True
except (FileNotFoundError, ProcessLookupError):
if path.exists():
return True
except OSError:
return True
return False
def execute_reboot():
"""Unit-only final guard: POSIX dpkg locks also protect direct APT callers.
Do not use flock for these files: APT/dpkg use POSIX record locks. The
parent retains both locks while systemctl runs and during shutdown.
"""
handles = []
try:
for path in PACKAGE_LOCKS:
fd = os.open(path, os.O_RDWR | os.O_NOFOLLOW | os.O_NONBLOCK)
handles.append(fd)
if not stat.S_ISREG(os.fstat(fd).st_mode):
return 76
fcntl.lockf(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
if reboot_status()['state'] != 'required':
return 77
subprocess.run(['/bin/systemctl', 'reboot'], check=True, timeout=10)
time.sleep(60)
return 0
except (OSError, subprocess.SubprocessError):
return 75
finally:
for fd in handles:
os.close(fd)
def reboot_context():
result = dict(hostname=socket.gethostname(), allowed=False, reason='unknown')
try:
boot_id = BOOT_ID.read_text().strip()
if not boot_id:
return result
result['boot_id'] = boot_id
except OSError:
return result
try:
jobs = list_jobs()
if not isinstance(jobs, list) or any(not isinstance(job, dict) for job in jobs):
return result
except Exception:
return result
if any(job.get('state') != 'complete' for job in jobs):
return dict(result, reason='busy')
if package_process_active():
return dict(result, reason='busy')
try:
fd = os.open(LOCK, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
except FileNotFoundError:
fd = None
except OSError:
return result
if fd is not None:
try:
if not stat.S_ISREG(os.fstat(fd).st_mode):
return result
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
return dict(result, reason='busy')
except OSError:
return result
finally:
os.close(fd)
reboot = reboot_status()
return dict(result, allowed=reboot['state']=='required',
reason='required' if reboot['state']=='required' else 'current' if reboot['state']=='current' else 'unknown')
if __name__ == '__main__':
sys.exit(execute_reboot() if sys.argv[1:] == ['--execute-reboot'] else 2)
+317
View File
@@ -0,0 +1,317 @@
"""Owners for explicitly started PVE updates, independent of browser viewers.
Only the fixed PVE wrapper can run. List/resume/input cannot create a process.
Jobs survive logout, not a Monitor/host restart. Missing IDs fail closed.
Neither disconnect nor a watchdog kills a host package transaction.
"""
import base64
import codecs
import errno
import fcntl
import json
import os
from pathlib import Path
import pty
import re
import select
import struct
import subprocess
import tempfile
import termios
import threading
import time
SCRIPT = "/usr/local/share/proxmenux/scripts/utilities/proxmox_update.sh"
RUN_ID = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
INTERACTION_ID = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
ATTENTION_AFTER = 4 * 3600
RESULT_TTL = 6 * 3600
MAX_JOBS = 16
MAX_OUTPUT = 256 * 1024
MAX_INPUT = 64 * 1024
_lock = threading.RLock()
_jobs = {}
class JobConflict(ValueError):
pass
def _group_active(pgid):
"""Observe descendants after an abnormal shell exit; ignore zombies."""
try:
for entry in os.scandir('/proc'):
if not entry.name.isdigit():
continue
try:
fields = Path(entry.path, 'stat').read_text().rsplit(') ', 1)[1].split()
if fields[0] != 'Z' and int(fields[2]) == pgid:
return True
except (OSError, ValueError, IndexError):
continue
return False
except OSError:
return True # Unknown group state must not permit a second updater.
def _identity(identity):
if not isinstance(identity, str) or not RUN_ID.fullmatch(identity):
raise ValueError("Invalid host update job ID")
class HostUpdateJob:
def __init__(self, identity, callback):
self.id = identity
self.created_at = time.time()
self.started = time.monotonic()
self.finished_at = self.exit_code = self.script_exit_code = None
self.state = "starting"
self.output, self.offset, self.revision = "", 0, 0
self.interaction = self.attention_reason = None
self.finalization_error = False
self.lock = threading.RLock()
self.process = self.master = self.log_path = None
self.callback = callback
self._input = bytearray()
self._interaction_buffer = ""
def launch(self):
# Start the owner before spawning anything: thread-start failure must
# never leave an updater that has no owner to drain its PTY.
threading.Thread(target=self._run, daemon=True,
name=f"host-update-{self.id}").start()
def _append(self, text):
with self.lock:
self.output = (self.output + text)[-MAX_OUTPUT:]
self.offset += len(text)
def _attention(self, reason):
with self.lock:
if self.attention_reason == reason and self.state == "attention":
return
self.attention_reason = reason
self.state = "attention"
self.revision += 1
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:
parts = line[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:
self.interaction = value
self.revision += 1
except (ValueError, UnicodeError, IndexError):
continue
def _run(self):
code, slave, interactions = 1, None, None
decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
try:
self.master, slave = pty.openpty()
os.set_blocking(self.master, False)
fd, self.log_path = tempfile.mkstemp(prefix="proxmenux_host_update_", suffix=".log")
os.close(fd)
fcntl.ioctl(self.master, termios.TIOCSWINSZ, struct.pack("HHHH", 30, 120, 0, 0))
env = dict(os.environ, EXECUTION_MODE="web", PROXMENUX_HOST_UPDATE_JOB="1",
WEB_LOG=self.log_path, TERM="xterm-256color", PYTHONUNBUFFERED="1")
interactions = open(self.log_path, encoding="utf-8", errors="replace")
self.process = subprocess.Popen(["/bin/bash", SCRIPT], stdin=slave,
stdout=slave, stderr=slave,
start_new_session=True, env=env)
os.close(slave)
slave = None
with self.lock:
self.state = "running"
self.revision += 1
eof, exited_at = False, None
while True:
running = self.process.poll() is None
now = time.monotonic()
if not running:
exited_at = exited_at or now
if _group_active(self.process.pid):
self._attention("shell_exited")
elif eof or now - exited_at >= 1:
break
if running and now - self.started >= ATTENTION_AFTER and not self.attention_reason:
self._attention("runtime_limit")
try:
self._interactions(interactions)
with self.lock:
pending = bool(self._input) and running
readable, writable, _ = select.select(
[] if eof else [self.master], [self.master] if pending else [], [], .05)
if writable:
with self.lock:
try:
size = os.write(self.master, self._input[:16384])
del self._input[:size]
except BlockingIOError:
pass
if readable:
try:
chunk = os.read(self.master, 16384)
except BlockingIOError:
continue
except OSError as exc:
if exc.errno != errno.EIO:
raise
chunk = b""
if chunk:
self._append(decoder.decode(chunk))
else:
eof = True
if eof:
time.sleep(.05)
except Exception:
# An observation failure must not turn into SIGTERM of
# dpkg. Keep owning the PTY and the single-job reservation.
self._attention("observation_failed")
time.sleep(.1)
code = self.process.wait()
self._append(decoder.decode(b"", final=True))
except Exception as exc:
self._append(f"\r\nHost update could not start: {type(exc).__name__}\r\n")
# Popen/setup failure before launch is a recorded failure. Once
# alive, preserve the updater even if observation setup failed.
if self.process is not None:
self._attention("observation_failed")
code = self.process.wait()
finally:
for resource in (interactions, slave, self.master):
if resource is not None:
try:
resource.close() if hasattr(resource, 'close') else os.close(resource)
except OSError:
pass
self.master = None
if self.log_path:
try:
os.unlink(self.log_path)
except OSError:
pass
with self.lock:
self.state = "finalizing"
self.script_exit_code = self.exit_code = code
self.interaction = None
self._input.clear()
self.revision += 1
try:
self.callback(SCRIPT, {"PROXMENUX_HOST_UPDATE_JOB": "1"}, code,
time.monotonic() - self.started)
except Exception:
self.finalization_error = True
self.exit_code = code or 1
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, state=self.state, created_at=self.created_at,
finished_at=self.finished_at, exit_code=self.exit_code,
script_exit_code=self.script_exit_code,
attention_reason=self.attention_reason,
finalization_error=self.finalization_error)
def read(self, offset):
with self.lock:
begin = self.offset - len(self.output)
cursor = max(begin, min(offset, self.offset))
return dict(self.view(), text=self.output[cursor-begin:], offset=self.offset,
truncated=offset < begin, interaction=self.interaction,
revision=self.revision)
def input(self, message):
with self.lock:
if self.state not in ("running", "attention") or self.master is None:
return
if isinstance(message, str):
if any(c in message for c in ("\x03", "\x1a", "\x1c")):
raise ValueError("Host package updates cannot be cancelled from the viewer")
encoded = message.encode()
if len(encoded) > 16384 or len(self._input) + len(encoded) > MAX_INPUT:
raise ValueError("Host update input buffer is full")
self._input.extend(encoded)
elif isinstance(message, dict) and 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 isinstance(message, dict) and message.get("type") == "interaction_response":
identity, value = message.get("id"), message.get("value")
if (not isinstance(identity, str) or not INTERACTION_ID.fullmatch(identity)
or not self.interaction or identity != self.interaction["id"]
or not isinstance(value, str) or len(value.encode()) > 16384):
raise ValueError("No matching pending host update interaction")
fd = os.open(f"/tmp/proxmenux_response_{identity}",
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
else:
raise ValueError("Unsupported host update input")
def _prune():
for identity, job in list(_jobs.items()):
if job.finished_at is not None and time.time() - job.finished_at > RESULT_TTL:
del _jobs[identity]
def start(identity, callback):
_identity(identity)
if os.path.realpath(SCRIPT) != SCRIPT or not os.path.isfile(SCRIPT):
raise ValueError("PVE update script is unavailable")
with _lock:
if identity in _jobs:
return _jobs[identity]
_prune()
if any(job.state != "complete" for job in _jobs.values()):
raise JobConflict("A host update is already running; reconnect to it")
while len(_jobs) >= MAX_JOBS:
del _jobs[min(_jobs, key=lambda key: _jobs[key].finished_at)]
job = HostUpdateJob(identity, callback)
_jobs[identity] = job
try:
job.launch()
except BaseException:
_jobs.pop(identity, None)
raise
return job
def get(identity):
_identity(identity)
with _lock:
if identity not in _jobs:
raise KeyError("Host update is unavailable; it was not restarted")
return _jobs[identity]
def list_jobs():
with _lock:
_prune()
return [job.view() for job in sorted(_jobs.values(), key=lambda job: job.created_at, reverse=True)]
+2
View File
@@ -50,6 +50,7 @@ def require_auth(f):
auth_header = request.headers.get('Authorization') auth_header = request.headers.get('Authorization')
if not auth_header: if not auth_header:
verify_token_full(None) # Guarded, credential-free rejection diagnostic.
return jsonify({ return jsonify({
"error": "Authentication required", "error": "Authentication required",
"message": "No authorization header provided" "message": "No authorization header provided"
@@ -58,6 +59,7 @@ def require_auth(f):
# Extract token from "Bearer <token>" format # Extract token from "Bearer <token>" format
parts = auth_header.split() parts = auth_header.split()
if len(parts) != 2 or parts[0].lower() != 'bearer': if len(parts) != 2 or parts[0].lower() != 'bearer':
verify_token_full(None)
return jsonify({ return jsonify({
"error": "Invalid authorization header", "error": "Invalid authorization header",
"message": "Authorization header must be in format: Bearer <token>" "message": "Authorization header must be in format: Bearer <token>"
+121
View File
@@ -0,0 +1,121 @@
"""Read-only reboot evidence, independent of APT pending-update caches."""
import os
import copy
from pathlib import Path
import re
import subprocess
_RELEASE = re.compile(r"^(\d+)\.(\d+)\.(\d+)-(\d+)-pve$")
_PACKAGE = re.compile(r"^(?:proxmox|pve)-kernel-(\d+\.\d+\.\d+-\d+-pve)(?:-signed)?(?::[a-z0-9]+)?$")
def reboot_status():
"""No package refresh, installation, mount or bootloader operation."""
try:
running = os.uname().release
except OSError:
return dict(state="unknown", running_kernel=None, installed_kernel=None)
result = dict(state="unknown", running_kernel=running, installed_kernel=None)
if not isinstance(running, str) or not re.fullmatch(r'\d+\.\d+[A-Za-z0-9_.+\-]*', running):
return result
try:
if Path('/run/reboot-required').exists():
return dict(result, state="required", reason="marker")
except OSError:
return result
match = _RELEASE.fullmatch(running)
if not match:
if running.endswith('-pve'):
return result
return dict(result, state="not_applicable")
try:
proc = subprocess.run(
['dpkg-query', '-W', '-f=${db:Status-Status}\t${binary:Package}\n'],
capture_output=True, text=True, timeout=5)
if proc.returncode != 0 or not proc.stdout.strip():
return result
installed = []
valid_states = {'installed', 'config-files', 'not-installed', 'unpacked',
'half-configured', 'half-installed', 'triggers-awaited', 'triggers-pending'}
for line in proc.stdout.splitlines():
fields = line.split('\t')
if len(fields) != 2 or fields[0] not in valid_states:
return result
state, package = fields
kernel = _PACKAGE.fullmatch(package)
if kernel:
if state in ('unpacked', 'half-configured', 'half-installed', 'triggers-awaited', 'triggers-pending'):
return result
if state == 'installed':
installed.append(kernel.group(1))
if not installed:
return result
newest = max(installed, key=lambda v: tuple(map(int, _RELEASE.fullmatch(v).groups())))
newer = tuple(map(int, _RELEASE.fullmatch(newest).groups())) > tuple(map(int, match.groups()))
return dict(result, state="required" if newer else "current", installed_kernel=newest,
reason="kernel" if newer else None)
except (OSError, subprocess.SubprocessError, ValueError, TypeError):
return result
def with_reboot_status(updates, reboot):
"""Copy, never rewrite a cached package result or hide its existing error."""
if reboot['state'] == 'not_applicable':
return updates
result = dict(updates or dict(status='UNKNOWN', count=None, checks={}))
result['checks'] = dict(result.get('checks') or {})
# Keep the package result separate so a later reboot-only overlay can
# clear its own INFO/UNKNOWN without hiding a package-check failure.
base_status = result.get('package_status', result.get('status', 'UNKNOWN'))
if result.get('all_dismissed'):
base_status = 'OK'
result['package_status'] = base_status
result['status'] = base_status
required = reboot['state'] == 'required'
unknown = reboot['state'] == 'unknown'
result['reboot_required'] = True if required else None if unknown else False
result['reboot_state'] = dict(reboot)
if required:
detail = (f"Reboot required: running {reboot['running_kernel']}; installed {reboot['installed_kernel']}"
if reboot.get('installed_kernel') else 'Reboot required by the operating system')
else:
detail = 'Could not determine whether a reboot is required' if unknown else 'No reboot required'
result['checks']['reboot_required'] = dict(status='INFO' if required else 'UNKNOWN' if unknown else 'OK',
detail=detail, error_key='reboot_required')
if result.get('status') == 'OK' and (required or unknown):
result['status'] = 'INFO' if required else 'UNKNOWN'
return result
def refresh_health_reboot(snapshot):
"""Refresh only reboot evidence on an immutable, dated health snapshot.
Never runs the full health check, APT, persistence writes or lifecycle
commands. Other categories and the audit timestamp retain their age.
"""
result = copy.deepcopy(snapshot)
details = result.get('details')
if not isinstance(details, dict) or not isinstance(details.get('updates'), dict):
raise ValueError('Health snapshot has no updates result')
details['updates'] = with_reboot_status(details['updates'], reboot_status())
if details['updates']['status'] == snapshot['details']['updates']['status']:
return result
issues = {status: [] for status in ('CRITICAL', 'WARNING', 'UNKNOWN', 'INFO')}
for key, block in details.items():
if not isinstance(block, dict):
continue
status = block.get('status', 'UNKNOWN')
if status in issues:
label = key.replace('_', ' ').capitalize()
reason = block.get('reason')
if key == 'updates' and not reason:
# Reuse the existing localized reboot detail contract rather
# than introducing an untranslated, context-free "Updates".
issues[status].append(block['checks']['reboot_required']['detail'])
else:
issues[status].append(f'{label}: {reason}' if reason else label)
result['overall'] = ('CRITICAL' if issues['CRITICAL'] else 'WARNING'
if issues['WARNING'] or issues['UNKNOWN'] else 'OK')
primary = next((values for values in issues.values() if values), [])
result['summary'] = '; '.join(primary) if primary else 'All systems operational'
return result
@@ -0,0 +1,56 @@
"""Read-only version information. No installer or configuration writes."""
from datetime import datetime, timezone
from pathlib import Path
import re
import threading
import time
import urllib.request
ROOT = Path('/usr/local/share/proxmenux')
URLS = {
'stable': 'https://raw.githubusercontent.com/MacRimi/ProxMenux/main/version.txt',
'beta': 'https://raw.githubusercontent.com/MacRimi/ProxMenux/develop/beta_version.txt',
}
TTL = 1800
RETRY = 60
_lock = threading.Lock()
_cache = {}
def _version(text):
if not isinstance(text, str) or not re.fullmatch(r'\d{1,8}(?:\.\d{1,8}){1,5}', text):
raise ValueError('Invalid version')
parts = tuple(map(int, text.split('.')))
return parts + (0,) * (6-len(parts))
def version_status():
result = dict(state='unknown', installed=None, available=None, checked_at=None, channel='stable')
try:
beta = ROOT/'beta_version.txt'
channel = 'beta' if beta.exists() else 'stable'
result['channel'] = channel
installed = (beta if channel == 'beta' else ROOT/'version.txt').read_text().strip()
_version(installed)
result.update(installed=installed, channel=channel)
except (OSError, ValueError):
return result # No remote probe without a usable installed version.
with _lock:
now = time.monotonic()
cache = _cache.get(channel, {})
age = now-cache.get('attempt', -float('inf'))
if not 0 <= age < (RETRY if cache.get('failed') else TTL):
try:
req = urllib.request.Request(URLS[channel], headers={'User-Agent': 'ProxMenux-Monitor'})
with urllib.request.urlopen(req, timeout=3) as response:
latest = response.read(129).decode('utf-8').strip()
_version(latest)
cache = dict(attempt=time.monotonic(), version=latest, failed=False,
checked_at=datetime.now(timezone.utc).isoformat())
except (OSError, ValueError, UnicodeError):
cache = dict(cache, attempt=time.monotonic(), failed=True)
_cache[channel] = cache
result.update(available=cache.get('version'), checked_at=cache.get('checked_at'))
if cache.get('version') and not cache.get('failed'):
result['state'] = 'available' if _version(cache['version']) > _version(installed) else 'current'
return result
+2
View File
@@ -1,4 +1,6 @@
{ {
"Host update lock is unavailable.": "Zámok aktualizácie servera nie je dostupný.",
"A host update is already running. Reconnect to it instead of starting another.": "Aktualizácia servera už beží. Otvor jej existujúci priebeh namiesto ďalšieho spustenia.",
"# Alternative if you prefer screen": "# Alternatíva, ak preferujete screen", "# Alternative if you prefer screen": "# Alternatíva, ak preferujete screen",
"# Recommended: avoids disconnection during upgrade": "# Odporúčané: zabráni odpojeniu počas aktualizácie", "# Recommended: avoids disconnection during upgrade": "# Odporúčané: zabráni odpojeniu počas aktualizácie",
"%s of %s updates failed — see the messages above.": "Zlyhalo %s z %s aktualizácií — pozri hlášky vyššie.", "%s of %s updates failed — see the messages above.": "Zlyhalo %s z %s aktualizácií — pozri hlášky vyššie.",
+4
View File
@@ -209,6 +209,10 @@ update_pve_safe() {
if ! pmx_confirm_update "$(translate "Proxmox Update")" "$menu_text" "$upgradable_list" \ if ! pmx_confirm_update "$(translate "Proxmox Update")" "$menu_text" "$upgradable_list" \
"$(translate "Do you want to proceed with the system update?")"; then "$(translate "Do you want to proceed with the system update?")"; then
msg_info2 "$(translate "Update cancelled by user")" msg_info2 "$(translate "Update cancelled by user")"
if [[ "${PROXMENUX_HOST_UPDATE_JOB:-}" == "1" && "${EXECUTION_MODE:-}" == "web" ]]; then
rm -f "$screen_capture"
return 130
fi
apt-get -y autoremove >/dev/null 2>&1 || true apt-get -y autoremove >/dev/null 2>&1 || true
apt-get -y autoclean >/dev/null 2>&1 || true apt-get -y autoclean >/dev/null 2>&1 || true
rm -f "$screen_capture" rm -f "$screen_capture"
+29
View File
@@ -45,6 +45,23 @@ export SCRIPT_TITLE="Proxmox system update"
NECESSARY_REBOOT=1 NECESSARY_REBOOT=1
_pmx_acquire_host_update_lock() {
# /run is root-owned; do not follow an unexpected link or truncate a
# pre-existing file. The inherited descriptor also covers Menu/API/CLI.
local path="$1"
if [[ -L "$path" || ( -e "$path" && ! -f "$path" ) ]]; then
msg_error "$(translate 'Host update lock is unavailable.')"
return 75
fi
exec {PROXMENUX_HOST_UPDATE_LOCK_FD}>> "$path" || return 75
if ! flock -n "$PROXMENUX_HOST_UPDATE_LOCK_FD"; then
msg_error "$(translate 'A host update is already running. Reconnect to it instead of starting another.')"
return 75
fi
}
_pmx_acquire_host_update_lock /run/proxmenux-host-update.lock || exit "$?"
# Suppress the Monitor's `service_fail` notifications while apt is running. # Suppress the Monitor's `service_fail` notifications while apt is running.
# PVE services (pve-cluster, pveproxy, pvedaemon, corosync…) get killed and # PVE services (pve-cluster, pveproxy, pvedaemon, corosync…) get killed and
# restarted as part of the upgrade — those events are expected, not real # restarted as part of the upgrade — those events are expected, not real
@@ -122,6 +139,18 @@ check_reboot() {
NECESSARY_REBOOT=1 NECESSARY_REBOOT=1
fi fi
# Detached web jobs finish without waiting for a final keystroke and
# never reboot the host. CLI callers retain their explicit prompt below.
if [[ "${PROXMENUX_HOST_UPDATE_JOB:-}" == "1" && "${EXECUTION_MODE:-}" == "web" ]]; then
if [[ "$NECESSARY_REBOOT" -eq 1 ]]; then
msg_warn "$(translate 'Reboot Required')"
msg_info2 "$(translate 'You can reboot later manually.')"
else
msg_ok "$(translate 'All changes applied. No reboot required.')"
fi
return 0
fi
if [[ "$NECESSARY_REBOOT" -eq 1 ]]; then if [[ "$NECESSARY_REBOOT" -eq 1 ]]; then
if whiptail --title "$(translate "Reboot Required")" \ if whiptail --title "$(translate "Reboot Required")" \
--yesno "$(translate "Some changes require a reboot to take effect. Do you want to restart now?")" 10 60; then --yesno "$(translate "Some changes require a reboot to take effect. Do you want to restart now?")" 10 60; then
+26
View File
@@ -261,5 +261,31 @@ for (const input of passthrough) {
check(`passthrough ${JSON.stringify(input)}`, () => assert.equal(select(input), input)); check(`passthrough ${JSON.stringify(input)}`, () => assert.equal(select(input), input));
for (const locale of Object.keys(catalogs)) check(`passthrough ${locale} ${JSON.stringify(input)}`, () => assert.equal(render(input, locale), input)); for (const locale of Object.keys(catalogs)) check(`passthrough ${locale} ${JSON.stringify(input)}`, () => assert.equal(render(input, locale), input));
} }
// Real summary contract from health_monitor: category prefix + reason.
for (const [locale, expectedText] of [
['sk', 'Aktualizácie systému: K dispozícii je aktualizácia pre bežiace jadro 7.0.14-25-pve'],
['en', 'System updates: Kernel update available for running 7.0.14-25-pve'],
]) {
check(`prefixed update summary ${locale}`, () => assert.equal(
render('Updates: Kernel update available for running 7.0.14-25-pve', locale), expectedText));
const opaque = 'Repository unavailable; diagnostic: /srv/path {count}\nline 2';
check(`prefixed diagnostic preserved ${locale}`, () => assert.equal(
render('Updates: ' + opaque, locale), translator(locale)('healthStatus.categories.updates') + ': ' + opaque));
}
let summaryMapper;
walk(source,n=>{if(ts.isVariableDeclaration(n)&&n.name.getText(source)==='translateHealthSummary')summaryMapper=n.initializer;});
assert.ok(summaryMapper,'actual summary mapper handles the real bare Updates result');
const summaryFactory=new Function('t',compile('const translateHealthText='+emit(mapper,source)+';const translateHealthSummary='+emit(summaryMapper,source))+';return translateHealthSummary;');
for(const locale of ['sk','en']){
const t=translator(locale), summary=summaryFactory(t);
const detail='Reboot required: running 7.0.14-25-pve; installed 7.0.14-26-pve';
const value={summary:'Updates',details:{updates:{checks:{reboot_required:{status:'INFO',detail}}}}};
const before=JSON.stringify(value);
assert.equal(summary(value),t('healthStatus.hostUpdate.rebootKernel',{running:'7.0.14-25-pve',installed:'7.0.14-26-pve'}));
assert.equal(JSON.stringify(value),before,'rendering never mutates the snapshot');
assert.equal(summary({summary:'Updates',details:{}}),t('healthStatus.categories.updates'));
assert.equal(summary({summary:'Updates',details:{updates:{checks:{reboot_required:{status:'UNKNOWN',detail}}}}}),t('healthStatus.categories.updates'),'unknown evidence cannot create a reboot-required summary');
assert.equal(summary({summary:'opaque diagnostic',details:{}}),'opaque diagnostic');
}
if (failures) throw new Error(`${failures} health-text assertions failed`); if (failures) throw new Error(`${failures} health-text assertions failed`);
console.log(`PASS: actual health mapper/provider; ${new Set(cases.map(row => row[1])).size} new keys, ${cases.length} message variants, ${Object.keys(catalogs).length} shipped locales and unconditional missing-key fallback`); console.log(`PASS: actual health mapper/provider; ${new Set(cases.map(row => row[1])).size} new keys, ${cases.length} message variants, ${Object.keys(catalogs).length} shipped locales and unconditional missing-key fallback`);
+84
View File
@@ -0,0 +1,84 @@
"""Real JWT verification, synthetic keys only; never imports live auth config."""
import ast
import contextlib
from datetime import datetime, timedelta
import hashlib
import io
from pathlib import Path
import threading
import time
import unittest
import jwt
ROOT = Path(__file__).resolve().parents[1]
class AuthReasons(unittest.TestCase):
def setUp(self):
source = ast.parse((ROOT / 'AppImage/scripts/auth_manager.py').read_text())
names = {'verify_token_full', '_log_auth_failure_throttled', 'generate_token'}
nodes = [n for n in source.body if isinstance(n, ast.FunctionDef) and n.name in names]
constants = {n.targets[0].id: ast.literal_eval(n.value) for n in source.body
if isinstance(n, ast.Assign) and isinstance(n.targets[0], ast.Name)
and n.targets[0].id in {'TOKEN_EXPIRATION_HOURS', 'JWT_ALGORITHM', 'JWT_ISSUER', 'JWT_AUDIENCE'}}
self.revoked = set()
self.scope = dict(jwt=jwt, JWT_AVAILABLE=True, hashlib=hashlib, time=time,
_get_jwt_secret=lambda: 'fixture-only-secret-for-offline-auth-tests',
_get_revoked_tokens_cached=lambda: self.revoked,
_AUTH_LOG_LOCK=threading.Lock(),
_AUTH_LOG_RATE=dict(last_ts=0., suppressed=0, last_msg=''),
datetime=datetime, timedelta=timedelta, **constants)
exec(compile(ast.Module(body=nodes, type_ignores=[]), 'actual-auth', 'exec'), self.scope)
def token(self, **values):
claims = dict(username='fixture-admin', exp=time.time()+120,
iss=self.scope['JWT_ISSUER'], aud=self.scope['JWT_AUDIENCE'])
claims.update(values)
return jwt.encode(claims, self.scope['_get_jwt_secret'](), algorithm='HS256')
def rejection(self, token, reason):
self.scope['_AUTH_LOG_RATE'].update(last_ts=0., suppressed=0, last_msg='')
out = io.StringIO()
with contextlib.redirect_stdout(out):
result = self.scope['verify_token_full'](token)
self.assertEqual(result, (None, None))
log = out.getvalue()
self.assertIn(reason, log)
for secret in (self.scope['_get_jwt_secret'](), token or 'not-present', 'fixture-admin'):
self.assertNotIn(secret, log)
def test_expired_invalid_revoked_and_missing_are_safe_categories(self):
self.rejection(self.token(exp=time.time()-10), 'expired')
self.rejection('fixture.invalid.token', 'invalid')
token = self.token()
self.revoked.add(hashlib.sha256(token.encode()).hexdigest())
self.rejection(token, 'revoked')
self.rejection(None, 'missing token')
def test_valid_and_legacy_scopes_keep_existing_contract(self):
verify = self.scope['verify_token_full']
self.assertEqual(verify(self.token()), ('fixture-admin', 'full_admin'))
self.assertEqual(verify(self.token(scope='read_only')), ('fixture-admin', 'read_only'))
legacy = jwt.encode(dict(username='fixture-admin', exp=time.time()+120),
self.scope['_get_jwt_secret'](), algorithm='HS256')
self.assertEqual(verify(legacy), ('fixture-admin', 'full_admin'))
def test_normal_session_lifetime_remains_24_hours(self):
self.assertEqual(self.scope['TOKEN_EXPIRATION_HOURS'], 24)
token = self.scope['generate_token']('fixture-admin')
payload = jwt.decode(token, self.scope['_get_jwt_secret'](), algorithms=['HS256'],
audience=self.scope['JWT_AUDIENCE'], issuer=self.scope['JWT_ISSUER'])
self.assertAlmostEqual(payload['exp']-payload['iat'], 86400, delta=1)
def test_repeated_auth_failures_are_rate_limited_without_secrets(self):
out = io.StringIO()
with contextlib.redirect_stdout(out):
for _ in range(100):
self.scope['verify_token_full']('fixture.invalid.token')
self.assertEqual(out.getvalue().count('[auth]'), 1)
self.assertNotIn('fixture.invalid.token', out.getvalue())
if __name__ == '__main__':
unittest.main(verbosity=2)
+52
View File
@@ -0,0 +1,52 @@
// Execute the actual destructive-action callback using an inert transport.
const assert=require('node:assert/strict'),fs=require('node:fs'),path=require('node:path'),vm=require('node:vm')
const app=path.resolve(__dirname,'../AppImage'),ts=require(path.join(app,'node_modules/typescript'))
const tree=ts.createSourceFile('controls.tsx',fs.readFileSync(path.join(app,'components/host-update-status.tsx'),'utf8'),ts.ScriptTarget.Latest,true,ts.ScriptKind.TSX)
let callback,triggerDisabled
function walk(n){
if(ts.isVariableDeclaration(n)&&n.name.getText(tree)==='confirm')callback=n.initializer.getText(tree)
if(ts.isJsxElement(n)&&n.openingElement.tagName.getText(tree)==='Button'&&n.children.some(c=>c.getText(tree).includes('healthStatus.restart.button'))){
triggerDisabled=n.openingElement.attributes.properties.find(p=>p.name?.getText(tree)==='disabled').initializer.expression.getText(tree)
}
ts.forEachChild(n,walk)
}
walk(tree);assert.ok(callback)
function fixture(){
const calls=[],events=[]
const scope={AbortController,JSON,Error,disabled:false,unavailable:false,context:{allowed:true,hostname:'fixture-host',boot_id:'fixture-boot'},submitted:false,
sending:{current:false},alive:{current:true},postRequest:{current:null},getApiUrl:p=>p,headers:()=>({}),
setSubmitted:v=>events.push(['submitted',v]),setPending:v=>events.push(['pending',v]),setError:v=>events.push(['error',v]),
setAccepted:v=>events.push(['accepted',v]),setOpen:v=>events.push(['open',v]),
setTimeout:()=>1,clearTimeout(){},fetch:async(p,options)=>{calls.push({p,options});return {status:202,json:async()=>({accepted:true})}}}
const run=vm.runInNewContext(ts.transpileModule('('+callback+')',{compilerOptions:{target:ts.ScriptTarget.ES2020}}).outputText,scope)
return {scope,calls,events,run}
}
async function main(){
assert.ok(triggerDisabled)
assert.equal(vm.runInNewContext(triggerDisabled,{disabled:false,unavailable:false,context:{allowed:true},accepted:false,pending:true}),true,'closing confirmation while its request is pending must not enable another request')
for(const block of ['disabled','unavailable','submitted','sending','missing-context','not-allowed']){
const x=fixture()
if(block==='sending')x.scope.sending.current=true
else if(block==='missing-context')x.scope.context=null
else if(block==='not-allowed')x.scope.context.allowed=false
else x.scope[block]=true
await x.run();assert.equal(x.calls.length,0);assert.equal(x.events.length,0)
}
const x=fixture();await x.run();await x.run()
assert.equal(x.calls.length,1,'duplicate confirm never sends a second request')
assert.equal(x.calls[0].p,'/api/health/reboot-after-update')
assert.equal(x.calls[0].options.method,'POST')
assert.deepEqual(JSON.parse(x.calls[0].options.body),{confirm:true,hostname:'fixture-host',boot_id:'fixture-boot'})
assert.ok(x.events.some(e=>e[0]==='accepted'&&e[1]===true))
assert.equal(x.scope.postRequest.current,null)
for(const response of [{status:409},{status:202,json:async()=>({accepted:false})}]){
const e=fixture();e.scope.fetch=async()=>response;await e.run()
assert.ok(e.events.some(v=>v[0]==='error'&&v[1]===true))
assert.ok(!e.events.some(v=>v[0]==='accepted'))
}
const e=fixture();e.scope.fetch=async()=>{throw Error('unknown outcome')};await e.run()
assert.ok(!e.events.some(v=>v[0]==='accepted'))
assert.ok(e.scope.sending.current,'unknown outcome is not automatically retried')
console.log('PASS actual confirmation guards, exact payload, duplicate suppression and unknown outcomes')
}
main().catch(e=>{console.error(e);process.exitCode=1})
+349
View File
@@ -0,0 +1,349 @@
"""Actual version/guard helpers and authenticated Flask routes; no real reboot."""
import fcntl
import importlib.util
import io
import json
from pathlib import Path
import shlex
import subprocess
import sys
import tempfile
from types import ModuleType, SimpleNamespace
import unittest
from contextlib import contextmanager
from unittest.mock import Mock, patch
from flask import Flask
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT/'AppImage/scripts'))
import host_update_controls as controls
import proxmenux_version_status as versions
SHUTDOWN_HELPER = b'/usr/share/unattended-upgrades/unattended-upgrade-shutdown'
WAITING_HELPER = b'/usr/bin/python3\0' + SHUTDOWN_HELPER + b'\0--wait-for-signal\0'
REAL_UPDATER = b'/usr/bin/python3\0/usr/bin/unattended-upgrade\0'
class VersionStatus(unittest.TestCase):
def setUp(self):
temporary = tempfile.TemporaryDirectory(); self.addCleanup(temporary.cleanup)
self.root = Path(temporary.name)
self.root_patch = patch.object(versions, 'ROOT', self.root); self.root_patch.start(); self.addCleanup(self.root_patch.stop)
versions._cache.clear()
(self.root/'version.txt').write_text('1.2.7\n')
self.now = 1000.
self.clock = patch.object(versions.time, 'monotonic', side_effect=lambda:self.now); self.clock.start(); self.addCleanup(self.clock.stop)
self.remote = Mock(return_value=io.BytesIO(b'1.2.8\n'))
self.network = patch.object(versions.urllib.request, 'urlopen', self.remote); self.network.start(); self.addCleanup(self.network.stop)
def test_real_contract_stable_and_beta_use_only_fixed_urls(self):
result = versions.version_status()
self.assertEqual((result['state'], result['installed'], result['available']), ('available','1.2.7','1.2.8'))
self.assertEqual(self.remote.call_args.args[0].full_url, versions.URLS['stable'])
(self.root/'beta_version.txt').write_text('1.2.9')
self.remote.return_value = io.BytesIO(b'1.2.10')
result = versions.version_status()
self.assertEqual((result['channel'], result['state'], result['available']), ('beta','available','1.2.10'))
self.assertEqual(self.remote.call_args.args[0].full_url, versions.URLS['beta'])
def test_fresh_cache_then_ttl_boundary_refetch(self):
versions.version_status()
for age in (0,1799.999):
self.now = 1000+age
self.assertEqual(versions.version_status()['state'],'available')
self.assertEqual(self.remote.call_count,1)
self.now=2800
self.remote.return_value = io.BytesIO(b'1.2.7')
self.assertEqual(versions.version_status()['state'],'current')
self.assertEqual(self.remote.call_count,2)
self.now=4600.001;self.remote.return_value=io.BytesIO(b'1.2.9')
self.assertEqual(versions.version_status()['state'],'available')
def test_failed_refresh_keeps_last_known_and_retries_after_sixty_seconds(self):
first=versions.version_status()
self.now=2800;self.remote.side_effect=OSError('fixture timeout')
failed=versions.version_status()
self.assertEqual(failed['state'],'unknown')
self.assertEqual(failed['available'],first['available'])
self.assertEqual(failed['checked_at'],first['checked_at'])
self.now=2859.999;versions.version_status();self.assertEqual(self.remote.call_count,2)
self.now=2860;versions.version_status();self.assertEqual(self.remote.call_count,3)
def test_missing_or_invalid_installed_never_probes_network(self):
for text in ('', 'junk', '1.2.7\npartial', '1.2.7-rc1'):
(self.root/'version.txt').write_text(text)
self.assertEqual(versions.version_status()['state'],'unknown')
(self.root/'version.txt').unlink()
self.assertEqual(versions.version_status()['state'],'unknown')
self.remote.assert_not_called()
def test_bad_remote_and_http_failure_never_mean_current_or_write_local_files(self):
before={p.name:p.read_bytes() for p in self.root.iterdir()}
for content in (b'',b'<html>error</html>',b'1.2.8\npartial',b'1.'+b'9'*128,b'\xff'):
versions._cache.clear(); self.remote.return_value=io.BytesIO(content)
self.assertEqual(versions.version_status()['state'],'unknown')
versions._cache.clear(); self.remote.side_effect=TimeoutError('fixture')
self.assertEqual(versions.version_status()['state'],'unknown')
self.assertEqual({p.name:p.read_bytes() for p in self.root.iterdir()},before)
def test_installed_version_change_and_numeric_comparison(self):
versions.version_status()
(self.root/'version.txt').write_text('1.2.10')
self.assertEqual(versions.version_status()['state'],'current')
self.assertEqual(self.remote.call_count,1)
self.assertEqual(versions._version('1.2'),versions._version('1.2.0'))
class RebootGuards(unittest.TestCase):
def setUp(self):
temporary=tempfile.TemporaryDirectory();self.addCleanup(temporary.cleanup)
self.root=Path(temporary.name); self.lock=self.root/'update.lock'
(self.root/'proc').mkdir()
self.proc=patch.object(controls,'PROC',self.root/'proc');self.proc.start();self.addCleanup(self.proc.stop)
boot=self.root/'boot_id';boot.write_text('fixture-boot')
self.boot=patch.object(controls,'BOOT_ID',boot);self.boot.start();self.addCleanup(self.boot.stop)
self.a=patch.object(controls,'LOCK',str(self.lock)); self.a.start(); self.addCleanup(self.a.stop)
self.jobs=patch.object(controls,'list_jobs',return_value=[]).start()
self.probe=patch.object(controls,'reboot_status',return_value={'state':'required'}).start()
self.addCleanup(patch.stopall)
def package(self, command=WAITING_HELPER, name='unattended-upgr', pid='123'):
path=self.root/'proc'/pid;path.mkdir(exist_ok=True)
(path/'comm').write_text(name+'\n')
(path/'cmdline').write_bytes(command)
return path
def test_waiting_shutdown_helper_does_not_block_required_reboot_or_run_commands(self):
# Debian's service ExecStart, including the /usr/bin/python3 shebang.
for command in (WAITING_HELPER, SHUTDOWN_HELPER+b'\0--wait-for-signal\0'):
with self.subTest(command=command),patch.object(controls.subprocess,'run') as run:
self.package(command)
self.assertEqual(controls.reboot_context()['reason'],'required')
run.assert_not_called()
def test_actual_package_tools_and_unattended_updater_still_block_before_probe(self):
for name in ('apt','apt-get','dpkg','unattended-upgr'):
with self.subTest(name=name):
self.package(REAL_UPDATER,name)
self.assertEqual(controls.reboot_context()['reason'],'busy')
self.probe.assert_not_called()
def test_waiting_helper_never_hides_another_active_updater(self):
self.package()
self.package(REAL_UPDATER,pid='124')
self.assertEqual(controls.reboot_context()['reason'],'busy')
self.probe.assert_not_called()
def test_helper_without_wait_flag_and_lookalike_arguments_stay_busy(self):
commands=(SHUTDOWN_HELPER+b'\0',
b'/usr/bin/unattended-upgrade\0--wait-for-signal\0',
b'/usr/bin/unattended-upgrade\0'+SHUTDOWN_HELPER+b'\0--wait-for-signal\0',
b'/bin/sh\0-c\0'+SHUTDOWN_HELPER+b' --wait-for-signal\0',
b'/usr/bin/python3\0-c\0'+SHUTDOWN_HELPER+b'\0--wait-for-signal\0',
WAITING_HELPER+b'--unexpected\0',
b'/tmp/unattended-upgrade-shutdown\0--wait-for-signal\0')
for command in commands:
with self.subTest(command=command):
self.package(command)
self.assertEqual(controls.reboot_context()['reason'],'busy')
self.probe.assert_not_called()
def test_empty_partial_malformed_and_missing_live_cmdline_stay_busy(self):
for command in (b'',b'\0',WAITING_HELPER[:-1],WAITING_HELPER+b'\0',b'\xff\0'):
with self.subTest(command=command):
self.package(command)
self.assertEqual(controls.reboot_context()['reason'],'busy')
(self.root/'proc/123/cmdline').unlink()
self.assertEqual(controls.reboot_context()['reason'],'busy')
self.probe.assert_not_called()
def test_unreadable_cmdline_blocks_but_process_that_exits_can_be_skipped(self):
process=self.package()
with patch.object(Path,'read_bytes',side_effect=PermissionError('fixture denied')):
self.assertEqual(controls.reboot_context()['reason'],'busy')
self.probe.assert_not_called()
def exited():
for path in process.iterdir():path.unlink()
process.rmdir()
raise FileNotFoundError('fixture exited after comm read')
with patch.object(Path,'read_bytes',side_effect=exited):
self.assertTrue(controls.reboot_context()['allowed'])
def test_active_attention_starting_and_unreadable_jobs_block_before_kernel_probe(self):
for state in ('starting','running','attention','finalizing',None):
self.jobs.return_value=[{'state':state}]
self.assertFalse(controls.reboot_context()['allowed'])
self.jobs.side_effect=OSError('fixture missing registry')
self.assertFalse(controls.reboot_context()['allowed'])
self.probe.assert_not_called()
def test_lock_probe_does_not_create_missing_file_and_symlink_is_refused(self):
self.assertTrue(controls.reboot_context()['allowed'])
self.assertFalse(self.lock.exists())
self.lock.symlink_to(self.root/'missing')
self.probe.reset_mock()
self.assertFalse(controls.reboot_context()['allowed'])
self.probe.assert_not_called()
def test_real_shared_flock_stops_probe_and_final_command_before_any_effect(self):
self.lock.touch()
marker=self.root/'inert-action'
command=list(controls.COMMAND)
self.assertEqual(command[5:],['/usr/bin/python3','/usr/local/share/proxmenux/monitor-app/usr/bin/host_update_controls.py','--execute-reboot'])
command[4]=str(self.lock);command[5:]=['/bin/sh','-c','printf reached > '+shlex.quote(str(marker))]
with self.lock.open('r') as owner:
fcntl.flock(owner,fcntl.LOCK_EX|fcntl.LOCK_NB)
self.assertFalse(controls.reboot_context()['allowed'])
self.probe.assert_not_called()
self.assertEqual(subprocess.run(command,check=False).returncode,75)
self.assertFalse(marker.exists())
self.assertEqual(subprocess.run(command,check=False).returncode,0)
self.assertEqual(marker.read_text(),'reached')
def test_current_unknown_and_read_failures_never_allow_restart(self):
for state in ('current','unknown','not_applicable'):
self.probe.return_value={'state':state}
self.assertFalse(controls.reboot_context()['allowed'])
def test_direct_package_process_blocks_before_kernel_probe(self):
path=self.root/'proc/123';path.mkdir();(path/'comm').write_text('apt-get\n')
self.assertFalse(controls.reboot_context()['allowed'])
self.probe.assert_not_called()
def test_worker_package_locks_missing_or_unknown_kernel_never_reboot(self):
paths=[str(self.root/name) for name in ('frontend','backend')]
with patch.object(controls,'PACKAGE_LOCKS',paths),patch.object(controls.subprocess,'run') as reboot,patch.object(controls.time,'sleep') as delay:
self.assertEqual(controls.execute_reboot(),75)
reboot.assert_not_called();delay.assert_not_called()
for path in paths:Path(path).touch()
self.probe.return_value={'state':'unknown'}
self.assertEqual(controls.execute_reboot(),77)
reboot.assert_not_called()
self.probe.return_value={'state':'required'}
self.assertEqual(controls.execute_reboot(),0)
reboot.assert_called_once_with(['/bin/systemctl','reboot'],check=True,timeout=10)
delay.assert_called_once_with(60)
def test_actual_posix_package_lock_blocks_worker_before_reboot(self):
paths=[str(self.root/name) for name in ('frontend','backend')]
for path in paths:Path(path).touch()
# A separate process holds the same POSIX lock, just as APT does.
code='import fcntl,sys,time; f=open(sys.argv[1],"r+"); fcntl.lockf(f,fcntl.LOCK_EX); print("locked",flush=True); time.sleep(5)'
for held in paths:
child=subprocess.Popen([sys.executable,'-c',code,held],stdout=subprocess.PIPE,text=True)
try:
self.assertEqual(child.stdout.readline().strip(),'locked')
with patch.object(controls,'PACKAGE_LOCKS',paths),patch.object(controls.subprocess,'run') as reboot:
self.assertEqual(controls.execute_reboot(),75)
reboot.assert_not_called()
self.probe.assert_not_called()
self.assertEqual([Path(path).read_bytes() for path in paths],[b'',b''])
finally:
child.terminate();child.wait(timeout=2);child.stdout.close()
class Routes(unittest.TestCase):
def setUp(self):
auth=ModuleType('auth_manager')
auth.load_auth_config=lambda:{'enabled':True}
auth.verify_token=lambda token:'fixture' if token=='admin' else None
auth.verify_token_full=lambda token:('fixture','full_admin') if token=='admin' else ('fixture','read_only') if token=='reader' else (None,None)
patches={'auth_manager':auth,'health_monitor':SimpleNamespace(health_monitor=object()),
'health_persistence':SimpleNamespace(health_persistence=object()),'mount_monitor':ModuleType('mount_monitor')}
self.modules=patch.dict(sys.modules,patches);self.modules.start();self.addCleanup(self.modules.stop)
def load(name,filename):
spec=importlib.util.spec_from_file_location(name,ROOT/'AppImage/scripts'/filename)
module=importlib.util.module_from_spec(spec);spec.loader.exec_module(module);return module
middleware=load('controls_middleware','jwt_middleware.py')
self.mw=patch.dict(sys.modules,{'jwt_middleware':middleware});self.mw.start();self.addCleanup(self.mw.stop)
routes=load('controls_routes','flask_health_routes.py')
self.app=Flask(__name__);self.app.register_blueprint(routes.health_bp)
self.run=Mock(return_value=(True,{}));self.state=Mock(return_value={'state':'running'})
self.app.extensions['host_update_power']={'run':self.run,'state':self.state}
self.real_context=controls.reboot_context
self.context=patch.object(controls,'reboot_context',return_value={'allowed':True,'reason':'required','boot_id':'fixture-boot'}).start()
self.version=patch.object(versions,'version_status',return_value={'state':'unknown'}).start()
self.addCleanup(patch.stopall)
self.client=self.app.test_client()
import socket
self.body={'confirm':True,'hostname':socket.gethostname(),'boot_id':'fixture-boot'}
@contextmanager
def actual_package_context(self,command):
with tempfile.TemporaryDirectory() as folder:
root=Path(folder);proc=root/'proc';(proc/'123').mkdir(parents=True)
(proc/'123/comm').write_text('unattended-upgr\n')
(proc/'123/cmdline').write_bytes(command)
boot=root/'boot_id';boot.write_text('fixture-boot')
with patch.object(controls,'PROC',proc),patch.object(controls,'BOOT_ID',boot),\
patch.object(controls,'LOCK',str(root/'missing-lock')),\
patch.object(controls,'list_jobs',return_value=[]),\
patch.object(controls,'reboot_status',return_value={'state':'required'}) as probe,\
patch.object(controls,'reboot_context',wraps=self.real_context):
yield probe
def test_actual_context_routes_allow_waiting_helper_and_only_queue_guarded_action(self):
with self.actual_package_context(WAITING_HELPER):
response=self.client.get('/api/health/reboot-context',headers={'Authorization':'Bearer admin'})
self.assertEqual(response.status_code,200)
self.assertTrue(response.json['allowed'])
self.run.assert_not_called()
response=self.client.post('/api/health/reboot-after-update',json=self.body,headers={'Authorization':'Bearer admin'})
self.assertEqual(response.status_code,202)
self.run.assert_called_once_with(controls.UNIT,list(controls.COMMAND),description='ProxMenux: reboot after host update',extra_properties=['RuntimeMaxSec=75'])
def test_actual_context_routes_deny_active_or_uncertain_updater_without_launch(self):
for command in (REAL_UPDATER,b'',WAITING_HELPER[:-1]):
with self.subTest(command=command),self.actual_package_context(command) as probe:
response=self.client.get('/api/health/reboot-context',headers={'Authorization':'Bearer admin'})
self.assertEqual(response.status_code,200)
self.assertEqual(response.json['reason'],'busy')
response=self.client.post('/api/health/reboot-after-update',json=self.body,headers={'Authorization':'Bearer admin'})
self.assertEqual(response.status_code,409)
self.assertEqual(response.json['reason'],'busy')
probe.assert_not_called();self.run.assert_not_called()
def test_denied_auth_and_methods_do_not_probe_or_launch(self):
for token in (None,'bad','reader'):
headers={'Authorization':'Bearer '+token} if token else {}
response=self.client.post('/api/health/reboot-after-update',json=self.body,headers=headers)
self.assertEqual(response.status_code,403 if token=='reader' else 401)
self.assertEqual(self.client.get('/api/health/reboot-after-update').status_code,405)
self.context.assert_not_called();self.run.assert_not_called()
self.assertEqual(self.client.get('/api/health/proxmenux-version').status_code,401)
self.version.assert_not_called()
def test_invalid_or_missing_confirmation_and_wrong_host_have_zero_probes(self):
for body in ({},{'confirm':'true'},dict(self.body,confirm=False),dict(self.body,hostname='another-host')):
self.assertEqual(self.client.post('/api/health/reboot-after-update',json=body,headers={'Authorization':'Bearer admin'}).status_code,400)
self.context.assert_not_called();self.run.assert_not_called()
def test_busy_current_unknown_and_unconfigured_runner_never_launch(self):
for reason in ('busy','current','unknown'):
self.context.return_value={'allowed':False,'reason':reason,'boot_id':'fixture-boot'}
self.assertEqual(self.client.post('/api/health/reboot-after-update',json=self.body,headers={'Authorization':'Bearer admin'}).status_code,409)
self.app.extensions.clear();self.context.reset_mock()
self.assertEqual(self.client.post('/api/health/reboot-after-update',json=self.body,headers={'Authorization':'Bearer admin'}).status_code,503)
self.context.assert_not_called();self.run.assert_not_called()
def test_stale_or_other_server_boot_identity_never_launches(self):
self.assertEqual(self.client.post('/api/health/reboot-after-update',json=dict(self.body,boot_id='another-boot'),headers={'Authorization':'Bearer admin'}).status_code,409)
self.run.assert_not_called()
def test_allowed_confirmation_queues_only_fixed_guarded_action_and_preserves_existing_api(self):
response=self.client.post('/api/health/reboot-after-update',json=self.body,headers={'Authorization':'Bearer admin'})
self.assertEqual(response.status_code,202);self.assertTrue(response.json['accepted'])
self.run.assert_called_once_with(controls.UNIT,list(controls.COMMAND),description='ProxMenux: reboot after host update',extra_properties=['RuntimeMaxSec=75'])
self.run.return_value=(False,{'status':409})
self.assertEqual(self.client.post('/api/health/reboot-after-update',json=self.body,headers={'Authorization':'Bearer admin'}).status_code,409)
def test_read_only_version_is_allowed_but_context_and_action_status_are_admin_only(self):
self.assertEqual(self.client.get('/api/health/proxmenux-version',headers={'Authorization':'Bearer reader'}).status_code,200)
for path in ('reboot-context','reboot-after-update/status'):
self.assertEqual(self.client.get('/api/health/'+path,headers={'Authorization':'Bearer reader'}).status_code,403)
self.context.assert_not_called();self.state.assert_not_called();self.run.assert_not_called()
if __name__=='__main__':
unittest.main(verbosity=2)
+372
View File
@@ -0,0 +1,372 @@
"""Real inert host workers plus actual auth/ticket/viewer boundaries. No APT."""
import ast
import base64
import importlib.util
import io
import json
import os
from pathlib import Path
import shlex
import sys
import tempfile
import threading
import time
from types import ModuleType, SimpleNamespace
import unittest
from unittest.mock import Mock, patch
from flask import Blueprint, Flask, jsonify, request
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'AppImage/scripts'))
import host_update_jobs as jobs
def wait(predicate, timeout=6):
limit = time.monotonic() + timeout
while not predicate():
if time.monotonic() >= limit:
raise AssertionError('Inert job did not reach the expected boundary')
time.sleep(.01)
class Jobs(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.folder = Path(self.tmp.name)
self.script, self.gate, self.events = [self.folder / n for n in ('host.sh', 'finish', 'events')]
self.script.write_text(f'echo started >> {shlex.quote(str(self.events))}\n'
f'while [ ! -e {shlex.quote(str(self.gate))} ]; do sleep .02; done\n'
f'echo final-step >> {shlex.quote(str(self.events))}\necho done\n')
self.fixed = patch.object(jobs, 'SCRIPT', str(self.script))
self.fixed.start()
self.addCleanup(self.fixed.stop)
jobs._jobs.clear()
self.callback = Mock()
self.addCleanup(self.finish_all)
def finish_all(self):
self.gate.touch()
for job in list(jobs._jobs.values()):
wait(lambda: job.state == 'complete')
jobs._jobs.clear()
def start(self, identity='job-1'):
return jobs.start(identity, self.callback)
def finish(self, job):
self.gate.touch()
wait(lambda: job.state == 'complete')
def test_real_worker_finishes_post_steps_without_any_viewer(self):
job = self.start()
wait(self.events.exists)
self.assertEqual(self.events.read_text().splitlines(), ['started'])
self.finish(job)
self.assertEqual(self.events.read_text().splitlines(), ['started', 'final-step'])
self.assertEqual(job.exit_code, 0)
self.callback.assert_called_once()
def test_same_id_is_idempotent_and_other_id_cannot_start(self):
first = self.start()
wait(self.events.exists)
with patch.object(jobs.subprocess, 'Popen') as spawn:
self.assertIs(self.start(), first)
with self.assertRaises(jobs.JobConflict):
self.start('other')
self.assertIs(jobs.get(first.id), first)
spawn.assert_not_called()
self.finish(first)
self.assertIs(self.start(), first)
def test_concurrent_start_has_one_process(self):
barrier, results = threading.Barrier(6), []
def run():
barrier.wait()
results.append(self.start())
workers = [threading.Thread(target=run) for _ in range(6)]
for worker in workers:
worker.start()
for worker in workers:
worker.join()
self.assertEqual(len({id(job) for job in results}), 1)
self.finish(results[0])
self.assertEqual(self.events.read_text().splitlines().count('started'), 1)
def test_read_list_and_missing_resume_have_zero_effects(self):
with patch.object(jobs.subprocess, 'Popen') as spawn, patch.object(jobs.tempfile, 'mkstemp') as files:
self.assertEqual(jobs.list_jobs(), [])
for value in ('missing', '../bad', '', None):
with self.subTest(value=value), self.assertRaises((KeyError, ValueError)):
jobs.get(value)
spawn.assert_not_called()
files.assert_not_called()
def test_invalid_start_and_missing_script_spawn_nothing(self):
with patch.object(jobs.subprocess, 'Popen') as spawn, patch.object(jobs.tempfile, 'mkstemp') as files:
for value in ('../bad', 'x'*65, None):
with self.assertRaises(ValueError):
self.start(value)
with patch.object(jobs.os.path, 'isfile', return_value=False):
with self.assertRaises(ValueError):
self.start()
spawn.assert_not_called()
files.assert_not_called()
def test_owner_thread_failure_cannot_spawn_an_unowned_update(self):
with patch.object(jobs.threading.Thread, 'start', side_effect=RuntimeError('thread unavailable')), patch.object(jobs.subprocess, 'Popen') as spawn:
with self.assertRaises(RuntimeError):
self.start()
spawn.assert_not_called()
self.assertEqual(jobs.list_jobs(), [])
def test_popen_failure_is_recorded_not_success_or_automatic_retry(self):
with patch.object(jobs.subprocess, 'Popen', side_effect=OSError('fixture failure')) as spawn:
job = self.start()
wait(lambda: job.state == 'complete')
self.assertEqual(job.exit_code, 1)
self.assertIs(self.start(), job)
spawn.assert_called_once()
self.assertFalse(self.events.exists())
def test_runtime_limit_marks_attention_without_signal_or_second_update(self):
with patch.object(jobs, 'ATTENTION_AFTER', .05), patch.object(jobs.os, 'killpg') as signals:
job = self.start()
wait(lambda: job.state == 'attention')
self.assertIsNone(job.process.poll())
with self.assertRaises(jobs.JobConflict):
self.start('other')
signals.assert_not_called()
self.finish(job)
self.assertEqual(job.exit_code, 0)
def test_explicit_viewer_cancel_and_terminal_control_cannot_kill_host(self):
job = self.start()
wait(self.events.exists)
with patch.object(jobs.os, 'killpg') as signals:
for value in ({'type':'cancel'}, '\x03', 'paste\x1a', '\x1c'):
with self.assertRaises(ValueError):
job.input(value)
signals.assert_not_called()
self.finish(job)
def test_partial_interaction_replays_and_can_only_be_answered_once(self):
job = self.start()
wait(self.events.exists)
identity = 'host-fixture-' + os.urandom(8).hex()
target = Path('/tmp/proxmenux_response_' + identity)
self.addCleanup(lambda: target.unlink(missing_ok=True))
b64 = lambda s: base64.b64encode(s.encode()).decode()
text = f'WEB_INTERACTION:yesno:{identity}:{b64("Confirm")}:{b64("Question")}\n'
job._interactions(io.StringIO(text[:17]))
self.assertIsNone(job.interaction)
job._interactions(io.StringIO(text[17:]))
self.assertEqual(jobs.get(job.id).read(0)['interaction']['id'], identity)
job.input(dict(type='interaction_response', id=identity, value='yes'))
self.assertEqual(target.read_text(), 'yes')
self.assertEqual(target.stat().st_mode & 0o777, 0o600)
with patch.object(jobs.os, 'open') as write:
with self.assertRaises(ValueError):
job.input(dict(type='interaction_response', id=identity, value='yes'))
write.assert_not_called()
self.finish(job)
def test_symlink_response_is_not_overwritten(self):
job = self.start()
wait(self.events.exists)
identity = 'host-fixture-' + os.urandom(8).hex()
target = Path('/tmp/proxmenux_response_' + identity)
target.symlink_to(self.events)
self.addCleanup(lambda: target.unlink(missing_ok=True))
job.interaction = dict(id=identity)
before = self.events.read_bytes()
with self.assertRaises(OSError):
job.input(dict(type='interaction_response', id=identity, value='no'))
self.assertEqual(self.events.read_bytes(), before)
self.finish(job)
def test_finalizing_reserves_host_and_failure_is_not_hidden(self):
entered, release = threading.Event(), threading.Event()
self.addCleanup(release.set)
def finalize(*_):
entered.set()
release.wait(3)
raise RuntimeError('cache failure')
self.callback.side_effect = finalize
job = self.start()
self.gate.touch()
self.assertTrue(entered.wait(3))
with self.assertRaises(jobs.JobConflict):
self.start('other')
release.set()
wait(lambda: job.state == 'complete')
self.assertEqual(job.script_exit_code, 0)
self.assertEqual(job.exit_code, 1)
self.assertTrue(job.finalization_error)
self.callback.assert_called_once()
def test_failed_shell_with_surviving_child_remains_reserved(self):
self.script.write_text(
f'(trap "" HUP; echo child-ready >> {shlex.quote(str(self.events))}; '
f'while [ ! -e {shlex.quote(str(self.gate))} ]; do sleep .02; done; '
f'echo child-finished >> {shlex.quote(str(self.events))}) &\n'
f'while [ ! -s {shlex.quote(str(self.events))} ]; do sleep .01; done\nexit 7\n')
job = self.start()
wait(lambda: job.state == 'attention')
with self.assertRaises(jobs.JobConflict):
self.start('other')
self.gate.touch()
wait(lambda: job.state == 'complete')
self.assertEqual(job.exit_code, 7)
self.assertIn('child-finished', self.events.read_text())
class Routes(unittest.TestCase):
finish_all = Jobs.finish_all
finish = Jobs.finish
def setUp(self):
Jobs.setUp(self)
self.app = Flask(__name__)
auth = ModuleType('auth_manager')
auth.load_auth_config = lambda: {'enabled':True}
auth.verify_token = lambda token: 'fixture' if token=='valid' else None
auth.verify_token_full = lambda token: ('fixture','full_admin') if token=='valid' else ('fixture','read_only') if token=='read-only' else (None,None)
modules = patch.dict(sys.modules, {'auth_manager':auth})
modules.start()
self.addCleanup(modules.stop)
spec = importlib.util.spec_from_file_location('host_jwt_fixture',ROOT/'AppImage/scripts/jwt_middleware.py')
middleware = importlib.util.module_from_spec(spec)
spec.loader.exec_module(middleware)
self.ws_routes = {}
def route(path):
def register(fn):
self.ws_routes[path]=fn
return fn
return register
scope=dict(terminal_bp=Blueprint('host_fixture',__name__),sock=SimpleNamespace(route=route),
request=request,jsonify=jsonify,require_admin_scope=middleware.require_admin_scope,
_TERMINAL_TICKETS={},_TICKETS_LOCK=threading.Lock(),_TICKET_TTL=5,
_TICKET_MAX_INFLIGHT=256,time=time,secrets=__import__('secrets'),json=json,
_host_update_completed=self.callback)
names={'_issue_terminal_ticket','_consume_terminal_ticket','_ws_auth_check',
'issue_terminal_ticket_route','host_update_jobs_status','host_update_result','host_update_viewer'}
nodes=[n for n in ast.parse((ROOT/'AppImage/scripts/flask_terminal_routes.py').read_text()).body if isinstance(n,ast.FunctionDef) and n.name in names]
self.assertEqual(len(nodes),len(names))
exec(compile(ast.Module(body=nodes,type_ignores=[]),'actual-host-routes','exec'),scope)
self.app.register_blueprint(scope['terminal_bp'])
self.client=self.app.test_client()
def ticket(self, token='valid'):
return self.client.post('/api/terminal/ticket',headers={'Authorization':'Bearer '+token})
def attach(self, init, ticket=None, identity='job-1'):
frames=iter([json.dumps(init)])
ws=Mock()
def receive(**_):
try:
return next(frames)
except StopIteration:
raise ConnectionError('viewer signed out')
ws.receive.side_effect=receive
with self.app.test_request_context('/ws/host-update/'+identity+('?ticket='+ticket if ticket else '')):
self.ws_routes['/ws/host-update/<run_id>'](ws,identity)
return ws
def test_401_and_read_only_cannot_list_start_or_get_a_ticket(self):
for token,status in [('expired',401),('read-only',403)]:
with patch.object(jobs.subprocess,'Popen') as spawn:
self.assertEqual(self.ticket(token).status_code,status)
self.assertEqual(self.client.get('/api/host-update-jobs',headers={'Authorization':'Bearer '+token}).status_code,status)
self.assertEqual(self.client.get('/api/host-update-jobs/fixture/result',headers={'Authorization':'Bearer '+token}).status_code,status)
self.attach(dict(resume=False))
spawn.assert_not_called()
self.assertEqual(jobs.list_jobs(),[])
def test_finished_result_is_read_only_and_bounded_by_owner_output(self):
view=dict(state='complete',exit_code=0,text='inert saved output',truncated=False)
with patch.object(jobs,'get',return_value=SimpleNamespace(read=Mock(return_value=view))) as get,patch.object(jobs,'start') as start,patch.object(jobs.subprocess,'Popen') as spawn:
response=self.client.get('/api/host-update-jobs/fixture/result',headers={'Authorization':'Bearer valid'})
self.assertEqual(response.status_code,200)
self.assertEqual(response.json,view)
self.assertEqual(response.headers['Cache-Control'],'no-store')
get.assert_called_once_with('fixture');start.assert_not_called();spawn.assert_not_called()
def test_result_auth_missing_invalid_running_never_starts_job(self):
with patch.object(jobs,'get') as get,patch.object(jobs,'start') as start,patch.object(jobs.subprocess,'Popen') as spawn:
self.assertEqual(self.client.get('/api/host-update-jobs/fixture/result').status_code,401)
get.assert_not_called()
get.side_effect=KeyError('missing')
self.assertEqual(self.client.get('/api/host-update-jobs/missing/result',headers={'Authorization':'Bearer valid'}).status_code,404)
get.side_effect=ValueError('invalid')
self.assertEqual(self.client.get('/api/host-update-jobs/invalid/result',headers={'Authorization':'Bearer valid'}).status_code,404)
get.side_effect=None
for state in ('running','attention','finalizing'):
get.return_value=SimpleNamespace(read=Mock(return_value=dict(state=state,exit_code=None)))
self.assertEqual(self.client.get('/api/host-update-jobs/fixture/result',headers={'Authorization':'Bearer valid'}).status_code,409)
start.assert_not_called();spawn.assert_not_called()
def test_real_route_disconnect_401_then_relogin_replays_one_job(self):
self.attach(dict(resume=False),self.ticket().json['ticket'])
job=jobs.get('job-1')
wait(self.events.exists)
self.assertEqual(self.client.get('/api/host-update-jobs',headers={'Authorization':'Bearer expired'}).status_code,401)
self.assertEqual(self.client.get('/api/host-update-jobs',headers={'Authorization':'Bearer valid'}).json['jobs'][0]['id'],job.id)
with patch.object(jobs.subprocess,'Popen') as spawn:
self.attach(dict(resume=True),self.ticket().json['ticket'])
spawn.assert_not_called()
self.finish(job)
ws=self.attach(dict(resume=True),self.ticket().json['ticket'])
messages=[json.loads(call.args[0]) for call in ws.send.call_args_list]
self.assertEqual(messages[-1],dict(type='script_complete',exit_code=0))
self.assertEqual(self.events.read_text().splitlines(),['started','final-step'])
self.callback.assert_called_once()
def test_missing_resume_extra_params_and_boolean_cursor_spawn_nothing(self):
with patch.object(jobs.subprocess,'Popen') as spawn, patch.object(jobs.tempfile,'mkstemp') as files:
for init in (dict(resume=True),dict(resume=False,params={'LD_PRELOAD':'bad'}),dict(resume=False,offset=True),dict(resume='false')):
self.attach(init,self.ticket().json['ticket'])
spawn.assert_not_called()
files.assert_not_called()
def test_legacy_action_api_keeps_fixed_command_and_cancel_cannot_target_host_job(self):
# Execute the actual existing API functions, with only the systemd
# boundary replaced. They must keep their original unit/argv contract.
source=ast.parse((ROOT/'AppImage/scripts/flask_server.py').read_text())
names={'api_system_pve_update_run','api_system_pve_update_status','api_system_pve_update_cancel'}
nodes=[n for n in source.body if isinstance(n,ast.FunctionDef) and n.name in names]
app=Flask('legacy-host-action-fixture')
run=Mock(return_value=(True,None))
state=Mock(return_value={'status':'running'})
cancel=Mock()
scope=dict(app=app,require_admin_scope=lambda fn:fn,require_auth=lambda fn:fn,
os=os,jsonify=jsonify,_PVE_UPDATE_SCRIPT=str(self.script),
_action_run=run,_action_state=state,_action_cancel=cancel)
exec(compile(ast.Module(body=nodes,type_ignores=[]),'actual-legacy-api','exec'),scope)
client=app.test_client()
self.assertEqual(client.post('/api/system/pve-update/run').status_code,202)
run.assert_called_once_with('proxmenux-action-pve-update',['/bin/bash',str(self.script)],
description='ProxMenux action: Proxmox VE update')
self.assertEqual(client.get('/api/system/pve-update/status').status_code,200)
self.assertEqual(client.delete('/api/system/pve-update').status_code,200)
cancel.assert_called_once_with('proxmenux-action-pve-update')
self.assertEqual(jobs.list_jobs(),[])
def test_actual_host_finalizer_invalidates_health_without_viewer_or_package_calls(self):
from types import SimpleNamespace
keys=('updates_check','overall_health','_bg_overall','_bg_detailed')
cache=SimpleNamespace(last_check_times=dict.fromkeys((*keys,'unrelated'),123),
cached_results=dict.fromkeys((*keys,'unrelated'),{'status':'old'}))
module=ModuleType('health_monitor')
module.health_monitor=cache
node=next(n for n in ast.parse((ROOT/'AppImage/scripts/flask_terminal_routes.py').read_text()).body
if isinstance(n,ast.FunctionDef) and n.name=='_host_update_completed')
scope={}
exec(compile(ast.Module(body=[node],type_ignores=[]),'actual-finalizer','exec'),scope)
with patch.dict(sys.modules,{'health_monitor':module}),patch.object(jobs.subprocess,'Popen') as spawn:
scope['_host_update_completed'](jobs.SCRIPT,{},0,1)
spawn.assert_not_called()
self.assertEqual(cache.last_check_times,{'unrelated':123})
self.assertEqual(cache.cached_results,{'unrelated':{'status':'old'}})
if __name__=='__main__':
unittest.main(verbosity=2)
+27
View File
@@ -0,0 +1,27 @@
"""Execute the actual backend-copy recipe, then import the packaged helpers."""
import os
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
ROOT=Path(__file__).resolve().parents[1]
class Packaging(unittest.TestCase):
def test_actual_appimage_copy_recipe_contains_importable_host_controls(self):
script=(ROOT/'AppImage/scripts/build_appimage.sh').read_text()
recipe='\n'.join(line for line in script.splitlines() if line.startswith('cp "$SCRIPT_DIR/') and '.py"' in line)
self.assertTrue(recipe)
with tempfile.TemporaryDirectory() as temporary:
folder=Path(temporary);(folder/'usr/bin').mkdir(parents=True)
result=subprocess.run(['bash','-eu','-c',recipe],env=dict(os.environ,SCRIPT_DIR=str(ROOT/'AppImage/scripts'),APP_DIR=str(folder)),capture_output=True,text=True,timeout=10)
self.assertEqual(result.returncode,0,result.stderr)
for name in ('host_update_controls.py','proxmenux_version_status.py','kernel_reboot.py','host_update_jobs.py'):
self.assertTrue((folder/'usr/bin'/name).is_file(),name+' omitted from AppImage')
self.assertEqual((folder/'usr/bin'/name).read_bytes(),(ROOT/'AppImage/scripts'/name).read_bytes())
code='import sys;sys.path.insert(0,sys.argv[1]);import host_update_controls,proxmenux_version_status'
result=subprocess.run([sys.executable,'-I','-B','-c',code,str(folder/'usr/bin')],capture_output=True,text=True,timeout=5)
self.assertEqual(result.returncode,0,result.stderr)
if __name__=='__main__':unittest.main(verbosity=2)
+214
View File
@@ -0,0 +1,214 @@
"""Actual wrapper + worker, with every machine effect replaced by local fixtures.
Only fixed filesystem locations are redirected. Package commands and reboot
are inert executables which record calls; no root, network or host update.
"""
import os
from pathlib import Path
import re
import subprocess
import sys
import tempfile
import time
import unittest
ROOT = Path(__file__).resolve().parents[1]
class HostUpdateShell(unittest.TestCase):
def test_packaging_copies_required_modules_with_no_host_probe_on_import(self):
source = (ROOT / 'AppImage/scripts/build_appimage.sh').read_text().splitlines()
copies = [f'cp "$SCRIPT_DIR/{name}.py" "$APP_DIR/usr/bin/"'
for name in ('host_update_jobs', 'kernel_reboot')]
for line in copies:
self.assertIn(line, source)
app = self.folder / 'AppDir'
(app / 'usr/bin').mkdir(parents=True)
result = subprocess.run(['/bin/bash', '-c', '\n'.join(copies)],
env=dict(os.environ, SCRIPT_DIR=str(ROOT / 'AppImage/scripts'), APP_DIR=str(app)),
capture_output=True, text=True, timeout=3)
self.assertEqual(result.returncode, 0, result.stderr)
code = ('import sys; from unittest.mock import patch; sys.path.insert(0,sys.argv[1]); '
'guard=patch("subprocess.Popen",side_effect=AssertionError("forbidden probe")); '
'guard.start(); import host_update_jobs,kernel_reboot; '
'assert host_update_jobs.list_jobs()==[]')
result = subprocess.run([sys.executable, '-I', '-B', '-c', code, str(app / 'usr/bin')],
capture_output=True, text=True, timeout=3)
self.assertEqual(result.returncode, 0, result.stderr)
def setUp(self):
self.temp = tempfile.TemporaryDirectory(prefix='host-update-shell-')
self.addCleanup(self.temp.cleanup)
self.folder = Path(self.temp.name)
self.bin = self.folder / 'bin'
self.bin.mkdir()
scripts = self.folder / 'scripts'
(scripts / 'utilities').mkdir(parents=True)
(scripts / 'global').mkdir()
self.calls = self.folder / 'calls'
self.calls.touch()
self.lock = self.folder / 'update.lock'
for name in ('utilities/proxmox_update.sh', 'global/update-pve-safe.sh'):
text = (ROOT / 'scripts' / name).read_text()
text = text.replace('/usr/local/share/proxmenux', str(self.folder))
text = text.replace('/run/proxmenux-host-update.lock', str(self.lock))
text = text.replace('/var/run/proxmenux-update-', str(self.folder / 'marker-'))
text = text.replace('/var/run/reboot-required', str(self.folder / 'reboot-required'))
text = text.replace('/var/log/proxmox-update-', str(self.folder / 'update-'))
text = text.replace('/tmp/proxmenux_screen_capture_', str(self.folder / 'capture-'))
(scripts / name).write_text(text)
(self.folder / 'utils.sh').write_text('''
load_language() { :; }
initialize_cache() { :; }
translate() { printf '%s' "$1"; }
msg_error() { echo "$*"; }
msg_warn() { echo "$*"; }
msg_info() { echo "$*"; }
msg_info2() { echo "$*"; }
msg_ok() { echo "$*"; }
msg_title() { echo "$*"; }
msg_success() { echo "$*"; }
show_proxmenux_logo() { :; }
LOCAL_SCRIPTS="$FIXTURE/scripts"
''')
# Keep the real confirmation contract added upstream; stub only the
# package/host effects, not the updater's shared decision helper.
confirmation = re.search(r'^pmx_confirm_update\(\) \{\n.*?^\}\n',
(ROOT / 'scripts/global/common-functions.sh').read_text(),
re.MULTILINE | re.DOTALL)
self.assertIsNotNone(confirmation)
(scripts / 'global/common-functions.sh').write_text('''
pmx_format_upgradable() { cat; }
pmx_dpkg_changes_since() { echo 'package changes fixture'; }
lvm_repair_check() { echo lvm-poststep >> "$CALLS"; }
pmx_rebuild_dkms_after_kernel() { echo dkms-poststep >> "$CALLS"; }
''' + confirmation.group(0))
(scripts / 'global/utils-install-functions.sh').write_text('ensure_repositories() { return 0; }\n')
commands = {
'pveversion': "echo 'pve-manager/9.2.24/test (running kernel: 7.0.14-25-pve)'",
'df': "printf 'Filesystem 1K-blocks Used Available Use%% Mounted\\nfixture 9999999 1 9999999 1%% /\\n'",
'uname': "echo 7.0.14-25-pve",
'dpkg-query': "printf 'install ok installed\\tproxmox-kernel-7.0.14-26-pve-signed\\n'",
'apt-cache': "echo 'Candidate: 9.2.24'",
'apt-get': 'echo "apt-get $*" >> "$CALLS"; exit 0',
'apt': '''echo "apt $*" >> "$CALLS"
if [[ "$1" == list ]]; then
printf 'Listing...\\npve-manager/stable 9.2.24 amd64 [upgradable from: 9.2.21]\\n'
else
touch "$FIXTURE/upgrade-started"
while [[ ! -e "$FIXTURE/release" ]]; do sleep .02; done
exit "${UPGRADE_EXIT:-0}"
fi''',
'dialog': 'echo dialog >> "$CALLS"; exit "${CONFIRM_EXIT:-0}"',
'whiptail': '''echo "whiptail $*" >> "$CALLS"
if [[ "$1" == --title && "$2" == "Proxmox Update" ]]; then exit "${CONFIRM_EXIT:-0}"; fi
exit 1''',
'reboot': 'echo FORBIDDEN-REBOOT >> "$CALLS"; exit 99',
'clear': ':',
}
for name, body in commands.items():
path = self.bin / name
path.write_text('#!/bin/bash\n' + body + '\n')
path.chmod(0o700)
self.env = dict(os.environ, PATH=str(self.bin)+':'+os.environ['PATH'],
FIXTURE=str(self.folder), CALLS=str(self.calls),
EXECUTION_MODE='web', PROXMENUX_HOST_UPDATE_JOB='1')
self.wrapper = scripts / 'utilities/proxmox_update.sh'
self.procs = []
self.addCleanup(self.finish)
def finish(self):
(self.folder / 'release').touch()
for proc in self.procs:
if proc.poll() is None:
proc.communicate('\n', timeout=8)
def start(self, **env):
proc = subprocess.Popen(['/bin/bash', str(self.wrapper)], env=dict(self.env, **env),
stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, text=True)
self.procs.append(proc)
return proc
def wait_upgrade(self):
until = time.monotonic() + 5
while not (self.folder / 'upgrade-started').exists():
self.assertLess(time.monotonic(), until, 'inert upgrade did not start')
time.sleep(.01)
def test_web_full_flow_finishes_poststeps_and_footer_without_reboot_or_keypress(self):
proc = self.start()
self.wait_upgrade()
(self.folder / 'release').touch()
output, _ = proc.communicate(timeout=8)
self.assertEqual(proc.returncode, 0, output)
calls = self.calls.read_text()
self.assertIn('lvm-poststep', calls)
self.assertIn('dkms-poststep', calls)
self.assertIn('apt-get -y autoremove', calls)
self.assertIn('apt-get -y autoclean', calls)
self.assertNotIn('FORBIDDEN-REBOOT', calls)
self.assertIn('whiptail --title Proxmox Update --yesno', calls)
self.assertNotIn('whiptail --title Reboot Required', calls)
self.assertIn('Reboot Required', output)
self.assertIn('You can reboot later manually.', output)
self.assertFalse((self.folder / 'marker-in-progress').exists())
self.assertTrue((self.folder / 'marker-just-finished').exists())
log = next(self.folder.glob('update-*.log')).read_text()
self.assertIn('--- Packages changed (exit 0) ---', log)
self.assertIn('Finished:', log)
def test_cancel_before_upgrade_has_no_upgrade_cleanup_or_reboot(self):
proc = self.start(CONFIRM_EXIT='1')
output, _ = proc.communicate(timeout=5)
self.assertEqual(proc.returncode, 130, output)
calls = self.calls.read_text()
for forbidden in ('full-upgrade', 'autoremove', 'autoclean', 'REBOOT', 'poststep'):
self.assertNotIn(forbidden, calls)
def test_failure_keeps_failure_and_never_offers_reboot(self):
(self.folder / 'release').touch()
proc = self.start(UPGRADE_EXIT='1')
output, _ = proc.communicate(timeout=5)
self.assertEqual(proc.returncode, 1, output)
for forbidden in ('autoremove', 'autoclean', 'REBOOT', 'poststep', 'whiptail --title Reboot Required'):
self.assertNotIn(forbidden, self.calls.read_text())
def test_old_menu_cli_contract_retains_explicit_reboot_prompt(self):
(self.folder / 'release').touch()
proc = self.start(PROXMENUX_HOST_UPDATE_JOB='', EXECUTION_MODE='cli')
output, _ = proc.communicate('\n', timeout=5)
self.assertEqual(proc.returncode, 0, output)
self.assertIn('whiptail --title Reboot Required --yesno', self.calls.read_text())
self.assertNotIn('FORBIDDEN-REBOOT', self.calls.read_text())
def test_shared_lock_rejects_second_legacy_or_web_caller_before_any_package_call(self):
first = self.start()
self.wait_upgrade()
before = self.calls.read_text()
for mode in ('cli', 'web'):
second = self.start(EXECUTION_MODE=mode)
output, _ = second.communicate(timeout=3)
self.assertEqual(second.returncode, 75, output)
self.assertEqual(self.calls.read_text(), before)
self.assertIn('already running', output)
self.assertIsNone(first.poll())
def test_lock_does_not_follow_symlink_or_truncate_existing_file(self):
target = self.folder / 'protected'
target.write_text('preserve')
self.lock.symlink_to(target)
proc = self.start()
output, _ = proc.communicate(timeout=3)
self.assertEqual(proc.returncode, 75, output)
self.assertEqual(target.read_text(), 'preserve')
self.assertEqual(self.calls.read_text(), '')
self.lock.unlink()
self.lock.write_text('existing metadata')
proc = self.start(CONFIRM_EXIT='1')
proc.communicate(timeout=5)
self.assertEqual(self.lock.read_text(), 'existing metadata')
if __name__ == '__main__':
unittest.main(verbosity=2)
+111
View File
@@ -0,0 +1,111 @@
"""Real HTTP + WebSocket + PTY under the same gevent patch as Monitor.
Authentication boundaries use synthetic identities; full JWT verification is
covered separately. The sole executable is an inert temporary shell script.
"""
from pathlib import Path
import subprocess
import sys
import unittest
ROOT = Path(__file__).resolve().parents[1]
def wire_fixture():
import json
import shlex
import tempfile
import time
from types import ModuleType
import requests
from flask import Flask
from gevent.pywsgi import WSGIServer
from simple_websocket import Client
sys.path.insert(0, str(ROOT / 'AppImage/scripts'))
auth = ModuleType('auth_manager')
auth.load_auth_config = lambda: {'enabled': True}
auth.verify_token = lambda token: 'fixture' if token == 'valid' else None
auth.verify_token_full = lambda token: ('fixture', 'full_admin') if token == 'valid' else ('fixture', 'read_only') if token == 'read-only' else (None, None)
sys.modules['auth_manager'] = auth
import flask_terminal_routes as routes
import host_update_jobs as jobs
callbacks = []
routes._host_update_completed = lambda *args: callbacks.append(args)
app = Flask(__name__)
app.register_blueprint(routes.terminal_bp)
routes.sock.init_app(app)
server = WSGIServer(('127.0.0.1', 0), app, log=None)
server.start()
base = f'http://127.0.0.1:{server.server_port}'
headers = {'Authorization': 'Bearer valid'}
def wait(predicate):
end = time.monotonic() + 5
while not predicate():
assert time.monotonic() < end, 'fixture timeout'
time.sleep(.01)
def ticket():
response = requests.post(base + '/api/terminal/ticket', headers=headers, timeout=3)
assert response.status_code == 200
return response.json()['ticket']
def connect(identity, resume):
client = Client(base.replace('http:', 'ws:') + '/ws/host-update/' + identity + '?ticket=' + ticket())
client.send(json.dumps(dict(resume=resume, offset=0)))
return client
try:
with tempfile.TemporaryDirectory(prefix='host-update-wire-') as folder:
folder = Path(folder)
gate, events, script = [folder / n for n in ('release', 'events', 'update.sh')]
script.write_text(f'echo start >> {shlex.quote(str(events))}\n'
f'while [ ! -f {shlex.quote(str(gate))} ]; do sleep .02; done\n'
f'echo poststep >> {shlex.quote(str(events))}\necho completed\n')
jobs.SCRIPT = str(script)
viewer = connect('one-host-run', False)
wait(events.exists)
if viewer.connected:
viewer.close()
assert requests.get(base + '/api/host-update-jobs', headers={'Authorization': 'Bearer expired'}, timeout=3).status_code == 401
assert requests.post(base + '/api/terminal/ticket', headers={'Authorization': 'Bearer read-only'}, timeout=3).status_code == 403
listed = requests.get(base + '/api/host-update-jobs', headers=headers, timeout=3).json()['jobs']
assert listed[0]['id'] == 'one-host-run'
viewer = connect('one-host-run', True)
gate.touch()
frames = []
while not frames or frames[-1]['type'] != 'script_complete':
message = viewer.receive(timeout=5)
assert message is not None
frames.append(json.loads(message))
assert frames[-1]['exit_code'] == 0
assert events.read_text().splitlines() == ['start', 'poststep']
assert len(callbacks) == 1
assert 'completed' in ''.join(frame.get('text', '') for frame in frames)
saved=requests.get(base+'/api/host-update-jobs/one-host-run/result',headers=headers,timeout=3)
assert saved.status_code==200 and saved.headers['Cache-Control']=='no-store'
assert saved.json()['exit_code']==0 and 'completed' in saved.json()['text']
assert events.read_text().splitlines()==['start','poststep'] and len(callbacks)==1
if viewer.connected:
viewer.close()
viewer = connect('missing-host-run', True)
assert json.loads(viewer.receive(timeout=3))['type'] == 'job_unavailable'
if viewer.connected:
viewer.close()
assert len(jobs.list_jobs()) == 1
assert len(callbacks) == 1
finally:
server.stop(timeout=2)
print('PASS real gevent HTTP/WS disconnect, 401, relogin, resume, PTY poststep and missing-ID no restart')
class HostWire(unittest.TestCase):
def test_actual_gevent_transport_survives_viewer_loss(self):
command = ('from gevent import monkey; monkey.patch_all(); '
'import runpy,sys; runpy.run_path(sys.argv[1],run_name="__wire__")')
result = subprocess.run([sys.executable, '-I', '-B', '-c', command, str(Path(__file__).resolve())],
capture_output=True, text=True, timeout=25)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertIn('PASS real gevent', result.stdout)
if __name__ == '__wire__':
wire_fixture()
elif __name__ == '__main__':
unittest.main(verbosity=2)
+95
View File
@@ -0,0 +1,95 @@
// Execute actual transport initializers, reconnect handlers and routing.
const assert = require('node:assert/strict')
const fs = require('node:fs'), path = require('node:path'), vm = require('node:vm')
const app = path.resolve(__dirname, '../AppImage')
const ts = require(path.join(app, 'node_modules/typescript'))
const text = fs.readFileSync(path.join(app, 'components/script-terminal-modal.tsx'), 'utf8')
const tree = ts.createSourceFile('modal.tsx', text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
const opens = [], messages = [], closes = []
let route, cleanup
function walk(n) {
if (ts.isBinaryExpression(n) && n.left.getText(tree) === 'ws.onopen') opens.push(n.right.getText(tree))
if (ts.isBinaryExpression(n) && n.left.getText(tree) === 'ws.onmessage') messages.push(n.right.getText(tree))
if (ts.isBinaryExpression(n) && n.left.getText(tree) === 'ws.onclose') closes.push(n.right.getText(tree))
if (ts.isVariableDeclaration(n) && n.name.getText(tree) === 'getScriptWebSocketUrl') route = n.initializer.getText(tree)
if (ts.isCallExpression(n) && n.expression.getText(tree) === 'useEffect'
&& n.arguments[0]?.getText(tree).includes('Transport mode is fixed')) cleanup = n.arguments[0].getText(tree)
ts.forEachChild(n, walk)
}
walk(tree)
const compile = (code, scope) => vm.runInNewContext(ts.transpileModule('('+code+')', {
compilerOptions:{target:ts.ScriptTarget.ES2020,module:ts.ModuleKind.CommonJS},
}).outputText, scope)
assert.equal(opens.length, 2)
for (const [index, open] of opens.entries()) {
for (const resume of [false, true]) {
const sent = [], scope = {backgroundUpdate:{runId:'same-id',kind:'host',resume},
scriptPath:'/forbidden-client-path', paramsRef:{current:{FORBIDDEN:'client-env'}},
outputOffsetRef:{current:42}, setConnectionStatus(){}, reconnectAttemptsRef:{current:0},
keepAliveIntervalRef:{current:null}, clearInterval(){}, setInterval(){return 1}, setTimeout(){},
WebSocket:{OPEN:1}, ws:{readyState:1,send:s=>sent.push(JSON.parse(s))}}
compile(open, scope)()
assert.equal(sent.length,1)
assert.deepEqual(sent[0],{resume:index===0 ? true : resume, offset:42},
'reconnect must attach; explicit initial start is the sole resume=false message')
}
}
for (const backgroundUpdate of [{runId:'same-id',kind:'host'}, {runId:'lxc-id',vmid:101}, undefined]) {
const actual = compile(route,{backgroundUpdate,API_PORT:'8008'})('legacy-id')
assert.equal(actual,'ws://localhost:8008/ws/'+(backgroundUpdate?.kind==='host'?'host-update/same-id':backgroundUpdate?'lxc-update/lxc-id':'script/legacy-id'))
}
for (const close of closes) {
const sent=[], results=[], scope={backgroundUpdate:{runId:'same-id',kind:'host'},
ws:{send:s=>sent.push(s)}, isOpenRef:{current:true}, isComplete:false,
completionReceivedRef:{current:false}, keepAliveIntervalRef:{current:null},
reconnectAttemptsRef:{current:0},reconnectTimeoutRef:{current:null},setTimeout(fn){fn();return 1},clearTimeout(){},
setConnectionStatus(){}, clearInterval(){}, termRef:{current:{writeln(){}}},term:{writeln(){}},
attemptReconnect(){results.push('attach')}, onCompleteRef:{current:()=>results.push('complete')},t:s=>s}
compile(close,scope)({})
assert.deepEqual(results,['attach'])
assert.deepEqual(sent,[])
}
const sent=[], scope={backgroundUpdate:{runId:'same-id',kind:'host'},
openGenerationRef:{current:0}, isOpenRef:{current:true}, wsRef:{current:{close(){},send:x=>sent.push(x)}},
termRef:{current:{dispose(){}}}, clearTimeout(){},clearInterval(){},
waitingTimeoutRef:{current:null}, reconnectTimeoutRef:{current:null}, keepAliveIntervalRef:{current:null},checkConnectionInterval:{current:null}}
compile(cleanup,scope)()()
assert.deepEqual(sent,[],'unmount cannot send cancellation or restart')
const health=fs.readFileSync(path.join(app,'components/health-status-modal.tsx'),'utf8')
const en=JSON.parse(fs.readFileSync(path.join(app,'messages/en/common.json'),'utf8'))
const sk=JSON.parse(fs.readFileSync(path.join(app,'messages/sk/common.json'),'utf8'))
assert.deepEqual(Object.keys(en.healthStatus.hostUpdate).sort(),Object.keys(sk.healthStatus.hostUpdate).sort())
assert.ok(health.includes('Number(categoryData?.count ?? 0) > 0'),'reboot-only state has no Update Now action')
const savedTree=ts.createSourceFile('result.tsx',fs.readFileSync(path.join(app,'components/host-update-result.tsx'),'utf8'),ts.ScriptTarget.Latest,true,ts.ScriptKind.TSX)
let readResult
function resultWalk(n){
if(ts.isArrowFunction(n)&&n.modifiers?.some(m=>m.kind===ts.SyntaxKind.AsyncKeyword)&&n.getText(savedTree).includes('value.state'))readResult=n.getText(savedTree)
ts.forEachChild(n,resultWalk)
}
resultWalk(savedTree);assert.ok(readResult)
async function results(){
const response={state:'complete',exit_code:0,text:'inert saved result',truncated:false}
for(const code of [0,1,130,-15]){
const calls=[],writes=[],codes=[],errors=[]
const scope={getAuthToken:()=>null,getApiUrl:p=>p,jobId:'saved-id',controller:new AbortController(),container:{current:{}},disposed:false,
fetch:async(p,options)=>{calls.push({p,options});return {ok:true,json:async()=>({...response,exit_code:code})}},
require:n=>n==='xterm'?{Terminal:class{loadAddon(){}open(){}write(v){writes.push(v)}writeln(v){writes.push(v)}}}:{FitAddon:class{fit(){}}},
ResizeObserver:class{observe(){}},setExitCode:v=>codes.push(v),setFailed:v=>errors.push(v),clearTimeout(){},timeout:1,t:s=>s}
await compile(readResult,scope)()
assert.deepEqual(codes,[code]);assert.deepEqual(errors,[]);assert.deepEqual(writes,['inert saved result'])
assert.equal(calls.length,1);assert.equal(calls[0].p,'/api/host-update-jobs/saved-id/result');assert.ok(!calls[0].options.method||calls[0].options.method==='GET')
for(const value of [null,{...response,state:'running'},{...response,exit_code:false},{...response,text:null},{...response,truncated:null}]){
codes.length=errors.length=writes.length=0
scope.fetch=async()=>({ok:true,json:async()=>value});await compile(readResult,scope)()
assert.deepEqual(codes,[]);assert.deepEqual(writes,[]);assert.deepEqual(errors,[true])
}
codes.length=errors.length=writes.length=0
scope.fetch=async()=>({ok:false});await compile(readResult,scope)()
assert.deepEqual(errors,[true]);assert.deepEqual(codes,[]);assert.deepEqual(writes,[])
codes.length=errors.length=writes.length=0;scope.disposed=true;scope.controller.abort()
scope.fetch=async()=>({ok:true,json:async()=>response});await compile(readResult,scope)()
assert.deepEqual(codes,[]);assert.deepEqual(errors,[]);assert.deepEqual(writes,[],'closed result cannot receive a late response')
}
console.log('PASS host/legacy/LXC transports and actual readonly result GET: success/error/cancel/invalid/late-response; zero WS/start callbacks')
}
results().catch(error=>{console.error(error);process.exitCode=1})
+52
View File
@@ -0,0 +1,52 @@
"""Actual installed-version function; package changes invalidate its old display."""
import ast
from pathlib import Path
from types import SimpleNamespace
import unittest
from unittest.mock import Mock
ROOT = Path(__file__).resolve().parents[1]
class VersionCache(unittest.TestCase):
def setUp(self):
source = ast.parse((ROOT/'AppImage/scripts/flask_server.py').read_text())
node = next(n for n in source.body if isinstance(n, ast.FunctionDef) and n.name == 'get_proxmox_version')
self.cache = dict(proxmox_version='9.2.21', proxmox_version_time=100, proxmox_version_stamp=1)
self.command = Mock(return_value=SimpleNamespace(returncode=0, stdout='pve-manager/9.2.24/hash\n'))
self.stamp = Mock(return_value=1)
self.scope = dict(is_pbs_host=Mock(return_value=False), get_platform=Mock(return_value={'version':'4.1.0'}),
_system_info_cache=self.cache, _SYSTEM_INFO_CACHE_TTL=21600,
_AVAILABLE_UPDATES_MIN_INTERVAL=30, _apt_state_stamp=self.stamp,
time=SimpleNamespace(time=Mock(return_value=200)), subprocess=SimpleNamespace(run=self.command))
exec(compile(ast.Module(body=[node], type_ignores=[]), 'actual-installed-version', 'exec'), self.scope)
def test_unchanged_package_state_has_zero_commands(self):
self.assertEqual(self.scope['get_proxmox_version'](), '9.2.21')
self.command.assert_not_called()
def test_package_change_at_floor_refreshes_version_without_apt(self):
self.stamp.return_value=2
self.scope['time'].time.return_value=129.999
self.assertEqual(self.scope['get_proxmox_version'](), '9.2.21')
self.command.assert_not_called()
self.scope['time'].time.return_value=130
self.assertEqual(self.scope['get_proxmox_version'](), '9.2.24')
self.command.assert_called_once_with(['pveversion'],capture_output=True,text=True,timeout=5)
self.assertEqual(self.cache['proxmox_version_stamp'],2)
def test_failed_read_returns_unknown_but_keeps_last_known_cache(self):
self.stamp.return_value=2
before=dict(self.cache)
self.command.side_effect=OSError('inert unavailable command')
self.assertIsNone(self.scope['get_proxmox_version']())
self.assertEqual(self.cache,before)
def test_pbs_guard_precedes_all_pve_package_probes(self):
self.scope['is_pbs_host'].return_value=True
self.assertEqual(self.scope['get_proxmox_version'](),'4.1.0')
self.stamp.assert_not_called();self.command.assert_not_called()
if __name__=='__main__':
unittest.main(verbosity=2)
+134
View File
@@ -0,0 +1,134 @@
"""Real dpkg contract shape and actual cached HealthMonitor method. No APT."""
import ast
import copy
from pathlib import Path
import subprocess
import sys
import time
from types import SimpleNamespace
import unittest
from unittest.mock import patch
ROOT=Path(__file__).resolve().parents[1]
sys.path.insert(0,str(ROOT/'AppImage/scripts'))
import kernel_reboot as kernel
def listing(*releases):
return '\n'.join('installed\tproxmox-kernel-'+v+'-signed' for v in releases)+'\n'
class Reboot(unittest.TestCase):
def setUp(self):
self.uname=patch.object(kernel.os,'uname',return_value=SimpleNamespace(release='7.0.14-25-pve'))
self.uname.start()
self.addCleanup(self.uname.stop)
self.marker=patch.object(Path,'exists',return_value=False)
self.marker.start()
self.addCleanup(self.marker.stop)
def check(self, output, rc=0):
with patch.object(kernel.subprocess,'run',return_value=subprocess.CompletedProcess([],rc,output,'')) as call:
result=kernel.reboot_status()
if call.called:
self.assertEqual(call.call_args.args[0][0],'dpkg-query')
self.assertNotIn('apt',call.call_args.args[0])
return result
def test_installed_26_running_25_requires_reboot_without_marker_or_pending_updates(self):
result=self.check(listing('7.0.14-25-pve','7.0.14-26-pve'))
self.assertEqual(result['state'],'required')
self.assertEqual(result['installed_kernel'],'7.0.14-26-pve')
def test_old_kernel_meta_and_removed_config_are_not_new_installed_kernel(self):
text=listing('7.0.14-25-pve','6.17.13-21-pve')+'installed\tproxmox-kernel-7.0\nconfig-files\tproxmox-kernel-7.0.14-26-pve-signed\n'
self.assertEqual(self.check(text)['state'],'current')
def test_reboot_into_new_kernel_clears_the_requirement(self):
with patch.object(kernel.os,'uname',return_value=SimpleNamespace(release='7.0.14-26-pve')):
self.assertEqual(self.check(listing('7.0.14-25-pve','7.0.14-26-pve'))['state'],'current')
def test_version_comparison_is_numeric(self):
self.assertEqual(self.check(listing('7.0.14-9-pve','7.0.14-100-pve'))['state'],'required')
def test_legacy_pve_package_and_arch_suffix_are_supported(self):
self.assertEqual(self.check('installed\tpve-kernel-7.0.14-26-pve:amd64\n')['state'],'required')
def test_missing_empty_malformed_and_partial_are_unknown_not_zero(self):
for text in ('','garbage','installed\tother-package\n','half-configured\tproxmox-kernel-7.0.14-26-pve-signed\n','bad-state\tfoo\n'):
with self.subTest(text=text):
self.assertEqual(self.check(text)['state'],'unknown')
self.assertEqual(self.check(listing('7.0.14-26-pve'),rc=1)['state'],'unknown')
def test_timeout_and_read_error_are_unknown(self):
for error in (subprocess.TimeoutExpired('dpkg-query',5),OSError('unreadable')):
with patch.object(kernel.subprocess,'run',side_effect=error):
self.assertEqual(kernel.reboot_status()['state'],'unknown')
def test_unavailable_or_invalid_uname_never_probes_packages(self):
with patch.object(kernel.os,'uname',side_effect=OSError('unknown')),patch.object(kernel.subprocess,'run') as packages:
self.assertEqual(kernel.reboot_status()['state'],'unknown')
packages.assert_not_called()
for release in ('','invalid','7.0.14--pve'):
with patch.object(kernel.os,'uname',return_value=SimpleNamespace(release=release)),patch.object(kernel.subprocess,'run') as packages:
self.assertEqual(kernel.reboot_status()['state'],'unknown')
packages.assert_not_called()
def test_supported_marker_never_requires_a_package_probe(self):
with patch.object(Path,'exists',return_value=True),patch.object(kernel.subprocess,'run') as packages:
self.assertEqual(kernel.reboot_status()['state'],'required')
packages.assert_not_called()
def test_non_pve_kernel_is_not_a_failed_pve_read(self):
with patch.object(kernel.os,'uname',return_value=SimpleNamespace(release='6.12.0-amd64')),patch.object(kernel.subprocess,'run') as packages:
self.assertEqual(kernel.reboot_status()['state'],'not_applicable')
packages.assert_not_called()
def test_overlay_does_not_mutate_cache_or_hide_existing_error(self):
for original in ('OK','UNKNOWN','WARNING','CRITICAL'):
cache=dict(status=original,count=0,checks={'pending_updates':dict(status='OK')})
before=copy.deepcopy(cache)
result=kernel.with_reboot_status(cache,dict(state='required',running_kernel='old',installed_kernel='new'))
self.assertEqual(cache,before)
self.assertTrue(result['reboot_required'])
self.assertEqual(result['status'],'INFO' if original=='OK' else original)
result=kernel.with_reboot_status(dict(status='OK',checks={}),dict(state='unknown',running_kernel='old',installed_kernel=None))
self.assertEqual(result['status'],'UNKNOWN')
self.assertIsNone(result['reboot_required'])
def test_actual_cached_health_path_refreshes_reboot_evidence_without_apt(self):
source=ast.parse((ROOT/'AppImage/scripts/health_monitor.py').read_text())
node=next(n for n in ast.walk(source) if isinstance(n,ast.FunctionDef) and n.name=='_check_updates')
node.decorator_list=[]
import __future__
scope=dict(os=kernel.os,time=time,subprocess=kernel.subprocess)
exec(compile(ast.Module(body=[node],type_ignores=[]),'actual-health','exec',flags=__future__.annotations.compiler_flag),scope)
for age in (0,599.999):
cache=dict(status='OK',count=0,checks={})
owner=SimpleNamespace(last_check_times={'updates_check':time.time()-age},cached_results={'updates_check':cache})
with patch.object(kernel.os.path,'exists',return_value=True),patch.object(kernel.os.path,'getmtime',return_value=0),patch.object(kernel.subprocess,'run',return_value=subprocess.CompletedProcess([],0,listing('7.0.14-26-pve'),'')) as probe:
result=scope['_check_updates'](owner)
self.assertEqual(result['status'],'INFO')
self.assertTrue(result['reboot_required'])
self.assertEqual(cache,dict(status='OK',count=0,checks={}))
self.assertEqual([call.args[0][0] for call in probe.call_args_list],['dpkg-query'])
def test_actual_health_failure_paths_keep_independent_reboot_evidence(self):
source=ast.parse((ROOT/'AppImage/scripts/health_monitor.py').read_text())
node=next(n for n in ast.walk(source) if isinstance(n,ast.FunctionDef) and n.name=='_check_updates')
import __future__
scope=dict(os=kernel.os,time=time,subprocess=kernel.subprocess)
exec(compile(ast.Module(body=[node],type_ignores=[]),'actual-health','exec',flags=__future__.annotations.compiler_flag),scope)
for age in (600,600.001):
for error in (subprocess.TimeoutExpired('apt-get',30),OSError('fixture-unavailable')):
owner=SimpleNamespace(last_check_times={'updates_check':time.time()-age},cached_results={})
def command(args, **kwargs):
if args[0]=='dpkg-query':
return subprocess.CompletedProcess(args,0,listing('7.0.14-26-pve'),'')
raise error
with patch.object(kernel.os.path,'exists',return_value=False),patch.object(kernel.subprocess,'run',side_effect=command) as probe:
result=scope['_check_updates'](owner)
self.assertEqual(result['status'],'UNKNOWN')
self.assertTrue(result['reboot_required'])
self.assertEqual([call.args[0][0] for call in probe.call_args_list],['dpkg-query','apt-get'])
if __name__=='__main__':
unittest.main(verbosity=2)
+43
View File
@@ -0,0 +1,43 @@
// Render the shipped component with an inert snapshot; no network or power.
const assert=require('node:assert/strict'),fs=require('node:fs'),path=require('node:path'),vm=require('node:vm')
const app=path.resolve(__dirname,'../AppImage'),ts=require(path.join(app,'node_modules/typescript'))
const react=require(path.join(app,'node_modules/react'))
const {renderToStaticMarkup}=require(path.join(app,'node_modules/react-dom/server'))
const code=ts.transpileModule(fs.readFileSync(path.join(app,'components/host-update-status.tsx'),'utf8'),{
compilerOptions:{target:ts.ScriptTarget.ES2020,module:ts.ModuleKind.CommonJS,jsx:ts.JsxEmit.ReactJSX,esModuleInterop:true}}).outputText
for(const locale of ['en','sk']){
const catalog=JSON.parse(fs.readFileSync(path.join(app,'messages',locale,'common.json'),'utf8'))
const t=(key,params={})=>{const text=key.split('.').reduce((x,k)=>x?.[k],catalog);assert.equal(typeof text,'string',key);return text.replace(/\{(\w+)\}/g,(_,k)=>params[k]??'{'+k+'}')}
const versions=catalog.healthStatus.versions
let value, effects=0
const exports={}
vm.runInNewContext(code,{exports,require:name=>{
if(name==='react')return {...react,useState:()=>[value,()=>{}],useEffect:()=>{},useRef:()=>({current:null})}
if(name==='react/jsx-runtime')return require(path.join(app,'node_modules/react/jsx-runtime'))
if(name==='@radix-ui/react-alert-dialog'||name==='./ui/button')return {}
if(name==='@/lib/i18n/provider')return {useT:()=>t}
if(name==='@/lib/api-config')return {getAuthToken:()=>null}
if(name==='lucide-react')return require(path.join(app,'node_modules/lucide-react'))
throw Error('Unexpected boundary '+name)
},fetch:()=>{effects++;throw Error('Forbidden request')},console})
const snapshot={installed:'1.2.7',available:'1.2.7',state:'current',channel:'stable',checked_at:'2026-10-28T08:00:00Z'}
const render=data=>{value=data;return renderToStaticMarkup(react.createElement(exports.ProxMenuxVersionCard,{getApiUrl:p=>p}))}
const current=render(snapshot)
assert.ok(current.includes('<details')&&current.includes('<summary'),'current version is a compact native disclosure')
assert.ok(!current.includes('<section')&&!current.includes('<dl>'),'up-to-date version has no expanded update card')
assert.ok(current.includes('1.2.7')&&current.includes('text-green-500'))
assert.ok(current.includes(versions.stable)&&current.includes(versions.checked.split('{')[0]))
const available=render({...snapshot,state:'available',available:'1.2.8'})
assert.ok(available.includes('<section')&&available.includes('<dl'))
assert.ok(available.includes('1.2.7')&&available.includes('1.2.8')&&available.includes(versions.available))
assert.ok(available.includes('text-blue-500'))
for(const data of [null,{...snapshot,state:'unknown'},{...snapshot,state:'unknown',available:null,checked_at:null,channel:'beta'}]){
const unknown=render(data)
assert.ok(unknown.includes('<details')&&unknown.includes('text-amber-500'))
assert.ok(unknown.includes(versions.unknown)&&!unknown.includes('text-green-500'))
if(data?.available)assert.ok(unknown.includes(versions.lastKnown),'old version remains explicitly last known')
else assert.ok(unknown.includes(versions.unverified),'missing data is never a current version')
}
assert.equal(effects,0,'rendering/disclosure never triggers update, installation or power')
}
console.log('PASS actual ProxMenux version rendering: compact current, expanded update, unknown/last-known; EN/SK and zero effects')
+150
View File
@@ -0,0 +1,150 @@
// Execute actual TS callbacks/effects with controlled transport and clocks.
const assert = require('node:assert/strict')
const fs = require('node:fs'), path = require('node:path'), vm = require('node:vm')
const app = path.resolve(__dirname, '../AppImage')
const ts = require(path.join(app, 'node_modules/typescript'))
function source(file, name, effectText) {
const tree = ts.createSourceFile(file, fs.readFileSync(path.join(app, 'components', file), 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
let callback, effect
function walk(n) {
if (ts.isVariableDeclaration(n) && n.name.getText(tree) === name) callback = n.initializer.arguments[0].getText(tree)
if (ts.isCallExpression(n) && n.expression.getText(tree) === 'useEffect' && n.arguments[0].getText(tree).includes(effectText)) effect = n.arguments[0].getText(tree)
ts.forEachChild(n, walk)
}
walk(tree)
assert.ok(callback && effect)
return {callback, effect}
}
const modal = source('health-status-modal.tsx', 'fetchHealthDetails', 'Recheck reboot evidence')
const header = source('proxmox-dashboard.tsx', 'fetchHealthInfoCount', 'Header reboot information')
const compile = (code, scope) => vm.runInNewContext(ts.transpileModule('('+code+')', {compilerOptions:{target:ts.ScriptTarget.ES2020}}).outputText, scope)
function environment() {
const calls = [], data = [], errors = [], counts = [], events = [], availability = [], timers = new Map(), listeners = new Map()
let timerId = 0
const categories = [{key:'updates', category:'updates'}]
const scope = {AbortController, CustomEvent:class {constructor(type, values){this.type=type;this.detail=values.detail}},
healthRequest:{current:null}, getAuthToken:()=>null, getApiUrl:p=>p, t:s=>s,
CATEGORIES:categories, HEALTH_CATEGORY_KEYS:categories,
setHealthData:v=>data.push(v), setDismissedItems(){}, setCustomSuppressions(){},
setLoading(){}, setError:v=>errors.push(v), setInfoCount:v=>counts.push(v),
setSnapshotUnavailable:v=>availability.push(v),
setTimeout(fn, delay){timers.set(++timerId,{fn,delay});return timerId}, clearTimeout:id=>timers.delete(id),
window:{dispatchEvent:e=>events.push(e)},
fetch:async(p, opts)=>{calls.push({p,opts});return {ok:true,json:async()=>payload('INFO')}},
fetchApi:async(p, opts)=>{calls.push({p,opts});return payload('INFO')}}
return {scope,calls,data,errors,counts,events,availability,timers,listeners}
}
function payload(status='OK') {return {health:{overall:'OK',timestamp:'original',details:{updates:{status,count:0}}},dismissed:[],custom_suppressions:[]}}
async function callbacks() {
for (const [item, args] of [[modal,[false,true]],[header,[true]]]) {
const env=environment(), run=compile(item.callback,env.scope)
await run(...args)
assert.equal(env.calls[0].p,'/api/health/full?cached=1')
assert.equal(env.scope.healthRequest.current,null)
assert.equal(env.timers.size,0)
assert.deepEqual(item===modal ? env.events[0].detail.infoCount : env.counts[0],1)
}
const env=environment(), run=compile(modal.callback,env.scope)
const pending=environment(), poll=compile(modal.callback,pending.scope)
pending.scope.fetch=async(p)=>{pending.calls.push({p});return {ok:false,status:503}}
await poll(false,true)
assert.deepEqual(pending.availability,[true],'expected cache gap is explicitly unavailable, not a red failure or healthy result')
assert.equal(pending.errors.filter(Boolean).length,0)
assert.equal(pending.data.length,0,'cache gap cannot erase or replace the last known data')
assert.deepEqual(pending.calls.map(x=>x.p),['/api/health/full?cached=1'],'no full/legacy fallback on cache miss')
pending.scope.fetch=async()=>({ok:true,json:async()=>payload('INFO')})
await poll(false,true)
assert.deepEqual(pending.availability,[true,false],'a real successful response clears the unavailable notice')
assert.equal(pending.data.length,1)
for(const status of [401,500]){
const failed=environment()
failed.scope.fetch=async()=>({ok:false,status})
await compile(modal.callback,failed.scope)(false,true)
assert.ok(failed.errors.filter(Boolean).length,'auth and server failures remain errors')
assert.equal(failed.data.length,0)
assert.ok(!failed.availability.includes(true),'real failures are not relabelled as a cache gap')
}
env.scope.fetch=async(p)=>{env.calls.push({p});return {ok:false}}
await run(false,true)
assert.deepEqual(env.calls.map(x=>x.p),['/api/health/full?cached=1'])
assert.equal(env.data.length,0)
assert.ok(env.errors.includes('healthStatus.errors.fetchFailed'))
const malformed=environment()
malformed.scope.fetch=async()=>({ok:true,json:async()=>({})})
await compile(modal.callback,malformed.scope)(false,true)
assert.equal(malformed.data.length,0)
assert.ok(malformed.errors.includes('healthStatus.errors.fetchFailed'))
const h=environment()
h.scope.fetchApi=async()=>({})
await compile(header.callback,h.scope)(true)
assert.equal(h.counts.length,0,'malformed data must not replace the previous count with zero')
}
async function races() {
for (const [item, args, forceArgs] of [[modal,[false,true],[true]],[header,[true],[]]]) {
const e=environment(), pending=[]
if(item===modal) e.scope.fetch=(p,opts)=>{e.calls.push({p,opts});return new Promise(resolve=>pending.push(resolve))}
else e.scope.fetchApi=(p,opts)=>{e.calls.push({p,opts});return new Promise(resolve=>pending.push(resolve))}
const run=compile(item.callback,e.scope), first=run(...args)
await run(...args)
assert.equal(e.calls.length,1,'overlapping polls are coalesced')
const second=run(...forceArgs)
assert.ok(e.calls[0].opts.signal.aborted,'explicit refresh supersedes old poll')
pending[1](item===modal ? {ok:true,json:async()=>payload('INFO')} : payload('INFO'))
await second
pending[0](item===modal ? {ok:true,json:async()=>payload('OK')} : payload('OK'))
await first
assert.equal(item===modal ? e.data.length : e.counts.length,1,'late response cannot overwrite new data')
assert.equal(item===modal ? e.events[0].detail.infoCount : e.counts[0],1)
assert.equal(e.timers.size,0)
}
}
function effects(item, activeTab='overview') {
const e=environment(), intervals=new Map(), listeners=new Map(), polls=[]
let timerId=0
e.scope.open=true; e.scope.activeTab=activeTab
e.scope.fetchHealthDetails=(...args)=>polls.push(args)
e.scope.fetchHealthInfoCount=(...args)=>polls.push(args)
e.scope.fetchSystemData=()=>{}; e.scope.fetchUpdateStatus=()=>{}
e.scope.setInterval=(fn,delay)=>{intervals.set(++timerId,{fn,delay});return timerId}
e.scope.clearInterval=id=>intervals.delete(id)
const target = name=>({addEventListener:(type,fn)=>listeners.set(name+type,fn),removeEventListener:(type,fn)=>{assert.equal(listeners.get(name+type),fn);listeners.delete(name+type)}})
Object.assign(e.scope.window,target('window'))
e.scope.document={visibilityState:'visible',...target('document')}
const cleanup=compile(item.effect,e.scope)()
const interval=[...intervals.values()].find(x=>x.delay===30000 && x.fn.toString().includes('fetchHealth'))
assert.ok(interval,'every tab polls health at 30 seconds')
assert.equal(polls.length,1,'initial full request retained')
interval.fn(); listeners.get('windowfocus')(); listeners.get('documentvisibilitychange')()
assert.equal(polls.length,4)
for(const args of polls.slice(1))assert.deepEqual(args,item===modal?[false,true]:[true])
e.scope.document.visibilityState='hidden'
interval.fn();listeners.get('documentvisibilitychange')()
assert.equal(polls.length,4,'hidden document does not poll')
const controller=new AbortController();e.scope.healthRequest.current=controller
cleanup()
assert.ok(controller.signal.aborted)
assert.equal(e.scope.healthRequest.current,null)
assert.equal(intervals.size,0)
assert.equal(listeners.size,0)
}
function expansion() {
const text=fs.readFileSync(path.join(app,'components/health-status-modal.tsx'),'utf8')
const tree=ts.createSourceFile('modal.tsx',text,ts.ScriptTarget.Latest,true,ts.ScriptKind.TSX)
let code
function walk(n) {
if(ts.isCallExpression(n)&&n.expression.getText(tree)==='useEffect'&&n.arguments[0].getText(tree).includes('newlyDegraded'))code=n.arguments[0].getText(tree)
ts.forEachChild(n,walk)
}
walk(tree);assert.ok(code)
let expanded=new Set(['updates'])
const scope={Set,CATEGORIES:[{key:'updates'},{key:'cpu'}],previousAutoExpanded:{current:new Set()},
healthData:{details:{updates:{status:'INFO'},cpu:{status:'OK'}}},setExpandedCategories:fn=>{expanded=fn(expanded)}}
const effect=compile(code,scope)
effect();assert.ok(expanded.has('updates'),'poll must preserve manually opened updates')
scope.healthData.details.cpu.status='WARNING';effect()
assert.ok(expanded.has('cpu'),'new warnings may expand')
expanded.delete('cpu');effect()
assert.ok(!expanded.has('cpu'),'poll must preserve user collapsing a known warning')
}
async function main() {await callbacks();await races();effects(modal);effects(header);effects(header,'settings');expansion();console.log('PASS actual health callbacks: polling, focus, errors, no fallback, races, expansion and cleanup')}
main().catch(e=>{console.error(e);process.exitCode=1})
+215
View File
@@ -0,0 +1,215 @@
"""Actual full-health route with inert collaborators; no APT or host actions."""
import ast
import copy
from pathlib import Path
import subprocess
import sys
import time
from types import SimpleNamespace
import unittest
from unittest.mock import Mock, patch
from flask import Flask, jsonify, request
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'AppImage/scripts'))
import kernel_reboot as kernel
def snapshot():
return dict(overall='OK', summary='All systems operational', timestamp='original', details={
'cpu': dict(status='OK', checks={}),
'updates': dict(status='OK', count=0, checks={'pending_updates': dict(status='OK')})})
class FullHealth(unittest.TestCase):
def setUp(self):
self.app = Flask(__name__)
self.now = time.time()
self.cached = snapshot()
self.owner = SimpleNamespace(cached_results={'_bg_detailed': self.cached},
last_check_times={'_bg_detailed': self.now},
get_detailed_status=Mock(return_value=snapshot()))
persistence = SimpleNamespace(get_active_errors=Mock(return_value=[]),
get_dismissed_errors=Mock(return_value=[]),
get_custom_suppressions=Mock(return_value=[]))
node = next(n for n in ast.parse((ROOT/'AppImage/scripts/flask_health_routes.py').read_text()).body
if isinstance(n, ast.FunctionDef) and n.name == 'get_full_health')
node.decorator_list = []
self.scope = dict(health_monitor=self.owner, health_persistence=persistence,
request=request, jsonify=jsonify)
exec(compile(ast.Module(body=[node], type_ignores=[]), 'actual-health-route', 'exec'), self.scope)
self.uname = patch.object(kernel.os, 'uname', return_value=SimpleNamespace(release='7.0.14-25-pve'))
self.marker = patch.object(Path, 'exists', return_value=False)
self.uname.start(); self.marker.start()
self.addCleanup(self.uname.stop); self.addCleanup(self.marker.stop)
def command(args, **kwargs):
self.assertEqual(args, ['dpkg-query', '-W', '-f=${db:Status-Status}\t${binary:Package}\n'])
return subprocess.CompletedProcess(args, 0, 'installed\tproxmox-kernel-7.0.14-26-pve-signed\n', '')
self.probe = patch.object(kernel.subprocess, 'run', side_effect=command).start()
self.addCleanup(patch.stopall)
def call(self, query=''):
with self.app.test_request_context('/api/health/full'+query), patch('time.time', return_value=self.now):
response = self.scope['get_full_health']()
if isinstance(response, tuple):
return response[0].get_json(), response[1]
return response.get_json(), response.status_code
def test_cached_full_response_detects_external_update_without_full_check(self):
before = copy.deepcopy(self.cached)
result, code = self.call()
self.assertEqual(code, 200)
self.assertTrue(result['health']['details']['updates']['reboot_required'])
self.assertEqual(result['health']['details']['updates']['count'], 0)
self.assertEqual(result['health']['timestamp'], 'original')
self.assertEqual(result['health']['summary'], 'Reboot required: running 7.0.14-25-pve; installed 7.0.14-26-pve')
self.assertEqual(self.cached, before)
self.owner.get_detailed_status.assert_not_called()
self.assertEqual(self.probe.call_count, 1)
def test_cached_only_miss_expiry_and_future_never_run_full_check_or_clear_cache(self):
for age in (360, 360.001, -1):
for query in ('?cached=1', '?cached=1&refresh=1'):
with self.subTest(age=age, query=query):
self.owner.last_check_times['_bg_detailed'] = self.now-age
before = copy.deepcopy(self.owner.cached_results)
result, code = self.call(query)
self.assertEqual(code, 503)
self.assertIn('error', result)
self.assertEqual(self.owner.cached_results, before)
self.owner.cached_results.clear()
self.assertEqual(self.call('?cached=1')[1], 503)
self.owner.get_detailed_status.assert_not_called()
self.probe.assert_not_called()
def test_real_completion_finalizer_then_cached_poll_has_no_new_audit(self):
import sys
from types import ModuleType
node = next(n for n in ast.parse((ROOT/'AppImage/scripts/flask_terminal_routes.py').read_text()).body
if isinstance(n, ast.FunctionDef) and n.name == '_host_update_completed')
scope = {}
exec(compile(ast.Module(body=[node], type_ignores=[]), 'actual-finalizer', 'exec'), scope)
module = ModuleType('health_monitor')
module.health_monitor = self.owner
with patch.dict(sys.modules, {'health_monitor': module}):
scope['_host_update_completed']('/fixed-script', {}, 0, 164)
self.assertNotIn('_bg_detailed', self.owner.cached_results)
result, code = self.call('?cached=1')
self.assertEqual(code, 503)
self.assertIn('error', result)
self.owner.get_detailed_status.assert_not_called()
self.probe.assert_not_called()
def test_cached_only_just_before_boundary_is_fresh_reboot_without_full_check(self):
self.owner.last_check_times['_bg_detailed'] = self.now-359.999
result, code = self.call('?cached=1')
self.assertEqual(code, 200)
self.assertTrue(result['health']['details']['updates']['reboot_required'])
self.owner.get_detailed_status.assert_not_called()
def test_invalid_timestamp_and_incomplete_snapshot_never_start_full_check(self):
for value in (None, 'bad', float('nan'), float('inf')):
self.owner.last_check_times['_bg_detailed'] = value
self.assertEqual(self.call('?cached=1')[1], 503)
self.owner.last_check_times['_bg_detailed'] = self.now
self.owner.cached_results['_bg_detailed'] = {'details': {}}
self.assertEqual(self.call('?cached=1')[1], 500)
self.owner.get_detailed_status.assert_not_called()
self.probe.assert_not_called()
def test_probe_failure_is_unknown_not_green_and_retains_other_categories(self):
self.cached['details']['cpu']['status'] = 'CRITICAL'
self.cached['overall'] = 'CRITICAL'
with patch.object(kernel.subprocess, 'run', side_effect=OSError('fixture-read-error')):
result, code = self.call('?cached=1')
self.assertEqual(code, 200)
health = result['health']
self.assertIsNone(health['details']['updates']['reboot_required'])
self.assertEqual(health['details']['updates']['status'], 'UNKNOWN')
self.assertEqual(health['overall'], 'CRITICAL')
self.assertEqual(health['details']['cpu'], self.cached['details']['cpu'])
def test_marker_zero_package_probes_and_no_process_or_shell_actions(self):
with patch.object(Path, 'exists', return_value=True), \
patch.object(kernel.subprocess, 'Popen') as spawn, patch.object(kernel.os, 'system') as shell:
self.assertTrue(self.call('?cached=1')[0]['health']['details']['updates']['reboot_required'])
spawn.assert_not_called()
shell.assert_not_called()
self.probe.assert_not_called()
def test_explicit_refresh_retains_original_full_check_contract(self):
result, code = self.call('?refresh=1')
self.assertEqual(code, 200)
self.owner.get_detailed_status.assert_called_once()
def test_explicit_refresh_publishes_then_poll_never_rechecks(self):
result, code = self.call('?refresh=1')
self.assertEqual(code, 200)
self.assertIn('_bg_detailed', self.owner.cached_results)
self.assertEqual(self.owner.cached_results['_bg_overall']['status'],result['health']['overall'])
self.assertEqual(self.owner.last_check_times['_bg_overall'],self.owner.last_check_times['_bg_detailed'])
before = copy.deepcopy(self.owner.cached_results)
result, code = self.call('?cached=1')
self.assertEqual(code, 200)
self.assertEqual(result['health']['timestamp'], 'original')
self.assertEqual(self.owner.cached_results, before)
self.owner.get_detailed_status.assert_called_once()
def test_invalid_live_result_never_publishes_or_becomes_fresh(self):
for result in (None, {}, {'details': {}}, {'details': {'updates': {'status': 'OK'}}}):
with self.subTest(result=result):
self.owner.cached_results.clear()
self.owner.last_check_times.clear()
self.owner.get_detailed_status.return_value = result
self.assertEqual(self.call()[1], 500)
self.assertNotIn('_bg_detailed', self.owner.cached_results)
self.assertNotIn('_bg_detailed', self.owner.last_check_times)
class Overlay(unittest.TestCase):
def test_repeated_required_unknown_current_preserves_package_result_and_snapshot(self):
states = [dict(state=s, running_kernel='old', installed_kernel='new' if s=='required' else None)
for s in ('required', 'unknown', 'current')]
for package in ('OK', 'INFO', 'WARNING', 'CRITICAL', 'UNKNOWN'):
value = snapshot()
value['details']['updates']['status'] = package
before = copy.deepcopy(value)
for state in states:
previous = copy.deepcopy(value)
with patch.object(kernel, 'reboot_status', return_value=state):
refreshed = kernel.refresh_health_reboot(value)
self.assertEqual(value, previous)
value = refreshed
block = value['details']['updates']
expected = ('INFO' if state['state']=='required' else 'UNKNOWN' if state['state']=='unknown' else 'OK') if package=='OK' else package
self.assertEqual(block['status'], expected)
self.assertEqual(block['package_status'], package)
self.assertEqual(value['timestamp'], 'original')
self.assertEqual(value['details']['cpu'], before['details']['cpu'])
def test_dismissed_package_warning_stays_dismissed_but_reboot_remains_visible(self):
value = snapshot()
value['details']['updates'].update(status='OK', package_status='WARNING', all_dismissed=True)
state = dict(state='required', running_kernel='old', installed_kernel='new')
with patch.object(kernel, 'reboot_status', return_value=state):
result = kernel.refresh_health_reboot(value)
self.assertEqual(result['details']['updates']['status'], 'INFO')
self.assertTrue(result['details']['updates']['all_dismissed'])
self.assertEqual(value['details']['updates']['status'], 'OK')
def test_unknown_recovery_recomputes_overall_without_erasing_unrelated_warning(self):
value = snapshot()
with patch.object(kernel, 'reboot_status', return_value=dict(state='unknown', running_kernel='old')):
value = kernel.refresh_health_reboot(value)
self.assertEqual(value['overall'], 'WARNING')
with patch.object(kernel, 'reboot_status', return_value=dict(state='current', running_kernel='new')):
recovered = kernel.refresh_health_reboot(value)
value['details']['cpu']['status'] = 'WARNING'
preserved = kernel.refresh_health_reboot(value)
self.assertEqual(recovered['overall'], 'OK')
self.assertEqual(preserved['overall'], 'WARNING')
if __name__ == '__main__':
unittest.main(verbosity=2)
+51
View File
@@ -0,0 +1,51 @@
// The retained health snapshot is informational, never permission to reboot.
const assert = require('node:assert/strict')
const fs = require('node:fs'), path = require('node:path'), vm = require('node:vm')
const app = path.resolve(__dirname, '../AppImage')
const ts = require(path.join(app, 'node_modules/typescript'))
const source = fs.readFileSync(path.join(app, 'components/host-update-status.tsx'), 'utf8')
const tree = ts.createSourceFile('restart.tsx', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
let trigger, contextEffect
function walk(n) {
if (ts.isJsxElement(n) && n.openingElement.tagName.getText(tree) === 'Button' && n.children.some(c => c.getText(tree).includes('healthStatus.restart.button'))) {
trigger = n.openingElement.attributes.properties.find(p => p.name?.getText(tree) === 'disabled').initializer.expression.getText(tree)
}
if (ts.isCallExpression(n) && n.expression.getText(tree) === 'useEffect' && n.arguments[0].getText(tree).includes('/api/health/reboot-context')) contextEffect = n.arguments[0].getText(tree)
ts.forEachChild(n, walk)
}
walk(tree)
const evaluate = (code, scope) => vm.runInNewContext(ts.transpileModule('(' + code + ')', {compilerOptions: {target: ts.ScriptTarget.ES2020}}).outputText, scope)
function enabled(overrides = {}) {
return !evaluate(trigger, {disabled: false, unavailable: false, context: {allowed: true}, pending: false, accepted: false, ...overrides})
}
async function main() {
assert.equal(enabled({context: null}), false, 'remount after loading/reboot cannot enable a retained button before a fresh context')
for (const reason of ['current', 'busy', 'unknown']) assert.equal(enabled({context: {allowed: false, reason}}), false)
for (const key of ['disabled', 'unavailable', 'pending', 'accepted']) assert.equal(enabled({[key]: true}), false, key)
assert.equal(enabled(), true, 'fresh required context permits an explicit confirmation')
const calls = [], contexts = [], intervals = new Map(), timers = new Map()
let seq = 0
const scope = {open: false, getApiUrl: p => p, headers: () => ({}), AbortController,
setContext: x => contexts.push(x), setSubmitted() {}, setError() {}, sending: {current: false},
setTimeout: f => {timers.set(++seq, f); return seq}, clearTimeout: id => timers.delete(id),
setInterval: (f, delay) => {intervals.set(++seq, {f, delay}); return seq}, clearInterval: id => intervals.delete(id),
fetch: async (p, opts) => {calls.push({p, opts}); return {ok: true, json: async () => ({hostname: 'fixture', boot_id: 'new-boot', allowed: false, reason: 'current'})}}}
const cleanup = evaluate(contextEffect, scope)()
await new Promise(setImmediate)
assert.equal(calls.length, 1, 'read the real reboot context even while confirmation is closed')
assert.equal(calls[0].p, '/api/health/reboot-context')
assert.ok(!calls[0].opts.method || calls[0].opts.method === 'GET')
assert.equal(contexts.at(-1).allowed, false)
assert.equal([...intervals.values()][0].delay, 30000, 'closed confirmation uses a bounded cheap probe')
scope.fetch = async () => {throw Error('offline')}
await [...intervals.values()][0].f()
assert.equal(contexts.at(-1), null, 'connection loss revokes old permission')
cleanup()
assert.equal(intervals.size, 0); assert.equal(timers.size, 0)
const modal = fs.readFileSync(path.join(app, 'components/health-status-modal.tsx'), 'utf8')
assert.ok(modal.includes('unavailable={loading || snapshotUnavailable || !!error}'), 'stale/error/refresh snapshots must disable the action')
assert.ok(!modal.includes('{healthData && !loading && ('), 'refresh must not unmount and forget an accepted reboot')
assert.ok(modal.includes('<fieldset disabled={loading}'), 'keeping old results mounted must not enable other actions during refresh')
console.log('PASS reboot recovery: closed-context probe, remount/current/offline/stale/pending guards, no POST or audit')
}
main().catch(e => {console.error(e); process.exitCode = 1})