Generalize OCI device setup and remove unused catalog hashes

This commit is contained in:
MacRimi
2026-09-26 01:22:37 +02:00
parent 88acceb8ef
commit f7266e7b44
26 changed files with 689 additions and 487 deletions
+25 -3
View File
@@ -20,6 +20,28 @@ die() {
exit 1
}
container_is_running() {
local status attempt
for (( attempt=1; attempt<=3; attempt++ )); do
if status=$(pct status "$VMID" 2>/dev/null); then
case "$status" in
'status: running') return 0 ;;
'status: stopped') return 1 ;;
esac
fi
# A failed pct probe must not be mistaken for a stopped container.
oci_log "pct status could not confirm CT $VMID (attempt $attempt); checking lxc-info"
if status=$(lxc-info -n "$VMID" -sH 2>/dev/null); then
case "$status" in
RUNNING) return 0 ;;
STOPPED) return 1 ;;
esac
fi
(( attempt < 3 )) && sleep 1
done
return 1
}
mount_ct_rootfs() {
oci_quiet pct mount "$VMID" || die "$(translate "Could not mount the container filesystem:") CT $VMID"
}
@@ -107,7 +129,7 @@ check_native_device_permissions() {
msg_info "$(translate "Checking the device permissions for the application user...")"
oci_log "Checking device access as abc (this is not a codec test)."
for (( attempt=0; attempt<60; attempt++ )); do
pct status "$VMID" | grep -q 'status: running' \
container_is_running \
|| die "$(translate "The container stopped before the GPU permissions were verified:") CT $VMID"
if pct exec "$VMID" -- s6-setuidgid abc sh -c \
'for path do test -r "$path" && test -w "$path" || exit 1; done' \
@@ -1792,7 +1814,7 @@ if [[ $START_AFTER == 1 && $HAS_STARTUP_HEALTHCHECK == 1 ]]; then
oci_log "Waiting for the service: $HC_URL"
msg_info "$(translate "Waiting for the application to respond...")"
while (( HC_ELAPSED < HC_TIMEOUT )); do
if [[ $(pct status "$VMID" 2>/dev/null || true) != "status: running" ]]; then
if ! container_is_running; then
oci_log "The container stopped during its first start. Last console messages:"
[[ -s $RUNTIME_CONSOLE_LOG ]] && tr -d '\r' <"$RUNTIME_CONSOLE_LOG" | tail -n 100 >>"${OCI_LOG:-/dev/stderr}"
die "$(translate "The container stopped before the application responded:") CT $VMID"
@@ -1838,7 +1860,7 @@ if [[ $START_AFTER == 1 && $HAS_RUNNING_CHECK == 1 ]]; then
msg_info "$(translate "Checking that the container keeps running...")"
RC_START=$(date +%s)
while (( $(date +%s) - RC_START < RC_STABILITY )); do
if [[ $(pct status "$VMID" 2>/dev/null || true) != "status: running" ]]; then
if ! container_is_running; then
oci_log "The container stopped after starting. Last console messages:"
[[ -s $RUNTIME_CONSOLE_LOG ]] && tr -d '\r' <"$RUNTIME_CONSOLE_LOG" | tail -n 100 >>"${OCI_LOG:-/dev/stderr}"
die "$(translate "The container stopped after starting:") CT $VMID"
+2 -6
View File
@@ -60,11 +60,7 @@ def render(template, digest, instance_id, ip=''):
source = template.get('source') or {}
image_url = (source.get('image_repository_url') or image_page(reference)
or ui.get('repository') or source.get('repository'))
resources = [('Image', image_url), ('App', ui.get('website')),
('App docs', ui.get('documentation'))]
repository = ui.get('repository') or source.get('repository')
if safe_url(repository) and repository != image_url:
resources.append(('Repository', repository))
resources = [('Image', image_url), ('App', ui.get('website'))]
resources = [link(label, url) for label, url in resources]
resources = [item for item in resources if item]
try:
@@ -88,7 +84,7 @@ def render(template, digest, instance_id, ip=''):
if not isinstance(path, str) or not path.startswith('/'):
path = '/'
url = f'{scheme}://{address}:{port}{path}'
item = f'{link(str(endpoint.get("label") or "Web UI"), url)}: {link(url, url)}'
item = f'&#127760; {link(str(endpoint.get("label") or "Web UI"), url)}: {link(url, url)}'
if item:
access.append(item)
badges = (
+4
View File
@@ -97,6 +97,10 @@ def propose(record, config):
before = parse_config(record['observed']['config'].encode())
current = parse_config(config)
changed = sorted(key for key in before.keys() | current.keys() if before.get(key) != current.get(key))
# Notes do not describe a mount, device or runtime setting. The OCI marker
# was checked above, so a presentation-only change needs no adoption.
if 'description' in changed:
changed.remove('description')
new_keys = [key for key in changed if key not in before and re.fullmatch(r'(mp|dev)[0-9]+', key)]
unsupported = [key for key in changed if key not in new_keys and key not in transaction.ADOPTABLE]
if unsupported:
+77 -11
View File
@@ -24,6 +24,7 @@ import stat
import sys
import time
import uuid
from urllib.parse import unquote
import oci_instances as instances
from oci_installation_state import image_from_archive, parse_config, private_directory, sha
@@ -204,6 +205,23 @@ def recovery_hint(after_recovery=False):
msg_warn(translate('The operation stopped halfway. Choose "Recover" for this container in the OCI management menu to restore the previous installation.'))
def recover_untouched(root, journal):
"""Close an operation that failed before the container was changed:
start the container again and restore its record, so nothing is left to
recover by hand. Returns whether it did."""
try:
state = json.loads(Path(journal).read_text())
if state.get('coordinated') or state.get('phase') in TERMINAL:
return False
if run('pct', 'config', str(state['vmid'])).decode() != state['before_config']:
return False
recover(root, Path(journal))
return True
except (OSError, ValueError, KeyError, RuntimeError, subprocess.SubprocessError) as error:
log(f'automatic recovery skipped: {error}')
return False
def fit(text):
"""A step line that is rewritten in place must not wrap."""
width = max(shutil.get_terminal_size((80, 24)).columns - 8, 30)
@@ -211,7 +229,11 @@ def fit(text):
def run(*args):
log('$ ' + shlex.join(str(arg) for arg in args))
private_description = args[:2] == ('pct', 'set') and '--description' in args
shown = [str(arg) for arg in args]
if private_description:
shown[shown.index('--description') + 1] = '[notes redacted]'
log('$ ' + shlex.join(shown))
# OCI extraction enters an unprivileged user namespace; PVE's newly created
# traversal directories must not inherit a caller's restrictive umask.
pve_creation = (args[:2] in (('pct', 'create'), ('pct', 'restore'))
@@ -224,12 +246,37 @@ def run(*args):
raise
if result.returncode:
log(f' exit {result.returncode}')
log_output(None if tuple(args[:2]) in DATA_COMMANDS else result.stdout, result.stderr)
log_output(None if private_description or tuple(args[:2]) in DATA_COMMANDS else result.stdout,
None if private_description else result.stderr)
if result.returncode:
raise RuntimeError(f"{args[0]} {translate('failed with exit code')} {result.returncode}")
return result.stdout
def verified_backup(vmid, directory, compression, unidentified, show=False):
"""A stop-mode vzdump of vmid in directory that passes its integrity
check. An archive that fails the check is written once more before the
operation gives up."""
suffix = 'zst' if compression == 'zstd' else 'gz'
for attempt in (1, 2):
run('vzdump', str(vmid), '--mode', 'stop', '--compress', compression,
'--dumpdir', str(directory), '--tmpdir', '/var/tmp')
backups = list(directory.glob(f'vzdump-lxc-*.tar.{suffix}'))
if len(backups) != 1:
raise ValueError(unidentified)
try:
run('zstd' if compression == 'zstd' else 'gzip', '-t', str(backups[0]))
return backups[0]
except RuntimeError:
if attempt == 2:
raise
log('backup attempt 1/2 failed its integrity check')
for damaged in directory.glob('vzdump-lxc-*'):
damaged.unlink()
if show:
msg_warn(translate('The backup did not pass its integrity check; creating it again...'))
def filehash(path):
value = hashlib.sha256()
with Path(path).open('rb') as source:
@@ -322,6 +369,10 @@ def external_changes(record, config, adopt=True):
return {}
before, now = parse_config(record['observed']['config'].encode()), parse_config(config)
changed = sorted(key for key in before.keys() | now.keys() if before.get(key) != now.get(key))
if ('description' in changed
and instances.identity(record['observed']['config'].encode()) == record['installation_id']
and instances.identity(config) == record['installation_id']):
changed.remove('description')
cores_key = 'cpulimit' if 'cpulimit' in before and 'cores' not in before else 'cores'
values = {}
for key in changed if adopt else ():
@@ -682,6 +733,22 @@ def restore_firewall(vmid, state):
Path(f'/etc/pve/firewall/{vmid}.fw').write_text(saved)
def original_description(state):
"""Return the user's original Notes, including text added outside ProxMenux."""
description = state.get('original_description')
if description is None:
# Journals created before this safeguard only have pct's escaped config.
description = unquote(parse_config(state['before_config'].encode()).get('description', ''))
if not isinstance(description, str) or instances.identity(
json.dumps({'description': description}).encode()) != state['record']['installation_id']:
raise ValueError(translate('The container identity changed; nothing was adopted'))
return description
def restore_description(vmid, state):
run('pct', 'set', str(vmid), '--description', original_description(state))
def release_stage(state):
"""After a commit the holder CT only keeps its own rootfs: every parked
volume went back to the application. Anything still attached keeps it."""
@@ -791,6 +858,7 @@ def apply(root, vmid, archive, operation, proposal=None, registry_digest=None, i
candidate = candidate_contract(record, operation, proposal)
before = run('pct', 'config', str(vmid))
cfg, actual, mac = preflight(record, candidate, before, coordinated)
description = original_description({'record': record, 'before_config': before.decode()})
original_sources, desired_sources = freeze_host_sources(record, candidate, acknowledge_external_data)
original_gpu = gpu_devices.planned(record['deployment'])
desired_gpu = gpu_devices.planned(candidate['deployment'])
@@ -823,6 +891,7 @@ def apply(root, vmid, archive, operation, proposal=None, registry_digest=None, i
for p, m in actual.items() if p in required and not m['volume'].startswith('/')])
state = {'schema_version': 1, 'id': directory.name, 'vmid': vmid, 'operation': operation,
'record': record, 'candidate_contract': candidate, 'before_config': before.decode(),
'original_description': description,
'archive': str(archive), 'archive_sha256': filehash(archive),
'registry_digest': registry_digest or image['manifest_digest'],
'runtime_template': candidate['template'], 'runtime_deployment': runtime_deployment,
@@ -867,14 +936,9 @@ def apply(root, vmid, archive, operation, proposal=None, registry_digest=None, i
else:
backup_dir = directory / 'backup'
private_directory(backup_dir)
run('vzdump', str(vmid), '--mode', 'stop', '--compress', backup_compression,
'--dumpdir', str(backup_dir), '--tmpdir', '/var/tmp')
suffix = 'zst' if backup_compression == 'zstd' else 'gz'
backups = list(backup_dir.glob(f'vzdump-lxc-*.tar.{suffix}'))
if len(backups) != 1:
raise ValueError(translate('The backup could not be identified; the image is not replaced'))
run('zstd' if backup_compression == 'zstd' else 'gzip', '-t', str(backups[0]))
state.update(backup=str(backups[0]), backup_sha256=filehash(backups[0]))
backup = verified_backup(vmid, backup_dir, backup_compression,
translate('The backup could not be identified; the image is not replaced'), show)
state.update(backup=str(backup), backup_sha256=filehash(backup))
checkpoint(journal, state, 'backup-ready')
if show:
msg_ok(translate('Backup created'))
@@ -920,6 +984,7 @@ def apply(root, vmid, archive, operation, proposal=None, registry_digest=None, i
if show:
progress = translate('Installing the new image:') if update else translate('Recreating the container:')
install_candidate(root, journal, state, progress)
restore_description(vmid, state)
restore_firewall(vmid, state)
if coordinated:
for key, value in state['preserved_stack_config'].items():
@@ -1239,7 +1304,8 @@ def main():
except (OSError, ValueError, KeyError, RuntimeError, subprocess.SubprocessError) as error:
with contextlib.redirect_stdout(output):
report_error(error, f'oci-{args.action}-{args.vmid}')
if pending_journal():
journal = pending_journal()
if journal and (args.action == 'recover' or not recover_untouched(args.root, journal)):
recovery_hint(after_recovery=args.action == 'recover')
return 1
+18
View File
@@ -223,6 +223,24 @@ def identity(config):
return match.group(1) if match else None
def same_config_except_notes(record, config):
"""Accept a presentation-only note edit, never a changed OCI identity or LXC setting."""
previous = record['observed']['config'].encode()
expected = record['installation_id']
if identity(previous) != expected or identity(config) != expected:
return False
def without_notes(value):
lines = value.splitlines(keepends=True)
notes = [line for line in lines if line.startswith(b'description: ')]
if len(notes) != 1:
return None
return b''.join(line for line in lines if not line.startswith(b'description: '))
original = without_notes(previous)
return original is not None and original == without_notes(config)
def save_assembly(root, primary_id, template, deployment, members):
path = location(root, primary_id).parent / 'stack-assembly.json'
if path.exists() or path.is_symlink():
+1 -2
View File
@@ -76,8 +76,7 @@ def refresh(root, vmid, apply=False):
if not nv.enabled(record['deployment']):
raise ValueError(translate('The instance does not use NVIDIA'))
config = instances.command('pct', 'config', str(vmid))
if (instances.identity(config) != record['installation_id']
or instances.sha(config) != record['observed']['config_sha256']):
if not instances.same_config_except_notes(record, config):
raise ValueError(translate('The container identity or configuration changed'))
previous = record['observed']['gpu_devices'][nv.KEY]
plan = nv.refresh_plan(config, previous)
+4 -8
View File
@@ -191,7 +191,7 @@ class NativeAdapter:
config = instances.command('pct', 'config', str(vmid))
if instances.identity(config) != record['installation_id']:
raise ValueError(translate('The identity of a member was replaced'))
if (not self.journal.exists() or not self.state().get('replacement_intent')) and sha(config) != record['observed']['config_sha256']:
if (not self.journal.exists() or not self.state().get('replacement_intent')) and not instances.same_config_except_notes(record, config):
raise ValueError(translate('A member configuration changed during the preparation'))
else:
if Path('/etc/pve/lxc/%s.conf' % vmid).exists():
@@ -321,13 +321,9 @@ class NativeAdapter:
self.validate(self.plan)
directory = self.journal.parent / ('backup-%s' % vmid)
private_directory(directory)
member_tx.run('vzdump', str(vmid), '--mode', 'stop', '--compress', 'zstd',
'--dumpdir', str(directory), '--tmpdir', '/var/tmp')
backups = list(directory.glob('vzdump-lxc-*.tar.zst'))
if len(backups) != 1:
raise ValueError(translate('The backup of a member could not be identified'))
member_tx.run('zstd', '-t', str(backups[0]))
return {'archive': str(backups[0]), 'sha256': member_tx.filehash(backups[0])}
archive = member_tx.verified_backup(vmid, directory, 'zstd',
translate('The backup of a member could not be identified'))
return {'archive': str(archive), 'sha256': member_tx.filehash(archive)}
def verify_backups(self, backups):
for backup in backups.values():
+2 -1
View File
@@ -192,7 +192,8 @@ def main():
return 1
except (OSError, ValueError, KeyError, RuntimeError, subprocess.SubprocessError) as error:
transaction.report_error(error, f'update-{args.vmid}')
if transaction.pending_journal():
journal = transaction.pending_journal()
if journal and not transaction.recover_untouched(instances.ROOT, journal):
transaction.recovery_hint()
return 1