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.
This commit is contained in:
5 files changed
+168
-15
No files matched your search
@@ -8,7 +8,7 @@ description: A Helm chart to deploy a Wireguard-tunneled media automation stack
|
|||||||
|
|
||||||
type: application
|
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
|
# 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
|
# stack bundles nine independently versioned projects, so there is no single
|
||||||
# "app version" to track here.
|
# "app version" to track here.
|
||||||
|
|||||||
@@ -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
|
Kubernetes makes every volumeMount its own bind mount and link() across two
|
||||||
mount points fails with EXDEV regardless of subPath, so an import copies.
|
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
|
The containers write as {{ .Values.puid }}:{{ .Values.pgid }}. The claim's root directory has to be
|
||||||
owned by someone else, fix the ownership before the first start — the pod
|
writable for them, and a subPath directory the kubelet creates inherits that
|
||||||
will not do it for you (fsGroupChangePolicy is OnRootMismatch, deliberately,
|
root's mode — so fix the ownership before the first start. The pod will not
|
||||||
so a large library is not recursively re-chowned on every start).
|
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" }}
|
{{- if eq $replicas "0" }}
|
||||||
|
|
||||||
SCALED TO ZERO
|
SCALED TO ZERO
|
||||||
|
|||||||
@@ -219,6 +219,96 @@ alive. It is not what opens the ports today.
|
|||||||
{{- join "," $ports -}}
|
{{- join "," $ports -}}
|
||||||
{{- end -}}
|
{{- 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
|
Recursive merge of an override onto defaults where null never deletes and set
|
||||||
values always win, including false and 0 (issue #99).
|
values always win, including false and 0 (issue #99).
|
||||||
|
|||||||
@@ -223,7 +223,7 @@ spec:
|
|||||||
- mountPath: /config
|
- mountPath: /config
|
||||||
name: qbittorrent-config
|
name: qbittorrent-config
|
||||||
- mountPath: /downloads
|
- mountPath: /downloads
|
||||||
name: media-downloads
|
name: {{ include "freesailarr.mediaVolumeName" (dict "root" $ "category" "downloads") }}
|
||||||
{{- with .Values.media.downloads.subPath }}
|
{{- with .Values.media.downloads.subPath }}
|
||||||
subPath: {{ . | quote }}
|
subPath: {{ . | quote }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
@@ -319,12 +319,12 @@ spec:
|
|||||||
- mountPath: /config
|
- mountPath: /config
|
||||||
name: radarr-config
|
name: radarr-config
|
||||||
- mountPath: /movies
|
- mountPath: /movies
|
||||||
name: media-movies
|
name: {{ include "freesailarr.mediaVolumeName" (dict "root" $ "category" "movies") }}
|
||||||
{{- with .Values.media.movies.subPath }}
|
{{- with .Values.media.movies.subPath }}
|
||||||
subPath: {{ . | quote }}
|
subPath: {{ . | quote }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
- mountPath: /downloads
|
- mountPath: /downloads
|
||||||
name: media-downloads
|
name: {{ include "freesailarr.mediaVolumeName" (dict "root" $ "category" "downloads") }}
|
||||||
{{- with .Values.media.downloads.subPath }}
|
{{- with .Values.media.downloads.subPath }}
|
||||||
subPath: {{ . | quote }}
|
subPath: {{ . | quote }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
@@ -387,12 +387,12 @@ spec:
|
|||||||
- mountPath: /config
|
- mountPath: /config
|
||||||
name: sonarr-config
|
name: sonarr-config
|
||||||
- mountPath: /tv
|
- mountPath: /tv
|
||||||
name: media-tv
|
name: {{ include "freesailarr.mediaVolumeName" (dict "root" $ "category" "tv") }}
|
||||||
{{- with .Values.media.tv.subPath }}
|
{{- with .Values.media.tv.subPath }}
|
||||||
subPath: {{ . | quote }}
|
subPath: {{ . | quote }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
- mountPath: /downloads
|
- mountPath: /downloads
|
||||||
name: media-downloads
|
name: {{ include "freesailarr.mediaVolumeName" (dict "root" $ "category" "downloads") }}
|
||||||
{{- with .Values.media.downloads.subPath }}
|
{{- with .Values.media.downloads.subPath }}
|
||||||
subPath: {{ . | quote }}
|
subPath: {{ . | quote }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
@@ -455,12 +455,12 @@ spec:
|
|||||||
- mountPath: /config
|
- mountPath: /config
|
||||||
name: bazarr-config
|
name: bazarr-config
|
||||||
- mountPath: /movies
|
- mountPath: /movies
|
||||||
name: media-movies
|
name: {{ include "freesailarr.mediaVolumeName" (dict "root" $ "category" "movies") }}
|
||||||
{{- with .Values.media.movies.subPath }}
|
{{- with .Values.media.movies.subPath }}
|
||||||
subPath: {{ . | quote }}
|
subPath: {{ . | quote }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
- mountPath: /tv
|
- mountPath: /tv
|
||||||
name: media-tv
|
name: {{ include "freesailarr.mediaVolumeName" (dict "root" $ "category" "tv") }}
|
||||||
{{- with .Values.media.tv.subPath }}
|
{{- with .Values.media.tv.subPath }}
|
||||||
subPath: {{ . | quote }}
|
subPath: {{ . | quote }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
@@ -475,21 +475,38 @@ spec:
|
|||||||
# Writable /tmp/gluetun for gluetun's public-IP cache (see the mount).
|
# Writable /tmp/gluetun for gluetun's public-IP cache (see the mount).
|
||||||
- name: gluetun-tmp
|
- name: gluetun-tmp
|
||||||
emptyDir: {}
|
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 or .Values.qbittorrent.enabled .Values.radarr.enabled .Values.sonarr.enabled }}
|
||||||
|
{{- if include "freesailarr.mediaVolumeOwned" (dict "root" $ "category" "downloads") }}
|
||||||
- name: media-downloads
|
- name: media-downloads
|
||||||
persistentVolumeClaim:
|
persistentVolumeClaim:
|
||||||
claimName: {{ required "media.downloads.existingClaim is required when qbittorrent, radarr or sonarr is enabled" .Values.media.downloads.existingClaim }}
|
claimName: {{ required "media.downloads.existingClaim is required when qbittorrent, radarr or sonarr is enabled" .Values.media.downloads.existingClaim }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
{{- if or .Values.radarr.enabled .Values.bazarr.enabled }}
|
{{- if or .Values.radarr.enabled .Values.bazarr.enabled }}
|
||||||
|
{{- if include "freesailarr.mediaVolumeOwned" (dict "root" $ "category" "movies") }}
|
||||||
- name: media-movies
|
- name: media-movies
|
||||||
persistentVolumeClaim:
|
persistentVolumeClaim:
|
||||||
claimName: {{ required "media.movies.existingClaim is required when radarr or bazarr is enabled" .Values.media.movies.existingClaim }}
|
claimName: {{ required "media.movies.existingClaim is required when radarr or bazarr is enabled" .Values.media.movies.existingClaim }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
{{- if or .Values.sonarr.enabled .Values.bazarr.enabled }}
|
{{- if or .Values.sonarr.enabled .Values.bazarr.enabled }}
|
||||||
|
{{- if include "freesailarr.mediaVolumeOwned" (dict "root" $ "category" "tv") }}
|
||||||
- name: media-tv
|
- name: media-tv
|
||||||
persistentVolumeClaim:
|
persistentVolumeClaim:
|
||||||
claimName: {{ required "media.tv.existingClaim is required when sonarr or bazarr is enabled" .Values.media.tv.existingClaim }}
|
claimName: {{ required "media.tv.existingClaim is required when sonarr or bazarr is enabled" .Values.media.tv.existingClaim }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
{{- if .Values.flaresolverr.enabled }}
|
{{- if .Values.flaresolverr.enabled }}
|
||||||
- name: flaresolverr-tmp
|
- name: flaresolverr-tmp
|
||||||
emptyDir: {}
|
emptyDir: {}
|
||||||
|
|||||||
+47
-3
@@ -54,6 +54,13 @@ securityContext:
|
|||||||
# group-writable for the non-root containers; fsGroupChangePolicy
|
# group-writable for the non-root containers; fsGroupChangePolicy
|
||||||
# OnRootMismatch avoids recursively re-chowning the (large, shared) media
|
# OnRootMismatch avoids recursively re-chowning the (large, shared) media
|
||||||
# claims on every pod start.
|
# 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
|
# seccompProfile RuntimeDefault applies the container runtime's default
|
||||||
# syscall filter to every container in the pod, init containers included, and
|
# 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
|
# is set at pod level so a container added later inherits it without the
|
||||||
@@ -82,9 +89,46 @@ storage:
|
|||||||
# tv: sonarr, bazarr
|
# tv: sonarr, bazarr
|
||||||
#
|
#
|
||||||
# subPath mounts a sub-directory of the claim instead of its root (null = the
|
# 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
|
# claim root, i.e. one claim per category). The same existingClaim may back two
|
||||||
# three categories with different subPaths - one library volume, one
|
# or all three categories, which is what makes the one-library-volume layout
|
||||||
# sub-directory per category, e.g. downloads / movies / tv-shows.
|
# 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
|
# 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:
|
# this chart, because the usual argument for a shared volume is hardlinking:
|
||||||
|
|||||||
Reference in new issue
Block a user