mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-01 10:22:08 +00:00
fix: preserve LXC OS hints and allow daily check margin
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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__":
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user