Compare commits
6 Commits
60fcce47df
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6dde2a3e0d | |||
| a1ae022dc3 | |||
| df1d715257 | |||
| a6518b507c | |||
| 948c9e3a38 | |||
| a7d9b269df |
34
flake.lock
generated
34
flake.lock
generated
@@ -1,5 +1,23 @@
|
||||
{
|
||||
"nodes": {
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1731533236,
|
||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1771848320,
|
||||
@@ -18,8 +36,24 @@
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
|
||||
36
flake.nix
36
flake.nix
@@ -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 = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
};
|
||||
|
||||
outputs =
|
||||
{ self, nixpkgs }:
|
||||
let
|
||||
supportedSystems = [
|
||||
{
|
||||
self,
|
||||
nixpkgs,
|
||||
flake-utils,
|
||||
}:
|
||||
flake-utils.lib.eachSystem
|
||||
[
|
||||
"x86_64-linux"
|
||||
"aarch64-linux"
|
||||
];
|
||||
forAllSystems = nixpkgs.lib.genAttrs supportedSystems;
|
||||
in
|
||||
{
|
||||
nixosModules.default = import ./module.nix;
|
||||
nixosModules.arr-init = import ./module.nix;
|
||||
|
||||
checks = forAllSystems (
|
||||
]
|
||||
(
|
||||
system:
|
||||
let
|
||||
pkgs = nixpkgs.legacyPackages.${system};
|
||||
in
|
||||
import ./tests {
|
||||
{
|
||||
checks = import ./tests {
|
||||
inherit pkgs;
|
||||
lib = nixpkgs.lib;
|
||||
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
1300
module.nix
File diff suppressed because it is too large
Load Diff
180
modules/bazarr.nix
Normal file
180
modules/bazarr.nix
Normal 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
8
modules/default.nix
Normal file
@@ -0,0 +1,8 @@
|
||||
{ ... }:
|
||||
{
|
||||
imports = [
|
||||
./servarr.nix
|
||||
./bazarr.nix
|
||||
./jellyseerr.nix
|
||||
];
|
||||
}
|
||||
191
modules/jellyseerr.nix
Normal file
191
modules/jellyseerr.nix
Normal 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
447
modules/servarr.nix
Normal 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
102
scripts/bazarr_init.py
Normal 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
140
scripts/common.py
Normal 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)
|
||||
143
scripts/ensure_config_xml.py
Normal file
143
scripts/ensure_config_xml.py
Normal 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
139
scripts/jellyseerr_init.py
Normal 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
384
scripts/servarr_init.py
Normal 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
183
tests/config-xml.nix
Normal 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}"
|
||||
'';
|
||||
}
|
||||
@@ -13,4 +13,8 @@
|
||||
health-checks = import ./health-checks.nix { inherit pkgs lib self; };
|
||||
delayed-start = import ./delayed-start.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; };
|
||||
}
|
||||
|
||||
@@ -9,6 +9,9 @@ pkgs.testers.runNixOSTest {
|
||||
|
||||
nodes.machine =
|
||||
{ pkgs, lib, ... }:
|
||||
let
|
||||
mocks = import ./lib/mocks.nix { inherit pkgs; };
|
||||
in
|
||||
{
|
||||
imports = [ self.nixosModules.default ];
|
||||
|
||||
@@ -22,71 +25,7 @@ pkgs.testers.runNixOSTest {
|
||||
gnugrep
|
||||
];
|
||||
|
||||
systemd.services.mock-qbittorrent =
|
||||
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.services.mock-qbittorrent = mocks.mkMockQbittorrent { };
|
||||
|
||||
# Create directories including one with spaces
|
||||
systemd.tmpfiles.rules = [
|
||||
|
||||
@@ -9,6 +9,9 @@ pkgs.testers.runNixOSTest {
|
||||
|
||||
nodes.machine =
|
||||
{ pkgs, lib, ... }:
|
||||
let
|
||||
mocks = import ./lib/mocks.nix { inherit pkgs; };
|
||||
in
|
||||
{
|
||||
imports = [ self.nixosModules.default ];
|
||||
|
||||
@@ -22,75 +25,16 @@ pkgs.testers.runNixOSTest {
|
||||
gnugrep
|
||||
];
|
||||
|
||||
systemd.services.mock-qbittorrent =
|
||||
let
|
||||
mockQbitScript = pkgs.writeScript "mock-qbittorrent.py" ''
|
||||
import json
|
||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
|
||||
CATEGORIES = {
|
||||
"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";
|
||||
systemd.services.mock-qbittorrent = mocks.mkMockQbittorrent {
|
||||
initialCategories = {
|
||||
tv = {
|
||||
name = "tv";
|
||||
savePath = "/downloads";
|
||||
};
|
||||
movies = {
|
||||
name = "movies";
|
||||
savePath = "/downloads";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -9,6 +9,9 @@ pkgs.testers.runNixOSTest {
|
||||
|
||||
nodes.machine =
|
||||
{ pkgs, lib, ... }:
|
||||
let
|
||||
mocks = import ./lib/mocks.nix { inherit pkgs; };
|
||||
in
|
||||
{
|
||||
imports = [ self.nixosModules.default ];
|
||||
|
||||
@@ -22,80 +25,21 @@ pkgs.testers.runNixOSTest {
|
||||
gnugrep
|
||||
];
|
||||
|
||||
systemd.services.mock-qbittorrent =
|
||||
let
|
||||
mockQbitScript = pkgs.writeScript "mock-qbittorrent.py" ''
|
||||
import json
|
||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
|
||||
CATEGORIES = {
|
||||
"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" ];
|
||||
systemd.services.mock-qbittorrent = mocks.mkMockQbittorrent {
|
||||
initialCategories = {
|
||||
tv = {
|
||||
name = "tv";
|
||||
savePath = "/downloads";
|
||||
};
|
||||
movies = {
|
||||
name = "movies";
|
||||
savePath = "/downloads";
|
||||
};
|
||||
};
|
||||
before = [
|
||||
"sonarr-init.service"
|
||||
"radarr-init.service"
|
||||
];
|
||||
serviceConfig = {
|
||||
ExecStart = "${pkgs.python3}/bin/python3 ${mockQbitScript}";
|
||||
Type = "simple";
|
||||
};
|
||||
};
|
||||
|
||||
systemd.tmpfiles.rules = [
|
||||
@@ -255,10 +199,6 @@ pkgs.testers.runNixOSTest {
|
||||
machine.wait_for_unit("sonarr-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
|
||||
machine.succeed(
|
||||
"API_KEY=$(grep -oP '(?<=<ApiKey>)[^<]+' /var/lib/sonarr/.config/NzbDrone/config.xml) && "
|
||||
|
||||
128
tests/lib/mocks.nix
Normal file
128
tests/lib/mocks.nix
Normal 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";
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -9,6 +9,9 @@ pkgs.testers.runNixOSTest {
|
||||
|
||||
nodes.machine =
|
||||
{ pkgs, lib, ... }:
|
||||
let
|
||||
mocks = import ./lib/mocks.nix { inherit pkgs; };
|
||||
in
|
||||
{
|
||||
imports = [ self.nixosModules.default ];
|
||||
|
||||
@@ -23,117 +26,17 @@ pkgs.testers.runNixOSTest {
|
||||
];
|
||||
|
||||
# Mock qBittorrent on port 6011
|
||||
systemd.services.mock-qbittorrent =
|
||||
let
|
||||
mockQbitScript = pkgs.writeScript "mock-qbittorrent.py" ''
|
||||
import json
|
||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||
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";
|
||||
systemd.services.mock-qbittorrent = mocks.mkMockQbittorrent {
|
||||
initialCategories = {
|
||||
tv = {
|
||||
name = "tv";
|
||||
savePath = "/downloads";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
# Mock SABnzbd on port 6012
|
||||
systemd.services.mock-sabnzbd =
|
||||
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.services.mock-sabnzbd = mocks.mkMockSabnzbd { };
|
||||
|
||||
systemd.tmpfiles.rules = [
|
||||
"d /media/tv 0755 sonarr sonarr -"
|
||||
|
||||
68
tests/naming.nix
Normal file
68
tests/naming.nix
Normal 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
147
tests/network-namespace.nix
Normal 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\")'"
|
||||
)
|
||||
'';
|
||||
}
|
||||
@@ -9,6 +9,9 @@ pkgs.testers.runNixOSTest {
|
||||
|
||||
nodes.machine =
|
||||
{ pkgs, lib, ... }:
|
||||
let
|
||||
mocks = import ./lib/mocks.nix { inherit pkgs; };
|
||||
in
|
||||
{
|
||||
imports = [ self.nixosModules.default ];
|
||||
|
||||
@@ -22,71 +25,7 @@ pkgs.testers.runNixOSTest {
|
||||
gnugrep
|
||||
];
|
||||
|
||||
systemd.services.mock-qbittorrent =
|
||||
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.services.mock-qbittorrent = mocks.mkMockQbittorrent { };
|
||||
|
||||
systemd.tmpfiles.rules = [
|
||||
"d /media/tv 0755 sonarr sonarr -"
|
||||
|
||||
100
tests/permanent-failure.nix
Normal file
100
tests/permanent-failure.nix
Normal 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:]}"
|
||||
'';
|
||||
}
|
||||
Reference in New Issue
Block a user