From a37b6f611296185326ad7e0c5ee5fe24a97f87fd Mon Sep 17 00:00:00 2001 From: Simon Gardling Date: Thu, 16 Apr 2026 16:34:53 -0400 Subject: [PATCH] test: add network namespace test Tests networkNamespacePath and networkNamespaceService options. Creates a network namespace, runs a mock Servarr inside it, verifies namespace isolation (mock unreachable from default ns), and confirms the init service provisions resources through the namespace. --- tests/default.nix | 1 + tests/network-namespace.nix | 135 ++++++++++++++++++++++++++++++++++++ 2 files changed, 136 insertions(+) create mode 100644 tests/network-namespace.nix diff --git a/tests/default.nix b/tests/default.nix index 51d7ae2..5388d22 100644 --- a/tests/default.nix +++ b/tests/default.nix @@ -14,4 +14,5 @@ delayed-start = import ./delayed-start.nix { inherit pkgs lib self; }; jellyseerr = import ./jellyseerr.nix { inherit pkgs lib self; }; naming = import ./naming.nix { inherit pkgs lib self; }; + network-namespace = import ./network-namespace.nix { inherit pkgs lib self; }; } diff --git a/tests/network-namespace.nix b/tests/network-namespace.nix new file mode 100644 index 0000000..0a72acd --- /dev/null +++ b/tests/network-namespace.nix @@ -0,0 +1,135 @@ +{ pkgs, lib, self }: +pkgs.testers.runNixOSTest { + name = "arr-init-network-namespace"; + nodes.machine = { pkgs, lib, ... }: { + imports = [ self.nixosModules.default ]; + system.stateVersion = "24.11"; + virtualisation.memorySize = 2048; + environment.systemPackages = with pkgs; [ curl jq gnugrep iproute2 ]; + + # Create the network namespace with loopback + systemd.services.create-netns = { + description = "Create test network namespace"; + wantedBy = [ "multi-user.target" ]; + before = [ "mock-sonarr.service" ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + ExecStart = "${pkgs.iproute2}/bin/ip netns add test-ns"; + ExecStartPost = "${pkgs.iproute2}/bin/ip netns exec test-ns ${pkgs.iproute2}/bin/ip link set lo up"; + ExecStop = "${pkgs.iproute2}/bin/ip netns delete test-ns"; + }; + }; + + # Mock Servarr API running inside the namespace + systemd.services.mock-sonarr = let + mockScript = pkgs.writeScript "mock-sonarr-ns.py" '' + import json + from http.server import HTTPServer, BaseHTTPRequestHandler + from urllib.parse import urlparse + + DOWNLOAD_CLIENTS = [] + ROOT_FOLDERS = [] + + class MockArr(BaseHTTPRequestHandler): + def _respond(self, code=200, body=b"", content_type="application/json"): + self.send_response(code) + self.send_header("Content-Type", content_type) + self.end_headers() + self.wfile.write(body if isinstance(body, bytes) else body.encode()) + + def do_GET(self): + path = urlparse(self.path).path + if path == "/api/v3/system/status": + self._respond(200, json.dumps({"version": "4.0.0"}).encode()) + elif path == "/api/v3/downloadclient": + self._respond(200, json.dumps(DOWNLOAD_CLIENTS).encode()) + elif path == "/api/v3/rootfolder": + self._respond(200, json.dumps(ROOT_FOLDERS).encode()) + else: + self._respond(200, b"{}") + + def do_POST(self): + path = urlparse(self.path).path + content_length = int(self.headers.get("Content-Length", 0)) + body = self.rfile.read(content_length) + if "/rootfolder" in path: + data = json.loads(body) + data["id"] = len(ROOT_FOLDERS) + 1 + ROOT_FOLDERS.append(data) + self._respond(201, json.dumps(data).encode()) + else: + self._respond(200, b"{}") + + def log_message(self, format, *args): + pass + + HTTPServer(("0.0.0.0", 8989), MockArr).serve_forever() + ''; + in { + description = "Mock Sonarr API in network namespace"; + after = [ "create-netns.service" ]; + requires = [ "create-netns.service" ]; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + ExecStart = "${pkgs.python3}/bin/python3 ${mockScript}"; + Type = "simple"; + NetworkNamespacePath = "/run/netns/test-ns"; + }; + }; + + # Pre-seed config.xml + systemd.tmpfiles.rules = [ + "d /var/lib/mock-sonarr 0755 root root -" + "f /var/lib/mock-sonarr/config.xml 0644 root root - test-api-key-ns" + "d /media/tv 0755 root root -" + ]; + + services.arrInit.sonarr = { + enable = true; + serviceName = "mock-sonarr"; + dataDir = "/var/lib/mock-sonarr"; + port = 8989; + healthChecks = false; + networkNamespacePath = "/run/netns/test-ns"; + networkNamespaceService = "create-netns"; + rootFolders = [ "/media/tv" ]; + }; + }; + testScript = '' + start_all() + machine.wait_for_unit("create-netns.service") + machine.wait_for_unit("mock-sonarr.service") + + with subtest("Unit has correct namespace configuration"): + unit_content = machine.succeed("systemctl cat mock-sonarr-init.service") + assert "NetworkNamespacePath=/run/netns/test-ns" in unit_content, \ + f"Expected NetworkNamespacePath in unit, got:\n{unit_content}" + assert "create-netns.service" in unit_content, \ + f"Expected create-netns.service dependency in unit, got:\n{unit_content}" + + with subtest("Mock API is reachable only inside namespace"): + # From the default namespace, the mock should NOT be reachable + machine.fail("curl -sf --connect-timeout 2 http://127.0.0.1:8989/api/v3/system/status") + # From inside the namespace, it should be reachable (wait for mock to start listening) + machine.wait_until_succeeds( + "ip netns exec test-ns curl -sf http://127.0.0.1:8989/api/v3/system/status " + "-H 'X-Api-Key: test-api-key-ns'", + timeout=30, + ) + + with subtest("Init service completes inside namespace"): + machine.succeed("systemctl restart mock-sonarr-init.service") + machine.wait_for_unit("mock-sonarr-init.service", timeout=30) + exit_code = machine.succeed( + "systemctl show mock-sonarr-init.service --property=ExecMainStatus | cut -d= -f2" + ).strip() + assert exit_code == "0", f"Expected exit code 0, got {exit_code}" + + with subtest("Root folder was provisioned via namespace"): + result = machine.succeed( + "ip netns exec test-ns curl -sf http://127.0.0.1:8989/api/v3/rootfolder " + "-H 'X-Api-Key: test-api-key-ns' | jq -e '.[] | select(.path == \"/media/tv\")'" + ) + ''; +}