mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-10-04 20:46:43 +00:00
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:
co-authored by
Claude Opus 5.5
parent
386d33df6e
commit
4437a671d2
@@ -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':[
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
@@ -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"))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user