MediathekArr fakes a Usenet indexer towards Prowlarr and a SABnzbd downloader towards Sonarr/Radarr and fetches from the ARD and ZDF media libraries. Both consumers already share gluetun's network namespace, so they reach it over 127.0.0.1 - no service, no DNS, no ingress detour. Purely additive: the flag defaults to false and a default render is byte-identical to 3.0.5 (verified against the previous chart for both the default values and the CI values). The four things that fail silently without them: - A START GATE on gluetun. MediathekArr fetches its ruleset once at start and never retries; started before the tunnel it answers every Newznab search with 0 results while the container is up, Ready and quiet. Kubernetes cannot order two regular containers and an initContainer would deadlock, so the wait sits in front of the image entrypoint: poll gluetun's health server on 127.0.0.1:9999 and exec the entrypoint afterwards; on timeout the container exits rather than starting blind, because a crash loop is visible and an empty indexer is not. The startup probe budget (300s) deliberately outlasts the gate (180s). - incomplete/ AND complete/ ON THE SHARED DOWNLOADS CLAIM, not in an emptyDir. An emptyDir counts against the container's ephemeral-storage limit, and one large download would have the kubelet evict the whole pod with all ten containers - the trap ytdlp-web-player documents. - THE COMPLETE PATH SONARR SEES. The claim is mounted at /downloads exactly as sonarr/radarr mount it, and the chart sets DOWNLOAD_COMPLETE_PATH itself, so the SABnzbd complete_dir it reports is the same absolute path on both sides. - TWO PORTS: 5007 (SABnzbd role and UI, primary) and 5008 (Newznab role for Prowlarr), both declared to gluetun's firewall and both on one Service. The SABnzbd API is at /download/api - /api on 5007 answers with a misleading Newznab validation error. Further details: - Image pinned by digest (tag + digest): "latest" is a 2024 build, "beta" moves. Single-arch linux/amd64. The built-in helm-values manager cannot follow that pair, so renovate.json5 gets the chart's first customManagers block; verified by dry-run with two fixtures, since a matching and a broken rule look identical while the pin is current. - Hardened as measured: non-root, uid/gid from puid/pgid, readOnlyRootFilesystem: true, all capabilities dropped, no PUID/PGID. - Probes on measured 200 endpoints (there is no health endpoint): readiness and startup on /download/, liveness as an exec covering BOTH ports, because one container runs two processes and a wedged Newznab half is exactly what makes searches come back empty. - No ingress by default: the UI links its assets with root-absolute paths, so it can only be served at the root of a host; on the shared domain that root belongs to seerr. A sub-path fails the render instead of producing a broken UI. - values.yaml and NOTES.txt document the geo MARKER PROCEDURE (not a table of series), the escape route of a German exit and its price for the whole stack, the fail_message: null trap, and the HLS question as an open question. - CI: mediathekarr enabled in ci/freesailarr/test-values.yaml, install timeout raised to 1200s for the extra image and the gate. Fixes #245
710 lines
38 KiB
YAML
710 lines
38 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 }}
|
|
{{- 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 }}
|
|
{{- 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 }}
|
|
{{- 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 }}
|