mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-09-29 10:06:41 +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>
150 lines
6.2 KiB
Python
150 lines
6.2 KiB
Python
"""Native Intel/AMD device preservation. NVIDIA library mounts need a separate profile."""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import stat
|
|
|
|
from oci_installation_state import parse_config
|
|
from oci_ui import translate
|
|
|
|
|
|
def gpu_path(path):
|
|
return isinstance(path, str) and (re.fullmatch(r'/dev/dri/(renderD|card)[0-9]+', path) is not None or path == '/dev/kfd')
|
|
|
|
|
|
def peripheral_path(path):
|
|
return isinstance(path, str) and re.fullmatch(
|
|
r'/dev/(apex_[0-9]+|ttyUSB[0-9]+|ttyACM[0-9]+|bus/usb/[0-9]{3}/[0-9]{3})', path) is not None
|
|
|
|
|
|
def system_path(path):
|
|
"""Fixed nodes the kernel always presents the same way. They carry no
|
|
identity beyond their device numbers, so there is no sysfs to interrogate:
|
|
the numbers and the permissions are the whole record."""
|
|
return isinstance(path, str) and re.fullmatch(
|
|
r'/dev/(kvm|fuse|net/tun|video[0-9]+|sg[0-9]+)', path) is not None
|
|
|
|
|
|
def block_path(path):
|
|
return isinstance(path, str) and re.fullmatch(r'/dev/sr[0-9]+', path) is not None
|
|
|
|
|
|
def known_path(path):
|
|
return gpu_path(path) or peripheral_path(path) or system_path(path) or block_path(path)
|
|
|
|
|
|
def snapshot(path):
|
|
if not known_path(path):
|
|
raise ValueError(translate('Device node outside the supported profiles'))
|
|
info = Path(path).stat()
|
|
if block_path(path):
|
|
if not stat.S_ISBLK(info.st_mode):
|
|
raise ValueError(translate('The selected device is not a block device'))
|
|
elif not stat.S_ISCHR(info.st_mode):
|
|
raise ValueError(translate('The selected device is not a character device'))
|
|
value = {'path': str(Path(path).resolve()), 'major': os.major(info.st_rdev),
|
|
'minor': os.minor(info.st_rdev), 'uid': info.st_uid, 'gid': info.st_gid,
|
|
'mode': stat.S_IMODE(info.st_mode)}
|
|
if peripheral_path(path):
|
|
sysfs = Path('/sys/dev/char') / f'{value["major"]}:{value["minor"]}'
|
|
value['sysfs_path'] = str(sysfs.resolve(strict=True))
|
|
if '/bus/usb/' in path:
|
|
for name in ('idVendor', 'idProduct', 'serial'):
|
|
field = sysfs / name
|
|
if field.is_file():
|
|
value[name] = field.read_text().strip()
|
|
elif gpu_path(path) and path != '/dev/kfd':
|
|
sysfs = Path('/sys/class/drm') / Path(path).name / 'device'
|
|
value.update(vendor=(sysfs / 'vendor').read_text().strip(), pci_path=str(sysfs.resolve()))
|
|
if value['vendor'] not in ('0x1002', '0x8086'):
|
|
raise ValueError(translate('The DRM node is not an Intel or AMD GPU; NVIDIA requires its library profile'))
|
|
return value
|
|
|
|
|
|
def planned(deployment):
|
|
result = {}
|
|
for device in deployment.get('devices', []):
|
|
path = device.get('host_path')
|
|
kind = device.get('kind', 'character-device')
|
|
if kind == 'block-device':
|
|
allowed = block_path(path)
|
|
elif kind == 'character-device':
|
|
allowed = gpu_path(path) or peripheral_path(path) or system_path(path)
|
|
else:
|
|
allowed = False
|
|
if not allowed:
|
|
raise ValueError(translate('Device outside the supported profiles; NVIDIA and device trees require another profile'))
|
|
if device.get('container_path') != path or path in result:
|
|
raise ValueError(translate('The device must keep its native path without duplicates'))
|
|
value = snapshot(path)
|
|
vendors = device.get('drm_vendor_ids')
|
|
if vendors and value.get('vendor') not in vendors:
|
|
raise ValueError(translate('The GPU vendor differs from the requested profile'))
|
|
mode = device.get('mode', '0660')
|
|
if mode != 'preserve-host' and (not isinstance(mode, str) or not re.fullmatch(r'0?[0-7]{3}', mode)):
|
|
raise ValueError(translate('Invalid device mode'))
|
|
if device.get('gid_strategy') not in ('none', 'host-device-gid'):
|
|
raise ValueError(translate('Unsupported device GID strategy'))
|
|
result[path] = value
|
|
return result
|
|
|
|
|
|
def verify(expected):
|
|
for path, value in expected.items():
|
|
if snapshot(path) != value:
|
|
raise ValueError(translate('The GPU identity or permissions changed; the container is not modified'))
|
|
|
|
|
|
def actual_devices(config):
|
|
result = {}
|
|
for key, value in parse_config(config).items():
|
|
if re.fullmatch(r'dev[0-9]+', key):
|
|
fields = dict(part.split('=', 1) for part in value.split(','))
|
|
path = fields.get('path')
|
|
if path in result:
|
|
raise ValueError(translate('Duplicated GPU device in the container'))
|
|
result[path] = fields
|
|
return result
|
|
|
|
|
|
def check(config, deployment):
|
|
expected = planned(deployment)
|
|
actual = actual_devices(config)
|
|
if actual.keys() != expected.keys():
|
|
raise ValueError(translate('The container devices do not match the saved record'))
|
|
for device in deployment.get('devices', []):
|
|
path = device['host_path']
|
|
fields = actual[path]
|
|
value = expected[path]
|
|
requested_mode = device.get('mode', '0660')
|
|
mode = value['mode'] if requested_mode == 'preserve-host' else int(requested_mode, 8)
|
|
gid = value['gid'] if device['gid_strategy'] == 'host-device-gid' else 0
|
|
if (set(fields) - {'path', 'mode', 'gid', 'uid', 'deny-write'}
|
|
or int(fields.get('mode', '0660'), 8) != mode
|
|
or int(fields.get('gid', 0)) != gid
|
|
or int(fields.get('uid', 0)) != int(device.get('uid', 0))
|
|
or fields.get('deny-write', '0') != ('1' if device.get('deny_write') else '0')):
|
|
raise ValueError(translate('The native GPU permissions were not kept'))
|
|
return expected
|
|
|
|
|
|
def verify_observation(expected, observed):
|
|
if observed.get('gpu_devices', {}) != expected:
|
|
raise ValueError(translate('The GPU evidence does not match the verified devices'))
|
|
verify(expected)
|
|
|
|
|
|
def capture(config):
|
|
result = {}
|
|
for path in actual_devices(config):
|
|
if not known_path(path):
|
|
continue
|
|
if gpu_path(path) and path != '/dev/kfd':
|
|
vendor = (Path('/sys/class/drm') / Path(path).name / 'device/vendor').read_text().strip()
|
|
if vendor not in ('0x1002', '0x8086'):
|
|
continue
|
|
result[path] = snapshot(path)
|
|
return result
|