diff --git a/.github/scripts/tests/test_oci_selection_setup_wording.py b/.github/scripts/tests/test_oci_selection_setup_wording.py index af2b9d38..9d35d7d5 100644 --- a/.github/scripts/tests/test_oci_selection_setup_wording.py +++ b/.github/scripts/tests/test_oci_selection_setup_wording.py @@ -7,7 +7,7 @@ from pathlib import Path import subprocess from types import SimpleNamespace from unittest import TestCase -from unittest.mock import Mock +from unittest.mock import Mock, patch ROOT = Path(__file__).resolve().parents[3] MENU = ROOT / 'oci/src/proxmenux_oci/management.py' @@ -48,6 +48,42 @@ class SelectionSetupWording(TestCase): ui.message.assert_called_once_with(EMPTY, 'OCI management') ui.choose.assert_not_called() + def _menu(self, inventories, selections): + rows = iter(inventories) + ui = SimpleNamespace(message=Mock(), choose=Mock(side_effect=selections)) + refresh = Mock(return_value={}) + manage = Mock(return_value=True) + fn = extracted('_interactive_management', os=SimpleNamespace(geteuid=lambda: 0), + shutil=SimpleNamespace(which=lambda _: '/fake/pct'), saved_inventory=lambda _: next(rows), + _clean_orphans=lambda project: None, + recover_automatically=lambda project: None, offer_recovery=lambda project, ui: None, + carry_records=refresh, manage_instance=manage, menu_size=lambda: 10, + STATUS_LABELS={}, translate=lambda s: s) + fn(Path('/fixture'), ui) + return ui, refresh, manage + + def test_the_list_comes_back_after_an_operation_without_refreshing_every_copy(self): + row = {'vmid': 101, 'title': 'Jellyfin', 'hostname': 'jellyfin', 'status': 'installed', 'image': 'jellyfin/jellyfin'} + ui, refresh, manage = self._menu([[row], [row]], ['101', None]) + self.assertEqual(ui.choose.call_count, 2) + manage.assert_called_once() + refresh.assert_called_once_with(Path('/fixture'), mount_stopped=False, shown=True) + + def test_removing_the_last_application_returns_without_a_message(self): + row = {'vmid': 101, 'title': 'Jellyfin', 'hostname': 'jellyfin', 'status': 'installed', 'image': 'jellyfin/jellyfin'} + ui, _, manage = self._menu([[row], []], ['101']) + manage.assert_called_once() + ui.message.assert_not_called() + + def test_the_watchdog_refreshes_the_copy_of_the_containers_it_changed(self): + refresh = Mock(return_value={}) + watchdog = SimpleNamespace(set_watchdog=Mock(side_effect=[[101, 102]])) + fn = extracted('set_watchdog', sys=SimpleNamespace(path=[]), carry_records=refresh, + instances=SimpleNamespace(ROOT=Path('/fixture'))) + with patch.dict('sys.modules', {'oci_watchdog': watchdog}): + self.assertTrue(fn(Path('/fixture'), [102], True)) + refresh.assert_called_once_with(Path('/fixture'), mount_stopped=False, vmids=[101, 102]) + def test_selector_filters_missing_guest_without_erasing_saved_contract(self): class Entry: name = '101' diff --git a/oci/src/proxmenux_oci/management.py b/oci/src/proxmenux_oci/management.py index 41e9928b..cbf8e041 100644 --- a/oci/src/proxmenux_oci/management.py +++ b/oci/src/proxmenux_oci/management.py @@ -254,27 +254,33 @@ def _interactive_management(project, ui): recover_automatically(project) offer_recovery(project, ui) carry_records(project, mount_stopped=False, shown=True) - rows = saved_inventory(project) - if not rows: - ui.message(translate('No registered OCI containers are available for selection on this host.'), translate('OCI management')) - return - # Same layout as the catalog lists; only an unusual state is shown. - tag_width = max(len(str(r['vmid'])) for r in rows) - header = f" {'CT':<{tag_width + 2}}{translate('Application')[:32]:<32} {translate('Image')}" - options = [] - for r in rows: - line = f"{(r['title'] or r['hostname'])[:32]:<32} " - if r['status'] == 'installed': - line += r['image'][:30] - else: - line += f"\\Z1{translate(STATUS_LABELS.get(r['status'], r['status']))}\\Zn" - options.append((str(r['vmid']), f"{line:<72}")) - selection = ui.choose(header, options, size=menu_size(), colors=True, - title=translate('Manage installed OCI applications')) - if selection is None: - return - row = next(r for r in rows if str(r['vmid']) == selection) - manage_instance(project, ui, row) + # After each operation the list is shown again. Every operation leaves + # the copies of the records up to date, so they are not all refreshed again. + first = True + while True: + rows = saved_inventory(project) + if not rows: + if first: + ui.message(translate('No registered OCI containers are available for selection on this host.'), translate('OCI management')) + return + first = False + # Same layout as the catalog lists; only an unusual state is shown. + tag_width = max(len(str(r['vmid'])) for r in rows) + header = f" {'CT':<{tag_width + 2}}{translate('Application')[:32]:<32} {translate('Image')}" + options = [] + for r in rows: + line = f"{(r['title'] or r['hostname'])[:32]:<32} " + if r['status'] == 'installed': + line += r['image'][:30] + else: + line += f"\\Z1{translate(STATUS_LABELS.get(r['status'], r['status']))}\\Zn" + options.append((str(r['vmid']), f"{line:<72}")) + selection = ui.choose(header, options, size=menu_size(), colors=True, + title=translate('Manage installed OCI applications')) + if selection is None: + return + row = next(r for r in rows if str(r['vmid']) == selection) + manage_instance(project, ui, row) def set_watchdog(project, vmids, enabled): @@ -291,6 +297,8 @@ def set_watchdog(project, vmids, enabled): done.update(oci_watchdog.set_watchdog(instances.ROOT, vmid, enabled)) except (OSError, ValueError, KeyError): return False + # The copy a restore on another host reads carries the watchdog as well. + carry_records(project, mount_stopped=False, vmids=sorted(done)) return True