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] 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)