mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-02 10:52:22 +00:00
feat(oci): show update availability and update selected applications
This commit is contained in:
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user