mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-01 10:22:08 +00:00
feat: add guarded host restart and read-only ProxMenux version status
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
@@ -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})
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user