Merge pull request #441 from Vaso73/fix/lxc-observed-state

fix: revalidate LXC data after external changes
This commit is contained in:
MacRimi
2026-10-13 19:54:08 +02:00
committed by GitHub
11 changed files with 927 additions and 107 deletions
@@ -202,6 +202,13 @@ class CommandDescriptionsTests(unittest.TestCase):
key, english["backup"]["destinations"][key]) key, english["backup"]["destinations"][key])
messages["backup"]["actions"].setdefault( messages["backup"]["actions"].setdefault(
"prepareSshKey", english["backup"]["actions"]["prepareSshKey"]) "prepareSshKey", english["backup"]["actions"]["prepareSshKey"])
# New LXC observation labels intentionally ship only EN/SK.
# Model the bot's future output in temporary catalogs, just
# like the other pending keys above; never rewrite locales.
for key in ("checkNow", "checkReadOnlyHelp", "checkCooldown",
"checkStale", "checkStaleHelp"):
messages["vmLxc"]["updates"].setdefault(
key, english["vmLxc"]["updates"][key])
path.write_text(json.dumps(messages, ensure_ascii=False)) path.write_text(json.dumps(messages, ensure_ascii=False))
before = {p: p.read_bytes() for p in root.glob("*/common.json")} before = {p: p.read_bytes() for p in root.glob("*/common.json")}
argv = [str(SCRIPT), "--source", str(root / "en/common.json"), argv = [str(SCRIPT), "--source", str(root / "en/common.json"),
+73 -6
View File
@@ -28,6 +28,7 @@ import { formatStorage } from "../lib/utils"
import { formatNetworkTraffic, getNetworkUnit } from "../lib/format-network" import { formatNetworkTraffic, getNetworkUnit } from "../lib/format-network"
import { fetchApi } from "../lib/api-config" import { fetchApi } from "../lib/api-config"
import { matchesVmSearch, normalizeVmSearchValue } from "../lib/vm-search" import { matchesVmSearch, normalizeVmSearchValue } from "../lib/vm-search"
import { checkLxcUpdates, isLxcUpdateStale } from "../lib/lxc-update-observation"
import DOMPurify from "dompurify" import DOMPurify from "dompurify"
import { marked } from "marked" import { marked } from "marked"
import { getCountFormKey, useI18n, useT } from "@/lib/i18n/provider" import { getCountFormKey, useI18n, useT } from "@/lib/i18n/provider"
@@ -84,6 +85,9 @@ interface LxcUpdateCheck {
helper_app_name?: string | null helper_app_name?: string | null
helper_updateable_known?: boolean helper_updateable_known?: boolean
os_family?: string | null os_family?: string | null
stale?: boolean
age_seconds?: number | null
stale_after_seconds?: number
} }
// Summary attached to LXC rows in /api/vms when the user has // Summary attached to LXC rows in /api/vms when the user has
@@ -912,6 +916,8 @@ export function VirtualMachines() {
// "settled" when the observed count differs from this baseline // "settled" when the observed count differs from this baseline
// (or after a 15 s safety timeout). // (or after a 15 s safety timeout).
const [updatesRefreshing, setUpdatesRefreshing] = useState(false) const [updatesRefreshing, setUpdatesRefreshing] = useState(false)
const [osCheckingVmid, setOsCheckingVmid] = useState<number | null>(null)
const [osCheckError, setOsCheckError] = useState<string | null>(null)
const [updatesResult, setUpdatesResult] = useState<{ pendingAfter: number; appliedCount: number } | null>(null) const [updatesResult, setUpdatesResult] = useState<{ pendingAfter: number; appliedCount: number } | null>(null)
const [updatesBaselineCount, setUpdatesBaselineCount] = useState<number | null>(null) const [updatesBaselineCount, setUpdatesBaselineCount] = useState<number | null>(null)
const selectedVMRef = useRef<VMData | null>(null) const selectedVMRef = useRef<VMData | null>(null)
@@ -1188,6 +1194,31 @@ export function VirtualMachines() {
selectedVMRef.current = selectedVM selectedVMRef.current = selectedVM
}, [selectedVM]) }, [selectedVM])
// Revalidate only the open modal, independently of the 2.5s list poll.
// This reads config, addresses and os-release, never package updates.
useEffect(() => {
const vmid = selectedVM?.vmid
if (vmid === undefined) return
let cancelled = false
let inFlight = false
const timer = setInterval(async () => {
if (inFlight) return
inFlight = true
try {
const details = await fetchApi<VMDetails>(`/api/vms/${vmid}`)
if (!cancelled) {
vmModalCacheRef.current.details.set(vmid, details)
setVMDetails(details)
}
} catch {
// Keep the last snapshot; the next interval retries the read.
} finally {
inFlight = false
}
}, 30000)
return () => { cancelled = true; clearInterval(timer) }
}, [selectedVM?.vmid])
useEffect(() => { useEffect(() => {
updatesBaselineCountRef.current = updatesBaselineCount updatesBaselineCountRef.current = updatesBaselineCount
}, [updatesBaselineCount]) }, [updatesBaselineCount])
@@ -1239,6 +1270,7 @@ export function VirtualMachines() {
const handleVMClick = async (vm: VMData) => { const handleVMClick = async (vm: VMData) => {
setSelectedVM(vm) setSelectedVM(vm)
setOsCheckError(null)
setBulkLoaded(null) setBulkLoaded(null)
setBulkConfigured(false) setBulkConfigured(false)
setBulkTargets(["os"]) setBulkTargets(["os"])
@@ -2647,6 +2679,26 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
// packages" section needs a clean `available`/`count` to avoid a // packages" section needs a clean `available`/`count` to avoid a
// false "N package pending" every time a registered app has an // false "N package pending" every time a registered app has an
// upstream bump. // upstream bump.
const checkOsUpdates = async (vmid: number) => {
setOsCheckingVmid(vmid)
setOsCheckError(null)
try {
await checkLxcUpdates(vmid)
const details = await fetchApi<VMDetails>(`/api/vms/${vmid}`)
vmModalCacheRef.current.details.set(vmid, details)
if (selectedVMRef.current?.vmid === vmid) setVMDetails(details)
await mutate()
} catch (error: any) {
if (selectedVMRef.current?.vmid === vmid) {
setOsCheckError(error.status === 429
? t("vmLxc.updates.checkCooldown", { seconds: error.body?.retry_after ?? 60 })
: error.message || t("vmLxc.updates.osStatusUnavailable"))
}
} finally {
setOsCheckingVmid(null)
}
}
const getAggregateUpdateCheck = (vm: VMData): LxcUpdateCheck | undefined => { const getAggregateUpdateCheck = (vm: VMData): LxcUpdateCheck | undefined => {
const uc = vm.update_check const uc = vm.update_check
const appCount = (vm.app_watches || []).filter( const appCount = (vm.app_watches || []).filter(
@@ -2702,7 +2754,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
count: total, count: total,
available: true, available: true,
packages: [...(uc?.packages || []), ...pendingNames], packages: [...(uc?.packages || []), ...pendingNames],
last_check: uc?.last_check ?? new Date().toISOString(), last_check: uc?.last_check ?? null,
} as LxcUpdateCheck } as LxcUpdateCheck
} }
@@ -2711,7 +2763,9 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
compact = false, compact = false,
onClick?: () => void, onClick?: () => void,
) => { ) => {
if (!uc?.available || !uc.count || uc.count <= 0) return null if (!uc) return null
const stale = uc.stale === true || (!!uc.last_check && isLxcUpdateStale(uc))
if (!stale && (!uc.available || !uc.count || uc.count <= 0)) return null
const last = uc.last_check const last = uc.last_check
? new Date(uc.last_check).toLocaleString() ? new Date(uc.last_check).toLocaleString()
: "—" : "—"
@@ -2759,8 +2813,8 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
space — matters most on the LXC card row where uptime, space — matters most on the LXC card row where uptime,
IP and other chips compete for width. Count alone with IP and other chips compete for width. Count alone with
the up-arrow reads unambiguously as "pending updates". */} the up-arrow reads unambiguously as "pending updates". */}
<ArrowUpCircle className={iconSize} /> {stale ? <Clock className={iconSize} /> : <ArrowUpCircle className={iconSize} />}
{uc.count} {stale ? t("vmLxc.updates.checkStale") : uc.count}
{/* Chevron only when the badge is wired up as a clickable {/* Chevron only when the badge is wired up as a clickable
shortcut — its absence on the dashboard card avoids shortcut — its absence on the dashboard card avoids
implying interactivity where there isn't any (the whole implying interactivity where there isn't any (the whole
@@ -5652,7 +5706,8 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
{!selectedVM.update_check?.managed_oci_app && !ociInstance && !ociRestored && {!selectedVM.update_check?.managed_oci_app && !ociInstance && !ociRestored &&
!selectedVM.update_check?.is_oci_lxc && (() => { !selectedVM.update_check?.is_oci_lxc && (() => {
const uc = selectedVM.update_check const uc = selectedVM.update_check
const osUpdateStatusKnown = !!uc && !uc.error const osUpdateStale = isLxcUpdateStale(uc)
const osUpdateStatusKnown = !!uc && !uc.error && !osUpdateStale
const hasOsUpdates = osUpdateStatusKnown && !!uc.available const hasOsUpdates = osUpdateStatusKnown && !!uc.available
const dockerAppWatch = (selectedVM.app_watches || []).find((a) => a.helper_slug === "docker") const dockerAppWatch = (selectedVM.app_watches || []).find((a) => a.helper_slug === "docker")
const dockerRegistered = !!dockerAppWatch const dockerRegistered = !!dockerAppWatch
@@ -5925,6 +5980,15 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
<div className="flex items-center gap-2 mb-3"> <div className="flex items-center gap-2 mb-3">
<Package className="h-4 w-4 text-muted-foreground" /> <Package className="h-4 w-4 text-muted-foreground" />
<h3 className="text-sm font-semibold text-foreground">{t("vmLxc.updates.osPackagesTitle")}</h3> <h3 className="text-sm font-semibold text-foreground">{t("vmLxc.updates.osPackagesTitle")}</h3>
<Button
size="sm" variant="outline" className="ml-auto"
disabled={selectedVM.status !== "running" || osCheckingVmid !== null}
title={t("vmLxc.updates.checkReadOnlyHelp")}
onClick={() => checkOsUpdates(selectedVM.vmid)}
>
<RefreshCw className={`h-4 w-4 mr-1.5 ${osCheckingVmid === selectedVM.vmid ? "animate-spin" : ""}`} />
{t("vmLxc.updates.checkNow")}
</Button>
</div> </div>
<div className="text-xs text-muted-foreground mb-3 leading-relaxed"> <div className="text-xs text-muted-foreground mb-3 leading-relaxed">
{t("vmLxc.updates.lastCheckedPrefix")}{" "} {t("vmLxc.updates.lastCheckedPrefix")}{" "}
@@ -5933,7 +5997,10 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
<> · {t("vmLxc.updates.familyLabel")} <code className="text-foreground/80">{uc.os_family}</code></> <> · {t("vmLxc.updates.familyLabel")} <code className="text-foreground/80">{uc.os_family}</code></>
)} )}
</div> </div>
{!osUpdateStatusKnown ? ( {osCheckError && <p role="alert" className="text-sm text-amber-400 mb-2">{osCheckError}</p>}
{osUpdateStale && !uc?.error ? (
<p className="text-sm text-amber-400">{t("vmLxc.updates.checkStaleHelp")}</p>
) : !osUpdateStatusKnown ? (
<div <div
className="text-sm text-muted-foreground flex items-center gap-2" className="text-sm text-muted-foreground flex items-center gap-2"
title={uc?.error || undefined} title={uc?.error || undefined}
+21
View File
@@ -0,0 +1,21 @@
import { fetchApi } from "./api-config"
interface UpdateObservation {
last_check?: string | null
stale?: boolean
stale_after_seconds?: number
}
export function isLxcUpdateStale(observation?: UpdateObservation, now = Date.now()): boolean {
if (!observation || observation.stale === true) return true
const checked = Date.parse(observation.last_check || "")
const age = now - checked
const limit = (observation.stale_after_seconds ?? 172800) * 1000
return !Number.isFinite(checked) || age < 0 || age >= limit
}
// Called only by the OS section's explicit button. Uses installed package
// state and existing indexes; fetching this never upgrades or refreshes APT.
export function checkLxcUpdates<T>(vmid: number): Promise<T> {
return fetchApi<T>(`/api/vms/${vmid}/updates/check`, { method: "POST" })
}
+5
View File
@@ -1332,6 +1332,11 @@
}, },
"updates": { "updates": {
"osPackagesTitle": "OS packages", "osPackagesTitle": "OS packages",
"checkNow": "Check now",
"checkReadOnlyHelp": "Read current OS and pending packages from existing indexes. No installation or package index refresh.",
"checkCooldown": "Check already running or recently requested. Try again in {seconds} seconds.",
"checkStale": "Check outdated",
"checkStaleHelp": "This result is older than 48 hours or has no valid check date. Run a new check before relying on it.",
"lastCheckedPrefix": "Last checked:", "lastCheckedPrefix": "Last checked:",
"familyLabel": "Family:", "familyLabel": "Family:",
"applyOsUpdate": "Apply OS update", "applyOsUpdate": "Apply OS update",
+5
View File
@@ -1334,6 +1334,11 @@
"updatedWithDockerImage": "Aktualizuje sa spolu so svojím obrazom Docker.", "updatedWithDockerImage": "Aktualizuje sa spolu so svojím obrazom Docker.",
"dockerBindingUnavailable": "Zatiaľ nie je priradený žiadny obraz. Skontrolujte názov kontajnera a obnovte inventár obrazov.", "dockerBindingUnavailable": "Zatiaľ nie je priradený žiadny obraz. Skontrolujte názov kontajnera a obnovte inventár obrazov.",
"osPackagesTitle": "Balíky systému", "osPackagesTitle": "Balíky systému",
"checkNow": "Skontrolovať teraz",
"checkReadOnlyHelp": "Zistí aktuálny systém a čakajúce balíky podľa existujúcich zoznamov. Nič nenainštaluje ani neobnoví zoznamy balíkov.",
"checkCooldown": "Kontrola už prebieha alebo sa práve vykonala. Skúste znova o {seconds} sekúnd.",
"checkStale": "Zastaraná kontrola",
"checkStaleHelp": "Výsledok je starší než 48 hodín alebo nemá platný dátum kontroly. Pred použitím spustite novú kontrolu.",
"lastCheckedPrefix": "Naposledy skontrolované:", "lastCheckedPrefix": "Naposledy skontrolované:",
"familyLabel": "Systém:", "familyLabel": "Systém:",
"applyOsUpdate": "Aktualizovať systém", "applyOsUpdate": "Aktualizovať systém",
+119 -69
View File
@@ -580,35 +580,34 @@ def parse_lxc_hardware_config(vmid, node):
def _get_lxc_ip_info_cached(vmid): def _get_lxc_ip_info_cached(vmid):
"""Return an LXC's complete IP classification from the lifecycle cache. """Probe at most once per TTL, also noticing DHCP changes without events."""
First read per CT spawns one `lxc-info` subprocess; subsequent reads are
free until the CT's lifecycle event drops the entry via
`_invalidate_lxc_ip`. Keeping the complete result (rather than only the
primary address) lets `/api/vms` expose every address without changing
the cache's lifecycle semantics.
"""
try: try:
vmid_int = int(vmid) vmid_int = int(vmid)
except (TypeError, ValueError): except (TypeError, ValueError):
return None return None
if vmid_int in _lxc_ip_cache: # Serialize cold/expired reads so concurrent list polls share one probe.
return _lxc_ip_cache[vmid_int] with _lxc_ip_cache_lock:
info = get_lxc_ip_from_lxc_info(vmid_int) hit = _lxc_ip_cache.get(vmid_int)
_remember_lxc_ip(vmid_int, info) if hit is not None:
return info info = hit[1]
ttl = (_LXC_IP_TTL if info and any(':' not in ip for ip in info.get('real_ips') or [])
else _LXC_IP_RETRY_TTL)
if time.monotonic() - hit[0] < ttl:
return info
_lxc_ip_cache.pop(vmid_int, None)
info = get_lxc_ip_from_lxc_info(vmid_int)
_remember_lxc_ip(vmid_int, info)
return info
def _remember_lxc_ip(vmid, info): def _remember_lxc_ip(vmid, info):
"""Keep the addresses of a container until its next lifecycle event. """Keep the addresses for a short TTL or until its next lifecycle event.
A container that shows addresses but no IPv4 of its own is still waiting Missing leases and failed probes use a shorter retry TTL. Concurrent
for its lease, or lost it when it rebooted from inside: that answer is not polling still shares the result instead of spawning a probe per request.
kept, so the next read looks again instead of serving an address of its
inner bridges until the container is restarted.
""" """
if info is None or any(':' not in ip for ip in info.get('real_ips') or []): with _lxc_ip_cache_lock:
_lxc_ip_cache[vmid] = info _lxc_ip_cache[int(vmid)] = (time.monotonic(), info)
def _get_lxc_primary_ip_cached(vmid): def _get_lxc_primary_ip_cached(vmid):
@@ -619,6 +618,41 @@ def _get_lxc_primary_ip_cached(vmid):
return info.get('primary_ip') or (info.get('real_ips') or [None])[0] return info.get('primary_ip') or (info.get('real_ips') or [None])[0]
def _lxc_runtime_details(payload):
"""Keep live addresses and OS separate from the .conf-versioned payload."""
if payload.get('vm_type') != 'lxc':
return payload
result = dict(payload)
result.pop('lxc_ip_info', None)
result.pop('os_info', None)
vmid = payload['vmid']
if _fast_guest_status(vmid, 'lxc') != 'running':
return result
info = _get_lxc_ip_info_cached(vmid)
if info:
result['lxc_ip_info'] = info
os_info = _vm_cache_get(_lxc_os_info_cache, vmid, _LXC_OS_INFO_TTL)
if os_info is None:
os_info = {}
try:
probe = subprocess.run(
['pct', 'exec', str(vmid), '--', 'cat', '/etc/os-release'],
capture_output=True, text=True, timeout=5)
if probe.returncode == 0:
keys = {'ID': 'id', 'VERSION_ID': 'version_id',
'NAME': 'name', 'PRETTY_NAME': 'pretty_name'}
for line in probe.stdout.splitlines():
key, sep, value = line.partition('=')
if sep and key.strip() in keys:
os_info[keys[key.strip()]] = value.strip().strip('\"').strip("'")
except (OSError, subprocess.SubprocessError):
pass
_vm_cache_put(_lxc_os_info_cache, vmid, os_info)
if os_info:
result['os_info'] = os_info
return result
def _normalise_lxc_ips(ip_info): def _normalise_lxc_ips(ip_info):
"""Return a stable, JSON-safe ordered list of LXC addresses. """Return a stable, JSON-safe ordered list of LXC addresses.
@@ -646,7 +680,8 @@ def _invalidate_lxc_ip(vmid):
"""Drop the cached IP so the next request re-probes `lxc-info`. """Drop the cached IP so the next request re-probes `lxc-info`.
Fired from the guest lifecycle handler on start/stop/reboot.""" Fired from the guest lifecycle handler on start/stop/reboot."""
try: try:
_lxc_ip_cache.pop(int(vmid), None) with _lxc_ip_cache_lock:
_lxc_ip_cache.pop(int(vmid), None)
except (TypeError, ValueError): except (TypeError, ValueError):
pass pass
@@ -675,9 +710,8 @@ def _restore_lxc_dhcp_clients():
def _warmup_lxc_ip_cache() -> int: def _warmup_lxc_ip_cache() -> int:
"""Populate the LXC IP cache for every running CT at Monitor """Populate the LXC IP cache for every running CT at Monitor
startup. After this runs, /api/vms serves the IPs from memory startup. After this runs, /api/vms serves the IPs from memory
without spawning `lxc-info` on the request path — the cache only until the short TTL expires or a lifecycle event invalidates it.
changes when a CT's lifecycle event (start/stop/reboot) fires the Returns the count for the startup log line.
invalidator. Returns the count for the startup log line.
""" """
try: try:
result = subprocess.run( result = subprocess.run(
@@ -1815,20 +1849,21 @@ _vm_mounts_cache: dict = {} # vmid -> (ts, payload) — LXC only
_VM_LIST_SEARCH_NOTES_TTL = 30 _VM_LIST_SEARCH_NOTES_TTL = 30
_vm_list_search_notes_cache: dict = {"ts": 0.0, "signature": (), "notes": {}} _vm_list_search_notes_cache: dict = {"ts": 0.0, "signature": (), "notes": {}}
_vm_list_search_notes_lock = threading.Lock() _vm_list_search_notes_lock = threading.Lock()
# LXC IP classification cache — populated on first read, held indefinitely. # DHCP addresses can change without a lifecycle event.
# A running CT's IP doesn't change; the cache is invalidated only when _LXC_IP_TTL = 60
# the CT's lifecycle event fires (start/stop/reboot), so no periodic _LXC_IP_RETRY_TTL = 5
# polling is needed. See `_handle_guest_lifecycle`.
_lxc_ip_cache: dict = {} _lxc_ip_cache: dict = {}
_lxc_ip_cache_lock = threading.RLock()
_lxc_os_info_cache: dict = {}
_LXC_OS_INFO_TTL = 60
# Effective TTL is "indefinite": these caches are refreshed only # Effective TTL is "indefinite": these caches are refreshed only
# by explicit event-based invalidation (`_vm_cache_invalidate` calls # by explicit event-based invalidation (`_vm_cache_invalidate` calls
# on start/stop/reboot, add/edit/delete app, apply update, edit # on start/stop/reboot, add/edit/delete app, apply update, edit
# schedule, create backup). No periodic poll — the prewarmer runs # schedule, create backup). No periodic poll — the prewarmer runs
# once at startup and then stays quiet. The rationale is that all # once at startup and then stays quiet. The rationale is that all
# of these payloads are backed by files (.conf / sidecar JSON / # of these payloads are backed by files (.conf / sidecar JSON /
# storage inventory) that only change through actions the Monitor # storage inventory). Details and mounts additionally validate the .conf
# either performs itself (invalidates in-line) or that require a # signature on each read, including edits made outside Monitor. Runtime data that DOES
# guest restart (start/stop invalidates too). Runtime data that DOES
# change without an invalidation event lives in a different path: # change without an invalidation event lives in a different path:
# - Live CPU/mem/disk/network per guest → served by /api/vms (SWR # - Live CPU/mem/disk/network per guest → served by /api/vms (SWR
# poll every 2.5 s from the client, no cache here). # poll every 2.5 s from the client, no cache here).
@@ -1923,12 +1958,16 @@ def _vm_cache_put(cache: dict, vmid: int, value) -> None:
_vm_conf_built: dict = {} # (cache name, vmid) -> .conf signature _vm_conf_built: dict = {} # (cache name, vmid) -> .conf signature
def _vm_conf_signature(vmid: int): def _vm_conf_signature(vmid: int):
import hashlib
for path in (f'/etc/pve/lxc/{int(vmid)}.conf', f'/etc/pve/qemu-server/{int(vmid)}.conf'): for path in (f'/etc/pve/lxc/{int(vmid)}.conf', f'/etc/pve/qemu-server/{int(vmid)}.conf'):
try: try:
info = os.stat(path) info = os.stat(path)
with open(path, 'rb') as handle:
digest = hashlib.sha256(handle.read()).digest()
except OSError: except OSError:
continue continue
return (info.st_mtime_ns, info.st_size) # Same-second, same-size changes must also invalidate the payload.
return (path, info.st_mtime_ns, info.st_size, digest)
return None return None
def _vm_conf_cache_get(cache: dict, name: str, vmid: int, ttl: int): def _vm_conf_cache_get(cache: dict, name: str, vmid: int, ttl: int):
@@ -1938,11 +1977,11 @@ def _vm_conf_cache_get(cache: dict, name: str, vmid: int, ttl: int):
return None return None
with _vm_modal_cache_lock: with _vm_modal_cache_lock:
built = _vm_conf_built.get((name, vmid)) built = _vm_conf_built.get((name, vmid))
return cached if built == _vm_conf_signature(vmid) else None return cached if built is not None and built == _vm_conf_signature(vmid) else None
def _vm_conf_cache_put(cache: dict, name: str, vmid: int, value, signature) -> None: def _vm_conf_cache_put(cache: dict, name: str, vmid: int, value, signature) -> None:
_vm_cache_put(cache, vmid, value)
with _vm_modal_cache_lock: with _vm_modal_cache_lock:
cache[vmid] = (time.time(), value)
_vm_conf_built[(name, vmid)] = signature _vm_conf_built[(name, vmid)] = signature
def _vm_cache_invalidate(vmid: int, *caches) -> None: def _vm_cache_invalidate(vmid: int, *caches) -> None:
@@ -1953,6 +1992,7 @@ def _vm_cache_invalidate(vmid: int, *caches) -> None:
targets = caches or ( targets = caches or (
_vm_details_cache, _vm_backups_cache, _vm_details_cache, _vm_backups_cache,
_vm_app_suggestions_cache, _vm_schedule_cache, _vm_mounts_cache, _vm_app_suggestions_cache, _vm_schedule_cache, _vm_mounts_cache,
_lxc_os_info_cache,
) )
with _vm_modal_cache_lock: with _vm_modal_cache_lock:
for c in targets: for c in targets:
@@ -6635,6 +6675,7 @@ def _get_lxc_update_status_map() -> dict:
continue continue
update = it.get('update_check') or {} update = it.get('update_check') or {}
out[str(vmid)] = { out[str(vmid)] = {
**managed_installs.lxc_update_freshness(update),
'available': bool(update.get('available')), 'available': bool(update.get('available')),
'count': int(update.get('_count') or 0), 'count': int(update.get('_count') or 0),
'security_count': int(update.get('_security_count') or 0), 'security_count': int(update.get('_security_count') or 0),
@@ -13594,6 +13635,46 @@ def api_managed_installs_refresh():
return jsonify({'success': False, 'message': str(e)}), 500 return jsonify({'success': False, 'message': str(e)}), 500
_lxc_update_check_lock = threading.Lock()
_lxc_update_check_attempts: dict = {}
_lxc_update_check_inflight: set = set()
_LXC_UPDATE_CHECK_INTERVAL = 60
@app.route('/api/vms/<int:vmid>/updates/check', methods=['POST'])
@require_auth
def api_lxc_updates_check(vmid):
"""Explicit read of OS and locally known packages, with no index refresh."""
import managed_installs
if not managed_installs._lxc_updates_detection_enabled():
return jsonify({'error': 'LXC update detection is disabled'}), 403
if _detect_vm_type_local(vmid) != 'lxc':
return jsonify({'error': 'LXC not found'}), 404
if _fast_guest_status(vmid, 'lxc') != 'running':
return jsonify({'error': 'LXC is not running'}), 409
now = time.monotonic()
with _lxc_update_check_lock:
last = _lxc_update_check_attempts.get(vmid)
if vmid in _lxc_update_check_inflight or (
last is not None and now - last < _LXC_UPDATE_CHECK_INTERVAL):
retry = max(1, int(_LXC_UPDATE_CHECK_INTERVAL - (now - (last if last is not None else now))) + 1)
return jsonify({'error': 'Check already running or recently requested',
'retry_after': retry}), 429, {'Retry-After': str(retry)}
_lxc_update_check_attempts[vmid] = now
_lxc_update_check_inflight.add(vmid)
try:
item = managed_installs.refresh_lxc(vmid, refresh_package_cache=False)
if item is None:
return jsonify({'error': 'No supported running LXC detected'}), 409
_vm_cache_invalidate(vmid, _lxc_os_info_cache)
return jsonify({'vmid': vmid, 'update_check': _get_lxc_update_status_map().get(str(vmid))})
except Exception as exc:
return jsonify({'error': str(exc)}), 500
finally:
with _lxc_update_check_lock:
_lxc_update_check_inflight.discard(vmid)
# ─── LXC Update Detection toggle ──────────────────────────────────────────── # ─── LXC Update Detection toggle ────────────────────────────────────────────
# Dedicated toggle so the operator can opt out of the per-CT `pct exec apt # Dedicated toggle so the operator can opt out of the per-CT `pct exec apt
# list --upgradable` scan entirely. The Notifications section keeps its own # list --upgradable` scan entirely. The Notifications section keeps its own
@@ -15269,12 +15350,12 @@ def api_vms_modal_cache_all():
def get_vm_config(vmid): def get_vm_config(vmid):
"""Get detailed configuration for a specific VM/LXC""" """Get detailed configuration for a specific VM/LXC"""
try: try:
# Taken before reading the .conf, so an edit during the build is seen next time.
signature = _vm_conf_signature(vmid)
cached = _vm_conf_cache_get(_vm_details_cache, 'details', vmid, _VM_DETAILS_TTL) cached = _vm_conf_cache_get(_vm_details_cache, 'details', vmid, _VM_DETAILS_TTL)
if cached is not None: if cached is not None:
return jsonify(cached) return jsonify(_lxc_runtime_details(cached))
# Taken before reading the .conf, so an edit during the build is seen next time.
signature = _vm_conf_signature(vmid)
node = get_proxmox_node_name() node = get_proxmox_node_name()
# Fast local path — read .conf directly and derive status from # Fast local path — read .conf directly and derive status from
@@ -15294,8 +15375,6 @@ def get_vm_config(vmid):
if not config: if not config:
return jsonify({'error': 'VM/LXC not found'}), 404 return jsonify({'error': 'VM/LXC not found'}), 404
status = _fast_guest_status(vmid, vm_type)
response_data = { response_data = {
'vmid': vmid, 'vmid': vmid,
'config': config, 'config': config,
@@ -15303,46 +15382,17 @@ def get_vm_config(vmid):
'vm_type': vm_type, 'vm_type': vm_type,
} }
# For LXC, try to get IP from lxc-info if running
if vm_type == 'lxc' and status == 'running':
lxc_ip_info = get_lxc_ip_from_lxc_info(vmid)
if lxc_ip_info:
response_data['lxc_ip_info'] = lxc_ip_info
# Get OS information for LXC
os_info = {}
if vm_type == 'lxc' and status == 'running':
try:
os_release_result = subprocess.run(
['pct', 'exec', str(vmid), '--', 'cat', '/etc/os-release'],
capture_output=True, text=True, timeout=5)
if os_release_result.returncode == 0:
for line in os_release_result.stdout.split('\n'):
line = line.strip()
if line.startswith('ID='):
os_info['id'] = line.split('=', 1)[1].strip('"').strip("'")
elif line.startswith('VERSION_ID='):
os_info['version_id'] = line.split('=', 1)[1].strip('"').strip("'")
elif line.startswith('NAME='):
os_info['name'] = line.split('=', 1)[1].strip('"').strip("'")
elif line.startswith('PRETTY_NAME='):
os_info['pretty_name'] = line.split('=', 1)[1].strip('"').strip("'")
except Exception:
pass
# Get hardware information for LXC — already reads the .conf # Get hardware information for LXC — already reads the .conf
# directly (no pvesh), so it's cheap. # directly (no pvesh), so it's cheap.
hardware_info = {} hardware_info = {}
if vm_type == 'lxc': if vm_type == 'lxc':
hardware_info = parse_lxc_hardware_config(vmid, node) hardware_info = parse_lxc_hardware_config(vmid, node)
if os_info:
response_data['os_info'] = os_info
if hardware_info: if hardware_info:
response_data['hardware_info'] = hardware_info response_data['hardware_info'] = hardware_info
_vm_conf_cache_put(_vm_details_cache, 'details', vmid, response_data, signature) _vm_conf_cache_put(_vm_details_cache, 'details', vmid, response_data, signature)
return jsonify(response_data) return jsonify(_lxc_runtime_details(response_data))
except Exception as e: except Exception as e:
return jsonify({'error': str(e)}), 500 return jsonify({'error': str(e)}), 500
+84 -29
View File
@@ -839,16 +839,17 @@ def _infer_helper_slug(vmid: str, hostname: str) -> Optional[str]:
return _identify_helper_slug(vmid, hostname)[0] return _identify_helper_slug(vmid, hostname)[0]
def _probe_lxc_os(vmid: str) -> Optional[str]: def _probe_lxc_os(vmid: str, *, raise_on_read_error: bool = False) -> Optional[str]:
"""Return a normalized family identifier (``debian`` / ``ubuntu`` / """Return a normalized family identifier (``debian`` / ``ubuntu`` /
``alpine``) by reading ``/etc/os-release`` inside the running CT. ``alpine``) by reading ``/etc/os-release`` inside the running CT.
Returns None for distributions whose package manager we don't yet Returns None for distributions whose package manager we don't yet
speak — those CTs are skipped in detection so the framework speak — those CTs are skipped in detection so the framework
doesn't keep retrying a checker we can't run. doesn't keep retrying a checker we can't run.
Cached per CT in the registry — re-probed only when the entry has Detection may reuse the registry hint; each explicit or scheduled update
no ``_os_family`` yet, since the OS rarely changes for the life of check re-probes this file before selecting the package manager.
a CT. Strict checks distinguish an unreadable file (OSError) from a readable
unsupported OS (None); legacy detection retains its Optional contract.
""" """
try: try:
r = subprocess.run( r = subprocess.run(
@@ -857,16 +858,29 @@ def _probe_lxc_os(vmid: str) -> Optional[str]:
timeout=_LXC_OS_PROBE_TIMEOUT_SEC, timeout=_LXC_OS_PROBE_TIMEOUT_SEC,
) )
except (subprocess.TimeoutExpired, FileNotFoundError, OSError): except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
if raise_on_read_error:
raise OSError('current OS release could not be read') from None
return None return None
if r.returncode != 0: if r.returncode != 0:
if raise_on_read_error:
raise OSError('current OS release could not be read')
return None return None
text = r.stdout.lower() release = {}
if "id=ubuntu" in text: for line in r.stdout.splitlines():
key, sep, value = line.partition("=")
if sep:
release[key.strip()] = value.strip().strip('\"').strip("'").lower()
identity = release.get("ID", "")
if not identity and raise_on_read_error:
raise OSError('current OS release has no usable ID')
if identity == "ubuntu":
return "ubuntu" return "ubuntu"
if "id=debian" in text or "id_like=debian" in text: if identity == "debian":
return "debian" return "debian"
if "id=alpine" in text: if identity == "alpine":
return "alpine" return "alpine"
if "debian" in release.get("ID_LIKE", "").split():
return "debian"
# Future Phase 1.5: CentOS/Rocky/Alma (dnf check-update), Arch # Future Phase 1.5: CentOS/Rocky/Alma (dnf check-update), Arch
# (checkupdates), openSUSE (zypper list-updates). Each needs a # (checkupdates), openSUSE (zypper list-updates). Each needs a
# parser similar to apt/apk — skip silently for now. # parser similar to apt/apk — skip silently for now.
@@ -877,10 +891,9 @@ def _detect_lxc_containers(only_vmid: Optional[int] = None) -> list[dict]:
"""Enumerate running Debian/Ubuntu CTs as registry entries. """Enumerate running Debian/Ubuntu CTs as registry entries.
OS detection is cached in the registry entry (`_os_family`), so the OS detection is cached in the registry entry (`_os_family`), so the
expensive ``pct exec cat /etc/os-release`` only runs the first time expensive ``pct exec cat /etc/os-release`` only runs during detection
a CT is seen. CT reinstalls with a different OS will keep the old when a CT is first seen. The checker independently re-reads it on each
family cached until the user resets the registry — acceptable check, including after a restore or external reinstall.
trade-off vs paying the probe cost every 24h cycle.
Detection respects the dedicated `lxc_updates.detection_enabled` Detection respects the dedicated `lxc_updates.detection_enabled`
toggle (Settings → LXC Update Detection). When OFF, this returns [] toggle (Settings → LXC Update Detection). When OFF, this returns []
@@ -933,13 +946,12 @@ def _detect_lxc_containers(only_vmid: Optional[int] = None) -> list[dict]:
# future ProxMenux-shipped OCI app). # future ProxMenux-shipped OCI app).
managed_oci_app = oci_managed.get(str(vmid)) managed_oci_app = oci_managed.get(str(vmid))
# OS family is only meaningful for non-OCI CTs. We still cache # OCI/managed images have their own updater. Retain an existing OS
# it for OCI (some images ARE Ubuntu/Debian underneath and # hint, but never execute a guest OS probe for generic detection.
# future features might use it), but we don't require it.
os_family = prior.get("_os_family") os_family = prior.get("_os_family")
if not os_family: if not os_family and not is_oci and not managed_oci_app:
os_family = _probe_lxc_os(vmid) os_family = _probe_lxc_os(vmid)
if not is_oci and os_family not in _SUPPORTED_OS_FAMILIES: if os_family not in _SUPPORTED_OS_FAMILIES:
# Non-OCI, non-supported family — the framework has # Non-OCI, non-supported family — the framework has
# no way to check its updates. Skip silently. # no way to check its updates. Skip silently.
continue continue
@@ -1442,12 +1454,35 @@ def _refresh_lxc_pkg_cache_if_stale(vmid: str, family: str) -> dict:
} }
def _check_lxc_updates(entry: dict) -> dict: # The daily collector persists at cycle end. Allow a full extra day so a
# normal collection delay never briefly marks every guest's result stale.
LXC_UPDATE_STALE_AFTER_SECONDS = 48 * 3600
def lxc_update_freshness(update: dict) -> dict:
"""Describe a stored observation, without running any guest command."""
age = None
try:
checked = datetime.datetime.fromisoformat(update.get("last_check", "").replace("Z", "+00:00"))
if checked.tzinfo is None:
checked = checked.replace(tzinfo=datetime.timezone.utc)
seconds = (datetime.datetime.now(datetime.timezone.utc) - checked).total_seconds()
if seconds >= 0:
age = int(seconds)
except (ValueError, TypeError, AttributeError):
pass
stale = age is None or age >= LXC_UPDATE_STALE_AFTER_SECONDS
return {"stale": stale, "age_seconds": age,
"stale_after_seconds": LXC_UPDATE_STALE_AFTER_SECONDS}
def _check_lxc_updates(entry: dict, *, refresh_package_cache: bool = True) -> dict:
"""Inspect pending package updates inside the LXC and report them. """Inspect pending package updates inside the LXC and report them.
Dispatches to the right package-manager parser based on the cached Re-read /etc/os-release on every explicit/scheduled check, so a restore
``_os_family``. If the CT's local apt/apk metadata cache is older or external reinstall cannot keep the old registry family. If the CT's
than 24h, runs a best-effort refresh first via local apt/apk metadata cache is older than 24h, the existing collector
runs a best-effort refresh first via
``_refresh_lxc_pkg_cache_if_stale`` — without this, CTs that no ``_refresh_lxc_pkg_cache_if_stale`` — without this, CTs that no
one ever runs ``apt update`` in (long-running appliances) report one ever runs ``apt update`` in (long-running appliances) report
0 pending updates even when upstream has hundreds queued. 0 pending updates even when upstream has hundreds queued.
@@ -1458,7 +1493,6 @@ def _check_lxc_updates(entry: dict) -> dict:
update set does. update set does.
""" """
vmid = entry.get("_vmid") vmid = entry.get("_vmid")
family = (entry.get("_os_family") or "").lower()
if not vmid: if not vmid:
return { return {
"available": False, "latest": None, "available": False, "latest": None,
@@ -1483,7 +1517,26 @@ def _check_lxc_updates(entry: dict) -> dict:
"_count": 0, "_security_count": 0, "_packages": [], "_count": 0, "_security_count": 0, "_packages": [],
} }
refresh_diag = _refresh_lxc_pkg_cache_if_stale(vmid, family) try:
family = _probe_lxc_os(str(vmid), raise_on_read_error=True)
except OSError:
# Keep the last known display hint, but never choose a package
# manager from it after a failed read or report a verified zero.
return {
"available": False, "latest": None, "last_check": _now_iso(),
"error": "current OS release could not be read",
}
entry["_os_family"] = family
if family not in _SUPPORTED_OS_FAMILIES:
return {
"available": False, "latest": None, "last_check": _now_iso(),
"error": "current OS could not be determined or is unsupported",
}
# The manual read-only action must never run apt-get/apk update.
refresh_diag = (_refresh_lxc_pkg_cache_if_stale(vmid, family)
if refresh_package_cache else {})
if family in ("debian", "ubuntu"): if family in ("debian", "ubuntu"):
ok, stdout, err = _run_pct_pkg_listing( ok, stdout, err = _run_pct_pkg_listing(
@@ -1783,12 +1836,12 @@ def check_for_updates(force: bool = False) -> list[dict]:
return updates_available return updates_available
def refresh_lxc(vmid: int) -> Optional[dict]: def refresh_lxc(vmid: int, *, refresh_package_cache: bool = True) -> Optional[dict]:
"""Detect and refresh exactly one running LXC. """Detect and refresh exactly one running LXC.
This is the lifecycle counterpart of the daily collector. It is called Called by lifecycle/apply finalizers and the explicit read-only action.
after a stopped container starts and deliberately leaves every other The latter passes refresh_package_cache=False to forbid index writes.
guest's registry row untouched. Every other guest's registry row is left untouched.
""" """
try: try:
target_vmid = int(vmid) target_vmid = int(vmid)
@@ -1813,7 +1866,8 @@ def refresh_lxc(vmid: int) -> Optional[dict]:
_merge_detected_entry(target, entry, now) _merge_detected_entry(target, entry, now)
try: try:
result = _check_lxc_updates(target) result = (_check_lxc_updates(target) if refresh_package_cache else
_check_lxc_updates(target, refresh_package_cache=False))
except Exception as exc: except Exception as exc:
result = { result = {
"available": False, "available": False,
@@ -1825,5 +1879,6 @@ def refresh_lxc(vmid: int) -> Optional[dict]:
reg["items"] = items reg["items"] = items
reg["version"] = _SCHEMA_VERSION reg["version"] = _SCHEMA_VERSION
reg["last_targeted_refresh"] = now reg["last_targeted_refresh"] = now
_write_registry(reg) if not _write_registry(reg):
raise OSError("could not persist LXC update check")
return dict(target) return dict(target)
@@ -2,6 +2,7 @@
import ast import ast
import threading import threading
from unittest.mock import Mock
import unittest import unittest
from pathlib import Path from pathlib import Path
@@ -109,6 +110,10 @@ class LxcPrimaryAddressTests(unittest.TestCase):
import types import types
self.server.update({ self.server.update({
"_lxc_ip_cache": {}, "_lxc_ip_cache": {},
"_lxc_ip_cache_lock": threading.RLock(),
"_LXC_IP_TTL": 60,
"_LXC_IP_RETRY_TTL": 5,
"time": Mock(monotonic=Mock(return_value=0)),
"subprocess": types.SimpleNamespace(run=run), "subprocess": types.SimpleNamespace(run=run),
"_lxc_isolated_ips": lambda _vmid: set(), "_lxc_isolated_ips": lambda _vmid: set(),
"_lxc_shares_host_network": lambda _vmid: False, "_lxc_shares_host_network": lambda _vmid: False,
@@ -126,7 +131,8 @@ class LxcPrimaryAddressTests(unittest.TestCase):
def test_an_answer_without_ipv4_of_its_own_is_read_again(self): def test_an_answer_without_ipv4_of_its_own_is_read_again(self):
self.read("172.30.232.1\n172.30.32.1\nfd0c:ac1e:2100::1\n") self.read("172.30.232.1\n172.30.32.1\nfd0c:ac1e:2100::1\n")
self.assertNotIn(121, self.server["_lxc_ip_cache"]) self.assertIn(121, self.server["_lxc_ip_cache"])
self.server["time"].monotonic.return_value = 5
self.assertEqual(self.read("192.168.0.100\n172.30.32.1\n")["primary_ip"], "192.168.0.100") self.assertEqual(self.read("192.168.0.100\n172.30.32.1\n")["primary_ip"], "192.168.0.100")
self.assertIn(121, self.server["_lxc_ip_cache"]) self.assertIn(121, self.server["_lxc_ip_cache"])
+14 -2
View File
@@ -1,5 +1,6 @@
"""The VM/LXC modal caches notice a .conf edited outside the Monitor.""" """The VM/LXC modal caches notice a .conf edited outside the Monitor."""
import ast import ast
import io
from pathlib import Path from pathlib import Path
import threading import threading
import time import time
@@ -16,6 +17,7 @@ class ConfAwareCacheTests(unittest.TestCase):
nodes = [n for n in tree.body if isinstance(n, ast.FunctionDef) and n.name in NAMES] nodes = [n for n in tree.body if isinstance(n, ast.FunctionDef) and n.name in NAMES]
self.assertEqual(sorted(n.name for n in nodes), sorted(NAMES)) self.assertEqual(sorted(n.name for n in nodes), sorted(NAMES))
self.files = {'/etc/pve/lxc/111.conf': (1, 300)} self.files = {'/etc/pve/lxc/111.conf': (1, 300)}
self.contents = {}
def stat(path): def stat(path):
if path not in self.files: if path not in self.files:
@@ -24,7 +26,7 @@ class ConfAwareCacheTests(unittest.TestCase):
return SimpleNamespace(st_mtime_ns=mtime, st_size=size) return SimpleNamespace(st_mtime_ns=mtime, st_size=size)
self.scope = {'os': SimpleNamespace(stat=stat), 'time': time, '_vm_modal_cache_lock': threading.Lock(), self.scope = {'os': SimpleNamespace(stat=stat), 'time': time, '_vm_modal_cache_lock': threading.Lock(),
'_vm_conf_built': {}} '_vm_conf_built': {}, 'open': lambda path, _: io.BytesIO(self.contents.get(path, b'onboot: 0'))}
exec(compile(ast.Module(body=nodes, type_ignores=[]), str(SERVER), 'exec'), self.scope) exec(compile(ast.Module(body=nodes, type_ignores=[]), str(SERVER), 'exec'), self.scope)
self.cache = {} self.cache = {}
@@ -50,9 +52,19 @@ class ConfAwareCacheTests(unittest.TestCase):
self.scope['_vm_cache_put'](self.cache, 111, {'old': True}) self.scope['_vm_cache_put'](self.cache, 111, {'old': True})
self.assertIsNone(self.get()) self.assertIsNone(self.get())
def test_same_size_same_mtime_content_edit_is_detected(self):
self.build({'onboot': 0})
self.contents['/etc/pve/lxc/111.conf'] = b'onboot: 1'
self.assertIsNone(self.get())
def test_deleted_configuration_is_never_served_from_cache(self):
self.build({'onboot': 0})
self.files.clear()
self.assertIsNone(self.get())
def test_vm_conf_path_and_missing_guest(self): def test_vm_conf_path_and_missing_guest(self):
self.files = {'/etc/pve/qemu-server/200.conf': (5, 10)} self.files = {'/etc/pve/qemu-server/200.conf': (5, 10)}
self.assertEqual(self.scope['_vm_conf_signature'](200), (5, 10)) self.assertEqual(self.scope['_vm_conf_signature'](200)[1:3], (5, 10))
self.assertIsNone(self.scope['_vm_conf_signature'](999)) self.assertIsNone(self.scope['_vm_conf_signature'](999))
+441
View File
@@ -0,0 +1,441 @@
"""Actual Flask routes and registry writes, with only PVE transports mocked.
Run with Flask installed. AST loading avoids starting Monitor's host workers;
route decorators remain intact, authentication is a fixture passthrough.
"""
import ast
import builtins
from concurrent.futures import ThreadPoolExecutor
import datetime
import os
from pathlib import Path
import subprocess
import sys
import tempfile
import threading
from types import SimpleNamespace
import unittest
from unittest.mock import Mock, patch
from flask import Flask, jsonify
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "AppImage/scripts"))
import managed_installs as managed
SERVER = ROOT / "AppImage/scripts/flask_server.py"
FUNCTIONS = {
"_get_lxc_ip_info_cached", "_remember_lxc_ip", "_invalidate_lxc_ip",
"_get_lxc_primary_ip_cached", "_normalise_lxc_ips", "_lxc_runtime_details",
"_vm_cache_get", "_vm_cache_put", "_vm_conf_signature",
"_vm_conf_cache_get", "_vm_conf_cache_put", "_vm_cache_invalidate",
"_read_pve_conf_fast", "_detect_vm_type_local", "_get_lxc_update_status_map",
"get_proxmox_vms", "api_vms", "get_vm_config", "api_lxc_updates_check",
"api_managed_installs_refresh", "api_managed_installs_get",
"api_vms_modal_cache_all", "api_lxc_mount_points",
}
GLOBALS = {
"_NUMERIC_CONF_KEYS", "_LXC_IP_TTL", "_LXC_IP_RETRY_TTL", "_lxc_ip_cache",
"_lxc_ip_cache_lock", "_lxc_os_info_cache", "_LXC_OS_INFO_TTL",
"_vm_details_cache", "_vm_modal_cache_lock", "_vm_conf_built",
"_VM_CACHE_INDEFINITE", "_VM_DETAILS_TTL", "_lxc_update_check_lock",
"_lxc_update_check_attempts", "_lxc_update_check_inflight",
"_LXC_UPDATE_CHECK_INTERVAL",
"_vm_backups_cache", "_VM_BACKUPS_TTL", "_vm_mounts_cache", "_VM_MOUNTS_TTL",
"_vm_app_suggestions_cache", "_VM_APP_SUGGESTIONS_TTL", "_vm_schedule_cache", "_VM_SCHEDULE_TTL",
}
class ObservedStateRoutesTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.conf = Path(self.temp.name) / "100.conf"
self.conf.write_text("onboot: 0\nnet0: name=eth0,ip=dhcp\ntags: old\n")
self.clock = Mock(time=Mock(return_value=1000), monotonic=Mock(return_value=1000))
real_open = builtins.open
def mapped(path):
if str(path) == "/etc/pve/lxc/100.conf":
return self.conf
if str(path).startswith("/etc/pve/"):
return Path(self.temp.name) / "missing"
return path
self.app = Flask(__name__)
self.scope = {
"app": self.app, "jsonify": jsonify, "require_auth": lambda fn: fn,
"time": self.clock, "threading": threading,
"os": SimpleNamespace(stat=lambda p: os.stat(mapped(p)),
path=SimpleNamespace(exists=lambda p: os.path.exists(mapped(p)))),
"open": lambda p, *a, **kw: real_open(mapped(p), *a, **kw),
"subprocess": subprocess,
}
nodes = []
for node in ast.parse(SERVER.read_text()).body:
if isinstance(node, ast.FunctionDef) and node.name in FUNCTIONS:
nodes.append(node)
elif isinstance(node, (ast.Assign, ast.AnnAssign)):
targets = node.targets if isinstance(node, ast.Assign) else [node.target]
if any(isinstance(t, ast.Name) and t.id in GLOBALS for t in targets):
nodes.append(node)
self.assertEqual({n.name for n in nodes if isinstance(n, ast.FunctionDef)}, FUNCTIONS)
exec(compile(ast.Module(body=nodes, type_ignores=[]), str(SERVER), "exec"), self.scope)
self.running = True
self.ip = "10.100.100.222"
self.release = 'ID="debian"\nVERSION_ID="13.7"\nPRETTY_NAME="Debian GNU/Linux 13"\n'
self.ip_probe = Mock(side_effect=lambda _id: {
"primary_ip": self.ip, "real_ips": [self.ip],
"all_ips": [self.ip, self.ip, "172.17.0.1"], "docker_ips": ["172.17.0.1"],
})
self.scope.update({
"get_lxc_ip_from_lxc_info": self.ip_probe,
"get_proxmox_node_name": lambda: "fixture-pve",
"_fast_guest_status": lambda *_: "running" if self.running else "stopped",
"_get_cached_vm_list_search_notes": lambda *_: {},
"_get_lxc_app_watch_map": lambda: {},
"_get_lxc_docker_inventory_map": lambda: {},
"get_cached_vm_disk": lambda _: None,
"_guest_lifecycle_lock": threading.RLock(),
"_guest_modal_cache_revision": {}, "_guest_modal_cache_epoch": 1,
"parse_lxc_hardware_config": lambda *_: {"privileged": False},
"get_cached_pvesh_cluster_resources_vm": lambda: [
{"type": "lxc", "node": "fixture-pve", "vmid": 100, "name": "dev-trixie",
"status": "running" if self.running else "stopped"},
],
})
self.commands = []
def run(command, **_):
self.commands.append(command)
if command[-2:] == ["cat", "/etc/os-release"]:
return SimpleNamespace(returncode=0, stdout=self.release, stderr="")
if command[-1] == "apt list --upgradable 2>/dev/null":
return SimpleNamespace(returncode=0, stdout="Listing...\n", stderr="")
raise AssertionError(f"Unexpected guest command: {command}")
for ctx in (
patch.object(subprocess, "run", side_effect=run),
patch.object(managed, "_DB_DIR", self.temp.name),
patch.object(managed, "_REGISTRY_PATH", str(Path(self.temp.name) / "registry.json")),
patch.object(managed, "_lxc_updates_detection_enabled", return_value=True),
patch.object(managed, "_list_pve_lxcs", return_value=[
{"vmid": "100", "name": "dev-trixie", "status": "running"},
]),
patch.object(managed, "_get_oci_managed_vmids", return_value={}),
patch.object(managed, "_probe_lxc_is_oci", return_value=False),
patch.object(managed, "_identify_helper_slug", return_value=(None, None)),
):
ctx.start()
self.addCleanup(ctx.stop)
managed._write_registry({"items": [
{"id": "lxc:100", "type": "lxc", "_vmid": "100", "_os_family": "ubuntu",
"update_check": {"available": True, "_count": 51, "_security_count": 24,
"_packages": [{"name": "old"}], "last_check": "2020-01-01T00:00:00Z"}},
{"id": "other", "type": "unhandled", "update_check": {"_count": 7}},
]})
self.client = self.app.test_client()
def row(self):
response = self.client.get("/api/vms")
self.assertEqual(response.status_code, 200, response.json)
return response.json[0]
def test_dhcp_change_after_ttl_without_lifecycle_and_api_compatibility(self):
first = self.row()
self.assertEqual(first["ip"], "10.100.100.222")
self.assertEqual(first["ips"], ["10.100.100.222", "172.17.0.1"])
self.ip = "10.100.100.220"
self.clock.monotonic.return_value = 1059
self.assertEqual(self.row()["ip"], "10.100.100.222")
self.ip_probe.assert_called_once_with(100)
self.clock.monotonic.return_value = 1060
current = self.row()
self.assertEqual(current["ip"], "10.100.100.220")
self.assertEqual(current["ips"], ["10.100.100.220", "172.17.0.1"])
self.assertEqual(self.ip_probe.call_count, 2)
self.assertEqual(self.commands, [])
def test_repeated_list_get_is_read_only_and_does_not_refresh_registry(self):
before = managed.get_registry()
for _ in range(10):
self.assertTrue(self.row()["update_check"]["stale"])
self.assertEqual(managed.get_registry(), before)
self.assertEqual(self.commands, [])
self.ip_probe.assert_called_once()
def test_concurrent_expired_ip_reads_share_one_probe(self):
self.scope["_get_lxc_ip_info_cached"](100)
self.clock.monotonic.return_value += 60
self.ip = "10.100.100.220"
with ThreadPoolExecutor(max_workers=8) as pool:
values = list(pool.map(self.scope["_get_lxc_primary_ip_cached"], [100] * 16))
self.assertEqual(set(values), {self.ip})
self.assertEqual(self.ip_probe.call_count, 2)
def test_package_listing_failure_stores_error_without_reusing_old_count(self):
with patch.object(managed, "_run_pct_pkg_listing", return_value=(False, "", "APT metadata unreadable")):
response = self.client.post("/api/vms/100/updates/check")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json["update_check"]["error"], "APT metadata unreadable")
self.assertNotIn("_count", managed.get_registry()["items"][0]["update_check"])
def test_onboot_network_and_tags_external_edit_rebuilds_next_detail(self):
first = self.client.get("/api/vms/100").json
self.assertEqual(first["config"]["onboot"], 0)
# Ensure distinct metadata even on coarse temporary filesystems.
self.conf.write_text("onboot: 1\nnet0: name=eth0,ip=dhcp,bridge=vmbr1\ntags: new;test\n")
os.utime(self.conf, ns=(2_000_000_000, 2_000_000_000))
current = self.client.get("/api/vms/100")
self.assertEqual(current.status_code, 200, current.json)
self.assertEqual(current.json["config"]["onboot"], 1)
self.assertEqual(current.json["config"]["tags"], "new;test")
self.assertIn("vmbr1", current.json["config"]["net0"])
self.assertEqual(current.json["vm_type"], "lxc")
self.assertIn("digest", current.json["config"])
self.assertEqual(current.json["lxc_ip_info"]["primary_ip"], self.ip)
self.assertEqual(current.json["os_info"]["version_id"], "13.7")
def test_runtime_ip_and_os_expire_even_with_unchanged_conf(self):
self.release = 'ID=ubuntu\nVERSION_ID=24.04\n'
self.assertEqual(self.client.get("/api/vms/100").json["os_info"]["id"], "ubuntu")
self.release = 'ID=debian\nVERSION_ID=13.7\n'
self.ip = "10.100.100.220"
self.clock.time.return_value += 60
self.clock.monotonic.return_value += 60
current = self.client.get("/api/vms/100").json
self.assertEqual(current["os_info"]["id"], "debian")
self.assertEqual(current["lxc_ip_info"]["primary_ip"], self.ip)
self.assertEqual(current["config"]["onboot"], 0)
def test_same_size_same_mtime_onboot_edit_is_visible_through_route(self):
self.assertEqual(self.client.get("/api/vms/100").json["config"]["onboot"], 0)
old = self.conf.stat()
self.conf.write_text(self.conf.read_text().replace("onboot: 0", "onboot: 1"))
os.utime(self.conf, ns=(old.st_atime_ns, old.st_mtime_ns))
self.assertEqual(self.conf.stat().st_size, old.st_size)
self.assertEqual(self.conf.stat().st_mtime_ns, old.st_mtime_ns)
self.assertEqual(self.client.get("/api/vms/100").json["config"]["onboot"], 1)
def test_bulk_cache_consumer_rejects_changed_config_without_live_probes(self):
import lxc_apps
self.client.get("/api/vms/100")
count = len(self.commands)
with patch.object(lxc_apps, "load_sidecar", return_value={"vmid": 100, "apps": []}):
response = self.client.get("/api/vms/modal-cache-all")
self.assertEqual(response.status_code, 200, response.json)
self.assertEqual(response.json["guests"][0]["details"]["config"]["onboot"], 0)
self.conf.write_text("onboot: 1\n")
response = self.client.get("/api/vms/modal-cache-all")
self.assertIsNone(response.json["guests"][0]["details"])
self.assertEqual(len(self.commands), count, "bulk cache remains a snapshot-only reader")
self.assertEqual(self.client.get("/api/vms/100").json["config"]["onboot"], 1)
def test_mount_cache_consumer_rebuilds_after_external_config_change(self):
provider = Mock(return_value={"ok": True, "mount_points": [{"target": "/old"}]})
with patch.dict(sys.modules, {"lxc_mount_points": SimpleNamespace(get_lxc_mount_points_static=provider)}):
path = "/api/lxc/100/mount-points"
self.assertEqual(self.client.get(path).json["mount_points"][0]["target"], "/old")
self.client.get(path)
provider.assert_called_once_with("100")
self.conf.write_text("onboot: 0\nmp0: local:100/vm-100-disk-1.raw,mp=/new\n")
provider.return_value = {"ok": True, "mount_points": [{"target": "/new"}]}
self.assertEqual(self.client.get(path).json["mount_points"][0]["target"], "/new")
self.assertEqual(provider.call_count, 2)
def test_read_only_post_replaces_ubuntu_51_with_debian_zero(self):
response = self.client.post("/api/vms/100/updates/check")
self.assertEqual(response.status_code, 200, response.json)
uc = response.json["update_check"]
self.assertEqual(uc["os_family"], "debian")
self.assertEqual((uc["count"], uc["security_count"], uc["packages"]), (0, 0, []))
self.assertFalse(uc["available"])
self.assertFalse(uc["stale"])
self.assertIsNone(uc["error"])
reg = managed.get_registry()
self.assertEqual(reg["items"][0]["_os_family"], "debian")
self.assertEqual(reg["items"][1]["update_check"]["_count"], 7)
self.assertEqual(self.row()["update_check"], uc)
self.assertEqual(len(self.commands), 2)
self.assertIn("apt list --upgradable", self.commands[1][-1])
self.assertFalse(any("apt-get" in str(cmd) or "upgrade" in str(cmd) and "upgradable" not in str(cmd)
for cmd in self.commands))
def test_per_guest_cooldown_and_inflight(self):
self.assertEqual(self.client.post("/api/vms/100/updates/check").status_code, 200)
count = len(self.commands)
denied = self.client.post("/api/vms/100/updates/check")
self.assertEqual(denied.status_code, 429)
self.assertGreater(int(denied.headers["Retry-After"]), 0)
self.assertEqual(len(self.commands), count)
self.clock.monotonic.return_value += 60
self.scope["_lxc_update_check_inflight"].add(100)
self.assertEqual(self.client.post("/api/vms/100/updates/check").status_code, 429)
self.scope["_lxc_update_check_inflight"].clear()
self.assertEqual(self.client.post("/api/vms/100/updates/check").status_code, 200)
def test_disabled_stopped_missing_and_get_action_are_rejected(self):
with patch.object(managed, "_lxc_updates_detection_enabled", return_value=False):
self.assertEqual(self.client.post("/api/vms/100/updates/check").status_code, 403)
self.running = False
self.assertEqual(self.client.post("/api/vms/100/updates/check").status_code, 409)
self.assertEqual(self.row()["ips"], [])
self.assertNotIn("os_info", self.client.get("/api/vms/100").json)
self.assertEqual(self.client.post("/api/vms/999/updates/check").status_code, 404)
self.assertEqual(self.client.get("/api/vms/100/updates/check").status_code, 405)
self.assertEqual(self.commands, [])
def test_readable_unsupported_os_replaces_old_family_without_claiming_zero(self):
self.release = "ID=unsupported\n"
response = self.client.post("/api/vms/100/updates/check")
self.assertEqual(response.status_code, 200)
self.assertIsNone(response.json["update_check"]["os_family"])
self.assertTrue(response.json["update_check"]["error"])
self.assertNotIn("_count", managed.get_registry()["items"][0]["update_check"])
self.assertEqual(len(self.commands), 1)
def test_unreadable_os_keeps_last_known_family_but_clears_old_update_count(self):
failures = [subprocess.TimeoutExpired('pct', 5), OSError('transport failed'),
SimpleNamespace(returncode=1, stdout='', stderr='read failed'),
SimpleNamespace(returncode=0, stdout='', stderr='')]
for failure in failures:
with self.subTest(failure=failure):
self.clock.monotonic.return_value += 60
with patch.object(subprocess, 'run') as run:
if isinstance(failure, Exception):
run.side_effect = failure
else:
run.return_value = failure
response = self.client.post('/api/vms/100/updates/check')
self.assertEqual(response.status_code, 200, response.json)
self.assertEqual(response.json['update_check']['os_family'], 'ubuntu')
self.assertTrue(response.json['update_check']['error'])
stored = managed.get_registry()['items'][0]
self.assertEqual(stored['_os_family'], 'ubuntu')
self.assertNotIn('_count', stored['update_check'])
run.assert_called_once()
def test_daily_and_global_refresh_keep_os_hint_on_read_failure(self):
for global_route in (False, True):
with self.subTest(global_route=global_route), \
patch.object(subprocess, 'run', side_effect=OSError('unreadable')), \
patch.object(managed, '_DETECTORS', [managed._detect_lxc_containers]), \
patch.object(managed, '_refresh_lxc_pkg_cache_if_stale') as refresh:
if global_route:
self.assertEqual(self.client.post('/api/managed-installs/refresh').status_code, 200)
else:
managed.check_for_updates()
stored = managed.get_registry()['items'][0]
self.assertEqual(stored['_os_family'], 'ubuntu')
self.assertTrue(stored['update_check']['error'])
self.assertNotIn('_count', stored['update_check'])
refresh.assert_not_called()
def test_legacy_os_detection_returns_none_on_transport_failure(self):
with patch.object(subprocess, 'run', side_effect=OSError('unreadable')):
self.assertIsNone(managed._probe_lxc_os('100'))
def test_new_oci_detection_and_check_never_probe_os(self):
managed._write_registry({'items': []})
for is_oci, membership in ((True, {}), (False, {'100': 'secure-gateway'})):
with self.subTest(is_oci=is_oci), \
patch.object(managed, '_probe_lxc_is_oci', return_value=is_oci), \
patch.object(managed, '_get_oci_managed_vmids', return_value=membership), \
patch.object(managed, '_probe_lxc_os', side_effect=AssertionError('OCI OS probe forbidden')):
entry = managed._detect_lxc_containers(only_vmid=100)[0]
self.assertIsNone(entry['_os_family'])
self.assertEqual(managed._check_lxc_updates(entry)['_count'], 0)
def test_registry_write_failure_is_reported_and_releases_inflight(self):
with patch.object(managed, "_write_registry", return_value=False):
self.assertEqual(self.client.post("/api/vms/100/updates/check").status_code, 500)
self.assertNotIn(100, self.scope["_lxc_update_check_inflight"])
self.assertEqual(managed.get_registry()["items"][0]["_os_family"], "ubuntu")
def test_daily_checker_reprobes_os_and_preserves_existing_index_refresh(self):
with patch.object(managed, "_refresh_lxc_pkg_cache_if_stale", return_value={}) as refresh:
managed.check_for_updates()
refresh.assert_called_once_with("100", "debian")
self.assertEqual(managed.get_registry()["items"][0]["_os_family"], "debian")
self.assertEqual(self.row()["update_check"]["count"], 0)
def test_existing_global_refresh_route_keeps_its_collector_contract(self):
with patch.object(managed, "_DETECTORS", [managed._detect_lxc_containers]), \
patch.object(managed, "_refresh_lxc_pkg_cache_if_stale", return_value={}) as refresh:
response = self.client.post("/api/managed-installs/refresh")
self.assertEqual(response.status_code, 200, response.json)
refresh.assert_called_once_with("100", "debian")
self.assertEqual(managed.get_registry()["items"][0]["_os_family"], "debian")
def test_existing_lifecycle_refresh_keeps_index_refresh_default(self):
with patch.object(managed, "_refresh_lxc_pkg_cache_if_stale", return_value={}) as refresh:
item = managed.refresh_lxc(100)
refresh.assert_called_once_with("100", "debian")
self.assertEqual(item["update_check"]["_count"], 0)
def test_other_lxc_has_independent_ip_cache_and_manual_rate_limit(self):
conf200 = Path(self.temp.name) / "200.conf"
conf200.write_text("onboot: 1\n")
original_open = self.scope["open"]
self.scope["open"] = lambda path, *args, **kw: (
builtins.open(conf200, *args, **kw) if path == "/etc/pve/lxc/200.conf"
else original_open(path, *args, **kw))
original_exists = self.scope["os"].path.exists
self.scope["os"].path.exists = lambda path: path == "/etc/pve/lxc/200.conf" or original_exists(path)
self.assertEqual(self.client.post("/api/vms/100/updates/check").status_code, 200)
with patch.object(managed, "_list_pve_lxcs", return_value=[
{"vmid": "200", "name": "other-ct", "status": "running"}]):
response = self.client.post("/api/vms/200/updates/check")
self.assertEqual(response.status_code, 200, response.json)
self.assertEqual(response.json["vmid"], 200)
self.assertEqual(response.json["update_check"]["count"], 0)
self.scope["_get_lxc_ip_info_cached"](100)
self.scope["_get_lxc_ip_info_cached"](200)
self.assertEqual(set(self.scope["_lxc_ip_cache"]), {100, 200})
self.assertEqual(self.client.post("/api/vms/100/updates/check").status_code, 429)
def test_oci_and_managed_apps_never_run_generic_package_check(self):
for flags in ({"_is_oci": True}, {"_managed_oci_app": "secure-gateway"}):
with self.subTest(flags=flags):
entry = {"_vmid": "100", "_os_family": "ubuntu", **flags}
before = len(self.commands)
result = managed._check_lxc_updates(entry, refresh_package_cache=False)
self.assertEqual(result["_count"], 0)
self.assertEqual(entry["_os_family"], "ubuntu")
self.assertEqual(len(self.commands) - before, 0)
def test_lifecycle_invalidation_still_forces_probe_and_negative_cache_is_bounded(self):
self.row()
self.ip = "10.100.100.220"
self.scope["_invalidate_lxc_ip"]("100")
self.assertEqual(self.row()["ip"], self.ip)
self.ip_probe.return_value = None
self.ip_probe.side_effect = None
self.scope["_invalidate_lxc_ip"](100)
for _ in range(5):
self.assertNotIn("ip", self.row())
self.assertEqual(self.ip_probe.call_count, 3)
self.clock.monotonic.return_value += 5
self.row()
self.assertEqual(self.ip_probe.call_count, 4)
class FreshnessTests(unittest.TestCase):
def test_old_missing_invalid_and_future_timestamps_are_stale(self):
for stamp in (None, "", "bad", "2020-01-01T00:00:00Z", "2999-01-01T00:00:00Z"):
self.assertTrue(managed.lxc_update_freshness({"last_check": stamp})["stale"])
def test_recent_check_and_threshold(self):
now = datetime.datetime.now(datetime.timezone.utc)
with patch.object(managed.datetime, 'datetime', wraps=datetime.datetime) as clock:
clock.now.return_value = now
for age, stale in ((60, False), (86400 + 600, False), (172799, False),
(172800, True), (172801, True)):
observation = {"last_check": (now - datetime.timedelta(seconds=age)).isoformat()}
result = managed.lxc_update_freshness(observation)
self.assertEqual(result["stale"], stale)
self.assertEqual(result["stale_after_seconds"], 172800)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,151 @@
// Execute actual UI helpers, button handler and badge render with fake I/O.
const assert = require("node:assert/strict")
const fs = require("node:fs")
const path = require("node:path")
const vm = require("node:vm")
const root = path.resolve(__dirname, "../../AppImage")
const ts = require(path.join(root, "node_modules/typescript"))
function compile(source, context) {
const result = ts.transpileModule(source, { compilerOptions: {
module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020, jsx: ts.JsxEmit.ReactJSX,
}, reportDiagnostics: true })
assert.equal(result.diagnostics.length, 0)
vm.runInNewContext(result.outputText, context)
return context.exports
}
const requests = []
const helpers = compile(fs.readFileSync(path.join(root, "lib/lxc-update-observation.ts"), "utf8"), {
exports: {}, require: () => ({ fetchApi: async (...args) => { requests.push(args); return {} } }),
})
assert.equal(requests.length, 0, "loading helpers never schedules a package check")
const now = Date.parse("2026-10-13T12:00:00Z")
const recent = { last_check: "2026-10-13T11:59:00Z" }
assert.equal(helpers.isLxcUpdateStale(recent, now), false)
for (const value of [undefined, {}, { last_check: "bad" }, { last_check: "2026-10-14T00:00:00Z" },
{ last_check: "2026-10-11T12:00:00Z" }, { ...recent, stale: true }]) {
assert.equal(helpers.isLxcUpdateStale(value, now), true)
}
for (const age of [86400, 86400 + 600, 172799]) {
assert.equal(helpers.isLxcUpdateStale({ last_check: new Date(now - age * 1000).toISOString() }, now), false,
'daily checks have a full extra day of freshness margin')
}
assert.equal(helpers.isLxcUpdateStale({ ...recent, stale_after_seconds: 60 }, now), true,
'the backend-provided threshold still takes precedence')
for (const lang of ['en', 'sk']) {
const catalog = JSON.parse(fs.readFileSync(path.join(root, `messages/${lang}/common.json`), 'utf8'))
assert.match(catalog.vmLxc.updates.checkStaleHelp, /48/)
}
const source = fs.readFileSync(path.join(root, "components/virtual-machines.tsx"), "utf8")
const tree = ts.createSourceFile("vm.tsx", source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
assert.equal(tree.parseDiagnostics.length, 0)
const wanted = new Set(["checkOsUpdates", "getAggregateUpdateCheck", "renderLxcUpdateBadge"])
const definitions = []
const status = []
let detailEffect
function walk(node) {
if (ts.isCallExpression(node) && node.expression.getText(tree) === "useEffect"
&& node.arguments[1]?.getText(tree) === "[selectedVM?.vmid]") {
detailEffect = node.arguments[0].getText(tree)
}
if (ts.isVariableDeclaration(node)) {
const name = node.name.getText(tree)
if (wanted.has(name)) definitions.push(`const ${node.getText(tree)};`)
if (["osUpdateStale", "osUpdateStatusKnown", "hasOsUpdates"].includes(name))
status.push(`const ${node.getText(tree)};`)
}
ts.forEachChild(node, walk)
}
walk(tree)
assert.equal(definitions.length, 3)
assert.equal(status.length, 3)
assert.ok(detailEffect)
let checking = null
let displayedError = null
let details = null
let errorToThrow = null
let polls = 0
const context = {
exports: {},
require: () => ({ jsx: (type, props) => ({ type, props }), jsxs: (type, props) => ({ type, props }) }),
Badge: "Badge", Clock: "Clock", ArrowUpCircle: "Arrow", ChevronRight: "Chevron",
t: (key, args) => key + (args ? JSON.stringify(args) : ""),
isLxcUpdateStale: o => helpers.isLxcUpdateStale(o, now),
checkLxcUpdates: async id => {
if (errorToThrow) throw errorToThrow
return helpers.checkLxcUpdates(id)
},
fetchApi: async endpoint => { requests.push([endpoint]); return { config: { onboot: 1 }, os_info: { id: "debian" } } },
mutate: async () => { polls++ },
selectedVMRef: { current: { vmid: 100 } },
selectedVM: { vmid: 100 },
setInterval: (callback, delay) => { context.detailTimer = { callback, delay }; return 1 },
clearInterval: id => { context.clearedTimer = id },
vmModalCacheRef: { current: { details: new Map() } },
setVMDetails: value => { details = value },
setOsCheckingVmid: value => { checking = value },
setOsCheckError: value => { displayedError = value },
}
const ui = compile(definitions.join("\n") + `
exports.check = checkOsUpdates;
exports.aggregate = getAggregateUpdateCheck;
exports.badge = renderLxcUpdateBadge;
exports.osStatus = (uc) => { ${status.join("\n")} return { osUpdateStatusKnown, hasOsUpdates }; };
exports.revalidateDetails = ${detailEffect};
`, context)
const legacy = { last_check: "2026-10-12T00:00:00Z", available: true, count: 51, stale: true }
assert.equal(ui.osStatus(legacy).osUpdateStatusKnown, false, "old count must not claim live OS status")
assert.equal(ui.osStatus({ ...recent, available: false, count: 0 }).osUpdateStatusKnown, true)
assert.equal(ui.osStatus({ ...recent, error: "probe failed" }).osUpdateStatusKnown, false)
assert.equal(ui.aggregate({ app_watches: [{ update_available: true, name: "App" }] }).last_check, null,
"an app badge cannot invent an OS check timestamp")
assert.equal(ui.badge(legacy).props.children[0].type, "Clock")
assert.equal(ui.badge(legacy).props.children[1], "vmLxc.updates.checkStale")
assert.equal(ui.badge({ ...recent, available: true, count: 2 }).props.children[1], 2)
assert.equal(ui.badge({ ...recent, available: false, count: 0 }), null)
assert.equal(requests.length, 0)
;(async () => {
await ui.check(100)
assert.equal(requests[0][0], "/api/vms/100/updates/check")
assert.equal(requests[0][1].method, "POST")
assert.equal(requests[1][0], "/api/vms/100")
assert.equal(polls, 1)
assert.equal(details.os_info.id, "debian")
assert.equal(checking, null)
const before = requests.length
errorToThrow = { status: 429, body: { retry_after: 60 } }
await ui.check(100)
assert.match(displayedError, /checkCooldown.*60/)
assert.equal(checking, null)
assert.equal(requests.length, before)
assert.equal(polls, 1)
// Switching guests while a request runs must not replace another modal.
errorToThrow = null
context.selectedVMRef.current = { vmid: 200 }
details = null
await ui.check(100)
assert.equal(details, null)
errorToThrow = { status: 429, body: { retry_after: 60 } }
displayedError = null
await ui.check(100)
assert.equal(displayedError, null, "errors from the previous guest cannot leak into another modal")
const beforeTimer = requests.length
const cleanup = ui.revalidateDetails()
assert.equal(context.detailTimer.delay, 30000)
assert.equal(requests.length, beforeTimer, "mounting the detail effect does not check packages")
await context.detailTimer.callback()
assert.equal(requests.length, beforeTimer + 1)
assert.equal(requests.at(-1)[0], "/api/vms/100")
cleanup()
assert.equal(context.clearedTimer, 1)
for (const locale of ["en", "sk"]) {
const messages = JSON.parse(fs.readFileSync(path.join(root, `messages/${locale}/common.json`))).vmLxc.updates
for (const key of ["checkNow", "checkReadOnlyHelp", "checkCooldown", "checkStale", "checkStaleHelp"])
assert.ok(messages[key]?.trim())
assert.match(messages.checkCooldown, /\{seconds\}/)
}
console.log("PASS observed-state UI: freshness, badges, safe button, cooldown, modal isolation, EN/SK")
})().catch(error => { console.error(error); process.exitCode = 1 })