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.
136 lines
5.5 KiB
Nix
136 lines
5.5 KiB
Nix
{ 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 - <Config><ApiKey>test-api-key-ns</ApiKey></Config>"
|
|
"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\")'"
|
|
)
|
|
'';
|
|
}
|