feat(oci): coherent advanced installer flow, container creation progress and USB device list

This commit is contained in:
MacRimi
2026-09-29 20:20:36 +02:00
parent db4ef3f29b
commit 135df4b2c5
20 changed files with 752 additions and 180 deletions
+4 -4
View File
@@ -361,7 +361,7 @@ fi
TAGS="media;oci;proxmenux"
msg_info "$(translate "Creating the container...")"
oci_quiet pct create "$DB_ID" "$DB_ARCHIVE" --rootfs "${ROOTFS_STORAGE}:8" \
oci_create_container "$DB_ID" "$DB_ARCHIVE" --rootfs "${ROOTFS_STORAGE}:8" \
--mp0 "${DATABASE_STORAGE}:${DATABASE_SIZE},mp=/var/lib/postgresql/data,backup=1" \
--hostname "${STACK_NAME}-db" --cores 2 --memory 2048 --swap 512 \
--net0 "name=eth0,bridge=${PRIVATE_BRIDGE},firewall=1,host-managed=1,ip=${DB_ADDRESS},type=veth" \
@@ -390,7 +390,7 @@ oci_quiet pct unmount "$DB_ID"
msg_ok "$(translate "Container created:") CT $DB_ID (PostgreSQL)"
msg_info "$(translate "Creating the container...")"
oci_quiet pct create "$VALKEY_ID" "$VALKEY_ARCHIVE" --rootfs "${ROOTFS_STORAGE}:4" \
oci_create_container "$VALKEY_ID" "$VALKEY_ARCHIVE" --rootfs "${ROOTFS_STORAGE}:4" \
--mp0 "${ROOTFS_STORAGE}:4,mp=/data,backup=1" \
--hostname "${STACK_NAME}-valkey" --cores 1 --memory 512 --swap 256 \
--net0 "name=eth0,bridge=${PRIVATE_BRIDGE},firewall=1,host-managed=1,ip=${VALKEY_ADDRESS},type=veth" \
@@ -412,7 +412,7 @@ set_lxc_directive "$VALKEY_ID" lxc.signal.halt SIGTERM
msg_ok "$(translate "Container created:") CT $VALKEY_ID (Valkey)"
msg_info "$(translate "Creating the container...")"
oci_quiet pct create "$ML_ID" "$ML_ARCHIVE" --rootfs "${ROOTFS_STORAGE}:12" \
oci_create_container "$ML_ID" "$ML_ARCHIVE" --rootfs "${ROOTFS_STORAGE}:12" \
--mp0 "${ROOTFS_STORAGE}:${MODEL_CACHE_SIZE},mp=/cache,backup=1" \
--hostname "${STACK_NAME}-ml" "${ML_CPU_ARGS[@]}" --memory "$ML_MEMORY" --swap "$ML_SWAP" \
--net0 "name=eth0,bridge=${FRONTEND_BRIDGE},firewall=1,host-managed=1,${ML_FRONTEND_NET},type=veth" \
@@ -447,7 +447,7 @@ if [[ $VIDEO_ACCELERATION == vaapi ]]; then
fi
msg_info "$(translate "Creating the container...")"
oci_quiet pct create "$SERVER_ID" "$SERVER_ARCHIVE" --rootfs "${ROOTFS_STORAGE}:16" \
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 \
--net0 "name=eth0,bridge=${FRONTEND_BRIDGE},firewall=1,host-managed=1,${FRONTEND_NET},type=veth" \
+3 -3
View File
@@ -343,7 +343,7 @@ fi
TAGS="productivity;oci;proxmenux"
msg_info "$(translate "Creating the container...")"
oci_quiet pct create "$DATABASE_ID" "$DATABASE_ARCHIVE" --rootfs "${ROOTFS_STORAGE}:8" \
oci_create_container "$DATABASE_ID" "$DATABASE_ARCHIVE" --rootfs "${ROOTFS_STORAGE}:8" \
--mp0 "${DATABASE_STORAGE}:${DATABASE_SIZE},mp=/var/lib/postgresql,backup=1" \
--hostname "${STACK_NAME}-db" --cores 2 --memory 1024 --swap 512 \
--net0 "name=eth0,bridge=${PRIVATE_BRIDGE},firewall=1,host-managed=1,ip=${DATABASE_ADDRESS},type=veth" \
@@ -380,7 +380,7 @@ set_runtime_env "$DATABASE_ID" TZ "$TIMEZONE"
msg_ok "$(translate "Container created:") CT $DATABASE_ID (PostgreSQL)"
msg_info "$(translate "Creating the container...")"
oci_quiet pct create "$CACHE_ID" "$CACHE_ARCHIVE" --rootfs "${ROOTFS_STORAGE}:4" \
oci_create_container "$CACHE_ID" "$CACHE_ARCHIVE" --rootfs "${ROOTFS_STORAGE}:4" \
--hostname "${STACK_NAME}-redis" --cores 1 --memory 512 --swap 256 \
--net0 "name=eth0,bridge=${PRIVATE_BRIDGE},firewall=1,host-managed=1,ip=${CACHE_ADDRESS},type=veth" \
--unprivileged 1 --features nesting=1 --cmode console --onboot "$ONBOOT" \
@@ -393,7 +393,7 @@ set_lxc_directive "$CACHE_ID" lxc.signal.halt SIGTERM
msg_ok "$(translate "Container created:") CT $CACHE_ID (Redis)"
msg_info "$(translate "Creating the container...")"
oci_quiet pct create "$APPLICATION_ID" "$APPLICATION_ARCHIVE" --rootfs "${ROOTFS_STORAGE}:8" \
oci_create_container "$APPLICATION_ID" "$APPLICATION_ARCHIVE" --rootfs "${ROOTFS_STORAGE}:8" \
--mp0 "$APPLICATION_MOUNT" --hostname "$STACK_NAME" \
--cores 2 --memory 2048 --swap 1024 \
--net0 "name=eth0,bridge=${FRONTEND_BRIDGE},firewall=1,host-managed=1,${FRONTEND_NET},type=veth" \
+1 -1
View File
@@ -1630,7 +1630,7 @@ fi
# pct keeps the OCI Entrypoint/Cmd/Env/User/WorkingDir/StopSignal metadata.
msg_info "$(translate "Creating the container...")"
oci_quiet pct create "${CREATE_ARGS[@]}" \
oci_create_container "${CREATE_ARGS[@]}" \
|| die "$(translate "Could not create the container:") CT $VMID"
CT_CREATED=1
msg_ok "$(translate "Container created:") CT $VMID ($HOSTNAME)"
+3 -3
View File
@@ -359,7 +359,7 @@ fi
TAGS="productivity;oci;proxmenux"
msg_info "$(translate "Creating the container...")"
oci_quiet pct create "$DATABASE_ID" "$DATABASE_ARCHIVE" --rootfs "${ROOTFS_STORAGE}:8" \
oci_create_container "$DATABASE_ID" "$DATABASE_ARCHIVE" --rootfs "${ROOTFS_STORAGE}:8" \
--mp0 "${DATABASE_STORAGE}:${DATABASE_SIZE},mp=/var/lib/postgresql,backup=1" \
--hostname "${STACK_NAME}-db" --cores 2 --memory 1024 --swap 512 \
--net0 "name=eth0,bridge=${PRIVATE_BRIDGE},firewall=1,host-managed=1,ip=${DATABASE_ADDRESS},type=veth" \
@@ -393,7 +393,7 @@ set_runtime_env "$DATABASE_ID" TZ "$TIMEZONE"
msg_ok "$(translate "Container created:") CT $DATABASE_ID (PostgreSQL)"
msg_info "$(translate "Creating the container...")"
oci_quiet pct create "$BROKER_ID" "$BROKER_ARCHIVE" --rootfs "${ROOTFS_STORAGE}:4" \
oci_create_container "$BROKER_ID" "$BROKER_ARCHIVE" --rootfs "${ROOTFS_STORAGE}:4" \
--mp0 "${APPLICATION_STORAGE}:${BROKER_SIZE},mp=/data,backup=1" \
--hostname "${STACK_NAME}-valkey" --cores 1 --memory 512 --swap 256 \
--net0 "name=eth0,bridge=${PRIVATE_BRIDGE},firewall=1,host-managed=1,ip=${BROKER_ADDRESS},type=veth" \
@@ -410,7 +410,7 @@ set_lxc_directive "$BROKER_ID" lxc.signal.halt SIGTERM
msg_ok "$(translate "Container created:") CT $BROKER_ID (Valkey)"
msg_info "$(translate "Creating the container...")"
oci_quiet pct create "$APPLICATION_ID" "$APPLICATION_ARCHIVE" --rootfs "${ROOTFS_STORAGE}:8" \
oci_create_container "$APPLICATION_ID" "$APPLICATION_ARCHIVE" --rootfs "${ROOTFS_STORAGE}:8" \
--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" \
+2 -2
View File
@@ -352,7 +352,7 @@ fi
TAGS="productivity;oci;proxmenux"
msg_info "$(translate "Creating the container...")"
oci_quiet pct create "$DATABASE_ID" "$DATABASE_ARCHIVE" --rootfs "${ROOTFS_STORAGE}:4" \
oci_create_container "$DATABASE_ID" "$DATABASE_ARCHIVE" --rootfs "${ROOTFS_STORAGE}:4" \
--mp0 "${DATABASE_STORAGE}:${DATABASE_SIZE},mp=/var/lib/postgresql/data,backup=1" \
--hostname "${STACK_NAME}-db" --cores 2 --memory 1024 --swap 512 \
--net0 "name=eth0,bridge=${PRIVATE_BRIDGE},firewall=1,host-managed=1,ip=${DATABASE_ADDRESS},type=veth" \
@@ -389,7 +389,7 @@ set_runtime_env "$DATABASE_ID" TZ "$TIMEZONE"
msg_ok "$(translate "Container created:") CT $DATABASE_ID (PostgreSQL)"
msg_info "$(translate "Creating the container...")"
oci_quiet pct create "$APPLICATION_ID" "$APPLICATION_ARCHIVE" --rootfs "${ROOTFS_STORAGE}:8" \
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 \
+43
View File
@@ -115,6 +115,49 @@ oci_quiet() {
fi
}
# `pct create` of an OCI archive, showing how much of the image is already
# unpacked into the rootfs. Arguments: VMID ARCHIVE [pct create options...]
oci_create_container() {
local vmid=$1 archive=$2
shift 2
local path=$archive rootfs="/var/lib/lxc/${vmid}/rootfs" log=${OCI_LOG:-/dev/null}
local total_mib=0 base_mib="" used_mib extracted_mib percentage pid status=0 elapsed=0 mounted=0 extracting=0
[[ $path == /* ]] || path=$(pvesm path "$archive" 2>/dev/null || true)
if [[ -n ${VERIFY_OCI_ARCHIVE:-} && -f $path ]]; then
total_mib=$(python3 "$VERIFY_OCI_ARCHIVE" --extracted-mib "$path" 2>/dev/null || printf '0')
[[ $total_mib =~ ^[0-9]+$ ]] || total_mib=0
fi
pct create "$vmid" "$archive" "$@" >>"$log" 2>&1 &
pid=$!
while kill -0 "$pid" 2>/dev/null; do
if mountpoint -q "$rootfs" 2>/dev/null; then
mounted=1
used_mib=$(df -BM --output=used "$rootfs" 2>/dev/null | tail -n 1 | tr -dc '0-9' || true)
used_mib=${used_mib:-0}
base_mib=${base_mib:-$used_mib}
extracted_mib=$(( used_mib > base_mib ? used_mib - base_mib : 0 ))
(( extracted_mib > 0 )) && extracting=1
if (( ! extracting )); then
msg_progress "$(translate "Creating the container: reading the image...") ${elapsed}s"
elif (( total_mib > 0 )); then
(( extracted_mib < total_mib )) || extracted_mib=$(( total_mib * 99 / 100 ))
percentage=$(( extracted_mib * 100 / total_mib ))
msg_progress "$(translate "Creating the container: extracting the image") ${extracted_mib} / ${total_mib} MiB (${percentage}%), ${elapsed}s"
else
msg_progress "$(translate "Creating the container: extracting the image") ${extracted_mib} MiB, ${elapsed}s"
fi
elif (( mounted )); then
msg_progress "$(translate "Creating the container: applying the image configuration...") ${elapsed}s"
else
msg_progress "$(translate "Creating the container: preparing its disk...") ${elapsed}s"
fi
sleep 1
elapsed=$((elapsed + 1))
done
wait "$pid" || status=$?
return "$status"
}
# Last lines of the log, for the error report.
oci_log_tail() {
[[ -n $OCI_LOG && -s $OCI_LOG ]] || return 0
+55
View File
@@ -5,7 +5,9 @@ from __future__ import annotations
import argparse
import hashlib
import json
import os
import struct
import sys
import tarfile
import zlib
@@ -114,10 +116,63 @@ def verify_archive(path: Path) -> None:
progress(f"OCI integrity verified: {path}")
def _layer_extracted_size(raw, member: tarfile.TarInfo) -> int | None:
"""What a layer takes once unpacked: the gzip trailer, the frame header of
zstd or the tar itself."""
raw.seek(member.offset_data)
head = raw.read(18)
if head.startswith(b"\x1f\x8b"):
raw.seek(member.offset_data + member.size - 4)
size = struct.unpack("<I", raw.read(4))[0]
# The trailer keeps the size modulo 4 GiB.
while size < member.size:
size += 1 << 32
return size
if head.startswith(b"\x28\xb5\x2f\xfd") and len(head) >= 6:
descriptor = head[4]
single_segment = descriptor >> 5 & 1
field = (1 if single_segment else 0, 2, 4, 8)[descriptor >> 6]
if not field:
return None
start = 5 + (0 if single_segment else 1) + (0, 1, 2, 4)[descriptor & 3]
value = int.from_bytes(head[start:start + field], "little")
return value + 256 if field == 2 else value
return member.size
def extracted_size(path: Path) -> int | None:
"""Estimated size of the image once its layers are unpacked, or None when
a layer does not record it."""
with tarfile.open(path, mode="r:") as archive, path.open("rb") as raw:
members = {member.name.lstrip("./"): member for member in archive.getmembers()}
def blob(digest: str) -> tarfile.TarInfo:
return members["blobs/" + digest.replace(":", "/")]
index = json.load(archive.extractfile(members["index.json"]))
manifest = json.load(archive.extractfile(blob(index["manifests"][0]["digest"])))
total = 0
for layer in manifest.get("layers", []):
size = _layer_extracted_size(raw, blob(layer["digest"]))
if size is None:
return None
total += size
return total
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("archive", type=Path)
parser.add_argument("--extracted-mib", action="store_true",
help="print the estimated unpacked size in MiB (0 when unknown)")
args = parser.parse_args()
if args.extracted_mib:
try:
size = extracted_size(args.archive)
except (tarfile.TarError, OSError, KeyError, ValueError, IndexError):
size = None
print((size or 0) >> 20)
return 0
try:
verify_archive(args.archive)
except VerificationError as exc:
+18 -8
View File
@@ -63,16 +63,23 @@ def build_suite(template, ui, mode='advanced'):
from .installer import ask_bridge, ask_storage
storage = ask_storage(ui, translate('Storage for rootfs and private configuration'), 'rootdir', 'local-lvm', mode)
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')):
raise StackError(translate('Invalid shared path'))
ordered = list(selected)
# Extra paths are asked with the data, against the paths each application will mount.
drafts = [{'name': app, 'main': False, 'deployment': {'mounts': [
{'type': 'managed-volume', 'container_path': '/app/config' if app == 'seerr' else '/config'},
*([{'type': 'host-bind', 'container_path': '/data'}] if app in MEDIA_APPS else [])]}}
for app in ordered]
if mode != DEFAULT_MODE:
from .custom_mounts import ask_stack_custom_mounts
ask_stack_custom_mounts(ui, drafts, storage)
bridge = ask_bridge(ui, translate('Access bridge'), 'vmbr0', mode)
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())
onboot = ui.confirm(translate('Start each LXC with Proxmox (no coordinated startup)'), False)
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')):
raise StackError(translate('Invalid shared path'))
ordered = list(selected)
services = []
credentials = None
if 'qbittorrent' in selected:
@@ -88,7 +95,7 @@ def build_suite(template, ui, mode='advanced'):
child_ui = (DefaultsUI() if mode == DEFAULT_MODE else
SuiteChildUI(ui, child['proxmox'].get('installer_profile', {})))
plan = build_deployment(copy.deepcopy(child), child_ui, mode)
plan.update(hostname=_hostname_default(name+'-'+app), start_after_create=False, onboot=onboot, template_storage=cache)
plan.update(hostname=_hostname_default(name+'-'+app), start_after_create=False, template_storage=cache)
plan['rootfs']['storage'] = storage
for env in plan['environment']:
if env['name'] == 'TZ': env['value'] = timezone
@@ -112,11 +119,14 @@ def build_suite(template, ui, mode='advanced'):
services[-1]['deferred_setup'] = True
if app == 'qbittorrent':
services[-1]['setup_credentials'] = credentials
for service, draft in zip(services, drafts):
service['deployment']['mounts'] += [m for m in draft['deployment']['mounts'] if m.get('custom')]
if mode != DEFAULT_MODE:
from .custom_mounts import ask_stack_custom_mounts
ask_stack_custom_mounts(ui, services, storage)
from .extra_devices import ask_stack_extra_devices
ask_stack_extra_devices(ui, services)
onboot = ui.confirm(translate('Start each LXC with Proxmox (no coordinated startup)'), False)
for service in services:
service['deployment']['onboot'] = onboot
return {'deployment_kind':'generic-multi-lxc-stack','suite_arr':True,'lifecycle_mode':'independent','stack_name':name,
'base_vmid':int(base) if base else None,'services':services,'shared_media':shared,'media_player':player,
'completion_notes':[
+2 -1
View File
@@ -54,7 +54,8 @@ def ask_custom_mounts(ui, mounts, storage):
'read_only': ui.confirm(translate('Mount read-only'), False),
'create_if_missing': mode == 'host-bind'}
if mode == 'managed-volume':
mount['source'] = ui.ask(translate('Proxmox storage for the volume'), storage)
from .installer import ask_storage
mount['source'] = ask_storage(ui, translate('Proxmox storage for the volume'), 'rootdir', storage)
mount['size_gb'] = int(ui.ask(translate('Volume size in GB'), '8'))
else:
mount['source'] = ui.ask(translate('Host directory (created if it does not exist)'), '/mnt/oci-shared/custom')
+18 -2
View File
@@ -3,6 +3,7 @@ import copy
import re
from pathlib import Path
from . import host
from .i18n import translate
from .ui import UserCancelled
@@ -12,6 +13,21 @@ USB_NODE = re.compile(r'/dev/(?:ttyUSB[0-9]+|ttyACM[0-9]+|bus/usb/[0-9]{3}/[0-9]
CORAL_NODE = re.compile(r'/dev/apex_[0-9]+')
def choose_usb_device(ui, title, default=None, attached=()):
"""A USB device of the host picked by its name, or a node typed by hand."""
options = [(row['path'], f"{row['name']} · {translate(row['kind'])}")
for row in host.usb_devices() if row['path'] not in attached]
manual_prompt = translate('Host USB node (e.g. /dev/ttyACM0 or /dev/bus/usb/003/004)')
if not options:
ui.message(translate('No USB device was found on this host.'))
return ui.ask(manual_prompt, default or '/dev/ttyACM0')
options.append(('manual', translate('Another node, typed by hand')))
selected = ui.choose(title, options, default if default in dict(options) else options[0][0])
if selected is None:
raise UserCancelled(translate('Device configuration cancelled'))
return ui.ask(manual_prompt, default or '/dev/ttyACM0') if selected == 'manual' else selected
def ask_extra_devices(ui, devices, unprivileged, allow_coral=False):
"""Keep manual attachments separate from image-owned GPU profiles."""
result = list(devices)
@@ -39,8 +55,8 @@ def ask_extra_devices(ui, devices, unprivileged, allow_coral=False):
path = ui.ask(translate('Host DRM node (e.g. /dev/dri/renderD128)'), default)
valid = GPU_NODE.fullmatch(path)
elif kind == 'usb':
path = ui.ask(translate('Host USB node (e.g. /dev/ttyACM0 or /dev/bus/usb/003/004)'),
'/dev/ttyACM0')
path = choose_usb_device(ui, translate('USB device to attach'),
attached={item.get('host_path') for item in result})
valid = USB_NODE.fullmatch(path)
else:
path = ui.ask(translate('Host Coral node (e.g. /dev/apex_0)'), '/dev/apex_0')
+70 -1
View File
@@ -1,8 +1,10 @@
"""Facts read from the local Proxmox node: storages, bridges and the timezone."""
"""Facts read from the local Proxmox node: storages, bridges, the timezone and
its USB devices."""
from __future__ import annotations
import ipaddress
import json
import re
import subprocess
from pathlib import Path
from typing import Any
@@ -97,3 +99,70 @@ def gib(value: Any) -> int:
return int(value) // 2**30
except (TypeError, ValueError):
return 0
# USB classes as the Monitor labels them.
_USB_CLASSES = {
"01": "Audio", "02": "Communications", "03": "HID", "06": "Imaging", "07": "Printer",
"0a": "CDC Data", "0b": "Smart Card", "0e": "Video", "10": "Audio/Video",
"e0": "Wireless Controller", "ef": "Miscellaneous", "fe": "Application Specific",
"ff": "Vendor Specific",
}
# UPS makers, whose devices report the HID class.
_USB_UPS_VENDORS = {"0463", "051d", "0764", "0d9f", "06da", "09ae", "047c", "075d", "10af", "0665"}
_USB_LEFT_OUT = {"08", "09"} # storage and hubs are not handed to an LXC as a device node
_SERIAL_NODE = re.compile(r"tty(?:ACM|USB)[0-9]+")
_LSUSB_LINE = re.compile(r"Bus\s+(\d+)\s+Device\s+(\d+):\s+ID\s+[0-9a-f]{4}:[0-9a-f]{4}\s*(.*)", re.IGNORECASE)
def _sysfs(path: Path) -> str:
try:
return path.read_text(encoding="utf-8", errors="replace").strip()
except OSError:
return ""
def usb_devices(root: Path = Path("/"), lsusb: str | None = None) -> list[dict[str, str]]:
"""USB peripherals of this node an LXC can receive, named as the Monitor
names them: a serial adapter by its tty node, any other device by its bus
node."""
if lsusb is None:
try:
lsusb = subprocess.run(["lsusb"], capture_output=True, text=True, timeout=5,
check=False).stdout
except (OSError, subprocess.TimeoutExpired):
lsusb = ""
names = {}
for line in lsusb.splitlines():
match = _LSUSB_LINE.match(line)
if match:
names[(int(match[1]), int(match[2]))] = match[3].strip()
serial: dict[str, list[str]] = {}
for tty in sorted((root / "sys/class/tty").glob("tty*")):
if not _SERIAL_NODE.fullmatch(tty.name):
continue
device = (tty / "device").resolve()
while device != device.parent and not (device / "idVendor").exists():
device = device.parent
if (device / "idVendor").exists():
serial.setdefault(device.name, []).append(tty.name)
rows = []
for device in sorted((root / "sys/bus/usb/devices").glob("*")):
vendor = _sysfs(device / "idVendor").lower()
if ":" in device.name or not vendor or vendor == "1d6b":
continue
device_class = _sysfs(device / "bDeviceClass").lower() or "00"
if device_class == "00":
device_class = _sysfs(device / f"{device.name}:1.0" / "bInterfaceClass").lower()
if device_class in _USB_LEFT_OUT:
continue
kind = "UPS" if device_class == "03" and vendor in _USB_UPS_VENDORS else _USB_CLASSES.get(device_class, "USB")
try:
bus, number = int(_sysfs(device / "busnum")), int(_sysfs(device / "devnum"))
except ValueError:
continue
name = (_sysfs(device / "product") or names.get((bus, number))
or f"{vendor}:{_sysfs(device / 'idProduct').lower()}")
for node in serial.get(device.name) or [f"bus/usb/{bus:03d}/{number:03d}"]:
rows.append({"path": f"/dev/{node}", "name": name, "kind": kind})
return rows
+135 -116
View File
@@ -80,14 +80,16 @@ def _ask_size(ui, container_path: str, default: int) -> int:
def ask_storage(ui, text: str, content: str, default: str, mode: str = ADVANCED_MODE) -> str:
"""A storage of this node that accepts `content`, picked from a list; in
default mode the preferred one when it exists, otherwise the one with most
free space."""
"""A storage of this node that accepts `content`, picked from a list unless
it is the only one; in default mode the preferred one when it exists,
otherwise the one with most free space."""
if mode == DEFAULT_MODE:
return host.default_storage(content, default)
rows = host.storages(content)
if not rows:
return ui.ask(text, default)
if len(rows) == 1:
return rows[0]["storage"]
options = [(row["storage"], f"{row.get('type', '')} {host.gib(row.get('avail'))} GB {translate('free')}")
for row in rows]
names = [name for name, _ in options]
@@ -259,27 +261,18 @@ def build_deployment(
if advanced:
vmid_text = ui.ask(translate("VMID (empty = next free)"), "", required=False)
hostname = ui.ask(translate("Hostname"), hostname_default)
rootfs_storage = ask_storage(ui, translate("Storage for rootfs"), "rootdir", defaults["rootfs_storage"])
volume_storage = ask_storage(ui, translate("Storage for persistent data"), "rootdir",
defaults["volume_storage"])
template_storage = ask_storage(ui, translate("Storage for the OCI image cache"), "vztmpl",
defaults["template_storage"])
rootfs_size = int(ui.ask(translate("Rootfs size in GB"), str(defaults["rootfs_size_gb"])))
cores = int(ui.ask(translate("CPU cores"), str(defaults["cores"])))
cpu_units = (int(ui.ask(translate("Relative CPU priority (cpuunits)"),
str(_cpu_units_default(int(cpu_shares)))))
if cpu_shares is not None else None)
memory = int(ui.ask(translate("Memory in MB"), str(memory_default)))
swap = int(ui.ask(translate("Swap in MB"), str(defaults["swap_mb"])))
bridge = ask_bridge(ui, translate("Network bridge"), defaults["bridge"])
if mac_address:
mac_address = ui.ask(translate("MAC address"), mac_address)
if host_monitor:
ipv4, gateway = "host", None
else:
ipv4, gateway = access.ask_ipv4(ui, bridge)
onboot = ui.confirm(translate("Start with Proxmox"), defaults["onboot"])
start_after = ui.confirm(translate("Start when finished"), True)
rootfs_storage = ask_storage(ui, translate("Storage for rootfs"), "rootdir", defaults["rootfs_storage"])
rootfs_size = int(ui.ask(translate("Rootfs size in GB"), str(defaults["rootfs_size_gb"])))
template_storage = ask_storage(ui, translate("Storage for the OCI image cache"), "vztmpl",
defaults["template_storage"])
# Each container volume asks for its own storage; this is the first one proposed.
volume_storage = rootfs_storage
else:
vmid_text = ""
hostname = hostname_default
@@ -291,11 +284,99 @@ def build_deployment(
cpu_units = _cpu_units_default(int(cpu_shares)) if cpu_shares is not None else None
memory = int(memory_default)
swap = int(defaults["swap_mb"])
mounts: list[dict[str, Any]] = []
host_bind_policies = installer_profile.get("host_bind_policies", {})
labels = {"managed-volume": translate("Container volume (included in backups)"),
"host-bind": translate("Host directory (not included in Proxmox backups)"),
"skip": translate("Do not mount")}
for item in template["container_contract"]["volumes"]:
choices = item["installation_choice"]
# A path that holds content of the user accepts a container volume or a
# host directory, and that decision is the user's in both modes; a path
# with a single option is never offered as a choice.
offered = list(choices)
chosen = advanced or {"managed-volume", "host-bind"} <= set(choices)
if chosen and len(offered) > 1:
# The answer is the user's: a host directory is never proposed.
proposed = "managed-volume" if item["default"] == "host-bind" else item["default"]
volume_mode = ui.choose(f"{translate('Where to store')} {item['container_path']}",
[(choice, labels[choice]) for choice in offered], proposed)
if volume_mode is None:
raise UserCancelled(translate("Volume configuration cancelled"))
else:
volume_mode = item["default"]
if volume_mode == "skip":
continue
if volume_mode == "managed-volume":
size = item["managed_volume"]["default_size_gb"]
if advanced:
volume_storage = ask_storage(ui, f"{translate('Storage for')} {item['container_path']}",
"rootdir", volume_storage)
# Whoever chooses a container volume also chooses how big it is.
if advanced or chosen:
size = _ask_size(ui, item["container_path"], size)
source = volume_storage
backup = item["managed_volume"]["backup"]
else:
source = _shared_host_path_default(template, item)
if chosen and not _is_system_bind(item):
source = ui.ask(f"{translate('Host path for')} {item['container_path']}", source)
size = None
backup = False
policy = host_bind_policies.get(item["container_path"], {})
mounts.append(
{
"type": volume_mode,
"container_path": item["container_path"],
"source": source,
"size_gb": size,
"backup": backup,
"read_only": item["read_only"],
"create_if_missing": bool(
policy.get("create_if_missing", not _is_system_bind(item))
),
}
)
if advanced:
mounts = ask_custom_mounts(ui, mounts, volume_storage)
if host_monitor:
for item in installer_profile.get("host_monitor_mounts", []):
mounts.append({"type": "host-bind", "container_path": item["target"],
"source": item["source"], "size_gb": None, "backup": False,
"read_only": True, "create_if_missing": False})
tmpfs_mounts: list[dict[str, Any]] = []
for item in installer_profile.get("tmpfs_mounts", []):
size_mb = int(item["default_size_mb"])
if advanced and item.get("prompt_size", True):
size_prompt = (translate(item["size_prompt"]) if item.get("size_prompt")
else f"{translate('tmpfs size in MB for')} {item['container_path']}")
size_mb = int(ui.ask(size_prompt, str(size_mb)))
if size_mb < int(item.get("minimum_size_mb", 1)):
raise InstallError(f"{translate('tmpfs size too small for')} {item['container_path']}")
tmpfs_mounts.append(
{
"container_path": item["container_path"],
"size_mb": size_mb,
"mount_options": item.get("mount_options", ["rw", "nosuid", "nodev"]),
}
)
if advanced:
bridge = ask_bridge(ui, translate("Network bridge"), defaults["bridge"])
if mac_address:
mac_address = ui.ask(translate("MAC address"), mac_address)
if host_monitor:
ipv4, gateway = "host", None
else:
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
onboot = bool(defaults["onboot"])
start_after = True
host_firewall = confirm_host_monitor_firewall(ui, template, bridge) if host_monitor else None
@@ -341,83 +422,6 @@ def build_deployment(
if value or item["required"]:
environment.append({"name": name, "value": value, "sensitive": item["sensitive"]})
mounts: list[dict[str, Any]] = []
host_bind_policies = installer_profile.get("host_bind_policies", {})
labels = {"managed-volume": translate("Container volume (included in backups)"),
"host-bind": translate("Host directory (not included in Proxmox backups)"),
"skip": translate("Do not mount")}
for item in template["container_contract"]["volumes"]:
choices = item["installation_choice"]
# A path that holds content of the user accepts a container volume or a
# host directory, and that decision is the user's in both modes; a path
# with a single option is never asked outside the advanced mode.
offered = list(choices)
chosen = advanced or {"managed-volume", "host-bind"} <= set(choices)
if chosen:
# The answer is the user's: a host directory is never proposed.
proposed = "managed-volume" if item["default"] == "host-bind" else item["default"]
volume_mode = ui.choose(f"{translate('Where to store')} {item['container_path']}",
[(choice, labels[choice]) for choice in offered], proposed)
if volume_mode is None:
raise UserCancelled(translate("Volume configuration cancelled"))
else:
volume_mode = item["default"]
if volume_mode == "skip":
continue
if volume_mode == "managed-volume":
size = item["managed_volume"]["default_size_gb"]
# Whoever chooses a container volume also chooses how big it is.
if advanced or chosen:
size = _ask_size(ui, item["container_path"], size)
source = volume_storage
backup = item["managed_volume"]["backup"]
else:
source = _shared_host_path_default(template, item)
if chosen:
source = ui.ask(f"{translate('Host path for')} {item['container_path']}", source)
size = None
backup = False
policy = host_bind_policies.get(item["container_path"], {})
mounts.append(
{
"type": volume_mode,
"container_path": item["container_path"],
"source": source,
"size_gb": size,
"backup": backup,
"read_only": item["read_only"],
"create_if_missing": bool(
policy.get("create_if_missing", not _is_system_bind(item))
),
}
)
if advanced:
mounts = ask_custom_mounts(ui, mounts, volume_storage)
if host_monitor:
for item in installer_profile.get("host_monitor_mounts", []):
mounts.append({"type": "host-bind", "container_path": item["target"],
"source": item["source"], "size_gb": None, "backup": False,
"read_only": True, "create_if_missing": False})
tmpfs_mounts: list[dict[str, Any]] = []
for item in installer_profile.get("tmpfs_mounts", []):
size_mb = int(item["default_size_mb"])
if advanced and item.get("prompt_size", True):
size_prompt = (translate(item["size_prompt"]) if item.get("size_prompt")
else f"{translate('tmpfs size in MB for')} {item['container_path']}")
size_mb = int(ui.ask(size_prompt, str(size_mb)))
if size_mb < int(item.get("minimum_size_mb", 1)):
raise InstallError(f"{translate('tmpfs size too small for')} {item['container_path']}")
tmpfs_mounts.append(
{
"container_path": item["container_path"],
"size_mb": size_mb,
"mount_options": item.get("mount_options", ["rw", "nosuid", "nodev"]),
}
)
devices, selected_hardware_profile, post_start_configurations, environment = configure_acceleration(
installer_profile, environment, unprivileged, ui, mode)
devices, completion_notes = configure_detector(installer_profile, devices, ui)
@@ -434,13 +438,6 @@ def build_deployment(
from .gpu import apply_profile_image
apply_profile_image(template, selected_hardware_profile)
if post_start_configurations and not start_after:
if not ui.confirm(translate("The application configuration needs a first start to complete.")
+ "\n\n" + translate("Start the LXC when finished to apply the selected configuration?"),
True):
raise UserCancelled(translate("The post-start configuration cannot be applied with the LXC stopped"))
start_after = True
host_modules: list[str] = []
for item in installer_profile.get("security", {}).get("host_modules", []):
enabled = (ui.confirm(translate(item["enable_prompt"]), item.get("enabled_default", True))
@@ -451,6 +448,20 @@ def build_deployment(
continue
host_modules.append(item["name"])
if advanced:
onboot = ui.confirm(translate("Start with Proxmox"), defaults["onboot"])
start_after = ui.confirm(translate("Start when finished"), True)
else:
onboot = bool(defaults["onboot"])
start_after = True
if post_start_configurations and not start_after:
if not ui.confirm(translate("The application configuration needs a first start to complete.")
+ "\n\n" + translate("Start the LXC when finished to apply the selected configuration?"),
True):
raise UserCancelled(translate("The post-start configuration cannot be applied with the LXC stopped"))
start_after = True
if installer_profile.get("haos_healthcheck"):
data = [m for m in mounts if m['container_path'] == '/mnt/data']
if (not unprivileged or defaults.get('ostype') != 'unmanaged'
@@ -674,10 +685,9 @@ def _build_immich_deployment(
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"])
database_storage = ask_storage(ui, translate("Local storage for PostgreSQL"), "rootdir", defaults["database_storage"])
template_storage = ask_storage(ui, translate("Storage for the OCI image cache"), "vztmpl", pve_defaults["template_storage"])
media_mode = ui.choose(
translate("Storage for the Immich library"),
translate("Where to store the Immich library"),
[
("managed-volume", translate("Dedicated container volume (included in backups)")),
("host-bind", translate("Shared host directory (not included in Proxmox backups)")),
@@ -697,6 +707,7 @@ def _build_immich_deployment(
media_root = 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_size = int(
ui.ask(translate("PostgreSQL volume size in GB"), str(defaults["database_size_gb"]))
)
@@ -809,7 +820,7 @@ def _build_nextcloud_stack_deployment(
rootfs_storage = ask_storage(ui, translate("Storage for rootfs"), "rootdir", defaults["rootfs_storage"])
template_storage = ask_storage(ui, translate("Storage for the OCI image cache"), "vztmpl", pve_defaults["template_storage"])
application_mode = ui.choose(
translate("Storage for Nextcloud files, configuration and data"),
translate("Where to store the Nextcloud files, configuration and data"),
[
("managed-volume", translate("Dedicated container volume (included in backups)")),
("host-bind", translate("Shared host directory (not included in Proxmox backups)")),
@@ -910,10 +921,9 @@ def _build_paperless_stack_deployment(
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"])
application_storage = ask_storage(ui, translate("Storage for Paperless data and documents"), "rootdir", defaults["application_storage"])
database_storage = ask_storage(ui, translate("Local storage for PostgreSQL"), "rootdir", defaults["database_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"])
data_size = int(
ui.ask(translate("Data volume size in GB"), str(defaults["data_volume_size_gb"]))
)
@@ -923,6 +933,7 @@ def _build_paperless_stack_deployment(
str(defaults["media_volume_size_gb"]),
)
)
database_storage = ask_storage(ui, translate("Local storage for PostgreSQL"), "rootdir", defaults["database_storage"])
database_size = int(
ui.ask(
translate("PostgreSQL volume size in GB"),
@@ -933,7 +944,7 @@ def _build_paperless_stack_deployment(
raise InstallError(translate("The Paperless persistent volumes need at least 8 GB"))
transfer_mode = ui.choose(
translate("Storage for the consume and export folders"),
translate("Where to store the consume and export folders"),
[
("host-bind", translate("Shared host directories (not included in Proxmox backups)")),
("managed-volume", translate("Dedicated container volumes (included in backups)")),
@@ -1029,11 +1040,10 @@ def _build_tandoor_stack_deployment(
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"])
database_storage = ask_storage(ui, translate("Local storage for PostgreSQL"), "rootdir", defaults["database_storage"])
template_storage = ask_storage(ui, translate("Storage for the OCI image cache"), "vztmpl", pve_defaults["template_storage"])
media_mode = ui.choose(
translate("Storage for recipe images and files"),
translate("Where to store the recipe images and files"),
[
("managed-volume", translate("Dedicated container volume (included in backups)")),
("host-bind", translate("Shared host directory (not included in Proxmox backups)")),
@@ -1061,12 +1071,14 @@ def _build_tandoor_stack_deployment(
media_storage = None
media_size = None
static_storage = ask_storage(ui, translate("Storage for staticfiles"), "rootdir", 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_size = int(
ui.ask(
translate("PostgreSQL volume size in GB"),
@@ -1098,6 +1110,7 @@ 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"])
start_after = ui.confirm(translate("Start when finished"), True)
if not start_after:
raise UserCancelled(
@@ -1111,7 +1124,7 @@ def _build_tandoor_stack_deployment(
"stack_name": stack_name,
"template_storage": template_storage,
"rootfs_storage": rootfs_storage,
"application_storage": defaults["application_storage"],
"application_storage": static_storage,
"static_size_gb": static_size,
"database_storage": database_storage,
"database_size_gb": database_size,
@@ -1128,7 +1141,7 @@ def _build_tandoor_stack_deployment(
"admin_email": admin_email,
},
"timezone": timezone,
"onboot": ui.confirm(translate("Start the stack with Proxmox"), pve_defaults["onboot"]),
"onboot": onboot,
"start_after_create": start_after,
"network": {
"frontend_bridge": frontend_bridge,
@@ -1547,8 +1560,14 @@ def configure_acceleration(installer_profile, environment, unprivileged, ui, mod
]
devices.append(device)
else:
host_path = (ui.ask(translate(item["path_prompt"]), item["host_path_default"]) if advanced
else item["host_path_default"])
if not advanced:
host_path = item["host_path_default"]
elif item.get("purpose") in ("serial", "user-selected-device"):
from .extra_devices import choose_usb_device
host_path = choose_usb_device(ui, translate(item["path_prompt"]), item["host_path_default"],
{device.get("host_path") for device in devices})
else:
host_path = ui.ask(translate(item["path_prompt"]), item["host_path_default"])
container_path = (
host_path
if item.get("container_path_strategy") == "same-as-host"
+60 -33
View File
@@ -269,10 +269,27 @@ class DefaultsUI:
return default
def info(self, text):
pass
def message(self, text):
pass
def _service_plan(template, service):
"""The single-container template and plan of one stack member."""
from .installer import build_deployment, DEFAULT_MODE
single = service_template(template, service, service['name'] == template['compose_stack']['main_service'])
if kind(service['image']) == 'postgres':
# Official PostgreSQL 18+ stores versioned PGDATA under this parent.
for m in single['container_contract']['volumes']:
if m['container_path'] == '/var/lib/postgresql/data' and service['image'].endswith(':latest'):
m['container_path'] = normalized_dependency_mount_path(m['container_path'], service['image'])
for e in single['container_contract']['environment']:
e['required'] = bool(e['example'])
e['example'] = 'stack-resolved-value' if e['example'] else ''
return single, build_deployment(single, DefaultsUI(), DEFAULT_MODE)
def build_stack(template, ui, mode='advanced'):
from .installer import build_deployment, _hostname_default, DEFAULT_MODE
from .installer import _hostname_default, DEFAULT_MODE
problems = assess(template)
if problems:
raise StackError('; '.join(problems))
@@ -282,15 +299,47 @@ def build_stack(template, ui, mode='advanced'):
vmid = ui.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
from .installer import _ask_size, ask_bridge, ask_storage
root = ask_storage(ui, translate('Storage for rootfs'), 'rootdir', defaults['rootfs_storage'], mode)
volumes = ask_storage(ui, translate('Storage for persistent data'), 'rootdir', defaults['volume_storage'], mode)
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)
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)
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}",
[('managed-volume', translate('Container volume (included in backups)')),
('host-bind', translate('Host directory (not included in Proxmox backups)'))],
'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')
if mount_mode == 'host-bind':
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
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'))
drafts.append({'name': s['name'], 'main': s['name'] == template['compose_stack']['main_service'],
'deployment': {'mounts': plan['mounts']}})
if mode != DEFAULT_MODE:
from .custom_mounts import ask_stack_custom_mounts
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())
onboot = ui.confirm(translate('Start the stack with Proxmox'), False)
envs = resolve_environments(services, timezone, template.get('proxmox', {}).get('stack_generators', {}))
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:
@@ -316,39 +365,16 @@ def build_stack(template, ui, mode='advanced'):
endpoint = template['first_run']['endpoints'][0]
env[key] = urlunsplit((url.scheme, '@STACK_LAN_IP@:'+str(endpoint['port']), url.path, url.query, url.fragment))
s['compose']['environment'] = env
single = service_template(template, s, main)
single, plan = _service_plan(template, s)
k = kind(s['image'])
if k == 'postgres':
# Official PostgreSQL 18+ stores versioned PGDATA under this parent.
for m in single['container_contract']['volumes']:
if m['container_path'] == '/var/lib/postgresql/data' and s['image'].endswith(':latest'):
m['container_path'] = normalized_dependency_mount_path(m['container_path'], s['image'])
for e in single['container_contract']['environment']:
e['required'] = bool(e['example'])
e['example'] = 'stack-resolved-value' if e['example'] else ''
plan = build_deployment(single, DefaultsUI(), DEFAULT_MODE)
# Values are already resolved; do not reinterpret user credentials as Compose variables.
plan['environment'] = [{'name':key,'value':value,'sensitive':True} for key,value in env.items() if value != '']
plan.update(hostname=_hostname_default(name+'-'+s['name']), template_storage=cache,
onboot=onboot, start_after_create=False)
start_after_create=False)
plan['rootfs']['storage'] = root
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'])
for m in plan['mounts']:
mount_mode = ('managed-volume' if mode == DEFAULT_MODE else
ui.choose(f"{s['name']}: {m['container_path']}",
[('managed-volume',translate('Container volume (included in backups)')),
('host-bind',translate('Host directory'))], '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')
if mount_mode == 'host-bind':
m['source'] = ui.ask(translate('Host directory'), '/mnt/oci-shared/'+name+'/'+s['name']+'/'+m['container_path'].strip('/').replace('/','-'))
m['size_gb'] = None
elif mode != DEFAULT_MODE:
m['size_gb'] = int(ui.ask(translate('Volume size in GB'), str(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'))
plan['mounts'] = drafts[index]['deployment']['mounts']
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':
@@ -377,10 +403,11 @@ def build_stack(template, ui, mode='advanced'):
if main:
plans[-1]['frontend_ipv4'] = addresses['']
if mode != DEFAULT_MODE:
from .custom_mounts import ask_stack_custom_mounts
ask_stack_custom_mounts(ui, plans, volumes)
from .extra_devices import ask_stack_extra_devices
ask_stack_extra_devices(ui, plans)
onboot = ui.confirm(translate('Start the stack with Proxmox'), False)
for plan in plans:
plan['deployment']['onboot'] = onboot
return {'deployment_kind':'generic-multi-lxc-stack','stack_name':name,'base_vmid':int(vmid) if vmid else None,
'completion_notes':template.get('proxmox',{}).get('stack_completion_notes',[]),
'rootfs_storage':root,'template_storage':cache,'onboot':onboot,'start_after_create':True,
+159
View File
@@ -0,0 +1,159 @@
"""The advanced installer asks for each data path's storage next to its size."""
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.catalog import Catalog
from proxmenux_oci.installer import ADVANCED_MODE, DEFAULT_MODE, build_deployment
STORAGES = [{"storage": "Public", "type": "nfs", "avail": 9 * 2**40},
{"storage": "local-lvm", "type": "lvmthin", "avail": 700 * 2**30}]
class RecordingUI:
"""Answers every question with its default, or with a scripted answer."""
def __init__(self, answers=None):
self.answers = answers or {}
self.asked = []
def _answer(self, text, default):
self.asked.append(text)
return self.answers.get(text, default)
def ask(self, text, default=None, required=True):
return self._answer(text, default if default is not None else "")
def password(self, text, required=True):
return self._answer(text, "secret")
def choose(self, text, options, default=None):
return self._answer(text, default)
def confirm(self, text, default=False):
return self._answer(text, default)
def checklist(self, text, options, default=None):
return self._answer(text, default)
def info(self, text):
pass
def message(self, text):
pass
def storages(content):
return STORAGES if content == "rootdir" else [{"storage": "local", "type": "dir", "avail": 2**35}]
def addresses(ui, bridge, names, *args):
return {name: "dhcp" for name in names}, None
@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_ipv4", return_value=("dhcp", None))
@patch("proxmenux_oci.installer.access.ask_addresses", side_effect=addresses)
class AdvancedFlowOrderTests(unittest.TestCase):
catalog = Catalog(ROOT)
def build(self, app, ui, mode=ADVANCED_MODE):
return build_deployment(self.catalog.compose(app), ui, mode)
def test_each_volume_asks_its_storage_and_then_its_size(self, *_):
ui = RecordingUI({"Storage for /data/movies": "Public"})
plan = self.build("jellyfin", ui)
volumes = {m["container_path"]: m["source"] for m in plan["mounts"]}
self.assertEqual(volumes, {"/config": "local-lvm", "/data/tvshows": "local-lvm",
"/data/movies": "Public"})
asked = ui.asked
self.assertEqual(asked.index("Size in GB of /config"), asked.index("Storage for /config") + 1)
self.assertNotIn("Storage for persistent data", asked)
self.assertNotIn("Where to store /config", asked)
def test_volumes_are_proposed_on_the_rootfs_storage(self, *_):
ui = RecordingUI({"Storage for rootfs": "Public"})
plan = self.build("jellyfin", ui)
self.assertEqual({m["source"] for m in plan["mounts"]}, {"Public"})
def test_system_resources_then_data_then_network_then_start(self, *_):
ui = RecordingUI()
self.build("jellyfin", ui)
asked = ui.asked
order = ["Memory in MB", "Storage for rootfs", "Rootfs size in GB", "Storage for /config",
"Where to store /data/movies", "Network bridge", "Value for TZ",
"Start with Proxmox", "Start when finished"]
self.assertEqual([asked.index(text) for text in order], sorted(asked.index(text) for text in order))
self.assertEqual(asked[-2:], ["Start with Proxmox", "Start when finished"])
def test_single_option_and_system_paths_are_not_asked(self, *_):
ui = RecordingUI()
plan = self.build("frigate", ui)
self.assertNotIn("Where to store /etc/localtime", ui.asked)
self.assertNotIn("Host path for /etc/localtime", ui.asked)
self.assertIn("Where to store /media/frigate", ui.asked)
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()
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"})
def assert_follows(self, asked, *texts):
positions = [asked.index(text) for text in texts]
self.assertEqual(positions, list(range(positions[0], positions[0] + len(texts))), texts)
def test_stack_asks_each_member_path_with_its_storage_and_size(self, *_):
ui = RecordingUI({"Storage for linkwarden-postgres: /var/lib/postgresql": "Public"})
plan = self.build("linkwarden", ui)
asked = ui.asked
self.assertNotIn("Storage for persistent data", asked)
self.assert_follows(asked, "Where to store linkwarden-postgres: /var/lib/postgresql",
"Storage for linkwarden-postgres: /var/lib/postgresql",
"Size in GB of linkwarden-postgres: /var/lib/postgresql")
self.assertLess(asked.index("Add extra paths to this stack"), asked.index("Access bridge"))
self.assertLess(asked.index("Access bridge"), asked.index("Timezone"))
self.assertEqual(asked[-1], "Start the stack with Proxmox")
sources = {(s["name"], m["container_path"]): m["source"]
for s in plan["services"] for m in s["deployment"]["mounts"]}
self.assertEqual(sources[("linkwarden-postgres", "/var/lib/postgresql")], "Public")
self.assertTrue(all(s["deployment"]["onboot"] is False for s in plan["services"]))
def test_arr_suite_asks_data_first_and_start_last(self, *_):
ui = RecordingUI()
plan = self.build("suite-arr", ui)
asked = ui.asked
self.assertLess(asked.index("Shared host media directory"), asked.index("Access bridge"))
self.assertLess(asked.index("Add extra paths to this stack"), asked.index("Access bridge"))
self.assertEqual(asked[-1], "Start each LXC with Proxmox (no coordinated startup)")
self.assertTrue(all("onboot" in s["deployment"] for s in plan["services"]))
def test_special_stacks_ask_each_storage_next_to_its_size(self, *_):
ui = RecordingUI()
self.build("immich", ui)
self.assert_follows(ui.asked, "Where to store the Immich library", "Storage for the Immich library",
"Library size in GB", "Local storage for PostgreSQL", "PostgreSQL volume size in GB")
ui = RecordingUI()
self.build("paperless-ngx", ui)
self.assert_follows(ui.asked, "Storage for Paperless data and documents", "Data volume size in GB",
"Documents volume size in GB", "Local storage for PostgreSQL",
"PostgreSQL volume size in GB")
ui = RecordingUI()
self.build("tandoor", ui)
self.assert_follows(ui.asked, "Storage for staticfiles", "staticfiles volume size in GB",
"Local storage for PostgreSQL", "PostgreSQL volume size in GB")
self.assertEqual(ui.asked[-2:], ["Start the stack with Proxmox", "Start when finished"])
if __name__ == "__main__":
unittest.main()
+56
View File
@@ -0,0 +1,56 @@
"""The container creation progress knows how big the image is once unpacked."""
import gzip
import hashlib
import io
import json
from pathlib import Path
import sys
import tarfile
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "remote"))
from verify_oci_archive import extracted_size
def zstd_frame(content_size: int) -> bytes:
# Magic, a descriptor with an 8-byte content size and single segment, the size.
return b"\x28\xb5\x2f\xfd" + bytes([0b11100000]) + content_size.to_bytes(8, "little") + b"\x00" * 16
class ExtractedSizeTests(unittest.TestCase):
def archive(self, layers):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
path = Path(tmp.name) / "image.tar"
blobs = {}
def add(data: bytes) -> str:
digest = "sha256:" + hashlib.sha256(data).hexdigest()
blobs[digest] = data
return digest
manifest = {"layers": [{"digest": add(data), "mediaType": "x"} for data in layers]}
index = {"manifests": [{"digest": add(json.dumps(manifest).encode())}]}
with tarfile.open(path, "w") as tar:
for name, data in [("oci-layout", b"{}"), ("index.json", json.dumps(index).encode()),
*((f"blobs/{d.replace(':', '/')}", b) for d, b in blobs.items())]:
info = tarfile.TarInfo(name)
info.size = len(data)
tar.addfile(info, io.BytesIO(data))
return path
def test_gzip_zstd_and_plain_layers_add_up(self):
path = self.archive([gzip.compress(b"a" * 300_000), zstd_frame(5 << 20), b"p" * 1000])
self.assertEqual(extracted_size(path), 300_000 + (5 << 20) + 1000)
def test_zstd_layer_without_content_size_is_unknown(self):
frame = b"\x28\xb5\x2f\xfd" + bytes([0b00000000, 0x50]) + b"\x00" * 16
self.assertIsNone(extracted_size(self.archive([frame])))
if __name__ == "__main__":
unittest.main()
+94
View File
@@ -0,0 +1,94 @@
"""USB devices are offered by name, as the Monitor shows them, with the node an LXC receives."""
import os
from pathlib import Path
import sys
import tempfile
import unittest
from unittest.mock import Mock, patch
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
from proxmenux_oci import host
from proxmenux_oci.extra_devices import ask_extra_devices, choose_usb_device
LSUSB = """Bus 003 Device 001: ID 1d6b:0002 Linux Foundation 2.0 root hub
Bus 003 Device 002: ID 0463:ffff MGE UPS Systems UPS
Bus 003 Device 003: ID 0658:0200 Sigma Designs, Inc. Aeotec Z-Stick Gen5 (ZW090) - UZB
Bus 003 Device 004: ID 1cf1:0030 Dresden Elektronik ZigBee gateway [ConBee II]
Bus 003 Device 005: ID 0781:5581 SanDisk Corp. Ultra
"""
class UsbDevicesTests(unittest.TestCase):
def setUp(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
self.root = Path(tmp.name)
pci = self.root / "sys/devices/pci0000:00/usb3"
self.device(pci, "usb3", "1d6b", "09", 1)
self.device(pci, "3-3", "0463", "00", 2, product="Ellipse ECO", interface="03")
self.device(pci, "3-8", "0658", "02", 3, tty="ttyACM1")
self.device(pci, "3-9", "1cf1", "02", 4, product="ConBee II", tty="ttyACM0")
self.device(pci, "3-10", "0781", "00", 5, product="Ultra", interface="08")
def device(self, parent, name, vendor, device_class, number, product="", interface="", tty=""):
path = parent / name
path.mkdir(parents=True)
for key, value in {"idVendor": vendor, "idProduct": "0001", "bDeviceClass": device_class,
"busnum": "3", "devnum": str(number)}.items():
(path / key).write_text(value + "\n")
if product:
(path / "product").write_text(product + "\n")
if interface:
(path / f"{name}:1.0").mkdir()
(path / f"{name}:1.0" / "bInterfaceClass").write_text(interface + "\n")
link = self.root / "sys/bus/usb/devices" / name
link.parent.mkdir(parents=True, exist_ok=True)
os.symlink(path, link)
if tty:
port = path / f"{name}:1.0" / "tty" / tty
port.mkdir(parents=True)
(self.root / "sys/class/tty").mkdir(parents=True, exist_ok=True)
(self.root / "sys/class/tty" / tty).mkdir()
os.symlink(path / f"{name}:1.0", self.root / "sys/class/tty" / tty / "device")
def test_devices_are_named_as_the_monitor_names_them(self):
rows = host.usb_devices(self.root, LSUSB)
self.assertEqual(rows, [
{"path": "/dev/bus/usb/003/002", "name": "Ellipse ECO", "kind": "UPS"},
{"path": "/dev/ttyACM1", "name": "Sigma Designs, Inc. Aeotec Z-Stick Gen5 (ZW090) - UZB",
"kind": "Communications"},
{"path": "/dev/ttyACM0", "name": "ConBee II", "kind": "Communications"},
])
def test_the_menu_lists_the_devices_and_leaves_attached_ones_out(self):
ui = Mock()
ui.choose.return_value = "/dev/ttyACM1"
with patch.object(host, "usb_devices", return_value=host.usb_devices(self.root, LSUSB)):
self.assertEqual(choose_usb_device(ui, "USB", attached={"/dev/ttyACM0"}), "/dev/ttyACM1")
tags = [tag for tag, _label in ui.choose.call_args.args[1]]
self.assertEqual(tags, ["/dev/bus/usb/003/002", "/dev/ttyACM1", "manual"])
ui.ask.assert_not_called()
def test_without_devices_the_node_is_typed(self):
ui = Mock()
ui.ask.return_value = "/dev/ttyUSB0"
with patch.object(host, "usb_devices", return_value=[]):
self.assertEqual(choose_usb_device(ui, "USB"), "/dev/ttyUSB0")
ui.message.assert_called_once()
ui.choose.assert_not_called()
def test_extra_usb_device_comes_from_the_list(self):
ui = Mock()
ui.confirm.side_effect = [True, False]
ui.choose.side_effect = ["usb", "/dev/ttyACM0"]
with patch.object(host, "usb_devices", return_value=host.usb_devices(self.root, LSUSB)):
devices = ask_extra_devices(ui, [], True)
self.assertEqual([(d["host_path"], d["container_path"]) for d in devices],
[("/dev/ttyACM0", "/dev/ttyACM0")])
if __name__ == "__main__":
unittest.main()