feat(oci): show update availability and update selected applications

This commit is contained in:
Jonatan Castro
2026-10-19 11:23:45 +02:00
parent 29778aa709
commit 70d4101243
22 changed files with 1644 additions and 80 deletions
+15
View File
@@ -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'
+1
View File
@@ -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
+10
View File
@@ -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/),
+98 -38
View File
@@ -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.
+161
View File
@@ -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
@@ -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()
+9
View File
@@ -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...",
+9
View File
@@ -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...",
+9
View File
@@ -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...",
+9
View File
@@ -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...",
+9
View File
@@ -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...",
+9
View File
@@ -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...",
+9
View File
@@ -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...",
+44
View File
@@ -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 |
+22 -2
View File
@@ -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.
+204
View File
@@ -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())
+50 -28
View File
@@ -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
+38 -12
View File
@@ -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"]
+184
View File
@@ -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
+283
View File
@@ -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()
+274
View File
@@ -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()
+73
View File
@@ -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()