qBittorrent reports finished releases at /downloads/<name>; sonarr and radarr
see the same inodes at /media/downloads/<name>, so Completed Download Handling
could never resolve what the client handed it. Configured, on prod, via each
app's own HTTP API:
* download client qBittorrent -> qbittorrent.qbittorrent.svc.cluster.local:8080
(it did not exist in EITHER arr: both /api/v3/downloadclient returned [])
* Remote Path Mapping /downloads/ -> /media/downloads/ for that host
Both built from each app's /downloadclient/schema, matched on identity before
writing, and re-running creates nothing. downloadclient/testall reports
isValid=true in both. Proven with a negative control: Sonarr's queue showed
outputPath=/downloads/... with the mapping deleted and /media/downloads/...
with it present, for the same completed torrent.
This state lives in sonarr.db / radarr.db on the config PVCs, NOT in git — a
rebuild from git alone does not restore it. Recorded as a wart, with path
agreement (repointing SavePath to /media/downloads) recommended as the durable
fix and put to the operator rather than taken unilaterally.
Co-Authored-By: Claude Opus 5 <[email protected]>
173 lines
13 KiB
YAML
173 lines
13 KiB
YAML
# ServiceDefinition: qbittorrent
|
|
# Source of truth: spec docs/13-service-catalog.md row 13; docs/03-networking-isolation-and-egress.md;
|
|
# docs/15-identity-and-access.md; decisions/adr-0015 (VPN egress steering).
|
|
# internal download client | egress VPN + kill-switch | config on Longhorn | auth forward-auth
|
|
# (bare web UI, no native OIDC).
|
|
#
|
|
# BULK DOWNLOADS TIER — DELIVERED 2026-09-12 (the deferral below is CLOSED; kept for the record).
|
|
# This entry used to ship config-only, with a comment promising to attach the downloads volume "once
|
|
# OQ-2 is resolved". OQ-2 was resolved for STATIC pre-existing datasets (schema 1.5.0 / renderer
|
|
# `class: bulk` + `export` -> a static, pre-bound NFS PV), and plex/sonarr/radarr/nextcloud all took
|
|
# their media mounts — but qBittorrent's was never added, so the DOWNLOAD CLIENT was the one member
|
|
# of the chain with no media volume at all. Measured on prod 2026-09-12:
|
|
# Session\DefaultSavePath=/downloads/ Session\TempPath=/downloads/incomplete/
|
|
# Downloads\SavePath=/downloads/ `ls /downloads` -> No such file or directory
|
|
# i.e. the app was configured to write a path that DID NOT EXIST, in a container with no volume there.
|
|
# Anything it did manage to create would have been invisible to sonarr/radarr, lost on every pod
|
|
# recreation, and filling the NODE's ephemeral disk in the meantime. The `downloads` entry under
|
|
# storage: below closes that. See docs/BULK-NFS-STORAGE.md and the bulk-tier L0 conformance test.
|
|
#
|
|
# VPN-class (docs/13 row 13; docs/03 "VPN-class set"): ALL of this pod's public-internet egress
|
|
# (peer/tracker/DHT traffic) MUST leave via the node PIA tunnel, never the WAN. `egress: vpn` renders
|
|
# the homelab.keithjackson.dev/egress-class=vpn pod label the shared CiliumEgressGatewayPolicy
|
|
# (platform/vpn-egress/egress-steering.yaml) selects on to steer + kill-switch the traffic OUTSIDE the
|
|
# workload (R-NET-05/06/07). No independent egress path; fail-closed if tun0 is down.
|
|
#
|
|
# Inbound reachability (<- sonarr, <- radarr) is an EAST-WEST allow rendered on the arr side; being
|
|
# VPN-class governs egress, not intra-cluster reachability. This service initiates nothing east-west, so
|
|
# dependencies is empty (reaches only DNS + VPN internet).
|
|
# Validates against schemas/servicedefinition.schema.json.
|
|
name: qbittorrent
|
|
kind: container
|
|
source:
|
|
image: lscr.io/linuxserver/qbittorrent:5.1.2 # pinned stable 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 — exactly as on sonarr/radarr. Measured on prod
|
|
# 2026-09-12 BEFORE this change: `id` inside the container reported the qbittorrent-nox process as
|
|
# uid=911 gid=911, and /mnt/Storage/plex/downloads is 0755 owned 1000:1000. 0755 grants write to the
|
|
# OWNER ONLY, so uid 911 would have mounted the export cleanly and then failed every write with
|
|
# EACCES — the "mounts fine, cannot write" failure the sonarr entry warns about at length.
|
|
env:
|
|
- name: PUID
|
|
value: "1000" # owner uid of /mnt/Storage/plex/downloads (and of every dir under the export)
|
|
- name: PGID
|
|
value: "3000" # the export's dominant group, matching sonarr/radarr: files this writes stay
|
|
# readable to the EXISTING non-k3s consumers of the same dataset, and land
|
|
# group-owned the same way the arrs' own writes do
|
|
podSecurityContext:
|
|
# For the LONGHORN config PVC only, and for the same reason radarr/sonarr set it: fsGroup is a NO-OP
|
|
# on the NFS volume (the in-tree plugin does no ownership management), but on a Longhorn filesystem
|
|
# the kubelet applies it, so /config becomes group-writable by the now-uid-1000 process without
|
|
# depending on the image chowning it. (This image DOES chown: init-qbittorrent-config runs
|
|
# `lsiown -R abc:abc /config`. Belt and braces — the live /config was 911:911 before this change,
|
|
# and losing write access to it would lose the WebUI password hash and the session state.)
|
|
fsGroup: 3000
|
|
exposure: internal # LAN-only web UI at qbittorrent.${BASE_DOMAIN} (docs/13 row 13; R-DNS/R-NET)
|
|
egress: vpn # ALL public egress via node PIA tunnel + kill-switch (docs/03; ADR-0015; R-NET-05/06)
|
|
storage:
|
|
- name: config
|
|
size: 5Gi
|
|
class: longhorn # settings/categories/session on 3-replica Longhorn; 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 DOWNLOADS AREA — ONE mount of the SAME export sonarr/radarr/plex already consume
|
|
# (192.168.2.102:/mnt/Storage/plex), narrowed by subPath to the `downloads` subtree and presented at
|
|
# /downloads, which is precisely where this app is ALREADY configured to write.
|
|
#
|
|
# WHY subPath: downloads AND NOT THE EXPORT ROOT AT /media (the shape sonarr/radarr use):
|
|
# * LEAST PRIVILEGE, AND IT COSTS NOTHING HERE. A download client has no business writing the
|
|
# LIBRARY. The arrs need the root mount because THEY hardlink downloads/ -> tv/ or movies/ and
|
|
# link() is a same-MOUNT operation (EXDEV otherwise) — their smallest common ancestor genuinely
|
|
# IS the export root. qBittorrent performs no such cross-directory link: it writes into the
|
|
# downloads tree and nothing else, so narrowing to that subtree removes movies/, tv/ and photos/
|
|
# from this pod's mount namespace without removing any operation it actually performs.
|
|
# * HARDLINKS STILL WORK, because they are not done here. The arrs still see ONE mount containing
|
|
# both /media/downloads and /media/tv, so their link() stays intra-mount. Proven live 2026-09-12:
|
|
# a file created from this pod under /downloads was hardlinked from the sonarr pod between
|
|
# /media/downloads/... and /media/tv/... with st_nlink 1 -> 2 and no EXDEV.
|
|
# * IT IS ONE MOUNT, so the L0 test `bulk-tier-distinct-from-replicated` assertion (6) — no
|
|
# workload mounts one export twice — holds. That assertion is about TWO mounts inside ONE pod;
|
|
# a single subPath mount is not the EXDEV trap, two are.
|
|
# * The narrowing is a CLIENT/kubelet guarantee, stated honestly: the NFS server still exports the
|
|
# whole dataset to this node, so the containment is the mount namespace, not the server's ACLs —
|
|
# the same class of caveat docs/BULK-NFS-STORAGE.md §5 records for plex's readOnly.
|
|
#
|
|
# NOT readOnly — this is the one workload whose whole job is WRITING here.
|
|
#
|
|
# KNOWN, DELIBERATE SIDE EFFECT ON SHARED STATE: the image's init-qbittorrent-config runs
|
|
# `lsiown abc:abc /downloads` (non-recursive) whenever /downloads is a mount, so starting this pod
|
|
# chgrps /mnt/Storage/plex/downloads from 1000:1000 to 1000:3000 on the SERVER. Benign and verified:
|
|
# the directory is mode 0755, whose group bits (r-x) are identical to its other bits, so no consumer
|
|
# — k3s or legacy — gains or loses any access. Recorded because it is a write to state shared with
|
|
# non-cluster consumers, which should never be a surprise.
|
|
#
|
|
# PATH AGREEMENT WITH THE ARRS IS STILL *NOT* CLOSED — see DECISIONS-LOG 2026-09-12 "Remote Path
|
|
# Mappings". This pod calls the finished file /downloads/<name>; sonarr/radarr call the same bytes
|
|
# /media/downloads/<name>. Same inode, different absolute path, so Completed Download Handling needs
|
|
# ONE Remote Path Mapping per arr (remote host `qbittorrent.qbittorrent.svc.cluster.local`, remote
|
|
# path /downloads/, local path /media/downloads/).
|
|
#
|
|
# THOSE MAPPINGS ARE NOW CONFIGURED AND VERIFIED ON PROD (2026-09-12), together with the qBittorrent
|
|
# download client itself — which did not exist in EITHER arr until then (both
|
|
# `GET /api/v3/downloadclient` returned `[]`, so completed-download handling had never been wired up
|
|
# at all). `downloadclient/testall` reports isValid=true in both, and Sonarr's queue was observed
|
|
# reporting outputPath=/downloads/... with the mapping deleted and /media/downloads/... with it
|
|
# present — same torrent, same client.
|
|
#
|
|
# BUT THEY LIVE IN THE ARRS' OWN DATABASES (app state on their config PVCs), NOT IN GIT: a rebuild
|
|
# from git alone does NOT restore them and no gate here notices their absence. The alternative —
|
|
# repointing qBittorrent's SavePath at /media/downloads and mounting this subPath there instead —
|
|
# buys path agreement at the cost of editing THIS app's config PVC, which is also app state; it was
|
|
# not chosen because it changes a running download client's save path, which the deferral above
|
|
# already shows is easy to get wrong. It is nonetheless the RECOMMENDED end state (it makes the
|
|
# mappings unnecessary and puts the mountPath half of the fix in git) and is awaiting an operator
|
|
# decision, not a technical blocker.
|
|
#
|
|
# RELATED OPEN BLOCKER: this app has no durable WebUI credential (no WebUI\Username /
|
|
# WebUI\Password_PBKDF2 in qBittorrent.conf), so v5 issues a NEW temporary `admin` password on every
|
|
# start. The arrs' saved download-client password therefore goes stale on every pod restart. See the
|
|
# same DECISIONS-LOG entry for the two fixes, neither of which was taken unilaterally.
|
|
- name: downloads
|
|
class: bulk
|
|
export: /mnt/Storage/plex # the ZFS dataset Storage/plex, on ${BULK_NFS_SERVER} — the SAME export
|
|
# plex (ro) and sonarr/radarr (rw) mount; one dataset, one set of inodes
|
|
subPath: downloads # the handoff subtree ONLY: no movies/, no tv/, no photos/
|
|
mountPath: /downloads # matches the app's EXISTING Session\DefaultSavePath / TempPath, so no
|
|
# change to config-PVC app state is needed to make this take effect
|
|
size: 9Ti # ADVISORY ONLY on a static NFS PV (the ZFS pool is the real limit): the
|
|
# dataset's 8.4 T used rounded up, stated the same way plex/sonarr/radarr
|
|
# state it so `kubectl get pv` reports something true
|
|
dependencies: [] # default-deny: reaches only DNS; arr reach it north of its own egress (docs/03; R-NET-01/02).
|
|
# STILL EMPTY WITH THE NFS MOUNT ABOVE, AND THAT IS CORRECT, NOT AN
|
|
# OVERSIGHT. An NFS PersistentVolume is mounted by the NODE KERNEL in the
|
|
# HOST netns, not by this pod: there is no pod-side socket to 2049, so no
|
|
# CiliumNetworkPolicy can permit or deny it and none is needed. Verified by
|
|
# construction — sonarr and radarr have mounted this same export read-write
|
|
# for weeks and neither has any CNP naming the NFS server or port 2049
|
|
# (grep apps/wave-1/sonarr.yaml: none). The SAME fact is what keeps the VPN
|
|
# egress gateway off this traffic (its selector is a podSelector), and
|
|
# 192.168.2.102 is inside the 192.168.0.0/16 excludedCIDR besides — see
|
|
# platform/vpn-egress/egress-steering.yaml, "BULK NFS".
|
|
ingressRoutes:
|
|
- host: qbittorrent.${BASE_DOMAIN}
|
|
port: 8080 # qBittorrent WebUI; protected at the Gateway by forward-auth (docs/09, docs/15)
|
|
auth:
|
|
mode: forward-auth # oauth2-proxy -> Keycloak; bare UI, no usable native OIDC (docs/15; R-IDP-02)
|
|
groups:
|
|
- media-users # authenticated AND in an authorized 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
|
|
update:
|
|
channel: stable # R-UPD-01
|
|
range: ">=5.0.0 <6.0.0" # stay on the qBittorrent 5.x stable line
|
|
cadence: "0 4 * * *"
|
|
autoRollbackOnFailedHealth: true # R-UPD-03
|
|
observability:
|
|
metrics: true
|
|
slis:
|
|
- up # download client down -> alert (docs/10; R-OBS-01)
|
|
backup:
|
|
policy: daily-offcluster # config (categories/settings) backed up off-cluster (docs/04; R-STATE-03)
|