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)