From 78bc53afc77120ab9c1b5dc914c0e3bcc3f5efb0 Mon Sep 17 00:00:00 2001 From: Keith Jackson Date: Sat, 12 Sep 2026 23:32:03 -0500 Subject: [PATCH] =?UTF-8?q?feat(qbittorrent):=20attach=20the=20downloads?= =?UTF-8?q?=20area=20of=20the=20bulk=20export=20=E2=80=94=20the=20chain=20?= =?UTF-8?q?was=20broken=20end=20to=20end?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit qBittorrent was the one member of the download chain with NO media volume. Its live config said Session\DefaultSavePath=/downloads/ Session\TempPath=/downloads/incomplete/ Downloads\SavePath=/downloads/ while `ls /downloads` in the container returned "No such file or directory". The catalog entry declared exactly one volume — config at /config on longhorn — and carried a comment deferring the downloads volume "until OQ-2 is resolved". OQ-2 WAS resolved (schema 1.5.0 static bulk NFS PVs); plex, sonarr, radarr and nextcloud all took their mounts and qBittorrent's was simply never added. So the download client was writing, or failing to write, a path that did not exist — and anything it did create would have been invisible to sonarr/radarr, lost on every pod recreation, and consuming the node's ephemeral disk until then. WHAT THIS ADDS to catalog/qbittorrent.yaml (rendered into apps/wave-0): * storage[downloads]: class bulk, export /mnt/Storage/plex, subPath downloads, mountPath /downloads, 9Ti advisory. ONE mount of the SAME export sonarr, radarr (rw) and plex (ro) already consume, narrowed to the handoff subtree. * env PUID=1000 / PGID=3000. LOAD-BEARING, not cosmetic: the container ran as uid 911 and /mnt/Storage/plex/downloads is 0755 owned 1000:1000, so the export would have mounted cleanly and then failed every write with EACCES. Measured both ways on prod: a write as uid 1000 succeeded, the same write as uid 911 was denied. * podSecurityContext.fsGroup 3000, for the LONGHORN config PVC only, exactly as sonarr/radarr set it — fsGroup is a no-op on an in-tree NFS volume. WHY subPath AND NOT THE EXPORT ROOT AT /media (sonarr/radarr's shape): a download client has no business writing the library. The arrs need the root mount because THEY hardlink downloads/ -> tv/|movies/ and link() is a same-MOUNT operation; qBittorrent performs no such link, so narrowing to downloads/ removes movies/, tv/ and photos/ from its mount namespace without removing any operation it performs. It is still ONE mount of the export, so the L0 assertion that no workload mounts one export twice (the EXDEV trap) holds — and the arrs' own link() stays intra-mount. Proven live: nlink 1 -> 2, same inode, no EXDEV, every step executed as uid 1000. NETWORK: nothing to grant and nothing to steer. An NFS PersistentVolume is mounted by the NODE KERNEL in the host netns — the established socket to 192.168.2.102:2049 is on the node's own LAN address, the pod netns has no socket to 2049 at all, and no CiliumNetworkPolicy on this cluster names that host or port. `ip route get 192.168.2.102` picks eno1 even on the tunnel-owning node, and Cilium's own egress map lists 192.168.0.0/16 as an Excluded CIDR for every vpn-class pod while 0.0.0.0/0 goes to the gateway. Public egress still exits the PIA address; the VPN guarantee is untouched. NOT CLOSED BY THIS COMMIT, deliberately: absolute paths still differ between this pod (/downloads/x) and the arrs (/media/downloads/x), so Completed Download Handling needs one Remote Path Mapping per arr. That lives in the arrs' own databases, not in git. Co-Authored-By: Claude Opus 5 --- apps/wave-0/qbittorrent.yaml | 57 ++++++++++++++++++ catalog/qbittorrent.yaml | 109 ++++++++++++++++++++++++++++++----- 2 files changed, 152 insertions(+), 14 deletions(-) diff --git a/apps/wave-0/qbittorrent.yaml b/apps/wave-0/qbittorrent.yaml index f956793..25ede18 100644 --- a/apps/wave-0/qbittorrent.yaml +++ b/apps/wave-0/qbittorrent.yaml @@ -35,6 +35,33 @@ spec: - protocol: TCP port: 53 --- +# PersistentVolume qbittorrent-downloads - static external NFS (bulk tier) +apiVersion: v1 +kind: PersistentVolume +metadata: + name: qbittorrent-downloads + labels: + app.kubernetes.io/name: qbittorrent +spec: + capacity: + storage: 9Ti + accessModes: + - ReadWriteMany + persistentVolumeReclaimPolicy: Retain + storageClassName: '' + mountOptions: + - nfsvers=4.1 + - hard + - noatime + - timeo=600 + - retrans=2 + nfs: + server: ${BULK_NFS_SERVER} + path: /mnt/Storage/plex + claimRef: + namespace: qbittorrent + name: qbittorrent-downloads +--- # PVC qbittorrent-config (R-STATE) apiVersion: v1 kind: PersistentVolumeClaim @@ -51,6 +78,23 @@ spec: requests: storage: 5Gi --- +# PVC qbittorrent-downloads (R-STATE) +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: qbittorrent-downloads + namespace: qbittorrent + labels: + app.kubernetes.io/name: qbittorrent +spec: + accessModes: + - ReadWriteMany + storageClassName: '' + volumeName: qbittorrent-downloads + resources: + requests: + storage: 9Ti +--- # Deployment (R-PLAT-02) apiVersion: apps/v1 kind: Deployment @@ -81,9 +125,17 @@ spec: image: lscr.io/linuxserver/qbittorrent:5.1.2 ports: - containerPort: 8080 + env: + - name: PUID + value: '1000' + - name: PGID + value: '3000' volumeMounts: - name: config mountPath: /config + - name: downloads + mountPath: /downloads + subPath: downloads - name: oauth2-proxy image: quay.io/oauth2-proxy/oauth2-proxy:v7.8.1 imagePullPolicy: IfNotPresent @@ -149,10 +201,15 @@ spec: port: http initialDelaySeconds: 5 periodSeconds: 15 + securityContext: + fsGroup: 3000 volumes: - name: config persistentVolumeClaim: claimName: qbittorrent-config + - name: downloads + persistentVolumeClaim: + claimName: qbittorrent-downloads --- # Service apiVersion: v1 diff --git a/catalog/qbittorrent.yaml b/catalog/qbittorrent.yaml index f0fe0bc..d9d3889 100644 --- a/catalog/qbittorrent.yaml +++ b/catalog/qbittorrent.yaml @@ -4,19 +4,18 @@ # internal download client | egress VPN + kill-switch | config on Longhorn | auth forward-auth # (bare web UI, no native OIDC). # -# BULK DOWNLOADS TIER — OPEN PREREQUISITE, intentionally NOT declared here (mirrors plex + sonarr/radarr): -# docs/13 row 13 places qBittorrent's downloads/staging volume on the `bulk` tier (large, non-3x- -# replicated media handoff to the arr stack). The `bulk` StorageClass exists -# (platform/longhorn/storageclasses.yaml) and both the schema and renderer support `class: bulk`, BUT -# it is a NON-PROVISIONING STUB pending OQ-2 (its NFS/SMB backing is REPLACE-ME and the NFS CSI driver -# is not installed) — a `bulk` PVC would never bind and would leave the pod Pending. Declaring it now -# would also pull the bulk-tier requirement into the change's "touched" set, whose in-cluster -# conformance test is not yet scaffolded, blocking the merge gate. So the downloads volume is DEFERRED -# and this onboarding ships config-only. Once OQ-2 is resolved and `bulk` provisions, attach downloads -# with a one-line add under storage: -# - name: downloads -# class: bulk # external mount; size omitted (sized outside the cluster) -# then re-render. See the WS-APPS report for the full bulk-downloads finding. +# 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 @@ -32,6 +31,27 @@ 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: @@ -49,7 +69,68 @@ storage: # 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. -dependencies: [] # default-deny: reaches only DNS; arr reach it north of its own egress (docs/03; R-NET-01/02) + # 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 *NOT* CLOSED BY THIS CHANGE — see the report/DECISIONS-LOG. This + # pod calls the finished file /downloads/; sonarr/radarr call the same bytes + # /media/downloads/. Same inode, different absolute path, so Completed Download Handling needs + # ONE Remote Path Mapping per arr (remote host `qbittorrent`, remote path /downloads/, local path + # /media/downloads/). That lives in the arrs' own databases (app state on their config PVCs), not in + # git, so it is an operator step and is called out rather than silently assumed. 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. + - 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)