From e83e66caaf384d2efc58a9a84cadf4846dfb6c73 Mon Sep 17 00:00:00 2001 From: VAIO73 <50487331+Vaso73@users.noreply.github.com> Date: Tue, 13 Oct 2026 12:56:00 +0000 Subject: [PATCH 1/2] fix: revalidate externally changed LXC state --- .../tests/test_command_descriptions.py | 7 + AppImage/components/virtual-machines.tsx | 79 +++- AppImage/lib/lxc-update-observation.ts | 21 + AppImage/messages/en/common.json | 5 + AppImage/messages/sk/common.json | 5 + AppImage/scripts/flask_server.py | 188 +++++---- AppImage/scripts/managed_installs.py | 84 ++-- .../scripts/tests/test_lxc_vm_ip_inventory.py | 8 +- AppImage/scripts/tests/test_vm_conf_cache.py | 16 +- tests/lxc_updates/test_observed_state.py | 386 ++++++++++++++++++ tests/lxc_updates/test_observed_state_ui.cjs | 141 +++++++ 11 files changed, 839 insertions(+), 101 deletions(-) create mode 100644 AppImage/lib/lxc-update-observation.ts create mode 100644 tests/lxc_updates/test_observed_state.py create mode 100644 tests/lxc_updates/test_observed_state_ui.cjs diff --git a/.github/scripts/tests/test_command_descriptions.py b/.github/scripts/tests/test_command_descriptions.py index cccdd326..2b870c85 100644 --- a/.github/scripts/tests/test_command_descriptions.py +++ b/.github/scripts/tests/test_command_descriptions.py @@ -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"), diff --git a/AppImage/components/virtual-machines.tsx b/AppImage/components/virtual-machines.tsx index b56cebc2..a0660d18 100644 --- a/AppImage/components/virtual-machines.tsx +++ b/AppImage/components/virtual-machines.tsx @@ -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(null) + const [osCheckError, setOsCheckError] = useState(null) const [updatesResult, setUpdatesResult] = useState<{ pendingAfter: number; appliedCount: number } | null>(null) const [updatesBaselineCount, setUpdatesBaselineCount] = useState(null) const selectedVMRef = useRef(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(`/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(`/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". */} - - {uc.count} + {stale ? : } + {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) => {

{t("vmLxc.updates.osPackagesTitle")}

+
{t("vmLxc.updates.lastCheckedPrefix")}{" "} @@ -5933,7 +5997,10 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { <> · {t("vmLxc.updates.familyLabel")} {uc.os_family} )}
- {!osUpdateStatusKnown ? ( + {osCheckError &&

{osCheckError}

} + {osUpdateStale && !uc?.error ? ( +

{t("vmLxc.updates.checkStaleHelp")}

+ ) : !osUpdateStatusKnown ? (
= 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(vmid: number): Promise { + return fetchApi(`/api/vms/${vmid}/updates/check`, { method: "POST" }) +} diff --git a/AppImage/messages/en/common.json b/AppImage/messages/en/common.json index d10c8b25..7160d135 100644 --- a/AppImage/messages/en/common.json +++ b/AppImage/messages/en/common.json @@ -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", diff --git a/AppImage/messages/sk/common.json b/AppImage/messages/sk/common.json index 2bebde16..6ba5e5fc 100644 --- a/AppImage/messages/sk/common.json +++ b/AppImage/messages/sk/common.json @@ -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", diff --git a/AppImage/scripts/flask_server.py b/AppImage/scripts/flask_server.py index 5f6ba83b..284f7f3f 100644 --- a/AppImage/scripts/flask_server.py +++ b/AppImage/scripts/flask_server.py @@ -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//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 diff --git a/AppImage/scripts/managed_installs.py b/AppImage/scripts/managed_installs.py index 6d2bc323..d0a223e5 100644 --- a/AppImage/scripts/managed_installs.py +++ b/AppImage/scripts/managed_installs.py @@ -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) diff --git a/AppImage/scripts/tests/test_lxc_vm_ip_inventory.py b/AppImage/scripts/tests/test_lxc_vm_ip_inventory.py index 5975bf59..e41dd989 100644 --- a/AppImage/scripts/tests/test_lxc_vm_ip_inventory.py +++ b/AppImage/scripts/tests/test_lxc_vm_ip_inventory.py @@ -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"]) diff --git a/AppImage/scripts/tests/test_vm_conf_cache.py b/AppImage/scripts/tests/test_vm_conf_cache.py index 32c1a11b..471031da 100644 --- a/AppImage/scripts/tests/test_vm_conf_cache.py +++ b/AppImage/scripts/tests/test_vm_conf_cache.py @@ -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)) diff --git a/tests/lxc_updates/test_observed_state.py b/tests/lxc_updates/test_observed_state.py new file mode 100644 index 00000000..4b60e242 --- /dev/null +++ b/tests/lxc_updates/test_observed_state.py @@ -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() diff --git a/tests/lxc_updates/test_observed_state_ui.cjs b/tests/lxc_updates/test_observed_state_ui.cjs new file mode 100644 index 00000000..58469363 --- /dev/null +++ b/tests/lxc_updates/test_observed_state_ui.cjs @@ -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 }) From 63f1e631b98535ed5c7357b9ff3853a04485687c Mon Sep 17 00:00:00 2001 From: VAIO73 <50487331+Vaso73@users.noreply.github.com> Date: Tue, 13 Oct 2026 17:11:43 +0000 Subject: [PATCH 2/2] fix: preserve LXC OS hints and allow daily check margin --- AppImage/lib/lxc-update-observation.ts | 2 +- AppImage/messages/en/common.json | 2 +- AppImage/messages/sk/common.json | 2 +- AppImage/scripts/managed_installs.py | 37 +++++++--- tests/lxc_updates/test_observed_state.py | 71 +++++++++++++++++--- tests/lxc_updates/test_observed_state_ui.cjs | 12 +++- 6 files changed, 104 insertions(+), 22 deletions(-) diff --git a/AppImage/lib/lxc-update-observation.ts b/AppImage/lib/lxc-update-observation.ts index a4b5d19b..6b19d02d 100644 --- a/AppImage/lib/lxc-update-observation.ts +++ b/AppImage/lib/lxc-update-observation.ts @@ -10,7 +10,7 @@ export function isLxcUpdateStale(observation?: UpdateObservation, now = Date.now 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 + const limit = (observation.stale_after_seconds ?? 172800) * 1000 return !Number.isFinite(checked) || age < 0 || age >= limit } diff --git a/AppImage/messages/en/common.json b/AppImage/messages/en/common.json index 7160d135..d2a012a0 100644 --- a/AppImage/messages/en/common.json +++ b/AppImage/messages/en/common.json @@ -1336,7 +1336,7 @@ "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.", + "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:", "familyLabel": "Family:", "applyOsUpdate": "Apply OS update", diff --git a/AppImage/messages/sk/common.json b/AppImage/messages/sk/common.json index 6ba5e5fc..28ec0e6f 100644 --- a/AppImage/messages/sk/common.json +++ b/AppImage/messages/sk/common.json @@ -1338,7 +1338,7 @@ "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.", + "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é:", "familyLabel": "Systém:", "applyOsUpdate": "Aktualizovať systém", diff --git a/AppImage/scripts/managed_installs.py b/AppImage/scripts/managed_installs.py index d0a223e5..a54707ec 100644 --- a/AppImage/scripts/managed_installs.py +++ b/AppImage/scripts/managed_installs.py @@ -839,7 +839,7 @@ def _infer_helper_slug(vmid: str, hostname: str) -> Optional[str]: 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`` / ``alpine``) by reading ``/etc/os-release`` inside the running CT. Returns None for distributions whose package manager we don't yet @@ -848,6 +848,8 @@ def _probe_lxc_os(vmid: str) -> Optional[str]: Detection may reuse the registry hint; each explicit or scheduled update check re-probes this file before selecting the package manager. + Strict checks distinguish an unreadable file (OSError) from a readable + unsupported OS (None); legacy detection retains its Optional contract. """ try: r = subprocess.run( @@ -856,8 +858,12 @@ def _probe_lxc_os(vmid: str) -> Optional[str]: timeout=_LXC_OS_PROBE_TIMEOUT_SEC, ) except (subprocess.TimeoutExpired, FileNotFoundError, OSError): + if raise_on_read_error: + raise OSError('current OS release could not be read') from None return None if r.returncode != 0: + if raise_on_read_error: + raise OSError('current OS release could not be read') return None release = {} for line in r.stdout.splitlines(): @@ -865,6 +871,8 @@ def _probe_lxc_os(vmid: str) -> Optional[str]: 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" if identity == "debian": @@ -938,13 +946,12 @@ def _detect_lxc_containers(only_vmid: Optional[int] = None) -> list[dict]: # future ProxMenux-shipped OCI app). managed_oci_app = oci_managed.get(str(vmid)) - # OS family is only meaningful for non-OCI CTs. We still cache - # it for OCI (some images ARE Ubuntu/Debian underneath and - # future features might use it), but we don't require it. + # OCI/managed images have their own updater. Retain an existing OS + # hint, but never execute a guest OS probe for generic detection. 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) - 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 # no way to check its updates. Skip silently. continue @@ -1447,7 +1454,9 @@ def _refresh_lxc_pkg_cache_if_stale(vmid: str, family: str) -> dict: } -LXC_UPDATE_STALE_AFTER_SECONDS = 24 * 3600 +# 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: @@ -1490,9 +1499,6 @@ def _check_lxc_updates(entry: dict, *, refresh_package_cache: bool = True) -> di "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 @@ -1511,6 +1517,17 @@ def _check_lxc_updates(entry: dict, *, refresh_package_cache: bool = True) -> di "_count": 0, "_security_count": 0, "_packages": [], } + 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(), diff --git a/tests/lxc_updates/test_observed_state.py b/tests/lxc_updates/test_observed_state.py index 4b60e242..9df042fc 100644 --- a/tests/lxc_updates/test_observed_state.py +++ b/tests/lxc_updates/test_observed_state.py @@ -285,7 +285,7 @@ class ObservedStateRoutesTests(unittest.TestCase): 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): + 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) @@ -294,6 +294,58 @@ class ObservedStateRoutesTests(unittest.TestCase): 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) @@ -349,8 +401,8 @@ class ObservedStateRoutesTests(unittest.TestCase): 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) + 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() @@ -375,11 +427,14 @@ class FreshnessTests(unittest.TestCase): 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) + 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__": diff --git a/tests/lxc_updates/test_observed_state_ui.cjs b/tests/lxc_updates/test_observed_state_ui.cjs index 58469363..020d866d 100644 --- a/tests/lxc_updates/test_observed_state_ui.cjs +++ b/tests/lxc_updates/test_observed_state_ui.cjs @@ -24,9 +24,19 @@ 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 }]) { + { 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)