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
@@ -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'], '')