fix: require running LXC for package checks

This commit is contained in:
VAIO73
2026-10-15 03:49:58 +00:00
parent 664ef981e5
commit 18236e06bc
8 changed files with 64 additions and 83 deletions
+6 -1
View File
@@ -6048,7 +6048,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
<h3 className="text-sm font-semibold text-foreground">{t("vmLxc.updates.osPackagesTitle")}</h3> <h3 className="text-sm font-semibold text-foreground">{t("vmLxc.updates.osPackagesTitle")}</h3>
<Button <Button
size="sm" variant="outline" className="ml-auto" size="sm" variant="outline" className="ml-auto"
disabled={osCheckingVmid !== null || updatesRefreshing || controlLoading || applyOpen} disabled={selectedVM.status !== "running" || osCheckingVmid !== null || updatesRefreshing || controlLoading || applyOpen}
title={t("vmLxc.updates.refreshCheckHelp")} title={t("vmLxc.updates.refreshCheckHelp")}
onClick={() => checkOsUpdates(selectedVM.vmid)} onClick={() => checkOsUpdates(selectedVM.vmid)}
> >
@@ -6056,6 +6056,11 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
{t("vmLxc.updates.checkSystemPackages")} {t("vmLxc.updates.checkSystemPackages")}
</Button> </Button>
</div> </div>
{selectedVM.status !== "running" && (
<p className="text-sm text-muted-foreground mb-3">
{t("vmLxc.updates.startBeforeCheck")}
</p>
)}
<div className="text-xs text-muted-foreground mb-3 leading-relaxed"> <div className="text-xs text-muted-foreground mb-3 leading-relaxed">
{t("vmLxc.updates.lastCheckedPrefix")}{" "} {t("vmLxc.updates.lastCheckedPrefix")}{" "}
{uc?.last_check ? new Date(uc.last_check).toLocaleString() : "—"} {uc?.last_check ? new Date(uc.last_check).toLocaleString() : "—"}
+2 -2
View File
@@ -31,8 +31,8 @@ export function checkLxcUpdates<T>(vmid: number): Promise<T> {
return fetchApi<T>(`/api/vms/${vmid}/updates/check`, { method: "POST" }) return fetchApi<T>(`/api/vms/${vmid}/updates/check`, { method: "POST" })
} }
// Separate explicit action: refresh indexes, check only, restore stopped CT. // Explicit action: refresh indexes and check an already-running CT. The
// The backend owns the job even if the browser closes or navigates away. // backend owns the job even if the browser closes or navigates away.
export async function refreshCheckLxcUpdates<T>(vmid: number): Promise<T> { export async function refreshCheckLxcUpdates<T>(vmid: number): Promise<T> {
const job = await fetchApi<{ job_id: string }>(`/api/vms/${vmid}/updates/refresh-check`, { method: "POST" }) const job = await fetchApi<{ job_id: string }>(`/api/vms/${vmid}/updates/refresh-check`, { method: "POST" })
const deadline = Date.now() + 8 * 60_000 const deadline = Date.now() + 8 * 60_000
+2 -2
View File
@@ -1335,9 +1335,9 @@
"checkNow": "Check updates", "checkNow": "Check updates",
"checkSystemPackages": "Check system packages", "checkSystemPackages": "Check system packages",
"checkInProgress": "Check in progress", "checkInProgress": "Check in progress",
"refreshCheckHelp": "Refreshes package indexes and checks pending updates. Nothing is installed. A stopped container is temporarily started and stopped again after the check.", "refreshCheckHelp": "Refreshes package indexes and checks pending updates while the container is running. Nothing is installed.",
"notCheckedYet": "Updates have not been checked yet.", "notCheckedYet": "Updates have not been checked yet.",
"checkStillRunning": "The check is still running. The monitor will restore the container's original state when it finishes.", "checkStillRunning": "The check is still running. Its result will be saved when it finishes.",
"checkBeforeUpdate": "Check updates first", "checkBeforeUpdate": "Check updates first",
"startBeforeCheck": "Start the container, then check for updates. No badge does not mean there are no updates.", "startBeforeCheck": "Start the container, then check for updates. No badge does not mean there are no updates.",
"checkReadOnlyHelp": "Read current OS and pending packages from existing indexes. No installation or package index refresh.", "checkReadOnlyHelp": "Read current OS and pending packages from existing indexes. No installation or package index refresh.",
+2 -2
View File
@@ -1337,9 +1337,9 @@
"checkNow": "Skontrolovať aktualizácie", "checkNow": "Skontrolovať aktualizácie",
"checkSystemPackages": "Skontrolovať balíky systému", "checkSystemPackages": "Skontrolovať balíky systému",
"checkInProgress": "Prebieha kontrola", "checkInProgress": "Prebieha kontrola",
"refreshCheckHelp": "Obnoví zoznamy balíkov a skontroluje čakajúce aktualizácie. Nič nenainštaluje. Vypnutý kontajner sa dočasne zapne a po kontrole sa opäť vypne.", "refreshCheckHelp": "Obnoví zoznamy balíkov a skontroluje čakajúce aktualizácie, kým je kontajner spustený. Nič nenainštaluje.",
"notCheckedYet": "Aktualizácie zatiaľ nie sú skontrolované.", "notCheckedYet": "Aktualizácie zatiaľ nie sú skontrolované.",
"checkStillRunning": "Kontrola ešte prebieha. Monitor po jej dokončení obnoví pôvodný stav kontajnera.", "checkStillRunning": "Kontrola ešte prebieha. Po dokončení sa uloží jej výsledok.",
"checkBeforeUpdate": "Najprv skontrolovať aktualizácie", "checkBeforeUpdate": "Najprv skontrolovať aktualizácie",
"startBeforeCheck": "Spustite kontajner a potom skontrolujte aktualizácie. Chýbajúci odznak neznamená, že nie sú žiadne aktualizácie.", "startBeforeCheck": "Spustite kontajner a potom skontrolujte aktualizácie. Chýbajúci odznak neznamená, že nie sú žiadne aktualizácie.",
"checkReadOnlyHelp": "Zistí aktuálny systém a čakajúce balíky podľa existujúcich zoznamov. Nič nenainštaluje ani neobnoví zoznamy balíkov.", "checkReadOnlyHelp": "Zistí aktuálny systém a čakajúce balíky podľa existujúcich zoznamov. Nič nenainštaluje ani neobnoví zoznamy balíkov.",
+1 -1
View File
@@ -13697,7 +13697,7 @@ def api_lxc_updates_check(vmid):
@app.route('/api/vms/<int:vmid>/updates/refresh-check', methods=['POST']) @app.route('/api/vms/<int:vmid>/updates/refresh-check', methods=['POST'])
@require_auth @require_auth
def api_lxc_updates_refresh_check(vmid): def api_lxc_updates_refresh_check(vmid):
"""Explicit index refresh/check; never installs. Worker owns restoration.""" """Explicit index refresh/check for a running CT; never installs."""
import lxc_check_job import lxc_check_job
try: try:
result, code = lxc_check_job.start(vmid) result, code = lxc_check_job.start(vmid)
+15 -34
View File
@@ -1,7 +1,7 @@
"""Explicit check-only jobs; the shared updater lock protects each CT. """Explicit check-only jobs; the shared updater lock protects each CT.
Importing or reading a job never probes a guest. Only start() may launch work. Importing or reading a job never probes a guest. Only start() may launch work
Package observations are saved before restoring a temporarily started guest. for an already-running CT; package indexes are never refreshed by starting one.
""" """
import datetime import datetime
import fcntl import fcntl
@@ -99,51 +99,30 @@ def checked_os(vmid):
def run(vmid, job_id, reservation): def run(vmid, job_id, reservation):
restore = False
error = None error = None
check_started = False
invalid = LOCK_DIR / f'proxmenux-lxc-check-invalid-{int(vmid)}' invalid = LOCK_DIR / f'proxmenux-lxc-check-invalid-{int(vmid)}'
try: try:
invalid.touch(mode=0o600) invalid.touch(mode=0o600)
guard(vmid) guard(vmid)
original = status(vmid) if status(vmid) != 'running':
if original == 'stopped': raise RuntimeError('Container is stopped; start it first')
# Set before start: a timed-out command can still have started CT. check_started = True
restore = True
command(vmid, 'start', timeout=60)
deadline = time.monotonic() + 60
while True:
try:
command(vmid, 'exec', '--', 'true', timeout=5)
break
except (RuntimeError, subprocess.TimeoutExpired):
if time.monotonic() >= deadline:
raise RuntimeError('Container did not become ready')
time.sleep(1)
observation = lxc_update_observation.capture(vmid, job_id, refresh_indexes=True) observation = lxc_update_observation.capture(vmid, job_id, refresh_indexes=True)
if not managed_installs.store_lxc_update_observation(vmid, observation): if not managed_installs.store_lxc_update_observation(vmid, observation):
raise RuntimeError(observation['result'].get('error') or 'Package check failed') raise RuntimeError(observation['result'].get('error') or 'Package check failed')
lxc_update_observation.discard(vmid, job_id) lxc_update_observation.discard(vmid, job_id)
except Exception as exc: except Exception as exc:
error = str(exc) error = str(exc)
# Preflight/start failures must revoke a previous positive observation. # A failed package operation must block installation. A state change
try: # before the worker runs did not check or alter packages, so keep the
managed_installs.store_lxc_update_observation(vmid, None) # last verified observation but leave the invalid marker in place.
except Exception: if check_started:
error += '; package result could not be persisted'
finally:
if restore:
try: try:
if status(vmid) != 'stopped': managed_installs.store_lxc_update_observation(vmid, None)
command(vmid, 'shutdown', '--timeout', '30', timeout=40)
if status(vmid) != 'stopped':
raise RuntimeError('Container is still running')
except Exception: except Exception:
try: error += '; package result could not be persisted'
command(vmid, 'stop', timeout=30) finally:
if status(vmid) != 'stopped':
raise RuntimeError('Container is still running')
except Exception:
error = (error + '; ' if error else '') + 'Original stopped state could not be restored'
if not error: if not error:
try: try:
invalid.unlink() invalid.unlink()
@@ -167,6 +146,8 @@ def start(vmid):
reservation = acquire(vmid) reservation = acquire(vmid)
try: try:
guard(vmid) guard(vmid)
if status(vmid) != 'running':
raise RuntimeError('Container is stopped; start it first')
job_id = uuid.uuid4().hex job_id = uuid.uuid4().hex
_jobs[job_id] = dict(vmid=vmid, state='running', error=None, created=now) _jobs[job_id] = dict(vmid=vmid, state='running', error=None, created=now)
_attempts[vmid] = now _attempts[vmid] = now
+32 -40
View File
@@ -63,14 +63,17 @@ class CheckOnlyTests(unittest.TestCase):
job.run(100, 'run', reservation) job.run(100, 'run', reservation)
return job.get(100, 'run'), capture, store return job.get(100, 'run'), capture, store
def test_stopped_check_restores_and_never_installs(self): def test_stopped_check_is_rejected_before_a_job_or_guest_mutation(self):
result, capture, store = self.run_check() with patch.object(job, 'command', side_effect=self.transport):
self.assertEqual(result['state'], 'complete') with self.assertRaisesRegex(RuntimeError, 'start it first'):
job.start(100)
self.assertEqual(self.state, 'stopped') self.assertEqual(self.state, 'stopped')
self.assertEqual(self.calls, ['status', 'start', 'exec', 'status', 'shutdown', 'status']) self.assertEqual(self.calls, ['status'])
capture.assert_called_once_with(100, 'run', refresh_indexes=True) self.assertEqual(job._jobs, {})
store.assert_called_once_with(100, self.result) self.assertEqual(job._attempts, {})
self.assertFalse((self.folder / 'proxmenux-lxc-check-invalid-100').exists()) self.assertFalse((self.folder / 'proxmenux-lxc-check-invalid-100').exists())
reservation = job.acquire(100)
reservation.close()
def test_running_check_has_no_lifecycle_effect(self): def test_running_check_has_no_lifecycle_effect(self):
self.state = 'running' self.state = 'running'
@@ -78,29 +81,36 @@ class CheckOnlyTests(unittest.TestCase):
self.assertEqual(result['state'], 'complete') self.assertEqual(result['state'], 'complete')
self.assertEqual(self.calls, ['status']) self.assertEqual(self.calls, ['status'])
self.assertEqual(self.state, 'running') self.assertEqual(self.state, 'running')
self.assertFalse((self.folder / 'proxmenux-lxc-check-invalid-100').exists())
def test_index_read_timeout_and_persistence_errors_restore_stopped(self): def test_successful_running_check_clears_an_older_failure_marker(self):
self.state = 'running'
(self.folder / 'proxmenux-lxc-check-invalid-100').touch()
result, _, _ = self.run_check()
self.assertEqual(result['state'], 'complete')
self.assertFalse((self.folder / 'proxmenux-lxc-check-invalid-100').exists())
def test_index_read_timeout_and_persistence_errors_keep_running_guest_running(self):
self.state = 'running'
for error in [RuntimeError('index failed'), OSError('read failed'), for error in [RuntimeError('index failed'), OSError('read failed'),
subprocess.TimeoutExpired('package listing', 30)]: subprocess.TimeoutExpired('package listing', 30)]:
with self.subTest(error=type(error).__name__): with self.subTest(error=type(error).__name__):
result, _, _ = self.run_check(capture_error=error) result, _, _ = self.run_check(capture_error=error)
self.assertEqual(result['state'], 'failed') self.assertEqual(result['state'], 'failed')
self.assertEqual(self.state, 'stopped') self.assertEqual(self.state, 'running')
result, _, _ = self.run_check(persistence_error=OSError('disk full')) result, _, _ = self.run_check(persistence_error=OSError('disk full'))
self.assertEqual(result['state'], 'failed') self.assertEqual(result['state'], 'failed')
self.assertEqual(self.state, 'stopped') self.assertEqual(self.state, 'running')
self.assertIn('could not be persisted', result['error']) self.assertIn('could not be persisted', result['error'])
def test_start_timeout_after_effect_is_restored(self): def test_unexpected_stopped_worker_never_starts_or_probes_the_guest(self):
def transport(vmid, action, *args, **kwargs): result, capture, store = self.run_check()
value = self.transport(vmid, action, *args, **kwargs)
if action == 'start':
raise subprocess.TimeoutExpired('start', 60)
return value
result, capture, _ = self.run_check(transport=transport)
self.assertEqual(result['state'], 'failed') self.assertEqual(result['state'], 'failed')
self.assertEqual(self.state, 'stopped') self.assertEqual(self.state, 'stopped')
self.assertEqual(self.calls, ['status'])
capture.assert_not_called() capture.assert_not_called()
store.assert_not_called()
self.assertTrue((self.folder / 'proxmenux-lxc-check-invalid-100').exists())
def test_unknown_status_does_not_start(self): def test_unknown_status_does_not_start(self):
self.state = 'unknown' self.state = 'unknown'
@@ -109,26 +119,6 @@ class CheckOnlyTests(unittest.TestCase):
self.assertEqual(self.calls, ['status']) self.assertEqual(self.calls, ['status'])
capture.assert_not_called() capture.assert_not_called()
def test_shutdown_failure_uses_verified_stop(self):
def transport(vmid, action, *args, **kwargs):
if action == 'shutdown':
raise RuntimeError('shutdown failed')
return self.transport(vmid, action, *args, **kwargs)
result, _, _ = self.run_check(transport=transport)
self.assertEqual(result['state'], 'complete')
self.assertEqual(self.state, 'stopped')
self.assertIn('stop', self.calls)
def test_failed_restore_revokes_installation(self):
def transport(vmid, action, *args, **kwargs):
if action in ('shutdown', 'stop'):
raise RuntimeError('stop failed')
return self.transport(vmid, action, *args, **kwargs)
result, _, _ = self.run_check(transport=transport)
self.assertEqual(result['state'], 'failed')
self.assertIn('could not be restored', result['error'])
self.assertTrue((self.folder / 'proxmenux-lxc-check-invalid-100').exists())
def test_guard_oci_config_lock_and_disabled_forbid_all_guest_calls(self): def test_guard_oci_config_lock_and_disabled_forbid_all_guest_calls(self):
for config in ['lxc.environment.runtime: OCI=1\n', 'lock: backup\n']: for config in ['lxc.environment.runtime: OCI=1\n', 'lock: backup\n']:
self.conf.write_text(config) self.conf.write_text(config)
@@ -163,7 +153,8 @@ class CheckOnlyTests(unittest.TestCase):
capture.assert_not_called() capture.assert_not_called()
def test_thread_start_failure_releases_lock_and_job(self): def test_thread_start_failure_releases_lock_and_job(self):
with patch.object(job.threading, 'Thread') as thread: with patch.object(job, 'status', return_value='running'), \
patch.object(job.threading, 'Thread') as thread:
thread.return_value.start.side_effect = RuntimeError('cannot create worker') thread.return_value.start.side_effect = RuntimeError('cannot create worker')
with self.assertRaises(RuntimeError): with self.assertRaises(RuntimeError):
job.start(100) job.start(100)
@@ -201,7 +192,8 @@ class CheckOnlyTests(unittest.TestCase):
job._attempts[100] = 100 job._attempts[100] = 100
reservation = Mock() reservation = Mock()
with patch.object(job.time, 'monotonic', return_value=100 + elapsed), \ with patch.object(job.time, 'monotonic', return_value=100 + elapsed), \
patch.object(job.threading, 'Thread'), patch.object(job, 'acquire', return_value=reservation): patch.object(job.threading, 'Thread'), patch.object(job, 'acquire', return_value=reservation), \
patch.object(job, 'status', return_value='running'):
result, code = job.start(100) result, code = job.start(100)
self.assertEqual(code, expected) self.assertEqual(code, expected)
job._jobs['active'] = dict(vmid=100, state='running', created=100) job._jobs['active'] = dict(vmid=100, state='running', created=100)
@@ -244,7 +236,7 @@ class CheckOnlyTests(unittest.TestCase):
def test_real_capture_refreshes_lists_and_persists_without_installation(self): def test_real_capture_refreshes_lists_and_persists_without_installation(self):
for count, index_rc in [(0, 0), (2, 0), (2, 1)]: for count, index_rc in [(0, 0), (2, 0), (2, 1)]:
with self.subTest(count=count, index_rc=index_rc): with self.subTest(count=count, index_rc=index_rc):
self.state = 'stopped' self.state = 'running'
reservation = job.acquire(100) reservation = job.acquire(100)
job._jobs['real'] = dict(vmid=100, state='running', created=time.monotonic()) job._jobs['real'] = dict(vmid=100, state='running', created=time.monotonic())
entry = {'id': 'lxc:100', 'type': 'lxc', '_vmid': '100', '_os_family': 'debian'} entry = {'id': 'lxc:100', 'type': 'lxc', '_vmid': '100', '_os_family': 'debian'}
@@ -264,7 +256,7 @@ class CheckOnlyTests(unittest.TestCase):
patch.object(job.managed_installs.subprocess, 'run', side_effect=transport): patch.object(job.managed_installs.subprocess, 'run', side_effect=transport):
job.run(100, 'real', reservation) job.run(100, 'real', reservation)
stored = job.managed_installs.get_registry()['items'][0]['update_check'] stored = job.managed_installs.get_registry()['items'][0]['update_check']
self.assertEqual(self.state, 'stopped') self.assertEqual(self.state, 'running')
self.assertEqual(job.get(100, 'real')['state'], 'failed' if index_rc else 'complete') self.assertEqual(job.get(100, 'real')['state'], 'failed' if index_rc else 'complete')
self.assertTrue(any('update' in command for command in commands)) self.assertTrue(any('update' in command for command in commands))
self.assertFalse(any(any(word in command for word in ('upgrade', 'install', 'full-upgrade')) self.assertFalse(any(any(word in command for word in ('upgrade', 'install', 'full-upgrade'))
+4 -1
View File
@@ -44,7 +44,7 @@ for (const locale of ['en', 'sk']) {
const t = key => key.split('.').reduce((o, part) => o?.[part], messages) ?? key const t = key => key.split('.').reduce((o, part) => o?.[part], messages) ?? key
const calls = [] const calls = []
const context = {exports: {}, t, customCmdEditingApp: null, uc: null, helperExists: false, const context = {exports: {}, t, customCmdEditingApp: null, uc: null, helperExists: false,
helperUsesWebUpdater: false, selectedVM: {vmid: 210}, openCustomCmdEditor() {}, helperUsesWebUpdater: false, selectedVM: {vmid: 210, status: 'stopped'}, openCustomCmdEditor() {},
openApplyTerminal: (...args) => calls.push(args), getUpdateActionButtonClass: () => 'original-style', openApplyTerminal: (...args) => calls.push(args), getUpdateActionButtonClass: () => 'original-style',
bulkApplying: false, osCheckingVmid: null, updatesRefreshing: false, controlLoading: false, bulkApplying: false, osCheckingVmid: null, updatesRefreshing: false, controlLoading: false,
applyOpen: false, osUpdateStatusKnown: true, hasOsUpdates: false, applyOpen: false, osUpdateStatusKnown: true, hasOsUpdates: false,
@@ -58,6 +58,7 @@ for (const locale of ['en', 'sk']) {
latest_version: '2.0', update_available: null, update_command: 'approved-command', checked_at: '2026-10-14T10:43:19Z'} latest_version: '2.0', update_available: null, update_command: 'approved-command', checked_at: '2026-10-14T10:43:19Z'}
const before = JSON.stringify(original) const before = JSON.stringify(original)
const rendered = ui.app(original) const rendered = ui.app(original)
assert.equal(ui.check().props.disabled, true, 'a stopped CT must not start an index refresh from the check button')
assert.match(text(rendered), locale === 'sk' ? /Nainštalovaná verzia nie je overená/ : /Installed version is unverified/) assert.match(text(rendered), locale === 'sk' ? /Nainštalovaná verzia nie je overená/ : /Installed version is unverified/)
assert.doesNotMatch(text(rendered), /Čaká sa|pending|upstreamAvailable/) assert.doesNotMatch(text(rendered), /Čaká sa|pending|upstreamAvailable/)
const action = flatten(rendered).find(n => n.type === 'Button') const action = flatten(rendered).find(n => n.type === 'Button')
@@ -108,6 +109,8 @@ for (const locale of ['en', 'sk']) {
assert.doesNotMatch(text(bulk(pending)), locale === 'sk' ? /nie je overená/ : /is unverified/) assert.doesNotMatch(text(bulk(pending)), locale === 'sk' ? /nie je overená/ : /is unverified/)
assert.equal(calls.length, 0, 'all status fixtures are side-effect free') assert.equal(calls.length, 0, 'all status fixtures are side-effect free')
} }
assert.match(source, /t\("vmLxc\.updates\.startBeforeCheck"\)/,
'the Updates tab must explain why checking a stopped CT is disabled')
;(async () => { ;(async () => {
const context = {exports: {}, osCheckRequestRef: {current: null}, applyOpen: false, controlLoading: false, const context = {exports: {}, osCheckRequestRef: {current: null}, applyOpen: false, controlLoading: false,
selectedVMRef: {current: {vmid: 210}}, vmModalCacheRef: {current: {details: new Map()}}, selectedVMRef: {current: {vmid: 210}}, vmModalCacheRef: {current: {details: new Map()}},