mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-10-04 20:46:43 +00:00
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:
@@ -0,0 +1,3 @@
|
||||
"""ProxMenux LinuxServer-to-Proxmox OCI laboratory tools."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,5 @@
|
||||
from .cli import main
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -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'}}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
@@ -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
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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'])
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
@@ -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])
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user