mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-09-30 02:26:53 +00:00
fix(monitor): register every OCI service port and read versions from the guest
This commit is contained in:
@@ -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" } : {}),
|
||||
|
||||
+119
-17
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user