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
+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,