diff --git a/oci/src/proxmenux_oci/catalog.py b/oci/src/proxmenux_oci/catalog.py index e43cd8ae..f2ac2575 100644 --- a/oci/src/proxmenux_oci/catalog.py +++ b/oci/src/proxmenux_oci/catalog.py @@ -797,8 +797,9 @@ class Catalog: item.update(environment_overrides[item["name"]]) from .stack import apply_stack_support apply_stack_support(template) - from .gpu import apply_gpu_contract + from .gpu import apply_gpu_contract, apply_nginx_runtime_contract apply_gpu_contract(template) + apply_nginx_runtime_contract(template) # Last, so the stack compiler does not reset it. self._apply_verification(app_id, template) diff --git a/oci/src/proxmenux_oci/gpu.py b/oci/src/proxmenux_oci/gpu.py index c4555d81..f9a7c851 100644 --- a/oci/src/proxmenux_oci/gpu.py +++ b/oci/src/proxmenux_oci/gpu.py @@ -8,6 +8,32 @@ from .i18n import translate LSIO_DEVICE_INIT = {"boinc", "emby", "jellyfin", "plex", "tvheadend"} +# Images that run their own bare nginx (no s6-overlay/LinuxServer init and no +# Selkies profile) to serve their web UI. LXC's volatile /run hides the +# /run/nginx directory shipped in the OCI rootfs, so nginx fails to start +# with "open() '/run/nginx/nginx.pid' failed (2: No such file or directory)". +# Add repositories here as they are discovered; see apply_selkies_contract +# and the linuxserver/libreoffice case in converter.py for the same fix +# applied through other code paths. +BARE_NGINX_RUNTIME_INIT = {"tsaridas/stremio-docker"} + + +def apply_nginx_runtime_contract(template: dict[str, Any]) -> None: + """Ensure /run/nginx exists for images with a bare nginx that does not + create it itself (see BARE_NGINX_RUNTIME_INIT).""" + profile = template.get("proxmox", {}).get("installer_profile", {}) + if profile.get("selkies"): + return # already handled by apply_selkies_contract + image = template.get("container_contract", {}).get("image", {}).get("reference", "") + repository = image.split("@", 1)[0].split(":", 1)[0] + if repository not in BARE_NGINX_RUNTIME_INIT: + return + mounts = profile.setdefault("tmpfs_mounts", []) + if not any(m.get("container_path") == "/run/nginx" for m in mounts): + mounts.append({"id": "nginx-runtime", "container_path": "/run/nginx", + "default_size_mb": 1, "minimum_size_mb": 1, "prompt_size": False, + "mount_options": ["rw", "nosuid", "nodev", "mode=0755"]}) + def apply_gpu_contract(template: dict[str, Any]) -> None: profile = template.get("proxmox", {}).get("installer_profile", {}) diff --git a/oci/tests/test_nginx_runtime_contract.py b/oci/tests/test_nginx_runtime_contract.py new file mode 100644 index 00000000..fb14ee12 --- /dev/null +++ b/oci/tests/test_nginx_runtime_contract.py @@ -0,0 +1,59 @@ +"""Regression tests for apply_nginx_runtime_contract (BARE_NGINX_RUNTIME_INIT). + +Covers the Stremio /run/nginx fix: images that run a bare nginx without an +s6-overlay/LinuxServer init or a Selkies profile need /run/nginx recreated +as a tmpfs mount, because LXC's volatile /run hides the directory shipped in +the OCI rootfs (nginx: [emerg] open() "/run/nginx/nginx.pid" failed). +""" +from pathlib import Path +import sys +import unittest + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "src")) + +from proxmenux_oci.catalog import Catalog +from proxmenux_oci.gpu import apply_nginx_runtime_contract + + +class BareNginxRuntimeContractTests(unittest.TestCase): + def test_bare_nginx_app_gets_run_mount(self): + """Stremio (curated, tsaridas/stremio-docker) gets exactly one + /run/nginx tmpfs mount in proxmox.installer_profile.tmpfs_mounts.""" + template = Catalog(ROOT).compose("stremio") + + mounts = template["proxmox"]["installer_profile"]["tmpfs_mounts"] + nginx_mounts = [m for m in mounts if m.get("container_path") == "/run/nginx"] + self.assertEqual(len(nginx_mounts), 1) + self.assertEqual(nginx_mounts[0]["id"], "nginx-runtime") + + def test_non_target_app_unaffected(self): + """Jellyfin Official (curated, image jellyfin/jellyfin — not in + BARE_NGINX_RUNTIME_INIT) must not get a new tmpfs mount.""" + template = Catalog(ROOT).compose("jellyfin-official") + + profile = template["proxmox"]["installer_profile"] + self.assertNotIn("tmpfs_mounts", profile) + + def test_contract_is_idempotent_on_stremio_template(self): + """Re-applying apply_nginx_runtime_contract() a second time on top + of an already-processed Stremio template (as compose() would do if + called again, or if the fix runs more than once in a pipeline) must + not duplicate the mount — exactly one /run/nginx entry survives. + This does not simulate a hand-authored /run/nginx entry or 2FAuth's + own pre-existing catalog mount; it only covers repeated application + of this specific contract function on its own prior output.""" + template = Catalog(ROOT).compose("stremio") + + # Call the contract function again on its own already-processed + # output, simulating repeated application in a pipeline. + apply_nginx_runtime_contract(template) + apply_nginx_runtime_contract(template) + + mounts = template["proxmox"]["installer_profile"]["tmpfs_mounts"] + nginx_mounts = [m for m in mounts if m.get("container_path") == "/run/nginx"] + self.assertEqual(len(nginx_mounts), 1) + + +if __name__ == "__main__": + unittest.main()