{ pkgs, self, }: pkgs.testers.runNixOSTest { name = "arr-init-config-xml"; nodes.machine = { pkgs, lib, ... }: { imports = [ self.nixosModules.default ]; system.stateVersion = "24.11"; environment.systemPackages = with pkgs; [ libxml2 gnugrep ]; services.sonarr = { enable = true; dataDir = "/var/lib/sonarr/.config/NzbDrone"; settings.server.port = lib.mkDefault 8989; }; services.prowlarr = { enable = true; }; # Sonarr: declare configXml to ensure Port and BindAddress services.arrInit.sonarr = { enable = true; serviceName = "sonarr"; dataDir = "/var/lib/sonarr/.config/NzbDrone"; port = 8989; configXml = { Port = 8989; BindAddress = "*"; AnalyticsEnabled = false; }; }; # Prowlarr: declare configXml to ensure Port — dataDir starts empty, # so preStart must create config.xml from scratch. services.arrInit.prowlarr = { enable = true; serviceName = "prowlarr"; dataDir = "/var/lib/prowlarr"; port = 9696; apiVersion = "v1"; configXml = { Port = 9696; BindAddress = "*"; EnableSsl = false; }; }; }; testScript = '' import xml.etree.ElementTree as ET def elem_text(xml: str, tag: str) -> str: """Return the text of root.. Asserts element exists.""" root = ET.fromstring(xml) node = root.find(tag) assert node is not None, f"<{tag}> missing from config.xml" assert node.text is not None, f"<{tag}> has no text in config.xml" return node.text start_all() # --- Subtest: config.xml created from scratch when missing --- with subtest("preStart creates config.xml if missing"): # Prowlarr's dataDir starts empty; preStart must create config.xml # before the service main process reads it. machine.wait_for_unit("prowlarr.service") machine.succeed("test -f /var/lib/prowlarr/config.xml") with subtest("created config.xml has declared elements"): xml = machine.succeed("cat /var/lib/prowlarr/config.xml") assert elem_text(xml, "Port") == "9696", f"Port={elem_text(xml, 'Port')}" assert elem_text(xml, "BindAddress") == "*" assert elem_text(xml, "EnableSsl") == "False" with subtest("config.xml is well-formed XML"): xml = machine.succeed("cat /var/lib/prowlarr/config.xml") # Must parse cleanly; will raise if malformed ET.fromstring(xml) # --- Subtest: config.xml patched when elements are missing --- with subtest("preStart patches existing config.xml with missing elements"): # Flow for a fresh dataDir: # 1. preStart creates config.xml with only declared elements # 2. Sonarr starts, reads it, generates ApiKey, writes back # We must wait for step 2 (ApiKey present) before asserting. machine.wait_for_unit("sonarr.service") machine.wait_until_succeeds( "grep -q '' /var/lib/sonarr/.config/NzbDrone/config.xml", timeout=120, ) xml = machine.succeed("cat /var/lib/sonarr/.config/NzbDrone/config.xml") assert elem_text(xml, "Port") == "8989" assert elem_text(xml, "BindAddress") == "*" assert elem_text(xml, "AnalyticsEnabled") == "False" with subtest("preStart preserves undeclared elements"): # Restart Sonarr: preStart runs again over existing config.xml with # an ApiKey. Our declared elements are re-applied, but ApiKey must survive. machine.succeed("systemctl restart sonarr.service") machine.wait_for_unit("sonarr.service") xml = machine.succeed("cat /var/lib/sonarr/.config/NzbDrone/config.xml") api_key = elem_text(xml, "ApiKey") assert len(api_key) > 0, "ApiKey is empty" # --- Subtest: preStart corrects wrong values --- with subtest("preStart fixes incorrect values on restart"): # Tamper with the Port value machine.succeed( "sed -i 's|9696|1234|' /var/lib/prowlarr/config.xml" ) machine.succeed("grep '1234' /var/lib/prowlarr/config.xml") # Restart the service; preStart should fix it machine.succeed("systemctl restart prowlarr.service") machine.wait_for_unit("prowlarr.service") xml = machine.succeed("cat /var/lib/prowlarr/config.xml") assert elem_text(xml, "Port") == "9696", "Port not corrected" # --- Subtest: idempotency --- with subtest("preStart is idempotent: bit-for-bit identical after restart"): xml_before = machine.succeed("cat /var/lib/prowlarr/config.xml") machine.succeed("systemctl restart prowlarr.service") machine.wait_for_unit("prowlarr.service") xml_after = machine.succeed("cat /var/lib/prowlarr/config.xml") assert xml_before == xml_after, ( "config.xml changed on idempotent restart" ) # --- Subtest: malformed XML recovery --- with subtest("preStart recovers from malformed config.xml"): # Corrupt the file completely machine.succeed( "echo 'not xml <<<' > /var/lib/prowlarr/config.xml" ) machine.succeed("systemctl restart prowlarr.service") machine.wait_for_unit("prowlarr.service") xml = machine.succeed("cat /var/lib/prowlarr/config.xml") # Should be a fresh with declared elements ET.fromstring(xml) assert elem_text(xml, "Port") == "9696" assert elem_text(xml, "BindAddress") == "*" # --- Subtest: file ownership preserved --- with subtest("preStart preserves ownership of config.xml"): # Prowlarr uses DynamicUser; owner is dynamic. Just verify the service # can read its own config.xml after preStart. machine.succeed("systemctl restart prowlarr.service") machine.wait_for_unit("prowlarr.service") # If ownership were wrong, the service would fail to start or read. # The unit being active is sufficient evidence. # --- Subtest: preStart permissions are sensible --- with subtest("config.xml has non-world-readable perms"): # ApiKey is sensitive; config.xml must not be world-readable. mode = machine.succeed( "stat -c %a /var/lib/sonarr/.config/NzbDrone/config.xml" ).strip() # Last digit must be 0 (no 'other' permissions) assert mode.endswith("0"), f"config.xml world-readable: mode={mode}" ''; }