mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-10-04 04:26:40 +00:00
Adds the OCI manager: an engine that turns a Docker Compose file into an LXC definition, a catalog of 365 applications drawn from LinuxServer.io and other container image sources, and a per-instance registry recording what each container was built from. Reachable from the main menu. Catalog text is translated like every other string in the project: the taglines go through translate() and land in lang/*.json, so the entries read in all eight languages instead of only English. Translation cache builder: - a failed translation leaves the key absent rather than writing English, which previously made the string count as translated forever - a result identical to a 3+ word source is rejected, catching a provider that silently returns the text it was given - strings that are nothing but glossary terms keep their source spelling instead of being discarded as failures - no backoff between attempts when the provider is deterministic - application names are protected so "HAOS One" survives translation - argos joins the provider list, and the workflow reads the OCI sources Audit & Report: - findings that moved in the wrong direction between runs are reported alongside the ones that improved - an accepted risk can carry a review date and is flagged when it falls due - backup checks explain in plain language what they looked at and what to do next Monitor: - disks can be excluded from periodic reads, and an idle disk says so instead of showing a stale temperature - per-disk identity survives a controller or enclosure change - scheduled Borg backups resolve their SSH key from the repository entry - PVE upgrades log the package list and the resulting dpkg changes The web build no longer copies scripts/ into public/: the documentation links to GitHub, so nothing read that folder. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1508 lines
74 KiB
Python
1508 lines
74 KiB
Python
from __future__ import annotations
|
|
|
|
import base64
|
|
import copy
|
|
import io
|
|
import json
|
|
import os
|
|
import re
|
|
import secrets
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tarfile
|
|
import tempfile
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from . import host
|
|
from . import network as access
|
|
from .i18n import translate
|
|
from .ui import DialogUI, TerminalUI, UserCancelled
|
|
from .custom_mounts import ask_custom_mounts
|
|
|
|
|
|
class InstallError(RuntimeError):
|
|
pass
|
|
|
|
|
|
DEFAULT_MODE = "default"
|
|
ADVANCED_MODE = "advanced"
|
|
|
|
|
|
def _example_default(name: str, example: str | None, timezone: str | None = None) -> str | None:
|
|
defaults = {"PUID": "1000", "PGID": "1000", "TZ": timezone or host.timezone()}
|
|
if name in defaults:
|
|
return defaults[name]
|
|
if not example:
|
|
return None
|
|
variable = re.fullmatch(r"\$\{?[A-Za-z_][A-Za-z0-9_]*(?::-(.*?))?\}?", example)
|
|
if variable:
|
|
return variable.group(1)
|
|
return example
|
|
|
|
|
|
def _hostname_default(value: str) -> str:
|
|
hostname = re.sub(r"[^a-z0-9-]+", "-", value.casefold()).strip("-")[:63].rstrip("-")
|
|
return hostname or "oci-app"
|
|
|
|
|
|
def _cpu_units_default(cpu_shares: int) -> int:
|
|
# Docker uses 1024 and modern Proxmox uses 100 as their neutral relative weight.
|
|
return max(8, min(10000, round(cpu_shares * 100 / 1024)))
|
|
|
|
|
|
def _is_system_bind(item: dict[str, Any]) -> bool:
|
|
target = item["container_path"]
|
|
source = str(item.get("compose_source_example") or "")
|
|
exact_system_files = {"/etc/localtime", "/etc/timezone", "/etc/hosts", "/etc/resolv.conf"}
|
|
system_prefixes = ("/dev/", "/proc/", "/sys/", "/run/", "/var/run/")
|
|
return target in exact_system_files or target.startswith(system_prefixes) or source.startswith(system_prefixes)
|
|
|
|
|
|
def _shared_host_path_default(template: dict[str, Any], item: dict[str, Any]) -> str:
|
|
if _is_system_bind(item):
|
|
return str(item.get("compose_source_example") or item["container_path"])
|
|
app_id = template["id"].removeprefix("image-")
|
|
app_id = app_id.removeprefix("linuxserver-")
|
|
volume_name = re.sub(r"[^a-z0-9]+", "-", item["container_path"].casefold()).strip("-")
|
|
return f"/mnt/oci-shared/{app_id}/{volume_name or 'data'}"
|
|
|
|
|
|
def _ask_size(ui, container_path: str, default: int) -> int:
|
|
"""Size in whole GB of a container volume."""
|
|
while True:
|
|
answer = str(ui.ask(f"{translate('Size in GB of')} {container_path}", str(default))).strip()
|
|
if answer.isdigit() and int(answer) >= 1:
|
|
return int(answer)
|
|
ui.message(f"{translate('Enter the size in whole GB, for example')} {default}.")
|
|
|
|
|
|
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."""
|
|
if mode == DEFAULT_MODE:
|
|
return host.default_storage(content, default)
|
|
rows = host.storages(content)
|
|
if not rows:
|
|
return ui.ask(text, default)
|
|
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]
|
|
selected = ui.choose(text, options, default if default in names else names[0])
|
|
if selected is None:
|
|
raise UserCancelled(text)
|
|
return selected
|
|
|
|
|
|
def ask_bridge(ui, text: str, default: str, mode: str = ADVANCED_MODE) -> str:
|
|
if mode == DEFAULT_MODE:
|
|
return host.default_bridge(default)
|
|
rows = host.bridges()
|
|
if not rows:
|
|
return ui.ask(text, default)
|
|
options = [(row["iface"], row.get("cidr") or (row.get("comments") or "").strip() or "-") for row in rows]
|
|
names = [name for name, _ in options]
|
|
selected = ui.choose(text, options, default if default in names else names[0])
|
|
if selected is None:
|
|
raise UserCancelled(text)
|
|
return selected
|
|
|
|
|
|
def _confirm_warning(ui, warning: str | None, fallback: str, question: str) -> bool:
|
|
return ui.confirm(f"{translate(warning) if warning else translate(fallback)}\n\n{translate(question)}", False)
|
|
|
|
|
|
def build_deployment(
|
|
template: dict[str, Any],
|
|
ui: TerminalUI | DialogUI | None = None,
|
|
mode: str = ADVANCED_MODE,
|
|
) -> dict[str, Any]:
|
|
ui = ui or TerminalUI()
|
|
if template.get('proxmox', {}).get('installer_profile', {}).get('stack_driver') == 'arr-suite':
|
|
from .arr_suite import build_suite
|
|
return build_suite(template, ui)
|
|
if template.get('proxmox', {}).get('installer_profile', {}).get('stack_driver') == 'generic-multi-lxc-stack':
|
|
from .stack import build_stack
|
|
return build_stack(template, ui)
|
|
if template.get("id") == "image-immich":
|
|
return _build_immich_deployment(template, ui)
|
|
if template.get("id") == "image-nextcloud-stack":
|
|
return _build_nextcloud_stack_deployment(template, ui)
|
|
if template.get("id") == "image-paperless-ngx":
|
|
return _build_paperless_stack_deployment(template, ui)
|
|
if template.get("id") == "image-tandoor":
|
|
return _build_tandoor_stack_deployment(template, ui)
|
|
if template.get("id") == "image-rclone":
|
|
return _build_rclone_deployment(template, ui)
|
|
advanced = mode != DEFAULT_MODE
|
|
defaults = template["proxmox"]["defaults"]
|
|
installer_profile = template["proxmox"].get("installer_profile", {})
|
|
security_profile = template["proxmox"].get("security_profile", {})
|
|
host_monitor = installer_profile.get("host_monitor")
|
|
monitor_scope = "host" if host_monitor else None
|
|
if host_monitor:
|
|
expected = {"image-glances": "glances", "image-netdata": "netdata"}
|
|
if host_monitor != expected.get(template.get("id")):
|
|
raise InstallError(translate("Unrecognized host monitor profile"))
|
|
fallback = host_monitor == "glances" and installer_profile.get("host_monitor_optional", False)
|
|
alternative = ("\n\n" + translate("If you answer No, Glances is installed without privileges and "
|
|
"monitors ONLY its own LXC, not Proxmox.") if fallback else "")
|
|
if not ui.confirm(translate("This monitor uses a privileged LXC, shares processes and network with "
|
|
"Proxmox and disables AppArmor in the CT. It uses the IP address and "
|
|
"firewall of the host. A compromised image could affect the host; do not "
|
|
"expose its web UI to the Internet.")
|
|
+ alternative + "\n\n" + translate("Accept this host monitoring profile?"), False):
|
|
if not fallback:
|
|
raise UserCancelled(translate("Host monitoring declined"))
|
|
host_monitor = None
|
|
monitor_scope = "container"
|
|
defaults = {**defaults, "unprivileged": True}
|
|
security_profile = {}
|
|
installer_profile = {**installer_profile, "security": {},
|
|
"host_monitor": None, "host_monitor_mounts": []}
|
|
if installer_profile.get("haos_healthcheck"):
|
|
if not ui.confirm(translate("HAOS One is a community image that runs Docker inside the container. The "
|
|
"LXC stays unprivileged, but the inner AppArmor profiles may not be "
|
|
"available. The first start downloads Home Assistant Core and its add-ons. "
|
|
"If the check fails, the CT and /mnt/data are kept for diagnosis.")
|
|
+ "\n\n" + translate("Continue with the experimental HAOS One profile?"), False):
|
|
raise UserCancelled(translate("HAOS One profile declined"))
|
|
unprivileged = False if host_monitor else bool(defaults.get("unprivileged", True))
|
|
privileged_acknowledged = bool(host_monitor)
|
|
security_relaxation_acknowledged = bool(host_monitor)
|
|
if security_profile.get("requires_host_pid_namespace") and not host_monitor:
|
|
raise InstallError(translate("This template requests the host PID namespace, which has no validated "
|
|
"safe LXC translation yet"))
|
|
if security_profile.get("optional_privileged_lxc") and not host_monitor:
|
|
if _confirm_warning(ui, security_profile.get("warning"),
|
|
"The source Compose requests privileged mode as a compatibility option.",
|
|
"Use the optional privileged mode requested by the source Compose?"):
|
|
unprivileged = False
|
|
privileged_acknowledged = True
|
|
if security_profile.get("requires_privileged_lxc") and not host_monitor:
|
|
if not _confirm_warning(ui, security_profile.get("warning"),
|
|
"This image requests a configuration with less isolation from the host.",
|
|
"Create this LXC in privileged mode?"):
|
|
raise UserCancelled(translate("Privileged installation declined"))
|
|
unprivileged = False
|
|
privileged_acknowledged = True
|
|
if security_profile.get("requires_relaxed_confinement") and not host_monitor:
|
|
if not _confirm_warning(ui, security_profile.get("warning"),
|
|
"This image needs to reduce the AppArmor or seccomp confinement.",
|
|
"Apply this required security relaxation?"):
|
|
raise UserCancelled(translate("Security relaxation declined"))
|
|
security_relaxation_acknowledged = True
|
|
|
|
selected_security_options = dict(
|
|
installer_profile.get("security", {}).get("options", {})
|
|
)
|
|
for item in installer_profile.get("security", {}).get("optional_relaxations", []):
|
|
enabled = (ui.confirm(translate(item["enable_prompt"]), item.get("enabled_default", False))
|
|
if advanced else item.get("enabled_default", False))
|
|
if enabled:
|
|
selected_security_options.update(item.get("options", {}))
|
|
security_relaxation_acknowledged = True
|
|
runtime_profile = installer_profile.get("runtime", {})
|
|
hostname_default = _hostname_default(runtime_profile.get("hostname")
|
|
or template["container_contract"]["container_name"])
|
|
cpu_shares = installer_profile.get("resources", {}).get("cpu_shares")
|
|
memory_default = installer_profile.get('resources', {}).get('memory_default_mb', defaults['memory_mb'])
|
|
mac_address = installer_profile.get("network", {}).get("mac_address")
|
|
timezone = host.timezone()
|
|
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)
|
|
else:
|
|
vmid_text = ""
|
|
hostname = hostname_default
|
|
rootfs_storage = ask_storage(ui, "", "rootdir", defaults["rootfs_storage"], DEFAULT_MODE)
|
|
volume_storage = ask_storage(ui, "", "rootdir", defaults["volume_storage"], DEFAULT_MODE)
|
|
template_storage = ask_storage(ui, "", "vztmpl", defaults["template_storage"], DEFAULT_MODE)
|
|
rootfs_size = int(defaults["rootfs_size_gb"])
|
|
cores = int(defaults["cores"])
|
|
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"])
|
|
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
|
|
|
|
environment: list[dict[str, str]] = []
|
|
for item in template["container_contract"]["environment"]:
|
|
name = item["name"]
|
|
if item.get("prompt"):
|
|
label = translate(item["prompt"])
|
|
elif item["sensitive"]:
|
|
label = f"{translate('Password or secret')} ({name})"
|
|
else:
|
|
label = f"{translate('Value for')} {name}"
|
|
default = _example_default(name, item["example"], timezone)
|
|
generated = installer_profile.get("generated_sensitive_environment", {}).get(name)
|
|
if item.get("prompt_user", True) is False:
|
|
if default in (None, "") and item["required"]:
|
|
raise InstallError(f"{name}: {translate('no declarative value')}")
|
|
if default not in (None, "") or item["required"]:
|
|
environment.append({"name": name, "value": default or "", "sensitive": item["sensitive"]})
|
|
continue
|
|
if not item["required"]:
|
|
# The image keeps its own default for every optional setting.
|
|
if not advanced:
|
|
continue
|
|
if default in (None, "") and not ui.confirm(f"{translate('Configure')} {label}", False):
|
|
continue
|
|
if item["sensitive"]:
|
|
if generated and (not advanced or not generated.get("prompt", True)):
|
|
value = ""
|
|
else:
|
|
prompt = f"{label} ({translate('empty = generate')})" if generated else label
|
|
value = ui.password(prompt, required=item["required"] and not generated)
|
|
if not value and generated:
|
|
if generated.get("strategy") != "token-hex":
|
|
raise InstallError(f"{name}: {translate('unsupported credential generator')}")
|
|
value = secrets.token_hex(int(generated.get("bytes", 16)))
|
|
if not value and item["required"]:
|
|
raise InstallError(f"{name}: {translate('a value is required')}")
|
|
elif not advanced and default not in (None, ""):
|
|
value = default
|
|
else:
|
|
value = ui.ask(label, default, required=item["required"])
|
|
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)
|
|
|
|
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))
|
|
if advanced else item.get("enabled_default", True) or item.get("required_by_compose"))
|
|
if not enabled:
|
|
if item.get("required_by_compose"):
|
|
raise UserCancelled(f"{translate('This image requires the host module')} {item['name']}")
|
|
continue
|
|
host_modules.append(item["name"])
|
|
|
|
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'
|
|
or not {'nesting=1', 'keyctl=1'}.issubset(defaults.get('features', []))
|
|
or len(data) != 1 or data[0]['type'] != 'managed-volume'
|
|
or not data[0]['backup'] or data[0]['size_gb'] < 16
|
|
or memory < 2048 or cores < 2 or rootfs_size < 12):
|
|
raise InstallError(translate("HAOS One requires an unprivileged unmanaged LXC with nesting and "
|
|
"keyctl, 2 cores, 2048 MB RAM, rootfs of at least 12 GB and /mnt/data "
|
|
"of at least 16 GB on a container volume included in backups"))
|
|
|
|
return {
|
|
"host_monitor": host_monitor,
|
|
"monitor_scope": monitor_scope,
|
|
"vmid": int(vmid_text) if vmid_text else None,
|
|
"ostype": defaults.get("ostype", "auto-from-image"),
|
|
"hostname": hostname,
|
|
"template_storage": template_storage,
|
|
"rootfs": {"storage": rootfs_storage, "size_gb": rootfs_size},
|
|
"resources": {
|
|
"cores": cores,
|
|
"memory_mb": memory,
|
|
"swap_mb": swap,
|
|
"cpu_units": cpu_units,
|
|
"rlimits": installer_profile.get('resources', {}).get('rlimits', []),
|
|
},
|
|
"network": {
|
|
"bridge": bridge,
|
|
"ipv4": ipv4,
|
|
"gateway": gateway,
|
|
"firewall": defaults["firewall"],
|
|
"host_managed": defaults["host_managed_network"],
|
|
"mac_address": mac_address,
|
|
},
|
|
"features": defaults.get("features", []),
|
|
"security": {
|
|
"unprivileged": unprivileged,
|
|
"privileged_acknowledged": privileged_acknowledged,
|
|
"relaxation_acknowledged": (
|
|
security_relaxation_acknowledged or privileged_acknowledged
|
|
),
|
|
"required_capabilities": installer_profile.get("security", {}).get(
|
|
"required_capabilities", []
|
|
),
|
|
"sysctls": installer_profile.get("security", {}).get("sysctls", []),
|
|
"options": selected_security_options,
|
|
"host_modules": host_modules,
|
|
},
|
|
"shutdown_timeout_seconds": defaults.get("shutdown_timeout_seconds", 30),
|
|
"onboot": onboot,
|
|
"start_after_create": start_after,
|
|
"environment": environment,
|
|
"mounts": mounts,
|
|
"tmpfs_mounts": tmpfs_mounts,
|
|
"devices": devices,
|
|
"hardware_profile": selected_hardware_profile,
|
|
"device_permissions": installer_profile.get("device_permissions") if devices else None,
|
|
"post_start_configurations": post_start_configurations,
|
|
"extra_hosts": installer_profile.get("extra_hosts", []),
|
|
}
|
|
|
|
|
|
def _build_rclone_deployment(
|
|
template: dict[str, Any],
|
|
ui: TerminalUI | DialogUI,
|
|
) -> dict[str, Any]:
|
|
schema = template["proxmox"]["laboratory_contract"]["configuration_schema"]
|
|
defaults = template["proxmox"]["defaults"]
|
|
if not ui.confirm(translate("Rclone mount needs a privileged LXC with FUSE access. The container is "
|
|
"dedicated to Rclone and its web UI must not be exposed to untrusted networks.")
|
|
+ "\n\n" + translate("Create this LXC in privileged mode?"), False):
|
|
raise UserCancelled(translate("Privileged installation declined"))
|
|
|
|
vmid_text = ui.ask(translate("VMID (empty = next free)"), "", required=False)
|
|
hostname = ui.ask(translate("Hostname"), schema["hostname"]["default"])
|
|
rootfs_storage = ask_storage(ui, translate("Storage for rootfs"), "rootdir", schema["rootfs_storage"]["default"])
|
|
config_storage = ask_storage(ui, translate("Storage for the persistent configuration"), "rootdir",
|
|
schema["config_storage"]["default"])
|
|
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(schema["rootfs_size_gb"]["default"])))
|
|
config_size = int(ui.ask(translate("Configuration size in GB"), str(schema["config_size_gb"]["default"])))
|
|
if rootfs_size < schema["rootfs_size_gb"]["minimum"]:
|
|
raise InstallError(translate("The Rclone rootfs needs at least 2 GB"))
|
|
if config_size < schema["config_size_gb"]["minimum"]:
|
|
raise InstallError(translate("The Rclone configuration needs at least 1 GB"))
|
|
data_host_path = ui.ask(translate("Shared directory for copy/sync operations"),
|
|
schema["data_host_path"]["default"])
|
|
username = ui.ask(translate("Web UI user"), schema["webui_username"]["default"])
|
|
if not re.fullmatch(r"[A-Za-z0-9._-]{1,64}", username):
|
|
raise InstallError(translate("The web UI user contains characters that are not allowed"))
|
|
password = ui.password(f"{translate('Web UI password')} ({translate('empty = generate')})", required=False)
|
|
if not password:
|
|
password = secrets.token_hex(16)
|
|
if len(password) < 24:
|
|
raise InstallError(translate("The web UI password must have at least 24 characters"))
|
|
bridge = ask_bridge(ui, translate("Network bridge"), schema["bridge"]["default"])
|
|
ipv4, gateway = access.ask_ipv4(ui, bridge)
|
|
|
|
return {
|
|
"vmid": int(vmid_text) if vmid_text else None,
|
|
"hostname": hostname,
|
|
"template_storage": template_storage,
|
|
"rootfs": {"storage": rootfs_storage, "size_gb": rootfs_size},
|
|
"resources": {
|
|
"cores": defaults["cores"],
|
|
"memory_mb": defaults["memory_mb"],
|
|
"swap_mb": defaults["swap_mb"],
|
|
"cpu_units": None,
|
|
},
|
|
"network": {
|
|
"bridge": bridge,
|
|
"ipv4": ipv4,
|
|
"gateway": gateway,
|
|
"firewall": defaults["firewall"],
|
|
"host_managed": defaults["host_managed_network"],
|
|
"mac_address": None,
|
|
},
|
|
"features": defaults["features"],
|
|
"security": {
|
|
"unprivileged": False,
|
|
"privileged_acknowledged": True,
|
|
"relaxation_acknowledged": True,
|
|
"required_capabilities": ["SYS_ADMIN"],
|
|
"sysctls": [],
|
|
"options": {},
|
|
"host_modules": [],
|
|
},
|
|
"shutdown_timeout_seconds": defaults["shutdown_timeout_seconds"],
|
|
"onboot": ui.confirm(translate("Start with Proxmox"), defaults["onboot"]),
|
|
"start_after_create": ui.confirm(translate("Start when finished"), True),
|
|
"environment": [
|
|
{"name": "XDG_CONFIG_HOME", "value": "/config", "sensitive": False},
|
|
{"name": "RCLONE_RC_USER", "value": username, "sensitive": False},
|
|
{"name": "RCLONE_RC_PASS", "value": password, "sensitive": True},
|
|
],
|
|
"mounts": [
|
|
{
|
|
"type": "managed-volume",
|
|
"container_path": "/config/rclone",
|
|
"source": config_storage,
|
|
"size_gb": config_size,
|
|
"backup": True,
|
|
"read_only": False,
|
|
"create_if_missing": False,
|
|
},
|
|
{
|
|
"type": "host-bind",
|
|
"container_path": "/data",
|
|
"source": data_host_path,
|
|
"size_gb": None,
|
|
"backup": False,
|
|
"read_only": False,
|
|
"create_if_missing": True,
|
|
},
|
|
],
|
|
"tmpfs_mounts": [],
|
|
"devices": [],
|
|
"extra_hosts": [],
|
|
}
|
|
|
|
|
|
def build_rclone_mount_deployment(
|
|
template: dict[str, Any],
|
|
ui: TerminalUI | DialogUI | None = None,
|
|
) -> dict[str, Any]:
|
|
ui = ui or TerminalUI()
|
|
schema = template["proxmox"]["laboratory_contract"]["configuration_schema"]
|
|
ui.message(translate("The remote must already be created and authorized in the Rclone web UI. This "
|
|
"operation restarts the CT and publishes two FUSE views on the host."))
|
|
vmid = int(ui.ask(translate("VMID of the Rclone OCI container")))
|
|
remote_name = ui.ask(translate("Exact name of the remote"))
|
|
if not re.fullmatch(r"[A-Za-z0-9._-]{1,64}", remote_name):
|
|
raise InstallError(translate("Invalid remote name"))
|
|
remote_path = ui.ask(translate("Path inside the remote (empty = root)"), "", required=False)
|
|
if "\n" in remote_path or "\r" in remote_path or remote_path.startswith("/"):
|
|
raise InstallError(translate("The remote path must be relative and cannot contain line breaks"))
|
|
mount_name = ui.ask(translate("Mount name"), remote_name)
|
|
if not re.fullmatch(r"[A-Za-z0-9._-]{1,64}", mount_name):
|
|
raise InstallError(translate("Invalid mount name"))
|
|
vfs_cache_mode = ui.choose(
|
|
translate("VFS cache mode"),
|
|
[(value, value) for value in schema["vfs_cache_mode"]["options"]],
|
|
schema["vfs_cache_mode"]["default"],
|
|
)
|
|
if vfs_cache_mode is None:
|
|
raise UserCancelled(translate("Mount configuration cancelled"))
|
|
shared_parent = ui.ask(translate("Common root for the published views"),
|
|
schema["shared_mount_root_parent"]["default"])
|
|
shared_rw = ui.ask(translate("Directory for the read/write view"), schema["shared_mount_root"]["default"])
|
|
shared_ro = ui.ask(translate("Directory for the read-only view"),
|
|
schema["shared_mount_read_only_root"]["default"])
|
|
if not ui.confirm(translate("Enable this FUSE mount now and restart the CT?"), False):
|
|
raise UserCancelled(translate("Mount activation cancelled"))
|
|
return {
|
|
"deployment_kind": "rclone-mount-activation",
|
|
"vmid": vmid,
|
|
"remote_name": remote_name,
|
|
"remote_path": remote_path,
|
|
"mount_name": mount_name,
|
|
"vfs_cache_mode": vfs_cache_mode,
|
|
"shared_mount_root_parent": shared_parent,
|
|
"shared_mount_root": shared_rw,
|
|
"shared_mount_read_only_root": shared_ro,
|
|
}
|
|
|
|
|
|
def _build_immich_deployment(
|
|
template: dict[str, Any],
|
|
ui: TerminalUI | DialogUI,
|
|
) -> dict[str, Any]:
|
|
defaults = template["proxmox"]["laboratory_contract"]["defaults"]
|
|
pve_defaults = template["proxmox"]["defaults"]
|
|
vmid_text = ui.ask(translate("Base VMID of the server (empty = next free block)"), "", required=False)
|
|
stack_name = ui.ask(translate("Stack name"), defaults["stack_name"])
|
|
if not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,31}", stack_name):
|
|
raise InstallError(translate("The stack name only accepts lowercase letters, numbers and hyphens"))
|
|
rootfs_storage = ask_storage(ui, translate("Storage for rootfs"), "rootdir", defaults["rootfs_storage"])
|
|
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"),
|
|
[
|
|
("managed-volume", translate("Dedicated container volume (included in backups)")),
|
|
("host-bind", translate("Shared host directory (not included in Proxmox backups)")),
|
|
],
|
|
"managed-volume",
|
|
)
|
|
if media_mode is None:
|
|
raise UserCancelled(translate("Immich configuration cancelled"))
|
|
if media_mode == "managed-volume":
|
|
media_storage = ask_storage(ui, translate("Storage for the Immich library"), "rootdir", pve_defaults["volume_storage"])
|
|
media_size = int(ui.ask(translate("Library size in GB"), "100"))
|
|
if media_size < 8:
|
|
raise InstallError(translate("The Immich library needs at least 8 GB"))
|
|
media_root = None
|
|
else:
|
|
media_default = defaults["shared_media_root"].replace("${stack_name}", stack_name)
|
|
media_root = ui.ask(translate("Shared host directory"), media_default)
|
|
media_storage = None
|
|
media_size = None
|
|
database_size = int(
|
|
ui.ask(translate("PostgreSQL volume size in GB"), str(defaults["database_size_gb"]))
|
|
)
|
|
if database_size < 8:
|
|
raise InstallError(translate("The PostgreSQL volume needs at least 8 GB"))
|
|
frontend_bridge = ask_bridge(ui, translate("Access bridge for Immich"), defaults["frontend_network"]["bridge"])
|
|
addresses, frontend_gateway = access.ask_addresses(
|
|
ui, frontend_bridge, [translate("Immich server"), translate("Immich machine learning")])
|
|
server_ipv4, ml_ipv4 = addresses.values()
|
|
timezone = ui.ask(translate("Timezone"), host.timezone())
|
|
video_acceleration = ui.choose(
|
|
translate("Video transcoding acceleration"),
|
|
[("vaapi", "VA-API"), ("cpu", "CPU")],
|
|
defaults["video_transcoding"]["acceleration"],
|
|
)
|
|
if video_acceleration is None:
|
|
raise UserCancelled(translate("Immich configuration cancelled"))
|
|
render_device = None
|
|
vaapi_driver = "auto"
|
|
if video_acceleration == "vaapi":
|
|
render_device = ui.ask(
|
|
translate("VA-API render device"), defaults["video_transcoding"]["render_device"]
|
|
)
|
|
vaapi_driver = ui.choose(
|
|
translate("VA-API driver"),
|
|
[("auto", translate("Automatic detection")), ("radeonsi", "AMD radeonsi"), ("iHD", "Intel iHD"), ("i965", "Intel i965")],
|
|
defaults["video_transcoding"]["driver"],
|
|
)
|
|
if vaapi_driver is None:
|
|
raise UserCancelled(translate("Immich configuration cancelled"))
|
|
ml_acceleration = ui.choose(
|
|
translate("Acceleration for Immich smart recognition"),
|
|
[("cpu", "CPU"), ("openvino", "Intel GPU / OpenVINO"),
|
|
("cuda", translate("NVIDIA GPU / CUDA (Toolkit on the host)"))],
|
|
"cpu",
|
|
)
|
|
if ml_acceleration is None:
|
|
raise UserCancelled(translate("Immich configuration cancelled"))
|
|
if ml_acceleration not in ("cpu", "openvino", "cuda"):
|
|
raise InstallError(translate("Recognition profile not implemented"))
|
|
ml_render = None
|
|
if ml_acceleration == "openvino":
|
|
ml_render = ui.ask(translate("Intel render device for recognition"), "/dev/dri/renderD128")
|
|
if not re.fullmatch(r"/dev/dri/renderD[0-9]+", ml_render):
|
|
raise InstallError(translate("Invalid Intel render path"))
|
|
if ml_acceleration != "cpu":
|
|
ui.message(translate("GPU recognition uses 8 GB of RAM and a limit of 4 CPU equivalents. These resources "
|
|
"were tested in the lab and are not a universal minimum. Compatibility depends on the "
|
|
"GPU, the models and the kernel. NVIDIA uses the GPUs of the Toolkit inventory; Intel "
|
|
"keeps the CPU topology."))
|
|
return {
|
|
"deployment_kind": "immich-four-lxc-stack",
|
|
"base_vmid": int(vmid_text) if vmid_text else None,
|
|
"stack_name": stack_name,
|
|
"template_storage": template_storage,
|
|
"rootfs_storage": rootfs_storage,
|
|
"database_storage": database_storage,
|
|
"database_size_gb": database_size,
|
|
"media": {
|
|
"mode": media_mode,
|
|
"storage": media_storage,
|
|
"size_gb": media_size,
|
|
"host_path": media_root,
|
|
"backup": media_mode == "managed-volume",
|
|
},
|
|
"timezone": timezone,
|
|
"onboot": ui.confirm(translate("Start the stack with Proxmox"), pve_defaults["onboot"]),
|
|
"start_after_create": ui.confirm(translate("Start when finished"), True),
|
|
"network": {
|
|
"frontend_bridge": frontend_bridge,
|
|
"frontend_ipv4": server_ipv4,
|
|
"machine_learning_frontend_ipv4": ml_ipv4,
|
|
"frontend_gateway": frontend_gateway,
|
|
"private_allocation": "automatic",
|
|
"private_bridge": defaults["private_network"]["bridge"],
|
|
"private_subnet": defaults["private_network"]["subnet"],
|
|
"private_host_address": defaults["private_network"]["host_address"],
|
|
"server_address": defaults["private_network"]["server_address"],
|
|
"machine_learning_address": defaults["private_network"]["machine_learning_address"],
|
|
"database_address": defaults["private_network"]["database_address"],
|
|
"valkey_address": defaults["private_network"]["valkey_address"],
|
|
},
|
|
"video_transcoding": {
|
|
"acceleration": video_acceleration,
|
|
"render_device": render_device,
|
|
"driver": vaapi_driver,
|
|
},
|
|
"machine_learning": {"acceleration": ml_acceleration, "render_device": ml_render,
|
|
"model_cache_size_gb": 8,
|
|
"resources": {"cores": 2 if ml_acceleration == "cpu" else 4,
|
|
"memory_mb": 2048 if ml_acceleration == "cpu" else 8192,
|
|
"swap_mb": 1024,
|
|
"cpu_allocation": "quota" if ml_acceleration == "openvino" else "cpuset"}},
|
|
}
|
|
|
|
|
|
def _build_nextcloud_stack_deployment(
|
|
template: dict[str, Any],
|
|
ui: TerminalUI | DialogUI,
|
|
) -> dict[str, Any]:
|
|
defaults = template["proxmox"]["laboratory_contract"]["defaults"]
|
|
pve_defaults = template["proxmox"]["defaults"]
|
|
vmid_text = ui.ask(
|
|
translate("Base VMID of Nextcloud (empty = next free block)"), "", required=False
|
|
)
|
|
stack_name = ui.ask(translate("Stack name"), defaults["stack_name"])
|
|
if not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,31}", stack_name):
|
|
raise InstallError(translate("The stack name only accepts lowercase letters, numbers and hyphens"))
|
|
|
|
rootfs_storage = ask_storage(ui, translate("Storage for rootfs"), "rootdir", defaults["rootfs_storage"])
|
|
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"),
|
|
[
|
|
("managed-volume", translate("Dedicated container volume (included in backups)")),
|
|
("host-bind", translate("Shared host directory (not included in Proxmox backups)")),
|
|
],
|
|
"managed-volume",
|
|
)
|
|
if application_mode is None:
|
|
raise UserCancelled(translate("Nextcloud configuration cancelled"))
|
|
if application_mode == "managed-volume":
|
|
application_storage = ask_storage(ui, translate("Storage for the Nextcloud data"), "rootdir", defaults["application_storage"])
|
|
application_size = int(
|
|
ui.ask(
|
|
translate("Nextcloud volume size in GB"),
|
|
str(defaults["application_volume_size_gb"]),
|
|
)
|
|
)
|
|
if application_size < 8:
|
|
raise InstallError(translate("The Nextcloud volume needs at least 8 GB"))
|
|
application_root = None
|
|
else:
|
|
shared_default = defaults["shared_application_root"].replace(
|
|
"${stack_name}", stack_name
|
|
)
|
|
application_root = ui.ask(translate("Shared host directory"), shared_default)
|
|
application_storage = None
|
|
application_size = None
|
|
|
|
database_storage = ask_storage(ui, translate("Local storage for PostgreSQL"), "rootdir", defaults["database_storage"])
|
|
database_size = int(
|
|
ui.ask(
|
|
translate("PostgreSQL volume size in GB"),
|
|
str(defaults["database_volume_size_gb"]),
|
|
)
|
|
)
|
|
if database_size < 8:
|
|
raise InstallError(translate("The PostgreSQL volume needs at least 8 GB"))
|
|
|
|
frontend_bridge = ask_bridge(ui, translate("Access bridge for Nextcloud"), defaults["frontend_network"]["bridge"])
|
|
addresses, frontend_gateway = access.ask_addresses(ui, frontend_bridge, [""])
|
|
timezone = ui.ask(translate("Timezone"), host.timezone())
|
|
admin_username = ui.ask(
|
|
translate("Initial administrator user"), defaults["application"]["admin_username"]
|
|
)
|
|
if not re.fullmatch(r"[A-Za-z0-9_.@-]+", admin_username):
|
|
raise InstallError(translate("The administrator user contains characters that are not allowed"))
|
|
|
|
private = defaults["private_network"]
|
|
return {
|
|
"deployment_kind": "nextcloud-three-lxc-stack",
|
|
"base_vmid": int(vmid_text) if vmid_text else None,
|
|
"stack_name": stack_name,
|
|
"template_storage": template_storage,
|
|
"rootfs_storage": rootfs_storage,
|
|
"application": {
|
|
"mode": application_mode,
|
|
"storage": application_storage,
|
|
"size_gb": application_size,
|
|
"host_path": application_root,
|
|
"backup": application_mode == "managed-volume",
|
|
"admin_username": admin_username,
|
|
"php_memory_limit": defaults["application"]["php_memory_limit"],
|
|
"php_upload_limit": defaults["application"]["php_upload_limit"],
|
|
"apache_body_limit": defaults["application"]["apache_body_limit"],
|
|
},
|
|
"database_storage": database_storage,
|
|
"database_size_gb": database_size,
|
|
"timezone": timezone,
|
|
"maintenance_window_start_utc": defaults["maintenance_window_start_utc"],
|
|
"default_phone_region": defaults["default_phone_region"],
|
|
"onboot": ui.confirm(translate("Start the stack with Proxmox"), pve_defaults["onboot"]),
|
|
"start_after_create": ui.confirm(translate("Start when finished"), True),
|
|
"network": {
|
|
"frontend_bridge": frontend_bridge,
|
|
"frontend_ipv4": addresses[""],
|
|
"frontend_gateway": frontend_gateway,
|
|
"private_allocation": "automatic",
|
|
"private_bridge": private["bridge"],
|
|
"private_subnet": private["subnet"],
|
|
"private_host_address": private["host_address"],
|
|
"application_address": private["application_address"],
|
|
"database_address": private["database_address"],
|
|
"cache_address": private["cache_address"],
|
|
},
|
|
}
|
|
|
|
|
|
def _build_paperless_stack_deployment(
|
|
template: dict[str, Any],
|
|
ui: TerminalUI | DialogUI,
|
|
) -> dict[str, Any]:
|
|
defaults = template["proxmox"]["laboratory_contract"]["defaults"]
|
|
pve_defaults = template["proxmox"]["defaults"]
|
|
vmid_text = ui.ask(
|
|
translate("Base VMID of Paperless (empty = next free block)"), "", required=False
|
|
)
|
|
stack_name = ui.ask(translate("Stack name"), defaults["stack_name"])
|
|
if not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,31}", stack_name):
|
|
raise InstallError(translate("The stack name only accepts lowercase letters, numbers and hyphens"))
|
|
|
|
rootfs_storage = ask_storage(ui, translate("Storage for rootfs"), "rootdir", defaults["rootfs_storage"])
|
|
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"])
|
|
|
|
data_size = int(
|
|
ui.ask(translate("Data volume size in GB"), str(defaults["data_volume_size_gb"]))
|
|
)
|
|
media_size = int(
|
|
ui.ask(
|
|
translate("Documents volume size in GB"),
|
|
str(defaults["media_volume_size_gb"]),
|
|
)
|
|
)
|
|
database_size = int(
|
|
ui.ask(
|
|
translate("PostgreSQL volume size in GB"),
|
|
str(defaults["database_volume_size_gb"]),
|
|
)
|
|
)
|
|
if min(data_size, database_size) < 8 or media_size < 8:
|
|
raise InstallError(translate("The Paperless persistent volumes need at least 8 GB"))
|
|
|
|
transfer_mode = ui.choose(
|
|
translate("Storage for 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)")),
|
|
],
|
|
"host-bind",
|
|
)
|
|
if transfer_mode is None:
|
|
raise UserCancelled(translate("Paperless configuration cancelled"))
|
|
transfer_size = None
|
|
transfer_root = None
|
|
if transfer_mode == "managed-volume":
|
|
transfer_size = int(
|
|
ui.ask(
|
|
translate("Size of each consume/export volume in GB"),
|
|
str(defaults["transfer_volume_size_gb"]),
|
|
)
|
|
)
|
|
if transfer_size < 1:
|
|
raise InstallError(translate("The consume/export volumes need at least 1 GB"))
|
|
else:
|
|
transfer_default = defaults["shared_transfer_root"].replace(
|
|
"${stack_name}", stack_name
|
|
)
|
|
transfer_root = ui.ask(
|
|
translate("Shared directory for consume and export"), transfer_default
|
|
)
|
|
|
|
frontend_bridge = ask_bridge(ui, translate("Access bridge for Paperless"), defaults["frontend_network"]["bridge"])
|
|
addresses, frontend_gateway = access.ask_addresses(ui, frontend_bridge, [""])
|
|
timezone = ui.ask(translate("Timezone"), host.timezone())
|
|
ocr_language = ui.ask(translate("OCR language (Tesseract code)"), defaults["ocr_language"])
|
|
if not re.fullmatch(r"[a-z]{3}(?:\+[a-z]{3})*", ocr_language):
|
|
raise InstallError(translate("The OCR language must use Tesseract codes, for example eng or eng+spa"))
|
|
admin_username = ui.ask(
|
|
translate("Initial administrator user"), defaults["application"]["admin_username"]
|
|
)
|
|
if not re.fullmatch(r"[A-Za-z0-9_.@-]+", admin_username):
|
|
raise InstallError(translate("The administrator user contains characters that are not allowed"))
|
|
|
|
private = defaults["private_network"]
|
|
return {
|
|
"deployment_kind": "paperless-three-lxc-stack",
|
|
"base_vmid": int(vmid_text) if vmid_text else None,
|
|
"stack_name": stack_name,
|
|
"template_storage": template_storage,
|
|
"rootfs_storage": rootfs_storage,
|
|
"application_storage": application_storage,
|
|
"data_size_gb": data_size,
|
|
"media_size_gb": media_size,
|
|
"database_storage": database_storage,
|
|
"database_size_gb": database_size,
|
|
"broker_size_gb": defaults["broker_volume_size_gb"],
|
|
"transfer": {
|
|
"mode": transfer_mode,
|
|
"storage": application_storage if transfer_mode == "managed-volume" else None,
|
|
"size_gb": transfer_size,
|
|
"host_path": transfer_root,
|
|
"backup": transfer_mode == "managed-volume",
|
|
},
|
|
"application": {
|
|
"admin_username": admin_username,
|
|
"ocr_language": ocr_language,
|
|
},
|
|
"timezone": timezone,
|
|
"onboot": ui.confirm(translate("Start the stack with Proxmox"), pve_defaults["onboot"]),
|
|
"start_after_create": ui.confirm(translate("Start when finished"), True),
|
|
"network": {
|
|
"frontend_bridge": frontend_bridge,
|
|
"frontend_ipv4": addresses[""],
|
|
"frontend_gateway": frontend_gateway,
|
|
"private_allocation": "automatic",
|
|
"private_bridge": private["bridge"],
|
|
"private_subnet": private["subnet"],
|
|
"private_host_address": private["host_address"],
|
|
"application_address": private["application_address"],
|
|
"database_address": private["database_address"],
|
|
"broker_address": private["broker_address"],
|
|
},
|
|
}
|
|
|
|
|
|
def _build_tandoor_stack_deployment(
|
|
template: dict[str, Any],
|
|
ui: TerminalUI | DialogUI,
|
|
) -> dict[str, Any]:
|
|
defaults = template["proxmox"]["laboratory_contract"]["defaults"]
|
|
pve_defaults = template["proxmox"]["defaults"]
|
|
vmid_text = ui.ask(
|
|
translate("Base VMID of Tandoor (empty = next free block)"), "", required=False
|
|
)
|
|
stack_name = ui.ask(translate("Stack name"), defaults["stack_name"])
|
|
if not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,31}", stack_name):
|
|
raise InstallError(translate("The stack name only accepts lowercase letters, numbers and hyphens"))
|
|
|
|
rootfs_storage = ask_storage(ui, translate("Storage for rootfs"), "rootdir", defaults["rootfs_storage"])
|
|
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"),
|
|
[
|
|
("managed-volume", translate("Dedicated container volume (included in backups)")),
|
|
("host-bind", translate("Shared host directory (not included in Proxmox backups)")),
|
|
],
|
|
"managed-volume",
|
|
)
|
|
if media_mode is None:
|
|
raise UserCancelled(translate("Tandoor configuration cancelled"))
|
|
if media_mode == "managed-volume":
|
|
media_storage = ask_storage(ui, translate("Storage for Tandoor files"), "rootdir", defaults["application_storage"])
|
|
media_size = int(
|
|
ui.ask(
|
|
translate("Files volume size in GB"),
|
|
str(defaults["media_volume_size_gb"]),
|
|
)
|
|
)
|
|
if media_size < 2:
|
|
raise InstallError(translate("The Tandoor files volume needs at least 2 GB"))
|
|
media_root = None
|
|
else:
|
|
media_default = defaults["shared_media_root"].replace(
|
|
"${stack_name}", stack_name
|
|
)
|
|
media_root = ui.ask(translate("Shared host directory"), media_default)
|
|
media_storage = None
|
|
media_size = None
|
|
|
|
static_size = int(
|
|
ui.ask(
|
|
translate("staticfiles volume size in GB"),
|
|
str(defaults["static_volume_size_gb"]),
|
|
)
|
|
)
|
|
database_size = int(
|
|
ui.ask(
|
|
translate("PostgreSQL volume size in GB"),
|
|
str(defaults["database_volume_size_gb"]),
|
|
)
|
|
)
|
|
if static_size < 1 or database_size < 4:
|
|
raise InstallError(
|
|
translate("Tandoor needs at least 1 GB for staticfiles and 4 GB for PostgreSQL")
|
|
)
|
|
|
|
frontend_bridge = ask_bridge(ui, translate("Access bridge for Tandoor"), defaults["frontend_network"]["bridge"])
|
|
addresses, frontend_gateway = access.ask_addresses(ui, frontend_bridge, [""])
|
|
timezone = ui.ask(translate("Timezone"), host.timezone())
|
|
allowed_hosts = ui.ask(
|
|
translate("Allowed hosts (comma separated; * allows access through the assigned IP)"),
|
|
defaults["allowed_hosts"],
|
|
)
|
|
if any(character in allowed_hosts for character in "\r\n"):
|
|
raise InstallError(translate("ALLOWED_HOSTS cannot contain line breaks"))
|
|
admin_username = ui.ask(
|
|
translate("Initial administrator user"), defaults["application"]["admin_username"]
|
|
)
|
|
if not re.fullmatch(r"[A-Za-z0-9_.@-]+", admin_username):
|
|
raise InstallError(translate("The administrator user contains characters that are not allowed"))
|
|
admin_email = ui.ask(
|
|
translate("Administrator email"), defaults["application"]["admin_email"]
|
|
)
|
|
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"))
|
|
|
|
start_after = ui.confirm(translate("Start when finished"), True)
|
|
if not start_after:
|
|
raise UserCancelled(
|
|
translate("Tandoor needs to complete its first start to create the initial administrator")
|
|
)
|
|
|
|
private = defaults["private_network"]
|
|
return {
|
|
"deployment_kind": "tandoor-two-lxc-stack",
|
|
"base_vmid": int(vmid_text) if vmid_text else None,
|
|
"stack_name": stack_name,
|
|
"template_storage": template_storage,
|
|
"rootfs_storage": rootfs_storage,
|
|
"application_storage": defaults["application_storage"],
|
|
"static_size_gb": static_size,
|
|
"database_storage": database_storage,
|
|
"database_size_gb": database_size,
|
|
"media": {
|
|
"mode": media_mode,
|
|
"storage": media_storage,
|
|
"size_gb": media_size,
|
|
"host_path": media_root,
|
|
"backup": media_mode == "managed-volume",
|
|
},
|
|
"application": {
|
|
"allowed_hosts": allowed_hosts,
|
|
"admin_username": admin_username,
|
|
"admin_email": admin_email,
|
|
},
|
|
"timezone": timezone,
|
|
"onboot": ui.confirm(translate("Start the stack with Proxmox"), pve_defaults["onboot"]),
|
|
"start_after_create": start_after,
|
|
"network": {
|
|
"frontend_bridge": frontend_bridge,
|
|
"frontend_ipv4": addresses[""],
|
|
"frontend_gateway": frontend_gateway,
|
|
"private_allocation": "automatic",
|
|
"private_bridge": private["bridge"],
|
|
"private_subnet": private["subnet"],
|
|
"private_host_address": private["host_address"],
|
|
"application_address": private["application_address"],
|
|
"database_address": private["database_address"],
|
|
},
|
|
}
|
|
|
|
|
|
def redacted(deployment: dict[str, Any]) -> dict[str, Any]:
|
|
payload = json.loads(json.dumps(deployment))
|
|
for item in payload.get("environment", []):
|
|
if item.pop("sensitive", False):
|
|
item["value"] = "********"
|
|
for service in payload.get('services', []):
|
|
if 'setup_credentials' in service:
|
|
service['setup_credentials']['password'] = '********'
|
|
if 'deployment' in service:
|
|
service['deployment'] = redacted(service['deployment'])
|
|
if 'template' in service:
|
|
service['template'] = {'id':service['template'].get('id')}
|
|
return payload
|
|
|
|
|
|
def run_remote_install(project_root, template_source, deployment, ssh_target, dry_run=False):
|
|
from .gpu import apply_profile_image
|
|
template = (copy.deepcopy(template_source) if isinstance(template_source, dict)
|
|
else json.loads(template_source.read_text()))
|
|
apply_profile_image(template, deployment.get('hardware_profile'))
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
effective = Path(directory) / 'template.json'
|
|
effective.write_text(json.dumps(template))
|
|
return _run_remote_install(project_root, effective, deployment, ssh_target, dry_run)
|
|
|
|
|
|
def _run_remote_install(
|
|
project_root: Path,
|
|
template_path: Path,
|
|
deployment: dict[str, Any],
|
|
ssh_target: str,
|
|
dry_run: bool = False,
|
|
) -> dict[str, Any] | None:
|
|
deployment_kind = deployment.get("deployment_kind")
|
|
stack_installers = {
|
|
"generic-multi-lxc-stack": "install_generic_stack.sh",
|
|
"immich-four-lxc-stack": "install_immich_stack.sh",
|
|
"nextcloud-three-lxc-stack": "install_nextcloud_stack.sh",
|
|
"paperless-three-lxc-stack": "install_paperless_stack.sh",
|
|
"tandoor-two-lxc-stack": "install_tandoor_stack.sh",
|
|
}
|
|
remote_script = project_root / "remote" / stack_installers.get(
|
|
deployment_kind, "install_oci.sh"
|
|
)
|
|
archive_verifier = project_root / "remote" / "verify_oci_archive.py"
|
|
runtime_resolver = project_root / "remote" / "oci_runtime.py"
|
|
rootfs_unshifter = project_root / "remote" / "unshift_oci_rootfs.py"
|
|
jellyfin_configurator = project_root / "remote" / "configure_jellyfin_encoding.py"
|
|
private_network_allocator = project_root / "remote" / "allocate_private_network.py"
|
|
stack_dependency_hook = project_root / "remote" / "stack_dependency_hook.sh"
|
|
deployment_bytes = (json.dumps(deployment, ensure_ascii=True, indent=2) + "\n").encode()
|
|
if ssh_target == "auto":
|
|
if os.geteuid() != 0 or not shutil.which("pct"):
|
|
raise InstallError(translate("The installation runs on the Proxmox node itself, as root"))
|
|
ssh_target = "local"
|
|
if ssh_target == "local":
|
|
with tempfile.TemporaryDirectory(prefix="proxmenux-oci-") as directory:
|
|
temporary = Path(directory)
|
|
script_copy = temporary / remote_script.name
|
|
verifier_copy = temporary / "verify_oci_archive.py"
|
|
resolver_copy = temporary / "oci_runtime.py"
|
|
unshifter_copy = temporary / "unshift_oci_rootfs.py"
|
|
jellyfin_configurator_copy = temporary / "configure_jellyfin_encoding.py"
|
|
private_network_allocator_copy = temporary / "allocate_private_network.py"
|
|
stack_dependency_hook_copy = temporary / "stack_dependency_hook.sh"
|
|
template_copy = temporary / "template.json"
|
|
deployment_copy = temporary / "deployment.json"
|
|
shutil.copy2(remote_script, script_copy)
|
|
shutil.copy2(archive_verifier, verifier_copy)
|
|
shutil.copy2(runtime_resolver, resolver_copy)
|
|
shutil.copy2(rootfs_unshifter, unshifter_copy)
|
|
shutil.copy2(jellyfin_configurator, jellyfin_configurator_copy)
|
|
shutil.copy2(private_network_allocator, private_network_allocator_copy)
|
|
shutil.copy2(stack_dependency_hook, stack_dependency_hook_copy)
|
|
shutil.copy2(project_root / 'remote' / 'haos_healthcheck.py', temporary / 'haos_healthcheck.py')
|
|
shutil.copy2(project_root / 'remote' / 'oci_installation_state.py', temporary / 'oci_installation_state.py')
|
|
shutil.copy2(project_root / 'remote' / 'oci_instances.py', temporary / 'oci_instances.py')
|
|
for helper in ('oci_ui.sh', 'oci_ui.py', 'oci_native_stack.py', 'oci_native_stack.sh', 'oci_instance_transaction.py', 'oci_host_mounts.py', 'oci_runtime_settings.py', 'oci_gpu_devices.py', 'oci_accelerators.py', 'oci_nvidia_runtime.py', 'oci_nvidia_refresh.py', 'oci_nvidia_dynamic.py', 'oci_update_current.py', 'oci_stack_replay.py', 'oci_stack_plan.py', 'oci_stack_transaction.py', 'oci_stack_native.py', 'oci_image_cache.py', 'nvidia_lxc_mount_lab.sh', 'oci_nvidia_setup.sh', 'oci_immich_ml.sh'):
|
|
shutil.copy2(project_root / 'remote' / helper, temporary / helper)
|
|
if deployment_kind == 'generic-multi-lxc-stack':
|
|
shutil.copy2(project_root / 'remote' / 'install_generic_stack.py', temporary / 'install_generic_stack.py')
|
|
shutil.copy2(project_root / 'remote' / 'install_oci.sh', temporary / 'install_oci.sh')
|
|
shutil.copy2(template_path, template_copy)
|
|
deployment_copy.write_bytes(deployment_bytes)
|
|
deployment_copy.chmod(0o600)
|
|
result = _stream_process(
|
|
[
|
|
"bash",
|
|
str(script_copy),
|
|
str(template_copy),
|
|
str(deployment_copy),
|
|
"1" if dry_run else "0",
|
|
]
|
|
)
|
|
if result is not None and not dry_run:
|
|
subprocess.run([sys.executable, str(temporary / "oci_image_cache.py")],
|
|
capture_output=True, check=False)
|
|
return result
|
|
|
|
with io.BytesIO() as buffer:
|
|
with tarfile.open(fileobj=buffer, mode="w:gz", format=tarfile.GNU_FORMAT) as archive:
|
|
archive.add(remote_script, arcname=remote_script.name)
|
|
archive.add(archive_verifier, arcname="verify_oci_archive.py")
|
|
archive.add(runtime_resolver, arcname="oci_runtime.py")
|
|
archive.add(rootfs_unshifter, arcname="unshift_oci_rootfs.py")
|
|
archive.add(
|
|
jellyfin_configurator,
|
|
arcname="configure_jellyfin_encoding.py",
|
|
)
|
|
archive.add(private_network_allocator, arcname="allocate_private_network.py")
|
|
archive.add(stack_dependency_hook, arcname="stack_dependency_hook.sh")
|
|
archive.add(project_root / 'remote' / 'haos_healthcheck.py', arcname='haos_healthcheck.py')
|
|
archive.add(project_root / 'remote' / 'oci_installation_state.py', arcname='oci_installation_state.py')
|
|
archive.add(project_root / 'remote' / 'oci_instances.py', arcname='oci_instances.py')
|
|
for helper in ('oci_ui.sh', 'oci_ui.py', 'oci_native_stack.py', 'oci_native_stack.sh', 'oci_instance_transaction.py', 'oci_host_mounts.py', 'oci_runtime_settings.py', 'oci_gpu_devices.py', 'oci_accelerators.py', 'oci_nvidia_runtime.py', 'oci_nvidia_refresh.py', 'oci_nvidia_dynamic.py', 'oci_update_current.py', 'oci_stack_replay.py', 'oci_stack_plan.py', 'oci_stack_transaction.py', 'oci_stack_native.py', 'oci_image_cache.py', 'nvidia_lxc_mount_lab.sh', 'oci_nvidia_setup.sh', 'oci_immich_ml.sh'):
|
|
archive.add(project_root / 'remote' / helper, arcname=helper)
|
|
if deployment_kind == 'generic-multi-lxc-stack':
|
|
archive.add(project_root / 'remote' / 'install_generic_stack.py', arcname='install_generic_stack.py')
|
|
archive.add(project_root / 'remote' / 'install_oci.sh', arcname='install_oci.sh')
|
|
archive.add(template_path, arcname="template.json")
|
|
info = tarfile.TarInfo("deployment.json")
|
|
info.size = len(deployment_bytes)
|
|
info.mode = 0o600
|
|
archive.addfile(info, io.BytesIO(deployment_bytes))
|
|
archive_bytes = buffer.getvalue()
|
|
|
|
command = [
|
|
"ssh",
|
|
"-o",
|
|
"StrictHostKeyChecking=accept-new",
|
|
ssh_target,
|
|
"tmp=$(mktemp -d /tmp/proxmenux-oci.XXXXXX); trap 'rm -rf \"$tmp\"' EXIT; "
|
|
f"tar -xzf - -C \"$tmp\"; bash \"$tmp/{remote_script.name}\" \"$tmp/template.json\" "
|
|
f"\"$tmp/deployment.json\" {'1' if dry_run else '0'}; status=$?; "
|
|
+ ("" if dry_run else "[ $status -ne 0 ] || python3 \"$tmp/oci_image_cache.py\" >/dev/null 2>&1; ")
|
|
+ "exit $status",
|
|
]
|
|
return _stream_process(command, archive_bytes)
|
|
|
|
|
|
def run_remote_rclone_mount(project_root, template_source, deployment, ssh_target, dry_run=False):
|
|
if not isinstance(template_source, dict):
|
|
return _run_remote_rclone_mount(project_root, template_source, deployment, ssh_target, dry_run)
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
path = Path(directory) / 'template.json'
|
|
path.write_text(json.dumps(template_source))
|
|
return _run_remote_rclone_mount(project_root, path, deployment, ssh_target, dry_run)
|
|
|
|
|
|
def _run_remote_rclone_mount(
|
|
project_root: Path,
|
|
template_path: Path,
|
|
deployment: dict[str, Any],
|
|
ssh_target: str,
|
|
dry_run: bool = False,
|
|
) -> dict[str, Any] | None:
|
|
remote_script = project_root / "remote" / "configure_rclone_mount.sh"
|
|
publisher = project_root / "remote" / "rclone_mount_publish.py"
|
|
deployment_bytes = (json.dumps(deployment, ensure_ascii=True, indent=2) + "\n").encode()
|
|
if ssh_target == "auto":
|
|
if os.geteuid() != 0 or not shutil.which("pct"):
|
|
raise InstallError(translate("The installation runs on the Proxmox node itself, as root"))
|
|
ssh_target = "local"
|
|
if ssh_target == "local":
|
|
with tempfile.TemporaryDirectory(prefix="proxmenux-rclone-") as directory:
|
|
temporary = Path(directory)
|
|
script_copy = temporary / remote_script.name
|
|
publisher_copy = temporary / publisher.name
|
|
template_copy = temporary / "template.json"
|
|
deployment_copy = temporary / "deployment.json"
|
|
shutil.copy2(remote_script, script_copy)
|
|
shutil.copy2(publisher, publisher_copy)
|
|
for helper in ('oci_ui.sh', 'oci_ui.py'):
|
|
shutil.copy2(project_root / 'remote' / helper, temporary / helper)
|
|
shutil.copy2(template_path, template_copy)
|
|
deployment_copy.write_bytes(deployment_bytes)
|
|
deployment_copy.chmod(0o600)
|
|
return _stream_process(
|
|
[
|
|
"bash",
|
|
str(script_copy),
|
|
str(template_copy),
|
|
str(deployment_copy),
|
|
"1" if dry_run else "0",
|
|
]
|
|
)
|
|
|
|
with io.BytesIO() as buffer:
|
|
with tarfile.open(fileobj=buffer, mode="w:gz", format=tarfile.GNU_FORMAT) as archive:
|
|
archive.add(remote_script, arcname=remote_script.name)
|
|
archive.add(publisher, arcname=publisher.name)
|
|
for helper in ('oci_ui.sh', 'oci_ui.py'):
|
|
archive.add(project_root / 'remote' / helper, arcname=helper)
|
|
archive.add(template_path, arcname="template.json")
|
|
info = tarfile.TarInfo("deployment.json")
|
|
info.size = len(deployment_bytes)
|
|
info.mode = 0o600
|
|
archive.addfile(info, io.BytesIO(deployment_bytes))
|
|
archive_bytes = buffer.getvalue()
|
|
command = [
|
|
"ssh",
|
|
"-o",
|
|
"StrictHostKeyChecking=accept-new",
|
|
ssh_target,
|
|
"tmp=$(mktemp -d /tmp/proxmenux-rclone.XXXXXX); trap 'rm -rf \"$tmp\"' EXIT; "
|
|
f"tar -xzf - -C \"$tmp\"; bash \"$tmp/{remote_script.name}\" "
|
|
f"\"$tmp/template.json\" \"$tmp/deployment.json\" {'1' if dry_run else '0'}",
|
|
]
|
|
return _stream_process(command, archive_bytes)
|
|
|
|
|
|
def _stream_process(command: list[str], standard_input: bytes | None = None) -> dict[str, Any] | None:
|
|
environment = os.environ.copy()
|
|
environment["OCI_SPINNER"] = "1" if sys.stdout.isatty() else "0"
|
|
process = subprocess.Popen(
|
|
command,
|
|
stdin=subprocess.PIPE if standard_input is not None else subprocess.DEVNULL,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.STDOUT,
|
|
text=False,
|
|
env=environment,
|
|
)
|
|
if standard_input is not None:
|
|
assert process.stdin is not None
|
|
process.stdin.write(standard_input)
|
|
process.stdin.close()
|
|
result: dict[str, Any] | None = None
|
|
assert process.stdout is not None
|
|
pending = b""
|
|
binary_output = getattr(sys.stdout, "buffer", None)
|
|
|
|
def emit(data: bytes) -> None:
|
|
if binary_output is not None:
|
|
binary_output.write(data)
|
|
binary_output.flush()
|
|
else:
|
|
sys.stdout.write(data.decode("utf-8", errors="replace"))
|
|
sys.stdout.flush()
|
|
|
|
while True:
|
|
chunk = os.read(process.stdout.fileno(), 1024)
|
|
if not chunk:
|
|
break
|
|
pending += chunk
|
|
while True:
|
|
newline = pending.find(b"\n")
|
|
carriage = pending.find(b"\r")
|
|
separators = [position for position in (newline, carriage) if position >= 0]
|
|
if not separators:
|
|
break
|
|
position = min(separators)
|
|
raw_line = pending[:position]
|
|
if pending[position : position + 2] == b"\r\n":
|
|
separator = b"\n"
|
|
pending = pending[position + 2 :]
|
|
else:
|
|
separator = pending[position : position + 1]
|
|
pending = pending[position + 1 :]
|
|
if raw_line.startswith(b"PROXMENUX_RESULT="):
|
|
encoded = raw_line.split(b"=", 1)[1]
|
|
result = json.loads(base64.b64decode(encoded).decode("utf-8"))
|
|
else:
|
|
emit(raw_line + separator)
|
|
if pending:
|
|
if pending.startswith(b"PROXMENUX_RESULT="):
|
|
encoded = pending.split(b"=", 1)[1]
|
|
result = json.loads(base64.b64decode(encoded).decode("utf-8"))
|
|
else:
|
|
emit(pending)
|
|
process.stdout.close()
|
|
return_code = process.wait()
|
|
if return_code != 0:
|
|
raise InstallError(f"{translate('The installation ended with exit code')} {return_code}")
|
|
return result
|
|
|
|
def configure_acceleration(installer_profile, environment, unprivileged, ui, mode=ADVANCED_MODE):
|
|
advanced = mode != DEFAULT_MODE
|
|
devices: list[dict[str, Any]] = []
|
|
selected_hardware_profile: str | None = None
|
|
selected_profile: dict[str, Any] | None = None
|
|
profile_device_requests: list[dict[str, Any]] = []
|
|
post_start_configurations: list[dict[str, Any]] = []
|
|
hardware = installer_profile.get("hardware_acceleration")
|
|
if hardware:
|
|
profiles = hardware.get("profiles", [])
|
|
options = [(item["id"], item["label"]) for item in profiles]
|
|
selected_hardware_profile = ui.choose(
|
|
translate(hardware.get("prompt", "Hardware acceleration")),
|
|
[(tag, translate(label)) for tag, label in options],
|
|
hardware.get("default", profiles[0]["id"] if profiles else None),
|
|
)
|
|
if selected_hardware_profile is None:
|
|
raise UserCancelled(translate("Acceleration configuration cancelled"))
|
|
selected_profile = next(
|
|
(item for item in profiles if item["id"] == selected_hardware_profile),
|
|
None,
|
|
)
|
|
if selected_profile is None:
|
|
raise InstallError(translate("Invalid acceleration profile"))
|
|
profile_device_requests = [
|
|
{**item, "selected_by_hardware_profile": True}
|
|
for item in selected_profile.get("device_requests", [])
|
|
]
|
|
for env_item in selected_profile.get("environment", []):
|
|
environment = [
|
|
entry for entry in environment if entry["name"] != env_item["name"]
|
|
]
|
|
environment.append(
|
|
{
|
|
"name": env_item["name"],
|
|
"value": str(env_item["value"]),
|
|
"sensitive": bool(env_item.get("sensitive", False)),
|
|
}
|
|
)
|
|
for configuration in selected_profile.get("post_start_configurations", []):
|
|
prompt = (translate(configuration["enable_prompt"]) if configuration.get("enable_prompt")
|
|
else f"{translate('Apply configuration')} {configuration['id']}")
|
|
enabled = (ui.confirm(prompt, configuration.get("enabled_default", True)) if advanced
|
|
else configuration.get("enabled_default", True))
|
|
if enabled:
|
|
post_start_configurations.append(copy.deepcopy(configuration))
|
|
|
|
device_requests = [
|
|
*installer_profile.get("optional_devices", []),
|
|
*installer_profile.get("device_requests", []),
|
|
*profile_device_requests,
|
|
]
|
|
for item in device_requests:
|
|
enabled = item.get("selected_by_hardware_profile", False) or (
|
|
ui.confirm(translate(item["enable_prompt"]), item.get("enabled_default", False)) if advanced
|
|
else item.get("enabled_default", False) or item.get("required_for_runtime", False))
|
|
if not enabled:
|
|
if item.get("required_for_runtime"):
|
|
raise UserCancelled(f"{translate('This variant requires the device')} {item['id']}")
|
|
continue
|
|
kind = item.get("kind", "character-device")
|
|
if kind == "nvidia-runtime":
|
|
device = {
|
|
"id": item["id"],
|
|
"kind": kind,
|
|
"device_selection": item.get("device_selection", "all-requested-by-compose"),
|
|
"runtime_mode": "dynamic" if unprivileged else "static",
|
|
}
|
|
if not unprivileged:
|
|
ui.message(translate("Dynamic NVIDIA is only validated for unprivileged containers. This profile "
|
|
"uses static mounts and must be recreated after the host driver changes."))
|
|
if item.get("append_host_device_gid_to_environment"):
|
|
device["append_host_device_gid_to_environment"] = item[
|
|
"append_host_device_gid_to_environment"
|
|
]
|
|
devices.append(device)
|
|
else:
|
|
host_path = (ui.ask(translate(item["path_prompt"]), item["host_path_default"]) if advanced
|
|
else item["host_path_default"])
|
|
container_path = (
|
|
host_path
|
|
if item.get("container_path_strategy") == "same-as-host"
|
|
else item.get("container_path", host_path)
|
|
)
|
|
device = {
|
|
"id": item["id"],
|
|
"kind": kind,
|
|
"host_path": host_path,
|
|
"container_path": container_path,
|
|
"mode": item.get("mode", "0660"),
|
|
"deny_write": item.get("deny_write", False),
|
|
"gid_strategy": item.get("gid_strategy", "host-device-gid"),
|
|
}
|
|
if item.get("append_host_device_gid_to_environment"):
|
|
device["append_host_device_gid_to_environment"] = item[
|
|
"append_host_device_gid_to_environment"
|
|
]
|
|
if item.get("drm_vendor_ids"):
|
|
device["drm_vendor_ids"] = item["drm_vendor_ids"]
|
|
if item.get("uid_from_environment"):
|
|
name = item["uid_from_environment"]
|
|
uid = next((entry["value"] for entry in environment if entry["name"] == name), "")
|
|
if not re.fullmatch(r"[0-9]+", str(uid)) or int(uid) >= 4294967295:
|
|
raise InstallError(f"{name}: {translate('must contain a valid numeric UID for the GPU')}")
|
|
device["uid"] = int(uid)
|
|
devices.append(device)
|
|
for env_item in item.get("environment", []):
|
|
selected = (ui.choose(translate(env_item["prompt"]),
|
|
[(value, value) for value in env_item["choices"]],
|
|
env_item.get("default")) if advanced else env_item.get("default"))
|
|
if selected is None:
|
|
raise UserCancelled(translate("Device configuration cancelled"))
|
|
if selected in env_item.get("omit_values", []):
|
|
continue
|
|
environment = [entry for entry in environment if entry["name"] != env_item["name"]]
|
|
environment.append(
|
|
{"name": env_item["name"], "value": selected, "sensitive": False}
|
|
)
|
|
|
|
device_paths = {
|
|
item["id"]: item.get("container_path")
|
|
for item in devices
|
|
if item.get("container_path")
|
|
}
|
|
# LSIO's native permission init needs explicit paths for PVE devN mounts:
|
|
# recursive find -type c can see the regular-file mountpoint d_type instead.
|
|
for name, identifiers in (selected_profile or {}).get("environment_from_devices", {}).items():
|
|
paths = [device_paths.get(identifier) for identifier in identifiers]
|
|
if not paths or any(
|
|
not path or not re.fullmatch(r"/dev/[A-Za-z0-9_/.-]+", path)
|
|
or ".." in path.split("/") for path in paths
|
|
):
|
|
raise InstallError(f"{translate('Invalid device paths for')} {name}")
|
|
environment = [entry for entry in environment if entry["name"] != name]
|
|
environment.append({"name": name, "value": " ".join(paths), "sensitive": False})
|
|
for configuration in post_start_configurations:
|
|
for setting, value in list(configuration.get("settings", {}).items()):
|
|
if not isinstance(value, dict) or "device_path_from" not in value:
|
|
continue
|
|
device_id = value["device_path_from"]
|
|
device_path = device_paths.get(device_id)
|
|
if not device_path:
|
|
raise InstallError(
|
|
f"{configuration['id']}: {translate('this configuration needs the device')} {device_id}"
|
|
)
|
|
configuration["settings"][setting] = device_path
|
|
|
|
return devices, selected_hardware_profile, post_start_configurations, environment
|