feat: add guarded host restart and read-only ProxMenux version status

This commit is contained in:
VAIO73
2026-10-28 17:30:44 +00:00
parent 503a946d85
commit 52cd21dcb1
11 changed files with 631 additions and 1 deletions
+2
View File
@@ -19,6 +19,7 @@ PYTHON_FILES = (
"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_lxc_update_jobs.py",
"tests/test_post_install_i18n.py",
"tests/test_remaining_cli_i18n.py",
@@ -42,6 +43,7 @@ PYTHON_FILES = (
NODE_FILES = (
"tests/test_host_update_ui.cjs",
"tests/test_reboot_health_refresh.cjs",
"tests/test_host_update_controls.cjs",
"tests/test_lxc_update_jobs.cjs",
"tests/test_api_import_boundaries.cjs",
"tests/test_api_errors.cjs",
@@ -42,6 +42,7 @@ import {
} from "lucide-react"
import { ScriptTerminalModal } from "./script-terminal-modal"
import { HostUpdateJobs, type HostUpdateJob } from "./host-update-jobs"
import { RestartAfterUpdate, ProxMenuxVersionCard } from "./host-update-status"
import { useT } from "@/lib/i18n/provider"
import { useIsPbs } from "@/lib/use-platform"
@@ -370,6 +371,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
"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"),
@@ -689,6 +691,11 @@ 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}
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"
@@ -896,6 +903,7 @@ 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" && <ProxMenuxVersionCard getApiUrl={getApiUrl} />}
{key === "updates" && Number(categoryData?.count ?? 0) > 0 && (
<div className="flex justify-end px-3 py-2 pt-1">
<Button
+157
View File
@@ -0,0 +1,157 @@
"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"
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, label, detail, icon }: {
getApiUrl: Url; disabled: 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(() => {
if (!open) return
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()
const interval = setInterval(read, 2000)
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 || !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 || 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 || !context?.allowed || 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"
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={state === "unknown" ? "text-amber-500" : state === "available" ? "text-blue-500" : "text-green-500"}>{t(`healthStatus.versions.${state}`)}</span></div>
<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>
{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>}
</section>
}
+24
View File
@@ -5309,6 +5309,29 @@
}
},
"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",
"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": "A host update has finished. View its result before starting another.",
@@ -5403,6 +5426,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)",
+24
View File
@@ -5181,6 +5181,29 @@
}
},
"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",
"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": "Aktualizácia servera sa skončila. Pred ďalším spustením si pozri jej výsledok.",
@@ -5314,6 +5337,7 @@
"runningKernelUpdate": "K dispozícii je aktualizácia jadra pre spustenie {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)",
+48 -1
View File
@@ -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():
+3
View File
@@ -2748,6 +2748,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).
+57
View File
@@ -0,0 +1,57 @@
"""Guarded reboot-after-update context; existing power API is unchanged."""
import fcntl
import os
from pathlib import Path
import socket
import stat
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')
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, '/bin/sh', '-c',
'/bin/systemctl reboot && /bin/sleep 60']
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')
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')
@@ -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
+44
View File
@@ -0,0 +1,44 @@
// 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
function walk(n){if(ts.isVariableDeclaration(n)&&n.name.getText(tree)==='confirm')callback=n.initializer.getText(tree);ts.forEachChild(n,walk)}
walk(tree);assert.ok(callback)
function fixture(){
const calls=[],events=[]
const scope={AbortController,JSON,Error,disabled: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(){
for(const block of ['disabled','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})
+208
View File
@@ -0,0 +1,208 @@
"""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 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
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'
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 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[-1],'/bin/systemctl reboot && /bin/sleep 60')
command[4]=str(self.lock);command[-1]='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'])
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.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'}
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)