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