feat(oci): run official container images as native LXC containers

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>
This commit is contained in:
MacRimi
2026-09-22 18:24:59 +02:00
co-authored by Claude Opus 5
parent b36498f215
commit bcabcb618c
670 changed files with 221410 additions and 215 deletions
+3
View File
@@ -0,0 +1,3 @@
"""ProxMenux LinuxServer-to-Proxmox OCI laboratory tools."""
__version__ = "0.1.0"
+5
View File
@@ -0,0 +1,5 @@
from .cli import main
if __name__ == "__main__":
raise SystemExit(main())
+129
View File
@@ -0,0 +1,129 @@
"""Selectable core Arr suite, reusing the catalog's individual image contracts."""
import copy
import json
from pathlib import Path
from .i18n import translate
from .stack import DefaultsUI, StackError
PLAYERS = ('jellyfin', 'plex', 'emby')
MEDIA_APPS = {'sonarr','radarr','lidarr','qbittorrent','sabnzbd','bazarr','unpackerr',*PLAYERS}
class SuiteChildUI(DefaultsUI):
"""Reuse image hardware questions without repeating the stack storage wizard."""
def __init__(self, ui, profile):
self.ui = ui
self.prompts = set()
def visit(value):
if isinstance(value, dict):
for key, item in value.items():
if key in ('prompt','path_prompt','enable_prompt') and isinstance(item,str):
# The installer may send either the template text or its translation.
self.prompts.add(item)
self.prompts.add(translate(item))
visit(item)
elif isinstance(value,list):
for item in value: visit(item)
visit(profile)
def ask(self, text, default=None, required=True):
return self.ui.ask(text,default,required) if text in self.prompts else super().ask(text,default,required)
def choose(self, text, options, default=None):
return self.ui.choose(text,options,default) if text in self.prompts else default
def confirm(self, text, default=False):
return self.ui.confirm(text,default) if text in self.prompts else default
def password(self, text, required=True):
return self.ui.password(text,required=required)
def build_suite(template, ui):
from .installer import build_deployment, _hostname_default
choices = template['proxmox']['installer_profile']['applications']
profile = template['proxmox']['installer_profile']
selected = ui.checklist(translate('Arr suite: applications to install'), [(x, x.capitalize()) for x in choices],
profile.get('default_applications',['prowlarr','sonarr','radarr','qbittorrent']))
if set(selected) - set(choices):
raise StackError(translate('Invalid suite application'))
player = ui.choose(translate('Media server'), [(x,x.capitalize()) for x in PLAYERS]+[('none',translate('None'))], 'jellyfin')
if player not in (*PLAYERS,'none'):
raise StackError(translate('Media server selection cancelled or invalid'))
if player != 'none': selected = list(selected)+[player]
if not selected:
raise StackError(translate('Select at least one suite application'))
if 'unpackerr' in selected and not set(selected) & {'sonarr','radarr','lidarr'}:
raise StackError(translate('Unpackerr requires Sonarr, Radarr or Lidarr in this suite'))
name = _hostname_default(ui.ask(translate('Stack name'), 'suite-arr'))
base = 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
storage = ask_storage(ui, translate('Storage for rootfs and private configuration'), 'rootdir', 'local-lvm')
cache = ask_storage(ui, translate('Storage for the OCI image cache'), 'vztmpl', 'local')
bridge = ask_bridge(ui, translate('Access bridge'), 'vmbr0')
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:
password = ui.password(translate('qBittorrent WebUI password (user: admin)'), required=True)
if not password:
raise StackError(translate('qBittorrent requires a non-empty password'))
credentials = {'username':'admin','password':password}
for app in ordered:
path = Path(__file__).resolve().parents[2] / 'catalog/apps' / (app+'.json')
child = json.loads(path.read_text())
if not child['compatibility']['automatic_install_candidate']:
raise StackError(f"{app}: {translate('individual template is blocked')}")
plan = build_deployment(copy.deepcopy(child), SuiteChildUI(ui,child['proxmox'].get('installer_profile',{})))
plan.update(hostname=_hostname_default(name+'-'+app), start_after_create=False, onboot=onboot, template_storage=cache)
plan['rootfs']['storage'] = storage
for env in plan['environment']:
if env['name'] == 'TZ': env['value'] = timezone
if env['name'] in ('PUID','PGID'): env['value'] = '1000'
config_path = '/app/config' if app=='seerr' else '/config'
config = next(copy.deepcopy(m) for m in plan['mounts'] if m['container_path']==config_path)
config.update(type='managed-volume', source=storage, size_gb=max(config.get('size_gb') or 0,8), backup=True)
plan['mounts'] = [config]
if app in MEDIA_APPS:
plan['mounts'].append({'type':'host-bind','source':shared,'container_path':'/data','size_gb':None,'backup':False,'read_only':False,'create_if_missing':True})
from .custom_mounts import ask_custom_mounts
ui.info(f"{translate('Additional paths for')} {app}")
plan['mounts'] = ask_custom_mounts(ui, plan['mounts'], storage)
endpoint = child['first_run']['endpoints'][0] if child['first_run']['endpoints'] else None
health = {'type':'http','timeout_seconds':360,'endpoint':endpoint} if endpoint else {'type':'running','timeout_seconds':60}
if app == 'qbittorrent':
child['first_run']['credentials'] = []
services.append({'name':app,'main':False,'kind':'application','offset':len(services),
'aliases':[app], 'frontend':app!='unpackerr', 'template':child,'deployment':plan,
'healthcheck':health, 'frontend_ipv4':addresses.get(app)})
if app in ('seerr','unpackerr'):
services[-1]['config_owner'] = 1000
if app == 'unpackerr':
services[-1]['deferred_setup'] = True
if app == 'qbittorrent':
services[-1]['setup_credentials'] = credentials
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':[
translate('Independent LXCs: no main container or hookscript. Each one keeps its own Start with Proxmox setting.'),
f"{translate('Shared host content (not included in LXC backups):')} {shared} -> /data"
if shared else translate('No shared media content.'),
translate('Libraries: /data/media/movies, /data/media/series and /data/media/music. Select them in the media server.'),
translate('Complete the media server and Seerr accounts, the Bazarr providers and the SABnzbd Usenet credentials when they are selected.'),
translate('Seerr/Bazarr connections, the SABnzbd client and the Lidarr profiles, root folder and client are configured manually in this version.'),
translate('Gluetun/VPN not yet available: this suite does not route downloads through a VPN.')
],
'rootfs_storage':storage,'template_storage':cache,'onboot':onboot,'start_after_create':True,
'network':{'frontend_bridge':bridge,'frontend_gateway':gateway,'private_allocation':'automatic','private_bridge':'vmbr10',
'private_subnet':'10.77.0.0/24','private_host_address':'10.77.0.1/24'}}
+989
View File
@@ -0,0 +1,989 @@
from __future__ import annotations
import hashlib
import re
from datetime import date, datetime, timezone
from typing import Any
import yaml
from .converter import (
SENSITIVE_NAME,
SUPPORTED_SERVICE_KEYS,
ConversionError,
_compose_installer_profile,
_compose_option_blockers,
_compose_security_profile,
_compose_runtime_adaptations,
_duration_seconds,
_environment_contract,
_image_contract,
_mount_contract,
_optional_markers,
_port_contract,
_compose_requests_privileged_lxc,
_shm_size_mb,
)
CASAOS_CATEGORY_MAP = {
"AI": ("ai", "AI / Coding & Dev-Tools"),
"Developer": ("ai", "AI / Coding & Dev-Tools"),
"Finance": ("finance", "Finance & Budgeting"),
"Home": ("smarthome", "IoT & Smart Home"),
"Media": ("media", "Media & Streaming"),
"Networking": ("network", "Network & Firewall"),
"Productivity": ("productivity", "Productivity & Workflows"),
"Social": ("communication", "Communication & Community"),
}
ARCHITECTURE_MAP = {
"amd64": "amd64",
"arm64": "arm64",
}
CASAOS_TRANSLATED_SERVICE_KEYS = SUPPORTED_SERVICE_KEYS | {"deploy", "network_mode"}
def normalize_app_id(value: str) -> str:
normalized = re.sub(r"[^a-z0-9]+", "-", value.casefold()).strip("-")
if not normalized:
raise ConversionError(f"No se puede normalizar el identificador CasaOS: {value!r}")
return normalized
def _localized(value: Any, fallback: str = "") -> dict[str, str]:
if isinstance(value, dict):
result = {str(key): str(text) for key, text in value.items() if text not in (None, "")}
if "en_US" not in result:
result["en_US"] = next(iter(result.values()), fallback)
return result
if value not in (None, ""):
return {"en_US": str(value)}
return {"en_US": fallback}
def _json_safe(value: Any) -> Any:
if isinstance(value, dict):
return {str(key): _json_safe(item) for key, item in value.items()}
if isinstance(value, list):
return [_json_safe(item) for item in value]
if isinstance(value, (date, datetime)):
return value.isoformat()
return value
def _main_service(compose: dict[str, Any], metadata: dict[str, Any]) -> tuple[str, dict[str, Any]]:
services = compose.get("services")
if not isinstance(services, dict) or not services:
raise ConversionError("El Compose CasaOS no contiene servicios")
service_name = metadata.get("main")
if not service_name and len(services) == 1:
service_name = next(iter(services))
if not service_name or service_name not in services:
raise ConversionError("x-casaos.main no identifica un servicio valido")
service = services[service_name]
if not isinstance(service, dict) or not service.get("image"):
raise ConversionError("El servicio principal CasaOS no declara una imagen")
return str(service_name), service
def _image_tail(image: str) -> str:
return normalize_app_id(canonical_image_repository(image).rsplit("/", 1)[-1])
def image_repository(image: str) -> str:
reference = image.strip().split("@", 1)[0]
slash = reference.rfind("/")
colon = reference.rfind(":")
return reference[:colon] if colon > slash else reference
def canonical_image_repository(image: str) -> str:
repository = image_repository(image).casefold()
for prefix in ("lscr.io/linuxserver/", "ghcr.io/linuxserver/", "docker.io/linuxserver/"):
if repository.startswith(prefix):
return f"linuxserver/{repository.rsplit('/', 1)[-1]}"
return repository
def latest_image_reference(image: str) -> str:
return f"{image_repository(image)}:latest"
def image_repository_url(image: str) -> str:
repository = image_repository(image)
parts = repository.split("/")
if "." in parts[0] or ":" in parts[0] or parts[0] == "localhost":
registry = parts[0]
path = "/".join(parts[1:])
else:
registry = "docker.io"
path = repository
if registry == "docker.io":
if "/" in path:
return f"https://hub.docker.com/r/{path}"
return f"https://hub.docker.com/_/{path}"
return f"https://{registry}/{path}"
def _variant(app_id: str, service: dict[str, Any]) -> str | None:
folded = app_id.casefold()
names = {
"nvidia": "nvidia",
"cuda": "nvidia",
"amd": "amd",
"rocm": "amd",
"intel": "intel",
"openvino": "intel",
"gpu": "gpu",
}
for marker, variant in names.items():
if re.search(rf"(?:^|[-_]){marker}(?:$|[-_])", folded):
return variant
hardware_text = str(
{
"devices": service.get("devices"),
"runtime": service.get("runtime"),
"environment": service.get("environment"),
"deploy": service.get("deploy"),
}
).casefold()
if "nvidia" in hardware_text or "cuda" in hardware_text:
return "nvidia"
if "rocm" in hardware_text or "/dev/kfd" in hardware_text:
return "amd"
if "openvino" in hardware_text:
return "intel"
if "/dev/dri" in hardware_text or "/dev/video" in hardware_text:
return "vaapi"
reservations = (((service.get("deploy") or {}).get("resources") or {}).get("reservations") or {})
if reservations.get("devices"):
return "gpu"
return None
def _base_app_id(app_id: str) -> str:
return re.sub(r"-(?:nvidia|cuda|amd|rocm|intel|openvino|gpu)$", "", app_id, flags=re.I)
def _functional_base_id(app_id: str, distribution: str) -> str:
base_id = _base_app_id(app_id)
if base_id.startswith("icewhale-"):
base_id = base_id.removeprefix("icewhale-")
if distribution in {"official", "linuxserver"} or "-" not in base_id:
return base_id
prefix, remainder = base_id.split("-", 1)
if len(prefix) >= 5 and (distribution.startswith(prefix) or prefix.startswith(distribution)):
return remainder
return base_id
def image_distributor(image: str, base_app_id: str) -> str:
repository = canonical_image_repository(image)
if repository.startswith("linuxserver/"):
return "linuxserver"
parts = repository.split("/")
if len(parts) == 1:
return "official"
if "." in parts[0] and len(parts) > 1:
owner = parts[1]
else:
owner = parts[0]
owner_id = normalize_app_id(owner)
compact_owner = owner_id.replace("-", "")
compact_app = base_app_id.replace("-", "")
if compact_owner in compact_app or compact_app in compact_owner:
return "official"
return owner_id
def image_provider(distribution: str) -> str:
return "linuxserver.io" if distribution == "linuxserver" else distribution
def image_documentation_url(image: str) -> str | None:
if canonical_image_repository(image).startswith("linuxserver/"):
return f"https://docs.linuxserver.io/images/docker-{_image_tail(image)}/"
return None
def parse_casaos_compose(compose_text: str) -> tuple[dict[str, Any], dict[str, Any], str, dict[str, Any]]:
try:
compose = yaml.safe_load(compose_text)
except yaml.YAMLError as exc:
raise ConversionError(f"Docker Compose CasaOS no valido: {exc}") from exc
if not isinstance(compose, dict):
raise ConversionError("El documento CasaOS no es un objeto Compose")
metadata = compose.get("x-casaos")
if not isinstance(metadata, dict):
raise ConversionError("El Compose no contiene metadatos x-casaos")
service_name, service = _main_service(compose, metadata)
return compose, metadata, service_name, service
def summarize_casaos_compose(compose_text: str, source_path: str) -> dict[str, Any]:
compose, metadata, service_name, service = parse_casaos_compose(compose_text)
app_id = normalize_app_id(str(compose.get("name") or source_path.split("/")[-2]))
title = _localized(metadata.get("title"), app_id)["en_US"]
architectures = []
for raw in metadata.get("architectures") or ["amd64"]:
architecture = ARCHITECTURE_MAP.get(str(raw).casefold())
if architecture and architecture not in architectures:
architectures.append(architecture)
image = str(service["image"])
identity_candidates = {
app_id,
normalize_app_id(service_name),
normalize_app_id(title),
_image_tail(image),
}
store_id = str(metadata.get("id") or "")
if store_id:
identity_candidates.add(normalize_app_id(store_id.rsplit(".", 1)[-1]))
distribution = image_distributor(image, _base_app_id(app_id))
return {
"id": app_id,
"base_id": _functional_base_id(app_id, distribution),
"title": title,
"description": _neutral_localized(metadata.get("description"), "")["en_US"],
"website": metadata.get("website"),
"repository": metadata.get("repo"),
"icon": None,
"architectures": architectures or ["amd64"],
"updated_at": str(metadata.get("update_at") or ""),
"version": str(metadata.get("version") or ""),
"store_id": store_id,
"main_service": service_name,
"main_image": image,
"main_image_repository": canonical_image_repository(image),
"selected_image": latest_image_reference(image),
"variant": _variant(app_id, service),
"distribution": distribution,
"identity_candidates": sorted(identity_candidates),
}
def _endpoint(metadata: dict[str, Any], service: dict[str, Any]) -> list[dict[str, Any]]:
raw_port = metadata.get("port_map")
if raw_port in (None, ""):
return []
try:
published_port = int(str(raw_port))
except ValueError:
return []
container_port = published_port
for item in service.get("ports") or []:
if isinstance(item, dict):
published = item.get("published")
target = item.get("target")
else:
port_text = str(item).split("/", 1)[0]
parts = port_text.split(":")
published = parts[-2] if len(parts) > 1 else None
target = parts[-1]
if str(published) == str(published_port):
try:
container_port = int(str(target))
except ValueError:
pass
break
scheme = str(metadata.get("scheme") or "http").casefold()
if scheme not in {"http", "https"}:
scheme = "http"
path = str(metadata.get("index") or "/")
if re.search(r"casaos|zimaos|zima", path, re.I):
path = "/"
if not path.startswith("/"):
path = f"/{path}"
return [
{
"label": "Web UI",
"scheme": scheme,
"port": container_port,
"path": path,
"source": "compose-metadata",
}
]
def _credentials(metadata: dict[str, Any]) -> list[dict[str, Any]]:
tips = metadata.get("tips") or {}
if not isinstance(tips, dict):
return []
before_install = tips.get("before_install") or {}
text = before_install.get("en_US", "") if isinstance(before_install, dict) else str(before_install)
lines = text.splitlines()
for index, line in enumerate(lines):
cells = [cell.strip().strip("`* ") for cell in line.strip().strip("|").split("|")]
if len(cells) < 2 or "user" not in cells[0].casefold() or "pass" not in cells[1].casefold():
continue
for row in lines[index + 1 :]:
values = [cell.strip().strip("`* ") for cell in row.strip().strip("|").split("|")]
if len(values) < 2:
break
if all(re.fullmatch(r"[-: ]+", value or "-") for value in values[:2]):
continue
username, password = values[:2]
if not username or not password:
continue
dynamic = any(marker in password.casefold() for marker in ("from log", "in the log", "generated"))
if dynamic:
return []
return [
{
"label": "Default login",
"type": "static-default",
"username": username,
"password": password,
"change_required": True,
"source": "casaos-x-casaos-tips",
"retrieval": None,
}
]
return []
def _tips(metadata: dict[str, Any]) -> list[str]:
result: list[str] = []
tips = metadata.get("tips") or {}
if not isinstance(tips, dict):
return result
for value in tips.values():
if isinstance(value, dict):
text = value.get("en_US") or next(iter(value.values()), "")
else:
text = value
if text:
result.append(str(text))
return result
def _neutral_localized(value: Any, fallback: str = "") -> dict[str, str]:
"""The source English of a catalog field, with the upstream product name
neutralised.
Only the source is kept. Copying the English into a second locale when
upstream carried no translation made the field look translated, which is
what stops it from ever being translated. Whatever reaches the reader goes
through `translate()` instead, like every other string in ProxMenux.
"""
english = _localized(value, fallback).get("en_US") or fallback
return {"en_US": re.sub(r"(?:CasaOS|ZimaOS|Zima)", "self-hosted server",
english, flags=re.I)}
def _neutral_scalar(value: Any, fallback: str | None = None) -> str | None:
if value in (None, ""):
return fallback
text = re.sub(r"(?:CasaOS|ZimaOS|Zima)", "", str(value), flags=re.I).strip(" -")
return text or fallback
def _neutralize_discovery_value(value: Any, replacement: str) -> Any:
if isinstance(value, dict):
return {
str(key): item if str(key) == "image" else _neutralize_discovery_value(item, replacement)
for key, item in value.items()
}
if isinstance(value, list):
return [_neutralize_discovery_value(item, replacement) for item in value]
if not isinstance(value, str):
return value
return re.sub(r"(?:CasaOS|ZimaOS|Zima)", replacement, value, flags=re.I)
def _environment_entries(value: Any) -> list[tuple[str, Any]]:
if isinstance(value, dict):
return [(str(name), raw) for name, raw in value.items()]
if isinstance(value, list):
return [
(str(item).partition("=")[0], str(item).partition("=")[2])
for item in value
if str(item).partition("=")[1]
]
return []
def _secret_binding_ids(services: dict[str, Any]) -> dict[tuple[str, str], str]:
records: list[tuple[str, str, str, tuple[str, ...]]] = []
for service_name, service in services.items():
if not isinstance(service, dict):
continue
for name, raw in _environment_entries(service.get("environment")):
if not SENSITIVE_NAME.search(name):
continue
text = str(raw or "")
reference = re.fullmatch(
r"\$\{?([A-Za-z_][A-Za-z0-9_]*)(?::-[^}]*)?\}?", text
)
normalized_name = normalize_app_id(name)
database_password_names = {
"db-password",
"database-password",
"postgres-password",
"postgresql-password",
"mysql-password",
"mariadb-password",
}
family = "database-password" if normalized_name in database_password_names else normalized_name
group = (
("reference", reference.group(1))
if reference
else ("literal", family, text)
)
records.append((str(service_name), name, text, group))
grouped: dict[tuple[str, ...], list[tuple[str, str, str, tuple[str, ...]]]] = {}
for record in records:
grouped.setdefault(record[3], []).append(record)
result: dict[tuple[str, str], str] = {}
for group, members in grouped.items():
if group[0] == "reference":
secret_id = normalize_app_id(group[1])
elif len(members) > 1:
names = [normalize_app_id(member[1]) for member in members]
secret_id = "db-password" if "db-password" in names else min(names, key=len)
else:
secret_id = normalize_app_id(members[0][1])
for service_name, name, _, _ in members:
result[(service_name, name)] = secret_id
return result
def _generated_environment(
value: Any,
replacement: str,
service_name: str,
secret_ids: dict[tuple[str, str], str],
) -> Any:
def secret_placeholder(name: str) -> str:
secret_id = secret_ids[(service_name, name)].replace("-", "_").upper()
return f"${{GENERATED_{secret_id}}}"
if isinstance(value, dict):
result = {}
for name, raw in value.items():
name = str(name)
result[name] = (
secret_placeholder(name)
if SENSITIVE_NAME.search(name)
else _neutralize_discovery_value(raw, replacement)
)
return result
if isinstance(value, list):
result = []
for raw in value:
name, separator, setting = str(raw).partition("=")
if separator and SENSITIVE_NAME.search(name):
result.append(f"{name}={secret_placeholder(name)}")
else:
result.append(_neutralize_discovery_value(raw, replacement))
return result
return _neutralize_discovery_value(value, replacement)
def _normalized_compose(compose: dict[str, Any], project_name: str) -> tuple[dict[str, Any], str]:
normalized = _json_safe(compose)
normalized.pop("x-casaos", None)
secret_ids = _secret_binding_ids(compose.get("services") or {})
for service_name, service in (normalized.get("services") or {}).items():
if not isinstance(service, dict):
continue
service.pop("x-casaos", None)
if service.get("image"):
service["image"] = latest_image_reference(str(service["image"]))
if "environment" in service:
service["environment"] = _generated_environment(
service["environment"], project_name, str(service_name), secret_ids
)
labels = service.get("labels")
if labels:
encoded_labels = str(labels)
if re.search(r"casaos|zimaos|icewhaletech/casaos-appstore", encoded_labels, re.I):
service.pop("labels", None)
normalized = _neutralize_discovery_value(normalized, project_name)
text = yaml.safe_dump(normalized, sort_keys=False, allow_unicode=False)
return normalized, text
def _generated_secrets(services: dict[str, Any]) -> list[dict[str, Any]]:
bindings: dict[str, list[dict[str, str]]] = {}
for service_name, service in services.items():
if not isinstance(service, dict):
continue
environment = service.get("environment") or {}
if isinstance(environment, dict):
entries = [(str(name), str(value or "")) for name, value in environment.items()]
elif isinstance(environment, list):
entries = [
(str(item).partition("=")[0], str(item).partition("=")[2])
for item in environment
]
else:
entries = []
for name, value in entries:
match = re.fullmatch(r"\$\{GENERATED_([A-Z0-9_]+)\}", value)
if match:
bindings.setdefault(match.group(1).casefold().replace("_", "-"), []).append(
{"service": str(service_name), "environment_variable": name}
)
return [
{
"id": secret_id,
"strategy": "generate-cryptographically-random-at-install",
"bindings": secret_bindings,
}
for secret_id, secret_bindings in sorted(bindings.items())
]
def _dependency_names(service: dict[str, Any], service_names: set[str]) -> list[str]:
value = service.get("depends_on") or []
names = value.keys() if isinstance(value, dict) else value
return sorted({str(name) for name in names if str(name) in service_names})
def _service_order(services: dict[str, Any], main_service: str) -> list[str]:
names = set(services)
dependencies = {
str(name): _dependency_names(service, names) if isinstance(service, dict) else []
for name, service in services.items()
}
ordered: list[str] = []
visiting: set[str] = set()
def visit(name: str) -> None:
if name in ordered or name in visiting:
return
visiting.add(name)
for dependency in dependencies[name]:
visit(dependency)
visiting.remove(name)
ordered.append(name)
for name in sorted(names - {main_service}):
visit(name)
visit(main_service)
return ordered
def _stack_storage(services: dict[str, Any], markers: dict[str, set[str]]) -> list[dict[str, Any]]:
storage: list[dict[str, Any]] = []
shared_targets = re.compile(
r"^/(?:data|downloads?|media|movies?|music|photos?|pictures?|recordings?|tv|videos?)(?:/|$)|/(?:library|uploads?)(?:/|$)",
re.I,
)
disposable_targets = {"/cache", "/tmp", "/transcode"}
system_targets = {"/etc/localtime", "/etc/timezone"}
runtime_targets = {"/var/run/docker.sock", "/run/docker.sock"}
for service_name, service in services.items():
if not isinstance(service, dict):
continue
mounts = _mount_contract(service.get("volumes"), markers["volumes"])
for mount in mounts:
target = mount["container_path"]
source = mount.get("compose_source_example")
system_bind = target in system_targets
runtime_bind = target in runtime_targets
shareable = bool(
not system_bind
and not runtime_bind
and source
and str(source).startswith("/")
and shared_targets.search(target)
)
if system_bind:
mode = "system-bind"
elif runtime_bind:
mode = "runtime-bind"
elif shareable:
mode = "host-bind"
else:
mode = "managed-volume"
storage.append(
{
"id": f"{normalize_app_id(str(service_name))}-{mount['id']}",
"service": str(service_name),
"container_path": target,
"mode": mode,
"user_selectable": shareable,
"backup": mode == "managed-volume" and target not in disposable_targets,
"shared_with_other_lxc": shareable,
"source_path": str(source) if system_bind else None,
"source_path_prompt": (
f"Host directory for {service_name}:{target}" if shareable else None
),
}
)
return storage
def _compose_stack_contract(
compose: dict[str, Any],
normalized_compose: dict[str, Any],
main_service: str,
markers: dict[str, set[str]],
) -> dict[str, Any]:
services = compose["services"]
names = set(services)
start_order = _service_order(services, main_service)
service_contracts = []
vmid_offsets = {main_service: 0}
vmid_offsets.update(
{name: offset for offset, name in enumerate((n for n in start_order if n != main_service), 1)}
)
for name in start_order:
value = services[name]
normalized_service = normalized_compose["services"][name]
ports = value.get("ports") or [] if isinstance(value, dict) else []
service_contracts.append(
{
"name": str(name),
"image": latest_image_reference(str(value.get("image")))
if isinstance(value, dict) and value.get("image")
else None,
"is_main": name == main_service,
"role": "frontend" if name == main_service else "dependency",
"vmid_offset": vmid_offsets[name],
"depends_on": _dependency_names(value, names) if isinstance(value, dict) else [],
"frontend_network": bool(name == main_service or ports),
"private_network": len(services) > 1,
"compose": _json_safe(normalized_service),
}
)
return {
"project_name": str(compose.get("name") or main_service),
"deployment_model": "one-native-oci-lxc-per-compose-service",
"user_experience": "single-application-install",
"main_service": main_service,
"service_count": len(services),
"services": service_contracts,
"top_level": _json_safe(
{key: value for key, value in normalized_compose.items() if key != "services"}
),
"networking": {
"frontend": "selected-proxmox-bridge",
"private_required": len(services) > 1,
"private_creation": "automatic-create-if-missing",
"private_address_allocation": "automatic-static-address-per-service",
"service_discovery": "private-addresses-with-compose-service-host-aliases",
"dependency_external_access": "disabled-unless-service-publishes-ports",
"prompt_user_for_private_network": False,
},
"storage": _stack_storage(normalized_compose["services"], markers),
"orchestration": {
"reserve_vmids_atomically": len(services),
"start_order": start_order,
"stop_order": list(reversed(start_order)),
"dependency_readiness": "compose-healthcheck-then-port-or-process-fallback",
"rollback_on_failure": "remove-new-rootfs-preserve-created-persistent-volumes",
},
"installer_inputs": {
"prompted": [
"stack_name",
"base_vmid",
"rootfs_storage",
"persistent_data_destinations",
"frontend_bridge",
"frontend_ipv4_mode",
],
"automatic": [
"dependent_vmids",
"private_bridge",
"private_subnet",
"private_service_addresses",
"compose_service_aliases",
"generated_secrets",
"dependency_start_and_stop_order",
],
"generated_secrets": _generated_secrets(normalized_compose["services"]),
},
}
def _memory_mb(service: dict[str, Any]) -> int:
value = (((service.get("deploy") or {}).get("resources") or {}).get("reservations") or {}).get("memory")
match = re.fullmatch(r"\s*(\d+(?:\.\d+)?)\s*([KMG]?)B?\s*", str(value or ""), re.I)
if not match:
return 1024
number = float(match.group(1))
unit = match.group(2).upper()
factors = {"": 1 / (1024 * 1024), "K": 1 / 1024, "M": 1, "G": 1024}
return max(128, int(number * factors[unit]))
def _blockers(
compose: dict[str, Any],
main_service: str,
optional_devices: set[str] | None = None,
) -> list[str]:
blockers: list[str] = []
services = compose.get("services") or {}
if len(services) > 1:
blockers.append("multi-service-compose")
for name, service in services.items():
if not isinstance(service, dict):
blockers.append(f"service:{name}:invalid-definition")
continue
if not service.get("image"):
blockers.append(f"service:{name}:missing-image")
prefix = "" if name == main_service else f"service:{name}:"
unsupported = set(service) - CASAOS_TRANSLATED_SERVICE_KEYS - {"x-casaos"}
for key in sorted(unsupported):
blockers.append(f"{prefix}compose-key:{key}")
blockers.extend(
f"{prefix}{item}"
for item in _compose_option_blockers(
service,
optional_devices if name == main_service else None,
)
)
for key in ("configs",):
if compose.get(key):
blockers.append(f"top-level-{key}")
return list(dict.fromkeys(blockers))
def convert_casaos_compose(
compose_text: str,
revision: str,
raw_url: str,
source_path: str,
pushed_at: str,
category: str | None = None,
category_label: str | None = None,
catalog_id: str | None = None,
) -> dict[str, Any]:
compose, metadata, service_name, service = parse_casaos_compose(compose_text)
summary = summarize_casaos_compose(compose_text, source_path)
markers = _optional_markers(compose_text)
ports = _port_contract(service.get("ports"), markers["ports"])
endpoints = _endpoint(metadata, service)
primary_endpoint = endpoints[0] if endpoints else None
raw_category = str(metadata.get("category") or "")
fallback_category, fallback_label = CASAOS_CATEGORY_MAP.get(raw_category, ("misc", "Miscellaneous"))
category = category or fallback_category
category_label = category_label or fallback_label
stop_grace_period = service.get("stop_grace_period")
stop_grace_seconds = _duration_seconds(stop_grace_period)
blockers = _blockers(compose, service_name, markers["devices"])
if stop_grace_period is not None and stop_grace_seconds is None:
blockers.append("stop-grace-period-format")
if service.get("shm_size") is not None and _shm_size_mb(service["shm_size"]) is None:
blockers.append("shm-size-format")
services = compose["services"]
untranslated_blockers = blockers + (
["native-multi-lxc-orchestrator-not-yet-implemented"]
if len(services) > 1
else []
)
project_name = normalize_app_id(str(compose.get("name") or summary["id"]))
normalized_compose, normalized_compose_text = _normalized_compose(compose, project_name)
normalized_service = normalized_compose["services"][service_name]
related_services = [
{
"name": str(name),
"image": str(normalized_compose["services"][name].get("image"))
if isinstance(value, dict) and value.get("image")
else None,
}
for name, value in services.items()
if name != service_name
]
definition_hash = hashlib.sha256(normalized_compose_text.encode("utf-8")).hexdigest()
architectures = summary["architectures"]
generated_at = datetime.now(timezone.utc).replace(microsecond=0).isoformat()
source_image = str(service["image"])
image = latest_image_reference(source_image)
repository_url = image_repository_url(source_image)
provider = image_provider(summary["distribution"])
return {
"schema_version": "0.5.0",
"kind": "proxmenux.oci-template",
"id": f"image-{catalog_id or summary['id']}",
"status": (
"generated-unvalidated"
if not untranslated_blockers
else "generated-review-required"
),
"catalog_ui": {
"title": _neutral_localized(metadata.get("title"), summary["title"]),
"tagline": _neutral_localized(metadata.get("tagline"), summary["description"]),
"description": _neutral_localized(metadata.get("description"), summary["description"]),
"category": category,
"category_label": category_label,
"author": _neutral_scalar(metadata.get("developer"), provider),
"developer": _neutral_scalar(metadata.get("developer")),
"icon": None,
"thumbnail": None,
"screenshots": [],
"architectures": architectures,
"launch": {
"scheme": primary_endpoint["scheme"] if primary_endpoint else "http",
"port": primary_endpoint["port"] if primary_endpoint else None,
"path": primary_endpoint["path"] if primary_endpoint else "/",
},
"website": metadata.get("website"),
"documentation": image_documentation_url(source_image),
"repository": repository_url,
"tips": [],
"mini_changelog": [],
"display_version": None,
"updated_at": None,
},
"source": {
"provider": provider,
"repository": repository_url,
"revision": definition_hash,
"image_repository_url": repository_url,
"readme_pushed_at": pushed_at,
"compose_sha256": definition_hash,
"generated_at": generated_at,
},
"container_contract": {
"service_name": service_name,
"container_name": service.get("container_name", service_name),
"image": _image_contract(image),
"environment": [
{**item, "source": "docker-compose"}
for item in _environment_contract(
normalized_service.get("environment"), markers["environment"]
)
],
"volumes": _mount_contract(normalized_service.get("volumes"), markers["volumes"]),
"ports": ports,
"related_services": [
{
"name": item["name"],
"image": item["image"],
}
for item in related_services
],
"restart": service.get("restart"),
"stop_grace_period": None if stop_grace_period is None else str(stop_grace_period),
"original_compose": normalized_compose_text,
},
"compose_stack": _compose_stack_contract(
compose, normalized_compose, service_name, markers
),
"first_run": {"endpoints": endpoints, "credentials": []},
"proxmox": {
"runtime": "native-oci-lxc",
"technology_status": "proxmox-technology-preview",
"defaults": {
"unprivileged": True,
"ostype": "auto-from-image",
"cores": 2,
"memory_mb": _memory_mb(service),
"swap_mb": 512,
"rootfs_size_gb": 8,
"rootfs_storage": "local-lvm",
"volume_storage": "local-lvm",
"template_storage": "local",
"bridge": "vmbr0",
"ipv4": "dhcp",
"firewall": True,
"host_managed_network": True,
"onboot": False,
"features": ["nesting=1"],
"shutdown_timeout_seconds": stop_grace_seconds or 30,
},
"image_metadata_policy": {
"entrypoint": "import-from-oci-image",
"cmd": "import-from-oci-image",
"environment": "import-image-env-then-apply-compose-overrides",
"user": "import-from-oci-image",
"working_dir": "import-from-oci-image",
"stop_signal": "import-from-oci-image",
},
**(
{"installer_profile": _compose_installer_profile(service, markers["devices"], markers["security_opt"])}
if _compose_installer_profile(service, markers["devices"], markers["security_opt"])
else {}
),
**(
{"security_profile": _compose_security_profile(service, markers["security_opt"])}
if _compose_security_profile(service, markers["security_opt"])
else {}
),
"adaptations": [
{
"id": "imported-compose-source",
"upstream_behavior": "The source definition deploys the complete Docker Compose application model.",
"native_lxc_behavior": "The source model is preserved and remains blocked until every service option has a reviewed native Proxmox mapping.",
"reason": "Catalog import must not imply runtime compatibility.",
"behavioral_impact": "No automatic installation before review.",
"validation": "pending-per-application",
},
{
"id": "rolling-latest-image",
"upstream_behavior": "A discovered Compose may pin a release tag or digest.",
"native_lxc_behavior": "ProxMenux selects the same image repository with the latest tag for catalog installations.",
"reason": "The automatic catalog intentionally offers rolling latest images; pinned versions belong to the future manual installer.",
"behavioral_impact": "The installed release can be newer than the discovered Compose revision.",
"validation": "pending-per-application",
},
{
"id": "dedicated-lxc-network",
"upstream_behavior": "Docker publishes selected container ports on the Docker host.",
"native_lxc_behavior": "A reviewed native application will listen on its original container ports at a dedicated LXC address.",
"reason": "A native LXC has its own address and does not require Docker port NAT.",
"behavioral_impact": "Published ports are metadata; ProxMenux URLs use the matching container target port.",
"validation": "pending-per-application",
},
{
"id": "compose-shm-size",
"upstream_behavior": "Compose sets the size of the container /dev/shm tmpfs.",
"native_lxc_behavior": "ProxMenux mounts a native LXC tmpfs at /dev/shm with the same requested capacity.",
"reason": "The OCI image runs directly as an LXC and therefore needs the equivalent Proxmox mount entry.",
"behavioral_impact": "None expected.",
"validation": "pending-per-application" if service.get("shm_size") is not None else "not-requested-by-compose",
},
{
"id": "compose-command",
"upstream_behavior": "Compose replaces the image Cmd while retaining its Entrypoint.",
"native_lxc_behavior": "ProxMenux reads the official OCI Entrypoint and combines it with the Compose command as the native LXC init command.",
"reason": "Proxmox stores the effective OCI process as one entrypoint string.",
"behavioral_impact": "None expected.",
"validation": "pending-per-application" if service.get("command") is not None else "not-requested-by-compose",
},
{
"id": "compose-privileged-mode",
"upstream_behavior": "Compose selects whether the container runs in privileged mode.",
"native_lxc_behavior": "ProxMenux keeps the LXC unprivileged by default and exposes the broad Compose privilege request only as an explicit compatibility option.",
"reason": "A Compose privilege request is source metadata, not proof that the image technically requires a privileged LXC.",
"behavioral_impact": "A privileged LXC has weaker isolation from the Proxmox host.",
"validation": "optional-explicit-user-consent" if _compose_requests_privileged_lxc(service) else "native-equivalent",
},
*_compose_runtime_adaptations(service),
],
},
"compatibility": {
"automatic_install_candidate": not untranslated_blockers,
"validated": False,
"supported_compose_keys": sorted(CASAOS_TRANSLATED_SERVICE_KEYS),
"untranslated_blockers": untranslated_blockers,
"policy": "Single-image definitions are installable when every declared Compose option has a native Proxmox translation. Multi-image and unsupported runtime features remain blocked until their orchestrator or mapping is available.",
},
"validation": {
"schema": "passed-at-generation",
"clean_install": "pending",
"service_health": "pending",
"restart_persistence": "pending",
"backup_restore": "pending",
"update_preserves_data": "pending",
},
"lifecycle": {
"update_strategy": "resolve-latest-image-then-apply-reviewed-native-lxc-update",
"registry_state": {
"resolved_architecture": None,
"resolved_digest": None,
"image_version_label": None,
"image_created": None,
},
"change_detection": "compare-compose-sha256-and-resolved-latest-image-digest",
"automatic_unattended_updates": False,
},
}
+762
View File
@@ -0,0 +1,762 @@
from __future__ import annotations
import json
import hashlib
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from .casaos import (
canonical_image_repository,
convert_casaos_compose,
image_provider,
image_repository_url,
normalize_app_id,
summarize_casaos_compose,
)
from .converter import ConversionError, convert_readme, summarize_readme
from .github_source import GitHubSource, Repository, SourceError
SUPPORTED_CATALOG_ARCHITECTURES = ("amd64", "arm64")
# Size of a container volume the installation does not ask about: its size is
# not the user's decision, so it is given room to grow.
MINIMUM_VOLUME_GB = 16
def supported_architectures(values: list[str]) -> list[str]:
return [architecture for architecture in SUPPORTED_CATALOG_ARCHITECTURES if architecture in values]
def imported_catalog_id(summary: dict[str, Any], used_ids: set[str]) -> str:
if summary.get("variant"):
candidate = f"{summary['base_id']}-{summary['variant']}"
elif summary["base_id"] in used_ids:
candidate = f"{summary['base_id']}-{summary['distribution']}"
else:
candidate = summary["id"]
if candidate in used_ids:
candidate = f"{candidate}-{summary['distribution']}"
base_candidate = candidate
suffix = 2
while candidate in used_ids:
candidate = f"{base_candidate}-{suffix}"
suffix += 1
return candidate
MULTI_CONTAINER_TEMPLATES = {"image-immich", "image-nextcloud-stack", "image-paperless-ngx", "image-tandoor"}
def is_multi_container(template: dict[str, Any]) -> bool:
driver = template.get("proxmox", {}).get("installer_profile", {}).get("stack_driver")
return template.get("id") in MULTI_CONTAINER_TEMPLATES or driver in ("generic-multi-lxc-stack", "arr-suite")
class Catalog:
def __init__(self, root: Path, source: GitHubSource | None = None) -> None:
self.root = root
self.catalog_dir = root / "catalog"
self.apps_dir = self.catalog_dir / "apps"
self.curated_dir = self.catalog_dir / "curated"
self.overlays_dir = self.catalog_dir / "overlays"
self.exclusions_path = self.catalog_dir / "exclusions.json"
self.categories_path = self.catalog_dir / "categories.json"
self._categories: dict[str, Any] | None = None
self.volume_policy_path = self.catalog_dir / "volume-policy.json"
self._volume_policy: dict[str, Any] | None = None
self.index_path = self.catalog_dir / "index.json"
self.schema_path = root / "schemas" / "oci-template.schema.json"
self.source = source or GitHubSource()
def sync_index(self) -> dict[str, Any]:
repos = self.source.list_linuxserver_repositories()
try:
proxmenux_metadata = self.source.proxmenux_app_metadata()
except (AttributeError, SourceError, OSError, RuntimeError):
proxmenux_metadata = {}
existing = self._existing_template_statuses()
discovered: list[tuple[Repository, dict[str, Any]]] = []
linuxserver_skipped: list[dict[str, str]] = []
def inspect(repo: Repository) -> tuple[Repository, dict[str, Any]]:
readme = self.source.readme_at_branch(repo)
return repo, summarize_readme(repo, readme)
with ThreadPoolExecutor(max_workers=8) as executor:
futures = {executor.submit(inspect, repo): repo for repo in repos}
for future in as_completed(futures):
repo = futures[future]
try:
discovered.append(future.result())
except (ConversionError, OSError, RuntimeError) as exc:
linuxserver_skipped.append({"repository": repo.name, "reason": str(exc)})
discovered.sort(key=lambda item: item[0].app_id.casefold())
linuxserver_items = [
{
"id": repo.app_id,
"provider": "linuxserver.io",
"title": summary["title"],
"repository_name": repo.name,
"repository": repo.html_url,
"description": summary["description"],
"website": summary["website"],
"icon": summary["icon"],
"architectures": supported_architectures(summary["architectures"]),
"default_branch": repo.default_branch,
"pushed_at": repo.pushed_at,
"updated_at": summary["updated_at"],
"main_image": summary["main_image"],
"category": proxmenux_metadata.get(repo.app_id, {}).get("category", "misc"),
"category_label": proxmenux_metadata.get(repo.app_id, {}).get(
"category_label", "Miscellaneous"
),
"template": f"apps/{repo.app_id}.json" if repo.app_id in existing else None,
"template_status": existing.get(repo.app_id),
"content_hash": self._template_hash(repo.app_id) if repo.app_id in existing else None,
}
for repo, summary in discovered
]
curated_items = self._curated_items(existing)
casaos_state = self.source.casaos_state()
casaos_discovered: list[tuple[str, dict[str, Any]]] = []
casaos_skipped: list[dict[str, str]] = []
def inspect_casaos(path: str) -> tuple[str, dict[str, Any]]:
compose_text, _ = self.source.casaos_compose(path, casaos_state["revision"])
return path, summarize_casaos_compose(compose_text, path)
with ThreadPoolExecutor(max_workers=8) as executor:
futures = {executor.submit(inspect_casaos, path): path for path in casaos_state["paths"]}
for future in as_completed(futures):
path = futures[future]
try:
casaos_discovered.append(future.result())
except (ConversionError, OSError, RuntimeError) as exc:
casaos_skipped.append({"path": path, "reason": str(exc)})
casaos_discovered.sort(key=lambda item: item[1]["id"])
imported_exclusions = self._imported_exclusions()
excluded_imports: list[dict[str, str]] = []
linuxserver_images = {
canonical_image_repository(item["main_image"]): item["id"] for item in linuxserver_items
}
duplicates: list[dict[str, Any]] = []
casaos_items: list[dict[str, Any]] = []
used_ids = {item["id"] for item in linuxserver_items + curated_items}
curated_replacements = {
source_id: item["id"]
for item in curated_items
for source_id in item.get("replaces_discovered_ids", [])
}
for path, summary in casaos_discovered:
if summary["id"] in imported_exclusions:
excluded_imports.append(
{
"source_id": summary["id"],
"source_path": path,
"reason": imported_exclusions[summary["id"]],
}
)
continue
replaced_by = curated_replacements.get(summary["id"])
if replaced_by:
duplicates.append(
{
"source_id": summary["id"],
"source_path": path,
"main_image": summary["main_image"],
"matched_catalog_id": replaced_by,
"reason": "replaced-by-curated-laboratory-profile",
}
)
continue
matched_linuxserver = linuxserver_images.get(summary["main_image_repository"])
if matched_linuxserver and summary["variant"] is None:
duplicates.append(
{
"source_id": summary["id"],
"source_path": path,
"main_image": summary["main_image"],
"matched_catalog_id": matched_linuxserver,
"reason": "same-linuxserver-image-without-distinct-deployment-variant",
}
)
continue
catalog_id = imported_catalog_id(summary, used_ids)
used_ids.add(catalog_id)
metadata = proxmenux_metadata.get(summary["id"], {})
if not metadata.get("category") and catalog_id in existing:
# The upstream metadata is keyed by the source application id
# and does not always carry a category, while the generated
# template has already resolved one. Without this the entry
# reaches the index under no category at all and the reader
# cannot find it by browsing.
try:
generated_ui = json.loads(
(self.apps_dir / f"{catalog_id}.json").read_text(encoding="utf-8")
)["catalog_ui"]
metadata = {
**metadata,
"category": generated_ui.get("category"),
"category_label": generated_ui.get("category_label"),
}
except (OSError, ValueError, KeyError):
pass
casaos_items.append(
{
"id": catalog_id,
"source_app_id": summary["id"],
"provider": image_provider(summary["distribution"]),
"template_family": "imported-compose",
"variant": summary["variant"],
"title": summary["title"],
"repository": image_repository_url(summary["main_image"]),
"description": summary["description"],
"website": summary["website"],
"icon": summary["icon"],
"architectures": supported_architectures(summary["architectures"]),
"default_branch": "main",
"source_path": path,
"source_revision": casaos_state["revision"],
"pushed_at": casaos_state["pushed_at"],
"updated_at": summary["updated_at"],
"main_image": summary["selected_image"],
"category": metadata.get("category"),
"category_label": metadata.get("category_label"),
"template": f"apps/{catalog_id}.json" if catalog_id in existing else None,
"template_status": existing.get(catalog_id),
"content_hash": self._template_hash(catalog_id) if catalog_id in existing else None,
}
)
applications = sorted(
linuxserver_items + curated_items + casaos_items,
key=lambda item: item["id"].casefold(),
)
payload = {
"schema_version": "0.2.0",
"kind": "proxmenux.oci-catalog-index",
"provider": "multiple-container-images",
"generated_at": datetime.now(timezone.utc).replace(microsecond=0).isoformat(),
"applications": applications,
"discovery": {
"linuxserver": {
"repositories_examined": len(repos),
"compose_applications": len(discovered),
"skipped_count": len(linuxserver_skipped),
"skipped": sorted(linuxserver_skipped, key=lambda item: item["repository"]),
},
"imported_composes": {
"compose_applications": len(casaos_discovered),
"included_count": len(casaos_items),
"duplicate_count": len(duplicates),
"duplicates": duplicates,
"excluded_count": len(excluded_imports),
"excluded": excluded_imports,
"skipped_count": len(casaos_skipped),
"skipped": sorted(casaos_skipped, key=lambda item: item["path"]),
},
"curated_profiles": {"included_count": len(curated_items)},
},
}
self._enrich_index_from_templates(payload)
self.catalog_dir.mkdir(parents=True, exist_ok=True)
self._write_json(self.index_path, payload)
return payload
def _imported_exclusions(self) -> dict[str, str]:
if not self.exclusions_path.exists():
return {}
payload = json.loads(self.exclusions_path.read_text(encoding="utf-8"))
return {
str(item["id"]): str(item["reason"])
for item in payload.get("imported_applications", [])
}
def load_index(self) -> dict[str, Any]:
if not self.index_path.exists():
return self.sync_index()
payload = json.loads(self.index_path.read_text(encoding="utf-8"))
self._enrich_index_from_templates(payload)
return payload
def categories(self) -> dict[str, Any]:
"""Category labels and the per-application corrections of categories.json."""
if self._categories is None:
try:
data = json.loads(self.categories_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
data = {}
self._categories = {"labels": data.get("labels", {}), "applications": data.get("applications", {})}
return self._categories
def _apply_category(self, app_id: str, ui: dict[str, Any]) -> None:
data = self.categories()
key = data["applications"].get(app_id) or ui.get("category") or "misc"
ui["category"] = key
ui["category_label"] = data["labels"].get(key) or ui.get("category_label") or key
def volume_policy(self) -> dict[str, Any]:
"""Paths of volume-policy.json that hold content of the user."""
if self._volume_policy is None:
try:
data = json.loads(self.volume_policy_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
data = {}
self._volume_policy = {"shared_paths": set(data.get("shared_paths", [])),
"applications": data.get("applications", {})}
return self._volume_policy
def _apply_volume_policy(self, app_id: str, contract: dict[str, Any]) -> None:
"""A path that holds content of the user is offered as a container
volume or as a host directory, and the installation asks which one; the
state of the application stays in a container volume without asking."""
policy = self.volume_policy()
shared = policy["shared_paths"] | set(policy["applications"].get(app_id, []))
for volume in contract.get("volumes", []):
choices = volume.get("installation_choice", [])
if "managed-volume" not in choices:
continue
optional = "skip" in choices
if volume["container_path"] in shared or volume.get("default") == "host-bind":
volume["installation_choice"] = ["managed-volume", "host-bind"] + (["skip"] if optional else [])
continue
volume["installation_choice"] = ["managed-volume"] + (["skip"] if optional else [])
if volume.get("default") not in volume["installation_choice"]:
volume["default"] = "managed-volume"
managed = volume.get("managed_volume")
if isinstance(managed, dict):
managed["default_size_gb"] = max(int(managed.get("default_size_gb") or 0), MINIMUM_VOLUME_GB)
def _enrich_index_from_templates(self, payload: dict[str, Any]) -> None:
for item in payload.get("applications", []):
overlay_path = self.overlays_dir / f"{item['id']}.json"
overlay_ui = json.loads(overlay_path.read_text(encoding="utf-8")).get("catalog_ui", {}) if overlay_path.exists() else {}
item["hidden"] = overlay_ui.get("hidden", False)
path = self.apps_dir / f"{item['id']}.json"
if not path.exists():
item["architectures"] = supported_architectures(
item.get("architectures", [])
)
continue
try:
template = json.loads(path.read_text(encoding="utf-8"))
compatibility = template["compatibility"]
ui = template["catalog_ui"]
item["hidden"] = overlay_ui.get("hidden", ui.get("hidden", False))
item["template_status"] = template["status"]
item["automatic_install_candidate"] = compatibility[
"automatic_install_candidate"
]
item["untranslated_blockers"] = compatibility[
"untranslated_blockers"
]
item["requires_privileged_lxc"] = bool(
template.get("proxmox", {})
.get("security_profile", {})
.get("requires_privileged_lxc")
)
item["optional_privileged_lxc"] = bool(
template.get("proxmox", {})
.get("security_profile", {})
.get("optional_privileged_lxc")
)
item["requires_host_pid_namespace"] = bool(
template.get("proxmox", {})
.get("security_profile", {})
.get("requires_host_pid_namespace")
)
item["requires_relaxed_confinement"] = bool(
template.get("proxmox", {})
.get("security_profile", {})
.get("requires_relaxed_confinement")
)
item["optional_relaxed_confinement"] = bool(
template.get("proxmox", {})
.get("security_profile", {})
.get("optional_relaxed_confinement")
)
item["requires_security_confirmation"] = bool(
template.get("proxmox", {})
.get("security_profile", {})
.get("confirmation_required")
)
item["architectures"] = supported_architectures(
overlay_ui.get("architectures", ui["architectures"]))
self._apply_category(item["id"], ui)
item["category"] = ui["category"]
item["category_label"] = ui["category_label"]
item["multi_container"] = is_multi_container(template)
except (OSError, json.JSONDecodeError, KeyError, TypeError):
item["automatic_install_candidate"] = False
item["untranslated_blockers"] = ["invalid-generated-template"]
def find_repo(self, app_id: str) -> Repository:
item = self.find_item(app_id)
if item.get("provider", "linuxserver.io") == "linuxserver.io":
metadata = {
"category": item.get("category"),
"category_label": item.get("category_label"),
}
if not metadata["category"]:
try:
metadata.update(self.source.proxmenux_app_metadata().get(app_id, {}))
except (AttributeError, SourceError, OSError, RuntimeError):
pass
return self._repository_from_item(item, metadata)
raise ConversionError(f"The application '{app_id}' does not come from LinuxServer")
def find_item(self, app_id: str) -> dict[str, Any]:
folded = app_id.casefold()
for item in self.load_index()["applications"]:
if item["id"].casefold() == folded:
return item
raise ConversionError(f"The application '{app_id}' is not in the index")
@staticmethod
def _repository_from_item(
item: dict[str, Any],
metadata: dict[str, Any] | None = None,
) -> Repository:
metadata = metadata or {}
return Repository(
name=item["repository_name"],
description=item.get("description") or "",
default_branch=item.get("default_branch") or "master",
html_url=item["repository"],
pushed_at=item.get("pushed_at") or "",
category=metadata.get("category") or item.get("category") or "misc",
category_label=metadata.get("category_label") or item.get("category_label") or "Miscellaneous",
)
def _preserve_registry_state(self, app_id: str, template: dict[str, Any]) -> None:
existing_path = self.apps_dir / f"{app_id}.json"
if not existing_path.exists():
return
try:
existing = json.loads(existing_path.read_text(encoding="utf-8"))
state = existing.get("lifecycle", {}).get("registry_state", {})
if not state.get("resolved_digest"):
return
template["lifecycle"]["registry_state"] = state
template["catalog_ui"]["display_version"] = existing.get("catalog_ui", {}).get("display_version")
except (OSError, json.JSONDecodeError, KeyError, TypeError):
return
def generate(self, app_id: str) -> tuple[Path, dict[str, Any]]:
item = self.find_item(app_id)
provider = item.get("provider", "linuxserver.io")
if item.get("template_family", "linuxserver-readme") == "linuxserver-readme":
repo = self._repository_from_item(item)
readme, revision, raw_url = self.source.readme(repo)
template = convert_readme(repo, readme, revision, raw_url)
elif item.get("template_family") == "imported-compose":
revision = item["source_revision"]
compose, raw_url = self.source.casaos_compose(item["source_path"], revision)
template = convert_casaos_compose(
compose,
revision,
raw_url,
item["source_path"],
item.get("pushed_at") or "",
item.get("category"),
item.get("category_label"),
item["id"],
)
elif item.get("template_family") == "curated-profile":
template = json.loads((self.root / item["curated_path"]).read_text(encoding="utf-8"))
else:
raise ConversionError(f"Proveedor no soportado: {provider}")
catalog_id = item["id"]
self._apply_overlay(catalog_id, template)
self._preserve_registry_state(catalog_id, template)
self.validate(template)
self.apps_dir.mkdir(parents=True, exist_ok=True)
destination = self.apps_dir / f"{catalog_id}.json"
self._write_json(destination, template)
self._update_index_template(catalog_id, template["status"])
return destination, template
def generate_all(self, progress: Any | None = None, provider: str = "all") -> dict[str, Any]:
index = self.load_index()
applications = [
item
for item in index["applications"]
if provider == "all"
or (provider == "imported" and item.get("template_family") == "imported-compose")
or (provider == "curated" and item.get("template_family") == "curated-profile")
or item.get("provider", "linuxserver.io") == provider
]
if any(item.get("template_family", "linuxserver-readme") == "linuxserver-readme" for item in applications) and not self.source.token:
raise ConversionError(
"Bulk generation requires GITHUB_TOKEN to obtain an immutable revision per application"
)
generated: list[str] = []
failed: list[dict[str, str]] = []
templates: dict[str, dict[str, Any]] = {}
def convert(item: dict[str, Any]) -> tuple[str, dict[str, Any]]:
item_provider = item.get("provider", "linuxserver.io")
if item.get("template_family", "linuxserver-readme") == "linuxserver-readme":
repo = self._repository_from_item(item)
readme, revision, raw_url = self.source.readme(repo)
template = convert_readme(repo, readme, revision, raw_url)
elif item.get("template_family") == "imported-compose":
revision = item["source_revision"]
compose, raw_url = self.source.casaos_compose(item["source_path"], revision)
template = convert_casaos_compose(
compose,
revision,
raw_url,
item["source_path"],
item.get("pushed_at") or "",
item.get("category"),
item.get("category_label"),
item["id"],
)
elif item.get("template_family") == "curated-profile":
template = json.loads(
(self.root / item["curated_path"]).read_text(encoding="utf-8")
)
else:
raise ConversionError(f"Proveedor no soportado: {item_provider}")
self._apply_overlay(item["id"], template)
self._preserve_registry_state(item["id"], template)
self.validate(template)
return item["id"], template
completed = 0
with ThreadPoolExecutor(max_workers=8) as executor:
futures = {executor.submit(convert, item): item for item in applications}
for future in as_completed(futures):
item = futures[future]
completed += 1
try:
app_id, template = future.result()
templates[app_id] = template
generated.append(app_id)
outcome = "ok"
except (ConversionError, OSError, RuntimeError) as exc:
failed.append({"id": item["id"], "reason": str(exc)})
outcome = "error"
if progress:
progress(completed, len(applications), item["id"], outcome)
self.apps_dir.mkdir(parents=True, exist_ok=True)
index_items = {item["id"]: item for item in applications}
for app_id in sorted(templates):
template = templates[app_id]
self._write_json(self.apps_dir / f"{app_id}.json", template)
item = index_items[app_id]
item["template"] = f"apps/{app_id}.json"
item["template_status"] = template["status"]
item["automatic_install_candidate"] = template["compatibility"][
"automatic_install_candidate"
]
item["untranslated_blockers"] = template["compatibility"][
"untranslated_blockers"
]
item["requires_privileged_lxc"] = bool(
template.get("proxmox", {})
.get("security_profile", {})
.get("requires_privileged_lxc")
)
item["optional_privileged_lxc"] = bool(
template.get("proxmox", {})
.get("security_profile", {})
.get("optional_privileged_lxc")
)
item["requires_host_pid_namespace"] = bool(
template.get("proxmox", {})
.get("security_profile", {})
.get("requires_host_pid_namespace")
)
item["requires_relaxed_confinement"] = bool(
template.get("proxmox", {})
.get("security_profile", {})
.get("requires_relaxed_confinement")
)
item["optional_relaxed_confinement"] = bool(
template.get("proxmox", {})
.get("security_profile", {})
.get("optional_relaxed_confinement")
)
item["requires_security_confirmation"] = bool(
template.get("proxmox", {})
.get("security_profile", {})
.get("confirmation_required")
)
item["architectures"] = supported_architectures(
template["catalog_ui"]["architectures"]
)
item["content_hash"] = self._template_hash(app_id)
self._write_json(self.index_path, index)
generated.sort()
failed.sort(key=lambda item: item["id"])
report = {
"generated": generated,
"generated_count": len(generated),
"failed": failed,
"failed_count": len(failed),
}
report_name = "generation-report.json" if provider == "all" else f"generation-report-{provider}.json"
self._write_json(self.catalog_dir / report_name, report)
return report
def validate(self, template: dict[str, Any], required: bool = True) -> None:
# Templates ship already validated; on a node without python3-jsonschema
# the installer skips the check instead of adding a package to the host.
try:
from jsonschema import Draft202012Validator
except ImportError:
if required:
raise ConversionError("python3-jsonschema is required to generate templates")
return
schema = json.loads(self.schema_path.read_text(encoding="utf-8"))
errors = sorted(Draft202012Validator(schema).iter_errors(template), key=lambda error: list(error.path))
if errors:
details = "; ".join(f"{'/'.join(map(str, error.path))}: {error.message}" for error in errors)
raise ConversionError(f"La plantilla generada no cumple el esquema: {details}")
def compose(self, app_id: str) -> dict[str, Any]:
"""The installable template, built only from the files shipped with
ProxMenux: the curated profile or the pre-generated template, with its
overlay applied. Nothing is downloaded and nothing is written."""
item = self.find_item(app_id)
if item.get("template_family") == "curated-profile":
path = self.root / item["curated_path"]
else:
path = self.apps_dir / f"{item['id']}.json"
if not path.exists():
raise ConversionError(f"No template is available for '{app_id}'")
template = json.loads(path.read_text(encoding="utf-8"))
self._apply_overlay(item["id"], template)
self._apply_category(item["id"], template["catalog_ui"])
self._apply_volume_policy(item["id"], template["container_contract"])
self.validate(template, required=False)
return template
def load_template(self, app_id: str, generate_if_missing: bool = True) -> dict[str, Any]:
path = self.apps_dir / f"{app_id}.json"
if not path.exists() and generate_if_missing:
_, template = self.generate(app_id)
return template
template = json.loads(path.read_text(encoding="utf-8"))
self.validate(template)
return template
def _existing_template_statuses(self) -> dict[str, str]:
result: dict[str, str] = {}
if not self.apps_dir.exists():
return result
for path in self.apps_dir.glob("*.json"):
try:
payload = json.loads(path.read_text(encoding="utf-8"))
result[path.stem] = payload.get("status", "unknown")
except (OSError, json.JSONDecodeError):
result[path.stem] = "invalid"
return result
def _curated_items(self, existing: dict[str, str]) -> list[dict[str, Any]]:
result: list[dict[str, Any]] = []
if not self.curated_dir.exists():
return result
for path in sorted(self.curated_dir.glob("*.json")):
template = json.loads(path.read_text(encoding="utf-8"))
self.validate(template)
app_id = template["id"].removeprefix("image-")
ui = template["catalog_ui"]
result.append(
{
"id": app_id,
"provider": template["source"]["provider"],
"template_family": "curated-profile",
"title": ui["title"].get("en_US") or app_id,
"repository": template["source"]["repository"],
"description": ui["description"].get("en_US") or "",
"website": ui.get("website"),
"icon": ui.get("icon"),
"architectures": supported_architectures(ui["architectures"]),
"updated_at": ui.get("updated_at"),
"main_image": template["container_contract"]["image"]["reference"],
"category": ui["category"],
"category_label": ui.get("category_label"),
"replaces_discovered_ids": template.get("proxmox", {})
.get("catalog", {})
.get("replaces_discovered_ids", []),
"curated_path": str(path.relative_to(self.root)),
"template": f"apps/{app_id}.json" if app_id in existing else None,
"template_status": existing.get(app_id),
"content_hash": self._template_hash(app_id) if app_id in existing else None,
}
)
return result
def _apply_overlay(self, app_id: str, template: dict[str, Any]) -> None:
path = self.overlays_dir / f"{app_id}.json"
if path.exists():
overlay = json.loads(path.read_text(encoding="utf-8"))
self._deep_merge(template, overlay)
from .stack import apply_stack_support
apply_stack_support(template)
from .gpu import apply_gpu_contract
apply_gpu_contract(template)
@classmethod
def _deep_merge(cls, target: dict[str, Any], overlay: dict[str, Any]) -> None:
for key, value in overlay.items():
if isinstance(value, dict) and isinstance(target.get(key), dict):
cls._deep_merge(target[key], value)
else:
target[key] = value
def _update_index_template(self, app_id: str, status: str) -> None:
index = self.load_index()
template = json.loads((self.apps_dir / f"{app_id}.json").read_text(encoding="utf-8"))
ui = template["catalog_ui"]
for item in index["applications"]:
if item["id"] == app_id:
item.update(
{
"provider": template["source"]["provider"],
"title": ui["title"].get("en_US") or app_id,
"repository": template["source"]["repository"],
"description": ui["description"].get("en_US") or "",
"website": ui.get("website"),
"icon": ui.get("icon"),
"architectures": supported_architectures(ui["architectures"]),
"updated_at": ui.get("updated_at"),
"main_image": template["container_contract"]["image"]["reference"],
"category": ui["category"],
"category_label": ui.get("category_label"),
"template": f"apps/{app_id}.json",
"template_status": status,
"automatic_install_candidate": template["compatibility"][
"automatic_install_candidate"
],
"untranslated_blockers": template["compatibility"][
"untranslated_blockers"
],
"content_hash": self._template_hash(app_id),
}
)
if item.get("template_family") == "curated-profile":
item["replaces_discovered_ids"] = (
template.get("proxmox", {})
.get("catalog", {})
.get("replaces_discovered_ids", [])
)
break
self._write_json(self.index_path, index)
@staticmethod
def _write_json(path: Path, payload: dict[str, Any]) -> None:
temporary = path.with_suffix(path.suffix + ".tmp")
temporary.write_text(json.dumps(payload, ensure_ascii=True, indent=2) + "\n", encoding="utf-8")
temporary.replace(path)
def _template_hash(self, app_id: str) -> str:
return hashlib.sha256((self.apps_dir / f"{app_id}.json").read_bytes()).hexdigest()
+622
View File
@@ -0,0 +1,622 @@
"""OCI manager Apps: catalog menus, installation flow and maintenance commands."""
from __future__ import annotations
import argparse
import json
import sys
import textwrap
import unicodedata
from pathlib import Path
from typing import Any
from . import console, images
from .catalog import Catalog
from .converter import ConversionError
from .github_source import SourceError
from .i18n import N_, source_text, translate
from .installer import (
ADVANCED_MODE,
DEFAULT_MODE,
InstallError,
build_deployment,
build_rclone_mount_deployment,
redacted,
run_remote_install,
run_remote_rclone_mount,
)
from .ui import APP_TITLE, UserCancelled, interactive_ui
PROJECT_ROOT = Path(__file__).resolve().parents[2]
DISPLAY_ARCHITECTURES = ("amd64", "arm64")
PUBLISHERS = {"linuxserver.io": "LinuxServer", "official": N_("Official image")}
STACK_LABELS = {
"server": N_("Server"),
"application": N_("Application"),
"machine_learning": N_("Machine learning"),
"database": "PostgreSQL",
"valkey": "Valkey",
"cache": "Redis",
"paperless": "Paperless-ngx",
"broker": "Valkey",
"tandoor": "Tandoor",
}
def _display_architectures(item: dict[str, Any]) -> str:
supported = [a for a in DISPLAY_ARCHITECTURES if a in item.get("architectures", [])]
return "/".join(supported) or "?"
def publisher(item: dict[str, Any]) -> str:
provider = str(item.get("provider") or "")
app_id = str(item.get("id") or "").casefold()
# A project that publishes its own image (immich, frigate, nextcloud...) is its official image.
if provider == "official" or (provider and app_id.startswith(provider.casefold())):
return translate(PUBLISHERS["official"])
return PUBLISHERS.get(provider, provider or "?")
def is_tested(item: dict[str, Any]) -> bool:
return item.get("template_status") == "laboratory-validated"
MENU_SIZE = (22, 75, 15)
MULTI_LABEL = r"\Z4MULTI\Zn"
TESTED_LABEL = r"\Z2✓\Zn"
def _installable(applications: list[dict[str, Any]]) -> list[dict[str, Any]]:
return [item for item in applications if not item.get("hidden") and item.get("automatic_install_candidate")]
NAME_WIDTH, ARCH_WIDTH, SOURCE_WIDTH = 26, 12, 14
ROW_WIDTH = 72
def _app_menu(applications: list[dict[str, Any]], keep_order: bool = False
) -> tuple[list[tuple[str, str]], dict[str, dict[str, Any]], str]:
"""Numbered rows in the Helper Scripts layout (name, architecture, source,
verified) and the column header aligned with them."""
options: list[tuple[str, str]] = []
index: dict[str, dict[str, Any]] = {}
ordered = applications if keep_order else sorted(
applications, key=lambda entry: str(entry.get("title") or entry["id"]).casefold())
for number, item in enumerate(ordered, 1):
title = str(item.get("title") or item["id"])
name = " ".join("".join(ch for ch in title if unicodedata.category(ch) != "So").split())
if item.get("multi_container"):
name = name[:NAME_WIDTH - 6]
cell = f"{name} {MULTI_LABEL}" + " " * (NAME_WIDTH - len(name) - 6)
else:
cell = f"{name[:NAME_WIDTH]:<{NAME_WIDTH}}"
row = f"{cell} {_display_architectures(item):<{ARCH_WIDTH}} {publisher(item)[:SOURCE_WIDTH]:<{SOURCE_WIDTH}}"
if is_tested(item):
row += f" {TESTED_LABEL}"
# Rows as wide as the list: dialog centers narrower lists, which would move them off the header.
options.append((str(number), f"{row:<{ROW_WIDTH}}"))
index[str(number)] = item
# dialog draws the rows after the list border and the tag column.
offset = " " * (len(str(len(ordered))) + 3)
header = (f"{offset}{translate('Name')[:NAME_WIDTH]:<{NAME_WIDTH}} "
f"{translate('Architecture')[:ARCH_WIDTH]:<{ARCH_WIDTH}} "
f"{translate('Source')[:SOURCE_WIDTH]:<{SOURCE_WIDTH}} {translate('Verified')}")
return options, index, header
def _yes_no(value: Any) -> str:
return translate("yes") if value else translate("no")
# ---------------------------------------------------------------- summaries
DETAIL_WIDTH = 92
SERVICE_KINDS = (("postgres", "PostgreSQL"), ("valkey", "Valkey"), ("redis", "Redis"), ("mariadb", "MariaDB"),
("mysql", "MySQL"), ("mongo", "MongoDB"), ("meilisearch", "Meilisearch"))
def _image_label(reference: str) -> str:
return str(reference or "").split("@", 1)[0]
def _service_kind(service: dict[str, Any]) -> str:
image = str(service.get("image") or "").casefold()
name = str(service.get("name") or "").casefold()
if service.get("is_main"):
return translate("Application")
if "machine-learning" in name or "machine-learning" in image:
return translate("Machine learning")
for key, label in SERVICE_KINDS:
if key in image:
return label
return translate("Service")
def _recommended_storage(volume: dict[str, Any]) -> str:
"""What the installation uses for this path, and the alternative when the
user can choose; the recommended one comes first."""
choices = set(volume.get("installation_choice", []))
default = volume.get("default")
if {"managed-volume", "host-bind"} <= choices:
both = f"{translate('Container volume')} / {translate('Host directory')}"
return f"{translate('Optional')}: {both}" if default == "skip" else both
if default == "skip":
return translate("Optional, not mounted by default")
return translate("Host system path") if default == "host-bind" else translate("Container volume")
def _app_detail_text(catalog: Catalog, item: dict[str, Any], template: dict[str, Any]) -> str:
ui = template["catalog_ui"]
contract = template["container_contract"]
profile = template.get("proxmox", {}).get("installer_profile", {})
lines = [rf"\Zb{source_text(ui.get('title')) or item['id']}\Zn", ""]
# The one-line tagline, not the full upstream description: it is what the
# reader needs to know what this is, it survives translation without
# drifting, and it goes through translate() like every other string.
description = translate(source_text(ui.get("tagline")) or source_text(ui.get("description")))
if description:
wrapped = textwrap.wrap(description, DETAIL_WIDTH)
if len(wrapped) > 8:
wrapped = wrapped[:8]
wrapped[-1] = wrapped[-1].rstrip(" .,;") + "…"
lines += wrapped + [""]
def row(label: str, value: str) -> None:
lines.append(f"{label + ':':<17} {value}")
row(translate("Source"), publisher(item))
row(translate("Status"), translate("Verified by ProxMenux") if is_tested(item)
else translate("Not yet verified by ProxMenux (beta)"))
row(translate("Architectures"), _display_architectures(ui))
endpoints = template.get("first_run", {}).get("endpoints", [])
if endpoints:
row(translate("Web access"), ", ".join(
f"{e.get('scheme', 'http')}://<IP>:{e.get('port')}{e.get('path') or '/'}" for e in endpoints))
if profile.get("stack_driver") == "arr-suite":
row(translate("Type"), translate("Application suite: one independent LXC per selected application"))
defaults = set(profile.get("default_applications") or ("prowlarr", "sonarr", "radarr", "qbittorrent"))
lines += ["", translate("Applications you can choose:")]
for app_id in profile.get("applications") or []:
try:
child = catalog.compose(app_id)
image = child["container_contract"]["image"]["reference"]
name = source_text(child["catalog_ui"]["title"]) or app_id
except (ConversionError, OSError, ValueError, KeyError):
image, name = "", app_id
mark = f" ({translate('selected by default')})" if app_id in defaults else ""
lines.append(f" {name + mark:<34} {_image_label(image)}")
lines.append(f" {translate('Media server') + ':':<34} Jellyfin, Plex, Emby {translate('or none')}")
elif item.get("multi_container"):
services = template.get("compose_stack", {}).get("services", [])
row(translate("Type"), translate("Multi-container application (experimental)"))
lines += ["", translate("Containers that will be created (one LXC per service, on a private network):")]
for service in services:
lines.append(f" {_service_kind(service):<20} {_image_label(service.get('image'))}")
else:
row(translate("Image"), _image_label(contract["image"]["reference"]))
volumes = contract.get("volumes", [])
if volumes:
width = max(28, *(len(volume["container_path"]) + 2 for volume in volumes))
lines += ["", f"{translate('Persistent data:'):<{width + 2}} {translate('Recommended')}"]
for volume in volumes:
lines.append(f" {volume['container_path']:<{width}} {_recommended_storage(volume)}")
if any({"managed-volume", "host-bind"} <= set(volume.get("installation_choice", []))
for volume in volumes):
lines.append(translate("The installation asks which one to use for these paths."))
hardware = profile.get("hardware_acceleration", {}).get("profiles", [])
if hardware:
lines += ["", translate("Hardware acceleration options:")]
lines += [f" {' '.join(translate(p.get('label', p['id'])).split())}" for p in hardware]
security = template.get("proxmox", {}).get("security_profile", {})
if security.get("requires_privileged_lxc"):
lines += ["", f"{translate('Security') + ':':<17} {translate('needs a privileged LXC')}"]
elif security.get("requires_relaxed_confinement"):
lines += ["", f"{translate('Security') + ':':<17} {translate('needs a relaxed AppArmor or seccomp profile')}"]
return "\n".join(lines)
def _deployment_summary_text(template: dict[str, Any], deployment: dict[str, Any]) -> str:
plan = redacted(deployment)
title = source_text(template["catalog_ui"]["title"]) or template["id"]
lines = [title, ""]
def row(label: str, value: Any) -> None:
lines.append(f"{label + ':':<16} {value}")
on = translate("on")
if plan.get("suite_arr"):
lines.append(translate("Independent applications, without a main container."))
else:
row(translate("Image"), template["container_contract"]["image"]["reference"])
if plan.get("deployment_kind"):
base_vmid = plan.get("base_vmid")
row(translate("Stack"), plan.get("stack_name", title))
row(translate("Base VMID"), base_vmid if base_vmid is not None else translate("next free block"))
services = template.get("compose_stack", {}).get("services", [])
if plan.get("deployment_kind") == "generic-multi-lxc-stack":
services = [dict(s, vmid_offset=s["offset"]) for s in plan["services"]]
for service in sorted(services, key=lambda item: item.get("vmid_offset", 0)):
offset = service.get("vmid_offset", 0)
vmid = base_vmid + offset if base_vmid is not None else f"base+{offset}"
lines.append(f" - {service['name']}: CT {vmid}")
lines.append("")
row("Rootfs", plan.get("rootfs_storage", "-"))
row(translate("Image cache"), plan.get("template_storage", "-"))
if plan.get("database_storage"):
row("PostgreSQL", f"{plan.get('database_size_gb', '-')} GB {on} {plan['database_storage']}")
for service in plan.get("services", []):
child = service["deployment"]
lines.append(f" {service['name']}: {child['resources']['cores']} CPU, "
f"{child['resources']['memory_mb']} MB RAM")
for mount in child["mounts"]:
target = (f"{mount['size_gb']} GB {on} {mount['source']}"
if mount["type"] == "managed-volume" else mount["source"])
lines.append(f" {mount['container_path']} → {target}")
for key, label in (("media", "Library"), ("application", "Application data"), ("transfer", "Consume/export")):
storage = plan.get(key)
if isinstance(storage, dict) and "mode" in storage:
if storage["mode"] == "host-bind":
row(translate(label), f"{translate('host directory')} {storage.get('host_path', '-')}")
else:
row(translate(label), f"{storage.get('size_gb', '-')} GB {on} {storage.get('storage', '-')}")
else:
row(translate("Container"), f"CT {plan.get('vmid') or translate('next free')} · {plan.get('hostname', '-')}")
resources = plan.get("resources", {})
if resources:
row(translate("Resources"), f"{resources.get('cores', '-')} CPU · {resources.get('memory_mb', '-')} MB RAM · "
f"{resources.get('swap_mb', '-')} MB swap")
rootfs = plan.get("rootfs")
if rootfs:
row(translate("Storage"), f"rootfs {rootfs['size_gb']} GB {on} {rootfs['storage']} · "
f"{translate('image cache on')} {plan.get('template_storage', '-')}")
mounts = plan.get("mounts", [])
if mounts:
lines.append(f"{translate('Data') + ':':<16}")
for mount in mounts:
if mount["type"] == "host-bind":
target = f"{translate('host directory')} {mount['source']}"
else:
target = f"{translate('Container volume')} {mount.get('size_gb', '-')} GB {on} {mount['source']}"
if mount.get("backup"):
target += f" ({translate('in backups')})"
if mount.get("read_only"):
target += f" ({translate('read-only')})"
lines.append(f" {mount['container_path']} → {target}")
network = plan.get("network", {})
if plan.get("host_monitor"):
row(translate("Network"), translate("IP address and firewall of the host"))
elif "frontend_bridge" in network:
addresses = [network[key] for key in ("frontend_ipv4", "machine_learning_frontend_ipv4") if network.get(key)]
addresses += [service["frontend_ipv4"] for service in plan.get("services", []) if service.get("frontend_ipv4")]
static = [address for address in addresses if address != "dhcp"]
row(translate("Network"), f"{network['frontend_bridge']} · {', '.join(static) if static else 'DHCP'}"
+ (f" · gw {network['frontend_gateway']}" if network.get("frontend_gateway") else "")
+ f" · {translate('private network assigned automatically')}")
elif network:
ipv4 = network.get("ipv4", "dhcp")
row(translate("Network"), f"{network.get('bridge', '-')} · {'DHCP' if ipv4 == 'dhcp' else ipv4}"
+ (f" · gw {network['gateway']}" if network.get("gateway") else ""))
devices = plan.get("devices", [])
if plan.get("hardware_profile") or devices:
profiles = (template.get("proxmox", {}).get("installer_profile", {})
.get("hardware_acceleration", {}).get("profiles", []))
selected = next((p for p in profiles if p["id"] == plan.get("hardware_profile")), None)
profile_label = (translate(selected["label"]).split(" ")[0] if selected
else plan.get("hardware_profile") or translate("custom"))
row(translate("Acceleration"),
", ".join([profile_label, *[d.get("host_path") or d.get("kind", "-") for d in devices]]))
security = plan.get("security")
if security:
row(translate("Security"),
translate("unprivileged LXC") if security.get("unprivileged") else translate("privileged LXC"))
environment = plan.get("environment", [])
if environment:
row(translate("Variables"), ", ".join(f"{item['name']}={item['value']}" for item in environment))
if plan.get("suite_arr"):
lines += ["", *[translate(note) for note in plan.get("completion_notes", [])]]
lines.append("")
row(translate("Start"), f"{translate('when finished')}: {_yes_no(plan.get('start_after_create'))} · "
f"{translate('with Proxmox')}: {_yes_no(plan.get('onboot'))}")
return "\n".join(lines)
def _print_installation_summary(result: dict[str, Any], images_removed: str | None = None) -> None:
console.msg_title(translate("Installation completed"))
if result.get("suite_arr"):
console.msg_ok(translate("Independent LXC applications installed"))
else:
console.msg_ok(f"CT {result['vmid']} · {translate('IP address')}: "
f"{result.get('ip') or translate('not available yet')}")
for name, vmid in (result.get("stack_vmids") or {}).items():
console.msg_ok(f"{translate(STACK_LABELS.get(name, name))}: CT {vmid}")
for item in result.get("urls") or []:
console.msg_ok(f"{translate(item['label'])}: {item['url']}")
for item in result.get("credentials") or []:
console.msg_info2(translate(item["label"]))
username = str(item["username"])
console.msg_ok(f"{translate('User')}: {translate(username) if ' ' in username else username}")
if item.get("password") is not None:
console.msg_ok(f"{translate('Password')}: {item['password'] or translate('(empty)')}")
else:
console.msg_warn(translate("The password could not be retrieved automatically"))
if item.get("change_required"):
console.msg_warn(translate("Change it after the first login."))
if images_removed:
console.msg_ok(images_removed)
for note in result.get("completion_notes") or []:
console.msg_note(translate(note))
if result.get("log"):
console.msg_note(f"{translate('Installation log:')} {result['log']}")
print()
console.msg_note(translate("OCI manager Apps is a beta: if something does not work as expected, "
"please report it on GitHub with the application name."))
# ---------------------------------------------------------------- menus
def _install(catalog: Catalog, ui, item: dict[str, Any], mode: str) -> None:
install_template(ui, catalog.compose(item["id"]), item["id"], mode)
def install_template(ui, template: dict[str, Any], identifier: str, mode: str) -> dict[str, Any] | None:
"""Configures and installs one template, from the catalog or written from a
definition the user gave."""
deployment = build_deployment(template, ui, mode)
if not ui.review(_deployment_summary_text(template, deployment), translate("Installation summary"),
question=translate("Install with this configuration?")):
return None
console.show_logo()
console.msg_title(f"{source_text(template['catalog_ui']['title']) or identifier} · {APP_TITLE}")
try:
result = run_remote_install(PROJECT_ROOT, template, deployment, "auto")
except InstallError as exc:
console.msg_error(str(exc))
console.wait_for_enter(translate("Press Enter to return to the menu..."))
return None
if result:
vmids = {int(v) for v in [result.get("vmid"), *(result.get("stack_vmids") or {}).values()] if v}
_, removed = images.offer_removal(ui, sorted(vmids))
_print_installation_summary(result, removed)
console.wait_for_enter(translate("Press Enter to return to the menu..."))
return result
def _rclone_mount(catalog: Catalog, ui) -> None:
template = catalog.compose("rclone")
deployment = build_rclone_mount_deployment(template, ui)
console.show_logo()
console.msg_title(translate("Rclone mount"))
result = run_remote_rclone_mount(PROJECT_ROOT, template, deployment, "auto")
if result:
console.msg_ok(f"Remote: {result['remote']}:")
console.msg_ok(f"{translate('Read/write')}: {result['read_write_path']}")
console.msg_ok(f"{translate('Read-only')}: {result['read_only_path']}")
console.wait_for_enter(translate("Press Enter to return to the menu..."))
def _app_detail(catalog: Catalog, ui, item: dict[str, Any]) -> None:
template = catalog.compose(item["id"])
actions = []
if item.get("multi_container"):
actions.append(("advanced", translate("Install (experimental)")))
else:
actions += [("default", translate("Install with default settings")),
("advanced", translate("Install with advanced settings"))]
if item["id"] == "rclone":
actions.append(("mount", translate("Enable a mount on an existing Rclone OCI container")))
numbered = {str(number): action for number, (action, _) in enumerate(actions, 1)}
options = [(str(number), label) for number, (_, label) in enumerate(actions, 1)]
title = source_text(template["catalog_ui"]["title"]) or item["id"]
selection = ui.detail_menu(_app_detail_text(catalog, item, template), options, "1", title=title)
action = numbered.get(selection or "")
if action is None:
return
if action == "mount":
_rclone_mount(catalog, ui)
return
_install(catalog, ui, item, DEFAULT_MODE if action == "default" else ADVANCED_MODE)
def _app_list(catalog: Catalog, ui, applications: list[dict[str, Any]], title: str,
keep_order: bool = False) -> None:
options, index, header = _app_menu(applications, keep_order)
selection = None
while True:
selection = ui.choose(header, options, selection, title=title, size=MENU_SIZE, colors=True)
if selection is None:
return
if selection in index:
_app_detail(catalog, ui, index[selection])
def _search(catalog: Catalog, ui, applications: list[dict[str, Any]]) -> None:
query = ui.ask(translate("Name or part of the description of the application"), required=False).strip()
if not query:
return
folded = query.casefold()
def rank(item: dict[str, Any]) -> int | None:
names = (item["id"].casefold(), str(item.get("title") or "").casefold())
if folded in names:
return 0
if any(name.startswith(folded) for name in names):
return 1
if any(folded in name for name in names):
return 2
return 3 if folded in str(item.get("description") or "").casefold() else None
ranked = sorted(((rank(item), str(item.get("title") or item["id"]).casefold(), item) for item in applications
if rank(item) is not None), key=lambda entry: entry[:2])
matches = [item for _, _, item in ranked]
if not matches:
ui.message(f"{translate('No applications match')}: '{query}'")
return
_app_list(catalog, ui, matches, f"{translate('Search results for:')} '{query}' ({len(matches)})",
keep_order=True)
def interactive(catalog: Catalog) -> int:
ui = interactive_ui()
if not catalog.index_path.exists():
ui.message(translate("The OCI catalog is not installed. Update ProxMenux and try again."))
return 1
applications = _installable(catalog.load_index()["applications"])
categories: dict[str, list[dict[str, Any]]] = {}
labels: dict[str, str] = {}
for item in applications:
key = item.get("category") or "misc"
categories.setdefault(key, []).append(item)
labels[key] = item.get("category_label") or key
order = sorted(categories, key=lambda key: (key == "misc", translate(labels[key]).casefold()))
category_by_index = {str(number): key for number, key in enumerate(order, 1)}
options = [
("search", translate("Search applications")),
("all", f"{translate('All applications'):<35} ({len(applications):>3})"),
("manage", translate("Manage installed OCI applications")),
("custom", translate("Install an image that is not in the catalog")),
("", ""),
] + [(number, f"{translate(labels[key]):<35} ({len(categories[key]):>3})")
for number, key in category_by_index.items()]
selection = "search"
while True:
selection = ui.choose(translate("Select a category or search for applications:"), options, selection,
size=MENU_SIZE)
if selection is None:
return 0
try:
if selection == "search":
_search(catalog, ui, applications)
elif selection == "all":
_app_list(catalog, ui, applications,
f"{translate('All applications')} ({len(applications)})")
elif selection == "manage":
from .management import interactive_management
interactive_management(PROJECT_ROOT, ui)
elif selection == "custom":
from .custom import explore
explore(ui)
elif selection in category_by_index:
key = category_by_index[selection]
_app_list(catalog, ui, categories[key], translate(labels[key]))
except UserCancelled:
continue
except (ConversionError, InstallError, OSError, ValueError) as exc:
console.stop_spinner()
ui.message(f"{translate('The operation could not be completed')}:\n\n{exc}")
# ---------------------------------------------------------------- maintenance CLI
def _template_summary_text(template: dict[str, Any]) -> str:
contract = template["container_contract"]
lines = [
source_text(template["catalog_ui"]["title"]),
f"Image: {contract['image']['reference']}",
f"Status: {template['status']}",
"Ports: " + (", ".join(f"{p['container_port']}/{p['protocol']}" for p in contract["ports"]) or "none"),
"Volumes: " + (", ".join(v["container_path"] for v in contract["volumes"]) or "none"),
]
blockers = template["compatibility"]["untranslated_blockers"]
if blockers:
lines.append(f"Blockers: {', '.join(blockers)}")
return "\n".join(lines)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="oci_manager_apps.sh",
description="ProxMenux OCI manager Apps (beta)")
subparsers = parser.add_subparsers(dest="command")
subparsers.add_parser("sync", help="Refresh the index from the image sources")
list_parser = subparsers.add_parser("list", help="List applications")
list_parser.add_argument("--filter", default="")
generate_parser = subparsers.add_parser("generate", help="Regenerate the template of one application")
generate_parser.add_argument("app")
generate_all_parser = subparsers.add_parser("generate-all", help="Regenerate the catalog templates")
generate_all_parser.add_argument("--provider", choices=["all", "linuxserver.io", "imported", "curated"],
default="all")
show_parser = subparsers.add_parser("show", help="Show the summary of a template")
show_parser.add_argument("app")
install_parser = subparsers.add_parser("install", help="Configure and install an application")
install_parser.add_argument("app")
install_parser.add_argument("--host", default="auto", help="'auto'/'local', or root@IP for development")
install_parser.add_argument("--advanced", action="store_true")
install_parser.add_argument("--dry-run", action="store_true")
rclone_parser = subparsers.add_parser("rclone-mount", help="Enable a mount on an installed Rclone OCI")
rclone_parser.add_argument("--host", default="auto")
rclone_parser.add_argument("--dry-run", action="store_true")
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
catalog = Catalog(PROJECT_ROOT)
try:
if not args.command:
return interactive(catalog)
if args.command == "sync":
payload = catalog.sync_index()
print(f"Index updated: {len(payload['applications'])} candidate applications")
return 0
if args.command == "list":
query = args.filter.casefold()
for item in catalog.load_index()["applications"]:
if not item.get("hidden", False) and query in item["id"].casefold():
candidate = item.get("automatic_install_candidate")
status = ("installable" if candidate else "pending adaptation" if candidate is False
else item.get("template_status") or "not generated")
print(f"{item['id']:<30} {_display_architectures(item):<12} {publisher(item):<14} {status}")
return 0
if args.command == "generate":
path, template = catalog.generate(args.app)
print(f"{_template_summary_text(template)}\n\n{path}")
return 0
if args.command == "generate-all":
def progress(current: int, total: int, app_id: str, outcome: str) -> None:
marker = "OK" if outcome == "ok" else "ERROR"
print(f"\r[{current:3}/{total}] {marker:<5} {app_id:<32}", end="", flush=True)
report = catalog.generate_all(progress=progress, provider=args.provider)
print(f"\nGenerated: {report['generated_count']}; failed: {report['failed_count']}; "
f"family: {args.provider}")
return 0 if not report["failed"] else 2
if args.command == "show":
print(_template_summary_text(catalog.compose(args.app)))
return 0
if args.command == "install":
template = catalog.compose(args.app)
if not template["compatibility"]["automatic_install_candidate"]:
raise InstallError("Installation blocked: " + ", ".join(template["compatibility"]["untranslated_blockers"]))
deployment = build_deployment(template, None, ADVANCED_MODE if args.advanced else DEFAULT_MODE)
print(_deployment_summary_text(template, deployment))
if not args.dry_run and input("\nInstall? [y/N]: ").strip().casefold() not in {"y", "yes"}:
print("Installation cancelled.")
return 0
result = run_remote_install(PROJECT_ROOT, template, deployment, args.host, args.dry_run)
if result:
_print_installation_summary(result)
return 0
if args.command == "rclone-mount":
template = catalog.compose("rclone")
deployment = build_rclone_mount_deployment(template)
print(json.dumps(deployment, ensure_ascii=False, indent=2))
result = run_remote_rclone_mount(PROJECT_ROOT, template, deployment, args.host, args.dry_run)
if result:
print(f"Read/write: {result['read_write_path']}\nRead-only: {result['read_only_path']}")
return 0
return 1
except (KeyboardInterrupt, UserCancelled):
console.stop_spinner()
print(f"\n{translate('Operation cancelled.')}")
return 130
except (ConversionError, SourceError, InstallError, OSError, ValueError) as exc:
console.stop_spinner()
print(f"ERROR: {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())
+134
View File
@@ -0,0 +1,134 @@
"""Terminal output in the ProxMenux style of utils.sh (msg_info, msg_ok, ...)."""
from __future__ import annotations
import os
import shutil
import subprocess
import sys
import textwrap
import threading
from .i18n import BASE_DIR
MG = "\033[1;35m"
GN = "\033[1;92m"
RD = "\033[01;31m"
YW = "\033[33m"
YWB = "\033[1;33m"
BL = "\033[36m"
BOLD = "\033[1m"
CL = "\033[m"
BFR = "\r\033[K"
TAB = " "
HOLD = "-"
CM = f"{GN}✓ {CL}"
FRAMES = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏")
_spinner: tuple[threading.Thread, threading.Event] | None = None
def _write(text: str) -> None:
sys.stdout.write(text)
sys.stdout.flush()
def _spin(stop: threading.Event) -> None:
index = 0
_write("\033[?25l")
while not stop.wait(0.1):
_write(f"\r {MG}{FRAMES[index]}{CL}")
index = (index + 1) % len(FRAMES)
def stop_spinner(clear_line: bool = True) -> None:
global _spinner
if _spinner is not None:
thread, stop = _spinner
stop.set()
thread.join()
_spinner = None
if clear_line:
_write("\r\033[K")
_write("\033[?25h")
def msg_info(text: str) -> None:
global _spinner
stop_spinner()
_write(f"{TAB}{MG}{HOLD}{text}")
if sys.stdout.isatty():
stop = threading.Event()
thread = threading.Thread(target=_spin, args=(stop,), daemon=True)
_spinner = (thread, stop)
thread.start()
else:
_write("\n")
def msg_ok(text: str) -> None:
stop_spinner(clear_line=False)
_write(f"{BFR}{TAB}{CM}{GN}{text}{CL}\n")
def msg_warn(text: str) -> None:
stop_spinner(clear_line=False)
_write(f"{BFR}{TAB}{CL} {YWB}{text}{CL}\n")
def msg_error(text: str) -> None:
stop_spinner(clear_line=False)
_write(f"{BFR}{TAB}{RD}[ERROR] {text}{CL}\n")
def msg_info2(text: str) -> None:
_write(f"{TAB}{BOLD}{YW}{HOLD} {text}{CL}\n")
def msg_note(text: str) -> None:
"""Closing information, in the colour ProxMenux uses for paths and values."""
stop_spinner(clear_line=False)
_write(f"{TAB}{BL}{text}{CL}\n")
def msg_success(text: str) -> None:
stop_spinner(clear_line=False)
_write(f"{TAB}{BOLD}{BL}{HOLD}{text}{CL}\n\n")
def ask_yes_no(text: str, default_yes: bool = True) -> bool:
"""A question asked in the middle of the output: whiptail draws over the
terminal and restores it, so what was printed stays on screen."""
width = 74
lines = sum(max(1, len(textwrap.wrap(line, width - 6) or [''])) for line in text.splitlines() or [''])
widget = ['whiptail', '--backtitle', 'ProxMenux', '--title', 'ProxMenux',
'--yesno', text, str(min(lines + 8, 20)), str(width)]
if not default_yes:
widget.insert(1, '--defaultno')
if shutil.which('whiptail'):
environment = dict(os.environ, NEWT_COLORS_FILE='/dev/null')
return subprocess.run(widget, env=environment, check=False).returncode == 0
answer = input(f"{text} [{'Y/n' if default_yes else 'y/N'}]: ").strip().casefold()
return default_yes if not answer else answer in {'y', 'yes', 's', 'si'}
def msg_title(text: str) -> None:
_write(f"\n\n{TAB}{BOLD}{HOLD} | {text} | {HOLD}{CL}\n\n\n")
def show_logo() -> None:
"""The ProxMenux banner; like show_proxmenux_logo it clears the screen."""
stop_spinner()
utils = BASE_DIR / "utils.sh"
if utils.is_file() and sys.stdout.isatty():
subprocess.run(["bash", "-c", 'source "$1" >/dev/null 2>&1; show_proxmenux_logo', "_", str(utils)],
check=False)
elif sys.stdout.isatty():
_write("\033[H\033[2J")
def wait_for_enter(text: str) -> None:
msg_success(text)
try:
input()
except EOFError:
pass
File diff suppressed because it is too large Load Diff
+574
View File
@@ -0,0 +1,574 @@
"""An image that is not in the catalog: its Compose file, or the image itself,
is translated into the same template the catalog applications use."""
from __future__ import annotations
import json
import re
import shlex
import subprocess
import sys
import urllib.request
from pathlib import Path
from typing import Any
import yaml
from . import console
from .casaos import convert_casaos_compose, normalize_app_id
from .cli import install_template
from .i18n import source_text
from .installer import ADVANCED_MODE, DEFAULT_MODE
from .converter import ConversionError
from .i18n import translate
from .ui import UserCancelled
IMAGE_REFERENCE = re.compile(r'^[A-Za-z0-9][A-Za-z0-9._/-]*(?::[A-Za-z0-9._-]+)?(?:@sha256:[a-f0-9]{64})?$')
MAX_COMPOSE_BYTES = 256 * 1024
class _Dumper(yaml.SafeDumper):
"""Compose written the way it is read: lists indented under their key, so
the #optional markers of the documentation keep their meaning."""
def increase_indent(self, flow=False, indentless=False):
return super().increase_indent(flow, False)
def _dump(document: dict[str, Any]) -> str:
return yaml.dump(document, Dumper=_Dumper, default_flow_style=False, sort_keys=True)
def _services(compose: dict[str, Any]) -> dict[str, Any]:
services = compose.get('services')
if not isinstance(services, dict) or not services:
raise ConversionError(translate('The Compose file declares no service'))
return services
def _published_port(service: dict[str, Any]) -> str | None:
for item in service.get('ports') or []:
if isinstance(item, dict):
published = item.get('published') or item.get('target')
else:
parts = str(item).split('/', 1)[0].split(':')
published = parts[-2] if len(parts) > 1 else parts[-1]
if str(published or '').isdigit():
return str(published)
return None
# Directories of the host that no application receives this way. The two time
# settings are the usual exception and are harmless.
HOST_SYSTEM_PATHS = ('/', '/bin', '/boot', '/dev', '/etc', '/lib', '/lib64', '/proc',
'/root', '/run', '/sbin', '/sys', '/usr', '/var')
TIME_SETTINGS = ('/etc/localtime', '/etc/timezone')
def _check_mounts(service: dict[str, Any]) -> None:
"""A Compose file that reaches into the host or into the Docker engine is
refused before anything else, with the reason."""
for item in service.get('volumes') or []:
if isinstance(item, dict):
source, target = str(item.get('source') or ''), str(item.get('target') or '')
else:
parts = str(item).split(':')
source, target = (parts[0], parts[1]) if len(parts) > 1 else ('', parts[0])
if 'docker.sock' in source or 'docker.sock' in target:
raise ConversionError(translate('It works through the Docker engine of the host, '
'and a native OCI container does not have one.'))
for path in (source, target):
clean = path.rstrip('/') or '/'
if clean in TIME_SETTINGS or not clean.startswith('/'):
continue
if clean in HOST_SYSTEM_PATHS:
raise ConversionError(
f"{translate('It asks for a system directory of the host:')} {clean}. "
f"{translate('ProxMenux does not give a container the system of its host.')}")
def _keep_reference(template: dict[str, Any], reference: str) -> None:
"""The catalog normalizes every image to its latest tag; an image given by
hand keeps the tag or the digest that was written."""
repository, _, digest = reference.partition('@')
tag = ''
if ':' in repository.rsplit('/', 1)[-1]:
repository, _, tag = repository.rpartition(':')
image = template['container_contract']['image']
image['reference'] = reference if (tag or digest) else f'{repository}:latest'
image['repository'] = repository
image['tag'] = tag or ('' if digest else 'latest')
image['digest'] = f'@{digest}' if digest else None
if tag or digest:
image['pull_policy'] = 'resolve-written-reference-to-architecture-digest-at-install'
def template_from_compose(text: str, title: str | None = None) -> dict[str, Any]:
"""The template of a Compose file with a single service. The metadata the
importer expects is taken from the service itself."""
try:
compose = yaml.safe_load(text)
except yaml.YAMLError as error:
raise ConversionError(f"{translate('The Compose file is not valid YAML:')} {error}") from error
if not isinstance(compose, dict):
raise ConversionError(translate('The Compose file does not contain a Compose document'))
services = _services(compose)
if len(services) > 1:
raise ConversionError(
f"{translate('The Compose file describes several images:')} {', '.join(services)}. "
f"{translate('Only one image at a time can be installed this way.')}")
name = next(iter(services))
service = services[name] or {}
if not service.get('image'):
raise ConversionError(f"{translate('The service declares no image:')} {name}")
if service.get('build'):
raise ConversionError(translate('The service builds its own image; only a published image can be installed'))
_check_mounts(service)
identifier = normalize_app_id(str(compose.get('name') or title or name))
metadata: dict[str, Any] = {'main': name, 'title': {'en_US': title or name}}
port = _published_port(service)
if port:
metadata.update(port_map=port, scheme='http', index='/')
if compose.get('x-casaos'):
document = text
else:
document = text.rstrip('\n') + '\n' + _dump({'x-casaos': metadata})
template = convert_casaos_compose(document, 'local', '',
f'local/{identifier}/docker-compose.yml', '')
_keep_reference(template, str(service['image']))
# The container takes its time settings from Proxmox, so the time files of
# the host are not attached to it.
contract = template['container_contract']
contract['volumes'] = [volume for volume in contract.get('volumes', [])
if volume['container_path'] not in TIME_SETTINGS]
profile = template.setdefault('proxmox', {}).setdefault('installer_profile', {})
preparations = []
for volume in contract.get('volumes', []):
# Docker marks paths as read-only in its own command; the container
# here gets them read and write, and the user decides where they go.
volume['read_only'] = False
# A fresh ext4 volume carries lost+found, which stops the images that
# take ownership of their own directories.
preparations.append({'container_path': volume['container_path'],
'remove_lost_found': True,
'owner_strategy': 'mapped-application-user',
'only_when_mount_type': 'managed-volume'})
if preparations:
profile['volume_preparations'] = preparations
files = [volume['container_path'] for volume in template['container_contract'].get('volumes', [])
if Path(volume['container_path']).suffix]
if files:
raise ConversionError(
f"{translate('The image expects files that are given to it one by one:')} {', '.join(files)}. "
f"{translate('ProxMenux attaches directories, not single files, so this image cannot be installed yet.')}")
return template
def compose_from_image(reference: str, title: str | None = None) -> str:
"""A Compose file written from what the image declares: its ports, its
persistent paths and its variables."""
if not IMAGE_REFERENCE.fullmatch(reference):
raise ConversionError(f"{translate('This is not a valid image reference:')} {reference}")
result = subprocess.run(['skopeo', 'inspect', '--config', f'docker://{reference}'],
capture_output=True, text=True, check=False, timeout=120)
if result.returncode != 0:
raise ConversionError(f"{translate('The image could not be read from its registry:')} "
f"{(result.stderr or '').strip().splitlines()[-1] if result.stderr else reference}")
config = (json.loads(result.stdout) or {}).get('config') or {}
name = normalize_app_id(title or reference.rsplit('/', 1)[-1].split(':', 1)[0].split('@', 1)[0])
service: dict[str, Any] = {'image': reference}
ports = [port.split('/', 1)[0] for port in (config.get('ExposedPorts') or {})
if port.endswith('/tcp') or '/' not in port]
if ports:
service['ports'] = [f'{port}:{port}' for port in ports]
volumes = sorted(config.get('Volumes') or {})
if volumes:
service['volumes'] = [f'./{Path(path).name or "data"}:{path}' for path in volumes]
environment = [item for item in (config.get('Env') or [])
if '=' in item and item.split('=', 1)[0] not in {'PATH', 'HOME', 'TERM'}]
if environment:
service['environment'] = environment
return _dump({'name': name, 'services': {name: service}})
DOCKER_RUN_VALUE_FLAGS = {
'--name': 'container_name', '--hostname': 'hostname', '-h': 'hostname',
'--restart': 'restart', '--user': 'user', '-u': 'user',
'--workdir': 'working_dir', '-w': 'working_dir', '--entrypoint': 'entrypoint',
'--shm-size': 'shm_size', '--network': 'network_mode', '--net': 'network_mode',
'--ipc': 'ipc', '--runtime': 'runtime', '--memory': 'mem_limit', '-m': 'mem_limit',
'--stop-timeout': 'stop_grace_period',
}
DOCKER_RUN_LIST_FLAGS = {
'-p': 'ports', '--publish': 'ports', '-v': 'volumes', '--volume': 'volumes',
'-e': 'environment', '--env': 'environment', '--device': 'devices',
'--cap-add': 'cap_add', '--sysctl': 'sysctls', '--group-add': 'group_add',
'--add-host': 'extra_hosts', '--label': 'labels', '--security-opt': 'security_opt',
}
DOCKER_RUN_IGNORED = {'-d', '--detach', '--rm', '-i', '--interactive', '-t', '--tty',
'-it', '-ti', '--init', '--pull', '--quiet', '-q'}
def compose_from_docker_run(command: str, title: str | None = None) -> str:
"""The Compose file of a `docker run` command, which is how many images
are documented."""
# Documentation writes optional lines as `#optional`, a shell comment that
# produces nothing when the command runs; here it is removed as well.
text = re.sub(r'`\s*#\s*optional\s*`', ' \x00optional\x00 ', command, flags=re.I)
text = re.sub(r'`[^`]*`', ' ', text).replace('\\\n', ' ').replace('\\', ' ')
# `-u $(id -u)` runs the container as the user who types the command; the
# LXC takes the user of the image instead, and its volumes are owned by it.
text, host_user = re.subn(r'(?:-u|--user)\s+\$\([^)]*\)(?::\$\([^)]*\))?', ' ', text)
remaining = re.search(r'\$\([^)]*\)', text)
if remaining:
raise ConversionError(f"{translate('The command works out a value by running another command:')} "
f"{remaining.group(0)}. {translate('Write the value it produces instead.')}")
tokens = [token for token in shlex.split(text) if not token.startswith('#')]
optional: set[str] = set()
for position, token in enumerate(tokens):
if token == '\x00optional\x00' and position:
optional.add(tokens[position - 1])
tokens = [token for token in tokens if token != '\x00optional\x00']
while tokens and tokens[0] in {'sudo', 'docker', 'podman', 'container', 'run'}:
tokens.pop(0)
service: dict[str, Any] = {}
unsupported: list[str] = []
image = None
index = 0
while index < len(tokens):
token = tokens[index]
if not token.startswith('-'):
image = token
arguments = tokens[index + 1:]
if arguments:
service['command'] = arguments
break
flag, _, inline = token.partition('=')
value = inline if inline else None
if flag in DOCKER_RUN_IGNORED or token in DOCKER_RUN_IGNORED:
index += 1
continue
if flag in DOCKER_RUN_VALUE_FLAGS or flag in DOCKER_RUN_LIST_FLAGS:
if value is None:
index += 1
value = tokens[index] if index < len(tokens) else ''
if flag in DOCKER_RUN_VALUE_FLAGS:
service[DOCKER_RUN_VALUE_FLAGS[flag]] = value
else:
service.setdefault(DOCKER_RUN_LIST_FLAGS[flag], []).append(value)
elif flag == '--privileged':
service['privileged'] = True
elif flag == '--gpus':
if value is None and index + 1 < len(tokens) and not tokens[index + 1].startswith('-'):
index += 1
service['deploy'] = {'resources': {'reservations': {'devices': [
{'driver': 'nvidia', 'count': 'all', 'capabilities': ['gpu']}]}}}
elif flag in {'--env-file'}:
raise ConversionError(translate('The command reads its variables from a file; '
'write them in the command or use a Compose file'))
else:
unsupported.append(flag)
index += 1
if unsupported:
raise ConversionError(f"{translate('The command uses options that cannot be translated:')} "
f"{', '.join(sorted(set(unsupported)))}")
if not image:
raise ConversionError(translate('The command does not name an image'))
if not IMAGE_REFERENCE.fullmatch(image):
raise ConversionError(f"{translate('This is not a valid image reference:')} {image}")
service['image'] = image
name = normalize_app_id(title or service.get('container_name')
or image.rsplit('/', 1)[-1].split(':', 1)[0].split('@', 1)[0])
document = _dump({'name': name, 'services': {name: service}})
if host_user:
note = translate('The command runs the container as the user of the host; the container '
'uses the user of its image instead.')
document = f'#note: {note}\n' + document
if not optional:
return document
lines = []
for line in document.splitlines():
value = line.strip().lstrip('- ').strip().strip('\'"')
lines.append(f'{line} #optional' if value and value in optional else line)
return '\n'.join(lines) + '\n'
def _read_url(url: str) -> str:
if not url.startswith(('http://', 'https://')):
raise ConversionError(translate('The address must start with http:// or https://'))
with urllib.request.urlopen(url, timeout=60) as response: # noqa: S310 - the user gives the address
return response.read(MAX_COMPOSE_BYTES + 1).decode('utf-8', errors='replace')
def _read_file(path: str) -> str:
file = Path(path).expanduser()
if not file.is_file():
raise ConversionError(f"{translate('The file does not exist:')} {file}")
if file.stat().st_size > MAX_COMPOSE_BYTES:
raise ConversionError(translate('The file is too large to be a Compose file'))
return file.read_text(encoding='utf-8', errors='replace')
def _read_pasted(ui, title: str | None = None) -> str:
"""The definition is pasted in the terminal: dialog cannot take it."""
console.show_logo()
console.msg_title(title or translate('Compose file of the application'))
console.msg_info2(translate('Paste it here and press Ctrl+D on an empty line.'))
print()
text = sys.stdin.read(MAX_COMPOSE_BYTES + 1)
if not text.strip():
raise UserCancelled(translate('No Compose file was given'))
return text
def read_definition(ui) -> tuple[str, str]:
"""The Compose file of the application and where it came from."""
from .cli import MENU_SIZE
source = ui.choose(translate('How is the image described?'), [
('paste', translate('Paste its Compose file in the terminal')),
('file', translate('Read its Compose file from a file of this host')),
('url', translate('Download its Compose file from an address')),
('run', translate('Paste its docker run command in the terminal')),
('image', translate('Only the image reference, with no Compose file')),
], 'paste', title=translate('Image that is not in the catalog'), size=MENU_SIZE)
if source is None:
raise UserCancelled(translate('No image was given'))
if source == 'paste':
return _read_pasted(ui), translate('pasted Compose file')
if source == 'file':
return _read_file(ui.ask(translate('Path of the Compose file'), '/root/docker-compose.yml')), \
translate('Compose file of this host')
if source == 'url':
return _read_url(ui.ask(translate('Address of the Compose file'), '')), translate('downloaded Compose file')
if source == 'run':
return compose_from_docker_run(_read_pasted(ui, translate('docker run command of the application'))), \
translate('docker run command')
reference = ui.ask(translate('Image reference (for example ghcr.io/user/application:latest)'), '')
return compose_from_image(reference), translate('image itself')
BLOCKER_TEXTS = {
'multi-service-compose': 'it describes more than one image',
'native-multi-lxc-orchestrator-not-yet-implemented': 'it describes more than one image',
'top-level-configs': 'it uses Compose configs, which have no equivalent here',
'devices-format': 'the devices it asks for are not written in a way that can be read',
'healthcheck-format': 'its health check is not written in a way that can be read',
'stop-grace-period-format': 'its stop timeout is not written in a way that can be read',
'shm-size-format': 'its shared memory size is not written in a way that can be read',
'ulimits-format-or-resource': 'the resource limits it asks for cannot be applied',
'mem-limit-format-or-below-proxmox-minimum': 'the memory limit it asks for cannot be applied',
'mem-limit-deploy-consistency-review': 'it asks for two different memory limits',
}
def blocker_text(code: str) -> str:
"""The reason a definition cannot be installed, in plain words."""
code = re.sub(r'^service:[^:]+:', '', code)
if code in BLOCKER_TEXTS:
return translate(BLOCKER_TEXTS[code])
if code.startswith('compose-key:'):
return f"{translate('it uses the Compose option')} {code.split(':', 1)[1]}"
if code.startswith('device-mapping:'):
return f"{translate('a device it asks for cannot be translated:')} {code.split(':', 1)[1]}"
if code.endswith(':missing-image'):
return translate('one of its services declares no image')
if code.endswith(':invalid-definition'):
return translate('one of its services is not written as a service')
return code
def ignored_settings(text: str) -> list[str]:
"""Settings of the definition that only mean something in Docker and are
not applied here; the user should know they were read and left aside."""
try:
compose = yaml.safe_load(text)
except yaml.YAMLError:
return []
if not isinstance(compose, dict):
return []
notes = []
for service in (compose.get('services') or {}).values():
if not isinstance(service, dict):
continue
deploy = service.get('deploy') or {}
swarm = sorted(set(deploy) - {'resources'}) if isinstance(deploy, dict) else []
if swarm:
notes.append(f"{translate('Only a Docker Swarm uses these settings, so they are not applied:')} "
f"deploy.{', deploy.'.join(swarm)}")
if service.get('labels'):
notes.append(translate('Its labels are not applied: they are read by other Docker tools.'))
for service in (compose.get('services') or {}).values():
if isinstance(service, dict) and any(
str(item).split(':')[0].rstrip('/') in TIME_SETTINGS for item in service.get('volumes') or []
if not isinstance(item, dict)):
notes.append(translate('The container takes its time zone from Proxmox, so the time files '
'of the host are not attached to it.'))
break
if compose.get('networks') or compose.get('volumes'):
notes.append(translate('The container gets its own address and its own volumes, so the networks '
'and volumes declared in the file are not used.'))
return notes
def registry_report(reference: str) -> tuple[bool, str]:
"""Whether the image really exists in its registry, and for which
architectures. A name written by hand is easy to get wrong."""
result = subprocess.run(['skopeo', 'inspect', '--raw', f'docker://{reference}'],
capture_output=True, text=True, check=False, timeout=120)
if result.returncode != 0:
return False, f"{translate('The image was not found in its registry, or it is private:')} {reference}"
try:
document = json.loads(result.stdout)
except ValueError:
return True, translate('The image is in its registry.')
manifests = document.get('manifests')
if not manifests:
# The image publishes a single manifest: its architecture is in the image itself.
detail = subprocess.run(['skopeo', 'inspect', f'docker://{reference}'],
capture_output=True, text=True, check=False, timeout=120)
try:
architecture = json.loads(detail.stdout).get('Architecture') if detail.returncode == 0 else None
except ValueError:
architecture = None
if not architecture:
return True, translate('The image is in its registry, for one architecture.')
return True, (f"{translate('The image is in its registry:')} {architecture} "
f"({translate('it publishes no other architecture')})")
architectures = sorted({item.get('platform', {}).get('architecture', '')
for item in manifests} - {'', 'unknown'})
return True, f"{translate('The image is in its registry:')} {', '.join(architectures)}"
def describe(template: dict[str, Any]) -> str:
"""What the translation understood, in the words of the installer, with
everything that changes how the container is created."""
contract = template['container_contract']
proxmox = template.get('proxmox', {}) or {}
profile = proxmox.get('installer_profile', {}) or {}
security = proxmox.get('security_profile', {}) or {}
lines = [f"{translate('Image') + ':':<14} {contract['image']['reference']}"]
endpoints = template.get('first_run', {}).get('endpoints') or []
if endpoints:
# The address is read from the published port; the image does not say
# whether it answers over http or https.
lines.append(f"{translate('Web access') + ':':<14} " + ', '.join(
f"{item.get('scheme', 'http')}://<IP>:{item.get('port')}{item.get('path') or '/'}" for item in endpoints)
+ f" ({translate('https if the image serves TLS')})")
# The container has its own address, so the port Docker publishes on the
# host is not used: the application answers on its own port.
ports = []
republished = False
for port in contract.get('ports') or []:
ports.append(str(port['container_port']) + ('' if port.get('required', True)
else f" ({translate('optional')})"))
republished = republished or (port.get('published_example')
and int(port['published_example']) != int(port['container_port']))
if ports:
lines.append(f"{translate('Ports') + ':':<14} {', '.join(ports)}")
if republished:
own = translate('the container has its own address, so the port Docker published '
'on the host is not needed')
lines.append(f"{'':<14} {own}")
if (profile.get('network') or {}).get('compose_mode') == 'host':
# Docker shares the network of the host; in Proxmox the container has
# its own address, which is what the installation gives it.
lines.append(f"{translate('Network') + ':':<14} "
f"{translate('it asks for the network of the host; the container gets its own address instead')}")
volumes = contract.get('volumes') or []
if volumes:
lines += ['', translate('Persistent data:')]
lines += [f" {volume['container_path']}"
+ ('' if volume.get('default') != 'skip' else f" ({translate('optional')})")
for volume in volumes]
environment = contract.get('environment') or []
if environment:
lines += ['', translate('Variables the installation asks for:')]
lines += [f" {item['name']}"
+ (f" = {item['example']}" if item.get('example') and not item['sensitive'] else '')
+ ('' if item.get('required', True) else f" ({translate('optional')})")
for item in environment]
devices = profile.get('device_requests') or []
if devices:
lines += ['', translate('Devices of the host it asks for:')]
lines += [f" {translate(str(item.get('enable_prompt') or item.get('purpose') or item.get('id')))}"
for item in devices]
warnings = []
if security.get('requires_privileged_lxc'):
warnings.append(translate('It needs a privileged container, which is not isolated from the host.'))
elif security.get('source_requests_privileged_lxc') or security.get('optional_privileged_lxc'):
warnings.append(translate('Its Compose file asks for privileged mode; the container is created '
'unprivileged and that mode is only offered as an option.'))
if security.get('requires_relaxed_confinement') or security.get('source_requests_relaxed_confinement'):
warnings.append(translate('It asks for capabilities or a relaxed confinement profile.'))
if security.get('requires_host_pid_namespace'):
warnings.append(translate('It asks to see the processes of the host.'))
if warnings:
lines += ['', translate('Worth knowing before installing it:')] + [f' {text}' for text in warnings]
blockers = template.get('compatibility', {}).get('untranslated_blockers') or []
if blockers:
lines += ['', translate('What cannot be translated:')] + [f' {blocker_text(blocker)}' for blocker in blockers]
return '\n'.join(lines)
# A value that looks like a secret is asked during the installation instead of
# being taken from the definition, where it is usually the documented example.
SECRET_NAME = re.compile(r'(?:^|_)(pass|passwd|password|pwd?|secret|token|apikey|api_key|key)$', re.I)
def _ask_secrets(template: dict[str, Any]) -> list[str]:
asked = []
for item in template['container_contract'].get('environment', []):
if SECRET_NAME.search(item['name']) and not item['sensitive']:
item.update(sensitive=True, example='', required=True)
asked.append(item['name'])
return asked
def _name(ui, template: dict[str, Any]) -> str:
default = normalize_app_id(source_text(template['catalog_ui'].get('title'))
or template['container_contract'].get('container_name') or 'oci-app')
name = normalize_app_id(ui.ask(translate('Name for this application'), default))
if not name:
raise UserCancelled(translate('No name was given'))
template['catalog_ui']['title'] = {'en_US': name}
template['container_contract']['container_name'] = name
return name
def explore(ui) -> None:
"""Reads a Compose file, a docker run command or an image reference,
reports what ProxMenux would install from it and installs it."""
text, origin = read_definition(ui)
notes = [line.partition(':')[2].strip() for line in text.splitlines() if line.startswith('#note:')]
notes += ignored_settings(text)
template = template_from_compose(text)
title = translate('Image that is not in the catalog')
summary = describe(template)
available, report = registry_report(template['container_contract']['image']['reference'])
summary += '\n\n' + report
if notes:
summary += '\n\n' + '\n'.join(notes)
if not available:
advice = translate('Some projects publish a Dockerfile and not an image: it has to be built '
'and published to a registry before it can be installed this way. An image '
'of a private registry needs credentials, which are not supported yet.')
ui.message(f'{summary}\n\n{advice}', title)
return
if not template.get('compatibility', {}).get('automatic_install_candidate'):
ui.message(f"{translate('This image cannot be installed as it is described:')}\n\n{summary}", title)
return
secrets = _ask_secrets(template)
if secrets:
summary += ('\n\n' + translate('These values are asked during the installation:') + ' '
+ ', '.join(secrets))
if not ui.review(f"{translate('This is what ProxMenux understood from the')} {origin}:\n\n{summary}",
title, question=translate('Install this image?'), default=False):
return
mode = ui.choose(translate('How is it installed?'),
[(DEFAULT_MODE, translate('Default: only what the application needs')),
(ADVANCED_MODE, translate('Advanced: every setting of the container'))],
DEFAULT_MODE, title=title)
if mode is None:
return
# The record of the instance keeps the whole template, which is what an
# update, a recreation or a removal read later.
install_template(ui, template, _name(ui, template), mode)
+63
View File
@@ -0,0 +1,63 @@
"""Optional user mounts, separate from the image's required persistence."""
from pathlib import PurePosixPath
from .i18n import translate
from .ui import UserCancelled
def valid_path(value):
if (not value.startswith('/') or value == '/' or
any(c.isspace() or c in ',\x00' for c in value) or
any(part in ('.', '..') for part in value.split('/'))):
raise ValueError(translate('Invalid absolute path; avoid spaces, commas and relative segments'))
value = str(PurePosixPath(value))
if value.startswith('//'):
raise ValueError(translate('Invalid path'))
return value
def overlaps(a, b):
return a == b or a.startswith(b.rstrip('/') + '/') or b.startswith(a.rstrip('/') + '/')
def validate_mount(mount, existing):
target = valid_path(mount['container_path'])
protected = ('/bin', '/sbin', '/etc', '/usr', '/lib', '/lib64', '/proc', '/sys', '/dev', '/run')
if any(overlaps(target, p) for p in protected):
raise ValueError(translate('The custom path cannot hide system directories'))
if any(overlaps(target, valid_path(m['container_path'])) for m in existing):
raise ValueError(translate('The custom path overlaps another mount'))
if mount['type'] == 'managed-volume':
if int(mount['size_gb']) < 1 or not mount.get('backup'):
raise ValueError(translate('Invalid internal volume'))
elif mount['type'] == 'host-bind':
valid_path(mount['source'])
if mount.get('backup'):
raise ValueError(translate('Bind mounts are not included in vzdump'))
else:
raise ValueError(translate('Invalid mount type'))
return target
def ask_custom_mounts(ui, mounts, storage):
result = list(mounts)
while ui.confirm(translate('Add an extra custom path'), False):
target = ui.ask(translate('Path inside the container (e.g. /media-extra)'))
mode = ui.choose(translate('Data location'), [
('managed-volume', translate('Container volume (included in backups)')),
('host-bind', translate('Host directory (not included in Proxmox backups)')),
], 'managed-volume')
if mode is None:
raise UserCancelled(translate('Custom path cancelled'))
mount = {'type': mode, 'container_path': target, 'custom': True,
'source': storage, 'size_gb': None, 'backup': mode == 'managed-volume',
'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)
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')
mount['container_path'] = validate_mount(mount, result)
result.append(mount)
return result
+205
View File
@@ -0,0 +1,205 @@
from __future__ import annotations
import json
import os
import re
import time
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass
from typing import Any
API_ROOT = "https://api.github.com"
RAW_ROOT = "https://raw.githubusercontent.com"
PROXMENUX_HELPERS_URL = (
"https://raw.githubusercontent.com/MacRimi/ProxMenux/develop/json/helpers_cache.json"
)
CASAOS_REPOSITORY = "IceWhaleTech/CasaOS-AppStore"
CASAOS_REPOSITORY_URL = f"https://github.com/{CASAOS_REPOSITORY}"
CATEGORY_TAGS = {
"*Arr Suite": "arr",
"AI / Coding & Dev-Tools": "ai",
"Adblock & DNS": "adblock",
"Authentication & Security": "security",
"Automation & Scheduling": "automation",
"Backup & Recovery": "backup",
"Business & ERP": "business",
"Communication & Community": "communication",
"Containers & Docker": "containers",
"Dashboards & Frontends": "dashboards",
"Databases": "databases",
"Documents & Notes": "documents",
"Files & Downloads": "downloads",
"Finance & Budgeting": "finance",
"Gaming & Leisure": "gaming",
"Host Management": "management",
"IoT & Smart Home": "smarthome",
"Media & Streaming": "media",
"Messaging & Queues": "messaging",
"Miscellaneous": "misc",
"Monitoring & Analytics": "monitoring",
"NVR & Cameras": "nvr",
"Network & Firewall": "network",
"Operating Systems & Appliances": "systems",
"Productivity & Workflows": "productivity",
"Remote Access & VPN": "remote",
"Webservers & Proxies": "web",
"ZigBee, Z-Wave & Matter": "zigbee",
}
class SourceError(RuntimeError):
pass
@dataclass(frozen=True)
class Repository:
name: str
description: str
default_branch: str
html_url: str
pushed_at: str
category: str = "misc"
category_label: str = "Miscellaneous"
@property
def app_id(self) -> str:
return self.name.removeprefix("docker-")
class GitHubSource:
def __init__(self, token: str | None = None) -> None:
self.token = token or os.environ.get("GITHUB_TOKEN")
def _request(self, url: str, accept: str = "application/vnd.github+json") -> bytes:
headers = {
"Accept": accept,
"User-Agent": "ProxMenux-OCI-Lab/0.1",
"X-GitHub-Api-Version": "2022-11-28",
}
if self.token:
headers["Authorization"] = f"Bearer {self.token}"
last_error: Exception | None = None
for attempt in range(3):
request = urllib.request.Request(url, headers=headers)
try:
with urllib.request.urlopen(request, timeout=30) as response:
return response.read()
except urllib.error.HTTPError as exc:
last_error = exc
remaining = exc.headers.get("X-RateLimit-Remaining")
if exc.code == 403 and remaining == "0":
raise SourceError(
f"GitHub devolvio HTTP 403 para {url}. "
"Define GITHUB_TOKEN para ampliar el limite de la API."
) from exc
if exc.code not in {429, 500, 502, 503, 504}:
raise SourceError(f"GitHub devolvio HTTP {exc.code} para {url}.") from exc
except (urllib.error.URLError, TimeoutError) as exc:
last_error = exc
if attempt < 2:
time.sleep(1.5 * (attempt + 1))
reason = getattr(last_error, "reason", last_error)
raise SourceError(f"No se pudo acceder a {url} despues de 3 intentos: {reason}") from last_error
def get_json(self, path_or_url: str) -> Any:
url = path_or_url if path_or_url.startswith("https://") else f"{API_ROOT}{path_or_url}"
return json.loads(self._request(url).decode("utf-8"))
def get_text(self, url: str) -> str:
return self._request(url, accept="text/plain").decode("utf-8")
def list_linuxserver_repositories(self) -> list[Repository]:
repos: list[Repository] = []
page = 1
while True:
payload = self.get_json(
f"/orgs/linuxserver/repos?type=public&sort=full_name&per_page=100&page={page}"
)
if not payload:
break
for item in payload:
name = item.get("name", "")
if not self._is_application_repository(item, name):
continue
repos.append(
Repository(
name=name,
description=item.get("description") or "",
default_branch=item.get("default_branch") or "master",
html_url=item.get("html_url") or f"https://github.com/linuxserver/{name}",
pushed_at=item.get("pushed_at") or "",
)
)
if len(payload) < 100:
break
page += 1
return sorted(repos, key=lambda repo: repo.app_id.casefold())
def proxmenux_app_metadata(self) -> dict[str, dict[str, Any]]:
payload = self.get_json(PROXMENUX_HELPERS_URL)
if not isinstance(payload, list):
raise SourceError("El catalogo de aplicaciones de ProxMenux no es una lista")
result: dict[str, dict[str, Any]] = {}
for item in payload:
if not isinstance(item, dict) or not item.get("slug"):
continue
category_names = item.get("category_names") or []
category_label = category_names[0] if category_names else "Miscellaneous"
result[str(item["slug"]).casefold()] = {
"category": CATEGORY_TAGS.get(category_label, "misc"),
"category_label": category_label,
}
return result
def casaos_state(self) -> dict[str, Any]:
commit = self.get_json(f"/repos/{CASAOS_REPOSITORY}/commits/main")
revision = str(commit["sha"])
tree = self.get_json(f"/repos/{CASAOS_REPOSITORY}/git/trees/{revision}?recursive=1")
if tree.get("truncated"):
raise SourceError("GitHub devolvio truncado el arbol del catalogo CasaOS")
paths = sorted(
str(item["path"])
for item in tree.get("tree", [])
if item.get("type") == "blob"
and re.fullmatch(r"Apps/[^/]+/docker-compose\.yml", str(item.get("path", "")))
)
if not paths:
raise SourceError("No se encontraron Compose en el catalogo CasaOS")
return {
"repository": CASAOS_REPOSITORY_URL,
"revision": revision,
"pushed_at": str(commit.get("commit", {}).get("committer", {}).get("date") or ""),
"paths": paths,
}
def casaos_compose(self, path: str, revision: str) -> tuple[str, str]:
encoded_path = urllib.parse.quote(path, safe="/")
raw_url = f"{RAW_ROOT}/{CASAOS_REPOSITORY}/{revision}/{encoded_path}"
return self.get_text(raw_url), raw_url
@staticmethod
def _is_application_repository(item: dict[str, Any], name: str) -> bool:
if item.get("archived") or item.get("fork") or not name.startswith("docker-"):
return False
infrastructure_prefixes = (
"docker-baseimage-",
"docker-ci",
"docker-jenkins",
"docker-mod",
"docker-qemu",
)
return not name.startswith(infrastructure_prefixes)
def readme(self, repo: Repository) -> tuple[str, str, str]:
commit = self.get_json(f"/repos/linuxserver/{repo.name}/commits/{repo.default_branch}")
revision = commit["sha"]
raw_url = f"{RAW_ROOT}/linuxserver/{repo.name}/{revision}/README.md"
return self.get_text(raw_url), revision, raw_url
def readme_at_branch(self, repo: Repository) -> str:
raw_url = f"{RAW_ROOT}/linuxserver/{repo.name}/{repo.default_branch}/README.md"
return self.get_text(raw_url)
+88
View File
@@ -0,0 +1,88 @@
"""Image-specific GPU contracts, applied after catalog overlays on regeneration."""
from __future__ import annotations
from typing import Any
from .i18n import translate
LSIO_DEVICE_INIT = {"boinc", "emby", "jellyfin", "plex", "tvheadend"}
def apply_gpu_contract(template: dict[str, Any]) -> None:
profile = template.get("proxmox", {}).get("installer_profile", {})
if profile.get('selkies'):
apply_selkies_contract(template)
groups = [profile, *profile.get("hardware_acceleration", {}).get("profiles", [])]
requests = [item for group in groups for key in ("device_requests", "optional_devices")
for item in group.get(key, [])]
gpu = [item for item in requests if item.get("kind") == "nvidia-runtime"
or str(item.get("host_path_default", "")).startswith(("/dev/dri", "/dev/kfd"))]
if not gpu:
return
image = template.get("container_contract", {}).get("image", {}).get("reference", "")
repository = image.split("@", 1)[0].split(":", 1)[0]
for item in gpu:
if str(item.get("host_path_default", "")).startswith("/dev/dri/renderD"):
item["container_path_strategy"] = "same-as-host"
profile["gpu_validation"] = {
"device_inventory": "host-sysfs-and-stat",
"application_acceleration": "requires-workload-test",
"tone_mapping": "not-implied-by-device-access",
}
if repository in {"lscr.io/linuxserver/" + app for app in LSIO_DEVICE_INIT} or profile.get('selkies'):
profile["device_permissions"] = {
"strategy": "linuxserver-native-init",
"service_user": "abc",
"environment": "ATTACHED_DEVICES_PERMS",
"paths": "all-resolved-selected-character-devices",
}
elif repository == "jlesage/handbrake":
for item in gpu:
item["append_host_device_gid_to_environment"] = "SUP_GROUP_IDS"
def apply_selkies_contract(template):
profile = template['proxmox']['installer_profile']
if not template['container_contract']['image']['reference'].startswith('lscr.io/linuxserver/'):
raise ValueError(translate('The Selkies profile requires a verified LinuxServer image'))
environment = template['container_contract']['environment']
if not any(e['name'] == 'LC_ALL' for e in environment):
environment.append({'name': 'LC_ALL', 'example': '', 'required': False,
'sensitive': False, 'source': 'upstream-documentation',
'prompt': 'Language/locale (e.g. es_ES.UTF-8; translation of every application is not guaranteed)'})
mounts = profile.setdefault('tmpfs_mounts', [])
if not any(m['container_path'] == '/run/nginx' for m in mounts):
mounts.append({'id': 'nginx-runtime', 'container_path': '/run/nginx',
'default_size_mb': 1, 'minimum_size_mb': 1, 'prompt_size': False,
'mount_options': ['rw', 'nosuid', 'nodev', 'mode=0755']})
if profile.get('hardware_acceleration') or profile.get('device_requests'):
return
profile['optional_devices'] = [d for d in profile.get('optional_devices', [])
if not str(d.get('host_path_default', '')).startswith('/dev/dri')]
profile['hardware_acceleration'] = {
'prompt': 'Selkies desktop and streaming acceleration', 'default': 'none',
'profiles': [
{'id': 'none', 'label': 'No GPU (CPU)', 'device_requests': [],
'environment': [{'name': 'AUTO_GPU', 'value': 'false'}]},
{'id': 'vaapi', 'label': 'Intel/AMD (streaming rendering and encoding)',
'device_requests': [{'id': 'selkies-render', 'kind': 'character-device',
'path_prompt': 'Intel/AMD render node', 'host_path_default': '/dev/dri/renderD128',
'container_path_strategy': 'same-as-host', 'mode': '0660',
'deny_write': False, 'gid_strategy': 'host-device-gid',
'drm_vendor_ids': ['0x8086', '0x1002']}],
'environment': [{'name': 'PIXELFLUX_WAYLAND', 'value': 'true'},
{'name': 'AUTO_GPU', 'value': 'false'}],
'environment_from_devices': {'DRINODE': ['selkies-render'],
'DRI_NODE': ['selkies-render'],
'ATTACHED_DEVICES_PERMS': ['selkies-render']}}
]}
def apply_profile_image(template, hardware_profile):
"""Resolve an upstream image channel declared by the selected GPU profile."""
import copy
profiles = template.get('proxmox', {}).get('installer_profile', {}).get('hardware_acceleration', {}).get('profiles', [])
selected = next((profile for profile in profiles if profile['id'] == hardware_profile), {})
if selected.get('image'):
template['container_contract']['image'] = copy.deepcopy(selected['image'])
+82
View File
@@ -0,0 +1,82 @@
"""Facts read from the local Proxmox node: storages, bridges and the timezone."""
from __future__ import annotations
import ipaddress
import json
import subprocess
from pathlib import Path
from typing import Any
def _pvesh(path: str, *arguments: str) -> list[dict[str, Any]]:
try:
result = subprocess.run(["pvesh", "get", path, "--output-format", "json", *arguments],
capture_output=True, text=True, timeout=30, check=False)
rows = json.loads(result.stdout) if result.returncode == 0 else []
except (OSError, ValueError, subprocess.TimeoutExpired):
return []
return [row for row in rows if isinstance(row, dict)] if isinstance(rows, list) else []
def storages(content: str) -> list[dict[str, Any]]:
"""Active storages of this node that accept `content` (rootdir, vztmpl, ...),
the one with most free space first."""
rows = [row for row in _pvesh("/nodes/localhost/storage", "--content", content, "--enabled", "1")
if row.get("active") and row.get("storage")]
return sorted(rows, key=lambda row: -(row.get("avail") or 0))
def default_storage(content: str, preferred: str) -> str:
names = [row["storage"] for row in storages(content)]
if preferred in names or not names:
return preferred
return names[0]
# Private networks that ProxMenux creates for multi-container applications.
PRIVATE_STACK_NETWORK = ipaddress.ip_network("10.77.0.0/16")
def _private_stack_bridge(row: dict[str, Any]) -> bool:
if row.get("bridge_ports") or not row.get("cidr"):
return False
try:
return ipaddress.ip_interface(row["cidr"]).network.subnet_of(PRIVATE_STACK_NETWORK)
except (TypeError, ValueError):
return False
def bridges(include_private: bool = False) -> list[dict[str, Any]]:
"""Bridges of this node; the private networks of multi-container
applications are left out unless `include_private` is set."""
rows = [row for row in _pvesh("/nodes/localhost/network", "--type", "any_bridge") if row.get("iface")
and (include_private or not _private_stack_bridge(row))]
return sorted(rows, key=lambda row: row["iface"])
def default_bridge(preferred: str) -> str:
names = [row["iface"] for row in bridges()]
if preferred in names or not names:
return preferred
return names[0]
def timezone() -> str:
try:
value = Path("/etc/timezone").read_text(encoding="utf-8").strip()
except OSError:
value = ""
if not value:
try:
value = subprocess.run(["timedatectl", "show", "-p", "Timezone", "--value"],
capture_output=True, text=True, timeout=10, check=False).stdout.strip()
except (OSError, subprocess.TimeoutExpired):
value = ""
return value or "Etc/UTC"
def gib(value: Any) -> int:
try:
return int(value) // 2**30
except (TypeError, ValueError):
return 0
+65
View File
@@ -0,0 +1,65 @@
"""ProxMenux text lookup, with the same cache and rules as translate() in utils.sh.
The language comes from /usr/local/share/proxmenux/config.json and the
translations from lang/<language>.json, both maintained by ProxMenux. English
is the source language: a missing translation returns the English text.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
BASE_DIR = Path("/usr/local/share/proxmenux")
_language: str | None = None
_cache: dict[str, str] | None = None
def language() -> str:
global _language
if _language is None:
try:
value = json.loads((BASE_DIR / "config.json").read_text(encoding="utf-8")).get("language")
except (OSError, ValueError, AttributeError):
value = None
_language = value if isinstance(value, str) and value else "en"
return _language
def N_(text: str) -> str:
"""Marks a literal kept in a table for the translation cache; the text is
translated where it is displayed."""
return text
def translate(text: str) -> str:
global _cache
if language() == "en":
return text
if _cache is None:
try:
data = json.loads((BASE_DIR / "lang" / f"{language()}.json").read_text(encoding="utf-8"))
_cache = {str(key): str(value) for key, value in data.items()} if isinstance(data, dict) else {}
except (OSError, ValueError):
_cache = {}
return _cache.get(text) or text
def source_text(value: Any) -> str:
"""The English a catalog field was written in.
Catalog text used to be stored per locale, which meant a second
translation system beside `translate()` and, in practice, one language
of the eight. The catalog now carries the source text only: whatever is
shown to the reader goes through `translate()` like every other string
in ProxMenux.
"""
# Stripped: the translation cache stores its keys stripped, so a field
# that carries a stray trailing newline would never find its translation
# and would silently render in English.
if isinstance(value, str):
return value.strip()
if not isinstance(value, dict):
return ""
return str(value.get("en_US") or "").strip()
+51
View File
@@ -0,0 +1,51 @@
"""The downloaded OCI images an installation was created from: the container
does not need them once it exists, so the user may delete them."""
from __future__ import annotations
import json
import subprocess
import sys
from pathlib import Path
from typing import Any
from . import console
from .i18n import translate
PROJECT_ROOT = Path(__file__).resolve().parents[2]
def _cache(command: str, vmids: list[int]) -> dict[str, Any] | None:
result = subprocess.run([sys.executable, str(PROJECT_ROOT / "remote" / "oci_image_cache.py"), command,
*map(str, vmids)], capture_output=True, text=True, check=False)
try:
return json.loads(result.stdout) if result.returncode == 0 else None
except ValueError:
return None
def size_text(size: int) -> str:
return f"{size / 1024**3:.1f} GB" if size >= 1024**3 else f"{max(1, round(size / 1024**2))} MB"
def offer_removal(ui, vmids: list[int]) -> tuple[bool, str | None]:
"""Asks whether to delete the images of these installations. Returns whether
the question was shown and the line to report when they were deleted."""
listed = _cache("list", vmids)
archives = (listed or {}).get("archives") or []
if not archives:
return False, None
total = size_text(sum(item["size"] for item in archives))
if len(archives) == 1:
text = (f"{translate('The container was created from a downloaded OCI image')} ({total}). "
f"{translate('The container does not need it any more; an update downloads the new version when there is one.')}"
f"\n\n{translate('Delete the image to free the space?')}")
else:
text = (f"{translate('The containers were created from downloaded OCI images')} ({len(archives)}, {total}). "
f"{translate('The containers do not need them any more; an update downloads the new versions when there are any.')}"
f"\n\n{translate('Delete the images to free the space?')}")
if not console.ask_yes_no(text, True):
return True, None
removed = _cache("remove", vmids)
if not removed or not removed.get("freed"):
return True, None
return True, f"{translate('Downloaded OCI images deleted:')} {size_text(removed['freed'])}"
File diff suppressed because it is too large Load Diff
+353
View File
@@ -0,0 +1,353 @@
"""Local PVE instance selection and explicit recovery UI."""
from __future__ import annotations
import json
import os
from pathlib import Path
import re
import shutil
import subprocess
import sys
import tempfile
from . import images
from .i18n import N_, source_text, translate
STATUS_LABELS = {'installed': N_('installed'), 'failed': N_('failed'), 'updating': N_('updating'),
'recovering': N_('recovering'), 'installing': N_('installing'),
'assembling': N_('assembling')}
def public_row(record, decision):
return {'vmid': record['vmid'], 'hostname': record.get('deployment', {}).get('hostname',
f'OCI {record["vmid"]}'),
'title': source_text(record.get('template', {}).get('catalog_ui', {}).get('title')),
'image': str(record.get('template', {}).get('container_contract', {}).get('image', {})
.get('reference', '')).split('@', 1)[0].rsplit('/', 1)[-1],
'status': record['status'], 'reason': decision.get('reason'),
'pending': bool(record.get('pending_transaction')),
'stack_pending': bool(record.get('pending_stack_transaction')),
'stack': bool(record.get('stack') or record.get('stack_member')
or record.get('native_stack_intent'))}
def inventory(project, progress=None):
sys.path.insert(0, str(project / 'remote'))
import oci_instances as instances
with instances.locked(instances.ROOT):
resources = json.loads(instances.command('pvesh', 'get', '/cluster/resources',
'--type', 'vm', '--output-format', 'json'))
registered = set()
for directory in instances.ROOT.iterdir():
if directory.name.isdecimal() and instances.has_contract(instances.ROOT, int(directory.name)):
vmid = int(directory.name)
registered.add(vmid)
record = instances.read(instances.ROOT, vmid)
registered.update(member['vmid'] for member in record.get('stack', {}).get('members', []))
configs = {}
selected = [row for row in resources if row.get('type') == 'lxc'
and int(row['vmid']) in registered]
for index, row in enumerate(selected, 1):
if progress:
progress(f"{translate('Checking OCI')} {index}/{len(selected)}: CT {row['vmid']}...")
if row.get('type') == 'lxc':
configs[int(row['vmid'])] = instances.command('pvesh', 'get',
f'/nodes/{row["node"]}/lxc/{row["vmid"]}/config', '--output-format', 'json')
decisions = instances.reconcile(instances.ROOT, resources, configs)
return [public_row(instances.read(instances.ROOT, d['vmid']), d)
for d in decisions if d['action'] == 'keep']
def saved_inventory(project):
"""Open the selector without invoking Proxmox or changing saved contracts."""
sys.path.insert(0, str(project / 'remote'))
import oci_instances as instances
with instances.locked(instances.ROOT):
rows = []
for directory in sorted(instances.ROOT.iterdir(), key=lambda path: path.name.zfill(10)):
if not (directory.name.isdecimal() and instances.has_contract(instances.ROOT, int(directory.name))
and instances.guest_exists(int(directory.name))):
continue
record = instances.read(instances.ROOT, int(directory.name))
row = public_row(record, {'reason': 'not-yet-checked'})
row['title'] = row['title'] or _stack_title(instances, record)
if row['hostname'] == f"OCI {record['vmid']}":
row['hostname'] = _config_hostname(record['vmid']) or row['hostname']
rows.append(row)
return rows
def _stack_title(instances, record):
"""Members of a dedicated stack keep a minimal template: name them after the stack."""
primary_id = record.get('stack_member', {}).get('primary_vmid', record['vmid'])
try:
primary = record if primary_id == record['vmid'] else instances.read(instances.ROOT, primary_id)
except (OSError, ValueError, KeyError):
return ''
for source in (primary.get('stack', {}), primary.get('native_stack_intent', {})):
title = source_text(source.get('template', {}).get('catalog_ui', {}).get('title'))
if title:
role = record.get('deployment', {}).get('role') or record.get('stack_member', {}).get('name')
return f"{title} · {role}" if role and primary_id != record['vmid'] else title
return ''
def _config_hostname(vmid):
for path in Path('/etc/pve/nodes').glob(f'*/lxc/{vmid}.conf'):
try:
for line in path.read_text().splitlines():
if line.startswith('hostname:'):
return line.split(':', 1)[1].strip()
if line.startswith('['):
break
except OSError:
continue
return ''
def check_selected(project, row):
"""Validate only the chosen container; lifecycle backends validate its stack."""
sys.path.insert(0, str(project / 'remote'))
import oci_instances as instances
with instances.locked(instances.ROOT):
record = instances.read(instances.ROOT, row['vmid'])
config = instances.command('pct', 'config', str(row['vmid']))
marker = instances.identity(config)
reason = 'matched' if marker == record['installation_id'] else 'identity-unconfirmed'
return public_row(record, {'reason': reason})
def _run_lifecycle(command, title):
"""The lifecycle programs print their own steps: they run on a clean screen
and their result stays readable until the user returns to the menu."""
from . import console
console.show_logo()
console.msg_title(title)
environment = dict(os.environ, OCI_SPINNER='1' if sys.stdout.isatty() else '0')
completed = subprocess.run(command, env=environment, check=False)
console.wait_for_enter(translate('Press Enter to return to the menu...'))
return completed.returncode == 0
def interactive_management(project, ui):
try:
_interactive_management(project, ui)
except BlockingIOError:
ui.message(translate('Another OCI operation is using the instance registry. Wait for it to finish and open this menu again; no container is modified.'),
translate('OCI management'))
except (OSError, ValueError, RuntimeError, subprocess.CalledProcessError):
ui.message(translate('OCI management could not be completed. Check the backend status; no additional cleanup has been authorized.'), translate('OCI management'))
def _interactive_management(project, ui):
if os.geteuid() != 0 or not shutil.which('pct'):
ui.message(translate('This interface runs on the Proxmox node as root. Open proxmenux-oci.sh on the Proxmox host.'), translate('OCI management'))
return
rows = saved_inventory(project)
if not rows:
ui.message(translate('No OCI instances are registered.'), translate('OCI management'))
return
# Same layout as the catalog lists; only an unusual state is shown.
tag_width = max(len(str(r['vmid'])) for r in rows)
header = f" {'CT':<{tag_width + 2}}{translate('Application')[:32]:<32} {translate('Image')}"
options = []
for r in rows:
line = f"{(r['title'] or r['hostname'])[:32]:<32} "
if r['status'] == 'installed':
line += r['image'][:30]
else:
line += f"\\Z1{translate(STATUS_LABELS.get(r['status'], r['status']))}\\Zn"
options.append((str(r['vmid']), f"{line:<72}"))
selection = ui.choose(header, options, size=(22, 75, 15), colors=True,
title=translate('Manage installed OCI applications'))
if selection is None:
return
row = next(r for r in rows if str(r['vmid']) == selection)
row = check_selected(project, row)
if row['reason'] != 'matched':
ui.message(translate('The selected CT does not match its OCI record. Its configuration will not be modified or deleted.'), translate('OCI management'))
return
if row['stack']:
_manage_stack(project, ui, row)
return
if not row['pending']:
if row['status'] != 'installed' or row['reason'] != 'matched':
ui.message(translate('The instance identity or status must be reviewed before updating.'), translate('OCI management'))
return
action = ui.choose(translate('Manage OCI'), [('update', translate('Update the image with the saved configuration')),
('recreate', translate('Recreate: edit resources, network, paths and GPU')),
('remove', translate('Remove: delete the application and its containers'))], 'update')
if action is None:
return
sys.path.insert(0, str(project / 'remote'))
import oci_instances as instances
record = instances.read(instances.ROOT, row['vmid'])
if action == 'remove':
_remove(project, ui, row['vmid'])
return
proposal = None
if action == 'recreate':
from .recreation import edit_recreation
from .cli import _deployment_summary_text
proposal = edit_recreation(record, ui)
if not ui.review(_deployment_summary_text(proposal['candidate']['template'],
proposal['candidate']['deployment']), translate('Recreate OCI'),
question=translate('Recreate with these options?'), default=True):
return
elif not ui.review(translate('The current image of the saved channel will be checked and downloaded. Resources, paths and GPU are kept. The CT is stopped during the replacement and a native backup is created first.'),
translate('Update OCI'), question=translate('Update now?'), default=True):
return
command = [sys.executable, str(project / 'remote/oci_update_current.py'), str(row['vmid'])]
desired = proposal['candidate'] if proposal else record
if any(m['type'] == 'host-bind' for m in desired['deployment'].get('mounts', [])):
if not ui.confirm(translate('Shared host data is not reverted by the backup. Continue?'), False):
return
command.append('--acknowledge-external-data')
title = translate('Recreate OCI') if proposal else translate('Update OCI')
if proposal is None:
completed = _run_lifecycle(command, title)
else:
# Saved environment/secrets must never be passed on the command line.
with tempfile.NamedTemporaryFile(mode='w', suffix='.json') as file:
json.dump(proposal, file)
file.flush()
completed = _run_lifecycle(command + ['--proposal', file.name], title)
if completed:
images.offer_removal(ui, [row['vmid']])
return
action = ui.choose(translate('Interrupted operation'), [('status', translate('View status')),
('recover', translate('Recover the previous installation'))], 'status')
if action is None:
return
if action == 'recover' and not ui.review(
translate('The previous native backup will be restored. Shared host directories are not reverted. Displaced disks are kept.'),
translate('Recover OCI'), question=translate('Recover now?'), default=True):
return
_run_lifecycle([sys.executable, str(project / 'remote/oci_instance_transaction.py'),
action, str(row['vmid'])],
translate('Recover OCI') if action == 'recover' else translate('OCI management'))
def _removal_summary(project, vmid):
"""What the removal deletes and what it keeps, read from the containers
themselves. A container of a multi-container application is never removed
on its own."""
sys.path.insert(0, str(project / 'remote'))
import oci_instances as instances
import oci_remove
from oci_installation_state import parse_config
primary_id, primary, members = oci_remove.members_of(instances.ROOT, vmid)
titles = [(primary.get('template') or {}).get('catalog_ui', {}).get('title'),
((primary.get('stack') or {}).get('template') or {}).get('catalog_ui', {}).get('title')]
application = next((source_text(title) for title in titles if source_text(title)), f'CT {primary_id}')
lines, volumes, kept = [], [], []
for member in members:
try:
record = instances.read(instances.ROOT, member)
except (OSError, ValueError, KeyError):
record = {}
config = oci_remove.guest_config(member)
cfg = parse_config(config) if config else {}
name = (cfg.get('hostname') or (record.get('stack_member') or {}).get('name')
or record.get('deployment', {}).get('hostname') or '')
lines.append(f' CT {member} {name}')
size = re.search(r'size=(\S+)', cfg.get('rootfs', ''))
volumes.append(f" CT {member}: rootfs {size.group(1) if size else '-'}")
for key, value in cfg.items():
if not re.fullmatch(r'mp[0-9]+', key):
continue
source, *rest = value.split(',')
options = dict(item.split('=', 1) for item in rest if '=' in item)
target = options.get('mp', '')
if source.startswith('/'):
kept.append(source)
else:
volumes.append(f" CT {member}: {target} ({options.get('size', '-')})")
for path in oci_remove.host_directories(instances.ROOT, members):
if path not in kept:
kept.append(path)
bridge = oci_remove.private_bridge(primary)
text = []
if len(members) > 1 and vmid == primary_id:
text += [f"{application} {translate('runs in')} {len(members)} {translate('containers')}. "
f"{translate('All of them are removed.')}", '']
elif len(members) > 1:
alone = translate('It cannot be removed on its own, because the application would stop '
'working: continuing removes the whole application.')
text += [f"CT {vmid} {translate('is one of the')} {len(members)} "
f"{translate('containers of')} {application}. {alone}", '']
text += [translate('Containers that are removed:'), *lines, '',
translate('Data that is deleted with them:'), *volumes]
if bridge:
text += ['', f"{translate('Private network of the application that is released:')} {bridge}"]
if kept:
text += ['', translate('Host directories that are kept, with their content:'),
*[f' {path}' for path in kept]]
else:
text += ['', translate('No host directory is used by this application.')]
return '\n'.join(text)
def _remove(project, ui, vmid):
try:
summary = _removal_summary(project, vmid)
except (OSError, ValueError, KeyError) as error:
ui.message(f"{translate('The removal could not be prepared:')} {error}", translate('Remove OCI'))
return
if not ui.review(summary, translate('Remove OCI'),
question=translate('Remove it? The data of its containers cannot be recovered afterwards.'),
default=False):
return
_run_lifecycle([sys.executable, str(project / 'remote/oci_remove.py'), str(vmid)],
translate('Remove OCI'))
def _manage_stack(project, ui, row):
sys.path.insert(0, str(project / 'remote'))
import oci_instances as instances
record = instances.read(instances.ROOT, row['vmid'])
primary_id = record.get('stack_member', {}).get('primary_vmid', row['vmid'])
primary = instances.read(instances.ROOT, primary_id)
pending = primary.get('pending_stack_transaction')
if not pending:
members = primary.get('stack', {}).get('members', [])
import oci_stack_replay
needs_replay = any(m.get('native_stack_intent') or
m.get('deployment', {}).get('rootfs_adaptation_replay_required') for m in members)
if not members or (needs_replay and not (
oci_stack_replay.nextcloud_menu_ready(primary) or
oci_stack_replay.paperless_menu_ready(primary) or
oci_stack_replay.tandoor_menu_ready(primary) or
oci_stack_replay.immich_menu_ready(primary))):
ui.message(translate('This stack requires replaying specific rootfs adaptations. Coordinated updates are not yet enabled for it.'), translate('OCI stack management'))
return
action = ui.choose(translate('Manage OCI stack'),
[('update', translate('Update every container of the application')),
('remove', translate('Remove: delete the application and its containers'))], 'update')
if action is None:
return
if action == 'remove':
_remove(project, ui, primary_id)
return
if not ui.review(f"{translate('All stack members are updated together. Main CT:')} {primary_id}, "
f"{translate('members:')} {len(members)}. "
f"{translate('All images are downloaded and verified first, and native backups are taken with the stack stopped. Contracts are published after the whole set is checked. If anything fails, all members are recovered.')}",
translate('Update OCI stack'), question=translate('Update the whole stack?'), default=True):
return
else:
if not ui.review(translate('A coordinated operation is pending. The whole previous stack will be recovered, not only the selected member. If the operation already finished, the cleanup of its markers is completed.'), translate('Recover OCI stack'),
question=translate('Recover or complete the operation?'), default=True):
return
import json
members = json.loads(Path(pending).read_text())['plan']['members']
command = [sys.executable, str(project / 'remote/oci_stack_native.py'), str(primary_id)]
if pending:
command.append('--recover')
if any(mount['type'] == 'host-bind' for member in members
for mount in member.get('deployment', {}).get('mounts', [])):
if not ui.confirm(translate('Shared host data is not reverted by the backups. Continue?'), False):
return
command.append('--acknowledge-external-data')
completed = _run_lifecycle(command, translate('Recover OCI stack') if pending else translate('Update OCI stack'))
if completed and not pending:
images.offer_removal(ui, [int(member['vmid']) for member in members])
+125
View File
@@ -0,0 +1,125 @@
"""IPv4 address of the containers on their access bridge: DHCP or static."""
from __future__ import annotations
import ipaddress
import re
from pathlib import Path
from . import host
from .i18n import translate
from .ui import UserCancelled
DHCP = "dhcp"
STATIC = "static"
def usable(address: ipaddress.IPv4Address, interface: ipaddress.IPv4Interface) -> bool:
network = interface.network
return (address.version == 4 and not address.is_multicast and not address.is_unspecified
and not address.is_loopback and address in network
and (network.prefixlen >= 31
or address not in (network.network_address, network.broadcast_address)))
def addresses_in_use() -> set[str]:
"""IPv4 addresses assigned to guests of the cluster and to the bridges of this node."""
used: set[str] = set()
for pattern in ("*/lxc/*.conf", "*/qemu-server/*.conf"):
for path in Path("/etc/pve/nodes").glob(pattern):
try:
text = path.read_text(encoding="utf-8", errors="ignore")
except OSError:
continue
used.update(re.findall(r"(?:^|[,\s])ip=(\d+\.\d+\.\d+\.\d+)/", text, re.MULTILINE))
for row in host.bridges(include_private=True):
if row.get("cidr"):
used.add(row["cidr"].split("/", 1)[0])
return used
def _bridge(bridge: str) -> dict:
return next((row for row in host.bridges() if row.get("iface") == bridge), {})
def _example(bridge: str, taken: set[str]) -> str:
"""An address of the bridge subnet that no guest uses, to show the format."""
try:
network = ipaddress.ip_interface(_bridge(bridge).get("cidr") or "").network
except ValueError:
network = None
if network is None or network.version != 4 or network.prefixlen > 24:
return "192.168.1.100/24"
address = next((a for a in (network.network_address + n for n in range(100, 250))
if str(a) not in taken), network.network_address + 100)
return f"{address}/{network.prefixlen}"
def _static_address(ui, bridge: str, label: str, default: str, taken: set[str]) -> ipaddress.IPv4Interface:
text = (f"{translate('Static IPv4 address for')} {label}" if label
else translate("Static IPv4 address"))
example = _example(bridge, taken)
text = f"{text} ({translate('with prefix, e.g.')} {example})"
while True:
value = ui.ask(text, default).strip()
try:
interface = ipaddress.ip_interface(value) if "/" in value else None
except ValueError:
interface = None
if interface is None or interface.version != 4 or not usable(interface.ip, interface):
ui.message(f"{translate('Enter a usable IPv4 address with its prefix, for example')} {example}")
continue
if str(interface.ip) in taken:
ui.message(f"{translate('The address is already assigned on this host or cluster:')} {interface.ip}")
continue
return interface
def _gateway(ui, bridge: str, interfaces: list[ipaddress.IPv4Interface], default: str | None) -> str | None:
default = default or _bridge(bridge).get("gateway") or ""
try:
if default and not all(usable(ipaddress.ip_address(default), i) for i in interfaces):
default = ""
except ValueError:
default = ""
while True:
value = ui.ask(translate("IPv4 gateway (empty = no outbound route)"), default, required=False).strip()
if not value:
return None
try:
gateway = ipaddress.ip_address(value)
except ValueError:
gateway = None
if gateway and all(usable(gateway, i) and gateway != i.ip for i in interfaces):
return str(gateway)
ui.message(translate("The gateway must be another usable address in the same subnet."))
def ask_addresses(ui, bridge: str, labels: list[str], current: dict[str, str] | None = None,
current_gateway: str | None = None) -> tuple[dict[str, str], str | None]:
"""One DHCP or static choice for the containers named in `labels` on
`bridge`; static addresses share one gateway."""
current = current or {}
static = any(value and value != DHCP for value in current.values())
title = translate("IPv4 address of the container") if len(labels) == 1 else translate("IPv4 address of the containers")
mode = ui.choose(title, [(DHCP, translate("DHCP (automatic)")), (STATIC, translate("Static IP"))],
STATIC if static else DHCP)
if mode is None:
raise UserCancelled(title)
if mode == DHCP:
return {label: DHCP for label in labels}, None
taken = addresses_in_use()
interfaces: dict[str, ipaddress.IPv4Interface] = {}
for label in labels:
own = current.get(label) if current.get(label) != DHCP else None
if own:
taken.discard(own.split("/", 1)[0])
interfaces[label] = _static_address(ui, bridge, label if len(labels) > 1 else "", own or "", taken)
taken.add(str(interfaces[label].ip))
gateway = _gateway(ui, bridge, list(interfaces.values()), current_gateway)
return {label: str(interface) for label, interface in interfaces.items()}, gateway
def ask_ipv4(ui, bridge: str, current: str | None = None,
current_gateway: str | None = None) -> tuple[str, str | None]:
addresses, gateway = ask_addresses(ui, bridge, [""], {"": current} if current else None, current_gateway)
return addresses[""], gateway
+35
View File
@@ -0,0 +1,35 @@
"""Build separate update/recreate proposals without mutating live contracts."""
import copy
def propose(instance, operation, current_template=None, edited_deployment=None):
if operation not in ('update', 'recreate'):
raise ValueError('Operacion no soportada')
if instance.get('status') != 'installed':
raise ValueError('La instancia no esta completada')
if operation == 'update' and (current_template is not None or edited_deployment is not None):
raise ValueError('Actualizar no puede modificar la configuracion; usa Recrear')
candidate = copy.deepcopy(instance)
if operation == 'recreate':
if current_template is None or edited_deployment is None:
raise ValueError('Recrear requiere la plantilla actual y la configuracion confirmada')
if current_template['id'] != instance['template']['id']:
raise ValueError('No se puede sustituir silenciosamente la aplicacion')
if edited_deployment.get('vmid') != instance['vmid']:
raise ValueError('Recrear conserva la identidad y el VMID')
candidate['template'] = copy.deepcopy(current_template)
candidate['deployment'] = copy.deepcopy(edited_deployment)
old = {m['container_path']: m for m in instance['deployment'].get('mounts', [])}
new = {m['container_path']: m for m in candidate['deployment'].get('mounts', [])}
if len(new) != len(candidate['deployment'].get('mounts', [])):
raise ValueError('Rutas duplicadas')
changed_storage = [p for p in old.keys() & new.keys()
if (old[p].get('type'), old[p].get('source')) !=
(new[p].get('type'), new[p].get('source'))]
return {'operation': operation, 'installation_id': instance['installation_id'],
'candidate': candidate, 'requires_confirmation': True,
'mounts': {'reuse': sorted(old.keys() & new.keys() - set(changed_storage)),
'add': sorted(new.keys() - old.keys()),
'detach_without_delete': sorted(old.keys() - new.keys()),
'storage_change_requires_migration': sorted(changed_storage)},
'execution_enabled': False}
+234
View File
@@ -0,0 +1,234 @@
"""Conservative recreation editor: preserve saved state and add data routes."""
import copy
import re
from pathlib import Path, PurePosixPath
from .i18n import translate
from .ui import UserCancelled
def positive_integer(ui, prompt, value, minimum=1):
result = int(ui.ask(prompt, str(value)))
if result < minimum:
raise ValueError(f"{prompt}: {translate('minimum')} {minimum}")
return result
def absolute_path(value):
if (not value.startswith('/') or value == '/' or str(PurePosixPath(value)) != value
or '..' in PurePosixPath(value).parts or any(c.isspace() or c == ',' for c in value)):
raise ValueError(translate('The path must be absolute and normalized'))
return value
def edit_network(deployment, ui):
from . import network as access
from .installer import ask_bridge
network = deployment['network']
bridge = ask_bridge(ui, translate('Access bridge'), network['bridge'])
ipv4, gateway = access.ask_ipv4(ui, bridge, network.get('ipv4'), network.get('gateway'))
network.update(bridge=bridge, ipv4=ipv4, gateway=gateway)
def edit_acceleration(candidate, ui):
from .installer import configure_acceleration
deployment = candidate['deployment']
template = candidate.get('template', {})
installer = template.get('proxmox', {}).get('installer_profile', {})
hardware = installer.get('hardware_acceleration')
if not hardware or not ui.confirm(translate('Change GPU acceleration?'), False):
return
profiles = hardware.get('profiles', [])
reference = template.get('container_contract', {}).get('image', {}).get('reference', '')
linuxserver = reference.split(':')[0].startswith(('lscr.io/linuxserver/', 'linuxserver/', 'docker.io/linuxserver/'))
if not linuxserver and any(e.get('name') == 'DOCKER_MODS' for p in profiles for e in p.get('environment', [])):
raise ValueError(translate('Docker Mods are only offered for compatible LinuxServer images'))
owned_ids = {d['id'] for p in profiles for d in p.get('device_requests', [])}
owned_configs = {c['id'] for p in profiles for c in p.get('post_start_configurations', [])}
old_profile = next((p for p in profiles if p['id'] == deployment.get('hardware_profile')), {})
environment = copy.deepcopy(deployment.get('environment', []))
for old in old_profile.get('environment', []):
if old['name'] != 'DOCKER_MODS':
environment = [e for e in environment if not (e['name'] == old['name'] and str(e['value']) == str(old['value']))]
owned_names = set(old_profile.get('environment_from_devices', {}))
owned_names.update(e['name'] for d in old_profile.get('device_requests', []) for e in d.get('environment', []))
environment = [e for e in environment if e['name'] not in owned_names]
mods = next((e for e in environment if e['name'] == 'DOCKER_MODS'), None)
custom_mods = []
if mods:
official_mods = {str(e['value']) for p in profiles for e in p.get('environment', []) if e['name'] == 'DOCKER_MODS'}
custom_mods = [m for m in str(mods['value']).split('|') if m and m not in official_mods]
environment = [e for e in environment if e['name'] != 'DOCKER_MODS']
profile = copy.deepcopy(installer)
profile['optional_devices'] = []
profile['device_requests'] = []
profile['hardware_acceleration']['default'] = deployment.get('hardware_profile') or hardware.get('default')
devices, selected, configurations, environment = configure_acceleration(
profile, environment, deployment.get('security', {}).get('unprivileged', True), ui)
new_mods = next((e for e in environment if e['name'] == 'DOCKER_MODS'), None)
if custom_mods:
if new_mods:
new_mods['value'] = '|'.join(dict.fromkeys(custom_mods + str(new_mods['value']).split('|')))
else:
environment.append(dict(mods, value='|'.join(custom_mods)))
deployment['devices'] = [d for d in deployment.get('devices', []) if d.get('id') not in owned_ids] + devices
from .gpu import apply_profile_image
apply_profile_image(template, selected)
deployment['hardware_profile'] = selected
deployment['environment'] = environment
deployment['post_start_configurations'] = [c for c in deployment.get('post_start_configurations', []) if c.get('id') not in owned_configs] + configurations
deployment['device_permissions'] = installer.get('device_permissions') if deployment['devices'] else None
def edit_environment(deployment, ui):
environment = deployment.setdefault('environment', [])
while ui.confirm(translate('Change or add an environment variable?'), False):
name = ui.ask(translate('Variable name'))
if not re.fullmatch(r'[A-Za-z_][A-Za-z0-9_]*', name):
raise ValueError(translate('Invalid variable name'))
matches = [e for e in environment if e['name'] == name]
if len(matches) > 1:
raise ValueError(translate('Duplicate variable in the contract; review it before editing'))
old = matches[0] if matches else None
sensitive = bool(old and old.get('sensitive'))
if not sensitive:
sensitive = ui.confirm(translate('Is the value a password or secret?'), True)
value = ui.password(f"{translate('New value for')} {name}") if sensitive else ui.ask(
f"{translate('Value for')} {name}", old.get('value', '') if old else '', required=False)
if '\x00' in value:
raise ValueError(translate('The value contains an unsupported character'))
item = dict(old or {}, name=name, value=value, sensitive=sensitive)
if old:
environment[environment.index(old)] = item
else:
environment.append(item)
def edit_peripherals(deployment, ui, allow_coral=False):
devices = deployment.setdefault('devices', [])
label = 'Coral/USB' if allow_coral else 'USB'
example = '/dev/apex_0, ' if allow_coral else ''
while ui.confirm(f"{translate('Add or change a device')} ({label})?", False):
path = ui.ask(f"{translate('Host device node')} ({translate('e.g.')} {example}/dev/ttyACM0, /dev/bus/usb/003/004)")
if not re.fullmatch(r'/dev/(apex_[0-9]+|ttyUSB[0-9]+|ttyACM[0-9]+|bus/usb/[0-9]{3}/[0-9]{3})', path):
raise ValueError(translate('Select a specific Coral or USB node, not the whole /dev'))
if path.startswith('/dev/apex_') and not allow_coral:
raise ValueError(translate('Coral is only offered for Frigate and CodeProject.AI'))
if '/bus/usb/' in path:
ui.info(translate('The USB number can change after reconnecting or rebooting. This profile does not remap it automatically or handle Coral USB re-enumeration. Do not share a dongle already used by another service.'))
old = next((d for d in devices if d.get('host_path') == path), None)
mode = ui.ask(translate('Node octal permissions (e.g. 0660)'), (old or {}).get('mode', '0660'))
if not re.fullmatch(r'0?[0-7]{3}', mode):
raise ValueError(translate('Invalid octal permissions'))
item = dict(old or {}, id=(old or {}).get('id', 'peripheral-' + path.removeprefix('/dev/').replace('/', '-')),
kind='character-device', host_path=path, container_path=path,
mode=mode, gid_strategy='host-device-gid', deny_write=False)
if old:
devices[devices.index(old)] = item
else:
devices.append(item)
def edit_recreation(record, ui):
candidate = copy.deepcopy(record)
refresh_template(candidate, ui)
deployment = candidate['deployment']
resources = deployment['resources']
resources['cores'] = positive_integer(ui, translate('Cores'), resources['cores'])
resources['memory_mb'] = positive_integer(ui, translate('RAM in MiB'), resources['memory_mb'], 128)
while ui.confirm(translate('Add a custom data path?'), False):
target = absolute_path(ui.ask(translate('Path inside the container'), '/data/custom'))
existing = [m['container_path'].rstrip('/') for m in deployment['mounts']]
if any(target == p or target.startswith(p + '/') or p.startswith(target + '/') for p in existing):
raise ValueError(translate('The path overlaps an existing mount'))
mode = ui.choose(translate('Persistence for the new path'),
[('managed-volume', translate('Container volume (included in backups)')),
('host-bind', translate('Host directory (not included in Proxmox backups)'))],
'managed-volume')
if mode is None:
raise UserCancelled(translate('Custom path cancelled'))
mount = {'type': mode, 'container_path': target, 'read_only': False}
if mode == 'managed-volume':
mount.update(source=ui.ask(translate('Proxmox storage for the volume'), deployment['rootfs']['storage']),
size_gb=positive_integer(ui, translate('Volume size in GB'), 4), backup=True)
else:
mount.update(source=absolute_path(ui.ask(translate('Host directory'),
'/mnt/oci-shared/custom')), size_gb=None, backup=False,
create_if_missing=True)
deployment['mounts'].append(mount)
if ui.confirm(translate('Change the access network?'), False):
edit_network(deployment, ui)
edit_acceleration(candidate, ui)
reference = candidate.get('template', {}).get('container_contract', {}).get('image', {}).get('reference', '')
repository = reference.split('@')[0].rsplit(':', 1)[0]
edit_peripherals(deployment, ui, repository in ('ghcr.io/blakeblackshear/frigate', 'codeproject/ai-server', 'docker.io/codeproject/ai-server'))
edit_environment(deployment, ui)
proposal = {'operation': 'recreate', 'candidate': candidate}
if record.get('observed', {}).get('config_sha256'):
proposal['base_config_sha256'] = record['observed']['config_sha256']
return proposal
def refresh_template(candidate, ui):
from .catalog import Catalog
old = candidate.get('template', {})
name = old.get('id', '').removeprefix('image-')
if not re.fullmatch(r'[a-z0-9][a-z0-9-]+', name):
return
root = Path(__file__).resolve().parents[2]
path = root / 'catalog' / 'apps' / (name + '.json')
if not path.is_file() or not old.get('container_contract', {}).get('image'):
return
latest = Catalog(root).load_template(name, generate_if_missing=False)
if latest == old:
return
if not ui.confirm(translate('Apply the options from the current catalog template? Your data and configuration are kept.'), True):
return
if latest['id'] != old['id'] or latest['container_contract']['image']['repository'] != old['container_contract']['image']['repository']:
raise ValueError(translate('The current template changes the image or identity; an explicit migration is required'))
deployment = candidate['deployment']
mounted = {m['container_path'] for m in deployment.get('mounts', [])}
for volume in latest['container_contract'].get('volumes', []):
if not volume.get('required', True) or volume['container_path'] in mounted:
continue
target = absolute_path(volume['container_path'])
ui.info(f"{translate('The current template requires a new persistent path:')} {target}. "
f"{translate('It is created empty; existing data is not migrated automatically.')}")
mode = ui.choose(f"{translate('Persistence for')} {target}",
[('managed-volume', translate('Container volume (included in backups)')),
('host-bind', translate('Host directory (not included in Proxmox backups)'))],
volume.get('default', 'managed-volume'))
if mode not in ('managed-volume', 'host-bind'):
raise UserCancelled(translate('Required new path cancelled'))
mount = {'container_path': target, 'type': mode, 'read_only': volume.get('read_only', False)}
if mode == 'managed-volume':
mount.update(source=ui.ask(translate('Proxmox storage for the volume'), deployment['rootfs']['storage']),
size_gb=positive_integer(ui, translate('Volume size in GB'), volume.get('managed_volume', {}).get('default_size_gb', 4)), backup=True)
else:
mount.update(source=absolute_path(ui.ask(translate('Host directory'), '/mnt/oci-shared/' + name + '/' + volume.get('id', 'data'))),
backup=False, size_gb=None, create_if_missing=True)
deployment.setdefault('mounts', []).append(mount)
mounted.add(target)
for item in latest['container_contract'].get('environment', []):
if not item.get('required') or any(e['name'] == item['name'] for e in deployment.get('environment', [])):
continue
prompt = translate(item.get('prompt', item['name']))
value = ui.password(prompt) if item['sensitive'] else ui.ask(prompt, item.get('example') or '')
deployment.setdefault('environment', []).append({'name': item['name'], 'value': value, 'sensitive': item['sensitive']})
profile = latest.get('proxmox', {}).get('installer_profile', {})
tmpfs = deployment.get('tmpfs_mounts', [])
for item in profile.get('tmpfs_mounts', []):
if any(m['container_path'] == item['container_path'] for m in tmpfs):
continue
size = item['default_size_mb']
if item.get('prompt_size', True):
size = positive_integer(ui, f"{translate('Tmpfs size in MiB for')} {item['container_path']}", size, item.get('minimum_size_mb', 1))
tmpfs.append({'container_path': item['container_path'], 'size_mb': size,
'mount_options': copy.deepcopy(item.get('mount_options', ['rw', 'nosuid', 'nodev']))})
deployment['tmpfs_mounts'] = tmpfs
sysctls = deployment.get('security', {}).get('sysctls', [])
for item in profile.get('security', {}).get('sysctls', []):
if not any(s['name'] == item['name'] for s in sysctls):
sysctls.append(copy.deepcopy(item))
deployment.setdefault('security', {})['sysctls'] = sysctls
candidate['template'] = latest
+379
View File
@@ -0,0 +1,379 @@
"""Compile supported Compose stacks into the existing single-LXC installer contract."""
from __future__ import annotations
import copy
import base64
import re
import secrets
from urllib.parse import urlsplit, urlunsplit, quote
import yaml
from .casaos import convert_casaos_compose, canonical_image_repository
from .converter import ConversionError
from .converter import _split_short_mount
from .i18n import translate
class StackError(ValueError):
pass
VARIABLE = re.compile(r'\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)')
def normalized_mounts(mounts):
result = []
for mount in mounts:
if isinstance(mount, str):
source, target, mode = _split_short_mount(mount)
if mode not in (None, 'rw', 'ro'):
raise StackError(f"{translate('Unsupported volume options:')} {mode}")
mount = {'source': source, 'target': target, 'read_only': mode == 'ro'}
if not isinstance(mount, dict):
raise StackError(translate('Unrecognized volume definition'))
target = mount.get('target', '')
if not target.startswith('/') or '..' in target.split('/') or target == '/':
raise StackError(translate('Invalid volume target'))
result.append(copy.deepcopy(mount))
return result
def environment(value):
if isinstance(value, list):
if any('=' not in item for item in value):
raise StackError(translate('Environment entry without an explicit value'))
value = dict(item.split('=', 1) for item in value)
if any(v is None for v in (value or {}).values()):
raise StackError(translate('Environment entry without an explicit value'))
return {k: str(v) for k, v in (value or {}).items()}
def kind(image):
repo = canonical_image_repository(image)
return repo if repo in {'postgres', 'redis', 'valkey/valkey', 'mariadb', 'linuxserver/mariadb', 'mongo', 'getmeili/meilisearch'} else 'application'
DEPENDENCY_VOLUMES = {'mariadb':['/var/lib/mysql'], 'linuxserver/mariadb':['/config'], 'mongo':['/data/db','/data/configdb'],
'getmeili/meilisearch':['/meili_data']}
def authenticated_redis(service):
c = service['compose']
return (kind(service['image']) == 'redis' and not c.get('entrypoint')
and c.get('command') == ['redis-server','--requirepass','${REDISCLI_AUTH}']
and bool(environment(c.get('environment')).get('REDISCLI_AUTH')))
def ordered_services(template):
stack = template['compose_stack']
services = {s['name']: copy.deepcopy(s) for s in stack['services']}
for name, values in template.get('proxmox', {}).get('stack_environment_overrides', {}).items():
if name not in services:
raise StackError(f"{translate('Environment override for an unknown service:')} {name}")
env = environment(services[name]['compose'].get('environment'))
env.update(values)
services[name]['compose']['environment'] = env
for name, command in template.get('proxmox', {}).get('stack_command_overrides', {}).items():
if name not in services:
raise StackError(f"{translate('Command override for an unknown service:')} {name}")
services[name]['compose']['command'] = copy.deepcopy(command)
done, visiting, ordered = set(), set(), []
def visit(name):
if name not in services:
raise StackError(f"{translate('Unknown dependency:')} {name}")
if name in visiting:
raise StackError(f"{translate('Circular dependency:')} {name}")
if name in done:
return
visiting.add(name)
raw = services[name]['compose'].get('depends_on', [])
for dep in raw:
if isinstance(raw, dict):
condition = raw[dep].get('condition', 'service_started')
if condition not in {'service_started', 'service_healthy'}:
raise StackError(f"{name}: {translate('unsupported dependency condition')} {condition}")
if raw[dep].get('required', True) is False:
raise StackError(f"{name}: {translate('optional dependencies are not yet supported')}")
visit(dep)
visiting.remove(name)
done.add(name)
ordered.append(services[name])
for name in services:
visit(name)
main = stack['main_service']
if any(main in s['compose'].get('depends_on', []) for s in services.values()):
raise StackError(translate('Services that depend on the main service are not yet supported'))
return [s for s in ordered if s['name'] != main] + [services[main]]
def service_template(parent, service, main=False):
c = copy.deepcopy(service['compose'])
mounts = normalized_mounts(c.get('volumes', []))
targets = {m['target'] for m in mounts}
for target in DEPENDENCY_VOLUMES.get(kind(service['image']), []):
if not any(target == t or target.startswith(t.rstrip('/')+'/') for t in targets):
mounts.append({'type':'volume','target':target})
c['volumes'] = mounts
for key in ('depends_on', 'networks', 'healthcheck', 'expose'):
c.pop(key, None)
c['environment'] = environment(c.get('environment'))
if authenticated_redis(service):
c['command'] = ['redis-server','--requirepass',c['environment']['REDISCLI_AUTH']]
single = convert_casaos_compose(
yaml.safe_dump({'name': service['name'], 'services': {service['name']: c},
'x-casaos': {'main': service['name'], 'title': {'en_US': service['name']}}}),
parent['source']['revision'], parent['source']['repository'],
service['name'], '', parent['catalog_ui']['category'],
parent['catalog_ui'].get('category_label'), service['name'],
)
# Preserve selected image and resolved stack values; the importer normalizes secrets.
single['container_contract']['image']['reference'] = service['image']
single['container_contract']['environment'] = [
{'name': k, 'example': v, 'required': True, 'sensitive': True, 'prompt_user': False}
for k, v in c['environment'].items()
]
# Only the main service reports the credentials of the application.
single['first_run'] = {'endpoints': [],
'credentials': copy.deepcopy(parent.get('first_run', {}).get('credentials', [])) if main else []}
single['proxmox'].setdefault('installer_profile', {}).pop('startup_healthcheck', None)
return single
def assess(template):
"""Reject untranslated semantics before any host resources are allocated."""
errors = []
try:
services = ordered_services(template)
top = template['compose_stack'].get('top_level', {})
if any(k not in {'name', 'networks', 'volumes', 'version'} for k in top):
raise StackError(translate('Top-level configs, secrets and other global options are not yet supported'))
networks = top.get('networks', {})
if len(networks) > 1 or any(v and any(not ((k == 'driver' and x == 'bridge') or (k == 'name' and isinstance(x,str))) for k,x in v.items()) for v in networks.values()):
raise StackError(translate('Multiple networks or external networks are not yet supported'))
if any(v for v in top.get('volumes', {}).values()):
raise StackError(translate('Top-level volume options are not yet supported'))
seen_sources = set()
for s in services:
c = s['compose']
for key in ('profiles','build','configs','secrets','env_file','pid','privileged','devices','runtime','cap_add','security_opt','network_mode','extra_hosts'):
if c.get(key):
errors.append(f"{s['name']}: {key} {translate('needs stack review')}")
if s['name'] != template['compose_stack']['main_service'] and kind(s['image']) == 'application':
errors.append(f"{s['name']}: {translate('health and persistence profile not yet defined')}")
if kind(s['image']) != 'application' and (c.get('command') or c.get('entrypoint')) and not authenticated_redis(s):
errors.append(f"{s['name']}: {translate('custom dependency commands are not yet supported')}")
for m in normalized_mounts(c.get('volumes', [])):
source = m.get('source')
if source is not None and source in seen_sources:
errors.append(translate('Volumes shared between services are not yet supported'))
if source is not None:
seen_sources.add(source)
if set(m) - {'type','source','target','read_only'} or m.get('read_only'):
errors.append(translate('Volume options are not yet supported'))
if m['target'].startswith(('/etc/', '/var/run/', '/run/', '/dev/', '/proc/', '/sys/')):
errors.append(translate('System path mounts are not yet supported'))
env = environment(c.get('environment'))
for k,v in env.items():
for a,b in VARIABLE.findall(v):
variable = a or b
if variable not in {'TZ','PUID','PGID'} and not variable.startswith('GENERATED_'):
errors.append(f'{s["name"]}: {translate("unsupported variable")} {variable}')
if variable.startswith('GENERATED_') and re.search(r'API_KEY|CLIENT_SECRET|CREDENTIALS_ENABLED', variable):
errors.append(f'{s["name"]}: {translate("unsupported external credential or boolean")} {variable}')
single = service_template(template,s)
errors.extend(f'{s["name"]}: {b}' for b in single['compatibility']['untranslated_blockers'])
if not template['first_run'].get('endpoints'):
errors.append(translate('Main endpoint not yet defined'))
resolve_environments(services, 'UTC', template.get('proxmox', {}).get('stack_generators', {}))
except (ValueError, KeyError, TypeError, ConversionError) as e:
errors.append(str(e))
return sorted(set(errors))
def resolve_environments(services, timezone, generators=None):
tokens = {'TZ': timezone, 'PUID': '1000', 'PGID': '1000'}
resolved = {}
for s in services:
env = environment(s['compose'].get('environment'))
def replace(match):
name = match.group(1) or match.group(2)
if name.startswith('GENERATED_'):
if name not in tokens:
generator = (generators or {}).get(name)
if generator:
if generator != {'encoding':'base64','bytes':32,'prefix':'base64:'}:
raise StackError(f"{translate('Unsupported secret generator:')} {name}")
tokens[name] = 'base64:'+base64.b64encode(secrets.token_bytes(32)).decode()
else:
tokens[name] = secrets.token_hex(32)
if name not in tokens:
raise StackError(f"{translate('Unresolved variable:')} {name}")
return tokens[name]
resolved[s['name']] = {k: VARIABLE.sub(replace, v) for k,v in env.items()}
# Bind URL credentials using the destination service, never a global text replacement.
databases = {}
for s in services:
if kind(s['image']) == 'postgres':
env = resolved[s['name']]
for alias in (s['name'], s['compose'].get('container_name', s['name'])):
databases[alias] = env
for env in resolved.values():
for key, value in list(env.items()):
if value.startswith(('postgres://', 'postgresql://')):
url = urlsplit(value)
db = databases.get(url.hostname)
if db is None:
raise StackError(f"{translate('PostgreSQL URL without an associated service:')} {key}")
user = db.get('POSTGRES_USER', 'postgres')
password = db.get('POSTGRES_PASSWORD')
if not password:
raise StackError(translate('PostgreSQL requires a password'))
host = url.hostname + (f':{url.port}' if url.port else '')
env[key] = urlunsplit((url.scheme, quote(user, safe='')+':'+quote(password,safe='')+'@'+host, url.path, url.query, url.fragment))
return resolved
class DefaultsUI:
def ask(self, text, default=None, required=True):
return default if default is not None else ''
def choose(self, text, options, default=None):
return default
def confirm(self, text, default=False):
return default
def info(self, text):
pass
def build_stack(template, ui):
from .installer import build_deployment, _hostname_default
problems = assess(template)
if problems:
raise StackError('; '.join(problems))
services = copy.deepcopy(ordered_services(template))
defaults = template['proxmox']['defaults']
name = _hostname_default(ui.ask(translate('Stack name'), template['compose_stack']['project_name']))
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
root = ask_storage(ui, translate('Storage for rootfs'), 'rootdir', defaults['rootfs_storage'])
volumes = ask_storage(ui, translate('Storage for persistent data'), 'rootdir', defaults['volume_storage'])
cache = ask_storage(ui, translate('Storage for the OCI image cache'), 'vztmpl', defaults['template_storage'])
bridge = ask_bridge(ui, translate('Access bridge'), defaults['bridge'])
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', {}))
for group in template.get('proxmox', {}).get('stack_optional_environment', []):
service_name = group['service']
if service_name not in envs:
raise StackError(f"{translate('Unknown credential service:')} {service_name}")
if not ui.confirm(f"{translate('Configure')} {group['label']} ({translate('optional')})", False):
continue
for field in group['fields']:
if field['name'] not in envs[service_name] or envs[service_name][field['name']] != '':
raise StackError(f"{translate('External field not reserved:')} {field['name']}")
value = (ui.password(field['label'], required=True) if field.get('sensitive',True)
else ui.ask(field['label'], required=True))
if not value or any(c in value for c in '\r\n'):
raise StackError(translate('External credential is empty or spans multiple lines'))
envs[service_name][field['name']] = value
plans = []
for index, s in enumerate(services):
main = s['name'] == template['compose_stack']['main_service']
env = envs[s['name']]
# Public application URLs cannot retain localhost in a remote deployment.
for key,value in list(env.items()):
if value.startswith(('http://localhost', 'http://127.0.0.1', 'http://0.0.0.0')):
url = urlsplit(value)
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)
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'] = '/var/lib/postgresql'
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())
# 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)
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']:
mode = ui.choose(f"{s['name']}: {m['container_path']}", [('managed-volume',translate('Container volume (included in backups)')),('host-bind',translate('Host directory'))], 'managed-volume')
if mode is None:
raise StackError(translate('Storage selection cancelled'))
m.update(type=mode, source=volumes, backup=mode=='managed-volume')
if 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
else:
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'))
from .custom_mounts import ask_custom_mounts
plan['mounts'] = ask_custom_mounts(ui, plan['mounts'], volumes)
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':
health = {'type':'exec','timeout_seconds':240,'argv':['healthcheck.sh','--connect','--innodb_initialized']}
elif k == 'linuxserver/mariadb':
if not all(env.get(key) for key in ('MYSQL_PASSWORD','MYSQL_USER','MYSQL_DATABASE')):
raise StackError(translate('LinuxServer MariaDB requires a user, database and password'))
check = "MYSQL_PWD=${MYSQL_PASSWORD:-$(tr '\\000' '\\n' < /proc/1/environ | sed -n 's/^MYSQL_PASSWORD=//p')}; export MYSQL_PWD; [ \"$(mariadb --protocol=tcp -h127.0.0.1 -u\"$1\" -D\"$2\" --batch --skip-column-names -e 'SELECT 1')\" = 1 ]"
health = {'type':'exec','timeout_seconds':240,'argv':['sh','-c',check,'healthcheck',env['MYSQL_USER'],env['MYSQL_DATABASE']]}
elif k == 'mongo':
health = {'type':'exec','timeout_seconds':180,'argv':['mongosh','--quiet','--host','127.0.0.1','--eval','quit(db.adminCommand({ping:1}).ok === 1 ? 0 : 1)']}
elif k == 'getmeili/meilisearch':
health = {'type':'http','timeout_seconds':180,'endpoint':{'scheme':'http','port':7700,'path':'/health'}}
elif k in {'redis','valkey/valkey'}:
cli = 'redis-cli' if k=='redis' else 'valkey-cli'
check = '[ "$('+cli+' --raw ping)" = PONG ]'
if authenticated_redis(s):
check = "REDISCLI_AUTH=${REDISCLI_AUTH:-$(tr '\\000' '\\n' < /proc/1/environ | sed -n 's/^REDISCLI_AUTH=//p')}; export REDISCLI_AUTH; " + check
health = {'type':'exec','timeout_seconds':90,'argv':['sh','-c',check]}
else:
endpoint = template['first_run']['endpoints'][0]
health = {'type':'http','timeout_seconds':360,'endpoint':endpoint}
plans.append({'name':s['name'],'main':main,'kind':k,'offset':0 if main else len(plans)+1,
'aliases':list(dict.fromkeys([s['name'],s['compose'].get('container_name',s['name'])])),
'template':single,'deployment':plan,'healthcheck':health})
if main:
plans[-1]['frontend_ipv4'] = addresses['']
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,
'network':{'frontend_bridge':bridge,'frontend_gateway':gateway,'private_allocation':'automatic','private_bridge':'vmbr10','private_subnet':'10.77.0.0/24','private_host_address':'10.77.0.1/24'},
'services':plans}
def apply_stack_support(template):
"""Only promote pending imported stacks that the compiler can represent."""
driver = template.get('proxmox',{}).get('installer_profile',{}).get('stack_driver')
if driver != 'generic-multi-lxc-stack' and 'native-multi-lxc-orchestrator-not-yet-implemented' not in template.get('compatibility',{}).get('untranslated_blockers',[]):
return
errors = assess(template)
if errors:
template['proxmox']['generic_stack_review'] = errors
if driver == 'generic-multi-lxc-stack':
template['compatibility']['automatic_install_candidate'] = False
template['compatibility']['untranslated_blockers'] = errors
template['status'] = 'generated-review-required'
return
template['proxmox'].pop('generic_stack_review',None)
template['proxmox']['installer_profile'] = {'stack_driver':'generic-multi-lxc-stack'}
template['compatibility']['untranslated_blockers'] = []
template['compatibility']['automatic_install_candidate'] = True
template['status'] = 'generated-unvalidated'
template['lifecycle']['dependency_lifecycle'] = {'implementation':'proxmox-hookscript','trigger':'main-lxc-pre-start','waits_for_dependency_healthchecks':True,'stops_dependencies_with_main':False}
+235
View File
@@ -0,0 +1,235 @@
from __future__ import annotations
import getpass
import os
import re
import shutil
import subprocess
import sys
import textwrap
from dataclasses import dataclass
from .i18n import translate
class UserCancelled(RuntimeError):
pass
APP_TITLE = "OCI manager Apps (beta)"
@dataclass
class TerminalUI:
title: str = APP_TITLE
def ask(self, text: str, default: str | None = None, required: bool = True) -> str:
suffix = f" [{default}]" if default not in (None, "") else ""
while True:
value = input(f"{text}{suffix}: ").strip()
if value:
return value
if default is not None:
return default
if not required:
return ""
print(translate("This value is required."))
def password(self, text: str, required: bool = True) -> str:
while True:
value = getpass.getpass(f"{text}: ")
if not value:
if not required:
return ""
print(translate("This value is required."))
continue
if value == getpass.getpass(f"{translate('Repeat to confirm')}: "):
return value
print(translate("The values do not match. Enter them again."))
def confirm(self, text: str, default: bool = False) -> bool:
suffix = " [Y/n]" if default else " [y/N]"
value = input(f"{text}{suffix}: ").strip().casefold()
if not value:
return default
return value in {"y", "yes", "s", "si"}
def choose(self, text: str, options: list[tuple[str, str]], default: str | None = None,
title: str | None = None, show_tags: bool = True,
size: tuple[int, int, int] | None = None, colors: bool = False) -> str | None:
options = [(tag, re.sub(r"\\Z.", "", label)) for tag, label in options if tag]
print(text)
for index, (_, label) in enumerate(options, 1):
print(f"{index:2}. {label}")
default_index = next((index for index, (tag, _) in enumerate(options, 1) if tag == default), None)
selected = self.ask(translate("Selection"), str(default_index) if default_index else None, required=False)
if selected.isdigit() and 1 <= int(selected) <= len(options):
return options[int(selected) - 1][0]
return None
def checklist(self, text, options, defaults):
return [tag for tag, label in options if self.confirm(label, tag in defaults)]
def detail_menu(self, text: str, options: list[tuple[str, str]], default: str | None = None,
title: str | None = None) -> str | None:
return self.choose(re.sub(r"\\Z.", "", text), options, default, title=title)
def message(self, text: str, title: str | None = None) -> None:
print(f"\n{title or self.title}\n{text}")
def review(self, text: str, title: str | None = None, question: str | None = None,
default: bool = True) -> bool:
self.message(text, title)
return self.confirm(question or translate("Continue?"), default)
def info(self, text: str) -> None:
print(text)
@dataclass
class DialogUI:
"""dialog widgets with the ProxMenux backtitle, used for every menu shown
before the installation starts."""
title: str = APP_TITLE
backtitle: str = "ProxMenux"
@staticmethod
def available() -> bool:
return bool(shutil.which("dialog") and sys.stdin.isatty() and sys.stdout.isatty())
def _run(self, widget: list[str], title: str | None = None) -> subprocess.CompletedProcess[str]:
environment = os.environ.copy()
if environment.get("TERM", "").casefold() in {"", "dumb", "unknown"}:
environment["TERM"] = "xterm-256color"
# dialog draws on the terminal and writes the selection to stderr.
return subprocess.run(
["dialog", "--no-collapse", "--backtitle", self.backtitle, "--title", title or self.title, *widget],
stdout=None, stderr=subprocess.PIPE, text=True, check=False, env=environment,
)
@staticmethod
def _size(text: str, min_height: int, width: int, extra: int = 6) -> tuple[str, str]:
terminal = shutil.get_terminal_size((100, 30))
width = max(50, min(width, terminal.columns - 4))
lines = sum(max(1, len(textwrap.wrap(line, max(20, width - 6)) or [""])) for line in text.splitlines() or [""])
height = max(min_height, min(lines + extra, terminal.lines - 2))
return str(height), str(width)
def ask(self, text: str, default: str | None = None, required: bool = True, title: str | None = None) -> str:
while True:
height, width = self._size(text, 10, 78, 7)
result = self._run(["--inputbox", f"\n{text}", height, width, default or ""], title)
if result.returncode != 0:
raise UserCancelled(text)
value = result.stderr.strip()
if value or not required or default is not None:
return value or default or ""
self.message(translate("This value is required."))
def password(self, text: str, required: bool = True, title: str | None = None) -> str:
while True:
height, width = self._size(text, 10, 78, 7)
result = self._run(["--insecure", "--passwordbox", f"\n{text}", height, width], title)
if result.returncode != 0:
raise UserCancelled(text)
value = result.stderr.rstrip("\n")
if not value:
if not required:
return ""
self.message(translate("This value is required."))
continue
repeated = self._run(["--insecure", "--passwordbox", f"\n{translate('Repeat to confirm')}", height, width], title)
if repeated.returncode != 0:
raise UserCancelled(text)
if value == repeated.stderr.rstrip("\n"):
return value
self.message(translate("The values do not match. Enter them again."))
def confirm(self, text: str, default: bool = False, title: str | None = None) -> bool:
height, width = self._size(text, 9, 78, 6)
widget = ["--yesno", f"\n{text}", height, width]
if not default:
widget = ["--defaultno", *widget]
return self._run(widget, title).returncode == 0
def choose(self, text: str, options: list[tuple[str, str]], default: str | None = None,
title: str | None = None, show_tags: bool = True,
size: tuple[int, int, int] | None = None, colors: bool = False) -> str | None:
if size:
widget = ["--colors"] if colors else []
widget += ["--default-item", default] if default else []
widget += ["--menu", text, *map(str, size)]
for tag, label in options:
widget += [tag, label]
result = self._run(widget, title)
return result.stderr.strip() if result.returncode == 0 else None
terminal = shutil.get_terminal_size((100, 30))
tag_width = max((len(tag) for tag, _ in options), default=0) + 2 if show_tags else 0
width = min(max(60, max((len(label) for _, label in options), default=0) + tag_width + 14,
max((len(line) for line in text.splitlines()), default=0) + 6),
terminal.columns - 4, 110)
text_lines = sum(max(1, len(textwrap.wrap(line, width - 6) or [""])) for line in text.splitlines() or [""])
height = min(max(len(options) + text_lines + 8, 14), terminal.lines - 2)
menu_height = max(3, min(len(options), height - text_lines - 8))
widget = ["--default-item", default] if default else []
if not show_tags:
widget.append("--no-tags")
widget += ["--menu", f"\n{text}", str(height), str(width), str(menu_height)]
for tag, label in options:
widget += [tag, label]
result = self._run(widget, title)
return result.stderr.strip() if result.returncode == 0 else None
def detail_menu(self, text: str, options: list[tuple[str, str]], default: str | None = None,
title: str | None = None) -> str | None:
"""A tall menu under a block of information. When the information does
not fit on screen it is shown first in a scrollable box."""
terminal = shutil.get_terminal_size((100, 30))
width = min(terminal.columns - 4, 104)
text_lines = sum(max(1, len(textwrap.wrap(line, width - 6) or [""])) for line in text.splitlines() or [""])
available = terminal.lines - 2
needed = text_lines + len(options) + 9
if needed > available:
self._run(["--colors", "--msgbox", f"\n{text}", str(available), str(width)], title)
text, needed = translate("Select an option"), len(options) + 10
height = min(available, needed)
widget = ["--colors"] + (["--default-item", default] if default else [])
widget += ["--menu", f"\n{text}", str(height), str(width), str(len(options))]
for tag, label in options:
widget += [tag, label]
result = self._run(widget, title)
return result.stderr.strip() if result.returncode == 0 else None
def checklist(self, text, options, defaults, title: str | None = None):
terminal = shutil.get_terminal_size((100, 30))
height = min(max(len(options) + 9, 14), terminal.lines - 2)
widget = ["--separate-output", "--checklist", f"\n{text}", str(height), "78",
str(max(4, min(len(options), height - 8)))]
for tag, label in options:
widget += [tag, label, "on" if tag in defaults else "off"]
result = self._run(widget, title)
if result.returncode != 0:
raise UserCancelled(text)
return result.stderr.split()
def message(self, text: str, title: str | None = None) -> None:
height, width = self._size(text, 8, 84, 6)
self._run(["--msgbox", f"\n{text}", height, width], title)
def review(self, text: str, title: str | None = None, question: str | None = None,
default: bool = True) -> bool:
body = f"{text}\n\n{question or translate('Continue?')}"
height, width = self._size(body, 12, 90, 6)
widget = ["--yesno", f"\n{body}", height, width]
if not default:
widget = ["--defaultno", *widget]
return self._run(widget, title).returncode == 0
def info(self, text: str, title: str | None = None) -> None:
height, width = self._size(text, 6, 70, 5)
self._run(["--infobox", f"\n{text}", height, width], title)
def interactive_ui() -> TerminalUI | DialogUI:
return DialogUI() if DialogUI.available() else TerminalUI()