From 00350655bfce81e3d1604f4272f7430a1e24b47b Mon Sep 17 00:00:00 2001 From: MacRimi Date: Thu, 15 Oct 2026 20:06:06 +0200 Subject: [PATCH] add NUT Server to the catalog - NUT Server (instantlinux/nut-upsd) runs the Network UPS Tools server for a UPS connected to the host by USB: the installation proposes the UPS the host detects, passes it to the container, generates the password of the upsmon user and starts with the host; clients such as PeaNUT connect on port 3493. - A recipe can ask for the UPS of the host: without one, the installation stops and says so, and the advanced installation lets the node be typed by hand. - The catalog lists 335 applications. - Tests for the proposed UPS, a host without one and the recipe. --- .../tests/test_command_descriptions.py | 2 + AppImage/components/lxc-terminal-modal.tsx | 13 + AppImage/messages/en/common.json | 4 +- AppImage/messages/es/common.json | 4 +- lang/es.json | 1 + oci/VALIDATION.md | 2 +- oci/catalog/apps/nut-server.json | 469 ++++++++++++++++++ oci/catalog/curated/nut-server.json | 469 ++++++++++++++++++ oci/catalog/generation-report-curated.json | 3 +- oci/catalog/index.json | 32 ++ oci/src/proxmenux_oci/installer.py | 17 +- oci/tests/test_usb_devices.py | 32 ++ 12 files changed, 1043 insertions(+), 5 deletions(-) create mode 100644 oci/catalog/apps/nut-server.json create mode 100644 oci/catalog/curated/nut-server.json diff --git a/.github/scripts/tests/test_command_descriptions.py b/.github/scripts/tests/test_command_descriptions.py index de269101..ec35c3b2 100644 --- a/.github/scripts/tests/test_command_descriptions.py +++ b/.github/scripts/tests/test_command_descriptions.py @@ -213,6 +213,8 @@ class CommandDescriptionsTests(unittest.TestCase): # Model the bot's future output in temporary catalogs, just # like the other pending keys above; never rewrite locales. messages["scriptTerminal"].setdefault("endOfInput", english["scriptTerminal"]["endOfInput"]) + for key in ("noShell", "enterFailed"): + messages["lxcTerminal"]["errors"].setdefault(key, english["lxcTerminal"]["errors"][key]) # The notification history panel texts await the bot as well. for key, value in english["settings"]["notifications"]["history"].items(): messages["settings"]["notifications"].setdefault("history", {}).setdefault(key, value) diff --git a/AppImage/components/lxc-terminal-modal.tsx b/AppImage/components/lxc-terminal-modal.tsx index fba19ab1..06b94886 100644 --- a/AppImage/components/lxc-terminal-modal.tsx +++ b/AppImage/components/lxc-terminal-modal.tsx @@ -382,6 +382,19 @@ export function LxcTerminalModal({ return } + + // The host prompt came back: pct enter could not enter the + // container. Show why and close, so the session is never left + // on the host shell under the container's name. + const reason = afterPctEnter.split(/\r?\n/).slice(0, -1) + .map((line) => line.trim()).filter(Boolean).join("\r\n") + if (reason) term.writeln(`\x1b[31m${reason}\x1b[0m`) + term.writeln(`\r\n\x1b[33m${/Failed to exec/.test(reason) + ? t("lxcTerminal.errors.noShell") + : t("lxcTerminal.errors.enterFailed")}\x1b[0m`) + isInsideLxcRef.current = true + ws.close() + return } } } else { diff --git a/AppImage/messages/en/common.json b/AppImage/messages/en/common.json index e26859f4..4b0b6e56 100644 --- a/AppImage/messages/en/common.json +++ b/AppImage/messages/en/common.json @@ -5014,7 +5014,9 @@ "lxcTerminal": { "title": "Terminal: {name} (ID: {id})", "errors": { - "invalidVmid": "Invalid VMID — refusing to execute pct enter" + "invalidVmid": "Invalid VMID — refusing to execute pct enter", + "noShell": "This container's image has no shell, so it cannot open a terminal. Its output is in the Logs tab.", + "enterFailed": "The container could not be entered." }, "commands": { "listFiles": "List all files with details", diff --git a/AppImage/messages/es/common.json b/AppImage/messages/es/common.json index da597ae4..e7e29198 100644 --- a/AppImage/messages/es/common.json +++ b/AppImage/messages/es/common.json @@ -5015,7 +5015,9 @@ "lxcTerminal": { "title": "Terminal: {name} (ID: {id})", "errors": { - "invalidVmid": "VMID no válido: se niega a ejecutar pct enter" + "invalidVmid": "VMID no válido: se niega a ejecutar pct enter", + "noShell": "La imagen de este contenedor no incluye shell, así que no puede abrir una terminal. Su salida está en la pestaña Logs.", + "enterFailed": "No se pudo entrar en el contenedor." }, "commands": { "listFiles": "Listar todos los archivos con detalles", diff --git a/lang/es.json b/lang/es.json index df9289a3..f85d5169 100644 --- a/lang/es.json +++ b/lang/es.json @@ -4066,6 +4066,7 @@ "No Shares Found": "No se encontraron recursos compartidos", "No Shares to Test": "Sin recursos compartidos que probar", "No Storage Found": "No se encontró almacenamiento", + "No UPS was found on a USB port of this host. Connect it and start the installation again.": "No se encontró ningún SAI en los puertos USB de este host. Conéctelo y vuelva a iniciar la instalación.", "No USB drives detected. Enter the mountpoint path manually:": "No se detectaron unidades USB.Ingrese la ruta del punto de montaje manualmente:", "No USB drives mounted by ProxMenux yet. Mount one first to use it as a target.": "Aún no hay unidades USB montadas por ProxMenux. Monte una primero para usarla como destino.", "No UUP folder found.": "No se encontró ninguna carpeta UUP.", diff --git a/oci/VALIDATION.md b/oci/VALIDATION.md index ab136e60..47dac724 100644 --- a/oci/VALIDATION.md +++ b/oci/VALIDATION.md @@ -2,7 +2,7 @@ -43 of 334 applications in the OCI catalog have been tested for real: 41 in the ProxMenux lab and 2 by the community. The OCI installer shows them as verified, with a ✓ in the lists. +43 of 335 applications in the OCI catalog have been tested for real: 41 in the ProxMenux lab and 2 by the community. The OCI installer shows them as verified, with a ✓ in the lists. Each test describes the scenario that was run and names the image it ran on. It does not cover every possible configuration of the application, and a newer image has not been tested until someone reports it. diff --git a/oci/catalog/apps/nut-server.json b/oci/catalog/apps/nut-server.json new file mode 100644 index 00000000..bfff7353 --- /dev/null +++ b/oci/catalog/apps/nut-server.json @@ -0,0 +1,469 @@ +{ + "schema_version": "0.5.0", + "kind": "proxmenux.oci-template", + "id": "image-nut-server", + "status": "generated-unvalidated", + "catalog_ui": { + "title": { + "en_US": "NUT Server" + }, + "tagline": { + "en_US": "Network UPS Tools server for a UPS connected to the host by USB." + }, + "description": { + "en_US": "Network UPS Tools server (upsd) with the driver of a UPS connected to a USB port of the host. It reads the battery, load and power state of the UPS and serves them on port 3493 to NUT clients: PeaNUT, the Proxmox host and any other machine that has to shut down when the power fails.\n\n**Before installing:**\n- Connect the UPS to a USB port of the Proxmox host. The installation passes it to the container.\n- Clients connect with the user upsmon and the password shown at the end of the installation.\n- The Proxmox host is not shut down by this server on its own: it needs its own NUT client (upsmon) pointing to it.\n\n**Learn More:**\n- [Network UPS Tools](https://networkupstools.org/)\n- [Image documentation](https://github.com/instantlinux/docker-tools/tree/main/images/nut-upsd)" + }, + "category": "monitoring", + "category_label": "Monitoring & Analytics", + "author": "instantlinux", + "developer": "Network UPS Tools", + "icon": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/network-ups-tools.webp", + "thumbnail": null, + "screenshots": [], + "architectures": [ + "amd64", + "arm64" + ], + "launch": { + "scheme": "http", + "port": null, + "path": "/" + }, + "website": "https://networkupstools.org/", + "documentation": "https://github.com/instantlinux/docker-tools/tree/main/images/nut-upsd", + "repository": "https://github.com/instantlinux/docker-tools", + "tips": [], + "mini_changelog": [], + "display_version": null, + "updated_at": null + }, + "source": { + "provider": "instantlinux", + "repository": "https://github.com/instantlinux/docker-tools", + "revision": "3d3e17dc5dafb3f544ce0bdaa5ea4cf3814d5b1cb5b6323af2c40d1b224c5fd9", + "image_repository_url": "https://hub.docker.com/r/instantlinux/nut-upsd", + "readme_pushed_at": "", + "compose_sha256": "3d3e17dc5dafb3f544ce0bdaa5ea4cf3814d5b1cb5b6323af2c40d1b224c5fd9", + "generated_at": "2026-10-15T17:50:24+00:00" + }, + "container_contract": { + "service_name": "nut-upsd", + "container_name": "nut-upsd", + "image": { + "reference": "instantlinux/nut-upsd:latest", + "registry": "docker.io", + "repository": "instantlinux/nut-upsd", + "tag": "latest", + "digest": null, + "pull_policy": "resolve-written-reference-to-architecture-digest-at-install" + }, + "environment": [ + { + "name": "NAME", + "example": "ups", + "required": true, + "sensitive": false, + "source": "docker-compose" + }, + { + "name": "DESCRIPTION", + "example": "UPS", + "required": true, + "sensitive": false, + "source": "docker-compose" + }, + { + "name": "DRIVER", + "example": "usbhid-ups", + "required": true, + "sensitive": false, + "source": "docker-compose" + }, + { + "name": "API_USER", + "example": "upsmon", + "required": true, + "sensitive": false, + "source": "docker-compose" + }, + { + "name": "API_PASSWORD", + "example": "", + "required": true, + "sensitive": true, + "source": "docker-compose", + "prompt": "Password of the NUT server user" + } + ], + "volumes": [], + "ports": [ + { + "container_port": 3493, + "published_example": 3493, + "protocol": "tcp", + "required": true, + "proxmox_behavior": "listener-on-dedicated-lxc-address-no-nat" + } + ], + "related_services": [], + "restart": "unless-stopped", + "stop_grace_period": null, + "original_compose": "services:\n nut-upsd:\n image: instantlinux/nut-upsd:latest\n container_name: nut-upsd\n restart: unless-stopped\n environment:\n NAME: ups\n DESCRIPTION: UPS\n DRIVER: usbhid-ups\n API_USER: upsmon\n API_PASSWORD: ${GENERATED_API_PASSWORD}\n ports:\n - 3493:3493\n devices:\n - /dev/bus/usb/001/001:/dev/bus/usb/001/001\n" + }, + "compose_stack": { + "project_name": "nut-upsd", + "deployment_model": "one-native-oci-lxc-per-compose-service", + "user_experience": "single-application-install", + "main_service": "nut-upsd", + "service_count": 1, + "services": [ + { + "name": "nut-upsd", + "image": "instantlinux/nut-upsd:latest", + "is_main": true, + "role": "frontend", + "vmid_offset": 0, + "depends_on": [], + "frontend_network": true, + "private_network": false, + "compose": { + "image": "instantlinux/nut-upsd:latest", + "container_name": "nut-upsd", + "restart": "unless-stopped", + "environment": { + "NAME": "ups", + "DESCRIPTION": "UPS", + "DRIVER": "usbhid-ups", + "API_USER": "upsmon", + "API_PASSWORD": "${GENERATED_API_PASSWORD}" + }, + "ports": [ + "3493:3493" + ], + "devices": [ + "/dev/bus/usb/001/001:/dev/bus/usb/001/001" + ] + } + } + ], + "top_level": {}, + "networking": { + "frontend": "selected-proxmox-bridge", + "private_required": false, + "private_creation": "automatic-create-if-missing", + "private_address_allocation": "automatic-static-address-per-service", + "service_discovery": "private-addresses-with-compose-service-host-aliases", + "dependency_external_access": "disabled-unless-service-publishes-ports", + "prompt_user_for_private_network": false + }, + "storage": [], + "orchestration": { + "reserve_vmids_atomically": 1, + "start_order": [ + "nut-upsd" + ], + "stop_order": [ + "nut-upsd" + ], + "dependency_readiness": "compose-healthcheck-then-port-or-process-fallback", + "rollback_on_failure": "remove-new-rootfs-preserve-created-persistent-volumes" + }, + "installer_inputs": { + "prompted": [ + "stack_name", + "base_vmid", + "rootfs_storage", + "persistent_data_destinations", + "frontend_bridge", + "frontend_ipv4_mode" + ], + "automatic": [ + "dependent_vmids", + "private_bridge", + "private_subnet", + "private_service_addresses", + "compose_service_aliases", + "generated_secrets", + "dependency_start_and_stop_order" + ], + "generated_secrets": [ + { + "id": "api-password", + "strategy": "generate-cryptographically-random-at-install", + "bindings": [ + { + "service": "nut-upsd", + "environment_variable": "API_PASSWORD" + } + ] + } + ] + } + }, + "first_run": { + "endpoints": [], + "credentials": [ + { + "label": "NUT server user for clients such as PeaNUT", + "type": "configured-or-installer-generated", + "username": "upsmon", + "password": null, + "password_environment": "API_PASSWORD", + "change_required": false, + "source": "image-documentation", + "retrieval": null + } + ] + }, + "proxmox": { + "runtime": "native-oci-lxc", + "technology_status": "proxmox-technology-preview", + "defaults": { + "unprivileged": true, + "ostype": "auto-from-image", + "cores": 1, + "memory_mb": 256, + "swap_mb": 256, + "rootfs_size_gb": 4, + "rootfs_storage": "local-lvm", + "volume_storage": "local-lvm", + "template_storage": "local", + "bridge": "vmbr0", + "ipv4": "dhcp", + "firewall": true, + "host_managed_network": true, + "onboot": true, + "features": [ + "nesting=1" + ], + "shutdown_timeout_seconds": 30 + }, + "image_metadata_policy": { + "entrypoint": "import-from-oci-image", + "cmd": "import-from-oci-image", + "environment": "import-image-env-then-apply-compose-overrides", + "user": "import-from-oci-image", + "working_dir": "import-from-oci-image", + "stop_signal": "import-from-oci-image" + }, + "installer_profile": { + "device_requests": [ + { + "id": "ups-usb", + "kind": "character-device", + "purpose": "ups", + "enable_prompt": "Pass the UPS to the LXC", + "enabled_default": true, + "required_for_runtime": true, + "required_by_compose": true, + "path_prompt": "USB device of the UPS", + "host_path_default": "/dev/bus/usb/001/001", + "container_path_strategy": "same-as-host", + "mode": "0666", + "deny_write": false, + "gid_strategy": "host-device-gid", + "source_mapping": "/dev/bus/usb/001/001:/dev/bus/usb/001/001" + } + ], + "generated_sensitive_environment": { + "API_PASSWORD": { + "strategy": "token-hex", + "bytes": 12, + "prompt": true + } + } + }, + "adaptations": [ + { + "id": "imported-compose-source", + "upstream_behavior": "The source definition deploys the complete Docker Compose application model.", + "native_lxc_behavior": "The source model is preserved and remains blocked until every service option has a reviewed native Proxmox mapping.", + "reason": "Catalog import must not imply runtime compatibility.", + "behavioral_impact": "No automatic installation before review.", + "validation": "pending-per-application" + }, + { + "id": "rolling-latest-image", + "upstream_behavior": "A discovered Compose may pin a release tag or digest.", + "native_lxc_behavior": "ProxMenux selects the same image repository with the latest tag for catalog installations.", + "reason": "The automatic catalog intentionally offers rolling latest images; pinned versions belong to the future manual installer.", + "behavioral_impact": "The installed release can be newer than the discovered Compose revision.", + "validation": "pending-per-application" + }, + { + "id": "dedicated-lxc-network", + "upstream_behavior": "Docker publishes selected container ports on the Docker host.", + "native_lxc_behavior": "A reviewed native application will listen on its original container ports at a dedicated LXC address.", + "reason": "A native LXC has its own address and does not require Docker port NAT.", + "behavioral_impact": "Published ports are metadata; ProxMenux URLs use the matching container target port.", + "validation": "pending-per-application" + }, + { + "id": "compose-shm-size", + "upstream_behavior": "Compose sets the size of the container /dev/shm tmpfs.", + "native_lxc_behavior": "ProxMenux mounts a native LXC tmpfs at /dev/shm with the same requested capacity.", + "reason": "The OCI image runs directly as an LXC and therefore needs the equivalent Proxmox mount entry.", + "behavioral_impact": "None expected.", + "validation": "not-requested-by-compose" + }, + { + "id": "compose-command", + "upstream_behavior": "Compose replaces the image Cmd while retaining its Entrypoint.", + "native_lxc_behavior": "ProxMenux reads the official OCI Entrypoint and combines it with the Compose command as the native LXC init command.", + "reason": "Proxmox stores the effective OCI process as one entrypoint string.", + "behavioral_impact": "None expected.", + "validation": "not-requested-by-compose" + }, + { + "id": "compose-privileged-mode", + "upstream_behavior": "Compose selects whether the container runs in privileged mode.", + "native_lxc_behavior": "ProxMenux keeps the LXC unprivileged by default and exposes the broad Compose privilege request only as an explicit compatibility option.", + "reason": "A Compose privilege request is source metadata, not proof that the image technically requires a privileged LXC.", + "behavioral_impact": "A privileged LXC has weaker isolation from the Proxmox host.", + "validation": "native-equivalent" + }, + { + "id": "compose-process-runtime", + "upstream_behavior": "Compose can replace Entrypoint, User and WorkingDir and request an init process or interactive terminal.", + "native_lxc_behavior": "ProxMenux applies the process overrides through native LXC init directives; lxc-init provides PID 1 supervision and the CT console provides terminal access.", + "reason": "The OCI process must start with the same identity, command and working directory without Docker.", + "behavioral_impact": "Compose stdin_open and tty become access through the Proxmox LXC console.", + "validation": "not-requested-by-compose" + }, + { + "id": "compose-healthcheck", + "upstream_behavior": "Docker periodically executes the declared container healthcheck.", + "native_lxc_behavior": "For a single-service LXC, ProxMenux translates HTTP localhost checks into a mandatory first-start service check.", + "reason": "Proxmox has no persistent Docker health state, while the installer still must detect a failed first boot.", + "behavioral_impact": "The check runs during installation rather than continuously after installation.", + "validation": "not-requested-by-compose" + }, + { + "id": "compose-cpu-priority", + "upstream_behavior": "Docker cpu_shares sets a relative scheduling weight with 1024 as its neutral value.", + "native_lxc_behavior": "ProxMenux converts the relative weight to Proxmox cpuunits with 100 as its neutral value and lets the user review it.", + "reason": "Both settings express relative CPU priority on different scales.", + "behavioral_impact": "Rounding and Proxmox minimum limits can slightly change very low weights.", + "validation": "not-requested-by-compose" + }, + { + "id": "compose-resource-limits", + "upstream_behavior": "mem_limit sets the memory ceiling; ulimits sets process soft/hard resource limits.", + "native_lxc_behavior": "mem_limit supplies the editable Proxmox memory default, rounded up to MiB; ulimits maps to lxc.prlimit with -1 represented as unlimited.", + "reason": "Use native Proxmox memory and LXC prlimits, without a wrapper or changing the host kernel configuration.", + "behavioral_impact": "User-selected memory overrides Compose. Swap is a separate choice. Kernel restrictions still apply; nproc counts processes by real UID, not by container.", + "validation": "not-requested-by-compose" + }, + { + "id": "compose-network-identity", + "upstream_behavior": "Compose can set hostname, MAC address, extra hosts and attach a service to Docker networks.", + "native_lxc_behavior": "ProxMenux applies hostname and MAC to net0, writes additional host aliases into the LXC and uses its dedicated bridge connection for single-service networks.", + "reason": "A dedicated LXC has its own network namespace and does not need a Docker bridge per service.", + "behavioral_impact": "host-gateway resolves to the IPv4 address of the selected Proxmox bridge.", + "validation": "not-requested-by-compose" + }, + { + "id": "compose-network-mode", + "upstream_behavior": "Docker host mode removes Docker network isolation; bridge and default use a Docker-managed network.", + "native_lxc_behavior": "The OCI process uses the dedicated LXC network namespace directly, so host, bridge and default all listen on the LXC address without Docker NAT.", + "reason": "The LXC is the application host and already has its own address and port namespace.", + "behavioral_impact": "host means the LXC host, never the Proxmox host; this preserves Proxmox network isolation.", + "validation": "not-requested-by-compose" + }, + { + "id": "compose-capabilities-and-sysctls", + "upstream_behavior": "Compose can add Linux capabilities and set kernel parameters in the container network namespace.", + "native_lxc_behavior": "ProxMenux validates requested capabilities against the native LXC capability set and writes namespaced network settings as lxc.sysctl directives.", + "reason": "A native OCI-LXC already starts with the namespaced capability set; lxc.cap.keep would incorrectly discard unrelated required capabilities.", + "behavioral_impact": "Host-global capabilities such as SYS_MODULE remain blocked until their host prerequisite is explicitly adapted.", + "validation": "not-requested-by-compose" + }, + { + "id": "docker-engine-metadata", + "upstream_behavior": "Compose labels annotate Docker objects and the json-file logging driver rotates Docker-managed logs.", + "native_lxc_behavior": "Labels remain source metadata; Docker json-file settings are not applied because the OCI process runs directly under LXC.", + "reason": "There is no Docker object or Docker json-file log behind a native OCI-LXC application.", + "behavioral_impact": "Docker-only label consumers and Docker log-driver rotation do not exist in the native deployment.", + "validation": "not-requested-by-compose" + }, + { + "id": "compose-device-passthrough", + "upstream_behavior": "Compose passes host character devices or requests an NVIDIA runtime GPU.", + "native_lxc_behavior": "ProxMenux converts recognized device declarations to native Proxmox dev resources; NVIDIA profiles also inject compatible host driver libraries read-only.", + "reason": "Native OCI-LXC does not execute Docker device or NVIDIA runtime hooks.", + "behavioral_impact": "Hardware is exposed only after explicit user confirmation and host-path validation.", + "validation": "pending-per-application" + }, + { + "id": "compose-host-ipc", + "upstream_behavior": "ipc: host shares the Docker host IPC namespace, commonly to avoid Docker's small default shared-memory allocation.", + "native_lxc_behavior": "The application keeps the LXC IPC namespace and receives a configurable 1 GiB /dev/shm instead of sharing Proxmox host IPC.", + "reason": "Processes in a single native LXC already share one IPC namespace; retaining isolation is safer than exposing host IPC.", + "behavioral_impact": "The application cannot exchange IPC objects with processes on the Proxmox host.", + "validation": "not-requested-by-compose" + } + ] + }, + "compatibility": { + "automatic_install_candidate": true, + "validated": false, + "supported_compose_keys": [ + "cap_add", + "command", + "container_name", + "cpu_shares", + "deploy", + "devices", + "entrypoint", + "environment", + "extra_hosts", + "group_add", + "healthcheck", + "hostname", + "image", + "init", + "ipc", + "labels", + "logging", + "mac_address", + "mem_limit", + "network_mode", + "networks", + "ports", + "privileged", + "restart", + "runtime", + "security_opt", + "shm_size", + "stdin_open", + "stop_grace_period", + "sysctls", + "tty", + "ulimits", + "user", + "volumes", + "working_dir" + ], + "untranslated_blockers": [], + "policy": "Single-image definitions are installable when every declared Compose option has a native Proxmox translation. Multi-image and unsupported runtime features remain blocked until their orchestrator or mapping is available." + }, + "validation": { + "schema": "passed-at-generation", + "clean_install": "pending", + "service_health": "pending", + "restart_persistence": "pending", + "backup_restore": "pending", + "update_preserves_data": "pending" + }, + "lifecycle": { + "update_strategy": "resolve-latest-image-then-apply-reviewed-native-lxc-update", + "registry_state": { + "resolved_architecture": null, + "resolved_digest": null, + "image_version_label": null, + "image_created": null + }, + "change_detection": "compare-compose-sha256-and-resolved-latest-image-digest", + "automatic_unattended_updates": false + } +} diff --git a/oci/catalog/curated/nut-server.json b/oci/catalog/curated/nut-server.json new file mode 100644 index 00000000..bfff7353 --- /dev/null +++ b/oci/catalog/curated/nut-server.json @@ -0,0 +1,469 @@ +{ + "schema_version": "0.5.0", + "kind": "proxmenux.oci-template", + "id": "image-nut-server", + "status": "generated-unvalidated", + "catalog_ui": { + "title": { + "en_US": "NUT Server" + }, + "tagline": { + "en_US": "Network UPS Tools server for a UPS connected to the host by USB." + }, + "description": { + "en_US": "Network UPS Tools server (upsd) with the driver of a UPS connected to a USB port of the host. It reads the battery, load and power state of the UPS and serves them on port 3493 to NUT clients: PeaNUT, the Proxmox host and any other machine that has to shut down when the power fails.\n\n**Before installing:**\n- Connect the UPS to a USB port of the Proxmox host. The installation passes it to the container.\n- Clients connect with the user upsmon and the password shown at the end of the installation.\n- The Proxmox host is not shut down by this server on its own: it needs its own NUT client (upsmon) pointing to it.\n\n**Learn More:**\n- [Network UPS Tools](https://networkupstools.org/)\n- [Image documentation](https://github.com/instantlinux/docker-tools/tree/main/images/nut-upsd)" + }, + "category": "monitoring", + "category_label": "Monitoring & Analytics", + "author": "instantlinux", + "developer": "Network UPS Tools", + "icon": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/network-ups-tools.webp", + "thumbnail": null, + "screenshots": [], + "architectures": [ + "amd64", + "arm64" + ], + "launch": { + "scheme": "http", + "port": null, + "path": "/" + }, + "website": "https://networkupstools.org/", + "documentation": "https://github.com/instantlinux/docker-tools/tree/main/images/nut-upsd", + "repository": "https://github.com/instantlinux/docker-tools", + "tips": [], + "mini_changelog": [], + "display_version": null, + "updated_at": null + }, + "source": { + "provider": "instantlinux", + "repository": "https://github.com/instantlinux/docker-tools", + "revision": "3d3e17dc5dafb3f544ce0bdaa5ea4cf3814d5b1cb5b6323af2c40d1b224c5fd9", + "image_repository_url": "https://hub.docker.com/r/instantlinux/nut-upsd", + "readme_pushed_at": "", + "compose_sha256": "3d3e17dc5dafb3f544ce0bdaa5ea4cf3814d5b1cb5b6323af2c40d1b224c5fd9", + "generated_at": "2026-10-15T17:50:24+00:00" + }, + "container_contract": { + "service_name": "nut-upsd", + "container_name": "nut-upsd", + "image": { + "reference": "instantlinux/nut-upsd:latest", + "registry": "docker.io", + "repository": "instantlinux/nut-upsd", + "tag": "latest", + "digest": null, + "pull_policy": "resolve-written-reference-to-architecture-digest-at-install" + }, + "environment": [ + { + "name": "NAME", + "example": "ups", + "required": true, + "sensitive": false, + "source": "docker-compose" + }, + { + "name": "DESCRIPTION", + "example": "UPS", + "required": true, + "sensitive": false, + "source": "docker-compose" + }, + { + "name": "DRIVER", + "example": "usbhid-ups", + "required": true, + "sensitive": false, + "source": "docker-compose" + }, + { + "name": "API_USER", + "example": "upsmon", + "required": true, + "sensitive": false, + "source": "docker-compose" + }, + { + "name": "API_PASSWORD", + "example": "", + "required": true, + "sensitive": true, + "source": "docker-compose", + "prompt": "Password of the NUT server user" + } + ], + "volumes": [], + "ports": [ + { + "container_port": 3493, + "published_example": 3493, + "protocol": "tcp", + "required": true, + "proxmox_behavior": "listener-on-dedicated-lxc-address-no-nat" + } + ], + "related_services": [], + "restart": "unless-stopped", + "stop_grace_period": null, + "original_compose": "services:\n nut-upsd:\n image: instantlinux/nut-upsd:latest\n container_name: nut-upsd\n restart: unless-stopped\n environment:\n NAME: ups\n DESCRIPTION: UPS\n DRIVER: usbhid-ups\n API_USER: upsmon\n API_PASSWORD: ${GENERATED_API_PASSWORD}\n ports:\n - 3493:3493\n devices:\n - /dev/bus/usb/001/001:/dev/bus/usb/001/001\n" + }, + "compose_stack": { + "project_name": "nut-upsd", + "deployment_model": "one-native-oci-lxc-per-compose-service", + "user_experience": "single-application-install", + "main_service": "nut-upsd", + "service_count": 1, + "services": [ + { + "name": "nut-upsd", + "image": "instantlinux/nut-upsd:latest", + "is_main": true, + "role": "frontend", + "vmid_offset": 0, + "depends_on": [], + "frontend_network": true, + "private_network": false, + "compose": { + "image": "instantlinux/nut-upsd:latest", + "container_name": "nut-upsd", + "restart": "unless-stopped", + "environment": { + "NAME": "ups", + "DESCRIPTION": "UPS", + "DRIVER": "usbhid-ups", + "API_USER": "upsmon", + "API_PASSWORD": "${GENERATED_API_PASSWORD}" + }, + "ports": [ + "3493:3493" + ], + "devices": [ + "/dev/bus/usb/001/001:/dev/bus/usb/001/001" + ] + } + } + ], + "top_level": {}, + "networking": { + "frontend": "selected-proxmox-bridge", + "private_required": false, + "private_creation": "automatic-create-if-missing", + "private_address_allocation": "automatic-static-address-per-service", + "service_discovery": "private-addresses-with-compose-service-host-aliases", + "dependency_external_access": "disabled-unless-service-publishes-ports", + "prompt_user_for_private_network": false + }, + "storage": [], + "orchestration": { + "reserve_vmids_atomically": 1, + "start_order": [ + "nut-upsd" + ], + "stop_order": [ + "nut-upsd" + ], + "dependency_readiness": "compose-healthcheck-then-port-or-process-fallback", + "rollback_on_failure": "remove-new-rootfs-preserve-created-persistent-volumes" + }, + "installer_inputs": { + "prompted": [ + "stack_name", + "base_vmid", + "rootfs_storage", + "persistent_data_destinations", + "frontend_bridge", + "frontend_ipv4_mode" + ], + "automatic": [ + "dependent_vmids", + "private_bridge", + "private_subnet", + "private_service_addresses", + "compose_service_aliases", + "generated_secrets", + "dependency_start_and_stop_order" + ], + "generated_secrets": [ + { + "id": "api-password", + "strategy": "generate-cryptographically-random-at-install", + "bindings": [ + { + "service": "nut-upsd", + "environment_variable": "API_PASSWORD" + } + ] + } + ] + } + }, + "first_run": { + "endpoints": [], + "credentials": [ + { + "label": "NUT server user for clients such as PeaNUT", + "type": "configured-or-installer-generated", + "username": "upsmon", + "password": null, + "password_environment": "API_PASSWORD", + "change_required": false, + "source": "image-documentation", + "retrieval": null + } + ] + }, + "proxmox": { + "runtime": "native-oci-lxc", + "technology_status": "proxmox-technology-preview", + "defaults": { + "unprivileged": true, + "ostype": "auto-from-image", + "cores": 1, + "memory_mb": 256, + "swap_mb": 256, + "rootfs_size_gb": 4, + "rootfs_storage": "local-lvm", + "volume_storage": "local-lvm", + "template_storage": "local", + "bridge": "vmbr0", + "ipv4": "dhcp", + "firewall": true, + "host_managed_network": true, + "onboot": true, + "features": [ + "nesting=1" + ], + "shutdown_timeout_seconds": 30 + }, + "image_metadata_policy": { + "entrypoint": "import-from-oci-image", + "cmd": "import-from-oci-image", + "environment": "import-image-env-then-apply-compose-overrides", + "user": "import-from-oci-image", + "working_dir": "import-from-oci-image", + "stop_signal": "import-from-oci-image" + }, + "installer_profile": { + "device_requests": [ + { + "id": "ups-usb", + "kind": "character-device", + "purpose": "ups", + "enable_prompt": "Pass the UPS to the LXC", + "enabled_default": true, + "required_for_runtime": true, + "required_by_compose": true, + "path_prompt": "USB device of the UPS", + "host_path_default": "/dev/bus/usb/001/001", + "container_path_strategy": "same-as-host", + "mode": "0666", + "deny_write": false, + "gid_strategy": "host-device-gid", + "source_mapping": "/dev/bus/usb/001/001:/dev/bus/usb/001/001" + } + ], + "generated_sensitive_environment": { + "API_PASSWORD": { + "strategy": "token-hex", + "bytes": 12, + "prompt": true + } + } + }, + "adaptations": [ + { + "id": "imported-compose-source", + "upstream_behavior": "The source definition deploys the complete Docker Compose application model.", + "native_lxc_behavior": "The source model is preserved and remains blocked until every service option has a reviewed native Proxmox mapping.", + "reason": "Catalog import must not imply runtime compatibility.", + "behavioral_impact": "No automatic installation before review.", + "validation": "pending-per-application" + }, + { + "id": "rolling-latest-image", + "upstream_behavior": "A discovered Compose may pin a release tag or digest.", + "native_lxc_behavior": "ProxMenux selects the same image repository with the latest tag for catalog installations.", + "reason": "The automatic catalog intentionally offers rolling latest images; pinned versions belong to the future manual installer.", + "behavioral_impact": "The installed release can be newer than the discovered Compose revision.", + "validation": "pending-per-application" + }, + { + "id": "dedicated-lxc-network", + "upstream_behavior": "Docker publishes selected container ports on the Docker host.", + "native_lxc_behavior": "A reviewed native application will listen on its original container ports at a dedicated LXC address.", + "reason": "A native LXC has its own address and does not require Docker port NAT.", + "behavioral_impact": "Published ports are metadata; ProxMenux URLs use the matching container target port.", + "validation": "pending-per-application" + }, + { + "id": "compose-shm-size", + "upstream_behavior": "Compose sets the size of the container /dev/shm tmpfs.", + "native_lxc_behavior": "ProxMenux mounts a native LXC tmpfs at /dev/shm with the same requested capacity.", + "reason": "The OCI image runs directly as an LXC and therefore needs the equivalent Proxmox mount entry.", + "behavioral_impact": "None expected.", + "validation": "not-requested-by-compose" + }, + { + "id": "compose-command", + "upstream_behavior": "Compose replaces the image Cmd while retaining its Entrypoint.", + "native_lxc_behavior": "ProxMenux reads the official OCI Entrypoint and combines it with the Compose command as the native LXC init command.", + "reason": "Proxmox stores the effective OCI process as one entrypoint string.", + "behavioral_impact": "None expected.", + "validation": "not-requested-by-compose" + }, + { + "id": "compose-privileged-mode", + "upstream_behavior": "Compose selects whether the container runs in privileged mode.", + "native_lxc_behavior": "ProxMenux keeps the LXC unprivileged by default and exposes the broad Compose privilege request only as an explicit compatibility option.", + "reason": "A Compose privilege request is source metadata, not proof that the image technically requires a privileged LXC.", + "behavioral_impact": "A privileged LXC has weaker isolation from the Proxmox host.", + "validation": "native-equivalent" + }, + { + "id": "compose-process-runtime", + "upstream_behavior": "Compose can replace Entrypoint, User and WorkingDir and request an init process or interactive terminal.", + "native_lxc_behavior": "ProxMenux applies the process overrides through native LXC init directives; lxc-init provides PID 1 supervision and the CT console provides terminal access.", + "reason": "The OCI process must start with the same identity, command and working directory without Docker.", + "behavioral_impact": "Compose stdin_open and tty become access through the Proxmox LXC console.", + "validation": "not-requested-by-compose" + }, + { + "id": "compose-healthcheck", + "upstream_behavior": "Docker periodically executes the declared container healthcheck.", + "native_lxc_behavior": "For a single-service LXC, ProxMenux translates HTTP localhost checks into a mandatory first-start service check.", + "reason": "Proxmox has no persistent Docker health state, while the installer still must detect a failed first boot.", + "behavioral_impact": "The check runs during installation rather than continuously after installation.", + "validation": "not-requested-by-compose" + }, + { + "id": "compose-cpu-priority", + "upstream_behavior": "Docker cpu_shares sets a relative scheduling weight with 1024 as its neutral value.", + "native_lxc_behavior": "ProxMenux converts the relative weight to Proxmox cpuunits with 100 as its neutral value and lets the user review it.", + "reason": "Both settings express relative CPU priority on different scales.", + "behavioral_impact": "Rounding and Proxmox minimum limits can slightly change very low weights.", + "validation": "not-requested-by-compose" + }, + { + "id": "compose-resource-limits", + "upstream_behavior": "mem_limit sets the memory ceiling; ulimits sets process soft/hard resource limits.", + "native_lxc_behavior": "mem_limit supplies the editable Proxmox memory default, rounded up to MiB; ulimits maps to lxc.prlimit with -1 represented as unlimited.", + "reason": "Use native Proxmox memory and LXC prlimits, without a wrapper or changing the host kernel configuration.", + "behavioral_impact": "User-selected memory overrides Compose. Swap is a separate choice. Kernel restrictions still apply; nproc counts processes by real UID, not by container.", + "validation": "not-requested-by-compose" + }, + { + "id": "compose-network-identity", + "upstream_behavior": "Compose can set hostname, MAC address, extra hosts and attach a service to Docker networks.", + "native_lxc_behavior": "ProxMenux applies hostname and MAC to net0, writes additional host aliases into the LXC and uses its dedicated bridge connection for single-service networks.", + "reason": "A dedicated LXC has its own network namespace and does not need a Docker bridge per service.", + "behavioral_impact": "host-gateway resolves to the IPv4 address of the selected Proxmox bridge.", + "validation": "not-requested-by-compose" + }, + { + "id": "compose-network-mode", + "upstream_behavior": "Docker host mode removes Docker network isolation; bridge and default use a Docker-managed network.", + "native_lxc_behavior": "The OCI process uses the dedicated LXC network namespace directly, so host, bridge and default all listen on the LXC address without Docker NAT.", + "reason": "The LXC is the application host and already has its own address and port namespace.", + "behavioral_impact": "host means the LXC host, never the Proxmox host; this preserves Proxmox network isolation.", + "validation": "not-requested-by-compose" + }, + { + "id": "compose-capabilities-and-sysctls", + "upstream_behavior": "Compose can add Linux capabilities and set kernel parameters in the container network namespace.", + "native_lxc_behavior": "ProxMenux validates requested capabilities against the native LXC capability set and writes namespaced network settings as lxc.sysctl directives.", + "reason": "A native OCI-LXC already starts with the namespaced capability set; lxc.cap.keep would incorrectly discard unrelated required capabilities.", + "behavioral_impact": "Host-global capabilities such as SYS_MODULE remain blocked until their host prerequisite is explicitly adapted.", + "validation": "not-requested-by-compose" + }, + { + "id": "docker-engine-metadata", + "upstream_behavior": "Compose labels annotate Docker objects and the json-file logging driver rotates Docker-managed logs.", + "native_lxc_behavior": "Labels remain source metadata; Docker json-file settings are not applied because the OCI process runs directly under LXC.", + "reason": "There is no Docker object or Docker json-file log behind a native OCI-LXC application.", + "behavioral_impact": "Docker-only label consumers and Docker log-driver rotation do not exist in the native deployment.", + "validation": "not-requested-by-compose" + }, + { + "id": "compose-device-passthrough", + "upstream_behavior": "Compose passes host character devices or requests an NVIDIA runtime GPU.", + "native_lxc_behavior": "ProxMenux converts recognized device declarations to native Proxmox dev resources; NVIDIA profiles also inject compatible host driver libraries read-only.", + "reason": "Native OCI-LXC does not execute Docker device or NVIDIA runtime hooks.", + "behavioral_impact": "Hardware is exposed only after explicit user confirmation and host-path validation.", + "validation": "pending-per-application" + }, + { + "id": "compose-host-ipc", + "upstream_behavior": "ipc: host shares the Docker host IPC namespace, commonly to avoid Docker's small default shared-memory allocation.", + "native_lxc_behavior": "The application keeps the LXC IPC namespace and receives a configurable 1 GiB /dev/shm instead of sharing Proxmox host IPC.", + "reason": "Processes in a single native LXC already share one IPC namespace; retaining isolation is safer than exposing host IPC.", + "behavioral_impact": "The application cannot exchange IPC objects with processes on the Proxmox host.", + "validation": "not-requested-by-compose" + } + ] + }, + "compatibility": { + "automatic_install_candidate": true, + "validated": false, + "supported_compose_keys": [ + "cap_add", + "command", + "container_name", + "cpu_shares", + "deploy", + "devices", + "entrypoint", + "environment", + "extra_hosts", + "group_add", + "healthcheck", + "hostname", + "image", + "init", + "ipc", + "labels", + "logging", + "mac_address", + "mem_limit", + "network_mode", + "networks", + "ports", + "privileged", + "restart", + "runtime", + "security_opt", + "shm_size", + "stdin_open", + "stop_grace_period", + "sysctls", + "tty", + "ulimits", + "user", + "volumes", + "working_dir" + ], + "untranslated_blockers": [], + "policy": "Single-image definitions are installable when every declared Compose option has a native Proxmox translation. Multi-image and unsupported runtime features remain blocked until their orchestrator or mapping is available." + }, + "validation": { + "schema": "passed-at-generation", + "clean_install": "pending", + "service_health": "pending", + "restart_persistence": "pending", + "backup_restore": "pending", + "update_preserves_data": "pending" + }, + "lifecycle": { + "update_strategy": "resolve-latest-image-then-apply-reviewed-native-lxc-update", + "registry_state": { + "resolved_architecture": null, + "resolved_digest": null, + "image_version_label": null, + "image_created": null + }, + "change_detection": "compare-compose-sha256-and-resolved-latest-image-digest", + "automatic_unattended_updates": false + } +} diff --git a/oci/catalog/generation-report-curated.json b/oci/catalog/generation-report-curated.json index c797e035..b40d683c 100644 --- a/oci/catalog/generation-report-curated.json +++ b/oci/catalog/generation-report-curated.json @@ -12,6 +12,7 @@ "makemkv", "mkvtoolnix", "nextcloud-stack", + "nut-server", "open-webui", "open-webui-cuda", "open-webui-ollama", @@ -22,7 +23,7 @@ "stremio", "tandoor" ], - "generated_count": 21, + "generated_count": 22, "failed": [], "failed_count": 0 } diff --git a/oci/catalog/index.json b/oci/catalog/index.json index 6a73ff8b..5d0cc45a 100644 --- a/oci/catalog/index.json +++ b/oci/catalog/index.json @@ -7154,6 +7154,38 @@ "hidden": false, "template_id": "image-node-red" }, + { + "id": "nut-server", + "provider": "instantlinux", + "template_family": "curated-profile", + "title": "NUT Server", + "repository": "https://github.com/instantlinux/docker-tools", + "description": "Network UPS Tools server for a UPS connected to the host by USB.", + "website": "https://networkupstools.org/", + "icon": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/network-ups-tools.webp", + "architectures": [ + "amd64", + "arm64" + ], + "updated_at": null, + "main_image": "instantlinux/nut-upsd:latest", + "category": "monitoring", + "category_label": "Monitoring & Analytics", + "replaces_discovered_ids": [], + "curated_path": "catalog/curated/nut-server.json", + "template": "apps/nut-server.json", + "template_status": "generated-unvalidated", + "automatic_install_candidate": true, + "untranslated_blockers": [], + "requires_privileged_lxc": false, + "optional_privileged_lxc": false, + "requires_host_pid_namespace": false, + "requires_relaxed_confinement": false, + "optional_relaxed_confinement": false, + "requires_security_confirmation": false, + "hidden": false, + "template_id": "image-nut-server" + }, { "id": "nzbfast", "source_app_id": "nzbfast", diff --git a/oci/src/proxmenux_oci/installer.py b/oci/src/proxmenux_oci/installer.py index e510ec97..7a093f75 100644 --- a/oci/src/proxmenux_oci/installer.py +++ b/oci/src/proxmenux_oci/installer.py @@ -82,6 +82,19 @@ def _image_documentation(template: dict[str, Any]) -> str | None: if str(ui.get(key) or "").startswith(("http://", "https://"))), None) +def ups_device_request(item: dict[str, Any], advanced: bool) -> dict[str, Any]: + """The UPS proposed is the one the host sees on USB: its bus node changes + with the port and the order of connection. Without one, only the advanced + installation goes on, to type the node by hand.""" + found = [row["path"] for row in host.usb_devices() if row["kind"] == "UPS"] + if found: + return {**item, "host_path_default": found[0]} + if not advanced: + raise UserCancelled(translate("No UPS was found on a USB port of this host. " + "Connect it and start the installation again.")) + return item + + def external_services_notice(template: dict[str, Any]) -> str | None: """What an application needs already running on the network, as a database of its own. Said before its installation asks how to reach it, so it can be @@ -1974,9 +1987,11 @@ def configure_acceleration(installer_profile, environment, unprivileged, ui, mod if vendor_id in item.get("drm_vendor_ids", []) for node in host.gpus()[vendor]] if nodes and item["host_path_default"] not in nodes: item = {**item, "host_path_default": nodes[0]} + if item.get("purpose") == "ups": + item = ups_device_request(item, advanced) if not advanced: host_path = item["host_path_default"] - elif item.get("purpose") in ("serial", "user-selected-device"): + elif item.get("purpose") in ("serial", "user-selected-device", "ups"): from .extra_devices import choose_usb_device host_path = choose_usb_device(ui, translate(item["path_prompt"]), item["host_path_default"], {device.get("host_path") for device in devices}) diff --git a/oci/tests/test_usb_devices.py b/oci/tests/test_usb_devices.py index 4b466bcb..55e22843 100644 --- a/oci/tests/test_usb_devices.py +++ b/oci/tests/test_usb_devices.py @@ -90,5 +90,37 @@ class UsbDevicesTests(unittest.TestCase): [("/dev/ttyACM0", "/dev/ttyACM0")]) +class UpsDeviceRequestTests(unittest.TestCase): + """The UPS a recipe asks for is the one the host sees on USB.""" + + REQUEST = {"id": "ups-usb", "purpose": "ups", "host_path_default": "/dev/bus/usb/001/001"} + + def test_the_host_ups_is_proposed(self): + from proxmenux_oci.installer import ups_device_request + rows = [{"path": "/dev/ttyACM0", "name": "ConBee II", "kind": "Serial"}, + {"path": "/dev/bus/usb/003/002", "name": "Ellipse ECO", "kind": "UPS"}] + with patch.object(host, "usb_devices", return_value=rows): + self.assertEqual(ups_device_request(self.REQUEST, False)["host_path_default"], "/dev/bus/usb/003/002") + + def test_without_a_ups_the_installation_stops(self): + from proxmenux_oci.installer import ups_device_request + from proxmenux_oci.ui import UserCancelled + with patch.object(host, "usb_devices", return_value=[]): + with self.assertRaises(UserCancelled): + ups_device_request(self.REQUEST, False) + # The advanced installation lets the node be typed by hand. + self.assertEqual(ups_device_request(self.REQUEST, True), self.REQUEST) + + def test_the_nut_server_recipe_asks_for_the_ups(self): + import json + recipe = json.loads((ROOT / "catalog/curated/nut-server.json").read_text(encoding="utf-8")) + self.assertEqual(recipe, json.loads((ROOT / "catalog/apps/nut-server.json").read_text(encoding="utf-8"))) + request, = recipe["proxmox"]["installer_profile"]["device_requests"] + self.assertEqual((request["purpose"], request["mode"], request["required_for_runtime"]), ("ups", "0666", True)) + self.assertIn("API_PASSWORD", recipe["proxmox"]["installer_profile"]["generated_sensitive_environment"]) + self.assertIsNone(recipe["catalog_ui"]["launch"]["port"]) + self.assertTrue(recipe["proxmox"]["defaults"]["onboot"]) + + if __name__ == "__main__": unittest.main()