fix: preserve verified LXC state before a failed start

This commit is contained in:
VAIO73
2026-10-14 17:51:53 +00:00
parent 3489dc0530
commit 638552e5f8
7 changed files with 228 additions and 19 deletions
@@ -128,6 +128,24 @@ class StoppedRegistryTests(unittest.TestCase):
self.assertEqual(row['_os_family'], 'alpine')
self.assert_no_guest_probe()
def test_targeted_observation_reactivates_removed_row_without_a_duplicate(self):
registry = managed._read_registry()
registry['items'][0]['removed_at'] = self.now
managed._write_registry(registry)
observation = {
'entry': {**self.row, '_os_family': 'debian'},
'result': {'available': True, 'last_check': self.now, '_count': 2},
}
self.assertTrue(managed.store_lxc_update_observation(210, observation))
registry = managed.get_registry()
rows = [row for row in registry['items'] if row['id'] == 'lxc:210']
self.assertEqual(len(rows), 1)
self.assertNotIn('removed_at', rows[0])
self.assertEqual(rows[0]['update_check']['_count'], 2)
self.assert_no_guest_probe()
def test_retained_result_still_expires_at_existing_48_hour_boundary(self):
managed.detect_and_register()
current = managed.get_active_items()[0]['update_check']
@@ -145,11 +163,11 @@ class StoppedRegistryTests(unittest.TestCase):
class InventoryContractTests(unittest.TestCase):
def test_default_failure_contract_and_strict_detector_contract(self):
for result in [subprocess.CompletedProcess([], 1, '', ''), subprocess.CompletedProcess([], 0, '', '')]:
with patch.object(managed.subprocess, 'run', return_value=result):
self.assertEqual(managed._list_pve_lxcs(), [])
with self.assertRaises(RuntimeError):
managed._list_pve_lxcs(strict=True)
result = subprocess.CompletedProcess([], 1, '', '')
with patch.object(managed.subprocess, 'run', return_value=result):
self.assertEqual(managed._list_pve_lxcs(), [])
with self.assertRaises(RuntimeError):
managed._list_pve_lxcs(strict=True)
def test_timeout_and_malformed_rows_are_not_confirmed_removal(self):
for failure in [subprocess.TimeoutExpired('pct', 5), FileNotFoundError(), OSError()]:
@@ -162,8 +180,12 @@ class InventoryContractTests(unittest.TestCase):
managed._list_pve_lxcs(strict=True)
def test_confirmed_empty_inventory_is_not_a_failed_read(self):
with patch.object(managed.subprocess, 'run', return_value=subprocess.CompletedProcess([], 0, 'VMID Status Lock Name\n', '')):
self.assertEqual(managed._list_pve_lxcs(strict=True), [])
for stdout in ['', '\n', 'VMID Status Lock Name\n']:
with self.subTest(stdout=repr(stdout)), patch.object(
managed.subprocess, 'run',
return_value=subprocess.CompletedProcess([], 0, stdout, ''),
):
self.assertEqual(managed._list_pve_lxcs(strict=True), [])
if __name__ == '__main__':
@@ -59,9 +59,13 @@ root = pathlib.Path(os.environ['FIXTURE_DIR'])
args = sys.argv[1:]
with (root/'commands').open('a') as log: log.write(json.dumps(args)+'\\n')
state = root/'state'
if args[0] == 'list': print('VMID Status Name\\n100 '+state.read_text()+' fixture')
if args[0] == 'list':
if os.environ.get('FOREIGN') == '1': print('VMID Status Name\\n200 stopped foreign')
else: print('VMID Status Name\\n100 '+state.read_text()+' fixture')
elif args[0] == 'status': print('status: '+state.read_text())
elif args[0] == 'start': state.write_text('running')
elif args[0] == 'start':
if os.environ.get('START_FAIL') == '1': sys.exit(1)
state.write_text('running')
elif args[0] == 'shutdown':
if os.environ.get('SHUTDOWN_FAIL') == '1': sys.exit(1)
state.write_text('stopped')
@@ -102,8 +106,10 @@ m._PCT_BIN = str(root/'bin/pct')
h.OBSERVATION_DIR = root/'observations'
apps._APPS_DIR = str(root/'apps')
apps._PCT_BIN = str(root/'bin/pct')
with (root/'commands').open('a') as log: log.write('CAPTURE\\n')
if os.environ.get('CAPTURE_FAIL') == '1': raise SystemExit(1)
marker = '--mark-started' in os.sys.argv
with (root/'commands').open('a') as log: log.write('MARK_STARTED\\n' if marker else 'CAPTURE\\n')
if os.environ.get('MARKER_FAIL') == '1' and marker: raise SystemExit(1)
if os.environ.get('CAPTURE_FAIL') == '1' and not marker: raise SystemExit(1)
m._detect_lxc_containers = lambda **_: ([{'id':'lxc:100','type':'lxc','_vmid':'100',
'_is_oci':os.environ.get('OCI') == '1'}] if (root/'state').read_text() == 'running' else [])
raise SystemExit(h.main())
@@ -169,11 +175,12 @@ raise SystemExit(h.main())
self.assertIsNone(row['error'])
self.assertEqual(row['os_family'], 'debian')
log = self.log.read_text()
self.assertLess(log.index('MARK_STARTED'), log.index('CAPTURE'))
self.assertLess(log.index('CAPTURE'), log.index('shutdown'))
self.assertLess(log.index('apt list --upgradable'), log.index('shutdown'))
self.assertNotIn('apt-get update', log)
self.assertEqual(log.count('dist-upgrade'), 1)
self.assertFalse(list(self.observations.glob('*.json')))
self.assertFalse(list(self.observations.iterdir()))
self.assertEqual(managed.get_registry()['items'][1]['update_check']['_count'], 7)
self.assertEqual(self.fixture.commands, [], 'GET and stopped finalization run no guest probes')
@@ -198,6 +205,33 @@ raise SystemExit(h.main())
def test_missing_capture_does_not_present_old_count_or_fresh_zero(self):
self.assert_unverified('CAPTURE_FAIL')
def test_pre_start_failure_or_foreign_target_preserves_last_verified_result(self):
for condition in ('START_FAIL', 'FOREIGN'):
with self.subTest(condition=condition):
registry = managed.get_registry()
registry['items'][0]['update_check'] = {
'available': True, 'last_check': '2026-10-14T00:00:00Z',
'_count': 20, 'error': None,
}
managed._write_registry(registry)
result = self.run_manual(**{condition: '1'})
self.assertNotEqual(result.returncode, 0)
self.assertEqual(self.state.read_text(), 'stopped')
stored = managed.get_registry()['items'][0]['update_check']
self.assertEqual(stored['_count'], 20)
self.assertIsNone(stored['error'])
log = self.log.read_text()
self.assertNotIn('MARK_STARTED', log)
self.assertNotIn('CAPTURE', log)
def test_started_run_with_unwritable_marker_is_reported_as_unverified(self):
result = self.run_manual(MARKER_FAIL='1', CAPTURE_FAIL='1')
self.assertEqual(result.returncode, 8)
self.assertEqual(self.state.read_text(), 'stopped')
stored = managed.get_registry()['items'][0]['update_check']
self.assertTrue(stored['error'])
self.assertNotIn('_count', stored)
def test_web_runner_without_identity_reports_unverified_instead_of_silent_skip(self):
env = {**self.env, 'EXECUTION_MODE': 'web'}
env.pop('RUN_ID')
@@ -210,6 +244,7 @@ raise SystemExit(h.main())
def test_rejected_handoff_is_retained_with_safe_diagnostic_and_no_false_zero(self):
self.observations.mkdir(mode=0o700)
handoff.mark_started(100, 'manual-fixture')
path = self.observations / '100-manual-fixture.json'
path.write_text(json.dumps({'private_payload': 'must-not-be-logged'}))
path.chmod(0o600)
@@ -219,7 +254,7 @@ raise SystemExit(h.main())
status='success', source='manual', target='os')
self.assertTrue(path.exists())
self.assertIn('reason=KeyError', output.getvalue())
self.assertIn('handoff=False verified=False', output.getvalue())
self.assertIn('handoff=False started=True untouched=False verified=False', output.getvalue())
self.assertNotIn('must-not-be-logged', output.getvalue())
self.assertTrue(self.fixture.row()['update_check']['error'])
self.assertNotIn('_count', managed.get_registry()['items'][0]['update_check'])
+46
View File
@@ -1,6 +1,9 @@
import ast
import sys
import subprocess
import tempfile
import threading
import time
import unittest
from types import SimpleNamespace
from pathlib import Path
@@ -23,6 +26,7 @@ class UpdateStrategyValidationTests(unittest.TestCase):
self.assertTrue(ok, config)
self.assertEqual(config["update_strategy"], "custom_override")
def test_legacy_helper_then_custom_is_normalised_to_override(self):
ok, config = lxc_apps.validate_config({
"name": "Jellyfin",
@@ -42,6 +46,48 @@ class UpdateStrategyValidationTests(unittest.TestCase):
self.assertEqual(config["update_strategy"], "custom_override")
class LifecycleInventoryResilienceTests(unittest.TestCase):
def test_failed_targeted_inventory_refresh_does_not_abort_started_lxc_rebuild(self):
source = ast.parse((REPO_ROOT / 'AppImage/scripts/flask_server.py').read_text())
function = next(node for node in source.body if isinstance(node, ast.FunctionDef)
and node.name == '_refresh_started_guest')
warmed = []
published = MagicMock()
lxc = SimpleNamespace(
invalidate_suggestion_probes=MagicMock(),
check_all=MagicMock(return_value={'vmid': 100, 'apps': []}),
_pct_exec=MagicMock(return_value=(1, '', 'not installed')),
ensure_oci_registration=MagicMock(),
get_suggestions=MagicMock(return_value={'suggestions': []}),
)
managed = SimpleNamespace(
refresh_lxc=MagicMock(side_effect=RuntimeError('pct list timed out')),
)
scope = {
'time': time,
'_wait_for_started_guest': lambda *_: True,
'_vm_cache_invalidate': MagicMock(),
'_vm_disk_cache': {},
'_warm_started_guest_handler': lambda vmid, route, _: warmed.append((vmid, route)),
'get_vm_config': object(), 'api_vm_backups': object(),
'api_vm_apps_schedule': object(), 'api_lxc_mount_points': object(),
'_publish_guest_modal_cache_revision': published,
'_vm_cache_put': MagicMock(), '_vm_app_suggestions_cache': {},
'_guest_lifecycle_lock': threading.RLock(),
'_guest_refresh_inflight': {100}, '_guest_modal_cache_revision': {},
'_guest_modal_cache_epoch': 0,
}
with patch.dict(sys.modules, {'lxc_apps': lxc, 'managed_installs': managed}):
exec(compile(ast.Module(body=[function], type_ignores=[]), 'lifecycle-refresh', 'exec'), scope)
scope['_refresh_started_guest'](100, 'lxc')
managed.refresh_lxc.assert_called_once_with(100)
lxc.check_all.assert_called_once_with(100, force=True)
self.assertEqual(len(warmed), 4)
self.assertGreaterEqual(published.call_count, 1)
self.assertNotIn(100, scope['_guest_refresh_inflight'])
class HelperEvidenceTests(unittest.TestCase):
def test_legacy_literal_wrapper_slug_is_supported(self):
wrapper = (