Files
arr-init/scripts/ensure_config_xml.py
Simon Gardling 6dde2a3e0d 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.
2026-04-17 00:45:21 -04:00

144 lines
4.3 KiB
Python

#!/usr/bin/env python3
"""Ensure a Servarr config.xml contains required elements before startup.
Reads a JSON config specifying the data directory and desired XML elements,
then creates or patches config.xml to include them. Existing values for
declared elements are overwritten; undeclared elements are preserved.
Invariants:
- The write is atomic (temp file + rename); partial writes cannot leave
a corrupt config.xml that would prevent the service from starting.
- Malformed input config.xml is replaced with a fresh <Config> root
rather than blocking startup forever.
- Existing file permissions are preserved across rewrites.
- The dataDir is created if missing; the app can then write into it.
"""
from __future__ import annotations
import io
import json
import os
import stat
import sys
import xml.etree.ElementTree as ET
def to_xml_text(value) -> str:
"""Convert a JSON-decoded value to the text Servarr expects.
- bool -> "True"/"False" (C# XmlSerializer capitalisation)
- everything else -> str(value)
"""
# bool must be checked before int since bool is a subclass of int
if isinstance(value, bool):
return "True" if value else "False"
return str(value)
def load_root(config_xml_path: str) -> tuple[ET.Element, bool]:
"""Parse existing config.xml or return a fresh <Config> root.
Returns (root, existed) where existed is False if the file was missing
or malformed and a new root was generated.
"""
if not os.path.isfile(config_xml_path):
return ET.Element("Config"), False
try:
tree = ET.parse(config_xml_path)
return tree.getroot(), True
except ET.ParseError as exc:
print(
f"Warning: {config_xml_path} is malformed ({exc}); "
"rewriting with a fresh <Config> root",
file=sys.stderr,
)
return ET.Element("Config"), False
def patch_root(root: ET.Element, elements: dict) -> bool:
"""Patch root in place with declared elements. Returns True if changed."""
changed = False
for key, value in elements.items():
text = to_xml_text(value)
node = root.find(key)
if node is None:
ET.SubElement(root, key).text = text
changed = True
print(f"Added <{key}>{text}</{key}>")
elif node.text != text:
old = node.text
node.text = text
changed = True
print(f"Updated <{key}> from {old!r} to {text!r}")
return changed
def serialize(root: ET.Element) -> str:
"""Pretty-print the XML tree to a string with trailing newline."""
tree = ET.ElementTree(root)
ET.indent(tree, space=" ")
buf = io.StringIO()
tree.write(buf, encoding="unicode", xml_declaration=False)
content = buf.getvalue()
if not content.endswith("\n"):
content += "\n"
return content
def atomic_write(path: str, content: str, mode: int | None) -> None:
"""Write content to path atomically, preserving permissions."""
tmp = f"{path}.tmp.{os.getpid()}"
try:
with open(tmp, "w") as f:
f.write(content)
if mode is not None:
os.chmod(tmp, mode)
os.replace(tmp, path)
except Exception:
# Best-effort cleanup; don't mask the real error
try:
os.unlink(tmp)
except FileNotFoundError:
pass
raise
def main() -> None:
if len(sys.argv) < 2:
print("Usage: ensure_config_xml.py <config.json>", file=sys.stderr)
sys.exit(1)
with open(sys.argv[1]) as f:
cfg = json.load(f)
data_dir = cfg["dataDir"]
elements = cfg["elements"]
if not elements:
return
os.makedirs(data_dir, exist_ok=True)
config_xml_path = os.path.join(data_dir, "config.xml")
root, existed = load_root(config_xml_path)
# Preserve existing mode if the file exists; otherwise default to 0600
# since config.xml contains ApiKey and must not be world-readable.
mode = (
stat.S_IMODE(os.stat(config_xml_path).st_mode) if existed else 0o600
)
changed = patch_root(root, elements)
if not changed and existed:
print(f"{config_xml_path} already correct")
return
atomic_write(config_xml_path, serialize(root), mode)
print(f"Wrote {config_xml_path}")
if __name__ == "__main__":
main()