Compare commits
6 Commits
main
...
9635aecb81
| Author | SHA1 | Date | |
|---|---|---|---|
| 9635aecb81 | |||
| a37b6f6112 | |||
| f766e5f71e | |||
| f86a5f1b39 | |||
| b464a8cea2 | |||
|
b97ed1e90c
|
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
182
modules/bazarr.nix
Normal file
182
modules/bazarr.nix
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
{
|
||||||
|
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
|
||||||
|
];
|
||||||
|
}
|
||||||
195
modules/jellyseerr.nix
Normal file
195
modules/jellyseerr.nix
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
{
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
385
modules/servarr.nix
Normal file
385
modules/servarr.nix
Normal file
@@ -0,0 +1,385 @@
|
|||||||
|
{
|
||||||
|
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 = true;
|
||||||
|
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}";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
# 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 != { }) {
|
||||||
|
systemd.services = 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;
|
||||||
|
};
|
||||||
|
}
|
||||||
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)
|
||||||
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()
|
||||||
376
scripts/servarr_init.py
Normal file
376
scripts/servarr_init.py
Normal file
@@ -0,0 +1,376 @@
|
|||||||
|
#!/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."""
|
||||||
|
current = _fields_to_dict(current_fields)
|
||||||
|
return any(desired.get(k) != current.get(k) for k in desired)
|
||||||
|
|
||||||
|
|
||||||
|
# -- 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()
|
||||||
@@ -13,4 +13,7 @@
|
|||||||
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; };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ pkgs.testers.runNixOSTest {
|
|||||||
];
|
];
|
||||||
|
|
||||||
services.arrInit.sonarr = {
|
services.arrInit.sonarr = {
|
||||||
|
healthChecks = false;
|
||||||
enable = true;
|
enable = true;
|
||||||
serviceName = "mock-sonarr";
|
serviceName = "mock-sonarr";
|
||||||
dataDir = "/var/lib/mock-sonarr";
|
dataDir = "/var/lib/mock-sonarr";
|
||||||
|
|||||||
@@ -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 = [
|
||||||
@@ -106,6 +45,7 @@ pkgs.testers.runNixOSTest {
|
|||||||
# Test 3: Path with spaces
|
# Test 3: Path with spaces
|
||||||
services.arrInit.sonarr = {
|
services.arrInit.sonarr = {
|
||||||
enable = true;
|
enable = true;
|
||||||
|
healthChecks = false;
|
||||||
serviceName = "sonarr";
|
serviceName = "sonarr";
|
||||||
dataDir = "/var/lib/sonarr/.config/NzbDrone";
|
dataDir = "/var/lib/sonarr/.config/NzbDrone";
|
||||||
port = 8989;
|
port = 8989;
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ pkgs.testers.runNixOSTest {
|
|||||||
# The dataDir points to a non-existent config.xml
|
# The dataDir points to a non-existent config.xml
|
||||||
services.arrInit.sonarr = {
|
services.arrInit.sonarr = {
|
||||||
enable = true;
|
enable = true;
|
||||||
|
healthChecks = false;
|
||||||
serviceName = "sonarr";
|
serviceName = "sonarr";
|
||||||
dataDir = "/var/lib/nonexistent";
|
dataDir = "/var/lib/nonexistent";
|
||||||
port = 8989;
|
port = 8989;
|
||||||
|
|||||||
@@ -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,12 @@ pkgs.testers.runNixOSTest {
|
|||||||
gnugrep
|
gnugrep
|
||||||
];
|
];
|
||||||
|
|
||||||
systemd.services.mock-qbittorrent =
|
systemd.services.mock-qbittorrent = mocks.mkMockQbittorrent {
|
||||||
let
|
initialCategories = {
|
||||||
mockQbitScript = pkgs.writeScript "mock-qbittorrent.py" ''
|
tv = { name = "tv"; savePath = "/downloads"; };
|
||||||
import json
|
movies = { name = "movies"; savePath = "/downloads"; };
|
||||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
|
||||||
from urllib.parse import parse_qs, urlparse
|
|
||||||
|
|
||||||
|
|
||||||
CATEGORIES = {
|
|
||||||
"tv": {"name": "tv", "savePath": "/downloads"},
|
|
||||||
"movies": {"name": "movies", "savePath": "/downloads"},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class QBitMock(BaseHTTPRequestHandler):
|
|
||||||
def _respond(self, code=200, body=b"Ok.", content_type="text/plain"):
|
|
||||||
self.send_response(code)
|
|
||||||
self.send_header("Content-Type", content_type)
|
|
||||||
self.send_header("Set-Cookie", "SID=mock_session_id; Path=/")
|
|
||||||
self.end_headers()
|
|
||||||
self.wfile.write(body if isinstance(body, bytes) else body.encode())
|
|
||||||
|
|
||||||
def do_GET(self):
|
|
||||||
path = self.path.split("?")[0]
|
|
||||||
if path == "/api/v2/app/webapiVersion":
|
|
||||||
self._respond(body=b"2.9.3")
|
|
||||||
elif path == "/api/v2/app/version":
|
|
||||||
self._respond(body=b"v5.0.0")
|
|
||||||
elif path == "/api/v2/torrents/info":
|
|
||||||
self._respond(body=b"[]", content_type="application/json")
|
|
||||||
elif path == "/api/v2/torrents/categories":
|
|
||||||
body = json.dumps(CATEGORIES).encode()
|
|
||||||
self._respond(body=body, content_type="application/json")
|
|
||||||
elif path == "/api/v2/app/preferences":
|
|
||||||
body = json.dumps({"save_path": "/tmp"}).encode()
|
|
||||||
self._respond(body=body, content_type="application/json")
|
|
||||||
else:
|
|
||||||
self._respond()
|
|
||||||
|
|
||||||
def do_POST(self):
|
|
||||||
content_length = int(self.headers.get("Content-Length", 0))
|
|
||||||
body = self.rfile.read(content_length).decode()
|
|
||||||
path = urlparse(self.path).path
|
|
||||||
query = parse_qs(urlparse(self.path).query)
|
|
||||||
form = parse_qs(body)
|
|
||||||
params = {**query, **form}
|
|
||||||
if path == "/api/v2/torrents/createCategory":
|
|
||||||
name = params.get("category", [""])[0]
|
|
||||||
save_path = params.get("savePath", params.get("save_path", [""]))[0] or "/downloads"
|
|
||||||
if name:
|
|
||||||
CATEGORIES[name] = {"name": name, "savePath": save_path}
|
|
||||||
if path in ["/api/v2/torrents/editCategory", "/api/v2/torrents/removeCategory"]:
|
|
||||||
self._respond()
|
|
||||||
return
|
|
||||||
self._respond()
|
|
||||||
|
|
||||||
def log_message(self, format, *args):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
HTTPServer(("0.0.0.0", 6011), QBitMock).serve_forever()
|
|
||||||
'';
|
|
||||||
in
|
|
||||||
{
|
|
||||||
description = "Mock qBittorrent API";
|
|
||||||
wantedBy = [ "multi-user.target" ];
|
|
||||||
serviceConfig = {
|
|
||||||
ExecStart = "${pkgs.python3}/bin/python3 ${mockQbitScript}";
|
|
||||||
Type = "simple";
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
};
|
||||||
|
|
||||||
systemd.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,13 @@ pkgs.testers.runNixOSTest {
|
|||||||
gnugrep
|
gnugrep
|
||||||
];
|
];
|
||||||
|
|
||||||
systemd.services.mock-qbittorrent =
|
systemd.services.mock-qbittorrent = mocks.mkMockQbittorrent {
|
||||||
let
|
initialCategories = {
|
||||||
mockQbitScript = pkgs.writeScript "mock-qbittorrent.py" ''
|
tv = { name = "tv"; savePath = "/downloads"; };
|
||||||
import json
|
movies = { name = "movies"; savePath = "/downloads"; };
|
||||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
|
||||||
from urllib.parse import parse_qs, urlparse
|
|
||||||
|
|
||||||
|
|
||||||
CATEGORIES = {
|
|
||||||
"tv": {"name": "tv", "savePath": "/downloads"},
|
|
||||||
"movies": {"name": "movies", "savePath": "/downloads"},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class QBitMock(BaseHTTPRequestHandler):
|
|
||||||
def _respond(self, code=200, body=b"Ok.", content_type="text/plain"):
|
|
||||||
self.send_response(code)
|
|
||||||
self.send_header("Content-Type", content_type)
|
|
||||||
self.send_header("Set-Cookie", "SID=mock_session_id; Path=/")
|
|
||||||
self.end_headers()
|
|
||||||
self.wfile.write(body if isinstance(body, bytes) else body.encode())
|
|
||||||
|
|
||||||
def do_GET(self):
|
|
||||||
path = self.path.split("?")[0]
|
|
||||||
if path == "/api/v2/app/webapiVersion":
|
|
||||||
self._respond(body=b"2.9.3")
|
|
||||||
elif path == "/api/v2/app/version":
|
|
||||||
self._respond(body=b"v5.0.0")
|
|
||||||
elif path == "/api/v2/torrents/info":
|
|
||||||
self._respond(body=b"[]", content_type="application/json")
|
|
||||||
elif path == "/api/v2/torrents/categories":
|
|
||||||
body = json.dumps(CATEGORIES).encode()
|
|
||||||
self._respond(body=body, content_type="application/json")
|
|
||||||
elif path == "/api/v2/app/preferences":
|
|
||||||
body = json.dumps({"save_path": "/tmp"}).encode()
|
|
||||||
self._respond(body=body, content_type="application/json")
|
|
||||||
else:
|
|
||||||
self._respond()
|
|
||||||
|
|
||||||
def do_POST(self):
|
|
||||||
content_length = int(self.headers.get("Content-Length", 0))
|
|
||||||
body = self.rfile.read(content_length).decode()
|
|
||||||
path = urlparse(self.path).path
|
|
||||||
query = parse_qs(urlparse(self.path).query)
|
|
||||||
form = parse_qs(body)
|
|
||||||
params = {**query, **form}
|
|
||||||
if path == "/api/v2/torrents/createCategory":
|
|
||||||
name = params.get("category", [""])[0]
|
|
||||||
save_path = params.get("savePath", params.get("save_path", [""]))[0] or "/downloads"
|
|
||||||
if name:
|
|
||||||
CATEGORIES[name] = {"name": name, "savePath": save_path}
|
|
||||||
if path in ["/api/v2/torrents/editCategory", "/api/v2/torrents/removeCategory"]:
|
|
||||||
self._respond()
|
|
||||||
return
|
|
||||||
self._respond()
|
|
||||||
|
|
||||||
def log_message(self, format, *args):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
HTTPServer(("0.0.0.0", 6011), QBitMock).serve_forever()
|
|
||||||
'';
|
|
||||||
in
|
|
||||||
{
|
|
||||||
description = "Mock qBittorrent API";
|
|
||||||
wantedBy = [ "multi-user.target" ];
|
|
||||||
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 -"
|
||||||
@@ -126,6 +61,7 @@ pkgs.testers.runNixOSTest {
|
|||||||
|
|
||||||
services.arrInit.sonarr = {
|
services.arrInit.sonarr = {
|
||||||
enable = true;
|
enable = true;
|
||||||
|
healthChecks = false;
|
||||||
serviceName = "sonarr";
|
serviceName = "sonarr";
|
||||||
dataDir = "/var/lib/sonarr/.config/NzbDrone";
|
dataDir = "/var/lib/sonarr/.config/NzbDrone";
|
||||||
port = 8989;
|
port = 8989;
|
||||||
@@ -148,6 +84,7 @@ pkgs.testers.runNixOSTest {
|
|||||||
|
|
||||||
services.arrInit.radarr = {
|
services.arrInit.radarr = {
|
||||||
enable = true;
|
enable = true;
|
||||||
|
healthChecks = false;
|
||||||
serviceName = "radarr";
|
serviceName = "radarr";
|
||||||
dataDir = "/var/lib/radarr/.config/Radarr";
|
dataDir = "/var/lib/radarr/.config/Radarr";
|
||||||
port = 7878;
|
port = 7878;
|
||||||
@@ -170,6 +107,7 @@ pkgs.testers.runNixOSTest {
|
|||||||
|
|
||||||
services.arrInit.prowlarr = {
|
services.arrInit.prowlarr = {
|
||||||
enable = true;
|
enable = true;
|
||||||
|
healthChecks = false;
|
||||||
serviceName = "prowlarr";
|
serviceName = "prowlarr";
|
||||||
dataDir = "/var/lib/prowlarr";
|
dataDir = "/var/lib/prowlarr";
|
||||||
port = 9696;
|
port = 9696;
|
||||||
@@ -255,10 +193,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) && "
|
||||||
|
|||||||
126
tests/lib/mocks.nix
Normal file
126
tests/lib/mocks.nix
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
# 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,14 @@ 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 = { name = "tv"; savePath = "/downloads"; };
|
||||||
import json
|
|
||||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
|
||||||
from urllib.parse import parse_qs, urlparse
|
|
||||||
|
|
||||||
|
|
||||||
CATEGORIES = {
|
|
||||||
"tv": {"name": "tv", "savePath": "/downloads"},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class QBitMock(BaseHTTPRequestHandler):
|
|
||||||
def _respond(self, code=200, body=b"Ok.", content_type="text/plain"):
|
|
||||||
self.send_response(code)
|
|
||||||
self.send_header("Content-Type", content_type)
|
|
||||||
self.send_header("Set-Cookie", "SID=mock_session_id; Path=/")
|
|
||||||
self.end_headers()
|
|
||||||
self.wfile.write(body if isinstance(body, bytes) else body.encode())
|
|
||||||
|
|
||||||
def do_GET(self):
|
|
||||||
path = self.path.split("?")[0]
|
|
||||||
if path == "/api/v2/app/webapiVersion":
|
|
||||||
self._respond(body=b"2.9.3")
|
|
||||||
elif path == "/api/v2/app/version":
|
|
||||||
self._respond(body=b"v5.0.0")
|
|
||||||
elif path == "/api/v2/torrents/info":
|
|
||||||
self._respond(body=b"[]", content_type="application/json")
|
|
||||||
elif path == "/api/v2/torrents/categories":
|
|
||||||
body = json.dumps(CATEGORIES).encode()
|
|
||||||
self._respond(body=body, content_type="application/json")
|
|
||||||
elif path == "/api/v2/app/preferences":
|
|
||||||
body = json.dumps({"save_path": "/tmp"}).encode()
|
|
||||||
self._respond(body=body, content_type="application/json")
|
|
||||||
else:
|
|
||||||
self._respond()
|
|
||||||
|
|
||||||
def do_POST(self):
|
|
||||||
content_length = int(self.headers.get("Content-Length", 0))
|
|
||||||
body = self.rfile.read(content_length).decode()
|
|
||||||
path = urlparse(self.path).path
|
|
||||||
query = parse_qs(urlparse(self.path).query)
|
|
||||||
form = parse_qs(body)
|
|
||||||
params = {**query, **form}
|
|
||||||
if path == "/api/v2/torrents/createCategory":
|
|
||||||
name = params.get("category", [""])[0]
|
|
||||||
save_path = params.get("savePath", params.get("save_path", [""]))[0] or "/downloads"
|
|
||||||
if name:
|
|
||||||
CATEGORIES[name] = {"name": name, "savePath": save_path}
|
|
||||||
self._respond()
|
|
||||||
|
|
||||||
def log_message(self, format, *args):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
HTTPServer(("0.0.0.0", 6011), QBitMock).serve_forever()
|
|
||||||
'';
|
|
||||||
in
|
|
||||||
{
|
|
||||||
description = "Mock qBittorrent API";
|
|
||||||
wantedBy = [ "multi-user.target" ];
|
|
||||||
serviceConfig = {
|
|
||||||
ExecStart = "${pkgs.python3}/bin/python3 ${mockQbitScript}";
|
|
||||||
Type = "simple";
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
};
|
||||||
|
|
||||||
# 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 -"
|
||||||
@@ -148,6 +48,7 @@ pkgs.testers.runNixOSTest {
|
|||||||
# Sonarr with TWO download clients: qBittorrent + SABnzbd
|
# Sonarr with TWO download clients: qBittorrent + SABnzbd
|
||||||
services.arrInit.sonarr = {
|
services.arrInit.sonarr = {
|
||||||
enable = true;
|
enable = true;
|
||||||
|
healthChecks = false;
|
||||||
serviceName = "sonarr";
|
serviceName = "sonarr";
|
||||||
dataDir = "/var/lib/sonarr/.config/NzbDrone";
|
dataDir = "/var/lib/sonarr/.config/NzbDrone";
|
||||||
port = 8989;
|
port = 8989;
|
||||||
|
|||||||
59
tests/naming.nix
Normal file
59
tests/naming.nix
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
{ 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;
|
||||||
|
healthChecks = false;
|
||||||
|
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:]}"
|
||||||
|
'';
|
||||||
|
}
|
||||||
135
tests/network-namespace.nix
Normal file
135
tests/network-namespace.nix
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
{ 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;
|
||||||
|
healthChecks = false;
|
||||||
|
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 -"
|
||||||
@@ -111,6 +50,7 @@ pkgs.testers.runNixOSTest {
|
|||||||
# Test 1: Only rootFolders (no downloadClients)
|
# Test 1: Only rootFolders (no downloadClients)
|
||||||
services.arrInit.sonarr = {
|
services.arrInit.sonarr = {
|
||||||
enable = true;
|
enable = true;
|
||||||
|
healthChecks = false;
|
||||||
serviceName = "sonarr";
|
serviceName = "sonarr";
|
||||||
dataDir = "/var/lib/sonarr/.config/NzbDrone";
|
dataDir = "/var/lib/sonarr/.config/NzbDrone";
|
||||||
port = 8989;
|
port = 8989;
|
||||||
@@ -122,6 +62,7 @@ pkgs.testers.runNixOSTest {
|
|||||||
# Test 2: Only downloadClients (no rootFolders)
|
# Test 2: Only downloadClients (no rootFolders)
|
||||||
services.arrInit.radarr = {
|
services.arrInit.radarr = {
|
||||||
enable = true;
|
enable = true;
|
||||||
|
healthChecks = false;
|
||||||
serviceName = "radarr";
|
serviceName = "radarr";
|
||||||
dataDir = "/var/lib/radarr/.config/Radarr";
|
dataDir = "/var/lib/radarr/.config/Radarr";
|
||||||
port = 7878;
|
port = 7878;
|
||||||
@@ -146,6 +87,7 @@ pkgs.testers.runNixOSTest {
|
|||||||
# Test 3: Only syncedApps (Prowlarr)
|
# Test 3: Only syncedApps (Prowlarr)
|
||||||
services.arrInit.prowlarr = {
|
services.arrInit.prowlarr = {
|
||||||
enable = true;
|
enable = true;
|
||||||
|
healthChecks = false;
|
||||||
serviceName = "prowlarr";
|
serviceName = "prowlarr";
|
||||||
dataDir = "/var/lib/prowlarr";
|
dataDir = "/var/lib/prowlarr";
|
||||||
port = 9696;
|
port = 9696;
|
||||||
|
|||||||
89
tests/permanent-failure.nix
Normal file
89
tests/permanent-failure.nix
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
{ 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;
|
||||||
|
healthChecks = false;
|
||||||
|
# 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