From 70d41012430b4698c4bbb2efab0e4679c9d70b92 Mon Sep 17 00:00:00 2001 From: Jonatan Castro <190036+jcastro@users.noreply.github.com> Date: Mon, 19 Oct 2026 11:23:45 +0200 Subject: [PATCH] feat(oci): show update availability and update selected applications --- .github/workflows/test-oci-registries.yml | 15 + AppImage/scripts/build_appimage.sh | 1 + AppImage/scripts/flask_server.py | 10 + AppImage/scripts/lxc_apps.py | 136 ++++++--- AppImage/scripts/oci_menu_bridge.py | 161 ++++++++++ .../scripts/tests/test_oci_parallel_checks.py | 124 ++++++++ lang/de.json | 9 + lang/es.json | 9 + lang/fr.json | 9 + lang/it.json | 9 + lang/pt.json | 9 + lang/sk.json | 9 + lang/sv.json | 9 + oci/README.md | 44 +++ oci/remote/oci_installation_state.py | 24 +- oci/remote/oci_update_check.py | 204 +++++++++++++ oci/src/proxmenux_oci/cli.py | 78 +++-- oci/src/proxmenux_oci/ui.py | 50 +++- oci/src/proxmenux_oci/updates.py | 184 ++++++++++++ oci/tests/test_update_check.py | 283 ++++++++++++++++++ oci/tests/test_update_menu.py | 274 +++++++++++++++++ oci/tests/test_update_metadata.py | 73 +++++ 22 files changed, 1644 insertions(+), 80 deletions(-) create mode 100644 AppImage/scripts/oci_menu_bridge.py create mode 100644 AppImage/scripts/tests/test_oci_parallel_checks.py create mode 100644 oci/remote/oci_update_check.py create mode 100644 oci/src/proxmenux_oci/updates.py create mode 100644 oci/tests/test_update_check.py create mode 100644 oci/tests/test_update_menu.py create mode 100644 oci/tests/test_update_metadata.py diff --git a/.github/workflows/test-oci-registries.yml b/.github/workflows/test-oci-registries.yml index a4f0b150..1b24a1a9 100644 --- a/.github/workflows/test-oci-registries.yml +++ b/.github/workflows/test-oci-registries.yml @@ -4,11 +4,19 @@ on: pull_request: paths: - 'oci/**' + - 'AppImage/scripts/oci_menu_bridge.py' + - 'AppImage/scripts/lxc_apps.py' + - 'AppImage/scripts/tests/test_oci_parallel_checks.py' + - 'AppImage/scripts/flask_server.py' - '.github/workflows/test-oci-registries.yml' push: branches: [main, develop] paths: - 'oci/**' + - 'AppImage/scripts/oci_menu_bridge.py' + - 'AppImage/scripts/lxc_apps.py' + - 'AppImage/scripts/tests/test_oci_parallel_checks.py' + - 'AppImage/scripts/flask_server.py' - '.github/workflows/test-oci-registries.yml' workflow_dispatch: @@ -54,6 +62,13 @@ jobs: env: PYTHONDONTWRITEBYTECODE: '1' run: python3 -m unittest discover -s oci/tests -p 'test_registry_*.py' -v + - name: Run OCI menu update checks and selection regressions + env: + PYTHONDONTWRITEBYTECODE: '1' + run: | + python3 -m unittest discover -s oci/tests -p 'test_update*.py' -v + python3 -m unittest discover -s AppImage/scripts/tests -p 'test_oci*.py' -v + python3 -m unittest discover -s oci/tests -p test_recreate_registry_digest.py -v - name: Run OCI Modify latency regressions env: PYTHONDONTWRITEBYTECODE: '1' diff --git a/AppImage/scripts/build_appimage.sh b/AppImage/scripts/build_appimage.sh index 4bee9953..c694de7b 100755 --- a/AppImage/scripts/build_appimage.sh +++ b/AppImage/scripts/build_appimage.sh @@ -151,6 +151,7 @@ cp "$SCRIPT_DIR/lxc_update_finalization.py" "$APP_DIR/usr/bin/" cp "$SCRIPT_DIR/lxc_check_job.py" "$APP_DIR/usr/bin/" cp "$SCRIPT_DIR/native_app_discovery.py" "$APP_DIR/usr/bin/" cp "$SCRIPT_DIR/lxc_apps.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ lxc_apps.py not found" +cp "$SCRIPT_DIR/oci_menu_bridge.py" "$APP_DIR/usr/bin/" cp "$SCRIPT_DIR/custom_links.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ custom_links.py not found" cp "$SCRIPT_DIR/recreate_docker_container.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ recreate_docker_container.py not found" chmod +x "$APP_DIR/usr/bin/recreate_docker_container.py" 2>/dev/null || true diff --git a/AppImage/scripts/flask_server.py b/AppImage/scripts/flask_server.py index 70cc4174..a8f822f6 100644 --- a/AppImage/scripts/flask_server.py +++ b/AppImage/scripts/flask_server.py @@ -23147,6 +23147,16 @@ if __name__ == '__main__': import sys import logging + # The root terminal menu shares the Monitor's checker and sidecar cache. + # This local socket changes neither the web API nor its authentication. + try: + if not is_pbs_host(): + import lxc_apps + import oci_menu_bridge + oci_menu_bridge.start(lxc_apps) + except Exception as _e: + print(f"[startup] OCI menu bridge unavailable: {_e}", file=sys.stderr) + # Regenerate /etc/cron.d/proxmenux-smart from the current schedule # config on every Monitor start. Pre-existing installs may carry # a cron file with the broken script path (without /storage/), diff --git a/AppImage/scripts/lxc_apps.py b/AppImage/scripts/lxc_apps.py index 4b6a5a2e..e6dea9af 100644 --- a/AppImage/scripts/lxc_apps.py +++ b/AppImage/scripts/lxc_apps.py @@ -4338,8 +4338,87 @@ def _detect_with_alt_healing(vmid, app: dict) -> tuple: return installed, err, False +_oci_check_locks: dict = {} +_oci_check_locks_guard = threading.Lock() + + def check_app( vmid, app_id: str, force: bool = False, notify: bool = True, +) -> Optional[dict]: + # Keep legacy detectors' locking unchanged. OCI network requests use a + # per-app lock and hold the shared cache lock only while reading/saving. + with _cache_lock: + sidecar = _read_sidecar(vmid) + app = _find_app(sidecar, app_id) if sidecar else None + if not app or app.get("installed_via") != "oci_image": + return _check_non_oci_app(vmid, app_id, force=force, notify=notify) + return _check_oci_app(vmid, app_id, force=force, notify=notify) + + +def _check_oci_app(vmid, app_id, force=False, notify=True): + key = (int(vmid), app_id) + with _oci_check_locks_guard: + lock = _oci_check_locks.setdefault(key, threading.Lock()) + with lock: + with _cache_lock: + sidecar = _read_sidecar(vmid) + app = _find_app(sidecar, app_id) if sidecar else None + if not app or app.get("installed_via") != "oci_image": + return sidecar + state = app.get("state") or _empty_state() + if not force and state.get("checked_at"): + try: + checked = datetime.datetime.strptime(state["checked_at"].rstrip("Z"), "%Y-%m-%dT%H:%M:%S") + age = (datetime.datetime.utcnow() - checked).total_seconds() + if 0 <= age < _UPSTREAM_CACHE_TTL_SEC: + return sidecar + except (ValueError, TypeError): + pass + before_app = copy.deepcopy(app) + before_record = _read_oci_record(vmid) + + # No global cache lock is held while waiting for registries. The same + # native resolver, credentials and digest comparisons are retained. + result = _oci_image_versions( + vmid, known=state, with_latest=not _oci_secondary_member(before_record)) + if result.get("busy"): + _recheck_after_oci_operation(vmid, app_id) + return _read_sidecar(vmid) + + with _cache_lock: + # Re-read rather than writing the pre-query copy: other apps, + # schedules and registrations may have changed while we waited. + sidecar = _read_sidecar(vmid) + app = _find_app(sidecar, app_id) if sidecar else None + if app != before_app or _read_oci_record(vmid) != before_record: + return sidecar # Never publish a result for a replaced/edited app. + app["state"] = { + "installed_version": result.get("installed_version"), + "latest_version": result.get("latest_version"), + "latest_published_at": None, + "update_available": result.get("update_available"), + "error": result.get("error"), + "checked_at": _now_iso(), + "installed_digest": result.get("installed_digest"), + "installed_registry_digest": result.get("installed_registry_digest"), + "registry_retry": bool(result.get("registry_retry")), + "latest_digest": result.get("latest_digest"), + "image_created": result.get("image_created"), + "latest_image_created": result.get("latest_image_created"), + "image_reference": result.get("image_reference"), + "image_repository": result.get("image_repository"), + } + sidecar["updated_at"] = _now_iso() + _write_sidecar(vmid, sidecar) + if app["state"]["registry_retry"]: + _retry_oci_registry(vmid, app_id) + if notify and app["state"]["update_available"]: + _fire_update_notification(vmid, app) + return sidecar + + +def _check_non_oci_app( + vmid, app_id: str, force: bool = False, notify: bool = True, ) -> Optional[dict]: with _cache_lock: sidecar = _read_sidecar(vmid) @@ -4369,40 +4448,6 @@ def check_app( except (ValueError, TypeError): pass - if app.get("installed_via") == "oci_image": - # 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 - app["state"] = { - "installed_version": result.get("installed_version"), - "latest_version": result.get("latest_version"), - "latest_published_at": None, - "update_available": result.get("update_available"), - "error": result.get("error"), - "checked_at": _now_iso(), - "installed_digest": result.get("installed_digest"), - "installed_registry_digest": result.get("installed_registry_digest"), - "registry_retry": bool(result.get("registry_retry")), - "latest_digest": result.get("latest_digest"), - "image_created": result.get("image_created"), - "latest_image_created": result.get("latest_image_created"), - "image_reference": result.get("image_reference"), - "image_repository": result.get("image_repository"), - } - sidecar["updated_at"] = _now_iso() - _write_sidecar(vmid, sidecar) - if app["state"]["registry_retry"]: - _retry_oci_registry(vmid, app_id) - # The payload names an image by its build date and digest when it - # states no version, so it decides whether there is anything to send. - if notify and app["state"]["update_available"]: - _fire_update_notification(vmid, app) - return sidecar - installed, inst_err, _healed = _detect_with_alt_healing(vmid, app) # Trigger the upstream fetch when ANY upstream source is # configured. The dispatcher inside `fetch_latest_upstream` @@ -5975,14 +6020,22 @@ def _oci_repository(reference: str) -> str: return repository -def _oci_resolve(module, reference: str, architecture: str) -> dict: +def _oci_resolve(module, reference: str, architecture: str, known=None) -> dict: # One retry: an anonymous registry answers the occasional request with an # error that the next one does not repeat. - try: + def resolve(): + # Older installed engines remain compatible; both paths use their + # native resolver. No separate registry client or verdict is added. + metadata = getattr(module, "resolve_candidate_metadata", None) + if known is not None and callable(metadata): + return metadata(reference, architecture, known) return module.resolve_candidate(reference, architecture) + + try: + return resolve() except RuntimeError: time.sleep(2) - return module.resolve_candidate(reference, architecture) + return resolve() # What the panel shows, translated, when the registry of an image did not answer. @@ -6065,8 +6118,15 @@ def _oci_image_versions(vmid, known: Optional[dict] = None, with_latest: bool = result["installed_version"] = _oci_guest_version(vmid, meta.get("template_id")) if not with_latest: return result + cached_metadata = None + if (known.get("installed_digest") == installed_digest + and known.get("image_reference") == reference and known.get("latest_digest")): + cached_metadata = {"manifest_digest": known["latest_digest"], + "version": known.get("latest_version"), + "created": known.get("latest_image_created"), + "architecture": architecture} try: - latest = _oci_resolve(module, reference, architecture) + latest = _oci_resolve(module, reference, architecture, known=cached_metadata) except Exception: # A registry that does not answer says nothing new about the image: # what the last check found for this same image still stands. diff --git a/AppImage/scripts/oci_menu_bridge.py b/AppImage/scripts/oci_menu_bridge.py new file mode 100644 index 00000000..c63943e6 --- /dev/null +++ b/AppImage/scripts/oci_menu_bridge.py @@ -0,0 +1,161 @@ +"""Root-only local access to the Monitor's existing OCI update checker. + +The terminal menu must not run a second registry checker or write sidecars in +another process. Requests execute in the Monitor, using its normal locks, +registration rules, retries and /etc/proxmenux/apps results. No TCP API or +authentication setting is changed. +""" +from __future__ import annotations + +import atexit +from concurrent.futures import ThreadPoolExecutor, as_completed +import errno +import json +import os +from pathlib import Path +import socket +import socketserver +import stat +import struct +import threading + +SOCKET = Path('/run/proxmenux-monitor/oci-check.sock') +_check_lock = threading.Lock() +_MAX_REQUEST = 128 * 1024 +_CHECK_WORKERS = 16 + + +def _root_peer(connection): + if not hasattr(socket, 'SO_PEERCRED'): + return False + credentials = connection.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, struct.calcsize('3i')) + return struct.unpack('3i', credentials)[1] == 0 + + +def _applications(request): + if not isinstance(request, dict) or set(request) != {'applications'}: + raise ValueError('Invalid request') + rows = request['applications'] + if not isinstance(rows, list) or len(rows) > 1000: + raise ValueError('Invalid applications') + seen = set() + for row in rows: + if (not isinstance(row, dict) or set(row) != {'vmid', 'installation_id'} + or type(row['vmid']) is not int or not 100 <= row['vmid'] <= 999999999 + or row['vmid'] in seen or not isinstance(row['installation_id'], str) + or not 1 <= len(row['installation_id']) <= 128): + raise ValueError('Invalid application') + seen.add(row['vmid']) + return rows + + +def check_applications(module, rows, report): + """Delegate exclusively to check_app, exactly as the Monitor's Check does.""" + report({'completed': 0, 'total': len(rows)}) + stopped = threading.Event() + + def check_one(row): + if stopped.is_set(): + return + vmid = row['vmid'] + record = module._read_oci_record(vmid) or {} + if (record.get('installation_id') == row['installation_id'] + and record.get('status') == 'installed' + and not record.get('pending_transaction') + and not record.get('pending_stack_transaction') + and not module._oci_secondary_member(record)): + # Honour dismissals and reuse the Monitor's registration and + # checker. The menu never invents registrations or notifications. + module.ensure_oci_registration(vmid) + sidecar = module.load_sidecar(vmid) or {} + app = next((app for app in sidecar.get('apps', []) + if app.get('installed_via') == 'oci_image'), None) + if app: + module.check_app(vmid, app['id'], force=True, notify=False) + + pool = ThreadPoolExecutor(max_workers=_CHECK_WORKERS, thread_name_prefix='oci-check') + failed = False + try: + futures = [pool.submit(check_one, row) for row in rows] + for completed, future in enumerate(as_completed(futures), 1): + try: + future.result() + except Exception: + failed = True # One failure never stops the other applications. + report({'completed': completed, 'total': len(rows)}) + finally: + stopped.set() + pool.shutdown(wait=True, cancel_futures=True) + report({'error': 'check_failed'} if failed else {'done': True}) + + +class _Handler(socketserver.StreamRequestHandler): + def handle(self): + if not _root_peer(self.request): + return + self.request.settimeout(10) + acquired = False + try: + line = self.rfile.readline(_MAX_REQUEST + 1) + if len(line) > _MAX_REQUEST or not line.endswith(b'\n'): + return + rows = _applications(json.loads(line)) + acquired = _check_lock.acquire(blocking=False) + if not acquired: + self.report({'error': 'check_running'}) + return + check_applications(self.server.apps, rows, self.report) + except (BrokenPipeError, ConnectionResetError, TimeoutError): + pass # Closing the menu stops the rest of this request. + except Exception: + try: + self.report({'error': 'check_failed'}) + except OSError: + pass + finally: + if acquired: + _check_lock.release() + + def report(self, event): + self.wfile.write((json.dumps(event) + '\n').encode()) + self.wfile.flush() + + +class _Server(socketserver.ThreadingUnixStreamServer): + daemon_threads = True + block_on_close = False + + +def start(apps, path=SOCKET): + """Start alongside Flask; a private socket avoids web login bypasses.""" + path = Path(path) + if os.geteuid() != 0: + return None + path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + parent = path.parent.lstat() + if not stat.S_ISDIR(parent.st_mode) or parent.st_uid != 0 or parent.st_mode & 0o077: + raise OSError('Unsafe Monitor runtime directory') + if path.exists() or path.is_symlink(): + old = path.lstat() + if not stat.S_ISSOCK(old.st_mode) or old.st_uid != 0: + raise OSError('Unsafe Monitor socket') + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as probe: + probe.settimeout(.2) + try: + probe.connect(str(path)) + return None # Already owned by a running Monitor. + except OSError as exc: + if exc.errno not in (errno.ECONNREFUSED, errno.ENOENT): + raise + path.unlink(missing_ok=True) + server = _Server(str(path), _Handler) + server.apps = apps + os.chmod(path, 0o600) + threading.Thread(target=server.serve_forever, name='oci-menu-check', daemon=True).start() + + def close(): + server.shutdown() + server.server_close() + path.unlink(missing_ok=True) + atexit.register(close) + return server diff --git a/AppImage/scripts/tests/test_oci_parallel_checks.py b/AppImage/scripts/tests/test_oci_parallel_checks.py new file mode 100644 index 00000000..012d9248 --- /dev/null +++ b/AppImage/scripts/tests/test_oci_parallel_checks.py @@ -0,0 +1,124 @@ +"""OCI registry waits run concurrently; shared app cache changes remain atomic.""" +from concurrent.futures import ThreadPoolExecutor +import copy +import json +from pathlib import Path +import sys +import tempfile +import threading +import unittest +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +import lxc_apps + +DIGEST = 'sha256:' + 'a' * 64 +NEW = 'sha256:' + 'b' * 64 + + +class ParallelOciChecksTests(unittest.TestCase): + def setUp(self): + tmp = tempfile.TemporaryDirectory(); self.addCleanup(tmp.cleanup) + self.root = Path(tmp.name) + self.records = {vmid: {'vmid': vmid, 'status': 'installed', 'installation_id': f'uuid-{vmid}'} + for vmid in range(110, 114)} + for vmid in self.records: + sidecar = {'vmid': vmid, 'schedule': {'enabled': False}, 'apps': [ + {'id': 'oci', 'installed_via': 'oci_image', 'name': 'App', 'state': {}}, + {'id': 'other', 'installed_via': 'manual', 'name': 'Other', 'state': {}}]} + (self.root / f'{vmid}.json').write_text(json.dumps(sidecar)) + for item in (patch.object(lxc_apps, '_APPS_DIR', str(self.root)), + patch.object(lxc_apps, '_read_oci_record', side_effect=lambda vmid: copy.deepcopy(self.records.get(int(vmid))))): + item.start(); self.addCleanup(item.stop) + + def result(self, vmid, **kwargs): + return {'installed_digest': DIGEST, 'installed_registry_digest': DIGEST, + 'latest_digest': NEW, 'update_available': True, 'installed_version': '1', + 'latest_version': '2', 'image_reference': 'example/app:latest'} + + def test_four_native_checks_enter_registry_before_any_one_can_finish(self): + rendezvous = threading.Barrier(4, timeout=3) + def versions(vmid, **kwargs): + rendezvous.wait() + return self.result(vmid) + with patch.object(lxc_apps, '_oci_image_versions', side_effect=versions) as query: + with ThreadPoolExecutor(max_workers=4) as pool: + results = list(pool.map(lambda vmid: lxc_apps.check_app(vmid, 'oci', force=True, notify=False), self.records)) + self.assertEqual(query.call_count, 4) + self.assertTrue(all(result['apps'][0]['state']['update_available'] for result in results)) + + def during_query(self, edit): + started, release, edited = threading.Event(), threading.Event(), threading.Event() + def versions(vmid, **kwargs): + started.set() + if not release.wait(3): raise AssertionError('Registry wait did not end') + return self.result(vmid) + def change(): + with lxc_apps._cache_lock: + sidecar = lxc_apps.load_sidecar(110) + edit(sidecar) + lxc_apps._write_sidecar(110, sidecar) + edited.set() + with patch.object(lxc_apps, '_oci_image_versions', side_effect=versions): + with ThreadPoolExecutor(max_workers=2) as pool: + future = pool.submit(lxc_apps.check_app, 110, 'oci', force=True, notify=False) + try: + self.assertTrue(started.wait(2)) + writer = pool.submit(change) + self.assertTrue(edited.wait(2), 'A registry call still holds the shared cache lock') + finally: + release.set() + future.result(); writer.result() + return lxc_apps.load_sidecar(110) + + def test_unrelated_apps_and_schedule_edits_survive_registry_wait(self): + def edit(sidecar): + sidecar['schedule']['enabled'] = True + sidecar['apps'][1]['name'] = 'Renamed while checking' + result = self.during_query(edit) + self.assertTrue(result['schedule']['enabled']) + self.assertEqual(result['apps'][1]['name'], 'Renamed while checking') + self.assertTrue(result['apps'][0]['state']['update_available']) + + def test_deleted_app_is_not_recreated_by_late_registry_answer(self): + result = self.during_query(lambda sidecar: sidecar['apps'].pop(0)) + self.assertEqual([app['id'] for app in result['apps']], ['other']) + + def test_edited_app_is_not_overwritten_by_late_registry_answer(self): + result = self.during_query(lambda sidecar: sidecar['apps'][0].update(name='Edited')) + self.assertEqual(result['apps'][0]['name'], 'Edited') + self.assertEqual(result['apps'][0]['state'], {}) + + def test_replaced_installation_cannot_receive_previous_images_answer(self): + def edit(sidecar): self.records[110]['installation_id'] = 'replacement' + result = self.during_query(edit) + self.assertEqual(result['apps'][0]['state'], {}) + + def test_checks_for_two_apps_in_same_container_keep_both_results(self): + sidecar = lxc_apps.load_sidecar(110) + sidecar['apps'][1]['installed_via'] = 'oci_image' + lxc_apps._write_sidecar(110, sidecar) + rendezvous = threading.Barrier(2, timeout=3) + def versions(vmid, **kwargs): + rendezvous.wait() + return self.result(vmid) + with patch.object(lxc_apps, '_oci_image_versions', side_effect=versions): + with ThreadPoolExecutor(max_workers=2) as pool: + futures = [pool.submit(lxc_apps.check_app, 110, app, force=True, notify=False) for app in ('oci', 'other')] + for future in futures: future.result() + result = lxc_apps.load_sidecar(110) + self.assertTrue(all(app['state']['update_available'] for app in result['apps'])) + + def test_native_registry_failure_is_persisted_and_retried_without_blocking_others(self): + def versions(vmid, **kwargs): + return {'error': 'registry unreachable', 'registry_retry': True} if vmid == 110 else self.result(vmid) + with patch.object(lxc_apps, '_oci_image_versions', side_effect=versions), \ + patch.object(lxc_apps, '_retry_oci_registry') as retry: + with ThreadPoolExecutor(max_workers=4) as pool: + results = list(pool.map(lambda vmid: lxc_apps.check_app(vmid, 'oci', force=True, notify=False), self.records)) + self.assertEqual(results[0]['apps'][0]['state']['error'], 'registry unreachable') + self.assertTrue(all(result['apps'][0]['state']['update_available'] for result in results[1:])) + retry.assert_called_once_with(110, 'oci') + + +if __name__ == '__main__': unittest.main() diff --git a/lang/de.json b/lang/de.json index e81d7622..ced51402 100644 --- a/lang/de.json +++ b/lang/de.json @@ -389,6 +389,7 @@ "An Industrial-Level Controllable and Efficient Zero-Shot Text-To-Speech System": "Ein industriell steuerbares und effizientes Zero-Shot-Text-to-Speech-System", "An Intel dedicated GPU has been detected without FLR support": "Es wurde eine dedizierte Intel-GPU ohne FLR-Unterstützung erkannt", "An accelerated video generation framework that speeds up end-to-end diffusion while preserving video quality": "Ein beschleunigtes Videogenerierungs-Framework, das die End-to-End-Diffusion beschleunigt und gleichzeitig die Videoqualität bewahrt", + "An application changed since the update check. Check for updates again.": "Eine Anwendung hat sich seit der letzten Prüfung geändert. Suche erneut nach Updates.", "An executable required by the adapter is missing in the new image": "Ein ausführbares required durch den Adapter fehlt im neuen Image", "An fstab entry already exists for:": "Es existiert bereits ein fstab-Eintrag für:", "An image probe container was started externally": "Ein Bildsondenbehälter wurde extern gestartet", @@ -818,6 +819,7 @@ "Check access to an image": "Überprüfen Sie den Zugriff auf ein Bild", "Check connectivity with another host": "Überprüfen Sie die Konnektivität mit einem anderen Host", "Check container mount points": "Überprüfen Sie die Container-Mount-Punkte", + "Check for updates": "Nach Updates suchen", "Check if container is unprivileged:": "Überprüfen Sie, ob der Container nicht privilegiert ist:", "Check kernel params (IOMMU flags)": "Überprüfen Sie die Kernel-Parameter (IOMMU-Flags).", "Check loaded GPU/TPU modules": "Überprüfen Sie die geladenen GPU/TPU-Module", @@ -838,6 +840,7 @@ "Checking ZFS autotrim configuration...": "ZFS-Autotrim-Konfiguration wird überprüft...", "Checking and repairing old LVM PV headers (if needed)...": "Überprüfung und Reparatur alter LVM-PV-Header (falls erforderlich) ...", "Checking conflicting drivers blacklist...": "Blacklist mit in Konflikt stehenden Treibern wird überprüft...", + "Checking for updates ({completed}/{total})": "Suche nach Updates ({completed}/{total})", "Checking for updates...": "Suche nach Updates...", "Checking free space in /var/cache/apt/archives...": "Überprüfen Sie den freien Speicherplatz in /var/cache/apt/archives...", "Checking if system disk is SSD or M.2...": "Überprüfen, ob es sich bei der Systemfestplatte um eine SSD oder M.2 handelt ...", @@ -4812,6 +4815,7 @@ "Press Enter to return...": "Drücken Sie die Eingabetaste, um zurückzukehren...", "Press Enter when the line has been pasted on the server...": "Drücken Sie die Eingabetaste, wenn die Zeile auf dem Server eingefügt wurde ...", "Press OK to see the preview, then confirm": "Drücken Sie OK, um die Vorschau anzuzeigen, und bestätigen Sie dann", + "Press Space to select applications to update. Select all includes every application in this list.": "Wähle mit der Leertaste die zu aktualisierenden Anwendungen aus. Alle auswählen umfasst sämtliche Anwendungen in dieser Liste.", "Press any key to continue...": "Drücken Sie eine beliebige Taste, um fortzufahren...", "Pretty uptime format": "Hübsches Uptime-Format", "Preview Backup": "Vorschau des Backups", @@ -5362,6 +5366,7 @@ "Reverting pigz wrapper...": "Pigz-Wrapper wird zurückgesetzt...", "Reverting vzdump speed tuning...": "vzdump-Geschwindigkeitsoptimierung wird zurückgesetzt...", "Review and apply": "Prüfen und anwenden", + "Review and update these applications?": "Auswahl prüfen und diese Anwendungen aktualisieren?", "Review external OCI changes": "Überprüfen Sie externe OCI-Änderungen", "Review package upgrade": "Paketaktualisierung prüfen", "Review passthrough config files": "Überprüfen Sie die Passthrough-Konfigurationsdateien", @@ -5613,6 +5618,7 @@ "Select a share to delete:": "Wählen Sie eine Freigabe zum Löschen aus:", "Select a specific Coral or USB node, not the whole /dev": "Wählen Sie einen bestimmten Coral- oder USB-Knoten, nicht den gesamten /dev", "Select access mode": "Wählen Sie den Zugriffsmodus", + "Select all": "Alle auswählen", "Select an existing group": "Wählen Sie eine vorhandene Gruppe aus", "Select an existing group:": "Wählen Sie eine vorhandene Gruppe aus:", "Select an export to delete:": "Wählen Sie einen Export zum Löschen aus:", @@ -6293,6 +6299,7 @@ "The Paperless persistent volumes need at least 8 GB": "Die papierlosen persistenten Volumes benötigen mindestens 8 GB", "The PostgreSQL volume needs at least 4 GB": "Das PostgreSQL-Volume benötigt mindestens 4 GB", "The PostgreSQL volume needs at least 8 GB": "Das PostgreSQL-Volume benötigt mindestens 8 GB", + "The ProxMenux Monitor update checker is not available. Start or update ProxMenux Monitor and try again.": "Die Update-Prüfung von ProxMenux Monitor ist nicht verfügbar. Starte oder aktualisiere ProxMenux Monitor und versuche es erneut.", "The Proxmox VE 8 update did not finish. The upgrade to Proxmox VE 9 was not started.": "Die Aktualisierung von Proxmox VE 8 wurde nicht abgeschlossen. Das Upgrade auf Proxmox VE 9 wurde nicht gestartet.", "The Proxmox archive keyring is missing; Ceph installation cannot continue safely": "Der Archivschlüsselbund Proxmox fehlt;Die Ceph-Installation kann nicht sicher fortgesetzt werden", "The Proxmox inventory and the local configurations differ": "Das Proxmox-Inventar und die lokalen Konfigurationen unterscheiden sich", @@ -7208,6 +7215,7 @@ "Update now?": "Update jetzt?", "Update package index:": "Paketindex aktualisieren:", "Update prepared": "Update vorbereitet", + "Update selected OCI applications": "Ausgewählte OCI-Anwendungen aktualisieren", "Update system to latest PVE 8.4+ (if not done already):": "Aktualisieren Sie das System auf die neueste PVE 8.4+ (falls noch nicht geschehen):", "Update the image with the saved configuration": "Aktualisieren Sie das Bild mit der gespeicherten Konfiguration", "Update the whole stack?": "Aktualisieren Sie den gesamten Stack?", @@ -7219,6 +7227,7 @@ "Updates and Packages Commands": "Befehle für Updates und Pakete", "Updates are not available yet for this application in this beta": "Updates sind für diese Anwendung in dieser Beta noch nicht verfügbar", "Updates are not available yet in this beta for applications that use a privileged container or advanced LXC settings": "Updates sind in dieser Beta noch nicht verfügbar für Anwendungen, die einen privilegierten Container oder erweiterte LXC-Einstellungen verwenden", + "Updates available": "Updates verfügbar", "Updates file is empty or unreadable.": "Die Aktualisierungsdatei ist leer oder nicht lesbar.", "Updating": "Aktualisierung", "Updating APT package lists...": "APT-Paketlisten werden aktualisiert...", diff --git a/lang/es.json b/lang/es.json index c01b7daa..6f4fe30d 100644 --- a/lang/es.json +++ b/lang/es.json @@ -385,6 +385,7 @@ "An Industrial-Level Controllable and Efficient Zero-Shot Text-To-Speech System": "Un sistema de texto a voz zero-shot de nivel industrial, controlable y eficiente", "An Intel dedicated GPU has been detected without FLR support": "Se ha detectado una GPU dedicada de Intel sin soporte FLR", "An accelerated video generation framework that speeds up end-to-end diffusion while preserving video quality": "Un framework de generación de vídeo que acelera la difusión de extremo a extremo conservando la calidad del vídeo", + "An application changed since the update check. Check for updates again.": "Una aplicación ha cambiado desde la última comprobación. Vuelve a buscar actualizaciones.", "An executable required by the adapter is missing in the new image": "Un ejecutable necesario para el adaptador falta en la nueva imagen", "An fstab entry already exists for:": "Ya existe una entrada fstab para:", "An image probe container was started externally": "Un contenedor de sonda de imagen se inició externamente", @@ -818,6 +819,7 @@ "Check access to an image": "Comprobar el acceso a una imagen", "Check connectivity with another host": "Verificar la conectividad con otro host", "Check container mount points": "Comprobar los puntos de montaje del contenedor", + "Check for updates": "Buscar actualizaciones", "Check if container is unprivileged:": "Comprobar si el contenedor no tiene privilegios:", "Check kernel params (IOMMU flags)": "Comprobar los parámetros del kernel (opciones IOMMU)", "Check loaded GPU/TPU modules": "Comprobar los módulos de GPU/TPU cargados", @@ -838,6 +840,7 @@ "Checking ZFS autotrim configuration...": "Comprobando la configuración de recorte automático de ZFS...", "Checking and repairing old LVM PV headers (if needed)...": "Comprobando y reparando cabeceras antiguas de PV de LVM (si es necesario)...", "Checking conflicting drivers blacklist...": "Comprobando la lista negra de controladores en conflicto...", + "Checking for updates ({completed}/{total})": "Buscando actualizaciones ({completed}/{total})", "Checking for updates...": "Buscando actualizaciones...", "Checking free space in /var/cache/apt/archives...": "Comprobando espacio libre en /var/cache/apt/archives...", "Checking if system disk is SSD or M.2...": "Comprobando si el disco del sistema es SSD o M.2...", @@ -4803,6 +4806,7 @@ "Press Enter to return...": "Pulsa Intro para volver...", "Press Enter when the line has been pasted on the server...": "Pulsa Intro cuando la línea se haya pegado en el servidor...", "Press OK to see the preview, then confirm": "Pulsa OK para ver la vista previa, luego confirma", + "Press Space to select applications to update. Select all includes every application in this list.": "Pulsa Espacio para seleccionar las aplicaciones que quieres actualizar. Seleccionar todas incluye todas las aplicaciones de esta lista.", "Press any key to continue...": "Pulsa cualquier tecla para continuar...", "Pretty uptime format": "Formato bastante tiempo de actividad", "Preview Backup": "Vista previa de backup", @@ -5346,6 +5350,7 @@ "Reverting pigz wrapper...": "Revertir la envoltura de pigz...", "Reverting vzdump speed tuning...": "Revertir el ajuste de velocidad de vzdump...", "Review and apply": "Revisar y aplicar", + "Review and update these applications?": "¿Quieres actualizar estas aplicaciones tras revisar la selección?", "Review external OCI changes": "Revisar cambios externos de OCI", "Review package upgrade": "Revisar la actualización de paquetes", "Review passthrough config files": "Revisar los archivos de configuración de passthrough", @@ -5597,6 +5602,7 @@ "Select a share to delete:": "Selecciona un recurso compartido para eliminar:", "Select a specific Coral or USB node, not the whole /dev": "Selecciona un Coral específico o nodo USB, no todo /dev", "Select access mode": "Seleccionar modo de acceso", + "Select all": "Seleccionar todas", "Select an existing group": "Seleccionar un grupo existente", "Select an existing group:": "Selecciona un grupo existente:", "Select an export to delete:": "Selecciona una exportación para eliminar:", @@ -6278,6 +6284,7 @@ "The Paperless persistent volumes need at least 8 GB": "Los volúmenes persistentes de Paperless necesitan al menos 8 GB", "The PostgreSQL volume needs at least 4 GB": "El volumen de PostgreSQL necesita al menos 4 GB", "The PostgreSQL volume needs at least 8 GB": "El volumen de PostgreSQL necesita al menos 8 GB", + "The ProxMenux Monitor update checker is not available. Start or update ProxMenux Monitor and try again.": "El comprobador de actualizaciones de ProxMenux Monitor no está disponible. Inicia o actualiza ProxMenux Monitor e inténtalo de nuevo.", "The Proxmox VE 8 update did not finish. The upgrade to Proxmox VE 9 was not started.": "La actualización de Proxmox VE 8 no ha terminado. No se ha iniciado la actualización a Proxmox VE 9.", "The Proxmox archive keyring is missing; Ceph installation cannot continue safely": "Falta el conjunto de claves de archivo Proxmox; La instalación de Ceph no puede continuar de forma segura", "The Proxmox inventory and the local configurations differ": "El inventario de Proxmox y las configuraciones locales difieren", @@ -7181,6 +7188,7 @@ "Update now?": "¿Actualizar ahora?", "Update package index:": "Actualizar índice del paquete:", "Update prepared": "Actualización preparada", + "Update selected OCI applications": "Actualizar las aplicaciones OCI seleccionadas", "Update system to latest PVE 8.4+ (if not done already):": "Actualiza el sistema a la última versión de PVE 8.4+ (si aún no lo has hecho):", "Update the image with the saved configuration": "Actualizar la imagen con la configuración guardada", "Update the whole stack?": "¿Actualizar toda la pila?", @@ -7192,6 +7200,7 @@ "Updates and Packages Commands": "Comandos de actualizaciones y paquetes", "Updates are not available yet for this application in this beta": "Aún no hay actualizaciones para esta aplicación en esta beta", "Updates are not available yet in this beta for applications that use a privileged container or advanced LXC settings": "Aún no se dispone de actualizaciones en esta beta para aplicaciones que utilizan un contenedor privilegiado o configuración avanzada LXC", + "Updates available": "Actualizaciones disponibles", "Updates file is empty or unreadable.": "El archivo de actualizaciones está vacío o es ilegible.", "Updating": "Actualizando", "Updating APT package lists...": "Actualizando listas de paquetes APT...", diff --git a/lang/fr.json b/lang/fr.json index 40682cd3..78f69575 100644 --- a/lang/fr.json +++ b/lang/fr.json @@ -389,6 +389,7 @@ "An Industrial-Level Controllable and Efficient Zero-Shot Text-To-Speech System": "Un système de discussion texte-à-parler efficace et contrôlable au niveau industriel", "An Intel dedicated GPU has been detected without FLR support": "Un GPU dédié Intel a été détecté sans prise en charge FLR", "An accelerated video generation framework that speeds up end-to-end diffusion while preserving video quality": "Un cadre de production vidéo accéléré qui accélère la diffusion de bout en bout tout en préservant la qualité vidéo", + "An application changed since the update check. Check for updates again.": "Une application a changé depuis la dernière vérification. Relancez la recherche de mises à jour.", "An executable required by the adapter is missing in the new image": "Une requi exécutable est manquante dans la nouvelle image", "An fstab entry already exists for:": "Une entrée fstab existe déjà pour :", "An image probe container was started externally": "Un conteneur de sonde d'image a été lancé à l'extérieur", @@ -818,6 +819,7 @@ "Check access to an image": "Vérifier l'accès à une image", "Check connectivity with another host": "Vérifiez la connectivité avec un autre hôte", "Check container mount points": "Vérifier les points de montage du conteneur", + "Check for updates": "Rechercher des mises à jour", "Check if container is unprivileged:": "Vérifiez si le conteneur n'est pas privilégié :", "Check kernel params (IOMMU flags)": "Vérifiez les paramètres du noyau (drapeaux IOMMU)", "Check loaded GPU/TPU modules": "Vérifiez les modules GPU/TPU chargés", @@ -838,6 +840,7 @@ "Checking ZFS autotrim configuration...": "Vérification de la configuration du découpage automatique ZFS...", "Checking and repairing old LVM PV headers (if needed)...": "Vérification et réparation des anciens collecteurs PV LVM (si nécessaire)...", "Checking conflicting drivers blacklist...": "Vérification de la liste noire des pilotes en conflit...", + "Checking for updates ({completed}/{total})": "Recherche de mises à jour ({completed}/{total})", "Checking for updates...": "Vérification des mises à jour...", "Checking free space in /var/cache/apt/archives...": "Vérification de l'espace libre dans /var/cache/apt/archives...", "Checking if system disk is SSD or M.2...": "Vérifier si le disque système est SSD ou M.2...", @@ -4812,6 +4815,7 @@ "Press Enter to return...": "Appuyez sur Entrée pour revenir...", "Press Enter when the line has been pasted on the server...": "Appuyez sur Entrée lorsque la ligne a été collée sur le serveur...", "Press OK to see the preview, then confirm": "Appuyez sur OK pour voir l'aperçu, puis confirmez", + "Press Space to select applications to update. Select all includes every application in this list.": "Appuyez sur Espace pour sélectionner les applications à mettre à jour. Tout sélectionner inclut toutes les applications de cette liste.", "Press any key to continue...": "Appuyez sur n'importe quelle touche pour continuer...", "Pretty uptime format": "Joli format de disponibilité", "Preview Backup": "Aperçu de la sauvegarde", @@ -5362,6 +5366,7 @@ "Reverting pigz wrapper...": "Rétablissement du wrapper pigz...", "Reverting vzdump speed tuning...": "Annulation du réglage de la vitesse de vzdump...", "Review and apply": "Vérifier et appliquer", + "Review and update these applications?": "Vérifier la sélection et mettre à jour ces applications ?", "Review external OCI changes": "Examiner les modifications externes de OCI", "Review package upgrade": "Examiner la mise à niveau des paquets", "Review passthrough config files": "Examiner les fichiers de configuration relais", @@ -5613,6 +5618,7 @@ "Select a share to delete:": "Sélectionnez un partage à supprimer :", "Select a specific Coral or USB node, not the whole /dev": "Sélectionnez un noeud spécifique Coral ou USB, pas l'ensemble /dev", "Select access mode": "Sélectionnez le mode d'accès", + "Select all": "Tout sélectionner", "Select an existing group": "Sélectionnez un groupe existant", "Select an existing group:": "Sélectionnez un groupe existant :", "Select an export to delete:": "Sélectionnez une exportation à supprimer :", @@ -6292,6 +6298,7 @@ "The Paperless persistent volumes need at least 8 GB": "Les volumes persistants sans papier nécessitent au moins 8 Go", "The PostgreSQL volume needs at least 4 GB": "Le volume PostgreSQL nécessite au moins 4 Go", "The PostgreSQL volume needs at least 8 GB": "Le volume PostgreSQL nécessite au moins 8 Go", + "The ProxMenux Monitor update checker is not available. Start or update ProxMenux Monitor and try again.": "La vérification des mises à jour de ProxMenux Monitor est indisponible. Démarrez ou mettez à jour ProxMenux Monitor, puis réessayez.", "The Proxmox VE 8 update did not finish. The upgrade to Proxmox VE 9 was not started.": "La mise à jour de Proxmox VE 8 n’a pas abouti. La mise à niveau vers Proxmox VE 9 n’a pas été lancée.", "The Proxmox archive keyring is missing; Ceph installation cannot continue safely": "Le trousseau de clés d'archive Proxmox est manquant ;L'installation de Ceph ne peut pas continuer en toute sécurité", "The Proxmox inventory and the local configurations differ": "L'inventaire Proxmox et les configurations locales diffèrent", @@ -7207,6 +7214,7 @@ "Update now?": "Mise à jour ?", "Update package index:": "Index du package de mise à jour :", "Update prepared": "Mise à jour préparée", + "Update selected OCI applications": "Mettre à jour les applications OCI sélectionnées", "Update system to latest PVE 8.4+ (if not done already):": "Mettre à jour le système vers la dernière version PVE 8.4+ (si ce n'est déjà fait) :", "Update the image with the saved configuration": "Mettre à jour l'image avec la configuration enregistrée", "Update the whole stack?": "Mettre à jour toute la pile ?", @@ -7218,6 +7226,7 @@ "Updates and Packages Commands": "Commandes de mises à jour et de packages", "Updates are not available yet for this application in this beta": "Les mises à jour ne sont pas encore disponibles pour cette application dans cette bêta", "Updates are not available yet in this beta for applications that use a privileged container or advanced LXC settings": "Les mises à jour ne sont pas encore disponibles dans cette bêta pour les applications qui utilisent un conteneur privilégié ou des paramètres avancés LXC", + "Updates available": "Mises à jour disponibles", "Updates file is empty or unreadable.": "Le fichier de mises à jour est vide ou illisible.", "Updating": "Mise à jour", "Updating APT package lists...": "Mise à jour des listes de packages APT...", diff --git a/lang/it.json b/lang/it.json index b71db0f4..93e80b8e 100644 --- a/lang/it.json +++ b/lang/it.json @@ -389,6 +389,7 @@ "An Industrial-Level Controllable and Efficient Zero-Shot Text-To-Speech System": "Un sistema di sintesi vocale da testo zero-shot, controllabile ed efficiente, di livello industriale", "An Intel dedicated GPU has been detected without FLR support": "È stata rilevata una GPU Intel dedicata senza supporto FLR", "An accelerated video generation framework that speeds up end-to-end diffusion while preserving video quality": "Un framework per la generazione accelerata di video che velocizza l'intero processo di diffusione preservando la qualità video", + "An application changed since the update check. Check for updates again.": "Un’applicazione è cambiata dall’ultima verifica. Cerca di nuovo gli aggiornamenti.", "An executable required by the adapter is missing in the new image": "Nella nuova immagine manca un eseguibile richiesto dall'adattatore", "An fstab entry already exists for:": "Esiste già una voce fstab per:", "An image probe container was started externally": "Un container usato per verificare l'immagine è stato avviato dall'esterno", @@ -818,6 +819,7 @@ "Check access to an image": "Controlla l'accesso a un'immagine", "Check connectivity with another host": "Controlla la connettività con un altro host", "Check container mount points": "Controlla i punti di montaggio del container", + "Check for updates": "Cerca aggiornamenti", "Check if container is unprivileged:": "Controlla se il container non è privilegiato:", "Check kernel params (IOMMU flags)": "Controlla i parametri del kernel (flag IOMMU)", "Check loaded GPU/TPU modules": "Controlla moduli GPU/TPU caricati", @@ -838,6 +840,7 @@ "Checking ZFS autotrim configuration...": "Controllo della configurazione autotrim di ZFS...", "Checking and repairing old LVM PV headers (if needed)...": "Controllo e riparazione delle vecchie intestazioni dei PV LVM (se necessario)...", "Checking conflicting drivers blacklist...": "Controllo della lista nera dei driver in conflitto...", + "Checking for updates ({completed}/{total})": "Ricerca aggiornamenti ({completed}/{total})", "Checking for updates...": "Controllo aggiornamenti...", "Checking free space in /var/cache/apt/archives...": "Controllo dello spazio libero in /var/cache/apt/archives...", "Checking if system disk is SSD or M.2...": "Verifica se il disco di sistema è SSD o M.2...", @@ -4812,6 +4815,7 @@ "Press Enter to return...": "Premi Invio per tornare...", "Press Enter when the line has been pasted on the server...": "Premi Invio quando la riga è stata incollata sul server...", "Press OK to see the preview, then confirm": "Premi OK per vedere l’anteprima, quindi conferma", + "Press Space to select applications to update. Select all includes every application in this list.": "Premi Spazio per selezionare le applicazioni da aggiornare. Seleziona tutte include tutte le applicazioni in questo elenco.", "Press any key to continue...": "Premi un tasto per continuare...", "Pretty uptime format": "Formato leggibile del tempo di attività", "Preview Backup": "Anteprima backup", @@ -5362,6 +5366,7 @@ "Reverting pigz wrapper...": "Rimozione del wrapper pigz e ripristino della configurazione precedente...", "Reverting vzdump speed tuning...": "Rimozione ottimizzazioni velocità vzdump…", "Review and apply": "Verifica e applica", + "Review and update these applications?": "Vuoi rivedere la selezione e aggiornare queste applicazioni?", "Review external OCI changes": "Esamina le modifiche OCI esterne", "Review package upgrade": "Esamina l'aggiornamento dei pacchetti", "Review passthrough config files": "Esamina i file di configurazione passthrough", @@ -5613,6 +5618,7 @@ "Select a share to delete:": "Seleziona una condivisione da eliminare:", "Select a specific Coral or USB node, not the whole /dev": "Seleziona un nodo Coral o USB specifico, non l’intera directory /dev", "Select access mode": "Seleziona modalità di accesso", + "Select all": "Seleziona tutte", "Select an existing group": "Seleziona un gruppo esistente", "Select an existing group:": "Seleziona un gruppo esistente:", "Select an export to delete:": "Seleziona un'esportazione da eliminare:", @@ -6293,6 +6299,7 @@ "The Paperless persistent volumes need at least 8 GB": "I volumi persistenti di Paperless richiedono almeno 8 GB", "The PostgreSQL volume needs at least 4 GB": "Il volume PostgreSQL richiede almeno 4 GB", "The PostgreSQL volume needs at least 8 GB": "Il volume PostgreSQL richiede almeno 8 GB", + "The ProxMenux Monitor update checker is not available. Start or update ProxMenux Monitor and try again.": "Il controllo degli aggiornamenti di ProxMenux Monitor non è disponibile. Avvia o aggiorna ProxMenux Monitor e riprova.", "The Proxmox VE 8 update did not finish. The upgrade to Proxmox VE 9 was not started.": "L’aggiornamento di Proxmox VE 8 non è stato completato. L’aggiornamento a Proxmox VE 9 non è stato avviato.", "The Proxmox archive keyring is missing; Ceph installation cannot continue safely": "Manca il portachiavi dell’archivio Proxmox; l’installazione di Ceph non può continuare in sicurezza", "The Proxmox inventory and the local configurations differ": "L'inventario Proxmox e le configurazioni locali differiscono", @@ -7208,6 +7215,7 @@ "Update now?": "Aggiornare ora?", "Update package index:": "Aggiornare l’indice pacchetti:", "Update prepared": "Aggiornamento preparato", + "Update selected OCI applications": "Aggiorna le applicazioni OCI selezionate", "Update system to latest PVE 8.4+ (if not done already):": "Aggiornare il sistema all’ultima versione PVE 8.4+ (se non già fatto):", "Update the image with the saved configuration": "Aggiorna l’immagine mantenendo la configurazione salvata", "Update the whole stack?": "Aggiornare l’intero stack?", @@ -7219,6 +7227,7 @@ "Updates and Packages Commands": "Comandi aggiornamenti e pacchetti", "Updates are not available yet for this application in this beta": "Gli aggiornamenti per questa applicazione non sono ancora disponibili in questa beta", "Updates are not available yet in this beta for applications that use a privileged container or advanced LXC settings": "Gli aggiornamenti non sono ancora disponibili in questa versione beta per applicazioni che usano un container privilegiato o impostazioni LXC avanzate", + "Updates available": "Aggiornamenti disponibili", "Updates file is empty or unreadable.": "Il file degli aggiornamenti è vuoto o illeggibile.", "Updating": "Aggiornamento", "Updating APT package lists...": "Aggiornamento elenchi pacchetti APT...", diff --git a/lang/pt.json b/lang/pt.json index a0a14f5f..8b98cda9 100644 --- a/lang/pt.json +++ b/lang/pt.json @@ -389,6 +389,7 @@ "An Industrial-Level Controllable and Efficient Zero-Shot Text-To-Speech System": "Um Sistema de Texto-A-Shot Controlável e Eficiente de Nível Industrial", "An Intel dedicated GPU has been detected without FLR support": "Uma GPU dedicada Intel foi detectada sem suporte FLR", "An accelerated video generation framework that speeds up end-to-end diffusion while preserving video quality": "Um framework acelerado de geração de vídeo que acelera a difusão de ponta a ponta, preservando a qualidade de vídeo", + "An application changed since the update check. Check for updates again.": "Uma aplicação foi alterada desde a última verificação. Procure atualizações novamente.", "An executable required by the adapter is missing in the new image": "Falta um executável required pelo adaptador na nova imagem", "An fstab entry already exists for:": "Já existe uma entrada fstab para:", "An image probe container was started externally": "Foi iniciado um recipiente de sonda de imagem externamente", @@ -818,6 +819,7 @@ "Check access to an image": "Verifique o acesso a uma imagem", "Check connectivity with another host": "Verifique a conectividade com outro host", "Check container mount points": "Verifique os pontos de montagem do contêiner", + "Check for updates": "Procurar atualizações", "Check if container is unprivileged:": "Verifique se o contêiner não tem privilégios:", "Check kernel params (IOMMU flags)": "Verifique os parâmetros do kernel (sinalizadores IOMMU)", "Check loaded GPU/TPU modules": "Verifique os módulos GPU/TPU carregados", @@ -838,6 +840,7 @@ "Checking ZFS autotrim configuration...": "Verificando a configuração do ajuste automático do ZFS...", "Checking and repairing old LVM PV headers (if needed)...": "Verificando e reparando coletores LVM PV antigos (se necessário)...", "Checking conflicting drivers blacklist...": "Verificando a lista negra de drivers conflitantes...", + "Checking for updates ({completed}/{total})": "A procurar atualizações ({completed}/{total})", "Checking for updates...": "Verificando atualizações...", "Checking free space in /var/cache/apt/archives...": "Verificando espaço livre em /var/cache/apt/archives...", "Checking if system disk is SSD or M.2...": "Verificando se o disco do sistema é SSD ou M.2...", @@ -4812,6 +4815,7 @@ "Press Enter to return...": "Pressione Enter para retornar...", "Press Enter when the line has been pasted on the server...": "Pressione Enter quando a linha for colada no servidor...", "Press OK to see the preview, then confirm": "Pressione OK para ver a visualização e confirme", + "Press Space to select applications to update. Select all includes every application in this list.": "Prima Espaço para selecionar as aplicações a atualizar. Selecionar todas inclui todas as aplicações desta lista.", "Press any key to continue...": "Pressione qualquer tecla para continuar...", "Pretty uptime format": "Formato de tempo de atividade bonito", "Preview Backup": "Visualizar backup", @@ -5362,6 +5366,7 @@ "Reverting pigz wrapper...": "Revertendo o wrapper pigz...", "Reverting vzdump speed tuning...": "Revertendo o ajuste de velocidade do vzdump...", "Review and apply": "Rever e aplicar", + "Review and update these applications?": "Rever a seleção e atualizar estas aplicações?", "Review external OCI changes": "revisar alterações externas do OCI", "Review package upgrade": "Rever a atualização de pacotes", "Review passthrough config files": "Revise os arquivos de configuração de passagem", @@ -5613,6 +5618,7 @@ "Select a share to delete:": "Selecione um compartilhamento para excluir:", "Select a specific Coral or USB node, not the whole /dev": "Selecione um nó Coral ou USB específico, não o /dev inteiro", "Select access mode": "Selecione o modo de acesso", + "Select all": "Selecionar todas", "Select an existing group": "Selecione um grupo existente", "Select an existing group:": "Selecione um grupo existente:", "Select an export to delete:": "Selecione uma exportação para excluir:", @@ -6293,6 +6299,7 @@ "The Paperless persistent volumes need at least 8 GB": "Os volumes persistentes sem papel precisam de pelo menos 8 GB", "The PostgreSQL volume needs at least 4 GB": "O volume PostgreSQL precisa de pelo menos 4 GB", "The PostgreSQL volume needs at least 8 GB": "O volume PostgreSQL precisa de pelo menos 8 GB", + "The ProxMenux Monitor update checker is not available. Start or update ProxMenux Monitor and try again.": "O verificador de atualizações do ProxMenux Monitor não está disponível. Inicie ou atualize o ProxMenux Monitor e tente novamente.", "The Proxmox VE 8 update did not finish. The upgrade to Proxmox VE 9 was not started.": "A atualização do Proxmox VE 8 não terminou. A atualização para o Proxmox VE 9 não foi iniciada.", "The Proxmox archive keyring is missing; Ceph installation cannot continue safely": "O chaveiro de arquivo Proxmox está faltando;A instalação do Ceph não pode continuar com segurança", "The Proxmox inventory and the local configurations differ": "O inventário Proxmox e as configurações locais diferem", @@ -7208,6 +7215,7 @@ "Update now?": "Actualizar agora?", "Update package index:": "Atualizar índice do pacote:", "Update prepared": "Actualização preparada", + "Update selected OCI applications": "Atualizar as aplicações OCI selecionadas", "Update system to latest PVE 8.4+ (if not done already):": "Atualize o sistema para o PVE 8.4+ mais recente (se ainda não tiver feito):", "Update the image with the saved configuration": "Atualizar a imagem com a configuração salva", "Update the whole stack?": "Atualizar toda a pilha?", @@ -7219,6 +7227,7 @@ "Updates and Packages Commands": "Comandos de atualizações e pacotes", "Updates are not available yet for this application in this beta": "As atualizações ainda não estão disponíveis para esta aplicação neste beta", "Updates are not available yet in this beta for applications that use a privileged container or advanced LXC settings": "Atualizações ainda não estão disponíveis neste beta para aplicações que usam um recipiente privilegiado ou configurações LXC avançadas", + "Updates available": "Atualizações disponíveis", "Updates file is empty or unreadable.": "O arquivo de atualizações está vazio ou ilegível.", "Updating": "Actualização", "Updating APT package lists...": "Atualizando listas de pacotes APT...", diff --git a/lang/sk.json b/lang/sk.json index 13346f9e..e813b979 100644 --- a/lang/sk.json +++ b/lang/sk.json @@ -389,6 +389,7 @@ "An Industrial-Level Controllable and Efficient Zero-Shot Text-To-Speech System": "Priemyselná úroveň ovládateľná a efektívna zero-Shot Text-To-Speech System", "An Intel dedicated GPU has been detected without FLR support": "Bola zistená dedikovaná Intel GPU bez podpory FLR", "An accelerated video generation framework that speeds up end-to-end diffusion while preserving video quality": "Zrýchlený rámec tvorby videa, ktorý urýchľuje šírenie do konca a zároveň zachováva kvalitu videa", + "An application changed since the update check. Check for updates again.": "Od poslednej kontroly sa aplikácia zmenila. Znova vyhľadajte aktualizácie.", "An executable required by the adapter is missing in the new image": "V novom obrázku chýba spustiteľný requi.", "An fstab entry already exists for:": "Záznam vo fstab už existuje pre:", "An image probe container was started externally": "Externe bola spustená obrazová sonda", @@ -818,6 +819,7 @@ "Check access to an image": "Kontrola prístupu k obrázku", "Check connectivity with another host": "Skontrolovať spojenie s iným hostom", "Check container mount points": "Skontrolovať body pripojenia kontajnera", + "Check for updates": "Vyhľadať aktualizácie", "Check if container is unprivileged:": "Skontrolovať, či je kontajner neprivilegovaný:", "Check kernel params (IOMMU flags)": "Skontrolovať parametre kernelu (IOMMU voľby)", "Check loaded GPU/TPU modules": "Skontrolovať načítané GPU/TPU moduly", @@ -838,6 +840,7 @@ "Checking ZFS autotrim configuration...": "Kontrolujem nastavenie ZFS autotrim...", "Checking and repairing old LVM PV headers (if needed)...": "Kontrolujem a opravujem staré LVM PV hlavičky (ak treba)...", "Checking conflicting drivers blacklist...": "Kontrolujem blacklist konfliktných ovládačov...", + "Checking for updates ({completed}/{total})": "Vyhľadávanie aktualizácií ({completed}/{total})", "Checking for updates...": "Kontrolujem aktualizácie...", "Checking free space in /var/cache/apt/archives...": "Kontrolujem voľné miesto v /var/cache/apt/archives...", "Checking if system disk is SSD or M.2...": "Kontrolujem, či je systémový disk SSD alebo M.2...", @@ -4814,6 +4817,7 @@ "Press Enter to return...": "Stlačte Enter pre návrat...", "Press Enter when the line has been pasted on the server...": "Po vložení riadka na server stlačte Enter...", "Press OK to see the preview, then confirm": "Stlačte OK na zobrazenie náhľadu a potom akciu potvrďte", + "Press Space to select applications to update. Select all includes every application in this list.": "Medzerníkom vyberte aplikácie, ktoré chcete aktualizovať. Možnosť Vybrať všetky zahŕňa všetky aplikácie v tomto zozname.", "Press any key to continue...": "Pokračujte stlačením ľubovoľného klávesu...", "Pretty uptime format": "Prehľadný formát doby behu systému", "Preview Backup": "Náhľad zálohy", @@ -5364,6 +5368,7 @@ "Reverting pigz wrapper...": "Vraciam späť wrapper pigz...", "Reverting vzdump speed tuning...": "Vraciam späť ladenie rýchlosti vzdump...", "Review and apply": "Skontrolovať a použiť", + "Review and update these applications?": "Skontrolovať výber a aktualizovať tieto aplikácie?", "Review external OCI changes": "Skontrolujte externé zmeny OCI", "Review package upgrade": "Skontrolovať aktualizáciu balíkov", "Review passthrough config files": "Skontrolovať konfiguračné súbory passthrough", @@ -5615,6 +5620,7 @@ "Select a share to delete:": "Vyberte zdieľanie na vymazanie:", "Select a specific Coral or USB node, not the whole /dev": "Vyberte špecifický Coral alebo USB uzol, nie celý /dev", "Select access mode": "Vyberte režim prístupu", + "Select all": "Vybrať všetky", "Select an existing group": "Vybrať existujúcu skupinu", "Select an existing group:": "Vyberte existujúcu skupinu:", "Select an export to delete:": "Vyberte export na vymazanie:", @@ -6294,6 +6300,7 @@ "The Paperless persistent volumes need at least 8 GB": "Trvalé objemy bez dokumentov potrebujú najmenej 8 GB", "The PostgreSQL volume needs at least 4 GB": "Objem PostgreSQL potrebuje najmenej 4 GB", "The PostgreSQL volume needs at least 8 GB": "Objem PostgreSQL potrebuje najmenej 8 GB", + "The ProxMenux Monitor update checker is not available. Start or update ProxMenux Monitor and try again.": "Kontrola aktualizácií v ProxMenux Monitor nie je dostupná. Spustite alebo aktualizujte ProxMenux Monitor a skúste to znova.", "The Proxmox VE 8 update did not finish. The upgrade to Proxmox VE 9 was not started.": "Aktualizácia Proxmox VE 8 sa nedokončila. Prechod na Proxmox VE 9 sa nespustil.", "The Proxmox archive keyring is missing; Ceph installation cannot continue safely": "Chýba zväzok kľúčov archívu Proxmox;Inštalácia Ceph nemôže bezpečne pokračovať", "The Proxmox inventory and the local configurations differ": "Súpis Proxmox a miestne konfigurácie sa líšia", @@ -7209,6 +7216,7 @@ "Update now?": "Aktualizácia?", "Update package index:": "Aktualizujte index balíkov:", "Update prepared": "Aktualizácia", + "Update selected OCI applications": "Aktualizovať vybrané aplikácie OCI", "Update system to latest PVE 8.4+ (if not done already):": "Aktualizujte systém na najnovšie PVE 8.4+ (ak to ešte nie je hotové):", "Update the image with the saved configuration": "Aktualizovať obrázok pomocou uloženej konfigurácie", "Update the whole stack?": "Aktualizovať celú kopu?", @@ -7220,6 +7228,7 @@ "Updates and Packages Commands": "Príkazy pre aktualizácie a balíky", "Updates are not available yet for this application in this beta": "Aktualizácie zatiaľ nie sú k dispozícii pre túto aplikáciu v tejto beta", "Updates are not available yet in this beta for applications that use a privileged container or advanced LXC settings": "Aktualizácie zatiaľ nie sú k dispozícii v tomto beta pre aplikácie, ktoré používajú privilegovaný kontajner alebo pokročilé nastavenia LXC", + "Updates available": "Dostupné aktualizácie", "Updates file is empty or unreadable.": "Súbor s aktualizáciami je prázdny alebo sa nedá prečítať.", "Updating": "Aktualizácia", "Updating APT package lists...": "Aktualizujem zoznamy APT balíkov...", diff --git a/lang/sv.json b/lang/sv.json index 9cffe5e4..047b2161 100644 --- a/lang/sv.json +++ b/lang/sv.json @@ -389,6 +389,7 @@ "An Industrial-Level Controllable and Efficient Zero-Shot Text-To-Speech System": "En industriell nivå kontrollerbar och effektiv noll-shot text-till-talsystem", "An Intel dedicated GPU has been detected without FLR support": "En dedikerad Intel GPU har upptäckts utan FLR-stöd", "An accelerated video generation framework that speeds up end-to-end diffusion while preserving video quality": "En accelererad videogenereringsram som påskyndar end-to-end diffusion samtidigt som videokvaliteten bevaras", + "An application changed since the update check. Check for updates again.": "Ett program har ändrats sedan den senaste kontrollen. Sök efter uppdateringar igen.", "An executable required by the adapter is missing in the new image": "En körbar required av adaptern saknas i den nya bilden", "An fstab entry already exists for:": "En fstab-post finns redan för:", "An image probe container was started externally": "En bildsond behållare startades externt", @@ -818,6 +819,7 @@ "Check access to an image": "Kontrollera åtkomst till en bild", "Check connectivity with another host": "Kontrollera anslutningen med en annan värd", "Check container mount points": "Kontrollera behållarens monteringspunkter", + "Check for updates": "Sök efter uppdateringar", "Check if container is unprivileged:": "Kontrollera om behållaren är oprivilegierad:", "Check kernel params (IOMMU flags)": "Kontrollera kärnparametrar (IOMMU-flaggor)", "Check loaded GPU/TPU modules": "Kontrollera laddade GPU/TPU-moduler", @@ -838,6 +840,7 @@ "Checking ZFS autotrim configuration...": "Kontrollerar ZFS autotrim-konfiguration...", "Checking and repairing old LVM PV headers (if needed)...": "Kontrollera och reparera gamla LVM PV-huvuden (om det behövs)...", "Checking conflicting drivers blacklist...": "Kontrollerar svartlista för motstridiga drivrutiner...", + "Checking for updates ({completed}/{total})": "Söker efter uppdateringar ({completed}/{total})", "Checking for updates...": "Söker efter uppdateringar...", "Checking free space in /var/cache/apt/archives...": "Kontrollerar ledigt utrymme i /var/cache/apt/archives...", "Checking if system disk is SSD or M.2...": "Kontrollerar om systemdisken är SSD eller M.2...", @@ -4812,6 +4815,7 @@ "Press Enter to return...": "Tryck på Enter för att gå tillbaka...", "Press Enter when the line has been pasted on the server...": "Tryck på Enter när raden har klistrats in på servern...", "Press OK to see the preview, then confirm": "Tryck på OK för att se förhandsgranskningen och bekräfta sedan", + "Press Space to select applications to update. Select all includes every application in this list.": "Tryck på mellanslag för att välja vilka program som ska uppdateras. Välj alla omfattar alla program i listan.", "Press any key to continue...": "Tryck på valfri tangent för att fortsätta...", "Pretty uptime format": "Ganska upptidsformat", "Preview Backup": "Förhandsgranska säkerhetskopiering", @@ -5362,6 +5366,7 @@ "Reverting pigz wrapper...": "Återställer pigz-omslag...", "Reverting vzdump speed tuning...": "Återställer vzdump-hastighetsinställning...", "Review and apply": "Granska och tillämpa", + "Review and update these applications?": "Granska valet och uppdatera dessa program?", "Review external OCI changes": "Granska externa OCI ändringar", "Review package upgrade": "Granska paketuppgraderingen", "Review passthrough config files": "Granska passthrough-konfigurationsfiler", @@ -5613,6 +5618,7 @@ "Select a share to delete:": "Välj en delning att radera:", "Select a specific Coral or USB node, not the whole /dev": "Välj en specifik Coral eller USB-nod, inte hela/dev", "Select access mode": "Välj åtkomstläge", + "Select all": "Välj alla", "Select an existing group": "Välj en befintlig grupp", "Select an existing group:": "Välj en befintlig grupp:", "Select an export to delete:": "Välj en export att radera:", @@ -6293,6 +6299,7 @@ "The Paperless persistent volumes need at least 8 GB": "De papperslösa ihållande volymerna behöver minst 8 GB", "The PostgreSQL volume needs at least 4 GB": "PostgreSQL-volymen behöver minst 4 GB", "The PostgreSQL volume needs at least 8 GB": "PostgreSQL-volymen behöver minst 8 GB", + "The ProxMenux Monitor update checker is not available. Start or update ProxMenux Monitor and try again.": "Uppdateringskontrollen i ProxMenux Monitor är inte tillgänglig. Starta eller uppdatera ProxMenux Monitor och försök igen.", "The Proxmox VE 8 update did not finish. The upgrade to Proxmox VE 9 was not started.": "Uppdateringen av Proxmox VE 8 slutfördes inte. Uppgraderingen till Proxmox VE 9 startades inte.", "The Proxmox archive keyring is missing; Ceph installation cannot continue safely": "Proxmox arkivnyckelringen saknas;Ceph-installationen kan inte fortsätta på ett säkert sätt", "The Proxmox inventory and the local configurations differ": "Proxmox-inventeringen och de lokala konfigurationerna skiljer sig åt", @@ -7208,6 +7215,7 @@ "Update now?": "Uppdatera nu?", "Update package index:": "Uppdatera paketindex:", "Update prepared": "Uppdatering förberedd", + "Update selected OCI applications": "Uppdatera valda OCI-program", "Update system to latest PVE 8.4+ (if not done already):": "Uppdatera systemet till senaste PVE 8.4+ (om det inte redan är gjort):", "Update the image with the saved configuration": "Uppdatera bilden med sparad konfiguration", "Update the whole stack?": "Uppdatera hela stacken?", @@ -7219,6 +7227,7 @@ "Updates and Packages Commands": "Uppdateringar och paketkommandon", "Updates are not available yet for this application in this beta": "Uppdateringar är inte tillgängliga ännu för denna ansökan i denna beta", "Updates are not available yet in this beta for applications that use a privileged container or advanced LXC settings": "Uppdateringar finns ännu inte i denna beta för applikationer som använder en privilegierad behållare eller avancerad LXC-inställningar.", + "Updates available": "Uppdateringar tillgängliga", "Updates file is empty or unreadable.": "Uppdateringsfilen är tom eller oläsbar.", "Updating": "Uppdatering", "Updating APT package lists...": "Uppdaterar APT-paketlistor...", diff --git a/oci/README.md b/oci/README.md index a18414a4..cf735188 100644 --- a/oci/README.md +++ b/oci/README.md @@ -137,6 +137,50 @@ An application made of several images is one entry of the catalog and one instal ## Update, modify, recreate and remove +The OCI menu has a **Check for updates** entry. Checking changes that same +entry to **Checking for updates (completed/total)**, then **Updates available (N)** when +newer images are found. Progress counts applications, including coordinated +applications with several containers, and advances as each finishes. When finished, the entry shows only +**Updates available (N)** or **Check for updates**. Only registry metadata is read; no image layers are +downloaded and no containers are changed. The menu remains usable during the +check. Leaving the OCI menu closes its request; the Monitor may finish +metadata queries already in progress before stopping the remaining checks. + +Open **Updates available (N)** to choose applications with Space. Nothing is +preselected, even when there is only one update. **Select all** at the top +includes every listed application. After reviewing the selection, each +application uses the existing update workflow, including its identity checks, +backup prompts and recovery. Cancelling or failing an update stops the batch. +The selection screen has only **Update** and **Cancel** buttons. + +Opening the menu reads the Monitor's existing `/etc/proxmenux/apps` results +without contacting registries. Its daily checks and the manual menu check use +the same `lxc_apps.check_app` implementation, cache and registry credentials. +There is no second checker, private update-status cache or fallback checker. +Manual checks run in the Monitor process through a root-only local Unix +socket, preserving its locks and registry retries without changing web +authentication. ProxMenux Monitor must be installed, running and include the +menu integration for manual checks. + +Manual checks run up to sixteen applications in parallel. The native checker +holds its shared cache lock only while reading or publishing a result; each +app has its own check lock. Before saving it re-reads the app and installation +record, discards answers for a concurrently edited/replaced app, and preserves +unrelated registrations and schedules. A fresh tag/index lookup that confirms +the same platform digest reuses its immutable version/build metadata. Changed +digests still fetch and verify the new metadata; installs and updates retain +the full image-contract verification path. Checks therefore still consult the +registry rather than returning a cached verdict as a new check. + +Results older than 24 hours or for a different installed image or reference +are not offered as current. Network failures retain the Monitor's existing +result and retry state, or remain unknown when no previous result exists. +Coordinated applications count once and follow the Monitor's existing policy: +update availability is tracked on the primary container, while secondary +containers are updated together with it. The menu does not add independent +update tracking for secondary images. A reference pinned to an immutable +digest does not follow newer tags. + *Manage installed OCI applications* lists the registered installations. Before any action a container is compared with its record; one that no longer matches is neither modified nor deleted. | Operation | What changes | What is kept | diff --git a/oci/remote/oci_installation_state.py b/oci/remote/oci_installation_state.py index 87a3c96c..ecc10691 100644 --- a/oci/remote/oci_installation_state.py +++ b/oci/remote/oci_installation_state.py @@ -163,7 +163,7 @@ def record_install(args): save_record(args.state_dir, record) -def resolve_candidate(reference, architecture): +def resolve_candidate(reference, architecture, *, _known_metadata=None): repo = reference.split('@', 1)[0] if ':' in repo.rsplit('/', 1)[-1]: repo = repo.rsplit(':', 1)[0] @@ -173,12 +173,27 @@ def resolve_candidate(reference, architecture): raw = command('skopeo', 'inspect', '--raw', 'docker://' + transport) registry_digest = 'sha256:' + sha(raw) manifest = json.loads(raw) - if 'manifests' in manifest: + indexed = 'manifests' in manifest + if indexed: matches = [m for m in manifest['manifests'] if m.get('platform', {}).get('architecture') == architecture and m.get('platform', {}).get('os') == 'linux'] if len(matches) != 1: raise ValueError(translate('A single matching image platform cannot be resolved')) digest = matches[0]['digest'] + else: + digest = registry_digest + # The Monitor needs only the published version/build and digest. A fresh + # tag/index lookup proving the same immutable platform digest also proves + # its previously verified metadata; fetching its manifest and config again + # adds no information. Install/update callers do not pass this option and + # always receive the complete verified image contract below. + if (isinstance(_known_metadata, dict) + and re.fullmatch(r'sha256:[0-9a-f]{64}', str(digest)) + and _known_metadata.get('manifest_digest') == digest + and _known_metadata.get('architecture') == architecture): + return {'manifest_digest': digest, 'registry_digest': registry_digest, + 'version': _known_metadata.get('version'), 'created': _known_metadata.get('created')} + if indexed: raw = command('skopeo', 'inspect', '--raw', 'docker://' + repo + '@' + digest) if 'sha256:' + sha(raw) != digest: raise ValueError(translate('The manifest does not match its digest')) @@ -204,6 +219,11 @@ def resolve_candidate(reference, architecture): 'defaults': defaults, 'version': version, 'created': config.get('created')} +def resolve_candidate_metadata(reference, architecture, known=None): + """Monitor-only metadata fast path through the normal native resolver.""" + return resolve_candidate(reference, architecture, _known_metadata=known) + + def same_image(candidate, image): """Whether what the registry serves is the image of an archive or a record. diff --git a/oci/remote/oci_update_check.py b/oci/remote/oci_update_check.py new file mode 100644 index 00000000..66dceee7 --- /dev/null +++ b/oci/remote/oci_update_check.py @@ -0,0 +1,204 @@ +#!/usr/bin/env python3 +"""Adapt the OCI menu to the ProxMenux Monitor checker and its native cache. + +This module never contacts a registry or writes an update-status cache. +""" +from __future__ import annotations + +import argparse +import datetime +import json +import os +from pathlib import Path +import re +import socket +import stat +import sys +import time + +import oci_instances as instances +from oci_installation_state import parse_config + +APPS = Path('/etc/proxmenux/apps') +SOCKET = Path('/run/proxmenux-monitor/oci-check.sock') +MAX_AGE = 24 * 60 * 60 +DIGEST = re.compile(r'sha256:[0-9a-f]{64}') + + +def title(record): + try: + template = (record.get('stack') or {}).get('template') or record.get('template') or {} + name = (template.get('catalog_ui') or {}).get('title') + if isinstance(name, dict): + name = name.get('en_US') or name.get('en') + return str(name or record.get('deployment', {}).get('hostname') or f"CT {record['vmid']}") + except (AttributeError, TypeError): + return f"CT {record['vmid']}" + + +def snapshot(root=instances.ROOT, configs=Path('/etc/pve/lxc')): + """Use local native config files: opening the menu makes no PVE/API calls.""" + records = {} + if not root.is_dir() or root.is_symlink(): + return [] + for path in sorted(root.iterdir()): + if not path.name.isdecimal() or int(path.name) < 100: + continue + vmid = int(path.name) + native = configs / f'{vmid}.conf' + if not native.is_file(): + continue # Deleted guests and guests on another node are not this host's apps. + try: + record = instances.read(root, vmid) + config = native.read_bytes() + # On disk, PVE stores the description in # comments, unlike + # `pct config`. Later [snapshot] and [PENDING] sections describe + # other configurations and must not supply this guest's identity. + current = config.split(b'\n[', 1)[0] + description = '\n'.join(line[1:] for line in current.decode().splitlines() if line.startswith('#')) + identity = instances.identity(json.dumps({'description': description}).encode()) + architecture = parse_config(current).get('arch') + reference = record['template']['container_contract']['image']['reference'] + image = (record.get('observed') or {}).get('image') or {} + ready = (record.get('status') == 'installed' + and not record.get('pending_transaction') + and not record.get('pending_stack_transaction') + and identity == record['installation_id'] + and architecture in ('amd64', 'arm64') + and isinstance(reference, str) and reference + and DIGEST.fullmatch(str(image.get('manifest_digest', '')))) + primary = int((record.get('stack_member') or {}).get('primary_vmid') or vmid) + records[vmid] = {'vmid': vmid, 'primary': primary, 'record': record, + 'reference': reference, + 'architecture': architecture, 'image': image, + 'ready': bool(ready)} + except (OSError, ValueError, KeyError, TypeError, AttributeError): + # Never call an unreadable, incomplete or mismatched guest up to date. + records[vmid] = {'vmid': vmid, 'primary': vmid, 'record': {'vmid': vmid}, + 'ready': False} + groups = {} + for member in records.values(): + groups.setdefault(member['primary'], []).append(member) + applications = [] + for primary, members in sorted(groups.items()): + main = records.get(primary) + try: + expected = {int(m['vmid']) for m in ((main or {}).get('record', {}).get('stack') or {}).get('members', [])} + complete = main is not None + except (ValueError, TypeError, KeyError, AttributeError): + expected, complete = set(), False + present = {m['vmid'] for m in members} + complete = complete and expected.issubset(present) + applications.append({'vmid': primary, 'title': title((main or members[0])['record']), + 'members': members, + 'complete': complete}) + return applications + + +def cached(root=instances.ROOT, configs=Path('/etc/pve/lxc'), now=None, apps=APPS): + """Read the Monitor's sidecars; obey its primary-container stack policy.""" + inventory = snapshot(root, configs) + result = {'checked': False, 'available': [], 'unknown': 0, 'applications': len(inventory)} + instant = time.time() if now is None else now + for app in inventory: + try: + if not app['complete'] or not all(member['ready'] for member in app['members']): + raise ValueError('Incomplete installation') + main = next(member for member in app['members'] if member['vmid'] == app['vmid']) + path = apps / f"{app['vmid']}.json" + if path.is_symlink(): + raise ValueError('Invalid cache') + data = json.loads(path.read_text()) + candidates = [row for row in data['apps'] if row.get('installed_via') == 'oci_image'] + if str(data['vmid']) != str(app['vmid']) or len(candidates) != 1: + raise ValueError('Ambiguous application') + state = candidates[0]['state'] + stamp = datetime.datetime.strptime(state['checked_at'], '%Y-%m-%dT%H:%M:%SZ') + age = instant - stamp.replace(tzinfo=datetime.timezone.utc).timestamp() + if (not 0 <= age <= MAX_AGE or state.get('error') + or state.get('installed_digest') != main['image']['manifest_digest'] + or state.get('image_reference') != main['reference'] + or type(state.get('update_available')) is not bool + or not DIGEST.fullmatch(str(state.get('latest_digest', '')))): + raise ValueError('Unusable result') + result['checked'] = True + if state['update_available']: + result['available'].append({'vmid': app['vmid'], 'title': app['title']}) + if state.get('registry_retry'): + result['unknown'] += 1 # Same retained result and retry as the Monitor. + except (OSError, ValueError, KeyError, TypeError, AttributeError, StopIteration): + result['unknown'] += 1 + return result + + +def monitor_available(path=SOCKET): + try: + parent, endpoint = path.parent.lstat(), path.lstat() + return (stat.S_ISDIR(parent.st_mode) and parent.st_uid == 0 + and not parent.st_mode & 0o077 and stat.S_ISSOCK(endpoint.st_mode) + and endpoint.st_uid == 0 and not endpoint.st_mode & 0o077) + except OSError: + return False + + +def check(root=instances.ROOT, configs=Path('/etc/pve/lxc'), progress=None, path=SOCKET): + """Request the existing checker in its own Monitor process, without HTTP.""" + if not monitor_available(path): + raise RuntimeError('ProxMenux Monitor checker unavailable') + inventory = snapshot(root, configs) + rows = [] + for app in inventory: + if not app['complete'] or not all(member['ready'] for member in app['members']): + continue + main = next(member for member in app['members'] if member['vmid'] == app['vmid']) + rows.append({'vmid': app['vmid'], 'installation_id': main['record']['installation_id']}) + total, skipped = len(inventory), len(inventory) - len(rows) + if progress: + progress(0, total) + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection: + connection.settimeout(600) # Native registry timeouts and retries remain authoritative. + connection.connect(str(path)) + connection.sendall((json.dumps({'applications': rows}) + '\n').encode()) + completed = -1 + with connection.makefile('rb') as stream: + while True: + line = stream.readline(4097) + if not line or len(line) > 4096: + raise RuntimeError('Monitor check incomplete') + event = json.loads(line) + if event.get('done') is True and completed == len(rows): + return + done, count = event.get('completed'), event.get('total') + if (type(done) is not int or type(count) is not int + or count != len(rows) or not completed <= done <= count or done < 0): + raise RuntimeError('Monitor check unavailable') + completed = done + if progress: + progress(skipped + completed, total) + + +def emit_progress(completed, total): + message = (json.dumps({'completed': completed, 'total': total}) + '\n').encode() + try: + os.write(sys.stdout.fileno(), message) + except (BlockingIOError, BrokenPipeError): + pass + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--root', type=Path, default=instances.ROOT) + parser.add_argument('--configs', type=Path, default=Path('/etc/pve/lxc')) + parser.add_argument('--progress', action='store_true', help='Emit numeric progress for the OCI menu') + args = parser.parse_args() + try: + if args.progress: + os.set_blocking(sys.stdout.fileno(), False) + check(args.root, args.configs, progress=emit_progress if args.progress else None) + return 0 + except (OSError, ValueError, KeyError, TypeError, RuntimeError): + return 1 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/oci/src/proxmenux_oci/cli.py b/oci/src/proxmenux_oci/cli.py index 14dda6e2..3b611be9 100644 --- a/oci/src/proxmenux_oci/cli.py +++ b/oci/src/proxmenux_oci/cli.py @@ -542,6 +542,8 @@ def _search(catalog: Catalog, ui, applications: list[dict[str, Any]]) -> None: def interactive(catalog: Catalog) -> int: + from .ui import MenuRefresh + from .updates import UpdateCheck, select_updates ui = interactive_ui() if not catalog.index_path.exists(): ui.message(translate("The OCI catalog is not installed. Update ProxMenux and try again.")) @@ -565,34 +567,54 @@ def interactive(catalog: Catalog) -> int: ] + [(number, f"{translate(labels[key]):<35} ({len(categories[key]):>3})") for number, key in category_by_index.items()] selection = "search" - while True: - selection = ui.choose(translate("Select a category or search for applications:"), ReadableTags(options), - selection, size=MENU_SIZE) - if selection is None: - return 0 - try: - if selection == "search": - _search(catalog, ui, applications) - elif selection == "all": - _app_list(catalog, ui, applications, - f"{translate('All applications')} ({len(applications)})") - elif selection == "manage": - from .management import interactive_management - interactive_management(PROJECT_ROOT, ui) - elif selection == "registries": - from .registry_accounts import open_registry_accounts - open_registry_accounts(ui) - elif selection == "custom": - from .custom import explore - explore(ui) - elif selection in category_by_index: - key = category_by_index[selection] - _app_list(catalog, ui, categories[key], translate(labels[key])) - except UserCancelled: - continue - except (ConversionError, InstallError, OSError, ValueError) as exc: - console.stop_spinner() - ui.message(f"{translate('The operation could not be completed')}:\n\n{exc}") + with UpdateCheck(PROJECT_ROOT) as updates: + while True: + state = updates.status() + entries = ReadableTags(options[:3] + [('updates', updates.label(state))] + options[3:]) + text = translate("Select a category or search for applications:") + if notice := updates.notice(state): + text += '\n\n' + notice + if updates.active and not getattr(ui, 'live_refresh', False): + ui.info(translate('Checking for updates...')) + updates.wait() + continue + try: + kwargs = {'refresh': updates.changed} if updates.active else {} + selected = ui.choose(text, entries, selection, size=MENU_SIZE, **kwargs) + except MenuRefresh: + continue + if selected is None: + return 0 + selection = selected + try: + if selection == "search": + _search(catalog, ui, applications) + elif selection == "all": + _app_list(catalog, ui, applications, + f"{translate('All applications')} ({len(applications)})") + elif selection == "manage": + from .management import interactive_management + interactive_management(PROJECT_ROOT, ui) + elif selection == "updates": + if not updates.active: + if state['available']: + select_updates(PROJECT_ROOT, ui, state['available']) + else: + updates.start() + elif selection == "registries": + from .registry_accounts import open_registry_accounts + open_registry_accounts(ui) + elif selection == "custom": + from .custom import explore + explore(ui) + elif selection in category_by_index: + key = category_by_index[selection] + _app_list(catalog, ui, categories[key], translate(labels[key])) + except UserCancelled: + continue + except (ConversionError, InstallError, OSError, ValueError) as exc: + console.stop_spinner() + ui.message(f"{translate('The operation could not be completed')}:\n\n{exc}") # ---------------------------------------------------------------- maintenance CLI diff --git a/oci/src/proxmenux_oci/ui.py b/oci/src/proxmenux_oci/ui.py index 6ff5955f..1f1b54d0 100644 --- a/oci/src/proxmenux_oci/ui.py +++ b/oci/src/proxmenux_oci/ui.py @@ -16,6 +16,10 @@ class UserCancelled(RuntimeError): pass +class MenuRefresh(RuntimeError): + """The background status changed; redraw the same menu.""" + + class BackRequested(RuntimeError): pass @@ -168,7 +172,7 @@ class TerminalUI: return options[int(selected) - 1][0] return None - def checklist(self, text, options, defaults): + def checklist(self, text, options, defaults, title: str | None = None, ok_label=None): return [tag for tag, label in options if self.confirm(label, tag in defaults)] def detail_menu(self, text: str, options: list[tuple[str, str]], default: str | None = None, @@ -214,12 +218,13 @@ class DialogUI: title: str = APP_TITLE backtitle: str = "ProxMenux" back_enabled: bool = False + live_refresh = True @staticmethod def available() -> bool: return bool(shutil.which("dialog") and sys.stdin.isatty() and sys.stdout.isatty()) - def _run(self, widget: list[str], title: str | None = None) -> subprocess.CompletedProcess[str]: + def _run(self, widget: list[str], title: str | None = None, refresh=None) -> subprocess.CompletedProcess[str]: environment = os.environ.copy() # ncurses otherwise waits a full second to distinguish Escape from # the prefix of a cursor/function key. Keep a short sequence window, @@ -231,11 +236,31 @@ class DialogUI: back_widget = ['--extra-button', '--extra-label', translate('Back')] if self.back_enabled and any( flag in widget for flag in ('--inputbox', '--passwordbox', '--yesno', '--menu', '--checklist')) else [] confirmation_labels = ['--yes-label', translate('Yes'), '--no-label', translate('No')] if '--yesno' in widget else [] - result = subprocess.run( - ["dialog", "--no-collapse", "--cancel-label", translate('Cancel'), "--backtitle", self.backtitle, "--title", title or self.title, - *confirmation_labels, *back_widget, *widget], - stdout=None, stderr=subprocess.PIPE, text=True, check=False, env=environment, - ) + command = ["dialog", "--no-collapse", "--cancel-label", translate('Cancel'), "--backtitle", self.backtitle, + "--title", title or self.title, *confirmation_labels, *back_widget, *widget] + if refresh is None: + result = subprocess.run(command, stdout=None, stderr=subprocess.PIPE, text=True, + check=False, env=environment) + else: + with subprocess.Popen(command, stdout=None, stderr=subprocess.PIPE, text=True, + env=environment) as process: + try: + while True: + try: + _, output = process.communicate(timeout=0.1) + result = subprocess.CompletedProcess(command, process.returncode, stderr=output) + break + except subprocess.TimeoutExpired: + if refresh() and process.poll() is None: + raise MenuRefresh() + finally: + if process.poll() is None: + process.terminate() + try: + process.communicate(timeout=2) + except subprocess.TimeoutExpired: + process.kill() + process.communicate() if result.returncode == 3 and back_widget: raise BackRequested() return result @@ -287,7 +312,7 @@ class DialogUI: def choose(self, text: str, options: list[tuple[str, str]], default: str | None = None, title: str | None = None, show_tags: bool = True, - size: tuple[int, int, int] | None = None, colors: bool = False) -> str | None: + size: tuple[int, int, int] | None = None, colors: bool = False, refresh=None) -> str | None: # A word is an internal value: the menu numbers the options, as the # rest of ProxMenux does, and returns the value of the chosen one. values: dict[str, str] = {} @@ -307,7 +332,7 @@ class DialogUI: widget += ["--menu", text, *map(str, size)] for tag, label in options: widget += [tag, label] - result = self._run(widget, title) + result = self._run(widget, title, refresh=refresh) if refresh else self._run(widget, title) selected = result.stderr.strip() if result.returncode == 0 else None return values.get(selected, selected) if values and selected else selected terminal = shutil.get_terminal_size((100, 30)) @@ -324,7 +349,7 @@ class DialogUI: widget += ["--menu", f"\n{text}", str(height), str(width), str(menu_height)] for tag, label in options: widget += [tag, label] - result = self._run(widget, title) + result = self._run(widget, title, refresh=refresh) if refresh else self._run(widget, title) selected = result.stderr.strip() if result.returncode == 0 else None return values.get(selected, selected) if values and selected else selected @@ -349,10 +374,11 @@ class DialogUI: result = self._run(widget, title) return result.stderr.strip() if result.returncode == 0 else None - def checklist(self, text, options, defaults, title: str | None = None): + def checklist(self, text, options, defaults, title: str | None = None, ok_label=None): terminal = shutil.get_terminal_size((100, 30)) height = min(max(len(options) + 9, 14), terminal.lines - 2) - widget = ["--separate-output", "--checklist", f"\n{text}", str(height), str(MENU_WIDTH), + widget = ['--ok-label', ok_label] if ok_label else [] + widget += ["--separate-output", "--checklist", f"\n{text}", str(height), str(MENU_WIDTH), str(max(4, min(len(options), height - 8)))] for tag, label in options: widget += [tag, label, "on" if tag in defaults else "off"] diff --git a/oci/src/proxmenux_oci/updates.py b/oci/src/proxmenux_oci/updates.py new file mode 100644 index 00000000..c01afbd3 --- /dev/null +++ b/oci/src/proxmenux_oci/updates.py @@ -0,0 +1,184 @@ +"""OCI menu update status and explicit, user-selected updates.""" +from __future__ import annotations + +import importlib +import json +import os +from pathlib import Path +import signal +import subprocess +import sys + +from .i18n import translate +from .ui import UserCancelled + + +class UpdateCheck: + def __init__(self, project): + self.project = Path(project) + sys.path.insert(0, str(self.project / 'remote')) + self.backend = importlib.import_module('oci_update_check') + self.process = None + self.failed = False + self.completed = self.total = 0 + self._progress_buffer = b'' + self._shown_progress = (0, 0) + + def __enter__(self): + return self + + def __exit__(self, *exc): + self.close() + + @property + def running(self): + return self.process is not None and self.process.poll() is None + + @property + def active(self): + # A finished child still needs one redraw to publish its final cache. + return self.process is not None + + def ready(self): + return self.process is not None and self.process.poll() is not None + + def _read_progress(self): + if self.process is None or self.process.stdout is None: + return + try: + while chunk := os.read(self.process.stdout.fileno(), 8192): + self._progress_buffer += chunk + while b'\n' in self._progress_buffer: + line, self._progress_buffer = self._progress_buffer.split(b'\n', 1) + try: + data = json.loads(line) + completed, total = data['completed'], data['total'] + if type(completed) is int and type(total) is int and 0 <= completed <= total: + self.completed, self.total = completed, total + except (ValueError, TypeError, KeyError): + pass # Never display worker output as UI text. + if len(self._progress_buffer) > 8192: + self._progress_buffer = b'' + except (BlockingIOError, OSError): + pass + + def changed(self): + self._read_progress() + return self.ready() or (self.completed, self.total) != self._shown_progress + + def _close_output(self): + if self.process is not None and self.process.stdout is not None: + self.process.stdout.close() + + def status(self): + self._read_progress() + if self.ready(): + self.failed = self.process.wait() != 0 + self._close_output() + self.process = None + try: + state = self.backend.cached() + except (OSError, ValueError, KeyError, TypeError): + state = {'checked': False, 'available': [], 'unknown': 0, 'applications': 0} + if self.failed: + # A failed worker must not present an older successful check as + # the outcome of the new request, or keep its old update list. + state = dict(state, checked=True, available=[], unknown=state['applications']) + return state + + def start(self): + if self.running: + return + if not self.backend.monitor_available(): + raise ValueError(translate('The ProxMenux Monitor update checker is not available. Start or update ProxMenux Monitor and try again.')) + state = self.status() + self.failed = False + self.completed, self.total = 0, state['applications'] + self._progress_buffer = b'' + self.process = subprocess.Popen( + [sys.executable, str(self.project / 'remote/oci_update_check.py'), '--progress'], + stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, + start_new_session=True, bufsize=0) + os.set_blocking(self.process.stdout.fileno(), False) + + def wait(self): + if self.process is not None: + while True: + self._read_progress() + try: + self.process.wait(timeout=0.1) + break + except subprocess.TimeoutExpired: + pass + + def close(self): + if self.process is None: + return + # Close only this client. The Monitor finishes its current metadata + # request and stops the remaining checks when the connection closes. + if self.process.poll() is None: + try: + os.killpg(self.process.pid, signal.SIGTERM) + except ProcessLookupError: + pass + try: + self.process.wait(timeout=2) + except subprocess.TimeoutExpired: + try: + os.killpg(self.process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + self.process.wait() + self._close_output() + self.process = None + + def label(self, state): + self._read_progress() + if self.active: + self._shown_progress = self.completed, self.total + return translate('Checking for updates ({completed}/{total})').format( + completed=self.completed, total=self.total) + count = len(state['available']) + return f"{translate('Updates available')} ({count})" if count else translate('Check for updates') + + def notice(self, state): + return '' # Keep the menu heading clear after a check. + + +def select_updates(project, ui, available): + """Nothing is preselected, including when exactly one update is available.""" + choices = [('all', translate('Select all'))] + choices += [(str(row['vmid']), f"CT {row['vmid']} · {row['title']}") for row in available] + defaults = [] + while True: + try: + picked = ui.checklist( + translate('Press Space to select applications to update. Select all includes every application in this list.'), + choices, defaults, title=translate('Updates available'), ok_label=translate('Update')) + except UserCancelled: + return False + selected = [row for row in available if 'all' in picked or str(row['vmid']) in picked] + if not selected: + return False + defaults = [str(row['vmid']) for row in selected] + names = '\n'.join(f" CT {row['vmid']} · {row['title']}" for row in selected) + if ui.review(names, translate('Update selected OCI applications'), + question=translate('Review and update these applications?'), default=False): + break + from .management import manage_instance, saved_inventory + # A change/removal since the check invalidates that application's result. + checker = UpdateCheck(project) + still_available = {row['vmid'] for row in checker.status()['available']} + current = {row['vmid']: row for row in saved_inventory(project)} + for row in selected: + vmid = row['vmid'] + if vmid not in still_available or vmid not in current: + ui.message(translate('An application changed since the update check. Check for updates again.'), + translate('Updates available')) + return False + for row in selected: + # Keep the existing identity checks, backup/storage prompts, update + # confirmation, coordinated stack update and recovery path. + if not manage_instance(project, ui, current[row['vmid']], action='update'): + break # A cancellation or failure never updates later selections. + return False diff --git a/oci/tests/test_update_check.py b/oci/tests/test_update_check.py new file mode 100644 index 00000000..0bff26eb --- /dev/null +++ b/oci/tests/test_update_check.py @@ -0,0 +1,283 @@ +"""The OCI menu shares the Monitor's checker and cache, with no fallback.""" +import datetime +import json +import os +from pathlib import Path +import socket +import stat +import subprocess +import sys +import tempfile +import threading +from types import SimpleNamespace +import unittest +from unittest.mock import Mock, patch +import uuid + +ROOT = Path(__file__).resolve().parents[1] +sys.path[:0] = [str(ROOT / 'src'), str(ROOT / 'remote'), str(ROOT.parent / 'AppImage/scripts')] +import oci_update_check as check +import oci_menu_bridge as bridge +import lxc_apps + +OLD = 'sha256:' + 'a' * 64 +NEW = 'sha256:' + 'b' * 64 + + +class UpdateCheckTests(unittest.TestCase): + def setUp(self): + tmp = tempfile.TemporaryDirectory(); self.addCleanup(tmp.cleanup) + self.folder = Path(tmp.name) + self.root, self.configs, self.apps = [self.folder / name for name in ('instances', 'configs', 'apps')] + for path in (self.root, self.configs, self.apps): path.mkdir() + self.now = datetime.datetime.now(datetime.timezone.utc).replace(microsecond=0) + + def record(self, vmid=110, reference='example/app:latest', **changes): + identifier = str(uuid.uuid4()) + record = {'schema_version': 1, 'vmid': vmid, 'installation_id': identifier, + 'status': 'installed', 'deployment': {'hostname': f'app-{vmid}'}, + 'template': {'container_contract': {'image': {'reference': reference}}}, + 'observed': {'image': {'manifest_digest': OLD}}} + record.update(changes) + directory = self.root / str(vmid); directory.mkdir(exist_ok=True) + (directory / 'oci-compose.json').write_text(json.dumps(record)) + (self.configs / f'{vmid}.conf').write_text(f'#proxmenux-instance={identifier}\narch: amd64\n') + return record + + def sidecar(self, vmid=110, **changes): + state = {'checked_at': self.now.strftime('%Y-%m-%dT%H:%M:%SZ'), + 'installed_digest': OLD, 'latest_digest': NEW, + 'image_reference': 'example/app:latest', 'update_available': True} + state.update(changes) + data = {'vmid': vmid, 'apps': [{'id': f'app-{vmid}', 'installed_via': 'oci_image', 'state': state}]} + (self.apps / f'{vmid}.json').write_text(json.dumps(data)) + return data + + def status(self, **kwargs): + return check.cached(self.root, self.configs, apps=self.apps, now=self.now.timestamp(), **kwargs) + + def test_menu_reads_only_native_cache_without_subprocess_or_socket(self): + self.record(); self.sidecar() + before = {str(p): p.read_bytes() for p in self.folder.rglob('*') if p.is_file()} + with patch.object(subprocess, 'run') as run, patch.object(socket, 'socket') as connection: + state = self.status() + self.assertEqual([r['vmid'] for r in state['available']], [110]) + run.assert_not_called(); connection.assert_not_called() + after = {str(p): p.read_bytes() for p in self.folder.rglob('*') if p.is_file()} + self.assertEqual(before, after) + self.assertNotIn('resolve_candidate', check.__dict__) + self.assertNotIn('query', check.__dict__) + + def test_missing_native_cache_is_unknown_even_with_old_separate_cache(self): + self.record() + (self.root / '.update-check.json').write_text(json.dumps({'applications': [{'vmid': 110, 'status': 'available'}]})) + state = self.status() + self.assertFalse(state['checked']); self.assertEqual(state['unknown'], 1) + self.assertFalse(state['available']) + + def test_native_false_is_current_without_recomputing_digests(self): + self.record(); self.sidecar(update_available=False) + state = self.status() + self.assertTrue(state['checked']); self.assertEqual(state['unknown'], 0) + self.assertFalse(state['available']) + + def test_native_retry_retains_available_result_and_unknown_state(self): + self.record(); self.sidecar(registry_retry=True) + self.assertEqual(len(self.status()['available']), 1) + self.assertEqual(self.status()['unknown'], 1) + + def test_native_error_or_missing_result_never_means_up_to_date(self): + self.record() + for changes in ({'error': 'registry unreachable'}, {'update_available': None}, {'latest_digest': None}): + self.sidecar(**changes) + state = self.status() + self.assertFalse(state['available']); self.assertEqual(state['unknown'], 1) + + def test_stale_future_or_different_image_cache_is_not_available(self): + self.record() + for changes in ({'checked_at': (self.now - datetime.timedelta(days=2)).strftime('%Y-%m-%dT%H:%M:%SZ')}, + {'checked_at': (self.now + datetime.timedelta(days=2)).strftime('%Y-%m-%dT%H:%M:%SZ')}, + {'installed_digest': NEW}, {'image_reference': 'other/image:latest'}): + self.sidecar(**changes) + self.assertFalse(self.status()['available']) + self.assertEqual(self.status()['unknown'], 1) + + def test_invalid_identity_or_pending_native_edit_invalidates_cached_update(self): + self.record(); self.sidecar() + (self.configs / '110.conf').write_text('arch: amd64\n') + self.assertFalse(self.status()['available']) + self.record(pending_transaction={'operation': 'update'}) + self.assertFalse(self.status()['available']) + + def test_snapshot_identity_cannot_replace_current_identity(self): + record = self.record(); self.sidecar() + (self.configs / '110.conf').write_text( + f"arch: amd64\n\n[previous]\n#proxmenux-instance={record['installation_id']}\n") + self.assertFalse(self.status()['available']) + + def test_remote_or_deleted_guest_is_excluded(self): + self.record(); self.sidecar(); (self.configs / '110.conf').unlink() + self.assertEqual(self.status()['applications'], 0) + self.assertFalse(self.status()['available']) + + def test_corrupt_ambiguous_or_symlinked_sidecars_are_unknown(self): + self.record(); path = self.apps / '110.json' + path.write_text('{broken') + self.assertEqual(self.status()['unknown'], 1) + data = self.sidecar(); data['apps'] *= 2; path.write_text(json.dumps(data)) + self.assertEqual(self.status()['unknown'], 1) + path.unlink(); path.symlink_to(self.root / '110/oci-compose.json') + self.assertEqual(self.status()['unknown'], 1) + + def test_stack_counts_once_and_follows_monitors_primary_tracking_rule(self): + self.record(110, stack_member={'primary_vmid': 110}, stack={'members': [{'vmid': 110}, {'vmid': 111}]}) + self.record(111, stack_member={'primary_vmid': 110}) + self.sidecar(110, update_available=False); self.sidecar(111, update_available=True) + self.assertEqual(self.status()['applications'], 1) + self.assertFalse(self.status()['available']) + self.sidecar(110) + self.assertEqual([r['vmid'] for r in self.status()['available']], [110]) + + def test_incomplete_stack_is_not_offered_for_update(self): + self.record(110, stack={'members': [{'vmid': 110}, {'vmid': 111}]}); self.sidecar() + self.assertFalse(self.status()['available']) + self.assertEqual(self.status()['unknown'], 1) + + def test_real_bridge_calls_native_checker_and_publishes_its_sidecar(self): + record = self.record(); self.sidecar(update_available=False) + path = self.folder / 'check.sock' + server = bridge._Server(str(path), bridge._Handler); server.apps = lxc_apps + worker = threading.Thread(target=server.serve_forever, daemon=True); worker.start() + self.addCleanup(server.server_close); self.addCleanup(server.shutdown) + result = {'installed_digest': OLD, 'installed_registry_digest': OLD, + 'image_reference': 'example/app:latest', 'installed_version': '1.0', + 'latest_version': '2.0', 'latest_digest': NEW, 'update_available': True} + events = [] + with patch.object(bridge, '_root_peer', return_value=True), \ + patch.object(check, 'monitor_available', return_value=True), \ + patch.object(lxc_apps, '_APPS_DIR', str(self.apps)), \ + patch.object(lxc_apps, '_read_oci_record', return_value=record), \ + patch.object(lxc_apps, 'ensure_oci_registration'), \ + patch.object(lxc_apps, '_oci_image_versions', return_value=result) as native, \ + patch.object(lxc_apps, '_fire_update_notification') as notify, \ + patch.object(subprocess, 'run') as external: + check.check(self.root, self.configs, path=path, progress=lambda *event: events.append(event)) + native.assert_called_once(); notify.assert_not_called(); external.assert_not_called() + self.assertEqual(events[0], (0, 1)); self.assertEqual(events[-1], (1, 1)) + self.assertEqual([r['vmid'] for r in self.status()['available']], [110]) + self.assertFalse((self.root / '.update-check.json').exists()) + + def test_missing_monitor_never_falls_back_to_registry_commands(self): + self.record() + with patch.object(check, 'monitor_available', return_value=False), patch.object(subprocess, 'run') as run: + with self.assertRaisesRegex(RuntimeError, 'Monitor'): + check.check(self.root, self.configs) + run.assert_not_called() + + def test_readonly_monitor_availability_rejects_non_private_endpoints(self): + path = Mock(); path.parent.lstat.return_value = SimpleNamespace(st_mode=stat.S_IFDIR | 0o700, st_uid=0) + path.lstat.return_value = SimpleNamespace(st_mode=stat.S_IFSOCK | 0o600, st_uid=0) + self.assertTrue(check.monitor_available(path)) + path.lstat.return_value.st_mode |= 0o066 + self.assertFalse(check.monitor_available(path)) + path.lstat.return_value.st_mode = stat.S_IFREG | 0o600 + self.assertFalse(check.monitor_available(path)) + path.lstat.return_value.st_mode = stat.S_IFSOCK | 0o600 + path.parent.lstat.return_value.st_uid = 1000 + self.assertFalse(check.monitor_available(path)) + + def test_full_or_closed_progress_pipe_does_not_abort_client(self): + for error in (BlockingIOError(), BrokenPipeError()): + with patch.object(check.os, 'write', side_effect=error), patch.object(check.sys, 'stdout'): + check.emit_progress(1, 2) + + +class BridgeTests(unittest.TestCase): + def module(self): + module = Mock() + module._read_oci_record.return_value = {'vmid': 110, 'installation_id': 'id-110', 'status': 'installed'} + module._oci_secondary_member.return_value = False + module.load_sidecar.return_value = {'apps': [{'id': 'native-app', 'installed_via': 'oci_image'}, + {'id': 'other-app', 'installed_via': 'dpkg'}]} + return module + + def test_only_native_oci_app_is_checked_without_unsolicited_notifications(self): + module = self.module(); events = [] + bridge.check_applications(module, [{'vmid': 110, 'installation_id': 'id-110'}], events.append) + module.check_app.assert_called_once_with(110, 'native-app', force=True, notify=False) + self.assertEqual(events, [{'completed': 0, 'total': 1}, {'completed': 1, 'total': 1}, {'done': True}]) + + def test_replaced_guest_pending_operation_and_secondary_are_not_queried(self): + for changes, secondary in (({'installation_id': 'replacement'}, False), + ({'pending_transaction': {'operation': 'update'}}, False), + ({'pending_stack_transaction': {'operation': 'update'}}, False), + ({}, True)): + module = self.module(); module._read_oci_record.return_value.update(changes) + module._oci_secondary_member.return_value = secondary + bridge.check_applications(module, [{'vmid': 110, 'installation_id': 'id-110'}], lambda _: None) + module.check_app.assert_not_called() + + def test_dismissed_registration_is_not_reinvented(self): + module = self.module(); module.load_sidecar.return_value = None + bridge.check_applications(module, [{'vmid': 110, 'installation_id': 'id-110'}], lambda _: None) + module.check_app.assert_not_called() + + def test_progress_advances_when_fast_app_finishes_before_first_slow_app(self): + module = self.module(); release = threading.Event(); events = [] + module._read_oci_record.side_effect = lambda vmid: { + 'vmid': vmid, 'installation_id': f'id-{vmid}', 'status': 'installed'} + def run(vmid, *args, **kwargs): + if vmid == 110 and not release.wait(3): + raise AssertionError('The fast app was blocked by the slow app') + def report(event): + events.append(event) + if event.get('completed') == 1: release.set() + module.check_app.side_effect = run + bridge.check_applications(module, [{'vmid': 110, 'installation_id': 'id-110'}, + {'vmid': 111, 'installation_id': 'id-111'}], report) + self.assertEqual(events, [{'completed': 0, 'total': 2}, {'completed': 1, 'total': 2}, + {'completed': 2, 'total': 2}, {'done': True}]) + self.assertEqual(module.check_app.call_count, 2) + + def test_unexpected_failure_does_not_skip_other_apps_or_report_success(self): + module = self.module(); events = [] + module._read_oci_record.side_effect = lambda vmid: { + 'vmid': vmid, 'installation_id': f'id-{vmid}', 'status': 'installed'} + module.check_app.side_effect = lambda vmid, *a, **kw: (_ for _ in ()).throw(RuntimeError()) if vmid == 110 else None + bridge.check_applications(module, [{'vmid': 110, 'installation_id': 'id-110'}, + {'vmid': 111, 'installation_id': 'id-111'}], events.append) + self.assertEqual(module.check_app.call_count, 2) + self.assertEqual(events[-2], {'completed': 2, 'total': 2}) + self.assertEqual(events[-1], {'error': 'check_failed'}) + + def test_requests_cannot_specify_commands_images_paths_or_duplicate_guests(self): + valid = {'vmid': 110, 'installation_id': 'id'} + for request in ({'command': ['sh']}, {'applications': '110'}, {'applications': [valid, valid]}, + {'applications': [{**valid, 'image': 'private:image'}]}, + {'applications': [{**valid, 'vmid': '../110'}]}, + {'applications': [{**valid, 'vmid': True}]}): + with self.subTest(request=request), self.assertRaises(ValueError): bridge._applications(request) + + def test_client_disconnect_stops_before_next_application(self): + module = self.module(); calls = [] + def report(event): + calls.append(event) + if event.get('completed') == 1: raise BrokenPipeError() + with self.assertRaises(BrokenPipeError): + bridge.check_applications(module, [{'vmid': 110, 'installation_id': 'id-110'}, + {'vmid': 111, 'installation_id': 'id-111'}], report) + self.assertEqual(module.check_app.call_count, 1) + + def test_non_root_peers_are_rejected_before_reading_requests(self): + handler = object.__new__(bridge._Handler); handler.request = Mock(); handler.rfile = Mock() + with patch.object(bridge, '_root_peer', return_value=False): handler.handle() + handler.rfile.readline.assert_not_called() + + def test_build_and_monitor_startup_include_bridge(self): + scripts = ROOT.parent / 'AppImage/scripts' + self.assertIn('oci_menu_bridge.py', (scripts / 'build_appimage.sh').read_text()) + self.assertIn('oci_menu_bridge.start(lxc_apps)', (scripts / 'flask_server.py').read_text()) + + +if __name__ == '__main__': + unittest.main() diff --git a/oci/tests/test_update_menu.py b/oci/tests/test_update_menu.py new file mode 100644 index 00000000..e1acb56c --- /dev/null +++ b/oci/tests/test_update_menu.py @@ -0,0 +1,274 @@ +"""Explicit update selection and live status redraw without lifecycle shortcuts.""" +from pathlib import Path +import ast +import json +import os +import re +import signal +import subprocess +import sys +from types import SimpleNamespace +import unittest +from unittest.mock import Mock, patch + +ROOT = Path(__file__).resolve().parents[1] +sys.path[:0] = [str(ROOT / 'src'), str(ROOT / 'remote')] +from proxmenux_oci import cli, management, updates +from proxmenux_oci.ui import DialogUI, MenuRefresh, ReadableTags, UserCancelled + +AVAILABLE = [{'vmid': 110, 'title': 'First app'}, {'vmid': 111, 'title': 'Second app'}] + + +class SelectionTests(unittest.TestCase): + def select(self, picked, available=AVAILABLE, review=True, fresh=None, outcomes=None): + ui = Mock() + ui.checklist.side_effect = picked if isinstance(picked, Exception) else [picked] + ui.review.return_value = review + current = [{**row, 'reason': 'matched'} for row in available] + with patch.object(updates, 'UpdateCheck') as checker, \ + patch.object(management, 'saved_inventory', return_value=current), \ + patch.object(management, 'manage_instance', side_effect=outcomes or [True] * len(available)) as run: + checker.return_value.status.return_value = {'available': available if fresh is None else fresh} + result = updates.select_updates(ROOT, ui, available) + return result, ui, run + + def test_one_update_is_not_checked_by_default(self): + _, ui, run = self.select([], available=AVAILABLE[:1]) + self.assertEqual(ui.checklist.call_args.args[2], []) + self.assertEqual(ui.checklist.call_args.args[1][0], ('all', 'Select all')) + run.assert_not_called() + + def test_only_selected_app_uses_existing_update_flow(self): + _, ui, run = self.select(['111']) + self.assertEqual(run.call_count, 1) + self.assertEqual(run.call_args.args[2]['vmid'], 111) + self.assertEqual(run.call_args.kwargs, {'action': 'update'}) + self.assertIn('Second app', ui.review.call_args.args[0]) + self.assertNotIn('First app', ui.review.call_args.args[0]) + + def test_select_all_includes_every_app_once(self): + _, ui, run = self.select(['all', '111']) + self.assertEqual([c.args[2]['vmid'] for c in run.call_args_list], [110, 111]) + self.assertFalse(ui.review.call_args.kwargs['default']) + + def test_cancel_does_nothing(self): + _, ui, run = self.select(UserCancelled()) + ui.review.assert_not_called(); run.assert_not_called() + + def test_selection_has_an_update_button_and_no_extra_action(self): + result, ui, run = self.select([]) + self.assertEqual(ui.checklist.call_args.kwargs['ok_label'], 'Update') + self.assertNotIn('extra_button', ui.checklist.call_args.kwargs) + self.assertFalse(result); ui.review.assert_not_called(); run.assert_not_called() + + def test_changed_app_blocks_batch_before_any_lifecycle(self): + _, ui, run = self.select(['all'], fresh=AVAILABLE[1:]) + run.assert_not_called() + self.assertIn('changed since', ui.message.call_args.args[0]) + + def test_failure_or_per_app_cancel_stops_later_selections(self): + _, _, run = self.select(['all'], outcomes=[False]) + self.assertEqual(run.call_count, 1) + + def test_declining_review_returns_to_selection_instead_of_updating(self): + ui = Mock(); ui.checklist.side_effect = [['all'], []]; ui.review.return_value = False + with patch.object(management, 'manage_instance') as run: + self.assertFalse(updates.select_updates(ROOT, ui, AVAILABLE)) + run.assert_not_called() + self.assertEqual(ui.checklist.call_args.args[2], ['110', '111']) + + +class StatusTests(unittest.TestCase): + def checker(self): + return updates.UpdateCheck(ROOT) + + def test_labels_show_cached_count_or_manual_check(self): + checker = self.checker() + self.assertEqual(checker.label({'available': []}), 'Check for updates') + self.assertEqual(checker.label({'available': AVAILABLE}), 'Updates available (2)') + checker.process = Mock(stdout=None); checker.process.poll.return_value = None + self.assertEqual(checker.label({'available': AVAILABLE}), 'Checking for updates (0/0)') + + def test_partial_check_keeps_the_entry_clean_without_a_header_warning(self): + checker = self.checker() + state = {'checked': True, 'available': [], 'unknown': 1, 'applications': 2} + self.assertEqual(checker.label(state), 'Check for updates') + self.assertEqual(checker.notice(state), '') + + def test_completed_checks_show_only_the_update_count(self): + state = {'checked': True, 'available': AVAILABLE, 'unknown': 0, 'applications': 2} + self.assertEqual(self.checker().label(state), 'Updates available (2)') + + def progress_checker(self): + reader, writer = os.pipe() + stream = os.fdopen(reader, 'rb', buffering=0) + self.addCleanup(stream.close); self.addCleanup(os.close, writer) + os.set_blocking(reader, False) + checker = self.checker(); checker.total = 24 + checker.process = Mock(stdout=stream); checker.process.poll.return_value = None + return checker, writer + + def test_numeric_progress_redraws_only_when_the_count_changes(self): + checker, writer = self.progress_checker() + self.assertEqual(checker.label({}), 'Checking for updates (0/24)') + self.assertFalse(checker.changed()) + os.write(writer, b'{"completed":12,"total":24}\n') + self.assertTrue(checker.changed()) + self.assertEqual(checker.label({}), 'Checking for updates (12/24)') + self.assertFalse(checker.changed()) + os.write(writer, b'{"completed":12,"total":24}\n') + self.assertFalse(checker.changed()) + + def test_split_and_invalid_worker_output_never_appears_in_the_menu(self): + checker, writer = self.progress_checker() + os.write(writer, b'password=secret\n{"completed":-1,"total":24}\n{"completed":') + self.assertEqual(checker.label({}), 'Checking for updates (0/24)') + os.write(writer, b'7,"total":24}\n{"completed":25,"total":24}\n') + self.assertEqual(checker.label({}), 'Checking for updates (7/24)') + + def test_worker_failure_does_not_reuse_an_old_successful_result(self): + checker = self.checker(); checker.process = Mock(stdout=None) + checker.process.poll.return_value = checker.process.wait.return_value = 1 + old = {'checked': True, 'available': AVAILABLE, 'unknown': 0, 'applications': 2} + with patch.object(checker.backend, 'cached', return_value=old): + state = checker.status() + self.assertEqual(checker.label(state), 'Check for updates') + self.assertEqual(state['available'], []) + self.assertEqual(checker.notice(state), '') + + def test_finished_worker_keeps_the_final_redraw_pending_until_status_is_consumed(self): + checker = self.checker(); checker.completed = checker.total = 2 + checker.process = Mock(stdout=None) + checker.process.poll.return_value = checker.process.wait.return_value = 0 + self.assertFalse(checker.running) + self.assertTrue(checker.active) + self.assertEqual(checker.label({}), 'Checking for updates (2/2)') + self.assertTrue(checker.changed()) + state = {'checked': True, 'available': AVAILABLE, 'unknown': 0, 'applications': 2} + with patch.object(checker.backend, 'cached', return_value=state): + final = checker.status() + self.assertFalse(checker.active) + self.assertEqual(checker.label(final), 'Updates available (2)') + + def test_check_process_is_isolated_and_displays_no_registry_errors(self): + checker = self.checker() + with patch.object(subprocess, 'Popen') as popen, patch.object(updates.os, 'set_blocking') as blocking, \ + patch.object(checker.backend, 'monitor_available', return_value=True): + checker.start() + args = popen.call_args + self.assertTrue(args.kwargs['start_new_session']) + self.assertEqual(args.kwargs['stderr'], subprocess.DEVNULL) + self.assertEqual(args.args[0][1], str(ROOT / 'remote/oci_update_check.py')) + self.assertIn('--progress', args.args[0]) + self.assertEqual(args.kwargs['stdout'], subprocess.PIPE) + blocking.assert_called_once_with(checker.process.stdout.fileno(), False) + + def test_missing_monitor_never_starts_a_fallback_checker(self): + checker = self.checker() + with patch.object(checker.backend, 'monitor_available', return_value=False), \ + patch.object(subprocess, 'Popen') as popen: + with self.assertRaisesRegex(ValueError, 'ProxMenux Monitor'): + checker.start() + popen.assert_not_called() + + def test_cancelling_stops_the_worker_process_group(self): + checker = self.checker(); process = Mock(); checker.process = process + process.pid = 4321; process.poll.return_value = None + with patch.object(updates.os, 'killpg') as kill: + checker.close() + kill.assert_called_once_with(4321, signal.SIGTERM) + process.wait.assert_called_once_with(timeout=2) + + def test_finished_worker_is_not_signalled_after_its_pid_could_be_reused(self): + checker = self.checker(); checker.process = Mock(); checker.process.poll.return_value = 0 + with patch.object(updates.os, 'killpg') as kill: + checker.close() + kill.assert_not_called() + + +class DialogTests(unittest.TestCase): + def test_status_change_redraws_menu_and_reaps_dialog(self): + process = Mock(); process.poll.return_value = None + process.communicate.side_effect = [subprocess.TimeoutExpired('dialog', .1), (None, '')] + popen = Mock(); popen.__enter__ = Mock(return_value=process); popen.__exit__ = Mock(return_value=False) + with patch.object(subprocess, 'Popen', return_value=popen): + with self.assertRaises(MenuRefresh): + DialogUI().choose('OCI', ReadableTags([('updates', 'Checking for updates...')]), + 'updates', size=(20, 75, 12), refresh=lambda: True) + process.terminate.assert_called_once() + self.assertEqual(process.communicate.call_count, 2) + + def test_escape_from_live_menu_remains_cancel(self): + process = Mock(); process.poll.return_value = 255; process.returncode = 255 + process.communicate.return_value = (None, '') + popen = Mock(); popen.__enter__ = Mock(return_value=process); popen.__exit__ = Mock(return_value=False) + with patch.object(subprocess, 'Popen', return_value=popen): + self.assertIsNone(DialogUI().choose('OCI', [('1', 'Checking')], refresh=lambda: False)) + process.terminate.assert_not_called() + + def test_checklist_starts_unselected_with_update_and_cancel_only(self): + ui = DialogUI() + with patch.object(ui, '_run', return_value=SimpleNamespace(returncode=0, stderr='111\n')) as run: + picked = ui.checklist('Updates', [('all', 'Select all'), ('111', 'App')], [], + ok_label='Update') + self.assertEqual(picked, ['111']) + widget = run.call_args.args[0] + self.assertNotIn('--extra-button', widget) + self.assertEqual(widget[widget.index('--ok-label') + 1], 'Update') + self.assertNotIn('on', widget) + + +class MainMenuTests(unittest.TestCase): + def test_status_changes_in_the_same_main_menu_entry(self): + class Checker: + running = False + completed = 0 + @property + def active(self): return self.running + def __enter__(self): return self + def __exit__(self, *exc): pass + def start(self): self.running = True + def status(self): return {'available': AVAILABLE if getattr(self, 'done', False) else []} + def changed(self): return True + def label(self, state): + return (f'Checking for updates ({self.completed}/2)' if self.running else + 'Updates available (2)' if state['available'] else 'Check for updates') + def notice(self, state): return '' + checker = Checker(); seen = [] + class UI: + live_refresh = True + def choose(self, text, entries, default, **kw): + seen.append(dict(entries)['updates']) + if len(seen) == 1: return 'updates' + if len(seen) == 2: + checker.completed = 1 + raise MenuRefresh() + if len(seen) == 3: + checker.running = False; checker.done = True + raise MenuRefresh() + return None + with patch.object(cli, 'interactive_ui', return_value=UI()), \ + patch.object(updates, 'UpdateCheck', return_value=checker): + self.assertEqual(cli.interactive(cli.Catalog(ROOT)), 0) + self.assertEqual(seen, ['Check for updates', 'Checking for updates (0/2)', + 'Checking for updates (1/2)', 'Updates available (2)']) + + +class TranslationTests(unittest.TestCase): + def test_update_menu_literals_are_translated_in_every_shipped_language(self): + source = ast.parse((ROOT / 'src/proxmenux_oci/updates.py').read_text()) + texts = {node.args[0].value for node in ast.walk(source) + if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) + and node.func.id == 'translate' and node.args + and isinstance(node.args[0], ast.Constant)} + for path in sorted((ROOT.parent / 'lang').glob('*.json')): + dictionary = json.loads(path.read_text()) + for text in texts: + with self.subTest(locale=path.stem, text=text): + self.assertTrue(dictionary.get(text)) + self.assertEqual(set(re.findall(r'\{\w+\}', text)), + set(re.findall(r'\{\w+\}', dictionary[text]))) + + +if __name__ == '__main__': + unittest.main() diff --git a/oci/tests/test_update_metadata.py b/oci/tests/test_update_metadata.py new file mode 100644 index 00000000..76bad47d --- /dev/null +++ b/oci/tests/test_update_metadata.py @@ -0,0 +1,73 @@ +"""Native image checks reuse immutable metadata only after a fresh digest lookup.""" +import json +from pathlib import Path +import sys +import unittest +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'remote')) +import oci_installation_state as state + + +def digest(value): return 'sha256:' + state.sha(value) + + +class MetadataTests(unittest.TestCase): + def setUp(self): + self.platform = json.dumps({'schemaVersion': 2, 'config': {'digest': 'sha256:' + 'c' * 64}, + 'layers': [{'digest': 'sha256:' + 'd' * 64, 'size': 15}]}).encode() + self.index = json.dumps({'schemaVersion': 2, 'manifests': [ + {'digest': digest(self.platform), 'platform': {'architecture': 'amd64', 'os': 'linux'}}]}).encode() + self.config = json.dumps({'architecture': 'amd64', 'os': 'linux', 'created': '2026-10-18T00:00:00Z', + 'config': {'Labels': {'org.opencontainers.image.version': '2.0'}, 'Env': []}}).encode() + self.known = {'manifest_digest': digest(self.platform), 'architecture': 'amd64', + 'version': '1.0', 'created': '2026-10-01T00:00:00Z'} + + def test_unchanged_multiarch_image_requires_only_a_fresh_index_lookup(self): + with patch.object(state, 'command', return_value=self.index) as command: + candidate = state.resolve_candidate_metadata('ghcr.io/example/private:latest', 'amd64', self.known) + command.assert_called_once_with('skopeo', 'inspect', '--raw', 'docker://ghcr.io/example/private:latest') + self.assertEqual(candidate['manifest_digest'], self.known['manifest_digest']) + self.assertEqual(candidate['version'], '1.0') + self.assertEqual(candidate['registry_digest'], digest(self.index)) + + def test_unchanged_singlearch_image_requires_only_a_fresh_manifest_lookup(self): + with patch.object(state, 'command', return_value=self.platform) as command: + candidate = state.resolve_candidate_metadata('example/app:latest', 'amd64', self.known) + self.assertEqual(command.call_count, 1) + self.assertEqual(candidate['version'], '1.0') + + def test_changed_digest_fetches_and_validates_new_metadata(self): + old = dict(self.known, manifest_digest='sha256:' + 'a' * 64) + with patch.object(state, 'command', side_effect=[self.index, self.platform, self.config]) as command: + candidate = state.resolve_candidate_metadata('example/app:latest', 'amd64', old) + self.assertEqual(command.call_count, 3) + self.assertEqual(candidate['version'], '2.0') + self.assertEqual(candidate['manifest_digest'], digest(self.platform)) + + def test_different_architecture_cannot_reuse_metadata(self): + known = dict(self.known, architecture='arm64') + with patch.object(state, 'command', side_effect=[self.index, self.platform, self.config]) as command: + candidate = state.resolve_candidate_metadata('example/app:latest', 'amd64', known) + self.assertEqual(command.call_count, 3) + self.assertEqual(candidate['version'], '2.0') + + def test_install_and_update_still_fetch_complete_verified_contract(self): + with patch.object(state, 'command', side_effect=[self.index, self.platform, self.config]) as command: + candidate = state.resolve_candidate('example/app:latest', 'amd64') + self.assertEqual(command.call_count, 3) + self.assertEqual(candidate['layers'], ['sha256:' + 'd' * 64]) + self.assertIn('Env', candidate['defaults']) + + def test_changed_manifest_must_still_match_the_registry_digest(self): + with patch.object(state, 'command', side_effect=[self.index, b'{}']): + with self.assertRaises(ValueError): + state.resolve_candidate_metadata('example/app:latest', 'amd64', None) + + def test_registry_failure_cannot_be_hidden_by_saved_metadata(self): + with patch.object(state, 'command', side_effect=RuntimeError('Registry unavailable')): + with self.assertRaises(RuntimeError): + state.resolve_candidate_metadata('example/app:latest', 'amd64', self.known) + + +if __name__ == '__main__': unittest.main()