155 lines
11 KiB
YAML
155 lines
11 KiB
YAML
# ServiceDefinition: radarr
|
|
# Source of truth: spec docs/13-service-catalog.md row 3; docs/15-identity-and-access.md; docs/03.
|
|
# internal | egress VPN (+ kill-switch) | config on Longhorn | auth forward-auth | arr→downloaders.
|
|
# Radarr (movies) 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: radarr
|
|
kind: container
|
|
source:
|
|
image: lscr.io/linuxserver/radarr:5.28.0 # pinned stable v5 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 radarr.${BASE_DOMAIN} (docs/13 row 3; 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/movies (the library) and /media/downloads (the import source) UNDER THE SAME MOUNT.
|
|
#
|
|
# *** THE ROOT MOUNT IS THE POINT, NOT A CONVENIENCE. *** Radarr 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/movies/ the library
|
|
# /mnt/Storage/plex/downloads/complete/radarr/ 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: movies at /media/movies, subPath: downloads elsewhere) creates
|
|
# TWO mounts, and link() across mounts fails EXDEV no matter what the server thinks. Radarr 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: Radarr can also write /media/tv,
|
|
# which is sonarr'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/movies (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
|
|
port: 32400 # arr -> plex: required since library is on NFS which does not provide file CRUD notifications
|
|
protocol: tcp
|
|
ingressRoutes:
|
|
- host: radarr.${BASE_DOMAIN}
|
|
port: 7878 # Radarr 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
|
|
# Answer Radarr's machine endpoints with 401 instead of a 302 to Keycloak (schema 1.9.0;
|
|
# renders --api-route, singular and repeatable, onto the oauth2-proxy sidecar).
|
|
#
|
|
# `^/signalr/` — MEASURED ON THE LIVE POD, 2026-10-01, 23h after the cookie fix deployed
|
|
# (radarr-7d55cbffb9-42hmh, 0 restarts): 232 unauthenticated requests in the pod's whole
|
|
# lifetime and ALL 232 are the same call — `POST /signalr/messages/negotiate` from a browser
|
|
# UA, answered 302 by the proxy with upstream `-`, i.e. Radarr never saw one of them. That is
|
|
# a left-open tab whose session the cookie rename orphaned, polling SignalR forever, silently
|
|
# redirected every time and NEVER re-prompting the operator. It is the reported symptom
|
|
# ("reloading never fixes it and I am never re-prompted") still running in production on a
|
|
# service nobody was watching. With 401 the UI's connection breaks LOUDLY, the operator
|
|
# reloads, and `/` is outside both regexes so the reload signs them back in.
|
|
#
|
|
# `^/api/` — measured on radarr, but on the PRE-FIX pod and NOT by me: the 2026-09-30
|
|
# diagnosis (decisions/2026-09-30-jackett-oauth2-proxy-session.md) recorded radarr's 156
|
|
# unauthenticated requests as `/api/v3/command` (62), `/api/v3/queue/details` (46) and
|
|
# `/signalr/messages/negotiate` (43). The current pod has logged ZERO /api/ traffic, so I did
|
|
# not re-measure it; it is included because 108 of 156 measured rejections on this app were
|
|
# /api/v3 XHRs, which is this app's own evidence rather than an analogy to another app.
|
|
#
|
|
# SCOPE: both are JSON/RPC namespaces a browser never NAVIGATES to. Radarr's UI is served at
|
|
# `/` with assets under /Content/ and /MediaCover/, none of which match — so a plain page load
|
|
# is still 302'd into a silent SSO sign-in.
|
|
apiRoutes:
|
|
- ^/api/
|
|
- ^/signalr/
|
|
update:
|
|
channel: stable # R-UPD-01
|
|
range: ">=5.0.0 <6.0.0" # stay on the Radarr v5 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 # Radarr config/DB is irreplaceable, backed up off-cluster (docs/04; R-STATE-03)
|