mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-01 10:22:08 +00:00
feat(oci): show update availability and update selected applications
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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())
|
||||
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user