mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-10-08 22:46:41 +00:00
feat(oci): coherent advanced installer flow, container creation progress and USB device list
This commit is contained in:
@@ -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" \
|
||||
|
||||
@@ -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" \
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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" \
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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':[
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user