Files
ProxMenux/oci/src/proxmenux_oci/extra_devices.py
T

118 lines
6.1 KiB
Python

"""Explicit native LXC devices beyond an application's curated profile."""
import copy
import re
from pathlib import Path
from . import host
from .i18n import translate
from .ui import UserCancelled
GPU_NODE = re.compile(r'/dev/dri/(?:renderD|card)[0-9]+|/dev/kfd')
USB_NODE = re.compile(r'/dev/(?:ttyUSB[0-9]+|ttyACM[0-9]+|bus/usb/[0-9]{3}/[0-9]{3})')
CORAL_NODE = re.compile(r'/dev/apex_[0-9]+')
def choose_usb_device(ui, title, default=None, attached=()):
"""A USB device of the host picked by its name, or a node typed by hand."""
options = [(row['path'], f"{row['name']} · {translate(row['kind'])}")
for row in host.usb_devices() if row['path'] not in attached]
manual_prompt = translate('Host USB node (e.g. /dev/ttyACM0 or /dev/bus/usb/003/004)')
if not options:
ui.message(translate('No USB device was found on this host.'))
return ui.ask(manual_prompt, default or '/dev/ttyACM0')
options.append(('manual', translate('Another node, typed by hand')))
selected = ui.choose(title, options, default if default in dict(options) else options[0][0])
if selected is None:
raise UserCancelled(translate('Device configuration cancelled'))
return ui.ask(manual_prompt, default or '/dev/ttyACM0') if selected == 'manual' else selected
def ask_extra_devices(ui, devices, unprivileged, allow_coral=False, kinds=('gpu', 'nvidia', 'usb')):
"""Keep manual attachments separate from image-owned GPU profiles."""
result = list(devices)
while ui.confirm(translate('Add another GPU or USB device manually?'), False):
options = [option for option in (
('gpu', translate('Intel/AMD DRM node (device only)')),
('nvidia', translate('NVIDIA runtime (device and host driver libraries)')),
('usb', translate('USB or serial device node')),
) if option[0] in kinds]
if allow_coral:
options.append(('coral', translate('Coral PCIe/M.2 device node')))
kind = ui.choose(translate('Device to attach'), options)
if kind is None:
raise UserCancelled(translate('Device configuration cancelled'))
if kind == 'nvidia':
if any(item.get('kind') == 'nvidia-runtime' for item in result):
raise ValueError(translate('NVIDIA is already attached'))
result.append({'id': 'manual-nvidia', 'kind': 'nvidia-runtime',
'device_selection': 'all-requested-by-compose',
'runtime_mode': 'dynamic' if unprivileged else 'static'})
continue
if kind == 'gpu':
candidates = sorted(str(path) for path in Path('/dev/dri').glob('renderD*'))
default = candidates[0] if candidates else '/dev/dri/renderD128'
path = ui.ask(translate('Host DRM node (e.g. /dev/dri/renderD128)'), default)
valid = GPU_NODE.fullmatch(path)
elif kind == 'usb':
path = choose_usb_device(ui, translate('USB device to attach'),
attached={item.get('host_path') for item in result})
valid = USB_NODE.fullmatch(path)
else:
path = ui.ask(translate('Host Coral node (e.g. /dev/apex_0)'), '/dev/apex_0')
valid = CORAL_NODE.fullmatch(path)
if not valid:
raise ValueError(translate('Choose a specific supported GPU or USB node'))
if any(item.get('host_path') == path for item in result):
raise ValueError(translate('This device is already attached'))
if kind == 'usb' and '/bus/usb/' in path:
ui.info(translate('USB bus numbers can change after reconnecting or rebooting.'))
result.append({'id': 'manual-' + path.removeprefix('/dev/').replace('/', '-'),
'kind': 'character-device', 'host_path': path, 'container_path': path,
'mode': '0660', 'deny_write': False,
'gid_strategy': 'host-device-gid'})
return result
def device_permissions(image, devices, existing=None):
if existing:
return existing
repository = image.split('@', 1)[0].rsplit(':', 1)[0]
if repository.startswith(('lscr.io/linuxserver/', 'linuxserver/', 'docker.io/linuxserver/')) and any(
item.get('kind') == 'character-device' for item in devices):
return {'strategy': 'linuxserver-native-init', 'service_user': 'abc',
'environment': 'ATTACHED_DEVICES_PERMS',
'paths': 'all-resolved-selected-character-devices'}
return None
def ask_stack_extra_devices(ui, services):
"""Ask once per device, then select the stack members that need it."""
devices = ask_extra_devices(ui, [], True)
if not devices:
return
options = [(service['name'], service['name']) for service in services]
for device in devices:
selected = ui.checklist(
f"{translate('Containers that will receive this device')}: "
f"{device.get('host_path', 'NVIDIA')}", options,
[service['name'] for service in services if service.get('main')] or [options[-1][0]])
if not selected or set(selected) - {name for name, _ in options}:
raise ValueError(translate('Select at least one stack container'))
for service in services:
if service['name'] not in selected:
continue
plan = service['deployment']
existing = plan.setdefault('devices', [])
if any(item.get('host_path') == device.get('host_path') if device.get('host_path')
else item.get('kind') == 'nvidia-runtime' for item in existing):
raise ValueError(translate('This device is already attached'))
member_device = copy.deepcopy(device)
if member_device['kind'] == 'nvidia-runtime':
member_device['runtime_mode'] = (
'dynamic' if plan.get('security', {}).get('unprivileged', True) else 'static')
existing.append(member_device)
image = service['template']['container_contract']['image']['reference']
plan['device_permissions'] = device_permissions(
image, existing, plan.get('device_permissions'))