# ServiceDefinition: sonarr # Source of truth: spec docs/13-service-catalog.md row 2; docs/15-identity-and-access.md; docs/03. # internal | egress VPN (+ kill-switch) | config on Longhorn | auth forward-auth | arr→downloaders. # Sonarr (TV) scrapes indexers and hands releases to the download client; both of those hops leave # the cluster over the shared PIA/WireGuard egress gateway (egress: vpn, ADR-0015; R-NET-05/06), and # the intra-cluster hops to qbittorrent/jackett are EXPLICIT east-west allows on top of default-deny # (R-NET-01/02). Validates against schemas/servicedefinition.schema.json. name: sonarr kind: container source: image: lscr.io/linuxserver/sonarr:4.0.15 # pinned stable v4 tag (R-UPD-01); update.range governs advancement # LinuxServer images run their service as the user `abc`, whose uid/gid these two set. They are # LOAD-BEARING for the bulk mount, not cosmetic: see the storage note below. The default is 911, # which cannot write a 1000:3000-owned 0755 tree. env: - name: PUID value: "1000" # owner uid of every directory under /mnt/Storage/plex - name: PGID value: "3000" # dominant group there; keeps files this writes readable to the # EXISTING non-k3s consumers of the same export, which stay live - name: TZ value: Etc/UTC # same safe default plex uses; the arrs schedule by local time podSecurityContext: # For the LONGHORN config PVC only. fsGroup does nothing on the NFS volume (the in-tree plugin # does no ownership management), but on a Longhorn filesystem the kubelet applies it, which is # what makes /config writable by the now-uid-1000 process WITHOUT relying on the image chowning # /config for itself when PUID changes. Matches PGID so the primary group owns the config too. fsGroup: 3000 exposure: internal # LAN-only web UI at sonarr.${BASE_DOMAIN} (docs/13 row 2; R-DNS/R-NET) egress: vpn # indexer scrape + downloader handoff steered via the shared VPN egress gateway, # kill-switch enforced OUTSIDE the workload (docs/03; ADR-0015; R-NET-05/06) storage: - name: config size: 5Gi class: longhorn # app DB / config on the 3-replica tier; DR-critical (docs/04; R-STATE-02) mountPath: /config # THE APP WRITES /config, NOT the renderer default /data/config. # LSIO images run with `-data=/config` (Jackett/qBittorrent likewise use # /config), so without this the Longhorn PVC mounted at /data/config sat # holding nothing but lost+found while the app wrote its entire # configuration to the container's EPHEMERAL writable layer — destroyed on # every pod recreation: restart, reschedule, image bump. Measured in-cluster # 2026-09-12: /data/config was dev=2080 with [lost+found], /config was # dev=2053 on the node's own disk with [Sentry asp config.xml logs]. # This is the SAME failure catalog/plex.yaml:54 already records fixing. # The live /config contents were copied onto each PVC before this landed, # so API keys and credentials other apps reference were preserved. # THE MEDIA TREE (docs/04 bulk tier) — ONE read-WRITE mount of the export ROOT at /media, giving # /media/tv (the library) and /media/downloads (the import source) UNDER THE SAME MOUNT. # # *** THE ROOT MOUNT IS THE POINT, NOT A CONVENIENCE. *** Sonarr imports by HARDLINKING or # atomically renaming a finished download into the library. Both are same-FILESYSTEM operations, # and to the kernel "same filesystem" means SAME MOUNT. The layout on the host is: # /mnt/Storage/plex/tv/ the library # /mnt/Storage/plex/downloads/complete/tv-sonarr/ where finished releases land # — siblings under one dataset, so a hardlink between them is legal on the server. But mounting # them as two subPath volumes (subPath: tv at /media/tv, subPath: downloads elsewhere) creates # TWO mounts, and link() across mounts fails EXDEV no matter what the server thinks. Sonarr then # silently degrades to copy-then-delete: every import rewrites the whole file over NFS and the # seeding copy is destroyed. Their smallest common ancestor IS the export root, so the root mount # is the MINIMAL mount that preserves hardlinks — least privilege bounded below by a hard # requirement, not a shortcut. (The cost is stated plainly: Sonarr can also write /media/movies, # which is radarr's. Narrowing that would cost hardlinks, which is a worse trade.) # # *** /media, NOT /data — AND THAT IS NOT A STYLE CHOICE. *** The config PVC above renders at the # renderer default /data/config. A media mount at /data would be mounted OVER it and MASK the # config volume entirely; the pod would come up with its app database shadowed by an 8 T media # tree. /media collides with nothing in a LinuxServer image and matches the path plex uses for # the same export, so when qbittorrent is eventually given this mount too, the arrs and the # download client will already agree on absolute paths — which is the other precondition for # hardlinked imports (otherwise every import needs a Remote Path Mapping). # # SEPARATE LATENT BUG, NOT FIXED HERE (deliberately — it changes live on-disk state): /data/config # is almost certainly the wrong place for this PVC in the first place. LinuxServer's arr images run # with `-data=/config`, so the app writes /config — the container's EPHEMERAL layer — while the # Longhorn PVC at /data/config sits unused. That is the EXACT failure plex's own mountPath comment # documents having already been bitten by. Fixing it means starting from an empty config volume, so # it belongs in its own change with the operator present. # # UID/GID — THE FAILURE THIS AVOIDS IS "MOUNTS FINE, CANNOT WRITE". The export is no_root_squash # + sec=sys, so the client's numeric uid is passed through verbatim and the server's ownership is # the whole story. The tree is 0755 owned 1000:3000 (some dirs 1000:1000), so ONLY uid 1000 can # write it. LinuxServer images default to PUID/PGID 911 — which would mount cleanly and then fail # every import with EACCES. The PUID/PGID set above make the process uid 1000, matching the owner. # # AND NOTE fsGroup CANNOT SUBSTITUTE FOR THAT. The in-tree NFS volume plugin performs no # ownership management, so podSecurityContext.fsGroup is a NO-OP on this volume — it is not the # lever here, and reaching for it is how this failure normally gets misdiagnosed. The fsGroup # that IS set above exists for the LONGHORN config PVC, so that changing PUID does not depend on # the image re-chowning /config for itself. - name: media class: bulk export: /mnt/Storage/plex # the ZFS dataset Storage/plex, on ${BULK_NFS_SERVER} mountPath: /media # => /media/tv (Root Folder) and /media/downloads (import source) size: 9Ti # ADVISORY ONLY on a static NFS PV: the dataset's 8.33 T used, # rounded up, so `kubectl get pv` reports something true. dependencies: - to: qbittorrent # arr → download client (docs/13 dependency graph; R-NET-02 explicit allow) port: 8080 protocol: tcp - to: jackett # arr → indexer proxy (docs/13 dependency graph; R-NET-02 explicit allow) port: 9117 protocol: tcp - to: plex # arr -> plex: required since library is on NFS which does not provide file CRUD notifications port: 32400 protocol: tcp ingressRoutes: - host: sonarr.${BASE_DOMAIN} port: 8989 # Sonarr web/API; TLS terminates at the shared Gateway (docs/08, docs/09) auth: mode: forward-auth # bare UI, no usable OIDC: oauth2-proxy → Keycloak at the Gateway (docs/15; R-IDP-02) groups: - media-users # authenticated AND in the media group (R-IDP-06) # See catalog/jackett.yaml for the full rationale: --allowed-group is repeatable and OR-ed, so # platform-admins here is a superset, not a widening (that group already carries cluster-admin # via k8s-oidc-rbac.yaml). Without it, an empty media-users locks every operator out with an # immediate 403 at the oauth2-proxy callback — observed on prod 2026-09-12. - platform-admins # operators of these services must not be locked out of them # NO `apiRoutes` HERE, AND NOT BECAUSE IT NEEDS NONE — because nothing measures that it does. # Sonarr is nearly the same codebase as Radarr, which is exactly what makes `^/api/` + # `^/signalr/` a tempting guess and still a guess. MEASURED 2026-10-01 on the live pod # (sonarr-ddf485775-8ckwt, 0 restarts, 23h): its oauth2-proxy log contains 13,643 lines and # ZERO unauthenticated requests — no /api/, no /signalr/, nothing but health probes. Nobody # has opened this UI since the cookie fix rolled, so the measurement is empty rather than # negative: it is evidence of no TRAFFIC, not evidence of no need. Add the routes when someone # reproduces the 302-on-XHR here, with the numbers. update: channel: stable # R-UPD-01 range: ">=4.0.0 <5.0.0" # stay on the Sonarr v4 stable line cadence: "0 4 * * *" autoRollbackOnFailedHealth: true # R-UPD-03 observability: metrics: true slis: - up # arr down => alert (docs/10; R-OBS-01) backup: policy: daily-offcluster # Sonarr config/DB is irreplaceable, backed up off-cluster (docs/04; R-STATE-03)