Files
helm-charts/freesailarr/templates/freesailarr.sfs.yaml
T
jklein 7003f3674c feat(freesailarr)!: eight image updates and a trustedNetworks value for the *arr apps
Collapses the eight open Renovate PRs for this stack into one change, and
answers what two of them turned out to carry.

The eight tags: qbittorrent 5.2.3 -> 5.2.4, bazarr 1.6.0 -> 1.6.2,
sonarr 4.0.19 -> 4.0.20, flaresolverr v3.5.0 -> v3.5.2,
recyclarr 8.7.1 -> 8.7.3, prowlarr 2.5.2 -> 2.6.5, radarr 6.3.0 -> 6.4.4,
seerr v3.4.1 -> v3.5.0. gluetun, the busybox init image and the
digest-pinned mediathekarr have no update pending and are untouched.

Major rather than patch, because prowlarr 2.6.3 and radarr 6.4.3 are not
plain bumps: they removed the hard-coded trusted proxy networks
(10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7, fe80::/10, read in
Startup.cs at the previously pinned tags) and replaced them with a
TrustedNetworks setting whose own default is string.Empty -
ForwardedHeadersConfigurator returns before registering a single
KnownNetwork while it is empty. Behind an in-cluster ingress controller
that means the forwarded headers are ignored: the client address the app
reports is the controller's pod IP, the scheme reads as http behind TLS
termination, and under "Disabled for Local Addresses" a proxied request
stops counting as local.

So a <app>.trustedNetworks value is added for prowlarr, radarr AND sonarr,
rendered as <APP>__SERVER__TRUSTEDNETWORKS and only when non-empty - an
empty variable cannot be told apart from an unset one by the app, but looks
handled to a reader of the manifest. The env form was verified per app
rather than inferred: all three bind ServerOptions to <App>:Server next to
a plain AddEnvironmentVariables(). A helper suppresses the chart's entry
when the app's own env block already carries the variable, for the reason
the UrlBase helper states - two identically named variables in one
container are not a defined state.

sonarr is included because it is already in the empty state: 4.0.19, the
version this chart shipped before, has no hard-coded networks either. It
arrived in what the convention classes as a plain app-version bump, so
nothing reviewed or documented it. Repairing two and knowingly leaving the
third would be the same silent pattern.

The default is empty, and this therefore does NOT restore the behaviour
from before the upgrade. That is deliberate and follows upstream's reason -
trusting X-Forwarded-For from any private network is a spoofing vector -
and it is made visible instead of reinstated: NOTES.txt warns in exactly
the combination that costs something, the app published through an Ingress
or an HTTPRoute and the value empty, and stays quiet otherwise.

AllowedHosts, added by the same upstream releases, is deliberately not
exposed. Its default is string.Empty as well and the app then accepts any
host, so there is nothing to repair; the trap is documented instead,
because if it is ever set the app appends only localhost, 127.0.0.1, [::1]
and its own hostname - neither the pod IP nor the Service DNS name - and
the kubelet probes the pod by IP, which an incomplete list answers with
HTTP 400.

At default values the rendered manifest changes in exactly eight lines, the
image tags; the new value renders nothing until it is set.
2026-10-12 22:00:23 +00:00

730 lines
39 KiB
YAML

apiVersion: apps/v1
kind: StatefulSet
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
labels:
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
spec:
replicas: {{ include "freesailarr.replicas" (dict "scaleDown" .Values.scaleDown "replicas" .Values.replicas) }}
# Governing service name. Each app has its own service (see
# freesailarr.service.yaml); nothing consumes the per-pod DNS name a
# governing service would provide, so the chart deliberately creates no
# object under this name - the field only has to be set.
serviceName: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
selector:
matchLabels:
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
template:
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
annotations:
# There is no ConfigMap in this chart, so checksum/config does not
# apply. What does apply is the op:// path: Helm never sees the secret's
# contents - the 1Password operator fills the Secret in the cluster -
# but it does see which item the OnePasswordItem points at. Repointing
# secrets.gluetunSecretRef at a different vault item therefore takes
# effect instead of silently leaving the old credentials in the running
# pod. The name differs from checksum/config on purpose: that one
# promises "the config changed", this one only "the config source
# changed" (#82).
checksum/secret-ref: {{ include (print $.Template.BasePath "/freesailarr-gluetun.secret.yaml") . | sha256sum }}
labels:
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
spec:
automountServiceAccountToken: false
securityContext:
{{- include "freesailarr.podSecurityContext" . | nindent 8 }}
{{- if and .Values.qbittorrent.enabled (or .Values.qbittorrent.networkInterface .Values.qbittorrent.savePath) }}
initContainers:
# qBittorrent reads neither the session's network interface nor its
# default save path from the environment - both live only in the
# persisted config file, which the app itself creates on first start.
# This container seeds them before qbittorrent starts, so a fresh
# install works out of the box. Both edits preserve all other settings:
# keys are inserted into the [BitTorrent] section (qBittorrent 5.x
# reads them there), and a missing file or section is created.
#
# The two keys are handled DIFFERENTLY on purpose:
#
# - Session\Interface is ENFORCED on every start. Without the binding
# libtorrent listens and announces on every interface of the shared
# pod network namespace (eth0/pod IP and loopback included);
# gluetun's kill switch drops those packets, trackers stay
# unreachable and torrents stall at 0 bytes. A deviating value is a
# leak, so an existing key is replaced.
# - Session\DefaultSavePath is only INSERTED WHEN ABSENT, never
# replaced. Without it qBittorrent falls back to /config/Downloads,
# i.e. the small config volume, which fills within minutes and puts
# every torrent into "errored" (#166). But where downloads go is a
# legitimate operator decision: someone who changes the path in the
# WebUI must not have it overwritten on every pod restart.
- name: qbittorrent-config-seed
image: "{{ .Values.qbittorrent.configInit.image.repository }}:{{ .Values.qbittorrent.configInit.image.tag }}"
imagePullPolicy: IfNotPresent
command:
- sh
- -c
- |
set -eu
conf=/config/qBittorrent/qBittorrent.conf
mkdir -p /config/qBittorrent
[ -f "$conf" ] || printf '[BitTorrent]\n' > "$conf"
# A file without a trailing newline would glue an appended
# section header onto the last key.
if [ -s "$conf" ] && [ -n "$(tail -c 1 "$conf")" ]; then
printf '\n' >> "$conf"
fi
grep -q '^\[BitTorrent\]$' "$conf" || printf '[BitTorrent]\n' >> "$conf"
{{- if .Values.qbittorrent.networkInterface }}
# Drop any existing binding keys, then re-insert them directly
# after the [BitTorrent] header.
awk -v ifc="$QBT_INTERFACE" '
/^Session\\Interface=/ { next }
/^Session\\InterfaceName=/ { next }
{ print }
/^\[BitTorrent\]$/ {
print "Session\\Interface=" ifc
print "Session\\InterfaceName=" ifc
}
' "$conf" > "${conf}.tmp"
mv "${conf}.tmp" "$conf"
echo "qBittorrent session bound to interface $QBT_INTERFACE"
{{- end }}
{{- if .Values.qbittorrent.savePath }}
# Insert only when the key is absent - an existing value is the
# operator's and stays untouched.
if grep -q '^Session\\DefaultSavePath=' "$conf"; then
echo "qBittorrent save path already set, left untouched: $(grep -m1 '^Session\\DefaultSavePath=' "$conf")"
else
awk -v sp="$QBT_SAVE_PATH" '
{ print }
/^\[BitTorrent\]$/ { print "Session\\DefaultSavePath=" sp }
' "$conf" > "${conf}.tmp"
mv "${conf}.tmp" "$conf"
echo "qBittorrent default save path seeded: $QBT_SAVE_PATH"
fi
{{- end }}
env:
{{- if .Values.qbittorrent.networkInterface }}
- name: QBT_INTERFACE
value: {{ .Values.qbittorrent.networkInterface | quote }}
{{- end }}
{{- if .Values.qbittorrent.savePath }}
- name: QBT_SAVE_PATH
value: {{ .Values.qbittorrent.savePath | quote }}
{{- end }}
resources:
{{- include "freesailarr.resourcesFor" (dict "root" $ "app" "configInit" "given" .Values.qbittorrent.configInit.resources) | nindent 12 }}
securityContext:
{{- include "freesailarr.securityContextFor" (dict "root" $ "app" "configInit" "given" .Values.qbittorrent.configInit.securityContext) | nindent 12 }}
volumeMounts:
- mountPath: /config
name: qbittorrent-config
{{- end }}
containers:
# gluetun is the VPN gateway: all app containers share the pod's
# network namespace, talk to each other over localhost and reach the
# internet only through the Wireguard tunnel (gluetun's firewall acts
# as kill switch).
- name: gluetun
image: "{{ .Values.gluetun.image.repository }}:{{ .Values.gluetun.image.tag }}"
imagePullPolicy: IfNotPresent
# An uncleanly exited gluetun (crash, OOM killer, node issue) leaves
# its ip rule, routing table and tun device behind in the shared pod
# network namespace, and the restarted container then fails forever
# ("adding ip rule 101 ... file exists" / silent tunnel breakage)
# while the kill switch blocks all egress. The upstream-recommended
# postStart hook is racy (it runs concurrently with the entrypoint
# and can delete the rule the new instance just created), so the
# stale state is cleaned synchronously before gluetun starts.
command:
- /bin/sh
- -c
- 'while ip rule del table 51820 2>/dev/null; do :; done; while ip -6 rule del table 51820 2>/dev/null; do :; done; ip link del tun0 2>/dev/null; exec /gluetun-entrypoint'
env:
- name: TZ
value: {{ .Values.timezone | quote }}
- name: VPN_SERVICE_PROVIDER
value: {{ .Values.gluetun.vpn.serviceProvider | quote }}
- name: VPN_TYPE
value: {{ .Values.gluetun.vpn.type | quote }}
- name: WIREGUARD_PRIVATE_KEY
valueFrom:
secretKeyRef:
name: {{ .Release.Name }}-{{ .Chart.Name }}-gluetun-secret
key: wireguard-private-key
- name: WIREGUARD_ADDRESSES
valueFrom:
secretKeyRef:
name: {{ .Release.Name }}-{{ .Chart.Name }}-gluetun-secret
key: wireguard-addresses
{{- with .Values.gluetun.vpn.serverCities }}
- name: SERVER_CITIES
value: {{ . | quote }}
{{- end }}
{{- with .Values.gluetun.vpn.serverCountries }}
- name: SERVER_COUNTRIES
value: {{ . | quote }}
{{- end }}
{{- with .Values.gluetun.vpn.dnsUpstreamResolvers }}
- name: DNS_UPSTREAM_RESOLVERS
value: {{ . | quote }}
{{- end }}
{{- with .Values.gluetun.vpn.firewallOutboundSubnets }}
- name: FIREWALL_OUTBOUND_SUBNETS
value: {{ . | quote }}
{{- end }}
# Declare the UI/probe ports of the enabled apps and the health
# server as reachable. Note that inside a pod network gluetun's own
# pod-subnet rule already accepts this traffic and these per-port
# rules never match (measured - see the helper's comment); they are
# the explicit statement of intent and the fallback if that subnet
# detection ever stops covering the pod IP.
- name: FIREWALL_INPUT_PORTS
value: {{ include "freesailarr.firewallInputPorts" . | quote }}
# Bind the health server to all interfaces so the kubelet HTTP
# probes (which connect to the pod IP, not localhost) reach it.
- name: HEALTH_SERVER_ADDRESS
value: ":9999"
{{- include "freesailarr.env" (dict "root" $ "env" .Values.gluetun.env) | nindent 12 }}
ports:
- containerPort: 9999
protocol: TCP
name: health
livenessProbe:
{{- include "freesailarr.probeFor" (dict "root" $ "app" "gluetun" "kind" "livenessProbe" "given" .Values.gluetun.livenessProbe) | nindent 12 }}
readinessProbe:
{{- include "freesailarr.probeFor" (dict "root" $ "app" "gluetun" "kind" "readinessProbe" "given" .Values.gluetun.readinessProbe) | nindent 12 }}
startupProbe:
{{- include "freesailarr.probeFor" (dict "root" $ "app" "gluetun" "kind" "startupProbe" "given" .Values.gluetun.startupProbe) | nindent 12 }}
resources:
{{- include "freesailarr.resourcesFor" (dict "root" $ "app" "gluetun" "given" .Values.gluetun.resources) | nindent 12 }}
securityContext:
{{- include "freesailarr.securityContextFor" (dict "root" $ "app" "gluetun" "given" .Values.gluetun.securityContext) | nindent 12 }}
volumeMounts:
- mountPath: /gluetun
name: gluetun-config
- mountPath: /dev/net/tun
name: dev-net-tun
# gluetun creates /tmp/gluetun (its public-IP cache) mode 0644,
# i.e. without the execute bit it needs to enter its own
# directory. It runs as root but with all capabilities except
# NET_ADMIN dropped, so DAC_OVERRIDE no longer papers over that
# and every write fails with "clearing public IP data: open
# /tmp/gluetun/ip: permission denied". An emptyDir provides the
# path with usable permissions.
# Remaining, cosmetic: gluetun also chowns the file, which needs
# CAP_CHOWN. With all capabilities but NET_ADMIN dropped that call
# is denied and logs "clearing public IP data: chown
# /tmp/gluetun/ip: operation not permitted" on every VPN restart.
# The file itself is written and the public IP is persisted
# (verified at runtime), so this is a log line, not a failure -
# granting CHOWN just to silence it would widen the container's
# capabilities for nothing.
- mountPath: /tmp/gluetun
name: gluetun-tmp
{{- if .Values.qbittorrent.enabled }}
- name: qbittorrent
image: "{{ .Values.qbittorrent.image.repository }}:{{ .Values.qbittorrent.image.tag }}"
imagePullPolicy: IfNotPresent
env:
- name: PUID
value: {{ .Values.puid | quote }}
- name: PGID
value: {{ .Values.pgid | quote }}
- name: TZ
value: {{ .Values.timezone | quote }}
- name: WEBUI_PORT
value: "8080"
{{- include "freesailarr.env" (dict "root" $ "env" .Values.qbittorrent.env) | nindent 12 }}
ports:
- containerPort: 8080
protocol: TCP
name: qbittorrent
livenessProbe:
{{- include "freesailarr.probeFor" (dict "root" $ "app" "qbittorrent" "kind" "livenessProbe" "given" .Values.qbittorrent.livenessProbe) | nindent 12 }}
readinessProbe:
{{- include "freesailarr.probeFor" (dict "root" $ "app" "qbittorrent" "kind" "readinessProbe" "given" .Values.qbittorrent.readinessProbe) | nindent 12 }}
startupProbe:
{{- include "freesailarr.probeFor" (dict "root" $ "app" "qbittorrent" "kind" "startupProbe" "given" .Values.qbittorrent.startupProbe) | nindent 12 }}
resources:
{{- include "freesailarr.resourcesFor" (dict "root" $ "app" "qbittorrent" "given" .Values.qbittorrent.resources) | nindent 12 }}
securityContext:
{{- include "freesailarr.securityContextFor" (dict "root" $ "app" "qbittorrent" "given" .Values.qbittorrent.securityContext) | nindent 12 }}
volumeMounts:
- mountPath: /config
name: qbittorrent-config
- mountPath: /downloads
name: {{ include "freesailarr.mediaVolumeName" (dict "root" $ "category" "downloads") }}
{{- with .Values.media.downloads.subPath }}
subPath: {{ . | quote }}
{{- end }}
{{- end }}
{{- if .Values.prowlarr.enabled }}
- name: prowlarr
image: "{{ .Values.prowlarr.image.repository }}:{{ .Values.prowlarr.image.tag }}"
imagePullPolicy: IfNotPresent
env:
- name: PUID
value: {{ .Values.puid | quote }}
- name: PGID
value: {{ .Values.pgid | quote }}
- name: TZ
value: {{ .Values.timezone | quote }}
{{- /* Base path for the shared-domain mode: the chart sets
UrlBase itself as soon as prowlarr renders under a
sub-path, unless the env block above already carries
the variable - see the freesailarr.urlBase helper. */}}
{{- with (include "freesailarr.urlBase" (dict "root" $ "app" "prowlarr")) }}
- name: PROWLARR__SERVER__URLBASE
value: {{ . | quote }}
{{- end }}
{{- /* Proxy networks whose forwarded headers prowlarr honours.
Emitted only when set - empty is the default and means no
proxy is trusted. See the freesailarr.trustedNetworks
helper and the value's comment in values.yaml. */}}
{{- with (include "freesailarr.trustedNetworks" (dict "root" $ "app" "prowlarr")) }}
- name: PROWLARR__SERVER__TRUSTEDNETWORKS
value: {{ . | quote }}
{{- end }}
{{- include "freesailarr.env" (dict "root" $ "env" .Values.prowlarr.env) | nindent 12 }}
ports:
- containerPort: 9696
protocol: TCP
name: prowlarr
livenessProbe:
{{- include "freesailarr.probeFor" (dict "root" $ "app" "prowlarr" "kind" "livenessProbe" "given" .Values.prowlarr.livenessProbe) | nindent 12 }}
readinessProbe:
{{- include "freesailarr.probeFor" (dict "root" $ "app" "prowlarr" "kind" "readinessProbe" "given" .Values.prowlarr.readinessProbe) | nindent 12 }}
startupProbe:
{{- include "freesailarr.probeFor" (dict "root" $ "app" "prowlarr" "kind" "startupProbe" "given" .Values.prowlarr.startupProbe) | nindent 12 }}
resources:
{{- include "freesailarr.resourcesFor" (dict "root" $ "app" "prowlarr" "given" .Values.prowlarr.resources) | nindent 12 }}
securityContext:
{{- include "freesailarr.securityContextFor" (dict "root" $ "app" "prowlarr" "given" .Values.prowlarr.securityContext) | nindent 12 }}
volumeMounts:
- mountPath: /config
name: prowlarr-config
{{- end }}
{{- if .Values.flaresolverr.enabled }}
- name: flaresolverr
image: "{{ .Values.flaresolverr.image.repository }}:{{ .Values.flaresolverr.image.tag }}"
imagePullPolicy: IfNotPresent
env:
- name: TZ
value: {{ .Values.timezone | quote }}
{{- include "freesailarr.env" (dict "root" $ "env" .Values.flaresolverr.env) | nindent 12 }}
ports:
- containerPort: 8191
protocol: TCP
name: flaresolverr
livenessProbe:
{{- include "freesailarr.probeFor" (dict "root" $ "app" "flaresolverr" "kind" "livenessProbe" "given" .Values.flaresolverr.livenessProbe) | nindent 12 }}
readinessProbe:
{{- include "freesailarr.probeFor" (dict "root" $ "app" "flaresolverr" "kind" "readinessProbe" "given" .Values.flaresolverr.readinessProbe) | nindent 12 }}
startupProbe:
{{- include "freesailarr.probeFor" (dict "root" $ "app" "flaresolverr" "kind" "startupProbe" "given" .Values.flaresolverr.startupProbe) | nindent 12 }}
resources:
{{- include "freesailarr.resourcesFor" (dict "root" $ "app" "flaresolverr" "given" .Values.flaresolverr.resources) | nindent 12 }}
securityContext:
{{- include "freesailarr.securityContextFor" (dict "root" $ "app" "flaresolverr" "given" .Values.flaresolverr.securityContext) | nindent 12 }}
volumeMounts:
# Writable paths for headless chromium so the root filesystem
# stays read-only; /dev/shm is a memory-backed emptyDir because
# chromium needs more shared memory than the container default.
- mountPath: /tmp
name: flaresolverr-tmp
- mountPath: /dev/shm
name: flaresolverr-shm
{{- end }}
{{- if .Values.radarr.enabled }}
- name: radarr
image: "{{ .Values.radarr.image.repository }}:{{ .Values.radarr.image.tag }}"
imagePullPolicy: IfNotPresent
env:
- name: PUID
value: {{ .Values.puid | quote }}
- name: PGID
value: {{ .Values.pgid | quote }}
- name: TZ
value: {{ .Values.timezone | quote }}
{{- /* Base path for the shared-domain mode: the chart sets
UrlBase itself as soon as radarr renders under a
sub-path, unless the env block above already carries
the variable - see the freesailarr.urlBase helper. */}}
{{- with (include "freesailarr.urlBase" (dict "root" $ "app" "radarr")) }}
- name: RADARR__SERVER__URLBASE
value: {{ . | quote }}
{{- end }}
{{- /* See the prowlarr TRUSTEDNETWORKS comment above. */}}
{{- with (include "freesailarr.trustedNetworks" (dict "root" $ "app" "radarr")) }}
- name: RADARR__SERVER__TRUSTEDNETWORKS
value: {{ . | quote }}
{{- end }}
{{- include "freesailarr.env" (dict "root" $ "env" .Values.radarr.env) | nindent 12 }}
ports:
- containerPort: 7878
protocol: TCP
name: radarr
livenessProbe:
{{- include "freesailarr.probeFor" (dict "root" $ "app" "radarr" "kind" "livenessProbe" "given" .Values.radarr.livenessProbe) | nindent 12 }}
readinessProbe:
{{- include "freesailarr.probeFor" (dict "root" $ "app" "radarr" "kind" "readinessProbe" "given" .Values.radarr.readinessProbe) | nindent 12 }}
startupProbe:
{{- include "freesailarr.probeFor" (dict "root" $ "app" "radarr" "kind" "startupProbe" "given" .Values.radarr.startupProbe) | nindent 12 }}
resources:
{{- include "freesailarr.resourcesFor" (dict "root" $ "app" "radarr" "given" .Values.radarr.resources) | nindent 12 }}
securityContext:
{{- include "freesailarr.securityContextFor" (dict "root" $ "app" "radarr" "given" .Values.radarr.securityContext) | nindent 12 }}
volumeMounts:
- mountPath: /config
name: radarr-config
- mountPath: /movies
name: {{ include "freesailarr.mediaVolumeName" (dict "root" $ "category" "movies") }}
{{- with .Values.media.movies.subPath }}
subPath: {{ . | quote }}
{{- end }}
- mountPath: /downloads
name: {{ include "freesailarr.mediaVolumeName" (dict "root" $ "category" "downloads") }}
{{- with .Values.media.downloads.subPath }}
subPath: {{ . | quote }}
{{- end }}
{{- end }}
{{- if .Values.recyclarr.enabled }}
- name: recyclarr
image: "{{ .Values.recyclarr.image.repository }}:{{ .Values.recyclarr.image.tag }}"
imagePullPolicy: IfNotPresent
env:
- name: TZ
value: {{ .Values.timezone | quote }}
- name: RECYCLARR_CREATE_CONFIG
value: "true"
{{- include "freesailarr.env" (dict "root" $ "env" .Values.recyclarr.env) | nindent 12 }}
livenessProbe:
{{- include "freesailarr.probeFor" (dict "root" $ "app" "recyclarr" "kind" "livenessProbe" "given" .Values.recyclarr.livenessProbe) | nindent 12 }}
readinessProbe:
{{- include "freesailarr.probeFor" (dict "root" $ "app" "recyclarr" "kind" "readinessProbe" "given" .Values.recyclarr.readinessProbe) | nindent 12 }}
startupProbe:
{{- include "freesailarr.probeFor" (dict "root" $ "app" "recyclarr" "kind" "startupProbe" "given" .Values.recyclarr.startupProbe) | nindent 12 }}
resources:
{{- include "freesailarr.resourcesFor" (dict "root" $ "app" "recyclarr" "given" .Values.recyclarr.resources) | nindent 12 }}
securityContext:
{{- include "freesailarr.securityContextFor" (dict "root" $ "app" "recyclarr" "given" .Values.recyclarr.securityContext) | nindent 12 }}
volumeMounts:
- mountPath: /config
name: recyclarr-config
# Writable scratch space for the .NET runtime so the root
# filesystem stays read-only.
- mountPath: /tmp
name: recyclarr-tmp
{{- end }}
{{- if .Values.sonarr.enabled }}
- name: sonarr
image: "{{ .Values.sonarr.image.repository }}:{{ .Values.sonarr.image.tag }}"
imagePullPolicy: IfNotPresent
env:
- name: PUID
value: {{ .Values.puid | quote }}
- name: PGID
value: {{ .Values.pgid | quote }}
- name: TZ
value: {{ .Values.timezone | quote }}
{{- /* Base path for the shared-domain mode: the chart sets
UrlBase itself as soon as sonarr renders under a
sub-path, unless the env block above already carries
the variable - see the freesailarr.urlBase helper. */}}
{{- with (include "freesailarr.urlBase" (dict "root" $ "app" "sonarr")) }}
- name: SONARR__SERVER__URLBASE
value: {{ . | quote }}
{{- end }}
{{- /* See the prowlarr TRUSTEDNETWORKS comment above. Sonarr did
not lose the trust with this chart version - 4.0.19 was
already in the empty state. */}}
{{- with (include "freesailarr.trustedNetworks" (dict "root" $ "app" "sonarr")) }}
- name: SONARR__SERVER__TRUSTEDNETWORKS
value: {{ . | quote }}
{{- end }}
{{- include "freesailarr.env" (dict "root" $ "env" .Values.sonarr.env) | nindent 12 }}
ports:
- containerPort: 8989
protocol: TCP
name: sonarr
livenessProbe:
{{- include "freesailarr.probeFor" (dict "root" $ "app" "sonarr" "kind" "livenessProbe" "given" .Values.sonarr.livenessProbe) | nindent 12 }}
readinessProbe:
{{- include "freesailarr.probeFor" (dict "root" $ "app" "sonarr" "kind" "readinessProbe" "given" .Values.sonarr.readinessProbe) | nindent 12 }}
startupProbe:
{{- include "freesailarr.probeFor" (dict "root" $ "app" "sonarr" "kind" "startupProbe" "given" .Values.sonarr.startupProbe) | nindent 12 }}
resources:
{{- include "freesailarr.resourcesFor" (dict "root" $ "app" "sonarr" "given" .Values.sonarr.resources) | nindent 12 }}
securityContext:
{{- include "freesailarr.securityContextFor" (dict "root" $ "app" "sonarr" "given" .Values.sonarr.securityContext) | nindent 12 }}
volumeMounts:
- mountPath: /config
name: sonarr-config
- mountPath: /tv
name: {{ include "freesailarr.mediaVolumeName" (dict "root" $ "category" "tv") }}
{{- with .Values.media.tv.subPath }}
subPath: {{ . | quote }}
{{- end }}
- mountPath: /downloads
name: {{ include "freesailarr.mediaVolumeName" (dict "root" $ "category" "downloads") }}
{{- with .Values.media.downloads.subPath }}
subPath: {{ . | quote }}
{{- end }}
{{- end }}
{{- if .Values.seerr.enabled }}
- name: seerr
image: "{{ .Values.seerr.image.repository }}:{{ .Values.seerr.image.tag }}"
imagePullPolicy: IfNotPresent
env:
- name: TZ
value: {{ .Values.timezone | quote }}
{{- include "freesailarr.env" (dict "root" $ "env" .Values.seerr.env) | nindent 12 }}
ports:
- containerPort: 5055
protocol: TCP
name: seerr
livenessProbe:
{{- include "freesailarr.probeFor" (dict "root" $ "app" "seerr" "kind" "livenessProbe" "given" .Values.seerr.livenessProbe) | nindent 12 }}
readinessProbe:
{{- include "freesailarr.probeFor" (dict "root" $ "app" "seerr" "kind" "readinessProbe" "given" .Values.seerr.readinessProbe) | nindent 12 }}
startupProbe:
{{- include "freesailarr.probeFor" (dict "root" $ "app" "seerr" "kind" "startupProbe" "given" .Values.seerr.startupProbe) | nindent 12 }}
resources:
{{- include "freesailarr.resourcesFor" (dict "root" $ "app" "seerr" "given" .Values.seerr.resources) | nindent 12 }}
securityContext:
{{- include "freesailarr.securityContextFor" (dict "root" $ "app" "seerr" "given" .Values.seerr.securityContext) | nindent 12 }}
volumeMounts:
- mountPath: /app/config
name: seerr-config
- mountPath: /tmp
name: seerr-tmp
{{- end }}
{{- if .Values.bazarr.enabled }}
- name: bazarr
image: "{{ .Values.bazarr.image.repository }}:{{ .Values.bazarr.image.tag }}"
imagePullPolicy: IfNotPresent
env:
- name: PUID
value: {{ .Values.puid | quote }}
- name: PGID
value: {{ .Values.pgid | quote }}
- name: TZ
value: {{ .Values.timezone | quote }}
{{- include "freesailarr.env" (dict "root" $ "env" .Values.bazarr.env) | nindent 12 }}
ports:
- containerPort: 6767
protocol: TCP
name: bazarr
livenessProbe:
{{- include "freesailarr.probeFor" (dict "root" $ "app" "bazarr" "kind" "livenessProbe" "given" .Values.bazarr.livenessProbe) | nindent 12 }}
readinessProbe:
{{- include "freesailarr.probeFor" (dict "root" $ "app" "bazarr" "kind" "readinessProbe" "given" .Values.bazarr.readinessProbe) | nindent 12 }}
startupProbe:
{{- include "freesailarr.probeFor" (dict "root" $ "app" "bazarr" "kind" "startupProbe" "given" .Values.bazarr.startupProbe) | nindent 12 }}
resources:
{{- include "freesailarr.resourcesFor" (dict "root" $ "app" "bazarr" "given" .Values.bazarr.resources) | nindent 12 }}
securityContext:
{{- include "freesailarr.securityContextFor" (dict "root" $ "app" "bazarr" "given" .Values.bazarr.securityContext) | nindent 12 }}
volumeMounts:
- mountPath: /config
name: bazarr-config
- mountPath: /movies
name: {{ include "freesailarr.mediaVolumeName" (dict "root" $ "category" "movies") }}
{{- with .Values.media.movies.subPath }}
subPath: {{ . | quote }}
{{- end }}
- mountPath: /tv
name: {{ include "freesailarr.mediaVolumeName" (dict "root" $ "category" "tv") }}
{{- with .Values.media.tv.subPath }}
subPath: {{ . | quote }}
{{- end }}
{{- end }}
{{- if .Values.mediathekarr.enabled }}
# MediathekArr, the tenth container and the only one off by default: a fake
# Usenet indexer for Prowlarr (Newznab, port 5008) and a fake SABnzbd
# for Sonarr/Radarr (port 5007), fetching from the ARD and ZDF media
# libraries. Both consumers sit in this pod and reach it over
# 127.0.0.1, so it needs neither a service nor DNS to do its job.
- name: mediathekarr
# Tag AND digest: the tag stays readable, the digest is what is
# actually pulled. "beta" moves, and "latest" is a 2024 build - see
# the values comment.
image: "{{ .Values.mediathekarr.image.repository }}:{{ .Values.mediathekarr.image.tag }}@{{ required "mediathekarr.image.digest is required - the tag alone is a moving reference" .Values.mediathekarr.image.digest }}"
imagePullPolicy: IfNotPresent
{{- if .Values.mediathekarr.startGate.enabled }}
# THE START GATE. Kubernetes cannot order two regular containers, and
# an initContainer waiting for gluetun would deadlock (gluetun only
# starts once the init containers are done). So the wait happens here,
# in front of the image entrypoint: poll gluetun's health server -
# 200 only while the tunnel actually works - and exec the entrypoint
# afterwards.
#
# Why this is not optional decoration: MediathekArr fetches its
# ruleset once at start and never retries. Started before the tunnel,
# it answered every Newznab search with 0 results for over eleven
# minutes - container up, Ready, nothing in the log. The failure case
# looks exactly like the healthy one, which is why the gate exits
# instead of starting anyway when the timeout passes: a crash-looping
# container is visible, a silently empty indexer is not.
#
# wget comes with the (Debian) image, bash is its entrypoint's own
# interpreter - no extra tooling and no shell built into the chart.
command:
- /bin/bash
- -c
- |
set -eu
deadline=$(( SECONDS + {{ int .Values.mediathekarr.startGate.timeoutSeconds }} ))
echo "mediathekarr: waiting for gluetun to report healthy (127.0.0.1:9999)"
until wget -q -T 5 -O /dev/null http://127.0.0.1:9999/; do
if [ "$SECONDS" -ge "$deadline" ]; then
echo "mediathekarr: gluetun did not report healthy within {{ int .Values.mediathekarr.startGate.timeoutSeconds }}s - exiting instead of starting without a tunnel, because a MediathekArr started too early answers every search with 0 results and reports nothing" >&2
exit 1
fi
sleep {{ int .Values.mediathekarr.startGate.intervalSeconds }}
done
echo "mediathekarr: gluetun healthy after ${SECONDS}s"
{{- if gt (int .Values.mediathekarr.startGate.settleSeconds) 0 }}
sleep {{ int .Values.mediathekarr.startGate.settleSeconds }}
{{- end }}
exec /app/docker_start.sh
{{- end }}
env:
- name: TZ
value: {{ .Values.timezone | quote }}
{{- /* Both download paths are set by the chart, not left to the
image: complete/ has to be visible to Sonarr/Radarr under
the SAME absolute path (they are handed this value as the
SABnzbd complete_dir, and a deviation imports nothing
without an error), and incomplete/ has to live on the media
claim rather than in an emptyDir, which would count against
the container's ephemeral-storage limit and let one large
download evict the whole pod. Setting them also keeps the
image's built-in /data/mediathek default out of play, which
the read-only root filesystem does not provide. */}}
- name: DOWNLOAD_INCOMPLETE_PATH
value: {{ printf "/downloads/%s/incomplete" .Values.mediathekarr.downloads.directory | quote }}
- name: DOWNLOAD_COMPLETE_PATH
value: {{ printf "/downloads/%s/complete" .Values.mediathekarr.downloads.directory | quote }}
{{- include "freesailarr.env" (dict "root" $ "env" .Values.mediathekarr.env) | nindent 12 }}
ports:
- containerPort: 5007
protocol: TCP
name: mediathekarr
- containerPort: 5008
protocol: TCP
name: ma-newznab
livenessProbe:
{{- include "freesailarr.probeFor" (dict "root" $ "app" "mediathekarr" "kind" "livenessProbe" "given" .Values.mediathekarr.livenessProbe) | nindent 12 }}
readinessProbe:
{{- include "freesailarr.probeFor" (dict "root" $ "app" "mediathekarr" "kind" "readinessProbe" "given" .Values.mediathekarr.readinessProbe) | nindent 12 }}
startupProbe:
{{- include "freesailarr.probeFor" (dict "root" $ "app" "mediathekarr" "kind" "startupProbe" "given" .Values.mediathekarr.startupProbe) | nindent 12 }}
resources:
{{- include "freesailarr.resourcesFor" (dict "root" $ "app" "mediathekarr" "given" .Values.mediathekarr.resources) | nindent 12 }}
securityContext:
{{- include "freesailarr.securityContextFor" (dict "root" $ "app" "mediathekarr" "given" .Values.mediathekarr.securityContext) | nindent 12 }}
volumeMounts:
- mountPath: /app/config
name: mediathekarr-config
{{- /* The same claim, the same mount path as sonarr/radarr - that
is what makes the complete_dir path identical on both
sides. */}}
- mountPath: /downloads
name: {{ include "freesailarr.mediaVolumeName" (dict "root" $ "category" "downloads") }}
{{- with .Values.media.downloads.subPath }}
subPath: {{ . | quote }}
{{- end }}
# Not a measured requirement: the container ran through with a
# read-only root and without a writable /tmp. Kept as cheap
# insurance for the .NET runtime, which is the kind of consumer that
# reaches for /tmp on a code path nobody exercised yet.
- mountPath: /tmp
name: mediathekarr-tmp
{{- end }}
restartPolicy: Always
volumes:
# The tun device gluetun needs to create the Wireguard tunnel.
- name: dev-net-tun
hostPath:
path: /dev/net/tun
type: CharDevice
# 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 .Values.mediathekarr.enabled }}
{{- if include "freesailarr.mediaVolumeOwned" (dict "root" $ "category" "downloads") }}
- name: media-downloads
persistentVolumeClaim:
claimName: {{ required "media.downloads.existingClaim is required when qbittorrent, radarr, sonarr or mediathekarr 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: {}
- name: flaresolverr-shm
emptyDir:
medium: Memory
sizeLimit: 512Mi
{{- end }}
{{- if .Values.recyclarr.enabled }}
- name: recyclarr-tmp
emptyDir: {}
{{- end }}
{{- if .Values.seerr.enabled }}
- name: seerr-tmp
emptyDir: {}
{{- end }}
{{- if .Values.mediathekarr.enabled }}
- name: mediathekarr-tmp
emptyDir: {}
{{- end }}
# Config volumes for gluetun and the enabled apps. NOTE: volumeClaimTemplates
# are immutable - toggling an app's enabled flag later requires recreating
# the StatefulSet once (kubectl delete statefulset <name> --cascade=orphan
# before upgrading; existing PVCs and their data survive).
volumeClaimTemplates:
{{- range $app := list "gluetun" "qbittorrent" "prowlarr" "radarr" "recyclarr" "sonarr" "seerr" "bazarr" "mediathekarr" }}
{{- $config := index $.Values $app }}
{{- if or (eq $app "gluetun") $config.enabled }}
- metadata:
name: {{ $app }}-config
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: {{ $config.storage.size }}
{{- with $.Values.storage.storageClass }}
storageClassName: {{ . }}
{{- end }}
{{- end }}
{{- end }}