Files
helm-charts/freesailarr/templates/_helpers.tpl
T
jklein bd1fdc09a0
Validate Helm Charts / Detect changed charts (pull_request) Successful in 4s
Validate Helm Charts / Resource quantities (pull_request) Successful in 14s
Validate Helm Charts / Selector match (pull_request) Successful in 14s
Validate Helm Charts / HTTPRoute match (pull_request) Successful in 14s
Validate Helm Charts / Build freesailarr (pull_request) Successful in 7s
Validate Helm Charts / Build outline (pull_request) Successful in 7s
Validate Helm Charts / Build paperless-ngx (pull_request) Successful in 8s
Validate Helm Charts / Build zipline (pull_request) Successful in 8s
Validate Helm Charts / Build calibre-web-automated (pull_request) Successful in 7s
Validate Helm Charts / Trivy config calibre-web-automated (pull_request) Successful in 19s
Validate Helm Charts / Trivy config freesailarr (pull_request) Successful in 24s
Validate Helm Charts / Trivy config outline (pull_request) Successful in 17s
Validate Helm Charts / Trivy config paperless-ngx (pull_request) Successful in 19s
Validate Helm Charts / Trivy config zipline (pull_request) Successful in 19s
Install-test / Install-test calibre-web-automated (pull_request) Successful in 2m11s
Install-test / Install-test freesailarr (pull_request) Successful in 3m55s
Install-test / Install-test outline (pull_request) Successful in 2m44s
Install-test / Install-test paperless-ngx (pull_request) Successful in 3m52s
Install-test / Install-test zipline (pull_request) Successful in 2m6s
Validate Helm Charts / Install-test (pull_request) Successful in 14m50s
feat: Gateway-API as an alternative to the Ingress in five annotation charts
Group 2 of the #114 sweep: the charts whose behaviour hangs off an nginx
annotation - calibre-web-automated, outline, paperless-ngx, zipline
(proxy-body-size) and freesailarr (rewrite-target).

Each gets the form the pilot (cyberchef, PR #115) fixed and group 1 (PR #116)
repeated: an httpRoute block off by default, a template rendering ONLY an
HTTPRoute with parentRefs - no Gateway, no gatewayClassName, no Certificate, no
implementation-specific policy object - render aborts on both modes at once
(naming both value paths), on an empty parentRefs and on a missing hostname,
empty hostnames meaning [ingress.domain], a schema entry closed against unknown
keys with an open parentRef definition, and a NOTES.txt that names the active
mode and what the mode costs.

Minor, additive, defaults unchanged: `helm template` with each chart's CI values
is byte-identical to origin/main for all five.

The upload limit is DOCUMENTED, not rebuilt (decision 2 of the issue). The
Gateway API knows no body size; a policy object would bind the chart to one
implementation. So the Gateway branch of NOTES.txt says, per chart and in the
chart's own terms, that the value is NOT ENFORCED and names where to set it: an
e-book library (calibre), an upload service (zipline), the web-UI half of the
document intake while the consume directory keeps no limit at all (paperless),
and the open #12 pair (outline).

Four findings that were not in the plan, each argued in the file it changes:

  freesailarr gated freesailarr.sharedIngressApps on ingress.enabled, and that
  list is read by the <APP>__SERVER__URLBASE variables as well as by the routing
  objects. In Gateway mode it would have come out EMPTY while the route served
  radarr, sonarr and prowlarr under their prefixes: UrlBase unset, assets and API
  calls linked at the host root, broken UI behind a Ready pod. The helper's two
  guards (seerr/mediathekarr only at "/", no duplicate paths) would have gone
  unenforced there too. Fixed with freesailarr.published (ingress.enabled OR
  httpRoute.enabled) - the same shape as sure.publishedOverTls in group 1, and
  identical to ingress.enabled while httpRoute.enabled is false. Measured: on
  origin/main with ingress.enabled: false the render emits ZERO URLBASE
  variables; with this change in Gateway mode it emits three.

  paperless-ngx was the last chart in the repository rendering its Ingress
  UNCONDITIONALLY. Without an ingress.enabled switch there is no second value
  path for the mutual-exclusion abort to name, and the Ingress plus its
  unconditional required() on clusterIssuer would have kept rendering in Gateway
  mode - a value demanded for nothing, which is issue #114's "Folge 1" and
  README guideline 12. The switch was added, defaulting to true, so the render is
  unchanged.

  paperless-ngx PAPERLESS_URL had no required() of its own: it relied on the
  Ingress template's, which stops firing the moment the Ingress is switchable.
  With httpRoute.hostnames set and ingress.domain empty it would have rendered
  "https://" and refused every login POST as a CSRF failure. It now carries its
  own required(), naming what the value is for.

  calibre-web-automated claimed, in values.yaml and in its schema, that
  ingress.domain is read nowhere but in the Ingress template. The HTTPRoute reads
  it, so the claim now holds only while both modes are off. Corrected rather than
  left standing - the same correction garage needed in group 1.

freesailarr is where the Gateway API is LESS machinery than what it replaces,
not a translation of it. rewrite-target applies per Ingress OBJECT, so
qBittorrent (no base path, prefix must be stripped) needed a second Ingress on
the shared host, which then had to repeat the first object's TLS secret name,
omit the cert-manager annotation conditionally so only one Certificate is
requested, and match an ImplementationSpecific regex path for the rewrite's
capture group. A URLRewrite filter applies per RULE: one route, one rule per
app, the filter on qBittorrent's rule, a plain PathPrefix. All four crutches
gone. It is also the only chart without httpRoute.hostnames - it has no
ingress.domain either, and a chart-global hostname list could not say which of
the ten apps it belonged to, so the hostnames stay in ingress.host and
<app>.ingress.host.

CI: one render overlay per chart under ci/httproute/, so the assertion is not
vacuum-green - ci/run-httproute-match-test.sh goes from 20 held assertions over
10 charts to 36 over 15. freesailarr's overlay moves bazarr onto a host of its
own, so the shared-host route AND the per-app route are both rendered; without
that the per-app branch would be exercised by nothing. The install-test fixtures
stay in Ingress mode: the install runs one configuration per chart, cyberchef
already pays that price against the real Gateway-API CRDs, and for paperless-ngx
that fixture is the only install of the valkey-subchart path anywhere.

README also carries a stale figure fixed here: the HTTPRoute test was still
documented at the pilot's 4 assertions over 2 charts, although group 1 had taken
it to 20 over 10.

Not proven, by construction: that traffic flows. No Gateway controller runs in
CI. Not run locally either: k3d, Docker, Trivy. The Gateway branch of NOTES.txt
is EXECUTED by the render test (a template error in it fails the job, verified)
but its text is printed by no pipeline stage - helm template does not emit
notes, and the install-test installs in Ingress mode.

Part of #114
2026-10-12 18:15:29 +00:00

1061 lines
36 KiB
Smarty

{{/*
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.<app>.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" <string|number> "factor" <float64>.
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 <app>.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.<app>.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 <APP>__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 <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 <app>.ingress.path falls back to
"/<app>", 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" <app name>.
*/}}
{{- 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 <app>.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 <APP>__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
<APP>__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 -}}
{{/*
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 <pod-subnet> -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" <dict> "given" <dict|nil> "path" <values path, for errors>.
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" <dict|nil> "key" <string> "path" <string>.
Needed because ".Values.<app>.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" <key in the fallback table> "given" <the values block>
and, for probes, "kind" <livenessProbe|readinessProbe|startupProbe>.
"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 -}}