Files
ProxMenux/oci/remote/oci_nvidia_refresh.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

146 lines
6.9 KiB
Python

#!/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)