diff --git a/AppImage/components/virtual-machines.tsx b/AppImage/components/virtual-machines.tsx index 9c8c8e2b..e93604cf 100644 --- a/AppImage/components/virtual-machines.tsx +++ b/AppImage/components/virtual-machines.tsx @@ -6048,7 +6048,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {

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

+ {selectedVM.status !== "running" && ( +

+ {t("vmLxc.updates.startBeforeCheck")} +

+ )}
{t("vmLxc.updates.lastCheckedPrefix")}{" "} {uc?.last_check ? new Date(uc.last_check).toLocaleString() : "—"} diff --git a/AppImage/lib/lxc-update-observation.ts b/AppImage/lib/lxc-update-observation.ts index 9c0bb4f7..8a00d565 100644 --- a/AppImage/lib/lxc-update-observation.ts +++ b/AppImage/lib/lxc-update-observation.ts @@ -31,8 +31,8 @@ export function checkLxcUpdates(vmid: number): Promise { return fetchApi(`/api/vms/${vmid}/updates/check`, { method: "POST" }) } -// Separate explicit action: refresh indexes, check only, restore stopped CT. -// The backend owns the job even if the browser closes or navigates away. +// Explicit action: refresh indexes and check an already-running CT. The +// backend owns the job even if the browser closes or navigates away. export async function refreshCheckLxcUpdates(vmid: number): Promise { const job = await fetchApi<{ job_id: string }>(`/api/vms/${vmid}/updates/refresh-check`, { method: "POST" }) const deadline = Date.now() + 8 * 60_000 diff --git a/AppImage/messages/en/common.json b/AppImage/messages/en/common.json index 0b6dbf80..8d510ee3 100644 --- a/AppImage/messages/en/common.json +++ b/AppImage/messages/en/common.json @@ -1335,9 +1335,9 @@ "checkNow": "Check updates", "checkSystemPackages": "Check system packages", "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.", - "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", "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.", diff --git a/AppImage/messages/sk/common.json b/AppImage/messages/sk/common.json index 3daf0d81..cbc8049b 100644 --- a/AppImage/messages/sk/common.json +++ b/AppImage/messages/sk/common.json @@ -1337,9 +1337,9 @@ "checkNow": "Skontrolovať aktualizácie", "checkSystemPackages": "Skontrolovať balíky systému", "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é.", - "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", "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.", diff --git a/AppImage/scripts/flask_server.py b/AppImage/scripts/flask_server.py index 02122f72..78434926 100644 --- a/AppImage/scripts/flask_server.py +++ b/AppImage/scripts/flask_server.py @@ -13697,7 +13697,7 @@ def api_lxc_updates_check(vmid): @app.route('/api/vms//updates/refresh-check', methods=['POST']) @require_auth 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 try: result, code = lxc_check_job.start(vmid) diff --git a/AppImage/scripts/lxc_check_job.py b/AppImage/scripts/lxc_check_job.py index 0f69af36..548a3440 100644 --- a/AppImage/scripts/lxc_check_job.py +++ b/AppImage/scripts/lxc_check_job.py @@ -1,7 +1,7 @@ """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. -Package observations are saved before restoring a temporarily started guest. +Importing or reading a job never probes a guest. Only start() may launch work +for an already-running CT; package indexes are never refreshed by starting one. """ import datetime import fcntl @@ -99,51 +99,30 @@ def checked_os(vmid): def run(vmid, job_id, reservation): - restore = False error = None + check_started = False invalid = LOCK_DIR / f'proxmenux-lxc-check-invalid-{int(vmid)}' try: invalid.touch(mode=0o600) guard(vmid) - original = status(vmid) - if original == 'stopped': - # Set before start: a timed-out command can still have started CT. - 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) + if status(vmid) != 'running': + raise RuntimeError('Container is stopped; start it first') + check_started = True observation = lxc_update_observation.capture(vmid, job_id, refresh_indexes=True) if not managed_installs.store_lxc_update_observation(vmid, observation): raise RuntimeError(observation['result'].get('error') or 'Package check failed') lxc_update_observation.discard(vmid, job_id) except Exception as exc: error = str(exc) - # Preflight/start failures must revoke a previous positive observation. - try: - managed_installs.store_lxc_update_observation(vmid, None) - except Exception: - error += '; package result could not be persisted' - finally: - if restore: + # A failed package operation must block installation. A state change + # before the worker runs did not check or alter packages, so keep the + # last verified observation but leave the invalid marker in place. + if check_started: try: - if status(vmid) != 'stopped': - command(vmid, 'shutdown', '--timeout', '30', timeout=40) - if status(vmid) != 'stopped': - raise RuntimeError('Container is still running') + managed_installs.store_lxc_update_observation(vmid, None) except Exception: - try: - command(vmid, 'stop', timeout=30) - if status(vmid) != 'stopped': - raise RuntimeError('Container is still running') - except Exception: - error = (error + '; ' if error else '') + 'Original stopped state could not be restored' + error += '; package result could not be persisted' + finally: if not error: try: invalid.unlink() @@ -167,6 +146,8 @@ def start(vmid): reservation = acquire(vmid) try: guard(vmid) + if status(vmid) != 'running': + raise RuntimeError('Container is stopped; start it first') job_id = uuid.uuid4().hex _jobs[job_id] = dict(vmid=vmid, state='running', error=None, created=now) _attempts[vmid] = now diff --git a/tests/lxc_updates/test_check_only_job.py b/tests/lxc_updates/test_check_only_job.py index 8517fca2..a6635970 100644 --- a/tests/lxc_updates/test_check_only_job.py +++ b/tests/lxc_updates/test_check_only_job.py @@ -63,14 +63,17 @@ class CheckOnlyTests(unittest.TestCase): job.run(100, 'run', reservation) return job.get(100, 'run'), capture, store - def test_stopped_check_restores_and_never_installs(self): - result, capture, store = self.run_check() - self.assertEqual(result['state'], 'complete') + def test_stopped_check_is_rejected_before_a_job_or_guest_mutation(self): + with patch.object(job, 'command', side_effect=self.transport): + with self.assertRaisesRegex(RuntimeError, 'start it first'): + job.start(100) self.assertEqual(self.state, 'stopped') - self.assertEqual(self.calls, ['status', 'start', 'exec', 'status', 'shutdown', 'status']) - capture.assert_called_once_with(100, 'run', refresh_indexes=True) - store.assert_called_once_with(100, self.result) + self.assertEqual(self.calls, ['status']) + self.assertEqual(job._jobs, {}) + self.assertEqual(job._attempts, {}) 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): self.state = 'running' @@ -78,29 +81,36 @@ class CheckOnlyTests(unittest.TestCase): self.assertEqual(result['state'], 'complete') self.assertEqual(self.calls, ['status']) 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'), subprocess.TimeoutExpired('package listing', 30)]: with self.subTest(error=type(error).__name__): result, _, _ = self.run_check(capture_error=error) self.assertEqual(result['state'], 'failed') - self.assertEqual(self.state, 'stopped') + self.assertEqual(self.state, 'running') result, _, _ = self.run_check(persistence_error=OSError('disk full')) self.assertEqual(result['state'], 'failed') - self.assertEqual(self.state, 'stopped') + self.assertEqual(self.state, 'running') self.assertIn('could not be persisted', result['error']) - def test_start_timeout_after_effect_is_restored(self): - def transport(vmid, action, *args, **kwargs): - value = self.transport(vmid, action, *args, **kwargs) - if action == 'start': - raise subprocess.TimeoutExpired('start', 60) - return value - result, capture, _ = self.run_check(transport=transport) + def test_unexpected_stopped_worker_never_starts_or_probes_the_guest(self): + result, capture, store = self.run_check() self.assertEqual(result['state'], 'failed') self.assertEqual(self.state, 'stopped') + self.assertEqual(self.calls, ['status']) 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): self.state = 'unknown' @@ -109,26 +119,6 @@ class CheckOnlyTests(unittest.TestCase): self.assertEqual(self.calls, ['status']) 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): for config in ['lxc.environment.runtime: OCI=1\n', 'lock: backup\n']: self.conf.write_text(config) @@ -163,7 +153,8 @@ class CheckOnlyTests(unittest.TestCase): capture.assert_not_called() 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') with self.assertRaises(RuntimeError): job.start(100) @@ -201,7 +192,8 @@ class CheckOnlyTests(unittest.TestCase): job._attempts[100] = 100 reservation = Mock() 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) self.assertEqual(code, expected) 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): for count, index_rc in [(0, 0), (2, 0), (2, 1)]: with self.subTest(count=count, index_rc=index_rc): - self.state = 'stopped' + self.state = 'running' reservation = job.acquire(100) job._jobs['real'] = dict(vmid=100, state='running', created=time.monotonic()) 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): job.run(100, 'real', reservation) 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.assertTrue(any('update' in command for command in commands)) self.assertFalse(any(any(word in command for word in ('upgrade', 'install', 'full-upgrade')) diff --git a/tests/lxc_updates/test_update_status_ui.cjs b/tests/lxc_updates/test_update_status_ui.cjs index 695fda8d..127bf321 100644 --- a/tests/lxc_updates/test_update_status_ui.cjs +++ b/tests/lxc_updates/test_update_status_ui.cjs @@ -44,7 +44,7 @@ for (const locale of ['en', 'sk']) { const t = key => key.split('.').reduce((o, part) => o?.[part], messages) ?? key const calls = [] 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', bulkApplying: false, osCheckingVmid: null, updatesRefreshing: false, controlLoading: 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'} const before = JSON.stringify(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.doesNotMatch(text(rendered), /Čaká sa|pending|upstreamAvailable/) 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.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 () => { const context = {exports: {}, osCheckRequestRef: {current: null}, applyOpen: false, controlLoading: false, selectedVMRef: {current: {vmid: 210}}, vmModalCacheRef: {current: {details: new Map()}},