{ 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; 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\")'" ) ''; }