mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-01 10:22:08 +00:00
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:
@@ -13,6 +13,15 @@ import sys
|
||||
import tempfile
|
||||
|
||||
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_post_install_i18n.py",
|
||||
"tests/test_remaining_cli_i18n.py",
|
||||
@@ -34,6 +43,11 @@ PYTHON_FILES = (
|
||||
"AppImage/scripts/tests/test_health_persistence_recurrence.py",
|
||||
)
|
||||
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_api_import_boundaries.cjs",
|
||||
"tests/test_api_errors.cjs",
|
||||
|
||||
@@ -69,13 +69,22 @@ class TerminalCatalogsTests(unittest.TestCase):
|
||||
def test_shipped_active_catalogs_keep_literal_contracts(self):
|
||||
result = subprocess.run(
|
||||
[sys.executable, '-B', str(SCRIPTS / 'check_terminal_catalogs.py'),
|
||||
'--root', str(ROOT), '--require-complete'],
|
||||
'--root', str(ROOT)],
|
||||
capture_output=True, text=True, timeout=60,
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
for locale in MODULE.DEFAULT_LANGUAGES:
|
||||
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):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
|
||||
@@ -69,7 +69,7 @@ jobs:
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- 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
|
||||
env:
|
||||
PYTHONDONTWRITEBYTECODE: '1'
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
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 { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
@@ -41,6 +41,9 @@ import {
|
||||
HelpCircle,
|
||||
} from "lucide-react"
|
||||
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 { useIsPbs } from "@/lib/use-platform"
|
||||
|
||||
@@ -126,12 +129,23 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
const [dismissedItems, setDismissedItems] = useState<DismissedError[]>([])
|
||||
const [customSuppressions, setCustomSuppressions] = useState<CustomSuppression[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [snapshotUnavailable, setSnapshotUnavailable] = useState(false)
|
||||
const [dismissingKey, setDismissingKey] = useState<string | null>(null)
|
||||
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set())
|
||||
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) => {
|
||||
setLoading(true)
|
||||
const fetchHealthDetails = useCallback(async (force = false, 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
|
||||
if (!cachedOnly) setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
@@ -144,14 +158,26 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
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
|
||||
|
||||
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
|
||||
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"))
|
||||
const data = await legacyResponse.json()
|
||||
if (healthRequest.current !== controller) return
|
||||
setSnapshotUnavailable(false)
|
||||
setHealthData(data)
|
||||
setDismissedItems([])
|
||||
setCustomSuppressions([])
|
||||
@@ -168,6 +194,9 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
}
|
||||
} else {
|
||||
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)
|
||||
setDismissedItems(fullData.dismissed || [])
|
||||
setCustomSuppressions(fullData.custom_suppressions || [])
|
||||
@@ -207,9 +236,16 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
})
|
||||
window.dispatchEvent(event)
|
||||
} 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 {
|
||||
setLoading(false)
|
||||
if (timeout !== undefined) clearTimeout(timeout)
|
||||
if (healthRequest.current === controller) {
|
||||
healthRequest.current = null
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}, [getApiUrl, t])
|
||||
|
||||
@@ -225,9 +261,21 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
fetchHealthDetails()
|
||||
// Auto-refresh every 5 minutes while modal is open
|
||||
const refreshInterval = setInterval(() => fetchHealthDetails(), 300000)
|
||||
return () => clearInterval(refreshInterval)
|
||||
// Recheck reboot evidence promptly without running APT/full health checks.
|
||||
const refresh = () => {
|
||||
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])
|
||||
|
||||
@@ -245,7 +293,11 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
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])
|
||||
|
||||
@@ -300,6 +352,10 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
|
||||
const translateHealthText = (value?: string): string => {
|
||||
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> = {
|
||||
"No temperature sensor detected - install lm-sensors if hardware supports it": t("healthStatus.details.noTemperatureSensor"),
|
||||
"Sustained high CPU usage": t("healthStatus.details.cpuHighSustained"),
|
||||
@@ -330,6 +386,10 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
"Active": t("healthStatus.details.active"),
|
||||
"UP": t("healthStatus.details.up"),
|
||||
"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"),
|
||||
"No security updates pending": t("healthStatus.details.noSecurityUpdates"),
|
||||
"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]
|
||||
|
||||
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] })
|
||||
match = value.match(/^(\d+) failed login attempts in 24h$/)
|
||||
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",
|
||||
"qmp_communication", "container_startup", "vm_startup", "oom_killer", "cluster_mode",
|
||||
"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",
|
||||
])
|
||||
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())
|
||||
}
|
||||
|
||||
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 = (
|
||||
checks: Record<string, { status: string; detail: string; dismissable?: boolean; [key: string]: any }>,
|
||||
categoryKey: string
|
||||
@@ -645,6 +718,12 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
{Object.entries(checks)
|
||||
.filter(([, checkData]) => checkData.installed !== false)
|
||||
.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 checkStatus = checkData.status?.toUpperCase() || "OK"
|
||||
|
||||
@@ -711,6 +790,14 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
</DialogDescription>
|
||||
</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 && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
@@ -724,8 +811,15 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
</div>
|
||||
)}
|
||||
|
||||
{healthData && !loading && (
|
||||
<div className="space-y-4">
|
||||
{snapshotUnavailable && (
|
||||
<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 */}
|
||||
<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">
|
||||
@@ -760,7 +854,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
|
||||
{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">
|
||||
<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>
|
||||
)}
|
||||
|
||||
@@ -845,18 +939,30 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
pending prevents the operator from spawning a
|
||||
terminal that would only report "System is
|
||||
already up to date". */}
|
||||
{key === "updates" && status?.toUpperCase() !== "OK" && (
|
||||
<div className="flex justify-end px-3 py-2 pt-1">
|
||||
{key === "updates" && Number(categoryData?.count ?? 0) > 0 && (
|
||||
<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
|
||||
size="sm"
|
||||
onClick={() => setShowUpdateTerminal(true)}
|
||||
className="bg-purple-600/15 hover:bg-purple-600/25 border border-purple-500/40 text-purple-300 hover:text-purple-200"
|
||||
disabled={hostJobs.some(job => job.state !== "complete")}
|
||||
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" />
|
||||
{t("healthStatus.updateNow")}
|
||||
{t("healthStatus.updateSystem")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{key === "updates" && <ProxMenuxVersionCard getApiUrl={getApiUrl} />}
|
||||
</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) })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</fieldset>
|
||||
)}
|
||||
</DialogContent>
|
||||
<HostUpdateResult jobId={resultJobId} getApiUrl={getApiUrl} onClose={() => setResultJobId(null)} />
|
||||
<ScriptTerminalModal
|
||||
key={hostJob?.runId || "host-update"}
|
||||
open={showUpdateTerminal}
|
||||
backgroundUpdate={hostJob || undefined}
|
||||
cancelledExitCode={130}
|
||||
onComplete={() => { void fetchHealthDetails(true) }}
|
||||
onClose={() => {
|
||||
setShowUpdateTerminal(false)
|
||||
// Force a fresh read (cache-busting via ?refresh=1) so the
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import React, { useState, useEffect, useMemo, useCallback } from "react"
|
||||
import React, { useState, useEffect, useMemo, useCallback, useRef } from "react"
|
||||
import useSWR from "swr"
|
||||
import { Badge } from "./ui/badge"
|
||||
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)
|
||||
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 {
|
||||
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
|
||||
|
||||
if (response && response.health?.details) {
|
||||
@@ -218,7 +226,10 @@ function MonitorDashboard({ product }: { product: Product }) {
|
||||
|
||||
setInfoCount(calculatedInfoCount)
|
||||
} 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()
|
||||
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 healthInterval: ReturnType<typeof setInterval> | null = null
|
||||
if (activeTab === "overview") {
|
||||
interval = setInterval(fetchSystemData, 30000) // 30 segundos
|
||||
healthInterval = setInterval(fetchHealthInfoCount, 30000) // Also refresh info count
|
||||
} else {
|
||||
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 () => {
|
||||
if (interval) clearInterval(interval)
|
||||
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])
|
||||
|
||||
@@ -391,6 +411,7 @@ function MonitorDashboard({ product }: { product: Product }) {
|
||||
const refreshData = async () => {
|
||||
setIsRefreshing(true)
|
||||
await fetchSystemData()
|
||||
await fetchHealthInfoCount()
|
||||
setComponentKey((prev) => prev + 1)
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
setIsRefreshing(false)
|
||||
|
||||
@@ -54,8 +54,8 @@ interface ScriptTerminalModalProps {
|
||||
completedSuccessfullyMessage?: string
|
||||
completedWithErrorMessage?: (exitCode: number) => string
|
||||
cancelledExitCode?: number
|
||||
// Only LXC Updates opts into a separate server-owned job transport.
|
||||
backgroundUpdate?: { runId: string; vmid: number; resume: boolean }
|
||||
// Explicit server-owned transports; ordinary /ws/script keeps its contract.
|
||||
backgroundUpdate?: { runId: string; vmid?: number; resume: boolean; kind?: "host" }
|
||||
// Optional callback fired when the script's WebSocket closes
|
||||
// (script_runner sends an exit code and then closes). Lets the
|
||||
// parent auto-dismiss the modal — used by host-backup's Restore
|
||||
@@ -178,7 +178,9 @@ export function ScriptTerminalModal({
|
||||
}
|
||||
}, 30000)
|
||||
|
||||
const initMessage = {
|
||||
const initMessage = backgroundUpdate?.kind === "host" ? {
|
||||
resume: true, offset: outputOffsetRef.current,
|
||||
} : {
|
||||
script_path: scriptPath,
|
||||
params: paramsRef.current,
|
||||
...(backgroundUpdate ? { resume: true, vmid: backgroundUpdate.vmid,
|
||||
@@ -221,6 +223,10 @@ export function ScriptTerminalModal({
|
||||
|
||||
try {
|
||||
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") {
|
||||
setIsWaitingNextInteraction(false)
|
||||
if (waitingTimeoutRef.current) clearTimeout(waitingTimeoutRef.current)
|
||||
@@ -429,7 +435,9 @@ export function ScriptTerminalModal({
|
||||
}
|
||||
}, 30000)
|
||||
|
||||
const initMessage = {
|
||||
const initMessage = backgroundUpdate?.kind === "host" ? {
|
||||
resume: backgroundUpdate.resume, offset: outputOffsetRef.current,
|
||||
} : {
|
||||
script_path: scriptPath,
|
||||
params: paramsRef.current,
|
||||
...(backgroundUpdate ? { resume: backgroundUpdate.resume,
|
||||
@@ -478,6 +486,10 @@ export function ScriptTerminalModal({
|
||||
|
||||
try {
|
||||
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") {
|
||||
setIsWaitingNextInteraction(false)
|
||||
if (waitingTimeoutRef.current) clearTimeout(waitingTimeoutRef.current)
|
||||
@@ -752,7 +764,7 @@ export function ScriptTerminalModal({
|
||||
|
||||
const getScriptWebSocketUrl = (sid: string): string => {
|
||||
const route = backgroundUpdate
|
||||
? `/ws/lxc-update/${encodeURIComponent(backgroundUpdate.runId)}`
|
||||
? `/ws/${backgroundUpdate.kind === "host" ? "host-update" : "lxc-update"}/${encodeURIComponent(backgroundUpdate.runId)}`
|
||||
: `/ws/script/${sid}`
|
||||
if (typeof window === "undefined") {
|
||||
return `ws://localhost:${API_PORT}${route}`
|
||||
|
||||
@@ -5309,6 +5309,48 @@
|
||||
}
|
||||
},
|
||||
"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",
|
||||
"description": "Detailed checks of all system components",
|
||||
"status": {
|
||||
@@ -5365,6 +5407,7 @@
|
||||
"system_age": "System age",
|
||||
"pending_updates": "Pending updates",
|
||||
"kernel_pve": "Kernel / PVE",
|
||||
"reboot_required": "Reboot required",
|
||||
"uptime": "Uptime",
|
||||
"certificates": "Certificates",
|
||||
"login_attempts": "Login attempts",
|
||||
@@ -5392,6 +5435,7 @@
|
||||
"runningKernelUpdate": "Kernel update available for running {kernel}",
|
||||
"kernelPveUpdates": "Kernel updates available: {kernelCount}; Proxmox updates available: {pveCount}",
|
||||
"pendingPackageUpdates": "Package updates pending: {count}",
|
||||
"noPendingUpdates": "No pending updates",
|
||||
"updatesAptError": "Failed to check for updates (apt-get error)",
|
||||
"updatesAptTimeout": "apt-get timed out - repository may be unreachable",
|
||||
"kernelPackagesMore": "{technicalIdentity} (+{count} more)",
|
||||
@@ -5435,7 +5479,7 @@
|
||||
"failedLogins": "{count} failed login attempts in 24h",
|
||||
"fail2banBannedIps": "Fail2Ban is currently blocking {count} IP addresses (jails: {jails})",
|
||||
"uptimeDays": "Uptime: {count} days",
|
||||
"pendingPackages": "{count} packages pending",
|
||||
"pendingPackages": "Planned packages: {count} (including new packages)",
|
||||
"updatedDaysAgo": "Last updated {count} days ago",
|
||||
"startupStoragesChecking": "Checking Proxmox storage after startup: {storages}",
|
||||
"startupStorageChecking": "Checking Proxmox storage after startup: {storage}",
|
||||
@@ -5463,6 +5507,10 @@
|
||||
},
|
||||
"noIssues": "No issues detected",
|
||||
"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})",
|
||||
"permanent": "Permanent",
|
||||
"dismissed": "Dismissed",
|
||||
|
||||
@@ -5181,6 +5181,48 @@
|
||||
}
|
||||
},
|
||||
"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",
|
||||
"description": "Podrobná kontrola všetkých častí systému",
|
||||
"status": {
|
||||
@@ -5237,6 +5279,7 @@
|
||||
"system_age": "Aktuálnosť systému",
|
||||
"pending_updates": "Čakajúce aktualizácie",
|
||||
"kernel_pve": "Jadro / PVE",
|
||||
"reboot_required": "Potrebný reštart",
|
||||
"uptime": "Doba prevádzky",
|
||||
"certificates": "Certifikáty",
|
||||
"login_attempts": "Pokusy o prihlásenie",
|
||||
@@ -5269,7 +5312,7 @@
|
||||
"failedLogins": "Neúspešné prihlásenia za 24 h: {count}",
|
||||
"fail2banBannedIps": "Fail2Ban momentálne blokuje IP adresy: {count} (pravidlá: {jails})",
|
||||
"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",
|
||||
"startupStoragesChecking": "Po štarte sa ešte overuje stav Proxmox úložísk: {storages}",
|
||||
"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})",
|
||||
"systemNotUpdatedMonths": "Systém nebol aktualizovaný za {days} dní (>18 mesiacov)",
|
||||
"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}",
|
||||
"pendingPackageUpdates": "Čakajúce aktualizácie balíka: {count}",
|
||||
"noPendingUpdates": "Žiadne čakajúce aktualizácie",
|
||||
"updatesAptError": "Nepodarilo sa skontrolovať aktualizácie (chyba apt-get)",
|
||||
"updatesAptTimeout": "Časový limit apt-get vypršal - úložisko môže byť nedostupné",
|
||||
"kernelPackagesMore": "{technicalIdentity} (+{count} viac)",
|
||||
@@ -5335,6 +5379,10 @@
|
||||
},
|
||||
"noIssues": "Neboli zistené žiadne problémy",
|
||||
"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})",
|
||||
"permanent": "Natrvalo",
|
||||
"dismissed": "Skryté",
|
||||
|
||||
@@ -394,10 +394,12 @@ def verify_token_full(token):
|
||||
API JWT 365 días sin scope.
|
||||
"""
|
||||
if not JWT_AVAILABLE or not token:
|
||||
_log_auth_failure_throttled("Session rejected: missing token or verifier unavailable")
|
||||
return None, None
|
||||
try:
|
||||
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
||||
if token_hash in _get_revoked_tokens_cached():
|
||||
_log_auth_failure_throttled("Session rejected: revoked")
|
||||
return None, None
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
@@ -409,8 +411,10 @@ def verify_token_full(token):
|
||||
payload = jwt.decode(token, _get_jwt_secret(), algorithms=[JWT_ALGORITHM])
|
||||
return payload.get('username'), payload.get('scope', 'full_admin')
|
||||
except jwt.ExpiredSignatureError:
|
||||
_log_auth_failure_throttled("Session rejected: expired")
|
||||
return None, None
|
||||
except jwt.InvalidTokenError:
|
||||
_log_auth_failure_throttled("Session rejected: invalid")
|
||||
return None, None
|
||||
|
||||
|
||||
@@ -437,9 +441,9 @@ def _log_auth_failure_throttled(msg):
|
||||
if _AUTH_LOG_RATE['suppressed']:
|
||||
print(f"[auth] {_AUTH_LOG_RATE['last_msg']} "
|
||||
f"(+{_AUTH_LOG_RATE['suppressed']} more in last "
|
||||
f"{int(elapsed)}s)")
|
||||
f"{int(elapsed)}s)", flush=True)
|
||||
else:
|
||||
print(f"[auth] {msg}")
|
||||
print(f"[auth] {msg}", flush=True)
|
||||
_AUTH_LOG_RATE['last_ts'] = now
|
||||
_AUTH_LOG_RATE['suppressed'] = 0
|
||||
_AUTH_LOG_RATE['last_msg'] = msg
|
||||
|
||||
@@ -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_finalization.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/native_app_discovery.py" "$APP_DIR/usr/bin/"
|
||||
cp "$SCRIPT_DIR/local_app_icons.py" "$APP_DIR/usr/bin/"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
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_persistence import health_persistence
|
||||
from jwt_middleware import require_auth, require_admin_scope
|
||||
@@ -17,6 +17,53 @@ except ImportError:
|
||||
|
||||
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'])
|
||||
@require_auth
|
||||
def get_health_status():
|
||||
@@ -243,10 +290,14 @@ def get_full_health():
|
||||
security before returning, so an event that just changed underlying state
|
||||
(Update Now finished, dismiss action) sees the new value immediately
|
||||
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
|
||||
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',
|
||||
'_bg_detailed', '_bg_overall', 'overall_health'):
|
||||
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
|
||||
bg_key = '_bg_detailed'
|
||||
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)
|
||||
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:
|
||||
# No fresh cache, run live (first load or cache expired)
|
||||
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()
|
||||
dismissed = health_persistence.get_dismissed_errors()
|
||||
|
||||
@@ -1752,6 +1752,7 @@ def get_uptime():
|
||||
_system_info_cache = {
|
||||
'proxmox_version': None,
|
||||
'proxmox_version_time': 0,
|
||||
'proxmox_version_stamp': 0.0,
|
||||
'available_updates': 0,
|
||||
'available_updates_time': 0,
|
||||
'available_updates_stamp': 0.0,
|
||||
@@ -2748,6 +2749,9 @@ def _action_state(unit_name):
|
||||
return result_dict
|
||||
|
||||
|
||||
app.extensions['host_update_power'] = {'run': _action_run, 'state': _action_state}
|
||||
|
||||
|
||||
def _action_cancel(unit_name):
|
||||
"""Stop a running transient action unit. Idempotent — succeeds on
|
||||
units that are not running (or don't exist).
|
||||
@@ -3504,14 +3508,16 @@ def get_coral_info():
|
||||
|
||||
|
||||
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
|
||||
if is_pbs_host():
|
||||
return get_platform().get('version')
|
||||
|
||||
now = time.time()
|
||||
if _system_info_cache['proxmox_version'] is not None and \
|
||||
now - _system_info_cache['proxmox_version_time'] < _SYSTEM_INFO_CACHE_TTL:
|
||||
stamp = _apt_state_stamp()
|
||||
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']
|
||||
|
||||
proxmox_version = None
|
||||
@@ -3527,8 +3533,11 @@ def get_proxmox_version():
|
||||
except Exception as e:
|
||||
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_time'] = now
|
||||
_system_info_cache['proxmox_version_stamp'] = stamp
|
||||
return proxmox_version
|
||||
|
||||
def _apt_state_stamp():
|
||||
|
||||
@@ -200,6 +200,90 @@ def lxc_update_viewer(ws, run_id):
|
||||
# No process termination, PTY close or completion callback here. The job's
|
||||
# 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'])
|
||||
def terminal_health():
|
||||
"""Health check for terminal service"""
|
||||
|
||||
@@ -4751,6 +4751,10 @@ class HealthMonitor:
|
||||
Inst lines and exposes them as an INFO sub-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()
|
||||
apt_history_path = '/var/log/apt/history.log'
|
||||
|
||||
@@ -4773,7 +4777,7 @@ class HealthMonitor:
|
||||
cache_ts = self.last_check_times[cache_key]
|
||||
history_changed = (history_mtime is not None and history_mtime > cache_ts)
|
||||
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:
|
||||
last_update_days = None
|
||||
@@ -4803,11 +4807,11 @@ class HealthMonitor:
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
print("[HealthMonitor] apt-get upgrade --dry-run timed out")
|
||||
return {
|
||||
return with_reboot_status({
|
||||
'status': 'UNKNOWN',
|
||||
'reason': 'apt-get timed out - repository may be unreachable',
|
||||
'count': 0, 'checks': {}
|
||||
}
|
||||
}, reboot)
|
||||
|
||||
status = 'OK'
|
||||
reason = None
|
||||
@@ -5124,11 +5128,11 @@ class HealthMonitor:
|
||||
|
||||
self.cached_results[cache_key] = update_result
|
||||
self.last_check_times[cache_key] = current_time
|
||||
return update_result
|
||||
return with_reboot_status(update_result, reboot)
|
||||
|
||||
except Exception as 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]:
|
||||
"""
|
||||
|
||||
@@ -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)
|
||||
@@ -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)]
|
||||
@@ -50,6 +50,7 @@ def require_auth(f):
|
||||
auth_header = request.headers.get('Authorization')
|
||||
|
||||
if not auth_header:
|
||||
verify_token_full(None) # Guarded, credential-free rejection diagnostic.
|
||||
return jsonify({
|
||||
"error": "Authentication required",
|
||||
"message": "No authorization header provided"
|
||||
@@ -58,6 +59,7 @@ def require_auth(f):
|
||||
# Extract token from "Bearer <token>" format
|
||||
parts = auth_header.split()
|
||||
if len(parts) != 2 or parts[0].lower() != 'bearer':
|
||||
verify_token_full(None)
|
||||
return jsonify({
|
||||
"error": "Invalid authorization header",
|
||||
"message": "Authorization header must be in format: Bearer <token>"
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
"# 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.",
|
||||
|
||||
@@ -209,6 +209,10 @@ update_pve_safe() {
|
||||
if ! pmx_confirm_update "$(translate "Proxmox Update")" "$menu_text" "$upgradable_list" \
|
||||
"$(translate "Do you want to proceed with the system update?")"; then
|
||||
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 autoclean >/dev/null 2>&1 || true
|
||||
rm -f "$screen_capture"
|
||||
|
||||
@@ -45,6 +45,23 @@ export SCRIPT_TITLE="Proxmox system update"
|
||||
|
||||
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.
|
||||
# PVE services (pve-cluster, pveproxy, pvedaemon, corosync…) get killed and
|
||||
# restarted as part of the upgrade — those events are expected, not real
|
||||
@@ -122,6 +139,18 @@ check_reboot() {
|
||||
NECESSARY_REBOOT=1
|
||||
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 whiptail --title "$(translate "Reboot Required")" \
|
||||
--yesno "$(translate "Some changes require a reboot to take effect. Do you want to restart now?")" 10 60; then
|
||||
|
||||
@@ -261,5 +261,31 @@ for (const input of passthrough) {
|
||||
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));
|
||||
}
|
||||
// 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`);
|
||||
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`);
|
||||
|
||||
@@ -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)
|
||||
@@ -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})
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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})
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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')&¤t.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')&¤t.includes('text-green-500'))
|
||||
assert.ok(current.includes(versions.stable)&¤t.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')
|
||||
@@ -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})
|
||||
@@ -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)
|
||||
@@ -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})
|
||||
Reference in New Issue
Block a user