From 4a77d11bc4b1f6935b52fa48dee08e5a67d15c2a Mon Sep 17 00:00:00 2001 From: MacRimi Date: Mon, 28 Sep 2026 17:05:52 +0200 Subject: [PATCH] fix(monitor): register every OCI service port and read versions from the guest --- AppImage/components/lxc-app-panel.tsx | 25 +++- AppImage/scripts/lxc_apps.py | 136 ++++++++++++++++--- AppImage/scripts/tests/test_oci_endpoints.py | 129 ++++++++++++++++++ oci/catalog/apps/frigate.json | 7 +- oci/catalog/curated/frigate.json | 7 +- oci/schemas/oci-template.schema.json | 15 +- 6 files changed, 297 insertions(+), 22 deletions(-) create mode 100644 AppImage/scripts/tests/test_oci_endpoints.py diff --git a/AppImage/components/lxc-app-panel.tsx b/AppImage/components/lxc-app-panel.tsx index bdbd8b20..c64a6c84 100644 --- a/AppImage/components/lxc-app-panel.tsx +++ b/AppImage/components/lxc-app-panel.tsx @@ -111,11 +111,22 @@ interface AppConfig { exclude_from_badge?: boolean } +// A port as the installation record states it: the application answers on +// the first one, and any other is a service of its own (go2rtc next to Frigate). +interface PortDetail { + port: number + scheme: string | null + path: string | null + description: string + logo_url: string +} + interface DetectedApp { slug: string name: string logo_url?: string | null default_ports?: number[] + port_details?: PortDetail[] | null // Categoría preset from helpers_cache.category_names[0] — used to // auto-fill the Web Link editor when the user clicks "Register". category?: string | null @@ -269,6 +280,7 @@ interface Suggestions { web_path_hint: string | null tracking_suggestion?: TrackingSuggestion | null default_ports?: number[] + port_details?: PortDetail[] | null logo_url?: string | null category?: string | null extras?: DetectedApp[] @@ -641,6 +653,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop name: suggestions.name_suggestion, logo_url: suggestions.logo_url, default_ports: suggestions.default_ports, + port_details: suggestions.port_details, category: suggestions.category, tracking_suggestion: suggestions.tracking_suggestion, scheme: suggestions.oci_instance?.scheme === "https" ? "https" @@ -844,7 +857,17 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop const suggestedPorts = isOciAdguard && p.slug === "image-adguard-home" ? [80, ...(adguardSetupAvailable ? [3000] : [])] : p.default_ports || [] - if (p.slug !== "docker" && suggestedPorts.length) { + if (p.port_details?.length && !(isOciAdguard && p.slug === "image-adguard-home")) { + seed.ports = p.port_details.map((d) => ({ + port: d.port, + ...(d.description ? { description: d.description } : {}), + scheme: d.scheme === "https" ? "https" as const + : d.scheme === "http" ? "http" as const : defaultSchemeFor(d.port), + web_path: d.path || s?.web_path_hint || "", + ...(d.logo_url ? { logo_url: d.logo_url } : {}), + ...(p.category ? { category: p.category } : {}), + })) + } else if (p.slug !== "docker" && suggestedPorts.length) { seed.ports = suggestedPorts.map((port) => ({ port, ...(isOciAdguard && port === 3000 ? { description: "Config" } : {}), diff --git a/AppImage/scripts/lxc_apps.py b/AppImage/scripts/lxc_apps.py index 849a68a1..caed7219 100644 --- a/AppImage/scripts/lxc_apps.py +++ b/AppImage/scripts/lxc_apps.py @@ -5313,7 +5313,7 @@ def _helper_slug_meta(vmid) -> Optional[dict]: _OCI_INSTANCE_ROOT = "/usr/local/share/proxmenux/oci/instances" _OCI_CATALOG_INDEX = "/usr/local/share/proxmenux/oci/engine/catalog/index.json" -_oci_catalog_cache: tuple[float, dict] | None = None +_oci_catalog_cache: tuple[float, dict, dict] | None = None def _oci_catalog_icons() -> dict: @@ -5325,26 +5325,72 @@ def _oci_catalog_icons() -> dict: answered 404 — so the panel reads the catalog and keeps the record as the fallback for an application the catalog no longer lists. """ + return _oci_catalog_entries()[0] + + +def _oci_catalog_entries() -> tuple[dict, dict]: + """Icons and template files of the catalog, keyed by template id and by + application id.""" global _oci_catalog_cache now = time.time() if _oci_catalog_cache and now - _oci_catalog_cache[0] < 600: - return _oci_catalog_cache[1] + return _oci_catalog_cache[1], _oci_catalog_cache[2] icons: dict = {} + templates: dict = {} try: with open(_OCI_CATALOG_INDEX, encoding="utf-8") as handle: for item in (json.load(handle) or {}).get("applications", []): - icon = (item or {}).get("icon") - if not isinstance(icon, str) or not icon.startswith("http"): - continue + item = item or {} + icon = item.get("icon") + template = item.get("template") # An installation records the template id; the catalog is keyed # by the application id and carries both. for key in (item.get("template_id"), item.get("id")): - if key: + if not key: + continue + if isinstance(icon, str) and icon.startswith("http"): icons.setdefault(key, icon) + if isinstance(template, str) and template.startswith("apps/"): + templates.setdefault(key, template) except (OSError, ValueError, TypeError): pass - _oci_catalog_cache = (now, icons) - return icons + _oci_catalog_cache = (now, icons, templates) + return icons, templates + + +def _oci_catalog_first_run(template_id) -> dict: + """The first_run contract of the current catalog template. An existing + install keeps a copy from the day it was created; what the catalog added + since, such as a service icon, is read here.""" + template = _oci_catalog_entries()[1].get(template_id or "") + if not template: + return {} + try: + with open(os.path.join(os.path.dirname(_OCI_CATALOG_INDEX), template), encoding="utf-8") as handle: + first_run = (json.load(handle) or {}).get("first_run") + except (OSError, ValueError, TypeError, AttributeError): + return {} + return first_run if isinstance(first_run, dict) else {} + + +def _oci_catalog_endpoint_icons(template_id) -> dict: + """Icon of each service a catalog application serves on a port of its own, + keyed by port.""" + endpoints = _oci_catalog_first_run(template_id).get("endpoints") or [] + return {entry["port"]: entry["icon"] for entry in endpoints + if isinstance(entry, dict) and isinstance(entry.get("port"), int) + and isinstance(entry.get("icon"), str) and entry["icon"].startswith("https://")} + + +# "Web UI 2" names no service: the LinuxServer templates list the same +# interface over http and https, or a port devices report to, that way. +_OCI_GENERIC_ENDPOINT = re.compile(r"(web\s*ui|ui)?\s*\d*", re.IGNORECASE) + + +def _oci_service_name(label) -> str: + """The service an endpoint label names: "go2rtc WebUI" is go2rtc.""" + label = str(label or "").strip() + return re.sub(r"\s*(web\s*ui|ui)$", "", label, flags=re.IGNORECASE).strip() or label def _oci_localised(value) -> str: @@ -5433,20 +5479,27 @@ def ensure_oci_registration(vmid) -> bool: meta = _oci_instance_meta(vmid) if not meta or not meta.get("name") or not _NAME_RE.match(str(meta["name"])): return False - port = meta.get("endpoint_port") or next(iter(meta.get("ports") or []), None) + category = meta.get("category_label") or meta.get("category") or "" + endpoints = meta.get("endpoints") or [] + if not endpoints: + port = meta.get("endpoint_port") or next(iter(meta.get("ports") or []), None) + endpoints = [{"port": port, "scheme": meta.get("endpoint_scheme"), "path": meta.get("endpoint_path"), + "description": "", "logo_url": ""}] if isinstance(port, int) else [] + ports = [] + for endpoint in endpoints: + entry = {"port": endpoint["port"], "scheme": endpoint.get("scheme") or "http", + "web_path": endpoint.get("path") or "/", "category": category, + "description": endpoint.get("description") or ""} + if endpoint.get("logo_url"): + entry["logo_url"] = endpoint["logo_url"] + ports.append(entry) payload = { "name": meta["name"], "installed_via": "oci_image", "helper_slug": meta.get("template_id") or "", "logo_url": meta.get("logo") or "", "update_method": "none", - "ports": [{ - "port": port, - "scheme": meta.get("endpoint_scheme") or "http", - "web_path": meta.get("endpoint_path") or "/", - "category": meta.get("category_label") or meta.get("category") or "", - "description": "", - }] if isinstance(port, int) else [], + "ports": ports, } # Registrations made at startup would each announce their update on their # own; the scheduled sweep sends pending updates together instead. @@ -5545,6 +5598,29 @@ def _oci_instance_meta(vmid) -> Optional[dict]: if 1 <= port <= 65535 and port not in ports: ports.append(port) template_id = str(template.get("id") or "").strip() + # The application is reached on its first endpoint and carries its own + # name and logo. Any other endpoint is a service of its own, such as + # go2rtc next to Frigate, and is described by its name and icon. + endpoints = [] + endpoint_icons = None + for entry in (template.get("first_run") or {}).get("endpoints") or []: + port = entry.get("port") if isinstance(entry, dict) else None + if not isinstance(port, int) or not 1 <= port <= 65535 or port in [e["port"] for e in endpoints]: + continue + detail = {"port": port, + "scheme": str(entry.get("scheme") or "").strip().lower() or None, + "path": str(entry.get("path") or "").strip() or None, + "description": "", "logo_url": ""} + if endpoints: + if _OCI_GENERIC_ENDPOINT.fullmatch(str(entry.get("label") or "").strip()): + continue + if endpoint_icons is None: + endpoint_icons = _oci_catalog_endpoint_icons(template_id) + icon = entry.get("icon") if isinstance(entry.get("icon"), str) else "" + icon = endpoint_icons.get(port) or icon + detail["description"] = _oci_service_name(entry.get("label")) + detail["logo_url"] = icon if icon.startswith("https://") else "" + endpoints.append(detail) catalog_icons = _oci_catalog_icons() logo = catalog_icons.get(template_id) or "" if not logo: @@ -5575,6 +5651,7 @@ def _oci_instance_meta(vmid) -> Optional[dict]: "endpoint_scheme": str(endpoint.get("scheme") or "").strip().lower() or None, "endpoint_path": str(endpoint.get("path") or "").strip() or None, "ports": ports, + "endpoints": endpoints, # The exact image this container was created from. Its digest is what # an update is decided on; the version label is only for reading. "installed_digest": str(observed_image.get("manifest_digest") or "").strip() or None, @@ -5677,6 +5754,10 @@ def _oci_image_versions(vmid, known: Optional[dict] = None, with_latest: bool = result["image_created"] = installed.get("created") except Exception as exc: return {**result, "error": f"could not read the installed image: {exc}"} + if not result.get("installed_version"): + # The container runs the installed digest, so what it states is the + # version of that image; once read it is kept with the digest. + result["installed_version"] = _oci_guest_version(vmid, meta.get("template_id")) if not with_latest: return result try: @@ -5693,6 +5774,18 @@ def _oci_image_versions(vmid, known: Optional[dict] = None, with_latest: bool = return result +def _oci_guest_version(vmid, template_id) -> Optional[str]: + """The application version for an image that publishes none, read from + the file the catalog template names. Frigate states it only in + /opt/frigate/frigate/version.py. Needs the container running.""" + spec = _oci_catalog_first_run(template_id).get("version_file") or {} + path, pattern = spec.get("path"), spec.get("regex") + if not (isinstance(path, str) and path.startswith("/") and isinstance(pattern, str)): + return None + rc, out, _err = _pct_exec(vmid, ["cat", path]) + return _extract_version(out, pattern) if rc == 0 else None + + def _oci_image_label(version: Optional[str], created: Optional[str], digest: Optional[str]) -> str: """One image, as a line a reader can compare: version, build date, digest.""" parts = [version] if version else [] @@ -6029,6 +6122,7 @@ def get_suggestions(vmid, force: bool = False) -> dict: "tracking_suggestion": det_tracking, }) + port_details = None if oci_meta: # The record states what this container runs, so a probe finding is # noise: CT 152 runs Chromium and ships a docker client, and offering @@ -6042,7 +6136,11 @@ def get_suggestions(vmid, force: bool = False) -> dict: name_sug = oci_meta["name"] or name_sug logo_url = oci_meta["logo"] or logo_url category_suggestion = oci_meta["category_label"] or suggest_category_for(slug) - if oci_meta["endpoint_port"]: + if oci_meta["endpoints"]: + port_details = oci_meta["endpoints"] + default_ports = [e["port"] for e in port_details] + ports = default_ports + [p for p in (oci_meta["ports"] or ports) if p not in default_ports] + elif oci_meta["endpoint_port"]: default_ports = [oci_meta["endpoint_port"]] ports = [oci_meta["endpoint_port"]] + [p for p in (oci_meta["ports"] or ports) if p != oci_meta["endpoint_port"]] @@ -6053,6 +6151,7 @@ def get_suggestions(vmid, force: bool = False) -> dict: # The first-run endpoint disappears once setup switches to :80. default_ports = [80] ports = [80, 3000] + port_details = None # Version tracking comes with the registration. Its updates are # decided by the image, which always has a build date and a digest, # so it applies even to an image that states no application version. @@ -6077,6 +6176,9 @@ def get_suggestions(vmid, force: bool = False) -> dict: "web_path_hint": web_hint, "tracking_suggestion": tracking, "default_ports": default_ports, + # Scheme, path, description and icon of each suggested port, when the + # installation record states them. + "port_details": port_details, "logo_url": logo_url or None, # Identity of a ProxMenux OCI install: the scheme the endpoint is # served on, the image it was created from, and the upstream source. diff --git a/AppImage/scripts/tests/test_oci_endpoints.py b/AppImage/scripts/tests/test_oci_endpoints.py new file mode 100644 index 00000000..1e63c58d --- /dev/null +++ b/AppImage/scripts/tests/test_oci_endpoints.py @@ -0,0 +1,129 @@ +import json +import sys +import tempfile +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 + +ICON = 'https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/go2rtc.webp' +VERSION_FILE = {'path': '/opt/frigate/frigate/version.py', 'regex': 'VERSION = "([0-9]+(?:\\.[0-9]+)+)'} +ENDPOINTS = [ + {'label': 'Frigate WebUI', 'scheme': 'http', 'port': 5000, 'path': '/', 'source': 'x'}, + {'label': 'go2rtc WebUI', 'scheme': 'http', 'port': 1984, 'path': '/', 'source': 'x'}, +] + + +class CatalogFixture(unittest.TestCase): + def setUp(self): + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.root = Path(tmp.name) + (self.root / 'catalog/apps').mkdir(parents=True) + (self.root / 'catalog/index.json').write_text(json.dumps({'applications': [ + {'id': 'frigate', 'template_id': 'image-frigate', 'template': 'apps/frigate.json', + 'icon': 'https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/frigate.webp'}]})) + catalog_endpoints = [dict(ENDPOINTS[0]), dict(ENDPOINTS[1], icon=ICON)] + (self.root / 'catalog/apps/frigate.json').write_text(json.dumps( + {'id': 'image-frigate', 'first_run': {'endpoints': catalog_endpoints, 'version_file': VERSION_FILE}})) + 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)] + for item in patches: + item.start() + self.addCleanup(item.stop) + + def install(self, endpoints, template_id='image-frigate'): + (self.root / 'instances/190').mkdir(parents=True) + (self.root / 'instances/190/oci-compose.json').write_text(json.dumps({ + 'vmid': 190, 'status': 'installed', 'installation_id': 'install-190', + 'observed': {'image': {'manifest_digest': 'sha256:' + 'b1' * 32, 'architecture': 'amd64'}}, + 'template': {'id': template_id, 'catalog_ui': {'title': 'Frigate'}, + 'first_run': {'endpoints': endpoints}, + 'container_contract': {'image': {'reference': 'ghcr.io/blakeblackshear/frigate:stable'}, + 'ports': [{'container_port': 8971}]}}})) + return lxc_apps._oci_instance_meta(190) + + +class OciEndpointTests(CatalogFixture): + def test_second_endpoint_is_its_own_service_with_the_current_catalog_icon(self): + # The record was written before the catalog gave go2rtc an icon. + meta = self.install(ENDPOINTS) + self.assertEqual(meta['endpoints'], [ + {'port': 5000, 'scheme': 'http', 'path': '/', 'description': '', 'logo_url': ''}, + {'port': 1984, 'scheme': 'http', 'path': '/', 'description': 'go2rtc', 'logo_url': ICON}, + ]) + self.assertEqual(meta['endpoint_port'], 5000) + + def test_single_endpoint_and_unknown_template(self): + meta = self.install([ENDPOINTS[0]], template_id='image-other') + self.assertEqual(meta['endpoints'], [ + {'port': 5000, 'scheme': 'http', 'path': '/', 'description': '', 'logo_url': ''}]) + + def test_generic_second_endpoint_is_not_a_service(self): + # LinuxServer lists the same interface over http and https as "Web UI 1" and "Web UI 2". + meta = self.install([dict(ENDPOINTS[0], label='Web UI 1', port=3000), + dict(ENDPOINTS[0], label='Web UI 2', port=3001, scheme='https')], + template_id='image-krita') + self.assertEqual([e['port'] for e in meta['endpoints']], [3000]) + + def test_service_name_from_label(self): + for label, name in [('go2rtc WebUI', 'go2rtc'), ('Admin Web UI', 'Admin'), ('Setup UI', 'Setup'), + ('Dashboard', 'Dashboard'), ('WebUI', 'WebUI'), ('', '')]: + with self.subTest(label=label): + self.assertEqual(lxc_apps._oci_service_name(label), name) + + def test_suggestion_offers_every_endpoint(self): + self.install(ENDPOINTS) + with patch.object(lxc_apps, '_probe_listening_ports', return_value=[]), \ + patch.object(lxc_apps, '_helper_slug_meta', return_value=None), \ + patch.object(lxc_apps, '_fetch_tracking_hints', return_value={}), \ + patch.object(lxc_apps, '_oci_image_versions', return_value={'error': 'offline'}), \ + patch.object(lxc_apps, '_read_sidecar', return_value=None): + suggestion = lxc_apps.get_suggestions(190) + self.assertEqual(suggestion['default_ports'], [5000, 1984]) + self.assertEqual([d['description'] for d in suggestion['port_details']], ['', 'go2rtc']) + self.assertEqual(suggestion['port_details'][1]['logo_url'], ICON) + + def test_automatic_registration_registers_every_endpoint(self): + self.install(ENDPOINTS) + with patch.object(lxc_apps, '_read_sidecar', return_value=None), \ + patch.object(lxc_apps, '_oci_dismissed', return_value={}), \ + patch.object(lxc_apps, 'add_app', return_value=(True, {})) as add: + self.assertTrue(lxc_apps.ensure_oci_registration(190)) + ports = add.call_args.args[1]['ports'] + self.assertEqual([(p['port'], p['description']) for p in ports], [(5000, ''), (1984, 'go2rtc')]) + self.assertNotIn('logo_url', ports[0]) + self.assertEqual(ports[1]['logo_url'], ICON) + + +class OciGuestVersionTests(CatalogFixture): + def test_version_read_from_the_file_the_catalog_names(self): + with patch.object(lxc_apps, '_pct_exec', return_value=(0, 'VERSION = "0.18.0-77a66e7"\n', '')) as run: + self.assertEqual(lxc_apps._oci_guest_version(190, 'image-frigate'), '0.18.0') + run.assert_called_once_with(190, ['cat', '/opt/frigate/frigate/version.py']) + + def test_stopped_container_and_template_without_version_file(self): + with patch.object(lxc_apps, '_pct_exec', return_value=(1, '', 'CT 190 not running')): + self.assertIsNone(lxc_apps._oci_guest_version(190, 'image-frigate')) + with patch.object(lxc_apps, '_pct_exec') as run: + self.assertIsNone(lxc_apps._oci_guest_version(190, 'image-other')) + run.assert_not_called() + + def test_image_without_version_label_uses_the_guest(self): + self.install(ENDPOINTS) + # A cached answer without a version must not stop the guest read. + known = {'installed_digest': 'sha256:' + 'b1' * 32, 'installed_version': None, 'image_created': '2026-09-06'} + with patch.object(lxc_apps, '_oci_operation_running', return_value=False), \ + patch.object(lxc_apps, '_oci_state_module', return_value=object()), \ + patch.object(lxc_apps, '_pct_exec', return_value=(0, 'VERSION = "0.18.0-77a66e7"', '')): + result = lxc_apps._oci_image_versions(190, known=known, with_latest=False) + self.assertEqual(result['installed_version'], '0.18.0') + self.assertEqual(result['image_created'], '2026-09-06') + + +if __name__ == '__main__': + unittest.main() diff --git a/oci/catalog/apps/frigate.json b/oci/catalog/apps/frigate.json index 10e8cd54..ad80bf52 100644 --- a/oci/catalog/apps/frigate.json +++ b/oci/catalog/apps/frigate.json @@ -333,9 +333,14 @@ "scheme": "http", "port": 1984, "path": "/", - "source": "integrated-go2rtc-native-oci-endpoint" + "source": "integrated-go2rtc-native-oci-endpoint", + "icon": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/go2rtc.webp" } ], + "version_file": { + "path": "/opt/frigate/frigate/version.py", + "regex": "VERSION = \"([0-9]+(?:\\.[0-9]+)+)" + }, "credentials": [] }, "proxmox": { diff --git a/oci/catalog/curated/frigate.json b/oci/catalog/curated/frigate.json index 17a212d7..4e61e278 100644 --- a/oci/catalog/curated/frigate.json +++ b/oci/catalog/curated/frigate.json @@ -333,9 +333,14 @@ "scheme": "http", "port": 1984, "path": "/", - "source": "integrated-go2rtc-native-oci-endpoint" + "source": "integrated-go2rtc-native-oci-endpoint", + "icon": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/go2rtc.webp" } ], + "version_file": { + "path": "/opt/frigate/frigate/version.py", + "regex": "VERSION = \"([0-9]+(?:\\.[0-9]+)+)" + }, "credentials": [] }, "proxmox": { diff --git a/oci/schemas/oci-template.schema.json b/oci/schemas/oci-template.schema.json index 55b82628..1e75f7f1 100644 --- a/oci/schemas/oci-template.schema.json +++ b/oci/schemas/oci-template.schema.json @@ -29,7 +29,8 @@ "additionalProperties": false, "properties": { "by": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9-]{0,38}$"}, - "date": {"type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"} + "date": {"type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"}, + "report": {"type": "string", "pattern": "^https://github\\.com/MacRimi/ProxMenux/(issues/[0-9]+|discussions/[0-9]+(#discussioncomment-[0-9]+)?)$"} } }, "catalog_ui": { @@ -216,11 +217,21 @@ "scheme": {"enum": ["http", "https"]}, "port": {"type": "integer", "minimum": 1, "maximum": 65535}, "path": {"type": "string", "pattern": "^/"}, - "source": {"type": "string"} + "source": {"type": "string"}, + "icon": {"type": "string", "pattern": "^https://"} }, "additionalProperties": false } }, + "version_file": { + "type": "object", + "required": ["path", "regex"], + "properties": { + "path": {"type": "string", "pattern": "^/"}, + "regex": {"type": "string"} + }, + "additionalProperties": false + }, "credentials": { "type": "array", "items": {