servarr: add configXml option with preStart hook
Adds services.arrInit.<name>.configXml for declaratively ensuring XML elements exist in a Servarr config.xml before the service starts. Generates a preStart hook on the main service that runs a Python helper to patch or create config.xml. Undeclared elements are preserved; declared elements are written with exact values. Primary use case: preventing recurring Prowlarr 'not listening on port' failures when config.xml loses the <Port> element — now guaranteed to exist before Prowlarr starts. Hardening: - Atomic writes (tmp + rename): power loss cannot corrupt config.xml - Malformed XML recovery: fresh <Config> root instead of blocking boot - Secure default mode (0600) for new files containing ApiKey - Preserves existing file mode on rewrite - Assertion against duplicate serviceName targeting Tests (10 subtests): creates-from-missing, patches-existing, preserves- undeclared, corrects-tampered, idempotent, malformed-recovery, ownership-preserved, not-world-readable.
This commit is contained in:
@@ -271,6 +271,31 @@ let
|
||||
seriesFolderFormat = "{Series Title}";
|
||||
};
|
||||
};
|
||||
|
||||
configXml = lib.mkOption {
|
||||
type = lib.types.attrsOf (
|
||||
lib.types.oneOf [
|
||||
lib.types.str
|
||||
lib.types.int
|
||||
lib.types.bool
|
||||
]
|
||||
);
|
||||
default = { };
|
||||
description = ''
|
||||
XML elements to ensure in the service's config.xml before startup.
|
||||
Each key-value pair corresponds to a direct child element of the
|
||||
<Config> root. Existing elements are updated if their value differs;
|
||||
new elements are added. Undeclared elements are preserved.
|
||||
|
||||
This runs as a preStart hook on the main service, guaranteeing
|
||||
config.xml is correct before the application reads it.
|
||||
'';
|
||||
example = {
|
||||
Port = 9696;
|
||||
BindAddress = "*";
|
||||
AnalyticsEnabled = false;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -324,6 +349,16 @@ let
|
||||
) inst.downloadClients;
|
||||
|
||||
enabledInstances = lib.filterAttrs (_: inst: inst.enable) cfg;
|
||||
configXmlInstances = lib.filterAttrs (_: inst: inst.configXml != { }) enabledInstances;
|
||||
|
||||
mkConfigXmlFile =
|
||||
name: inst:
|
||||
pkgs.writeText "${name}-config-xml.json" (
|
||||
builtins.toJSON {
|
||||
inherit (inst) dataDir;
|
||||
elements = inst.configXml;
|
||||
}
|
||||
);
|
||||
|
||||
# Shared hardening options for all init services.
|
||||
hardeningConfig = {
|
||||
@@ -349,26 +384,44 @@ in
|
||||
};
|
||||
|
||||
config = lib.mkIf (enabledInstances != { }) {
|
||||
systemd.services = lib.mapAttrs' (
|
||||
name: inst:
|
||||
lib.nameValuePair "${inst.serviceName}-init" {
|
||||
description = "Initialize ${name} API connections";
|
||||
after =
|
||||
[
|
||||
assertions =
|
||||
let
|
||||
configXmlTargets = map (inst: inst.serviceName) (builtins.attrValues configXmlInstances);
|
||||
in
|
||||
[
|
||||
{
|
||||
# Two arrInit entries targeting the same systemd service with configXml
|
||||
# would silently collide on the preStart definition; only one would win.
|
||||
# Force the user to deduplicate instead of producing surprising behaviour.
|
||||
assertion = (lib.length configXmlTargets) == (lib.length (lib.unique configXmlTargets));
|
||||
message = ''
|
||||
services.arrInit: multiple entries target the same serviceName with configXml.
|
||||
Each systemd service may have configXml defined by at most one arrInit entry.
|
||||
Targets: ${lib.concatStringsSep ", " configXmlTargets}
|
||||
'';
|
||||
}
|
||||
];
|
||||
|
||||
systemd.services =
|
||||
# Init services: oneshot units that configure the app via HTTP API
|
||||
(lib.mapAttrs' (
|
||||
name: inst:
|
||||
lib.nameValuePair "${inst.serviceName}-init" {
|
||||
description = "Initialize ${name} API connections";
|
||||
after = [
|
||||
"${inst.serviceName}.service"
|
||||
]
|
||||
++ (getSyncedAppDeps inst)
|
||||
++ (getDownloadClientDeps inst)
|
||||
++ (lib.optional (inst.networkNamespaceService != null) "${inst.networkNamespaceService}.service");
|
||||
requires = [ "${inst.serviceName}.service" ] ++ (getDownloadClientDeps inst);
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
environment.PYTHONPATH = "${scriptDir}";
|
||||
unitConfig = {
|
||||
StartLimitIntervalSec = 5 * (inst.apiTimeout + 30);
|
||||
StartLimitBurst = 5;
|
||||
};
|
||||
serviceConfig =
|
||||
{
|
||||
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";
|
||||
@@ -379,7 +432,16 @@ in
|
||||
// lib.optionalAttrs (inst.networkNamespacePath != null) {
|
||||
NetworkNamespacePath = inst.networkNamespacePath;
|
||||
};
|
||||
}
|
||||
) enabledInstances;
|
||||
}
|
||||
) enabledInstances)
|
||||
# config.xml preStart: ensure declared elements exist before the service reads them
|
||||
// (lib.mapAttrs' (
|
||||
name: inst:
|
||||
lib.nameValuePair inst.serviceName {
|
||||
preStart = lib.mkBefore (
|
||||
"${pythonEnv}/bin/python3 ${scriptDir}/ensure_config_xml.py ${mkConfigXmlFile name inst}"
|
||||
);
|
||||
}
|
||||
) configXmlInstances);
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user