{{/* Render the body of a container "resources:" block from a values dict of the form resources: limitFactor: 3 # optional, default 3 requests: { cpu, memory, ephemeral-storage / ephemeralStorage, ... } limits: { ... } # optional, every entry optional Rules (variant B, see issue #19): - requests are rendered as given (camelCase "ephemeralStorage" is normalized to the Kubernetes key "ephemeral-storage"). - an explicitly set limit always wins. - a missing memory / ephemeral-storage limit is computed as limitFactor x the corresponding request. - a missing cpu limit is NOT set at all (the kubelet then treats CPU as unlimited; CPU is compressible, so this cannot kill the workload). Usage: resources: {{- include "freesailarr.resources" .Values..resources | nindent 12 }} NOTE: this helper is duplicated into every chart's templates/_helpers.tpl with the chart's name as define prefix, because sharing it would mean a library chart, and a dependency is admissible but expensive (README, guideline 14) - this chart takes none. The reference implementation lives in template-chart. */}} {{- define "freesailarr.resources" -}} {{- $resources := . | default dict -}} {{- $factor := 3.0 -}} {{- if not (kindIs "invalid" $resources.limitFactor) -}} {{- $factor = float64 $resources.limitFactor -}} {{- end -}} {{- $requests := dict -}} {{- range $key, $value := ($resources.requests | default dict) -}} {{- if not (kindIs "invalid" $value) -}} {{- $_ := set $requests (include "freesailarr.resourceKey" $key) $value -}} {{- end -}} {{- end -}} {{- $limits := dict -}} {{- range $key, $value := ($resources.limits | default dict) -}} {{- if not (kindIs "invalid" $value) -}} {{- $_ := set $limits (include "freesailarr.resourceKey" $key) $value -}} {{- end -}} {{- end -}} {{- range $key, $request := $requests -}} {{- if and (ne $key "cpu") (not (hasKey $limits $key)) -}} {{- $_ := set $limits $key (include "freesailarr.multiplyQuantity" (dict "quantity" $request "factor" $factor)) -}} {{- end -}} {{- end -}} {{- $out := dict -}} {{- with $requests }}{{- $_ := set $out "requests" . }}{{- end -}} {{- with $limits }}{{- $_ := set $out "limits" . }}{{- end -}} {{- toYaml $out -}} {{- end -}} {{/* Normalize a resource name from values to its Kubernetes key. */}} {{- define "freesailarr.resourceKey" -}} {{- if eq . "ephemeralStorage" -}}ephemeral-storage{{- else -}}{{ . }}{{- end -}} {{- end -}} {{/* Multiply a Kubernetes quantity by a factor, keeping its suffix. Input: dict "quantity" "factor" . Handles plain numbers ("2", "0.5", 1073741824), decimal ("1.5Gi"), binary ("512Mi", "1Gi", "10Ki") and milli/SI suffixes ("100m", "500M"). Integer results are printed without a decimal point. */}} {{- define "freesailarr.multiplyQuantity" -}} {{- $quantity := include "freesailarr.formatNumber" .quantity -}} {{- $number := regexFind "^[0-9]+(\\.[0-9]+)?" $quantity -}} {{- if not $number -}} {{- fail (printf "cannot parse resource quantity %q" $quantity) -}} {{- end -}} {{- $suffix := trimPrefix $number $quantity -}} {{- if not (regexMatch "^(m|k|M|G|T|P|E|Ki|Mi|Gi|Ti|Pi|Ei)?$" $suffix) -}} {{- fail (printf "unsupported resource quantity suffix %q in %q" $suffix $quantity) -}} {{- end -}} {{- $product := mulf (float64 $number) .factor -}} {{- if eq $product (floor $product) -}} {{- printf "%d%s" (int64 $product) $suffix -}} {{- else -}} {{- printf "%s%s" (include "freesailarr.formatNumber" $product) $suffix -}} {{- end -}} {{- end -}} {{/* Render a number as a plain decimal string, never in exponential notation. Why this exists (issue #157): the null-safe fallback helpers pass values through fromYaml, which parses every number as float64. Go's "%v" switches float64 to exponential notation from about 1e6 upwards, so a bare byte count like 1048576 became "1.048576e+06" - not a valid Kubernetes quantity, and the render aborted. Strings ("512Mi") are passed through untouched. */}} {{- define "freesailarr.formatNumber" -}} {{- if kindIs "float64" . -}} {{- regexReplaceAll "\\.?0+$" (printf "%.10f" .) "" -}} {{- else -}} {{- printf "%v" . -}} {{- end -}} {{- end -}} {{/* Render the replica count for a Deployment/StatefulSet. Input: dict "scaleDown" the chart-global .Values.scaleDown flag (may be nil) "replicas" the workload's replicas value (may be nil) Rules (issue #25): - scaleDown: true takes precedence and always yields 0. - otherwise an explicitly set replicas value is used as given, including an explicit 0 — Helm's "default" would swallow 0, hence the explicit kindIs "invalid" nil check. - a missing replicas value defaults to 1. Usage: replicas: {{ include "freesailarr.replicas" (dict "scaleDown" .Values.scaleDown "replicas" .Values.replicas) }} NOTE: like the resources helper above, this is duplicated into every chart's templates/_helpers.tpl with the chart's name as define prefix. The reference implementation lives in template-chart. */}} {{- define "freesailarr.replicas" -}} {{- if .scaleDown -}} 0 {{- else if not (kindIs "invalid" .replicas) -}} {{- .replicas -}} {{- else -}} 1 {{- end -}} {{- end -}} {{/* Render additive env entries (the repo-wide .env convention: tpl-rendered, supporting the value, secretKeyRef and configMapKeyRef forms). Shared as a helper here because this chart has ten containers, each with its own env block. Usage: {{- include "freesailarr.env" (dict "root" $ "env" .Values..env) | nindent 12 }} */}} {{- define "freesailarr.env" -}} {{- $root := .root -}} {{- range $value := .env }} {{- if $value.value }} - name: {{ $value.name }} value: {{ tpl $value.value $root | quote }} {{- else if $value.valueFrom }} {{- if $value.valueFrom.secretKeyRef }} - name: {{ $value.name }} valueFrom: secretKeyRef: name: {{ tpl $value.valueFrom.secretKeyRef.name $root | quote }} key: {{ tpl $value.valueFrom.secretKeyRef.key $root | quote }} {{- else if $value.valueFrom.configMapKeyRef }} - name: {{ $value.name }} valueFrom: configMapKeyRef: name: {{ tpl $value.valueFrom.configMapKeyRef.name $root | quote }} key: {{ tpl $value.valueFrom.configMapKeyRef.key $root | quote }} {{- end }} {{- end }} {{- end }} {{- end -}} {{/* The enabled app containers that listen on a port, as a JSON array of {name, port} dicts (container port name == app name == service name suffix). Consumed by the per-app services, the per-app ingresses and the firewall-ports helper below: {{- $apps := include "freesailarr.portApps" . | fromJsonArray }} recyclarr is deliberately absent: in daemon mode it serves no port, so it gets no container port, no service and no ingress. flaresolverr is present - it listens on 8191 and therefore gets a service - but it has no ingress block in values and is never exposed from outside the cluster. "port" is the app's PRIMARY port: the one its service is named after and the one an ingress points at. mediathekarr is the only app with a second one, because it runs two processes in one container - the SABnzbd role on 5007 (the UI, hence the primary) and the Newznab role Prowlarr consumes on 5008. Optional "extraPorts" entries ({name, port}) are additional ports of the SAME service and are declared to gluetun's firewall alongside the primary; they never take part in the ingress, which addresses a service port by name. */}} {{- define "freesailarr.portApps" -}} {{- $apps := list -}} {{- range $app := list (dict "name" "qbittorrent" "port" 8080) (dict "name" "prowlarr" "port" 9696) (dict "name" "flaresolverr" "port" 8191) (dict "name" "radarr" "port" 7878) (dict "name" "sonarr" "port" 8989) (dict "name" "seerr" "port" 5055) (dict "name" "bazarr" "port" 6767) (dict "name" "mediathekarr" "port" 5007 "extraPorts" (list (dict "name" "ma-newznab" "port" 5008))) -}} {{- if (index $.Values $app.name).enabled -}} {{- $apps = append $apps $app -}} {{- end -}} {{- end -}} {{- toJson $apps -}} {{- end -}} {{/* Is this release published through a routing object at all? Returns the string "true" or the empty string (falsy in a template "if"). Input: the root context. ONE PLACE, BECAUSE THE WRONG ANSWER IS INVISIBLE (Gitea #114) ingress.enabled is the chart's master switch, and until the Gateway-API mode existed it was also the answer to "is anything published". It stopped being that answer in exactly one configuration - and that configuration is the whole point of the new mode: httpRoute.enabled: true forces ingress.enabled: false (the two are mutually exclusive, the render aborts otherwise), while the apps ARE published, now through HTTPRoutes. The consumer that makes this load-bearing is NOT the routing templates - those know their own mode - but freesailarr.sharedIngressApps below, which is also read by the __SERVER__URLBASE environment variables (see freesailarr.urlBase). Read from ingress.enabled alone, that list would come out EMPTY in Gateway mode, so Radarr, Sonarr and Prowlarr would be told no base path while their routes serve them under /radarr, /sonarr and /prowlarr. Each app would then answer at the sub-path with a page whose assets and API calls are linked at the host root, i.e. a broken UI behind a Ready pod - the same silent class as sure's TLS flags, found in the same sweep. The duplicate-path and root-only guards in that helper would also have gone unenforced in Gateway mode, for the same reason. With httpRoute.enabled at its default of false this expression IS ingress.enabled, which is why adding it changes nothing for an existing release: `helm template` with unchanged values renders byte-identically. */}} {{- define "freesailarr.published" -}} {{- $httpRoute := .Values.httpRoute | default dict -}} {{- if or .Values.ingress.enabled $httpRoute.enabled -}}true{{- end -}} {{- end -}} {{/* The apps that are published through a routing object: the enabled port apps whose values carry an ingress block with enabled: true. Used by BOTH routing templates - the per-app .ingress.enabled toggle selects which apps are exposed in Ingress mode and in Gateway mode alike, and only the Ingress-only fields of that block (clusterIssuer, className, annotations) have no meaning in the second one. The name is historical; renaming it would churn every call site for no behaviour. {{- $apps := include "freesailarr.ingressApps" . | fromJsonArray }} */}} {{- define "freesailarr.ingressApps" -}} {{- $apps := list -}} {{- range $app := include "freesailarr.portApps" . | fromJsonArray -}} {{- $config := index $.Values $app.name -}} {{- if and $config.ingress $config.ingress.enabled -}} {{- $apps = append $apps $app -}} {{- end -}} {{- end -}} {{- toJson $apps -}} {{- end -}} {{/* The sub-path an app is served under in the shared-domain mode, e.g. "/sonarr". Null-safe by construction: an absent or erased .ingress.path falls back to "/", and to "/" for the two apps that can only live at the root - seerr, which has no base-path support at all, and mediathekarr, whose UI links its assets with root-absolute paths (see the guard in sharedIngressApps). Input: dict "root" $ "app" . */}} {{- define "freesailarr.appPath" -}} {{- $config := (index .root.Values .app).ingress -}} {{- $path := "" -}} {{- if kindIs "map" $config -}} {{- $path = $config.path -}} {{- end -}} {{- if or (kindIs "invalid" $path) (eq (toString $path) "") -}} {{- if has .app (list "seerr" "mediathekarr") -}} {{- $path = "/" -}} {{- else -}} {{- $path = printf "/%s" .app -}} {{- end -}} {{- end -}} {{- $path -}} {{- end -}} {{/* The apps served under the shared domain (ingress.host), as a JSON array of {name, port, path} dicts, sorted by path length descending so the rendered list is deterministic. The shared-domain mode is off unless ingress.host is set; then it covers the apps whose routing object renders at all (freesailarr.ingressApps, i.e. the enabled port apps with ingress.enabled) and which do not carry a host of their own. A per-app host always wins and keeps rendering its own object exactly as before, which is what makes mixed operation fall out for free. Gated on freesailarr.published and NOT on ingress.enabled (Gitea #114): this list is read by the UrlBase env vars too, and in Gateway mode ingress.enabled is false by construction while the apps are published all the same. See that helper for what reading the switch directly would have broken silently. Two guards live here rather than in the routing templates, so that every consumer - the Ingress objects, the HTTPRoutes, the UrlBase env vars and NOTES.txt - sees the same validated list: 1. seerr or mediathekarr under any path other than "/". seerr has no base-path support (upstream request open since May 2022) and MediathekArr's UI links its assets with root-absolute paths, so a chart that rendered either under a sub-path would produce something that provably cannot work. Both at "/" on one host is caught by guard 2 below - one of them then needs a host of its own. 2. Two apps claiming the same path on the shared host. Only paths on the shared host can collide; apps with their own host are on a different host and are therefore not part of this check. {{- $shared := include "freesailarr.sharedIngressApps" . | fromJsonArray }} */}} {{- define "freesailarr.sharedIngressApps" -}} {{- $root := . -}} {{- $apps := list -}} {{- $host := $root.Values.ingress.host | default "" -}} {{- if and (include "freesailarr.published" $root) $host -}} {{- $seen := dict -}} {{- range $app := include "freesailarr.ingressApps" $root | fromJsonArray -}} {{- $config := (index $root.Values $app.name).ingress -}} {{- if not ($config.host | default "") -}} {{- $path := include "freesailarr.appPath" (dict "root" $root "app" $app.name) -}} {{- $rootOnly := dict "seerr" "seerr has no base-path support (https://github.com/Fallenbagel/jellyseerr/issues/97)" "mediathekarr" "MediathekArr links its UI assets with root-absolute paths (/download/css/..., measured), so a sub-path stays broken even behind a prefix-stripping rewrite - the browser then asks for those assets at the host root" -}} {{- if and (hasKey $rootOnly $app.name) (ne $path "/") -}} {{- fail (printf "%s.ingress.path is %q, but %s and it can therefore only be served at \"/\". Either leave %s.ingress.path at \"/\" or give it its own %s.ingress.host." $app.name $path (index $rootOnly $app.name) $app.name $app.name) -}} {{- end -}} {{- if hasKey $seen $path -}} {{- fail (printf "%s.ingress.path and %s.ingress.path are both %q on the shared host %q - every app under ingress.host needs its own path. Where neither can move (seerr and mediathekarr can only be served at \"/\"), give one of them its own .ingress.host." (index $seen $path) $app.name $path $host) -}} {{- end -}} {{- $_ := set $seen $path $app.name -}} {{- $apps = append $apps (dict "name" $app.name "port" $app.port "path" $path) -}} {{- end -}} {{- end -}} {{- end -}} {{- /* Sort by path length descending, ties broken alphabetically. The keys are unique because the guard above rejects duplicate paths. */ -}} {{- $keyed := dict -}} {{- $keys := list -}} {{- range $app := $apps -}} {{- $key := printf "%03d|%s" (sub 999 (len $app.path)) $app.path -}} {{- $_ := set $keyed $key $app -}} {{- $keys = append $keys $key -}} {{- end -}} {{- $sorted := list -}} {{- range $key := sortAlpha $keys -}} {{- $sorted = append $sorted (index $keyed $key) -}} {{- end -}} {{- toJson $sorted -}} {{- end -}} {{/* The value for a servarr app's __SERVER__URLBASE variable, or the empty string when the chart must not set it. Radarr, Sonarr and Prowlarr read their base path from this environment variable (schema __CONFIGNAMESPACE__CONFIGITEM, https://wiki.servarr.com/sonarr/environment-variables), so the chart can configure them itself as soon as they render under a sub-path. Mode-independent by construction (Gitea #114): the sub-path an app is served under is the same in Ingress and in Gateway mode, so this variable follows freesailarr.sharedIngressApps and not the routing object. That is only true because that helper asks freesailarr.published rather than ingress.enabled - reading the switch directly would have emptied this variable in Gateway mode while the route still served the app under its prefix. Empty - i.e. the chart sets nothing - in three cases: - the app is not served under the shared host, or is served at "/"; - the app's own env block already carries the variable. The env blocks are documented as additive; emitting a second entry with the same name would break that promise, and two identically named variables in one container are not a defined state to build on. Input: dict "root" $ "app" <"radarr"|"sonarr"|"prowlarr">. */}} {{- define "freesailarr.urlBase" -}} {{- $root := .root -}} {{- $app := .app -}} {{- $var := printf "%s__SERVER__URLBASE" (upper $app) -}} {{- $path := "" -}} {{- range $entry := include "freesailarr.sharedIngressApps" $root | fromJsonArray -}} {{- if eq $entry.name $app -}} {{- $path = $entry.path -}} {{- end -}} {{- end -}} {{- if eq $path "/" -}} {{- $path = "" -}} {{- end -}} {{- if $path -}} {{- range $entry := (index $root.Values $app).env -}} {{- if eq (toString $entry.name) $var -}} {{- $path = "" -}} {{- end -}} {{- end -}} {{- end -}} {{- $path -}} {{- end -}} {{/* The value for a servarr app's __SERVER__TRUSTEDNETWORKS variable, or the empty string when the chart must not set it. Same env schema as the UrlBase variable above (__CONFIGNAMESPACE__CONFIGITEM): verified for all three apps at the tags this chart pins, where Bootstrap.cs binds services.Configure(config.GetSection(":Server")) next to a plain .AddEnvironmentVariables(). The variable is emitted ONLY when the value is non-empty. An empty TRUSTEDNETWORKS would be indistinguishable from an unset one for the app - upstream reads string.Empty either way - but not for a reader of the manifest, to whom it would look like a setting that had been taken care of. Empty - i.e. the chart sets nothing - in two cases: - the app's trustedNetworks value is empty, which is the DEFAULT. The apps then trust no proxy at all; see the value's comment in values.yaml for what that costs and why it is not a preserved behaviour; - the app's own env block already carries the variable, for the same reason the UrlBase helper above stays out of that case: the env blocks are additive, and two identically named variables in one container are not a defined state to build on. Input: dict "root" $ "app" <"radarr"|"sonarr"|"prowlarr">. */}} {{- define "freesailarr.trustedNetworks" -}} {{- $root := .root -}} {{- $app := .app -}} {{- $var := printf "%s__SERVER__TRUSTEDNETWORKS" (upper $app) -}} {{- $value := (index $root.Values $app).trustedNetworks | default "" -}} {{- if $value -}} {{- range $entry := (index $root.Values $app).env -}} {{- if eq (toString $entry.name) $var -}} {{- $value = "" -}} {{- end -}} {{- end -}} {{- end -}} {{- $value -}} {{- end -}} {{/* Comma-separated FIREWALL_INPUT_PORTS for gluetun: the ports of the enabled app containers (ingress traffic and the kubelet probes both arrive on the pod IP) plus 9999 for gluetun's own health server. Measured caveat: inside a pod network these rules never actually match. gluetun detects the pod's own subnet and inserts "-A INPUT -d -i eth0 -j ACCEPT" ahead of the per-port rules, and every packet arriving at the pod IP matches it by destination - the packet counters on the --dport rules stayed at 0 while the subnet rule absorbed the traffic. The declaration is kept as an explicit statement of which ports are meant to be reachable, and because that subnet detection is gluetun's, not a guarantee of the chart's: a CNI that hands out a /32 or a future gluetun that drops the rule would make these the only thing keeping probes and ingress alive. It is not what opens the ports today. */}} {{- define "freesailarr.firewallInputPorts" -}} {{- $ports := list -}} {{- range $app := include "freesailarr.portApps" . | fromJsonArray -}} {{- $ports = append $ports (int $app.port) -}} {{- range $extra := $app.extraPorts | default list -}} {{- $ports = append $ports (int $extra.port) -}} {{- end -}} {{- end -}} {{- $ports = append $ports 9999 -}} {{- join "," $ports -}} {{- end -}} {{/* Whether a media category is mounted at all, i.e. whether at least one app that mounts it is enabled. "true" when it is, the empty string when it is not (an empty string is falsy in a template "if", so the result can be used directly). downloads: qbittorrent, radarr, sonarr, mediathekarr movies: radarr, bazarr tv: sonarr, bazarr Input: dict "root" $ "category" <"downloads"|"movies"|"tv">. */}} {{- define "freesailarr.mediaCategoryEnabled" -}} {{- $v := .root.Values -}} {{- if eq .category "downloads" -}} {{- if or $v.qbittorrent.enabled $v.radarr.enabled $v.sonarr.enabled $v.mediathekarr.enabled -}}true{{- end -}} {{- else if eq .category "movies" -}} {{- if or $v.radarr.enabled $v.bazarr.enabled -}}true{{- end -}} {{- else if eq .category "tv" -}} {{- if or $v.sonarr.enabled $v.bazarr.enabled -}}true{{- end -}} {{- else -}} {{- fail (printf "unknown media category %q" .category) -}} {{- end -}} {{- end -}} {{/* The pod volume name a media category mounts (issue #164). Several categories may point at the same existingClaim - one library volume with the categories as sub-directories is the intended layout. A pod may reference such a claim only ONCE: two volume entries with the same claimName leave the pod in Init:0/1 forever, without a single kubelet event (measured on RKE2/Longhorn, with RWO as well as RWX). The claim therefore gets exactly one volume entry and the categories differ only in the subPath of their mounts. This helper names the owner of that entry: the FIRST ENABLED category, in the fixed order downloads, movies, tv that carries the same claim. Two properties of that rule are load-bearing: - The order is fixed and defined here, not derived from the values. A rule like "the alphabetically smallest" or "whichever comes first in the values" would move the volume name when an unrelated flag changes, and a moved volume name is a pod restart. - Only ENABLED categories can own the entry, because the three entries are rendered under different conditions (see mediaCategoryEnabled above). A disabled owner renders no entry, so its name would be a dangling reference and the pod would be rejected by the API server. Example: with qbittorrent, radarr and sonarr off and bazarr on, "downloads" is not mounted at all - "movies" owns a claim shared by all three categories. A category with no claim owns itself, so the required() message for the missing claim still fires at the category the user actually configured. Input: dict "root" $ "category" <"downloads"|"movies"|"tv">. Usage (mount) : name: {{ include "freesailarr.mediaVolumeName" (dict "root" $ "category" "movies") }} Usage (volume): render the entry only for a category that owns itself. */}} {{- define "freesailarr.mediaVolumeName" -}} {{- $root := .root -}} {{- $claim := (index $root.Values.media .category).existingClaim | default "" -}} {{- $owner := "" -}} {{- if $claim -}} {{- range $candidate := list "downloads" "movies" "tv" -}} {{- if not $owner -}} {{- if include "freesailarr.mediaCategoryEnabled" (dict "root" $root "category" $candidate) -}} {{- if eq ((index $root.Values.media $candidate).existingClaim | default "") $claim -}} {{- $owner = $candidate -}} {{- end -}} {{- end -}} {{- end -}} {{- end -}} {{- end -}} {{- if not $owner -}} {{- $owner = .category -}} {{- end -}} media-{{ $owner }} {{- end -}} {{/* Whether a media category owns its volume entry, i.e. whether the volumes block renders an entry for it. "true" or the empty string, see mediaVolumeName above. Input: dict "root" $ "category" <"downloads"|"movies"|"tv">. */}} {{- define "freesailarr.mediaVolumeOwned" -}} {{- if eq (include "freesailarr.mediaVolumeName" .) (printf "media-%s" .category) -}}true{{- end -}} {{- end -}} {{/* Recursive merge of an override onto defaults where null never deletes and set values always win, including false and 0 (issue #99). Input: dict "defaults" "given" "path" . NOTE: like the resources helper above, this is duplicated into every chart's templates/_helpers.tpl with the chart's name as define prefix. The reference implementation lives in template-chart. */}} {{- define "freesailarr.defaultedDict" -}} {{- $defaults := .defaults | default dict -}} {{- $given := .given -}} {{- $path := .path | default "value" -}} {{- if kindIs "invalid" $given -}} {{- $given = dict -}} {{- end -}} {{- if not (kindIs "map" $given) -}} {{- fail (printf "%s must be a mapping or null, got %s (%v)" $path (kindOf $given) $given) -}} {{- end -}} {{- $out := deepCopy $defaults -}} {{- range $key, $value := $given -}} {{- if not (kindIs "invalid" $value) -}} {{- $default := index $defaults $key -}} {{- if and (kindIs "map" $value) (kindIs "map" $default) -}} {{- $_ := set $out $key (fromYaml (include "freesailarr.defaultedDict" (dict "defaults" $default "given" $value "path" (printf "%s.%s" $path $key)))) -}} {{- else if kindIs "map" $default -}} {{- fail (printf "%s.%s must be a mapping or null, got %s (%v)" $path $key (kindOf $value) $value) -}} {{- else -}} {{- $_ := set $out $key $value -}} {{- end -}} {{- end -}} {{- end -}} {{- toYaml $out -}} {{- end -}} {{/* Pick one sub-block out of a values block that may itself be null. Input: dict "block" "key" "path" . Needed because ".Values..securityContext" can itself be erased, and indexing into nil inside a template is not an error but silently yields nothing. */}} {{- define "freesailarr.subBlock" -}} {{- $block := .block -}} {{- if kindIs "invalid" $block -}} {{- $block = dict -}} {{- end -}} {{- if not (kindIs "map" $block) -}} {{- fail (printf "%s must be a mapping or null, got %s (%v)" .path (kindOf $block) $block) -}} {{- end -}} {{- toYaml (dict "value" (index $block .key)) -}} {{- end -}} {{/* The chart's own defaults for every block of the "silent class" from issue #99 - security contexts, probes and resources. An override that erases one of these blocks (`key:` with no children, i.e. null) would otherwise render a container without hardening, without a probe or without resource accounting: it comes up, reports green and is quietly weaker. The accessors below merge the user's block onto these defaults with freesailarr.defaultedDict, where null never deletes. These MUST stay in sync with values.yaml, which remains the documented, user-facing place for them; this copy is only the fallback. The sync is not left to discipline: rendering the chart with every one of these blocks explicitly set to null must produce byte-identical output to rendering it with the defaults, which is how the table was verified and how a drift shows up. Two rules this table exists to honour (README, "Chart style guidelines"): - It reproduces THIS chart's defaults, deviations included, not the repo standard. The five linuxserver.io containers deliberately run without runAsNonRoot and with a writable root filesystem, because their s6-overlay init starts as root and drops to PUID/PGID; "restoring" the clean standard here would leave them on the image's built-in uid 911 and make the existing media volumes inaccessible - the exact failure calibre-web-automated hit. flaresolverr's numerically pinned uid/gid 1000 and gluetun's NET_ADMIN are reproduced for the same reason. - The probe defaults carry their CONTEXT, not just timings: every httpGet keeps its path and port, and recyclarr - which serves no HTTP port in daemon mode - keeps its exec command. A probe default without them is worse than no default. */}} {{- define "freesailarr.fallbacks" -}} gluetun: livenessProbe: httpGet: path: / port: 9999 periodSeconds: 10 timeoutSeconds: 5 failureThreshold: 6 readinessProbe: httpGet: path: / port: 9999 periodSeconds: 10 timeoutSeconds: 5 failureThreshold: 3 startupProbe: httpGet: path: / port: 9999 periodSeconds: 5 timeoutSeconds: 5 failureThreshold: 30 resources: limitFactor: 3 requests: cpu: 0.1 memory: 128Mi ephemeralStorage: 50Mi securityContext: allowPrivilegeEscalation: false readOnlyRootFilesystem: false capabilities: drop: - ALL add: - NET_ADMIN qbittorrent: livenessProbe: httpGet: path: / port: 8080 periodSeconds: 10 timeoutSeconds: 5 failureThreshold: 3 readinessProbe: httpGet: path: / port: 8080 periodSeconds: 10 timeoutSeconds: 5 failureThreshold: 3 startupProbe: httpGet: path: / port: 8080 periodSeconds: 5 timeoutSeconds: 5 failureThreshold: 30 resources: limitFactor: 3 requests: cpu: 0.2 memory: 512Mi ephemeralStorage: 100Mi securityContext: allowPrivilegeEscalation: false readOnlyRootFilesystem: false capabilities: drop: - ALL add: - CHOWN - DAC_OVERRIDE - FOWNER - SETGID - SETUID prowlarr: livenessProbe: httpGet: path: /ping port: 9696 periodSeconds: 10 timeoutSeconds: 5 failureThreshold: 3 readinessProbe: httpGet: path: /ping port: 9696 periodSeconds: 10 timeoutSeconds: 5 failureThreshold: 3 startupProbe: httpGet: path: /ping port: 9696 periodSeconds: 5 timeoutSeconds: 5 failureThreshold: 30 resources: limitFactor: 3 requests: cpu: 0.1 memory: 256Mi ephemeralStorage: 100Mi securityContext: allowPrivilegeEscalation: false readOnlyRootFilesystem: false capabilities: drop: - ALL add: - CHOWN - DAC_OVERRIDE - FOWNER - SETGID - SETUID flaresolverr: livenessProbe: httpGet: path: /health port: 8191 periodSeconds: 10 timeoutSeconds: 5 failureThreshold: 3 readinessProbe: httpGet: path: /health port: 8191 periodSeconds: 10 timeoutSeconds: 5 failureThreshold: 3 startupProbe: httpGet: path: /health port: 8191 periodSeconds: 5 timeoutSeconds: 5 failureThreshold: 30 resources: limitFactor: 3 requests: cpu: 0.2 memory: 512Mi ephemeralStorage: 200Mi securityContext: allowPrivilegeEscalation: false readOnlyRootFilesystem: false runAsNonRoot: true runAsUser: 1000 runAsGroup: 1000 capabilities: drop: - ALL radarr: livenessProbe: httpGet: path: /ping port: 7878 periodSeconds: 10 timeoutSeconds: 5 failureThreshold: 3 readinessProbe: httpGet: path: /ping port: 7878 periodSeconds: 10 timeoutSeconds: 5 failureThreshold: 3 startupProbe: httpGet: path: /ping port: 7878 periodSeconds: 5 timeoutSeconds: 5 failureThreshold: 30 resources: limitFactor: 3 requests: cpu: 0.1 memory: 256Mi ephemeralStorage: 100Mi securityContext: allowPrivilegeEscalation: false readOnlyRootFilesystem: false capabilities: drop: - ALL add: - CHOWN - DAC_OVERRIDE - FOWNER - SETGID - SETUID recyclarr: livenessProbe: exec: command: - recyclarr - --version periodSeconds: 30 timeoutSeconds: 10 failureThreshold: 3 readinessProbe: exec: command: - recyclarr - --version periodSeconds: 30 timeoutSeconds: 10 failureThreshold: 3 startupProbe: exec: command: - recyclarr - --version periodSeconds: 5 timeoutSeconds: 10 failureThreshold: 30 resources: limitFactor: 3 requests: cpu: 0.05 memory: 128Mi ephemeralStorage: 50Mi securityContext: runAsUser: {{ .Values.puid }} runAsGroup: {{ .Values.pgid }} allowPrivilegeEscalation: false readOnlyRootFilesystem: true runAsNonRoot: true capabilities: drop: - ALL sonarr: livenessProbe: httpGet: path: /ping port: 8989 periodSeconds: 10 timeoutSeconds: 5 failureThreshold: 3 readinessProbe: httpGet: path: /ping port: 8989 periodSeconds: 10 timeoutSeconds: 5 failureThreshold: 3 startupProbe: httpGet: path: /ping port: 8989 periodSeconds: 5 timeoutSeconds: 5 failureThreshold: 30 resources: limitFactor: 3 requests: cpu: 0.1 memory: 256Mi ephemeralStorage: 100Mi securityContext: allowPrivilegeEscalation: false readOnlyRootFilesystem: false capabilities: drop: - ALL add: - CHOWN - DAC_OVERRIDE - FOWNER - SETGID - SETUID seerr: livenessProbe: httpGet: path: /api/v1/settings/public port: 5055 periodSeconds: 10 timeoutSeconds: 5 failureThreshold: 3 readinessProbe: httpGet: path: /api/v1/settings/public port: 5055 periodSeconds: 10 timeoutSeconds: 5 failureThreshold: 3 startupProbe: httpGet: path: /api/v1/settings/public port: 5055 periodSeconds: 10 timeoutSeconds: 5 failureThreshold: 30 resources: limitFactor: 3 requests: cpu: 0.1 memory: 256Mi ephemeralStorage: 100Mi securityContext: runAsUser: {{ .Values.puid }} runAsGroup: {{ .Values.pgid }} allowPrivilegeEscalation: false readOnlyRootFilesystem: true runAsNonRoot: true capabilities: drop: - ALL bazarr: livenessProbe: httpGet: path: / port: 6767 periodSeconds: 10 timeoutSeconds: 5 failureThreshold: 3 readinessProbe: httpGet: path: / port: 6767 periodSeconds: 10 timeoutSeconds: 5 failureThreshold: 3 startupProbe: httpGet: path: / port: 6767 periodSeconds: 5 timeoutSeconds: 5 failureThreshold: 30 resources: limitFactor: 3 requests: cpu: 0.1 memory: 256Mi ephemeralStorage: 100Mi securityContext: allowPrivilegeEscalation: false readOnlyRootFilesystem: false capabilities: drop: - ALL add: - CHOWN - DAC_OVERRIDE - FOWNER - SETGID - SETUID mediathekarr: livenessProbe: exec: command: - /bin/bash - -c - >- wget -q -T 5 -O /dev/null "http://127.0.0.1:5008/api?t=caps" && wget -q -T 5 -O /dev/null "http://127.0.0.1:5007/download/api?mode=version&output=json&apikey=x" periodSeconds: 30 timeoutSeconds: 15 failureThreshold: 3 readinessProbe: httpGet: path: /download/ port: 5007 periodSeconds: 10 timeoutSeconds: 5 failureThreshold: 3 startupProbe: httpGet: path: /download/ port: 5007 periodSeconds: 5 timeoutSeconds: 5 failureThreshold: 60 resources: limitFactor: 3 requests: cpu: 0.1 memory: 256Mi ephemeralStorage: 100Mi securityContext: runAsUser: {{ .Values.puid }} runAsGroup: {{ .Values.pgid }} allowPrivilegeEscalation: false readOnlyRootFilesystem: true runAsNonRoot: true capabilities: drop: - ALL configInit: resources: limitFactor: 3 requests: cpu: 10m memory: 16Mi ephemeralStorage: 1Mi securityContext: runAsUser: {{ .Values.puid }} runAsGroup: {{ .Values.pgid }} runAsNonRoot: true allowPrivilegeEscalation: false readOnlyRootFilesystem: true capabilities: drop: - ALL {{- end -}} {{/* The effective pod security context. Separate from the per-container table because it is the one block shared by all containers of the pod. */}} {{- define "freesailarr.podSecurityContext" -}} {{- $given := (fromYaml (include "freesailarr.subBlock" (dict "block" .Values.securityContext "key" "pod" "path" "securityContext"))).value -}} {{- $defaults := dict "fsGroup" 1000 "fsGroupChangePolicy" "OnRootMismatch" "seccompProfile" (dict "type" "RuntimeDefault") -}} {{- include "freesailarr.defaultedDict" (dict "defaults" $defaults "given" $given "path" "securityContext.pod") -}} {{- end -}} {{/* The effective probe / security context / resources for one container. Input: dict "root" $ "app" "given" and, for probes, "kind" . "given" is passed in rather than derived from "app" because the qbittorrent init container's blocks live under qbittorrent.configInit, not under a top-level key of the same name. */}} {{- define "freesailarr.probeFor" -}} {{- $fb := fromYaml (include "freesailarr.fallbacks" .root) -}} {{- $defaults := index $fb .app .kind -}} {{- include "freesailarr.defaultedDict" (dict "defaults" $defaults "given" .given "path" (printf "%s.%s" .app .kind)) -}} {{- end -}} {{- define "freesailarr.securityContextFor" -}} {{- $fb := fromYaml (include "freesailarr.fallbacks" .root) -}} {{- $defaults := index $fb .app "securityContext" -}} {{- include "freesailarr.defaultedDict" (dict "defaults" $defaults "given" .given "path" (printf "%s.securityContext" .app)) -}} {{- end -}} {{- define "freesailarr.resourcesFor" -}} {{- $fb := fromYaml (include "freesailarr.fallbacks" .root) -}} {{- $defaults := index $fb .app "resources" -}} {{- $merged := fromYaml (include "freesailarr.defaultedDict" (dict "defaults" $defaults "given" .given "path" (printf "%s.resources" .app))) -}} {{- include "freesailarr.resources" $merged -}} {{- end -}}