mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-10-07 05:56:40 +00:00
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>
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Explicit stopped-CT NVIDIA refresh. Never changes the desired deployment."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import copy
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
import oci_instances as instances
|
||||
import oci_nvidia_runtime as nv
|
||||
from oci_ui import translate, msg_info, msg_ok, msg_error
|
||||
|
||||
|
||||
def destination(root, name):
|
||||
if not name.startswith('/') or '..' in Path(name).parts:
|
||||
raise ValueError(translate('Invalid NVIDIA destination'))
|
||||
parent = (root / name.lstrip('/')).parent.resolve()
|
||||
if parent != root and root not in parent.parents:
|
||||
raise ValueError(translate('The NVIDIA destination escapes the rootfs'))
|
||||
return parent / Path(name).name
|
||||
|
||||
|
||||
def prepare(root, plan):
|
||||
root = root.resolve()
|
||||
# Validate every destination before making any rootfs change.
|
||||
files = {name: destination(root, name) for name in plan['inventory']['files']}
|
||||
links = {name: destination(root, name) for name in plan['links']}
|
||||
for path in list(files.values()) + list(links.values()):
|
||||
if path.exists() and not path.is_file() and not path.is_symlink():
|
||||
raise ValueError(translate('The NVIDIA destination cannot be replaced'))
|
||||
for path in files.values():
|
||||
create_parent(path.parent, root)
|
||||
if path.is_symlink():
|
||||
path.unlink()
|
||||
if not path.exists():
|
||||
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o644)
|
||||
os.close(fd)
|
||||
owner = path.parent.stat()
|
||||
os.chown(path, owner.st_uid, owner.st_gid)
|
||||
for name, path in links.items():
|
||||
create_parent(path.parent, root)
|
||||
if path.exists() or path.is_symlink():
|
||||
path.unlink()
|
||||
path.symlink_to(plan['links'][name])
|
||||
# Native LXC cannot mount onto an alias in a usr-merged image.
|
||||
lines = []
|
||||
for line in plan['config'].decode().splitlines():
|
||||
if line.startswith('lxc.mount.entry: '):
|
||||
fields = line.split(': ', 1)[1].split()
|
||||
fields[1] = str(files['/' + fields[1]].relative_to(root))
|
||||
line = 'lxc.mount.entry: ' + ' '.join(fields)
|
||||
lines.append(line)
|
||||
return ('\n'.join(lines) + '\n').encode()
|
||||
|
||||
|
||||
def create_parent(path, root):
|
||||
if path.exists():
|
||||
if not path.is_dir():
|
||||
raise ValueError(translate('The parent of an NVIDIA destination is not a directory'))
|
||||
return
|
||||
if path == root:
|
||||
raise ValueError(translate('The rootfs is not mounted'))
|
||||
create_parent(path.parent, root)
|
||||
path.mkdir(mode=0o755)
|
||||
owner = path.parent.stat()
|
||||
os.chown(path, owner.st_uid, owner.st_gid)
|
||||
|
||||
|
||||
def refresh(root, vmid, apply=False):
|
||||
with instances.locked(root):
|
||||
record = instances.read(root, vmid)
|
||||
if record['status'] != 'installed' or record.get('pending_transaction'):
|
||||
raise ValueError(translate('The instance has a pending operation'))
|
||||
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']):
|
||||
raise ValueError(translate('The container identity or configuration changed'))
|
||||
previous = record['observed']['gpu_devices'][nv.KEY]
|
||||
plan = nv.refresh_plan(config, previous)
|
||||
journal = instances.location(root, vmid).parent / 'nvidia-refresh.json'
|
||||
if journal.exists() or journal.is_symlink():
|
||||
raise ValueError(translate('A previous NVIDIA refresh is pending review'))
|
||||
if not apply:
|
||||
msg_ok(translate('Current NVIDIA inventory resolved: a refresh is required') if plan['changed']
|
||||
else translate('Current NVIDIA inventory resolved: no refresh is required'))
|
||||
return
|
||||
if not plan['changed']:
|
||||
msg_ok(translate('The NVIDIA runtime is up to date; the container is not modified or started'))
|
||||
return
|
||||
if instances.command('pct', 'status', str(vmid)).strip() != b'status: stopped':
|
||||
raise ValueError(translate('Stop the container before the NVIDIA refresh'))
|
||||
instances.write(journal, {'phase': 'preparing', 'record': record,
|
||||
'inventory': plan['inventory']})
|
||||
msg_info(translate('Refreshing the NVIDIA runtime...'))
|
||||
instances.command('pct', 'mount', str(vmid))
|
||||
try:
|
||||
candidate = prepare(Path(f'/var/lib/lxc/{vmid}/rootfs'), plan)
|
||||
finally:
|
||||
instances.command('pct', 'unmount', str(vmid))
|
||||
nv.verify(plan['inventory'])
|
||||
if instances.command('pct', 'config', str(vmid)) != config:
|
||||
raise ValueError(translate('The configuration changed during the NVIDIA refresh'))
|
||||
conf = Path(f'/etc/pve/lxc/{vmid}.conf')
|
||||
# Same native configuration file used by the common installer.
|
||||
conf.write_bytes(candidate)
|
||||
instances.write(journal, {'phase': 'validating', 'record': record,
|
||||
'inventory': plan['inventory']})
|
||||
instances.command('pct', 'start', str(vmid))
|
||||
try:
|
||||
nv.validate_runtime(vmid, plan['inventory'])
|
||||
finally:
|
||||
instances.command('pct', 'shutdown', str(vmid), '--timeout', '30')
|
||||
updated = copy.deepcopy(record)
|
||||
updated['observed'] = instances.observe(vmid, record['installation_id'],
|
||||
record['observed']['archive_path'], record['observed']['resolved_registry_digest'],
|
||||
record['observed']['image'])
|
||||
nv.check_mounts(updated['observed']['config'].encode(), plan['inventory'])
|
||||
nv.check_devices(updated['observed']['config'].encode(), plan['inventory'])
|
||||
if updated['observed']['gpu_devices'][nv.KEY] != plan['inventory']:
|
||||
raise ValueError(translate('The observed inventory differs from the validated runtime'))
|
||||
nv.verify(plan['inventory'])
|
||||
instances.write(instances.location(root, vmid), updated)
|
||||
journal.unlink()
|
||||
msg_ok(translate('NVIDIA refresh validated; the container is stopped and its settings are kept'))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('vmid', type=int)
|
||||
parser.add_argument('--root', type=Path, default=instances.ROOT)
|
||||
parser.add_argument('--apply', action='store_true')
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
refresh(args.root, args.vmid, args.apply)
|
||||
except BlockingIOError:
|
||||
msg_error(translate('Another OCI operation is using the registry. This operation was not started.'))
|
||||
raise SystemExit(1)
|
||||
except (OSError, ValueError, KeyError, RuntimeError, subprocess.SubprocessError) as error:
|
||||
msg_error(str(error) if not isinstance(error, KeyError) else
|
||||
translate('The saved OCI record is incomplete or has an unexpected format.'))
|
||||
raise SystemExit(1)
|
||||
Reference in New Issue
Block a user