Merge pull request #165 from jk-cluster/fix/freesailarr-shared-media-claim

freesailarr: one volume entry per media claim (2.0.2+up1.0.0)
This commit is contained in:
jklein authored and GitHub committed 2026-08-24 19:08:00 +00:00
commit b2d74ca5fb
5 files changed
+168 -15

No files matched your search

+1 -1
View File
@@ -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.
+6 -4
View File
@@ -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
+90
View File
@@ -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).
+24 -7
View File
@@ -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: {}
+47 -3
View File
@@ -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: