mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-10-08 22:46:41 +00:00
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.
308 lines
15 KiB
Python
308 lines
15 KiB
Python
#!/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())
|