Compare commits

..

6 Commits

Author SHA1 Message Date
9635aecb81 test: add permanent failure test
Verifies the service enters failed state after exhausting all
StartLimitBurst retries when the API never becomes available.
Checks StartLimitIntervalSec/Burst configuration and confirms
repeated timeout messages appear in the journal.
2026-04-16 16:35:28 -04:00
a37b6f6112 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.
2026-04-16 16:34:53 -04:00
f766e5f71e test: add naming configuration test
Exercises the naming option which was previously untested.
Verifies fields are applied to Sonarr via config/naming API
and validates idempotency (second run reports 'already correct').
2026-04-16 16:34:28 -04:00
f86a5f1b39 refactor: split module.nix into per-service modules
Replace the 1301-line monolithic module.nix with focused modules:
- modules/servarr.nix  (Sonarr/Radarr/Prowlarr)
- modules/bazarr.nix   (Bazarr provider connections)
- modules/jellyseerr.nix (Jellyseerr quality profiles)
- modules/default.nix  (import aggregator)

Python scripts (from prior commit) are referenced as standalone
files via PYTHONPATH, with config passed as a JSON file argument.

New options and behavioral changes:
- Add bindAddress option to all services (default 127.0.0.1)
- Change healthChecks default from false to true
- Replace hardcoded wg.service dependency with configurable
  networkNamespaceService option
- Add systemd hardening: PrivateTmp, NoNewPrivileges, ProtectHome,
  ProtectKernelTunables/Modules, ProtectControlGroups,
  RestrictSUIDSGID, SystemCallArchitectures=native

Test updates:
- Extract mock qBittorrent/SABnzbd servers into tests/lib/mocks.nix
- Add healthChecks=false to tests not exercising health checks
- Fix duplicate wait_for_unit calls in integration test
2026-04-16 16:34:04 -04:00
b464a8cea2 refactor: extract Python scripts into standalone files
Move embedded Python scripts out of Nix string interpolation into
standalone files under scripts/.  Each script reads its configuration
from a JSON file passed as the first CLI argument.

Shared utilities (API key reading, API polling, health check loop)
are consolidated into common.py, eliminating three copies of
read_api_key and wait_for_api.

Implementation improvements included in the extraction:
- Remove pyarr dependency; all HTTP calls use raw requests
- Add update semantics: download clients and synced apps are now
  compared against desired state and updated on drift via PUT
- Bazarr configure_provider compares API keys and updates stale ones
- Narrow health_check_loop exception clause from bare Exception to
  (RequestException, ValueError, KeyError)
- Fix double resp.json() call in resolve_profile_id (jellyseerr)
- Replace os.system with subprocess.run for Jellyseerr restart
- Handle missing 'value' key in Servarr field API responses
2026-04-16 16:33:18 -04:00
b97ed1e90c flake.nix: use flake-utils for system gen 2026-04-16 13:50:51 -04:00
18 changed files with 228 additions and 661 deletions

View File

@@ -127,7 +127,8 @@ let
configFile = pkgs.writeText "bazarr-init-config.json" mkBazarrInitConfig; configFile = pkgs.writeText "bazarr-init-config.json" mkBazarrInitConfig;
bazarrDeps = [ bazarrDeps =
[
"bazarr.service" "bazarr.service"
] ]
++ (lib.optional cfg.sonarr.enable "${cfg.sonarr.serviceName}.service") ++ (lib.optional cfg.sonarr.enable "${cfg.sonarr.serviceName}.service")
@@ -167,7 +168,8 @@ in
StartLimitIntervalSec = 5 * (cfg.apiTimeout + 30); StartLimitIntervalSec = 5 * (cfg.apiTimeout + 30);
StartLimitBurst = 5; StartLimitBurst = 5;
}; };
serviceConfig = { serviceConfig =
{
Type = "oneshot"; Type = "oneshot";
RemainAfterExit = true; RemainAfterExit = true;
Restart = "on-failure"; Restart = "on-failure";

View File

@@ -128,7 +128,10 @@ let
sonarr = { sonarr = {
profileName = cfg.sonarr.profileName; profileName = cfg.sonarr.profileName;
animeProfileName = 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; dataDir = cfg.sonarr.dataDir;
bindAddress = cfg.sonarr.bindAddress; bindAddress = cfg.sonarr.bindAddress;
port = cfg.sonarr.port; port = cfg.sonarr.port;
@@ -178,7 +181,8 @@ in
StartLimitIntervalSec = 5 * (cfg.apiTimeout + 30); StartLimitIntervalSec = 5 * (cfg.apiTimeout + 30);
StartLimitBurst = 5; StartLimitBurst = 5;
}; };
serviceConfig = { serviceConfig =
{
Type = "oneshot"; Type = "oneshot";
RemainAfterExit = true; RemainAfterExit = true;
Restart = "on-failure"; Restart = "on-failure";

View File

@@ -216,7 +216,7 @@ let
healthChecks = lib.mkOption { healthChecks = lib.mkOption {
type = lib.types.bool; type = lib.types.bool;
default = false; default = true;
description = '' description = ''
When enabled, the init service will verify connectivity after provisioning: When enabled, the init service will verify connectivity after provisioning:
- Tests all download clients are reachable via the application's testall API - Tests all download clients are reachable via the application's testall API
@@ -271,31 +271,6 @@ let
seriesFolderFormat = "{Series Title}"; 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
<Config> 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;
};
};
}; };
}; };
@@ -349,16 +324,6 @@ let
) inst.downloadClients; ) inst.downloadClients;
enabledInstances = lib.filterAttrs (_: inst: inst.enable) cfg; 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. # Shared hardening options for all init services.
hardeningConfig = { hardeningConfig = {
@@ -384,31 +349,12 @@ in
}; };
config = lib.mkIf (enabledInstances != { }) { config = lib.mkIf (enabledInstances != { }) {
assertions = systemd.services = lib.mapAttrs' (
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: name: inst:
lib.nameValuePair "${inst.serviceName}-init" { lib.nameValuePair "${inst.serviceName}-init" {
description = "Initialize ${name} API connections"; description = "Initialize ${name} API connections";
after = [ after =
[
"${inst.serviceName}.service" "${inst.serviceName}.service"
] ]
++ (getSyncedAppDeps inst) ++ (getSyncedAppDeps inst)
@@ -421,7 +367,8 @@ in
StartLimitIntervalSec = 5 * (inst.apiTimeout + 30); StartLimitIntervalSec = 5 * (inst.apiTimeout + 30);
StartLimitBurst = 5; StartLimitBurst = 5;
}; };
serviceConfig = { serviceConfig =
{
Type = "oneshot"; Type = "oneshot";
RemainAfterExit = true; RemainAfterExit = true;
Restart = "on-failure"; Restart = "on-failure";
@@ -433,15 +380,6 @@ in
NetworkNamespacePath = inst.networkNamespacePath; 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);
}; };
} }

View File

@@ -1,143 +0,0 @@
#!/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 <Config> 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 <Config> 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 <Config> 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}</{key}>")
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 <config.json>", 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()

View File

@@ -42,17 +42,9 @@ def _dict_to_fields(d):
def _needs_field_update(desired, current_fields): def _needs_field_update(desired, current_fields):
"""Return True if any desired field value differs from the current state. """Return True if any desired field value differs from the current state."""
Skips fields that the API returns masked (e.g. '********' for API keys
and passwords) since comparison against the real value always shows drift.
"""
current = _fields_to_dict(current_fields) current = _fields_to_dict(current_fields)
return any( return any(desired.get(k) != current.get(k) for k in desired)
desired.get(k) != current.get(k)
for k in desired
if current.get(k) != "********"
)
# -- Download clients -------------------------------------------------------- # -- Download clients --------------------------------------------------------

View File

@@ -1,183 +0,0 @@
{
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.<tag>. 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 '<ApiKey>' /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|<Port>9696</Port>|<Port>1234</Port>|' /var/lib/prowlarr/config.xml"
)
machine.succeed("grep '<Port>1234</Port>' /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 <valid/> 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 <Config> 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}"
'';
}

View File

@@ -16,5 +16,4 @@
naming = import ./naming.nix { inherit pkgs lib self; }; naming = import ./naming.nix { inherit pkgs lib self; };
network-namespace = import ./network-namespace.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; }; permanent-failure = import ./permanent-failure.nix { inherit pkgs lib self; };
config-xml = import ./config-xml.nix { inherit pkgs self; };
} }

View File

@@ -97,6 +97,7 @@ pkgs.testers.runNixOSTest {
]; ];
services.arrInit.sonarr = { services.arrInit.sonarr = {
healthChecks = false;
enable = true; enable = true;
serviceName = "mock-sonarr"; serviceName = "mock-sonarr";
dataDir = "/var/lib/mock-sonarr"; dataDir = "/var/lib/mock-sonarr";

View File

@@ -45,6 +45,7 @@ pkgs.testers.runNixOSTest {
# Test 3: Path with spaces # Test 3: Path with spaces
services.arrInit.sonarr = { services.arrInit.sonarr = {
enable = true; enable = true;
healthChecks = false;
serviceName = "sonarr"; serviceName = "sonarr";
dataDir = "/var/lib/sonarr/.config/NzbDrone"; dataDir = "/var/lib/sonarr/.config/NzbDrone";
port = 8989; port = 8989;

View File

@@ -26,6 +26,7 @@ pkgs.testers.runNixOSTest {
# The dataDir points to a non-existent config.xml # The dataDir points to a non-existent config.xml
services.arrInit.sonarr = { services.arrInit.sonarr = {
enable = true; enable = true;
healthChecks = false;
serviceName = "sonarr"; serviceName = "sonarr";
dataDir = "/var/lib/nonexistent"; dataDir = "/var/lib/nonexistent";
port = 8989; port = 8989;

View File

@@ -27,14 +27,8 @@ pkgs.testers.runNixOSTest {
systemd.services.mock-qbittorrent = mocks.mkMockQbittorrent { systemd.services.mock-qbittorrent = mocks.mkMockQbittorrent {
initialCategories = { initialCategories = {
tv = { tv = { name = "tv"; savePath = "/downloads"; };
name = "tv"; movies = { name = "movies"; savePath = "/downloads"; };
savePath = "/downloads";
};
movies = {
name = "movies";
savePath = "/downloads";
};
}; };
}; };

View File

@@ -27,19 +27,10 @@ pkgs.testers.runNixOSTest {
systemd.services.mock-qbittorrent = mocks.mkMockQbittorrent { systemd.services.mock-qbittorrent = mocks.mkMockQbittorrent {
initialCategories = { initialCategories = {
tv = { tv = { name = "tv"; savePath = "/downloads"; };
name = "tv"; movies = { name = "movies"; savePath = "/downloads"; };
savePath = "/downloads";
}; };
movies = { before = [ "sonarr-init.service" "radarr-init.service" ];
name = "movies";
savePath = "/downloads";
};
};
before = [
"sonarr-init.service"
"radarr-init.service"
];
}; };
systemd.tmpfiles.rules = [ systemd.tmpfiles.rules = [
@@ -70,6 +61,7 @@ pkgs.testers.runNixOSTest {
services.arrInit.sonarr = { services.arrInit.sonarr = {
enable = true; enable = true;
healthChecks = false;
serviceName = "sonarr"; serviceName = "sonarr";
dataDir = "/var/lib/sonarr/.config/NzbDrone"; dataDir = "/var/lib/sonarr/.config/NzbDrone";
port = 8989; port = 8989;
@@ -92,6 +84,7 @@ pkgs.testers.runNixOSTest {
services.arrInit.radarr = { services.arrInit.radarr = {
enable = true; enable = true;
healthChecks = false;
serviceName = "radarr"; serviceName = "radarr";
dataDir = "/var/lib/radarr/.config/Radarr"; dataDir = "/var/lib/radarr/.config/Radarr";
port = 7878; port = 7878;
@@ -114,6 +107,7 @@ pkgs.testers.runNixOSTest {
services.arrInit.prowlarr = { services.arrInit.prowlarr = {
enable = true; enable = true;
healthChecks = false;
serviceName = "prowlarr"; serviceName = "prowlarr";
dataDir = "/var/lib/prowlarr"; dataDir = "/var/lib/prowlarr";
port = 9696; port = 9696;

View File

@@ -85,9 +85,7 @@
# Mock SABnzbd API. # Mock SABnzbd API.
mkMockSabnzbd = mkMockSabnzbd =
{ { port ? 6012 }:
port ? 6012,
}:
let let
mockScript = pkgs.writeScript "mock-sabnzbd.py" '' mockScript = pkgs.writeScript "mock-sabnzbd.py" ''
import json import json

View File

@@ -28,10 +28,7 @@ pkgs.testers.runNixOSTest {
# Mock qBittorrent on port 6011 # Mock qBittorrent on port 6011
systemd.services.mock-qbittorrent = mocks.mkMockQbittorrent { systemd.services.mock-qbittorrent = mocks.mkMockQbittorrent {
initialCategories = { initialCategories = {
tv = { tv = { name = "tv"; savePath = "/downloads"; };
name = "tv";
savePath = "/downloads";
};
}; };
}; };
@@ -51,6 +48,7 @@ pkgs.testers.runNixOSTest {
# Sonarr with TWO download clients: qBittorrent + SABnzbd # Sonarr with TWO download clients: qBittorrent + SABnzbd
services.arrInit.sonarr = { services.arrInit.sonarr = {
enable = true; enable = true;
healthChecks = false;
serviceName = "sonarr"; serviceName = "sonarr";
dataDir = "/var/lib/sonarr/.config/NzbDrone"; dataDir = "/var/lib/sonarr/.config/NzbDrone";
port = 8989; port = 8989;

View File

@@ -1,21 +1,11 @@
{ { pkgs, lib, self }:
pkgs,
lib,
self,
}:
pkgs.testers.runNixOSTest { pkgs.testers.runNixOSTest {
name = "arr-init-naming"; name = "arr-init-naming";
nodes.machine = nodes.machine = { pkgs, lib, ... }: {
{ pkgs, lib, ... }:
{
imports = [ self.nixosModules.default ]; imports = [ self.nixosModules.default ];
system.stateVersion = "24.11"; system.stateVersion = "24.11";
virtualisation.memorySize = 4096; virtualisation.memorySize = 4096;
environment.systemPackages = with pkgs; [ environment.systemPackages = with pkgs; [ curl jq gnugrep ];
curl
jq
gnugrep
];
services.sonarr = { services.sonarr = {
enable = true; enable = true;
@@ -28,6 +18,7 @@ pkgs.testers.runNixOSTest {
serviceName = "sonarr"; serviceName = "sonarr";
dataDir = "/var/lib/sonarr/.config/NzbDrone"; dataDir = "/var/lib/sonarr/.config/NzbDrone";
port = 8989; port = 8989;
healthChecks = false;
naming = { naming = {
renameEpisodes = true; renameEpisodes = true;
standardEpisodeFormat = "{Series Title} - S{season:00}E{episode:00} - {Episode Title} {Quality Full}"; standardEpisodeFormat = "{Series Title} - S{season:00}E{episode:00} - {Episode Title} {Quality Full}";

View File

@@ -1,22 +1,11 @@
{ { pkgs, lib, self }:
pkgs,
lib,
self,
}:
pkgs.testers.runNixOSTest { pkgs.testers.runNixOSTest {
name = "arr-init-network-namespace"; name = "arr-init-network-namespace";
nodes.machine = nodes.machine = { pkgs, lib, ... }: {
{ pkgs, lib, ... }:
{
imports = [ self.nixosModules.default ]; imports = [ self.nixosModules.default ];
system.stateVersion = "24.11"; system.stateVersion = "24.11";
virtualisation.memorySize = 2048; virtualisation.memorySize = 2048;
environment.systemPackages = with pkgs; [ environment.systemPackages = with pkgs; [ curl jq gnugrep iproute2 ];
curl
jq
gnugrep
iproute2
];
# Create the network namespace with loopback # Create the network namespace with loopback
systemd.services.create-netns = { systemd.services.create-netns = {
@@ -33,8 +22,7 @@ pkgs.testers.runNixOSTest {
}; };
# Mock Servarr API running inside the namespace # Mock Servarr API running inside the namespace
systemd.services.mock-sonarr = systemd.services.mock-sonarr = let
let
mockScript = pkgs.writeScript "mock-sonarr-ns.py" '' mockScript = pkgs.writeScript "mock-sonarr-ns.py" ''
import json import json
from http.server import HTTPServer, BaseHTTPRequestHandler from http.server import HTTPServer, BaseHTTPRequestHandler
@@ -78,8 +66,7 @@ pkgs.testers.runNixOSTest {
HTTPServer(("0.0.0.0", 8989), MockArr).serve_forever() HTTPServer(("0.0.0.0", 8989), MockArr).serve_forever()
''; '';
in in {
{
description = "Mock Sonarr API in network namespace"; description = "Mock Sonarr API in network namespace";
after = [ "create-netns.service" ]; after = [ "create-netns.service" ];
requires = [ "create-netns.service" ]; requires = [ "create-netns.service" ];
@@ -103,6 +90,7 @@ pkgs.testers.runNixOSTest {
serviceName = "mock-sonarr"; serviceName = "mock-sonarr";
dataDir = "/var/lib/mock-sonarr"; dataDir = "/var/lib/mock-sonarr";
port = 8989; port = 8989;
healthChecks = false;
networkNamespacePath = "/run/netns/test-ns"; networkNamespacePath = "/run/netns/test-ns";
networkNamespaceService = "create-netns"; networkNamespaceService = "create-netns";
rootFolders = [ "/media/tv" ]; rootFolders = [ "/media/tv" ];

View File

@@ -50,6 +50,7 @@ pkgs.testers.runNixOSTest {
# Test 1: Only rootFolders (no downloadClients) # Test 1: Only rootFolders (no downloadClients)
services.arrInit.sonarr = { services.arrInit.sonarr = {
enable = true; enable = true;
healthChecks = false;
serviceName = "sonarr"; serviceName = "sonarr";
dataDir = "/var/lib/sonarr/.config/NzbDrone"; dataDir = "/var/lib/sonarr/.config/NzbDrone";
port = 8989; port = 8989;
@@ -61,6 +62,7 @@ pkgs.testers.runNixOSTest {
# Test 2: Only downloadClients (no rootFolders) # Test 2: Only downloadClients (no rootFolders)
services.arrInit.radarr = { services.arrInit.radarr = {
enable = true; enable = true;
healthChecks = false;
serviceName = "radarr"; serviceName = "radarr";
dataDir = "/var/lib/radarr/.config/Radarr"; dataDir = "/var/lib/radarr/.config/Radarr";
port = 7878; port = 7878;
@@ -85,6 +87,7 @@ pkgs.testers.runNixOSTest {
# Test 3: Only syncedApps (Prowlarr) # Test 3: Only syncedApps (Prowlarr)
services.arrInit.prowlarr = { services.arrInit.prowlarr = {
enable = true; enable = true;
healthChecks = false;
serviceName = "prowlarr"; serviceName = "prowlarr";
dataDir = "/var/lib/prowlarr"; dataDir = "/var/lib/prowlarr";
port = 9696; port = 9696;

View File

@@ -1,25 +1,14 @@
{ { pkgs, lib, self }:
pkgs,
lib,
self,
}:
pkgs.testers.runNixOSTest { pkgs.testers.runNixOSTest {
name = "arr-init-permanent-failure"; name = "arr-init-permanent-failure";
nodes.machine = nodes.machine = { pkgs, lib, ... }: {
{ pkgs, lib, ... }:
{
imports = [ self.nixosModules.default ]; imports = [ self.nixosModules.default ];
system.stateVersion = "24.11"; system.stateVersion = "24.11";
virtualisation.memorySize = 2048; virtualisation.memorySize = 2048;
environment.systemPackages = with pkgs; [ environment.systemPackages = with pkgs; [ curl jq gnugrep ];
curl
jq
gnugrep
];
# Mock that always returns 503 # Mock that always returns 503
systemd.services.mock-sonarr = systemd.services.mock-sonarr = let
let
mockScript = pkgs.writeScript "mock-sonarr-fail.py" '' mockScript = pkgs.writeScript "mock-sonarr-fail.py" ''
from http.server import HTTPServer, BaseHTTPRequestHandler from http.server import HTTPServer, BaseHTTPRequestHandler
@@ -38,8 +27,7 @@ pkgs.testers.runNixOSTest {
HTTPServer(("0.0.0.0", 8989), FailMock).serve_forever() HTTPServer(("0.0.0.0", 8989), FailMock).serve_forever()
''; '';
in in {
{
description = "Mock Sonarr that never becomes ready"; description = "Mock Sonarr that never becomes ready";
wantedBy = [ "multi-user.target" ]; wantedBy = [ "multi-user.target" ];
serviceConfig = { serviceConfig = {
@@ -59,6 +47,7 @@ pkgs.testers.runNixOSTest {
serviceName = "mock-sonarr"; serviceName = "mock-sonarr";
dataDir = "/var/lib/mock-sonarr"; dataDir = "/var/lib/mock-sonarr";
port = 8989; port = 8989;
healthChecks = false;
# Very short timeout so retries happen fast # Very short timeout so retries happen fast
apiTimeout = 3; apiTimeout = 3;
}; };