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:
@@ -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()
|
||||
Reference in New Issue
Block a user