fix: preserve update observations for stopped LXC guests

This commit is contained in:
VAIO73
2026-10-14 17:51:53 +00:00
parent ceb783bcf2
commit 3489dc0530
10 changed files with 1225 additions and 10 deletions
+145
View File
@@ -0,0 +1,145 @@
"""Installed versions must survive a temporary CT's post-update shutdown."""
import copy
from datetime import datetime, timezone, timedelta
import tempfile
from pathlib import Path
import sys
import unittest
from unittest.mock import Mock, patch
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / 'AppImage/scripts'))
import lxc_apps as apps
import lxc_update_observation as handoff
class InstalledObservationTests(unittest.TestCase):
def setUp(self):
self.folder = tempfile.TemporaryDirectory()
self.addCleanup(self.folder.cleanup)
self.data = {'apps': [dict(id='docker', name='Docker', installed_via='binary',
binary_path='/usr/bin/docker', state=dict(installed_version='29.8.1',
latest_version='29.9.0', update_available=True, checked_at='2026-01-01T00:00:00Z')),
dict(id='manual', installed_via='manual', installed_version='1.0',
state=dict(installed_version='1.0', latest_version='2.0', update_available=True))],
'bulk_update': {'targets': ['os', 'app:manual']}, 'schedule': {'enabled': False}}
self.write = Mock(return_value=True)
self.probe = Mock(return_value=('29.9.0', None))
for name, value in [('_read_sidecar', lambda _: copy.deepcopy(self.data)),
('_write_sidecar', self.write),
('detect_installed_version', self.probe)]:
p = patch.object(apps, name, value)
p.start()
self.addCleanup(p.stop)
def capture(self):
return apps.capture_installed_observations(210)
def test_new_docker_version_clears_old_pending_without_upstream_or_updater(self):
with patch.object(apps, 'fetch_latest_upstream_details') as upstream:
observations = self.capture()
result = apps.store_installed_observations(210, observations)
self.assertEqual(result['errors'], [])
state = self.write.call_args.args[1]['apps'][0]['state']
self.assertEqual(state['installed_version'], '29.9.0')
self.assertFalse(state['update_available'])
self.assertEqual(state['latest_version'], '29.9.0')
self.assertEqual(self.probe.call_count, 1, 'manual versions cannot be auto-proven')
upstream.assert_not_called()
self.assertEqual(self.write.call_args.args[1]['bulk_update'], self.data['bulk_update'])
self.assertEqual(self.write.call_args.args[1]['schedule'], self.data['schedule'])
def test_capture_never_writes_shared_sidecar_and_skips_manual_docker_oci(self):
self.data['apps'].extend([dict(id='oci', installed_via='oci_image'),
dict(id='image', installed_via='docker')])
self.assertEqual(len(self.capture()), 1)
self.write.assert_not_called()
self.assertEqual(self.probe.call_count, 1)
def test_failed_probe_preserves_last_known_separately_and_does_not_claim_current(self):
self.probe.return_value = (None, 'secret must never be copied to handoff')
observations = self.capture()
self.assertNotIn('secret', str(observations))
result = apps.store_installed_observations(210, observations)
self.assertTrue(result['errors'])
state = self.write.call_args.args[1]['apps'][0]['state']
self.assertEqual(state['last_known_installed_version'], '29.8.1')
self.assertIsNone(state['installed_version'])
self.assertIsNone(state['update_available'])
self.assertTrue(state['error'])
def test_config_edit_deletion_and_newer_check_cannot_be_overwritten(self):
for kind in ('config', 'deleted', 'newer'):
with self.subTest(kind=kind):
observations = self.capture()
original = copy.deepcopy(self.data)
if kind == 'config': self.data['apps'][0]['binary_path'] = '/other/docker'
if kind == 'deleted': self.data['apps'] = []
if kind == 'newer': self.data['apps'][0]['state']['checked_at'] = '9999-01-01T00:00:00Z'
result = apps.store_installed_observations(210, observations)
self.assertTrue(result['errors'])
self.write.assert_not_called()
self.data = original
def test_malformed_duplicate_future_and_expired_observations_never_write_or_probe(self):
observation = self.capture()[0]
for value in (None, {}, [{}], [observation, observation],
[{**observation, 'fingerprint': '../unsafe'}],
[{**observation, 'checked_at': '9999-01-01T00:00:00Z'}],
[{**observation, 'checked_at': '2000-01-01T00:00:00Z'}],
[{**observation, 'installed_version': ['29.9.0']}],
[{**observation, 'verified': 'yes'}]):
with self.subTest(value=value):
self.assertTrue(apps.store_installed_observations(210, value)['errors'])
self.write.assert_not_called()
self.assertEqual(self.probe.call_count, 1)
def test_stale_boundary_has_margin_and_rejects_only_after_hour(self):
observation = self.capture()[0]
fixed = datetime(2026, 1, 2, tzinfo=timezone.utc)
with patch.object(apps.time, 'time', return_value=fixed.timestamp()):
for age in (3599, 3600, 3601):
self.write.reset_mock()
value = {**observation, 'checked_at': (fixed - timedelta(seconds=age)).isoformat()}
result = apps.store_installed_observations(210, [value])
self.assertEqual(bool(result['errors']), age > 3600)
self.assertEqual(self.write.called, age <= 3600)
def test_failed_persistence_is_an_error(self):
self.write.return_value = False
with self.assertRaises(OSError):
apps.store_installed_observations(210, self.capture())
def test_installed_probe_does_not_hide_upstream_error(self):
self.data['apps'][0]['state']['error'] = 'upstream unavailable'
apps.store_installed_observations(210, self.capture())
self.assertEqual(self.write.call_args.args[1]['apps'][0]['state']['error'], 'upstream unavailable')
class CaptureScopeTests(unittest.TestCase):
def test_check_only_and_oci_never_capture_app_probes(self):
with tempfile.TemporaryDirectory() as directory, \
patch.object(handoff, 'OBSERVATION_DIR', Path(directory) / 'observations'), \
patch.object(handoff.managed_installs, '_detect_lxc_containers',
return_value=[{'_vmid': '210', '_is_oci': True}]), \
patch.object(handoff.managed_installs, '_check_lxc_updates', return_value={}), \
patch.object(apps, 'capture_installed_observations') as capture:
handoff.capture(210, 'check-only')
handoff.capture(210, 'oci', include_apps=True)
capture.assert_not_called()
def test_missing_guest_or_maintenance_preflight_never_capture_apps(self):
with tempfile.TemporaryDirectory() as directory, \
patch.object(handoff, 'OBSERVATION_DIR', Path(directory) / 'observations'), \
patch.object(handoff.managed_installs, '_detect_lxc_containers', return_value=[]) as detect, \
patch.object(handoff.managed_installs, '_probe_lxc_os', return_value='debian'), \
patch.object(handoff.managed_installs, '_check_lxc_updates', return_value={}), \
patch.object(handoff.subprocess, 'run', return_value=Mock(returncode=0)), \
patch.object(apps, 'capture_installed_observations') as capture:
handoff.capture(210, 'missing', include_apps=True)
detect.return_value = [{'_vmid': '210'}]
handoff.capture(210, 'maintenance', refresh_indexes=True, include_apps=True)
capture.assert_not_called()
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,170 @@
"""Actual registry/detection/checker consumers; PVE transports are isolated."""
import ast
import copy
import datetime
import json
import os
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
from unittest.mock import Mock, patch
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / 'AppImage/scripts'))
import managed_installs as managed
class StoppedRegistryTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
path = Path(self.temp.name) / 'registry.json'
self.now = datetime.datetime.utcnow().isoformat() + 'Z'
self.checked = {'last_check': self.now, 'available': True, 'error': None,
'_count': 1, '_packages': [{'name': 'fixture-package', 'current': '1', 'latest': '2'}]}
self.row = {'id': 'lxc:210', 'type': 'lxc', '_vmid': '210', 'name': 'fixture',
'_os_family': 'debian', 'update_check': copy.deepcopy(self.checked)}
path.write_text(json.dumps({'items': [self.row]}))
self.inventory = [{'vmid': '210', 'status': 'stopped', 'name': 'fixture'}]
patches = [patch.object(managed, '_REGISTRY_PATH', str(path)),
patch.object(managed, '_DB_DIR', self.temp.name),
patch.object(managed, '_DETECTORS', [managed._detect_lxc_containers]),
patch.object(managed, '_lxc_updates_detection_enabled', return_value=True),
patch.object(managed, '_list_pve_lxcs', side_effect=lambda **_: self.inventory),
patch.object(managed, '_get_oci_managed_vmids', return_value={})]
self.forbidden = {}
for name in ['_probe_lxc_os', '_probe_lxc_is_oci', '_identify_helper_slug', '_run_pct_pkg_listing']:
mock = Mock(side_effect=AssertionError('stopped guest probe forbidden'))
self.forbidden[name] = mock
patches.append(patch.object(managed, name, mock))
for p in patches:
p.start()
self.addCleanup(p.stop)
def assert_no_guest_probe(self):
for mock in self.forbidden.values():
mock.assert_not_called()
def test_startup_detection_preserves_stopped_result_then_notification_check_skips_guest(self):
managed.detect_and_register()
self.assertEqual(managed.get_active_items()[0]['update_check'], self.checked)
checker = Mock(side_effect=AssertionError('checker must not run on stopped guest'))
with patch.object(managed, '_CHECKERS', {'lxc': checker}):
self.assertEqual(managed.check_for_updates(force=True), [])
checker.assert_not_called()
self.assertEqual(managed.get_active_items()[0]['update_check'], self.checked)
self.assert_no_guest_probe()
def test_monitor_status_projection_survives_process_reinitialization(self):
managed.detect_and_register()
source = ast.parse((ROOT / 'AppImage/scripts/flask_server.py').read_text())
function = next(n for n in source.body if isinstance(n, ast.FunctionDef)
and n.name == '_get_lxc_update_status_map')
scope = {'os': os}
exec(compile(ast.Module(body=[function], type_ignores=[]), 'actual-status-map', 'exec'), scope)
status = scope['_get_lxc_update_status_map']()['210']
self.assertEqual(status['last_check'], self.now)
self.assertEqual(status['count'], 1)
self.assertTrue(status['available'])
self.assert_no_guest_probe()
def test_legacy_stopped_row_removed_by_startup_is_reactivated_without_new_observation(self):
reg = managed._read_registry()
reg['items'][0]['removed_at'] = self.now
managed._write_registry(reg)
managed.detect_and_register()
active = managed.get_active_items()
self.assertEqual(len(active), 1)
self.assertEqual(active[0]['update_check'], self.checked)
self.assert_no_guest_probe()
def test_genuinely_deleted_guest_and_disabled_detection_remain_removed(self):
for disabled in [False, True]:
managed._write_registry({'items': [copy.deepcopy(self.row)]})
self.inventory = [] if not disabled else [{'vmid': '210', 'status': 'stopped', 'name': 'fixture'}]
with patch.object(managed, '_lxc_updates_detection_enabled', return_value=not disabled):
managed.detect_and_register()
self.assertEqual(managed.get_active_items(), [])
self.assert_no_guest_probe()
def test_unknown_stopped_guest_does_not_get_a_fabricated_package_result(self):
self.inventory.append({'vmid': '300', 'status': 'stopped', 'name': 'new'})
managed.detect_and_register()
self.assertEqual([x['id'] for x in managed.get_active_items()], ['lxc:210'])
self.assert_no_guest_probe()
def test_targeted_refresh_does_not_start_or_probe_stopped_guest(self):
self.assertIsNone(managed.refresh_lxc(210, refresh_package_cache=False))
self.assertEqual(managed.get_active_items()[0]['update_check'], self.checked)
self.assert_no_guest_probe()
def test_inventory_failure_preserves_known_row_without_checker_or_fresh_timestamp(self):
with patch.object(managed, '_list_pve_lxcs', side_effect=RuntimeError('inventory unavailable')):
managed.detect_and_register()
managed.check_for_updates()
self.assertEqual(managed.get_active_items()[0]['update_check'], self.checked)
self.assert_no_guest_probe()
def test_running_guest_uses_real_checker_and_refreshes_result(self):
self.inventory[0]['status'] = 'running'
checker = Mock(return_value={'available': False, 'last_check': self.now, '_count': 0})
with patch.object(managed, '_CHECKERS', {'lxc': checker}):
managed.check_for_updates()
checker.assert_called_once()
self.assertFalse(managed.get_active_items()[0]['update_check']['available'])
def test_presence_merge_does_not_overwrite_a_newer_check_during_detection(self):
fresh = {'entry': {**self.row, '_os_family': 'alpine'},
'result': {'available': True, 'last_check': self.now, '_count': 2}}
def concurrent_check():
self.assertTrue(managed.store_lxc_update_observation(210, fresh))
return {}
with patch.object(managed, '_get_oci_managed_vmids', side_effect=concurrent_check):
managed.detect_and_register()
row = managed.get_active_items()[0]
self.assertEqual(row['update_check']['_count'], 2)
self.assertEqual(row['_os_family'], 'alpine')
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']
now = datetime.datetime.fromisoformat(self.now.rstrip('Z'))
for seconds, expected in [(172799, False), (172800, True), (172801, True)]:
instant = (now + datetime.timedelta(seconds=seconds)).replace(tzinfo=datetime.timezone.utc)
class Clock(datetime.datetime):
@classmethod
def now(cls, tz=None):
return instant
with patch.object(managed.datetime, 'datetime', Clock):
self.assertEqual(managed.lxc_update_freshness(current)['stale'], expected)
self.assert_no_guest_probe()
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)
def test_timeout_and_malformed_rows_are_not_confirmed_removal(self):
for failure in [subprocess.TimeoutExpired('pct', 5), FileNotFoundError(), OSError()]:
with patch.object(managed.subprocess, 'run', side_effect=failure):
self.assertEqual(managed._list_pve_lxcs(), [])
with self.assertRaises(RuntimeError):
managed._list_pve_lxcs(strict=True)
with patch.object(managed.subprocess, 'run', return_value=subprocess.CompletedProcess([], 0, 'VMID Status Lock Name\ninvalid-row\n', '')):
with self.assertRaises(RuntimeError):
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), [])
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,476 @@
"""Real update runner -> trusted handoff -> real completion/API, fake CT only."""
import ast
from contextlib import redirect_stdout
from datetime import datetime
import io
import json
import os
from pathlib import Path
import re
import shlex
import subprocess
import sys
import threading
import time
from types import SimpleNamespace
import unittest
from unittest.mock import Mock, patch
import uuid
import test_observed_state as observed
import managed_installs as managed
import lxc_update_observation as handoff
from flask import request
ROOT = observed.ROOT
RUNNER = ROOT / "scripts/lxc/apply_updates.sh"
REAL_RUN = subprocess.run
class StoppedUpdateObservationTests(unittest.TestCase):
def setUp(self):
self.real_run = REAL_RUN
self.fixture = observed.ObservedStateRoutesTests()
self.fixture.setUp()
self.addCleanup(self.fixture.doCleanups)
self.folder = Path(self.fixture.temp.name)
self.state = self.folder / "state"
self.state.write_text("stopped")
self.log = self.folder / "commands"
self.log.write_text("")
self.bin = self.folder / "bin"
self.bin.mkdir()
self.observations = self.folder / "observations"
ctx = patch.object(handoff, "OBSERVATION_DIR", self.observations)
ctx.start()
self.addCleanup(ctx.stop)
registry = managed.get_registry()
registry["items"][0]["update_check"]["_count"] = 20
managed._write_registry(registry)
self.env = {**os.environ, "PATH": f"{self.bin}:{os.environ['PATH']}",
"PYTHONPATH": str(ROOT / "AppImage/scripts"),
"FIXTURE_DIR": str(self.folder), "VMID": "100", "TARGET": "os",
"BACKUP": "0", "RESTART": "0", "RUN_HELPER": "0",
"UPDATE_COMMAND": "", "RUN_ID": "manual-fixture",
"PROXMENUX_LOCK_DIR": str(self.folder), "REMAINING": "0"}
self.write("pct", f"#!{sys.executable}\n" + '''
import json, os, pathlib, sys
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')
elif args[0] == 'status': print('status: '+state.read_text())
elif args[0] == 'start': state.write_text('running')
elif args[0] == 'shutdown':
if os.environ.get('SHUTDOWN_FAIL') == '1': sys.exit(1)
state.write_text('stopped')
elif args[0] == 'reboot':
if os.environ.get('REBOOT_FAIL') == '1': sys.exit(1)
if os.environ.get('REBOOT_TRANSITION') == '1': state.write_text('restarting')
elif args[0] == 'exec':
if state.read_text() != 'running': sys.exit(1)
command = ' '.join(args[3:])
if '/etc/os-release' in command: print('ID=debian')
elif 'dist-upgrade' in command:
if os.environ.get('UPDATE_FAIL') == '1': sys.exit(4)
if os.environ.get('CLEAR_AFTER_UPGRADE') == '1': (root/'remaining').write_text('0')
elif 'apt-get update' in command:
if os.environ.get('INDEX_FAIL') == '1': sys.exit(3)
elif 'apt list --upgradable' in command:
if os.environ.get('PROBE_FAIL') == '1': sys.exit(1)
print('Listing...')
pending = (root/'remaining').read_text() if (root/'remaining').exists() else os.environ['REMAINING']
for n in range(int(pending)):
print(f'pkg{n}/stable 2.0 amd64 [upgradable from: 1.0]')
elif command == '/usr/bin/docker --version':
if os.environ.get('DOCKER_PROBE_FAIL') == '1': sys.exit(1)
print('Docker version 29.9.0')
else: sys.exit(91)
else: sys.exit(92)
''')
driver = self.folder / "capture.py"
driver.write_text('''
import os, pathlib
import managed_installs as m
import lxc_update_observation as h
import lxc_apps as apps
root = pathlib.Path(os.environ['FIXTURE_DIR'])
m._DB_DIR = str(root)
m._REGISTRY_PATH = str(root/'registry.json')
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)
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())
''')
self.write("python3", "#!/bin/bash\n"
f"exec {shlex.quote(sys.executable)} {shlex.quote(str(driver))} \"${{@:2}}\"\n")
self.write("sleep", "#!/bin/sh\nexit 0\n")
self.write("flock", "#!/bin/sh\nexit 0\n")
names = {"_finalize_lxc_update", "_terminal_lxc_update_completed",
"api_lxc_updates_applied", "_run_scheduled_update"}
nodes = [n for n in ast.parse(observed.SERVER.read_text()).body
if isinstance(n, ast.FunctionDef) and n.name in names]
self.scope = self.fixture.scope
self.scope.update({
"os": os, "re": re, "time": time, "uuid": uuid, "datetime": datetime, "json": json,
"request": request, "_LXC_APPLY_UPDATES_SCRIPT": str(RUNNER),
"_LXC_INSTALL_CHECKED_OS_SCRIPT": str(ROOT / 'scripts/lxc/install_checked_os_updates.sh'),
"_APPLY_UPDATES_SCRIPT": str(RUNNER),
"_normalise_lxc_update_run_id": lambda value, **_: value or "manual-fixture",
"_normalise_lxc_update_targets": lambda values, target: values or [target],
"_normalise_lxc_update_labels": lambda values: values or [],
"_json_list": lambda value: json.loads(value or '[]'),
"_lxc_update_finalizations": {}, "_lxc_update_finalization_lock": threading.RLock(),
"_LXC_UPDATE_FINALIZATION_TTL": 3600,
"_fast_guest_status": lambda *_: self.state.read_text(),
"_lxc_update_snapshot": lambda *_: {"ct_name": "fixture"},
"_lxc_update_target_labels": lambda *_: ["OS"],
"_lxc_update_details": lambda **kwargs: str(kwargs),
"_publish_guest_modal_cache_revision": Mock(),
"notification_manager": Mock(),
"_create_lxc_update_log": lambda *_: (None, None),
"_append_lxc_update_log": lambda *_: None,
"_prune_lxc_update_logs": lambda *_: None,
"_normalise_schedule_targets": lambda sched: sched.get('targets') or ['os'],
"_scheduled_helper_enabled": lambda *_: False,
"_compose_scheduled_update_command": lambda *_, **__: '',
"_inspect_lxc_reboot_requirement": lambda *_, **__: (False, [], None),
})
exec(compile(ast.Module(body=nodes, type_ignores=[]), 'update-completion', 'exec'), self.scope)
def write(self, name, content):
path = self.bin / name
path.write_text(content)
path.chmod(0o755)
def run_manual(self, **extra):
env = {**self.env, **extra}
result = self.real_run(['/bin/bash', str(RUNNER)], env=env, text=True,
capture_output=True, timeout=20)
# The runner's separate process has not modified the shared registry.
self.assertEqual(managed.get_registry()['items'][0]['update_check']['_count'], 20)
self.scope['_terminal_lxc_update_completed'](script_path=str(RUNNER),
params={'RUN_ID': env.get('RUN_ID'), 'VMID': '100', 'TARGET': 'os'},
exit_code=result.returncode, duration_seconds=1)
return result
def test_manual_hook_stores_verified_zero_after_shutdown_without_restart(self):
result = self.run_manual()
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertEqual(self.state.read_text(), 'stopped')
row = self.fixture.row()['update_check']
self.assertEqual(row['count'], 0)
self.assertIsNone(row['error'])
self.assertEqual(row['os_family'], 'debian')
log = self.log.read_text()
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.assertEqual(managed.get_registry()['items'][1]['update_check']['_count'], 7)
self.assertEqual(self.fixture.commands, [], 'GET and stopped finalization run no guest probes')
def test_partial_update_keeps_verified_remaining_packages(self):
result = self.run_manual(REMAINING='3', UPDATE_FAIL='1')
self.assertEqual(result.returncode, 4)
self.assertEqual(self.state.read_text(), 'stopped')
self.assertEqual(self.fixture.row()['update_check']['count'], 3)
def assert_unverified(self, flag):
result = self.run_manual(**{flag: '1'})
self.assertEqual(result.returncode, 0, 'verification failure is not updater failure')
self.assertEqual(self.state.read_text(), 'stopped')
stored = managed.get_registry()['items'][0]['update_check']
self.assertTrue(stored['error'])
self.assertNotIn('_count', stored)
self.assertTrue(self.fixture.row()['update_check']['error'])
def test_failed_probe_does_not_present_old_count_or_fresh_zero(self):
self.assert_unverified('PROBE_FAIL')
def test_missing_capture_does_not_present_old_count_or_fresh_zero(self):
self.assert_unverified('CAPTURE_FAIL')
def test_web_runner_without_identity_reports_unverified_instead_of_silent_skip(self):
env = {**self.env, 'EXECUTION_MODE': 'web'}
env.pop('RUN_ID')
result = self.real_run(['/bin/bash', str(RUNNER)], env=env,
capture_output=True, text=True, timeout=20)
self.assertEqual(result.returncode, 0)
self.assertEqual(self.state.read_text(), 'stopped')
self.assertIn('verification has no RUN_ID', result.stderr)
self.assertNotIn('CAPTURE', self.log.read_text())
def test_rejected_handoff_is_retained_with_safe_diagnostic_and_no_false_zero(self):
self.observations.mkdir(mode=0o700)
path = self.observations / '100-manual-fixture.json'
path.write_text(json.dumps({'private_payload': 'must-not-be-logged'}))
path.chmod(0o600)
output = io.StringIO()
with redirect_stdout(output):
self.scope['_finalize_lxc_update'](100, run_id='manual-fixture',
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.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'])
def test_frontend_completion_route_deduplicates_authoritative_hook(self):
self.run_manual()
class InlineThread:
def __init__(self, target, args, kwargs, **_):
self.callback = lambda: target(*args, **kwargs)
def start(self):
self.callback()
self.scope['threading'] = SimpleNamespace(Thread=InlineThread)
response = self.fixture.client.post('/api/lxc-updates/100/applied', json={
'success': True, 'run_id': 'manual-fixture', 'target': 'os'})
self.assertEqual(response.status_code, 202)
self.assertEqual(self.fixture.row()['update_check']['count'], 0)
self.scope['notification_manager'].emit_event.assert_called_once()
def test_entrypoint_and_imported_server_share_finalization_for_same_run(self):
# AppRun executes flask_server as __main__; health collectors later
# import flask_server by name. Those namespaces must not own separate
# finalization maps when the terminal hook and HTTP callback collide.
tree = ast.parse(observed.SERVER.read_text())
state_names = {'_lxc_update_finalization_lock', '_lxc_update_finalizations'}
initializers = []
for node in tree.body:
if isinstance(node, ast.ImportFrom) and node.module == 'lxc_update_finalization':
initializers.append(node)
elif isinstance(node, ast.Assign) and any(
isinstance(target, ast.Name) and target.id in state_names for target in node.targets):
initializers.append(node)
elif isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name) and node.target.id in state_names:
initializers.append(node)
hook_scope = dict(self.scope)
exec(compile(ast.Module(body=initializers, type_ignores=[]), 'entrypoint-state', 'exec'), hook_scope)
exec(compile(ast.Module(body=initializers, type_ignores=[]), 'imported-state', 'exec'), self.scope)
for scope in (hook_scope, self.scope):
scope['_lxc_update_finalizations'].clear()
self.addCleanup(scope['_lxc_update_finalizations'].clear)
functions = [node for node in tree.body if isinstance(node, ast.FunctionDef)
and node.name in {'_finalize_lxc_update', '_terminal_lxc_update_completed'}]
exec(compile(ast.Module(body=functions, type_ignores=[]), 'entrypoint-hook', 'exec'), hook_scope)
self.scope['_terminal_lxc_update_completed'] = hook_scope['_terminal_lxc_update_completed']
self.run_manual()
self.assertEqual(self.fixture.row()['update_check']['count'], 0)
class InlineThread:
def __init__(self, target, args, kwargs, **_):
self.callback = lambda: target(*args, **kwargs)
def start(self):
self.callback()
self.scope['threading'] = SimpleNamespace(Thread=InlineThread)
response = self.fixture.client.post('/api/lxc-updates/100/applied', json={
'success': True, 'run_id': 'manual-fixture', 'target': 'os'})
self.assertEqual(response.status_code, 202)
result = self.fixture.row()['update_check']
self.assertEqual(result['count'], 0)
self.assertIsNone(result['error'])
self.scope['notification_manager'].emit_event.assert_called_once()
def test_scheduled_path_executes_real_runner_and_real_finalization(self):
def run(command, **kwargs):
kwargs['env'].update({key: value for key, value in self.env.items()
if key != 'RUN_ID'})
return self.real_run(command, **kwargs)
self.scope['subprocess'] = SimpleNamespace(run=run, TimeoutExpired=subprocess.TimeoutExpired)
result = self.scope['_run_scheduled_update'](100, {'targets': ['os'], 'target': 'os'})
self.assertEqual(result['status'], 'success', result)
self.assertEqual(self.state.read_text(), 'stopped')
self.assertEqual(self.fixture.row()['update_check']['count'], 0)
def test_legacy_cli_without_run_id_preserves_update_and_original_state(self):
env = dict(self.env)
env.pop('RUN_ID')
result = self.real_run(['/bin/bash', str(RUNNER)], env=env,
capture_output=True, text=True, timeout=20)
self.assertEqual(result.returncode, 0)
self.assertEqual(self.state.read_text(), 'stopped')
self.assertNotIn('CAPTURE', self.log.read_text())
def test_appimage_packaging_copies_the_actual_observation_helper(self):
builder = (ROOT / 'AppImage/scripts/build_appimage.sh').read_text()
copies = [line for line in builder.splitlines()
if line.startswith('cp ') and '$SCRIPT_DIR/lxc_update_observation.py' in line]
self.assertEqual(len(copies), 1)
appdir = self.folder / 'AppDir'
(appdir / 'usr/bin').mkdir(parents=True)
result = self.real_run(['/bin/bash', '-ec', copies[0]], env={**os.environ,
'SCRIPT_DIR': str(ROOT / 'AppImage/scripts'), 'APP_DIR': str(appdir)},
capture_output=True, text=True, timeout=5)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual((appdir / 'usr/bin/lxc_update_observation.py').read_bytes(),
(ROOT / 'AppImage/scripts/lxc_update_observation.py').read_bytes())
shared = [line for line in builder.splitlines()
if line.startswith('cp ') and '$SCRIPT_DIR/lxc_update_finalization.py' in line]
self.assertEqual(len(shared), 1)
result = self.real_run(['/bin/bash', '-ec', shared[0]], env={**os.environ,
'SCRIPT_DIR': str(ROOT / 'AppImage/scripts'), 'APP_DIR': str(appdir)},
capture_output=True, text=True, timeout=5)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual((appdir / 'usr/bin/lxc_update_finalization.py').read_bytes(),
(ROOT / 'AppImage/scripts/lxc_update_finalization.py').read_bytes())
def test_bad_or_untrusted_handoffs_are_not_accepted(self):
self.run_manual()
self.observations.mkdir(exist_ok=True)
path = self.observations / '100-manual-fixture.json'
for body in ('{}', '[]', '{bad'):
path.write_text(body)
path.chmod(0o600)
self.assertIsNone(handoff.read(100, 'manual-fixture'))
path.unlink()
path.symlink_to(self.folder / 'registry.json')
self.assertIsNone(handoff.read(100, 'manual-fixture'))
with self.assertRaises(ValueError):
handoff.read(100, '../../escape')
def test_wrong_run_expired_incomplete_and_public_observations_are_rejected(self):
handoff.capture(100, 'manual-fixture')
original = handoff.read(100, 'manual-fixture')
self.assertIsNotNone(original)
path = self.observations / '100-manual-fixture.json'
cases = []
for key, value in (('vmid', 200), ('run_id', 'wrong-run'),
('captured_at', '2020-01-01T00:00:00+00:00'),
('captured_at', '2999-01-01T00:00:00+00:00')):
cases.append({**original, key: value})
cases.append({**original, 'result': {'available': False, 'last_check': 'now'}})
for data in cases:
path.write_text(json.dumps(data))
self.assertIsNone(handoff.read(100, 'manual-fixture'))
path.write_text(json.dumps(original))
path.chmod(0o644)
self.assertIsNone(handoff.read(100, 'manual-fixture'))
path.unlink()
os.mkfifo(path, mode=0o600)
self.assertIsNone(handoff.read(100, 'manual-fixture'))
def test_oci_observation_never_runs_a_generic_package_listing(self):
result = self.run_manual(OCI='1')
self.assertEqual(result.returncode, 0)
self.assertNotIn('apt list --upgradable', self.log.read_text())
self.assertTrue(self.fixture.row()['update_check']['is_oci_lxc'])
def test_oci_handoff_without_family_accepts_only_complete_skipped_zero(self):
with patch.object(managed, '_probe_lxc_is_oci', return_value=True):
observation = handoff.capture(100, 'manual-fixture')
observation['entry']['_os_family'] = None
path = self.observations / '100-manual-fixture.json'
path.write_text(json.dumps(observation))
self.assertIsNotNone(handoff.read(100, 'manual-fixture'))
for patch_result in ({'available': True}, {'_count': 1},
{'_security_count': 1}, {'_packages': [{'name': 'unexpected'}]}):
path.write_text(json.dumps({**observation, 'result': {**observation['result'], **patch_result}}))
self.assertIsNone(handoff.read(100, 'manual-fixture'))
observation['entry']['_is_oci'] = False
path.write_text(json.dumps(observation))
self.assertIsNone(handoff.read(100, 'manual-fixture'), 'non-OCI still needs a verified supported family')
def test_reboot_failure_preserves_exit_code_and_still_verifies_before_stop(self):
result = self.run_manual(RESTART='1', REBOOT_FAIL='1')
self.assertEqual(result.returncode, 6)
self.assertEqual(self.state.read_text(), 'stopped')
self.assertEqual(self.fixture.row()['update_check']['count'], 0)
def test_capture_precedes_reboot_and_is_not_retried_in_transitional_guest(self):
result = self.run_manual(RESTART='1', REBOOT_TRANSITION='1')
self.assertEqual(result.returncode, 0)
self.assertEqual(self.state.read_text(), 'stopped')
self.assertEqual(self.fixture.row()['update_check']['count'], 0)
log = self.log.read_text()
self.assertLess(log.index('CAPTURE'), log.index('reboot'))
self.assertEqual(log.count('CAPTURE'), 1)
def test_registry_failure_does_not_discard_the_valid_observation(self):
result = self.real_run(['/bin/bash', str(RUNNER)], env=self.env,
capture_output=True, text=True, timeout=20)
self.assertEqual(result.returncode, 0)
with patch.object(managed, '_write_registry', return_value=False):
outcome = self.scope['_finalize_lxc_update'](100, run_id='manual-fixture',
status='success', source='manual', target='os')
self.assertTrue(outcome['verification_errors'])
self.assertIsNotNone(handoff.read(100, 'manual-fixture'))
self.assertEqual(managed.get_registry()['items'][0]['update_check']['_count'], 20)
def test_originally_running_guest_keeps_existing_finalization_path(self):
self.state.write_text('running')
import lxc_apps
with patch.object(lxc_apps, 'mark_manual_versions_unverified'), \
patch.object(lxc_apps, 'check_all'):
result = self.run_manual()
self.assertEqual(result.returncode, 0)
self.assertEqual(self.state.read_text(), 'running')
self.assertNotIn('CAPTURE', self.log.read_text())
self.assertEqual(self.fixture.row()['update_check']['count'], 0)
def test_stopped_finalizer_consumes_captured_app_versions_without_guest_probes(self):
import lxc_apps
self.state.write_text('running')
observation = handoff.capture(100, 'manual-fixture')
observation['apps'] = [{'id': 'docker', 'installed_version': '29.9.0'}]
self.state.write_text('stopped')
with patch.object(handoff, 'read', return_value=observation), \
patch.object(lxc_apps, 'store_installed_observations', create=True,
return_value={'errors': [], 'applied': 1}) as store, \
patch.object(lxc_apps, 'check_all') as check, \
patch.object(lxc_apps, 'mark_manual_versions_unverified') as manual:
self.scope['_finalize_lxc_update'](100, run_id='manual-fixture',
status='success', source='manual', target='both',
requested_targets=['os', 'app:ghstats'])
store.assert_called_once_with(100, observation['apps'])
manual.assert_called_once_with(100, ['ghstats'])
check.assert_not_called()
def test_real_os_runner_updates_registered_docker_before_shutdown_and_backend_merge(self):
import lxc_apps
folder = self.folder / 'apps'
folder.mkdir()
sidecar = folder / '100.json'
original = {'apps': [{'id': 'docker', 'installed_via': 'binary',
'binary_path': '/usr/bin/docker', 'state': {'installed_version': '29.8.1',
'latest_version': '29.9.0', 'checked_at': '2026-01-01T00:00:00Z',
'update_available': True}}], 'schedule': {'enabled': False}}
sidecar.write_text(json.dumps(original))
with patch.object(lxc_apps, '_APPS_DIR', str(folder)), \
patch.object(lxc_apps, 'check_all') as check:
result = self.run_manual()
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertEqual(self.state.read_text(), 'stopped')
saved = json.loads(sidecar.read_text())
self.assertEqual(saved['apps'][0]['state']['installed_version'], '29.9.0')
self.assertFalse(saved['apps'][0]['state']['update_available'])
self.assertEqual(saved['schedule'], original['schedule'])
commands = self.log.read_text()
self.assertLess(commands.index('/usr/bin/docker'), commands.index('shutdown'))
self.assertEqual(commands.count('/usr/bin/docker'), 1)
check.assert_not_called()
def test_failed_app_write_retains_handoff_and_does_not_claim_verified(self):
import lxc_apps
self.state.write_text('running')
observation = handoff.capture(100, 'manual-fixture')
observation['apps'] = []
path = handoff._path(100, 'manual-fixture')
path.write_text(json.dumps(observation))
self.state.write_text('stopped')
with patch.object(lxc_apps, 'store_installed_observations', side_effect=OSError):
outcome = self.scope['_finalize_lxc_update'](100, run_id='manual-fixture',
status='success', source='manual', target='os')
self.assertTrue(path.exists())
self.assertIn('post-update application persistence failed', outcome['verification_errors'])
if __name__ == '__main__':
unittest.main()