feat(oci): recover applications after a Proxmox reinstall, cluster records and AMD GPU profiles

The installation record of an OCI application travels with its container:
a copy inside the container and another in /etc/pve, written together
with the one kept on the host. A container restored on a newly installed
Proxmox, restored with another ID or moved to another node of a cluster
is recognised and registered again, with its private network, hookscript,
Rclone mount, host firewall rule and NVIDIA runtime. The Monitor offers
the same recovery from the Updates tab.

AMD GPUs are offered by generation. A GPU the ROCm image supports takes
the profile as it is; one of a supported family (Radeon 680M, 780M) is an
experimental option that asks for confirmation and is never proposed; an
older one is not offered. The GPU is checked with a real inference before
the installation accepts it. Recreate changes what runs recognition in an
installed Immich, between the CPU and a GPU of the host.

Updates:
- A failed update that is restored and checked removes its temporary
  container and the disks of the failed attempt.
- Every container volume is part of the backups, so Jellyfin, Plex and
  Hugo update with their default installation.
- An image published with a Docker-format manifest is recognised by its
  layers and build time and updates.
- The Proxmox notes of a multi-container application link to its LAN
  address.
This commit is contained in:
MacRimi
2026-10-04 20:32:17 +02:00
parent 7e9f16fdb3
commit 1f9d2acef5
67 changed files with 4464 additions and 126 deletions
+2 -2
View File
@@ -116,7 +116,7 @@
],
"default": "host-bind",
"managed_volume": {
"backup": false,
"backup": true,
"default_size_gb": 32
}
},
@@ -132,7 +132,7 @@
],
"default": "host-bind",
"managed_volume": {
"backup": false,
"backup": true,
"default_size_gb": 32
}
}
+2 -1
View File
@@ -117,7 +117,7 @@
],
"default": "host-bind",
"managed_volume": {
"backup": false,
"backup": true,
"default_size_gb": 100
}
}
@@ -887,6 +887,7 @@
{
"id": "rocm",
"label": "AMD (VA-API and ROCm detection)",
"amd_gfx_targets": [100300, 110000, 110001, 110002, 110500, 110501, 120000, 120001],
"architectures": [
"amd64"
],
+2 -2
View File
@@ -87,7 +87,7 @@
],
"default": "managed-volume",
"managed_volume": {
"backup": false,
"backup": true,
"default_size_gb": 8
}
}
@@ -190,7 +190,7 @@
"container_path": "/cache",
"mode": "managed-volume",
"user_selectable": false,
"backup": false,
"backup": true,
"shared_with_other_lxc": false,
"source_path": null,
"source_path_prompt": null
+5 -5
View File
@@ -366,7 +366,7 @@
"container_path": "/cache",
"mode": "managed-volume",
"user_selectable": false,
"backup": false,
"backup": true,
"shared_with_other_lxc": false,
"source_path": null,
"source_path_prompt": null
@@ -638,8 +638,8 @@
{
"pci_id": "1002:164c",
"gpu": "AMD Lucienne integrated graphics",
"reason": "Real buffalo_l inference caused SDMA/compute timeouts and repeated host GPU resets.",
"tested_on": "2026-08-26"
"reason": "The ROCm image carries no kernels for gfx90c: every inference aborts in rocBLAS. The installer reads the generation from the compute driver and keeps recognition on the CPU.",
"tested_on": "2026-10-03"
}
]
},
@@ -1208,8 +1208,8 @@
{
"pci_id": "1002:164c",
"gpu": "AMD Lucienne integrated graphics",
"reason": "Real buffalo_l inference caused SDMA/compute timeouts and repeated host GPU resets.",
"tested_on": "2026-08-26"
"reason": "The ROCm image carries no kernels for gfx90c: every inference aborts in rocBLAS. The installer reads the generation from the compute driver and keeps recognition on the CPU.",
"tested_on": "2026-10-03"
}
],
"warning": "Do not enable automatically on unsupported AMD integrated GPUs. HSA overrides are manual compatibility experiments, not a validated default."
+2 -2
View File
@@ -100,7 +100,7 @@
],
"default": "managed-volume",
"managed_volume": {
"backup": false,
"backup": true,
"default_size_gb": 8
}
},
@@ -116,7 +116,7 @@
],
"default": "host-bind",
"managed_volume": {
"backup": false,
"backup": true,
"default_size_gb": 32
}
}
+2 -2
View File
@@ -132,7 +132,7 @@
],
"default": "managed-volume",
"managed_volume": {
"backup": false,
"backup": true,
"default_size_gb": 16
}
},
@@ -148,7 +148,7 @@
],
"default": "host-bind",
"managed_volume": {
"backup": false,
"backup": true,
"default_size_gb": 32
}
}
+2 -2
View File
@@ -116,7 +116,7 @@
],
"default": "host-bind",
"managed_volume": {
"backup": false,
"backup": true,
"default_size_gb": 32
}
},
@@ -132,7 +132,7 @@
],
"default": "host-bind",
"managed_volume": {
"backup": false,
"backup": true,
"default_size_gb": 32
}
}
+2 -1
View File
@@ -117,7 +117,7 @@
],
"default": "host-bind",
"managed_volume": {
"backup": false,
"backup": true,
"default_size_gb": 100
}
}
@@ -887,6 +887,7 @@
{
"id": "rocm",
"label": "AMD (VA-API and ROCm detection)",
"amd_gfx_targets": [100300, 110000, 110001, 110002, 110500, 110501, 120000, 120001],
"architectures": [
"amd64"
],
+5 -5
View File
@@ -366,7 +366,7 @@
"container_path": "/cache",
"mode": "managed-volume",
"user_selectable": false,
"backup": false,
"backup": true,
"shared_with_other_lxc": false,
"source_path": null,
"source_path_prompt": null
@@ -638,8 +638,8 @@
{
"pci_id": "1002:164c",
"gpu": "AMD Lucienne integrated graphics",
"reason": "Real buffalo_l inference caused SDMA/compute timeouts and repeated host GPU resets.",
"tested_on": "2026-08-26"
"reason": "The ROCm image carries no kernels for gfx90c: every inference aborts in rocBLAS. The installer reads the generation from the compute driver and keeps recognition on the CPU.",
"tested_on": "2026-10-03"
}
]
},
@@ -1208,8 +1208,8 @@
{
"pci_id": "1002:164c",
"gpu": "AMD Lucienne integrated graphics",
"reason": "Real buffalo_l inference caused SDMA/compute timeouts and repeated host GPU resets.",
"tested_on": "2026-08-26"
"reason": "The ROCm image carries no kernels for gfx90c: every inference aborts in rocBLAS. The installer reads the generation from the compute driver and keeps recognition on the CPU.",
"tested_on": "2026-10-03"
}
],
"warning": "Do not enable automatically on unsupported AMD integrated GPUs. HSA overrides are manual compatibility experiments, not a validated default."
+2 -2
View File
@@ -100,7 +100,7 @@
],
"default": "managed-volume",
"managed_volume": {
"backup": false,
"backup": true,
"default_size_gb": 8
}
},
@@ -116,7 +116,7 @@
],
"default": "host-bind",
"managed_volume": {
"backup": false,
"backup": true,
"default_size_gb": 32
}
}
+2 -2
View File
@@ -132,7 +132,7 @@
],
"default": "managed-volume",
"managed_volume": {
"backup": false,
"backup": true,
"default_size_gb": 16
}
},
@@ -148,7 +148,7 @@
],
"default": "host-bind",
"managed_volume": {
"backup": false,
"backup": true,
"default_size_gb": 32
}
}
+6
View File
@@ -176,6 +176,12 @@ oci_quiet pct unmount "$VMID"
sed -i -E '/^lxc\.environment\.runtime: RCLONE_RC_(USER|PASS)=/d' "/etc/pve/lxc/${VMID}.conf"
oci_quiet pct set "$VMID" --entrypoint /usr/local/bin/rclone-mount-lxc-start
sed -i -E '/^hookscript:/d' "/etc/pve/lxc/${VMID}.conf"
# A new Proxmox installation accepts no snippets on `local`.
if ! pvesm status --content snippets 2>/dev/null | awk 'NR > 1 && $1 == "local" {found=1} END {exit !found}'; then
LOCAL_CONTENT=$(pvesh get /storage/local --output-format json 2>/dev/null | jq -r '.content // empty')
[[ -n $LOCAL_CONTENT ]] || die "$(translate "The local storage does not accept snippets")"
oci_quiet pvesm set local --content "${LOCAL_CONTENT},snippets"
fi
oci_quiet pct set "$VMID" --hookscript "local:snippets/$(basename "$HOOK_PATH")"
msg_ok "$(translate "Mount mode applied")"
+7
View File
@@ -98,6 +98,9 @@ done
[[ -r $STACK_DEPENDENCY_HOOK ]] || die "$(translate "The stack startup hook was not found")"
ML_ACCELERATION=$(jq -er '.machine_learning.acceleration // "cpu"' "$DEPLOYMENT_FILE")
ML_GFX_OVERRIDE=$(jq -r '.machine_learning.gfx_override // empty' "$DEPLOYMENT_FILE")
[[ -z $ML_GFX_OVERRIDE || ( $ML_ACCELERATION == rocm && $ML_GFX_OVERRIDE =~ ^[0-9]{1,2}\.[0-9]\.[0-9]$ ) ]] \
|| die "$(translate "Invalid ROCm generation override")"
source "$SCRIPT_DIR/oci_nvidia_setup.sh"
source "$SCRIPT_DIR/oci_immich_ml.sh"
validate_immich_ml_profile
@@ -437,6 +440,10 @@ set_runtime_env "$ML_ID" MACHINE_LEARNING_CACHE_FOLDER /cache
set_runtime_env "$ML_ID" TRANSFORMERS_CACHE /cache
set_runtime_env "$ML_ID" MACHINE_LEARNING_MODEL_INTRA_OP_THREADS 2
set_runtime_env "$ML_ID" MACHINE_LEARNING_MODEL_INTER_OP_THREADS 1
if [[ -n $ML_GFX_OVERRIDE ]]; then
set_runtime_env "$ML_ID" HSA_OVERRIDE_GFX_VERSION "$ML_GFX_OVERRIDE"
set_runtime_env "$ML_ID" HSA_USE_SVM 0
fi
configure_immich_ml_gpu
oci_quiet pct mount "$ML_ID"
ML_ROOT="/var/lib/lxc/${ML_ID}/rootfs"
+3 -2
View File
@@ -285,7 +285,8 @@ apply_host_monitor_firewall() {
apply_host_monitor() {
[[ -n ${HOST_MONITOR:-} ]] || return 0
# PVE permits lxc.include but not namespace keys directly in the CT config.
# This static, cluster-persistent companion must accompany cross-host restores.
# This static, cluster-persistent companion is written again by the recovery
# of a container restored on another host.
# /etc/pve/proxmenux is the same on every node of a cluster; /etc/pve/lxc
# is the folder of this node only.
local include=/etc/pve/proxmenux/host-monitor native
@@ -303,7 +304,7 @@ apply_host_monitor() {
# Do not remove the Proxmox pre-start, autodev or post-stop hooks.
set_lxc_directive lxc.hook.mount ""
msg_ok "$(translate "Host monitor configured: shared PID and network namespaces, LXCFS disabled in this container")"
msg_info2 "$(translate "Every node of this cluster already has this file. If you restore this container on any other Proxmox host, copy it to the same path first, because the container backup does not include it:") $include"
msg_info2 "$(translate "Every node of this cluster already has this file. A backup of the container does not include it: on another Proxmox host it is written again when the application is recovered from Manage installed OCI applications:") $include"
}
verify_host_monitor() {
+385
View File
@@ -0,0 +1,385 @@
#!/usr/bin/env python3
"""Copies of the record of an OCI application that outlive the host record.
The record of an installation lives on the disk of the node that installed
it. Two copies are kept with it, written together after every operation:
- inside the container, in its root filesystem, so a backup carries it: a
container restored on another Proxmox host, or on this one after a
reinstall, still has what is needed to register it again. The container
could change this copy, so it is checked against the configuration Proxmox
restored before anything is taken from it;
- in /etc/pve, which every node of a cluster shares and only root of the host
reads, so a container that migrates finds its record on the node it moves
to. This one is trusted.
Neither is read back as a source of truth while the host record is current.
"""
from __future__ import annotations
import argparse
import contextlib
import datetime
import json
import os
from pathlib import Path
import re
import socket
import stat
import subprocess
import sys
import uuid
import oci_instances as instances
from oci_installation_state import sha
from oci_ui import translate
DIRECTORY = '.proxmenux'
NAME = 'oci-record.json'
KIND = 'proxmenux.oci-carried-record'
STAMP = 'carried.sha256'
LIMIT = 16 * 1024 * 1024
STACK_CONTRACT = '/etc/pve/priv/proxmenux-stack-{}.json'
NVIDIA_HOOK = re.compile(r'/usr/local/lib/proxmenux/oci/nvidia-mount-([a-f0-9]{64})\.sh')
SNIPPETS = Path('/var/lib/vz/snippets')
# Every node of a cluster reads this folder and only root of the host can:
# a container that moves to another node finds its record there.
CLUSTER = Path('/etc/pve/priv/proxmenux/oci')
RCLONE_HOOK = re.compile(r'^hookscript: local:snippets/(proxmenux-rclone-[0-9]+-fuse-hook\.sh)$', re.MULTILINE)
def _run(*args):
return subprocess.run(args, capture_output=True, text=True, timeout=120, check=False)
def running_pid(vmid):
result = _run('lxc-info', '-n', str(int(vmid)), '-pH')
pid = result.stdout.strip()
return int(pid) if result.returncode == 0 and pid.isdigit() else None
@contextlib.contextmanager
def container_root(vmid, mount_stopped=True):
"""The root filesystem of the container as the host sees it, or None. A
running container is reached through its init process; a stopped one is
mounted for as long as the block lasts."""
pid = running_pid(vmid)
if pid:
yield Path(f'/proc/{pid}/root')
return
if not mount_stopped or _run('pct', 'mount', str(int(vmid))).returncode != 0:
yield None
return
try:
yield Path(f'/var/lib/lxc/{int(vmid)}/rootfs')
finally:
_run('pct', 'unmount', str(int(vmid)))
def mapped_root(config):
"""The host owner that is root inside the container."""
owner = {}
for line in config.splitlines():
fields = line.partition(': ')[2].split()
if line.startswith('lxc.idmap: ') and len(fields) == 4 and fields[1] == '0' and fields[0] in 'ug':
owner.setdefault(fields[0], int(fields[2]))
default = 100000 if re.search(r'^unprivileged: 1$', config, re.MULTILINE) else 0
return owner.get('u', default), owner.get('g', default)
def _directory(root, owner=None):
"""The private folder of the copy, opened without following a link the
container could have left in its place."""
root_fd = os.open(root, os.O_RDONLY | os.O_DIRECTORY)
try:
if owner is not None:
with contextlib.suppress(FileExistsError):
os.mkdir(DIRECTORY, 0o700, dir_fd=root_fd)
fd = os.open(DIRECTORY, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=root_fd)
finally:
os.close(root_fd)
if owner is not None:
os.fchown(fd, *owner)
os.fchmod(fd, 0o700)
return fd
def valid_copy(value):
return (isinstance(value, dict) and value.get('kind') == KIND and value.get('schema_version') == 1
and isinstance(value.get('record'), dict))
def read_cluster(vmid):
"""The copy the cluster keeps for a container, or None."""
path = CLUSTER / f'{int(vmid)}.json'
try:
if path.is_symlink() or path.stat().st_size > LIMIT:
return None
value = json.loads(path.read_text())
except (OSError, ValueError):
return None
return value if valid_copy(value) else None
def write_cluster(vmid, value):
"""Best effort: /etc/pve is read-only without quorum and takes files of
up to 1 MiB. The copy inside the container does not depend on it."""
try:
CLUSTER.mkdir(parents=True, exist_ok=True)
(CLUSTER / f'{int(vmid)}.json').write_text(json.dumps(value))
except OSError:
return False
return True
def remove_cluster(vmid):
with contextlib.suppress(OSError):
(CLUSTER / f'{int(vmid)}.json').unlink()
def keep_cluster(vmid, value, generation):
"""Leave in the cluster the same copy the container carries."""
shared = read_cluster(vmid)
if shared is not None and digest(shared) == digest(value) and shared.get('generation') == generation:
return
write_cluster(vmid, dict(value, generation=generation, saved_at=value.get('saved_at')
or datetime.datetime.now(datetime.timezone.utc).isoformat()))
def replaced_elsewhere(record, record_path, shared, node):
"""Whether another node of the cluster wrote a different record for this
container after the one of this host: the container was changed there and
came back."""
if shared is None or shared.get('node') == node or shared['record'] == record \
or shared['record'].get('installation_id') != record['installation_id'] \
or shared['record'].get('vmid') != record['vmid']:
return False
try:
saved = datetime.datetime.fromisoformat(str(shared.get('saved_at')))
return saved.timestamp() > record_path.stat().st_mtime
except (ValueError, OSError, TypeError):
return False
def read_copy(root):
"""The copy a container carries, or None when it has none that can be used."""
try:
directory = _directory(root)
except OSError:
return None
try:
fd = os.open(NAME, os.O_RDONLY | os.O_NOFOLLOW, dir_fd=directory)
except OSError:
return None
finally:
os.close(directory)
with os.fdopen(fd, 'rb') as source:
info = os.fstat(source.fileno())
if not stat.S_ISREG(info.st_mode) or info.st_size > LIMIT:
return None
try:
value = json.loads(source.read(LIMIT))
except ValueError:
return None
return value if valid_copy(value) else None
def write_copy(root, value, owner):
directory = _directory(root, owner)
temporary = f'.{NAME}.{os.getpid()}'
try:
with contextlib.suppress(FileNotFoundError):
os.unlink(temporary, dir_fd=directory)
fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600, dir_fd=directory)
try:
with os.fdopen(fd, 'w') as output:
json.dump(value, output)
output.flush()
os.fchown(output.fileno(), *owner)
os.fsync(output.fileno())
os.rename(temporary, NAME, src_dir_fd=directory, dst_dir_fd=directory)
except BaseException:
with contextlib.suppress(OSError):
os.unlink(temporary, dir_fd=directory)
raise
os.fsync(directory)
finally:
os.close(directory)
def rclone_mount(config):
"""Where the hookscript of an Rclone container publishes its mount on the
host. The hookscript itself is written again from the engine."""
hook = RCLONE_HOOK.search(config)
if not hook:
return None
path = SNIPPETS / hook[1]
try:
if path.is_symlink():
return None
text = path.read_text()
except (OSError, UnicodeDecodeError):
return None
name = re.search(r'^inside=/data/mounts/(\S+)$', text, re.MULTILINE)
views = {key: re.search(rf'^{key}=(/\S+)/(\S+)$', text, re.MULTILINE) for key in ('published', 'published_ro')}
parent = re.search(r'^\s*if ! mountpoint -q (/\S+); then$', text, re.MULTILINE)
if not name or not parent or not all(views.values()) \
or any(view[2] != name[1] for view in views.values()):
return None
return {'mount_name': name[1], 'shared_mount_root': views['published'][1],
'shared_mount_read_only_root': views['published_ro'][1], 'shared_mount_root_parent': parent[1]}
def bundle(record, config):
"""What the container carries: its record and the host files a start of
the application depends on."""
vmid = record['vmid']
value = {'schema_version': 1, 'kind': KIND, 'node': socket.gethostname().split('.', 1)[0],
'vmid': vmid, 'record': record}
contract = Path(STACK_CONTRACT.format(vmid))
if contract.is_file() and not contract.is_symlink():
with contextlib.suppress(OSError, ValueError):
value['stack_contract'] = json.loads(contract.read_text())
hook = NVIDIA_HOOK.search(config)
if hook:
path = Path(hook[0])
with contextlib.suppress(OSError, UnicodeDecodeError):
if path.is_file() and not path.is_symlink() and sha(path.read_bytes()) == hook[1]:
value['nvidia_hook'] = {'sha256': hook[1], 'content': path.read_text()}
mount = rclone_mount(config)
if mount:
value['rclone_mount'] = mount
return value
def digest(value):
stable = {key: item for key, item in value.items() if key not in ('saved_at', 'generation')}
return sha(json.dumps(stable, sort_keys=True, separators=(',', ':')).encode())
def read_stamp(path):
"""What this host last wrote into the container: the digest of the copy
and the mark that tells that copy from any other."""
try:
text = path.read_text().strip()
except OSError:
return {}
try:
value = json.loads(text)
except ValueError:
return {'digest': text}
return value if isinstance(value, dict) else {}
def superseded(record, existing, stamp):
"""Whether the container carries a copy this host did not write, with a
record that differs from the one of this host. That is a container that
came back: migrated to another node and changed there, restored from a
backup made before the last operation, or rolled back to a snapshot. Its
content is the one its own copy describes."""
if not stamp.get('generation') or existing.get('generation') == stamp['generation']:
return False
carried_record = existing['record']
return (carried_record.get('installation_id') == record['installation_id']
and carried_record.get('vmid') == record['vmid'] and carried_record != record)
def carry(root, vmid, mount_stopped=True, verify=False, adopted=False):
"""Leave the current record inside its container. Returns 'carried',
'current' when the copy was already up to date, 'skipped' when the
instance is in the middle of an operation or cannot be reached, or
'stale' when the container came back with another record: the record of
this host is set aside, and the container is then recovered like any
restored one. A stopped container is opened only when its copy is known to
be old, or when `verify` asks to look anyway before an operation. A record
that was just recovered is `adopted`: it replaces the copies it came from."""
try:
record = instances.read(root, vmid)
except (OSError, ValueError, KeyError):
return 'skipped'
if record.get('status') != 'installed' or record.get('pending_transaction') \
or record.get('pending_stack_transaction'):
return 'skipped'
result = _run('pct', 'config', str(vmid))
if result.returncode != 0 or instances.identity(result.stdout.encode()) != record['installation_id']:
return 'skipped'
value = bundle(record, result.stdout)
expected = digest(value)
record_path = instances.location(root, vmid)
path = record_path.parent / STAMP
stamp = read_stamp(path)
if not adopted and replaced_elsewhere(record, record_path, read_cluster(vmid), value['node']):
record_path.replace(record_path.with_name(f"retired-{record['installation_id']}.json"))
with contextlib.suppress(OSError):
path.unlink()
return 'stale'
if not running_pid(vmid):
if stamp.get('digest') == expected and stamp.get('generation') and not verify:
keep_cluster(vmid, value, stamp['generation'])
return 'current'
if not mount_stopped:
return 'skipped'
try:
with container_root(vmid, mount_stopped) as rootfs:
if rootfs is None:
return 'skipped'
existing = read_copy(rootfs)
if existing is not None and not adopted and superseded(record, existing, stamp):
outcome = 'stale'
elif existing is not None and digest(existing) == expected and existing.get('generation') \
and existing.get('generation') == stamp.get('generation'):
outcome = 'current'
else:
value['saved_at'] = datetime.datetime.now(datetime.timezone.utc).isoformat()
value['generation'] = uuid.uuid4().hex
write_copy(rootfs, value, mapped_root(result.stdout))
stamp = {'generation': value['generation']}
outcome = 'carried'
if outcome == 'stale':
record_path.replace(record_path.with_name(f"retired-{record['installation_id']}.json"))
with contextlib.suppress(OSError):
path.unlink()
return outcome
path.write_text(json.dumps({'digest': expected, 'generation': stamp.get('generation')}) + '\n')
path.chmod(0o600)
if stamp.get('generation'):
keep_cluster(vmid, value, stamp['generation'])
except OSError:
return 'skipped'
return outcome
def registered(root):
if not root.is_dir():
return []
return sorted(int(d.name) for d in root.iterdir()
if d.name.isdecimal() and instances.has_contract(root, int(d.name)))
def sync(root, vmids=None, mount_stopped=True, verify=False):
"""Bring the copies of the given instances, or of all, up to date. Never
waits for the registry: an operation in progress carries its own copy
when it ends."""
try:
with instances.locked(root):
return {vmid: carry(root, vmid, mount_stopped, verify)
for vmid in (registered(root) if vmids is None else vmids)}
except (BlockingIOError, OSError, ValueError):
return {}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('vmid', type=int, nargs='*')
parser.add_argument('--root', type=Path, default=instances.ROOT)
parser.add_argument('--running-only', action='store_true')
parser.add_argument('--verify', action='store_true')
args = parser.parse_args()
if os.geteuid() != 0:
parser.error(translate('Root privileges are required'))
print(json.dumps(sync(args.root, args.vmid or None, not args.running_only, args.verify)))
return 0
if __name__ == '__main__':
sys.exit(main())
+39
View File
@@ -114,9 +114,41 @@ configure_immich_ml_gpu() {
esac
}
# The generation of the AMD GPU as the compute driver names it, e.g. gfx1030.
amd_gfx_arch() {
local target
target=$(awk '$1 == "gfx_target_version" && $2 > 0 {print $2}' \
/sys/class/kfd/kfd/topology/nodes/*/properties 2>/dev/null | sort -n | tail -1)
[[ -n $target ]] || return 1
printf 'gfx%d%d%x' $((target / 10000)) $((target / 100 % 100)) $((target % 100))
}
validate_immich_ml_runtime() {
[[ $ML_ACCELERATION != cpu ]] || return 0
msg_info "$(translate "Checking the GPU of the machine learning container...")"
if [[ $ML_ACCELERATION == rocm ]]; then
# The provider being present says nothing about this GPU: the image
# carries kernels for some generations, and on any other every inference
# aborts.
local arch
if [[ -n ${ML_GFX_OVERRIDE:-} ]]; then
# ROCm is told to treat this GPU as the generation of its family.
arch="gfx${ML_GFX_OVERRIDE//./}"
else
arch=$(amd_gfx_arch) || arch=""
fi
if [[ -n $arch ]]; then
# Listed first: a match ends grep early, and with pipefail the listing
# it cut short would read as a failure.
local kernels
kernels=$(pct exec "$ML_ID" -- sh -c 'ls /opt/rocm/lib/rocblas/library 2>/dev/null' || true)
grep -Eq "[_-]${arch}\\.(dat|co|hsaco)" <<<"$kernels" || {
oci_log "The ROCm image has no kernels for $arch"
msg_warn "$(translate "The ROCm image has no support for the AMD GPU of this host:") $arch"
return 1
}
fi
fi
oci_quiet pct exec "$ML_ID" -- python -c '
import ctypes
import sys
@@ -135,5 +167,12 @@ else:
assert driver.cuInit(0) == 0, "CUDA driver initialization failed"
print("Immich ML GPU runtime:", profile, "available; model inference is tested separately")
' "$ML_ACCELERATION" || return
if [[ $ML_ACCELERATION == rocm ]]; then
# One real inference on the GPU: the provider alone does not prove it.
python3 "${SCRIPT_DIR}/oci_rocm_check.py" "$ML_ID" >>"${OCI_LOG:-/dev/null}" 2>&1 || {
msg_warn "$(translate "The AMD GPU did not complete a test inference with ROCm")"
return 1
}
fi
msg_ok "$(translate "GPU available for machine learning:") $ML_ACCELERATION"
}
+307
View File
@@ -0,0 +1,307 @@
#!/usr/bin/env python3
"""Change what runs the recognition of an installed Immich: the CPU or a GPU.
The machine learning container takes the image built for the new choice and
the devices that choice needs. Its model cache, the library, the database and
the other containers keep their data. The change is applied with an update of
the application, so a failure restores the previous containers, and with them
the previous choice.
"""
from __future__ import annotations
import argparse
import os
from pathlib import Path
import re
import subprocess
import sys
import oci_instances as instances
import oci_stack_modify
import oci_stack_native
from oci_ui import msg_error, msg_info, msg_ok, msg_warn, translate
ADAPTER = 'install_immich_stack.sh'
ENGINE = Path(__file__).resolve().parent
PROFILES = ('cpu', 'openvino', 'cuda', 'rocm')
RENDER = re.compile(r'/dev/dri/renderD[0-9]+')
# What a previous choice left in the container: its devices and the settings
# of its runtime.
GPU_DEVICE = re.compile(r'/dev/dri/renderD[0-9]+|/dev/kfd|/dev/nvidia\S*')
GPU_LINE = re.compile(r'lxc\.environment\.runtime: HSA_[A-Z_]+=|lxc\.environment: NVIDIA_[A-Z_]+=|'
r'lxc\.hook\.mount: /usr/local/lib/proxmenux/oci/nvidia-mount-')
ROCM_ROOTFS_GB = 40
NVIDIA_CAPABILITIES = 'compute,utility'
GPU_VARIABLES = ('NVIDIA_DRIVER_CAPABILITIES', 'HSA_OVERRIDE_GFX_VERSION', 'HSA_USE_SVM')
def run(*command):
result = subprocess.run(command, capture_output=True, text=True, check=False)
if result.returncode != 0:
detail = (result.stderr or result.stdout).strip().splitlines()[-1:] or ['']
raise RuntimeError(f"{' '.join(command[:3])}: {detail[0]}")
return result.stdout
def members(root, primary_id):
"""The record of each container of the application, by its role."""
primary = instances.read(root, primary_id)
found = {}
for member in (primary.get('stack') or {}).get('members', []):
record = instances.read(root, member['vmid'])
profile = record['deployment'].get('replay_profile') or {}
if profile.get('adapter') != ADAPTER:
raise ValueError(translate('This application is not an Immich installed by ProxMenux'))
found[profile.get('role')] = record
if set(found) != {'server', 'database', 'valkey', 'machine-learning'} or found['server']['vmid'] != primary_id:
raise ValueError(translate('This application is not an Immich installed by ProxMenux'))
return primary, found
def current(record):
return (record['deployment'].get('machine_learning') or {}).get('acceleration', 'cpu')
def conf(vmid):
return Path(f'/etc/pve/lxc/{int(vmid)}.conf')
def settings(vmid):
"""The current configuration of the container, one value per key."""
lines = [line.partition(': ') for line in run('pct', 'config', str(vmid)).splitlines()]
return {key: value for key, separator, value in lines if separator}
def strip_gpu(vmid):
"""Take the devices and runtime settings of the previous choice away."""
for key, value in oci_stack_modify.entries(vmid, 'dev').items():
if GPU_DEVICE.fullmatch(oci_stack_modify.device_path(value)):
run('pct', 'set', str(vmid), '--delete', key)
text = conf(vmid).read_text()
head, separator, snapshots = text.partition('\n[')
rebuilt = '\n'.join(line for line in head.split('\n') if not GPU_LINE.match(line)) + separator + snapshots
if rebuilt != text:
conf(vmid).write_text(rebuilt)
def append_lines(vmid, lines):
"""Add lines to the current configuration, before any snapshot section."""
head, separator, snapshots = conf(vmid).read_text().partition('\n[')
conf(vmid).write_text(head.rstrip('\n') + '\n' + '\n'.join(lines) + '\n' + separator + snapshots)
def add_device(vmid, path):
info = os.stat(path)
run('pct', 'set', str(vmid), '--' + oci_stack_modify.free_key(vmid, 'dev'),
f'path={path},gid={info.st_gid},mode=0660')
def configure(vmid, acceleration, render_device, gfx_override):
"""Give the stopped container what the new choice needs."""
strip_gpu(vmid)
if acceleration in ('openvino', 'rocm'):
add_device(vmid, render_device)
if acceleration == 'rocm':
add_device(vmid, '/dev/kfd')
if gfx_override:
append_lines(vmid, [f'lxc.environment.runtime: HSA_OVERRIDE_GFX_VERSION={gfx_override}',
'lxc.environment.runtime: HSA_USE_SVM=0'])
size = re.search(r'size=([0-9]+)G', settings(vmid).get('rootfs', ''))
if size and int(size[1]) < ROCM_ROOTFS_GB:
# The ROCm image is several times larger than the others.
run('pct', 'resize', str(vmid), 'rootfs', f'{ROCM_ROOTFS_GB}G')
if acceleration == 'cuda':
# The same NVIDIA setup the installation uses.
run('bash', '-c', f'set -e; SCRIPT_DIR="{ENGINE}"; source "$SCRIPT_DIR/oci_ui.sh"; '
'die() { msg_error "$*"; exit 1; }; oci_log() { :; }; '
'source "$SCRIPT_DIR/oci_nvidia_setup.sh"; source "$SCRIPT_DIR/oci_immich_ml.sh"; '
f'configure_immich_nvidia {int(vmid)} {NVIDIA_CAPABILITIES}')
run('pct', 'set', str(vmid), '--memory', str(memory(acceleration)))
values = settings(vmid)
limited = 'cpulimit' in values and 'cores' not in values
# Intel keeps the CPU topology and limits its share of time; the others
# are given four cores.
if acceleration == 'openvino' and not limited:
run('pct', 'set', str(vmid), '--cpulimit', '4', '--delete', 'cores')
elif acceleration != 'openvino' and limited:
run('pct', 'set', str(vmid), '--cores', '4', '--delete', 'cpulimit')
def validate(acceleration, render_device, gfx_override):
if acceleration not in PROFILES:
raise ValueError(translate('Immich GPU profile not validated'))
if acceleration in ('openvino', 'rocm'):
if not render_device or not RENDER.fullmatch(render_device) or not Path(render_device).is_char_device():
raise ValueError(f"{translate('The render device does not exist:')} {render_device}")
vendor = Path(f'/sys/class/drm/{Path(render_device).name}/device/vendor').read_text().strip()
if vendor != ('0x8086' if acceleration == 'openvino' else '0x1002'):
raise ValueError(translate('The render device does not belong to the GPU of that choice'))
if acceleration == 'rocm' and not Path('/dev/kfd').is_char_device():
raise ValueError(translate('ROCm requires /dev/kfd on the host'))
if acceleration == 'cuda' and subprocess.run(['nvidia-smi', '-L'], capture_output=True, check=False).returncode:
raise ValueError(translate('The host NVIDIA driver is not responding correctly'))
if gfx_override and (acceleration != 'rocm' or not re.fullmatch(r'[0-9]{1,2}\.[0-9]\.[0-9]', gfx_override)):
raise ValueError(translate('Invalid ROCm generation override'))
def recorded(record, acceleration, render_device, gfx_override):
"""The recognition settings of a record after the change."""
settings = dict(record['deployment'].get('machine_learning') or {})
settings.update(acceleration=acceleration,
render_device=render_device if acceleration in ('openvino', 'rocm') else None,
gfx_override=gfx_override if acceleration == 'rocm' else None)
settings['resources'] = dict(settings.get('resources') or {}, memory_mb=memory(acceleration),
cpu_allocation='quota' if acceleration == 'openvino' else 'cpuset')
return settings
def memory(acceleration):
return 4096 if acceleration == 'cpu' else 8192
def resources(current, acceleration):
"""The resources a record declares for the container of the new choice."""
result = dict(current, memory_mb=memory(acceleration))
result.pop('cpu_allocation', None)
if acceleration == 'openvino':
result['cpu_allocation'] = 'quota'
return result
def declared(environment, acceleration, gfx_override):
"""The variables a record declares, with the ones of the new choice."""
result = [entry for entry in environment if entry.get('name') not in GPU_VARIABLES]
if acceleration == 'cuda':
result.append({'name': 'NVIDIA_DRIVER_CAPABILITIES', 'value': NVIDIA_CAPABILITIES})
if acceleration == 'rocm' and gfx_override:
result += [{'name': 'HSA_OVERRIDE_GFX_VERSION', 'value': gfx_override}, {'name': 'HSA_USE_SVM', 'value': '0'}]
return result
def declare(record, acceleration, gfx_override):
"""A record an update already rebuilt describes its container by itself:
it takes the variables, the resources and the disk of the new choice."""
plan = record['deployment']
if 'native_config' in plan:
return
plan['environment'] = declared(plan.get('environment', []), acceleration, gfx_override)
plan['resources'] = resources(plan.get('resources') or {}, acceleration)
size = re.search(r'size=([0-9]+)G', settings(record['vmid']).get('rootfs', ''))
if size and isinstance(plan.get('rootfs'), dict):
plan['rootfs']['size_gb'] = int(size[1])
profile = (record['template'].get('proxmox') or {}).get('installer_profile')
if isinstance(profile, dict):
profile.pop('cpu_allocation', None)
if acceleration == 'openvino':
profile['cpu_allocation'] = 'quota'
def chosen(record):
"""What runs recognition according to a record."""
saved = record['deployment'].get('machine_learning') or {}
return saved.get('acceleration', 'cpu'), saved.get('render_device'), saved.get('gfx_override')
def stop(vmid):
try:
run('pct', 'shutdown', str(vmid), '--timeout', '60')
except RuntimeError:
run('pct', 'stop', str(vmid))
def apply(root, primary_id, vmid, images, acceleration, render_device, gfx_override):
"""Give the stopped container and the records one choice."""
configure(vmid, acceleration, render_device, gfx_override)
learning = instances.read(root, vmid)
learning['template']['container_contract']['image']['reference'] = images[acceleration]
learning['deployment']['machine_learning'] = recorded(learning, acceleration, render_device, gfx_override)
declare(learning, acceleration, gfx_override)
instances.write(instances.location(root, vmid), learning)
primary = instances.read(root, primary_id)
primary['stack']['deployment']['machine_learning'] = recorded(
{'deployment': primary['stack']['deployment']}, acceleration, render_device, gfx_override)
intent = (primary.get('native_stack_intent') or {}).get('deployment')
if isinstance(intent, dict) and 'machine_learning' in intent:
intent['machine_learning'] = recorded({'deployment': intent}, acceleration, render_device, gfx_override)
instances.write(instances.location(root, primary_id), primary)
oci_stack_modify.register(root, vmid)
def revert(root, primary_id, vmid, images, previous, was_running):
"""Give the container and the records the choice they had."""
try:
if oci_stack_modify.is_running(vmid):
stop(vmid)
apply(root, primary_id, vmid, images, *previous)
except (OSError, ValueError, KeyError, RuntimeError, subprocess.SubprocessError) as error:
msg_warn(f"{translate('The previous recognition choice could not be put back:')} {error}")
return
if was_running:
subprocess.run(['pct', 'start', str(vmid)], capture_output=True, check=False)
def change(root, primary_id, acceleration, render_device=None, gfx_override=None):
validate(acceleration, render_device, gfx_override)
with instances.locked(root):
primary, found = members(root, primary_id)
learning = found['machine-learning']
vmid = learning['vmid']
if any(record.get('status') != 'installed' or record.get('pending_transaction')
or record.get('pending_stack_transaction') for record in found.values()):
raise ValueError(translate('The container has an operation pending; finish or recover it first'))
if current(learning) == acceleration:
raise ValueError(translate('Recognition already runs on that choice'))
images = primary['stack']['template']['proxmox']['application_options']['machine_learning']['profile_images']
previous = chosen(learning)
was_running = oci_stack_modify.is_running(vmid)
try:
if was_running:
msg_info(translate('Stopping the container...'))
stop(vmid)
msg_ok(translate('Container stopped'))
msg_info(translate('Preparing the machine learning container for the new choice...'))
apply(root, primary_id, vmid, images, acceleration, render_device, gfx_override)
msg_ok(translate('Machine learning container prepared'))
except BaseException:
revert(root, primary_id, vmid, images, previous, was_running)
raise
try:
# The image of the new choice replaces the one in use, as an update does.
oci_stack_native.run(primary_id, acknowledge_external_data=True)
except BaseException:
with instances.locked(root):
# An update left halfway keeps its own record of what to restore.
if not instances.read(root, primary_id).get('pending_stack_transaction'):
revert(root, primary_id, vmid, images, previous, was_running)
raise
if was_running and not oci_stack_modify.is_running(vmid):
# An update leaves each container as it found it, and this one was
# stopped for the change.
run('pct', 'start', str(vmid))
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('vmid', type=int, help='main container of the application')
parser.add_argument('--acceleration', required=True, choices=PROFILES)
parser.add_argument('--render-device')
parser.add_argument('--gfx-override')
parser.add_argument('--root', type=Path, default=instances.ROOT)
args = parser.parse_args()
if os.geteuid() != 0:
parser.error(translate('Root privileges are required'))
try:
change(args.root, args.vmid, args.acceleration, args.render_device, args.gfx_override)
except BlockingIOError:
msg_error(translate('Another OCI operation is using the instance registry. Wait for it to finish.'))
return 1
except (OSError, ValueError, KeyError, RuntimeError, StopIteration, subprocess.SubprocessError) as error:
if not getattr(error, 'oci_reported', False):
msg_error(f"{translate('The recognition of Immich was not changed:')} {error}")
return 1
msg_ok(translate('Recognition of Immich changed; the application was updated with the new choice.'))
return 0
if __name__ == '__main__':
sys.exit(main())
+16 -2
View File
@@ -88,6 +88,8 @@ def image_from_archive(path):
manifest = blob(digest)
config = blob(manifest['config']['digest'])
return {'manifest_digest': digest, 'config_digest': manifest['config']['digest'],
'layers': [layer['digest'] for layer in manifest.get('layers', [])],
'created': config.get('created'),
'architecture': config['architecture'], 'os': config.get('os'),
'defaults': {k: config.get('config', {}).get(k) for k in FIELDS}}
@@ -190,8 +192,20 @@ def resolve_candidate(reference, architecture):
# The build date identifies the image as the publisher released it: it is
# what changes when an image is rebuilt, whether or not the application
# version inside it moved.
return {'manifest_digest': digest, 'defaults': defaults, 'version': version,
'created': config.get('created')}
return {'manifest_digest': digest, 'layers': [layer['digest'] for layer in json.loads(raw).get('layers', [])],
'defaults': defaults, 'version': version, 'created': config.get('created')}
def same_image(candidate, image):
"""Whether what the registry serves is the image of an archive or a record.
The OCI archive made from a manifest published in the Docker format has
another manifest and another configuration digest: both are rewritten. Its
layers and the moment the image was built are the same in both."""
if candidate['manifest_digest'] == image.get('manifest_digest'):
return True
return bool(candidate.get('layers') and candidate.get('created')
and candidate['layers'] == image.get('layers') and candidate['created'] == image.get('created'))
def compare(record, current, candidate=None):
+20 -4
View File
@@ -764,9 +764,11 @@ def restore_description(vmid, state):
run('pct', 'set', str(vmid), '--description', original_description(state))
def release_stage(state):
def release_stage(state, discard=False):
"""After a commit the holder CT only keeps its own rootfs: every parked
volume went back to the application. Anything still attached keeps it."""
volume went back to the application. After a verified recovery it holds
the disks of the failed attempt, which the restored backup replaced:
`discard` removes them with it. Otherwise anything still attached keeps it."""
stage = state.get('stage')
if not stage:
return
@@ -774,11 +776,23 @@ def release_stage(state):
config = owned(stage, 'proxmenux-transaction=' + state['id'])
except (ValueError, RuntimeError, subprocess.CalledProcessError):
return
if mounts(config) or any(re.fullmatch(r'unused[0-9]+', key) for key in parse_config(config)):
held = mounts(config) or any(re.fullmatch(r'unused[0-9]+', key) for key in parse_config(config))
if held and not discard:
return
if discard:
stop(stage)
run('pct', 'destroy', str(stage))
def discard_stage(state):
"""Remove the holder CT of an operation that ended in a verified recovery.
A failure here leaves it in place and does not undo the recovery."""
try:
release_stage(state, discard=True)
except (OSError, ValueError, RuntimeError, subprocess.SubprocessError) as error:
log(f'cleanup: {error}')
def gib(size):
return f'{size / 1024**3:.1f} GB'
@@ -1124,7 +1138,9 @@ def complete_recovery(root, journal, state):
instances.write(instances.location(root, vmid), restored)
checkpoint(journal, state, 'rolled-back')
if show:
msg_ok(translate('Recovery completed. The displaced disks and the backup are kept; nothing was deleted automatically.'))
# A member of a stack is released when the whole stack is back.
discard_stage(state)
msg_ok(translate('Recovery completed. The disks of the failed attempt were removed; the backup is kept.'))
if state.get('original_host_sources') or state.get('desired_host_sources'):
msg_info2(translate('Shared host files are kept as they are; the backup does not restore their content.'))
+37 -4
View File
@@ -2,12 +2,14 @@
"""Instance recording adapter for the existing dedicated stack installers."""
import argparse
import copy
import ipaddress
import json
import os
from pathlib import Path
import re
import subprocess
import sys
import time
import oci_console
import oci_instances as instances
@@ -103,6 +105,40 @@ def capture_rootfs(root, vmid):
instances.write(instances.location(root, vmid), record)
PRIVATE_STACK_NETWORK = ipaddress.ip_network('10.77.0.0/16')
def access_address(vmid, wait=20):
"""The address a container is reached at from the local network. A member
of a multi-container application also has a leg on its private network,
and that address answers from the host only: it is used when the
container has no other."""
def addresses():
result = subprocess.run(['lxc-info', '-n', str(vmid), '-iH'], capture_output=True, text=True, timeout=5)
found = []
for line in result.stdout.splitlines():
try:
found.append(ipaddress.IPv4Address(line.strip()))
except ValueError:
continue
return found
def outside(found):
return next((str(address) for address in found if address not in PRIVATE_STACK_NETWORK), '')
found = addresses()
config = subprocess.run(['pct', 'config', str(vmid)], capture_output=True, text=True, timeout=30).stdout
legs = re.findall(r'^net[0-9]+: .*?(?:^|,)ip=([^,\s]+)', config, re.MULTILINE)
# A leg outside the private network may still be waiting for its lease.
expected = any(leg == 'dhcp' or (leg[:1].isdigit() and ipaddress.ip_interface(leg).ip not in PRIVATE_STACK_NETWORK)
for leg in legs)
deadline = time.monotonic() + (wait if expected else 0)
while not outside(found) and time.monotonic() < deadline:
time.sleep(2)
found = addresses()
return outside(found) or (str(found[0]) if found else '')
def finalize(root, primary):
parent = instances.read(root, primary)
intent = parent['native_stack_intent']
@@ -118,10 +154,7 @@ def finalize(root, primary):
presentation['catalog_ui'] = {**stack_ui, **(presentation.get('catalog_ui') or {})}
if not (presentation.get('first_run') or {}).get('endpoints'):
presentation['first_run'] = intent['template'].get('first_run') or {}
ip_result = subprocess.run(['lxc-info', '-n', str(vmid), '-iH'],
capture_output=True, text=True, timeout=5)
ip = next((line.strip() for line in ip_result.stdout.splitlines()
if re.fullmatch(r'[0-9]+(?:\.[0-9]+){3}', line.strip())), '')
ip = access_address(vmid)
description = render(presentation, plan['image']['manifest_digest'],
record['installation_id'], ip)
subprocess.run(['pct', 'set', str(vmid), '--description', description], check=True)
+30
View File
@@ -68,6 +68,36 @@ def create_parent(path, root):
os.chown(path, owner.st_uid, owner.st_gid)
def rebuild(root, vmid, same_gpu=False):
"""Point a stopped container at the NVIDIA driver of this host. The caller
holds the registry and the container is not started: it is the step a
restore on a host with another driver needs before the first start.
Returns whether anything had to change."""
record = instances.read(root, vmid)
config = instances.command('pct', 'config', str(vmid))
if not instances.same_config_except_notes(record, config):
raise ValueError(translate('The container identity or configuration changed'))
plan = nv.refresh_plan(config, record['observed']['gpu_devices'][nv.KEY], same_gpu=same_gpu)
if not plan['changed']:
return False
if instances.command('pct', 'status', str(vmid)).strip() != b'status: stopped':
raise ValueError(translate('Stop the container before the NVIDIA refresh'))
instances.command('pct', 'mount', str(vmid))
try:
candidate = prepare(Path(f'/var/lib/lxc/{vmid}/rootfs'), plan)
finally:
instances.command('pct', 'unmount', str(vmid))
Path(f'/etc/pve/lxc/{vmid}.conf').write_bytes(candidate)
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'])
instances.write(instances.location(root, vmid), updated)
return True
def refresh(root, vmid, apply=False):
with instances.locked(root):
record = instances.read(root, vmid)
+4 -2
View File
@@ -75,11 +75,13 @@ def verify(value):
raise ValueError(translate('The NVIDIA driver or inventory changed; the operation was stopped'))
def refresh_plan(config, previous, current=None):
def refresh_plan(config, previous, current=None, same_gpu=True):
"""Resolve current host components without treating a driver version as intent.
This only prepares a plan; applying it requires a stopped-CT transaction and
preparing file destinations/library links before the next native start.
A container restored on another host takes the GPU of that host: the
application asked for the NVIDIA runtime, not for one card.
"""
check_devices(config, previous)
check_mounts(config, previous)
@@ -92,7 +94,7 @@ def refresh_plan(config, previous, current=None):
raise ValueError(translate('Incomplete NVIDIA identity'))
result.append(tuple(fields[:2]))
return sorted(result)
if identities(previous) != identities(current):
if same_gpu and identities(previous) != identities(current):
raise ValueError(translate('The physical NVIDIA selection changed'))
# Remove only entries already validated against our recorded inventory.
kept = []
+5 -1
View File
@@ -29,6 +29,7 @@ CLUSTER_NODES = Path('/etc/pve/nodes')
SNIPPETS = Path('/var/lib/vz/snippets')
# The App tab of ProxMenux Monitor keeps one file per VMID.
MONITOR_APPS = Path('/etc/proxmenux/apps')
CLUSTER_RECORDS = Path('/etc/pve/priv/proxmenux/oci')
HOST_MONITOR_INCLUDES = (Path('/etc/pve/proxmenux/host-monitor'), Path('/etc/pve/lxc/proxmenux-host-monitor'))
STACK_HOOK = 'proxmenux-stack-dependencies.sh'
@@ -136,6 +137,7 @@ def remove_host_state(vmid):
except OSError:
pass
_unlink(hook)
_unlink(CLUSTER_RECORDS / f'{int(vmid)}.json')
_unlink(MONITOR_APPS / f'{int(vmid)}.json')
dismissed = MONITOR_APPS / '.oci-dismissed.json'
try:
@@ -178,6 +180,7 @@ def _leftovers(vmid):
"""Whether anything of the container is still on the host."""
paths = [runtime_settings.include_path(vmid), runtime_settings.legacy_include_path(vmid),
SNIPPETS / f'proxmenux-rclone-{int(vmid)}-fuse-hook.sh', MONITOR_APPS / f'{int(vmid)}.json',
CLUSTER_RECORDS / f'{int(vmid)}.json',
*oci_console.LOG_DIR.glob(f'{int(vmid)}.console.log*')]
return any(path.exists() for path in paths)
@@ -191,7 +194,8 @@ def sweep_orphans(root):
for directory, pattern in ((runtime_settings.include_path(0).parent, r'([0-9]+)\.sysctls'),
(runtime_settings.legacy_include_path(0).parent, r'([0-9]+)\.proxmenux-sysctls'),
(oci_console.LOG_DIR, r'([0-9]+)\.console\.log.*'),
(SNIPPETS, r'proxmenux-rclone-([0-9]+)-fuse-hook\.sh')):
(SNIPPETS, r'proxmenux-rclone-([0-9]+)-fuse-hook\.sh'),
(CLUSTER_RECORDS, r'([0-9]+)\.json')):
if directory.is_dir():
found.update(int(m.group(1)) for m in (re.fullmatch(pattern, p.name) for p in directory.iterdir()) if m)
# Only the App tab registrations of OCI installs; the other ones belong to
File diff suppressed because it is too large Load Diff
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env python3
"""A real inference on the AMD GPU of a container, through ROCm.
That the MIGraphX provider is listed says nothing about the GPU of this host:
an image carries kernels for some generations only, and on any other the
first inference aborts. This runs a small convolution model on the GPU inside
the container and fails when it does not come back with a result.
"""
from __future__ import annotations
import argparse
import re
import subprocess
import sys
# A model of five operators (convolution, pooling and a dense layer), enough
# to make ROCm compile and run on the GPU without downloading anything.
MODEL = (
'CAg65wUKPAoBeAoBdwoBYhIBYyIEQ29udioVCgxrZXJuZWxfc2hhcGVAA0ADoAEHKhEKBHBhZHNAAUABQAFAAaABBwoMCgFjEgFy'
'IgRSZWx1ChkKAXISAWciEUdsb2JhbEF2ZXJhZ2VQb29sCg8KAWcSAWYiB0ZsYXR0ZW4KFAoBZgoCZncKAmZiEgF5IgRHZW1tEgVw'
'cm9iZSrAAwgECAMIAwgDEAFCAXdKsAOu/QA5e7v0POCS4LyqZLa9sDs6vdcWy70cFMU78DwJPpibSb2CJX69qqNIPVEuEj3xtSw8'
'U4++vWu0P7vqZY49x6UJvn5wO71qr0K+dgwEvvuXPL4vlsC8WskBvkI43jwWaYA8QyKZvKzbgL4Lply9i+2euzylOTyXrxy+ELBD'
'vZVmyL1epqW9pUXZPRNipb1fIlW7gB+1PfIKb70yAze8Bfw0PAgA0Ts15Pq9D3/5O74kCz54bR6+ZwCwPbWMQzyGX4O9uNdMPl0c'
'nD1HnfW9vyz0O0o2bD17ppq8K9yLPcf22bv9pog9Ak4TPipgir1BaaY8U8U9vT+EUDwwI/O9LUhtvUe5oLwdEbg9nYrqPX2HB74m'
'vqK9X3yEPRcGTL7itj29GGUfvOW3AD6fMI090AYGvfz3Fr3I9cy8aQIcPqtRL71lxvi8pWsQPczeRbyAnaG8NSnkvaIDl7rdsDW9'
'r9LuPabAhT1DOh67a+KIPeg1C726edc914sNuhP0bj3+LwS+CgAOPUPfLL7talC+b235vCBOuL1eZIY889xlPj9Wqr06kX+9VESo'
'PDHwST0qGQgEEAFCAWJKEAAAAAAAAAAAAAAAAAAAAAAqLAgECAIQAUICZndKIAmDkLy5sqi8S92PPUX0VD1istO9DrsBvIJBZztd'
'9de9KhIIAhABQgJmYkoIAAAAAAAAAABaGwoBeBIWChQIARIQCgIIAQoCCAMKAggICgIICGITCgF5Eg4KDAgBEggKAggBCgIIAkIE'
'CgAQDQ=='
)
PROBE = """
import base64, sys
import numpy as np
import onnxruntime as ort
options = ort.SessionOptions()
options.log_severity_level = 3
session = ort.InferenceSession(base64.b64decode(sys.argv[1]), options, providers=['MIGraphXExecutionProvider'])
assert session.get_providers()[0] == 'MIGraphXExecutionProvider', session.get_providers()
result = session.run(None, {'x': np.ones((1, 3, 8, 8), np.float32)})[0]
assert result.shape == (1, 2) and np.isfinite(result).all(), result
print('ROCm inference on the GPU: ok')
"""
def variables(config):
"""What the container tells ROCm about its GPU. A command run in the
container does not receive the variables its application starts with."""
found = re.findall(r'^lxc\.environment\.runtime: (.+)$', config, re.M)
for line in re.findall(r'^env: (.+)$', config, re.M):
found += line.split('\0')
return [value for value in found if re.fullmatch(r'HSA_[A-Z_]+=[0-9A-Za-z.]+', value)]
def check(vmid, timeout=300):
"""Raises RuntimeError when the container cannot run the model on its GPU."""
vmid = str(int(vmid))
config = subprocess.run(['pct', 'config', vmid], capture_output=True, text=True, check=False).stdout
try:
result = subprocess.run(['pct', 'exec', vmid, '--', 'env', *variables(config), 'python', '-c', PROBE, MODEL],
capture_output=True, text=True, timeout=timeout, check=False)
except subprocess.TimeoutExpired as error:
raise RuntimeError('the GPU did not answer in time') from error
if result.returncode != 0:
detail = (result.stderr or result.stdout).strip().splitlines()[-1:] or ['']
raise RuntimeError(detail[0][:300])
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('vmid', type=int)
args = parser.parse_args()
try:
check(args.vmid)
except RuntimeError as error:
print(error, file=sys.stderr)
return 1
return 0
if __name__ == '__main__':
sys.exit(main())
+20 -11
View File
@@ -377,6 +377,8 @@ class NativeAdapter:
member_tx.run('pct', 'exec', str(vmid), '--', 'python', '-c',
'import onnxruntime as ort; '
'assert "MIGraphXExecutionProvider" in ort.get_available_providers()')
import oci_rocm_check
oci_rocm_check.check(vmid)
if acceleration in ('openvino', 'cuda'):
member_tx.run('pct', 'exec', str(vmid), '--', 'python', '-c',
'import sys,ctypes,onnxruntime as ort; p=sys.argv[1]; '
@@ -561,7 +563,7 @@ class NativeAdapter:
record.pop('pending_stack_transaction', None)
instances.write(instances.location(self.root, vmid), record)
try:
self.release_stages()
self.release_stages(recovered=state['phase'] == 'rolled-back')
if self.keep_backup and state['phase'] == 'committed':
import oci_keep_backup
for backup in sorted(self.journal.parent.glob('backup-*/vzdump-lxc-*.tar.zst')):
@@ -574,18 +576,25 @@ class NativeAdapter:
except (OSError, ValueError) as exc:
member_tx.log(f'cleanup: {exc}')
def release_stages(self):
"""The temporary containers that held the data of each member; one
that still has a disk attached is kept."""
def release_stages(self, recovered=False):
"""The temporary containers that held the data of each member. When
the whole stack was recovered they are removed with the disks of the
failed attempt; otherwise one that still has a disk attached is kept."""
journals = [(path, True) for path in self.journal.parent.glob('recovery-*/transaction.json')]
for vmid in self.records:
folder = instances.location(self.root, vmid).parent / 'transactions'
for member_journal in folder.glob('*/transaction.json'):
try:
state = json.loads(member_journal.read_text())
except (OSError, ValueError):
continue
if (state.get('coordinated') or {}).get('journal') == str(self.journal):
member_tx.release_stage(state)
journals += [(path, False) for path in folder.glob('*/transaction.json')]
for member_journal, own in journals:
try:
state = json.loads(member_journal.read_text())
except (OSError, ValueError):
continue
if not own and (state.get('coordinated') or {}).get('journal') != str(self.journal):
continue
if recovered and state.get('phase') == 'rolled-back':
member_tx.discard_stage(state)
else:
member_tx.release_stage(state)
def prune_backups(self, include_current):
"""The backups of closed operations are removed, those of this one
+4 -4
View File
@@ -12,7 +12,7 @@ import tempfile
import oci_instances as instances
import oci_instance_transaction as transaction
from oci_installation_state import image_from_archive
from oci_installation_state import image_from_archive, same_image
from oci_ui import translate, msg_info, msg_ok, msg_warn, msg_error, msg_info2
@@ -65,7 +65,7 @@ def resolve_archive(desired, config, current=None, check=None):
if not re.fullmatch(r'sha256:[a-f0-9]{64}', digest):
raise ValueError(translate('Invalid registry digest'))
msg_ok(f"{translate('Image:')} {reference} ({candidate.get('version') or digest[7:19]})")
if digest == current:
if current and same_image(candidate, current):
return None, digest
if check:
check()
@@ -85,7 +85,7 @@ def resolve_archive(desired, config, current=None, check=None):
translate('The image did not pass the integrity check'))
except RuntimeError:
return False
return image_from_archive(str(path))['manifest_digest'] == digest
return same_image(candidate, image_from_archive(str(path)))
if archive.exists():
msg_info(translate('Verifying the image integrity...'))
@@ -161,7 +161,7 @@ def update(vmid, acknowledge_external_data=False, proposal=None, keep_backup=Non
changes = transaction.external_changes(record, config)
transaction.preflight(record, desired, config)
msg_ok(translate('Container checked'))
current = record['observed']['image']['manifest_digest'] if operation == 'update' else None
current = record['observed']['image'] if operation == 'update' else None
archive, digest = resolve_archive(desired, config, current, lambda: transaction.require_backup_space(
instances.location(instances.ROOT, vmid).parent, [vmid]))
if archive is None:
+14 -1
View File
@@ -17,10 +17,23 @@ find_snippet_storage() {
fi
storage=$(pvesm status --content snippets 2>/dev/null \
| awk 'NR > 1 && $3 == "active" {print $1; exit}')
[[ -n $storage ]] || die "No active storage supports snippets"
if [[ -z $storage ]]; then
# A new Proxmox installation accepts no snippets on any storage.
enable_local_snippets || die "No active storage supports snippets"
storage=local
fi
printf '%s' "$storage"
}
enable_local_snippets() {
local content
content=$(pvesh get /storage/local --output-format json 2>/dev/null | jq -r '.content // empty')
[[ -n $content ]] || return 1
pvesm set local --content "${content},snippets" >/dev/null 2>&1 || return 1
pvesm status --content snippets 2>/dev/null \
| awk 'NR > 1 && $1 == "local" && $3 == "active" {found=1} END {exit !found}'
}
install_hook() {
local main_id=${1:?missing main VMID} source_config=${2:?missing lifecycle JSON}
local storage hook_volume hook_path target_config
+1 -2
View File
@@ -575,7 +575,6 @@ def _stack_storage(services: dict[str, Any], markers: dict[str, set[str]]) -> li
r"^/(?:data|downloads?|media|movies?|music|photos?|pictures?|recordings?|tv|videos?)(?:/|$)|/(?:library|uploads?)(?:/|$)",
re.I,
)
disposable_targets = {"/cache", "/tmp", "/transcode"}
system_targets = {"/etc/localtime", "/etc/timezone"}
runtime_targets = {"/var/run/docker.sock", "/run/docker.sock"}
for service_name, service in services.items():
@@ -609,7 +608,7 @@ def _stack_storage(services: dict[str, Any], markers: dict[str, set[str]]) -> li
"container_path": target,
"mode": mode,
"user_selectable": shareable,
"backup": mode == "managed-volume" and target not in disposable_targets,
"backup": mode == "managed-volume",
"shared_with_other_lxc": shareable,
"source_path": str(source) if system_bind else None,
"source_path_prompt": (
+11
View File
@@ -422,6 +422,8 @@ def install_template(ui, template: dict[str, Any], identifier: str, mode: str) -
console.wait_for_enter(translate("Press Enter to return to the menu..."))
return None
if result:
from .management import carry_records
carry_records(PROJECT_ROOT)
vmids = {int(v) for v in [result.get("vmid"), *(result.get("stack_vmids") or {}).values()] if v}
_, removed = images.offer_removal(ui, sorted(vmids))
_print_installation_summary(result, removed)
@@ -436,6 +438,8 @@ def _rclone_mount(catalog: Catalog, ui) -> None:
console.msg_title(translate("Rclone mount"))
result = run_remote_rclone_mount(PROJECT_ROOT, template, deployment, "auto")
if result:
from .management import carry_records
carry_records(PROJECT_ROOT)
console.msg_ok(f"Remote: {result['remote']}:")
console.msg_ok(f"{translate('Read/write')}: {result['read_write_path']}")
console.msg_ok(f"{translate('Read-only')}: {result['read_only_path']}")
@@ -590,6 +594,7 @@ def build_parser() -> argparse.ArgumentParser:
rclone_parser = subparsers.add_parser("rclone-mount", help="Enable a mount on an installed Rclone OCI")
rclone_parser.add_argument("--host", default="auto")
rclone_parser.add_argument("--dry-run", action="store_true")
subparsers.add_parser("recover", help="Register again the OCI applications restored from a backup")
manage_parser = subparsers.add_parser("manage", help="Update or recreate one installed OCI instance")
manage_parser.add_argument("vmid", type=int)
manage_parser.add_argument("--action", choices=("update", "recreate"), required=True)
@@ -654,8 +659,14 @@ def main(argv: list[str] | None = None) -> int:
return 0
result = run_remote_install(PROJECT_ROOT, template, deployment, args.host, args.dry_run)
if result:
if not args.dry_run:
from .management import carry_records
carry_records(PROJECT_ROOT)
_print_installation_summary(result)
return 0
if args.command == "recover":
from .management import direct_recovery
return direct_recovery(PROJECT_ROOT)
if args.command == "manage":
from .management import direct_management
lifecycle_args = []
+1 -1
View File
@@ -233,7 +233,7 @@ def _mount_contract(value: Any, optional: set[str]) -> list[dict[str, Any]]:
else "skip"
),
"managed_volume": {
"backup": target not in {"/cache", "/tmp", "/transcode"},
"backup": True,
"default_size_gb": 4 if target == "/config" else 8,
},
}
+79 -2
View File
@@ -141,7 +141,7 @@ def gpus(root: Path = Path("/")) -> dict[str, Any]:
"""The GPUs an installation can use: the render nodes of each Intel and
AMD GPU, and whether NVIDIA is usable on the host."""
vendors = {"0x8086": "intel", "0x1002": "amd"}
found: dict[str, Any] = {"intel": [], "amd": [], "nvidia": False}
found: dict[str, Any] = {"intel": [], "amd": [], "nvidia": False, "amd_gfx_target": None}
for node in sorted((root / "sys/class/drm").glob("renderD*")):
vendor = vendors.get(_sysfs(node / "device/vendor").lower())
if vendor:
@@ -153,14 +153,91 @@ def gpus(root: Path = Path("/")) -> dict[str, Any]:
timeout=15, check=False).returncode == 0
except (OSError, subprocess.TimeoutExpired):
found["nvidia"] = False
found["amd_gfx_target"] = amd_gfx_target(root)
return found
def amd_gfx_target(root: Path = Path("/")) -> int | None:
"""The generation of the AMD GPU as its compute driver reports it: 90012
for gfx90c (Vega integrated graphics), 100300 for gfx1030 (RDNA2). None
when the driver exposes no compute device."""
best = None
for path in sorted((root / "sys/class/kfd/kfd/topology/nodes").glob("*/properties")):
match = re.search(r"^gfx_target_version (\d+)$", _sysfs(path), re.MULTILINE)
if match and int(match[1]) > 0:
best = max(best or 0, int(match[1]))
return best
# The GPU generations the ROCm images of the catalog carry kernels for: RDNA2
# and newer, as gfx_target_version reports them, and the Instinct accelerators.
# On any other one the compute process of the image aborts.
ROCM_TARGETS = (100300, 110000, 110001, 110002, 110500, 110501, 120000, 120001)
ROCM_ACCELERATOR_TARGETS = (90008, 90010, 90402, 90500)
def gfx_name(target: int) -> str:
"""The name ROCm gives a generation: 90012 is gfx90c, 100300 is gfx1030."""
return f"gfx{target // 10000}{target // 100 % 100}{target % 100:x}"
def rocm_override(target: int | None) -> str | None:
"""The generation to present to ROCm, as HSA_OVERRIDE_GFX_VERSION, on a
GPU its images carry no kernels for but whose family they do: the RDNA2
ones (the Radeon 680M among them) as gfx1030 and the RDNA3 ones (the
Radeon 780M) as gfx1100. None when the GPU needs none or has none."""
if target is None or target in ROCM_TARGETS + ROCM_ACCELERATOR_TARGETS:
return None
if 100300 < target < 100400:
return "10.3.0"
if 110000 < target < 110100:
return "11.0.0"
return None
def rocm_support(target: int | None, targets=ROCM_TARGETS + ROCM_ACCELERATOR_TARGETS) -> str | None:
"""How an image with kernels for `targets` runs on this GPU: "native",
"experimental" when the GPU has to be presented as the generation of its
family, which ROCm does not support officially and can stop responding
under load, or None when it does not run. A driver that does not say the
generation does not rule the GPU out."""
if target is None or target in targets:
return "native"
override = rocm_override(target)
if override is None:
return None
major, minor, stepping = (int(part) for part in override.split("."))
return "experimental" if major * 10000 + minor * 100 + stepping in targets else None
def amd_gpu_name(root: Path = Path("/")) -> str:
"""The AMD GPU of the host as its owner knows it, with the generation ROCm
sees: "Radeon 680M (gfx1035)"."""
target = amd_gfx_target(root)
generation = gfx_name(target) if target else ""
name = ""
try:
listed = subprocess.run(["lspci", "-nn"], capture_output=True, text=True, timeout=5, check=False).stdout
except (OSError, subprocess.TimeoutExpired):
listed = ""
for line in listed.splitlines():
described = re.search(r"\[AMD/ATI\]\s*(.*?)\s*\[1002:", line)
if described and re.search(r"VGA|Display|3D", line):
marketed = re.search(r"\[([^\]]+)\]", described[1])
name = marketed[1] if marketed else described[1]
break
name = name or "AMD GPU"
return f"{name} ({generation})" if generation else name
def rocm_blocker(storage: str | None, needed_gb: int = 40) -> str | None:
"""Why this host cannot run recognition on an AMD GPU with ROCm, or None.
ROCm needs the compute interface of the driver and room for its image."""
ROCm needs the compute interface of the driver, a GPU of a generation its
image supports and room for that image."""
if not Path("/dev/kfd").is_char_device():
return "kfd"
if rocm_support(amd_gfx_target()) is None:
return "generation"
row = next((item for item in storages("rootdir") if item.get("storage") == storage), None)
if row is not None and gib(row.get("avail")) < needed_gb:
return "space"
+89 -34
View File
@@ -805,20 +805,45 @@ def _ask_immich_acceleration(ui, rootfs_storage: str | None = None) -> tuple[str
if not vendors:
real.message(translate("No usable GPU was found on this host. Immich will be installed on the CPU."))
return software
# Recognition on an AMD GPU depends on ROCm: it is offered when the host
# can run it, marked as experimental on a GPU ROCm does not support
# officially, and left out, with the reason, when it cannot.
amd_blocker = host.rocm_blocker(rootfs_storage) if found["amd"] else None
amd_experimental = (found["amd"] and not amd_blocker
and host.rocm_support(found.get("amd_gfx_target")) == "experimental")
if amd_blocker:
reason = (translate("The AMD driver does not offer its compute interface (/dev/kfd) on this host.")
if amd_blocker == "kfd" else
f"{translate('The ROCm image has no support for the AMD GPU of this host:')} {host.amd_gpu_name()}."
if amd_blocker == "generation" else
translate("The storage has less than 40 GB free for the ROCm image."))
real.message(f"{reason}\n\n{translate('Recognition is not offered on the AMD GPU; video transcoding is.')}")
mark = f" — {translate('experimental on this GPU')}" if amd_experimental else ""
options = [("cpu", translate("No acceleration (CPU)"))]
for vendor in vendors:
options += [(vendor, f"{names[vendor]}: {translate('video + recognition')}"),
(f"{vendor}-video", f"{names[vendor]}: {translate('video only')}"),
(f"{vendor}-ml", f"{names[vendor]}: {translate('recognition only')}")]
recognition = vendor != "amd" or not amd_blocker
suffix = mark if vendor == "amd" else ""
if recognition:
options.append((vendor, f"{names[vendor]}: {translate('video + recognition')}{suffix}"))
options.append((f"{vendor}-video", f"{names[vendor]}: {translate('video only')}"))
if recognition:
options.append((f"{vendor}-ml", f"{names[vendor]}: {translate('recognition only')}{suffix}"))
if found["nvidia"]:
options += [(f"{vendor}+nvidia", f"{names[vendor]}: {translate('video')} · NVIDIA: {translate('recognition')}")
for vendor in ("intel", "amd") if found[vendor]]
# The GPU matters for Immich, so the first one is proposed whole.
selected = real.choose(translate("Hardware acceleration for Immich"), options, vendors[0])
if selected is None:
raise UserCancelled(translate("Immich configuration cancelled"))
if selected == "cpu":
return software
# The GPU matters for Immich, so the first one is proposed whole; what is
# experimental or missing is never the proposal.
proposed = vendors[0]
if proposed == "amd" and (amd_blocker or amd_experimental):
proposed = "amd-video"
while True:
selected = real.choose(translate("Hardware acceleration for Immich"), options, proposed)
if selected is None:
raise UserCancelled(translate("Immich configuration cancelled"))
if selected == "cpu":
return software
if not (amd_experimental and selected in ("amd", "amd-ml")) or confirm_experimental_rocm(real):
break
if "+" in selected:
video_vendor, ml_vendor = selected.split("+", 1)
else:
@@ -848,20 +873,10 @@ def _ask_immich_acceleration(ui, rootfs_storage: str | None = None) -> tuple[str
elif ml_vendor == "intel":
ml_acceleration, ml_render = "openvino", render_device or found["intel"][0]
elif ml_vendor == "amd":
# ROCm is checked before it is promised; when the host cannot run it,
# recognition stays on the CPU.
blocker = host.rocm_blocker(rootfs_storage)
if blocker:
reason = (translate("The AMD driver does not offer its compute interface (/dev/kfd) on this host.")
if blocker == "kfd" else
translate("The storage has less than 40 GB free for the ROCm image."))
real.message(f"{reason}\n\n{translate('Recognition runs on the CPU.')}")
else:
ml_acceleration, ml_render = "rocm", render_device or found["amd"][0]
if ml_acceleration == "rocm":
ml_acceleration, ml_render = "rocm", render_device or found["amd"][0]
if ml_acceleration == "rocm" and not amd_experimental:
real.message(translate("Recognition on AMD uses ROCm. Its image is several times larger than the others, "
"so the first installation takes longer, and whether a GPU works with it depends "
"on its model."))
"so the first installation takes longer."))
if ml_acceleration != "cpu":
ui.message(translate("GPU recognition uses 8 GB of RAM and a limit of 4 CPU equivalents. These resources "
"were tested in the lab and are not a universal minimum. Compatibility depends on the "
@@ -962,6 +977,10 @@ def _build_immich_deployment(
"driver": vaapi_driver,
},
"machine_learning": {"acceleration": ml_acceleration, "render_device": ml_render,
# The generation ROCm is told to use on a GPU of a
# family its image supports, such as the 680M.
"gfx_override": (host.rocm_override(host.gpus().get("amd_gfx_target"))
if ml_acceleration == "rocm" else None),
"model_cache_size_gb": 8,
"resources": {"cores": 4,
"memory_mb": 4096 if ml_acceleration == "cpu" else 8192,
@@ -1428,7 +1447,7 @@ def _run_remote_install(
shutil.copy2(project_root / 'remote' / 'haos_healthcheck.py', temporary / 'haos_healthcheck.py')
shutil.copy2(project_root / 'remote' / 'oci_installation_state.py', temporary / 'oci_installation_state.py')
shutil.copy2(project_root / 'remote' / 'oci_instances.py', temporary / 'oci_instances.py')
for helper in ('oci_ui.sh', 'oci_ui.py', 'oci_description.py', 'oci_console.py', 'oci_native_stack.py', 'oci_native_stack.sh', 'oci_instance_transaction.py', 'oci_host_mounts.py', 'oci_runtime_settings.py', 'oci_gpu_devices.py', 'oci_accelerators.py', 'oci_nvidia_runtime.py', 'oci_nvidia_refresh.py', 'oci_nvidia_dynamic.py', 'oci_update_current.py', 'oci_stack_replay.py', 'oci_stack_plan.py', 'oci_stack_transaction.py', 'oci_stack_native.py', 'oci_image_cache.py', 'nvidia_lxc_mount_lab.sh', 'oci_nvidia_setup.sh', 'oci_immich_ml.sh'):
for helper in ('oci_ui.sh', 'oci_ui.py', 'oci_description.py', 'oci_console.py', 'oci_native_stack.py', 'oci_native_stack.sh', 'oci_instance_transaction.py', 'oci_host_mounts.py', 'oci_runtime_settings.py', 'oci_gpu_devices.py', 'oci_accelerators.py', 'oci_nvidia_runtime.py', 'oci_nvidia_refresh.py', 'oci_nvidia_dynamic.py', 'oci_update_current.py', 'oci_stack_replay.py', 'oci_stack_plan.py', 'oci_stack_transaction.py', 'oci_stack_native.py', 'oci_image_cache.py', 'nvidia_lxc_mount_lab.sh', 'oci_nvidia_setup.sh', 'oci_immich_ml.sh', 'oci_rocm_check.py'):
shutil.copy2(project_root / 'remote' / helper, temporary / helper)
if deployment_kind == 'generic-multi-lxc-stack':
shutil.copy2(project_root / 'remote' / 'install_generic_stack.py', temporary / 'install_generic_stack.py')
@@ -1465,7 +1484,7 @@ def _run_remote_install(
archive.add(project_root / 'remote' / 'haos_healthcheck.py', arcname='haos_healthcheck.py')
archive.add(project_root / 'remote' / 'oci_installation_state.py', arcname='oci_installation_state.py')
archive.add(project_root / 'remote' / 'oci_instances.py', arcname='oci_instances.py')
for helper in ('oci_ui.sh', 'oci_ui.py', 'oci_description.py', 'oci_console.py', 'oci_native_stack.py', 'oci_native_stack.sh', 'oci_instance_transaction.py', 'oci_host_mounts.py', 'oci_runtime_settings.py', 'oci_gpu_devices.py', 'oci_accelerators.py', 'oci_nvidia_runtime.py', 'oci_nvidia_refresh.py', 'oci_nvidia_dynamic.py', 'oci_update_current.py', 'oci_stack_replay.py', 'oci_stack_plan.py', 'oci_stack_transaction.py', 'oci_stack_native.py', 'oci_image_cache.py', 'nvidia_lxc_mount_lab.sh', 'oci_nvidia_setup.sh', 'oci_immich_ml.sh'):
for helper in ('oci_ui.sh', 'oci_ui.py', 'oci_description.py', 'oci_console.py', 'oci_native_stack.py', 'oci_native_stack.sh', 'oci_instance_transaction.py', 'oci_host_mounts.py', 'oci_runtime_settings.py', 'oci_gpu_devices.py', 'oci_accelerators.py', 'oci_nvidia_runtime.py', 'oci_nvidia_refresh.py', 'oci_nvidia_dynamic.py', 'oci_update_current.py', 'oci_stack_replay.py', 'oci_stack_plan.py', 'oci_stack_transaction.py', 'oci_stack_native.py', 'oci_image_cache.py', 'nvidia_lxc_mount_lab.sh', 'oci_nvidia_setup.sh', 'oci_immich_ml.sh', 'oci_rocm_check.py'):
archive.add(project_root / 'remote' / helper, arcname=helper)
if deployment_kind == 'generic-multi-lxc-stack':
archive.add(project_root / 'remote' / 'install_generic_stack.py', arcname='install_generic_stack.py')
@@ -1678,6 +1697,11 @@ def _profile_usable(profile: dict[str, Any], found: dict[str, Any]) -> bool:
"""Whether the host has what an acceleration profile needs: the NVIDIA
runtime, a GPU of the vendor it is written for, or ROCm's compute device."""
vendors = {"0x8086": "intel", "0x1002": "amd"}
# An image built for ROCm carries the kernels of some GPU generations
# only; on an older one its compute process aborts.
targets = profile.get("amd_gfx_targets")
if targets and host.rocm_support(found.get("amd_gfx_target"), tuple(targets)) is None:
return False
for request in profile.get("device_requests", []):
if request.get("kind") == "nvidia-runtime":
if not found["nvidia"]:
@@ -1694,6 +1718,20 @@ def _profile_usable(profile: dict[str, Any], found: dict[str, Any]) -> bool:
return True
def confirm_experimental_rocm(ui) -> bool:
"""What using ROCm means on a GPU it does not support officially, said
before the larger image is downloaded."""
return ui.confirm(
f"{host.amd_gpu_name()}\n\n"
+ translate("ROCm does not support this GPU officially. The application can use it by presenting it as "
"another generation of its family: it is faster than the CPU, but under load the GPU can stop "
"responding, and then the application does not answer until its container is restarted. The "
"ROCm image is also larger than the others.")
+ "\n\n" + translate("If it does not work well, the acceleration can be changed back from Manage installed "
"OCI applications, without reinstalling.")
+ "\n\n" + translate("Use the GPU with ROCm anyway?"), False)
def configure_acceleration(installer_profile, environment, unprivileged, ui, mode=ADVANCED_MODE):
advanced = mode != DEFAULT_MODE
devices: list[dict[str, Any]] = []
@@ -1710,22 +1748,35 @@ def configure_acceleration(installer_profile, environment, unprivileged, ui, mod
found = host.gpus()
usable = [item for item in profiles
if item["id"] == default_profile or _profile_usable(item, found)]
experimental = {item["id"] for item in usable if item.get("amd_gfx_targets") and host.rocm_support(
found.get("amd_gfx_target"), tuple(item["amd_gfx_targets"])) == "experimental"}
options = [(item["id"], item["label"]) for item in usable]
asked = advanced or not installer_profile.get("selkies")
if asked and found["amd"] and any(item.get("amd_gfx_targets") and item not in usable and host.rocm_support(
found.get("amd_gfx_target"), tuple(item["amd_gfx_targets"])) is None for item in profiles):
# Its owner may expect the GPU to be offered: say why it is not.
ui.message(f"{translate('The ROCm image of this application has no support for the AMD GPU of this host:')} "
f"{host.amd_gpu_name()}. {translate('Its ROCm profile is not offered.')}")
if asked and len(usable) < len(profiles) and len(usable) == 1:
# Nothing but the CPU is left: say why there is nothing to choose.
ui.message(translate("No usable GPU was found on this host. The application will be installed "
"without hardware acceleration."))
asked = False
selected_hardware_profile = (
ui.choose(
translate(hardware.get("prompt", "Hardware acceleration")),
[(tag, translate(label)) for tag, label in options],
default_profile,
) if asked else default_profile
)
if selected_hardware_profile is None:
raise UserCancelled(translate("Acceleration configuration cancelled"))
while True:
selected_hardware_profile = (
ui.choose(
translate(hardware.get("prompt", "Hardware acceleration")),
[(tag, translate(label) + (f" — {translate('experimental on this GPU')}" if tag in experimental else ""))
for tag, label in options],
default_profile,
) if asked else default_profile
)
if selected_hardware_profile is None:
raise UserCancelled(translate("Acceleration configuration cancelled"))
# The profile an application already uses is not asked about again.
if (selected_hardware_profile not in experimental or not asked
or selected_hardware_profile == default_profile or confirm_experimental_rocm(ui)):
break
selected_profile = next(
(item for item in profiles if item["id"] == selected_hardware_profile),
None,
@@ -1736,7 +1787,11 @@ def configure_acceleration(installer_profile, environment, unprivileged, ui, mod
{**item, "selected_by_hardware_profile": True}
for item in selected_profile.get("device_requests", [])
]
for env_item in selected_profile.get("environment", []):
profile_environment = list(selected_profile.get("environment", []))
if selected_hardware_profile in experimental:
profile_environment.append({"name": "HSA_OVERRIDE_GFX_VERSION",
"value": host.rocm_override(found.get("amd_gfx_target"))})
for env_item in profile_environment:
environment = [
entry for entry in environment if entry["name"] != env_item["name"]
]
+97
View File
@@ -125,11 +125,98 @@ def _run_lifecycle(command, title):
console.msg_title(title)
environment = dict(os.environ, OCI_SPINNER='1' if sys.stdout.isatty() else '0')
completed = subprocess.run(command, env=environment, check=False)
carry_records(images.PROJECT_ROOT)
if sys.stdin.isatty():
console.wait_for_enter(translate('Press Enter to return to the menu...'))
return completed.returncode == 0
def carry_records(project, mount_stopped=True, vmids=None, verify=False):
"""Leave inside each container the copy of its record that a restore on
another host needs. It is refreshed after every operation. Returns what
was found for each container."""
sys.path.insert(0, str(project / 'remote'))
try:
import oci_carried_record
return oci_carried_record.sync(oci_carried_record.instances.ROOT, vmids, mount_stopped, verify)
except (ImportError, OSError, ValueError, RuntimeError, subprocess.SubprocessError):
return {}
def recover_automatically(project):
"""Register, without asking, the containers whose record the cluster
keeps: the ones that migrated to this node. Nothing is started."""
if not restored_applications(project):
return
try:
subprocess.run([sys.executable, str(project / 'remote/oci_restore_recovery.py'), 'recover', '--automatic'],
capture_output=True, timeout=600, check=False)
except (OSError, subprocess.SubprocessError):
pass
def restored_applications(project):
"""Restored containers of this node that have no record on this host."""
sys.path.insert(0, str(project / 'remote'))
try:
import oci_restore_recovery
return oci_restore_recovery.pending(oci_restore_recovery.instances.ROOT)
except (ImportError, OSError, ValueError, RuntimeError):
return []
def _restored_firewall_rules(project):
"""The host firewall rules the restored host monitors had, as they would
be on this host."""
try:
result = subprocess.run([sys.executable, str(project / 'remote/oci_restore_recovery.py'), 'plan'],
capture_output=True, text=True, timeout=300, check=False)
plans = json.loads(result.stdout) if result.returncode == 0 else []
except (OSError, ValueError, subprocess.SubprocessError):
return []
return [rule for plan in plans if not plan.get('blockers') for rule in plan.get('firewall', [])]
def offer_recovery(project, ui):
"""A restored application cannot be managed until this host knows it
again: offer to register it before the list is shown."""
found = restored_applications(project)
if not found:
return
lines = '\n'.join(f" CT {row['vmid']} {row['hostname']}" for row in found)
text = (f"{translate('These containers were restored from a backup and this host has no record of their OCI application:')}"
f"\n\n{lines}\n\n"
f"{translate('Until they are registered again they cannot be updated or managed from here. The recovery checks first that everything they need is on this host and changes nothing otherwise.')}"
f"\n\n{translate('Recover them now?')}")
if not ui.confirm(text, default=True):
return
command = [sys.executable, str(project / 'remote/oci_restore_recovery.py'), 'recover']
for rule in _restored_firewall_rules(project):
if ui.confirm(translate('CT {vmid} is a host monitor and had a rule in the host firewall. Allow TCP port {port} from {subnet} through the firewall of this host? Existing firewall rules are not changed.').format(
vmid=rule['vmid'], port=rule['port'], subnet=rule['source']), default=False):
command.append('--host-firewall')
break
if ui.confirm(translate('Start the applications once they are registered? Answer No if the original containers are still running on another host: both would use the same addresses.'),
default=False):
command.append('--start')
_run_lifecycle(command, translate('Recover restored OCI applications'))
def direct_recovery(project):
"""The recovery of restored applications without the list of the menu: the
entry ProxMenux Monitor uses for its Recover button."""
from .ui import interactive_ui
ui = interactive_ui()
if os.geteuid() != 0 or not shutil.which('pct'):
ui.message(translate('This interface runs on the Proxmox node as root. Open OCI manager Apps from the ProxMenux menu on the Proxmox host.'), translate('OCI management'))
return EXIT_FAILED
if not restored_applications(project):
ui.message(translate('No restored OCI application is waiting to be recovered.'), translate('Restored OCI applications'))
return EXIT_DONE
offer_recovery(project, ui)
return EXIT_DONE if not restored_applications(project) else EXIT_FAILED
def interactive_management(project, ui):
try:
_interactive_management(project, ui)
@@ -157,6 +244,9 @@ def _interactive_management(project, ui):
ui.message(translate('This interface runs on the Proxmox node as root. Open OCI manager Apps from the ProxMenux menu on the Proxmox host.'), translate('OCI management'))
return
_clean_orphans(project)
recover_automatically(project)
offer_recovery(project, ui)
carry_records(project, mount_stopped=False)
rows = saved_inventory(project)
if not rows:
ui.message(translate('No registered OCI containers are available for selection on this host.'), translate('OCI management'))
@@ -184,6 +274,12 @@ def manage_instance(project, ui, row, action=None, lifecycle_args=()):
"""What the menu does with one instance once it is selected. `action`
skips the choice of operation, as ProxMenux Monitor does; the extra
`lifecycle_args` are passed to the program that performs it."""
# A container that came back from another node or from an older backup
# carries the record that describes it; the one of this host is not used.
if carry_records(project, vmids=[row['vmid']], verify=True).get(row['vmid']) == 'stale':
ui.message(translate('This container was restored or came back from another host after its record on this host was written. Open this menu again to recover it; nothing was changed.'),
translate('OCI management'))
return False
row = check_selected(project, row)
if row['reason'] != 'matched':
ui.message(translate('The selected CT does not match its OCI record. Its configuration will not be modified or deleted.'), translate('OCI management'))
@@ -500,6 +596,7 @@ def direct_management(project, vmid, action, lifecycle_args=(), unattended=False
ui.message(translate('Only the update of the image runs unattended.'), translate('OCI management'))
return EXIT_FAILED
try:
recover_automatically(project)
row = next((r for r in saved_inventory(project) if r['vmid'] == vmid), None)
if row is None:
ui.message(translate('This container is not a registered OCI instance.'), translate('OCI management'))
+71
View File
@@ -144,7 +144,78 @@ def summary(member, changes):
return '\n'.join(lines)
def immich_learning(primary):
"""The machine learning container of an Immich, or None for any other application."""
for member in primary.get('stack', {}).get('members', []):
if member.get('deployment', {}).get('replay_profile') == {'adapter': 'install_immich_stack.sh',
'role': 'machine-learning'}:
return member
return None
def recognition_choices(storage):
"""What can run the recognition of Immich on this host, each with the
devices it needs."""
from . import host
found = host.gpus()
choices = [('cpu', translate('CPU'), {})]
if found['intel']:
choices.append(('openvino', 'Intel GPU', {'render': found['intel'][0]}))
if found['nvidia']:
choices.append(('cuda', 'NVIDIA GPU', {}))
if found['amd'] and not host.rocm_blocker(storage):
experimental = host.rocm_support(found.get('amd_gfx_target')) == 'experimental'
label = 'AMD GPU' + (f" — {translate('experimental on this GPU')}" if experimental else '')
choices.append(('rocm', label, {'render': found['amd'][0], 'experimental': experimental,
'override': host.rocm_override(found.get('amd_gfx_target'))}))
return choices
def change_recognition(project, ui, primary, member, run_lifecycle):
"""Move the recognition of Immich between the CPU and a GPU of the host."""
from .installer import confirm_experimental_rocm
current = (member.get('deployment', {}).get('machine_learning') or {}).get('acceleration', 'cpu')
storage = _config(member['vmid']).get('rootfs', 'local-lvm:').split(':', 1)[0]
choices = recognition_choices(storage)
if [tag for tag, _, _ in choices] == ['cpu'] and current == 'cpu':
ui.message(translate('No usable GPU was found on this host. Recognition stays on the CPU.'),
translate('Recreate OCI'))
return False
selected = ui.choose(translate('What runs the recognition of Immich'),
[(tag, label) for tag, label, _ in choices], current)
if selected is None or selected == current:
ui.message(translate('Nothing was changed.'), translate('Recreate OCI'))
return False
details = next(extra for tag, _, extra in choices if tag == selected)
if details.get('experimental') and not confirm_experimental_rocm(ui):
return False
labels = {tag: label for tag, label, _ in choices}
text = (f"{translate('Recognition')}: {labels.get(current, current)} → {labels[selected]}\n\n"
+ translate('The machine learning container is rebuilt with the image of the new choice. Its model '
'cache, the library and the database are kept. The whole application is stopped and '
'updated, as in an update; if anything fails, the previous containers and the previous '
'choice are restored.'))
if not ui.review(text, translate('Recreate OCI'), question=translate('Change the recognition now?'), default=True):
return False
command = [sys.executable, str(project / 'remote/oci_immich_recognition.py'), str(primary['vmid']),
'--acceleration', selected]
if details.get('render'):
command += ['--render-device', details['render']]
if details.get('override'):
command += ['--gfx-override', details['override']]
return run_lifecycle(command, translate('Recreate OCI'))
def recreate_stack(project, ui, primary, run_lifecycle):
learning = immich_learning(primary)
if learning is not None:
what = ui.choose(translate('What to recreate'),
[('paths', translate('Add or remove extra paths and devices')),
('recognition', translate('Change what runs recognition: CPU or GPU'))], 'paths')
if what is None:
return False
if what == 'recognition':
return change_recognition(project, ui, primary, learning, run_lifecycle)
members = application_members(primary)
if not members:
ui.message(translate('This stack has no saved members to update.'), translate('OCI stack management'))
+81
View File
@@ -0,0 +1,81 @@
"""The record of an installation travels inside its container, so a backup
restored on another host still carries it."""
import json
import os
from pathlib import Path
import sys
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "remote"))
import oci_carried_record as carried
def bundle(**extra):
return {"schema_version": 1, "kind": carried.KIND, "node": "pve", "vmid": 120,
"record": {"vmid": 120, "installation_id": "488ed3cb-1145-477a-b90f-c46777d69fb2"}, **extra}
class CarriedRecordTests(unittest.TestCase):
def setUp(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
self.rootfs = Path(tmp.name) / "rootfs"
self.rootfs.mkdir()
self.outside = Path(tmp.name) / "outside"
self.outside.mkdir()
self.owner = (os.getuid(), os.getgid())
def test_the_copy_is_private_and_read_back_as_written(self):
carried.write_copy(self.rootfs, bundle(), self.owner)
path = self.rootfs / carried.DIRECTORY / carried.NAME
self.assertEqual(path.stat().st_mode & 0o777, 0o600)
self.assertEqual(path.parent.stat().st_mode & 0o777, 0o700)
self.assertEqual(carried.read_copy(self.rootfs), bundle())
self.assertEqual(os.listdir(path.parent), [carried.NAME])
def test_a_newer_record_replaces_the_copy(self):
carried.write_copy(self.rootfs, bundle(), self.owner)
carried.write_copy(self.rootfs, bundle(node="other"), self.owner)
self.assertEqual(carried.read_copy(self.rootfs)["node"], "other")
def test_a_link_left_in_place_of_the_folder_is_not_followed(self):
(self.rootfs / carried.DIRECTORY).symlink_to(self.outside)
with self.assertRaises(OSError):
carried.write_copy(self.rootfs, bundle(), self.owner)
self.assertEqual(os.listdir(self.outside), [])
(self.outside / carried.NAME).write_text(json.dumps(bundle()))
self.assertIsNone(carried.read_copy(self.rootfs))
def test_a_link_left_in_place_of_the_copy_is_not_followed(self):
(self.outside / "secret").write_text(json.dumps(bundle()))
(self.rootfs / carried.DIRECTORY).mkdir()
(self.rootfs / carried.DIRECTORY / carried.NAME).symlink_to(self.outside / "secret")
self.assertIsNone(carried.read_copy(self.rootfs))
def test_what_is_not_a_copy_is_not_read(self):
self.assertIsNone(carried.read_copy(self.rootfs))
folder = self.rootfs / carried.DIRECTORY
folder.mkdir()
for content in ("not json", json.dumps([1]), json.dumps(bundle(kind="other")),
json.dumps(bundle(schema_version=2)), json.dumps({**bundle(), "record": "text"})):
(folder / carried.NAME).write_text(content)
self.assertIsNone(carried.read_copy(self.rootfs), content)
def test_the_time_it_was_saved_does_not_make_a_copy_different(self):
self.assertEqual(carried.digest(bundle(saved_at="2026-10-03")), carried.digest(bundle(saved_at="2026-10-04")))
self.assertEqual(carried.digest(bundle(generation="a")), carried.digest(bundle(generation="b")))
self.assertNotEqual(carried.digest(bundle()), carried.digest(bundle(node="other")))
def test_the_owner_is_root_of_the_container(self):
self.assertEqual(carried.mapped_root("arch: amd64\nunprivileged: 1\n"), (100000, 100000))
self.assertEqual(carried.mapped_root("arch: amd64\n"), (0, 0))
custom = "unprivileged: 1\nlxc.idmap: u 0 200000 65536\nlxc.idmap: g 0 300000 65536\nlxc.idmap: u 1000 1000 1\n"
self.assertEqual(carried.mapped_root(custom), (200000, 300000))
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,47 @@
"""A container volume is always part of the backups: an update restores its
application from one, and refuses a volume that would be left out."""
import json
from pathlib import Path
import sys
import unittest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
def volumes_without_backup(value, found=None):
"""Container paths a template would create as a container volume left out of the backups."""
found = [] if found is None else found
if isinstance(value, dict):
managed = value.get("managed_volume")
if (isinstance(managed, dict) and managed.get("backup") is not True
and "managed-volume" in (value.get("installation_choice") or [])):
found.append(value.get("container_path"))
if value.get("mode") == "managed-volume" and value.get("backup") is False:
found.append(value.get("container_path"))
for item in value.values():
volumes_without_backup(item, found)
elif isinstance(value, list):
for item in value:
volumes_without_backup(item, found)
return found
class ContainerVolumeBackupTests(unittest.TestCase):
def test_no_application_of_the_catalog_creates_a_volume_left_out_of_the_backups(self):
for folder in ("apps", "curated"):
for path in sorted((ROOT / "catalog" / folder).glob("*.json")):
template = json.loads(path.read_text(encoding="utf-8"))
self.assertEqual(volumes_without_backup(template), [], f"{folder}/{path.name}")
def test_a_converted_compose_backs_up_every_volume_it_creates(self):
from proxmenux_oci import converter
contract = converter._mount_contract(["/srv/app/config:/config", "/srv/app/cache:/cache",
"/srv/app/transcode:/transcode", "/srv/app/tmp:/tmp"], set())
self.assertEqual([item["container_path"] for item in contract], ["/config", "/cache", "/transcode", "/tmp"])
self.assertTrue(all(item["managed_volume"]["backup"] is True for item in contract))
if __name__ == "__main__":
unittest.main()
+143
View File
@@ -53,3 +53,146 @@ class FrigateAmdProfileTests(unittest.TestCase):
if __name__ == "__main__":
unittest.main()
class FrigateAmdGenerationTests(unittest.TestCase):
"""The ROCm image of Frigate carries the kernels of RDNA2 and newer GPUs:
on an older one, such as the integrated Vega of a Ryzen 5000U, its
detector aborts, so the profile is not offered there."""
def profile(self):
profiles = Catalog(ROOT).compose("frigate")["proxmox"]["installer_profile"]["hardware_acceleration"]["profiles"]
return next(item for item in profiles if item["id"] == "rocm")
def usable(self, target):
from proxmenux_oci.installer import _profile_usable
found = {"intel": [], "amd": ["/dev/dri/renderD128"], "nvidia": False, "amd_gfx_target": target}
with patch("proxmenux_oci.installer.Path.is_char_device", return_value=True):
return _profile_usable(self.profile(), found)
def test_the_profile_is_offered_on_the_generations_the_image_supports(self):
self.assertIn(100300, self.profile()["amd_gfx_targets"])
self.assertTrue(self.usable(100300))
self.assertTrue(self.usable(110501))
self.assertFalse(self.usable(90012))
self.assertFalse(self.usable(100100))
# The other GPUs of a family it supports are offered as experimental.
self.assertTrue(self.usable(100305))
self.assertTrue(self.usable(110003))
# A driver that does not say the generation does not hide the profile.
self.assertTrue(self.usable(None))
def test_the_generation_is_read_from_the_compute_driver(self):
import tempfile
from proxmenux_oci import host
with tempfile.TemporaryDirectory() as directory:
nodes = Path(directory) / "sys/class/kfd/kfd/topology/nodes"
for index, value in enumerate((0, 90012)):
(nodes / str(index)).mkdir(parents=True)
(nodes / str(index) / "properties").write_text(f"simd_count 32\ngfx_target_version {value}\ndevice_id 5708\n")
self.assertEqual(host.amd_gfx_target(Path(directory)), 90012)
self.assertIsNone(host.amd_gfx_target(Path(directory) / "none"))
self.assertEqual([host.gfx_name(value) for value in (90012, 100300, 110501, 90010, 90402)],
["gfx90c", "gfx1030", "gfx1151", "gfx90a", "gfx942"])
def test_recognition_with_rocm_needs_a_supported_generation(self):
from proxmenux_oci import host
with patch("proxmenux_oci.host.Path.is_char_device", return_value=True), \
patch("proxmenux_oci.host.storages", return_value=[]):
for target, expected in ((90012, "generation"), (100100, "generation"), (100305, None),
(110003, None), (110501, None), (90010, None), (None, None)):
with patch("proxmenux_oci.host.amd_gfx_target", return_value=target):
self.assertEqual(host.rocm_blocker("local-lvm"), expected, target)
def test_how_each_generation_runs_rocm(self):
from proxmenux_oci import host
expected = {100300: ("native", None), 110501: ("native", None), 90010: ("native", None),
100305: ("experimental", "10.3.0"), 100302: ("experimental", "10.3.0"),
110003: ("experimental", "11.0.0"), 90012: (None, None), 100100: (None, None),
110502: (None, None), None: ("native", None)}
for target, (support, override) in expected.items():
self.assertEqual((host.rocm_support(target), host.rocm_override(target)), (support, override), target)
# An image with fewer kernels supports fewer generations.
self.assertIsNone(host.rocm_support(110003, (100300,)))
def test_the_gpu_is_named_as_its_owner_knows_it(self):
from proxmenux_oci import host
from types import SimpleNamespace
listed = ("35:00.0 VGA compatible controller [0300]: Advanced Micro Devices, Inc. [AMD/ATI] "
"Rembrandt [Radeon 680M] [1002:1681] (rev c7)\n")
plain = "04:00.0 VGA compatible controller [0300]: Advanced Micro Devices, Inc. [AMD/ATI] Lucienne [1002:164c] (rev c1)\n"
for output, target, name in ((listed, 100305, "Radeon 680M (gfx1035)"), (plain, 90012, "Lucienne (gfx90c)"),
("", 100300, "AMD GPU (gfx1030)"), ("", None, "AMD GPU")):
with patch("proxmenux_oci.host.subprocess.run", return_value=SimpleNamespace(stdout=output)), \
patch("proxmenux_oci.host.amd_gfx_target", return_value=target):
self.assertEqual(host.amd_gpu_name(), name)
@patch("proxmenux_oci.i18n.language", return_value="en")
@patch("proxmenux_oci.installer.host.storages", side_effect=storages)
@patch("proxmenux_oci.installer.host.bridges", return_value=[{"iface": "vmbr0", "cidr": "192.0.2.10/24"}])
@patch("proxmenux_oci.installer.host.timezone", return_value="Europe/Madrid")
@patch("proxmenux_oci.installer.Path.is_char_device", return_value=True)
@patch("proxmenux_oci.installer.host.amd_gpu_name", return_value="Radeon 680M (gfx1035)")
class FrigateExperimentalRocmTests(unittest.TestCase):
"""On a GPU ROCm does not support officially the profile is offered as
experimental, explained before the larger image is downloaded, and never
taken without a yes."""
WARNING = "ROCm does not support this GPU"
class UI(RecordingUI):
def __init__(self, answers, accept):
super().__init__(answers)
self.labels, self.messages, self.accept, self.asked_warning = {}, [], accept, 0
def choose(self, text, options, default=None):
self.labels[text] = dict(options)
return super().choose(text, options, default)
def confirm(self, text, default=False):
if FrigateExperimentalRocmTests.WARNING in text:
self.asked_warning += 1
return self.accept
return default
def message(self, text, title=None):
self.messages.append(text)
def build(self, target, answers, accept=True):
found = {"intel": [], "amd": ["/dev/dri/renderD128"], "nvidia": False, "amd_gfx_target": target}
ui = self.UI(answers, accept)
with patch("proxmenux_oci.installer.host.gpus", return_value=found):
plan = build_deployment(Catalog(ROOT).compose("frigate"), ui, DEFAULT_MODE)
return ui, plan
def override(self, plan):
return [item["value"] for item in plan["environment"] if item["name"] == "HSA_OVERRIDE_GFX_VERSION"]
def test_the_profile_is_marked_and_confirmed_and_sets_the_generation(self, *_):
ui, plan = self.build(100305, {PROMPT: "rocm"})
self.assertIn("experimental on this GPU", ui.labels[PROMPT]["rocm"])
self.assertEqual(ui.asked_warning, 1)
self.assertEqual(plan["hardware_profile"], "rocm")
self.assertEqual(self.override(plan), ["10.3.0"])
def test_a_supported_gpu_has_no_mark_and_no_question(self, *_):
ui, plan = self.build(100300, {PROMPT: "rocm"})
self.assertNotIn("experimental", ui.labels[PROMPT]["rocm"])
self.assertEqual((ui.asked_warning, self.override(plan)), (0, []))
def test_without_a_yes_the_menu_is_asked_again(self, *_):
answers = iter(["rocm", "vaapi"])
found = {"intel": [], "amd": ["/dev/dri/renderD128"], "nvidia": False, "amd_gfx_target": 100305}
ui = self.UI({}, False)
ui.choose = lambda text, options, default=None: next(answers) if text == PROMPT else default
with patch("proxmenux_oci.installer.host.gpus", return_value=found):
plan = build_deployment(Catalog(ROOT).compose("frigate"), ui, DEFAULT_MODE)
self.assertEqual(plan["hardware_profile"], "vaapi")
self.assertEqual(self.override(plan), [])
def test_a_gpu_the_image_cannot_use_is_not_offered_and_the_reason_is_said(self, *_):
with patch("proxmenux_oci.installer.host.amd_gpu_name", return_value="Lucienne (gfx90c)"):
ui, plan = self.build(90012, {})
self.assertNotIn("rocm", ui.labels[PROMPT])
self.assertTrue(any("Lucienne (gfx90c)" in message and "not offered" in message for message in ui.messages))
+48 -5
View File
@@ -91,14 +91,57 @@ class ImmichAccelerationTests(unittest.TestCase):
self.assertIn("No usable GPU", ui.messages[0])
self.assertEqual(result, ("cpu", "cpu"), mode)
def test_an_amd_host_that_cannot_run_rocm_says_so_and_recognises_on_the_cpu(self, *_):
for blocker, text in (("kfd", "/dev/kfd"), ("space", "40 GB")):
with patch("proxmenux_oci.installer.host.rocm_blocker", return_value=blocker):
ui, result, _ = self.plan(AMD, DEFAULT_MODE, "amd")
def test_an_amd_host_that_cannot_run_rocm_offers_video_only_and_says_why(self, *_):
for blocker, text in (("kfd", "/dev/kfd"), ("space", "40 GB"), ("generation", "no support for the AMD GPU")):
with patch("proxmenux_oci.installer.host.rocm_blocker", return_value=blocker), \
patch("proxmenux_oci.installer.host.amd_gpu_name", return_value="Lucienne (gfx90c)"):
ui, result, _ = self.plan(AMD, DEFAULT_MODE)
self.assertEqual(ui.options[PROMPT], ["cpu", "amd-video"], blocker)
self.assertEqual(ui.defaults[PROMPT], "amd-video", blocker)
self.assertEqual(result, ("vaapi", "cpu"), blocker)
self.assertTrue(any(text in message and "Recognition runs on the CPU." in message
self.assertTrue(any(text in message and "Recognition is not offered" in message
for message in ui.messages), blocker)
def test_a_gpu_rocm_supports_is_proposed_whole(self, *_):
for target in (100300, 110501, None):
ui, result, plan = self.plan(dict(AMD, amd_gfx_target=target), DEFAULT_MODE)
self.assertEqual(ui.defaults[PROMPT], "amd", target)
self.assertEqual(result, ("vaapi", "rocm"), target)
self.assertIsNone(plan["machine_learning"]["gfx_override"], target)
def test_a_gpu_rocm_does_not_support_officially_is_offered_as_experimental(self, *_):
gpus = dict(AMD, amd_gfx_target=100305)
with patch("proxmenux_oci.installer.host.amd_gpu_name", return_value="Radeon 680M (gfx1035)"):
# Never the proposal: left alone, recognition stays on the CPU.
ui, result, _ = self.plan(gpus, DEFAULT_MODE)
self.assertEqual(ui.options[PROMPT], ["cpu", "amd", "amd-video", "amd-ml"])
self.assertEqual(ui.defaults[PROMPT], "amd-video")
self.assertEqual(result, ("vaapi", "cpu"))
# Chosen and confirmed, it is installed with the generation of its family.
ui = OptionsUI({PROMPT: "amd"})
ui.confirm = lambda text, default=False: True if "ROCm does not support this GPU" in text else default
with patch("proxmenux_oci.installer.host.gpus", return_value=gpus):
plan = build_deployment(self.template, ui, DEFAULT_MODE)
self.assertEqual(plan["machine_learning"]["acceleration"], "rocm")
self.assertEqual(plan["machine_learning"]["gfx_override"], "10.3.0")
# Declined, the menu is asked again.
answers = iter(["amd", "amd-video"])
ui = OptionsUI()
ui.choose = lambda text, options, default=None: next(answers) if text == PROMPT else default
with patch("proxmenux_oci.installer.host.gpus", return_value=gpus):
plan = build_deployment(self.template, ui, DEFAULT_MODE)
self.assertEqual(plan["machine_learning"]["acceleration"], "cpu")
self.assertEqual(plan["video_transcoding"]["acceleration"], "vaapi")
def test_the_780m_family_is_presented_as_its_generation(self, *_):
gpus = dict(AMD, amd_gfx_target=110003)
ui = OptionsUI({PROMPT: "amd-ml"})
ui.confirm = lambda text, default=False: True if "ROCm does not support this GPU" in text else default
with patch("proxmenux_oci.installer.host.gpus", return_value=gpus), \
patch("proxmenux_oci.installer.host.amd_gpu_name", return_value="Radeon 780M (gfx1103)"):
plan = build_deployment(self.template, ui, DEFAULT_MODE)
self.assertEqual(plan["machine_learning"]["gfx_override"], "11.0.0")
def test_machine_learning_gets_four_cores_and_at_least_four_gigabytes(self, *_):
_, _, plan = self.plan(BOTH, DEFAULT_MODE, "cpu")
self.assertEqual(plan["machine_learning"]["resources"]["cores"], 4)
+288
View File
@@ -0,0 +1,288 @@
"""The recognition of an installed Immich moves between the CPU and a GPU of
the host without reinstalling: the machine learning container takes the image
and the devices of the new choice."""
from pathlib import Path
import sys
import tempfile
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "remote"))
sys.path.insert(0, str(ROOT / "src"))
import oci_immich_recognition as recognition
import oci_rocm_check
CONFIG = """arch: amd64
cores: 4
dev0: path=/dev/dri/renderD128,gid=993,mode=0660
dev1: path=/dev/kfd,gid=993,mode=0660
memory: 8192
lxc.environment.runtime: IMMICH_PORT=3003
lxc.environment.runtime: HSA_OVERRIDE_GFX_VERSION=10.3.0
lxc.environment.runtime: HSA_USE_SVM=0
lxc.environment: NVIDIA_VISIBLE_DEVICES=all
lxc.hook.mount: /usr/local/lib/proxmenux/oci/nvidia-mount-abc.sh
lxc.signal.halt: SIGTERM
[snapshot]
arch: amd64
lxc.environment.runtime: HSA_USE_SVM=0
"""
class ConfigurationTests(unittest.TestCase):
def setUp(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
self.path = Path(tmp.name) / "101.conf"
self.path.write_text(CONFIG)
patcher = patch.object(recognition, "conf", return_value=self.path)
patcher.start()
self.addCleanup(patcher.stop)
def test_what_the_previous_choice_left_is_taken_away(self):
deleted = []
with patch.object(recognition.oci_stack_modify, "entries",
return_value={"dev0": "path=/dev/dri/renderD128,gid=993", "dev1": "path=/dev/kfd,gid=993",
"dev2": "path=/dev/ttyUSB0,gid=20"}), \
patch.object(recognition, "run", side_effect=lambda *command: deleted.append(command[-1])):
recognition.strip_gpu(101)
self.assertEqual(deleted, ["dev0", "dev1"])
text = self.path.read_text()
current, _, snapshot = text.partition("\n[")
for gone in ("HSA_OVERRIDE", "HSA_USE_SVM", "NVIDIA_VISIBLE_DEVICES", "nvidia-mount"):
self.assertNotIn(gone, current)
self.assertIn("lxc.environment.runtime: IMMICH_PORT=3003\n", current)
self.assertIn("lxc.signal.halt: SIGTERM\n", current)
# A snapshot is history: it is not rewritten.
self.assertEqual(snapshot, CONFIG.partition("\n[")[2])
def test_new_settings_go_before_the_snapshots(self):
recognition.append_lines(101, ["lxc.environment.runtime: HSA_OVERRIDE_GFX_VERSION=11.0.0"])
current, _, snapshot = self.path.read_text().partition("\n[")
self.assertTrue(current.endswith("lxc.environment.runtime: HSA_OVERRIDE_GFX_VERSION=11.0.0\n"))
self.assertEqual(snapshot, CONFIG.partition("\n[")[2])
class RecordTests(unittest.TestCase):
RECORD = {"deployment": {"machine_learning": {
"acceleration": "openvino", "render_device": "/dev/dri/renderD128", "model_cache_size_gb": 8,
"resources": {"cores": 4, "memory_mb": 8192, "swap_mb": 1024, "cpu_allocation": "quota"}}}}
def test_the_record_says_what_runs_recognition_and_with_what_resources(self):
cpu = recognition.recorded(self.RECORD, "cpu", None, None)
self.assertEqual((cpu["acceleration"], cpu["render_device"], cpu["gfx_override"]), ("cpu", None, None))
self.assertEqual((cpu["resources"]["memory_mb"], cpu["resources"]["cpu_allocation"]), (4096, "cpuset"))
self.assertEqual((cpu["model_cache_size_gb"], cpu["resources"]["swap_mb"]), (8, 1024))
rocm = recognition.recorded(self.RECORD, "rocm", "/dev/dri/renderD129", "10.3.0")
self.assertEqual((rocm["acceleration"], rocm["render_device"], rocm["gfx_override"]),
("rocm", "/dev/dri/renderD129", "10.3.0"))
self.assertEqual(rocm["resources"]["memory_mb"], 8192)
self.assertEqual(recognition.recorded(self.RECORD, "openvino", "/dev/dri/renderD128", None)["resources"]
["cpu_allocation"], "quota")
# The record it was read from is not changed.
self.assertEqual(self.RECORD["deployment"]["machine_learning"]["acceleration"], "openvino")
def test_the_record_declares_the_variables_of_the_new_choice(self):
base = [{"name": "IMMICH_PORT", "value": "3003"}]
names = lambda found: [(entry["name"], entry["value"]) for entry in found]
nvidia = recognition.declared(base, "cuda", None)
self.assertEqual(names(nvidia), [("IMMICH_PORT", "3003"), ("NVIDIA_DRIVER_CAPABILITIES", "compute,utility")])
amd = recognition.declared(nvidia, "rocm", "10.3.0")
self.assertEqual(names(amd), [("IMMICH_PORT", "3003"), ("HSA_OVERRIDE_GFX_VERSION", "10.3.0"),
("HSA_USE_SVM", "0")])
self.assertEqual(names(recognition.declared(nvidia, "rocm", None)), names(base))
self.assertEqual(names(recognition.declared(amd, "cpu", None)), names(base))
self.assertEqual(names(recognition.declared(amd, "openvino", None)), names(base))
def test_the_record_declares_the_resources_of_the_new_choice(self):
intel = {"cores": 4, "memory_mb": 8192, "swap_mb": 1024, "cpu_allocation": "quota"}
self.assertEqual(recognition.resources(intel, "cpu"), {"cores": 4, "memory_mb": 4096, "swap_mb": 1024})
self.assertEqual(recognition.resources(intel, "cuda"), {"cores": 4, "memory_mb": 8192, "swap_mb": 1024})
self.assertEqual(recognition.resources(recognition.resources(intel, "cpu"), "openvino"), intel)
self.assertEqual(intel["cpu_allocation"], "quota")
def test_a_record_rebuilt_by_an_update_describes_the_new_container(self):
def record(**plan):
return {"vmid": 101, "template": {"proxmox": {"installer_profile": {"id": "ml", "cpu_allocation": "quota"}}},
"deployment": {"environment": [], "rootfs": {"storage": "local-lvm", "size_gb": 12},
"resources": {"cores": 4, "memory_mb": 8192, "cpu_allocation": "quota"}, **plan}}
with patch.object(recognition, "settings", return_value={"rootfs": "local-lvm:vm-101-disk-0,size=40G"}):
rebuilt = record()
recognition.declare(rebuilt, "rocm", "11.0.0")
self.assertEqual(rebuilt["deployment"]["rootfs"]["size_gb"], 40)
self.assertEqual(rebuilt["deployment"]["resources"], {"cores": 4, "memory_mb": 8192})
self.assertEqual(rebuilt["template"]["proxmox"]["installer_profile"], {"id": "ml"})
self.assertEqual([entry["name"] for entry in rebuilt["deployment"]["environment"]],
["HSA_OVERRIDE_GFX_VERSION", "HSA_USE_SVM"])
recognition.declare(rebuilt, "openvino", None)
self.assertEqual(rebuilt["template"]["proxmox"]["installer_profile"]["cpu_allocation"], "quota")
self.assertEqual(rebuilt["deployment"]["environment"], [])
# A record still described by its native configuration is read from it.
native = record(native_config="arch: amd64")
recognition.declare(native, "rocm", "11.0.0")
self.assertEqual(native, record(native_config="arch: amd64"))
def test_a_choice_this_host_cannot_serve_is_refused_before_anything_changes(self):
with self.assertRaises(ValueError):
recognition.validate("vulkan", None, None)
with self.assertRaises(ValueError):
recognition.validate("rocm", "/dev/dri/renderD999", None)
with self.assertRaises(ValueError):
recognition.validate("openvino", "/etc/passwd", None)
with self.assertRaises(ValueError):
recognition.validate("cpu", None, "10.3.0")
recognition.validate("cpu", None, None)
def test_only_an_immich_of_proxmenux_is_changed(self):
def member(vmid, adapter, role):
return {"vmid": vmid, "deployment": {"replay_profile": {"adapter": adapter, "role": role}}}
def records(roles, adapter="install_immich_stack.sh"):
found = {vmid: member(vmid, adapter, role) for vmid, role in roles.items()}
found[100]["stack"] = {"members": [{"vmid": vmid} for vmid in roles]}
return found
immich = records({100: "server", 101: "machine-learning", 102: "database", 103: "valkey"})
with patch.object(recognition.instances, "read", side_effect=lambda root, vmid: immich[vmid]):
_, found = recognition.members(Path("/nonexistent"), 100)
self.assertEqual(found["machine-learning"]["vmid"], 101)
for other in (records({100: "application", 101: "database"}, "install_tandoor_stack.sh"),
records({100: "server", 101: "machine-learning"})):
with patch.object(recognition.instances, "read", side_effect=lambda root, vmid: other[vmid]):
with self.assertRaises(ValueError):
recognition.members(Path("/nonexistent"), 100)
class RevertTests(unittest.TestCase):
def test_the_previous_choice_is_what_the_record_says(self):
record = {"deployment": {"machine_learning": {"acceleration": "rocm", "render_device": "/dev/dri/renderD128",
"gfx_override": "10.3.0"}}}
self.assertEqual(recognition.chosen(record), ("rocm", "/dev/dri/renderD128", "10.3.0"))
self.assertEqual(recognition.chosen({"deployment": {}}), ("cpu", None, None))
def test_a_failed_change_puts_the_previous_choice_back_the_way_it_was_given(self):
calls = []
with patch.object(recognition.oci_stack_modify, "is_running", return_value=True), \
patch.object(recognition, "stop", side_effect=lambda vmid: calls.append(("stop", vmid))), \
patch.object(recognition, "apply", side_effect=lambda *arguments: calls.append(("apply",) + arguments[2:])), \
patch.object(recognition.subprocess, "run", side_effect=lambda command, **_: calls.append(tuple(command[:3]))):
recognition.revert(Path("/nonexistent"), 100, 101, {"cpu": "image"}, ("cpu", None, None), True)
self.assertEqual(calls, [("stop", 101), ("apply", 101, {"cpu": "image"}, "cpu", None, None),
("pct", "start", "101")])
def test_a_revert_that_fails_does_not_hide_the_first_error(self):
with patch.object(recognition.oci_stack_modify, "is_running", return_value=False), \
patch.object(recognition, "apply", side_effect=RuntimeError("pct set: locked")), \
patch.object(recognition, "msg_warn") as warned, \
patch.object(recognition.subprocess, "run") as started:
recognition.revert(Path("/nonexistent"), 100, 101, {}, ("cpu", None, None), True)
self.assertIn("pct set: locked", warned.call_args[0][0])
started.assert_not_called()
class RocmProbeTests(unittest.TestCase):
def test_the_probe_runs_with_what_the_container_tells_rocm(self):
config = ("arch: amd64\nlxc.environment.runtime: PATH=/opt/venv/bin\n"
"lxc.environment.runtime: HSA_OVERRIDE_GFX_VERSION=10.3.0\nlxc.environment.runtime: HSA_USE_SVM=0\n"
"lxc.environment.runtime: HSA_BAD=1; reboot\n")
self.assertEqual(oci_rocm_check.variables(config), ["HSA_OVERRIDE_GFX_VERSION=10.3.0", "HSA_USE_SVM=0"])
self.assertEqual(oci_rocm_check.variables("arch: amd64\n"), [])
listed = "arch: amd64\nenv: PATH=/opt/venv/bin\0HSA_OVERRIDE_GFX_VERSION=11.0.0\0HSA_USE_SVM=0\0HOME=/root\n"
self.assertEqual(oci_rocm_check.variables(listed), ["HSA_OVERRIDE_GFX_VERSION=11.0.0", "HSA_USE_SVM=0"])
def protobuf_fields(data):
"""The fields of a protobuf message as (number, value) pairs."""
position = 0
def varint():
nonlocal position
value = shift = 0
while True:
byte = data[position]
position += 1
value |= (byte & 0x7F) << shift
shift += 7
if not byte & 0x80:
return value
while position < len(data):
key = varint()
number, wire = key >> 3, key & 7
if wire == 0:
yield number, varint()
elif wire == 2:
size = varint()
yield number, data[position:position + size]
position += size
else:
position += 4 if wire == 5 else 8
def packed_numbers(data):
numbers, value, shift = [], 0, 0
for byte in data:
value |= (byte & 0x7F) << shift
shift += 7
if not byte & 0x80:
numbers.append(value)
value = shift = 0
return numbers
class RocmProbeModelTests(unittest.TestCase):
def test_the_embedded_model_has_layers_that_fit_each_other(self):
import base64
model = base64.b64decode(oci_rocm_check.MODEL, validate=True)
graph = next(value for number, value in protobuf_fields(model) if number == 7)
shapes = {}
for number, tensor in protobuf_fields(graph):
if number != 5:
continue
dims, name = [], None
for field, value in protobuf_fields(tensor):
if field == 1:
dims += packed_numbers(value) if isinstance(value, bytes) else [value]
elif field == 8:
name = value.decode()
shapes[name] = dims
# The dense layer takes one value per channel of the convolution.
self.assertEqual(shapes["w"], [4, 3, 3, 3])
self.assertEqual(shapes["fw"], [shapes["w"][0], 2])
self.assertEqual((shapes["b"], shapes["fb"]), ([4], [2]))
class ChoicesTests(unittest.TestCase):
def choices(self, found, blocker=None):
from proxmenux_oci import stack_recreation
with patch("proxmenux_oci.host.gpus", return_value=found), \
patch("proxmenux_oci.host.rocm_blocker", return_value=blocker):
return stack_recreation.recognition_choices("local-lvm")
def test_the_choices_are_what_the_host_has(self):
node = "/dev/dri/renderD128"
tags = lambda found, blocker=None: [tag for tag, _, _ in self.choices(found, blocker)]
self.assertEqual(tags({"intel": [], "amd": [], "nvidia": False}), ["cpu"])
self.assertEqual(tags({"intel": [node], "amd": [], "nvidia": True}), ["cpu", "openvino", "cuda"])
self.assertEqual(tags({"intel": [], "amd": [node], "nvidia": False, "amd_gfx_target": 100300}), ["cpu", "rocm"])
self.assertEqual(tags({"intel": [], "amd": [node], "nvidia": False, "amd_gfx_target": 90012}, "generation"), ["cpu"])
def test_an_amd_gpu_rocm_does_not_support_officially_is_marked(self):
found = {"intel": [], "amd": ["/dev/dri/renderD128"], "nvidia": False, "amd_gfx_target": 100305}
tag, label, details = self.choices(found)[-1]
self.assertEqual(tag, "rocm")
self.assertIn("experimental", label)
self.assertEqual((details["experimental"], details["override"], details["render"]),
(True, "10.3.0", "/dev/dri/renderD128"))
native = self.choices(dict(found, amd_gfx_target=110501))[-1]
self.assertEqual((native[2]["experimental"], native[2]["override"]), (False, None))
self.assertNotIn("experimental", native[1])
if __name__ == "__main__":
unittest.main()
+9
View File
@@ -32,6 +32,8 @@ class RemovalCleanupTests(unittest.TestCase):
self.snippets.mkdir()
self.apps = self.root / "apps"
self.apps.mkdir()
self.records = self.root / "cluster-records"
self.records.mkdir()
self.cluster = self.root / "proxmenux"
self.cluster.mkdir()
self.legacy = self.root / "legacy"
@@ -41,6 +43,7 @@ class RemovalCleanupTests(unittest.TestCase):
patch.object(oci_remove, "CLUSTER_NODES", self.nodes),
patch.object(oci_remove, "SNIPPETS", self.snippets),
patch.object(oci_remove, "MONITOR_APPS", self.apps),
patch.object(oci_remove, "CLUSTER_RECORDS", self.records),
patch.object(oci_remove, "HOST_MONITOR_INCLUDES", self.host_monitor),
patch.object(runtime_settings, "include_path", lambda vmid: self.cluster / f"{vmid}.sysctls"),
patch.object(runtime_settings, "legacy_include_path", lambda vmid: self.legacy / f"{vmid}.proxmenux-sysctls"),
@@ -67,6 +70,12 @@ class RemovalCleanupTests(unittest.TestCase):
self.assertFalse((self.apps / "113.json").exists())
self.assertEqual(json.loads((self.apps / ".oci-dismissed.json").read_text()), {"112": "b"})
def test_the_copy_of_the_record_in_the_cluster_goes_with_the_container(self):
(self.records / "120.json").write_text("{}")
(self.records / "121.json").write_text("{}")
oci_remove.remove_host_state(120)
self.assertEqual([path.name for path in self.records.iterdir()], ["121.json"])
def test_a_published_view_with_content_is_kept(self):
shared = self.root / "shared"
(shared / "rw/drive").mkdir(parents=True)
+550
View File
@@ -0,0 +1,550 @@
"""A container restored from a backup is found, checked against what this
host has, and registered again only when its whole application can be."""
import copy
import ipaddress
import os
from pathlib import Path
import sys
import tempfile
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "remote"))
import oci_carried_record as carried
import oci_instances as instances
import oci_restore_recovery as recovery
APP = "83e373f2-5ccc-4548-9b89-22d0956d1c77"
DB = "488ed3cb-1145-477a-b90f-c46777d69fb2"
def saved(identity, hostname, extra=""):
return f"#<div>Tandoor</div>\n#<!-- proxmenux-instance={identity} -->\narch: amd64\nhostname: {hostname}\n{extra}"
def config(identity, extra=""):
return (f"arch: amd64\ndescription: <!-- proxmenux-instance={identity} -->\nhostname: app\nmemory: 2048\n"
f"rootfs: local-lvm:vm-119-disk-0,size=8G\nunprivileged: 1\n{extra}")
CONSOLE = ("lxc.console.logfile: /var/log/proxmenux/oci/{0}.console.log\n"
"lxc.hook.pre-start: /bin/sh -c 'mkdir -p /var/log/proxmenux/oci; "
"test -x /usr/local/share/proxmenux/oci/engine/remote/oci_console_mark.sh "
"&& /usr/local/share/proxmenux/oci/engine/remote/oci_console_mark.sh {0}; exit 0'\n")
def record(vmid, identity, **extra):
return {"schema_version": 1, "vmid": vmid, "installation_id": identity, "status": "installed",
"template": {"catalog_ui": {"title": {"en_US": "Tandoor Recipes"}}},
"deployment": {"vmid": vmid, "hostname": "app", "rootfs": {"storage": "local-lvm", "size_gb": 8}},
"observed": {"config": config(identity), "config_sha256": "old", "api_config_sha256": "old",
"image": {"manifest_digest": "sha256:1"}, "archive_path": "/gone.tar",
"resolved_registry_digest": "sha256:1"}, **extra}
def entry(vmid, identity, value, text=None):
return {"vmid": vmid, "installation_id": identity, "hostname": f"ct{vmid}", "problem": None,
"config": text if text is not None else config(identity),
"copy": {"schema_version": 1, "kind": carried.KIND, "node": "old", "vmid": vmid, "record": value}}
class DiscoveryTests(unittest.TestCase):
def setUp(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
self.root = Path(tmp.name)
def register(self, vmid, identity):
with patch.object(instances, "private_directory", lambda path: path.mkdir(parents=True, exist_ok=True)):
instances.write(instances.location(self.root, vmid), record(vmid, identity))
def pending(self, guests):
with patch.object(recovery, "local_guests", return_value=guests):
return [row["vmid"] for row in recovery.pending(self.root)]
def test_the_mark_is_read_from_the_saved_configuration(self):
self.assertEqual(recovery.marker(saved(APP, "app")), APP)
self.assertIsNone(recovery.marker("arch: amd64\nhostname: plain\n"))
def test_a_marked_container_without_record_waits_for_recovery(self):
guests = {119: saved(APP, "app"), 120: saved(DB, "db"), 100: "arch: amd64\nhostname: plain\n"}
self.assertEqual(self.pending(guests), [119, 120])
self.register(119, APP)
self.assertEqual(self.pending(guests), [120])
def test_the_record_of_another_installation_does_not_count(self):
self.register(119, DB)
self.assertEqual(self.pending({119: saved(APP, "app")}), [119])
def test_a_container_that_carries_no_record_is_not_offered_again(self):
guests = {119: saved(APP, "app"), 120: saved(DB, "db")}
with patch.object(instances, "private_directory", lambda path: path.mkdir(parents=True, exist_ok=True)), \
patch.object(recovery, "local_guests", return_value=guests):
recovery.set_unrecoverable(self.root, [{"vmid": 119, "installation_id": APP}])
self.assertEqual(self.pending(guests), [120])
# Another installation restored on that ID is a new case.
self.assertEqual(self.pending({119: saved(DB, "other")}), [119])
def test_a_clone_of_a_registered_container_is_left_alone(self):
self.register(119, APP)
self.assertEqual(self.pending({119: saved(APP, "app"), 130: saved(APP, "copy")}), [])
class ApplicationTests(unittest.TestCase):
def stack(self):
database = record(120, DB, stack_member={"stack_id": APP, "primary_vmid": 119, "name": "database"})
application = record(119, APP, stack_member={"stack_id": APP, "primary_vmid": 119, "name": "application"})
application["stack"] = {"id": APP, "template": {"catalog_ui": {"title": {"en_US": "Tandoor Recipes"}}},
"members": [copy.deepcopy(application), copy.deepcopy(database)]}
return application, database
def plans(self, found, guests, known=None):
with patch.object(recovery, "local_guests", return_value=guests), \
patch.object(recovery, "registered_identities", return_value=known or {}):
return recovery.applications(Path("/nonexistent"), found)
def test_the_containers_of_one_application_are_recovered_together(self):
application, database = self.stack()
plans = self.plans([entry(120, DB, database), entry(119, APP, application)],
{119: saved(APP, "app"), 120: saved(DB, "db")})
self.assertEqual(len(plans), 1)
self.assertEqual((plans[0]["title"], plans[0]["primary"], plans[0]["blockers"]), ("Tandoor Recipes", 119, []))
self.assertEqual([m["state"] for m in plans[0]["members"]], ["restored", "restored"])
def test_an_application_with_a_container_missing_is_not_recovered(self):
application, _ = self.stack()
plans = self.plans([entry(119, APP, application)], {119: saved(APP, "app")})
self.assertEqual(len(plans[0]["blockers"]), 1)
self.assertIn("CT 120 (database)", plans[0]["blockers"][0])
def test_containers_restored_with_other_ids_keep_their_application(self):
application, database = self.stack()
application["stack"]["deployment"] = {"base_vmid": 119, "services": [
{"vmid": 119, "name": "application"},
{"vmid": 120, "name": "database", "healthcheck": {"timeout_seconds": 120}}]}
application["deployment"]["create_arguments"] = ["119", "local:vztmpl/app.tar"]
application["observed"]["config"] = config(APP, CONSOLE.format(119))
found = [entry(140, APP, application), entry(135, DB, database)]
found[0]["copy"]["stack_contract"] = {"schema": 1, "dependencies": [
{"vmid": 120, "label": "PostgreSQL", "healthcheck": {"type": "running", "timeout_seconds": 120}}]}
plans = self.plans(found, {140: saved(APP, "app"), 135: saved(DB, "db")})
plan = plans[0]
self.assertEqual((plan["blockers"], plan["renumbered"], plan["primary"]), ([], {119: 140, 120: 135}, 140))
self.assertEqual([m["vmid"] for m in plan["members"]], [140, 135])
main, other = (e["copy"]["record"] for e in plan["restored"])
self.assertEqual((main["vmid"], main["deployment"]["vmid"], main["stack_member"]["primary_vmid"]), (140, 140, 140))
self.assertEqual((other["vmid"], other["stack_member"]["primary_vmid"]), (135, 140))
self.assertEqual([m["vmid"] for m in main["stack"]["members"]], [140, 135])
self.assertEqual(main["stack"]["deployment"]["base_vmid"], 140)
services = main["stack"]["deployment"]["services"]
self.assertEqual([s["vmid"] for s in services], [140, 135])
# A number that is not an ID stays: 120 seconds is not CT 120.
self.assertEqual(services[1]["healthcheck"]["timeout_seconds"], 120)
contract = plan["restored"][0]["copy"]["stack_contract"]["dependencies"][0]
self.assertEqual((contract["vmid"], contract["healthcheck"]["timeout_seconds"]), (135, 120))
self.assertEqual(main["deployment"]["create_arguments"][0], "140")
self.assertIn("/var/log/proxmenux/oci/140.console.log", main["observed"]["config"])
self.assertIn("oci_console_mark.sh 140; exit 0", main["observed"]["config"])
self.assertNotIn("119", main["observed"]["config"].replace("vm-119-disk", ""))
self.assertEqual(plan["restored"][0]["recorded"], 119)
def test_the_lines_of_a_configuration_that_carry_the_id(self):
text = config(APP, CONSOLE.format(119) + "lxc.include: /etc/pve/proxmenux/119.sysctls\nmemory: 119\n")
result = recovery.renumbered_lines(text, 119, 140)
self.assertIn("lxc.console.logfile: /var/log/proxmenux/oci/140.console.log\n", result)
self.assertIn("oci_console_mark.sh 140; exit 0'\n", result)
self.assertIn("lxc.include: /etc/pve/proxmenux/140.sysctls\n", result)
# Proxmox renames the volumes; anything else that happens to read 119 stays.
self.assertIn("rootfs: local-lvm:vm-119-disk-0,size=8G\n", result)
self.assertIn("memory: 119\n", result)
self.assertTrue(result.endswith("\n"))
legacy = recovery.renumbered_lines("lxc.include: /etc/pve/lxc/119.proxmenux-sysctls\n", 119, 140)
self.assertEqual(legacy, "lxc.include: /etc/pve/proxmenux/140.sysctls\n")
def test_a_container_without_its_copy_stops_the_recovery(self):
broken = dict(entry(119, APP, record(119, APP)), copy=None, problem="no copy")
plans = self.plans([broken], {119: saved(APP, "app")})
self.assertEqual(plans[0]["blockers"], ["CT 119: no copy"])
class NetworkTests(unittest.TestCase):
def check(self, text, links=("lo", "vmbr0"), addresses=None, routes=(), defined=None, guests=None):
plan = {"restored": [entry(119, APP, record(119, APP), text)], "members": [{"vmid": 119}],
"blockers": [], "notes": [], "bridges": {}}
with patch.object(recovery, "links", return_value=set(links)), \
patch.object(recovery, "host_addresses", return_value=addresses or {}), \
patch.object(recovery, "routed_networks", return_value=list(routes)), \
patch.object(recovery, "defined_network", return_value=defined), \
patch.object(recovery, "local_guests", return_value=guests or {}):
recovery.check_networks(plan)
return plan
PRIVATE = config(APP, "net0: name=eth0,bridge=vmbr0,ip=dhcp\nnet1: name=eth1,bridge=vmbr11,ip=10.77.1.40/24\n")
def test_a_missing_private_network_is_created_with_the_same_subnet(self):
plan = self.check(self.PRIVATE)
self.assertEqual(plan["blockers"], [])
self.assertEqual(plan["bridges"], {"vmbr11": ipaddress.ip_network("10.77.1.0/24")})
def test_a_private_network_already_here_is_left_as_it_is(self):
plan = self.check(self.PRIVATE, links=("vmbr0", "vmbr11"),
addresses={"vmbr11": [ipaddress.ip_interface("10.77.1.1/24")]})
self.assertEqual((plan["blockers"], plan["bridges"]), ([], {}))
def test_the_subnet_in_use_on_this_host_cancels_the_recovery(self):
plan = self.check(self.PRIVATE, links=("vmbr0", "vmbr20"),
addresses={"vmbr20": [ipaddress.ip_interface("10.77.1.1/24")]})
self.assertEqual(len(plan["blockers"]), 1)
self.assertIn("10.77.1.0/24", plan["blockers"][0])
routed = self.check(self.PRIVATE, routes=[("vmbr0", ipaddress.ip_network("10.0.0.0/8"))])
self.assertEqual(len(routed["blockers"]), 1)
def test_the_bridge_name_used_for_another_network_cancels_the_recovery(self):
plan = self.check(self.PRIVATE, links=("vmbr0", "vmbr11"),
addresses={"vmbr11": [ipaddress.ip_interface("192.168.5.1/24")]})
self.assertEqual(len(plan["blockers"]), 1)
defined = self.check(self.PRIVATE, defined=ipaddress.ip_network("192.168.5.0/24"))
self.assertEqual(len(defined["blockers"]), 1)
def test_an_address_taken_by_another_container_cancels_the_recovery(self):
plan = self.check(self.PRIVATE, guests={200: "net0: name=eth0,bridge=vmbr30,ip=10.77.1.40/24\n"})
self.assertTrue(any("10.77.1.40" in line for line in plan["blockers"]))
def test_a_missing_bridge_of_the_local_network_is_not_invented(self):
plan = self.check(config(APP, "net0: name=eth0,bridge=vmbr5,ip=dhcp\n"))
self.assertEqual(len(plan["blockers"]), 1)
self.assertEqual(plan["bridges"], {})
class RecordTests(unittest.TestCase):
def test_what_a_restore_changes(self):
related = recovery.restore_related
self.assertTrue(related("rootfs", "local-lvm:vm-119-disk-0,size=8G", "tank:subvol-119-disk-0,size=8G"))
self.assertTrue(related("mp0", "local-lvm:vm-119-disk-1,mp=/data,backup=1,size=2G",
"tank:subvol-119-disk-1,mp=/data,backup=1,size=2G"))
self.assertFalse(related("mp0", "local-lvm:vm-119-disk-1,mp=/data,backup=1,size=2G",
"local-lvm:vm-119-disk-1,mp=/other,backup=1,size=2G"))
self.assertFalse(related("mp0", "/srv/a,mp=/data", "/srv/b,mp=/data"))
self.assertTrue(related("net0", "name=eth0,bridge=vmbr0,hwaddr=AA:AA,ip=dhcp", "name=eth0,bridge=vmbr0,hwaddr=BB:BB,ip=dhcp"))
self.assertFalse(related("net0", "name=eth0,bridge=vmbr0,ip=dhcp", "name=eth0,bridge=vmbr1,ip=dhcp"))
self.assertTrue(related("hookscript", "local:snippets/a.sh", "nas:snippets/a.sh"))
self.assertTrue(related("dev0", "path=/dev/dri/renderD128", None))
self.assertTrue(related("mp1", "/srv/media,mp=/media", None))
self.assertFalse(related("mp1", "local-lvm:vm-119-disk-1,mp=/data,backup=1,size=2G", None))
self.assertFalse(related("memory", "2048", "4096"))
self.assertFalse(related("mp2", None, "/etc,mp=/host"))
def standalone(self, text, value=None):
value = value or record(119, APP)
observed = {"config": text, "config_sha256": "new", "api_config_sha256": "new"}
with patch.object(instances, "observe", return_value=observed) as observe, \
patch.object(recovery, "capture_sources", return_value={}), \
patch.object(recovery, "capture_gpu_devices", return_value={}):
return recovery.standalone_record(entry(119, APP, value, text)), observe
def test_volumes_on_another_storage_become_the_reference(self):
text = config(APP).replace("local-lvm:vm-119-disk-0", "tank:subvol-119-disk-0")
result, observe = self.standalone(text)
observe.assert_called_once()
self.assertEqual(result["observed"]["config"], text)
self.assertEqual(result["deployment"]["rootfs"]["storage"], "tank")
def test_a_change_made_outside_stays_a_difference(self):
text = config(APP).replace("local-lvm:vm-119-disk-0", "tank:subvol-119-disk-0").replace("memory: 2048", "memory: 4096")
result, observe = self.standalone(text)
observe.assert_not_called()
self.assertIn("memory: 2048", result["observed"]["config"])
self.assertIn("rootfs: tank:subvol-119-disk-0,size=8G", result["observed"]["config"])
self.assertNotIn("api_config_sha256", result["observed"])
self.assertEqual(result["deployment"]["rootfs"]["storage"], "tank")
def test_a_single_container_restored_with_another_id_is_registered_under_it(self):
value = record(119, APP)
value["observed"]["config"] = config(APP, CONSOLE.format(119))
found = entry(141, APP, value, config(APP, CONSOLE.format(141)).replace("vm-119-disk-0", "vm-141-disk-0"))
with patch.object(recovery, "local_guests", return_value={141: saved(APP, "app")}), \
patch.object(recovery, "registered_identities", return_value={}):
plan = recovery.applications(Path("/nonexistent"), [found])[0]
self.assertEqual((plan["blockers"], plan["renumbered"]), ([], {119: 141}))
observed = {"config": found["config"], "config_sha256": "new"}
with patch.object(instances, "observe", return_value=observed) as observe:
result = recovery.standalone_record(plan["restored"][0])
observe.assert_called_once()
self.assertEqual((result["vmid"], result["deployment"]["vmid"]), (141, 141))
def test_the_copy_cannot_add_a_host_directory_or_a_device(self):
value = record(119, APP)
value["deployment"]["mounts"] = [
{"type": "host-bind", "source": "/etc", "container_path": "/host"},
{"type": "host-bind", "source": "/srv/media", "container_path": "/media"},
{"type": "managed-volume", "source": "local-lvm", "container_path": "/data"}]
value["deployment"]["devices"] = [{"kind": "character-device", "host_path": "/dev/kvm"},
{"kind": "character-device", "host_path": "/dev/dri/renderD128"}]
extra = "mp0: /srv/media,mp=/media,backup=0\nmp1: tank:subvol-119-disk-1,mp=/data,backup=1,size=2G\ndev0: path=/dev/dri/renderD128,gid=104\n"
value["observed"]["config"] = config(APP, extra.replace("tank:subvol", "local-lvm:vm"))
result, _ = self.standalone(config(APP, extra), value)
self.assertEqual([m["source"] for m in result["deployment"]["mounts"]], ["/srv/media", "tank"])
self.assertEqual([d["host_path"] for d in result["deployment"]["devices"]], ["/dev/dri/renderD128"])
def test_the_start_order_must_name_containers_of_the_application(self):
contract = {"schema": 1, "stack": "tandoor", "dependencies": [
{"vmid": 120, "label": "PostgreSQL",
"healthcheck": {"type": "exec", "timeout_seconds": 120, "argv": ["pg_isready"]}}]}
self.assertTrue(recovery.valid_contract(contract, {120}))
self.assertFalse(recovery.valid_contract(contract, {121}))
self.assertFalse(recovery.valid_contract(None, {120}))
http = copy.deepcopy(contract)
http["dependencies"][0]["healthcheck"] = {"type": "http", "timeout_seconds": 60, "url": "http://10.77.1.41:8080/health"}
self.assertTrue(recovery.valid_contract(http, {120}))
http["dependencies"][0]["healthcheck"]["url"] = "http://192.168.0.1/admin"
self.assertFalse(recovery.valid_contract(http, {120}))
class RcloneMountTests(unittest.TestCase):
MOUNT = {"mount_name": "drive", "shared_mount_root": "/mnt/oci-shared/remotes",
"shared_mount_read_only_root": "/mnt/oci-shared/remotes-ro", "shared_mount_root_parent": "/mnt/oci-shared"}
def test_a_mount_inside_its_common_root_is_published_again(self):
self.assertEqual(recovery.rclone_mount(dict(self.MOUNT, extra="ignored")), self.MOUNT)
def test_a_mount_the_host_cannot_publish_safely_is_left_to_the_user(self):
for change in ({"mount_name": "../etc"}, {"mount_name": "a b"}, {"shared_mount_root": "/srv/other"},
{"shared_mount_root_parent": "/etc", "shared_mount_root": "/etc/remotes",
"shared_mount_read_only_root": "/etc/remotes-ro"},
{"shared_mount_root": "/mnt/oci-shared/../../etc"}, {"shared_mount_root_parent": "/"},
{"shared_mount_read_only_root": 5}):
self.assertIsNone(recovery.rclone_mount({**self.MOUNT, **change}), change)
self.assertIsNone(recovery.rclone_mount(None))
def test_the_hookscript_of_the_mount_follows_the_new_id(self):
text = "hookscript: local:snippets/proxmenux-rclone-119-fuse-hook.sh\nmemory: 119\n"
self.assertEqual(recovery.renumbered_lines(text, 119, 143),
"hookscript: local:snippets/proxmenux-rclone-143-fuse-hook.sh\nmemory: 119\n")
other = "hookscript: local:snippets/proxmenux-stack-dependencies.sh\n"
self.assertEqual(recovery.renumbered_lines(other, 119, 143), other)
def test_the_parameters_are_read_from_the_hookscript_of_the_host(self):
hook = ("inside=/data/mounts/drive\npublished=/mnt/oci-shared/remotes/drive\n"
"published_ro=/mnt/oci-shared/remotes-ro/drive\n if ! mountpoint -q /mnt/oci-shared; then\n")
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
(Path(tmp.name) / "proxmenux-rclone-119-fuse-hook.sh").write_text(hook)
config = "arch: amd64\nhookscript: local:snippets/proxmenux-rclone-119-fuse-hook.sh\n"
with patch.object(carried, "SNIPPETS", Path(tmp.name)):
self.assertEqual(carried.rclone_mount(config), self.MOUNT)
self.assertIsNone(carried.rclone_mount("arch: amd64\n"))
self.assertIsNone(carried.rclone_mount(config.replace("119", "120")))
class NvidiaStaticTests(unittest.TestCase):
"""A privileged container names every file of the NVIDIA driver of its host."""
@staticmethod
def inventory(version, uuid):
library = f"/usr/lib/x86_64-linux-gnu/libnvidia-ml.so.{version}"
return {"gpus": [f"Quadro P1000, {uuid}, {version}"], "toolkit_version": ["cli-version: 1", "lib-version: 1"],
"devices": {"/dev/nvidia0": {"source": "/dev/nvidia0", "uid": 0, "gid": 0, "mode": 0o666,
"major": 195, "minor": 0}},
"files": {library: {"source": library, "uid": 0, "gid": 0, "mode": 0o644, "size": 1, "sha256": "x"}},
"links": {}}
def test_the_driver_files_are_named_again_for_the_host_the_container_is_on(self):
import oci_nvidia_runtime as runtime
old, new = self.inventory("550.1", "GPU-aaa"), self.inventory("560.2", "GPU-aaa")
text = ("arch: amd64\ndev0: path=/dev/nvidia0,mode=0666,gid=0,deny-write=0\n"
"lxc.mount.entry: /usr/lib/x86_64-linux-gnu/libnvidia-ml.so.550.1 "
"usr/lib/x86_64-linux-gnu/libnvidia-ml.so.550.1 none ro,bind,create=file 0 0\n").encode()
plan = runtime.refresh_plan(text, old, new)
self.assertTrue(plan["changed"])
self.assertIn(b"libnvidia-ml.so.560.2 usr/lib/x86_64-linux-gnu/libnvidia-ml.so.560.2", plan["config"])
self.assertNotIn(b"550.1", plan["config"])
other = self.inventory("560.2", "GPU-bbb")
with self.assertRaises(ValueError):
runtime.refresh_plan(text, old, other)
self.assertTrue(runtime.refresh_plan(text, old, other, same_gpu=False)["changed"])
self.assertFalse(runtime.refresh_plan(text, old, old)["changed"])
class ReturnedContainerTests(unittest.TestCase):
"""A container that came back from another node, an older backup or a
snapshot carries the record that describes it."""
def copy(self, value, generation):
return {"schema_version": 1, "kind": carried.KIND, "node": "other", "vmid": 119,
"generation": generation, "record": value}
def test_a_copy_this_host_did_not_write_with_another_record_wins(self):
host, older = record(119, APP), record(119, APP)
older["deployment"]["hostname"] = "before-the-update"
stamp = {"digest": "x", "generation": "ours"}
self.assertTrue(carried.superseded(host, self.copy(older, "theirs"), stamp))
# Written by this host: the record changed here afterwards.
self.assertFalse(carried.superseded(host, self.copy(older, "ours"), stamp))
# Came back without changes: the same record.
self.assertFalse(carried.superseded(host, self.copy(record(119, APP), "theirs"), stamp))
# Nothing was written by this host yet, or the copy is of another installation.
self.assertFalse(carried.superseded(host, self.copy(older, "theirs"), {}))
self.assertFalse(carried.superseded(host, self.copy(record(119, DB), "theirs"), stamp))
def test_the_mark_of_the_last_copy_is_kept_on_the_host(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
path = Path(tmp.name) / carried.STAMP
self.assertEqual(carried.read_stamp(path), {})
path.write_text("abc\n")
self.assertEqual(carried.read_stamp(path), {"digest": "abc"})
path.write_text('{"digest": "abc", "generation": "g1"}\n')
self.assertEqual(carried.read_stamp(path), {"digest": "abc", "generation": "g1"})
class ClusterCopyTests(unittest.TestCase):
"""Every node of a cluster reads the copy kept in /etc/pve."""
def setUp(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
self.root = Path(tmp.name)
patcher = patch.object(carried, "CLUSTER", self.root / "cluster")
patcher.start()
self.addCleanup(patcher.stop)
def copy(self, value, generation="g1", node="pve1", saved_at="2026-10-03T10:00:00+00:00"):
return {"schema_version": 1, "kind": carried.KIND, "node": node, "vmid": value["vmid"],
"generation": generation, "saved_at": saved_at, "record": value}
def test_the_copy_is_written_and_read_back(self):
self.assertIsNone(carried.read_cluster(119))
self.assertTrue(carried.write_cluster(119, self.copy(record(119, APP))))
self.assertEqual(carried.read_cluster(119)["record"]["installation_id"], APP)
(carried.CLUSTER / "120.json").write_text("not json")
self.assertIsNone(carried.read_cluster(120))
carried.remove_cluster(119)
self.assertIsNone(carried.read_cluster(119))
def test_it_follows_the_copy_the_container_carries(self):
value = self.copy(record(119, APP))
carried.keep_cluster(119, value, "g1")
written = (carried.CLUSTER / "119.json").stat().st_mtime_ns
carried.keep_cluster(119, value, "g1")
self.assertEqual((carried.CLUSTER / "119.json").stat().st_mtime_ns, written)
carried.keep_cluster(119, value, "g2")
self.assertEqual(carried.read_cluster(119)["generation"], "g2")
def test_a_record_changed_on_another_node_replaces_the_one_of_this_host(self):
path = self.root / "oci-compose.json"
path.write_text("{}")
os.utime(path, (1_000_000_000, 1_000_000_000))
host, changed = record(119, APP), record(119, APP)
changed["deployment"]["hostname"] = "updated-on-the-other-node"
self.assertTrue(carried.replaced_elsewhere(host, path, self.copy(changed, node="pve2"), "pve1"))
# Written by this node, the same record, another installation or an older copy.
self.assertFalse(carried.replaced_elsewhere(host, path, self.copy(changed, node="pve1"), "pve1"))
self.assertFalse(carried.replaced_elsewhere(host, path, self.copy(record(119, APP), node="pve2"), "pve1"))
self.assertFalse(carried.replaced_elsewhere(host, path, self.copy(record(119, DB), node="pve2"), "pve1"))
old = self.copy(changed, node="pve2", saved_at="1999-01-01T00:00:00+00:00")
self.assertFalse(carried.replaced_elsewhere(host, path, old, "pve1"))
self.assertFalse(carried.replaced_elsewhere(host, path, None, "pve1"))
def examine(self, inside, shared):
if shared is not None:
carried.write_cluster(119, shared)
done = type("Done", (), {"returncode": 0, "stdout": config(APP)})()
class Root:
def __enter__(self): return Path("/rootfs")
def __exit__(self, *_): return False
with patch.object(recovery, "run", return_value=done), \
patch.object(carried, "container_root", return_value=Root()), \
patch.object(carried, "read_copy", return_value=inside):
return recovery.examine({"vmid": 119, "installation_id": APP, "hostname": "app"})
def test_the_same_copy_in_the_cluster_is_taken_without_asking(self):
value = self.copy(record(119, APP), "g1")
result = self.examine(dict(value), value)
self.assertTrue(result["trusted"])
self.assertIsNone(result["problem"])
def test_a_container_of_another_moment_is_described_by_its_own_copy(self):
older = record(119, APP)
older["deployment"]["hostname"] = "before"
result = self.examine(self.copy(older, "g0"), self.copy(record(119, APP), "g1"))
self.assertFalse(result["trusted"])
self.assertEqual(result["copy"]["record"]["deployment"]["hostname"], "before")
def test_a_container_without_its_copy_takes_the_one_of_the_cluster_and_asks(self):
result = self.examine(None, self.copy(record(119, APP), "g1"))
self.assertFalse(result["trusted"])
self.assertIsNone(result["problem"])
self.assertIsNotNone(result["copy"])
carried.remove_cluster(119)
nothing = self.examine(None, None)
self.assertTrue(nothing["unrecoverable"])
def test_the_copy_of_another_installation_is_not_used(self):
carried.remove_cluster(119)
result = self.examine(self.copy(record(119, APP), "g1"), self.copy(record(119, DB), "g1"))
self.assertFalse(result["trusted"])
self.assertEqual(result["copy"]["record"]["installation_id"], APP)
def test_only_what_needs_no_question_is_registered_on_its_own(self):
def plan(**extra):
item = dict(entry(119, APP, record(119, APP)), trusted=True)
item.update(extra.pop("entry", {}))
return {"blockers": [], "bridges": {}, "renumbered": {}, "hold": False, "restored": [item], **extra}
self.assertTrue(recovery.automatic(plan()))
self.assertFalse(recovery.automatic(plan(entry={"trusted": False})))
self.assertFalse(recovery.automatic(plan(bridges={"vmbr11": "10.77.1.0/24"})))
self.assertFalse(recovery.automatic(plan(blockers=["missing"])))
self.assertFalse(recovery.automatic(plan(renumbered={119: 140})))
self.assertFalse(recovery.automatic(plan(entry={"firewall": {"port": 19999}})))
self.assertFalse(recovery.automatic(plan(entry={"contract": {"schema": 1}})))
class RegistrationTests(unittest.TestCase):
def test_no_record_is_left_when_the_application_cannot_be_registered_whole(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
root = Path(tmp.name)
database = record(120, DB, stack_member={"stack_id": APP, "primary_vmid": 119, "name": "database"})
application = record(119, APP, stack_member={"stack_id": APP, "primary_vmid": 119, "name": "application"})
plan = {"restored": [entry(119, APP, application), entry(120, DB, database)]}
def refuse(_root, vmid):
if vmid == 120:
raise ValueError("cannot be updated")
with patch.object(instances, "private_directory", lambda path: path.mkdir(parents=True, exist_ok=True)), \
patch.object(recovery.oci_stack_modify, "register", side_effect=refuse):
with self.assertRaises(ValueError):
recovery.register(root, plan)
self.assertFalse(instances.location(root, 119).exists())
self.assertFalse(instances.location(root, 120).exists())
def test_the_plan_each_container_was_installed_with_is_left_as_it_is_now(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
root = Path(tmp.name)
database = record(150, DB, stack_member={"stack_id": APP, "primary_vmid": 151, "name": "database"})
database["deployment"]["mounts"] = [{"source": "local-lvm:vm-150-disk-1"}]
application = record(151, APP, stack_member={"stack_id": APP, "primary_vmid": 151, "name": "application"})
application["stack"] = {"id": APP, "members": [], "deployment": {"services": [
{"name": "database", "vmid": 150, "deployment": {"mounts": [{"source": "local-lvm:vm-120-disk-1"}]}},
{"name": "application", "vmid": 151, "deployment": {"create_arguments": ["119"]}},
{"name": "check", "vmid": 150, "healthcheck": {"type": "running"}}]}}
with patch.object(instances, "private_directory", lambda path: path.mkdir(parents=True, exist_ok=True)):
instances.write(instances.location(root, 150), database)
instances.write(instances.location(root, 151), application)
recovery.refresh_service_plans(root, [151, 150])
services = instances.read(root, 151)["stack"]["deployment"]["services"]
self.assertEqual(services[0]["deployment"]["mounts"], [{"source": "local-lvm:vm-150-disk-1"}])
self.assertEqual(services[1]["deployment"], application["deployment"])
self.assertNotIn("deployment", services[2])
if __name__ == "__main__":
unittest.main()
+47
View File
@@ -0,0 +1,47 @@
"""An image is the same in the registry and in the archive made from it,
whichever format the registry publishes its manifest in."""
from pathlib import Path
import sys
import unittest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "remote"))
from oci_installation_state import same_image
REGISTRY = "sha256:" + "a" * 64
ARCHIVE = "sha256:" + "b" * 64
LAYERS = ["sha256:" + "c" * 64, "sha256:" + "d" * 64]
BUILT = "2026-09-15T01:13:55.019799592Z"
class SameImageTests(unittest.TestCase):
def test_an_oci_manifest_keeps_its_digest(self):
self.assertTrue(same_image({"manifest_digest": REGISTRY}, {"manifest_digest": REGISTRY}))
def test_a_docker_manifest_is_recognised_by_its_layers_and_when_it_was_built(self):
self.assertTrue(same_image({"manifest_digest": REGISTRY, "layers": LAYERS, "created": BUILT},
{"manifest_digest": ARCHIVE, "layers": list(LAYERS), "created": BUILT}))
def test_another_image_is_not_the_same(self):
candidate = {"manifest_digest": REGISTRY, "layers": LAYERS, "created": BUILT}
self.assertFalse(same_image(candidate, {"manifest_digest": ARCHIVE, "layers": LAYERS[:1], "created": BUILT}))
self.assertFalse(same_image(candidate, {"manifest_digest": ARCHIVE, "layers": LAYERS[::-1], "created": BUILT}))
# The same layers rebuilt with another configuration are another image.
self.assertFalse(same_image(candidate, {"manifest_digest": ARCHIVE, "layers": LAYERS,
"created": "2026-10-01T00:00:00Z"}))
def test_what_is_missing_never_matches(self):
self.assertFalse(same_image({"manifest_digest": REGISTRY}, {"manifest_digest": ARCHIVE}))
self.assertFalse(same_image({"manifest_digest": REGISTRY, "layers": [], "created": BUILT},
{"manifest_digest": ARCHIVE, "layers": [], "created": BUILT}))
self.assertFalse(same_image({"manifest_digest": REGISTRY, "layers": LAYERS, "created": None},
{"manifest_digest": ARCHIVE, "layers": LAYERS, "created": None}))
# A record saved before the layers were kept is compared by its digest alone.
self.assertFalse(same_image({"manifest_digest": REGISTRY, "layers": LAYERS, "created": BUILT},
{"manifest_digest": ARCHIVE}))
if __name__ == "__main__":
unittest.main()
+47
View File
@@ -0,0 +1,47 @@
"""The notes Proxmox shows for a multi-container application link to the
address it answers on from the local network, not to its private leg."""
from pathlib import Path
import sys
from types import SimpleNamespace
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "remote"))
import oci_native_stack as stack
TWO_LEGS = "net0: name=eth0,bridge=vmbr0,ip=dhcp,type=veth\nnet1: name=eth1,bridge=vmbr10,ip=10.77.0.10/24,type=veth\n"
PRIVATE_ONLY = "net0: name=eth0,bridge=vmbr10,ip=10.77.0.12/24,type=veth\n"
class AccessAddressTests(unittest.TestCase):
def address(self, config, *answers, wait=20):
listed = iter(answers)
def run(command, **_):
if command[0] == "pct":
return SimpleNamespace(stdout=config)
return SimpleNamespace(stdout=next(listed))
with patch.object(stack.subprocess, "run", side_effect=run), patch.object(stack.time, "sleep"):
return stack.access_address(101, wait)
def test_the_address_of_the_local_network_is_used(self):
self.assertEqual(self.address(TWO_LEGS, "10.77.0.10\n192.168.0.44\n"), "192.168.0.44")
self.assertEqual(self.address(TWO_LEGS, "192.168.0.44\n10.77.0.10\n"), "192.168.0.44")
def test_a_lease_that_has_not_arrived_is_waited_for(self):
self.assertEqual(self.address(TWO_LEGS, "10.77.0.10\n", "10.77.0.10\n192.168.0.44\n"), "192.168.0.44")
def test_a_container_with_only_its_private_leg_keeps_that_address(self):
self.assertEqual(self.address(PRIVATE_ONLY, "10.77.0.12\n"), "10.77.0.12")
def test_a_container_without_address_has_none(self):
self.assertEqual(self.address(PRIVATE_ONLY, "\n"), "")
self.assertEqual(self.address(TWO_LEGS, "10.77.0.10\n", wait=0), "10.77.0.10")
if __name__ == "__main__":
unittest.main()
+62
View File
@@ -0,0 +1,62 @@
"""The temporary container that holds the data of an application during an
update is removed when the operation ends: empty after a commit, and with the
disks of the failed attempt after a verified recovery."""
from pathlib import Path
import sys
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "remote"))
import oci_instance_transaction as transaction
EMPTY = b"arch: amd64\nhostname: oci-data-holder\nrootfs: local-lvm:vm-104-disk-0,size=8G\n"
HOLDING = EMPTY + b"mp0: local-lvm:vm-104-disk-1,mp=/transaction-retained/bd021f9e,backup=1,size=32G\n"
STATE = {"id": "4dfbfd9223a440c1b07a0334b08fc4fd", "stage": 104}
class StageReleaseTests(unittest.TestCase):
def release(self, config, **arguments):
calls = []
with patch.object(transaction, "owned", return_value=config) as owned, \
patch.object(transaction, "stop", side_effect=lambda vmid: calls.append(("stop", vmid))), \
patch.object(transaction, "run", side_effect=lambda *command: calls.append(command)):
transaction.release_stage(STATE, **arguments)
owned.assert_called_once_with(104, "proxmenux-transaction=" + STATE["id"])
return calls
def test_after_a_commit_the_empty_container_is_removed(self):
self.assertEqual(self.release(EMPTY), [("pct", "destroy", "104")])
def test_a_container_that_still_holds_a_disk_is_kept(self):
self.assertEqual(self.release(HOLDING), [])
self.assertEqual(self.release(EMPTY + b"unused0: local-lvm:vm-104-disk-2\n"), [])
def test_after_a_verified_recovery_it_is_removed_with_what_it_holds(self):
self.assertEqual(self.release(HOLDING, discard=True), [("stop", 104), ("pct", "destroy", "104")])
def test_a_container_of_another_operation_is_never_removed(self):
calls = []
with patch.object(transaction, "owned", side_effect=ValueError("not ours")), \
patch.object(transaction, "run", side_effect=lambda *command: calls.append(command)):
transaction.release_stage(STATE, discard=True)
self.assertEqual(calls, [])
def test_an_operation_without_a_temporary_container_does_nothing(self):
with patch.object(transaction, "owned") as owned:
transaction.release_stage({"id": "x"}, discard=True)
owned.assert_not_called()
def test_a_removal_that_fails_does_not_undo_the_recovery(self):
with patch.object(transaction, "owned", return_value=HOLDING), \
patch.object(transaction, "stop"), \
patch.object(transaction, "run", side_effect=RuntimeError("pct failed with exit code 255")), \
patch.object(transaction, "log") as logged:
transaction.discard_stage(STATE)
self.assertIn("pct failed", logged.call_args[0][0])
if __name__ == "__main__":
unittest.main()