Compare commits

...

6 Commits

Author SHA1 Message Date
6dde2a3e0d servarr: add configXml option with preStart hook
Adds services.arrInit.<name>.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 <Port> element — now guaranteed to
exist before Prowlarr starts.

Hardening:
- Atomic writes (tmp + rename): power loss cannot corrupt config.xml
- Malformed XML recovery: fresh <Config> 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.
2026-04-17 00:45:21 -04:00
a1ae022dc3 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 17:30:11 -04:00
df1d715257 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 17:29:58 -04:00
a6518b507c 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 17:29:44 -04:00
948c9e3a38 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:
- Add bindAddress option to all services (default 127.0.0.1)
- 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
- Fix duplicate wait_for_unit calls in integration test
2026-04-16 17:29:25 -04:00
a7d9b269df 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 Servarr fields with missing 'value' key
- Skip masked fields (privacy=apiKey/password) in drift detection
  to prevent spurious updates every run
2026-04-16 17:28:44 -04:00
23 changed files with 2469 additions and 1702 deletions

34
flake.lock generated
View File

@@ -1,5 +1,23 @@
{ {
"nodes": { "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": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1771848320, "lastModified": 1771848320,
@@ -18,8 +36,24 @@
}, },
"root": { "root": {
"inputs": { "inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs" "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", "root": "root",

View File

@@ -1,35 +1,39 @@
{ {
description = "Declarative API initialization for Servarr applications (Sonarr, Radarr, Prowlarr, Bazarr)"; description = "Declarative API initialization for Servarr applications (Sonarr, Radarr, Prowlarr, Bazarr, Jellyseerr)";
inputs = { inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
}; };
outputs = outputs =
{ self, nixpkgs }: {
let self,
supportedSystems = [ nixpkgs,
flake-utils,
}:
flake-utils.lib.eachSystem
[
"x86_64-linux" "x86_64-linux"
"aarch64-linux" "aarch64-linux"
]; ]
forAllSystems = nixpkgs.lib.genAttrs supportedSystems; (
in
{
nixosModules.default = import ./module.nix;
nixosModules.arr-init = import ./module.nix;
checks = forAllSystems (
system: system:
let let
pkgs = nixpkgs.legacyPackages.${system}; pkgs = nixpkgs.legacyPackages.${system};
in in
import ./tests { {
checks = import ./tests {
inherit pkgs; inherit pkgs;
lib = nixpkgs.lib; lib = nixpkgs.lib;
inherit self; inherit self;
} };
);
formatter = forAllSystems (system: nixpkgs.legacyPackages.${system}.nixfmt-tree); formatter = pkgs.nixfmt-tree;
}
)
// {
nixosModules.default = import ./modules;
nixosModules.arr-init = import ./modules;
}; };
} }

1300
module.nix

File diff suppressed because it is too large Load Diff

180
modules/bazarr.nix Normal file
View File

@@ -0,0 +1,180 @@
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.bazarrInit;
scriptDir = ../scripts;
pythonEnv = pkgs.python3.withPackages (
ps: with ps; [
pyyaml
requests
]
);
bazarrProviderModule = lib.types.submodule {
options = {
enable = lib.mkEnableOption "provider connection";
dataDir = lib.mkOption {
type = lib.types.str;
description = "Path to the provider's data directory containing config.xml.";
example = "/services/sonarr";
};
bindAddress = lib.mkOption {
type = lib.types.str;
default = "127.0.0.1";
description = "IP address of the provider (Sonarr/Radarr) for Bazarr to connect to.";
};
port = lib.mkOption {
type = lib.types.port;
description = "API port of the provider.";
example = 8989;
};
serviceName = lib.mkOption {
type = lib.types.str;
description = "Name of the systemd service to depend on.";
example = "sonarr";
};
};
};
bazarrInitModule = lib.types.submodule {
options = {
enable = lib.mkEnableOption "Bazarr API initialization";
dataDir = lib.mkOption {
type = lib.types.str;
description = "Path to Bazarr's data directory containing config/config.yaml.";
example = "/services/bazarr";
};
bindAddress = lib.mkOption {
type = lib.types.str;
default = "127.0.0.1";
description = "IP address the Bazarr API is listening on.";
};
port = lib.mkOption {
type = lib.types.port;
default = 6767;
description = "API port of Bazarr.";
};
apiTimeout = lib.mkOption {
type = lib.types.ints.positive;
default = 90;
description = ''
Seconds to wait for the Bazarr API to become available before
considering the init attempt failed. When the API is not reachable
within this window, the service exits non-zero and systemd's
Restart=on-failure will schedule another attempt after RestartSec.
The systemd start limit is computed from this value to allow 5 full
retry cycles before the unit enters permanent failure.
'';
};
sonarr = lib.mkOption {
type = bazarrProviderModule;
default = {
enable = false;
};
description = "Sonarr provider configuration.";
};
radarr = lib.mkOption {
type = bazarrProviderModule;
default = {
enable = false;
};
description = "Radarr provider configuration.";
};
};
};
mkBazarrInitConfig = builtins.toJSON {
dataDir = cfg.dataDir;
bindAddress = cfg.bindAddress;
port = cfg.port;
apiTimeout = cfg.apiTimeout;
providers =
{ }
// lib.optionalAttrs cfg.sonarr.enable {
sonarr = {
enable = true;
dataDir = cfg.sonarr.dataDir;
bindAddress = cfg.sonarr.bindAddress;
port = cfg.sonarr.port;
};
}
// lib.optionalAttrs cfg.radarr.enable {
radarr = {
enable = true;
dataDir = cfg.radarr.dataDir;
bindAddress = cfg.radarr.bindAddress;
port = cfg.radarr.port;
};
};
};
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");
hardeningConfig = {
PrivateTmp = true;
NoNewPrivileges = true;
ProtectKernelTunables = true;
ProtectKernelModules = true;
ProtectControlGroups = true;
RestrictSUIDSGID = true;
ProtectHome = true;
SystemCallArchitectures = "native";
};
in
{
options.services.bazarrInit = lib.mkOption {
type = bazarrInitModule;
default = {
enable = false;
};
description = ''
Bazarr API initialization for connecting Sonarr and Radarr providers.
Bazarr uses a different API than Servarr applications, so it has its own module.
'';
};
config = lib.mkIf cfg.enable {
systemd.services.bazarr-init = {
description = "Initialize Bazarr API connections";
after = bazarrDeps;
requires = bazarrDeps;
wantedBy = [ "multi-user.target" ];
environment.PYTHONPATH = "${scriptDir}";
unitConfig = {
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;
};
};
}

8
modules/default.nix Normal file
View File

@@ -0,0 +1,8 @@
{ ... }:
{
imports = [
./servarr.nix
./bazarr.nix
./jellyseerr.nix
];
}

191
modules/jellyseerr.nix Normal file
View File

@@ -0,0 +1,191 @@
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.jellyseerrInit;
scriptDir = ../scripts;
pythonEnv = pkgs.python3.withPackages (
ps: with ps; [
pyyaml
requests
]
);
jellyseerrProviderModule = lib.types.submodule {
options = {
profileName = lib.mkOption {
type = lib.types.str;
description = "Quality profile name to set as the default. Resolved to an ID at runtime by querying the Servarr API.";
example = "Remux + WEB 2160p";
};
dataDir = lib.mkOption {
type = lib.types.str;
description = "Path to the Servarr application data directory containing config.xml.";
example = "/services/radarr";
};
bindAddress = lib.mkOption {
type = lib.types.str;
default = "127.0.0.1";
description = "IP address the Servarr application API is listening on.";
};
port = lib.mkOption {
type = lib.types.port;
description = "API port of the Servarr application.";
example = 7878;
};
serviceName = lib.mkOption {
type = lib.types.str;
description = "Name of the systemd service to depend on.";
example = "radarr";
};
};
};
jellyseerrInitModule = lib.types.submodule {
options = {
enable = lib.mkEnableOption "Jellyseerr quality profile initialization";
configDir = lib.mkOption {
type = lib.types.str;
description = "Path to Jellyseerr's data directory containing settings.json.";
example = "/services/jellyseerr";
};
apiTimeout = lib.mkOption {
type = lib.types.ints.positive;
default = 90;
description = "Seconds to wait for Radarr/Sonarr APIs to become available.";
};
radarr = lib.mkOption {
type = jellyseerrProviderModule;
description = "Radarr quality profile configuration for Jellyseerr.";
};
sonarr = lib.mkOption {
type = lib.types.submodule {
options = {
profileName = lib.mkOption {
type = lib.types.str;
description = "Quality profile name for TV series.";
example = "WEB-2160p";
};
animeProfileName = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = "Quality profile name for anime. Defaults to profileName when null.";
};
dataDir = lib.mkOption {
type = lib.types.str;
description = "Path to the Sonarr data directory containing config.xml.";
example = "/services/sonarr";
};
bindAddress = lib.mkOption {
type = lib.types.str;
default = "127.0.0.1";
description = "IP address the Sonarr API is listening on.";
};
port = lib.mkOption {
type = lib.types.port;
description = "API port of Sonarr.";
example = 8989;
};
serviceName = lib.mkOption {
type = lib.types.str;
description = "Name of the systemd service to depend on.";
example = "sonarr";
};
};
};
description = "Sonarr quality profile configuration for Jellyseerr.";
};
};
};
mkJellyseerrInitConfig = builtins.toJSON {
configDir = cfg.configDir;
apiTimeout = cfg.apiTimeout;
radarr = {
profileName = cfg.radarr.profileName;
dataDir = cfg.radarr.dataDir;
bindAddress = cfg.radarr.bindAddress;
port = cfg.radarr.port;
};
sonarr = {
profileName = cfg.sonarr.profileName;
animeProfileName =
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;
};
};
configFile = pkgs.writeText "jellyseerr-init-config.json" mkJellyseerrInitConfig;
jellyseerrDeps = [
"jellyseerr.service"
"${cfg.radarr.serviceName}.service"
"${cfg.sonarr.serviceName}.service"
];
hardeningConfig = {
PrivateTmp = true;
NoNewPrivileges = true;
ProtectKernelTunables = true;
ProtectKernelModules = true;
ProtectControlGroups = true;
RestrictSUIDSGID = true;
ProtectHome = true;
SystemCallArchitectures = "native";
};
in
{
options.services.jellyseerrInit = lib.mkOption {
type = jellyseerrInitModule;
default = {
enable = false;
};
description = ''
Jellyseerr quality profile initialization.
Patches Jellyseerr's settings.json so new requests default to the
correct Radarr/Sonarr quality profiles, resolved by name at runtime.
'';
};
config = lib.mkIf cfg.enable {
systemd.services.jellyseerr-init = {
description = "Initialize Jellyseerr quality profile defaults";
after = jellyseerrDeps;
requires = jellyseerrDeps;
wantedBy = [ "multi-user.target" ];
environment.PYTHONPATH = "${scriptDir}";
unitConfig = {
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;
};
};
}

447
modules/servarr.nix Normal file
View File

@@ -0,0 +1,447 @@
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.arrInit;
scriptDir = ../scripts;
pythonEnv = pkgs.python3.withPackages (
ps: with ps; [
pyyaml
requests
]
);
downloadClientModule = lib.types.submodule {
options = {
name = lib.mkOption {
type = lib.types.str;
description = "Display name of the download client (e.g. \"qBittorrent\").";
example = "qBittorrent";
};
implementation = lib.mkOption {
type = lib.types.str;
description = "Implementation identifier for the Servarr API.";
example = "QBittorrent";
};
configContract = lib.mkOption {
type = lib.types.str;
description = "Config contract identifier for the Servarr API.";
example = "QBittorrentSettings";
};
protocol = lib.mkOption {
type = lib.types.enum [
"torrent"
"usenet"
];
default = "torrent";
description = "Download protocol type.";
};
fields = lib.mkOption {
type = lib.types.attrsOf lib.types.anything;
default = { };
description = ''
Flat key/value pairs for the download client configuration.
These are converted to the API's [{name, value}] array format.
'';
example = {
host = "192.168.15.1";
port = 6011;
useSsl = false;
tvCategory = "tvshows";
};
};
serviceName = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = ''
Name of the systemd service for this download client.
When set, the init service will depend on (After + Requires) this service,
ensuring the download client is running before health checks execute.
'';
example = "qbittorrent";
};
};
};
syncedAppModule = lib.types.submodule {
options = {
name = lib.mkOption {
type = lib.types.str;
description = "Display name of the application to sync (e.g. \"Sonarr\").";
example = "Sonarr";
};
implementation = lib.mkOption {
type = lib.types.str;
description = "Implementation identifier for the Prowlarr application API.";
example = "Sonarr";
};
configContract = lib.mkOption {
type = lib.types.str;
description = "Config contract identifier for the Prowlarr application API.";
example = "SonarrSettings";
};
syncLevel = lib.mkOption {
type = lib.types.str;
default = "fullSync";
description = "Sync level for the application.";
};
prowlarrUrl = lib.mkOption {
type = lib.types.str;
description = "URL of the Prowlarr instance.";
example = "http://localhost:9696";
};
baseUrl = lib.mkOption {
type = lib.types.str;
description = "URL of the target application.";
example = "http://localhost:8989";
};
apiKeyFrom = lib.mkOption {
type = lib.types.str;
description = "Path to the config.xml file to read the API key from at runtime.";
example = "/services/sonarr/config.xml";
};
syncCategories = lib.mkOption {
type = lib.types.listOf lib.types.int;
default = [ ];
description = ''
List of Newznab category IDs to sync for this application.
When empty (default), categories are auto-detected at runtime
by querying Prowlarr's indexer/categories API endpoint and
collecting all IDs under the parent category matching the
implementation type (e.g. Sonarr -> TV, Radarr -> Movies).
'';
example = [
5000
5010
5020
];
};
serviceName = lib.mkOption {
type = lib.types.str;
description = "Name of the systemd service to depend on for reading the API key.";
example = "sonarr";
};
};
};
instanceModule = lib.types.submodule {
options = {
enable = lib.mkEnableOption "Servarr application API initialization";
serviceName = lib.mkOption {
type = lib.types.str;
description = "Name of the systemd service this init depends on.";
example = "sonarr";
};
dataDir = lib.mkOption {
type = lib.types.str;
description = "Path to the application data directory containing config.xml.";
example = "/var/lib/sonarr";
};
bindAddress = lib.mkOption {
type = lib.types.str;
default = "127.0.0.1";
description = "IP address the Servarr application API is listening on.";
};
port = lib.mkOption {
type = lib.types.port;
description = "API port of the Servarr application.";
example = 8989;
};
apiVersion = lib.mkOption {
type = lib.types.str;
default = "v3";
description = "API version string used in the base URL.";
};
networkNamespacePath = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = "If set, run this init service inside the given network namespace path (e.g. /run/netns/wg).";
};
networkNamespaceService = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = ''
Systemd service that manages the network namespace.
When set, the init service orders after this service.
'';
example = "wg";
};
downloadClients = lib.mkOption {
type = lib.types.listOf downloadClientModule;
default = [ ];
description = "List of download clients to configure via the API.";
};
rootFolders = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
description = "List of root folder paths to configure via the API.";
example = [
"/media/tv"
"/media/movies"
];
};
syncedApps = lib.mkOption {
type = lib.types.listOf syncedAppModule;
default = [ ];
description = "Applications to register for indexer sync (Prowlarr only).";
};
healthChecks = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
When enabled, the init service will verify connectivity after provisioning:
- Tests all download clients are reachable via the application's testall API
- For Prowlarr instances: tests all synced applications are reachable
The init service will fail if any health check fails after all retries.
'';
};
healthCheckRetries = lib.mkOption {
type = lib.types.ints.unsigned;
default = 5;
description = ''
Number of times to retry health checks before failing.
Each retry waits healthCheckInterval seconds.
'';
};
healthCheckInterval = lib.mkOption {
type = lib.types.ints.positive;
default = 10;
description = "Seconds to wait between health check retries.";
};
apiTimeout = lib.mkOption {
type = lib.types.ints.positive;
default = 90;
description = ''
Seconds to wait for the application API to become available before
considering the init attempt failed. When the API is not reachable
within this window, the service exits non-zero and systemd's
Restart=on-failure will schedule another attempt after RestartSec.
The systemd start limit is computed from this value to allow 5 full
retry cycles before the unit enters permanent failure.
'';
};
naming = lib.mkOption {
type = lib.types.attrsOf lib.types.anything;
default = { };
description = ''
Naming configuration to set via the API's config/naming endpoint.
Keys/values map directly to the API fields (e.g. renameEpisodes,
standardEpisodeFormat for Sonarr; renameMovies, standardMovieFormat
for Radarr). Only specified fields are updated; unspecified fields
retain their current values.
'';
example = {
renameEpisodes = true;
standardEpisodeFormat = "{Series Title} - S{season:00}E{episode:00} - {Episode Title} {Quality Full}";
seasonFolderFormat = "Season {season}";
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;
};
};
};
};
mkInitConfig =
name: inst:
builtins.toJSON {
inherit name;
inherit (inst)
dataDir
bindAddress
port
apiVersion
apiTimeout
healthChecks
healthCheckRetries
healthCheckInterval
rootFolders
naming
;
downloadClients = map (dc: {
inherit (dc)
name
implementation
configContract
protocol
fields
;
}) inst.downloadClients;
syncedApps = map (app: {
inherit (app)
name
implementation
configContract
syncLevel
prowlarrUrl
baseUrl
apiKeyFrom
syncCategories
;
}) inst.syncedApps;
};
mkConfigFile = name: inst: pkgs.writeText "${name}-init-config.json" (mkInitConfig name inst);
getSyncedAppDeps = inst: map (app: "${app.serviceName}.service") inst.syncedApps;
getDownloadClientDeps =
inst:
lib.concatMap (
dc: lib.optional (dc.serviceName != null) "${dc.serviceName}.service"
) 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 = {
PrivateTmp = true;
NoNewPrivileges = true;
ProtectKernelTunables = true;
ProtectKernelModules = true;
ProtectControlGroups = true;
RestrictSUIDSGID = true;
ProtectHome = true;
SystemCallArchitectures = "native";
};
in
{
options.services.arrInit = lib.mkOption {
type = lib.types.attrsOf instanceModule;
default = { };
description = ''
Attribute set of Servarr application instances to initialize via their APIs.
Each instance generates a systemd oneshot service that idempotently configures
download clients, root folders, and synced applications.
'';
};
config = lib.mkIf (enabledInstances != { }) {
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 = {
Type = "oneshot";
RemainAfterExit = true;
Restart = "on-failure";
RestartSec = 30;
ExecStart = "${pythonEnv}/bin/python3 ${scriptDir}/servarr_init.py ${mkConfigFile name inst}";
}
// hardeningConfig
// lib.optionalAttrs (inst.networkNamespacePath != null) {
NetworkNamespacePath = inst.networkNamespacePath;
};
}
) 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);
};
}

102
scripts/bazarr_init.py Normal file
View File

@@ -0,0 +1,102 @@
#!/usr/bin/env python3
"""Declarative API initialization for Bazarr provider connections.
Idempotently configures Sonarr and Radarr providers in Bazarr via its
settings API. Detects stale API keys and updates them.
"""
import os
import requests as http
from common import (
load_config,
read_api_key_xml,
read_api_key_yaml,
wait_for_api,
)
def configure_provider(base_url, api_key, provider_type, provider_config):
"""Idempotently configure a Sonarr/Radarr provider in Bazarr.
Compares the stored API key against the current one and updates if stale.
"""
ltype = provider_type.lower()
print(f"Checking {provider_type} provider...")
resp = http.get(
f"{base_url}/api/system/settings",
headers={"X-API-KEY": api_key},
timeout=30,
)
resp.raise_for_status()
settings = resp.json()
use_flag = settings.get("general", {}).get(f"use_{ltype}", False)
existing_key = settings.get(ltype, {}).get("apikey", "")
provider_api_key = read_api_key_xml(
f"{provider_config['dataDir']}/config.xml"
)
bind_address = provider_config.get("bindAddress", "127.0.0.1")
if use_flag and existing_key == provider_api_key:
print(f"{provider_type} provider already correct, skipping")
return
action = "Updating" if use_flag else "Adding"
print(f"{action} {provider_type} provider...")
resp = http.post(
f"{base_url}/api/system/settings",
headers={"X-API-KEY": api_key},
data={
f"settings-general-use_{ltype}": "true",
f"settings-{ltype}-ip": bind_address,
f"settings-{ltype}-port": str(provider_config["port"]),
f"settings-{ltype}-apikey": provider_api_key,
f"settings-{ltype}-ssl": "false",
f"settings-{ltype}-base_url": "/",
},
timeout=30,
)
resp.raise_for_status()
print(f"{provider_type} provider configured")
def main():
cfg = load_config()
data_dir = cfg["dataDir"]
bind_address = cfg.get("bindAddress", "127.0.0.1")
port = cfg["port"]
api_timeout = cfg["apiTimeout"]
config_yaml = f"{data_dir}/config/config.yaml"
if not os.path.isfile(config_yaml):
print(f"Config file {config_yaml} not found, skipping bazarr init")
return
api_key = read_api_key_yaml(config_yaml)
base_url = f"http://{bind_address}:{port}"
wait_for_api(
base_url,
api_key,
api_timeout,
"Bazarr",
header_name="X-API-KEY",
status_path="/api/system/status",
)
providers = cfg.get("providers", {})
if providers.get("sonarr", {}).get("enable"):
configure_provider(base_url, api_key, "Sonarr", providers["sonarr"])
if providers.get("radarr", {}).get("enable"):
configure_provider(base_url, api_key, "Radarr", providers["radarr"])
print("Bazarr init complete")
if __name__ == "__main__":
main()

140
scripts/common.py Normal file
View File

@@ -0,0 +1,140 @@
"""Shared utilities for arr-init scripts."""
import json
import sys
import time
import xml.etree.ElementTree as ET
import requests as http
import yaml
def load_config():
"""Load JSON configuration from the path given as the first CLI argument."""
if len(sys.argv) < 2:
print("Usage: script <config.json>", file=sys.stderr)
sys.exit(1)
with open(sys.argv[1]) as f:
return json.load(f)
def read_api_key_xml(config_xml_path):
"""Extract <ApiKey> from a Servarr config.xml file."""
tree = ET.parse(config_xml_path)
node = tree.find("ApiKey")
if node is None or not node.text:
raise ValueError(f"Could not find ApiKey in {config_xml_path}")
return node.text
def read_api_key_yaml(config_yaml_path):
"""Extract the apikey from Bazarr's config.yaml (auth section)."""
with open(config_yaml_path) as fh:
data = yaml.safe_load(fh)
try:
return data["auth"]["apikey"]
except (KeyError, TypeError) as exc:
raise ValueError(
f"Could not find auth.apikey in {config_yaml_path}"
) from exc
def wait_for_api(
base_url,
api_key,
timeout,
name,
*,
header_name="X-Api-Key",
status_path="/system/status",
):
"""Poll a status endpoint until the API responds or timeout.
Args:
base_url: Base URL including any API version prefix.
api_key: API key for authentication.
timeout: Maximum seconds to wait.
name: Human-readable service name for log messages.
header_name: HTTP header name for the API key.
status_path: Path appended to base_url for the health probe.
"""
print(f"Waiting for {name} API (timeout: {timeout}s)...")
for i in range(1, timeout + 1):
try:
resp = http.get(
f"{base_url}{status_path}",
headers={header_name: api_key},
timeout=5,
)
if resp.ok:
print(f"{name} API is ready")
return
except (http.ConnectionError, http.Timeout):
pass
if i == timeout:
print(
f"{name} API not available after {timeout} seconds",
file=sys.stderr,
)
sys.exit(1)
time.sleep(1)
def health_check_loop(url, api_key, entity_name, svc_name, max_retries, interval):
"""POST to a testall endpoint with retry logic.
Exits the process on permanent failure so the systemd unit reflects the error.
"""
attempt = 0
while True:
healthy = True
last_error = ""
try:
resp = http.post(
url,
headers={
"X-Api-Key": api_key,
"Content-Type": "application/json",
},
timeout=30,
)
result = resp.json()
failures = [
item for item in result if not item.get("isValid", True)
]
if failures:
healthy = False
last_error = "\n".join(
f" - ID {f['id']}: "
+ ", ".join(
v["errorMessage"]
for v in f.get("validationFailures", [])
)
for f in failures
)
except (http.RequestException, ValueError, KeyError) as exc:
healthy = False
last_error = (
f"could not reach {svc_name} API for {entity_name} test: {exc}"
)
if healthy:
print(f"All {entity_name}s healthy")
return
attempt += 1
if attempt > max_retries:
print(
f"Health check FAILED after {attempt} attempts: "
f"{entity_name}(s) unreachable:",
file=sys.stderr,
)
print(last_error, file=sys.stderr)
sys.exit(1)
print(
f"{entity_name.capitalize()} health check failed "
f"(attempt {attempt}/{max_retries}), "
f"retrying in {interval}s..."
)
time.sleep(interval)

View File

@@ -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 <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()

139
scripts/jellyseerr_init.py Normal file
View File

@@ -0,0 +1,139 @@
#!/usr/bin/env python3
"""Declarative quality profile initialization for Jellyseerr.
Resolves profile names to IDs by querying Radarr/Sonarr APIs, then patches
Jellyseerr's settings.json so new requests default to the correct quality
profiles.
"""
import json
import os
import subprocess
import sys
import requests as http
from common import load_config, read_api_key_xml, wait_for_api
def resolve_profile_id(base_url, api_key, profile_name, app_name):
"""Query a Servarr app for quality profiles and resolve a name to an ID."""
resp = http.get(
f"{base_url}/qualityprofile",
headers={"X-Api-Key": api_key},
timeout=30,
)
resp.raise_for_status()
profiles = resp.json()
for profile in profiles:
if profile["name"] == profile_name:
print(f"Resolved {app_name} profile '{profile_name}' -> ID {profile['id']}")
return profile["id"]
available = [p["name"] for p in profiles]
print(
f"Profile '{profile_name}' not found in {app_name}. "
f"Available: {available}",
file=sys.stderr,
)
sys.exit(1)
def main():
cfg = load_config()
settings_path = os.path.join(cfg["configDir"], "settings.json")
if not os.path.isfile(settings_path):
print(f"{settings_path} not found, skipping (Jellyseerr not yet initialized)")
return
timeout = cfg["apiTimeout"]
# Resolve Radarr profile
radarr_cfg = cfg["radarr"]
radarr_key = read_api_key_xml(f"{radarr_cfg['dataDir']}/config.xml")
radarr_bind = radarr_cfg.get("bindAddress", "127.0.0.1")
radarr_base = f"http://{radarr_bind}:{radarr_cfg['port']}/api/v3"
wait_for_api(radarr_base, radarr_key, timeout, "Radarr")
radarr_profile_id = resolve_profile_id(
radarr_base, radarr_key, radarr_cfg["profileName"], "Radarr",
)
# Resolve Sonarr profiles
sonarr_cfg = cfg["sonarr"]
sonarr_key = read_api_key_xml(f"{sonarr_cfg['dataDir']}/config.xml")
sonarr_bind = sonarr_cfg.get("bindAddress", "127.0.0.1")
sonarr_base = f"http://{sonarr_bind}:{sonarr_cfg['port']}/api/v3"
wait_for_api(sonarr_base, sonarr_key, timeout, "Sonarr")
sonarr_profile_id = resolve_profile_id(
sonarr_base, sonarr_key, sonarr_cfg["profileName"], "Sonarr",
)
sonarr_anime_profile_id = resolve_profile_id(
sonarr_base, sonarr_key, sonarr_cfg["animeProfileName"], "Sonarr (anime)",
)
# Patch settings.json
with open(settings_path) as f:
settings = json.load(f)
changed = False
for entry in settings.get("radarr", []):
if (
entry.get("activeProfileId") != radarr_profile_id
or entry.get("activeProfileName") != radarr_cfg["profileName"]
):
entry["activeProfileId"] = radarr_profile_id
entry["activeProfileName"] = radarr_cfg["profileName"]
changed = True
print(
f"Radarr '{entry.get('name', '?')}': "
f"set profile to {radarr_cfg['profileName']} (ID {radarr_profile_id})"
)
for entry in settings.get("sonarr", []):
updates = {}
if (
entry.get("activeProfileId") != sonarr_profile_id
or entry.get("activeProfileName") != sonarr_cfg["profileName"]
):
updates["activeProfileId"] = sonarr_profile_id
updates["activeProfileName"] = sonarr_cfg["profileName"]
if (
entry.get("activeAnimeProfileId") != sonarr_anime_profile_id
or entry.get("activeAnimeProfileName") != sonarr_cfg["animeProfileName"]
):
updates["activeAnimeProfileId"] = sonarr_anime_profile_id
updates["activeAnimeProfileName"] = sonarr_cfg["animeProfileName"]
if updates:
entry.update(updates)
changed = True
print(
f"Sonarr '{entry.get('name', '?')}': "
f"set profile to {sonarr_cfg['profileName']} (ID {sonarr_profile_id})"
)
if not changed:
print("Jellyseerr profiles already correct, no changes needed")
return
with open(settings_path, "w") as f:
json.dump(settings, f, indent=2)
print("Updated settings.json, restarting Jellyseerr...")
result = subprocess.run(
["systemctl", "restart", "jellyseerr.service"],
capture_output=True,
text=True,
)
if result.returncode != 0:
print(
f"Failed to restart Jellyseerr: {result.stderr.strip()}",
file=sys.stderr,
)
sys.exit(1)
print("Jellyseerr init complete")
if __name__ == "__main__":
main()

384
scripts/servarr_init.py Normal file
View File

@@ -0,0 +1,384 @@
#!/usr/bin/env python3
"""Declarative API initialization for Servarr applications.
Idempotently provisions download clients, root folders, synced applications
(Prowlarr), and naming configuration via the Servarr HTTP API. Existing
entities are updated when their configuration drifts from the declared state.
"""
import sys
import requests as http
from common import (
health_check_loop,
load_config,
read_api_key_xml,
wait_for_api,
)
# Maps Prowlarr application implementation names to Newznab parent category
# names used for automatic sync-category detection.
IMPLEMENTATION_CATEGORY_MAP = {
"Sonarr": "TV",
"Radarr": "Movies",
"Lidarr": "Audio",
"Readarr": "Books",
"Whisparr": "XXX",
}
def _fields_to_dict(fields):
"""Convert the API's [{name, value}] array into a flat dict.
Some API responses omit the 'value' key for null/unset fields.
"""
return {f["name"]: f.get("value") for f in fields}
def _dict_to_fields(d):
"""Convert a flat dict into the API's [{name, value}] array."""
return [{"name": k, "value": v} for k, v in d.items()]
def _needs_field_update(desired, current_fields):
"""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)
return any(
desired.get(k) != current.get(k)
for k in desired
if current.get(k) != "********"
)
# -- Download clients --------------------------------------------------------
def ensure_download_clients(base_url, api_key, download_clients):
"""Idempotently provision download clients, updating on drift."""
resp = http.get(
f"{base_url}/downloadclient",
headers={"X-Api-Key": api_key},
timeout=30,
)
resp.raise_for_status()
existing_by_name = {dc["name"]: dc for dc in resp.json()}
for dc in download_clients:
dc_name = dc["name"]
desired_fields = dc["fields"]
if dc_name in existing_by_name:
current = existing_by_name[dc_name]
drift = (
current.get("implementation") != dc["implementation"]
or current.get("configContract") != dc["configContract"]
or current.get("protocol") != dc["protocol"]
or _needs_field_update(desired_fields, current.get("fields", []))
)
if not drift:
print(f"Download client '{dc_name}' already correct, skipping")
continue
print(f"Updating download client '{dc_name}'...")
payload = {**current}
payload.update(
implementation=dc["implementation"],
configContract=dc["configContract"],
protocol=dc["protocol"],
fields=_dict_to_fields(desired_fields),
)
resp = http.put(
f"{base_url}/downloadclient/{current['id']}",
headers={"X-Api-Key": api_key, "Content-Type": "application/json"},
params={"forceSave": "true"},
json=payload,
timeout=30,
)
resp.raise_for_status()
print(f"Download client '{dc_name}' updated")
else:
print(f"Adding download client '{dc_name}'...")
payload = {
"enable": True,
"protocol": dc["protocol"],
"priority": 1,
"name": dc_name,
"implementation": dc["implementation"],
"configContract": dc["configContract"],
"fields": _dict_to_fields(desired_fields),
"tags": [],
}
resp = http.post(
f"{base_url}/downloadclient",
headers={"X-Api-Key": api_key, "Content-Type": "application/json"},
params={"forceSave": "true"},
json=payload,
timeout=30,
)
resp.raise_for_status()
print(f"Download client '{dc_name}' added")
# -- Root folders ------------------------------------------------------------
def ensure_root_folders(base_url, api_key, root_folders):
"""Idempotently provision root folders (create-only)."""
resp = http.get(
f"{base_url}/rootfolder",
headers={"X-Api-Key": api_key},
timeout=30,
)
resp.raise_for_status()
existing_paths = {rf["path"] for rf in resp.json()}
for path in root_folders:
if path in existing_paths:
print(f"Root folder '{path}' already exists, skipping")
continue
print(f"Adding root folder '{path}'...")
resp = http.post(
f"{base_url}/rootfolder",
headers={"X-Api-Key": api_key, "Content-Type": "application/json"},
json={"path": path},
timeout=30,
)
resp.raise_for_status()
print(f"Root folder '{path}' added")
# -- Synced applications (Prowlarr) ------------------------------------------
def resolve_sync_categories(base_url, api_key, implementation, explicit):
"""Resolve Newznab sync categories, auto-detecting from Prowlarr if needed."""
if explicit:
return explicit
category_name = IMPLEMENTATION_CATEGORY_MAP.get(implementation)
if not category_name:
return []
print(f"Auto-detecting sync categories for {implementation}...")
resp = http.get(
f"{base_url}/indexer/categories",
headers={"X-Api-Key": api_key},
timeout=30,
)
resp.raise_for_status()
sync_cats = []
for cat in resp.json():
if cat["name"] == category_name:
sync_cats.append(cat["id"])
for sub in cat.get("subCategories", []):
sync_cats.append(sub["id"])
if not sync_cats:
print(
f"Warning: could not auto-detect categories for "
f"'{category_name}', using empty list",
file=sys.stderr,
)
return []
print(f"Resolved sync categories: {sync_cats}")
return sync_cats
def ensure_synced_apps(base_url, api_key, synced_apps):
"""Idempotently provision synced applications (Prowlarr), updating on drift."""
resp = http.get(
f"{base_url}/applications",
headers={"X-Api-Key": api_key},
timeout=30,
)
resp.raise_for_status()
existing_by_name = {app["name"]: app for app in resp.json()}
for app in synced_apps:
app_name = app["name"]
target_api_key = read_api_key_xml(app["apiKeyFrom"])
sync_categories = resolve_sync_categories(
base_url, api_key, app["implementation"], app.get("syncCategories", []),
)
desired_fields = {
"prowlarrUrl": app["prowlarrUrl"],
"baseUrl": app["baseUrl"],
"apiKey": target_api_key,
"syncCategories": sync_categories,
}
if app_name in existing_by_name:
current = existing_by_name[app_name]
drift = (
current.get("implementation") != app["implementation"]
or current.get("configContract") != app["configContract"]
or current.get("syncLevel") != app["syncLevel"]
or _needs_field_update(desired_fields, current.get("fields", []))
)
if not drift:
print(f"Synced app '{app_name}' already correct, skipping")
continue
print(f"Updating synced app '{app_name}'...")
payload = {**current}
payload.update(
implementation=app["implementation"],
configContract=app["configContract"],
syncLevel=app["syncLevel"],
fields=_dict_to_fields(desired_fields),
)
resp = http.put(
f"{base_url}/applications/{current['id']}",
headers={"X-Api-Key": api_key, "Content-Type": "application/json"},
params={"forceSave": "true"},
json=payload,
timeout=30,
)
resp.raise_for_status()
print(f"Synced app '{app_name}' updated")
else:
print(f"Adding synced app '{app_name}'...")
payload = {
"name": app_name,
"implementation": app["implementation"],
"configContract": app["configContract"],
"syncLevel": app["syncLevel"],
"fields": _dict_to_fields(desired_fields),
"tags": [],
}
resp = http.post(
f"{base_url}/applications",
headers={"X-Api-Key": api_key, "Content-Type": "application/json"},
params={"forceSave": "true"},
json=payload,
timeout=30,
)
resp.raise_for_status()
print(f"Synced app '{app_name}' added")
# -- Naming ------------------------------------------------------------------
def update_naming(base_url, api_key, naming_config):
"""Merge desired naming fields into the current config."""
if not naming_config:
return
print("Checking naming configuration...")
resp = http.get(
f"{base_url}/config/naming",
headers={"X-Api-Key": api_key},
timeout=30,
)
resp.raise_for_status()
current = resp.json()
needs_update = any(
naming_config.get(k) != current.get(k) for k in naming_config
)
if not needs_update:
print("Naming configuration already correct, skipping")
return
print("Updating naming configuration...")
merged = {**current, **naming_config}
resp = http.put(
f"{base_url}/config/naming",
headers={"X-Api-Key": api_key, "Content-Type": "application/json"},
json=merged,
timeout=30,
)
resp.raise_for_status()
print("Naming configuration updated")
# -- Health checks -----------------------------------------------------------
def run_health_checks(base_url, api_key, name, cfg):
"""Run connectivity health checks if enabled."""
if not cfg["healthChecks"]:
return
print(f"Running {name} health checks...")
max_retries = cfg["healthCheckRetries"]
interval = cfg["healthCheckInterval"]
if cfg.get("downloadClients"):
print("Testing download client connectivity...")
health_check_loop(
f"{base_url}/downloadclient/testall",
api_key,
"download client",
name,
max_retries,
interval,
)
if cfg.get("syncedApps"):
print("Testing synced application connectivity...")
health_check_loop(
f"{base_url}/applications/testall",
api_key,
"synced application",
name,
max_retries,
interval,
)
print(f"{name} health checks passed")
# -- Main --------------------------------------------------------------------
def main():
cfg = load_config()
name = cfg["name"]
data_dir = cfg["dataDir"]
bind_address = cfg.get("bindAddress", "127.0.0.1")
port = cfg["port"]
api_version = cfg["apiVersion"]
api_timeout = cfg["apiTimeout"]
import os
config_xml = f"{data_dir}/config.xml"
if not os.path.isfile(config_xml):
print(f"Config file {config_xml} not found, skipping {name} init")
return
api_key = read_api_key_xml(config_xml)
base_url = f"http://{bind_address}:{port}/api/{api_version}"
wait_for_api(base_url, api_key, api_timeout, name)
if cfg.get("downloadClients"):
ensure_download_clients(base_url, api_key, cfg["downloadClients"])
if cfg.get("rootFolders"):
ensure_root_folders(base_url, api_key, cfg["rootFolders"])
if cfg.get("syncedApps"):
ensure_synced_apps(base_url, api_key, cfg["syncedApps"])
if cfg.get("naming"):
update_naming(base_url, api_key, cfg["naming"])
run_health_checks(base_url, api_key, name, cfg)
print(f"{name} init complete")
if __name__ == "__main__":
main()

183
tests/config-xml.nix Normal file
View File

@@ -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.<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

@@ -13,4 +13,8 @@
health-checks = import ./health-checks.nix { inherit pkgs lib self; }; health-checks = import ./health-checks.nix { inherit pkgs lib self; };
delayed-start = import ./delayed-start.nix { inherit pkgs lib self; }; delayed-start = import ./delayed-start.nix { inherit pkgs lib self; };
jellyseerr = import ./jellyseerr.nix { inherit pkgs lib self; }; jellyseerr = import ./jellyseerr.nix { inherit pkgs lib self; };
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; };
} }

View File

@@ -9,6 +9,9 @@ pkgs.testers.runNixOSTest {
nodes.machine = nodes.machine =
{ pkgs, lib, ... }: { pkgs, lib, ... }:
let
mocks = import ./lib/mocks.nix { inherit pkgs; };
in
{ {
imports = [ self.nixosModules.default ]; imports = [ self.nixosModules.default ];
@@ -22,71 +25,7 @@ pkgs.testers.runNixOSTest {
gnugrep gnugrep
]; ];
systemd.services.mock-qbittorrent = systemd.services.mock-qbittorrent = mocks.mkMockQbittorrent { };
let
mockQbitScript = pkgs.writeScript "mock-qbittorrent.py" ''
import json
from http.server import HTTPServer, BaseHTTPRequestHandler
from urllib.parse import parse_qs, urlparse
CATEGORIES = {}
class QBitMock(BaseHTTPRequestHandler):
def _respond(self, code=200, body=b"Ok.", content_type="text/plain"):
self.send_response(code)
self.send_header("Content-Type", content_type)
self.send_header("Set-Cookie", "SID=mock_session_id; Path=/")
self.end_headers()
self.wfile.write(body if isinstance(body, bytes) else body.encode())
def do_GET(self):
path = self.path.split("?")[0]
if path == "/api/v2/app/webapiVersion":
self._respond(body=b"2.9.3")
elif path == "/api/v2/app/version":
self._respond(body=b"v5.0.0")
elif path == "/api/v2/torrents/info":
self._respond(body=b"[]", content_type="application/json")
elif path == "/api/v2/torrents/categories":
body = json.dumps(CATEGORIES).encode()
self._respond(body=body, content_type="application/json")
elif path == "/api/v2/app/preferences":
body = json.dumps({"save_path": "/tmp"}).encode()
self._respond(body=body, content_type="application/json")
else:
self._respond()
def do_POST(self):
content_length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(content_length).decode()
path = urlparse(self.path).path
query = parse_qs(urlparse(self.path).query)
form = parse_qs(body)
params = {**query, **form}
if path == "/api/v2/torrents/createCategory":
name = params.get("category", [""])[0]
save_path = params.get("savePath", params.get("save_path", [""]))[0] or "/downloads"
if name:
CATEGORIES[name] = {"name": name, "savePath": save_path}
self._respond()
def log_message(self, format, *args):
pass
HTTPServer(("0.0.0.0", 6011), QBitMock).serve_forever()
'';
in
{
description = "Mock qBittorrent API";
wantedBy = [ "multi-user.target" ];
serviceConfig = {
ExecStart = "${pkgs.python3}/bin/python3 ${mockQbitScript}";
Type = "simple";
};
};
# Create directories including one with spaces # Create directories including one with spaces
systemd.tmpfiles.rules = [ systemd.tmpfiles.rules = [

View File

@@ -9,6 +9,9 @@ pkgs.testers.runNixOSTest {
nodes.machine = nodes.machine =
{ pkgs, lib, ... }: { pkgs, lib, ... }:
let
mocks = import ./lib/mocks.nix { inherit pkgs; };
in
{ {
imports = [ self.nixosModules.default ]; imports = [ self.nixosModules.default ];
@@ -22,75 +25,16 @@ pkgs.testers.runNixOSTest {
gnugrep gnugrep
]; ];
systemd.services.mock-qbittorrent = systemd.services.mock-qbittorrent = mocks.mkMockQbittorrent {
let initialCategories = {
mockQbitScript = pkgs.writeScript "mock-qbittorrent.py" '' tv = {
import json name = "tv";
from http.server import HTTPServer, BaseHTTPRequestHandler savePath = "/downloads";
from urllib.parse import parse_qs, urlparse };
movies = {
name = "movies";
CATEGORIES = { savePath = "/downloads";
"tv": {"name": "tv", "savePath": "/downloads"}, };
"movies": {"name": "movies", "savePath": "/downloads"},
}
class QBitMock(BaseHTTPRequestHandler):
def _respond(self, code=200, body=b"Ok.", content_type="text/plain"):
self.send_response(code)
self.send_header("Content-Type", content_type)
self.send_header("Set-Cookie", "SID=mock_session_id; Path=/")
self.end_headers()
self.wfile.write(body if isinstance(body, bytes) else body.encode())
def do_GET(self):
path = self.path.split("?")[0]
if path == "/api/v2/app/webapiVersion":
self._respond(body=b"2.9.3")
elif path == "/api/v2/app/version":
self._respond(body=b"v5.0.0")
elif path == "/api/v2/torrents/info":
self._respond(body=b"[]", content_type="application/json")
elif path == "/api/v2/torrents/categories":
body = json.dumps(CATEGORIES).encode()
self._respond(body=body, content_type="application/json")
elif path == "/api/v2/app/preferences":
body = json.dumps({"save_path": "/tmp"}).encode()
self._respond(body=body, content_type="application/json")
else:
self._respond()
def do_POST(self):
content_length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(content_length).decode()
path = urlparse(self.path).path
query = parse_qs(urlparse(self.path).query)
form = parse_qs(body)
params = {**query, **form}
if path == "/api/v2/torrents/createCategory":
name = params.get("category", [""])[0]
save_path = params.get("savePath", params.get("save_path", [""]))[0] or "/downloads"
if name:
CATEGORIES[name] = {"name": name, "savePath": save_path}
if path in ["/api/v2/torrents/editCategory", "/api/v2/torrents/removeCategory"]:
self._respond()
return
self._respond()
def log_message(self, format, *args):
pass
HTTPServer(("0.0.0.0", 6011), QBitMock).serve_forever()
'';
in
{
description = "Mock qBittorrent API";
wantedBy = [ "multi-user.target" ];
serviceConfig = {
ExecStart = "${pkgs.python3}/bin/python3 ${mockQbitScript}";
Type = "simple";
}; };
}; };

View File

@@ -9,6 +9,9 @@ pkgs.testers.runNixOSTest {
nodes.machine = nodes.machine =
{ pkgs, lib, ... }: { pkgs, lib, ... }:
let
mocks = import ./lib/mocks.nix { inherit pkgs; };
in
{ {
imports = [ self.nixosModules.default ]; imports = [ self.nixosModules.default ];
@@ -22,80 +25,21 @@ pkgs.testers.runNixOSTest {
gnugrep gnugrep
]; ];
systemd.services.mock-qbittorrent = systemd.services.mock-qbittorrent = mocks.mkMockQbittorrent {
let initialCategories = {
mockQbitScript = pkgs.writeScript "mock-qbittorrent.py" '' tv = {
import json name = "tv";
from http.server import HTTPServer, BaseHTTPRequestHandler savePath = "/downloads";
from urllib.parse import parse_qs, urlparse };
movies = {
name = "movies";
CATEGORIES = { savePath = "/downloads";
"tv": {"name": "tv", "savePath": "/downloads"}, };
"movies": {"name": "movies", "savePath": "/downloads"}, };
}
class QBitMock(BaseHTTPRequestHandler):
def _respond(self, code=200, body=b"Ok.", content_type="text/plain"):
self.send_response(code)
self.send_header("Content-Type", content_type)
self.send_header("Set-Cookie", "SID=mock_session_id; Path=/")
self.end_headers()
self.wfile.write(body if isinstance(body, bytes) else body.encode())
def do_GET(self):
path = self.path.split("?")[0]
if path == "/api/v2/app/webapiVersion":
self._respond(body=b"2.9.3")
elif path == "/api/v2/app/version":
self._respond(body=b"v5.0.0")
elif path == "/api/v2/torrents/info":
self._respond(body=b"[]", content_type="application/json")
elif path == "/api/v2/torrents/categories":
body = json.dumps(CATEGORIES).encode()
self._respond(body=body, content_type="application/json")
elif path == "/api/v2/app/preferences":
body = json.dumps({"save_path": "/tmp"}).encode()
self._respond(body=body, content_type="application/json")
else:
self._respond()
def do_POST(self):
content_length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(content_length).decode()
path = urlparse(self.path).path
query = parse_qs(urlparse(self.path).query)
form = parse_qs(body)
params = {**query, **form}
if path == "/api/v2/torrents/createCategory":
name = params.get("category", [""])[0]
save_path = params.get("savePath", params.get("save_path", [""]))[0] or "/downloads"
if name:
CATEGORIES[name] = {"name": name, "savePath": save_path}
if path in ["/api/v2/torrents/editCategory", "/api/v2/torrents/removeCategory"]:
self._respond()
return
self._respond()
def log_message(self, format, *args):
pass
HTTPServer(("0.0.0.0", 6011), QBitMock).serve_forever()
'';
in
{
description = "Mock qBittorrent API";
wantedBy = [ "multi-user.target" ];
before = [ before = [
"sonarr-init.service" "sonarr-init.service"
"radarr-init.service" "radarr-init.service"
]; ];
serviceConfig = {
ExecStart = "${pkgs.python3}/bin/python3 ${mockQbitScript}";
Type = "simple";
};
}; };
systemd.tmpfiles.rules = [ systemd.tmpfiles.rules = [
@@ -255,10 +199,6 @@ pkgs.testers.runNixOSTest {
machine.wait_for_unit("sonarr-init.service") machine.wait_for_unit("sonarr-init.service")
machine.wait_for_unit("radarr-init.service") machine.wait_for_unit("radarr-init.service")
# Wait for init services to complete
machine.wait_for_unit("sonarr-init.service")
machine.wait_for_unit("radarr-init.service")
# Verify Sonarr download clients # Verify Sonarr download clients
machine.succeed( machine.succeed(
"API_KEY=$(grep -oP '(?<=<ApiKey>)[^<]+' /var/lib/sonarr/.config/NzbDrone/config.xml) && " "API_KEY=$(grep -oP '(?<=<ApiKey>)[^<]+' /var/lib/sonarr/.config/NzbDrone/config.xml) && "

128
tests/lib/mocks.nix Normal file
View File

@@ -0,0 +1,128 @@
# Shared mock service generators for arr-init NixOS tests.
#
# Usage (from a test file):
# let mocks = import ./lib/mocks.nix { inherit pkgs; };
# in { systemd.services.mock-qbittorrent = mocks.mkMockQbittorrent { }; }
{ pkgs }:
{
# Mock qBittorrent WebUI API.
#
# Args:
# port - TCP port (default 6011)
# initialCategories - Nix attrset seeded as the CATEGORIES dict
# before - systemd Before= list
mkMockQbittorrent =
{
port ? 6011,
initialCategories ? { },
before ? [ ],
}:
let
categoriesJson = builtins.toJSON initialCategories;
mockScript = pkgs.writeScript "mock-qbittorrent.py" ''
import json
from http.server import HTTPServer, BaseHTTPRequestHandler
from urllib.parse import parse_qs, urlparse
CATEGORIES = json.loads('${categoriesJson}')
class QBitMock(BaseHTTPRequestHandler):
def _respond(self, code=200, body=b"Ok.", content_type="text/plain"):
self.send_response(code)
self.send_header("Content-Type", content_type)
self.send_header("Set-Cookie", "SID=mock_session_id; Path=/")
self.end_headers()
self.wfile.write(body if isinstance(body, bytes) else body.encode())
def do_GET(self):
path = self.path.split("?")[0]
if path == "/api/v2/app/webapiVersion":
self._respond(body=b"2.9.3")
elif path == "/api/v2/app/version":
self._respond(body=b"v5.0.0")
elif path == "/api/v2/torrents/info":
self._respond(body=b"[]", content_type="application/json")
elif path == "/api/v2/torrents/categories":
body = json.dumps(CATEGORIES).encode()
self._respond(body=body, content_type="application/json")
elif path == "/api/v2/app/preferences":
body = json.dumps({"save_path": "/tmp"}).encode()
self._respond(body=body, content_type="application/json")
else:
self._respond()
def do_POST(self):
content_length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(content_length).decode()
path = urlparse(self.path).path
query = parse_qs(urlparse(self.path).query)
form = parse_qs(body)
params = {**query, **form}
if path == "/api/v2/torrents/createCategory":
name = params.get("category", [""])[0]
save_path = params.get("savePath", params.get("save_path", [""]))[0] or "/downloads"
if name:
CATEGORIES[name] = {"name": name, "savePath": save_path}
self._respond()
def log_message(self, format, *args):
pass
HTTPServer(("0.0.0.0", ${toString port}), QBitMock).serve_forever()
'';
in
{
description = "Mock qBittorrent API";
wantedBy = [ "multi-user.target" ];
inherit before;
serviceConfig = {
ExecStart = "${pkgs.python3}/bin/python3 ${mockScript}";
Type = "simple";
};
};
# Mock SABnzbd API.
mkMockSabnzbd =
{
port ? 6012,
}:
let
mockScript = pkgs.writeScript "mock-sabnzbd.py" ''
import json
from http.server import HTTPServer, BaseHTTPRequestHandler
class SabMock(BaseHTTPRequestHandler):
def _respond(self, code=200, body=b'{"config": {}}', 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):
if "mode=config" in self.path or "mode=version" in self.path:
self._respond(body=b'{"config": {"misc": {"api_key": "test"}}, "version": "4.0.0"}')
elif "mode=get_config" in self.path:
self._respond(body=b'{"config": {"misc": {"complete_dir": "/downloads/usenet", "pre_check": false}, "categories": [{"name": "tv", "order": 0, "pp": "", "script": "Default", "dir": "tv"}], "sorters": []}}')
else:
self._respond()
def do_POST(self):
self._respond()
def log_message(self, format, *args):
pass
HTTPServer(("0.0.0.0", ${toString port}), SabMock).serve_forever()
'';
in
{
description = "Mock SABnzbd API";
wantedBy = [ "multi-user.target" ];
serviceConfig = {
ExecStart = "${pkgs.python3}/bin/python3 ${mockScript}";
Type = "simple";
};
};
}

View File

@@ -9,6 +9,9 @@ pkgs.testers.runNixOSTest {
nodes.machine = nodes.machine =
{ pkgs, lib, ... }: { pkgs, lib, ... }:
let
mocks = import ./lib/mocks.nix { inherit pkgs; };
in
{ {
imports = [ self.nixosModules.default ]; imports = [ self.nixosModules.default ];
@@ -23,117 +26,17 @@ pkgs.testers.runNixOSTest {
]; ];
# Mock qBittorrent on port 6011 # Mock qBittorrent on port 6011
systemd.services.mock-qbittorrent = systemd.services.mock-qbittorrent = mocks.mkMockQbittorrent {
let initialCategories = {
mockQbitScript = pkgs.writeScript "mock-qbittorrent.py" '' tv = {
import json name = "tv";
from http.server import HTTPServer, BaseHTTPRequestHandler savePath = "/downloads";
from urllib.parse import parse_qs, urlparse };
CATEGORIES = {
"tv": {"name": "tv", "savePath": "/downloads"},
}
class QBitMock(BaseHTTPRequestHandler):
def _respond(self, code=200, body=b"Ok.", content_type="text/plain"):
self.send_response(code)
self.send_header("Content-Type", content_type)
self.send_header("Set-Cookie", "SID=mock_session_id; Path=/")
self.end_headers()
self.wfile.write(body if isinstance(body, bytes) else body.encode())
def do_GET(self):
path = self.path.split("?")[0]
if path == "/api/v2/app/webapiVersion":
self._respond(body=b"2.9.3")
elif path == "/api/v2/app/version":
self._respond(body=b"v5.0.0")
elif path == "/api/v2/torrents/info":
self._respond(body=b"[]", content_type="application/json")
elif path == "/api/v2/torrents/categories":
body = json.dumps(CATEGORIES).encode()
self._respond(body=body, content_type="application/json")
elif path == "/api/v2/app/preferences":
body = json.dumps({"save_path": "/tmp"}).encode()
self._respond(body=body, content_type="application/json")
else:
self._respond()
def do_POST(self):
content_length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(content_length).decode()
path = urlparse(self.path).path
query = parse_qs(urlparse(self.path).query)
form = parse_qs(body)
params = {**query, **form}
if path == "/api/v2/torrents/createCategory":
name = params.get("category", [""])[0]
save_path = params.get("savePath", params.get("save_path", [""]))[0] or "/downloads"
if name:
CATEGORIES[name] = {"name": name, "savePath": save_path}
self._respond()
def log_message(self, format, *args):
pass
HTTPServer(("0.0.0.0", 6011), QBitMock).serve_forever()
'';
in
{
description = "Mock qBittorrent API";
wantedBy = [ "multi-user.target" ];
serviceConfig = {
ExecStart = "${pkgs.python3}/bin/python3 ${mockQbitScript}";
Type = "simple";
}; };
}; };
# Mock SABnzbd on port 6012 # Mock SABnzbd on port 6012
systemd.services.mock-sabnzbd = systemd.services.mock-sabnzbd = mocks.mkMockSabnzbd { };
let
mockSabScript = pkgs.writeScript "mock-sabnzbd.py" ''
import json
from http.server import HTTPServer, BaseHTTPRequestHandler
from urllib.parse import parse_qs, urlparse
class SabMock(BaseHTTPRequestHandler):
def _respond(self, code=200, body=b'{"config": {}}', 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 = self.path.split("?")[0]
if "mode=config" in self.path or "mode=version" in self.path:
self._respond(body=b'{"config": {"misc": {"api_key": "test"}}, "version": "4.0.0"}')
elif "mode=get_config" in self.path:
self._respond(body=b'{"config": {"misc": {"complete_dir": "/downloads/usenet", "pre_check": false}, "categories": [{"name": "tv", "order": 0, "pp": "", "script": "Default", "dir": "tv"}], "sorters": []}}')
else:
self._respond()
def do_POST(self):
self._respond()
def log_message(self, format, *args):
pass
HTTPServer(("0.0.0.0", 6012), SabMock).serve_forever()
'';
in
{
description = "Mock SABnzbd API";
wantedBy = [ "multi-user.target" ];
serviceConfig = {
ExecStart = "${pkgs.python3}/bin/python3 ${mockSabScript}";
Type = "simple";
};
};
systemd.tmpfiles.rules = [ systemd.tmpfiles.rules = [
"d /media/tv 0755 sonarr sonarr -" "d /media/tv 0755 sonarr sonarr -"

68
tests/naming.nix Normal file
View File

@@ -0,0 +1,68 @@
{
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
];
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}";
};
};
};
testScript = ''
start_all()
machine.wait_for_unit("sonarr.service")
machine.wait_until_succeeds(
"API_KEY=$(grep -oP '(?<=<ApiKey>)[^<]+' /var/lib/sonarr/.config/NzbDrone/config.xml) && "
"curl -sf http://localhost:8989/api/v3/system/status -H \"X-Api-Key: $API_KEY\"",
timeout=120,
)
machine.succeed("systemctl restart sonarr-init.service")
machine.wait_for_unit("sonarr-init.service")
with subtest("Naming configuration was applied"):
machine.succeed(
"API_KEY=$(grep -oP '(?<=<ApiKey>)[^<]+' /var/lib/sonarr/.config/NzbDrone/config.xml) && "
"curl -sf http://localhost:8989/api/v3/config/naming -H \"X-Api-Key: $API_KEY\" | "
"jq -e '.renameEpisodes == true'"
)
machine.succeed(
"API_KEY=$(grep -oP '(?<=<ApiKey>)[^<]+' /var/lib/sonarr/.config/NzbDrone/config.xml) && "
"curl -sf http://localhost:8989/api/v3/config/naming -H \"X-Api-Key: $API_KEY\" | "
"jq -e '.seasonFolderFormat == \"Season {season}\"'"
)
with subtest("Naming idempotency - second run does not change anything"):
machine.succeed("systemctl restart sonarr-init.service")
machine.wait_for_unit("sonarr-init.service")
journal = machine.succeed("journalctl -u sonarr-init.service --no-pager")
assert "already correct" in journal.lower(), \
f"Expected 'already correct' on idempotent run, got: {journal[-500:]}"
'';
}

147
tests/network-namespace.nix Normal file
View File

@@ -0,0 +1,147 @@
{
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;
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\")'"
)
'';
}

View File

@@ -9,6 +9,9 @@ pkgs.testers.runNixOSTest {
nodes.machine = nodes.machine =
{ pkgs, lib, ... }: { pkgs, lib, ... }:
let
mocks = import ./lib/mocks.nix { inherit pkgs; };
in
{ {
imports = [ self.nixosModules.default ]; imports = [ self.nixosModules.default ];
@@ -22,71 +25,7 @@ pkgs.testers.runNixOSTest {
gnugrep gnugrep
]; ];
systemd.services.mock-qbittorrent = systemd.services.mock-qbittorrent = mocks.mkMockQbittorrent { };
let
mockQbitScript = pkgs.writeScript "mock-qbittorrent.py" ''
import json
from http.server import HTTPServer, BaseHTTPRequestHandler
from urllib.parse import parse_qs, urlparse
CATEGORIES = {}
class QBitMock(BaseHTTPRequestHandler):
def _respond(self, code=200, body=b"Ok.", content_type="text/plain"):
self.send_response(code)
self.send_header("Content-Type", content_type)
self.send_header("Set-Cookie", "SID=mock_session_id; Path=/")
self.end_headers()
self.wfile.write(body if isinstance(body, bytes) else body.encode())
def do_GET(self):
path = self.path.split("?")[0]
if path == "/api/v2/app/webapiVersion":
self._respond(body=b"2.9.3")
elif path == "/api/v2/app/version":
self._respond(body=b"v5.0.0")
elif path == "/api/v2/torrents/info":
self._respond(body=b"[]", content_type="application/json")
elif path == "/api/v2/torrents/categories":
body = json.dumps(CATEGORIES).encode()
self._respond(body=body, content_type="application/json")
elif path == "/api/v2/app/preferences":
body = json.dumps({"save_path": "/tmp"}).encode()
self._respond(body=body, content_type="application/json")
else:
self._respond()
def do_POST(self):
content_length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(content_length).decode()
path = urlparse(self.path).path
query = parse_qs(urlparse(self.path).query)
form = parse_qs(body)
params = {**query, **form}
if path == "/api/v2/torrents/createCategory":
name = params.get("category", [""])[0]
save_path = params.get("savePath", params.get("save_path", [""]))[0] or "/downloads"
if name:
CATEGORIES[name] = {"name": name, "savePath": save_path}
self._respond()
def log_message(self, format, *args):
pass
HTTPServer(("0.0.0.0", 6011), QBitMock).serve_forever()
'';
in
{
description = "Mock qBittorrent API";
wantedBy = [ "multi-user.target" ];
serviceConfig = {
ExecStart = "${pkgs.python3}/bin/python3 ${mockQbitScript}";
Type = "simple";
};
};
systemd.tmpfiles.rules = [ systemd.tmpfiles.rules = [
"d /media/tv 0755 sonarr sonarr -" "d /media/tv 0755 sonarr sonarr -"

100
tests/permanent-failure.nix Normal file
View File

@@ -0,0 +1,100 @@
{
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
];
# 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")
def do_POST(self):
self.do_GET()
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";
};
};
# 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-fail</ApiKey></Config>"
];
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")
with subtest("Start limit is configured correctly"):
unit_content = machine.succeed("systemctl cat mock-sonarr-init.service")
# StartLimitIntervalSec = 5 * (3 + 30) = 165
assert "StartLimitIntervalSec=165" in unit_content, \
f"Expected StartLimitIntervalSec=165, got:\n{unit_content}"
assert "StartLimitBurst=5" in unit_content, \
f"Expected StartLimitBurst=5, got:\n{unit_content}"
with subtest("Service enters permanent failure after exhausting retries"):
# Wait for the start-limit to be hit: "Start request repeated too quickly" in journal
machine.wait_until_succeeds(
"journalctl -u mock-sonarr-init.service --no-pager | grep -q 'Start request repeated too quickly'",
timeout=120,
)
state = machine.succeed(
"systemctl show mock-sonarr-init.service --property=ActiveState | cut -d= -f2"
).strip()
assert state == "failed", f"Expected 'failed' state, got '{state}'"
with subtest("Journal shows repeated timeout messages"):
journal = machine.succeed("journalctl -u mock-sonarr-init.service --no-pager")
# Count timeout messages - should have multiple
timeout_count = journal.lower().count("not available after 3 seconds")
assert timeout_count >= 2, \
f"Expected at least 2 timeout messages, got {timeout_count}. Journal:\n{journal[-1000:]}"
'';
}