From 39e9613ee3315e03a6ec81a93e150adbea3ac615 Mon Sep 17 00:00:00 2001 From: jklein Date: Mon, 24 Aug 2026 21:01:53 +0200 Subject: [PATCH] freesailarr: one volume entry per media claim (2.0.2+up1.0.0) media.downloads, media.movies and media.tv rendered three fixed volume entries, one per category. Two categories on the same existingClaim therefore produced two pod volumes with an identical claimName, and such a pod never starts: it stays in Init:0/1 without a single kubelet event (measured on RKE2/Longhorn, RWO and RWX alike). The volume list is now deduplicated by claim name. A new mediaVolumeName helper names the entry after the first ENABLED category in the fixed order downloads, movies, tv that carries the claim; the other categories mount that same entry and differ only in their subPath. The restriction to enabled categories matters because the three entries render under different conditions - a canonical owner whose entry is not rendered would be a dangling reference. With three distinct claims the chart renders byte-identically to 2.0.1, hence a patch bump. Also corrected in the values documentation, after measuring instead of assuming: the kubelet creates missing subPath directories itself, but they inherit the claim root's mode, and fsGroup cannot be relied on to fix that - Longhorn's fsGroupPolicy (ReadWriteOnceWithFSType) skips fsGroup on RWX claims entirely. What has to hold is that the claim root is writable for PUID/PGID. No chown or init container was added. --- freesailarr/Chart.yaml | 2 +- freesailarr/templates/NOTES.txt | 10 ++- freesailarr/templates/_helpers.tpl | 90 ++++++++++++++++++++++ freesailarr/templates/freesailarr.sfs.yaml | 31 ++++++-- freesailarr/values.yaml | 50 +++++++++++- 5 files changed, 168 insertions(+), 15 deletions(-) diff --git a/freesailarr/Chart.yaml b/freesailarr/Chart.yaml index 5b8025e..bbf3728 100644 --- a/freesailarr/Chart.yaml +++ b/freesailarr/Chart.yaml @@ -8,7 +8,7 @@ description: A Helm chart to deploy a Wireguard-tunneled media automation stack type: application -version: 2.0.1+up1.0.0 +version: 2.0.2+up1.0.0 # The chart's own version marker, freely chosen and tied to no upstream. The # stack bundles nine independently versioned projects, so there is no single # "app version" to track here. diff --git a/freesailarr/templates/NOTES.txt b/freesailarr/templates/NOTES.txt index e8e6976..5e4fac7 100644 --- a/freesailarr/templates/NOTES.txt +++ b/freesailarr/templates/NOTES.txt @@ -87,10 +87,12 @@ THE MEDIA CLAIMS ARE YOURS, THE CONFIG VOLUMES ARE THE CHART'S Kubernetes makes every volumeMount its own bind mount and link() across two mount points fails with EXDEV regardless of subPath, so an import copies. - The containers write as {{ .Values.puid }}:{{ .Values.pgid }}. If the claims already hold data - owned by someone else, fix the ownership before the first start — the pod - will not do it for you (fsGroupChangePolicy is OnRootMismatch, deliberately, - so a large library is not recursively re-chowned on every start). + The containers write as {{ .Values.puid }}:{{ .Values.pgid }}. The claim's root directory has to be + writable for them, and a subPath directory the kubelet creates inherits that + root's mode — so fix the ownership before the first start. The pod will not + do it for you: fsGroupChangePolicy is OnRootMismatch so a large library is + not re-chowned on every start, and on an RWX claim a driver like Longhorn + (fsGroupPolicy ReadWriteOnceWithFSType) ignores fsGroup altogether. {{- if eq $replicas "0" }} SCALED TO ZERO diff --git a/freesailarr/templates/_helpers.tpl b/freesailarr/templates/_helpers.tpl index 47e236b..5e695ba 100644 --- a/freesailarr/templates/_helpers.tpl +++ b/freesailarr/templates/_helpers.tpl @@ -219,6 +219,96 @@ alive. It is not what opens the ports today. {{- join "," $ports -}} {{- end -}} +{{/* +Whether a media category is mounted at all, i.e. whether at least one app that +mounts it is enabled. "true" when it is, the empty string when it is not (an +empty string is falsy in a template "if", so the result can be used directly). + + downloads: qbittorrent, radarr, sonarr + movies: radarr, bazarr + tv: sonarr, bazarr + +Input: dict "root" $ "category" <"downloads"|"movies"|"tv">. +*/}} +{{- define "freesailarr.mediaCategoryEnabled" -}} +{{- $v := .root.Values -}} +{{- if eq .category "downloads" -}} +{{- if or $v.qbittorrent.enabled $v.radarr.enabled $v.sonarr.enabled -}}true{{- end -}} +{{- else if eq .category "movies" -}} +{{- if or $v.radarr.enabled $v.bazarr.enabled -}}true{{- end -}} +{{- else if eq .category "tv" -}} +{{- if or $v.sonarr.enabled $v.bazarr.enabled -}}true{{- end -}} +{{- else -}} +{{- fail (printf "unknown media category %q" .category) -}} +{{- end -}} +{{- end -}} + +{{/* +The pod volume name a media category mounts (issue #164). + +Several categories may point at the same existingClaim - one library volume with +the categories as sub-directories is the intended layout. A pod may reference +such a claim only ONCE: two volume entries with the same claimName leave the pod +in Init:0/1 forever, without a single kubelet event (measured on RKE2/Longhorn, +with RWO as well as RWX). The claim therefore gets exactly one volume entry and +the categories differ only in the subPath of their mounts. + +This helper names the owner of that entry: the FIRST ENABLED category, in the +fixed order + + downloads, movies, tv + +that carries the same claim. Two properties of that rule are load-bearing: + + - The order is fixed and defined here, not derived from the values. A rule + like "the alphabetically smallest" or "whichever comes first in the values" + would move the volume name when an unrelated flag changes, and a moved + volume name is a pod restart. + - Only ENABLED categories can own the entry, because the three entries are + rendered under different conditions (see mediaCategoryEnabled above). A + disabled owner renders no entry, so its name would be a dangling reference + and the pod would be rejected by the API server. Example: with qbittorrent, + radarr and sonarr off and bazarr on, "downloads" is not mounted at all - + "movies" owns a claim shared by all three categories. + +A category with no claim owns itself, so the required() message for the missing +claim still fires at the category the user actually configured. + +Input: dict "root" $ "category" <"downloads"|"movies"|"tv">. +Usage (mount) : name: {{ include "freesailarr.mediaVolumeName" (dict "root" $ "category" "movies") }} +Usage (volume): render the entry only for a category that owns itself. +*/}} +{{- define "freesailarr.mediaVolumeName" -}} +{{- $root := .root -}} +{{- $claim := (index $root.Values.media .category).existingClaim | default "" -}} +{{- $owner := "" -}} +{{- if $claim -}} +{{- range $candidate := list "downloads" "movies" "tv" -}} +{{- if not $owner -}} +{{- if include "freesailarr.mediaCategoryEnabled" (dict "root" $root "category" $candidate) -}} +{{- if eq ((index $root.Values.media $candidate).existingClaim | default "") $claim -}} +{{- $owner = $candidate -}} +{{- end -}} +{{- end -}} +{{- end -}} +{{- end -}} +{{- end -}} +{{- if not $owner -}} +{{- $owner = .category -}} +{{- end -}} +media-{{ $owner }} +{{- end -}} + +{{/* +Whether a media category owns its volume entry, i.e. whether the volumes block +renders an entry for it. "true" or the empty string, see mediaVolumeName above. + +Input: dict "root" $ "category" <"downloads"|"movies"|"tv">. +*/}} +{{- define "freesailarr.mediaVolumeOwned" -}} +{{- if eq (include "freesailarr.mediaVolumeName" .) (printf "media-%s" .category) -}}true{{- end -}} +{{- end -}} + {{/* Recursive merge of an override onto defaults where null never deletes and set values always win, including false and 0 (issue #99). diff --git a/freesailarr/templates/freesailarr.sfs.yaml b/freesailarr/templates/freesailarr.sfs.yaml index d896f3c..8033339 100644 --- a/freesailarr/templates/freesailarr.sfs.yaml +++ b/freesailarr/templates/freesailarr.sfs.yaml @@ -223,7 +223,7 @@ spec: - mountPath: /config name: qbittorrent-config - mountPath: /downloads - name: media-downloads + name: {{ include "freesailarr.mediaVolumeName" (dict "root" $ "category" "downloads") }} {{- with .Values.media.downloads.subPath }} subPath: {{ . | quote }} {{- end }} @@ -319,12 +319,12 @@ spec: - mountPath: /config name: radarr-config - mountPath: /movies - name: media-movies + name: {{ include "freesailarr.mediaVolumeName" (dict "root" $ "category" "movies") }} {{- with .Values.media.movies.subPath }} subPath: {{ . | quote }} {{- end }} - mountPath: /downloads - name: media-downloads + name: {{ include "freesailarr.mediaVolumeName" (dict "root" $ "category" "downloads") }} {{- with .Values.media.downloads.subPath }} subPath: {{ . | quote }} {{- end }} @@ -387,12 +387,12 @@ spec: - mountPath: /config name: sonarr-config - mountPath: /tv - name: media-tv + name: {{ include "freesailarr.mediaVolumeName" (dict "root" $ "category" "tv") }} {{- with .Values.media.tv.subPath }} subPath: {{ . | quote }} {{- end }} - mountPath: /downloads - name: media-downloads + name: {{ include "freesailarr.mediaVolumeName" (dict "root" $ "category" "downloads") }} {{- with .Values.media.downloads.subPath }} subPath: {{ . | quote }} {{- end }} @@ -455,12 +455,12 @@ spec: - mountPath: /config name: bazarr-config - mountPath: /movies - name: media-movies + name: {{ include "freesailarr.mediaVolumeName" (dict "root" $ "category" "movies") }} {{- with .Values.media.movies.subPath }} subPath: {{ . | quote }} {{- end }} - mountPath: /tv - name: media-tv + name: {{ include "freesailarr.mediaVolumeName" (dict "root" $ "category" "tv") }} {{- with .Values.media.tv.subPath }} subPath: {{ . | quote }} {{- end }} @@ -475,21 +475,38 @@ spec: # Writable /tmp/gluetun for gluetun's public-IP cache (see the mount). - name: gluetun-tmp emptyDir: {} + {{- /* + One volume entry per distinct media claim, not per category: several + categories may point at the same existingClaim, and a pod that + references one claim twice never starts (#164). The entry belongs to the + first enabled category that carries the claim; the others mount the same + entry with their own subPath - see the mediaVolumeName helper for the + ordering rule. Deliberately a template comment and not a "#" one like + its neighbours: it must not appear in the rendered manifest, so that a + setup with three separate claims still renders byte-identically to + 2.0.1 and the diff itself proves the change is inert there. + */}} {{- if or .Values.qbittorrent.enabled .Values.radarr.enabled .Values.sonarr.enabled }} + {{- if include "freesailarr.mediaVolumeOwned" (dict "root" $ "category" "downloads") }} - name: media-downloads persistentVolumeClaim: claimName: {{ required "media.downloads.existingClaim is required when qbittorrent, radarr or sonarr is enabled" .Values.media.downloads.existingClaim }} {{- end }} + {{- end }} {{- if or .Values.radarr.enabled .Values.bazarr.enabled }} + {{- if include "freesailarr.mediaVolumeOwned" (dict "root" $ "category" "movies") }} - name: media-movies persistentVolumeClaim: claimName: {{ required "media.movies.existingClaim is required when radarr or bazarr is enabled" .Values.media.movies.existingClaim }} {{- end }} + {{- end }} {{- if or .Values.sonarr.enabled .Values.bazarr.enabled }} + {{- if include "freesailarr.mediaVolumeOwned" (dict "root" $ "category" "tv") }} - name: media-tv persistentVolumeClaim: claimName: {{ required "media.tv.existingClaim is required when sonarr or bazarr is enabled" .Values.media.tv.existingClaim }} {{- end }} + {{- end }} {{- if .Values.flaresolverr.enabled }} - name: flaresolverr-tmp emptyDir: {} diff --git a/freesailarr/values.yaml b/freesailarr/values.yaml index 106ffd3..8bfa404 100644 --- a/freesailarr/values.yaml +++ b/freesailarr/values.yaml @@ -54,6 +54,13 @@ securityContext: # group-writable for the non-root containers; fsGroupChangePolicy # OnRootMismatch avoids recursively re-chowning the (large, shared) media # claims on every pod start. + # How far that reaches is the CSI driver's decision, not this chart's, and the + # media claims are the case where it stops short: a driver whose fsGroupPolicy + # is ReadWriteOnceWithFSType - Longhorn's setting - applies fsGroup to RWO + # volumes only, so on an RWX media claim fsGroup is ignored outright and + # OnRootMismatch never even gets a say. The config claims are RWO and are + # covered normally. Consequence for the media claims: their root directory has + # to be writable for PUID/PGID by itself, see the media block below (#164). # seccompProfile RuntimeDefault applies the container runtime's default # syscall filter to every container in the pod, init containers included, and # is set at pod level so a container added later inherits it without the @@ -82,9 +89,46 @@ storage: # tv: sonarr, bazarr # # subPath mounts a sub-directory of the claim instead of its root (null = the -# claim root, i.e. one claim per category). The same existingClaim may back all -# three categories with different subPaths - one library volume, one -# sub-directory per category, e.g. downloads / movies / tv-shows. +# claim root, i.e. one claim per category). The same existingClaim may back two +# or all three categories, which is what makes the one-library-volume layout +# possible - one claim, one sub-directory per category: +# +# media: +# downloads: { existingClaim: media-library, subPath: downloads } +# movies: { existingClaim: media-library, subPath: movies } +# tv: { existingClaim: media-library, subPath: tv-shows } +# +# Categories sharing a claim get exactly ONE pod volume entry and differ only in +# the subPath of their mounts (#164). That is not cosmetic: a pod that lists the +# same claim twice never starts - it stays in Init:0/1 forever without a single +# kubelet event (measured on RKE2/Longhorn, with RWO as well as RWX). The entry +# is named after the first ENABLED category in the fixed order downloads, +# movies, tv that carries the claim, so the name does not wander when an +# unrelated app flag changes (see the mediaVolumeName helper). +# +# The subPath directories do NOT have to exist beforehand - the kubelet creates +# a missing one. What it does not do is give it usable permissions: the created +# directory belongs to root and inherits the mode of the CLAIM ROOT. So the one +# thing that has to hold is: +# +# the root directory of the claim must be writable for PUID/PGID. +# +# Do not count on fsGroup for that. Whether the kubelet applies fsGroup to a +# volume at all is the CSI driver's decision (its fsGroupPolicy), not the pod's: +# Longhorn ships ReadWriteOnceWithFSType, so fsGroup applies to its RWO volumes +# and is silently ignored on an RWX claim - measured by the reporter of #164 on +# the target cluster (kubectl get csidriver driver.longhorn.io -o +# jsonpath='{.spec.fsGroupPolicy}'). +# +# In practice this works out on Longhorn because Longhorn hands out its volume +# root as mode 777, and a subPath directory created under it inherits those 777 +# - root-owned, but writable for everyone. That is a property of the driver, not +# of Kubernetes. Measured in k3d against a claim whose root is 0755 and with no +# fsGroup in effect: the kubelet creates /movies as root:root 0755 and the +# container's `touch` fails with EPERM. On such a driver, prepare the claim root +# once (chown 1000:1000 or chmod g+w with the right group) before the first +# start - the chart deliberately does not chown anything: a multi-TB library +# must not be walked on every pod start. # # What this buys and what it does NOT buy - measured in a k3d cluster against # this chart, because the usual argument for a shared volume is hardlinking: