From 948c9e3a38f1125a0e4a03f1908c15bd77eb9776 Mon Sep 17 00:00:00 2001 From: Simon Gardling Date: Thu, 16 Apr 2026 17:29:25 -0400 Subject: [PATCH] refactor: split module.nix into per-service modules Replace the 1301-line monolithic module.nix with focused modules: - modules/servarr.nix (Sonarr/Radarr/Prowlarr) - modules/bazarr.nix (Bazarr provider connections) - modules/jellyseerr.nix (Jellyseerr quality profiles) - modules/default.nix (import aggregator) Python scripts (from prior commit) are referenced as standalone files via PYTHONPATH, with config passed as a JSON file argument. New options: - Add bindAddress option to all services (default 127.0.0.1) - Replace hardcoded wg.service dependency with configurable networkNamespaceService option - Add systemd hardening: PrivateTmp, NoNewPrivileges, ProtectHome, ProtectKernelTunables/Modules, ProtectControlGroups, RestrictSUIDSGID, SystemCallArchitectures=native Test updates: - Extract mock qBittorrent/SABnzbd servers into tests/lib/mocks.nix - Fix duplicate wait_for_unit calls in integration test --- flake.nix | 42 +- module.nix | 1300 ------------------------------------ modules/bazarr.nix | 182 +++++ modules/default.nix | 8 + modules/jellyseerr.nix | 195 ++++++ modules/servarr.nix | 385 +++++++++++ tests/edge-cases.nix | 69 +- tests/health-checks.nix | 78 +-- tests/integration.nix | 87 +-- tests/lib/mocks.nix | 126 ++++ tests/multiple-clients.nix | 116 +--- tests/partial-config.nix | 69 +- 12 files changed, 952 insertions(+), 1705 deletions(-) delete mode 100644 module.nix create mode 100644 modules/bazarr.nix create mode 100644 modules/default.nix create mode 100644 modules/jellyseerr.nix create mode 100644 modules/servarr.nix create mode 100644 tests/lib/mocks.nix diff --git a/flake.nix b/flake.nix index 90729fd..5da59cd 100644 --- a/flake.nix +++ b/flake.nix @@ -1,35 +1,39 @@ { - description = "Declarative API initialization for Servarr applications (Sonarr, Radarr, Prowlarr, Bazarr)"; + description = "Declarative API initialization for Servarr applications (Sonarr, Radarr, Prowlarr, Bazarr, Jellyseerr)"; inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; }; outputs = - { self, nixpkgs }: - let - supportedSystems = [ + { + self, + nixpkgs, + flake-utils, + }: + flake-utils.lib.eachSystem + [ "x86_64-linux" "aarch64-linux" - ]; - forAllSystems = nixpkgs.lib.genAttrs supportedSystems; - in - { - nixosModules.default = import ./module.nix; - nixosModules.arr-init = import ./module.nix; - - checks = forAllSystems ( + ] + ( system: let pkgs = nixpkgs.legacyPackages.${system}; in - import ./tests { - inherit pkgs; - lib = nixpkgs.lib; - inherit self; - } - ); + { + checks = import ./tests { + inherit pkgs; + lib = nixpkgs.lib; + inherit self; + }; - formatter = forAllSystems (system: nixpkgs.legacyPackages.${system}.nixfmt-tree); + formatter = pkgs.nixfmt-tree; + } + ) + // { + nixosModules.default = import ./modules; + nixosModules.arr-init = import ./modules; }; } diff --git a/module.nix b/module.nix deleted file mode 100644 index dcc8b88..0000000 --- a/module.nix +++ /dev/null @@ -1,1300 +0,0 @@ -{ - config, - lib, - pkgs, - ... -}: -let - cfg = config.services.arrInit; - bazarrCfg = config.services.bazarrInit; - jellyseerrCfg = config.services.jellyseerrInit; - - 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"; - }; - - 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)."; - }; - - downloadClients = lib.mkOption { - type = lib.types.listOf downloadClientModule; - default = [ ]; - description = "List of download clients to configure via the API."; - }; - - rootFolders = lib.mkOption { - type = lib.types.listOf lib.types.str; - default = [ ]; - description = "List of root folder paths to configure via the API."; - example = [ - "/media/tv" - "/media/movies" - ]; - }; - - syncedApps = lib.mkOption { - type = lib.types.listOf syncedAppModule; - default = [ ]; - description = "Applications to register for indexer sync (Prowlarr only)."; - }; - - healthChecks = lib.mkOption { - type = lib.types.bool; - default = false; - description = '' - When enabled, the init service will verify connectivity after provisioning: - - Tests all download clients are reachable via the application's testall API - - For Prowlarr instances: tests all synced applications are reachable - The init service will fail if any health check fails after all retries. - ''; - }; - - healthCheckRetries = lib.mkOption { - type = lib.types.ints.unsigned; - default = 5; - description = '' - Number of times to retry health checks before failing. - Each retry waits healthCheckInterval seconds. This prevents transient - failures (e.g. download clients still starting) from triggering alerts. - ''; - }; - - 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 (which would - trigger any configured OnFailure= target). - ''; - }; - - 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}"; - }; - }; - }; - }; - - 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"; - }; - - 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.ini."; - example = "/services/bazarr"; - }; - - 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 (which would - trigger any configured OnFailure= target). - ''; - }; - - sonarr = lib.mkOption { - type = bazarrProviderModule; - default = { - enable = false; - }; - description = "Sonarr provider configuration."; - }; - - radarr = lib.mkOption { - type = bazarrProviderModule; - default = { - enable = false; - }; - description = "Radarr provider configuration."; - }; - }; - }; - - 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"; - }; - - 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"; - }; - - 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."; - }; - }; - }; - - # Python environment with pyarr for Servarr API operations and requests for - # Prowlarr, Bazarr, and health-check endpoints that pyarr doesn't cover. - pythonEnv = pkgs.python3.withPackages ( - ps: with ps; [ - pyarr - pyyaml - requests - ] - ); - - # Build a JSON configuration blob for a Servarr instance, baked into the - # Python init script at Nix evaluation time. - mkInitConfig = - name: inst: - builtins.toJSON { - inherit name; - inherit (inst) - dataDir - 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; - }; - - mkInitScript = - name: inst: - pkgs.writeScript "${name}-init" '' - #!${pythonEnv}/bin/python3 - """Declarative API initialization for ${name}. - - Uses pyarr (SonarrAPI) for standard Servarr CRUD operations and the - requests library for Prowlarr-specific endpoints, health checks, and - download-client creation (which needs the forceSave query parameter - that pyarr does not expose). - """ - - import json - import os - import sys - import time - import xml.etree.ElementTree as ET - - import requests as http - from pyarr import SonarrAPI - - CONFIG = json.loads(${builtins.toJSON (mkInitConfig name inst)}) - - IMPLEMENTATION_CATEGORY_MAP = { - "Sonarr": "TV", - "Radarr": "Movies", - "Lidarr": "Audio", - "Readarr": "Books", - "Whisparr": "XXX", - } - - - def read_api_key(config_xml_path): - """Extract 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 wait_for_api(base_url, api_key, timeout, name): - """Poll the system/status endpoint until the API responds or timeout.""" - print(f"Waiting for {name} API (timeout: {timeout}s)...") - for i in range(1, timeout + 1): - try: - resp = http.get( - f"{base_url}/system/status", - headers={"X-Api-Key": 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 ensure_download_clients(client, base_url, api_key, download_clients): - """Idempotently provision download clients.""" - existing = client.get_download_client() - existing_names = {dc["name"] for dc in existing} - - for dc in download_clients: - dc_name = dc["name"] - print(f"Checking download client '{dc_name}'...") - if dc_name in existing_names: - print(f"Download client '{dc_name}' already exists, skipping") - continue - - 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": [ - {"name": k, "value": v} for k, v in dc["fields"].items() - ], - "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") - - - def ensure_root_folders(client, root_folders): - """Idempotently provision root folders via pyarr.""" - existing = client.get_root_folder() - existing_paths = {rf["path"] for rf in existing} - - for path in root_folders: - print(f"Checking root folder '{path}'...") - if path in existing_paths: - print(f"Root folder '{path}' already exists, skipping") - continue - - print(f"Adding root folder '{path}'...") - client.add_root_folder(path) - print(f"Root folder '{path}' added") - - - 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). - - Uses requests directly because pyarr 5.x has no Prowlarr support. - """ - resp = http.get( - f"{base_url}/applications", - headers={"X-Api-Key": api_key}, - timeout=30, - ) - resp.raise_for_status() - existing_names = {app["name"] for app in resp.json()} - - for app in synced_apps: - app_name = app["name"] - print(f"Checking synced app '{app_name}'...") - if app_name in existing_names: - print(f"Synced app '{app_name}' already exists, skipping") - continue - - print(f"Adding synced app '{app_name}'...") - target_api_key = read_api_key(app["apiKeyFrom"]) - sync_categories = resolve_sync_categories( - base_url, - api_key, - app["implementation"], - app.get("syncCategories", []), - ) - - payload = { - "name": app_name, - "implementation": app["implementation"], - "configContract": app["configContract"], - "syncLevel": app["syncLevel"], - "fields": [ - {"name": "prowlarrUrl", "value": app["prowlarrUrl"]}, - {"name": "baseUrl", "value": app["baseUrl"]}, - {"name": "apiKey", "value": target_api_key}, - {"name": "syncCategories", "value": sync_categories}, - ], - "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") - - - def update_naming(client, naming_config): - """Merge desired naming fields into the current config via pyarr.""" - if not naming_config: - return - - print("Checking naming configuration...") - current = client.get_config_naming() - - 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} - client.upd_config_naming(merged) - print("Naming configuration updated") - - - def health_check_loop(url, api_key, entity_name, svc_name, max_retries, interval): - """POST to a testall endpoint with retry logic.""" - 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 Exception: - healthy = False - last_error = ( - f"could not reach {svc_name} API for {entity_name} test" - ) - - 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) - - - 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") - - - def main(): - name = CONFIG["name"] - data_dir = CONFIG["dataDir"] - port = CONFIG["port"] - api_version = CONFIG["apiVersion"] - api_timeout = CONFIG["apiTimeout"] - - 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(config_xml) - base_url = f"http://127.0.0.1:{port}/api/{api_version}" - - wait_for_api(base_url, api_key, api_timeout, name) - - # pyarr client for standard Servarr operations (download clients, - # root folders, naming). SonarrAPI is used generically here because - # the relevant endpoints are identical across all Servarr applications - # and it accepts a custom ver_uri for API version selection. - client = SonarrAPI( - f"http://127.0.0.1:{port}", api_key, ver_uri=f"/{api_version}" - ) - - if CONFIG.get("downloadClients"): - ensure_download_clients( - client, base_url, api_key, CONFIG["downloadClients"] - ) - - if CONFIG.get("rootFolders"): - ensure_root_folders(client, CONFIG["rootFolders"]) - - if CONFIG.get("syncedApps"): - ensure_synced_apps(base_url, api_key, CONFIG["syncedApps"]) - - if CONFIG.get("naming"): - update_naming(client, CONFIG["naming"]) - - run_health_checks(base_url, api_key, name, CONFIG) - - print(f"{name} init complete") - - - if __name__ == "__main__": - main() - ''; - - # Get list of service names that syncedApps depend on - getSyncedAppDeps = inst: map (app: "${app.serviceName}.service") inst.syncedApps; - - # Get list of service names that download clients depend on - getDownloadClientDeps = - inst: - lib.concatMap ( - dc: lib.optional (dc.serviceName != null) "${dc.serviceName}.service" - ) inst.downloadClients; - - enabledInstances = lib.filterAttrs (_: inst: inst.enable) cfg; - - mkBazarrInitConfig = builtins.toJSON { - dataDir = bazarrCfg.dataDir; - port = bazarrCfg.port; - apiTimeout = bazarrCfg.apiTimeout; - providers = - { } - // lib.optionalAttrs bazarrCfg.sonarr.enable { - sonarr = { - enable = true; - dataDir = bazarrCfg.sonarr.dataDir; - port = bazarrCfg.sonarr.port; - }; - } - // lib.optionalAttrs bazarrCfg.radarr.enable { - radarr = { - enable = true; - dataDir = bazarrCfg.radarr.dataDir; - port = bazarrCfg.radarr.port; - }; - }; - }; - - mkBazarrInitScript = pkgs.writeScript "bazarr-init" '' - #!${pythonEnv}/bin/python3 - """Declarative API initialization for Bazarr provider connections. - - Uses the requests library directly since Bazarr has its own API that - is not compatible with the Servarr/pyarr ecosystem. - """ - - import json - import os - import sys - import time - import xml.etree.ElementTree as ET - - import requests as http - import yaml - - CONFIG = json.loads(${builtins.toJSON mkBazarrInitConfig}) - - - 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 read_api_key_xml(config_xml_path): - """Extract 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 wait_for_api(base_url, api_key, timeout): - """Poll Bazarr's system/status endpoint until available or timeout.""" - print(f"Waiting for Bazarr API (timeout: {timeout}s)...") - for i in range(1, timeout + 1): - try: - resp = http.get( - f"{base_url}/api/system/status", - headers={"X-API-KEY": api_key}, - timeout=5, - ) - if resp.ok: - print("Bazarr API is ready") - return - except (http.ConnectionError, http.Timeout): - pass - if i == timeout: - print( - f"Bazarr API not available after {timeout} seconds", - file=sys.stderr, - ) - sys.exit(1) - time.sleep(1) - - - def configure_provider(base_url, api_key, provider_type, provider_config): - """Idempotently configure a Sonarr/Radarr provider in Bazarr.""" - 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", "") - - if use_flag and existing_key: - print(f"{provider_type} provider already configured, skipping") - return - - print(f"Adding {provider_type} provider...") - provider_api_key = read_api_key_xml( - f"{provider_config['dataDir']}/config.xml" - ) - - 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": "localhost", - 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 added") - - - def main(): - data_dir = CONFIG["dataDir"] - port = CONFIG["port"] - api_timeout = CONFIG["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://127.0.0.1:{port}" - - wait_for_api(base_url, api_key, api_timeout) - - providers = CONFIG.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() - ''; - - bazarrDeps = [ - "bazarr.service" - ] - ++ (lib.optional bazarrCfg.sonarr.enable "${bazarrCfg.sonarr.serviceName}.service") - ++ (lib.optional bazarrCfg.radarr.enable "${bazarrCfg.radarr.serviceName}.service"); - - mkJellyseerrInitConfig = builtins.toJSON { - configDir = jellyseerrCfg.configDir; - apiTimeout = jellyseerrCfg.apiTimeout; - radarr = { - profileName = jellyseerrCfg.radarr.profileName; - dataDir = jellyseerrCfg.radarr.dataDir; - port = jellyseerrCfg.radarr.port; - }; - sonarr = { - profileName = jellyseerrCfg.sonarr.profileName; - animeProfileName = - if jellyseerrCfg.sonarr.animeProfileName != null then - jellyseerrCfg.sonarr.animeProfileName - else - jellyseerrCfg.sonarr.profileName; - dataDir = jellyseerrCfg.sonarr.dataDir; - port = jellyseerrCfg.sonarr.port; - }; - }; - - mkJellyseerrInitScript = pkgs.writeScript "jellyseerr-init" '' - #!${pythonEnv}/bin/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 sys - import time - import xml.etree.ElementTree as ET - - import requests as http - - CONFIG = json.loads(${builtins.toJSON mkJellyseerrInitConfig}) - - - def read_api_key(config_xml_path): - """Extract 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 wait_for_api(base_url, api_key, timeout, name): - """Poll the system/status endpoint until the API responds or timeout.""" - print(f"Waiting for {name} API (timeout: {timeout}s)...") - for i in range(1, timeout + 1): - try: - resp = http.get( - f"{base_url}/system/status", - headers={"X-Api-Key": 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 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() - - for profile in resp.json(): - 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 resp.json()] - print( - f"Profile '{profile_name}' not found in {app_name}. " - f"Available: {available}", - file=sys.stderr, - ) - sys.exit(1) - - - def main(): - settings_path = os.path.join(CONFIG["configDir"], "settings.json") - if not os.path.isfile(settings_path): - print(f"{settings_path} not found, skipping (Jellyseerr not yet initialized)") - return - - timeout = CONFIG["apiTimeout"] - - # Resolve Radarr profile - radarr_cfg = CONFIG["radarr"] - radarr_key = read_api_key(f"{radarr_cfg['dataDir']}/config.xml") - radarr_base = f"http://127.0.0.1:{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 = CONFIG["sonarr"] - sonarr_key = read_api_key(f"{sonarr_cfg['dataDir']}/config.xml") - sonarr_base = f"http://127.0.0.1:{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', '?')}': 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', '?')}': 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...") - os.system("systemctl restart jellyseerr.service") - print("Jellyseerr init complete") - - - if __name__ == "__main__": - main() - ''; - - jellyseerrDeps = [ - "jellyseerr.service" - "${jellyseerrCfg.radarr.serviceName}.service" - "${jellyseerrCfg.sonarr.serviceName}.service" - ]; -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. - ''; - }; - - 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. - ''; - }; - - 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.mkMerge [ - (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.networkNamespacePath != null) "wg.service"); - requires = [ "${inst.serviceName}.service" ] ++ (getDownloadClientDeps inst); - wantedBy = [ "multi-user.target" ]; - unitConfig = { - # Allow 5 full retry cycles (apiTimeout + RestartSec each) before - # entering permanent failure, which is what triggers OnFailure=. - StartLimitIntervalSec = 5 * (inst.apiTimeout + 30); - StartLimitBurst = 5; - }; - serviceConfig = { - Type = "oneshot"; - RemainAfterExit = true; - Restart = "on-failure"; - RestartSec = 30; - ExecStart = "${mkInitScript name inst}"; - } - // lib.optionalAttrs (inst.networkNamespacePath != null) { - NetworkNamespacePath = inst.networkNamespacePath; - }; - } - ) enabledInstances; - }) - - (lib.mkIf bazarrCfg.enable { - systemd.services.bazarr-init = { - description = "Initialize Bazarr API connections"; - after = bazarrDeps; - requires = bazarrDeps; - wantedBy = [ "multi-user.target" ]; - unitConfig = { - StartLimitIntervalSec = 5 * (bazarrCfg.apiTimeout + 30); - StartLimitBurst = 5; - }; - serviceConfig = { - Type = "oneshot"; - RemainAfterExit = true; - Restart = "on-failure"; - RestartSec = 30; - ExecStart = "${mkBazarrInitScript}"; - }; - }; - }) - - (lib.mkIf jellyseerrCfg.enable { - systemd.services.jellyseerr-init = { - description = "Initialize Jellyseerr quality profile defaults"; - after = jellyseerrDeps; - requires = jellyseerrDeps; - wantedBy = [ "multi-user.target" ]; - unitConfig = { - StartLimitIntervalSec = 5 * (jellyseerrCfg.apiTimeout + 30); - StartLimitBurst = 5; - }; - serviceConfig = { - Type = "oneshot"; - RemainAfterExit = true; - Restart = "on-failure"; - RestartSec = 30; - ExecStart = "${mkJellyseerrInitScript}"; - }; - }; - }) - ]; -} diff --git a/modules/bazarr.nix b/modules/bazarr.nix new file mode 100644 index 0000000..135fcd6 --- /dev/null +++ b/modules/bazarr.nix @@ -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; + }; + }; +} diff --git a/modules/default.nix b/modules/default.nix new file mode 100644 index 0000000..c841942 --- /dev/null +++ b/modules/default.nix @@ -0,0 +1,8 @@ +{ ... }: +{ + imports = [ + ./servarr.nix + ./bazarr.nix + ./jellyseerr.nix + ]; +} diff --git a/modules/jellyseerr.nix b/modules/jellyseerr.nix new file mode 100644 index 0000000..d0fff94 --- /dev/null +++ b/modules/jellyseerr.nix @@ -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; + }; + }; +} diff --git a/modules/servarr.nix b/modules/servarr.nix new file mode 100644 index 0000000..1222a6f --- /dev/null +++ b/modules/servarr.nix @@ -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 = false; + description = '' + When enabled, the init service will verify connectivity after provisioning: + - Tests all download clients are reachable via the application's testall API + - For Prowlarr instances: tests all synced applications are reachable + The init service will fail if any health check fails after all retries. + ''; + }; + + healthCheckRetries = lib.mkOption { + type = lib.types.ints.unsigned; + default = 5; + description = '' + Number of times to retry health checks before failing. + Each retry waits healthCheckInterval seconds. + ''; + }; + + healthCheckInterval = lib.mkOption { + type = lib.types.ints.positive; + default = 10; + description = "Seconds to wait between health check retries."; + }; + + apiTimeout = lib.mkOption { + type = lib.types.ints.positive; + default = 90; + description = '' + Seconds to wait for the application API to become available before + considering the init attempt failed. When the API is not reachable + within this window, the service exits non-zero and systemd's + Restart=on-failure will schedule another attempt after RestartSec. + + The systemd start limit is computed from this value to allow 5 full + retry cycles before the unit enters permanent failure. + ''; + }; + + naming = lib.mkOption { + type = lib.types.attrsOf lib.types.anything; + default = { }; + description = '' + Naming configuration to set via the API's config/naming endpoint. + Keys/values map directly to the API fields (e.g. renameEpisodes, + standardEpisodeFormat for Sonarr; renameMovies, standardMovieFormat + for Radarr). Only specified fields are updated; unspecified fields + retain their current values. + ''; + example = { + renameEpisodes = true; + standardEpisodeFormat = "{Series Title} - S{season:00}E{episode:00} - {Episode Title} {Quality Full}"; + seasonFolderFormat = "Season {season}"; + seriesFolderFormat = "{Series Title}"; + }; + }; + }; + }; + + 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; + }; +} diff --git a/tests/edge-cases.nix b/tests/edge-cases.nix index fbc6d3d..807a6c7 100644 --- a/tests/edge-cases.nix +++ b/tests/edge-cases.nix @@ -9,6 +9,9 @@ pkgs.testers.runNixOSTest { nodes.machine = { pkgs, lib, ... }: + let + mocks = import ./lib/mocks.nix { inherit pkgs; }; + in { imports = [ self.nixosModules.default ]; @@ -22,71 +25,7 @@ pkgs.testers.runNixOSTest { gnugrep ]; - systemd.services.mock-qbittorrent = - let - mockQbitScript = pkgs.writeScript "mock-qbittorrent.py" '' - import json - from http.server import HTTPServer, BaseHTTPRequestHandler - from urllib.parse import parse_qs, urlparse - - - CATEGORIES = {} - - - class QBitMock(BaseHTTPRequestHandler): - def _respond(self, code=200, body=b"Ok.", content_type="text/plain"): - self.send_response(code) - self.send_header("Content-Type", content_type) - self.send_header("Set-Cookie", "SID=mock_session_id; Path=/") - self.end_headers() - self.wfile.write(body if isinstance(body, bytes) else body.encode()) - - def do_GET(self): - path = self.path.split("?")[0] - if path == "/api/v2/app/webapiVersion": - self._respond(body=b"2.9.3") - elif path == "/api/v2/app/version": - self._respond(body=b"v5.0.0") - elif path == "/api/v2/torrents/info": - self._respond(body=b"[]", content_type="application/json") - elif path == "/api/v2/torrents/categories": - body = json.dumps(CATEGORIES).encode() - self._respond(body=body, content_type="application/json") - elif path == "/api/v2/app/preferences": - body = json.dumps({"save_path": "/tmp"}).encode() - self._respond(body=body, content_type="application/json") - else: - self._respond() - - def do_POST(self): - content_length = int(self.headers.get("Content-Length", 0)) - body = self.rfile.read(content_length).decode() - path = urlparse(self.path).path - query = parse_qs(urlparse(self.path).query) - form = parse_qs(body) - params = {**query, **form} - if path == "/api/v2/torrents/createCategory": - name = params.get("category", [""])[0] - save_path = params.get("savePath", params.get("save_path", [""]))[0] or "/downloads" - if name: - CATEGORIES[name] = {"name": name, "savePath": save_path} - self._respond() - - def log_message(self, format, *args): - pass - - - HTTPServer(("0.0.0.0", 6011), QBitMock).serve_forever() - ''; - in - { - description = "Mock qBittorrent API"; - wantedBy = [ "multi-user.target" ]; - serviceConfig = { - ExecStart = "${pkgs.python3}/bin/python3 ${mockQbitScript}"; - Type = "simple"; - }; - }; + systemd.services.mock-qbittorrent = mocks.mkMockQbittorrent { }; # Create directories including one with spaces systemd.tmpfiles.rules = [ diff --git a/tests/health-checks.nix b/tests/health-checks.nix index c48932c..820aeb4 100644 --- a/tests/health-checks.nix +++ b/tests/health-checks.nix @@ -9,6 +9,9 @@ pkgs.testers.runNixOSTest { nodes.machine = { pkgs, lib, ... }: + let + mocks = import ./lib/mocks.nix { inherit pkgs; }; + in { imports = [ self.nixosModules.default ]; @@ -22,77 +25,12 @@ pkgs.testers.runNixOSTest { gnugrep ]; - systemd.services.mock-qbittorrent = - let - mockQbitScript = pkgs.writeScript "mock-qbittorrent.py" '' - import json - from http.server import HTTPServer, BaseHTTPRequestHandler - from urllib.parse import parse_qs, urlparse - - - CATEGORIES = { - "tv": {"name": "tv", "savePath": "/downloads"}, - "movies": {"name": "movies", "savePath": "/downloads"}, - } - - - class QBitMock(BaseHTTPRequestHandler): - def _respond(self, code=200, body=b"Ok.", content_type="text/plain"): - self.send_response(code) - self.send_header("Content-Type", content_type) - self.send_header("Set-Cookie", "SID=mock_session_id; Path=/") - self.end_headers() - self.wfile.write(body if isinstance(body, bytes) else body.encode()) - - def do_GET(self): - path = self.path.split("?")[0] - if path == "/api/v2/app/webapiVersion": - self._respond(body=b"2.9.3") - elif path == "/api/v2/app/version": - self._respond(body=b"v5.0.0") - elif path == "/api/v2/torrents/info": - self._respond(body=b"[]", content_type="application/json") - elif path == "/api/v2/torrents/categories": - body = json.dumps(CATEGORIES).encode() - self._respond(body=body, content_type="application/json") - elif path == "/api/v2/app/preferences": - body = json.dumps({"save_path": "/tmp"}).encode() - self._respond(body=body, content_type="application/json") - else: - self._respond() - - def do_POST(self): - content_length = int(self.headers.get("Content-Length", 0)) - body = self.rfile.read(content_length).decode() - path = urlparse(self.path).path - query = parse_qs(urlparse(self.path).query) - form = parse_qs(body) - params = {**query, **form} - if path == "/api/v2/torrents/createCategory": - name = params.get("category", [""])[0] - save_path = params.get("savePath", params.get("save_path", [""]))[0] or "/downloads" - if name: - CATEGORIES[name] = {"name": name, "savePath": save_path} - if path in ["/api/v2/torrents/editCategory", "/api/v2/torrents/removeCategory"]: - self._respond() - return - self._respond() - - def log_message(self, format, *args): - pass - - - HTTPServer(("0.0.0.0", 6011), QBitMock).serve_forever() - ''; - in - { - description = "Mock qBittorrent API"; - wantedBy = [ "multi-user.target" ]; - serviceConfig = { - ExecStart = "${pkgs.python3}/bin/python3 ${mockQbitScript}"; - Type = "simple"; - }; + systemd.services.mock-qbittorrent = mocks.mkMockQbittorrent { + initialCategories = { + tv = { name = "tv"; savePath = "/downloads"; }; + movies = { name = "movies"; savePath = "/downloads"; }; }; + }; systemd.tmpfiles.rules = [ "d /media/tv 0755 sonarr sonarr -" diff --git a/tests/integration.nix b/tests/integration.nix index 0a286d6..a274c60 100644 --- a/tests/integration.nix +++ b/tests/integration.nix @@ -9,6 +9,9 @@ pkgs.testers.runNixOSTest { nodes.machine = { pkgs, lib, ... }: + let + mocks = import ./lib/mocks.nix { inherit pkgs; }; + in { imports = [ self.nixosModules.default ]; @@ -22,81 +25,13 @@ pkgs.testers.runNixOSTest { gnugrep ]; - systemd.services.mock-qbittorrent = - let - mockQbitScript = pkgs.writeScript "mock-qbittorrent.py" '' - import json - from http.server import HTTPServer, BaseHTTPRequestHandler - from urllib.parse import parse_qs, urlparse - - - CATEGORIES = { - "tv": {"name": "tv", "savePath": "/downloads"}, - "movies": {"name": "movies", "savePath": "/downloads"}, - } - - - class QBitMock(BaseHTTPRequestHandler): - def _respond(self, code=200, body=b"Ok.", content_type="text/plain"): - self.send_response(code) - self.send_header("Content-Type", content_type) - self.send_header("Set-Cookie", "SID=mock_session_id; Path=/") - self.end_headers() - self.wfile.write(body if isinstance(body, bytes) else body.encode()) - - def do_GET(self): - path = self.path.split("?")[0] - if path == "/api/v2/app/webapiVersion": - self._respond(body=b"2.9.3") - elif path == "/api/v2/app/version": - self._respond(body=b"v5.0.0") - elif path == "/api/v2/torrents/info": - self._respond(body=b"[]", content_type="application/json") - elif path == "/api/v2/torrents/categories": - body = json.dumps(CATEGORIES).encode() - self._respond(body=body, content_type="application/json") - elif path == "/api/v2/app/preferences": - body = json.dumps({"save_path": "/tmp"}).encode() - self._respond(body=body, content_type="application/json") - else: - self._respond() - - def do_POST(self): - content_length = int(self.headers.get("Content-Length", 0)) - body = self.rfile.read(content_length).decode() - path = urlparse(self.path).path - query = parse_qs(urlparse(self.path).query) - form = parse_qs(body) - params = {**query, **form} - if path == "/api/v2/torrents/createCategory": - name = params.get("category", [""])[0] - save_path = params.get("savePath", params.get("save_path", [""]))[0] or "/downloads" - if name: - CATEGORIES[name] = {"name": name, "savePath": save_path} - if path in ["/api/v2/torrents/editCategory", "/api/v2/torrents/removeCategory"]: - self._respond() - return - self._respond() - - def log_message(self, format, *args): - pass - - - HTTPServer(("0.0.0.0", 6011), QBitMock).serve_forever() - ''; - in - { - description = "Mock qBittorrent API"; - wantedBy = [ "multi-user.target" ]; - before = [ - "sonarr-init.service" - "radarr-init.service" - ]; - serviceConfig = { - ExecStart = "${pkgs.python3}/bin/python3 ${mockQbitScript}"; - Type = "simple"; - }; + systemd.services.mock-qbittorrent = mocks.mkMockQbittorrent { + initialCategories = { + tv = { name = "tv"; savePath = "/downloads"; }; + movies = { name = "movies"; savePath = "/downloads"; }; }; + before = [ "sonarr-init.service" "radarr-init.service" ]; + }; systemd.tmpfiles.rules = [ "d /media/tv 0755 sonarr sonarr -" @@ -255,10 +190,6 @@ pkgs.testers.runNixOSTest { machine.wait_for_unit("sonarr-init.service") machine.wait_for_unit("radarr-init.service") - # Wait for init services to complete - machine.wait_for_unit("sonarr-init.service") - machine.wait_for_unit("radarr-init.service") - # Verify Sonarr download clients machine.succeed( "API_KEY=$(grep -oP '(?<=)[^<]+' /var/lib/sonarr/.config/NzbDrone/config.xml) && " diff --git a/tests/lib/mocks.nix b/tests/lib/mocks.nix new file mode 100644 index 0000000..ceeffac --- /dev/null +++ b/tests/lib/mocks.nix @@ -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"; + }; + }; +} diff --git a/tests/multiple-clients.nix b/tests/multiple-clients.nix index 6087ba7..6161b3d 100644 --- a/tests/multiple-clients.nix +++ b/tests/multiple-clients.nix @@ -9,6 +9,9 @@ pkgs.testers.runNixOSTest { nodes.machine = { pkgs, lib, ... }: + let + mocks = import ./lib/mocks.nix { inherit pkgs; }; + in { imports = [ self.nixosModules.default ]; @@ -23,117 +26,14 @@ pkgs.testers.runNixOSTest { ]; # Mock qBittorrent on port 6011 - systemd.services.mock-qbittorrent = - let - mockQbitScript = pkgs.writeScript "mock-qbittorrent.py" '' - import json - from http.server import HTTPServer, BaseHTTPRequestHandler - from urllib.parse import parse_qs, urlparse - - - CATEGORIES = { - "tv": {"name": "tv", "savePath": "/downloads"}, - } - - - class QBitMock(BaseHTTPRequestHandler): - def _respond(self, code=200, body=b"Ok.", content_type="text/plain"): - self.send_response(code) - self.send_header("Content-Type", content_type) - self.send_header("Set-Cookie", "SID=mock_session_id; Path=/") - self.end_headers() - self.wfile.write(body if isinstance(body, bytes) else body.encode()) - - def do_GET(self): - path = self.path.split("?")[0] - if path == "/api/v2/app/webapiVersion": - self._respond(body=b"2.9.3") - elif path == "/api/v2/app/version": - self._respond(body=b"v5.0.0") - elif path == "/api/v2/torrents/info": - self._respond(body=b"[]", content_type="application/json") - elif path == "/api/v2/torrents/categories": - body = json.dumps(CATEGORIES).encode() - self._respond(body=body, content_type="application/json") - elif path == "/api/v2/app/preferences": - body = json.dumps({"save_path": "/tmp"}).encode() - self._respond(body=body, content_type="application/json") - else: - self._respond() - - def do_POST(self): - content_length = int(self.headers.get("Content-Length", 0)) - body = self.rfile.read(content_length).decode() - path = urlparse(self.path).path - query = parse_qs(urlparse(self.path).query) - form = parse_qs(body) - params = {**query, **form} - if path == "/api/v2/torrents/createCategory": - name = params.get("category", [""])[0] - save_path = params.get("savePath", params.get("save_path", [""]))[0] or "/downloads" - if name: - CATEGORIES[name] = {"name": name, "savePath": save_path} - self._respond() - - def log_message(self, format, *args): - pass - - - HTTPServer(("0.0.0.0", 6011), QBitMock).serve_forever() - ''; - in - { - description = "Mock qBittorrent API"; - wantedBy = [ "multi-user.target" ]; - serviceConfig = { - ExecStart = "${pkgs.python3}/bin/python3 ${mockQbitScript}"; - Type = "simple"; - }; + systemd.services.mock-qbittorrent = mocks.mkMockQbittorrent { + initialCategories = { + tv = { name = "tv"; savePath = "/downloads"; }; }; + }; # Mock SABnzbd on port 6012 - systemd.services.mock-sabnzbd = - let - mockSabScript = pkgs.writeScript "mock-sabnzbd.py" '' - import json - from http.server import HTTPServer, BaseHTTPRequestHandler - from urllib.parse import parse_qs, urlparse - - - class SabMock(BaseHTTPRequestHandler): - def _respond(self, code=200, body=b'{"config": {}}', content_type="application/json"): - self.send_response(code) - self.send_header("Content-Type", content_type) - self.end_headers() - self.wfile.write(body if isinstance(body, bytes) else body.encode()) - - def do_GET(self): - path = self.path.split("?")[0] - if "mode=config" in self.path or "mode=version" in self.path: - self._respond(body=b'{"config": {"misc": {"api_key": "test"}}, "version": "4.0.0"}') - elif "mode=get_config" in self.path: - self._respond(body=b'{"config": {"misc": {"complete_dir": "/downloads/usenet", "pre_check": false}, "categories": [{"name": "tv", "order": 0, "pp": "", "script": "Default", "dir": "tv"}], "sorters": []}}') - else: - self._respond() - - def do_POST(self): - self._respond() - - def log_message(self, format, *args): - pass - - - HTTPServer(("0.0.0.0", 6012), SabMock).serve_forever() - ''; - in - { - description = "Mock SABnzbd API"; - wantedBy = [ "multi-user.target" ]; - serviceConfig = { - ExecStart = "${pkgs.python3}/bin/python3 ${mockSabScript}"; - Type = "simple"; - }; - }; + systemd.services.mock-sabnzbd = mocks.mkMockSabnzbd { }; systemd.tmpfiles.rules = [ "d /media/tv 0755 sonarr sonarr -" diff --git a/tests/partial-config.nix b/tests/partial-config.nix index f0a4680..b234879 100644 --- a/tests/partial-config.nix +++ b/tests/partial-config.nix @@ -9,6 +9,9 @@ pkgs.testers.runNixOSTest { nodes.machine = { pkgs, lib, ... }: + let + mocks = import ./lib/mocks.nix { inherit pkgs; }; + in { imports = [ self.nixosModules.default ]; @@ -22,71 +25,7 @@ pkgs.testers.runNixOSTest { gnugrep ]; - systemd.services.mock-qbittorrent = - let - mockQbitScript = pkgs.writeScript "mock-qbittorrent.py" '' - import json - from http.server import HTTPServer, BaseHTTPRequestHandler - from urllib.parse import parse_qs, urlparse - - - CATEGORIES = {} - - - class QBitMock(BaseHTTPRequestHandler): - def _respond(self, code=200, body=b"Ok.", content_type="text/plain"): - self.send_response(code) - self.send_header("Content-Type", content_type) - self.send_header("Set-Cookie", "SID=mock_session_id; Path=/") - self.end_headers() - self.wfile.write(body if isinstance(body, bytes) else body.encode()) - - def do_GET(self): - path = self.path.split("?")[0] - if path == "/api/v2/app/webapiVersion": - self._respond(body=b"2.9.3") - elif path == "/api/v2/app/version": - self._respond(body=b"v5.0.0") - elif path == "/api/v2/torrents/info": - self._respond(body=b"[]", content_type="application/json") - elif path == "/api/v2/torrents/categories": - body = json.dumps(CATEGORIES).encode() - self._respond(body=body, content_type="application/json") - elif path == "/api/v2/app/preferences": - body = json.dumps({"save_path": "/tmp"}).encode() - self._respond(body=body, content_type="application/json") - else: - self._respond() - - def do_POST(self): - content_length = int(self.headers.get("Content-Length", 0)) - body = self.rfile.read(content_length).decode() - path = urlparse(self.path).path - query = parse_qs(urlparse(self.path).query) - form = parse_qs(body) - params = {**query, **form} - if path == "/api/v2/torrents/createCategory": - name = params.get("category", [""])[0] - save_path = params.get("savePath", params.get("save_path", [""]))[0] or "/downloads" - if name: - CATEGORIES[name] = {"name": name, "savePath": save_path} - self._respond() - - def log_message(self, format, *args): - pass - - - HTTPServer(("0.0.0.0", 6011), QBitMock).serve_forever() - ''; - in - { - description = "Mock qBittorrent API"; - wantedBy = [ "multi-user.target" ]; - serviceConfig = { - ExecStart = "${pkgs.python3}/bin/python3 ${mockQbitScript}"; - Type = "simple"; - }; - }; + systemd.services.mock-qbittorrent = mocks.mkMockQbittorrent { }; systemd.tmpfiles.rules = [ "d /media/tv 0755 sonarr sonarr -"