feat(oci): GPU selection per host and per image, one notification per update, and App tab for stack containers

- Immich asks what runs its video and its recognition in one menu, in both
  modes, and gives the GPU to the server and to Machine learning; AMD uses ROCm
- Frigate, Ollama, llama.cpp, Faster Whisper and Piper take the image built
  for the chosen GPU
- The acceleration menu offers only what the host can run
- An update or a recreation sends one notification with its result instead of
  the stop, backup and start of each container
- A private bridge with nothing connected is not reported as down
- Secondary containers of a stack appear in the App tab with their version and
  logo; Secure Gateway shows the same update state in both views
- A mistyped value in the wizard asks the same question again
This commit is contained in:
MacRimi
2026-10-02 21:45:38 +02:00
parent 20ee21c08f
commit 9b5cefb81a
55 changed files with 2294 additions and 113 deletions
+1
View File
@@ -138,6 +138,7 @@ cp "$SCRIPT_DIR/mount_monitor.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠
cp "$SCRIPT_DIR/lxc_mount_points.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ lxc_mount_points.py not found"
cp "$SCRIPT_DIR/oci_console_logs.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ oci_console_logs.py not found"
cp "$SCRIPT_DIR/oci_instance_info.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ oci_instance_info.py not found"
cp "$SCRIPT_DIR/oci_operations.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ oci_operations.py not found"
cp "$SCRIPT_DIR/disk_temperature_history.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ disk_temperature_history.py not found"
cp "$SCRIPT_DIR/smartctl_resolver.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ smartctl_resolver.py not found"
cp "$SCRIPT_DIR/disk_identity.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ disk_identity.py not found"
@@ -1625,6 +1625,54 @@ def internal_shutdown_event():
return jsonify({'error': 'internal_error', 'detail': str(e)}), 500
# ─── Internal OCI Event Endpoint ─────────────────────────────────
_OCI_EVENTS = {
'oci_update_completed': 'INFO', 'oci_update_failed': 'WARNING',
'oci_recreate_completed': 'INFO', 'oci_recreate_failed': 'WARNING',
}
@notification_bp.route('/api/internal/oci-event', methods=['POST'])
def internal_oci_event():
"""Called by the OCI engine when an update or a recreation ends, with its
result. Only accepts requests from this host."""
remote_addr = request.remote_addr or ''
try:
import ipaddress
addr = ipaddress.ip_address(remote_addr.split('%')[0])
if isinstance(addr, ipaddress.IPv6Address) and addr.ipv4_mapped is not None:
addr = addr.ipv4_mapped
is_loopback = addr.is_loopback
except (ValueError, TypeError):
is_loopback = remote_addr in ('127.0.0.1', '::1', 'localhost')
if not is_loopback:
return jsonify({'error': 'forbidden', 'detail': 'localhost only'}), 403
try:
data = request.get_json(silent=True) or {}
event_type = str(data.get('event') or '')
if event_type not in _OCI_EVENTS:
return jsonify({'error': 'invalid_event_type'}), 400
vmid = str(data.get('vmid') or '')
notification_manager.emit_event(
event_type=event_type,
severity=_OCI_EVENTS[event_type],
data={
'hostname': str(data.get('hostname') or 'unknown'),
'app_name': str(data.get('app_name') or f'CT {vmid}')[:120],
'vmid': vmid,
'containers': str(data.get('containers') or '')[:400],
'reason': str(data.get('reason') or '')[:600],
},
source='proxmenux',
entity='ct',
entity_id=vmid,
)
return jsonify({'success': True, 'event_type': event_type}), 200
except Exception as e:
return jsonify({'error': 'internal_error', 'detail': str(e)}), 500
# ─── Internal Restore Event Endpoint ─────────────────────────────
@notification_bp.route('/api/internal/restore-event', methods=['POST'])
+13
View File
@@ -545,6 +545,15 @@ def update_auth_key(app_id: str):
}), 500
def _sync_managed_registry(app_id: str) -> None:
"""Keep the Updates tab of the container in step with this page."""
try:
import managed_installs
managed_installs.refresh_oci_app(app_id)
except Exception as e:
logger.warning(f"Could not refresh the managed registry for {app_id}: {e}")
@oci_bp.route("/installed/<app_id>/update-check", methods=["GET"])
@require_auth
def installed_update_check(app_id: str):
@@ -558,6 +567,8 @@ def installed_update_check(app_id: str):
try:
force = request.args.get("force", "").lower() in ("1", "true", "yes")
result = oci_manager.check_app_update_available(app_id, force=force)
if force:
_sync_managed_registry(app_id)
return jsonify({"success": True, **result})
except Exception as e:
logger.error(f"Failed to check app update for {app_id}: {e}")
@@ -572,6 +583,8 @@ def installed_update_apply(app_id: str):
would cause an unnecessary brief disconnect."""
try:
result = oci_manager.update_app(app_id)
if result.get("success"):
_sync_managed_registry(app_id)
status_code = 200 if result.get("success") else 500
return jsonify(result), status_code
except Exception as e:
+26
View File
@@ -3158,6 +3158,20 @@ class HealthMonitor:
print(f"[HealthMonitor] Disk/IO check failed: {e}")
return {'status': 'UNKNOWN', 'reason': f'Disk check unavailable: {str(e)}', 'checks': {}, 'dismissable': True}
@staticmethod
def _bridge_is_idle(interface: str, root: str = '/sys/class/net') -> bool:
"""Whether a bridge is administratively up with no port attached.
Such a bridge reports no carrier, which is its normal state and not a
failure. A bridge that is set down, or one that has ports and still no
carrier, is not idle."""
try:
with open(f'{root}/{interface}/flags', encoding='ascii') as handle:
administratively_up = bool(int(handle.read().strip(), 16) & 0x1)
return administratively_up and not os.listdir(f'{root}/{interface}/brif')
except (OSError, ValueError):
return False
def _check_network_optimized(self) -> Dict[str, Any]:
"""
Optimized network check - only alerts for interfaces that are actually in use.
@@ -3206,6 +3220,18 @@ class HealthMonitor:
# Check if it's a bridge interface (always important for VMs/LXCs)
if interface.startswith('vmbr'):
if self._bridge_is_idle(interface):
# A bridge with no port attached has no carrier: the
# private network of an application whose containers
# are stopped, during an update for example. Nothing
# is down; nothing is connected to it.
interface_details[interface] = {
'status': 'OK',
'reason': 'Bridge without attached ports',
'is_up': False,
}
health_persistence.resolve_error(interface, 'Bridge without attached ports')
continue
should_alert = True
alert_reason = 'Bridge interface DOWN (VMs/LXCs may be affected)'
+57 -10
View File
@@ -4332,7 +4332,10 @@ def check_app(
pass
if app.get("installed_via") == "oci_image":
result = _oci_image_versions(vmid, known=state)
# A secondary container of a stack shows the version it runs; the
# registry is not asked, because it is not updated on its own.
result = _oci_image_versions(
vmid, known=state, with_latest=not _oci_secondary_member(_read_oci_record(vmid)))
if result.get("busy"):
_recheck_after_oci_operation(vmid, app_id)
return sidecar
@@ -5458,30 +5461,50 @@ def _dismiss_oci_registration(vmid) -> None:
print(f"[ProxMenux] lxc_apps: could not save the OCI dismissal: {exc}")
def _oci_secondary_member(record) -> bool:
"""Whether the record belongs to a container of a stack other than its main one."""
if not isinstance(record, dict):
return False
member = record.get("stack_member") if isinstance(record.get("stack_member"), dict) else {}
return member.get("primary_vmid") not in (None, record.get("vmid"))
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
image. The auxiliary members of a stack are not registered, nor is a
container that already has applications, nor one whose registration the
user removed."""
image. A secondary container of a stack, its database or its cache, is
registered with the version it runs and no tracking: the stack is updated
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."""
record = _read_oci_record(vmid)
if not record or record.get("status") != "installed":
return False
member = record.get("stack_member") if isinstance(record.get("stack_member"), dict) else {}
if member.get("primary_vmid") not in (None, record.get("vmid")):
return False
secondary = _oci_secondary_member(record)
if _oci_dismissed().get(str(int(vmid))) == record.get("installation_id"):
return False
with _cache_lock:
sidecar = _read_sidecar(vmid)
if sidecar and sidecar.get("apps"):
# An application registered before its logo could be resolved
# takes it now; nothing else of what is registered is touched.
missing = [app for app in sidecar["apps"]
if app.get("installed_via") == "oci_image" and not app.get("logo_url")]
logo = (_oci_instance_meta(vmid) or {}).get("logo") if missing else ""
if logo:
for app in missing:
app["logo_url"] = logo
_write_sidecar(vmid, sidecar)
return False
meta = _oci_instance_meta(vmid)
if not meta or not meta.get("name") or not _NAME_RE.match(str(meta["name"])):
return False
category = meta.get("category_label") or meta.get("category") or ""
endpoints = meta.get("endpoints") or []
if not endpoints:
if secondary:
# A database or a cache is reached by the application, not by the user.
endpoints = []
elif 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 []
@@ -5543,6 +5566,15 @@ def _recheck_after_oci_operation(vmid, app_id: str) -> None:
threading.Thread(target=wait_and_check, name=f"oci-recheck-{vmid}", daemon=True).start()
_OCI_ICON_BASE = "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp"
# The services a stack runs beside its application, by the name of their image.
_OCI_SERVICE_ICONS = {
name: f"{_OCI_ICON_BASE}/{icon}.webp"
for name, icon in (("postgres", "postgresql"), ("valkey", "valkey"), ("redis", "redis"),
("mariadb", "mariadb"), ("mongo", "mongodb"), ("meilisearch", "meilisearch"))
}
def _oci_instance_meta(vmid) -> Optional[dict]:
"""What ProxMenux itself recorded when it installed this container.
@@ -5623,12 +5655,27 @@ def _oci_instance_meta(vmid) -> Optional[dict]:
endpoints.append(detail)
catalog_icons = _oci_catalog_icons()
logo = catalog_icons.get(template_id) or ""
repository = str(image.get("reference") or "").split("@", 1)[0]
basename = repository.rsplit("/", 1)[-1].rsplit(":", 1)[0].strip().lower()
# A container of a stack records its own template id, `image-immich-server`,
# which the catalog does not list: the application it belongs to does.
stack_id = str(stack_template.get("id") or "").strip()
secondary = _oci_secondary_member(record)
if not stack_id and secondary:
primary = _read_oci_record(member.get("primary_vmid")) or {}
stack_id = str(((primary.get("stack") or {}).get("template") or {}).get("id") or "").strip()
application = stack_id.removeprefix("image-").removesuffix("-stack")
if not logo and stack_id and (not secondary or (application and basename.startswith(application))):
# The main container, or one that carries the application in its own
# name, such as Immich's machine learning.
logo = catalog_icons.get(stack_id) or ""
if not logo:
# A stack or a one-off image has no catalog entry of its own, but the
# image it runs usually does: the Nextcloud stack wears Nextcloud's.
repository = str(image.get("reference") or "").split("@", 1)[0]
basename = repository.rsplit("/", 1)[-1].rsplit(":", 1)[0].strip().lower()
logo = catalog_icons.get(basename) or ""
if not logo:
# The database or the cache beside an application.
logo = _OCI_SERVICE_ICONS.get(basename, "")
if not logo:
logo = ui.get("icon") if isinstance(ui.get("icon"), str) else ""
website = ui.get("website") if isinstance(ui.get("website"), str) else ""
+21
View File
@@ -1716,6 +1716,27 @@ def _store_update_result(item: dict, result: dict) -> None:
item["update_check"][extra_key] = result[extra_key]
def refresh_oci_app(app_id: str) -> Optional[dict]:
"""Read one OCI-managed application again and store what it reports.
Its card on the Security page and the Updates tab of its container read
different stores; after an update or a forced check from either one, the
registry is brought to the same state the application reports now."""
with _lock:
reg = _read_registry()
for it in reg.get("items", []):
if (it.get("type") != "oci_app" or it.get("removed_at")
or it.get("_oci_app_id") != app_id):
continue
result = _check_oci_app(it)
_store_update_result(it, result)
if result.get("current"):
it["current_version"] = result["current"]
_write_registry(reg)
return it
return None
def check_for_updates(force: bool = False) -> list[dict]:
"""Run every type-specific checker over active items, persist
the updated state, return the list of items that have an update
+10
View File
@@ -1275,6 +1275,16 @@ class NotificationManager:
if self._is_backup_running():
return
# The stop, the backup and the start of a container the OCI manager is
# updating or recreating are steps of that operation, which reports
# its own result when it ends.
try:
import oci_operations
if oci_operations.quiet(event):
return
except Exception:
pass
# Check storage exclusions for storage-related events.
# If the storage is excluded from notifications, suppress the event entirely.
_STORAGE_EVENTS = {'storage_unavailable', 'storage_low_space', 'storage_warning', 'storage_error',
@@ -870,6 +870,44 @@ TEMPLATES = {
'group': 'vm_ct',
'default_enabled': True,
},
'oci_update_completed': {
'title': '{hostname}: {app_name} updated',
'body': '{app_name} was updated to its new image and its data was kept.\nContainers: {containers}',
'label': 'OCI application updated',
'group': 'vm_ct',
'default_enabled': True,
},
'oci_update_failed': {
'title': '{hostname}: {app_name} update did not complete',
'body': (
'The update of {app_name} did not complete.\n'
'Reason: {reason}\n'
'Containers: {containers}\n'
'Open Manage installed OCI applications to check its state.'
),
'label': 'OCI application update failed',
'group': 'vm_ct',
'default_enabled': True,
},
'oci_recreate_completed': {
'title': '{hostname}: {app_name} recreated',
'body': '{app_name} was recreated with its new options and its data was kept.\nContainers: {containers}',
'label': 'OCI application recreated',
'group': 'vm_ct',
'default_enabled': True,
},
'oci_recreate_failed': {
'title': '{hostname}: {app_name} recreation did not complete',
'body': (
'The recreation of {app_name} did not complete.\n'
'Reason: {reason}\n'
'Containers: {containers}\n'
'Open Manage installed OCI applications to check its state.'
),
'label': 'OCI application recreation failed',
'group': 'vm_ct',
'default_enabled': True,
},
'app_update_available': {
'title': '{hostname}: {app_name} update available on CT {vmid}',
'body': (
@@ -2314,6 +2352,10 @@ EVENT_EMOJI = {
'lxc_updates_available': '\U0001F4E6', # \uD83D\uDCE6 package \u2014 pending CT updates
'apt_listchanges': '\U0001F4E6', # package-maintainer NEWS via PVE mail
'lxc_update_applied': '\u2705', # \u2705 check \u2014 update applied
'oci_update_completed': '\u2705',
'oci_update_failed': '\u26A0\uFE0F',
'oci_recreate_completed': '\u2705',
'oci_recreate_failed': '\u26A0\uFE0F',
'app_update_available': '\U0001F195', # \ud83c\udd95 NEW \u2014 upstream app release
'docker_stack_update_available': '\U0001F433',
'vm_start': '\u25B6\uFE0F', # play button
+62
View File
@@ -0,0 +1,62 @@
"""Containers an OCI manager operation is working on.
An update or a recreation stops, backs up and starts its containers. The OCI
engine marks them while it works and reports the result itself, so their
stop, start and backup notices are part of the operation, not news.
"""
import json
import os
import re
import time
MARKERS = '/run/proxmenux/oci-operations'
# The last start of an operation is noticed a little after it returned.
GRACE_SECONDS = 180
# A mark left behind by an operation that died is not trusted for ever.
STALE_SECONDS = 6 * 3600
QUIET_EVENTS = frozenset({
'vm_start', 'vm_stop', 'vm_shutdown', 'vm_restart',
'ct_start', 'ct_stop', 'ct_shutdown', 'ct_restart',
'backup_start', 'backup_complete',
})
_OCI_BACKUP_PATH = '/proxmenux/oci/instances/'
def active(vmid, now=None, root=MARKERS) -> bool:
try:
with open(os.path.join(root, str(int(vmid))), encoding='utf-8') as handle:
mark = json.load(handle)
started = float(mark.get('started') or 0)
ended = mark.get('ended')
except (OSError, ValueError, TypeError):
return False
now = time.time() if now is None else now
if ended is None:
return now - started < STALE_SECONDS
return now - float(ended) < GRACE_SECONDS
def _vmids(event) -> set:
data = event.data or {}
found = set()
for value in (data.get('vmid'), getattr(event, 'entity_id', '')):
if str(value or '').isdigit():
found.add(int(value))
if event.event_type.startswith('backup_'):
text = ' '.join(str(data.get(key) or '') for key in ('reason', 'pve_message', 'vmname', 'guests'))
found.update(int(value) for value in re.findall(r'\((\d{3,})\)|vzdump-(?:lxc|qemu)-(\d+)-', text)
for value in value if value)
return found
def quiet(event, root=MARKERS) -> bool:
"""Whether the event is a step of a running OCI operation."""
if event.event_type not in QUIET_EVENTS or event.severity in ('CRITICAL', 'WARNING'):
return False
data = event.data or {}
if event.event_type.startswith('backup_') and any(
_OCI_BACKUP_PATH in str(data.get(key) or '') for key in ('reason', 'pve_message', 'filename')):
# The working copy of an update lives in the OCI registry of the host.
return True
vmids = _vmids(event)
return bool(vmids) and all(active(vmid, root=root) for vmid in vmids)
@@ -0,0 +1,39 @@
"""A bridge with no port attached has no carrier and is not a failure: the
private network of an application whose containers are stopped."""
import sys
import tempfile
from pathlib import Path
import unittest
SCRIPTS = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(SCRIPTS))
from health_monitor import HealthMonitor
class IdleBridgeTests(unittest.TestCase):
def bridge(self, flags, ports=()):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
folder = Path(tmp.name) / 'vmbr10'
(folder / 'brif').mkdir(parents=True)
(folder / 'flags').write_text(flags + '\n')
for port in ports:
(folder / 'brif' / port).mkdir()
return HealthMonitor._bridge_is_idle('vmbr10', tmp.name)
def test_an_up_bridge_with_no_port_is_idle(self):
self.assertTrue(self.bridge('0x1003'))
def test_a_bridge_with_ports_and_no_carrier_is_not_idle(self):
self.assertFalse(self.bridge('0x1003', ['enp3s0']))
self.assertFalse(self.bridge('0x1003', ['veth115i1']))
def test_a_bridge_set_down_is_not_idle(self):
self.assertFalse(self.bridge('0x1002'))
def test_an_interface_that_is_not_a_bridge_is_not_idle(self):
self.assertFalse(HealthMonitor._bridge_is_idle('vmbr10', '/nonexistent'))
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,75 @@
"""While the OCI manager updates or recreates an application, the stop, the
backup and the start of its containers are steps of that operation."""
import json
import sys
import tempfile
import time
from pathlib import Path
from types import SimpleNamespace
import unittest
SCRIPTS = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(SCRIPTS))
import oci_operations
def event(kind, vmid=None, severity='INFO', **data):
if vmid is not None:
data['vmid'] = str(vmid)
return SimpleNamespace(event_type=kind, severity=severity, data=data, entity_id=str(vmid or ''))
class OperationQuietTests(unittest.TestCase):
def setUp(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
self.root = tmp.name
def mark(self, vmid, started, ended=None):
Path(self.root, str(vmid)).write_text(json.dumps({'started': started, 'ended': ended}))
def test_the_steps_of_a_running_operation_are_quiet(self):
self.mark(115, time.time())
for kind in ('ct_shutdown', 'ct_stop', 'ct_start', 'ct_restart', 'backup_start', 'backup_complete'):
self.assertTrue(oci_operations.quiet(event(kind, 115), self.root), kind)
def test_another_container_is_still_reported(self):
self.mark(115, time.time())
self.assertFalse(oci_operations.quiet(event('ct_stop', 200), self.root))
self.assertFalse(oci_operations.quiet(event('ct_stop'), self.root))
def test_a_problem_is_never_silenced(self):
self.mark(115, time.time())
self.assertFalse(oci_operations.quiet(event('ct_stop', 115, severity='WARNING'), self.root))
self.assertFalse(oci_operations.quiet(event('ct_fail', 115), self.root))
self.assertFalse(oci_operations.quiet(event('backup_fail', 115), self.root))
def test_the_last_start_is_still_quiet_just_after_the_operation(self):
now = time.time()
self.mark(115, now - 60, ended=now - 60)
self.assertTrue(oci_operations.quiet(event('ct_start', 115), self.root))
self.mark(115, now - 3600, ended=now - 3600)
self.assertFalse(oci_operations.quiet(event('ct_start', 115), self.root))
def test_a_mark_left_by_a_dead_operation_expires(self):
self.mark(115, time.time() - 7 * 3600)
self.assertFalse(oci_operations.quiet(event('ct_stop', 115), self.root))
def test_the_working_copy_of_an_update_is_recognised_by_its_path(self):
archive = ('/usr/local/share/proxmenux/oci/instances/115/stack-transactions/abc/backup-117/'
'vzdump-lxc-117-2026_10_02-20_45_41.tar.zst')
self.assertTrue(oci_operations.quiet(event('backup_complete', pve_message=archive), self.root))
self.assertFalse(oci_operations.quiet(
event('backup_complete', pve_message='/var/lib/vz/dump/vzdump-lxc-117-2026.tar.zst'), self.root))
def test_a_backup_of_several_guests_is_quiet_only_when_all_belong_to_the_operation(self):
self.mark(115, time.time())
self.mark(117, time.time())
both = event('backup_start', reason='VM/CT:\n CT immich-server (115)\n CT immich-db (117)')
mixed = event('backup_start', reason='VM/CT:\n CT immich-db (117)\n CT other (200)')
self.assertTrue(oci_operations.quiet(both, self.root))
self.assertFalse(oci_operations.quiet(mixed, self.root))
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,123 @@
"""A secondary container of an OCI stack is registered with the version it
runs and no version tracking; its application is updated with the stack."""
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
DIGEST = 'sha256:' + 'ab' * 32
class StackMemberRegistrationTests(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': []}))
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'))]
for item in patches:
item.start()
self.addCleanup(item.stop)
def record(self, vmid, primary, reference):
folder = self.root / f'instances/{vmid}'
folder.mkdir(parents=True)
(folder / 'oci-compose.json').write_text(json.dumps({
'vmid': vmid, 'status': 'installed', 'installation_id': f'install-{vmid}',
'stack_member': {'name': 'database', 'primary_vmid': primary},
'stack': {'template': {'id': 'stack-nextcloud', 'catalog_ui': {'title': 'Nextcloud'},
'first_run': {'endpoints': [{'port': 80, 'scheme': 'http', 'path': '/'}]}}},
'observed': {'image': {'manifest_digest': DIGEST, 'architecture': 'amd64'}},
'template': {'id': f'stack-nextcloud-{vmid}',
'container_contract': {'image': {'reference': reference},
'ports': [{'container_port': 5432}]}}}))
def test_the_database_of_a_stack_is_registered_without_a_web_port(self):
self.record(131, 129, 'docker.io/library/postgres:16-alpine')
with patch.object(lxc_apps, 'add_app', return_value=(True, {})) as add:
self.assertTrue(lxc_apps.ensure_oci_registration(131))
payload = add.call_args.args[1]
self.assertEqual(payload['name'], 'Postgres')
self.assertEqual(payload['installed_via'], 'oci_image')
self.assertEqual(payload['ports'], [])
def test_a_secondary_container_is_told_apart_from_the_main_one(self):
self.assertTrue(lxc_apps._oci_secondary_member({'vmid': 131, 'stack_member': {'primary_vmid': 129}}))
self.assertFalse(lxc_apps._oci_secondary_member({'vmid': 129, 'stack_member': {'primary_vmid': 129}}))
self.assertFalse(lxc_apps._oci_secondary_member({'vmid': 120}))
self.assertFalse(lxc_apps._oci_secondary_member(None))
def test_the_registry_is_not_asked_for_a_secondary_container(self):
self.record(131, 129, 'docker.io/library/postgres:16-alpine')
class Engine:
@staticmethod
def resolve_candidate(reference, architecture):
assert '@' in reference, 'only the installed digest is read'
return {'version': '16.15', 'created': '2026-09-01T00:00:00Z', 'manifest_digest': DIGEST}
with patch.object(lxc_apps, '_oci_state_module', return_value=Engine):
result = lxc_apps._oci_image_versions(
131, with_latest=not lxc_apps._oci_secondary_member(lxc_apps._read_oci_record(131)))
self.assertEqual(result['installed_version'], '16.15')
self.assertNotIn('update_available', result)
self.assertNotIn('latest_version', result)
if __name__ == '__main__':
unittest.main()
class StackLogoTests(StackMemberRegistrationTests):
ICON = 'https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/immich.webp'
def stack(self):
(self.root / 'catalog/index.json').write_text(json.dumps({'applications': [
{'id': 'immich', 'template_id': 'image-immich', 'template': 'apps/immich.json', 'icon': self.ICON}]}))
members = {115: ('server', 'ghcr.io/immich-app/immich-server:release'),
116: ('machine-learning', 'ghcr.io/immich-app/immich-machine-learning:release'),
117: ('database', 'ghcr.io/immich-app/postgres:14-vectorchord0.4.3'),
118: ('valkey', 'docker.io/valkey/valkey:9')}
for vmid, (role, reference) in members.items():
folder = self.root / f'instances/{vmid}'
folder.mkdir(parents=True)
record = {'vmid': vmid, 'status': 'installed', 'installation_id': f'install-{vmid}',
'stack_member': {'name': role, 'primary_vmid': 115},
'observed': {'image': {'manifest_digest': DIGEST, 'architecture': 'amd64'}},
'template': {'id': f'image-immich-{role}',
'container_contract': {'image': {'reference': reference}, 'ports': []}}}
if vmid == 115:
record['stack'] = {'template': {'id': 'image-immich', 'catalog_ui': {'title': 'Immich', 'icon': None}}}
(folder / 'oci-compose.json').write_text(json.dumps(record))
def test_the_main_container_and_machine_learning_wear_the_application_logo(self):
self.stack()
self.assertEqual(lxc_apps._oci_instance_meta(115)['logo'], self.ICON)
self.assertEqual(lxc_apps._oci_instance_meta(116)['logo'], self.ICON)
def test_the_database_and_the_cache_wear_their_own(self):
self.stack()
self.assertTrue(lxc_apps._oci_instance_meta(117)['logo'].endswith('/postgresql.webp'))
self.assertTrue(lxc_apps._oci_instance_meta(118)['logo'].endswith('/valkey.webp'))
def test_an_application_registered_without_logo_takes_it_later(self):
self.stack()
sidecar = {'vmid': 115, 'apps': [{'id': 'a', 'name': 'Immich', 'installed_via': 'oci_image', 'logo_url': ''},
{'id': 'b', 'name': 'Other', 'installed_via': '', 'logo_url': ''}]}
written = {}
with patch.object(lxc_apps, '_read_sidecar', return_value=sidecar), \
patch.object(lxc_apps, '_write_sidecar', side_effect=lambda vmid, data: written.update(data)):
self.assertFalse(lxc_apps.ensure_oci_registration(115))
self.assertEqual(written['apps'][0]['logo_url'], self.ICON)
self.assertEqual(written['apps'][1]['logo_url'], '')