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
+44
View File
@@ -137,6 +137,50 @@ An application made of several images is one entry of the catalog and one instal
## Update, modify, recreate and remove
The OCI menu has a **Check for updates** entry. Checking changes that same
entry to **Checking for updates (completed/total)**, then **Updates available (N)** when
newer images are found. Progress counts applications, including coordinated
applications with several containers, and advances as each finishes. When finished, the entry shows only
**Updates available (N)** or **Check for updates**. Only registry metadata is read; no image layers are
downloaded and no containers are changed. The menu remains usable during the
check. Leaving the OCI menu closes its request; the Monitor may finish
metadata queries already in progress before stopping the remaining checks.
Open **Updates available (N)** to choose applications with Space. Nothing is
preselected, even when there is only one update. **Select all** at the top
includes every listed application. After reviewing the selection, each
application uses the existing update workflow, including its identity checks,
backup prompts and recovery. Cancelling or failing an update stops the batch.
The selection screen has only **Update** and **Cancel** buttons.
Opening the menu reads the Monitor's existing `/etc/proxmenux/apps` results
without contacting registries. Its daily checks and the manual menu check use
the same `lxc_apps.check_app` implementation, cache and registry credentials.
There is no second checker, private update-status cache or fallback checker.
Manual checks run in the Monitor process through a root-only local Unix
socket, preserving its locks and registry retries without changing web
authentication. ProxMenux Monitor must be installed, running and include the
menu integration for manual checks.
Manual checks run up to sixteen applications in parallel. The native checker
holds its shared cache lock only while reading or publishing a result; each
app has its own check lock. Before saving it re-reads the app and installation
record, discards answers for a concurrently edited/replaced app, and preserves
unrelated registrations and schedules. A fresh tag/index lookup that confirms
the same platform digest reuses its immutable version/build metadata. Changed
digests still fetch and verify the new metadata; installs and updates retain
the full image-contract verification path. Checks therefore still consult the
registry rather than returning a cached verdict as a new check.
Results older than 24 hours or for a different installed image or reference
are not offered as current. Network failures retain the Monitor's existing
result and retry state, or remain unknown when no previous result exists.
Coordinated applications count once and follow the Monitor's existing policy:
update availability is tracked on the primary container, while secondary
containers are updated together with it. The menu does not add independent
update tracking for secondary images. A reference pinned to an immutable
digest does not follow newer tags.
*Manage installed OCI applications* lists the registered installations. Before any action a container is compared with its record; one that no longer matches is neither modified nor deleted.
| Operation | What changes | What is kept |
+22 -2
View File
@@ -163,7 +163,7 @@ def record_install(args):
save_record(args.state_dir, record)
def resolve_candidate(reference, architecture):
def resolve_candidate(reference, architecture, *, _known_metadata=None):
repo = reference.split('@', 1)[0]
if ':' in repo.rsplit('/', 1)[-1]:
repo = repo.rsplit(':', 1)[0]
@@ -173,12 +173,27 @@ def resolve_candidate(reference, architecture):
raw = command('skopeo', 'inspect', '--raw', 'docker://' + transport)
registry_digest = 'sha256:' + sha(raw)
manifest = json.loads(raw)
if 'manifests' in manifest:
indexed = 'manifests' in manifest
if indexed:
matches = [m for m in manifest['manifests'] if m.get('platform', {}).get('architecture') == architecture
and m.get('platform', {}).get('os') == 'linux']
if len(matches) != 1:
raise ValueError(translate('A single matching image platform cannot be resolved'))
digest = matches[0]['digest']
else:
digest = registry_digest
# The Monitor needs only the published version/build and digest. A fresh
# tag/index lookup proving the same immutable platform digest also proves
# its previously verified metadata; fetching its manifest and config again
# adds no information. Install/update callers do not pass this option and
# always receive the complete verified image contract below.
if (isinstance(_known_metadata, dict)
and re.fullmatch(r'sha256:[0-9a-f]{64}', str(digest))
and _known_metadata.get('manifest_digest') == digest
and _known_metadata.get('architecture') == architecture):
return {'manifest_digest': digest, 'registry_digest': registry_digest,
'version': _known_metadata.get('version'), 'created': _known_metadata.get('created')}
if indexed:
raw = command('skopeo', 'inspect', '--raw', 'docker://' + repo + '@' + digest)
if 'sha256:' + sha(raw) != digest:
raise ValueError(translate('The manifest does not match its digest'))
@@ -204,6 +219,11 @@ def resolve_candidate(reference, architecture):
'defaults': defaults, 'version': version, 'created': config.get('created')}
def resolve_candidate_metadata(reference, architecture, known=None):
"""Monitor-only metadata fast path through the normal native resolver."""
return resolve_candidate(reference, architecture, _known_metadata=known)
def same_image(candidate, image):
"""Whether what the registry serves is the image of an archive or a record.
+204
View File
@@ -0,0 +1,204 @@
#!/usr/bin/env python3
"""Adapt the OCI menu to the ProxMenux Monitor checker and its native cache.
This module never contacts a registry or writes an update-status cache.
"""
from __future__ import annotations
import argparse
import datetime
import json
import os
from pathlib import Path
import re
import socket
import stat
import sys
import time
import oci_instances as instances
from oci_installation_state import parse_config
APPS = Path('/etc/proxmenux/apps')
SOCKET = Path('/run/proxmenux-monitor/oci-check.sock')
MAX_AGE = 24 * 60 * 60
DIGEST = re.compile(r'sha256:[0-9a-f]{64}')
def title(record):
try:
template = (record.get('stack') or {}).get('template') or record.get('template') or {}
name = (template.get('catalog_ui') or {}).get('title')
if isinstance(name, dict):
name = name.get('en_US') or name.get('en')
return str(name or record.get('deployment', {}).get('hostname') or f"CT {record['vmid']}")
except (AttributeError, TypeError):
return f"CT {record['vmid']}"
def snapshot(root=instances.ROOT, configs=Path('/etc/pve/lxc')):
"""Use local native config files: opening the menu makes no PVE/API calls."""
records = {}
if not root.is_dir() or root.is_symlink():
return []
for path in sorted(root.iterdir()):
if not path.name.isdecimal() or int(path.name) < 100:
continue
vmid = int(path.name)
native = configs / f'{vmid}.conf'
if not native.is_file():
continue # Deleted guests and guests on another node are not this host's apps.
try:
record = instances.read(root, vmid)
config = native.read_bytes()
# On disk, PVE stores the description in # comments, unlike
# `pct config`. Later [snapshot] and [PENDING] sections describe
# other configurations and must not supply this guest's identity.
current = config.split(b'\n[', 1)[0]
description = '\n'.join(line[1:] for line in current.decode().splitlines() if line.startswith('#'))
identity = instances.identity(json.dumps({'description': description}).encode())
architecture = parse_config(current).get('arch')
reference = record['template']['container_contract']['image']['reference']
image = (record.get('observed') or {}).get('image') or {}
ready = (record.get('status') == 'installed'
and not record.get('pending_transaction')
and not record.get('pending_stack_transaction')
and identity == record['installation_id']
and architecture in ('amd64', 'arm64')
and isinstance(reference, str) and reference
and DIGEST.fullmatch(str(image.get('manifest_digest', ''))))
primary = int((record.get('stack_member') or {}).get('primary_vmid') or vmid)
records[vmid] = {'vmid': vmid, 'primary': primary, 'record': record,
'reference': reference,
'architecture': architecture, 'image': image,
'ready': bool(ready)}
except (OSError, ValueError, KeyError, TypeError, AttributeError):
# Never call an unreadable, incomplete or mismatched guest up to date.
records[vmid] = {'vmid': vmid, 'primary': vmid, 'record': {'vmid': vmid},
'ready': False}
groups = {}
for member in records.values():
groups.setdefault(member['primary'], []).append(member)
applications = []
for primary, members in sorted(groups.items()):
main = records.get(primary)
try:
expected = {int(m['vmid']) for m in ((main or {}).get('record', {}).get('stack') or {}).get('members', [])}
complete = main is not None
except (ValueError, TypeError, KeyError, AttributeError):
expected, complete = set(), False
present = {m['vmid'] for m in members}
complete = complete and expected.issubset(present)
applications.append({'vmid': primary, 'title': title((main or members[0])['record']),
'members': members,
'complete': complete})
return applications
def cached(root=instances.ROOT, configs=Path('/etc/pve/lxc'), now=None, apps=APPS):
"""Read the Monitor's sidecars; obey its primary-container stack policy."""
inventory = snapshot(root, configs)
result = {'checked': False, 'available': [], 'unknown': 0, 'applications': len(inventory)}
instant = time.time() if now is None else now
for app in inventory:
try:
if not app['complete'] or not all(member['ready'] for member in app['members']):
raise ValueError('Incomplete installation')
main = next(member for member in app['members'] if member['vmid'] == app['vmid'])
path = apps / f"{app['vmid']}.json"
if path.is_symlink():
raise ValueError('Invalid cache')
data = json.loads(path.read_text())
candidates = [row for row in data['apps'] if row.get('installed_via') == 'oci_image']
if str(data['vmid']) != str(app['vmid']) or len(candidates) != 1:
raise ValueError('Ambiguous application')
state = candidates[0]['state']
stamp = datetime.datetime.strptime(state['checked_at'], '%Y-%m-%dT%H:%M:%SZ')
age = instant - stamp.replace(tzinfo=datetime.timezone.utc).timestamp()
if (not 0 <= age <= MAX_AGE or state.get('error')
or state.get('installed_digest') != main['image']['manifest_digest']
or state.get('image_reference') != main['reference']
or type(state.get('update_available')) is not bool
or not DIGEST.fullmatch(str(state.get('latest_digest', '')))):
raise ValueError('Unusable result')
result['checked'] = True
if state['update_available']:
result['available'].append({'vmid': app['vmid'], 'title': app['title']})
if state.get('registry_retry'):
result['unknown'] += 1 # Same retained result and retry as the Monitor.
except (OSError, ValueError, KeyError, TypeError, AttributeError, StopIteration):
result['unknown'] += 1
return result
def monitor_available(path=SOCKET):
try:
parent, endpoint = path.parent.lstat(), path.lstat()
return (stat.S_ISDIR(parent.st_mode) and parent.st_uid == 0
and not parent.st_mode & 0o077 and stat.S_ISSOCK(endpoint.st_mode)
and endpoint.st_uid == 0 and not endpoint.st_mode & 0o077)
except OSError:
return False
def check(root=instances.ROOT, configs=Path('/etc/pve/lxc'), progress=None, path=SOCKET):
"""Request the existing checker in its own Monitor process, without HTTP."""
if not monitor_available(path):
raise RuntimeError('ProxMenux Monitor checker unavailable')
inventory = snapshot(root, configs)
rows = []
for app in inventory:
if not app['complete'] or not all(member['ready'] for member in app['members']):
continue
main = next(member for member in app['members'] if member['vmid'] == app['vmid'])
rows.append({'vmid': app['vmid'], 'installation_id': main['record']['installation_id']})
total, skipped = len(inventory), len(inventory) - len(rows)
if progress:
progress(0, total)
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection:
connection.settimeout(600) # Native registry timeouts and retries remain authoritative.
connection.connect(str(path))
connection.sendall((json.dumps({'applications': rows}) + '\n').encode())
completed = -1
with connection.makefile('rb') as stream:
while True:
line = stream.readline(4097)
if not line or len(line) > 4096:
raise RuntimeError('Monitor check incomplete')
event = json.loads(line)
if event.get('done') is True and completed == len(rows):
return
done, count = event.get('completed'), event.get('total')
if (type(done) is not int or type(count) is not int
or count != len(rows) or not completed <= done <= count or done < 0):
raise RuntimeError('Monitor check unavailable')
completed = done
if progress:
progress(skipped + completed, total)
def emit_progress(completed, total):
message = (json.dumps({'completed': completed, 'total': total}) + '\n').encode()
try:
os.write(sys.stdout.fileno(), message)
except (BlockingIOError, BrokenPipeError):
pass
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--root', type=Path, default=instances.ROOT)
parser.add_argument('--configs', type=Path, default=Path('/etc/pve/lxc'))
parser.add_argument('--progress', action='store_true', help='Emit numeric progress for the OCI menu')
args = parser.parse_args()
try:
if args.progress:
os.set_blocking(sys.stdout.fileno(), False)
check(args.root, args.configs, progress=emit_progress if args.progress else None)
return 0
except (OSError, ValueError, KeyError, TypeError, RuntimeError):
return 1
if __name__ == '__main__':
raise SystemExit(main())
+50 -28
View File
@@ -542,6 +542,8 @@ def _search(catalog: Catalog, ui, applications: list[dict[str, Any]]) -> None:
def interactive(catalog: Catalog) -> int:
from .ui import MenuRefresh
from .updates import UpdateCheck, select_updates
ui = interactive_ui()
if not catalog.index_path.exists():
ui.message(translate("The OCI catalog is not installed. Update ProxMenux and try again."))
@@ -565,34 +567,54 @@ def interactive(catalog: Catalog) -> int:
] + [(number, f"{translate(labels[key]):<35} ({len(categories[key]):>3})")
for number, key in category_by_index.items()]
selection = "search"
while True:
selection = ui.choose(translate("Select a category or search for applications:"), ReadableTags(options),
selection, size=MENU_SIZE)
if selection is None:
return 0
try:
if selection == "search":
_search(catalog, ui, applications)
elif selection == "all":
_app_list(catalog, ui, applications,
f"{translate('All applications')} ({len(applications)})")
elif selection == "manage":
from .management import interactive_management
interactive_management(PROJECT_ROOT, ui)
elif selection == "registries":
from .registry_accounts import open_registry_accounts
open_registry_accounts(ui)
elif selection == "custom":
from .custom import explore
explore(ui)
elif selection in category_by_index:
key = category_by_index[selection]
_app_list(catalog, ui, categories[key], translate(labels[key]))
except UserCancelled:
continue
except (ConversionError, InstallError, OSError, ValueError) as exc:
console.stop_spinner()
ui.message(f"{translate('The operation could not be completed')}:\n\n{exc}")
with UpdateCheck(PROJECT_ROOT) as updates:
while True:
state = updates.status()
entries = ReadableTags(options[:3] + [('updates', updates.label(state))] + options[3:])
text = translate("Select a category or search for applications:")
if notice := updates.notice(state):
text += '\n\n' + notice
if updates.active and not getattr(ui, 'live_refresh', False):
ui.info(translate('Checking for updates...'))
updates.wait()
continue
try:
kwargs = {'refresh': updates.changed} if updates.active else {}
selected = ui.choose(text, entries, selection, size=MENU_SIZE, **kwargs)
except MenuRefresh:
continue
if selected is None:
return 0
selection = selected
try:
if selection == "search":
_search(catalog, ui, applications)
elif selection == "all":
_app_list(catalog, ui, applications,
f"{translate('All applications')} ({len(applications)})")
elif selection == "manage":
from .management import interactive_management
interactive_management(PROJECT_ROOT, ui)
elif selection == "updates":
if not updates.active:
if state['available']:
select_updates(PROJECT_ROOT, ui, state['available'])
else:
updates.start()
elif selection == "registries":
from .registry_accounts import open_registry_accounts
open_registry_accounts(ui)
elif selection == "custom":
from .custom import explore
explore(ui)
elif selection in category_by_index:
key = category_by_index[selection]
_app_list(catalog, ui, categories[key], translate(labels[key]))
except UserCancelled:
continue
except (ConversionError, InstallError, OSError, ValueError) as exc:
console.stop_spinner()
ui.message(f"{translate('The operation could not be completed')}:\n\n{exc}")
# ---------------------------------------------------------------- maintenance CLI
+38 -12
View File
@@ -16,6 +16,10 @@ class UserCancelled(RuntimeError):
pass
class MenuRefresh(RuntimeError):
"""The background status changed; redraw the same menu."""
class BackRequested(RuntimeError):
pass
@@ -168,7 +172,7 @@ class TerminalUI:
return options[int(selected) - 1][0]
return None
def checklist(self, text, options, defaults):
def checklist(self, text, options, defaults, title: str | None = None, ok_label=None):
return [tag for tag, label in options if self.confirm(label, tag in defaults)]
def detail_menu(self, text: str, options: list[tuple[str, str]], default: str | None = None,
@@ -214,12 +218,13 @@ class DialogUI:
title: str = APP_TITLE
backtitle: str = "ProxMenux"
back_enabled: bool = False
live_refresh = True
@staticmethod
def available() -> bool:
return bool(shutil.which("dialog") and sys.stdin.isatty() and sys.stdout.isatty())
def _run(self, widget: list[str], title: str | None = None) -> subprocess.CompletedProcess[str]:
def _run(self, widget: list[str], title: str | None = None, refresh=None) -> subprocess.CompletedProcess[str]:
environment = os.environ.copy()
# ncurses otherwise waits a full second to distinguish Escape from
# the prefix of a cursor/function key. Keep a short sequence window,
@@ -231,11 +236,31 @@ class DialogUI:
back_widget = ['--extra-button', '--extra-label', translate('Back')] if self.back_enabled and any(
flag in widget for flag in ('--inputbox', '--passwordbox', '--yesno', '--menu', '--checklist')) else []
confirmation_labels = ['--yes-label', translate('Yes'), '--no-label', translate('No')] if '--yesno' in widget else []
result = subprocess.run(
["dialog", "--no-collapse", "--cancel-label", translate('Cancel'), "--backtitle", self.backtitle, "--title", title or self.title,
*confirmation_labels, *back_widget, *widget],
stdout=None, stderr=subprocess.PIPE, text=True, check=False, env=environment,
)
command = ["dialog", "--no-collapse", "--cancel-label", translate('Cancel'), "--backtitle", self.backtitle,
"--title", title or self.title, *confirmation_labels, *back_widget, *widget]
if refresh is None:
result = subprocess.run(command, stdout=None, stderr=subprocess.PIPE, text=True,
check=False, env=environment)
else:
with subprocess.Popen(command, stdout=None, stderr=subprocess.PIPE, text=True,
env=environment) as process:
try:
while True:
try:
_, output = process.communicate(timeout=0.1)
result = subprocess.CompletedProcess(command, process.returncode, stderr=output)
break
except subprocess.TimeoutExpired:
if refresh() and process.poll() is None:
raise MenuRefresh()
finally:
if process.poll() is None:
process.terminate()
try:
process.communicate(timeout=2)
except subprocess.TimeoutExpired:
process.kill()
process.communicate()
if result.returncode == 3 and back_widget:
raise BackRequested()
return result
@@ -287,7 +312,7 @@ class DialogUI:
def choose(self, text: str, options: list[tuple[str, str]], default: str | None = None,
title: str | None = None, show_tags: bool = True,
size: tuple[int, int, int] | None = None, colors: bool = False) -> str | None:
size: tuple[int, int, int] | None = None, colors: bool = False, refresh=None) -> str | None:
# A word is an internal value: the menu numbers the options, as the
# rest of ProxMenux does, and returns the value of the chosen one.
values: dict[str, str] = {}
@@ -307,7 +332,7 @@ class DialogUI:
widget += ["--menu", text, *map(str, size)]
for tag, label in options:
widget += [tag, label]
result = self._run(widget, title)
result = self._run(widget, title, refresh=refresh) if refresh else self._run(widget, title)
selected = result.stderr.strip() if result.returncode == 0 else None
return values.get(selected, selected) if values and selected else selected
terminal = shutil.get_terminal_size((100, 30))
@@ -324,7 +349,7 @@ class DialogUI:
widget += ["--menu", f"\n{text}", str(height), str(width), str(menu_height)]
for tag, label in options:
widget += [tag, label]
result = self._run(widget, title)
result = self._run(widget, title, refresh=refresh) if refresh else self._run(widget, title)
selected = result.stderr.strip() if result.returncode == 0 else None
return values.get(selected, selected) if values and selected else selected
@@ -349,10 +374,11 @@ class DialogUI:
result = self._run(widget, title)
return result.stderr.strip() if result.returncode == 0 else None
def checklist(self, text, options, defaults, title: str | None = None):
def checklist(self, text, options, defaults, title: str | None = None, ok_label=None):
terminal = shutil.get_terminal_size((100, 30))
height = min(max(len(options) + 9, 14), terminal.lines - 2)
widget = ["--separate-output", "--checklist", f"\n{text}", str(height), str(MENU_WIDTH),
widget = ['--ok-label', ok_label] if ok_label else []
widget += ["--separate-output", "--checklist", f"\n{text}", str(height), str(MENU_WIDTH),
str(max(4, min(len(options), height - 8)))]
for tag, label in options:
widget += [tag, label, "on" if tag in defaults else "off"]
+184
View File
@@ -0,0 +1,184 @@
"""OCI menu update status and explicit, user-selected updates."""
from __future__ import annotations
import importlib
import json
import os
from pathlib import Path
import signal
import subprocess
import sys
from .i18n import translate
from .ui import UserCancelled
class UpdateCheck:
def __init__(self, project):
self.project = Path(project)
sys.path.insert(0, str(self.project / 'remote'))
self.backend = importlib.import_module('oci_update_check')
self.process = None
self.failed = False
self.completed = self.total = 0
self._progress_buffer = b''
self._shown_progress = (0, 0)
def __enter__(self):
return self
def __exit__(self, *exc):
self.close()
@property
def running(self):
return self.process is not None and self.process.poll() is None
@property
def active(self):
# A finished child still needs one redraw to publish its final cache.
return self.process is not None
def ready(self):
return self.process is not None and self.process.poll() is not None
def _read_progress(self):
if self.process is None or self.process.stdout is None:
return
try:
while chunk := os.read(self.process.stdout.fileno(), 8192):
self._progress_buffer += chunk
while b'\n' in self._progress_buffer:
line, self._progress_buffer = self._progress_buffer.split(b'\n', 1)
try:
data = json.loads(line)
completed, total = data['completed'], data['total']
if type(completed) is int and type(total) is int and 0 <= completed <= total:
self.completed, self.total = completed, total
except (ValueError, TypeError, KeyError):
pass # Never display worker output as UI text.
if len(self._progress_buffer) > 8192:
self._progress_buffer = b''
except (BlockingIOError, OSError):
pass
def changed(self):
self._read_progress()
return self.ready() or (self.completed, self.total) != self._shown_progress
def _close_output(self):
if self.process is not None and self.process.stdout is not None:
self.process.stdout.close()
def status(self):
self._read_progress()
if self.ready():
self.failed = self.process.wait() != 0
self._close_output()
self.process = None
try:
state = self.backend.cached()
except (OSError, ValueError, KeyError, TypeError):
state = {'checked': False, 'available': [], 'unknown': 0, 'applications': 0}
if self.failed:
# A failed worker must not present an older successful check as
# the outcome of the new request, or keep its old update list.
state = dict(state, checked=True, available=[], unknown=state['applications'])
return state
def start(self):
if self.running:
return
if not self.backend.monitor_available():
raise ValueError(translate('The ProxMenux Monitor update checker is not available. Start or update ProxMenux Monitor and try again.'))
state = self.status()
self.failed = False
self.completed, self.total = 0, state['applications']
self._progress_buffer = b''
self.process = subprocess.Popen(
[sys.executable, str(self.project / 'remote/oci_update_check.py'), '--progress'],
stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
start_new_session=True, bufsize=0)
os.set_blocking(self.process.stdout.fileno(), False)
def wait(self):
if self.process is not None:
while True:
self._read_progress()
try:
self.process.wait(timeout=0.1)
break
except subprocess.TimeoutExpired:
pass
def close(self):
if self.process is None:
return
# Close only this client. The Monitor finishes its current metadata
# request and stops the remaining checks when the connection closes.
if self.process.poll() is None:
try:
os.killpg(self.process.pid, signal.SIGTERM)
except ProcessLookupError:
pass
try:
self.process.wait(timeout=2)
except subprocess.TimeoutExpired:
try:
os.killpg(self.process.pid, signal.SIGKILL)
except ProcessLookupError:
pass
self.process.wait()
self._close_output()
self.process = None
def label(self, state):
self._read_progress()
if self.active:
self._shown_progress = self.completed, self.total
return translate('Checking for updates ({completed}/{total})').format(
completed=self.completed, total=self.total)
count = len(state['available'])
return f"{translate('Updates available')} ({count})" if count else translate('Check for updates')
def notice(self, state):
return '' # Keep the menu heading clear after a check.
def select_updates(project, ui, available):
"""Nothing is preselected, including when exactly one update is available."""
choices = [('all', translate('Select all'))]
choices += [(str(row['vmid']), f"CT {row['vmid']} · {row['title']}") for row in available]
defaults = []
while True:
try:
picked = ui.checklist(
translate('Press Space to select applications to update. Select all includes every application in this list.'),
choices, defaults, title=translate('Updates available'), ok_label=translate('Update'))
except UserCancelled:
return False
selected = [row for row in available if 'all' in picked or str(row['vmid']) in picked]
if not selected:
return False
defaults = [str(row['vmid']) for row in selected]
names = '\n'.join(f" CT {row['vmid']} · {row['title']}" for row in selected)
if ui.review(names, translate('Update selected OCI applications'),
question=translate('Review and update these applications?'), default=False):
break
from .management import manage_instance, saved_inventory
# A change/removal since the check invalidates that application's result.
checker = UpdateCheck(project)
still_available = {row['vmid'] for row in checker.status()['available']}
current = {row['vmid']: row for row in saved_inventory(project)}
for row in selected:
vmid = row['vmid']
if vmid not in still_available or vmid not in current:
ui.message(translate('An application changed since the update check. Check for updates again.'),
translate('Updates available'))
return False
for row in selected:
# Keep the existing identity checks, backup/storage prompts, update
# confirmation, coordinated stack update and recovery path.
if not manage_instance(project, ui, current[row['vmid']], action='update'):
break # A cancellation or failure never updates later selections.
return False
+283
View File
@@ -0,0 +1,283 @@
"""The OCI menu shares the Monitor's checker and cache, with no fallback."""
import datetime
import json
import os
from pathlib import Path
import socket
import stat
import subprocess
import sys
import tempfile
import threading
from types import SimpleNamespace
import unittest
from unittest.mock import Mock, patch
import uuid
ROOT = Path(__file__).resolve().parents[1]
sys.path[:0] = [str(ROOT / 'src'), str(ROOT / 'remote'), str(ROOT.parent / 'AppImage/scripts')]
import oci_update_check as check
import oci_menu_bridge as bridge
import lxc_apps
OLD = 'sha256:' + 'a' * 64
NEW = 'sha256:' + 'b' * 64
class UpdateCheckTests(unittest.TestCase):
def setUp(self):
tmp = tempfile.TemporaryDirectory(); self.addCleanup(tmp.cleanup)
self.folder = Path(tmp.name)
self.root, self.configs, self.apps = [self.folder / name for name in ('instances', 'configs', 'apps')]
for path in (self.root, self.configs, self.apps): path.mkdir()
self.now = datetime.datetime.now(datetime.timezone.utc).replace(microsecond=0)
def record(self, vmid=110, reference='example/app:latest', **changes):
identifier = str(uuid.uuid4())
record = {'schema_version': 1, 'vmid': vmid, 'installation_id': identifier,
'status': 'installed', 'deployment': {'hostname': f'app-{vmid}'},
'template': {'container_contract': {'image': {'reference': reference}}},
'observed': {'image': {'manifest_digest': OLD}}}
record.update(changes)
directory = self.root / str(vmid); directory.mkdir(exist_ok=True)
(directory / 'oci-compose.json').write_text(json.dumps(record))
(self.configs / f'{vmid}.conf').write_text(f'#proxmenux-instance={identifier}\narch: amd64\n')
return record
def sidecar(self, vmid=110, **changes):
state = {'checked_at': self.now.strftime('%Y-%m-%dT%H:%M:%SZ'),
'installed_digest': OLD, 'latest_digest': NEW,
'image_reference': 'example/app:latest', 'update_available': True}
state.update(changes)
data = {'vmid': vmid, 'apps': [{'id': f'app-{vmid}', 'installed_via': 'oci_image', 'state': state}]}
(self.apps / f'{vmid}.json').write_text(json.dumps(data))
return data
def status(self, **kwargs):
return check.cached(self.root, self.configs, apps=self.apps, now=self.now.timestamp(), **kwargs)
def test_menu_reads_only_native_cache_without_subprocess_or_socket(self):
self.record(); self.sidecar()
before = {str(p): p.read_bytes() for p in self.folder.rglob('*') if p.is_file()}
with patch.object(subprocess, 'run') as run, patch.object(socket, 'socket') as connection:
state = self.status()
self.assertEqual([r['vmid'] for r in state['available']], [110])
run.assert_not_called(); connection.assert_not_called()
after = {str(p): p.read_bytes() for p in self.folder.rglob('*') if p.is_file()}
self.assertEqual(before, after)
self.assertNotIn('resolve_candidate', check.__dict__)
self.assertNotIn('query', check.__dict__)
def test_missing_native_cache_is_unknown_even_with_old_separate_cache(self):
self.record()
(self.root / '.update-check.json').write_text(json.dumps({'applications': [{'vmid': 110, 'status': 'available'}]}))
state = self.status()
self.assertFalse(state['checked']); self.assertEqual(state['unknown'], 1)
self.assertFalse(state['available'])
def test_native_false_is_current_without_recomputing_digests(self):
self.record(); self.sidecar(update_available=False)
state = self.status()
self.assertTrue(state['checked']); self.assertEqual(state['unknown'], 0)
self.assertFalse(state['available'])
def test_native_retry_retains_available_result_and_unknown_state(self):
self.record(); self.sidecar(registry_retry=True)
self.assertEqual(len(self.status()['available']), 1)
self.assertEqual(self.status()['unknown'], 1)
def test_native_error_or_missing_result_never_means_up_to_date(self):
self.record()
for changes in ({'error': 'registry unreachable'}, {'update_available': None}, {'latest_digest': None}):
self.sidecar(**changes)
state = self.status()
self.assertFalse(state['available']); self.assertEqual(state['unknown'], 1)
def test_stale_future_or_different_image_cache_is_not_available(self):
self.record()
for changes in ({'checked_at': (self.now - datetime.timedelta(days=2)).strftime('%Y-%m-%dT%H:%M:%SZ')},
{'checked_at': (self.now + datetime.timedelta(days=2)).strftime('%Y-%m-%dT%H:%M:%SZ')},
{'installed_digest': NEW}, {'image_reference': 'other/image:latest'}):
self.sidecar(**changes)
self.assertFalse(self.status()['available'])
self.assertEqual(self.status()['unknown'], 1)
def test_invalid_identity_or_pending_native_edit_invalidates_cached_update(self):
self.record(); self.sidecar()
(self.configs / '110.conf').write_text('arch: amd64\n')
self.assertFalse(self.status()['available'])
self.record(pending_transaction={'operation': 'update'})
self.assertFalse(self.status()['available'])
def test_snapshot_identity_cannot_replace_current_identity(self):
record = self.record(); self.sidecar()
(self.configs / '110.conf').write_text(
f"arch: amd64\n\n[previous]\n#proxmenux-instance={record['installation_id']}\n")
self.assertFalse(self.status()['available'])
def test_remote_or_deleted_guest_is_excluded(self):
self.record(); self.sidecar(); (self.configs / '110.conf').unlink()
self.assertEqual(self.status()['applications'], 0)
self.assertFalse(self.status()['available'])
def test_corrupt_ambiguous_or_symlinked_sidecars_are_unknown(self):
self.record(); path = self.apps / '110.json'
path.write_text('{broken')
self.assertEqual(self.status()['unknown'], 1)
data = self.sidecar(); data['apps'] *= 2; path.write_text(json.dumps(data))
self.assertEqual(self.status()['unknown'], 1)
path.unlink(); path.symlink_to(self.root / '110/oci-compose.json')
self.assertEqual(self.status()['unknown'], 1)
def test_stack_counts_once_and_follows_monitors_primary_tracking_rule(self):
self.record(110, stack_member={'primary_vmid': 110}, stack={'members': [{'vmid': 110}, {'vmid': 111}]})
self.record(111, stack_member={'primary_vmid': 110})
self.sidecar(110, update_available=False); self.sidecar(111, update_available=True)
self.assertEqual(self.status()['applications'], 1)
self.assertFalse(self.status()['available'])
self.sidecar(110)
self.assertEqual([r['vmid'] for r in self.status()['available']], [110])
def test_incomplete_stack_is_not_offered_for_update(self):
self.record(110, stack={'members': [{'vmid': 110}, {'vmid': 111}]}); self.sidecar()
self.assertFalse(self.status()['available'])
self.assertEqual(self.status()['unknown'], 1)
def test_real_bridge_calls_native_checker_and_publishes_its_sidecar(self):
record = self.record(); self.sidecar(update_available=False)
path = self.folder / 'check.sock'
server = bridge._Server(str(path), bridge._Handler); server.apps = lxc_apps
worker = threading.Thread(target=server.serve_forever, daemon=True); worker.start()
self.addCleanup(server.server_close); self.addCleanup(server.shutdown)
result = {'installed_digest': OLD, 'installed_registry_digest': OLD,
'image_reference': 'example/app:latest', 'installed_version': '1.0',
'latest_version': '2.0', 'latest_digest': NEW, 'update_available': True}
events = []
with patch.object(bridge, '_root_peer', return_value=True), \
patch.object(check, 'monitor_available', return_value=True), \
patch.object(lxc_apps, '_APPS_DIR', str(self.apps)), \
patch.object(lxc_apps, '_read_oci_record', return_value=record), \
patch.object(lxc_apps, 'ensure_oci_registration'), \
patch.object(lxc_apps, '_oci_image_versions', return_value=result) as native, \
patch.object(lxc_apps, '_fire_update_notification') as notify, \
patch.object(subprocess, 'run') as external:
check.check(self.root, self.configs, path=path, progress=lambda *event: events.append(event))
native.assert_called_once(); notify.assert_not_called(); external.assert_not_called()
self.assertEqual(events[0], (0, 1)); self.assertEqual(events[-1], (1, 1))
self.assertEqual([r['vmid'] for r in self.status()['available']], [110])
self.assertFalse((self.root / '.update-check.json').exists())
def test_missing_monitor_never_falls_back_to_registry_commands(self):
self.record()
with patch.object(check, 'monitor_available', return_value=False), patch.object(subprocess, 'run') as run:
with self.assertRaisesRegex(RuntimeError, 'Monitor'):
check.check(self.root, self.configs)
run.assert_not_called()
def test_readonly_monitor_availability_rejects_non_private_endpoints(self):
path = Mock(); path.parent.lstat.return_value = SimpleNamespace(st_mode=stat.S_IFDIR | 0o700, st_uid=0)
path.lstat.return_value = SimpleNamespace(st_mode=stat.S_IFSOCK | 0o600, st_uid=0)
self.assertTrue(check.monitor_available(path))
path.lstat.return_value.st_mode |= 0o066
self.assertFalse(check.monitor_available(path))
path.lstat.return_value.st_mode = stat.S_IFREG | 0o600
self.assertFalse(check.monitor_available(path))
path.lstat.return_value.st_mode = stat.S_IFSOCK | 0o600
path.parent.lstat.return_value.st_uid = 1000
self.assertFalse(check.monitor_available(path))
def test_full_or_closed_progress_pipe_does_not_abort_client(self):
for error in (BlockingIOError(), BrokenPipeError()):
with patch.object(check.os, 'write', side_effect=error), patch.object(check.sys, 'stdout'):
check.emit_progress(1, 2)
class BridgeTests(unittest.TestCase):
def module(self):
module = Mock()
module._read_oci_record.return_value = {'vmid': 110, 'installation_id': 'id-110', 'status': 'installed'}
module._oci_secondary_member.return_value = False
module.load_sidecar.return_value = {'apps': [{'id': 'native-app', 'installed_via': 'oci_image'},
{'id': 'other-app', 'installed_via': 'dpkg'}]}
return module
def test_only_native_oci_app_is_checked_without_unsolicited_notifications(self):
module = self.module(); events = []
bridge.check_applications(module, [{'vmid': 110, 'installation_id': 'id-110'}], events.append)
module.check_app.assert_called_once_with(110, 'native-app', force=True, notify=False)
self.assertEqual(events, [{'completed': 0, 'total': 1}, {'completed': 1, 'total': 1}, {'done': True}])
def test_replaced_guest_pending_operation_and_secondary_are_not_queried(self):
for changes, secondary in (({'installation_id': 'replacement'}, False),
({'pending_transaction': {'operation': 'update'}}, False),
({'pending_stack_transaction': {'operation': 'update'}}, False),
({}, True)):
module = self.module(); module._read_oci_record.return_value.update(changes)
module._oci_secondary_member.return_value = secondary
bridge.check_applications(module, [{'vmid': 110, 'installation_id': 'id-110'}], lambda _: None)
module.check_app.assert_not_called()
def test_dismissed_registration_is_not_reinvented(self):
module = self.module(); module.load_sidecar.return_value = None
bridge.check_applications(module, [{'vmid': 110, 'installation_id': 'id-110'}], lambda _: None)
module.check_app.assert_not_called()
def test_progress_advances_when_fast_app_finishes_before_first_slow_app(self):
module = self.module(); release = threading.Event(); events = []
module._read_oci_record.side_effect = lambda vmid: {
'vmid': vmid, 'installation_id': f'id-{vmid}', 'status': 'installed'}
def run(vmid, *args, **kwargs):
if vmid == 110 and not release.wait(3):
raise AssertionError('The fast app was blocked by the slow app')
def report(event):
events.append(event)
if event.get('completed') == 1: release.set()
module.check_app.side_effect = run
bridge.check_applications(module, [{'vmid': 110, 'installation_id': 'id-110'},
{'vmid': 111, 'installation_id': 'id-111'}], report)
self.assertEqual(events, [{'completed': 0, 'total': 2}, {'completed': 1, 'total': 2},
{'completed': 2, 'total': 2}, {'done': True}])
self.assertEqual(module.check_app.call_count, 2)
def test_unexpected_failure_does_not_skip_other_apps_or_report_success(self):
module = self.module(); events = []
module._read_oci_record.side_effect = lambda vmid: {
'vmid': vmid, 'installation_id': f'id-{vmid}', 'status': 'installed'}
module.check_app.side_effect = lambda vmid, *a, **kw: (_ for _ in ()).throw(RuntimeError()) if vmid == 110 else None
bridge.check_applications(module, [{'vmid': 110, 'installation_id': 'id-110'},
{'vmid': 111, 'installation_id': 'id-111'}], events.append)
self.assertEqual(module.check_app.call_count, 2)
self.assertEqual(events[-2], {'completed': 2, 'total': 2})
self.assertEqual(events[-1], {'error': 'check_failed'})
def test_requests_cannot_specify_commands_images_paths_or_duplicate_guests(self):
valid = {'vmid': 110, 'installation_id': 'id'}
for request in ({'command': ['sh']}, {'applications': '110'}, {'applications': [valid, valid]},
{'applications': [{**valid, 'image': 'private:image'}]},
{'applications': [{**valid, 'vmid': '../110'}]},
{'applications': [{**valid, 'vmid': True}]}):
with self.subTest(request=request), self.assertRaises(ValueError): bridge._applications(request)
def test_client_disconnect_stops_before_next_application(self):
module = self.module(); calls = []
def report(event):
calls.append(event)
if event.get('completed') == 1: raise BrokenPipeError()
with self.assertRaises(BrokenPipeError):
bridge.check_applications(module, [{'vmid': 110, 'installation_id': 'id-110'},
{'vmid': 111, 'installation_id': 'id-111'}], report)
self.assertEqual(module.check_app.call_count, 1)
def test_non_root_peers_are_rejected_before_reading_requests(self):
handler = object.__new__(bridge._Handler); handler.request = Mock(); handler.rfile = Mock()
with patch.object(bridge, '_root_peer', return_value=False): handler.handle()
handler.rfile.readline.assert_not_called()
def test_build_and_monitor_startup_include_bridge(self):
scripts = ROOT.parent / 'AppImage/scripts'
self.assertIn('oci_menu_bridge.py', (scripts / 'build_appimage.sh').read_text())
self.assertIn('oci_menu_bridge.start(lxc_apps)', (scripts / 'flask_server.py').read_text())
if __name__ == '__main__':
unittest.main()
+274
View File
@@ -0,0 +1,274 @@
"""Explicit update selection and live status redraw without lifecycle shortcuts."""
from pathlib import Path
import ast
import json
import os
import re
import signal
import subprocess
import sys
from types import SimpleNamespace
import unittest
from unittest.mock import Mock, patch
ROOT = Path(__file__).resolve().parents[1]
sys.path[:0] = [str(ROOT / 'src'), str(ROOT / 'remote')]
from proxmenux_oci import cli, management, updates
from proxmenux_oci.ui import DialogUI, MenuRefresh, ReadableTags, UserCancelled
AVAILABLE = [{'vmid': 110, 'title': 'First app'}, {'vmid': 111, 'title': 'Second app'}]
class SelectionTests(unittest.TestCase):
def select(self, picked, available=AVAILABLE, review=True, fresh=None, outcomes=None):
ui = Mock()
ui.checklist.side_effect = picked if isinstance(picked, Exception) else [picked]
ui.review.return_value = review
current = [{**row, 'reason': 'matched'} for row in available]
with patch.object(updates, 'UpdateCheck') as checker, \
patch.object(management, 'saved_inventory', return_value=current), \
patch.object(management, 'manage_instance', side_effect=outcomes or [True] * len(available)) as run:
checker.return_value.status.return_value = {'available': available if fresh is None else fresh}
result = updates.select_updates(ROOT, ui, available)
return result, ui, run
def test_one_update_is_not_checked_by_default(self):
_, ui, run = self.select([], available=AVAILABLE[:1])
self.assertEqual(ui.checklist.call_args.args[2], [])
self.assertEqual(ui.checklist.call_args.args[1][0], ('all', 'Select all'))
run.assert_not_called()
def test_only_selected_app_uses_existing_update_flow(self):
_, ui, run = self.select(['111'])
self.assertEqual(run.call_count, 1)
self.assertEqual(run.call_args.args[2]['vmid'], 111)
self.assertEqual(run.call_args.kwargs, {'action': 'update'})
self.assertIn('Second app', ui.review.call_args.args[0])
self.assertNotIn('First app', ui.review.call_args.args[0])
def test_select_all_includes_every_app_once(self):
_, ui, run = self.select(['all', '111'])
self.assertEqual([c.args[2]['vmid'] for c in run.call_args_list], [110, 111])
self.assertFalse(ui.review.call_args.kwargs['default'])
def test_cancel_does_nothing(self):
_, ui, run = self.select(UserCancelled())
ui.review.assert_not_called(); run.assert_not_called()
def test_selection_has_an_update_button_and_no_extra_action(self):
result, ui, run = self.select([])
self.assertEqual(ui.checklist.call_args.kwargs['ok_label'], 'Update')
self.assertNotIn('extra_button', ui.checklist.call_args.kwargs)
self.assertFalse(result); ui.review.assert_not_called(); run.assert_not_called()
def test_changed_app_blocks_batch_before_any_lifecycle(self):
_, ui, run = self.select(['all'], fresh=AVAILABLE[1:])
run.assert_not_called()
self.assertIn('changed since', ui.message.call_args.args[0])
def test_failure_or_per_app_cancel_stops_later_selections(self):
_, _, run = self.select(['all'], outcomes=[False])
self.assertEqual(run.call_count, 1)
def test_declining_review_returns_to_selection_instead_of_updating(self):
ui = Mock(); ui.checklist.side_effect = [['all'], []]; ui.review.return_value = False
with patch.object(management, 'manage_instance') as run:
self.assertFalse(updates.select_updates(ROOT, ui, AVAILABLE))
run.assert_not_called()
self.assertEqual(ui.checklist.call_args.args[2], ['110', '111'])
class StatusTests(unittest.TestCase):
def checker(self):
return updates.UpdateCheck(ROOT)
def test_labels_show_cached_count_or_manual_check(self):
checker = self.checker()
self.assertEqual(checker.label({'available': []}), 'Check for updates')
self.assertEqual(checker.label({'available': AVAILABLE}), 'Updates available (2)')
checker.process = Mock(stdout=None); checker.process.poll.return_value = None
self.assertEqual(checker.label({'available': AVAILABLE}), 'Checking for updates (0/0)')
def test_partial_check_keeps_the_entry_clean_without_a_header_warning(self):
checker = self.checker()
state = {'checked': True, 'available': [], 'unknown': 1, 'applications': 2}
self.assertEqual(checker.label(state), 'Check for updates')
self.assertEqual(checker.notice(state), '')
def test_completed_checks_show_only_the_update_count(self):
state = {'checked': True, 'available': AVAILABLE, 'unknown': 0, 'applications': 2}
self.assertEqual(self.checker().label(state), 'Updates available (2)')
def progress_checker(self):
reader, writer = os.pipe()
stream = os.fdopen(reader, 'rb', buffering=0)
self.addCleanup(stream.close); self.addCleanup(os.close, writer)
os.set_blocking(reader, False)
checker = self.checker(); checker.total = 24
checker.process = Mock(stdout=stream); checker.process.poll.return_value = None
return checker, writer
def test_numeric_progress_redraws_only_when_the_count_changes(self):
checker, writer = self.progress_checker()
self.assertEqual(checker.label({}), 'Checking for updates (0/24)')
self.assertFalse(checker.changed())
os.write(writer, b'{"completed":12,"total":24}\n')
self.assertTrue(checker.changed())
self.assertEqual(checker.label({}), 'Checking for updates (12/24)')
self.assertFalse(checker.changed())
os.write(writer, b'{"completed":12,"total":24}\n')
self.assertFalse(checker.changed())
def test_split_and_invalid_worker_output_never_appears_in_the_menu(self):
checker, writer = self.progress_checker()
os.write(writer, b'password=secret\n{"completed":-1,"total":24}\n{"completed":')
self.assertEqual(checker.label({}), 'Checking for updates (0/24)')
os.write(writer, b'7,"total":24}\n{"completed":25,"total":24}\n')
self.assertEqual(checker.label({}), 'Checking for updates (7/24)')
def test_worker_failure_does_not_reuse_an_old_successful_result(self):
checker = self.checker(); checker.process = Mock(stdout=None)
checker.process.poll.return_value = checker.process.wait.return_value = 1
old = {'checked': True, 'available': AVAILABLE, 'unknown': 0, 'applications': 2}
with patch.object(checker.backend, 'cached', return_value=old):
state = checker.status()
self.assertEqual(checker.label(state), 'Check for updates')
self.assertEqual(state['available'], [])
self.assertEqual(checker.notice(state), '')
def test_finished_worker_keeps_the_final_redraw_pending_until_status_is_consumed(self):
checker = self.checker(); checker.completed = checker.total = 2
checker.process = Mock(stdout=None)
checker.process.poll.return_value = checker.process.wait.return_value = 0
self.assertFalse(checker.running)
self.assertTrue(checker.active)
self.assertEqual(checker.label({}), 'Checking for updates (2/2)')
self.assertTrue(checker.changed())
state = {'checked': True, 'available': AVAILABLE, 'unknown': 0, 'applications': 2}
with patch.object(checker.backend, 'cached', return_value=state):
final = checker.status()
self.assertFalse(checker.active)
self.assertEqual(checker.label(final), 'Updates available (2)')
def test_check_process_is_isolated_and_displays_no_registry_errors(self):
checker = self.checker()
with patch.object(subprocess, 'Popen') as popen, patch.object(updates.os, 'set_blocking') as blocking, \
patch.object(checker.backend, 'monitor_available', return_value=True):
checker.start()
args = popen.call_args
self.assertTrue(args.kwargs['start_new_session'])
self.assertEqual(args.kwargs['stderr'], subprocess.DEVNULL)
self.assertEqual(args.args[0][1], str(ROOT / 'remote/oci_update_check.py'))
self.assertIn('--progress', args.args[0])
self.assertEqual(args.kwargs['stdout'], subprocess.PIPE)
blocking.assert_called_once_with(checker.process.stdout.fileno(), False)
def test_missing_monitor_never_starts_a_fallback_checker(self):
checker = self.checker()
with patch.object(checker.backend, 'monitor_available', return_value=False), \
patch.object(subprocess, 'Popen') as popen:
with self.assertRaisesRegex(ValueError, 'ProxMenux Monitor'):
checker.start()
popen.assert_not_called()
def test_cancelling_stops_the_worker_process_group(self):
checker = self.checker(); process = Mock(); checker.process = process
process.pid = 4321; process.poll.return_value = None
with patch.object(updates.os, 'killpg') as kill:
checker.close()
kill.assert_called_once_with(4321, signal.SIGTERM)
process.wait.assert_called_once_with(timeout=2)
def test_finished_worker_is_not_signalled_after_its_pid_could_be_reused(self):
checker = self.checker(); checker.process = Mock(); checker.process.poll.return_value = 0
with patch.object(updates.os, 'killpg') as kill:
checker.close()
kill.assert_not_called()
class DialogTests(unittest.TestCase):
def test_status_change_redraws_menu_and_reaps_dialog(self):
process = Mock(); process.poll.return_value = None
process.communicate.side_effect = [subprocess.TimeoutExpired('dialog', .1), (None, '')]
popen = Mock(); popen.__enter__ = Mock(return_value=process); popen.__exit__ = Mock(return_value=False)
with patch.object(subprocess, 'Popen', return_value=popen):
with self.assertRaises(MenuRefresh):
DialogUI().choose('OCI', ReadableTags([('updates', 'Checking for updates...')]),
'updates', size=(20, 75, 12), refresh=lambda: True)
process.terminate.assert_called_once()
self.assertEqual(process.communicate.call_count, 2)
def test_escape_from_live_menu_remains_cancel(self):
process = Mock(); process.poll.return_value = 255; process.returncode = 255
process.communicate.return_value = (None, '')
popen = Mock(); popen.__enter__ = Mock(return_value=process); popen.__exit__ = Mock(return_value=False)
with patch.object(subprocess, 'Popen', return_value=popen):
self.assertIsNone(DialogUI().choose('OCI', [('1', 'Checking')], refresh=lambda: False))
process.terminate.assert_not_called()
def test_checklist_starts_unselected_with_update_and_cancel_only(self):
ui = DialogUI()
with patch.object(ui, '_run', return_value=SimpleNamespace(returncode=0, stderr='111\n')) as run:
picked = ui.checklist('Updates', [('all', 'Select all'), ('111', 'App')], [],
ok_label='Update')
self.assertEqual(picked, ['111'])
widget = run.call_args.args[0]
self.assertNotIn('--extra-button', widget)
self.assertEqual(widget[widget.index('--ok-label') + 1], 'Update')
self.assertNotIn('on', widget)
class MainMenuTests(unittest.TestCase):
def test_status_changes_in_the_same_main_menu_entry(self):
class Checker:
running = False
completed = 0
@property
def active(self): return self.running
def __enter__(self): return self
def __exit__(self, *exc): pass
def start(self): self.running = True
def status(self): return {'available': AVAILABLE if getattr(self, 'done', False) else []}
def changed(self): return True
def label(self, state):
return (f'Checking for updates ({self.completed}/2)' if self.running else
'Updates available (2)' if state['available'] else 'Check for updates')
def notice(self, state): return ''
checker = Checker(); seen = []
class UI:
live_refresh = True
def choose(self, text, entries, default, **kw):
seen.append(dict(entries)['updates'])
if len(seen) == 1: return 'updates'
if len(seen) == 2:
checker.completed = 1
raise MenuRefresh()
if len(seen) == 3:
checker.running = False; checker.done = True
raise MenuRefresh()
return None
with patch.object(cli, 'interactive_ui', return_value=UI()), \
patch.object(updates, 'UpdateCheck', return_value=checker):
self.assertEqual(cli.interactive(cli.Catalog(ROOT)), 0)
self.assertEqual(seen, ['Check for updates', 'Checking for updates (0/2)',
'Checking for updates (1/2)', 'Updates available (2)'])
class TranslationTests(unittest.TestCase):
def test_update_menu_literals_are_translated_in_every_shipped_language(self):
source = ast.parse((ROOT / 'src/proxmenux_oci/updates.py').read_text())
texts = {node.args[0].value for node in ast.walk(source)
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name)
and node.func.id == 'translate' and node.args
and isinstance(node.args[0], ast.Constant)}
for path in sorted((ROOT.parent / 'lang').glob('*.json')):
dictionary = json.loads(path.read_text())
for text in texts:
with self.subTest(locale=path.stem, text=text):
self.assertTrue(dictionary.get(text))
self.assertEqual(set(re.findall(r'\{\w+\}', text)),
set(re.findall(r'\{\w+\}', dictionary[text])))
if __name__ == '__main__':
unittest.main()
+73
View File
@@ -0,0 +1,73 @@
"""Native image checks reuse immutable metadata only after a fresh digest lookup."""
import json
from pathlib import Path
import sys
import unittest
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'remote'))
import oci_installation_state as state
def digest(value): return 'sha256:' + state.sha(value)
class MetadataTests(unittest.TestCase):
def setUp(self):
self.platform = json.dumps({'schemaVersion': 2, 'config': {'digest': 'sha256:' + 'c' * 64},
'layers': [{'digest': 'sha256:' + 'd' * 64, 'size': 15}]}).encode()
self.index = json.dumps({'schemaVersion': 2, 'manifests': [
{'digest': digest(self.platform), 'platform': {'architecture': 'amd64', 'os': 'linux'}}]}).encode()
self.config = json.dumps({'architecture': 'amd64', 'os': 'linux', 'created': '2026-10-18T00:00:00Z',
'config': {'Labels': {'org.opencontainers.image.version': '2.0'}, 'Env': []}}).encode()
self.known = {'manifest_digest': digest(self.platform), 'architecture': 'amd64',
'version': '1.0', 'created': '2026-10-01T00:00:00Z'}
def test_unchanged_multiarch_image_requires_only_a_fresh_index_lookup(self):
with patch.object(state, 'command', return_value=self.index) as command:
candidate = state.resolve_candidate_metadata('ghcr.io/example/private:latest', 'amd64', self.known)
command.assert_called_once_with('skopeo', 'inspect', '--raw', 'docker://ghcr.io/example/private:latest')
self.assertEqual(candidate['manifest_digest'], self.known['manifest_digest'])
self.assertEqual(candidate['version'], '1.0')
self.assertEqual(candidate['registry_digest'], digest(self.index))
def test_unchanged_singlearch_image_requires_only_a_fresh_manifest_lookup(self):
with patch.object(state, 'command', return_value=self.platform) as command:
candidate = state.resolve_candidate_metadata('example/app:latest', 'amd64', self.known)
self.assertEqual(command.call_count, 1)
self.assertEqual(candidate['version'], '1.0')
def test_changed_digest_fetches_and_validates_new_metadata(self):
old = dict(self.known, manifest_digest='sha256:' + 'a' * 64)
with patch.object(state, 'command', side_effect=[self.index, self.platform, self.config]) as command:
candidate = state.resolve_candidate_metadata('example/app:latest', 'amd64', old)
self.assertEqual(command.call_count, 3)
self.assertEqual(candidate['version'], '2.0')
self.assertEqual(candidate['manifest_digest'], digest(self.platform))
def test_different_architecture_cannot_reuse_metadata(self):
known = dict(self.known, architecture='arm64')
with patch.object(state, 'command', side_effect=[self.index, self.platform, self.config]) as command:
candidate = state.resolve_candidate_metadata('example/app:latest', 'amd64', known)
self.assertEqual(command.call_count, 3)
self.assertEqual(candidate['version'], '2.0')
def test_install_and_update_still_fetch_complete_verified_contract(self):
with patch.object(state, 'command', side_effect=[self.index, self.platform, self.config]) as command:
candidate = state.resolve_candidate('example/app:latest', 'amd64')
self.assertEqual(command.call_count, 3)
self.assertEqual(candidate['layers'], ['sha256:' + 'd' * 64])
self.assertIn('Env', candidate['defaults'])
def test_changed_manifest_must_still_match_the_registry_digest(self):
with patch.object(state, 'command', side_effect=[self.index, b'{}']):
with self.assertRaises(ValueError):
state.resolve_candidate_metadata('example/app:latest', 'amd64', None)
def test_registry_failure_cannot_be_hidden_by_saved_metadata(self):
with patch.object(state, 'command', side_effect=RuntimeError('Registry unavailable')):
with self.assertRaises(RuntimeError):
state.resolve_candidate_metadata('example/app:latest', 'amd64', self.known)
if __name__ == '__main__': unittest.main()