mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-09-29 18:16:43 +00:00
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>
245 lines
11 KiB
Python
245 lines
11 KiB
Python
#!/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())
|