#!/usr/bin/env python3 """Private installation evidence and read-only update diagnostics. No updater.""" from __future__ import annotations import argparse import contextlib import datetime import fcntl import hashlib import json import os from pathlib import Path import re import subprocess import sys import tarfile import tempfile import uuid from oci_ui import translate, msg_error, msg_ok ROOT = Path('/var/lib/proxmenux/oci-installations') FIELDS = ('Entrypoint', 'Cmd', 'Env', 'User', 'WorkingDir', 'StopSignal', 'Volumes', 'ExposedPorts', 'Healthcheck') def command(*args): result = subprocess.run(args, capture_output=True, timeout=120) if result.returncode: # Tool errors may contain credentials or environment values. raise RuntimeError(f"{args[0]} {translate('failed with exit code')} {result.returncode}") return result.stdout def sha(data): return hashlib.sha256(data).hexdigest() def image_from_archive(path): with tarfile.open(path) as archive: members = {m.name.removeprefix('./'): m for m in archive.getmembers() if m.isfile()} def read(name, digest=None): member = members[name] if member.size > 16 * 1024 * 1024: raise ValueError(translate('OCI metadata too large')) data = archive.extractfile(member).read() if digest and 'sha256:' + sha(data) != digest: raise ValueError(translate('OCI metadata integrity mismatch')) return json.loads(data) def blob(digest): if not re.fullmatch(r'sha256:[0-9a-f]{64}', digest): raise ValueError(translate('Invalid OCI digest')) return read('blobs/sha256/' + digest[7:], digest) descriptors = read('index.json')['manifests'] if len(descriptors) != 1: raise ValueError(translate('A single-platform OCI archive is required')) digest = descriptors[0]['digest'] manifest = blob(digest) config = blob(manifest['config']['digest']) return {'manifest_digest': digest, 'config_digest': manifest['config']['digest'], 'architecture': config['architecture'], 'os': config.get('os'), 'defaults': {k: config.get('config', {}).get(k) for k in FIELDS}} def parse_config(data): values = {} for line in data.decode().splitlines(): if line and not line.startswith('#') and ': ' in line: key, value = line.split(': ', 1) values[key] = value return values def private_directory(path): path.mkdir(parents=True, exist_ok=True, mode=0o700) if path.is_symlink() or path.stat().st_uid != os.geteuid(): raise ValueError(translate('Unsafe registry directory')) path.chmod(0o700) def save_record(root, record): private_directory(root) with (root / '.lock').open('a') as lock: os.chmod(root / '.lock', 0o600) fcntl.flock(lock, fcntl.LOCK_EX) path = root / f"{record['vmid']}.json" if path.exists() or path.is_symlink(): if path.is_symlink(): raise ValueError(translate('Unsafe record')) history = root / 'history' private_directory(history) # Keep the current record present until its replacement is durable. os.link(path, history / f"{record['vmid']}-{uuid.uuid4().hex}.json") fd, temporary = tempfile.mkstemp(dir=root, prefix='.record-') try: with os.fdopen(fd, 'w') as out: json.dump(record, out, indent=2, ensure_ascii=True) out.write('\n') out.flush() os.fsync(out.fileno()) os.replace(temporary, path) directory_fd = os.open(root, os.O_RDONLY) try: os.fsync(directory_fd) finally: os.close(directory_fd) finally: if os.path.exists(temporary): os.unlink(temporary) def record_install(args): template = json.loads(Path(args.template).read_text()) deployment = json.loads(Path(args.deployment).read_text()) config = command('pct', 'config', str(args.vmid)) image = image_from_archive(args.archive) record = {'schema_version': 1, 'installation_id': str(uuid.uuid4()), 'vmid': args.vmid, 'recorded_at': datetime.datetime.now(datetime.timezone.utc).isoformat(), 'provenance': 'installer-completed', 'image': image, 'reference': template['container_contract']['image']['reference'], 'resolved_registry_digest': args.digest, 'archive_path': args.archive, 'template': template, 'deployment': deployment, 'config_sha256': sha(config), 'config': config.decode(), 'start_after_create_requested': bool(deployment.get('start_after_create')), 'automatic_update_enabled': False} save_record(args.state_dir, record) def resolve_candidate(reference, architecture): repo = reference.split('@', 1)[0] if ':' in repo.rsplit('/', 1)[-1]: repo = repo.rsplit(':', 1)[0] transport = reference if '@' in reference: transport = repo + '@' + reference.split('@', 1)[1] raw = command('skopeo', 'inspect', '--raw', 'docker://' + transport) manifest = json.loads(raw) if 'manifests' in manifest: 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'] raw = command('skopeo', 'inspect', '--raw', 'docker://' + repo + '@' + digest) if 'sha256:' + sha(raw) != digest: raise ValueError(translate('The manifest does not match its digest')) digest = 'sha256:' + sha(raw) config = json.loads(command('skopeo', 'inspect', '--config', 'docker://' + repo + '@' + digest)) if config.get('architecture') != architecture or config.get('os') != 'linux': raise ValueError(translate('Incompatible image platform')) labels = config.get('config', {}).get('Labels') or {} return {'manifest_digest': digest, 'defaults': {k: config.get('config', {}).get(k) for k in FIELDS}, 'version': labels.get('org.opencontainers.image.version') or labels.get('build_version')} def compare(record, current, candidate=None): blockers = [] cfg = parse_config(current) if sha(current) != record['config_sha256']: blockers.append('configuration-drift-or-vmid-reused') mounts = [] for key, value in cfg.items(): if not re.fullmatch(r'mp\d+', key): continue parts = value.split(',') source = parts[0].removeprefix('volume=') options = dict(p.split('=', 1) for p in parts[1:] if '=' in p) managed = not source.startswith('/') and ':' in source mounts.append(options.get('mp')) if not managed or options.get('backup') != '1': blockers.append('persistent-mount-needs-backup:' + key) declared = set(record['image']['defaults'].get('Volumes') or {}) declared.update(v['container_path'] for v in record['template'].get('container_contract', {}).get('volumes', []) if v.get('container_path')) for path in sorted(declared): if path not in mounts: blockers.append('image-volume-not-externalized:' + path) deployment = record['deployment'] if deployment.get('stack_managed'): blockers.append('stack-member-requires-coordination') if deployment.get('deployment_kind') not in (None, 'single-lxc'): blockers.append('stack-or-special-deployment-requires-coordination') if cfg.get('hookscript'): blockers.append('hookscript-requires-coordination') if record['template'].get('installer_profile', {}).get('post_start_configurations') or deployment.get('post_start_configurations'): blockers.append('rootfs-adaptations-require-replay') report = {'vmid': record['vmid'], 'registered': True, 'update_available': None, 'automatic_update_enabled': False, 'blockers': blockers, 'requires': ['consistent-backup', 'review-rootfs-only-data', 'transactional-updater-not-implemented']} if candidate: report['update_available'] = candidate['manifest_digest'] != record['image']['manifest_digest'] report['changed_image_fields'] = [key for key in FIELDS if record['image']['defaults'].get(key) != candidate['defaults'].get(key)] old_env = dict(v.split('=', 1) for v in record['image']['defaults'].get('Env') or [] if '=' in v) new_env = dict(v.split('=', 1) for v in candidate['defaults'].get('Env') or [] if '=' in v) report['changed_environment_names'] = sorted(k for k in old_env.keys() | new_env.keys() if old_env.get(k) != new_env.get(k)) report['candidate_digest'] = candidate['manifest_digest'] return report def main(argv=None): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--state-dir', type=Path, default=ROOT) sub = parser.add_subparsers(dest='action', required=True) sub.add_parser('inventory') diagnose = sub.add_parser('diagnose') diagnose.add_argument('vmid', type=int) diagnose.add_argument('--check-registry', action='store_true') record = sub.add_parser('record', help='Internal installer operation; not legacy adoption') record.add_argument('vmid', type=int) for flag in ('template', 'deployment', 'archive', 'digest'): record.add_argument('--' + flag, required=True) args = parser.parse_args(argv) if os.geteuid() != 0: parser.error(translate('Run as root on the Proxmox node; the registry contains private data')) try: if args.action == 'record': record_install(args) msg_ok(translate('Private installation record saved')) elif args.action == 'inventory': rows = command('pct', 'list').decode().splitlines()[1:] print(json.dumps([{'vmid': int(row.split()[0]), 'registered': (args.state_dir / (row.split()[0] + '.json')).is_file()} for row in rows if row.strip()], indent=2)) else: path = args.state_dir / f'{args.vmid}.json' if not path.exists(): print(json.dumps({'vmid': args.vmid, 'registered': False, 'automatic_update_enabled': False, 'blockers': ['unregistered-installation-no-automatic-adoption']})) return 0 record = json.loads(path.read_text()) if record.get('schema_version') != 1 or record.get('vmid') != args.vmid: raise ValueError(translate('Incompatible record')) current = command('pct', 'config', str(args.vmid)) candidate = resolve_candidate(record['reference'], record['image']['architecture']) if args.check_registry else None print(json.dumps(compare(record, current, candidate), indent=2)) return 0 except (OSError, ValueError, KeyError, RuntimeError, subprocess.TimeoutExpired, tarfile.TarError): with contextlib.redirect_stdout(sys.stderr): msg_error(translate('The record or diagnosis could not be completed; no update was run.')) return 1 if __name__ == '__main__': raise SystemExit(main())