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.
This commit is contained in:
MacRimi
2026-10-15 20:06:06 +02:00
parent abbb3afc0c
commit 00350655bf
12 changed files with 1043 additions and 5 deletions
@@ -213,6 +213,8 @@ class CommandDescriptionsTests(unittest.TestCase):
# Model the bot's future output in temporary catalogs, just # Model the bot's future output in temporary catalogs, just
# like the other pending keys above; never rewrite locales. # like the other pending keys above; never rewrite locales.
messages["scriptTerminal"].setdefault("endOfInput", english["scriptTerminal"]["endOfInput"]) 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. # The notification history panel texts await the bot as well.
for key, value in english["settings"]["notifications"]["history"].items(): for key, value in english["settings"]["notifications"]["history"].items():
messages["settings"]["notifications"].setdefault("history", {}).setdefault(key, value) messages["settings"]["notifications"].setdefault("history", {}).setdefault(key, value)
@@ -382,6 +382,19 @@ export function LxcTerminalModal({
return 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 { } else {
+3 -1
View File
@@ -5014,7 +5014,9 @@
"lxcTerminal": { "lxcTerminal": {
"title": "Terminal: {name} (ID: {id})", "title": "Terminal: {name} (ID: {id})",
"errors": { "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": { "commands": {
"listFiles": "List all files with details", "listFiles": "List all files with details",
+3 -1
View File
@@ -5015,7 +5015,9 @@
"lxcTerminal": { "lxcTerminal": {
"title": "Terminal: {name} (ID: {id})", "title": "Terminal: {name} (ID: {id})",
"errors": { "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": { "commands": {
"listFiles": "Listar todos los archivos con detalles", "listFiles": "Listar todos los archivos con detalles",
+1
View File
@@ -4066,6 +4066,7 @@
"No Shares Found": "No se encontraron recursos compartidos", "No Shares Found": "No se encontraron recursos compartidos",
"No Shares to Test": "Sin recursos compartidos que probar", "No Shares to Test": "Sin recursos compartidos que probar",
"No Storage Found": "No se encontró almacenamiento", "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 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 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.", "No UUP folder found.": "No se encontró ninguna carpeta UUP.",
+1 -1
View File
@@ -2,7 +2,7 @@
<!-- Generated from oci/catalog/verification.json by .github/scripts/oci_validation.py. Do not edit by hand. --> <!-- Generated from oci/catalog/verification.json by .github/scripts/oci_validation.py. Do not edit by hand. -->
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. 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.
+469
View File
@@ -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
}
}
+469
View File
@@ -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
}
}
+2 -1
View File
@@ -12,6 +12,7 @@
"makemkv", "makemkv",
"mkvtoolnix", "mkvtoolnix",
"nextcloud-stack", "nextcloud-stack",
"nut-server",
"open-webui", "open-webui",
"open-webui-cuda", "open-webui-cuda",
"open-webui-ollama", "open-webui-ollama",
@@ -22,7 +23,7 @@
"stremio", "stremio",
"tandoor" "tandoor"
], ],
"generated_count": 21, "generated_count": 22,
"failed": [], "failed": [],
"failed_count": 0 "failed_count": 0
} }
+32
View File
@@ -7154,6 +7154,38 @@
"hidden": false, "hidden": false,
"template_id": "image-node-red" "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", "id": "nzbfast",
"source_app_id": "nzbfast", "source_app_id": "nzbfast",
+16 -1
View File
@@ -82,6 +82,19 @@ def _image_documentation(template: dict[str, Any]) -> str | None:
if str(ui.get(key) or "").startswith(("http://", "https://"))), 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: def external_services_notice(template: dict[str, Any]) -> str | None:
"""What an application needs already running on the network, as a database """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 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 vendor_id in item.get("drm_vendor_ids", []) for node in host.gpus()[vendor]]
if nodes and item["host_path_default"] not in nodes: if nodes and item["host_path_default"] not in nodes:
item = {**item, "host_path_default": nodes[0]} item = {**item, "host_path_default": nodes[0]}
if item.get("purpose") == "ups":
item = ups_device_request(item, advanced)
if not advanced: if not advanced:
host_path = item["host_path_default"] 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 from .extra_devices import choose_usb_device
host_path = choose_usb_device(ui, translate(item["path_prompt"]), item["host_path_default"], host_path = choose_usb_device(ui, translate(item["path_prompt"]), item["host_path_default"],
{device.get("host_path") for device in devices}) {device.get("host_path") for device in devices})
+32
View File
@@ -90,5 +90,37 @@ class UsbDevicesTests(unittest.TestCase):
[("/dev/ttyACM0", "/dev/ttyACM0")]) [("/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__": if __name__ == "__main__":
unittest.main() unittest.main()