ProxMenux 1.2.6.2-beta: OCI containers in the Monitor, docs and fixes

OCI manager Apps
- App tab: containers installed from an OCI image are identified from their
  installation record; the application and image versions are shown and an
  update is detected by image digest; repository link; Refresh data.
- Updates tab for OCI containers: Update and Recreate run the same flow as the
  OCI menu in the Monitor terminal; the pre-update backup can be kept in a
  backup storage; scheduled image updates with an optional minimum age.
- Logs tab: console output of the application, kept on the host
  (lxc.console.logfile + logrotate) and followed live.
- The Proxmox console opens a shell (cmode: shell) when the image has one.
- A damaged image download is fetched again before failing.
- Multi-container applications open at their LAN address; volume mount
  points on block storage report their usage.

Monitor
- Proxmox notifications are delivered to a loopback-only HTTP listener when
  HTTPS is enabled, so they no longer fail certificate verification.
- Log persistence counts recurring patterns only; an ended burst is not
  reported as persistent and its warning clears on its own (#386).
- Proxmox notification config backups are deduplicated and capped at three.
- The update icon on the Apps page opens the container on its Updates tab.
- Version 1.2.6.2-beta and its release notes in every Monitor language.

Docs
- OCI manager Apps and Audit & Report rebuilt as per-page message files,
  with a new page for OCI containers in the Monitor.
- Seven pages fixed where rich-text tags were missing from t.rich.

Translations
- Spanish fixes across the OCI engine, the Monitor and the TUI menus.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
MacRimi
2026-09-25 21:51:12 +02:00
co-authored by Claude Opus 5.5
parent 386d33df6e
commit 4437a671d2
524 changed files with 14459 additions and 3841 deletions
+11 -9
View File
@@ -40,8 +40,8 @@ class SuiteChildUI(DefaultsUI):
return self.ui.password(text,required=required)
def build_suite(template, ui):
from .installer import build_deployment, _hostname_default
def build_suite(template, ui, mode='advanced'):
from .installer import build_deployment, _hostname_default, DEFAULT_MODE
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],
@@ -61,9 +61,9 @@ def build_suite(template, ui):
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')
storage = ask_storage(ui, translate('Storage for rootfs and private configuration'), 'rootdir', 'local-lvm', mode)
cache = ask_storage(ui, translate('Storage for the OCI image cache'), 'vztmpl', 'local', mode)
bridge = ask_bridge(ui, translate('Access bridge'), 'vmbr0', mode)
reachable = [app for app in selected if app != 'unpackerr']
labels, gateway = access.ask_addresses(ui, bridge, [app.capitalize() for app in reachable])
addresses = dict(zip(reachable, labels.values()))
@@ -85,7 +85,9 @@ def build_suite(template, ui):
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',{})))
child_ui = (DefaultsUI() if mode == DEFAULT_MODE else
SuiteChildUI(ui, child['proxmox'].get('installer_profile', {})))
plan = build_deployment(copy.deepcopy(child), child_ui, mode)
plan.update(hostname=_hostname_default(name+'-'+app), start_after_create=False, onboot=onboot, template_storage=cache)
plan['rootfs']['storage'] = storage
for env in plan['environment']:
@@ -97,9 +99,6 @@ def build_suite(template, ui):
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':
@@ -113,6 +112,9 @@ def build_suite(template, ui):
services[-1]['deferred_setup'] = True
if app == 'qbittorrent':
services[-1]['setup_credentials'] = credentials
if mode != DEFAULT_MODE:
from .custom_mounts import ask_stack_custom_mounts
ask_stack_custom_mounts(ui, services, storage)
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':[
+5 -5
View File
@@ -48,7 +48,7 @@ CASAOS_TRANSLATED_SERVICE_KEYS = SUPPORTED_SERVICE_KEYS | {"deploy", "network_mo
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}")
raise ConversionError(f"Cannot normalise the CasaOS identifier: {value!r}")
return normalized
@@ -76,7 +76,7 @@ def _json_safe(value: Any) -> Any:
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")
raise ConversionError("The CasaOS Compose file has no services")
service_name = metadata.get("main")
if not service_name and len(services) == 1:
service_name = next(iter(services))
@@ -84,7 +84,7 @@ def _main_service(compose: dict[str, Any], metadata: dict[str, Any]) -> tuple[st
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")
raise ConversionError("The main CasaOS service declares no image")
return str(service_name), service
@@ -214,10 +214,10 @@ def parse_casaos_compose(compose_text: str) -> tuple[dict[str, Any], dict[str, A
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")
raise ConversionError("The CasaOS document is not a Compose mapping")
metadata = compose.get("x-casaos")
if not isinstance(metadata, dict):
raise ConversionError("El Compose no contiene metadatos x-casaos")
raise ConversionError("The Compose file has no x-casaos metadata")
service_name, service = _main_service(compose, metadata)
return compose, metadata, service_name, service
+69
View File
@@ -69,6 +69,69 @@ class Catalog:
self.schema_path = root / "schemas" / "oci-template.schema.json"
self.source = source or GitHubSource()
def apply_icons(self) -> dict[str, int]:
"""Point every catalog_ui icon at an icon that is known to exist.
Rewrites the index and the application templates from the jsdelivr
icon sets. An application neither set covers is left without an icon
on purpose: the panel then draws its placeholder, which reads better
than the broken image a stale URL produces.
"""
from .icons import IconResolver
resolver = IconResolver()
index_path = self.catalog_dir / "index.json"
index = json.loads(index_path.read_text(encoding="utf-8"))
report = {"resolved": 0, "kept": 0, "cleared": 0, "unchanged": 0, "themed": 0,
"missing": []}
icons: dict[str, str | None] = {}
for item in index.get("applications", []):
match = resolver.resolve(item.get("id"), item.get("title"))
url = match["url"] if match else None
if not url:
# Neither icon set covers it. The publisher's own URL is kept
# only when it answers, so nothing points at a missing file.
inherited = item.get("icon")
if resolver.reachable(inherited):
url = inherited
report["kept"] += 1
elif not item.get("hidden"):
report["missing"].append(item.get("id"))
icons[item["id"]] = url
if match and match["themed"]:
report["themed"] += 1
if item.get("icon") == url:
report["unchanged"] += 1
elif url:
report["resolved"] += 1
else:
report["cleared"] += 1
item["icon"] = url
template_ids: dict[str, str] = {}
for app_id, url in icons.items():
path = self.apps_dir / f"{app_id}.json"
if not path.is_file():
continue
template = json.loads(path.read_text(encoding="utf-8"))
# An installation records the template id, not the catalog id, so
# the index carries both and the panel can find the icon of what
# it installed without opening every template.
if isinstance(template.get("id"), str):
template_ids[app_id] = template["id"]
ui = template.get("catalog_ui")
if not isinstance(ui, dict) or ui.get("icon") == url:
continue
ui["icon"] = url
path.write_text(json.dumps(template, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8")
for item in index.get("applications", []):
template_id = template_ids.get(item["id"])
if template_id:
item["template_id"] = template_id
index_path.write_text(json.dumps(index, ensure_ascii=True, indent=2) + "\n",
encoding="utf-8")
return report
def sync_index(self) -> dict[str, Any]:
repos = self.source.list_linuxserver_repositories()
try:
@@ -333,6 +396,12 @@ class Catalog:
def _enrich_index_from_templates(self, payload: dict[str, Any]) -> None:
for item in payload.get("applications", []):
# categories.json is the hand-maintained classification and has the
# final word. The index took its category from the Helper-Scripts
# catalogue, which does not list the LinuxServer desktop
# applications at all: 138 of them reached the reader under
# Miscellaneous while their category sat here all along.
self._apply_category(item["id"], item)
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)
+30 -5
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import argparse
import json
import re
import sys
import textwrap
import unicodedata
@@ -401,11 +402,8 @@ def _rclone_mount(catalog: Catalog, ui) -> None:
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"))]
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)}
@@ -538,6 +536,7 @@ def build_parser() -> argparse.ArgumentParser:
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")
subparsers.add_parser("apply-icons", help="Resolve catalog icons against the jsdelivr icon sets")
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")
@@ -548,6 +547,16 @@ def build_parser() -> argparse.ArgumentParser:
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")
manage_parser = subparsers.add_parser("manage", help="Update or recreate one installed OCI instance")
manage_parser.add_argument("vmid", type=int)
manage_parser.add_argument("--action", choices=("update", "recreate"), required=True)
manage_parser.add_argument("--keep-backup", metavar="STORAGE",
help="Keep the backup taken before the update in this Proxmox storage")
manage_parser.add_argument("--unattended", action="store_true",
help="Scheduled run: no questions; stops where a person has to decide")
manage_parser.add_argument("--acknowledge-external-data", action="store_true",
help="Host directories are not reverted by the backup (confirmed beforehand)")
manage_parser.add_argument("--min-image-age-days", type=int, default=0)
return parser
@@ -583,6 +592,11 @@ def main(argv: list[str] | None = None) -> int:
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 == "apply-icons":
report = catalog.apply_icons()
print(f"Icons resolved: {report['resolved']}; cleared: {report['cleared']}; "
f"unchanged: {report['unchanged']}; with a theme variant: {report['themed']}")
return 0
if args.command == "show":
print(_template_summary_text(catalog.compose(args.app)))
return 0
@@ -599,6 +613,17 @@ def main(argv: list[str] | None = None) -> int:
if result:
_print_installation_summary(result)
return 0
if args.command == "manage":
from .management import direct_management
lifecycle_args = []
if args.keep_backup:
if not re.fullmatch(r"[A-Za-z0-9._-]{1,64}", args.keep_backup):
raise InstallError(translate("Invalid storage name"))
lifecycle_args += ["--keep-backup", args.keep_backup]
if args.acknowledge_external_data:
lifecycle_args.append("--acknowledge-external-data")
return direct_management(PROJECT_ROOT, args.vmid, args.action, lifecycle_args,
args.unattended, max(0, args.min_image_age_days))
if args.command == "rclone-mount":
template = catalog.compose("rclone")
deployment = build_rclone_mount_deployment(template)
+17 -11
View File
@@ -80,7 +80,7 @@ def extract_compose(readme: str) -> str:
None,
)
if heading_index is None:
raise ConversionError("El README no contiene una seccion docker-compose reconocible")
raise ConversionError("The README has no recognisable docker-compose section")
fence_start = next(
(index for index in range(heading_index + 1, len(lines)) if lines[index].strip().startswith("```")),
@@ -93,11 +93,11 @@ def extract_compose(readme: str) -> str:
None,
)
if fence_end is None:
raise ConversionError("El bloque docker-compose no esta cerrado")
raise ConversionError("The docker-compose block is not closed")
compose = "\n".join(lines[fence_start + 1 : fence_end]).strip() + "\n"
if "services:" not in compose:
raise ConversionError("El bloque encontrado no parece un Docker Compose")
raise ConversionError("The block found does not look like a Docker Compose file")
return compose
@@ -141,7 +141,7 @@ def _environment_contract(value: Any, optional: set[str]) -> list[dict[str, Any]
name, separator, raw_value = text.partition("=")
entries.append((name, raw_value if separator else None))
else:
raise ConversionError("environment debe ser una lista o un objeto")
raise ConversionError("environment must be a list or a mapping")
for name, raw_value in entries:
name = str(name)
@@ -178,7 +178,7 @@ def _mount_contract(value: Any, optional: set[str]) -> list[dict[str, Any]]:
if value is None:
return []
if not isinstance(value, list):
raise ConversionError("volumes debe ser una lista")
raise ConversionError("volumes must be a list")
result: list[dict[str, Any]] = []
for index, item in enumerate(value):
if isinstance(item, dict):
@@ -245,7 +245,7 @@ def _port_contract(value: Any, optional: set[str]) -> list[dict[str, Any]]:
if value is None:
return []
if not isinstance(value, list):
raise ConversionError("ports debe ser una lista")
raise ConversionError("ports must be a list")
result: list[dict[str, Any]] = []
for item in value:
if isinstance(item, dict):
@@ -326,7 +326,7 @@ def _image_name(image: Any) -> str:
def _catalog_identifier(app_id: str) -> str:
normalized = re.sub(r"[^a-z0-9]+", "-", app_id.casefold()).strip("-")
if not normalized:
raise ConversionError(f"No se puede normalizar el identificador: {app_id!r}")
raise ConversionError(f"Cannot normalise the identifier: {app_id!r}")
return f"linuxserver-{normalized}"
@@ -1404,7 +1404,7 @@ def summarize_readme(repo: Repository, readme: str) -> dict[str, Any]:
raise ConversionError(f"Docker Compose no valido: {exc}") from exc
services = compose.get("services") if isinstance(compose, dict) else None
if not isinstance(services, dict) or not services:
raise ConversionError("El Compose no contiene services")
raise ConversionError("The Compose file has no services")
candidates = [
value
for value in services.values()
@@ -1427,7 +1427,10 @@ def summarize_readme(repo: Repository, readme: str) -> dict[str, Any]:
"description": description or repo.description,
"website": website,
"architectures": architectures,
"icon": f"https://raw.githubusercontent.com/linuxserver/docker-templates/master/linuxserver.io/img/{repo.app_id}-icon.png",
# Resolved by `apply-icons` against the published icon sets. Building
# the URL here from the application id named a file that does not
# exist for most of the catalog.
"icon": None,
"updated_at": changelog[0]["date"] if changelog else repo.pushed_at,
"main_image": str(main_service["image"]),
}
@@ -1445,7 +1448,7 @@ def convert_readme(
except yaml.YAMLError as exc:
raise ConversionError(f"Docker Compose no valido: {exc}") from exc
if not isinstance(compose, dict) or not isinstance(compose.get("services"), dict):
raise ConversionError("El Compose no contiene services")
raise ConversionError("The Compose file has no services")
services = compose["services"]
multi_service = len(services) > 1
if multi_service:
@@ -1511,7 +1514,10 @@ def convert_readme(
"category_label": repo.category_label,
"author": "LinuxServer.io",
"developer": None,
"icon": f"https://raw.githubusercontent.com/linuxserver/docker-templates/master/linuxserver.io/img/{repo.app_id}-icon.png",
# Resolved by `apply-icons` against the published icon sets. Building
# the URL here from the application id named a file that does not
# exist for most of the catalog.
"icon": None,
"thumbnail": f"https://raw.githubusercontent.com/linuxserver/docker-templates/master/linuxserver.io/img/{repo.app_id}-banner.png",
"screenshots": [],
"architectures": _architectures(readme),
+40
View File
@@ -61,3 +61,43 @@ def ask_custom_mounts(ui, mounts, storage):
mount['container_path'] = validate_mount(mount, result)
result.append(mount)
return result
def ask_stack_custom_mounts(ui, services, storage):
"""Collect extra paths once, then attach each path to selected stack members."""
if not ui.confirm(translate('Add extra paths to this stack'), False):
return
options = [(service['name'], service['name']) for service in services]
defaults = [service['name'] for service in services if service.get('main')]
while True:
selected = ui.checklist(translate('Containers that will receive this path'),
options, defaults or [options[0][0]])
if not selected or set(selected) - {name for name, _ in options}:
raise ValueError(translate('Select at least one stack container'))
mode = ui.choose(translate('Data location'), [
('managed-volume', translate('Container volume (included in backups)')),
('host-bind', translate('Host directory (not included in Proxmox backups)')),
], 'host-bind' if len(selected) > 1 else 'managed-volume')
if mode is None:
raise UserCancelled(translate('Custom path cancelled'))
source = (ui.ask(translate('Proxmox storage for the volume'), storage)
if mode == 'managed-volume' else
ui.ask(translate('Host directory (created if it does not exist)'), '/mnt/oci-shared/custom'))
size = int(ui.ask(translate('Volume size in GB'), '8')) if mode == 'managed-volume' else None
read_only = ui.confirm(translate('Mount read-only'), False)
default_target = '/media-extra'
additions = []
for service in services:
if service['name'] not in selected:
continue
target = ui.ask(f"{service['name']}: {translate('Path inside the container')}", default_target)
mount = {'type': mode, 'container_path': target, 'custom': True,
'source': source, 'size_gb': size, 'backup': mode == 'managed-volume',
'read_only': read_only, 'create_if_missing': mode == 'host-bind'}
mount['container_path'] = validate_mount(mount, service['deployment']['mounts'])
additions.append((service, mount))
default_target = target
for service, mount in additions:
service['deployment']['mounts'].append(mount)
if not ui.confirm(translate('Add another extra path to this stack'), False):
break
+6 -6
View File
@@ -93,11 +93,11 @@ class GitHubSource:
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."
f"GitHub returned HTTP 403 for {url}. "
"Set GITHUB_TOKEN to raise the API rate limit."
) from exc
if exc.code not in {429, 500, 502, 503, 504}:
raise SourceError(f"GitHub devolvio HTTP {exc.code} para {url}.") from exc
raise SourceError(f"GitHub returned HTTP {exc.code} para {url}.") from exc
except (urllib.error.URLError, TimeoutError) as exc:
last_error = exc
if attempt < 2:
@@ -142,7 +142,7 @@ class GitHubSource:
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")
raise SourceError("The ProxMenux application catalogue is not a list")
result: dict[str, dict[str, Any]] = {}
for item in payload:
if not isinstance(item, dict) or not item.get("slug"):
@@ -160,7 +160,7 @@ class GitHubSource:
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")
raise SourceError("GitHub returned a truncated tree for the CasaOS catalogue")
paths = sorted(
str(item["path"])
for item in tree.get("tree", [])
@@ -168,7 +168,7 @@ class GitHubSource:
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")
raise SourceError("No Compose files were found in the CasaOS catalogue")
return {
"repository": CASAOS_REPOSITORY_URL,
"revision": revision,
+47 -20
View File
@@ -56,27 +56,54 @@ def apply_selkies_contract(template):
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'):
if not profile.get('hardware_acceleration'):
# Fold optional Compose DRI devices into the Selkies GPU choice rather
# than asking twice or bypassing the acceleration menu.
removable = lambda device: (str(device.get('host_path_default', '')).startswith('/dev/dri')
and not device.get('required_by_compose'))
profile['device_requests'] = [d for d in profile.get('device_requests', []) if not removable(d)]
profile['optional_devices'] = [d for d in profile.get('optional_devices', [])
if not removable(d)]
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']}}
],
}
# NVIDIA needs the host driver/Toolkit and is unsupported by Alpine Selkies images.
hardware = profile.get('hardware_acceleration')
if not hardware or 'alpine' in profile['selkies'].get('base', '').lower():
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']}}
]}
profile['selkies']['nvidia'] = 'available-in-advanced-mode-with-compatible-host-driver-and-toolkit'
profiles = hardware.setdefault('profiles', [])
if any(item.get('id') == 'nvidia' for item in profiles):
return
profiles.append({
'id': 'nvidia',
'label': 'NVIDIA (Selkies rendering and NVENC)',
'device_requests': [{
'id': 'selkies-nvidia', 'kind': 'nvidia-runtime',
'device_selection': 'all-requested-by-compose',
}],
'environment': [
{'name': 'AUTO_GPU', 'value': 'true'},
{'name': 'NVIDIA_VISIBLE_DEVICES', 'value': 'all'},
{'name': 'NVIDIA_DRIVER_CAPABILITIES', 'value': 'compute,video,graphics,utility,display'},
],
})
def apply_profile_image(template, hardware_profile):
+187
View File
@@ -0,0 +1,187 @@
"""Application icons for the catalog, resolved against the jsdelivr icon sets.
The icon a catalog entry carries used to be a URL built from the application
id, which nobody ever requested: seven out of ten answered 404, so the panel
drew a broken image instead of a plain placeholder. These two sets publish an
index, so an entry only keeps an icon that is known to exist, and every icon
comes from the same CDN as the rest of the interface.
"""
from __future__ import annotations
import json
import re
import urllib.request
SELFHST_INDEX = "https://cdn.jsdelivr.net/gh/selfhst/icons@main/index.json"
SELFHST_ICON = "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/{name}.webp"
HOMARR_INDEX = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@main/tree.json"
HOMARR_ICON = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@main/webp/{name}.webp"
# The two sources the catalog itself is built from publish their icons beside
# the application, and both are served by the same CDN. Their file names are
# read from the repository tree rather than derived: linuxserver names them
# `<id>-logo.png`, and the `-icon.png` this catalog used to build answered 404
# for seven entries out of ten.
PUBLISHER_TREES = (
("https://api.github.com/repos/IceWhaleTech/CasaOS-AppStore/git/trees/main?recursive=1",
r"Apps/(?P<name>[^/]+)/icon\.(?:svg|png|webp)",
"https://cdn.jsdelivr.net/gh/IceWhaleTech/CasaOS-AppStore@main/{path}"),
("https://api.github.com/repos/linuxserver/docker-templates/git/trees/master?recursive=1",
r"linuxserver\.io/img/(?P<name>.+?)-(?:logo|icon)\.(?:png|svg|jpg|webp)",
"https://cdn.jsdelivr.net/gh/linuxserver/docker-templates@master/{path}"),
)
TIMEOUT = 30
VARIANT_SUFFIX_RE = re.compile(r"-(?:dark|light)$")
# Catalog ids carry the publisher or the accelerator when the same application
# ships under several images: emby-official, open-webui-cuda, kavita-jvmilazz0.
# They are the same application and they wear the same icon.
QUALIFIER_RE = re.compile(
r"[-_](?:official|stack|nvidia|cuda|rocm|gpu|ollama|jlesage|jvmilazz0)$")
# Applications neither icon set names, resolved by hand and verified. A few are
# only ProxMenux compositions, so they borrow the icon of what they assemble:
# HAOS One wears Home Assistant's, and the Arr suite wears Servarr's.
CURATED = {
"blade-of-agony": "https://cdn.jsdelivr.net/gh/linuxserver/docker-templates@master/linuxserver.io/img/boa-logo.png",
"budge": "https://cdn.jsdelivr.net/gh/linuxserver/budge@master/frontend/public/logo512.png",
"changedetection.io": "https://cdn.jsdelivr.net/gh/linuxserver/docker-templates@master/linuxserver.io/img/changedetection-icon.png",
"dosbox-staging": "https://cdn.jsdelivr.net/gh/linuxserver/docker-templates@master/linuxserver.io/img/dosbox-logo.png",
"dsh-harness": "https://cdn.jsdelivr.net/gh/deepseek-ai/deepseek-harness@master/apps/web/public/favicon.svg",
"faster-whisper": "https://cdn.jsdelivr.net/gh/home-assistant/brands@master/core_integrations/wyoming/icon.png",
"haos-one": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/home-assistant.webp",
"luanti": "https://cdn.jsdelivr.net/gh/linuxserver/docker-templates@master/linuxserver.io/img/minetest-icon.png",
"msedge": "https://cdn.jsdelivr.net/gh/linuxserver/docker-templates@master/linuxserver.io/img/edge-logo.png",
"pyload-ng": "https://cdn.jsdelivr.net/gh/linuxserver/docker-templates@master/linuxserver.io/img/pyload-logo.png",
"stable-diffusion-webui-nvidia": "https://cdn.jsdelivr.net/gh/IceWhaleTech/CasaOS-AppStore@main/Apps/StableDiffusionWebUI/icon.svg",
"suite-arr": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@main/svg/servarr.svg",
# SWAG publishes no icon of its own; what sits under its name is a 424 KB
# animated banner. The publisher's mark stands in for it.
"swag": "https://cdn.jsdelivr.net/gh/linuxserver/docker-templates@master/linuxserver.io/img/linuxserver-ls-logo.png",
"vscodium-web": "https://cdn.jsdelivr.net/gh/linuxserver/docker-templates@master/linuxserver.io/img/vscodium-icon.png",
}
def _key(value: str | None) -> str:
return re.sub(r"[^a-z0-9]", "", (value or "").lower())
def _read(url: str) -> object:
request = urllib.request.Request(url, headers={"User-Agent": "ProxMenux"})
with urllib.request.urlopen(request, timeout=TIMEOUT) as response:
return json.loads(response.read())
class IconResolver:
"""Resolve an application id and title to an icon on cdn.jsdelivr.net.
selfh.st is preferred because most of the interface already draws from it;
dashboard-icons covers what it lacks. Both are consulted by exact id, then
by id and title with separators removed, which is what recovers
``homeassistant`` from ``home-assistant`` and ``actualbudget`` from
``actual-budget``.
"""
def __init__(self, selfhst: object | None = None, homarr: object | None = None,
publishers: list[object] | None = None) -> None:
self._sets: list[tuple[dict[str, str], set[str], str]] = []
self._load_selfhst(selfhst if selfhst is not None else _read(SELFHST_INDEX))
self._load_homarr(homarr if homarr is not None else _read(HOMARR_INDEX))
for position, (url, pattern, template) in enumerate(PUBLISHER_TREES):
tree = publishers[position] if publishers is not None else None
if tree is None:
try:
tree = _read(url)
except Exception:
# A publisher tree that cannot be read costs coverage, not
# correctness: the generic sets already answered first.
continue
self._load_publisher(tree, pattern, template)
def _load_publisher(self, tree: object, pattern: str, template: str) -> None:
expression = re.compile(pattern)
names: dict[str, str] = {}
entries = tree.get("tree") if isinstance(tree, dict) else None
for entry in entries if isinstance(entries, list) else []:
if not isinstance(entry, dict) or entry.get("type") != "blob":
continue
match = expression.fullmatch(str(entry.get("path") or ""))
if not match:
continue
name = match.group("name")
for candidate in (name.lower(), _key(name)):
if candidate:
names.setdefault(candidate, entry["path"])
self._sets.append((names, set(), template.replace("{path}", "{name}")))
def _load_selfhst(self, index: object) -> None:
names: dict[str, str] = {}
themed: set[str] = set()
for entry in index if isinstance(index, list) else []:
if not isinstance(entry, dict) or entry.get("WebP") != "Yes":
continue
reference = str(entry.get("Reference") or "").strip()
if not reference:
continue
for candidate in (reference.lower(), _key(reference), _key(entry.get("Name"))):
if candidate:
names.setdefault(candidate, reference)
if entry.get("Dark") == "Yes" or entry.get("Light") == "Yes":
themed.add(reference)
self._sets.append((names, themed, SELFHST_ICON))
def _load_homarr(self, tree: object) -> None:
names: dict[str, str] = {}
themed: set[str] = set()
files = tree.get("webp") if isinstance(tree, dict) else None
for filename in files if isinstance(files, list) else []:
stem = str(filename)
if not stem.endswith(".webp"):
continue
stem = stem[: -len(".webp")]
base = VARIANT_SUFFIX_RE.sub("", stem)
if base != stem:
themed.add(base)
continue
for candidate in (base.lower(), _key(base)):
if candidate:
names.setdefault(candidate, base)
self._sets.append((names, themed, HOMARR_ICON))
@staticmethod
def reachable(url: str | None) -> bool:
"""Whether a URL inherited from a publisher still serves an image.
The linuxserver templates URL was built from the application id and
answers 404 for seven out of ten entries, so it is only kept for the
ones where the file is really there.
"""
if not isinstance(url, str) or not url.startswith(("http://", "https://")):
return False
request = urllib.request.Request(url, method="HEAD",
headers={"User-Agent": "ProxMenux"})
try:
with urllib.request.urlopen(request, timeout=TIMEOUT) as response:
return response.status == 200
except Exception:
return False
def resolve(self, app_id: str | None, title: str | None = None) -> dict[str, object] | None:
"""The icon for one application, or None when neither set has it."""
base = str(app_id or "").strip().lower()
if base in CURATED:
return {"url": CURATED[base], "themed": False}
trimmed = base
while True:
shorter = QUALIFIER_RE.sub("", trimmed)
if shorter == trimmed:
break
trimmed = shorter
lookups = [base, _key(app_id), _key(title)]
if trimmed != base:
lookups += [trimmed, _key(trimmed)]
for names, themed, pattern in self._sets:
for lookup in lookups:
name = names.get(lookup) if lookup else None
if name:
return {"url": pattern.format(name=name), "themed": name in themed}
return None
+16 -8
View File
@@ -122,10 +122,15 @@ def build_deployment(
ui = ui or TerminalUI()
if template.get('proxmox', {}).get('installer_profile', {}).get('stack_driver') == 'arr-suite':
from .arr_suite import build_suite
return build_suite(template, ui)
return build_suite(template, ui, mode)
if template.get('proxmox', {}).get('installer_profile', {}).get('stack_driver') == 'generic-multi-lxc-stack':
from .stack import build_stack
return build_stack(template, ui)
return build_stack(template, ui, mode)
if mode == DEFAULT_MODE and template.get('id') in {
'image-immich', 'image-nextcloud-stack', 'image-paperless-ngx', 'image-tandoor'
}:
from .stack import DefaultsUI
ui = DefaultsUI()
if template.get("id") == "image-immich":
return _build_immich_deployment(template, ui)
if template.get("id") == "image-nextcloud-stack":
@@ -1159,7 +1164,7 @@ def _run_remote_install(
shutil.copy2(project_root / 'remote' / 'haos_healthcheck.py', temporary / 'haos_healthcheck.py')
shutil.copy2(project_root / 'remote' / 'oci_installation_state.py', temporary / 'oci_installation_state.py')
shutil.copy2(project_root / 'remote' / 'oci_instances.py', temporary / 'oci_instances.py')
for helper in ('oci_ui.sh', 'oci_ui.py', 'oci_native_stack.py', 'oci_native_stack.sh', 'oci_instance_transaction.py', 'oci_host_mounts.py', 'oci_runtime_settings.py', 'oci_gpu_devices.py', 'oci_accelerators.py', 'oci_nvidia_runtime.py', 'oci_nvidia_refresh.py', 'oci_nvidia_dynamic.py', 'oci_update_current.py', 'oci_stack_replay.py', 'oci_stack_plan.py', 'oci_stack_transaction.py', 'oci_stack_native.py', 'oci_image_cache.py', 'nvidia_lxc_mount_lab.sh', 'oci_nvidia_setup.sh', 'oci_immich_ml.sh'):
for helper in ('oci_ui.sh', 'oci_ui.py', 'oci_description.py', 'oci_console.py', 'oci_native_stack.py', 'oci_native_stack.sh', 'oci_instance_transaction.py', 'oci_host_mounts.py', 'oci_runtime_settings.py', 'oci_gpu_devices.py', 'oci_accelerators.py', 'oci_nvidia_runtime.py', 'oci_nvidia_refresh.py', 'oci_nvidia_dynamic.py', 'oci_update_current.py', 'oci_stack_replay.py', 'oci_stack_plan.py', 'oci_stack_transaction.py', 'oci_stack_native.py', 'oci_image_cache.py', 'nvidia_lxc_mount_lab.sh', 'oci_nvidia_setup.sh', 'oci_immich_ml.sh'):
shutil.copy2(project_root / 'remote' / helper, temporary / helper)
if deployment_kind == 'generic-multi-lxc-stack':
shutil.copy2(project_root / 'remote' / 'install_generic_stack.py', temporary / 'install_generic_stack.py')
@@ -1196,7 +1201,7 @@ def _run_remote_install(
archive.add(project_root / 'remote' / 'haos_healthcheck.py', arcname='haos_healthcheck.py')
archive.add(project_root / 'remote' / 'oci_installation_state.py', arcname='oci_installation_state.py')
archive.add(project_root / 'remote' / 'oci_instances.py', arcname='oci_instances.py')
for helper in ('oci_ui.sh', 'oci_ui.py', 'oci_native_stack.py', 'oci_native_stack.sh', 'oci_instance_transaction.py', 'oci_host_mounts.py', 'oci_runtime_settings.py', 'oci_gpu_devices.py', 'oci_accelerators.py', 'oci_nvidia_runtime.py', 'oci_nvidia_refresh.py', 'oci_nvidia_dynamic.py', 'oci_update_current.py', 'oci_stack_replay.py', 'oci_stack_plan.py', 'oci_stack_transaction.py', 'oci_stack_native.py', 'oci_image_cache.py', 'nvidia_lxc_mount_lab.sh', 'oci_nvidia_setup.sh', 'oci_immich_ml.sh'):
for helper in ('oci_ui.sh', 'oci_ui.py', 'oci_description.py', 'oci_console.py', 'oci_native_stack.py', 'oci_native_stack.sh', 'oci_instance_transaction.py', 'oci_host_mounts.py', 'oci_runtime_settings.py', 'oci_gpu_devices.py', 'oci_accelerators.py', 'oci_nvidia_runtime.py', 'oci_nvidia_refresh.py', 'oci_nvidia_dynamic.py', 'oci_update_current.py', 'oci_stack_replay.py', 'oci_stack_plan.py', 'oci_stack_transaction.py', 'oci_stack_native.py', 'oci_image_cache.py', 'nvidia_lxc_mount_lab.sh', 'oci_nvidia_setup.sh', 'oci_immich_ml.sh'):
archive.add(project_root / 'remote' / helper, arcname=helper)
if deployment_kind == 'generic-multi-lxc-stack':
archive.add(project_root / 'remote' / 'install_generic_stack.py', arcname='install_generic_stack.py')
@@ -1368,10 +1373,13 @@ def configure_acceleration(installer_profile, environment, unprivileged, ui, mod
if hardware:
profiles = hardware.get("profiles", [])
options = [(item["id"], item["label"]) for item in profiles]
selected_hardware_profile = ui.choose(
translate(hardware.get("prompt", "Hardware acceleration")),
[(tag, translate(label)) for tag, label in options],
hardware.get("default", profiles[0]["id"] if profiles else None),
default_profile = hardware.get("default", profiles[0]["id"] if profiles else None)
selected_hardware_profile = (
ui.choose(
translate(hardware.get("prompt", "Hardware acceleration")),
[(tag, translate(label)) for tag, label in options],
default_profile,
) if advanced or not installer_profile.get("selkies") else default_profile
)
if selected_hardware_profile is None:
raise UserCancelled(translate("Acceleration configuration cancelled"))
+169 -42
View File
@@ -125,7 +125,8 @@ def _run_lifecycle(command, title):
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...'))
if sys.stdin.isatty():
console.wait_for_enter(translate('Press Enter to return to the menu...'))
return completed.returncode == 0
@@ -163,28 +164,54 @@ def _interactive_management(project, ui):
if selection is None:
return
row = next(r for r in rows if str(r['vmid']) == selection)
manage_instance(project, ui, row)
def manage_instance(project, ui, row, action=None, lifecycle_args=()):
"""What the menu does with one instance once it is selected. `action`
skips the choice of operation, as ProxMenux Monitor does; the extra
`lifecycle_args` are passed to the program that performs it."""
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
return False
if row['stack']:
_manage_stack(project, ui, row)
return
return _manage_stack(project, ui, row, action, lifecycle_args)
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')
return False
if action is None:
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 False
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
return _remove(project, ui, row['vmid'])
import oci_instance_reconcile as reconcile
try:
current_config = instances.command('pct', 'config', str(row['vmid']))
adoption = reconcile.propose(record, current_config)
except (OSError, ValueError, RuntimeError) as error:
ui.message(str(error), translate('Review external OCI changes'))
return False
if adoption:
summary = '\n'.join(adoption['details'])
if not ui.review(
f"{translate('These Proxmox resources were added outside ProxMenux:')}\n\n{summary}\n\n"
+ translate('They will be added to oci-compose before continuing. Unsupported or changed resources are not imported.'),
translate('Review external OCI changes'),
question=translate('Include these resources in oci-compose?'), default=False):
return False
try:
record = reconcile.commit(instances.ROOT, row['vmid'], adoption)
except (OSError, ValueError, RuntimeError) as error:
ui.message(str(error), translate('Review external OCI changes'))
return False
proposal = None
if action == 'recreate':
from .recreation import edit_recreation
@@ -193,16 +220,18 @@ def _interactive_management(project, 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
return False
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'])]
return False
command = [sys.executable, str(project / 'remote/oci_update_current.py'), str(row['vmid']),
*lifecycle_args]
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')
if '--acknowledge-external-data' not in command:
if not ui.confirm(translate('Shared host data is not reverted by the backup. Continue?'), False):
return False
command.append('--acknowledge-external-data')
title = translate('Recreate OCI') if proposal else translate('Update OCI')
if proposal is None:
completed = _run_lifecycle(command, title)
@@ -212,20 +241,20 @@ def _interactive_management(project, ui):
json.dump(proposal, file)
file.flush()
completed = _run_lifecycle(command + ['--proposal', file.name], title)
if completed:
if completed and not getattr(ui, 'unattended', False):
images.offer_removal(ui, [row['vmid']])
return
return completed
action = ui.choose(translate('Interrupted operation'), [('status', translate('View status')),
('recover', translate('Recover the previous installation'))], 'status')
if action is None:
return
return False
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'))
return False
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):
@@ -293,16 +322,16 @@ def _remove(project, ui, vmid):
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
return False
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'))
return False
return _run_lifecycle([sys.executable, str(project / 'remote/oci_remove.py'), str(vmid)],
translate('Remove OCI'))
def _manage_stack(project, ui, row):
def _manage_stack(project, ui, row, action=None, lifecycle_args=()):
sys.path.insert(0, str(project / 'remote'))
import oci_instances as instances
record = instances.read(instances.ROOT, row['vmid'])
@@ -320,34 +349,132 @@ def _manage_stack(project, ui, row):
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')
return False
if action == 'recreate':
ui.message(translate('A multi-container application is not recreated: its containers are updated together.'), translate('OCI stack management'))
return False
if action is None:
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 False
if action == 'remove':
_remove(project, ui, primary_id)
return
return _remove(project, ui, primary_id)
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
return False
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
return False
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')
else:
command.extend(lifecycle_args)
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')
if '--acknowledge-external-data' not in command:
if not ui.confirm(translate('Shared host data is not reverted by the backups. Continue?'), False):
return False
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:
if completed and not pending and not getattr(ui, 'unattended', False):
images.offer_removal(ui, [int(member['vmid']) for member in members])
return completed
class UnattendedUI:
"""The answers of a scheduled run: a step with a positive default goes on,
and one that needs a person (adopting external changes) stops the run."""
unattended = True
def __init__(self):
self.declined = None
def message(self, text, title=None):
print(f"{title}: {text}" if title else text, flush=True)
def review(self, text, title=None, question=None, default=False, **_):
if not default:
self.declined = title or question
self.message(text, title)
return default
def confirm(self, text, default=False, **_):
if not default:
self.declined = text
return default
def choose(self, *_, **__):
return None
def ask(self, text, *_, **__):
raise RuntimeError(f"{translate('A scheduled run cannot answer:')} {text}")
# Exit codes of `manage`, read by ProxMenux Monitor.
EXIT_DONE, EXIT_FAILED, EXIT_NOT_OCI, EXIT_BUSY, EXIT_NEEDS_REVIEW, EXIT_TOO_RECENT = 0, 1, 2, 3, 4, 5
def _image_too_recent(project, vmid, min_age_days):
"""Whether a new image of the instance, or of any member of its stack, is
younger than the given number of days. An unchanged digest is never too
recent: there is nothing to install."""
import datetime
sys.path.insert(0, str(project / 'remote'))
import oci_instances as instances
from oci_installation_state import parse_config, resolve_candidate
record = instances.read(instances.ROOT, vmid)
primary_id = record.get('stack_member', {}).get('primary_vmid', vmid)
primary = instances.read(instances.ROOT, primary_id) if primary_id != vmid else record
members = [m['vmid'] for m in primary.get('stack', {}).get('members', [])] or [vmid]
limit = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=min_age_days)
for member in members:
member_record = instances.read(instances.ROOT, member)
reference = member_record['template']['container_contract']['image']['reference']
arch = parse_config(instances.command('pct', 'config', str(member)))['arch']
candidate = resolve_candidate(reference, arch)
installed = (member_record.get('observed', {}).get('image') or {}).get('manifest_digest')
if candidate.get('manifest_digest') == installed or not candidate.get('created'):
continue
created = datetime.datetime.fromisoformat(str(candidate['created']).replace('Z', '+00:00'))
if created.tzinfo is None:
created = created.replace(tzinfo=datetime.timezone.utc)
if created > limit:
return True
return False
def direct_management(project, vmid, action, lifecycle_args=(), unattended=False, min_image_age_days=0):
"""One operation on one instance, without the list of the menu: the entry
ProxMenux Monitor uses for its Update and Recreate buttons and for
scheduled updates."""
from .ui import interactive_ui
ui = UnattendedUI() if unattended else interactive_ui()
if os.geteuid() != 0 or not shutil.which('pct'):
ui.message(translate('This interface runs on the Proxmox node as root. Open OCI manager Apps from the ProxMenux menu on the Proxmox host.'), translate('OCI management'))
return EXIT_FAILED
if unattended and action != 'update':
ui.message(translate('Only the update of the image runs unattended.'), translate('OCI management'))
return EXIT_FAILED
try:
row = next((r for r in saved_inventory(project) if r['vmid'] == vmid), None)
if row is None:
ui.message(translate('This container is not a registered OCI instance.'), translate('OCI management'))
return EXIT_NOT_OCI
if min_image_age_days > 0 and action == 'update' and _image_too_recent(project, vmid, min_image_age_days):
ui.message(translate('The new image is more recent than the minimum age set for scheduled updates; it is not installed yet.'), translate('Update OCI'))
return EXIT_TOO_RECENT
completed = manage_instance(project, ui, row, action, lifecycle_args)
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'))
return EXIT_BUSY
if unattended and ui.declined:
return EXIT_NEEDS_REVIEW
return EXIT_DONE if completed else EXIT_FAILED
+18 -14
View File
@@ -247,8 +247,8 @@ class DefaultsUI:
pass
def build_stack(template, ui):
from .installer import build_deployment, _hostname_default
def build_stack(template, ui, mode='advanced'):
from .installer import build_deployment, _hostname_default, DEFAULT_MODE
problems = assess(template)
if problems:
raise StackError('; '.join(problems))
@@ -259,10 +259,10 @@ def build_stack(template, ui):
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'])
root = ask_storage(ui, translate('Storage for rootfs'), 'rootdir', defaults['rootfs_storage'], mode)
volumes = ask_storage(ui, translate('Storage for persistent data'), 'rootdir', defaults['volume_storage'], mode)
cache = ask_storage(ui, translate('Storage for the OCI image cache'), 'vztmpl', defaults['template_storage'], mode)
bridge = ask_bridge(ui, translate('Access bridge'), defaults['bridge'], mode)
addresses, gateway = access.ask_addresses(ui, bridge, [''])
timezone = ui.ask(translate('Timezone'), host.timezone())
onboot = ui.confirm(translate('Start the stack with Proxmox'), False)
@@ -302,7 +302,7 @@ def build_stack(template, ui):
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())
plan = build_deployment(single, DefaultsUI(), DEFAULT_MODE)
# Values are already resolved; do not reinterpret user credentials as Compose variables.
plan['environment'] = [{'name':key,'value':value,'sensitive':True} for key,value in env.items() if value != '']
plan.update(hostname=_hostname_default(name+'-'+s['name']), template_storage=cache,
@@ -311,19 +311,20 @@ def build_stack(template, ui):
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:
mount_mode = ('managed-volume' if mode == DEFAULT_MODE else
ui.choose(f"{s['name']}: {m['container_path']}",
[('managed-volume',translate('Container volume (included in backups)')),
('host-bind',translate('Host directory'))], 'managed-volume'))
if mount_mode is None:
raise StackError(translate('Storage selection cancelled'))
m.update(type=mode, source=volumes, backup=mode=='managed-volume')
if mode == 'host-bind':
m.update(type=mount_mode, source=volumes, backup=mount_mode=='managed-volume')
if mount_mode == 'host-bind':
m['source'] = ui.ask(translate('Host directory'), '/mnt/oci-shared/'+name+'/'+s['name']+'/'+m['container_path'].strip('/').replace('/','-'))
m['size_gb'] = None
else:
elif mode != DEFAULT_MODE:
m['size_gb'] = int(ui.ask(translate('Volume size in GB'), str(max(8,m['size_gb'] or 8))))
if m['size_gb'] is not None and m['size_gb'] < 1:
raise StackError(translate('Invalid volume size'))
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':
@@ -351,6 +352,9 @@ def build_stack(template, ui):
'template':single,'deployment':plan,'healthcheck':health})
if main:
plans[-1]['frontend_ipv4'] = addresses['']
if mode != DEFAULT_MODE:
from .custom_mounts import ask_stack_custom_mounts
ask_stack_custom_mounts(ui, plans, volumes)
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,