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

This commit is contained in:
Jonatan Castro
2026-10-19 11:23:45 +02:00
parent 29778aa709
commit 70d4101243
22 changed files with 1644 additions and 80 deletions
+1
View File
@@ -151,6 +151,7 @@ cp "$SCRIPT_DIR/lxc_update_finalization.py" "$APP_DIR/usr/bin/"
cp "$SCRIPT_DIR/lxc_check_job.py" "$APP_DIR/usr/bin/"
cp "$SCRIPT_DIR/native_app_discovery.py" "$APP_DIR/usr/bin/"
cp "$SCRIPT_DIR/lxc_apps.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ lxc_apps.py not found"
cp "$SCRIPT_DIR/oci_menu_bridge.py" "$APP_DIR/usr/bin/"
cp "$SCRIPT_DIR/custom_links.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ custom_links.py not found"
cp "$SCRIPT_DIR/recreate_docker_container.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ recreate_docker_container.py not found"
chmod +x "$APP_DIR/usr/bin/recreate_docker_container.py" 2>/dev/null || true
+10
View File
@@ -23147,6 +23147,16 @@ if __name__ == '__main__':
import sys
import logging
# The root terminal menu shares the Monitor's checker and sidecar cache.
# This local socket changes neither the web API nor its authentication.
try:
if not is_pbs_host():
import lxc_apps
import oci_menu_bridge
oci_menu_bridge.start(lxc_apps)
except Exception as _e:
print(f"[startup] OCI menu bridge unavailable: {_e}", file=sys.stderr)
# Regenerate /etc/cron.d/proxmenux-smart from the current schedule
# config on every Monitor start. Pre-existing installs may carry
# a cron file with the broken script path (without /storage/),
+98 -38
View File
@@ -4338,8 +4338,87 @@ def _detect_with_alt_healing(vmid, app: dict) -> tuple:
return installed, err, False
_oci_check_locks: dict = {}
_oci_check_locks_guard = threading.Lock()
def check_app(
vmid, app_id: str, force: bool = False, notify: bool = True,
) -> Optional[dict]:
# Keep legacy detectors' locking unchanged. OCI network requests use a
# per-app lock and hold the shared cache lock only while reading/saving.
with _cache_lock:
sidecar = _read_sidecar(vmid)
app = _find_app(sidecar, app_id) if sidecar else None
if not app or app.get("installed_via") != "oci_image":
return _check_non_oci_app(vmid, app_id, force=force, notify=notify)
return _check_oci_app(vmid, app_id, force=force, notify=notify)
def _check_oci_app(vmid, app_id, force=False, notify=True):
key = (int(vmid), app_id)
with _oci_check_locks_guard:
lock = _oci_check_locks.setdefault(key, threading.Lock())
with lock:
with _cache_lock:
sidecar = _read_sidecar(vmid)
app = _find_app(sidecar, app_id) if sidecar else None
if not app or app.get("installed_via") != "oci_image":
return sidecar
state = app.get("state") or _empty_state()
if not force and state.get("checked_at"):
try:
checked = datetime.datetime.strptime(state["checked_at"].rstrip("Z"), "%Y-%m-%dT%H:%M:%S")
age = (datetime.datetime.utcnow() - checked).total_seconds()
if 0 <= age < _UPSTREAM_CACHE_TTL_SEC:
return sidecar
except (ValueError, TypeError):
pass
before_app = copy.deepcopy(app)
before_record = _read_oci_record(vmid)
# No global cache lock is held while waiting for registries. The same
# native resolver, credentials and digest comparisons are retained.
result = _oci_image_versions(
vmid, known=state, with_latest=not _oci_secondary_member(before_record))
if result.get("busy"):
_recheck_after_oci_operation(vmid, app_id)
return _read_sidecar(vmid)
with _cache_lock:
# Re-read rather than writing the pre-query copy: other apps,
# schedules and registrations may have changed while we waited.
sidecar = _read_sidecar(vmid)
app = _find_app(sidecar, app_id) if sidecar else None
if app != before_app or _read_oci_record(vmid) != before_record:
return sidecar # Never publish a result for a replaced/edited app.
app["state"] = {
"installed_version": result.get("installed_version"),
"latest_version": result.get("latest_version"),
"latest_published_at": None,
"update_available": result.get("update_available"),
"error": result.get("error"),
"checked_at": _now_iso(),
"installed_digest": result.get("installed_digest"),
"installed_registry_digest": result.get("installed_registry_digest"),
"registry_retry": bool(result.get("registry_retry")),
"latest_digest": result.get("latest_digest"),
"image_created": result.get("image_created"),
"latest_image_created": result.get("latest_image_created"),
"image_reference": result.get("image_reference"),
"image_repository": result.get("image_repository"),
}
sidecar["updated_at"] = _now_iso()
_write_sidecar(vmid, sidecar)
if app["state"]["registry_retry"]:
_retry_oci_registry(vmid, app_id)
if notify and app["state"]["update_available"]:
_fire_update_notification(vmid, app)
return sidecar
def _check_non_oci_app(
vmid, app_id: str, force: bool = False, notify: bool = True,
) -> Optional[dict]:
with _cache_lock:
sidecar = _read_sidecar(vmid)
@@ -4369,40 +4448,6 @@ def check_app(
except (ValueError, TypeError):
pass
if app.get("installed_via") == "oci_image":
# A secondary container of a stack shows the version it runs; the
# registry is not asked, because it is not updated on its own.
result = _oci_image_versions(
vmid, known=state, with_latest=not _oci_secondary_member(_read_oci_record(vmid)))
if result.get("busy"):
_recheck_after_oci_operation(vmid, app_id)
return sidecar
app["state"] = {
"installed_version": result.get("installed_version"),
"latest_version": result.get("latest_version"),
"latest_published_at": None,
"update_available": result.get("update_available"),
"error": result.get("error"),
"checked_at": _now_iso(),
"installed_digest": result.get("installed_digest"),
"installed_registry_digest": result.get("installed_registry_digest"),
"registry_retry": bool(result.get("registry_retry")),
"latest_digest": result.get("latest_digest"),
"image_created": result.get("image_created"),
"latest_image_created": result.get("latest_image_created"),
"image_reference": result.get("image_reference"),
"image_repository": result.get("image_repository"),
}
sidecar["updated_at"] = _now_iso()
_write_sidecar(vmid, sidecar)
if app["state"]["registry_retry"]:
_retry_oci_registry(vmid, app_id)
# The payload names an image by its build date and digest when it
# states no version, so it decides whether there is anything to send.
if notify and app["state"]["update_available"]:
_fire_update_notification(vmid, app)
return sidecar
installed, inst_err, _healed = _detect_with_alt_healing(vmid, app)
# Trigger the upstream fetch when ANY upstream source is
# configured. The dispatcher inside `fetch_latest_upstream`
@@ -5975,14 +6020,22 @@ def _oci_repository(reference: str) -> str:
return repository
def _oci_resolve(module, reference: str, architecture: str) -> dict:
def _oci_resolve(module, reference: str, architecture: str, known=None) -> dict:
# One retry: an anonymous registry answers the occasional request with an
# error that the next one does not repeat.
try:
def resolve():
# Older installed engines remain compatible; both paths use their
# native resolver. No separate registry client or verdict is added.
metadata = getattr(module, "resolve_candidate_metadata", None)
if known is not None and callable(metadata):
return metadata(reference, architecture, known)
return module.resolve_candidate(reference, architecture)
try:
return resolve()
except RuntimeError:
time.sleep(2)
return module.resolve_candidate(reference, architecture)
return resolve()
# What the panel shows, translated, when the registry of an image did not answer.
@@ -6065,8 +6118,15 @@ def _oci_image_versions(vmid, known: Optional[dict] = None, with_latest: bool =
result["installed_version"] = _oci_guest_version(vmid, meta.get("template_id"))
if not with_latest:
return result
cached_metadata = None
if (known.get("installed_digest") == installed_digest
and known.get("image_reference") == reference and known.get("latest_digest")):
cached_metadata = {"manifest_digest": known["latest_digest"],
"version": known.get("latest_version"),
"created": known.get("latest_image_created"),
"architecture": architecture}
try:
latest = _oci_resolve(module, reference, architecture)
latest = _oci_resolve(module, reference, architecture, known=cached_metadata)
except Exception:
# A registry that does not answer says nothing new about the image:
# what the last check found for this same image still stands.
+161
View File
@@ -0,0 +1,161 @@
"""Root-only local access to the Monitor's existing OCI update checker.
The terminal menu must not run a second registry checker or write sidecars in
another process. Requests execute in the Monitor, using its normal locks,
registration rules, retries and /etc/proxmenux/apps results. No TCP API or
authentication setting is changed.
"""
from __future__ import annotations
import atexit
from concurrent.futures import ThreadPoolExecutor, as_completed
import errno
import json
import os
from pathlib import Path
import socket
import socketserver
import stat
import struct
import threading
SOCKET = Path('/run/proxmenux-monitor/oci-check.sock')
_check_lock = threading.Lock()
_MAX_REQUEST = 128 * 1024
_CHECK_WORKERS = 16
def _root_peer(connection):
if not hasattr(socket, 'SO_PEERCRED'):
return False
credentials = connection.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, struct.calcsize('3i'))
return struct.unpack('3i', credentials)[1] == 0
def _applications(request):
if not isinstance(request, dict) or set(request) != {'applications'}:
raise ValueError('Invalid request')
rows = request['applications']
if not isinstance(rows, list) or len(rows) > 1000:
raise ValueError('Invalid applications')
seen = set()
for row in rows:
if (not isinstance(row, dict) or set(row) != {'vmid', 'installation_id'}
or type(row['vmid']) is not int or not 100 <= row['vmid'] <= 999999999
or row['vmid'] in seen or not isinstance(row['installation_id'], str)
or not 1 <= len(row['installation_id']) <= 128):
raise ValueError('Invalid application')
seen.add(row['vmid'])
return rows
def check_applications(module, rows, report):
"""Delegate exclusively to check_app, exactly as the Monitor's Check does."""
report({'completed': 0, 'total': len(rows)})
stopped = threading.Event()
def check_one(row):
if stopped.is_set():
return
vmid = row['vmid']
record = module._read_oci_record(vmid) or {}
if (record.get('installation_id') == row['installation_id']
and record.get('status') == 'installed'
and not record.get('pending_transaction')
and not record.get('pending_stack_transaction')
and not module._oci_secondary_member(record)):
# Honour dismissals and reuse the Monitor's registration and
# checker. The menu never invents registrations or notifications.
module.ensure_oci_registration(vmid)
sidecar = module.load_sidecar(vmid) or {}
app = next((app for app in sidecar.get('apps', [])
if app.get('installed_via') == 'oci_image'), None)
if app:
module.check_app(vmid, app['id'], force=True, notify=False)
pool = ThreadPoolExecutor(max_workers=_CHECK_WORKERS, thread_name_prefix='oci-check')
failed = False
try:
futures = [pool.submit(check_one, row) for row in rows]
for completed, future in enumerate(as_completed(futures), 1):
try:
future.result()
except Exception:
failed = True # One failure never stops the other applications.
report({'completed': completed, 'total': len(rows)})
finally:
stopped.set()
pool.shutdown(wait=True, cancel_futures=True)
report({'error': 'check_failed'} if failed else {'done': True})
class _Handler(socketserver.StreamRequestHandler):
def handle(self):
if not _root_peer(self.request):
return
self.request.settimeout(10)
acquired = False
try:
line = self.rfile.readline(_MAX_REQUEST + 1)
if len(line) > _MAX_REQUEST or not line.endswith(b'\n'):
return
rows = _applications(json.loads(line))
acquired = _check_lock.acquire(blocking=False)
if not acquired:
self.report({'error': 'check_running'})
return
check_applications(self.server.apps, rows, self.report)
except (BrokenPipeError, ConnectionResetError, TimeoutError):
pass # Closing the menu stops the rest of this request.
except Exception:
try:
self.report({'error': 'check_failed'})
except OSError:
pass
finally:
if acquired:
_check_lock.release()
def report(self, event):
self.wfile.write((json.dumps(event) + '\n').encode())
self.wfile.flush()
class _Server(socketserver.ThreadingUnixStreamServer):
daemon_threads = True
block_on_close = False
def start(apps, path=SOCKET):
"""Start alongside Flask; a private socket avoids web login bypasses."""
path = Path(path)
if os.geteuid() != 0:
return None
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
parent = path.parent.lstat()
if not stat.S_ISDIR(parent.st_mode) or parent.st_uid != 0 or parent.st_mode & 0o077:
raise OSError('Unsafe Monitor runtime directory')
if path.exists() or path.is_symlink():
old = path.lstat()
if not stat.S_ISSOCK(old.st_mode) or old.st_uid != 0:
raise OSError('Unsafe Monitor socket')
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as probe:
probe.settimeout(.2)
try:
probe.connect(str(path))
return None # Already owned by a running Monitor.
except OSError as exc:
if exc.errno not in (errno.ECONNREFUSED, errno.ENOENT):
raise
path.unlink(missing_ok=True)
server = _Server(str(path), _Handler)
server.apps = apps
os.chmod(path, 0o600)
threading.Thread(target=server.serve_forever, name='oci-menu-check', daemon=True).start()
def close():
server.shutdown()
server.server_close()
path.unlink(missing_ok=True)
atexit.register(close)
return server
@@ -0,0 +1,124 @@
"""OCI registry waits run concurrently; shared app cache changes remain atomic."""
from concurrent.futures import ThreadPoolExecutor
import copy
import json
from pathlib import Path
import sys
import tempfile
import threading
import unittest
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import lxc_apps
DIGEST = 'sha256:' + 'a' * 64
NEW = 'sha256:' + 'b' * 64
class ParallelOciChecksTests(unittest.TestCase):
def setUp(self):
tmp = tempfile.TemporaryDirectory(); self.addCleanup(tmp.cleanup)
self.root = Path(tmp.name)
self.records = {vmid: {'vmid': vmid, 'status': 'installed', 'installation_id': f'uuid-{vmid}'}
for vmid in range(110, 114)}
for vmid in self.records:
sidecar = {'vmid': vmid, 'schedule': {'enabled': False}, 'apps': [
{'id': 'oci', 'installed_via': 'oci_image', 'name': 'App', 'state': {}},
{'id': 'other', 'installed_via': 'manual', 'name': 'Other', 'state': {}}]}
(self.root / f'{vmid}.json').write_text(json.dumps(sidecar))
for item in (patch.object(lxc_apps, '_APPS_DIR', str(self.root)),
patch.object(lxc_apps, '_read_oci_record', side_effect=lambda vmid: copy.deepcopy(self.records.get(int(vmid))))):
item.start(); self.addCleanup(item.stop)
def result(self, vmid, **kwargs):
return {'installed_digest': DIGEST, 'installed_registry_digest': DIGEST,
'latest_digest': NEW, 'update_available': True, 'installed_version': '1',
'latest_version': '2', 'image_reference': 'example/app:latest'}
def test_four_native_checks_enter_registry_before_any_one_can_finish(self):
rendezvous = threading.Barrier(4, timeout=3)
def versions(vmid, **kwargs):
rendezvous.wait()
return self.result(vmid)
with patch.object(lxc_apps, '_oci_image_versions', side_effect=versions) as query:
with ThreadPoolExecutor(max_workers=4) as pool:
results = list(pool.map(lambda vmid: lxc_apps.check_app(vmid, 'oci', force=True, notify=False), self.records))
self.assertEqual(query.call_count, 4)
self.assertTrue(all(result['apps'][0]['state']['update_available'] for result in results))
def during_query(self, edit):
started, release, edited = threading.Event(), threading.Event(), threading.Event()
def versions(vmid, **kwargs):
started.set()
if not release.wait(3): raise AssertionError('Registry wait did not end')
return self.result(vmid)
def change():
with lxc_apps._cache_lock:
sidecar = lxc_apps.load_sidecar(110)
edit(sidecar)
lxc_apps._write_sidecar(110, sidecar)
edited.set()
with patch.object(lxc_apps, '_oci_image_versions', side_effect=versions):
with ThreadPoolExecutor(max_workers=2) as pool:
future = pool.submit(lxc_apps.check_app, 110, 'oci', force=True, notify=False)
try:
self.assertTrue(started.wait(2))
writer = pool.submit(change)
self.assertTrue(edited.wait(2), 'A registry call still holds the shared cache lock')
finally:
release.set()
future.result(); writer.result()
return lxc_apps.load_sidecar(110)
def test_unrelated_apps_and_schedule_edits_survive_registry_wait(self):
def edit(sidecar):
sidecar['schedule']['enabled'] = True
sidecar['apps'][1]['name'] = 'Renamed while checking'
result = self.during_query(edit)
self.assertTrue(result['schedule']['enabled'])
self.assertEqual(result['apps'][1]['name'], 'Renamed while checking')
self.assertTrue(result['apps'][0]['state']['update_available'])
def test_deleted_app_is_not_recreated_by_late_registry_answer(self):
result = self.during_query(lambda sidecar: sidecar['apps'].pop(0))
self.assertEqual([app['id'] for app in result['apps']], ['other'])
def test_edited_app_is_not_overwritten_by_late_registry_answer(self):
result = self.during_query(lambda sidecar: sidecar['apps'][0].update(name='Edited'))
self.assertEqual(result['apps'][0]['name'], 'Edited')
self.assertEqual(result['apps'][0]['state'], {})
def test_replaced_installation_cannot_receive_previous_images_answer(self):
def edit(sidecar): self.records[110]['installation_id'] = 'replacement'
result = self.during_query(edit)
self.assertEqual(result['apps'][0]['state'], {})
def test_checks_for_two_apps_in_same_container_keep_both_results(self):
sidecar = lxc_apps.load_sidecar(110)
sidecar['apps'][1]['installed_via'] = 'oci_image'
lxc_apps._write_sidecar(110, sidecar)
rendezvous = threading.Barrier(2, timeout=3)
def versions(vmid, **kwargs):
rendezvous.wait()
return self.result(vmid)
with patch.object(lxc_apps, '_oci_image_versions', side_effect=versions):
with ThreadPoolExecutor(max_workers=2) as pool:
futures = [pool.submit(lxc_apps.check_app, 110, app, force=True, notify=False) for app in ('oci', 'other')]
for future in futures: future.result()
result = lxc_apps.load_sidecar(110)
self.assertTrue(all(app['state']['update_available'] for app in result['apps']))
def test_native_registry_failure_is_persisted_and_retried_without_blocking_others(self):
def versions(vmid, **kwargs):
return {'error': 'registry unreachable', 'registry_retry': True} if vmid == 110 else self.result(vmid)
with patch.object(lxc_apps, '_oci_image_versions', side_effect=versions), \
patch.object(lxc_apps, '_retry_oci_registry') as retry:
with ThreadPoolExecutor(max_workers=4) as pool:
results = list(pool.map(lambda vmid: lxc_apps.check_app(vmid, 'oci', force=True, notify=False), self.records))
self.assertEqual(results[0]['apps'][0]['state']['error'], 'registry unreachable')
self.assertTrue(all(result['apps'][0]['state']['update_available'] for result in results[1:]))
retry.assert_called_once_with(110, 'oci')
if __name__ == '__main__': unittest.main()