mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-10-06 21:46:44 +00:00
Generalize OCI device setup and remove unused catalog hashes
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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'🌐 {link(str(endpoint.get("label") or "Web UI"), url)}: {link(url, url)}'
|
||||
if item:
|
||||
access.append(item)
|
||||
badges = (
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user