feat(oci): show update availability and update selected applications

This commit is contained in:
Jonatan Castro
2026-10-19 11:23:45 +02:00
parent 29778aa709
commit 70d4101243
22 changed files with 1644 additions and 80 deletions
+283
View File
@@ -0,0 +1,283 @@
"""The OCI menu shares the Monitor's checker and cache, with no fallback."""
import datetime
import json
import os
from pathlib import Path
import socket
import stat
import subprocess
import sys
import tempfile
import threading
from types import SimpleNamespace
import unittest
from unittest.mock import Mock, patch
import uuid
ROOT = Path(__file__).resolve().parents[1]
sys.path[:0] = [str(ROOT / 'src'), str(ROOT / 'remote'), str(ROOT.parent / 'AppImage/scripts')]
import oci_update_check as check
import oci_menu_bridge as bridge
import lxc_apps
OLD = 'sha256:' + 'a' * 64
NEW = 'sha256:' + 'b' * 64
class UpdateCheckTests(unittest.TestCase):
def setUp(self):
tmp = tempfile.TemporaryDirectory(); self.addCleanup(tmp.cleanup)
self.folder = Path(tmp.name)
self.root, self.configs, self.apps = [self.folder / name for name in ('instances', 'configs', 'apps')]
for path in (self.root, self.configs, self.apps): path.mkdir()
self.now = datetime.datetime.now(datetime.timezone.utc).replace(microsecond=0)
def record(self, vmid=110, reference='example/app:latest', **changes):
identifier = str(uuid.uuid4())
record = {'schema_version': 1, 'vmid': vmid, 'installation_id': identifier,
'status': 'installed', 'deployment': {'hostname': f'app-{vmid}'},
'template': {'container_contract': {'image': {'reference': reference}}},
'observed': {'image': {'manifest_digest': OLD}}}
record.update(changes)
directory = self.root / str(vmid); directory.mkdir(exist_ok=True)
(directory / 'oci-compose.json').write_text(json.dumps(record))
(self.configs / f'{vmid}.conf').write_text(f'#proxmenux-instance={identifier}\narch: amd64\n')
return record
def sidecar(self, vmid=110, **changes):
state = {'checked_at': self.now.strftime('%Y-%m-%dT%H:%M:%SZ'),
'installed_digest': OLD, 'latest_digest': NEW,
'image_reference': 'example/app:latest', 'update_available': True}
state.update(changes)
data = {'vmid': vmid, 'apps': [{'id': f'app-{vmid}', 'installed_via': 'oci_image', 'state': state}]}
(self.apps / f'{vmid}.json').write_text(json.dumps(data))
return data
def status(self, **kwargs):
return check.cached(self.root, self.configs, apps=self.apps, now=self.now.timestamp(), **kwargs)
def test_menu_reads_only_native_cache_without_subprocess_or_socket(self):
self.record(); self.sidecar()
before = {str(p): p.read_bytes() for p in self.folder.rglob('*') if p.is_file()}
with patch.object(subprocess, 'run') as run, patch.object(socket, 'socket') as connection:
state = self.status()
self.assertEqual([r['vmid'] for r in state['available']], [110])
run.assert_not_called(); connection.assert_not_called()
after = {str(p): p.read_bytes() for p in self.folder.rglob('*') if p.is_file()}
self.assertEqual(before, after)
self.assertNotIn('resolve_candidate', check.__dict__)
self.assertNotIn('query', check.__dict__)
def test_missing_native_cache_is_unknown_even_with_old_separate_cache(self):
self.record()
(self.root / '.update-check.json').write_text(json.dumps({'applications': [{'vmid': 110, 'status': 'available'}]}))
state = self.status()
self.assertFalse(state['checked']); self.assertEqual(state['unknown'], 1)
self.assertFalse(state['available'])
def test_native_false_is_current_without_recomputing_digests(self):
self.record(); self.sidecar(update_available=False)
state = self.status()
self.assertTrue(state['checked']); self.assertEqual(state['unknown'], 0)
self.assertFalse(state['available'])
def test_native_retry_retains_available_result_and_unknown_state(self):
self.record(); self.sidecar(registry_retry=True)
self.assertEqual(len(self.status()['available']), 1)
self.assertEqual(self.status()['unknown'], 1)
def test_native_error_or_missing_result_never_means_up_to_date(self):
self.record()
for changes in ({'error': 'registry unreachable'}, {'update_available': None}, {'latest_digest': None}):
self.sidecar(**changes)
state = self.status()
self.assertFalse(state['available']); self.assertEqual(state['unknown'], 1)
def test_stale_future_or_different_image_cache_is_not_available(self):
self.record()
for changes in ({'checked_at': (self.now - datetime.timedelta(days=2)).strftime('%Y-%m-%dT%H:%M:%SZ')},
{'checked_at': (self.now + datetime.timedelta(days=2)).strftime('%Y-%m-%dT%H:%M:%SZ')},
{'installed_digest': NEW}, {'image_reference': 'other/image:latest'}):
self.sidecar(**changes)
self.assertFalse(self.status()['available'])
self.assertEqual(self.status()['unknown'], 1)
def test_invalid_identity_or_pending_native_edit_invalidates_cached_update(self):
self.record(); self.sidecar()
(self.configs / '110.conf').write_text('arch: amd64\n')
self.assertFalse(self.status()['available'])
self.record(pending_transaction={'operation': 'update'})
self.assertFalse(self.status()['available'])
def test_snapshot_identity_cannot_replace_current_identity(self):
record = self.record(); self.sidecar()
(self.configs / '110.conf').write_text(
f"arch: amd64\n\n[previous]\n#proxmenux-instance={record['installation_id']}\n")
self.assertFalse(self.status()['available'])
def test_remote_or_deleted_guest_is_excluded(self):
self.record(); self.sidecar(); (self.configs / '110.conf').unlink()
self.assertEqual(self.status()['applications'], 0)
self.assertFalse(self.status()['available'])
def test_corrupt_ambiguous_or_symlinked_sidecars_are_unknown(self):
self.record(); path = self.apps / '110.json'
path.write_text('{broken')
self.assertEqual(self.status()['unknown'], 1)
data = self.sidecar(); data['apps'] *= 2; path.write_text(json.dumps(data))
self.assertEqual(self.status()['unknown'], 1)
path.unlink(); path.symlink_to(self.root / '110/oci-compose.json')
self.assertEqual(self.status()['unknown'], 1)
def test_stack_counts_once_and_follows_monitors_primary_tracking_rule(self):
self.record(110, stack_member={'primary_vmid': 110}, stack={'members': [{'vmid': 110}, {'vmid': 111}]})
self.record(111, stack_member={'primary_vmid': 110})
self.sidecar(110, update_available=False); self.sidecar(111, update_available=True)
self.assertEqual(self.status()['applications'], 1)
self.assertFalse(self.status()['available'])
self.sidecar(110)
self.assertEqual([r['vmid'] for r in self.status()['available']], [110])
def test_incomplete_stack_is_not_offered_for_update(self):
self.record(110, stack={'members': [{'vmid': 110}, {'vmid': 111}]}); self.sidecar()
self.assertFalse(self.status()['available'])
self.assertEqual(self.status()['unknown'], 1)
def test_real_bridge_calls_native_checker_and_publishes_its_sidecar(self):
record = self.record(); self.sidecar(update_available=False)
path = self.folder / 'check.sock'
server = bridge._Server(str(path), bridge._Handler); server.apps = lxc_apps
worker = threading.Thread(target=server.serve_forever, daemon=True); worker.start()
self.addCleanup(server.server_close); self.addCleanup(server.shutdown)
result = {'installed_digest': OLD, 'installed_registry_digest': OLD,
'image_reference': 'example/app:latest', 'installed_version': '1.0',
'latest_version': '2.0', 'latest_digest': NEW, 'update_available': True}
events = []
with patch.object(bridge, '_root_peer', return_value=True), \
patch.object(check, 'monitor_available', return_value=True), \
patch.object(lxc_apps, '_APPS_DIR', str(self.apps)), \
patch.object(lxc_apps, '_read_oci_record', return_value=record), \
patch.object(lxc_apps, 'ensure_oci_registration'), \
patch.object(lxc_apps, '_oci_image_versions', return_value=result) as native, \
patch.object(lxc_apps, '_fire_update_notification') as notify, \
patch.object(subprocess, 'run') as external:
check.check(self.root, self.configs, path=path, progress=lambda *event: events.append(event))
native.assert_called_once(); notify.assert_not_called(); external.assert_not_called()
self.assertEqual(events[0], (0, 1)); self.assertEqual(events[-1], (1, 1))
self.assertEqual([r['vmid'] for r in self.status()['available']], [110])
self.assertFalse((self.root / '.update-check.json').exists())
def test_missing_monitor_never_falls_back_to_registry_commands(self):
self.record()
with patch.object(check, 'monitor_available', return_value=False), patch.object(subprocess, 'run') as run:
with self.assertRaisesRegex(RuntimeError, 'Monitor'):
check.check(self.root, self.configs)
run.assert_not_called()
def test_readonly_monitor_availability_rejects_non_private_endpoints(self):
path = Mock(); path.parent.lstat.return_value = SimpleNamespace(st_mode=stat.S_IFDIR | 0o700, st_uid=0)
path.lstat.return_value = SimpleNamespace(st_mode=stat.S_IFSOCK | 0o600, st_uid=0)
self.assertTrue(check.monitor_available(path))
path.lstat.return_value.st_mode |= 0o066
self.assertFalse(check.monitor_available(path))
path.lstat.return_value.st_mode = stat.S_IFREG | 0o600
self.assertFalse(check.monitor_available(path))
path.lstat.return_value.st_mode = stat.S_IFSOCK | 0o600
path.parent.lstat.return_value.st_uid = 1000
self.assertFalse(check.monitor_available(path))
def test_full_or_closed_progress_pipe_does_not_abort_client(self):
for error in (BlockingIOError(), BrokenPipeError()):
with patch.object(check.os, 'write', side_effect=error), patch.object(check.sys, 'stdout'):
check.emit_progress(1, 2)
class BridgeTests(unittest.TestCase):
def module(self):
module = Mock()
module._read_oci_record.return_value = {'vmid': 110, 'installation_id': 'id-110', 'status': 'installed'}
module._oci_secondary_member.return_value = False
module.load_sidecar.return_value = {'apps': [{'id': 'native-app', 'installed_via': 'oci_image'},
{'id': 'other-app', 'installed_via': 'dpkg'}]}
return module
def test_only_native_oci_app_is_checked_without_unsolicited_notifications(self):
module = self.module(); events = []
bridge.check_applications(module, [{'vmid': 110, 'installation_id': 'id-110'}], events.append)
module.check_app.assert_called_once_with(110, 'native-app', force=True, notify=False)
self.assertEqual(events, [{'completed': 0, 'total': 1}, {'completed': 1, 'total': 1}, {'done': True}])
def test_replaced_guest_pending_operation_and_secondary_are_not_queried(self):
for changes, secondary in (({'installation_id': 'replacement'}, False),
({'pending_transaction': {'operation': 'update'}}, False),
({'pending_stack_transaction': {'operation': 'update'}}, False),
({}, True)):
module = self.module(); module._read_oci_record.return_value.update(changes)
module._oci_secondary_member.return_value = secondary
bridge.check_applications(module, [{'vmid': 110, 'installation_id': 'id-110'}], lambda _: None)
module.check_app.assert_not_called()
def test_dismissed_registration_is_not_reinvented(self):
module = self.module(); module.load_sidecar.return_value = None
bridge.check_applications(module, [{'vmid': 110, 'installation_id': 'id-110'}], lambda _: None)
module.check_app.assert_not_called()
def test_progress_advances_when_fast_app_finishes_before_first_slow_app(self):
module = self.module(); release = threading.Event(); events = []
module._read_oci_record.side_effect = lambda vmid: {
'vmid': vmid, 'installation_id': f'id-{vmid}', 'status': 'installed'}
def run(vmid, *args, **kwargs):
if vmid == 110 and not release.wait(3):
raise AssertionError('The fast app was blocked by the slow app')
def report(event):
events.append(event)
if event.get('completed') == 1: release.set()
module.check_app.side_effect = run
bridge.check_applications(module, [{'vmid': 110, 'installation_id': 'id-110'},
{'vmid': 111, 'installation_id': 'id-111'}], report)
self.assertEqual(events, [{'completed': 0, 'total': 2}, {'completed': 1, 'total': 2},
{'completed': 2, 'total': 2}, {'done': True}])
self.assertEqual(module.check_app.call_count, 2)
def test_unexpected_failure_does_not_skip_other_apps_or_report_success(self):
module = self.module(); events = []
module._read_oci_record.side_effect = lambda vmid: {
'vmid': vmid, 'installation_id': f'id-{vmid}', 'status': 'installed'}
module.check_app.side_effect = lambda vmid, *a, **kw: (_ for _ in ()).throw(RuntimeError()) if vmid == 110 else None
bridge.check_applications(module, [{'vmid': 110, 'installation_id': 'id-110'},
{'vmid': 111, 'installation_id': 'id-111'}], events.append)
self.assertEqual(module.check_app.call_count, 2)
self.assertEqual(events[-2], {'completed': 2, 'total': 2})
self.assertEqual(events[-1], {'error': 'check_failed'})
def test_requests_cannot_specify_commands_images_paths_or_duplicate_guests(self):
valid = {'vmid': 110, 'installation_id': 'id'}
for request in ({'command': ['sh']}, {'applications': '110'}, {'applications': [valid, valid]},
{'applications': [{**valid, 'image': 'private:image'}]},
{'applications': [{**valid, 'vmid': '../110'}]},
{'applications': [{**valid, 'vmid': True}]}):
with self.subTest(request=request), self.assertRaises(ValueError): bridge._applications(request)
def test_client_disconnect_stops_before_next_application(self):
module = self.module(); calls = []
def report(event):
calls.append(event)
if event.get('completed') == 1: raise BrokenPipeError()
with self.assertRaises(BrokenPipeError):
bridge.check_applications(module, [{'vmid': 110, 'installation_id': 'id-110'},
{'vmid': 111, 'installation_id': 'id-111'}], report)
self.assertEqual(module.check_app.call_count, 1)
def test_non_root_peers_are_rejected_before_reading_requests(self):
handler = object.__new__(bridge._Handler); handler.request = Mock(); handler.rfile = Mock()
with patch.object(bridge, '_root_peer', return_value=False): handler.handle()
handler.rfile.readline.assert_not_called()
def test_build_and_monitor_startup_include_bridge(self):
scripts = ROOT.parent / 'AppImage/scripts'
self.assertIn('oci_menu_bridge.py', (scripts / 'build_appimage.sh').read_text())
self.assertIn('oci_menu_bridge.start(lxc_apps)', (scripts / 'flask_server.py').read_text())
if __name__ == '__main__':
unittest.main()
+274
View File
@@ -0,0 +1,274 @@
"""Explicit update selection and live status redraw without lifecycle shortcuts."""
from pathlib import Path
import ast
import json
import os
import re
import signal
import subprocess
import sys
from types import SimpleNamespace
import unittest
from unittest.mock import Mock, patch
ROOT = Path(__file__).resolve().parents[1]
sys.path[:0] = [str(ROOT / 'src'), str(ROOT / 'remote')]
from proxmenux_oci import cli, management, updates
from proxmenux_oci.ui import DialogUI, MenuRefresh, ReadableTags, UserCancelled
AVAILABLE = [{'vmid': 110, 'title': 'First app'}, {'vmid': 111, 'title': 'Second app'}]
class SelectionTests(unittest.TestCase):
def select(self, picked, available=AVAILABLE, review=True, fresh=None, outcomes=None):
ui = Mock()
ui.checklist.side_effect = picked if isinstance(picked, Exception) else [picked]
ui.review.return_value = review
current = [{**row, 'reason': 'matched'} for row in available]
with patch.object(updates, 'UpdateCheck') as checker, \
patch.object(management, 'saved_inventory', return_value=current), \
patch.object(management, 'manage_instance', side_effect=outcomes or [True] * len(available)) as run:
checker.return_value.status.return_value = {'available': available if fresh is None else fresh}
result = updates.select_updates(ROOT, ui, available)
return result, ui, run
def test_one_update_is_not_checked_by_default(self):
_, ui, run = self.select([], available=AVAILABLE[:1])
self.assertEqual(ui.checklist.call_args.args[2], [])
self.assertEqual(ui.checklist.call_args.args[1][0], ('all', 'Select all'))
run.assert_not_called()
def test_only_selected_app_uses_existing_update_flow(self):
_, ui, run = self.select(['111'])
self.assertEqual(run.call_count, 1)
self.assertEqual(run.call_args.args[2]['vmid'], 111)
self.assertEqual(run.call_args.kwargs, {'action': 'update'})
self.assertIn('Second app', ui.review.call_args.args[0])
self.assertNotIn('First app', ui.review.call_args.args[0])
def test_select_all_includes_every_app_once(self):
_, ui, run = self.select(['all', '111'])
self.assertEqual([c.args[2]['vmid'] for c in run.call_args_list], [110, 111])
self.assertFalse(ui.review.call_args.kwargs['default'])
def test_cancel_does_nothing(self):
_, ui, run = self.select(UserCancelled())
ui.review.assert_not_called(); run.assert_not_called()
def test_selection_has_an_update_button_and_no_extra_action(self):
result, ui, run = self.select([])
self.assertEqual(ui.checklist.call_args.kwargs['ok_label'], 'Update')
self.assertNotIn('extra_button', ui.checklist.call_args.kwargs)
self.assertFalse(result); ui.review.assert_not_called(); run.assert_not_called()
def test_changed_app_blocks_batch_before_any_lifecycle(self):
_, ui, run = self.select(['all'], fresh=AVAILABLE[1:])
run.assert_not_called()
self.assertIn('changed since', ui.message.call_args.args[0])
def test_failure_or_per_app_cancel_stops_later_selections(self):
_, _, run = self.select(['all'], outcomes=[False])
self.assertEqual(run.call_count, 1)
def test_declining_review_returns_to_selection_instead_of_updating(self):
ui = Mock(); ui.checklist.side_effect = [['all'], []]; ui.review.return_value = False
with patch.object(management, 'manage_instance') as run:
self.assertFalse(updates.select_updates(ROOT, ui, AVAILABLE))
run.assert_not_called()
self.assertEqual(ui.checklist.call_args.args[2], ['110', '111'])
class StatusTests(unittest.TestCase):
def checker(self):
return updates.UpdateCheck(ROOT)
def test_labels_show_cached_count_or_manual_check(self):
checker = self.checker()
self.assertEqual(checker.label({'available': []}), 'Check for updates')
self.assertEqual(checker.label({'available': AVAILABLE}), 'Updates available (2)')
checker.process = Mock(stdout=None); checker.process.poll.return_value = None
self.assertEqual(checker.label({'available': AVAILABLE}), 'Checking for updates (0/0)')
def test_partial_check_keeps_the_entry_clean_without_a_header_warning(self):
checker = self.checker()
state = {'checked': True, 'available': [], 'unknown': 1, 'applications': 2}
self.assertEqual(checker.label(state), 'Check for updates')
self.assertEqual(checker.notice(state), '')
def test_completed_checks_show_only_the_update_count(self):
state = {'checked': True, 'available': AVAILABLE, 'unknown': 0, 'applications': 2}
self.assertEqual(self.checker().label(state), 'Updates available (2)')
def progress_checker(self):
reader, writer = os.pipe()
stream = os.fdopen(reader, 'rb', buffering=0)
self.addCleanup(stream.close); self.addCleanup(os.close, writer)
os.set_blocking(reader, False)
checker = self.checker(); checker.total = 24
checker.process = Mock(stdout=stream); checker.process.poll.return_value = None
return checker, writer
def test_numeric_progress_redraws_only_when_the_count_changes(self):
checker, writer = self.progress_checker()
self.assertEqual(checker.label({}), 'Checking for updates (0/24)')
self.assertFalse(checker.changed())
os.write(writer, b'{"completed":12,"total":24}\n')
self.assertTrue(checker.changed())
self.assertEqual(checker.label({}), 'Checking for updates (12/24)')
self.assertFalse(checker.changed())
os.write(writer, b'{"completed":12,"total":24}\n')
self.assertFalse(checker.changed())
def test_split_and_invalid_worker_output_never_appears_in_the_menu(self):
checker, writer = self.progress_checker()
os.write(writer, b'password=secret\n{"completed":-1,"total":24}\n{"completed":')
self.assertEqual(checker.label({}), 'Checking for updates (0/24)')
os.write(writer, b'7,"total":24}\n{"completed":25,"total":24}\n')
self.assertEqual(checker.label({}), 'Checking for updates (7/24)')
def test_worker_failure_does_not_reuse_an_old_successful_result(self):
checker = self.checker(); checker.process = Mock(stdout=None)
checker.process.poll.return_value = checker.process.wait.return_value = 1
old = {'checked': True, 'available': AVAILABLE, 'unknown': 0, 'applications': 2}
with patch.object(checker.backend, 'cached', return_value=old):
state = checker.status()
self.assertEqual(checker.label(state), 'Check for updates')
self.assertEqual(state['available'], [])
self.assertEqual(checker.notice(state), '')
def test_finished_worker_keeps_the_final_redraw_pending_until_status_is_consumed(self):
checker = self.checker(); checker.completed = checker.total = 2
checker.process = Mock(stdout=None)
checker.process.poll.return_value = checker.process.wait.return_value = 0
self.assertFalse(checker.running)
self.assertTrue(checker.active)
self.assertEqual(checker.label({}), 'Checking for updates (2/2)')
self.assertTrue(checker.changed())
state = {'checked': True, 'available': AVAILABLE, 'unknown': 0, 'applications': 2}
with patch.object(checker.backend, 'cached', return_value=state):
final = checker.status()
self.assertFalse(checker.active)
self.assertEqual(checker.label(final), 'Updates available (2)')
def test_check_process_is_isolated_and_displays_no_registry_errors(self):
checker = self.checker()
with patch.object(subprocess, 'Popen') as popen, patch.object(updates.os, 'set_blocking') as blocking, \
patch.object(checker.backend, 'monitor_available', return_value=True):
checker.start()
args = popen.call_args
self.assertTrue(args.kwargs['start_new_session'])
self.assertEqual(args.kwargs['stderr'], subprocess.DEVNULL)
self.assertEqual(args.args[0][1], str(ROOT / 'remote/oci_update_check.py'))
self.assertIn('--progress', args.args[0])
self.assertEqual(args.kwargs['stdout'], subprocess.PIPE)
blocking.assert_called_once_with(checker.process.stdout.fileno(), False)
def test_missing_monitor_never_starts_a_fallback_checker(self):
checker = self.checker()
with patch.object(checker.backend, 'monitor_available', return_value=False), \
patch.object(subprocess, 'Popen') as popen:
with self.assertRaisesRegex(ValueError, 'ProxMenux Monitor'):
checker.start()
popen.assert_not_called()
def test_cancelling_stops_the_worker_process_group(self):
checker = self.checker(); process = Mock(); checker.process = process
process.pid = 4321; process.poll.return_value = None
with patch.object(updates.os, 'killpg') as kill:
checker.close()
kill.assert_called_once_with(4321, signal.SIGTERM)
process.wait.assert_called_once_with(timeout=2)
def test_finished_worker_is_not_signalled_after_its_pid_could_be_reused(self):
checker = self.checker(); checker.process = Mock(); checker.process.poll.return_value = 0
with patch.object(updates.os, 'killpg') as kill:
checker.close()
kill.assert_not_called()
class DialogTests(unittest.TestCase):
def test_status_change_redraws_menu_and_reaps_dialog(self):
process = Mock(); process.poll.return_value = None
process.communicate.side_effect = [subprocess.TimeoutExpired('dialog', .1), (None, '')]
popen = Mock(); popen.__enter__ = Mock(return_value=process); popen.__exit__ = Mock(return_value=False)
with patch.object(subprocess, 'Popen', return_value=popen):
with self.assertRaises(MenuRefresh):
DialogUI().choose('OCI', ReadableTags([('updates', 'Checking for updates...')]),
'updates', size=(20, 75, 12), refresh=lambda: True)
process.terminate.assert_called_once()
self.assertEqual(process.communicate.call_count, 2)
def test_escape_from_live_menu_remains_cancel(self):
process = Mock(); process.poll.return_value = 255; process.returncode = 255
process.communicate.return_value = (None, '')
popen = Mock(); popen.__enter__ = Mock(return_value=process); popen.__exit__ = Mock(return_value=False)
with patch.object(subprocess, 'Popen', return_value=popen):
self.assertIsNone(DialogUI().choose('OCI', [('1', 'Checking')], refresh=lambda: False))
process.terminate.assert_not_called()
def test_checklist_starts_unselected_with_update_and_cancel_only(self):
ui = DialogUI()
with patch.object(ui, '_run', return_value=SimpleNamespace(returncode=0, stderr='111\n')) as run:
picked = ui.checklist('Updates', [('all', 'Select all'), ('111', 'App')], [],
ok_label='Update')
self.assertEqual(picked, ['111'])
widget = run.call_args.args[0]
self.assertNotIn('--extra-button', widget)
self.assertEqual(widget[widget.index('--ok-label') + 1], 'Update')
self.assertNotIn('on', widget)
class MainMenuTests(unittest.TestCase):
def test_status_changes_in_the_same_main_menu_entry(self):
class Checker:
running = False
completed = 0
@property
def active(self): return self.running
def __enter__(self): return self
def __exit__(self, *exc): pass
def start(self): self.running = True
def status(self): return {'available': AVAILABLE if getattr(self, 'done', False) else []}
def changed(self): return True
def label(self, state):
return (f'Checking for updates ({self.completed}/2)' if self.running else
'Updates available (2)' if state['available'] else 'Check for updates')
def notice(self, state): return ''
checker = Checker(); seen = []
class UI:
live_refresh = True
def choose(self, text, entries, default, **kw):
seen.append(dict(entries)['updates'])
if len(seen) == 1: return 'updates'
if len(seen) == 2:
checker.completed = 1
raise MenuRefresh()
if len(seen) == 3:
checker.running = False; checker.done = True
raise MenuRefresh()
return None
with patch.object(cli, 'interactive_ui', return_value=UI()), \
patch.object(updates, 'UpdateCheck', return_value=checker):
self.assertEqual(cli.interactive(cli.Catalog(ROOT)), 0)
self.assertEqual(seen, ['Check for updates', 'Checking for updates (0/2)',
'Checking for updates (1/2)', 'Updates available (2)'])
class TranslationTests(unittest.TestCase):
def test_update_menu_literals_are_translated_in_every_shipped_language(self):
source = ast.parse((ROOT / 'src/proxmenux_oci/updates.py').read_text())
texts = {node.args[0].value for node in ast.walk(source)
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name)
and node.func.id == 'translate' and node.args
and isinstance(node.args[0], ast.Constant)}
for path in sorted((ROOT.parent / 'lang').glob('*.json')):
dictionary = json.loads(path.read_text())
for text in texts:
with self.subTest(locale=path.stem, text=text):
self.assertTrue(dictionary.get(text))
self.assertEqual(set(re.findall(r'\{\w+\}', text)),
set(re.findall(r'\{\w+\}', dictionary[text])))
if __name__ == '__main__':
unittest.main()
+73
View File
@@ -0,0 +1,73 @@
"""Native image checks reuse immutable metadata only after a fresh digest lookup."""
import json
from pathlib import Path
import sys
import unittest
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'remote'))
import oci_installation_state as state
def digest(value): return 'sha256:' + state.sha(value)
class MetadataTests(unittest.TestCase):
def setUp(self):
self.platform = json.dumps({'schemaVersion': 2, 'config': {'digest': 'sha256:' + 'c' * 64},
'layers': [{'digest': 'sha256:' + 'd' * 64, 'size': 15}]}).encode()
self.index = json.dumps({'schemaVersion': 2, 'manifests': [
{'digest': digest(self.platform), 'platform': {'architecture': 'amd64', 'os': 'linux'}}]}).encode()
self.config = json.dumps({'architecture': 'amd64', 'os': 'linux', 'created': '2026-10-18T00:00:00Z',
'config': {'Labels': {'org.opencontainers.image.version': '2.0'}, 'Env': []}}).encode()
self.known = {'manifest_digest': digest(self.platform), 'architecture': 'amd64',
'version': '1.0', 'created': '2026-10-01T00:00:00Z'}
def test_unchanged_multiarch_image_requires_only_a_fresh_index_lookup(self):
with patch.object(state, 'command', return_value=self.index) as command:
candidate = state.resolve_candidate_metadata('ghcr.io/example/private:latest', 'amd64', self.known)
command.assert_called_once_with('skopeo', 'inspect', '--raw', 'docker://ghcr.io/example/private:latest')
self.assertEqual(candidate['manifest_digest'], self.known['manifest_digest'])
self.assertEqual(candidate['version'], '1.0')
self.assertEqual(candidate['registry_digest'], digest(self.index))
def test_unchanged_singlearch_image_requires_only_a_fresh_manifest_lookup(self):
with patch.object(state, 'command', return_value=self.platform) as command:
candidate = state.resolve_candidate_metadata('example/app:latest', 'amd64', self.known)
self.assertEqual(command.call_count, 1)
self.assertEqual(candidate['version'], '1.0')
def test_changed_digest_fetches_and_validates_new_metadata(self):
old = dict(self.known, manifest_digest='sha256:' + 'a' * 64)
with patch.object(state, 'command', side_effect=[self.index, self.platform, self.config]) as command:
candidate = state.resolve_candidate_metadata('example/app:latest', 'amd64', old)
self.assertEqual(command.call_count, 3)
self.assertEqual(candidate['version'], '2.0')
self.assertEqual(candidate['manifest_digest'], digest(self.platform))
def test_different_architecture_cannot_reuse_metadata(self):
known = dict(self.known, architecture='arm64')
with patch.object(state, 'command', side_effect=[self.index, self.platform, self.config]) as command:
candidate = state.resolve_candidate_metadata('example/app:latest', 'amd64', known)
self.assertEqual(command.call_count, 3)
self.assertEqual(candidate['version'], '2.0')
def test_install_and_update_still_fetch_complete_verified_contract(self):
with patch.object(state, 'command', side_effect=[self.index, self.platform, self.config]) as command:
candidate = state.resolve_candidate('example/app:latest', 'amd64')
self.assertEqual(command.call_count, 3)
self.assertEqual(candidate['layers'], ['sha256:' + 'd' * 64])
self.assertIn('Env', candidate['defaults'])
def test_changed_manifest_must_still_match_the_registry_digest(self):
with patch.object(state, 'command', side_effect=[self.index, b'{}']):
with self.assertRaises(ValueError):
state.resolve_candidate_metadata('example/app:latest', 'amd64', None)
def test_registry_failure_cannot_be_hidden_by_saved_metadata(self):
with patch.object(state, 'command', side_effect=RuntimeError('Registry unavailable')):
with self.assertRaises(RuntimeError):
state.resolve_candidate_metadata('example/app:latest', 'amd64', self.known)
if __name__ == '__main__': unittest.main()