fix(monitor): an application installed from the OCI menu shows on the Apps page on its own

This commit is contained in:
MacRimi
2026-10-13 16:56:57 +02:00
parent d7f2f13cd9
commit ea5831d1bf
3 changed files with 188 additions and 0 deletions
+3
View File
@@ -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
+53
View File
@@ -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
@@ -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()