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 --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 }}