fix: revalidate externally changed LXC state

This commit is contained in:
VAIO73
2026-10-13 17:08:42 +00:00
parent ea5831d1bf
commit e83e66caaf
11 changed files with 839 additions and 101 deletions
@@ -202,6 +202,13 @@ class CommandDescriptionsTests(unittest.TestCase):
key, english["backup"]["destinations"][key])
messages["backup"]["actions"].setdefault(
"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))
before = {p: p.read_bytes() for p in root.glob("*/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 { fetchApi } from "../lib/api-config"
import { matchesVmSearch, normalizeVmSearchValue } from "../lib/vm-search"
import { checkLxcUpdates, isLxcUpdateStale } from "../lib/lxc-update-observation"
import DOMPurify from "dompurify"
import { marked } from "marked"
import { getCountFormKey, useI18n, useT } from "@/lib/i18n/provider"
@@ -84,6 +85,9 @@ interface LxcUpdateCheck {
helper_app_name?: string | null
helper_updateable_known?: boolean
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
@@ -912,6 +916,8 @@ export function VirtualMachines() {
// "settled" when the observed count differs from this baseline
// (or after a 15 s safety timeout).
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 [updatesBaselineCount, setUpdatesBaselineCount] = useState<number | null>(null)
const selectedVMRef = useRef<VMData | null>(null)
@@ -1188,6 +1194,31 @@ export function VirtualMachines() {
selectedVMRef.current = 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(() => {
updatesBaselineCountRef.current = updatesBaselineCount
}, [updatesBaselineCount])
@@ -1239,6 +1270,7 @@ export function VirtualMachines() {
const handleVMClick = async (vm: VMData) => {
setSelectedVM(vm)
setOsCheckError(null)
setBulkLoaded(null)
setBulkConfigured(false)
setBulkTargets(["os"])
@@ -2647,6 +2679,26 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
// packages" section needs a clean `available`/`count` to avoid a
// false "N package pending" every time a registered app has an
// 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 uc = vm.update_check
const appCount = (vm.app_watches || []).filter(
@@ -2702,7 +2754,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
count: total,
available: true,
packages: [...(uc?.packages || []), ...pendingNames],
last_check: uc?.last_check ?? new Date().toISOString(),
last_check: uc?.last_check ?? null,
} as LxcUpdateCheck
}
@@ -2711,7 +2763,9 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
compact = false,
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
? 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,
IP and other chips compete for width. Count alone with
the up-arrow reads unambiguously as "pending updates". */}
<ArrowUpCircle className={iconSize} />
{uc.count}
{stale ? <Clock className={iconSize} /> : <ArrowUpCircle className={iconSize} />}
{stale ? t("vmLxc.updates.checkStale") : uc.count}
{/* Chevron only when the badge is wired up as a clickable
shortcut — its absence on the dashboard card avoids
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?.is_oci_lxc && (() => {
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 dockerAppWatch = (selectedVM.app_watches || []).find((a) => a.helper_slug === "docker")
const dockerRegistered = !!dockerAppWatch
@@ -5925,6 +5980,15 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
<div className="flex items-center gap-2 mb-3">
<Package className="h-4 w-4 text-muted-foreground" />
<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 className="text-xs text-muted-foreground mb-3 leading-relaxed">
{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></>
)}
</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
className="text-sm text-muted-foreground flex items-center gap-2"
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 ?? 86400) * 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": {
"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 24 hours or has no valid check date. Run a new check before relying on it.",
"lastCheckedPrefix": "Last checked:",
"familyLabel": "Family:",
"applyOsUpdate": "Apply OS update",
+5
View File
@@ -1334,6 +1334,11 @@
"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.",
"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ž 24 hodín alebo nemá platný dátum kontroly. Pred použitím spustite novú kontrolu.",
"lastCheckedPrefix": "Naposledy skontrolované:",
"familyLabel": "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):
"""Return an LXC's complete IP classification from the lifecycle cache.
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.
"""
"""Probe at most once per TTL, also noticing DHCP changes without events."""
try:
vmid_int = int(vmid)
except (TypeError, ValueError):
return None
if vmid_int in _lxc_ip_cache:
return _lxc_ip_cache[vmid_int]
info = get_lxc_ip_from_lxc_info(vmid_int)
_remember_lxc_ip(vmid_int, info)
return info
# Serialize cold/expired reads so concurrent list polls share one probe.
with _lxc_ip_cache_lock:
hit = _lxc_ip_cache.get(vmid_int)
if hit is not None:
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):
"""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
for its lease, or lost it when it rebooted from inside: that answer is not
kept, so the next read looks again instead of serving an address of its
inner bridges until the container is restarted.
Missing leases and failed probes use a shorter retry TTL. Concurrent
polling still shares the result instead of spawning a probe per request.
"""
if info is None or any(':' not in ip for ip in info.get('real_ips') or []):
_lxc_ip_cache[vmid] = info
with _lxc_ip_cache_lock:
_lxc_ip_cache[int(vmid)] = (time.monotonic(), info)
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]
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):
"""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`.
Fired from the guest lifecycle handler on start/stop/reboot."""
try:
_lxc_ip_cache.pop(int(vmid), None)
with _lxc_ip_cache_lock:
_lxc_ip_cache.pop(int(vmid), None)
except (TypeError, ValueError):
pass
@@ -675,9 +710,8 @@ def _restore_lxc_dhcp_clients():
def _warmup_lxc_ip_cache() -> int:
"""Populate the LXC IP cache for every running CT at Monitor
startup. After this runs, /api/vms serves the IPs from memory
without spawning `lxc-info` on the request path — the cache only
changes when a CT's lifecycle event (start/stop/reboot) fires the
invalidator. Returns the count for the startup log line.
until the short TTL expires or a lifecycle event invalidates it.
Returns the count for the startup log line.
"""
try:
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_cache: dict = {"ts": 0.0, "signature": (), "notes": {}}
_vm_list_search_notes_lock = threading.Lock()
# LXC IP classification cache — populated on first read, held indefinitely.
# A running CT's IP doesn't change; the cache is invalidated only when
# the CT's lifecycle event fires (start/stop/reboot), so no periodic
# polling is needed. See `_handle_guest_lifecycle`.
# DHCP addresses can change without a lifecycle event.
_LXC_IP_TTL = 60
_LXC_IP_RETRY_TTL = 5
_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
# by explicit event-based invalidation (`_vm_cache_invalidate` calls
# on start/stop/reboot, add/edit/delete app, apply update, edit
# schedule, create backup). No periodic poll — the prewarmer runs
# once at startup and then stays quiet. The rationale is that all
# of these payloads are backed by files (.conf / sidecar JSON /
# storage inventory) that only change through actions the Monitor
# either performs itself (invalidates in-line) or that require a
# guest restart (start/stop invalidates too). Runtime data that DOES
# storage inventory). Details and mounts additionally validate the .conf
# signature on each read, including edits made outside Monitor. Runtime data that DOES
# change without an invalidation event lives in a different path:
# - Live CPU/mem/disk/network per guest → served by /api/vms (SWR
# 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
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'):
try:
info = os.stat(path)
with open(path, 'rb') as handle:
digest = hashlib.sha256(handle.read()).digest()
except OSError:
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
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
with _vm_modal_cache_lock:
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:
_vm_cache_put(cache, vmid, value)
with _vm_modal_cache_lock:
cache[vmid] = (time.time(), value)
_vm_conf_built[(name, vmid)] = signature
def _vm_cache_invalidate(vmid: int, *caches) -> None:
@@ -1953,6 +1992,7 @@ def _vm_cache_invalidate(vmid: int, *caches) -> None:
targets = caches or (
_vm_details_cache, _vm_backups_cache,
_vm_app_suggestions_cache, _vm_schedule_cache, _vm_mounts_cache,
_lxc_os_info_cache,
)
with _vm_modal_cache_lock:
for c in targets:
@@ -6635,6 +6675,7 @@ def _get_lxc_update_status_map() -> dict:
continue
update = it.get('update_check') or {}
out[str(vmid)] = {
**managed_installs.lxc_update_freshness(update),
'available': bool(update.get('available')),
'count': int(update.get('_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
_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 ────────────────────────────────────────────
# 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
@@ -15269,12 +15350,12 @@ def api_vms_modal_cache_all():
def get_vm_config(vmid):
"""Get detailed configuration for a specific VM/LXC"""
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)
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()
# Fast local path — read .conf directly and derive status from
@@ -15294,8 +15375,6 @@ def get_vm_config(vmid):
if not config:
return jsonify({'error': 'VM/LXC not found'}), 404
status = _fast_guest_status(vmid, vm_type)
response_data = {
'vmid': vmid,
'config': config,
@@ -15303,46 +15382,17 @@ def get_vm_config(vmid):
'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
# directly (no pvesh), so it's cheap.
hardware_info = {}
if vm_type == 'lxc':
hardware_info = parse_lxc_hardware_config(vmid, node)
if os_info:
response_data['os_info'] = os_info
if hardware_info:
response_data['hardware_info'] = hardware_info
_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:
return jsonify({'error': str(e)}), 500
+61 -23
View File
@@ -846,9 +846,8 @@ def _probe_lxc_os(vmid: str) -> Optional[str]:
speak — those CTs are skipped in detection so the framework
doesn't keep retrying a checker we can't run.
Cached per CT in the registry — re-probed only when the entry has
no ``_os_family`` yet, since the OS rarely changes for the life of
a CT.
Detection may reuse the registry hint; each explicit or scheduled update
check re-probes this file before selecting the package manager.
"""
try:
r = subprocess.run(
@@ -860,13 +859,20 @@ def _probe_lxc_os(vmid: str) -> Optional[str]:
return None
if r.returncode != 0:
return None
text = r.stdout.lower()
if "id=ubuntu" in text:
release = {}
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 identity == "ubuntu":
return "ubuntu"
if "id=debian" in text or "id_like=debian" in text:
if identity == "debian":
return "debian"
if "id=alpine" in text:
if identity == "alpine":
return "alpine"
if "debian" in release.get("ID_LIKE", "").split():
return "debian"
# Future Phase 1.5: CentOS/Rocky/Alma (dnf check-update), Arch
# (checkupdates), openSUSE (zypper list-updates). Each needs a
# parser similar to apt/apk — skip silently for now.
@@ -877,10 +883,9 @@ def _detect_lxc_containers(only_vmid: Optional[int] = None) -> list[dict]:
"""Enumerate running Debian/Ubuntu CTs as registry entries.
OS detection is cached in the registry entry (`_os_family`), so the
expensive ``pct exec cat /etc/os-release`` only runs the first time
a CT is seen. CT reinstalls with a different OS will keep the old
family cached until the user resets the registry — acceptable
trade-off vs paying the probe cost every 24h cycle.
expensive ``pct exec cat /etc/os-release`` only runs during detection
when a CT is first seen. The checker independently re-reads it on each
check, including after a restore or external reinstall.
Detection respects the dedicated `lxc_updates.detection_enabled`
toggle (Settings → LXC Update Detection). When OFF, this returns []
@@ -1442,12 +1447,33 @@ def _refresh_lxc_pkg_cache_if_stale(vmid: str, family: str) -> dict:
}
def _check_lxc_updates(entry: dict) -> dict:
LXC_UPDATE_STALE_AFTER_SECONDS = 24 * 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.
Dispatches to the right package-manager parser based on the cached
``_os_family``. If the CT's local apt/apk metadata cache is older
than 24h, runs a best-effort refresh first via
Re-read /etc/os-release on every explicit/scheduled check, so a restore
or external reinstall cannot keep the old registry family. If the CT's
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
one ever runs ``apt update`` in (long-running appliances) report
0 pending updates even when upstream has hundreds queued.
@@ -1458,13 +1484,15 @@ def _check_lxc_updates(entry: dict) -> dict:
update set does.
"""
vmid = entry.get("_vmid")
family = (entry.get("_os_family") or "").lower()
if not vmid:
return {
"available": False, "latest": None,
"last_check": _now_iso(), "error": "no vmid in entry",
}
family = _probe_lxc_os(str(vmid))
entry["_os_family"] = family
# OCI-image CTs are immutable by design — apt/apk upgrade inside
# them is the wrong workflow (update = rebuild from a newer image
# tag). Skip the package-manager probe entirely so the UI doesn't
@@ -1483,7 +1511,15 @@ def _check_lxc_updates(entry: dict) -> dict:
"_count": 0, "_security_count": 0, "_packages": [],
}
refresh_diag = _refresh_lxc_pkg_cache_if_stale(vmid, 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"):
ok, stdout, err = _run_pct_pkg_listing(
@@ -1783,12 +1819,12 @@ def check_for_updates(force: bool = False) -> list[dict]:
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.
This is the lifecycle counterpart of the daily collector. It is called
after a stopped container starts and deliberately leaves every other
guest's registry row untouched.
Called by lifecycle/apply finalizers and the explicit read-only action.
The latter passes refresh_package_cache=False to forbid index writes.
Every other guest's registry row is left untouched.
"""
try:
target_vmid = int(vmid)
@@ -1813,7 +1849,8 @@ def refresh_lxc(vmid: int) -> Optional[dict]:
_merge_detected_entry(target, entry, now)
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:
result = {
"available": False,
@@ -1825,5 +1862,6 @@ def refresh_lxc(vmid: int) -> Optional[dict]:
reg["items"] = items
reg["version"] = _SCHEMA_VERSION
reg["last_targeted_refresh"] = now
_write_registry(reg)
if not _write_registry(reg):
raise OSError("could not persist LXC update check")
return dict(target)
@@ -2,6 +2,7 @@
import ast
import threading
from unittest.mock import Mock
import unittest
from pathlib import Path
@@ -109,6 +110,10 @@ class LxcPrimaryAddressTests(unittest.TestCase):
import types
self.server.update({
"_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),
"_lxc_isolated_ips": lambda _vmid: set(),
"_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):
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.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."""
import ast
import io
from pathlib import Path
import threading
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]
self.assertEqual(sorted(n.name for n in nodes), sorted(NAMES))
self.files = {'/etc/pve/lxc/111.conf': (1, 300)}
self.contents = {}
def stat(path):
if path not in self.files:
@@ -24,7 +26,7 @@ class ConfAwareCacheTests(unittest.TestCase):
return SimpleNamespace(st_mtime_ns=mtime, st_size=size)
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)
self.cache = {}
@@ -50,9 +52,19 @@ class ConfAwareCacheTests(unittest.TestCase):
self.scope['_vm_cache_put'](self.cache, 111, {'old': True})
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):
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))
+386
View File
@@ -0,0 +1,386 @@
"""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_failed_os_probe_does_not_keep_old_family_or_claim_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_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"], "debian")
self.assertEqual(len(self.commands) - before, 1)
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)
for age, stale in ((60, False), (86401, 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"], 86400)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,141 @@
// 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-12T12:00:00Z" }, { ...recent, stale: true }]) {
assert.equal(helpers.isLxcUpdateStale(value, now), true)
}
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 })