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": {
|
"nodes": {
|
||||||
|
"flake-utils": {
|
||||||
|
"inputs": {
|
||||||
|
"systems": "systems"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1731533236,
|
||||||
|
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1771848320,
|
"lastModified": 1771848320,
|
||||||
@@ -18,8 +36,24 @@
|
|||||||
},
|
},
|
||||||
"root": {
|
"root": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
|
"flake-utils": "flake-utils",
|
||||||
"nixpkgs": "nixpkgs"
|
"nixpkgs": "nixpkgs"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"systems": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1681028828,
|
||||||
|
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"root": "root",
|
"root": "root",
|
||||||
|
|||||||
42
flake.nix
42
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 = {
|
inputs = {
|
||||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||||
|
flake-utils.url = "github:numtide/flake-utils";
|
||||||
};
|
};
|
||||||
|
|
||||||
outputs =
|
outputs =
|
||||||
{ self, nixpkgs }:
|
{
|
||||||
let
|
self,
|
||||||
supportedSystems = [
|
nixpkgs,
|
||||||
|
flake-utils,
|
||||||
|
}:
|
||||||
|
flake-utils.lib.eachSystem
|
||||||
|
[
|
||||||
"x86_64-linux"
|
"x86_64-linux"
|
||||||
"aarch64-linux"
|
"aarch64-linux"
|
||||||
];
|
]
|
||||||
forAllSystems = nixpkgs.lib.genAttrs supportedSystems;
|
(
|
||||||
in
|
|
||||||
{
|
|
||||||
nixosModules.default = import ./module.nix;
|
|
||||||
nixosModules.arr-init = import ./module.nix;
|
|
||||||
|
|
||||||
checks = forAllSystems (
|
|
||||||
system:
|
system:
|
||||||
let
|
let
|
||||||
pkgs = nixpkgs.legacyPackages.${system};
|
pkgs = nixpkgs.legacyPackages.${system};
|
||||||
in
|
in
|
||||||
import ./tests {
|
{
|
||||||
inherit pkgs;
|
checks = import ./tests {
|
||||||
lib = nixpkgs.lib;
|
inherit pkgs;
|
||||||
inherit self;
|
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; };
|
health-checks = import ./health-checks.nix { inherit pkgs lib self; };
|
||||||
delayed-start = import ./delayed-start.nix { inherit pkgs lib self; };
|
delayed-start = import ./delayed-start.nix { inherit pkgs lib self; };
|
||||||
jellyseerr = import ./jellyseerr.nix { inherit pkgs lib self; };
|
jellyseerr = import ./jellyseerr.nix { inherit pkgs lib self; };
|
||||||
|
naming = import ./naming.nix { inherit pkgs lib self; };
|
||||||
|
network-namespace = import ./network-namespace.nix { inherit pkgs lib self; };
|
||||||
|
permanent-failure = import ./permanent-failure.nix { inherit pkgs lib self; };
|
||||||
|
config-xml = import ./config-xml.nix { inherit pkgs self; };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ pkgs.testers.runNixOSTest {
|
|||||||
|
|
||||||
nodes.machine =
|
nodes.machine =
|
||||||
{ pkgs, lib, ... }:
|
{ pkgs, lib, ... }:
|
||||||
|
let
|
||||||
|
mocks = import ./lib/mocks.nix { inherit pkgs; };
|
||||||
|
in
|
||||||
{
|
{
|
||||||
imports = [ self.nixosModules.default ];
|
imports = [ self.nixosModules.default ];
|
||||||
|
|
||||||
@@ -22,71 +25,7 @@ pkgs.testers.runNixOSTest {
|
|||||||
gnugrep
|
gnugrep
|
||||||
];
|
];
|
||||||
|
|
||||||
systemd.services.mock-qbittorrent =
|
systemd.services.mock-qbittorrent = mocks.mkMockQbittorrent { };
|
||||||
let
|
|
||||||
mockQbitScript = pkgs.writeScript "mock-qbittorrent.py" ''
|
|
||||||
import json
|
|
||||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
|
||||||
from urllib.parse import parse_qs, urlparse
|
|
||||||
|
|
||||||
|
|
||||||
CATEGORIES = {}
|
|
||||||
|
|
||||||
|
|
||||||
class QBitMock(BaseHTTPRequestHandler):
|
|
||||||
def _respond(self, code=200, body=b"Ok.", content_type="text/plain"):
|
|
||||||
self.send_response(code)
|
|
||||||
self.send_header("Content-Type", content_type)
|
|
||||||
self.send_header("Set-Cookie", "SID=mock_session_id; Path=/")
|
|
||||||
self.end_headers()
|
|
||||||
self.wfile.write(body if isinstance(body, bytes) else body.encode())
|
|
||||||
|
|
||||||
def do_GET(self):
|
|
||||||
path = self.path.split("?")[0]
|
|
||||||
if path == "/api/v2/app/webapiVersion":
|
|
||||||
self._respond(body=b"2.9.3")
|
|
||||||
elif path == "/api/v2/app/version":
|
|
||||||
self._respond(body=b"v5.0.0")
|
|
||||||
elif path == "/api/v2/torrents/info":
|
|
||||||
self._respond(body=b"[]", content_type="application/json")
|
|
||||||
elif path == "/api/v2/torrents/categories":
|
|
||||||
body = json.dumps(CATEGORIES).encode()
|
|
||||||
self._respond(body=body, content_type="application/json")
|
|
||||||
elif path == "/api/v2/app/preferences":
|
|
||||||
body = json.dumps({"save_path": "/tmp"}).encode()
|
|
||||||
self._respond(body=body, content_type="application/json")
|
|
||||||
else:
|
|
||||||
self._respond()
|
|
||||||
|
|
||||||
def do_POST(self):
|
|
||||||
content_length = int(self.headers.get("Content-Length", 0))
|
|
||||||
body = self.rfile.read(content_length).decode()
|
|
||||||
path = urlparse(self.path).path
|
|
||||||
query = parse_qs(urlparse(self.path).query)
|
|
||||||
form = parse_qs(body)
|
|
||||||
params = {**query, **form}
|
|
||||||
if path == "/api/v2/torrents/createCategory":
|
|
||||||
name = params.get("category", [""])[0]
|
|
||||||
save_path = params.get("savePath", params.get("save_path", [""]))[0] or "/downloads"
|
|
||||||
if name:
|
|
||||||
CATEGORIES[name] = {"name": name, "savePath": save_path}
|
|
||||||
self._respond()
|
|
||||||
|
|
||||||
def log_message(self, format, *args):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
HTTPServer(("0.0.0.0", 6011), QBitMock).serve_forever()
|
|
||||||
'';
|
|
||||||
in
|
|
||||||
{
|
|
||||||
description = "Mock qBittorrent API";
|
|
||||||
wantedBy = [ "multi-user.target" ];
|
|
||||||
serviceConfig = {
|
|
||||||
ExecStart = "${pkgs.python3}/bin/python3 ${mockQbitScript}";
|
|
||||||
Type = "simple";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
# Create directories including one with spaces
|
# Create directories including one with spaces
|
||||||
systemd.tmpfiles.rules = [
|
systemd.tmpfiles.rules = [
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ pkgs.testers.runNixOSTest {
|
|||||||
|
|
||||||
nodes.machine =
|
nodes.machine =
|
||||||
{ pkgs, lib, ... }:
|
{ pkgs, lib, ... }:
|
||||||
|
let
|
||||||
|
mocks = import ./lib/mocks.nix { inherit pkgs; };
|
||||||
|
in
|
||||||
{
|
{
|
||||||
imports = [ self.nixosModules.default ];
|
imports = [ self.nixosModules.default ];
|
||||||
|
|
||||||
@@ -22,77 +25,18 @@ pkgs.testers.runNixOSTest {
|
|||||||
gnugrep
|
gnugrep
|
||||||
];
|
];
|
||||||
|
|
||||||
systemd.services.mock-qbittorrent =
|
systemd.services.mock-qbittorrent = mocks.mkMockQbittorrent {
|
||||||
let
|
initialCategories = {
|
||||||
mockQbitScript = pkgs.writeScript "mock-qbittorrent.py" ''
|
tv = {
|
||||||
import json
|
name = "tv";
|
||||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
savePath = "/downloads";
|
||||||
from urllib.parse import parse_qs, urlparse
|
};
|
||||||
|
movies = {
|
||||||
|
name = "movies";
|
||||||
CATEGORIES = {
|
savePath = "/downloads";
|
||||||
"tv": {"name": "tv", "savePath": "/downloads"},
|
|
||||||
"movies": {"name": "movies", "savePath": "/downloads"},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class QBitMock(BaseHTTPRequestHandler):
|
|
||||||
def _respond(self, code=200, body=b"Ok.", content_type="text/plain"):
|
|
||||||
self.send_response(code)
|
|
||||||
self.send_header("Content-Type", content_type)
|
|
||||||
self.send_header("Set-Cookie", "SID=mock_session_id; Path=/")
|
|
||||||
self.end_headers()
|
|
||||||
self.wfile.write(body if isinstance(body, bytes) else body.encode())
|
|
||||||
|
|
||||||
def do_GET(self):
|
|
||||||
path = self.path.split("?")[0]
|
|
||||||
if path == "/api/v2/app/webapiVersion":
|
|
||||||
self._respond(body=b"2.9.3")
|
|
||||||
elif path == "/api/v2/app/version":
|
|
||||||
self._respond(body=b"v5.0.0")
|
|
||||||
elif path == "/api/v2/torrents/info":
|
|
||||||
self._respond(body=b"[]", content_type="application/json")
|
|
||||||
elif path == "/api/v2/torrents/categories":
|
|
||||||
body = json.dumps(CATEGORIES).encode()
|
|
||||||
self._respond(body=body, content_type="application/json")
|
|
||||||
elif path == "/api/v2/app/preferences":
|
|
||||||
body = json.dumps({"save_path": "/tmp"}).encode()
|
|
||||||
self._respond(body=body, content_type="application/json")
|
|
||||||
else:
|
|
||||||
self._respond()
|
|
||||||
|
|
||||||
def do_POST(self):
|
|
||||||
content_length = int(self.headers.get("Content-Length", 0))
|
|
||||||
body = self.rfile.read(content_length).decode()
|
|
||||||
path = urlparse(self.path).path
|
|
||||||
query = parse_qs(urlparse(self.path).query)
|
|
||||||
form = parse_qs(body)
|
|
||||||
params = {**query, **form}
|
|
||||||
if path == "/api/v2/torrents/createCategory":
|
|
||||||
name = params.get("category", [""])[0]
|
|
||||||
save_path = params.get("savePath", params.get("save_path", [""]))[0] or "/downloads"
|
|
||||||
if name:
|
|
||||||
CATEGORIES[name] = {"name": name, "savePath": save_path}
|
|
||||||
if path in ["/api/v2/torrents/editCategory", "/api/v2/torrents/removeCategory"]:
|
|
||||||
self._respond()
|
|
||||||
return
|
|
||||||
self._respond()
|
|
||||||
|
|
||||||
def log_message(self, format, *args):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
HTTPServer(("0.0.0.0", 6011), QBitMock).serve_forever()
|
|
||||||
'';
|
|
||||||
in
|
|
||||||
{
|
|
||||||
description = "Mock qBittorrent API";
|
|
||||||
wantedBy = [ "multi-user.target" ];
|
|
||||||
serviceConfig = {
|
|
||||||
ExecStart = "${pkgs.python3}/bin/python3 ${mockQbitScript}";
|
|
||||||
Type = "simple";
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
};
|
||||||
|
|
||||||
systemd.tmpfiles.rules = [
|
systemd.tmpfiles.rules = [
|
||||||
"d /media/tv 0755 sonarr sonarr -"
|
"d /media/tv 0755 sonarr sonarr -"
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ pkgs.testers.runNixOSTest {
|
|||||||
|
|
||||||
nodes.machine =
|
nodes.machine =
|
||||||
{ pkgs, lib, ... }:
|
{ pkgs, lib, ... }:
|
||||||
|
let
|
||||||
|
mocks = import ./lib/mocks.nix { inherit pkgs; };
|
||||||
|
in
|
||||||
{
|
{
|
||||||
imports = [ self.nixosModules.default ];
|
imports = [ self.nixosModules.default ];
|
||||||
|
|
||||||
@@ -22,81 +25,22 @@ pkgs.testers.runNixOSTest {
|
|||||||
gnugrep
|
gnugrep
|
||||||
];
|
];
|
||||||
|
|
||||||
systemd.services.mock-qbittorrent =
|
systemd.services.mock-qbittorrent = mocks.mkMockQbittorrent {
|
||||||
let
|
initialCategories = {
|
||||||
mockQbitScript = pkgs.writeScript "mock-qbittorrent.py" ''
|
tv = {
|
||||||
import json
|
name = "tv";
|
||||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
savePath = "/downloads";
|
||||||
from urllib.parse import parse_qs, urlparse
|
};
|
||||||
|
movies = {
|
||||||
|
name = "movies";
|
||||||
CATEGORIES = {
|
savePath = "/downloads";
|
||||||
"tv": {"name": "tv", "savePath": "/downloads"},
|
|
||||||
"movies": {"name": "movies", "savePath": "/downloads"},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class QBitMock(BaseHTTPRequestHandler):
|
|
||||||
def _respond(self, code=200, body=b"Ok.", content_type="text/plain"):
|
|
||||||
self.send_response(code)
|
|
||||||
self.send_header("Content-Type", content_type)
|
|
||||||
self.send_header("Set-Cookie", "SID=mock_session_id; Path=/")
|
|
||||||
self.end_headers()
|
|
||||||
self.wfile.write(body if isinstance(body, bytes) else body.encode())
|
|
||||||
|
|
||||||
def do_GET(self):
|
|
||||||
path = self.path.split("?")[0]
|
|
||||||
if path == "/api/v2/app/webapiVersion":
|
|
||||||
self._respond(body=b"2.9.3")
|
|
||||||
elif path == "/api/v2/app/version":
|
|
||||||
self._respond(body=b"v5.0.0")
|
|
||||||
elif path == "/api/v2/torrents/info":
|
|
||||||
self._respond(body=b"[]", content_type="application/json")
|
|
||||||
elif path == "/api/v2/torrents/categories":
|
|
||||||
body = json.dumps(CATEGORIES).encode()
|
|
||||||
self._respond(body=body, content_type="application/json")
|
|
||||||
elif path == "/api/v2/app/preferences":
|
|
||||||
body = json.dumps({"save_path": "/tmp"}).encode()
|
|
||||||
self._respond(body=body, content_type="application/json")
|
|
||||||
else:
|
|
||||||
self._respond()
|
|
||||||
|
|
||||||
def do_POST(self):
|
|
||||||
content_length = int(self.headers.get("Content-Length", 0))
|
|
||||||
body = self.rfile.read(content_length).decode()
|
|
||||||
path = urlparse(self.path).path
|
|
||||||
query = parse_qs(urlparse(self.path).query)
|
|
||||||
form = parse_qs(body)
|
|
||||||
params = {**query, **form}
|
|
||||||
if path == "/api/v2/torrents/createCategory":
|
|
||||||
name = params.get("category", [""])[0]
|
|
||||||
save_path = params.get("savePath", params.get("save_path", [""]))[0] or "/downloads"
|
|
||||||
if name:
|
|
||||||
CATEGORIES[name] = {"name": name, "savePath": save_path}
|
|
||||||
if path in ["/api/v2/torrents/editCategory", "/api/v2/torrents/removeCategory"]:
|
|
||||||
self._respond()
|
|
||||||
return
|
|
||||||
self._respond()
|
|
||||||
|
|
||||||
def log_message(self, format, *args):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
HTTPServer(("0.0.0.0", 6011), QBitMock).serve_forever()
|
|
||||||
'';
|
|
||||||
in
|
|
||||||
{
|
|
||||||
description = "Mock qBittorrent API";
|
|
||||||
wantedBy = [ "multi-user.target" ];
|
|
||||||
before = [
|
|
||||||
"sonarr-init.service"
|
|
||||||
"radarr-init.service"
|
|
||||||
];
|
|
||||||
serviceConfig = {
|
|
||||||
ExecStart = "${pkgs.python3}/bin/python3 ${mockQbitScript}";
|
|
||||||
Type = "simple";
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
before = [
|
||||||
|
"sonarr-init.service"
|
||||||
|
"radarr-init.service"
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
systemd.tmpfiles.rules = [
|
systemd.tmpfiles.rules = [
|
||||||
"d /media/tv 0755 sonarr sonarr -"
|
"d /media/tv 0755 sonarr sonarr -"
|
||||||
@@ -255,10 +199,6 @@ pkgs.testers.runNixOSTest {
|
|||||||
machine.wait_for_unit("sonarr-init.service")
|
machine.wait_for_unit("sonarr-init.service")
|
||||||
machine.wait_for_unit("radarr-init.service")
|
machine.wait_for_unit("radarr-init.service")
|
||||||
|
|
||||||
# Wait for init services to complete
|
|
||||||
machine.wait_for_unit("sonarr-init.service")
|
|
||||||
machine.wait_for_unit("radarr-init.service")
|
|
||||||
|
|
||||||
# Verify Sonarr download clients
|
# Verify Sonarr download clients
|
||||||
machine.succeed(
|
machine.succeed(
|
||||||
"API_KEY=$(grep -oP '(?<=<ApiKey>)[^<]+' /var/lib/sonarr/.config/NzbDrone/config.xml) && "
|
"API_KEY=$(grep -oP '(?<=<ApiKey>)[^<]+' /var/lib/sonarr/.config/NzbDrone/config.xml) && "
|
||||||
|
|||||||
128
tests/lib/mocks.nix
Normal file
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 =
|
nodes.machine =
|
||||||
{ pkgs, lib, ... }:
|
{ pkgs, lib, ... }:
|
||||||
|
let
|
||||||
|
mocks = import ./lib/mocks.nix { inherit pkgs; };
|
||||||
|
in
|
||||||
{
|
{
|
||||||
imports = [ self.nixosModules.default ];
|
imports = [ self.nixosModules.default ];
|
||||||
|
|
||||||
@@ -23,117 +26,17 @@ pkgs.testers.runNixOSTest {
|
|||||||
];
|
];
|
||||||
|
|
||||||
# Mock qBittorrent on port 6011
|
# Mock qBittorrent on port 6011
|
||||||
systemd.services.mock-qbittorrent =
|
systemd.services.mock-qbittorrent = mocks.mkMockQbittorrent {
|
||||||
let
|
initialCategories = {
|
||||||
mockQbitScript = pkgs.writeScript "mock-qbittorrent.py" ''
|
tv = {
|
||||||
import json
|
name = "tv";
|
||||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
savePath = "/downloads";
|
||||||
from urllib.parse import parse_qs, urlparse
|
|
||||||
|
|
||||||
|
|
||||||
CATEGORIES = {
|
|
||||||
"tv": {"name": "tv", "savePath": "/downloads"},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class QBitMock(BaseHTTPRequestHandler):
|
|
||||||
def _respond(self, code=200, body=b"Ok.", content_type="text/plain"):
|
|
||||||
self.send_response(code)
|
|
||||||
self.send_header("Content-Type", content_type)
|
|
||||||
self.send_header("Set-Cookie", "SID=mock_session_id; Path=/")
|
|
||||||
self.end_headers()
|
|
||||||
self.wfile.write(body if isinstance(body, bytes) else body.encode())
|
|
||||||
|
|
||||||
def do_GET(self):
|
|
||||||
path = self.path.split("?")[0]
|
|
||||||
if path == "/api/v2/app/webapiVersion":
|
|
||||||
self._respond(body=b"2.9.3")
|
|
||||||
elif path == "/api/v2/app/version":
|
|
||||||
self._respond(body=b"v5.0.0")
|
|
||||||
elif path == "/api/v2/torrents/info":
|
|
||||||
self._respond(body=b"[]", content_type="application/json")
|
|
||||||
elif path == "/api/v2/torrents/categories":
|
|
||||||
body = json.dumps(CATEGORIES).encode()
|
|
||||||
self._respond(body=body, content_type="application/json")
|
|
||||||
elif path == "/api/v2/app/preferences":
|
|
||||||
body = json.dumps({"save_path": "/tmp"}).encode()
|
|
||||||
self._respond(body=body, content_type="application/json")
|
|
||||||
else:
|
|
||||||
self._respond()
|
|
||||||
|
|
||||||
def do_POST(self):
|
|
||||||
content_length = int(self.headers.get("Content-Length", 0))
|
|
||||||
body = self.rfile.read(content_length).decode()
|
|
||||||
path = urlparse(self.path).path
|
|
||||||
query = parse_qs(urlparse(self.path).query)
|
|
||||||
form = parse_qs(body)
|
|
||||||
params = {**query, **form}
|
|
||||||
if path == "/api/v2/torrents/createCategory":
|
|
||||||
name = params.get("category", [""])[0]
|
|
||||||
save_path = params.get("savePath", params.get("save_path", [""]))[0] or "/downloads"
|
|
||||||
if name:
|
|
||||||
CATEGORIES[name] = {"name": name, "savePath": save_path}
|
|
||||||
self._respond()
|
|
||||||
|
|
||||||
def log_message(self, format, *args):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
HTTPServer(("0.0.0.0", 6011), QBitMock).serve_forever()
|
|
||||||
'';
|
|
||||||
in
|
|
||||||
{
|
|
||||||
description = "Mock qBittorrent API";
|
|
||||||
wantedBy = [ "multi-user.target" ];
|
|
||||||
serviceConfig = {
|
|
||||||
ExecStart = "${pkgs.python3}/bin/python3 ${mockQbitScript}";
|
|
||||||
Type = "simple";
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
};
|
||||||
|
|
||||||
# Mock SABnzbd on port 6012
|
# Mock SABnzbd on port 6012
|
||||||
systemd.services.mock-sabnzbd =
|
systemd.services.mock-sabnzbd = mocks.mkMockSabnzbd { };
|
||||||
let
|
|
||||||
mockSabScript = pkgs.writeScript "mock-sabnzbd.py" ''
|
|
||||||
import json
|
|
||||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
|
||||||
from urllib.parse import parse_qs, urlparse
|
|
||||||
|
|
||||||
|
|
||||||
class SabMock(BaseHTTPRequestHandler):
|
|
||||||
def _respond(self, code=200, body=b'{"config": {}}', content_type="application/json"):
|
|
||||||
self.send_response(code)
|
|
||||||
self.send_header("Content-Type", content_type)
|
|
||||||
self.end_headers()
|
|
||||||
self.wfile.write(body if isinstance(body, bytes) else body.encode())
|
|
||||||
|
|
||||||
def do_GET(self):
|
|
||||||
path = self.path.split("?")[0]
|
|
||||||
if "mode=config" in self.path or "mode=version" in self.path:
|
|
||||||
self._respond(body=b'{"config": {"misc": {"api_key": "test"}}, "version": "4.0.0"}')
|
|
||||||
elif "mode=get_config" in self.path:
|
|
||||||
self._respond(body=b'{"config": {"misc": {"complete_dir": "/downloads/usenet", "pre_check": false}, "categories": [{"name": "tv", "order": 0, "pp": "", "script": "Default", "dir": "tv"}], "sorters": []}}')
|
|
||||||
else:
|
|
||||||
self._respond()
|
|
||||||
|
|
||||||
def do_POST(self):
|
|
||||||
self._respond()
|
|
||||||
|
|
||||||
def log_message(self, format, *args):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
HTTPServer(("0.0.0.0", 6012), SabMock).serve_forever()
|
|
||||||
'';
|
|
||||||
in
|
|
||||||
{
|
|
||||||
description = "Mock SABnzbd API";
|
|
||||||
wantedBy = [ "multi-user.target" ];
|
|
||||||
serviceConfig = {
|
|
||||||
ExecStart = "${pkgs.python3}/bin/python3 ${mockSabScript}";
|
|
||||||
Type = "simple";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
systemd.tmpfiles.rules = [
|
systemd.tmpfiles.rules = [
|
||||||
"d /media/tv 0755 sonarr sonarr -"
|
"d /media/tv 0755 sonarr sonarr -"
|
||||||
|
|||||||
68
tests/naming.nix
Normal file
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 =
|
nodes.machine =
|
||||||
{ pkgs, lib, ... }:
|
{ pkgs, lib, ... }:
|
||||||
|
let
|
||||||
|
mocks = import ./lib/mocks.nix { inherit pkgs; };
|
||||||
|
in
|
||||||
{
|
{
|
||||||
imports = [ self.nixosModules.default ];
|
imports = [ self.nixosModules.default ];
|
||||||
|
|
||||||
@@ -22,71 +25,7 @@ pkgs.testers.runNixOSTest {
|
|||||||
gnugrep
|
gnugrep
|
||||||
];
|
];
|
||||||
|
|
||||||
systemd.services.mock-qbittorrent =
|
systemd.services.mock-qbittorrent = mocks.mkMockQbittorrent { };
|
||||||
let
|
|
||||||
mockQbitScript = pkgs.writeScript "mock-qbittorrent.py" ''
|
|
||||||
import json
|
|
||||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
|
||||||
from urllib.parse import parse_qs, urlparse
|
|
||||||
|
|
||||||
|
|
||||||
CATEGORIES = {}
|
|
||||||
|
|
||||||
|
|
||||||
class QBitMock(BaseHTTPRequestHandler):
|
|
||||||
def _respond(self, code=200, body=b"Ok.", content_type="text/plain"):
|
|
||||||
self.send_response(code)
|
|
||||||
self.send_header("Content-Type", content_type)
|
|
||||||
self.send_header("Set-Cookie", "SID=mock_session_id; Path=/")
|
|
||||||
self.end_headers()
|
|
||||||
self.wfile.write(body if isinstance(body, bytes) else body.encode())
|
|
||||||
|
|
||||||
def do_GET(self):
|
|
||||||
path = self.path.split("?")[0]
|
|
||||||
if path == "/api/v2/app/webapiVersion":
|
|
||||||
self._respond(body=b"2.9.3")
|
|
||||||
elif path == "/api/v2/app/version":
|
|
||||||
self._respond(body=b"v5.0.0")
|
|
||||||
elif path == "/api/v2/torrents/info":
|
|
||||||
self._respond(body=b"[]", content_type="application/json")
|
|
||||||
elif path == "/api/v2/torrents/categories":
|
|
||||||
body = json.dumps(CATEGORIES).encode()
|
|
||||||
self._respond(body=body, content_type="application/json")
|
|
||||||
elif path == "/api/v2/app/preferences":
|
|
||||||
body = json.dumps({"save_path": "/tmp"}).encode()
|
|
||||||
self._respond(body=body, content_type="application/json")
|
|
||||||
else:
|
|
||||||
self._respond()
|
|
||||||
|
|
||||||
def do_POST(self):
|
|
||||||
content_length = int(self.headers.get("Content-Length", 0))
|
|
||||||
body = self.rfile.read(content_length).decode()
|
|
||||||
path = urlparse(self.path).path
|
|
||||||
query = parse_qs(urlparse(self.path).query)
|
|
||||||
form = parse_qs(body)
|
|
||||||
params = {**query, **form}
|
|
||||||
if path == "/api/v2/torrents/createCategory":
|
|
||||||
name = params.get("category", [""])[0]
|
|
||||||
save_path = params.get("savePath", params.get("save_path", [""]))[0] or "/downloads"
|
|
||||||
if name:
|
|
||||||
CATEGORIES[name] = {"name": name, "savePath": save_path}
|
|
||||||
self._respond()
|
|
||||||
|
|
||||||
def log_message(self, format, *args):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
HTTPServer(("0.0.0.0", 6011), QBitMock).serve_forever()
|
|
||||||
'';
|
|
||||||
in
|
|
||||||
{
|
|
||||||
description = "Mock qBittorrent API";
|
|
||||||
wantedBy = [ "multi-user.target" ];
|
|
||||||
serviceConfig = {
|
|
||||||
ExecStart = "${pkgs.python3}/bin/python3 ${mockQbitScript}";
|
|
||||||
Type = "simple";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
systemd.tmpfiles.rules = [
|
systemd.tmpfiles.rules = [
|
||||||
"d /media/tv 0755 sonarr sonarr -"
|
"d /media/tv 0755 sonarr sonarr -"
|
||||||
|
|||||||
100
tests/permanent-failure.nix
Normal file
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