diff --git a/AppImage/scripts/flask_server.py b/AppImage/scripts/flask_server.py index bb4dc9ca..5f6ba83b 100644 --- a/AppImage/scripts/flask_server.py +++ b/AppImage/scripts/flask_server.py @@ -6704,6 +6704,9 @@ def _get_lxc_app_watch_map() -> dict: out: dict = {} try: import lxc_apps + # An application installed from the menu since the last look is + # registered now and shows with the next refresh of the page. + lxc_apps.register_new_oci_installations() out.update(lxc_apps.get_active_apps() or {}) except Exception: pass diff --git a/AppImage/scripts/lxc_apps.py b/AppImage/scripts/lxc_apps.py index fc85f3c2..f67a3460 100644 --- a/AppImage/scripts/lxc_apps.py +++ b/AppImage/scripts/lxc_apps.py @@ -5473,6 +5473,12 @@ def _oci_secondary_member(record) -> bool: return member.get("primary_vmid") not in (None, record.get("vmid")) +# One registration at a time for each container: the sweep at startup, the App +# tab and the look at new installations can all ask for the same one. +_oci_registration_locks: dict = {} +_oci_registration_guard = threading.Lock() + + def ensure_oci_registration(vmid) -> bool: """Register the application ProxMenux installed from an OCI image the first time the Monitor sees its container, with version tracking by the @@ -5481,6 +5487,53 @@ def ensure_oci_registration(vmid) -> bool: as a whole from its main container. A container that already has applications is left alone, and so is one whose registration the user removed.""" + with _oci_registration_guard: + lock = _oci_registration_locks.setdefault(int(vmid), threading.Lock()) + with lock: + return _register_oci_installation(vmid) + + +# The records already looked at, each by the time it was last written. +_oci_records_seen: dict = {} + + +def register_new_oci_installations(wait: bool = False) -> list: + """Register the applications installed from OCI images since the last + look, so one installed from the menu shows in the Monitor without opening + its container. A record is looked at again only when it is written again, + as it is when its installation ends. The registration asks the registry + for the version of the image, so it runs apart from the request that + noticed the installation. Returns the containers it looks at.""" + try: + folders = [entry for entry in os.scandir(_OCI_INSTANCE_ROOT) if entry.name.isdigit()] + except OSError: + return [] + pending = [] + for folder in folders: + try: + written = os.stat(os.path.join(folder.path, "oci-compose.json")).st_mtime_ns + except OSError: + continue + vmid = int(folder.name) + if _oci_records_seen.get(vmid) != written: + _oci_records_seen[vmid] = written + pending.append(vmid) + + def register(): + for vmid in pending: + try: + ensure_oci_registration(vmid) + except Exception as exc: + print(f"[ProxMenux] lxc_apps: OCI registration of CT {vmid} failed: {exc}") + + if pending and wait: + register() + elif pending: + threading.Thread(target=register, name="oci-registration", daemon=True).start() + return pending + + +def _register_oci_installation(vmid) -> bool: record = _read_oci_record(vmid) if not record or record.get("status") != "installed": return False diff --git a/AppImage/scripts/tests/test_oci_new_installation_registration.py b/AppImage/scripts/tests/test_oci_new_installation_registration.py new file mode 100644 index 00000000..508906a2 --- /dev/null +++ b/AppImage/scripts/tests/test_oci_new_installation_registration.py @@ -0,0 +1,132 @@ +"""An application installed from the OCI menu is registered by the Monitor +without opening its container: the list of guests looks at the records of the +installer and registers the ones written since its last look.""" +import json +import sys +import tempfile +import threading +import time +from pathlib import Path +import unittest +from unittest.mock import patch + +SCRIPTS = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(SCRIPTS)) +import lxc_apps + + +class NewInstallationRegistrationTests(unittest.TestCase): + def setUp(self): + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.root = Path(tmp.name) + (self.root / 'catalog').mkdir() + (self.root / 'catalog/index.json').write_text(json.dumps({'applications': []})) + (self.root / 'instances').mkdir() + patches = [patch.object(lxc_apps, '_OCI_INSTANCE_ROOT', str(self.root / 'instances')), + patch.object(lxc_apps, '_OCI_CATALOG_INDEX', str(self.root / 'catalog/index.json')), + patch.object(lxc_apps, '_oci_catalog_cache', None), + patch.object(lxc_apps, '_APPS_DIR', str(self.root / 'apps')), + patch.object(lxc_apps, '_OCI_DISMISSED_FILE', str(self.root / 'apps/.oci-dismissed.json')), + patch.object(lxc_apps, '_oci_records_seen', {})] + for item in patches: + item.start() + self.addCleanup(item.stop) + self.moment = time.time_ns() + + def record(self, vmid, status='installed'): + folder = self.root / f'instances/{vmid}' + folder.mkdir(parents=True, exist_ok=True) + path = folder / 'oci-compose.json' + path.write_text(json.dumps({ + 'vmid': vmid, 'status': status, 'installation_id': f'install-{vmid}', + 'template': {'id': 'image-duplicati-official', 'catalog_ui': {'title': 'Duplicati Official'}, + 'first_run': {'endpoints': [{'port': 8200, 'scheme': 'http', 'path': '/'}]}, + 'container_contract': {'image': {'reference': 'duplicati/duplicati:latest'}, + 'ports': [{'container_port': 8200}]}}})) + # Every write of a record leaves a later time on it. + self.moment += 1_000_000_000 + import os + os.utime(path, ns=(self.moment, self.moment)) + + def sidecar(self, vmid): + folder = self.root / 'apps' + folder.mkdir(exist_ok=True) + (folder / f'{vmid}.json').write_text(json.dumps( + {'apps': [{'id': 'a', 'name': 'Duplicati Official', 'installed_via': 'oci_image', 'logo_url': 'x'}]})) + + def test_a_new_installation_is_registered_without_opening_its_container(self): + self.record(125) + with patch.object(lxc_apps, 'add_app', return_value=(True, {})) as add: + self.assertEqual(lxc_apps.register_new_oci_installations(wait=True), [125]) + self.assertEqual(add.call_args.args[0], 125) + self.assertEqual(add.call_args.args[1]['name'], 'Duplicati Official') + self.assertEqual(add.call_args.args[1]['installed_via'], 'oci_image') + + def test_a_record_is_looked_at_once_until_it_is_written_again(self): + self.record(125) + with patch.object(lxc_apps, 'ensure_oci_registration') as ensure: + self.assertEqual(lxc_apps.register_new_oci_installations(wait=True), [125]) + self.assertEqual(lxc_apps.register_new_oci_installations(wait=True), []) + self.assertEqual(lxc_apps.register_new_oci_installations(wait=True), []) + self.record(126) + self.assertEqual(lxc_apps.register_new_oci_installations(wait=True), [126]) + self.record(125) + self.assertEqual(lxc_apps.register_new_oci_installations(wait=True), [125]) + self.assertEqual([call.args[0] for call in ensure.call_args_list], [125, 126, 125]) + + def test_an_installation_is_registered_when_it_ends(self): + self.record(125, status='installing') + with patch.object(lxc_apps, 'add_app', return_value=(True, {})) as add: + lxc_apps.register_new_oci_installations(wait=True) + add.assert_not_called() + self.record(125) + lxc_apps.register_new_oci_installations(wait=True) + add.assert_called_once() + + def test_the_request_that_notices_it_does_not_wait_for_the_registration(self): + self.record(125) + started, release = threading.Event(), threading.Event() + + def slow(vmid): + started.set() + release.wait(5) + + with patch.object(lxc_apps, 'ensure_oci_registration', side_effect=slow): + self.assertEqual(lxc_apps.register_new_oci_installations(), [125]) + self.assertTrue(started.wait(5)) + # Still registering: a second look starts nothing more. + self.assertEqual(lxc_apps.register_new_oci_installations(), []) + release.set() + + def test_two_requests_for_the_same_container_register_it_once(self): + self.record(125) + calls = [] + + def add(vmid, payload, notify=True): + calls.append(vmid) + time.sleep(0.2) + self.sidecar(vmid) + return True, {} + + with patch.object(lxc_apps, 'add_app', side_effect=add): + threads = [threading.Thread(target=lxc_apps.ensure_oci_registration, args=(125,)) for _ in range(3)] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + self.assertEqual(calls, [125]) + + def test_no_records_and_no_folder_are_not_an_error(self): + self.assertEqual(lxc_apps.register_new_oci_installations(wait=True), []) + with patch.object(lxc_apps, '_OCI_INSTANCE_ROOT', str(self.root / 'missing')): + self.assertEqual(lxc_apps.register_new_oci_installations(wait=True), []) + + def test_the_list_of_guests_looks_for_new_installations(self): + source = (SCRIPTS / 'flask_server.py').read_text() + body = source.split('def _get_lxc_app_watch_map() -> dict:', 1)[1].split('\ndef ', 1)[0] + self.assertLess(body.index('lxc_apps.register_new_oci_installations()'), body.index('lxc_apps.get_active_apps()')) + + +if __name__ == '__main__': + unittest.main()