mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-10-08 22:46:41 +00:00
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:
@@ -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'], '')
|
||||
Reference in New Issue
Block a user