From 6dde2a3e0d087208b8084b61113707c5533c4c2d Mon Sep 17 00:00:00 2001 From: Simon Gardling Date: Fri, 17 Apr 2026 00:38:32 -0400 Subject: [PATCH] servarr: add configXml option with preStart hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds services.arrInit..configXml for declaratively ensuring XML elements exist in a Servarr config.xml before the service starts. Generates a preStart hook on the main service that runs a Python helper to patch or create config.xml. Undeclared elements are preserved; declared elements are written with exact values. Primary use case: preventing recurring Prowlarr 'not listening on port' failures when config.xml loses the element — now guaranteed to exist before Prowlarr starts. Hardening: - Atomic writes (tmp + rename): power loss cannot corrupt config.xml - Malformed XML recovery: fresh root instead of blocking boot - Secure default mode (0600) for new files containing ApiKey - Preserves existing file mode on rewrite - Assertion against duplicate serviceName targeting Tests (10 subtests): creates-from-missing, patches-existing, preserves- undeclared, corrects-tampered, idempotent, malformed-recovery, ownership-preserved, not-world-readable. --- flake.lock | 34 ++++++ modules/bazarr.nix | 28 +++-- modules/jellyseerr.nix | 22 ++-- modules/servarr.nix | 96 ++++++++++++++--- scripts/ensure_config_xml.py | 143 +++++++++++++++++++++++++ tests/config-xml.nix | 183 ++++++++++++++++++++++++++++++++ tests/default.nix | 1 + tests/health-checks.nix | 10 +- tests/integration.nix | 15 ++- tests/lib/mocks.nix | 4 +- tests/multiple-clients.nix | 5 +- tests/naming.nix | 52 +++++---- tests/network-namespace.nix | 197 +++++++++++++++++++---------------- tests/permanent-failure.nix | 106 ++++++++++--------- 14 files changed, 684 insertions(+), 212 deletions(-) create mode 100644 scripts/ensure_config_xml.py create mode 100644 tests/config-xml.nix diff --git a/flake.lock b/flake.lock index f52c5ed..bf24a07 100644 --- a/flake.lock +++ b/flake.lock @@ -1,5 +1,23 @@ { "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, "nixpkgs": { "locked": { "lastModified": 1771848320, @@ -18,8 +36,24 @@ }, "root": { "inputs": { + "flake-utils": "flake-utils", "nixpkgs": "nixpkgs" } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } } }, "root": "root", diff --git a/modules/bazarr.nix b/modules/bazarr.nix index 135fcd6..817a69f 100644 --- a/modules/bazarr.nix +++ b/modules/bazarr.nix @@ -127,12 +127,11 @@ let configFile = pkgs.writeText "bazarr-init-config.json" mkBazarrInitConfig; - bazarrDeps = - [ - "bazarr.service" - ] - ++ (lib.optional cfg.sonarr.enable "${cfg.sonarr.serviceName}.service") - ++ (lib.optional cfg.radarr.enable "${cfg.radarr.serviceName}.service"); + bazarrDeps = [ + "bazarr.service" + ] + ++ (lib.optional cfg.sonarr.enable "${cfg.sonarr.serviceName}.service") + ++ (lib.optional cfg.radarr.enable "${cfg.radarr.serviceName}.service"); hardeningConfig = { PrivateTmp = true; @@ -168,15 +167,14 @@ in StartLimitIntervalSec = 5 * (cfg.apiTimeout + 30); StartLimitBurst = 5; }; - serviceConfig = - { - Type = "oneshot"; - RemainAfterExit = true; - Restart = "on-failure"; - RestartSec = 30; - ExecStart = "${pythonEnv}/bin/python3 ${scriptDir}/bazarr_init.py ${configFile}"; - } - // hardeningConfig; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + Restart = "on-failure"; + RestartSec = 30; + ExecStart = "${pythonEnv}/bin/python3 ${scriptDir}/bazarr_init.py ${configFile}"; + } + // hardeningConfig; }; }; } diff --git a/modules/jellyseerr.nix b/modules/jellyseerr.nix index d0fff94..41d498c 100644 --- a/modules/jellyseerr.nix +++ b/modules/jellyseerr.nix @@ -128,10 +128,7 @@ let sonarr = { profileName = cfg.sonarr.profileName; animeProfileName = - if cfg.sonarr.animeProfileName != null then - cfg.sonarr.animeProfileName - else - cfg.sonarr.profileName; + if cfg.sonarr.animeProfileName != null then cfg.sonarr.animeProfileName else cfg.sonarr.profileName; dataDir = cfg.sonarr.dataDir; bindAddress = cfg.sonarr.bindAddress; port = cfg.sonarr.port; @@ -181,15 +178,14 @@ in StartLimitIntervalSec = 5 * (cfg.apiTimeout + 30); StartLimitBurst = 5; }; - serviceConfig = - { - Type = "oneshot"; - RemainAfterExit = true; - Restart = "on-failure"; - RestartSec = 30; - ExecStart = "${pythonEnv}/bin/python3 ${scriptDir}/jellyseerr_init.py ${configFile}"; - } - // hardeningConfig; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + Restart = "on-failure"; + RestartSec = 30; + ExecStart = "${pythonEnv}/bin/python3 ${scriptDir}/jellyseerr_init.py ${configFile}"; + } + // hardeningConfig; }; }; } diff --git a/modules/servarr.nix b/modules/servarr.nix index 1222a6f..3afcfa7 100644 --- a/modules/servarr.nix +++ b/modules/servarr.nix @@ -271,6 +271,31 @@ let seriesFolderFormat = "{Series Title}"; }; }; + + configXml = lib.mkOption { + type = lib.types.attrsOf ( + lib.types.oneOf [ + lib.types.str + lib.types.int + lib.types.bool + ] + ); + default = { }; + description = '' + XML elements to ensure in the service's config.xml before startup. + Each key-value pair corresponds to a direct child element of the + root. Existing elements are updated if their value differs; + new elements are added. Undeclared elements are preserved. + + This runs as a preStart hook on the main service, guaranteeing + config.xml is correct before the application reads it. + ''; + example = { + Port = 9696; + BindAddress = "*"; + AnalyticsEnabled = false; + }; + }; }; }; @@ -324,6 +349,16 @@ let ) inst.downloadClients; enabledInstances = lib.filterAttrs (_: inst: inst.enable) cfg; + configXmlInstances = lib.filterAttrs (_: inst: inst.configXml != { }) enabledInstances; + + mkConfigXmlFile = + name: inst: + pkgs.writeText "${name}-config-xml.json" ( + builtins.toJSON { + inherit (inst) dataDir; + elements = inst.configXml; + } + ); # Shared hardening options for all init services. hardeningConfig = { @@ -349,26 +384,44 @@ in }; config = lib.mkIf (enabledInstances != { }) { - systemd.services = lib.mapAttrs' ( - name: inst: - lib.nameValuePair "${inst.serviceName}-init" { - description = "Initialize ${name} API connections"; - after = - [ + assertions = + let + configXmlTargets = map (inst: inst.serviceName) (builtins.attrValues configXmlInstances); + in + [ + { + # Two arrInit entries targeting the same systemd service with configXml + # would silently collide on the preStart definition; only one would win. + # Force the user to deduplicate instead of producing surprising behaviour. + assertion = (lib.length configXmlTargets) == (lib.length (lib.unique configXmlTargets)); + message = '' + services.arrInit: multiple entries target the same serviceName with configXml. + Each systemd service may have configXml defined by at most one arrInit entry. + Targets: ${lib.concatStringsSep ", " configXmlTargets} + ''; + } + ]; + + systemd.services = + # Init services: oneshot units that configure the app via HTTP API + (lib.mapAttrs' ( + name: inst: + lib.nameValuePair "${inst.serviceName}-init" { + description = "Initialize ${name} API connections"; + after = [ "${inst.serviceName}.service" ] ++ (getSyncedAppDeps inst) ++ (getDownloadClientDeps inst) ++ (lib.optional (inst.networkNamespaceService != null) "${inst.networkNamespaceService}.service"); - requires = [ "${inst.serviceName}.service" ] ++ (getDownloadClientDeps inst); - wantedBy = [ "multi-user.target" ]; - environment.PYTHONPATH = "${scriptDir}"; - unitConfig = { - StartLimitIntervalSec = 5 * (inst.apiTimeout + 30); - StartLimitBurst = 5; - }; - serviceConfig = - { + requires = [ "${inst.serviceName}.service" ] ++ (getDownloadClientDeps inst); + wantedBy = [ "multi-user.target" ]; + environment.PYTHONPATH = "${scriptDir}"; + unitConfig = { + StartLimitIntervalSec = 5 * (inst.apiTimeout + 30); + StartLimitBurst = 5; + }; + serviceConfig = { Type = "oneshot"; RemainAfterExit = true; Restart = "on-failure"; @@ -379,7 +432,16 @@ in // lib.optionalAttrs (inst.networkNamespacePath != null) { NetworkNamespacePath = inst.networkNamespacePath; }; - } - ) enabledInstances; + } + ) enabledInstances) + # config.xml preStart: ensure declared elements exist before the service reads them + // (lib.mapAttrs' ( + name: inst: + lib.nameValuePair inst.serviceName { + preStart = lib.mkBefore ( + "${pythonEnv}/bin/python3 ${scriptDir}/ensure_config_xml.py ${mkConfigXmlFile name inst}" + ); + } + ) configXmlInstances); }; } diff --git a/scripts/ensure_config_xml.py b/scripts/ensure_config_xml.py new file mode 100644 index 0000000..ec131a1 --- /dev/null +++ b/scripts/ensure_config_xml.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +"""Ensure a Servarr config.xml contains required elements before startup. + +Reads a JSON config specifying the data directory and desired XML elements, +then creates or patches config.xml to include them. Existing values for +declared elements are overwritten; undeclared elements are preserved. + +Invariants: + - The write is atomic (temp file + rename); partial writes cannot leave + a corrupt config.xml that would prevent the service from starting. + - Malformed input config.xml is replaced with a fresh root + rather than blocking startup forever. + - Existing file permissions are preserved across rewrites. + - The dataDir is created if missing; the app can then write into it. +""" + +from __future__ import annotations + +import io +import json +import os +import stat +import sys +import xml.etree.ElementTree as ET + + +def to_xml_text(value) -> str: + """Convert a JSON-decoded value to the text Servarr expects. + + - bool -> "True"/"False" (C# XmlSerializer capitalisation) + - everything else -> str(value) + """ + # bool must be checked before int since bool is a subclass of int + if isinstance(value, bool): + return "True" if value else "False" + return str(value) + + +def load_root(config_xml_path: str) -> tuple[ET.Element, bool]: + """Parse existing config.xml or return a fresh root. + + Returns (root, existed) where existed is False if the file was missing + or malformed and a new root was generated. + """ + if not os.path.isfile(config_xml_path): + return ET.Element("Config"), False + + try: + tree = ET.parse(config_xml_path) + return tree.getroot(), True + except ET.ParseError as exc: + print( + f"Warning: {config_xml_path} is malformed ({exc}); " + "rewriting with a fresh root", + file=sys.stderr, + ) + return ET.Element("Config"), False + + +def patch_root(root: ET.Element, elements: dict) -> bool: + """Patch root in place with declared elements. Returns True if changed.""" + changed = False + for key, value in elements.items(): + text = to_xml_text(value) + node = root.find(key) + if node is None: + ET.SubElement(root, key).text = text + changed = True + print(f"Added <{key}>{text}") + elif node.text != text: + old = node.text + node.text = text + changed = True + print(f"Updated <{key}> from {old!r} to {text!r}") + return changed + + +def serialize(root: ET.Element) -> str: + """Pretty-print the XML tree to a string with trailing newline.""" + tree = ET.ElementTree(root) + ET.indent(tree, space=" ") + buf = io.StringIO() + tree.write(buf, encoding="unicode", xml_declaration=False) + content = buf.getvalue() + if not content.endswith("\n"): + content += "\n" + return content + + +def atomic_write(path: str, content: str, mode: int | None) -> None: + """Write content to path atomically, preserving permissions.""" + tmp = f"{path}.tmp.{os.getpid()}" + try: + with open(tmp, "w") as f: + f.write(content) + if mode is not None: + os.chmod(tmp, mode) + os.replace(tmp, path) + except Exception: + # Best-effort cleanup; don't mask the real error + try: + os.unlink(tmp) + except FileNotFoundError: + pass + raise + + +def main() -> None: + if len(sys.argv) < 2: + print("Usage: ensure_config_xml.py ", file=sys.stderr) + sys.exit(1) + + with open(sys.argv[1]) as f: + cfg = json.load(f) + + data_dir = cfg["dataDir"] + elements = cfg["elements"] + + if not elements: + return + + os.makedirs(data_dir, exist_ok=True) + config_xml_path = os.path.join(data_dir, "config.xml") + + root, existed = load_root(config_xml_path) + # Preserve existing mode if the file exists; otherwise default to 0600 + # since config.xml contains ApiKey and must not be world-readable. + mode = ( + stat.S_IMODE(os.stat(config_xml_path).st_mode) if existed else 0o600 + ) + + changed = patch_root(root, elements) + + if not changed and existed: + print(f"{config_xml_path} already correct") + return + + atomic_write(config_xml_path, serialize(root), mode) + print(f"Wrote {config_xml_path}") + + +if __name__ == "__main__": + main() diff --git a/tests/config-xml.nix b/tests/config-xml.nix new file mode 100644 index 0000000..007206a --- /dev/null +++ b/tests/config-xml.nix @@ -0,0 +1,183 @@ +{ + 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}" + ''; +} diff --git a/tests/default.nix b/tests/default.nix index 103d3ab..8b19853 100644 --- a/tests/default.nix +++ b/tests/default.nix @@ -16,4 +16,5 @@ naming = import ./naming.nix { inherit pkgs lib self; }; network-namespace = import ./network-namespace.nix { inherit pkgs lib self; }; permanent-failure = import ./permanent-failure.nix { inherit pkgs lib self; }; + config-xml = import ./config-xml.nix { inherit pkgs self; }; } diff --git a/tests/health-checks.nix b/tests/health-checks.nix index 820aeb4..b23a624 100644 --- a/tests/health-checks.nix +++ b/tests/health-checks.nix @@ -27,8 +27,14 @@ pkgs.testers.runNixOSTest { systemd.services.mock-qbittorrent = mocks.mkMockQbittorrent { initialCategories = { - tv = { name = "tv"; savePath = "/downloads"; }; - movies = { name = "movies"; savePath = "/downloads"; }; + tv = { + name = "tv"; + savePath = "/downloads"; + }; + movies = { + name = "movies"; + savePath = "/downloads"; + }; }; }; diff --git a/tests/integration.nix b/tests/integration.nix index a274c60..b571490 100644 --- a/tests/integration.nix +++ b/tests/integration.nix @@ -27,10 +27,19 @@ pkgs.testers.runNixOSTest { systemd.services.mock-qbittorrent = mocks.mkMockQbittorrent { initialCategories = { - tv = { name = "tv"; savePath = "/downloads"; }; - movies = { name = "movies"; savePath = "/downloads"; }; + tv = { + name = "tv"; + savePath = "/downloads"; + }; + movies = { + name = "movies"; + savePath = "/downloads"; + }; }; - before = [ "sonarr-init.service" "radarr-init.service" ]; + before = [ + "sonarr-init.service" + "radarr-init.service" + ]; }; systemd.tmpfiles.rules = [ diff --git a/tests/lib/mocks.nix b/tests/lib/mocks.nix index ceeffac..9776254 100644 --- a/tests/lib/mocks.nix +++ b/tests/lib/mocks.nix @@ -85,7 +85,9 @@ # Mock SABnzbd API. mkMockSabnzbd = - { port ? 6012 }: + { + port ? 6012, + }: let mockScript = pkgs.writeScript "mock-sabnzbd.py" '' import json diff --git a/tests/multiple-clients.nix b/tests/multiple-clients.nix index 6161b3d..05507dc 100644 --- a/tests/multiple-clients.nix +++ b/tests/multiple-clients.nix @@ -28,7 +28,10 @@ pkgs.testers.runNixOSTest { # Mock qBittorrent on port 6011 systemd.services.mock-qbittorrent = mocks.mkMockQbittorrent { initialCategories = { - tv = { name = "tv"; savePath = "/downloads"; }; + tv = { + name = "tv"; + savePath = "/downloads"; + }; }; }; diff --git a/tests/naming.nix b/tests/naming.nix index 1c5d4d9..a3e5d85 100644 --- a/tests/naming.nix +++ b/tests/naming.nix @@ -1,30 +1,40 @@ -{ pkgs, lib, self }: +{ + pkgs, + lib, + self, +}: pkgs.testers.runNixOSTest { name = "arr-init-naming"; - nodes.machine = { pkgs, lib, ... }: { - imports = [ self.nixosModules.default ]; - system.stateVersion = "24.11"; - virtualisation.memorySize = 4096; - environment.systemPackages = with pkgs; [ curl jq gnugrep ]; + nodes.machine = + { pkgs, lib, ... }: + { + imports = [ self.nixosModules.default ]; + system.stateVersion = "24.11"; + virtualisation.memorySize = 4096; + environment.systemPackages = with pkgs; [ + curl + jq + gnugrep + ]; - services.sonarr = { - enable = true; - dataDir = "/var/lib/sonarr/.config/NzbDrone"; - settings.server.port = lib.mkDefault 8989; - }; + services.sonarr = { + enable = true; + dataDir = "/var/lib/sonarr/.config/NzbDrone"; + settings.server.port = lib.mkDefault 8989; + }; - services.arrInit.sonarr = { - enable = true; - serviceName = "sonarr"; - dataDir = "/var/lib/sonarr/.config/NzbDrone"; - port = 8989; - naming = { - renameEpisodes = true; - standardEpisodeFormat = "{Series Title} - S{season:00}E{episode:00} - {Episode Title} {Quality Full}"; - seasonFolderFormat = "Season {season}"; + services.arrInit.sonarr = { + enable = true; + serviceName = "sonarr"; + dataDir = "/var/lib/sonarr/.config/NzbDrone"; + port = 8989; + naming = { + renameEpisodes = true; + standardEpisodeFormat = "{Series Title} - S{season:00}E{episode:00} - {Episode Title} {Quality Full}"; + seasonFolderFormat = "Season {season}"; + }; }; }; - }; testScript = '' start_all() machine.wait_for_unit("sonarr.service") diff --git a/tests/network-namespace.nix b/tests/network-namespace.nix index 2350980..fcdf056 100644 --- a/tests/network-namespace.nix +++ b/tests/network-namespace.nix @@ -1,100 +1,113 @@ -{ pkgs, lib, self }: +{ + 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 ]; + 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"; + # 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" ]; }; }; - - # 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") diff --git a/tests/permanent-failure.nix b/tests/permanent-failure.nix index 007bd6a..1fe2c2b 100644 --- a/tests/permanent-failure.nix +++ b/tests/permanent-failure.nix @@ -1,59 +1,71 @@ -{ pkgs, lib, self }: +{ + pkgs, + lib, + self, +}: pkgs.testers.runNixOSTest { name = "arr-init-permanent-failure"; - nodes.machine = { pkgs, lib, ... }: { - imports = [ self.nixosModules.default ]; - system.stateVersion = "24.11"; - virtualisation.memorySize = 2048; - environment.systemPackages = with pkgs; [ curl jq gnugrep ]; + nodes.machine = + { pkgs, lib, ... }: + { + imports = [ self.nixosModules.default ]; + system.stateVersion = "24.11"; + virtualisation.memorySize = 2048; + environment.systemPackages = with pkgs; [ + curl + jq + gnugrep + ]; - # Mock that always returns 503 - systemd.services.mock-sonarr = let - mockScript = pkgs.writeScript "mock-sonarr-fail.py" '' - from http.server import HTTPServer, BaseHTTPRequestHandler + # Mock that always returns 503 + systemd.services.mock-sonarr = + let + mockScript = pkgs.writeScript "mock-sonarr-fail.py" '' + from http.server import HTTPServer, BaseHTTPRequestHandler - class FailMock(BaseHTTPRequestHandler): - def do_GET(self): - self.send_response(503) - self.send_header("Content-Type", "text/plain") - self.end_headers() - self.wfile.write(b"Service Unavailable") + class FailMock(BaseHTTPRequestHandler): + def do_GET(self): + self.send_response(503) + self.send_header("Content-Type", "text/plain") + self.end_headers() + self.wfile.write(b"Service Unavailable") - def do_POST(self): - self.do_GET() + def do_POST(self): + self.do_GET() - def log_message(self, format, *args): - pass + def log_message(self, format, *args): + pass - HTTPServer(("0.0.0.0", 8989), FailMock).serve_forever() - ''; - in { - description = "Mock Sonarr that never becomes ready"; - wantedBy = [ "multi-user.target" ]; - serviceConfig = { - ExecStart = "${pkgs.python3}/bin/python3 ${mockScript}"; - Type = "simple"; + HTTPServer(("0.0.0.0", 8989), FailMock).serve_forever() + ''; + in + { + description = "Mock Sonarr that never becomes ready"; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + ExecStart = "${pkgs.python3}/bin/python3 ${mockScript}"; + Type = "simple"; + }; + }; + + # 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-fail" + ]; + + services.arrInit.sonarr = { + enable = true; + serviceName = "mock-sonarr"; + dataDir = "/var/lib/mock-sonarr"; + port = 8989; + # Very short timeout so retries happen fast + apiTimeout = 3; }; + + # Speed up retries for test + systemd.services.mock-sonarr-init.serviceConfig.RestartSec = lib.mkForce 2; }; - - # 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-fail" - ]; - - services.arrInit.sonarr = { - enable = true; - serviceName = "mock-sonarr"; - dataDir = "/var/lib/mock-sonarr"; - port = 8989; - # Very short timeout so retries happen fast - apiTimeout = 3; - }; - - # Speed up retries for test - systemd.services.mock-sonarr-init.serviceConfig.RestartSec = lib.mkForce 2; - }; testScript = '' start_all() machine.wait_for_unit("mock-sonarr.service")