Files
ProxMenux/oci/remote/oci_update_lab.py
T
MacRimiandClaude Opus 5 bcabcb618c feat(oci): run official container images as native LXC containers
Adds the OCI manager: an engine that turns a Docker Compose file into an
LXC definition, a catalog of 365 applications drawn from LinuxServer.io
and other container image sources, and a per-instance registry recording
what each container was built from. Reachable from the main menu.

Catalog text is translated like every other string in the project: the
taglines go through translate() and land in lang/*.json, so the entries
read in all eight languages instead of only English.

Translation cache builder:
- a failed translation leaves the key absent rather than writing English,
  which previously made the string count as translated forever
- a result identical to a 3+ word source is rejected, catching a provider
  that silently returns the text it was given
- strings that are nothing but glossary terms keep their source spelling
  instead of being discarded as failures
- no backoff between attempts when the provider is deterministic
- application names are protected so "HAOS One" survives translation
- argos joins the provider list, and the workflow reads the OCI sources

Audit & Report:
- findings that moved in the wrong direction between runs are reported
  alongside the ones that improved
- an accepted risk can carry a review date and is flagged when it falls due
- backup checks explain in plain language what they looked at and what to
  do next

Monitor:
- disks can be excluded from periodic reads, and an idle disk says so
  instead of showing a stale temperature
- per-disk identity survives a controller or enclosure change
- scheduled Borg backups resolve their SSH key from the repository entry
- PVE upgrades log the package list and the resulting dpkg changes

The web build no longer copies scripts/ into public/: the documentation
links to GitHub, so nothing read that folder.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 18:24:59 +02:00

248 lines
11 KiB
Python

#!/usr/bin/env python3
"""Recoverable nginx laboratory transaction. NOT a general OCI updater."""
from __future__ import annotations
import argparse
import copy
import fcntl
import hashlib
import json
import os
from pathlib import Path
import shutil
import subprocess
import tempfile
import time
import uuid
from oci_installation_state import image_from_archive, parse_config, private_directory, save_record, sha
from verify_oci_archive import verify_archive, VerificationError
def run(*args):
print('Paso:', args[0], args[1] if len(args) > 1 else '', flush=True)
p = subprocess.run(args, capture_output=True, timeout=600)
if p.returncode:
raise RuntimeError(f'{args[0]} fallo con codigo {p.returncode}; transaccion conservada')
return p.stdout
def atomic(path, value):
fd, name = tempfile.mkstemp(dir=path.parent, prefix='.journal-')
try:
with os.fdopen(fd, 'w') as out:
json.dump(value, out, indent=2)
out.flush()
os.fsync(out.fileno())
os.replace(name, path)
fd = os.open(path.parent, os.O_RDONLY)
try:
os.fsync(fd)
finally:
os.close(fd)
finally:
if os.path.exists(name):
os.unlink(name)
def checkpoint(path, state, phase):
state['phase'] = phase
atomic(path, state)
print('Estado:', phase, flush=True)
def filehash(path):
h = hashlib.sha256()
with open(path, 'rb') as f:
for block in iter(lambda: f.read(1024 * 1024), b''):
h.update(block)
return h.hexdigest()
def preflight(record, config):
cfg = parse_config(config)
if record['config_sha256'] != sha(config):
raise ValueError('Configuracion modificada desde el registro')
if cfg.get('hostname') != 'oci-update-lab' or record['reference'] != 'docker.io/library/nginx:alpine':
raise ValueError('Solo se admite el nginx de laboratorio')
allowed = {'arch', 'cores', 'description', 'entrypoint', 'env', 'hostname', 'memory', 'mp0', 'net0',
'onboot', 'ostype', 'rootfs', 'swap', 'tags', 'unprivileged', 'lxc.init.cwd',
'lxc.signal.halt', 'cmode', 'console', 'tty'}
if set(cfg) - allowed or '[' in config.decode():
raise ValueError('Configuracion avanzada/snapshots no soportada')
if cfg.get('unprivileged') != '1' or cfg.get('onboot', '0') != '0':
raise ValueError('El laboratorio requiere unprivileged=1 y onboot=0')
mp = cfg.get('mp0', '')
if not mp.startswith('local-lvm:') or 'mp=/usr/share/nginx/html' not in mp or 'backup=1' not in mp:
raise ValueError('Solo se admite mp0 gestionado y respaldado del laboratorio')
if not cfg.get('rootfs', '').startswith('local-lvm:'):
raise ValueError('Storage de laboratorio no soportado')
return cfg
def health(vmid):
for _ in range(30):
try:
data = run('pct', 'exec', str(vmid), '--', 'wget', '-qO-', 'http://127.0.0.1/')
if data == b'oci-persistence-proof':
return
except RuntimeError:
pass
time.sleep(1)
raise RuntimeError('Healthcheck de datos/HTTP fallido')
def stop(vmid):
if b'running' in run('pct', 'status', str(vmid)):
run('pct', 'shutdown', str(vmid), '--timeout', '60')
def create(vmid, archive, cfg, hostname, marker):
run('pct', 'create', str(vmid), archive, '--rootfs', 'local-lvm:2',
'--hostname', hostname, '--cores', cfg.get('cores', '1'),
'--memory', cfg.get('memory', '256'), '--swap', cfg.get('swap', '128'),
'--unprivileged', '1', '--onboot', '0', '--tags', cfg.get('tags', 'lab'),
'--net0', cfg['net0'], '--description', marker)
def transaction(args, journal):
if journal.exists():
raise ValueError('Ya existe una transaccion; consultar status o recover')
record = json.loads((args.state_dir / f'{args.vmid}.json').read_text())
if record.get('vmid') != args.vmid or record.get('schema_version') != 1:
raise ValueError('Registro incompatible')
before = run('pct', 'config', str(args.vmid))
cfg = preflight(record, before)
# Check HA separately; hostname/tags alone must never authorize mutation.
resources = json.loads(run('pvesh', 'get', '/cluster/ha/resources', '--output-format', 'json'))
if any(r.get('sid') == f'ct:{args.vmid}' for r in resources):
raise ValueError('HA no soportado')
if shutil.disk_usage(journal.parent).free < 8 * 1024**3:
raise ValueError('Se requieren 8 GiB libres para esta prueba y su backup')
archive = str(Path(args.archive).resolve())
verify_archive(Path(archive))
candidate = image_from_archive(archive)
if candidate['architecture'] != record['image']['architecture']:
raise ValueError('Arquitectura incompatible')
if candidate['manifest_digest'] == record['image']['manifest_digest']:
raise ValueError('La imagen ya coincide; no se requiere actualizar')
# This lab contract has no user runtime overrides or custom entrypoint.
if candidate['defaults'].get('Entrypoint') != record['image']['defaults'].get('Entrypoint') or candidate['defaults'].get('Cmd') != record['image']['defaults'].get('Cmd'):
raise ValueError('Cambio de comando fuera del alcance del laboratorio')
state = {'id': uuid.uuid4().hex, 'vmid': args.vmid, 'record': record, 'cfg': cfg,
'archive': archive, 'candidate': candidate, 'was_running': b'running' in run('pct', 'status', str(args.vmid))}
checkpoint(journal, state, 'prepared')
stop(args.vmid)
checkpoint(journal, state, 'backing-up')
backup_dir = journal.parent / state['id']
private_directory(backup_dir)
run('vzdump', str(args.vmid), '--mode', 'stop', '--compress', 'zstd', '--dumpdir', str(backup_dir), '--tmpdir', '/var/tmp')
backups = list(backup_dir.glob('vzdump-lxc-*.tar.zst'))
if len(backups) != 1:
raise ValueError('Backup no identificado')
run('zstd', '-t', str(backups[0]))
state.update(backup=str(backups[0]), backup_sha256=filehash(backups[0]))
checkpoint(journal, state, 'backup-ready')
stage = int(run('pvesh', 'get', '/cluster/nextid').strip())
state['stage'] = stage
checkpoint(journal, state, 'creating-stage')
# Staging never starts, so it can retain the original MAC without collisions.
create(stage, archive, cfg, 'oci-update-stage', state['id'])
checkpoint(journal, state, 'parking-data')
run('pct', 'move-volume', str(args.vmid), 'mp0', '--target-vmid', str(stage), '--target-volume', 'mp0')
checkpoint(journal, state, 'data-parked')
if args.interrupt_after == 'data-parked':
raise RuntimeError('Interrupcion de laboratorio solicitada; ejecutar recover')
current = parse_config(run('pct', 'config', str(args.vmid)))
if current != {k: v for k, v in cfg.items() if k != 'mp0'}:
raise ValueError('Cambio concurrente detectado; no se destruye el CT')
checkpoint(journal, state, 'replacing-root')
run('pct', 'destroy', str(args.vmid))
create(args.vmid, archive, cfg, cfg['hostname'], state['id'])
checkpoint(journal, state, 'root-replaced')
if args.interrupt_after == 'root-replaced':
raise RuntimeError('Interrupcion de laboratorio solicitada; ejecutar recover')
checkpoint(journal, state, 'returning-data')
run('pct', 'move-volume', str(stage), 'mp0', '--target-vmid', str(args.vmid), '--target-volume', 'mp0')
checkpoint(journal, state, 'checking-service')
run('pct', 'start', str(args.vmid))
health(args.vmid)
if not state['was_running']:
stop(args.vmid)
updated = copy.deepcopy(record)
config = run('pct', 'config', str(args.vmid))
updated.update(image=candidate, archive_path=archive, config=config.decode(), config_sha256=sha(config),
installation_id=str(uuid.uuid4()), previous_installation_id=record['installation_id'],
resolved_registry_digest=candidate['manifest_digest'], last_update_transaction=state['id'])
save_record(args.state_dir, updated)
checkpoint(journal, state, 'committed')
def recover(args, journal):
state = json.loads(journal.read_text())
if state['phase'] in ('committed', 'rolled-back'):
raise ValueError('Transaccion terminada; no se restaura automaticamente')
backup = state.get('backup')
if not backup or filehash(backup) != state['backup_sha256']:
raise ValueError('No hay backup verificado; recuperar manualmente sin destruir datos')
vmid = state['vmid']
path = Path(f'/etc/pve/lxc/{vmid}.conf')
if path.exists():
config = run('pct', 'config', str(vmid))
cfg = parse_config(config)
if state['id'] not in config.decode() and cfg.get('rootfs') != state['cfg']['rootfs']:
raise ValueError('VMID posiblemente reutilizado; recuperacion bloqueada')
stop(vmid)
if state.get('stage') and Path(f"/etc/pve/lxc/{state['stage']}.conf").exists():
config = run('pct', 'config', str(state['stage']))
if state['id'] not in config.decode():
raise ValueError('Staging ajeno; recuperacion bloqueada')
stop(state['stage'])
checkpoint(journal, state, 'restoring-backup')
run('pct', 'restore', str(vmid), backup, '--force', '1', '--storage', 'local-lvm', '--description', state['id'])
run('pct', 'start', str(vmid))
health(vmid)
if not state['was_running']:
stop(vmid)
restored = copy.deepcopy(state['record'])
config = run('pct', 'config', str(vmid))
restored.update(config=config.decode(), config_sha256=sha(config), recovered_transaction=state['id'])
save_record(args.state_dir, restored)
checkpoint(journal, state, 'rolled-back')
print('Staging y backup conservados, sin limpieza automatica.')
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('action', choices=['apply', 'status', 'recover'])
parser.add_argument('vmid', type=int)
parser.add_argument('--state-dir', type=Path, required=True)
parser.add_argument('--transaction-dir', type=Path, required=True)
parser.add_argument('--archive')
parser.add_argument('--interrupt-after', choices=['data-parked', 'root-replaced'])
args = parser.parse_args()
if os.geteuid() != 0:
parser.error('Se requiere root')
private_directory(args.transaction_dir)
journal = args.transaction_dir / f'{args.vmid}.json'
with Path(f'/run/lock/proxmenux-oci-lab-{args.vmid}.lock').open('w') as lock:
try:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
if args.action == 'status':
state = json.loads(journal.read_text())
print(json.dumps({k: state.get(k) for k in ('id', 'vmid', 'stage', 'phase')}, indent=2))
elif args.action == 'recover':
recover(args, journal)
else:
if not args.archive:
parser.error('apply requiere --archive')
transaction(args, journal)
except (OSError, ValueError, RuntimeError, KeyError, VerificationError, subprocess.TimeoutExpired) as error:
print(f'Proceso detenido ({type(error).__name__}). Diario privado: {journal}. Consultar status/recover.')
return 1
return 0
if __name__ == '__main__':
raise SystemExit(main())