Generalize OCI device setup and remove unused catalog hashes

This commit is contained in:
MacRimi
2026-09-26 01:22:37 +02:00
parent 88acceb8ef
commit f7266e7b44
26 changed files with 689 additions and 487 deletions
+1 -1
View File
@@ -82,7 +82,7 @@
{
"name": "ND_BASEURL",
"example": "",
"required": true,
"required": false,
"sensitive": false,
"source": "docker-compose"
},
File diff suppressed because it is too large Load Diff
+25 -3
View File
@@ -20,6 +20,28 @@ die() {
exit 1
}
container_is_running() {
local status attempt
for (( attempt=1; attempt<=3; attempt++ )); do
if status=$(pct status "$VMID" 2>/dev/null); then
case "$status" in
'status: running') return 0 ;;
'status: stopped') return 1 ;;
esac
fi
# A failed pct probe must not be mistaken for a stopped container.
oci_log "pct status could not confirm CT $VMID (attempt $attempt); checking lxc-info"
if status=$(lxc-info -n "$VMID" -sH 2>/dev/null); then
case "$status" in
RUNNING) return 0 ;;
STOPPED) return 1 ;;
esac
fi
(( attempt < 3 )) && sleep 1
done
return 1
}
mount_ct_rootfs() {
oci_quiet pct mount "$VMID" || die "$(translate "Could not mount the container filesystem:") CT $VMID"
}
@@ -107,7 +129,7 @@ check_native_device_permissions() {
msg_info "$(translate "Checking the device permissions for the application user...")"
oci_log "Checking device access as abc (this is not a codec test)."
for (( attempt=0; attempt<60; attempt++ )); do
pct status "$VMID" | grep -q 'status: running' \
container_is_running \
|| die "$(translate "The container stopped before the GPU permissions were verified:") CT $VMID"
if pct exec "$VMID" -- s6-setuidgid abc sh -c \
'for path do test -r "$path" && test -w "$path" || exit 1; done' \
@@ -1792,7 +1814,7 @@ if [[ $START_AFTER == 1 && $HAS_STARTUP_HEALTHCHECK == 1 ]]; then
oci_log "Waiting for the service: $HC_URL"
msg_info "$(translate "Waiting for the application to respond...")"
while (( HC_ELAPSED < HC_TIMEOUT )); do
if [[ $(pct status "$VMID" 2>/dev/null || true) != "status: running" ]]; then
if ! container_is_running; then
oci_log "The container stopped during its first start. Last console messages:"
[[ -s $RUNTIME_CONSOLE_LOG ]] && tr -d '\r' <"$RUNTIME_CONSOLE_LOG" | tail -n 100 >>"${OCI_LOG:-/dev/stderr}"
die "$(translate "The container stopped before the application responded:") CT $VMID"
@@ -1838,7 +1860,7 @@ if [[ $START_AFTER == 1 && $HAS_RUNNING_CHECK == 1 ]]; then
msg_info "$(translate "Checking that the container keeps running...")"
RC_START=$(date +%s)
while (( $(date +%s) - RC_START < RC_STABILITY )); do
if [[ $(pct status "$VMID" 2>/dev/null || true) != "status: running" ]]; then
if ! container_is_running; then
oci_log "The container stopped after starting. Last console messages:"
[[ -s $RUNTIME_CONSOLE_LOG ]] && tr -d '\r' <"$RUNTIME_CONSOLE_LOG" | tail -n 100 >>"${OCI_LOG:-/dev/stderr}"
die "$(translate "The container stopped after starting:") CT $VMID"
+2 -6
View File
@@ -60,11 +60,7 @@ def render(template, digest, instance_id, ip=''):
source = template.get('source') or {}
image_url = (source.get('image_repository_url') or image_page(reference)
or ui.get('repository') or source.get('repository'))
resources = [('Image', image_url), ('App', ui.get('website')),
('App docs', ui.get('documentation'))]
repository = ui.get('repository') or source.get('repository')
if safe_url(repository) and repository != image_url:
resources.append(('Repository', repository))
resources = [('Image', image_url), ('App', ui.get('website'))]
resources = [link(label, url) for label, url in resources]
resources = [item for item in resources if item]
try:
@@ -88,7 +84,7 @@ def render(template, digest, instance_id, ip=''):
if not isinstance(path, str) or not path.startswith('/'):
path = '/'
url = f'{scheme}://{address}:{port}{path}'
item = f'{link(str(endpoint.get("label") or "Web UI"), url)}: {link(url, url)}'
item = f'&#127760; {link(str(endpoint.get("label") or "Web UI"), url)}: {link(url, url)}'
if item:
access.append(item)
badges = (
+4
View File
@@ -97,6 +97,10 @@ def propose(record, config):
before = parse_config(record['observed']['config'].encode())
current = parse_config(config)
changed = sorted(key for key in before.keys() | current.keys() if before.get(key) != current.get(key))
# Notes do not describe a mount, device or runtime setting. The OCI marker
# was checked above, so a presentation-only change needs no adoption.
if 'description' in changed:
changed.remove('description')
new_keys = [key for key in changed if key not in before and re.fullmatch(r'(mp|dev)[0-9]+', key)]
unsupported = [key for key in changed if key not in new_keys and key not in transaction.ADOPTABLE]
if unsupported:
+77 -11
View File
@@ -24,6 +24,7 @@ import stat
import sys
import time
import uuid
from urllib.parse import unquote
import oci_instances as instances
from oci_installation_state import image_from_archive, parse_config, private_directory, sha
@@ -204,6 +205,23 @@ def recovery_hint(after_recovery=False):
msg_warn(translate('The operation stopped halfway. Choose "Recover" for this container in the OCI management menu to restore the previous installation.'))
def recover_untouched(root, journal):
"""Close an operation that failed before the container was changed:
start the container again and restore its record, so nothing is left to
recover by hand. Returns whether it did."""
try:
state = json.loads(Path(journal).read_text())
if state.get('coordinated') or state.get('phase') in TERMINAL:
return False
if run('pct', 'config', str(state['vmid'])).decode() != state['before_config']:
return False
recover(root, Path(journal))
return True
except (OSError, ValueError, KeyError, RuntimeError, subprocess.SubprocessError) as error:
log(f'automatic recovery skipped: {error}')
return False
def fit(text):
"""A step line that is rewritten in place must not wrap."""
width = max(shutil.get_terminal_size((80, 24)).columns - 8, 30)
@@ -211,7 +229,11 @@ def fit(text):
def run(*args):
log('$ ' + shlex.join(str(arg) for arg in args))
private_description = args[:2] == ('pct', 'set') and '--description' in args
shown = [str(arg) for arg in args]
if private_description:
shown[shown.index('--description') + 1] = '[notes redacted]'
log('$ ' + shlex.join(shown))
# OCI extraction enters an unprivileged user namespace; PVE's newly created
# traversal directories must not inherit a caller's restrictive umask.
pve_creation = (args[:2] in (('pct', 'create'), ('pct', 'restore'))
@@ -224,12 +246,37 @@ def run(*args):
raise
if result.returncode:
log(f' exit {result.returncode}')
log_output(None if tuple(args[:2]) in DATA_COMMANDS else result.stdout, result.stderr)
log_output(None if private_description or tuple(args[:2]) in DATA_COMMANDS else result.stdout,
None if private_description else result.stderr)
if result.returncode:
raise RuntimeError(f"{args[0]} {translate('failed with exit code')} {result.returncode}")
return result.stdout
def verified_backup(vmid, directory, compression, unidentified, show=False):
"""A stop-mode vzdump of vmid in directory that passes its integrity
check. An archive that fails the check is written once more before the
operation gives up."""
suffix = 'zst' if compression == 'zstd' else 'gz'
for attempt in (1, 2):
run('vzdump', str(vmid), '--mode', 'stop', '--compress', compression,
'--dumpdir', str(directory), '--tmpdir', '/var/tmp')
backups = list(directory.glob(f'vzdump-lxc-*.tar.{suffix}'))
if len(backups) != 1:
raise ValueError(unidentified)
try:
run('zstd' if compression == 'zstd' else 'gzip', '-t', str(backups[0]))
return backups[0]
except RuntimeError:
if attempt == 2:
raise
log('backup attempt 1/2 failed its integrity check')
for damaged in directory.glob('vzdump-lxc-*'):
damaged.unlink()
if show:
msg_warn(translate('The backup did not pass its integrity check; creating it again...'))
def filehash(path):
value = hashlib.sha256()
with Path(path).open('rb') as source:
@@ -322,6 +369,10 @@ def external_changes(record, config, adopt=True):
return {}
before, now = parse_config(record['observed']['config'].encode()), parse_config(config)
changed = sorted(key for key in before.keys() | now.keys() if before.get(key) != now.get(key))
if ('description' in changed
and instances.identity(record['observed']['config'].encode()) == record['installation_id']
and instances.identity(config) == record['installation_id']):
changed.remove('description')
cores_key = 'cpulimit' if 'cpulimit' in before and 'cores' not in before else 'cores'
values = {}
for key in changed if adopt else ():
@@ -682,6 +733,22 @@ def restore_firewall(vmid, state):
Path(f'/etc/pve/firewall/{vmid}.fw').write_text(saved)
def original_description(state):
"""Return the user's original Notes, including text added outside ProxMenux."""
description = state.get('original_description')
if description is None:
# Journals created before this safeguard only have pct's escaped config.
description = unquote(parse_config(state['before_config'].encode()).get('description', ''))
if not isinstance(description, str) or instances.identity(
json.dumps({'description': description}).encode()) != state['record']['installation_id']:
raise ValueError(translate('The container identity changed; nothing was adopted'))
return description
def restore_description(vmid, state):
run('pct', 'set', str(vmid), '--description', original_description(state))
def release_stage(state):
"""After a commit the holder CT only keeps its own rootfs: every parked
volume went back to the application. Anything still attached keeps it."""
@@ -791,6 +858,7 @@ def apply(root, vmid, archive, operation, proposal=None, registry_digest=None, i
candidate = candidate_contract(record, operation, proposal)
before = run('pct', 'config', str(vmid))
cfg, actual, mac = preflight(record, candidate, before, coordinated)
description = original_description({'record': record, 'before_config': before.decode()})
original_sources, desired_sources = freeze_host_sources(record, candidate, acknowledge_external_data)
original_gpu = gpu_devices.planned(record['deployment'])
desired_gpu = gpu_devices.planned(candidate['deployment'])
@@ -823,6 +891,7 @@ def apply(root, vmid, archive, operation, proposal=None, registry_digest=None, i
for p, m in actual.items() if p in required and not m['volume'].startswith('/')])
state = {'schema_version': 1, 'id': directory.name, 'vmid': vmid, 'operation': operation,
'record': record, 'candidate_contract': candidate, 'before_config': before.decode(),
'original_description': description,
'archive': str(archive), 'archive_sha256': filehash(archive),
'registry_digest': registry_digest or image['manifest_digest'],
'runtime_template': candidate['template'], 'runtime_deployment': runtime_deployment,
@@ -867,14 +936,9 @@ def apply(root, vmid, archive, operation, proposal=None, registry_digest=None, i
else:
backup_dir = directory / 'backup'
private_directory(backup_dir)
run('vzdump', str(vmid), '--mode', 'stop', '--compress', backup_compression,
'--dumpdir', str(backup_dir), '--tmpdir', '/var/tmp')
suffix = 'zst' if backup_compression == 'zstd' else 'gz'
backups = list(backup_dir.glob(f'vzdump-lxc-*.tar.{suffix}'))
if len(backups) != 1:
raise ValueError(translate('The backup could not be identified; the image is not replaced'))
run('zstd' if backup_compression == 'zstd' else 'gzip', '-t', str(backups[0]))
state.update(backup=str(backups[0]), backup_sha256=filehash(backups[0]))
backup = verified_backup(vmid, backup_dir, backup_compression,
translate('The backup could not be identified; the image is not replaced'), show)
state.update(backup=str(backup), backup_sha256=filehash(backup))
checkpoint(journal, state, 'backup-ready')
if show:
msg_ok(translate('Backup created'))
@@ -920,6 +984,7 @@ def apply(root, vmid, archive, operation, proposal=None, registry_digest=None, i
if show:
progress = translate('Installing the new image:') if update else translate('Recreating the container:')
install_candidate(root, journal, state, progress)
restore_description(vmid, state)
restore_firewall(vmid, state)
if coordinated:
for key, value in state['preserved_stack_config'].items():
@@ -1239,7 +1304,8 @@ def main():
except (OSError, ValueError, KeyError, RuntimeError, subprocess.SubprocessError) as error:
with contextlib.redirect_stdout(output):
report_error(error, f'oci-{args.action}-{args.vmid}')
if pending_journal():
journal = pending_journal()
if journal and (args.action == 'recover' or not recover_untouched(args.root, journal)):
recovery_hint(after_recovery=args.action == 'recover')
return 1
+18
View File
@@ -223,6 +223,24 @@ def identity(config):
return match.group(1) if match else None
def same_config_except_notes(record, config):
"""Accept a presentation-only note edit, never a changed OCI identity or LXC setting."""
previous = record['observed']['config'].encode()
expected = record['installation_id']
if identity(previous) != expected or identity(config) != expected:
return False
def without_notes(value):
lines = value.splitlines(keepends=True)
notes = [line for line in lines if line.startswith(b'description: ')]
if len(notes) != 1:
return None
return b''.join(line for line in lines if not line.startswith(b'description: '))
original = without_notes(previous)
return original is not None and original == without_notes(config)
def save_assembly(root, primary_id, template, deployment, members):
path = location(root, primary_id).parent / 'stack-assembly.json'
if path.exists() or path.is_symlink():
+1 -2
View File
@@ -76,8 +76,7 @@ def refresh(root, vmid, apply=False):
if not nv.enabled(record['deployment']):
raise ValueError(translate('The instance does not use NVIDIA'))
config = instances.command('pct', 'config', str(vmid))
if (instances.identity(config) != record['installation_id']
or instances.sha(config) != record['observed']['config_sha256']):
if not instances.same_config_except_notes(record, config):
raise ValueError(translate('The container identity or configuration changed'))
previous = record['observed']['gpu_devices'][nv.KEY]
plan = nv.refresh_plan(config, previous)
+4 -8
View File
@@ -191,7 +191,7 @@ class NativeAdapter:
config = instances.command('pct', 'config', str(vmid))
if instances.identity(config) != record['installation_id']:
raise ValueError(translate('The identity of a member was replaced'))
if (not self.journal.exists() or not self.state().get('replacement_intent')) and sha(config) != record['observed']['config_sha256']:
if (not self.journal.exists() or not self.state().get('replacement_intent')) and not instances.same_config_except_notes(record, config):
raise ValueError(translate('A member configuration changed during the preparation'))
else:
if Path('/etc/pve/lxc/%s.conf' % vmid).exists():
@@ -321,13 +321,9 @@ class NativeAdapter:
self.validate(self.plan)
directory = self.journal.parent / ('backup-%s' % vmid)
private_directory(directory)
member_tx.run('vzdump', str(vmid), '--mode', 'stop', '--compress', 'zstd',
'--dumpdir', str(directory), '--tmpdir', '/var/tmp')
backups = list(directory.glob('vzdump-lxc-*.tar.zst'))
if len(backups) != 1:
raise ValueError(translate('The backup of a member could not be identified'))
member_tx.run('zstd', '-t', str(backups[0]))
return {'archive': str(backups[0]), 'sha256': member_tx.filehash(backups[0])}
archive = member_tx.verified_backup(vmid, directory, 'zstd',
translate('The backup of a member could not be identified'))
return {'archive': str(archive), 'sha256': member_tx.filehash(archive)}
def verify_backups(self, backups):
for backup in backups.values():
+2 -1
View File
@@ -192,7 +192,8 @@ def main():
return 1
except (OSError, ValueError, KeyError, RuntimeError, subprocess.SubprocessError) as error:
transaction.report_error(error, f'update-{args.vmid}')
if transaction.pending_journal():
journal = transaction.pending_journal()
if journal and not transaction.recover_untouched(instances.ROOT, journal):
transaction.recovery_hint()
return 1
+2
View File
@@ -115,6 +115,8 @@ def build_suite(template, ui, mode='advanced'):
if mode != DEFAULT_MODE:
from .custom_mounts import ask_stack_custom_mounts
ask_stack_custom_mounts(ui, services, storage)
from .extra_devices import ask_stack_extra_devices
ask_stack_extra_devices(ui, services)
return {'deployment_kind':'generic-multi-lxc-stack','suite_arr':True,'lifecycle_mode':'independent','stack_name':name,
'base_vmid':int(base) if base else None,'services':services,'shared_media':shared,'media_player':player,
'completion_notes':[
+24 -9
View File
@@ -1,7 +1,6 @@
from __future__ import annotations
import json
import hashlib
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime, timezone
from pathlib import Path
@@ -176,7 +175,6 @@ class Catalog:
),
"template": f"apps/{repo.app_id}.json" if repo.app_id in existing else None,
"template_status": existing.get(repo.app_id),
"content_hash": self._template_hash(repo.app_id) if repo.app_id in existing else None,
}
for repo, summary in discovered
]
@@ -291,7 +289,6 @@ class Catalog:
"category_label": metadata.get("category_label"),
"template": f"apps/{catalog_id}.json" if catalog_id in existing else None,
"template_status": existing.get(catalog_id),
"content_hash": self._template_hash(catalog_id) if catalog_id in existing else None,
}
)
@@ -515,6 +512,24 @@ class Catalog:
except (OSError, json.JSONDecodeError, KeyError, TypeError):
return
def _preserve_optional_environment(self, app_id: str, template: dict[str, Any]) -> None:
existing_path = self.apps_dir / f"{app_id}.json"
if not existing_path.is_file():
return
try:
existing = json.loads(existing_path.read_text(encoding="utf-8"))
if (existing["container_contract"]["image"]["repository"] !=
template["container_contract"]["image"]["repository"]):
return
optional = {item["name"]: item for item in existing["container_contract"]["environment"]
if item.get("required") is False}
for item in template["container_contract"]["environment"]:
previous = optional.get(item["name"])
if previous and previous.get("example") == item.get("example"):
item["required"] = False
except (OSError, json.JSONDecodeError, KeyError, TypeError):
return
def generate(self, app_id: str) -> tuple[Path, dict[str, Any]]:
item = self.find_item(app_id)
provider = item.get("provider", "linuxserver.io")
@@ -541,6 +556,7 @@ class Catalog:
raise ConversionError(f"Proveedor no soportado: {provider}")
catalog_id = item["id"]
self._apply_overlay(catalog_id, template)
self._preserve_optional_environment(catalog_id, template)
self._preserve_registry_state(catalog_id, template)
self.validate(template)
self.apps_dir.mkdir(parents=True, exist_ok=True)
@@ -593,6 +609,7 @@ class Catalog:
else:
raise ConversionError(f"Proveedor no soportado: {item_provider}")
self._apply_overlay(item["id"], template)
self._preserve_optional_environment(item["id"], template)
self._preserve_registry_state(item["id"], template)
self.validate(template)
return item["id"], template
@@ -661,7 +678,6 @@ class Catalog:
item["architectures"] = supported_architectures(
template["catalog_ui"]["architectures"]
)
item["content_hash"] = self._template_hash(app_id)
self._write_json(self.index_path, index)
generated.sort()
failed.sort(key=lambda item: item["id"])
@@ -759,7 +775,6 @@ class Catalog:
"curated_path": str(path.relative_to(self.root)),
"template": f"apps/{app_id}.json" if app_id in existing else None,
"template_status": existing.get(app_id),
"content_hash": self._template_hash(app_id) if app_id in existing else None,
}
)
return result
@@ -768,7 +783,11 @@ class Catalog:
path = self.overlays_dir / f"{app_id}.json"
if path.exists():
overlay = json.loads(path.read_text(encoding="utf-8"))
environment_overrides = overlay.pop("environment_overrides", {})
self._deep_merge(template, overlay)
for item in template.get("container_contract", {}).get("environment", []):
if item.get("name") in environment_overrides:
item.update(environment_overrides[item["name"]])
from .stack import apply_stack_support
apply_stack_support(template)
from .gpu import apply_gpu_contract
@@ -809,7 +828,6 @@ class Catalog:
"untranslated_blockers": template["compatibility"][
"untranslated_blockers"
],
"content_hash": self._template_hash(app_id),
}
)
if item.get("template_family") == "curated-profile":
@@ -826,6 +844,3 @@ class Catalog:
temporary = path.with_suffix(path.suffix + ".tmp")
temporary.write_text(json.dumps(payload, ensure_ascii=True, indent=2) + "\n", encoding="utf-8")
temporary.replace(path)
def _template_hash(self, app_id: str) -> str:
return hashlib.sha256((self.apps_dir / f"{app_id}.json").read_bytes()).hexdigest()
+17 -3
View File
@@ -366,10 +366,24 @@ def _install(catalog: Catalog, ui, item: dict[str, Any], mode: str) -> None:
def install_template(ui, template: dict[str, Any], identifier: str, mode: str) -> dict[str, Any] | None:
"""Configures and installs one template, from the catalog or written from a
definition the user gave."""
deployment = build_deployment(template, ui, mode)
if not ui.review(_deployment_summary_text(template, deployment), translate("Installation summary"),
question=translate("Install with this configuration?")):
from .ui import BacktrackUI, RestartWizard
wizard = BacktrackUI(ui)
try:
while True:
candidate = copy.deepcopy(template)
try:
deployment = build_deployment(candidate, wizard, mode)
approved = wizard.review(_deployment_summary_text(candidate, deployment),
translate("Installation summary"),
question=translate("Install with this configuration?"))
break
except RestartWizard:
wizard.restart()
finally:
wizard.close()
if not approved:
return None
template = candidate
console.show_logo()
console.msg_title(f"{source_text(template['catalog_ui']['title']) or identifier} · {APP_TITLE}")
try:
+104
View File
@@ -0,0 +1,104 @@
"""Explicit native LXC devices beyond an application's curated profile."""
import copy
import re
from pathlib import Path
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 ask_extra_devices(ui, devices, unprivileged, allow_coral=False):
"""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 = [
('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 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'))
if not ui.confirm(translate('Passing NVIDIA does not enable acceleration inside the application. '
'The host needs NVIDIA Container Toolkit and a compatible image. Continue?'), False):
continue
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 = ui.ask(translate('Host USB node (e.g. /dev/ttyACM0 or /dev/bus/usb/003/004)'),
'/dev/ttyACM0')
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'))
+13 -1
View File
@@ -20,6 +20,7 @@ from . import network as access
from .i18n import translate
from .ui import DialogUI, TerminalUI, UserCancelled
from .custom_mounts import ask_custom_mounts
from .extra_devices import device_permissions
class InstallError(RuntimeError):
@@ -379,6 +380,15 @@ def build_deployment(
devices, selected_hardware_profile, post_start_configurations, environment = configure_acceleration(
installer_profile, environment, unprivileged, ui, mode)
if advanced:
from .extra_devices import ask_extra_devices
reference = template['container_contract']['image']['reference']
repository = reference.split('@', 1)[0].rsplit(':', 1)[0]
devices = ask_extra_devices(
ui, devices, unprivileged,
allow_coral=repository in ('ghcr.io/blakeblackshear/frigate',
'codeproject/ai-server', 'docker.io/codeproject/ai-server'))
from .gpu import apply_profile_image
apply_profile_image(template, selected_hardware_profile)
@@ -455,7 +465,9 @@ def build_deployment(
"tmpfs_mounts": tmpfs_mounts,
"devices": devices,
"hardware_profile": selected_hardware_profile,
"device_permissions": installer_profile.get("device_permissions") if devices else None,
"device_permissions": (device_permissions(template['container_contract']['image']['reference'],
devices, installer_profile.get('device_permissions'))
if devices else None),
"post_start_configurations": post_start_configurations,
"extra_hosts": installer_profile.get("extra_hosts", []),
}
+15 -4
View File
@@ -216,10 +216,21 @@ def manage_instance(project, ui, row, action=None, lifecycle_args=()):
if action == 'recreate':
from .recreation import edit_recreation
from .cli import _deployment_summary_text
proposal = edit_recreation(record, ui)
if not ui.review(_deployment_summary_text(proposal['candidate']['template'],
proposal['candidate']['deployment']), translate('Recreate OCI'),
question=translate('Recreate with these options?'), default=True):
from .ui import BacktrackUI, RestartWizard
wizard = BacktrackUI(ui)
try:
while True:
try:
proposal = edit_recreation(record, wizard)
approved = wizard.review(_deployment_summary_text(proposal['candidate']['template'],
proposal['candidate']['deployment']), translate('Recreate OCI'),
question=translate('Recreate with these options?'), default=True)
break
except RestartWizard:
wizard.restart()
finally:
wizard.close()
if not approved:
return False
elif not ui.review(translate('The current image of the saved channel will be checked and downloaded. Resources, paths and GPU are kept. The CT is stopped during the replacement and a native backup is created first.'),
translate('Update OCI'), question=translate('Update now?'), default=True):
+20 -51
View File
@@ -104,31 +104,6 @@ def edit_environment(deployment, ui):
environment.append(item)
def edit_peripherals(deployment, ui, allow_coral=False):
devices = deployment.setdefault('devices', [])
label = 'Coral/USB' if allow_coral else 'USB'
example = '/dev/apex_0, ' if allow_coral else ''
while ui.confirm(f"{translate('Add or change a device')} ({label})?", False):
path = ui.ask(f"{translate('Host device node')} ({translate('e.g.')} {example}/dev/ttyACM0, /dev/bus/usb/003/004)")
if not re.fullmatch(r'/dev/(apex_[0-9]+|ttyUSB[0-9]+|ttyACM[0-9]+|bus/usb/[0-9]{3}/[0-9]{3})', path):
raise ValueError(translate('Select a specific Coral or USB node, not the whole /dev'))
if path.startswith('/dev/apex_') and not allow_coral:
raise ValueError(translate('Coral is only offered for Frigate and CodeProject.AI'))
if '/bus/usb/' in path:
ui.info(translate('The USB number can change after reconnecting or rebooting. This profile does not remap it automatically or handle Coral USB re-enumeration. Do not share a dongle already used by another service.'))
old = next((d for d in devices if d.get('host_path') == path), None)
mode = ui.ask(translate('Node octal permissions (e.g. 0660)'), (old or {}).get('mode', '0660'))
if not re.fullmatch(r'0?[0-7]{3}', mode):
raise ValueError(translate('Invalid octal permissions'))
item = dict(old or {}, id=(old or {}).get('id', 'peripheral-' + path.removeprefix('/dev/').replace('/', '-')),
kind='character-device', host_path=path, container_path=path,
mode=mode, gid_strategy='host-device-gid', deny_write=False)
if old:
devices[devices.index(old)] = item
else:
devices.append(item)
def edit_recreation(record, ui):
candidate = copy.deepcopy(record)
refresh_template(candidate, ui)
@@ -136,32 +111,21 @@ def edit_recreation(record, ui):
resources = deployment['resources']
resources['cores'] = positive_integer(ui, translate('Cores'), resources['cores'])
resources['memory_mb'] = positive_integer(ui, translate('RAM in MiB'), resources['memory_mb'], 128)
while ui.confirm(translate('Add a custom data path?'), False):
target = absolute_path(ui.ask(translate('Path inside the container'), '/data/custom'))
existing = [m['container_path'].rstrip('/') for m in deployment['mounts']]
if any(target == p or target.startswith(p + '/') or p.startswith(target + '/') for p in existing):
raise ValueError(translate('The path overlaps an existing mount'))
mode = ui.choose(translate('Persistence for the new path'),
[('managed-volume', translate('Container volume (included in backups)')),
('host-bind', translate('Host directory (not included in Proxmox backups)'))],
'managed-volume')
if mode is None:
raise UserCancelled(translate('Custom path cancelled'))
mount = {'type': mode, 'container_path': target, 'read_only': False}
if mode == 'managed-volume':
mount.update(source=ui.ask(translate('Proxmox storage for the volume'), deployment['rootfs']['storage']),
size_gb=positive_integer(ui, translate('Volume size in GB'), 4), backup=True)
else:
mount.update(source=absolute_path(ui.ask(translate('Host directory'),
'/mnt/oci-shared/custom')), size_gb=None, backup=False,
create_if_missing=True)
deployment['mounts'].append(mount)
from .custom_mounts import ask_custom_mounts
deployment['mounts'] = ask_custom_mounts(
ui, deployment['mounts'], deployment['rootfs']['storage'])
if ui.confirm(translate('Change the access network?'), False):
edit_network(deployment, ui)
edit_acceleration(candidate, ui)
from .extra_devices import ask_extra_devices
reference = candidate.get('template', {}).get('container_contract', {}).get('image', {}).get('reference', '')
repository = reference.split('@')[0].rsplit(':', 1)[0]
edit_peripherals(deployment, ui, repository in ('ghcr.io/blakeblackshear/frigate', 'codeproject/ai-server', 'docker.io/codeproject/ai-server'))
deployment['devices'] = ask_extra_devices(
ui, deployment.get('devices', []), deployment.get('security', {}).get('unprivileged', True),
allow_coral=repository in ('ghcr.io/blakeblackshear/frigate', 'codeproject/ai-server', 'docker.io/codeproject/ai-server'))
from .extra_devices import device_permissions
deployment['device_permissions'] = device_permissions(
reference, deployment['devices'], deployment.get('device_permissions'))
edit_environment(deployment, ui)
proposal = {'operation': 'recreate', 'candidate': candidate}
if record.get('observed', {}).get('config_sha256'):
@@ -172,14 +136,19 @@ def edit_recreation(record, ui):
def refresh_template(candidate, ui):
from .catalog import Catalog
old = candidate.get('template', {})
name = old.get('id', '').removeprefix('image-')
if not re.fullmatch(r'[a-z0-9][a-z0-9-]+', name):
template_id = old.get('id', '')
if not re.fullmatch(r'[a-z0-9][a-z0-9-]+', template_id):
return
root = Path(__file__).resolve().parents[2]
path = root / 'catalog' / 'apps' / (name + '.json')
if not path.is_file() or not old.get('container_contract', {}).get('image'):
if not old.get('container_contract', {}).get('image'):
return
latest = Catalog(root).load_template(name, generate_if_missing=False)
catalog = Catalog(root)
matching = [item for item in catalog.load_index()['applications']
if item.get('template_id') == template_id]
if len(matching) != 1:
return
name = matching[0]['id']
latest = catalog.compose(name)
if latest == old:
return
if not ui.confirm(translate('Apply the options from the current catalog template? Your data and configuration are kept.'), True):
+2
View File
@@ -355,6 +355,8 @@ def build_stack(template, ui, mode='advanced'):
if mode != DEFAULT_MODE:
from .custom_mounts import ask_stack_custom_mounts
ask_stack_custom_mounts(ui, plans, volumes)
from .extra_devices import ask_stack_extra_devices
ask_stack_extra_devices(ui, plans)
return {'deployment_kind':'generic-multi-lxc-stack','stack_name':name,'base_vmid':int(vmid) if vmid else None,
'completion_notes':template.get('proxmox',{}).get('stack_completion_notes',[]),
'rootfs_storage':root,'template_storage':cache,'onboot':onboot,'start_after_create':True,
+96 -3
View File
@@ -16,17 +16,96 @@ class UserCancelled(RuntimeError):
pass
class BackRequested(RuntimeError):
pass
class RestartWizard(RuntimeError):
pass
class BacktrackUI:
"""Replay prior answers when returning to a previous wizard question."""
def __init__(self, base):
self.base = base
self.answers = []
self.cursor = 0
self.previous_back_enabled = getattr(base, 'back_enabled', False)
base.back_enabled = True
def __getattr__(self, name):
return getattr(self.base, name)
def close(self):
self.base.back_enabled = self.previous_back_enabled
def restart(self):
self.cursor = 0
def _call(self, name, *args, **kwargs):
if self.cursor < len(self.answers):
saved_name, value = self.answers[self.cursor]
if saved_name != name:
self.answers = self.answers[:self.cursor]
else:
self.cursor += 1
return value
try:
value = getattr(self.base, name)(*args, **kwargs)
except BackRequested:
if self.cursor:
self.answers = self.answers[:self.cursor - 1]
self.cursor = 0
raise RestartWizard()
raise UserCancelled(translate('Wizard cancelled'))
self.answers.append((name, value))
self.cursor += 1
return value
def ask(self, *args, **kwargs):
return self._call('ask', *args, **kwargs)
def password(self, *args, **kwargs):
return self._call('password', *args, **kwargs)
def confirm(self, *args, **kwargs):
return self._call('confirm', *args, **kwargs)
def choose(self, *args, **kwargs):
return self._call('choose', *args, **kwargs)
def checklist(self, *args, **kwargs):
return self._call('checklist', *args, **kwargs)
def detail_menu(self, *args, **kwargs):
return self._call('detail_menu', *args, **kwargs)
def review(self, *args, **kwargs):
try:
return self.base.review(*args, **kwargs)
except BackRequested:
if self.cursor:
self.answers = self.answers[:self.cursor - 1]
self.cursor = 0
raise RestartWizard()
raise UserCancelled(translate('Wizard cancelled'))
APP_TITLE = "OCI manager Apps (beta)"
@dataclass
class TerminalUI:
title: str = APP_TITLE
back_enabled: bool = False
def ask(self, text: str, default: str | None = None, required: bool = True) -> str:
suffix = f" [{default}]" if default not in (None, "") else ""
while True:
value = input(f"{text}{suffix}: ").strip()
if self.back_enabled and value == ':back':
raise BackRequested()
if value:
return value
if default is not None:
@@ -38,18 +117,25 @@ class TerminalUI:
def password(self, text: str, required: bool = True) -> str:
while True:
value = getpass.getpass(f"{text}: ")
if self.back_enabled and value == ':back':
raise BackRequested()
if not value:
if not required:
return ""
print(translate("This value is required."))
continue
if value == getpass.getpass(f"{translate('Repeat to confirm')}: "):
repeated = getpass.getpass(f"{translate('Repeat to confirm')}: ")
if self.back_enabled and repeated == ':back':
raise BackRequested()
if value == repeated:
return value
print(translate("The values do not match. Enter them again."))
def confirm(self, text: str, default: bool = False) -> bool:
suffix = " [Y/n]" if default else " [y/N]"
value = input(f"{text}{suffix}: ").strip().casefold()
if self.back_enabled and value == ':back':
raise BackRequested()
if not value:
return default
return value in {"y", "yes", "s", "si"}
@@ -93,6 +179,7 @@ class DialogUI:
title: str = APP_TITLE
backtitle: str = "ProxMenux"
back_enabled: bool = False
@staticmethod
def available() -> bool:
@@ -103,10 +190,16 @@ class DialogUI:
if environment.get("TERM", "").casefold() in {"", "dumb", "unknown"}:
environment["TERM"] = "xterm-256color"
# dialog draws on the terminal and writes the selection to stderr.
return subprocess.run(
["dialog", "--no-collapse", "--backtitle", self.backtitle, "--title", title or self.title, *widget],
back_widget = ['--extra-button', '--extra-label', 'Volver'] if self.back_enabled and any(
flag in widget for flag in ('--inputbox', '--passwordbox', '--yesno', '--menu', '--checklist')) else []
result = subprocess.run(
["dialog", "--no-collapse", "--backtitle", self.backtitle, "--title", title or self.title,
*back_widget, *widget],
stdout=None, stderr=subprocess.PIPE, text=True, check=False, env=environment,
)
if result.returncode == 3 and back_widget:
raise BackRequested()
return result
@staticmethod
def _size(text: str, min_height: int, width: int, extra: int = 6) -> tuple[str, str]:
+58
View File
@@ -5,6 +5,7 @@ import sys
import tempfile
import unittest
from unittest.mock import patch
from subprocess import CompletedProcess
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'remote'))
@@ -35,6 +36,63 @@ class MountTests(unittest.TestCase):
class ProposalTests(unittest.TestCase):
def test_note_only_comparison_keeps_other_lxc_settings_strict(self):
marker = '11111111-1111-1111-1111-111111111111'
before = f'description: Old proxmenux-instance={marker}\ncores: 2\n'.encode()
record = {'installation_id': marker, 'observed': {'config': before.decode()}}
updated = f'description: New proxmenux-instance={marker}\ncores: 2\n'.encode()
self.assertTrue(reconcile.instances.same_config_except_notes(record, updated))
self.assertFalse(reconcile.instances.same_config_except_notes(record, updated.replace(b'cores: 2', b'cores: 4')))
self.assertFalse(reconcile.instances.same_config_except_notes(record, b'description: Other instance\ncores: 2\n'))
def test_replacement_restores_user_notes_verbatim(self):
marker = '11111111-1111-1111-1111-111111111111'
notes = f'<p>Nota personal: no borrar</p><!-- proxmenux-instance={marker} -->'
state = {'record': {'installation_id': marker}, 'original_description': notes}
with patch.object(reconcile.transaction, 'run') as run:
reconcile.transaction.restore_description(200, state)
run.assert_called_once_with('pct', 'set', '200', '--description', notes)
state.pop('original_description')
state['before_config'] = f'description: {notes.replace("%", "%25").replace("<", "%3C").replace(">", "%3E")}\n'
self.assertEqual(reconcile.transaction.original_description(state), notes)
def test_restoring_notes_does_not_log_their_contents(self):
notes = '<p>Nota privada del usuario</p>'
with (patch.object(reconcile.transaction, 'log') as log,
patch.object(reconcile.transaction.subprocess, 'run',
return_value=CompletedProcess([], 0, b'', b'')) as command):
reconcile.transaction.run('pct', 'set', '200', '--description', notes)
command.assert_called_once()
self.assertNotIn(notes, str(log.call_args))
def test_notes_only_change_does_not_require_adoption_or_block_update(self):
marker = '11111111-1111-1111-1111-111111111111'
config = f'description: Old notes proxmenux-instance={marker}\n'.encode()
updated = f'description: New notes proxmenux-instance={marker}\n'.encode()
record = {
'vmid': 200, 'status': 'installed', 'installation_id': marker,
'deployment': {'mounts': [], 'devices': []},
'observed': {'config': config.decode(), 'config_sha256': sha(config)},
}
self.assertIsNone(reconcile.propose(record, updated))
self.assertEqual(reconcile.transaction.external_changes(record, updated), {})
def test_notes_cannot_hide_identity_or_network_changes(self):
marker = '11111111-1111-1111-1111-111111111111'
config = f'description: Old notes proxmenux-instance={marker}\n'.encode()
record = {
'vmid': 200, 'status': 'installed', 'installation_id': marker,
'deployment': {'mounts': [], 'devices': []},
'observed': {'config': config.decode(), 'config_sha256': sha(config)},
}
with self.assertRaises(ValueError):
reconcile.propose(record, b'description: Different instance\n')
changed = f'description: New notes proxmenux-instance={marker}\nnet0: name=eth0,bridge=vmbr1\n'.encode()
with self.assertRaises(ValueError):
reconcile.propose(record, changed)
with self.assertRaises(ValueError):
reconcile.transaction.external_changes(record, changed)
def test_new_volume_requires_confirmation_before_contract_changes(self):
config = b'description: proxmenux-instance=11111111-1111-1111-1111-111111111111\n'
added = config + b'mp2: local-lvm:vm-200-disk-3,mp=/media,size=8G,backup=1\n'
+11
View File
@@ -29,10 +29,13 @@ class DescriptionTests(unittest.TestCase):
self.assertIn('Image: <code>adguard/adguardhome:latest</code> ', notes)
self.assertIn('>Image</a> &middot; ', notes)
self.assertIn('>App</a>', notes)
self.assertNotIn('>App docs</a>', notes)
self.assertNotIn('>Repository</a>', notes)
self.assertIn('href="http://192.168.0.42:3000/" target="_blank" rel="noopener noreferrer">Setup (first run)</a>', notes)
self.assertIn('>http://192.168.0.42:3000/</a>', notes)
self.assertIn('href="http://192.168.0.42:80/" target="_blank" rel="noopener noreferrer">Web UI (after setup)</a>', notes)
self.assertIn('>http://192.168.0.42:80/</a>', notes)
self.assertEqual(notes.count('&#127760; '), 2)
def test_missing_ip_does_not_publish_placeholder_links(self):
template = json.loads((CATALOG / 'adguard-home.json').read_text())
@@ -40,6 +43,14 @@ class DescriptionTests(unittest.TestCase):
self.assertNotIn('http://:3000', notes)
self.assertIn('proxmenux-instance=' + INSTANCE, notes)
def test_chromium_notes_keep_only_image_and_app_links(self):
template = json.loads((CATALOG / 'chromium.json').read_text())
notes = render(template, '', INSTANCE, '192.168.0.37')
self.assertIn('>Image</a>', notes)
self.assertIn('>App</a>', notes)
self.assertNotIn('>App docs</a>', notes)
self.assertNotIn('>Repository</a>', notes)
def test_untrusted_title_is_escaped(self):
template = {'id': 'example', 'catalog_ui': {'title': {'en_US': '<script>x</script>'}},
'container_contract': {'image': {'reference': 'example:latest'}}}
+69
View File
@@ -0,0 +1,69 @@
"""Regression tests for catalog options added after an OCI installation."""
import copy
from pathlib import Path
import sys
import unittest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'src'))
from proxmenux_oci.catalog import Catalog
from proxmenux_oci.recreation import refresh_template
class ConfirmingUI:
def confirm(self, _message, _default=False):
return True
def info(self, _message):
pass
def ask(self, _message, default=''):
return default
class RecreationCatalogTests(unittest.TestCase):
def test_catalog_index_has_no_unused_template_hashes(self):
applications = Catalog(ROOT).load_index()['applications']
self.assertTrue(applications)
self.assertTrue(all('content_hash' not in item for item in applications))
def test_internal_template_id_resolves_current_catalog_template(self):
catalog = Catalog(ROOT)
previous = catalog.load_template('chromium', generate_if_missing=False)
self.assertEqual(previous['id'], 'linuxserver-chromium')
previous = copy.deepcopy(previous)
previous['catalog_ui']['title']['en_US'] = 'Old Chromium'
candidate = {'template': previous, 'deployment': {
'rootfs': {'storage': 'local-lvm'},
'mounts': [{'container_path': '/config'}],
'environment': [{'name': item['name'], 'value': item.get('example') or 'value',
'sensitive': item['sensitive']}
for item in previous['container_contract']['environment']
if item['required']],
'security': {},
}}
refresh_template(candidate, ConfirmingUI())
self.assertEqual(candidate['template']['catalog_ui']['title']['en_US'],
catalog.compose('chromium')['catalog_ui']['title']['en_US'])
def test_navidrome_baseurl_is_optional(self):
catalog = Catalog(ROOT)
template = catalog.compose('navidrome')
base_url = next(item for item in template['container_contract']['environment']
if item['name'] == 'ND_BASEURL')
self.assertFalse(base_url['required'])
self.assertEqual(base_url['example'], '')
regenerated = copy.deepcopy(template)
target = next(item for item in regenerated['container_contract']['environment']
if item['name'] == 'ND_BASEURL')
target['required'] = True
catalog._preserve_optional_environment('navidrome', regenerated)
self.assertFalse(target['required'])
if __name__ == '__main__':
unittest.main()
+104
View File
@@ -0,0 +1,104 @@
"""Shared wizard navigation and device choices do not depend on app overlays."""
from pathlib import Path
import sys
import unittest
from unittest.mock import patch
from subprocess import CompletedProcess
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'src'))
from proxmenux_oci.extra_devices import (ask_extra_devices, ask_stack_extra_devices,
device_permissions)
from proxmenux_oci.ui import BackRequested, BacktrackUI, DialogUI, RestartWizard
class SequenceUI:
def __init__(self, answers):
self.answers = iter(answers)
self.back_enabled = False
def ask(self, *_args, **_kwargs):
result = next(self.answers)
if result == 'back':
raise BackRequested()
return result
def confirm(self, *_args, **_kwargs):
return next(self.answers)
def choose(self, *_args, **_kwargs):
return next(self.answers)
def checklist(self, *_args, **_kwargs):
return next(self.answers)
def info(self, _text):
pass
class WizardTests(unittest.TestCase):
def test_back_returns_to_previous_answer_without_reasking_first(self):
base = SequenceUI(['first', 'second', 'back', 'corrected', 'third'])
wizard = BacktrackUI(base)
try:
self.assertEqual(wizard.ask('first'), 'first')
self.assertEqual(wizard.ask('second'), 'second')
with self.assertRaises(RestartWizard):
wizard.ask('third')
wizard.restart()
self.assertEqual(wizard.ask('first'), 'first')
self.assertEqual(wizard.ask('second'), 'corrected')
self.assertEqual(wizard.ask('third'), 'third')
finally:
wizard.close()
self.assertFalse(base.back_enabled)
def test_back_from_review_reopens_last_question(self):
class ReviewUI(SequenceUI):
def review(self, *_args, **_kwargs):
raise BackRequested()
wizard = BacktrackUI(ReviewUI(['value', 'replacement']))
try:
self.assertEqual(wizard.ask('value'), 'value')
with self.assertRaises(RestartWizard):
wizard.review('summary')
wizard.restart()
self.assertEqual(wizard.ask('value'), 'replacement')
finally:
wizard.close()
def test_manual_usb_is_available_without_profile(self):
ui = SequenceUI([True, 'usb', '/dev/ttyACM0', False])
devices = ask_extra_devices(ui, [], True)
self.assertEqual(devices[0]['host_path'], '/dev/ttyACM0')
self.assertEqual(devices[0]['container_path'], '/dev/ttyACM0')
def test_linuxserver_device_permissions_are_generic(self):
permissions = device_permissions('lscr.io/linuxserver/chromium:latest',
[{'kind': 'character-device'}])
self.assertEqual(permissions['strategy'], 'linuxserver-native-init')
def test_stack_device_goes_only_to_selected_member(self):
ui = SequenceUI([True, 'usb', '/dev/ttyACM0', False, ['server']])
services = [
{'name': name, 'main': name == 'server',
'template': {'container_contract': {'image': {'reference': 'example/app:latest'}}},
'deployment': {'devices': [], 'security': {'unprivileged': True}}}
for name in ('database', 'server')
]
ask_stack_extra_devices(ui, services)
self.assertEqual(services[0]['deployment']['devices'], [])
self.assertEqual(services[1]['deployment']['devices'][0]['host_path'], '/dev/ttyACM0')
def test_dialog_back_button_is_only_on_wizard_inputs(self):
ui = DialogUI(back_enabled=True)
with patch('proxmenux_oci.ui.subprocess.run', return_value=CompletedProcess([], 3, '', '')) as run:
with self.assertRaises(BackRequested):
ui._run(['--inputbox', 'Name', '10', '50', ''])
self.assertIn('--extra-button', run.call_args.args[0])
if __name__ == '__main__':
unittest.main()