From 914c2d020530f52438a2fd1dce4687001ee17a5c Mon Sep 17 00:00:00 2001 From: jklein Date: Sat, 22 Aug 2026 23:44:32 +0200 Subject: [PATCH 01/19] Add freesailarr chart: gluetun-tunneled media automation stack Single-pod StatefulSet running gluetun (appVersion 3.41.1) as Wireguard VPN gateway with qbittorrent, prowlarr, flaresolverr, radarr, recyclarr, sonarr, seerr and bazarr sharing its network namespace. Config volumes via volumeClaimTemplates, media libraries as existing claims, per-app enable/ingress toggles, computed FIREWALL_INPUT_PORTS, hardened security contexts with documented per-image deviations. Wires up publish workflow, change-app-version choice, install-test exclusion and README overview. --- .github/workflows/change-app-version.yml | 1 + .github/workflows/publish-freesailarr.yml | 15 + README.md | 1 + ci/excluded-charts.txt | 1 + freesailarr/.helmignore | 23 + freesailarr/Chart.yaml | 12 + freesailarr/templates/_helpers.tpl | 189 ++++++ .../templates/freesailarr-gluetun.secret.yaml | 6 + .../templates/freesailarr.ingress.yaml | 38 ++ .../templates/freesailarr.service.yaml | 18 + freesailarr/templates/freesailarr.sfs.yaml | 459 ++++++++++++++ freesailarr/values.yaml | 571 ++++++++++++++++++ 12 files changed, 1334 insertions(+) create mode 100644 .github/workflows/publish-freesailarr.yml create mode 100644 freesailarr/.helmignore create mode 100644 freesailarr/Chart.yaml create mode 100644 freesailarr/templates/_helpers.tpl create mode 100644 freesailarr/templates/freesailarr-gluetun.secret.yaml create mode 100644 freesailarr/templates/freesailarr.ingress.yaml create mode 100644 freesailarr/templates/freesailarr.service.yaml create mode 100644 freesailarr/templates/freesailarr.sfs.yaml create mode 100644 freesailarr/values.yaml diff --git a/.github/workflows/change-app-version.yml b/.github/workflows/change-app-version.yml index c116759..1c1aaea 100644 --- a/.github/workflows/change-app-version.yml +++ b/.github/workflows/change-app-version.yml @@ -35,6 +35,7 @@ on: - minecraft-router - minecraft-server - rss-bridge + - freesailarr workflow_call: inputs: app-version: diff --git a/.github/workflows/publish-freesailarr.yml b/.github/workflows/publish-freesailarr.yml new file mode 100644 index 0000000..8fe35ef --- /dev/null +++ b/.github/workflows/publish-freesailarr.yml @@ -0,0 +1,15 @@ +name: Publish Freesailarr Helm Chart +on: + workflow_dispatch: + push: + branches: + - main + paths: + - freesailarr/** +jobs: + publish-freesailarr: + name: Publish Freesailarr Helm Chart + uses: ./.github/workflows/publish-helm-chart.yml + with: + chart-name: 'freesailarr' + secrets: inherit diff --git a/README.md b/README.md index 7dfd11c..b7e10ca 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ Collection of some self-made helm-charts. Each top-level directory is a standalo | `apache-tika` | Apache Tika | Deployment + Service only (no ingress) | | `calibre-web-automated` | Calibre-Web Automated | StatefulSet, extra consume-PVC | | `cyberchef` | CyberChef | Stateless Deployment | +| `freesailarr` | gluetun VPN media stack (qbittorrent, prowlarr, flaresolverr, radarr, recyclarr, sonarr, seerr, bazarr) | Single-pod StatefulSet: all apps share gluetun's network namespace; config volumes via volumeClaimTemplates, media PVCs as existingClaims | | `gotenberg` | Gotenberg | Deployment + Service only (no ingress) | | `homeassistant` | Home Assistant | StatefulSet, has `NOTES.txt` and Helm tests | | `matrix-synapse` | Matrix Synapse | StatefulSet plus an nginx delegation Deployment/Service | diff --git a/ci/excluded-charts.txt b/ci/excluded-charts.txt index d53b450..ca96541 100644 --- a/ci/excluded-charts.txt +++ b/ci/excluded-charts.txt @@ -1,4 +1,5 @@ # Charts excluded from the PR install-test, one per line: . # ci/run-install-test.sh skips these with the given reason. +freesailarr gluetun never becomes healthy without a real Wireguard key and VPN connectivity, and every app container sits behind its firewall - the pod cannot become Ready on a CI runner satisfactory the game-server image is multiple GB and Steam downloads 10GB+ game files on first start - impractical on a CI runner template-chart scaffold with placeholder image "ghcr.io/ORG/IMAGE:xxx", never published diff --git a/freesailarr/.helmignore b/freesailarr/.helmignore new file mode 100644 index 0000000..0e8a0eb --- /dev/null +++ b/freesailarr/.helmignore @@ -0,0 +1,23 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*.orig +*~ +# Various IDEs +.project +.idea/ +*.tmproj +.vscode/ diff --git a/freesailarr/Chart.yaml b/freesailarr/Chart.yaml new file mode 100644 index 0000000..c5191a9 --- /dev/null +++ b/freesailarr/Chart.yaml @@ -0,0 +1,12 @@ +apiVersion: v2 +name: freesailarr +description: A Helm chart to deploy a Wireguard-tunneled media automation stack (gluetun, qbittorrent, the *arrs, seerr) on Kubernetes +icon: https://raw.githubusercontent.com/qdm12/gluetun/master/doc/logo_256.png + +type: application + +version: 1.0.0+up3.41.1 +appVersion: "3.41.1" + +maintainers: + - name: jklein diff --git a/freesailarr/templates/_helpers.tpl b/freesailarr/templates/_helpers.tpl new file mode 100644 index 0000000..d616a81 --- /dev/null +++ b/freesailarr/templates/_helpers.tpl @@ -0,0 +1,189 @@ +{{/* +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: charts in this repo deliberately have no dependencies, so this helper 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.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 := printf "%v" .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 "%v%s" $product $suffix -}} +{{- 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 nine 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 apps that expose a web UI, as a JSON array of {name, port} dicts +(container port name == app name == ingress subdomain). Consumed by the +service, the ingress and the firewall-ports helper below: + + {{- $apps := include "freesailarr.uiApps" . | fromJsonArray }} +*/}} +{{- define "freesailarr.uiApps" -}} +{{- $apps := list -}} +{{- range $app := list + (dict "name" "qbittorrent" "port" 8080) + (dict "name" "prowlarr" "port" 9696) + (dict "name" "radarr" "port" 7878) + (dict "name" "sonarr" "port" 8989) + (dict "name" "seerr" "port" 5055) + (dict "name" "bazarr" "port" 6767) -}} +{{- if (index $.Values $app.name).enabled -}} +{{- $apps = append $apps $app -}} +{{- end -}} +{{- end -}} +{{- toJson $apps -}} +{{- end -}} + +{{/* +Comma-separated FIREWALL_INPUT_PORTS for gluetun: the UI ports of the enabled +apps (ingress traffic arrives on the pod IP), flaresolverr's 8191 when enabled +(the kubelet probes connect to the pod IP too) and 9999 for gluetun's own +health server. Without these the gluetun firewall drops the traffic. +*/}} +{{- define "freesailarr.firewallInputPorts" -}} +{{- $ports := list -}} +{{- range $app := include "freesailarr.uiApps" . | fromJsonArray -}} +{{- $ports = append $ports (int $app.port) -}} +{{- end -}} +{{- if .Values.flaresolverr.enabled -}} +{{- $ports = append $ports 8191 -}} +{{- end -}} +{{- $ports = append $ports 9999 -}} +{{- join "," $ports -}} +{{- end -}} diff --git a/freesailarr/templates/freesailarr-gluetun.secret.yaml b/freesailarr/templates/freesailarr-gluetun.secret.yaml new file mode 100644 index 0000000..a5f3a9d --- /dev/null +++ b/freesailarr/templates/freesailarr-gluetun.secret.yaml @@ -0,0 +1,6 @@ +apiVersion: onepassword.com/v1 +kind: OnePasswordItem +metadata: + name: {{ .Release.Name }}-{{ .Chart.Name }}-gluetun-secret +spec: + itemPath: {{ required "You must provide a gluetunSecret Reference" .Values.secrets.gluetunSecretRef }} diff --git a/freesailarr/templates/freesailarr.ingress.yaml b/freesailarr/templates/freesailarr.ingress.yaml new file mode 100644 index 0000000..0852c35 --- /dev/null +++ b/freesailarr/templates/freesailarr.ingress.yaml @@ -0,0 +1,38 @@ +{{- $apps := list }} +{{- range $app := include "freesailarr.uiApps" . | fromJsonArray }} +{{- if (index $.Values $app.name).ingress.enabled }} +{{- $apps = append $apps $app }} +{{- end }} +{{- end }} +{{- if and .Values.ingress.enabled (gt (len $apps) 0) }} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ .Release.Name }}-{{ .Chart.Name }}-ingress + annotations: + cert-manager.io/cluster-issuer: {{ required "A Cluster-Issuer must be provided" .Values.ingress.clusterIssuer }} + {{- with .Values.ingress.annotations }} + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + ingressClassName: {{ .Values.ingress.className }} + rules: + {{- range $apps }} + - host: {{ .name }}.{{ required "A Domain/Host must be provided" $.Values.ingress.domain }} + http: + paths: + - backend: + service: + name: {{ $.Release.Name }}-{{ $.Chart.Name }}-service + port: + name: {{ .name }} + path: / + pathType: Prefix + {{- end }} + tls: + - hosts: + {{- range $apps }} + - {{ .name }}.{{ $.Values.ingress.domain }} + {{- end }} + secretName: tls-{{ .Release.Name }}-{{ .Chart.Name }}-ingress +{{- end }} diff --git a/freesailarr/templates/freesailarr.service.yaml b/freesailarr/templates/freesailarr.service.yaml new file mode 100644 index 0000000..cb0ef5c --- /dev/null +++ b/freesailarr/templates/freesailarr.service.yaml @@ -0,0 +1,18 @@ +{{- $apps := include "freesailarr.uiApps" . | fromJsonArray }} +{{- if gt (len $apps) 0 }} +apiVersion: v1 +kind: Service +metadata: + name: {{ .Release.Name }}-{{ .Chart.Name }}-service +spec: + selector: + app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs + ports: + {{- range $apps }} + - protocol: TCP + port: {{ int .port }} + targetPort: {{ .name }} + name: {{ .name }} + {{- end }} + type: ClusterIP +{{- end }} diff --git a/freesailarr/templates/freesailarr.sfs.yaml b/freesailarr/templates/freesailarr.sfs.yaml new file mode 100644 index 0000000..a7ed34e --- /dev/null +++ b/freesailarr/templates/freesailarr.sfs.yaml @@ -0,0 +1,459 @@ +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) }} + serviceName: {{ .Release.Name }}-{{ .Chart.Name }}-service + selector: + matchLabels: + app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs + template: + metadata: + name: {{ .Release.Name }}-{{ .Chart.Name }}-sfs + labels: + app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs + spec: + automountServiceAccountToken: false + securityContext: + {{- toYaml .Values.securityContext.pod | nindent 8 }} + 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: "qmcgaw/gluetun:{{ .Chart.AppVersion }}" + imagePullPolicy: IfNotPresent + 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 }} + # Open the firewall for the UI/probe ports of the enabled apps and + # the health server - kubelet probes and ingress traffic arrive on + # the pod IP and would be dropped otherwise. + - 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: + {{- toYaml .Values.gluetun.livenessProbe | nindent 12 }} + readinessProbe: + {{- toYaml .Values.gluetun.readinessProbe | nindent 12 }} + startupProbe: + {{- toYaml .Values.gluetun.startupProbe | nindent 12 }} + resources: + {{- include "freesailarr.resources" .Values.gluetun.resources | nindent 12 }} + securityContext: + {{- toYaml .Values.gluetun.securityContext | nindent 12 }} + volumeMounts: + - mountPath: /gluetun + name: gluetun-config + - mountPath: /dev/net/tun + name: dev-net-tun + {{- 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: + {{- toYaml .Values.qbittorrent.livenessProbe | nindent 12 }} + readinessProbe: + {{- toYaml .Values.qbittorrent.readinessProbe | nindent 12 }} + startupProbe: + {{- toYaml .Values.qbittorrent.startupProbe | nindent 12 }} + resources: + {{- include "freesailarr.resources" .Values.qbittorrent.resources | nindent 12 }} + securityContext: + {{- toYaml .Values.qbittorrent.securityContext | nindent 12 }} + volumeMounts: + - mountPath: /config + name: qbittorrent-config + - mountPath: /downloads + name: media-downloads + # Writable paths for s6-overlay so the root filesystem stays + # read-only (same pattern for all linuxserver.io containers). + - mountPath: /run + name: qbittorrent-run + - mountPath: /tmp + name: qbittorrent-tmp + {{- 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 }} + {{- include "freesailarr.env" (dict "root" $ "env" .Values.prowlarr.env) | nindent 12 }} + ports: + - containerPort: 9696 + protocol: TCP + name: prowlarr + livenessProbe: + {{- toYaml .Values.prowlarr.livenessProbe | nindent 12 }} + readinessProbe: + {{- toYaml .Values.prowlarr.readinessProbe | nindent 12 }} + startupProbe: + {{- toYaml .Values.prowlarr.startupProbe | nindent 12 }} + resources: + {{- include "freesailarr.resources" .Values.prowlarr.resources | nindent 12 }} + securityContext: + {{- toYaml .Values.prowlarr.securityContext | nindent 12 }} + volumeMounts: + - mountPath: /config + name: prowlarr-config + - mountPath: /run + name: prowlarr-run + - mountPath: /tmp + name: prowlarr-tmp + {{- 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: + {{- toYaml .Values.flaresolverr.livenessProbe | nindent 12 }} + readinessProbe: + {{- toYaml .Values.flaresolverr.readinessProbe | nindent 12 }} + startupProbe: + {{- toYaml .Values.flaresolverr.startupProbe | nindent 12 }} + resources: + {{- include "freesailarr.resources" .Values.flaresolverr.resources | nindent 12 }} + securityContext: + {{- toYaml .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 }} + {{- include "freesailarr.env" (dict "root" $ "env" .Values.radarr.env) | nindent 12 }} + ports: + - containerPort: 7878 + protocol: TCP + name: radarr + livenessProbe: + {{- toYaml .Values.radarr.livenessProbe | nindent 12 }} + readinessProbe: + {{- toYaml .Values.radarr.readinessProbe | nindent 12 }} + startupProbe: + {{- toYaml .Values.radarr.startupProbe | nindent 12 }} + resources: + {{- include "freesailarr.resources" .Values.radarr.resources | nindent 12 }} + securityContext: + {{- toYaml .Values.radarr.securityContext | nindent 12 }} + volumeMounts: + - mountPath: /config + name: radarr-config + - mountPath: /movies + name: media-movies + - mountPath: /downloads + name: media-downloads + - mountPath: /run + name: radarr-run + - mountPath: /tmp + name: radarr-tmp + {{- 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: + {{- toYaml .Values.recyclarr.livenessProbe | nindent 12 }} + readinessProbe: + {{- toYaml .Values.recyclarr.readinessProbe | nindent 12 }} + startupProbe: + {{- toYaml .Values.recyclarr.startupProbe | nindent 12 }} + resources: + {{- include "freesailarr.resources" .Values.recyclarr.resources | nindent 12 }} + securityContext: + runAsUser: {{ .Values.puid }} + runAsGroup: {{ .Values.pgid }} + {{- toYaml .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 }} + {{- include "freesailarr.env" (dict "root" $ "env" .Values.sonarr.env) | nindent 12 }} + ports: + - containerPort: 8989 + protocol: TCP + name: sonarr + livenessProbe: + {{- toYaml .Values.sonarr.livenessProbe | nindent 12 }} + readinessProbe: + {{- toYaml .Values.sonarr.readinessProbe | nindent 12 }} + startupProbe: + {{- toYaml .Values.sonarr.startupProbe | nindent 12 }} + resources: + {{- include "freesailarr.resources" .Values.sonarr.resources | nindent 12 }} + securityContext: + {{- toYaml .Values.sonarr.securityContext | nindent 12 }} + volumeMounts: + - mountPath: /config + name: sonarr-config + - mountPath: /tv + name: media-tv + - mountPath: /downloads + name: media-downloads + - mountPath: /run + name: sonarr-run + - mountPath: /tmp + name: sonarr-tmp + {{- 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: + {{- toYaml .Values.seerr.livenessProbe | nindent 12 }} + readinessProbe: + {{- toYaml .Values.seerr.readinessProbe | nindent 12 }} + startupProbe: + {{- toYaml .Values.seerr.startupProbe | nindent 12 }} + resources: + {{- include "freesailarr.resources" .Values.seerr.resources | nindent 12 }} + securityContext: + runAsUser: {{ .Values.puid }} + runAsGroup: {{ .Values.pgid }} + {{- toYaml .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: + {{- toYaml .Values.bazarr.livenessProbe | nindent 12 }} + readinessProbe: + {{- toYaml .Values.bazarr.readinessProbe | nindent 12 }} + startupProbe: + {{- toYaml .Values.bazarr.startupProbe | nindent 12 }} + resources: + {{- include "freesailarr.resources" .Values.bazarr.resources | nindent 12 }} + securityContext: + {{- toYaml .Values.bazarr.securityContext | nindent 12 }} + volumeMounts: + - mountPath: /config + name: bazarr-config + - mountPath: /movies + name: media-movies + - mountPath: /tv + name: media-tv + - mountPath: /run + name: bazarr-run + - mountPath: /tmp + name: bazarr-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 + {{- if or .Values.qbittorrent.enabled .Values.radarr.enabled .Values.sonarr.enabled }} + - name: media-downloads + persistentVolumeClaim: + claimName: {{ required "media.downloads.existingClaim is required when qbittorrent, radarr or sonarr is enabled" .Values.media.downloads.existingClaim }} + {{- end }} + {{- if or .Values.radarr.enabled .Values.bazarr.enabled }} + - name: media-movies + persistentVolumeClaim: + claimName: {{ required "media.movies.existingClaim is required when radarr or bazarr is enabled" .Values.media.movies.existingClaim }} + {{- end }} + {{- if or .Values.sonarr.enabled .Values.bazarr.enabled }} + - name: media-tv + persistentVolumeClaim: + claimName: {{ required "media.tv.existingClaim is required when sonarr or bazarr is enabled" .Values.media.tv.existingClaim }} + {{- end }} + {{- if .Values.qbittorrent.enabled }} + - name: qbittorrent-run + emptyDir: {} + - name: qbittorrent-tmp + emptyDir: {} + {{- end }} + {{- if .Values.prowlarr.enabled }} + - name: prowlarr-run + emptyDir: {} + - name: prowlarr-tmp + emptyDir: {} + {{- end }} + {{- if .Values.flaresolverr.enabled }} + - name: flaresolverr-tmp + emptyDir: {} + - name: flaresolverr-shm + emptyDir: + medium: Memory + sizeLimit: 512Mi + {{- end }} + {{- if .Values.radarr.enabled }} + - name: radarr-run + emptyDir: {} + - name: radarr-tmp + emptyDir: {} + {{- end }} + {{- if .Values.recyclarr.enabled }} + - name: recyclarr-tmp + emptyDir: {} + {{- end }} + {{- if .Values.sonarr.enabled }} + - name: sonarr-run + emptyDir: {} + - name: sonarr-tmp + emptyDir: {} + {{- end }} + {{- if .Values.seerr.enabled }} + - name: seerr-tmp + emptyDir: {} + {{- end }} + {{- if .Values.bazarr.enabled }} + - name: bazarr-run + emptyDir: {} + - name: bazarr-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" }} + {{- $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 }} diff --git a/freesailarr/values.yaml b/freesailarr/values.yaml new file mode 100644 index 0000000..07b0ee4 --- /dev/null +++ b/freesailarr/values.yaml @@ -0,0 +1,571 @@ +# freesailarr runs gluetun plus the whole media automation stack in a single +# StatefulSet pod: every app container shares gluetun's network namespace, the +# containers talk to each other over localhost and all traffic leaves through +# the Wireguard tunnel. +# +# Image pinning (documented deviation from the Chart.AppVersion standard): +# appVersion pins the gluetun image only. All other app images are pinned via +# values (.image.repository / .image.tag) because the stack combines +# nine independently versioned upstream projects; the pinned tags are reviewed +# alongside every app-version update round, like side images in other charts. + +# Scale the whole chart down without uninstalling it: scaleDown: true renders +# every Deployment/StatefulSet with replicas: 0 (and suspends CronJobs) and +# takes precedence over any per-workload replicas value. PVCs, Secrets and +# Services stay in place; scaleDown: false brings the workloads back up. +scaleDown: false + +# Replica count for the whole stack (one pod contains all containers); 0 is +# allowed and scales it down. The chart-global scaleDown flag takes precedence. +# More than 1 replica is not meaningful: every replica would run its own +# isolated stack on its own config volumes. +replicas: 1 + +secrets: + # op:// item path for the gluetun VPN credentials. The 1Password item must + # contain the fields "wireguard-private-key" (the Wireguard private key) and + # "wireguard-addresses" (the Wireguard interface address, e.g. 10.x.y.z/32). + gluetunSecretRef: null + +# User/group the linuxserver.io containers drop to after init (their PUID/PGID +# mechanism) and the runAsUser/runAsGroup for the seerr and recyclarr +# containers. Media and config volumes must be writable for this user. +puid: 1000 +pgid: 1000 +# Timezone for all containers. +timezone: Europe/Zurich + +securityContext: + # Pod-level security context. No pod-level runAsNonRoot/runAsUser: the pod + # mixes root containers (gluetun, linuxserver.io images) with non-root ones + # (seerr, recyclarr, flaresolverr); users are set per container instead. + # fsGroup (keep in sync with pgid) makes freshly provisioned config volumes + # group-writable for the non-root containers; fsGroupChangePolicy + # OnRootMismatch avoids recursively re-chowning the (large, shared) media + # claims on every pod start. + pod: + fsGroup: 1000 + fsGroupChangePolicy: OnRootMismatch + +storage: + # StorageClass for all config volumeClaimTemplates (null = cluster default). + storageClass: null + +# Media libraries as pre-existing PVCs (shared with the separately deployed +# Jellyfin) - the chart mounts but never creates them. Each claim is required +# as soon as an app that mounts it is enabled: +# downloads: qbittorrent, radarr, sonarr +# movies: radarr, bazarr +# tv: sonarr, bazarr +media: + downloads: + existingClaim: null + movies: + existingClaim: null + tv: + existingClaim: null + +ingress: + enabled: true + clusterIssuer: null + domain: null + className: nginx + # Extra annotations for the Ingress; merged with the cert-manager + # cluster-issuer annotation the chart always sets. + annotations: {} + +gluetun: + # Image tag comes from Chart.AppVersion (gluetun is the chart's main app). + vpn: + serviceProvider: mullvad + type: wireguard + serverCities: Zurich + serverCountries: Switzerland + dnsUpstreamResolvers: quad9 + # Comma-separated CIDRs the gluetun firewall allows outbound traffic to + # outside the VPN tunnel. Set this to the cluster pod and service CIDRs + # (e.g. "10.42.0.0/16,10.43.0.0/16" on k3s) - without it the firewall + # blocks cluster-internal egress such as DNS lookups against cluster DNS + # and seerr talking to the in-cluster Jellyfin. + firewallOutboundSubnets: null + storage: + # Size of the gluetun config volume (server list cache under /gluetun). + size: 1Gi + # Extra environment variables, appended to the ones the chart always sets. + # Values are rendered through tpl; value, secretKeyRef and configMapKeyRef + # forms are supported. (Same for every .env block below.) + env: [] + # gluetun's built-in HTTP health server; the chart binds it to :9999 (env + # HEALTH_SERVER_ADDRESS) so the kubelet probes, which connect to the pod IP, + # can reach it. It returns 200 only while the VPN tunnel is up and working. + livenessProbe: + httpGet: + path: / + port: 9999 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + readinessProbe: + httpGet: + path: / + port: 9999 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + # The startup probe gates liveness/readiness. Budget for image start plus + # Wireguard handshake: 5s * 30 = 150s. + startupProbe: + httpGet: + path: / + port: 9999 + periodSeconds: 5 + timeoutSeconds: 5 + failureThreshold: 30 + resources: + # Limits are optional: memory and ephemeral-storage limits default to + # limitFactor x the request, an explicitly set limit always wins, and a + # CPU limit is only set when configured explicitly under "limits". + # (Same for every .resources block below.) + limitFactor: 3 + requests: + cpu: 0.1 + memory: 128Mi + ephemeralStorage: 50Mi + # Deviations from the hardening baseline, forced by gluetun's role as VPN + # gateway: + # - runs as root (no runAsNonRoot): required to create the tun device and + # program the firewall. + # - NET_ADMIN capability: creates the Wireguard tunnel and the iptables + # rules (firewall, kill switch). + # - readOnlyRootFilesystem: false: gluetun rewrites /etc/resolv.conf and + # other files on its root filesystem at runtime. + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: false + capabilities: + drop: + - ALL + add: + - NET_ADMIN + +qbittorrent: + enabled: true + image: + repository: linuxserver/qbittorrent + tag: 5.2.1 + ingress: + enabled: true + storage: + size: 1Gi + env: [] + livenessProbe: + httpGet: + path: / + port: 8080 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + readinessProbe: + httpGet: + path: / + port: 8080 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + # The startup probe gates liveness/readiness. Startup budget = 5s * 30 = 150s. + startupProbe: + httpGet: + path: / + port: 8080 + periodSeconds: 5 + timeoutSeconds: 5 + failureThreshold: 30 + resources: + limitFactor: 3 + requests: + cpu: 0.2 + memory: 512Mi + ephemeralStorage: 100Mi + # Deviations from the hardening baseline, forced by the linuxserver.io + # images (s6-overlay init that starts as root and drops to PUID/PGID): + # - no runAsNonRoot: s6-overlay must start as root to create the abc user + # from PUID/PGID and chown /config. + # - capabilities: ALL dropped except what the s6-overlay init needs: + # CHOWN/FOWNER/DAC_OVERRIDE (taking ownership of /config and /run) and + # SETUID/SETGID (dropping from root to the PUID/PGID user). + # readOnlyRootFilesystem stays true: the writable paths (/run, /tmp) are + # mounted as emptyDirs. (Same block for all linuxserver.io containers: + # qbittorrent, prowlarr, radarr, sonarr, bazarr.) + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: + - ALL + add: + - CHOWN + - DAC_OVERRIDE + - FOWNER + - SETGID + - SETUID + +prowlarr: + enabled: true + image: + repository: linuxserver/prowlarr + tag: 2.3.5 + ingress: + enabled: true + storage: + size: 1Gi + env: [] + # The *arr apps expose /ping as an unauthenticated health endpoint. + livenessProbe: + httpGet: + path: /ping + port: 9696 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /ping + port: 9696 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + # The startup probe gates liveness/readiness. Startup budget = 5s * 30 = 150s. + startupProbe: + httpGet: + path: /ping + port: 9696 + periodSeconds: 5 + timeoutSeconds: 5 + failureThreshold: 30 + resources: + limitFactor: 3 + requests: + cpu: 0.1 + memory: 256Mi + ephemeralStorage: 100Mi + # linuxserver.io image - see the qbittorrent securityContext comment. + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: + - ALL + add: + - CHOWN + - DAC_OVERRIDE + - FOWNER + - SETGID + - SETUID + +flaresolverr: + enabled: true + image: + repository: ghcr.io/flaresolverr/flaresolverr + tag: v3.5.0 + # No ingress and no config volume: flaresolverr is stateless and only + # consumed by prowlarr over localhost. + env: [] + # /health is flaresolverr's dedicated lightweight health endpoint. + livenessProbe: + httpGet: + path: /health + port: 8191 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /health + port: 8191 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + # The startup probe gates liveness/readiness. Startup budget = 5s * 30 = 150s. + startupProbe: + httpGet: + path: /health + port: 8191 + periodSeconds: 5 + timeoutSeconds: 5 + failureThreshold: 30 + resources: + limitFactor: 3 + requests: + cpu: 0.2 + memory: 512Mi + ephemeralStorage: 200Mi + # The image runs as its own non-root user; chromium's writable paths (/tmp, + # /dev/shm) are emptyDirs, so the full hardening baseline holds. + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + runAsNonRoot: true + capabilities: + drop: + - ALL + +radarr: + enabled: true + image: + repository: linuxserver/radarr + tag: 6.1.1 + ingress: + enabled: true + storage: + size: 1Gi + env: [] + livenessProbe: + httpGet: + path: /ping + port: 7878 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /ping + port: 7878 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + # The startup probe gates liveness/readiness. Startup budget = 5s * 30 = 150s. + startupProbe: + httpGet: + path: /ping + port: 7878 + periodSeconds: 5 + timeoutSeconds: 5 + failureThreshold: 30 + resources: + limitFactor: 3 + requests: + cpu: 0.1 + memory: 256Mi + ephemeralStorage: 100Mi + # linuxserver.io image - see the qbittorrent securityContext comment. + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: + - ALL + add: + - CHOWN + - DAC_OVERRIDE + - FOWNER + - SETGID + - SETUID + +recyclarr: + enabled: true + image: + repository: ghcr.io/recyclarr/recyclarr + tag: 8.6.0 + # No ingress: recyclarr has no web UI, it syncs TRaSH guides to radarr and + # sonarr on a schedule (daemon mode). + storage: + size: 1Gi + env: [] + # recyclarr serves no HTTP port in daemon mode, so all probes exec the CLI. + livenessProbe: + exec: + command: + - recyclarr + - --version + periodSeconds: 30 + timeoutSeconds: 10 + failureThreshold: 3 + readinessProbe: + exec: + command: + - recyclarr + - --version + periodSeconds: 30 + timeoutSeconds: 10 + failureThreshold: 3 + # The startup probe gates liveness/readiness. Startup budget = 5s * 30 = 150s. + startupProbe: + exec: + command: + - recyclarr + - --version + periodSeconds: 5 + timeoutSeconds: 10 + failureThreshold: 30 + resources: + limitFactor: 3 + requests: + cpu: 0.05 + memory: 128Mi + ephemeralStorage: 50Mi + # The image supports running as an arbitrary non-root user; runAsUser and + # runAsGroup are set from the global puid/pgid values in the template - do + # not set them here. + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + runAsNonRoot: true + capabilities: + drop: + - ALL + +sonarr: + enabled: true + image: + repository: linuxserver/sonarr + tag: 4.0.17 + ingress: + enabled: true + storage: + size: 1Gi + env: [] + livenessProbe: + httpGet: + path: /ping + port: 8989 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /ping + port: 8989 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + # The startup probe gates liveness/readiness. Startup budget = 5s * 30 = 150s. + startupProbe: + httpGet: + path: /ping + port: 8989 + periodSeconds: 5 + timeoutSeconds: 5 + failureThreshold: 30 + resources: + limitFactor: 3 + requests: + cpu: 0.1 + memory: 256Mi + ephemeralStorage: 100Mi + # linuxserver.io image - see the qbittorrent securityContext comment. + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: + - ALL + add: + - CHOWN + - DAC_OVERRIDE + - FOWNER + - SETGID + - SETUID + +seerr: + enabled: true + image: + repository: ghcr.io/seerr-team/seerr + tag: v3.3.0 + ingress: + enabled: true + storage: + size: 1Gi + env: [] + # /api/v1/settings/public answers unauthenticated once the app is up. + 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 + # The startup probe gates liveness/readiness. First boot runs database + # migrations: budget 10s * 30 = 300s. + 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 + # The image runs fine as an arbitrary non-root user; runAsUser and + # runAsGroup are set from the global puid/pgid values in the template - do + # not set them here. + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + runAsNonRoot: true + capabilities: + drop: + - ALL + +bazarr: + enabled: true + image: + repository: linuxserver/bazarr + tag: 1.5.6 + ingress: + enabled: true + storage: + size: 1Gi + env: [] + # bazarr has no unauthenticated health endpoint; / serves the UI with 200. + livenessProbe: + httpGet: + path: / + port: 6767 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + readinessProbe: + httpGet: + path: / + port: 6767 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + # The startup probe gates liveness/readiness. Startup budget = 5s * 30 = 150s. + startupProbe: + httpGet: + path: / + port: 6767 + periodSeconds: 5 + timeoutSeconds: 5 + failureThreshold: 30 + resources: + limitFactor: 3 + requests: + cpu: 0.1 + memory: 256Mi + ephemeralStorage: 100Mi + # linuxserver.io image - see the qbittorrent securityContext comment. + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: + - ALL + add: + - CHOWN + - DAC_OVERRIDE + - FOWNER + - SETGID + - SETUID From d1933e0b53acc1f9cbe128b654fb47452cce9987 Mon Sep 17 00:00:00 2001 From: jklein Date: Sat, 22 Aug 2026 23:54:46 +0200 Subject: [PATCH 02/19] Fix gluetun image tag: Docker Hub tags carry a v prefix The issue plan listed qmcgaw/gluetun:3.41.1, but the published tag is v3.41.1. appVersion is the exact image tag per the style guide, so appVersion becomes v3.41.1 and the chart version 1.0.0+upv3.41.1 (precedent: matrix-synapse, satisfactory, calibre-web-automated). --- freesailarr/Chart.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/freesailarr/Chart.yaml b/freesailarr/Chart.yaml index c5191a9..a2b3389 100644 --- a/freesailarr/Chart.yaml +++ b/freesailarr/Chart.yaml @@ -5,8 +5,8 @@ icon: https://raw.githubusercontent.com/qdm12/gluetun/master/doc/logo_256.png type: application -version: 1.0.0+up3.41.1 -appVersion: "3.41.1" +version: 1.0.0+upv3.41.1 +appVersion: "v3.41.1" maintainers: - name: jklein From 1ed70e31dd4d8e2a92f1dd77a2b6c5d6ffad9681 Mon Sep 17 00:00:00 2001 From: jklein Date: Sun, 23 Aug 2026 00:01:15 +0200 Subject: [PATCH 03/19] Pin flaresolverr runAsUser/runAsGroup to 1000 The image declares its non-root user by name only, which the kubelet's runAsNonRoot check cannot verify (CreateContainerConfigError); pinning the uid/gid numerically satisfies the check. --- freesailarr/values.yaml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/freesailarr/values.yaml b/freesailarr/values.yaml index 07b0ee4..1b5e395 100644 --- a/freesailarr/values.yaml +++ b/freesailarr/values.yaml @@ -300,11 +300,15 @@ flaresolverr: memory: 512Mi ephemeralStorage: 200Mi # The image runs as its own non-root user; chromium's writable paths (/tmp, - # /dev/shm) are emptyDirs, so the full hardening baseline holds. + # /dev/shm) are emptyDirs, so the full hardening baseline holds. runAsUser/ + # runAsGroup pin the image's "flaresolverr" user numerically (uid/gid 1000, + # the image declares it by name only, which runAsNonRoot cannot verify). securityContext: allowPrivilegeEscalation: false readOnlyRootFilesystem: true runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 capabilities: drop: - ALL From bce2b147a814a96750cf24d91eb9b4a20eac7dfc Mon Sep 17 00:00:00 2001 From: jklein Date: Sun, 23 Aug 2026 00:16:27 +0200 Subject: [PATCH 04/19] Set HOME=/tmp for flaresolverr The image's home is /app on the read-only root filesystem, but flaresolverr writes chromedriver caches under $HOME (/app/.local); pointing HOME at the /tmp emptyDir keeps the root filesystem read-only. --- freesailarr/templates/freesailarr.sfs.yaml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/freesailarr/templates/freesailarr.sfs.yaml b/freesailarr/templates/freesailarr.sfs.yaml index a7ed34e..268a72c 100644 --- a/freesailarr/templates/freesailarr.sfs.yaml +++ b/freesailarr/templates/freesailarr.sfs.yaml @@ -170,6 +170,11 @@ spec: env: - name: TZ value: {{ .Values.timezone | quote }} + # flaresolverr writes chromedriver caches under $HOME (image + # default /app, which is read-only); point HOME at the writable + # /tmp emptyDir so the root filesystem can stay read-only. + - name: HOME + value: /tmp {{- include "freesailarr.env" (dict "root" $ "env" .Values.flaresolverr.env) | nindent 12 }} ports: - containerPort: 8191 From 2211226c8ea56113cc50dbf1c891ca27e978b220 Mon Sep 17 00:00:00 2001 From: jklein Date: Sun, 23 Aug 2026 00:24:24 +0200 Subject: [PATCH 05/19] Allow writable root filesystem for flaresolverr The image copies and patches the chromedriver binary to the hardcoded path /app/chromedriver at startup (no env override exists) and writes chromedriver caches under /app, so readOnlyRootFilesystem cannot hold; deviation documented in values.yaml. Reverts the HOME=/tmp workaround, which only covered the cache path, not the binary patching. --- freesailarr/templates/freesailarr.sfs.yaml | 5 ----- freesailarr/values.yaml | 14 +++++++++----- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/freesailarr/templates/freesailarr.sfs.yaml b/freesailarr/templates/freesailarr.sfs.yaml index 268a72c..a7ed34e 100644 --- a/freesailarr/templates/freesailarr.sfs.yaml +++ b/freesailarr/templates/freesailarr.sfs.yaml @@ -170,11 +170,6 @@ spec: env: - name: TZ value: {{ .Values.timezone | quote }} - # flaresolverr writes chromedriver caches under $HOME (image - # default /app, which is read-only); point HOME at the writable - # /tmp emptyDir so the root filesystem can stay read-only. - - name: HOME - value: /tmp {{- include "freesailarr.env" (dict "root" $ "env" .Values.flaresolverr.env) | nindent 12 }} ports: - containerPort: 8191 diff --git a/freesailarr/values.yaml b/freesailarr/values.yaml index 1b5e395..4d588c3 100644 --- a/freesailarr/values.yaml +++ b/freesailarr/values.yaml @@ -299,13 +299,17 @@ flaresolverr: cpu: 0.2 memory: 512Mi ephemeralStorage: 200Mi - # The image runs as its own non-root user; chromium's writable paths (/tmp, - # /dev/shm) are emptyDirs, so the full hardening baseline holds. runAsUser/ - # runAsGroup pin the image's "flaresolverr" user numerically (uid/gid 1000, - # the image declares it by name only, which runAsNonRoot cannot verify). + # Deviations from the hardening baseline, forced by the flaresolverr image: + # - readOnlyRootFilesystem: false: on startup flaresolverr copies and + # patches the chromedriver binary to the hardcoded path /app/chromedriver + # and writes chromedriver caches under /app (its home directory). + # - runAsUser/runAsGroup pin the image's "flaresolverr" user numerically + # (uid/gid 1000; the image declares it by name only, which the kubelet's + # runAsNonRoot check cannot verify). + # Chromium's scratch paths (/tmp, /dev/shm) are emptyDirs. securityContext: allowPrivilegeEscalation: false - readOnlyRootFilesystem: true + readOnlyRootFilesystem: false runAsNonRoot: true runAsUser: 1000 runAsGroup: 1000 From 818a71f7b6af8a0412718bb737cf12a541030000 Mon Sep 17 00:00:00 2001 From: jklein Date: Sun, 23 Aug 2026 00:30:26 +0200 Subject: [PATCH 06/19] Clean up stale ip rules on gluetun container start An uncleanly exited gluetun leaves its ip rule in the shared pod network namespace; the restarted container then fails forever with 'adding ip rule 101: file exists' while the kill switch keeps blocking all egress. Apply the upstream-recommended Kubernetes fix: a postStart hook dropping table-51820 rules. --- freesailarr/templates/freesailarr.sfs.yaml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/freesailarr/templates/freesailarr.sfs.yaml b/freesailarr/templates/freesailarr.sfs.yaml index a7ed34e..3bf1335 100644 --- a/freesailarr/templates/freesailarr.sfs.yaml +++ b/freesailarr/templates/freesailarr.sfs.yaml @@ -27,6 +27,17 @@ spec: - name: gluetun image: "qmcgaw/gluetun:{{ .Chart.AppVersion }}" imagePullPolicy: IfNotPresent + # An uncleanly exited gluetun (crash, OOM, node issue) leaves its ip + # rule behind in the shared pod network namespace, and the restarted + # container then fails forever with "ip rule 101 ... file exists". + # Upstream-recommended fix for Kubernetes: drop stale rules on start. + lifecycle: + postStart: + exec: + command: + - /bin/sh + - -c + - (ip rule del table 51820; ip -6 rule del table 51820) || true env: - name: TZ value: {{ .Values.timezone | quote }} From 0a5035ae83b407d1e74f6ce4cbbd0c8a5244d891 Mon Sep 17 00:00:00 2001 From: jklein Date: Sun, 23 Aug 2026 00:38:41 +0200 Subject: [PATCH 07/19] Clean stale VPN state synchronously before gluetun starts Replace the upstream-recommended postStart hook, which runs concurrently with the entrypoint and loses the race (observed: it deleted the ip rule the restarted gluetun had just created, leaving the container in a permanent CrashLoop behind the kill switch). A command wrapper now removes stale table-51820 rules and the leftover tun device deterministically before exec'ing /gluetun-entrypoint, covering container restarts after unclean exits. --- freesailarr/templates/freesailarr.sfs.yaml | 23 +++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/freesailarr/templates/freesailarr.sfs.yaml b/freesailarr/templates/freesailarr.sfs.yaml index 3bf1335..af740f9 100644 --- a/freesailarr/templates/freesailarr.sfs.yaml +++ b/freesailarr/templates/freesailarr.sfs.yaml @@ -27,17 +27,18 @@ spec: - name: gluetun image: "qmcgaw/gluetun:{{ .Chart.AppVersion }}" imagePullPolicy: IfNotPresent - # An uncleanly exited gluetun (crash, OOM, node issue) leaves its ip - # rule behind in the shared pod network namespace, and the restarted - # container then fails forever with "ip rule 101 ... file exists". - # Upstream-recommended fix for Kubernetes: drop stale rules on start. - lifecycle: - postStart: - exec: - command: - - /bin/sh - - -c - - (ip rule del table 51820; ip -6 rule del table 51820) || true + # 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 }} From 265190d175514842349fb2b4e01ae057fef90ab4 Mon Sep 17 00:00:00 2001 From: jklein Date: Sun, 23 Aug 2026 00:49:13 +0200 Subject: [PATCH 08/19] Calibrate gluetun liveness more tolerant than readiness Short tunnel re-negotiations recover internally; a kubelet restart only prolongs the outage while the kill switch already blocks all egress. Liveness budget 60s, readiness stays at 30s. --- freesailarr/values.yaml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/freesailarr/values.yaml b/freesailarr/values.yaml index 4d588c3..4d50f05 100644 --- a/freesailarr/values.yaml +++ b/freesailarr/values.yaml @@ -98,13 +98,17 @@ gluetun: # gluetun's built-in HTTP health server; the chart binds it to :9999 (env # HEALTH_SERVER_ADDRESS) so the kubelet probes, which connect to the pod IP, # can reach it. It returns 200 only while the VPN tunnel is up and working. + # Liveness is more tolerant (60s) than readiness (30s): during a short + # tunnel re-negotiation gluetun recovers on its own and a kubelet restart + # would only prolong the outage; the kill switch keeps all egress blocked + # the whole time, so nothing runs unprotected. livenessProbe: httpGet: path: / port: 9999 periodSeconds: 10 timeoutSeconds: 5 - failureThreshold: 3 + failureThreshold: 6 readinessProbe: httpGet: path: / From 5e2d8e408324b9b5327fee100561e24e0293c2ba Mon Sep 17 00:00:00 2001 From: jklein Date: Sun, 23 Aug 2026 01:07:52 +0200 Subject: [PATCH 09/19] Allow writable root filesystem for the linuxserver.io containers With readOnlyRootFilesystem the s6-overlay init cannot rewrite /etc/passwd, so the containers silently kept the image's built-in uid/gid 911 instead of the configured PUID/PGID (verified at runtime) - breaking ownership on the shared media volumes. Drop the now-unneeded /run and /tmp emptyDirs; deviation documented in values.yaml. --- freesailarr/templates/freesailarr.sfs.yaml | 52 ---------------------- freesailarr/values.yaml | 25 ++++++----- 2 files changed, 15 insertions(+), 62 deletions(-) diff --git a/freesailarr/templates/freesailarr.sfs.yaml b/freesailarr/templates/freesailarr.sfs.yaml index af740f9..8297e23 100644 --- a/freesailarr/templates/freesailarr.sfs.yaml +++ b/freesailarr/templates/freesailarr.sfs.yaml @@ -134,12 +134,6 @@ spec: name: qbittorrent-config - mountPath: /downloads name: media-downloads - # Writable paths for s6-overlay so the root filesystem stays - # read-only (same pattern for all linuxserver.io containers). - - mountPath: /run - name: qbittorrent-run - - mountPath: /tmp - name: qbittorrent-tmp {{- end }} {{- if .Values.prowlarr.enabled }} - name: prowlarr @@ -170,10 +164,6 @@ spec: volumeMounts: - mountPath: /config name: prowlarr-config - - mountPath: /run - name: prowlarr-run - - mountPath: /tmp - name: prowlarr-tmp {{- end }} {{- if .Values.flaresolverr.enabled }} - name: flaresolverr @@ -239,10 +229,6 @@ spec: name: media-movies - mountPath: /downloads name: media-downloads - - mountPath: /run - name: radarr-run - - mountPath: /tmp - name: radarr-tmp {{- end }} {{- if .Values.recyclarr.enabled }} - name: recyclarr @@ -307,10 +293,6 @@ spec: name: media-tv - mountPath: /downloads name: media-downloads - - mountPath: /run - name: sonarr-run - - mountPath: /tmp - name: sonarr-tmp {{- end }} {{- if .Values.seerr.enabled }} - name: seerr @@ -375,10 +357,6 @@ spec: name: media-movies - mountPath: /tv name: media-tv - - mountPath: /run - name: bazarr-run - - mountPath: /tmp - name: bazarr-tmp {{- end }} restartPolicy: Always volumes: @@ -402,18 +380,6 @@ spec: persistentVolumeClaim: claimName: {{ required "media.tv.existingClaim is required when sonarr or bazarr is enabled" .Values.media.tv.existingClaim }} {{- end }} - {{- if .Values.qbittorrent.enabled }} - - name: qbittorrent-run - emptyDir: {} - - name: qbittorrent-tmp - emptyDir: {} - {{- end }} - {{- if .Values.prowlarr.enabled }} - - name: prowlarr-run - emptyDir: {} - - name: prowlarr-tmp - emptyDir: {} - {{- end }} {{- if .Values.flaresolverr.enabled }} - name: flaresolverr-tmp emptyDir: {} @@ -422,32 +388,14 @@ spec: medium: Memory sizeLimit: 512Mi {{- end }} - {{- if .Values.radarr.enabled }} - - name: radarr-run - emptyDir: {} - - name: radarr-tmp - emptyDir: {} - {{- end }} {{- if .Values.recyclarr.enabled }} - name: recyclarr-tmp emptyDir: {} {{- end }} - {{- if .Values.sonarr.enabled }} - - name: sonarr-run - emptyDir: {} - - name: sonarr-tmp - emptyDir: {} - {{- end }} {{- if .Values.seerr.enabled }} - name: seerr-tmp emptyDir: {} {{- end }} - {{- if .Values.bazarr.enabled }} - - name: bazarr-run - emptyDir: {} - - name: bazarr-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 diff --git a/freesailarr/values.yaml b/freesailarr/values.yaml index 4d50f05..b012180 100644 --- a/freesailarr/values.yaml +++ b/freesailarr/values.yaml @@ -192,17 +192,22 @@ qbittorrent: ephemeralStorage: 100Mi # Deviations from the hardening baseline, forced by the linuxserver.io # images (s6-overlay init that starts as root and drops to PUID/PGID): - # - no runAsNonRoot: s6-overlay must start as root to create the abc user - # from PUID/PGID and chown /config. + # - no runAsNonRoot: s6-overlay must start as root to apply PUID/PGID and + # chown /config. + # - readOnlyRootFilesystem: false: the s6 init applies PUID/PGID by + # rewriting /etc/passwd and /etc/group and stages runtime state on the + # root filesystem. With a read-only root filesystem the containers still + # start, but silently keep the image's built-in uid/gid (911) instead of + # PUID/PGID (verified at runtime) - unacceptable for the shared media + # volumes. # - capabilities: ALL dropped except what the s6-overlay init needs: # CHOWN/FOWNER/DAC_OVERRIDE (taking ownership of /config and /run) and # SETUID/SETGID (dropping from root to the PUID/PGID user). - # readOnlyRootFilesystem stays true: the writable paths (/run, /tmp) are - # mounted as emptyDirs. (Same block for all linuxserver.io containers: - # qbittorrent, prowlarr, radarr, sonarr, bazarr.) + # (Same block for all linuxserver.io containers: qbittorrent, prowlarr, + # radarr, sonarr, bazarr.) securityContext: allowPrivilegeEscalation: false - readOnlyRootFilesystem: true + readOnlyRootFilesystem: false capabilities: drop: - ALL @@ -255,7 +260,7 @@ prowlarr: # linuxserver.io image - see the qbittorrent securityContext comment. securityContext: allowPrivilegeEscalation: false - readOnlyRootFilesystem: true + readOnlyRootFilesystem: false capabilities: drop: - ALL @@ -362,7 +367,7 @@ radarr: # linuxserver.io image - see the qbittorrent securityContext comment. securityContext: allowPrivilegeEscalation: false - readOnlyRootFilesystem: true + readOnlyRootFilesystem: false capabilities: drop: - ALL @@ -467,7 +472,7 @@ sonarr: # linuxserver.io image - see the qbittorrent securityContext comment. securityContext: allowPrivilegeEscalation: false - readOnlyRootFilesystem: true + readOnlyRootFilesystem: false capabilities: drop: - ALL @@ -571,7 +576,7 @@ bazarr: # linuxserver.io image - see the qbittorrent securityContext comment. securityContext: allowPrivilegeEscalation: false - readOnlyRootFilesystem: true + readOnlyRootFilesystem: false capabilities: drop: - ALL From adc87c2cc1edf74e4b18e9264464257b980ed57d Mon Sep 17 00:00:00 2001 From: jklein Date: Sun, 23 Aug 2026 11:15:43 +0200 Subject: [PATCH 10/19] Bind the qBittorrent session to the VPN interface qBittorrent had no network interface configured, so libtorrent listened and announced on every interface of the shared pod network namespace - including eth0 (the pod IP) and loopback. gluetun's kill switch drops exactly those packets, so trackers answered with "skipping tracker announce (unreachable)" or timed out, no peers were found and torrents stalled at 0 bytes in stalledDL. Beyond the stall this is a leak: without the binding qBittorrent also listens on the pod IP, i.e. reachable past the tunnel. The setting only exists in the persisted config file, which qBittorrent creates itself on first start, so an init container seeds it before the app comes up. The edit is idempotent and preserves existing user settings: it replaces the keys if present, inserts them into the [BitTorrent] section if missing, and creates file or section if needed. The interface is configurable via qbittorrent.networkInterface (default tun0); emptying it drops the init container. --- freesailarr/templates/freesailarr.sfs.yaml | 57 ++++++++++++++++++++++ freesailarr/values.yaml | 35 +++++++++++++ 2 files changed, 92 insertions(+) diff --git a/freesailarr/templates/freesailarr.sfs.yaml b/freesailarr/templates/freesailarr.sfs.yaml index 8297e23..f99095d 100644 --- a/freesailarr/templates/freesailarr.sfs.yaml +++ b/freesailarr/templates/freesailarr.sfs.yaml @@ -19,6 +19,63 @@ spec: automountServiceAccountToken: false securityContext: {{- toYaml .Values.securityContext.pod | nindent 8 }} + {{- if and .Values.qbittorrent.enabled .Values.qbittorrent.networkInterface }} + initContainers: + # qBittorrent has no environment variable for the session's network + # interface - it only reads it from the persisted config file, which + # the app itself creates on first 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. Seeding the two keys before qbittorrent + # starts closes that leak and makes a fresh install work out of the + # box. The edit is idempotent and preserves all other settings: + # existing keys are replaced, missing ones are inserted into the + # [BitTorrent] section (qBittorrent 5.x reads them there), and a + # missing file or section is created. + - name: qbittorrent-config-interface + 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" + # 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" + env: + - name: QBT_INTERFACE + value: {{ .Values.qbittorrent.networkInterface | quote }} + resources: + {{- include "freesailarr.resources" .Values.qbittorrent.configInit.resources | nindent 12 }} + securityContext: + runAsUser: {{ .Values.puid }} + runAsGroup: {{ .Values.pgid }} + {{- toYaml .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 diff --git a/freesailarr/values.yaml b/freesailarr/values.yaml index b012180..64aa8ab 100644 --- a/freesailarr/values.yaml +++ b/freesailarr/values.yaml @@ -161,6 +161,41 @@ qbittorrent: enabled: true storage: size: 1Gi + # Network interface qBittorrent binds its BitTorrent session to - gluetun's + # Wireguard device. Without this binding libtorrent listens and announces on + # every interface in the shared pod network namespace, including eth0 (the + # pod IP) and loopback. Those announces bypass the tunnel, so gluetun's kill + # switch (OUTPUT DROP except tun0 and the allowed cluster subnets) drops + # them: trackers report "skipping tracker announce (unreachable)", no peers + # are found and torrents stall at 0 bytes in stalledDL. Binding to tun0 is + # therefore both the functional fix and a leak fix - it stops qBittorrent + # from listening on the pod IP past the tunnel. The value is written into + # /config/qBittorrent/qBittorrent.conf by the config-interface init + # container below; setting it to null or "" removes that init container and + # leaves the binding to the user (not recommended). + networkInterface: tun0 + # The init container that seeds qbittorrent.networkInterface into the + # persisted qBittorrent config before the app starts. Runs as puid/pgid so + # the config file keeps the ownership the app expects. + configInit: + # Auxiliary busybox image. Pinned independently of appVersion (which + # tracks gluetun); reviewed alongside every app-version update round. + image: + repository: busybox + tag: "1.38.0" + resources: + limitFactor: 3 + requests: + cpu: 10m + memory: 16Mi + ephemeralStorage: 1Mi + securityContext: + runAsNonRoot: true + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: + - ALL env: [] livenessProbe: httpGet: From d4f03cdfd9021cec5d97eac656e8889570f01a72 Mon Sep 17 00:00:00 2001 From: jklein Date: Sun, 23 Aug 2026 11:16:24 +0200 Subject: [PATCH 11/19] Give gluetun a writable /tmp/gluetun gluetun logs "clearing public IP data: open /tmp/gluetun/ip: permission denied" every few seconds and never persists its public IP. It creates /tmp/gluetun with mode 0644, i.e. without the execute bit needed to enter the directory; it runs as root, but with all capabilities except NET_ADMIN dropped there is no DAC_OVERRIDE left to paper over the missing bit. An emptyDir provides the path with usable permissions. --- freesailarr/templates/freesailarr.sfs.yaml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/freesailarr/templates/freesailarr.sfs.yaml b/freesailarr/templates/freesailarr.sfs.yaml index f99095d..3235310 100644 --- a/freesailarr/templates/freesailarr.sfs.yaml +++ b/freesailarr/templates/freesailarr.sfs.yaml @@ -158,6 +158,15 @@ spec: 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. + - mountPath: /tmp/gluetun + name: gluetun-tmp {{- if .Values.qbittorrent.enabled }} - name: qbittorrent image: "{{ .Values.qbittorrent.image.repository }}:{{ .Values.qbittorrent.image.tag }}" @@ -422,6 +431,9 @@ spec: hostPath: path: /dev/net/tun type: CharDevice + # Writable /tmp/gluetun for gluetun's public-IP cache (see the mount). + - name: gluetun-tmp + emptyDir: {} {{- if or .Values.qbittorrent.enabled .Values.radarr.enabled .Values.sonarr.enabled }} - name: media-downloads persistentVolumeClaim: From cdb66a32f400a7e6b61a9428e69edc390b0d58ef Mon Sep 17 00:00:00 2001 From: jklein Date: Mon, 24 Aug 2026 13:45:38 +0200 Subject: [PATCH 12/19] Per-app ingress and service, media subPaths Three review changes to the freesailarr chart. Ingress: the chart no longer derives hosts as . from one Ingress object. The global ingress.domain is gone; every app carries its own .ingress.host and renders its own Ingress ---ingress with its own TLS secret tls----ingress. The hosts are therefore independent and may live in unrelated domains. The host is required, but the required() call sits inside the loop over the *enabled* app ingresses, so switching an app's ingress off must not make the chart demand a host for it. clusterIssuer, className and annotations stay global defaults and are overridable per app; an explicitly set per-app value wins (nil check instead of "default", so an empty override is honoured). Media: media..subPath mounts a sub-directory of the claim instead of its root, default null (claim root = previous behaviour). The same existingClaim may back all three categories with different subPaths. That is the point rather than cosmetics: Radarr and Sonarr import by moving, and a move within one filesystem can be a hardlink instead of a copy - across volume boundaries every import duplicates the file. Services: the collective Service with named ports is replaced by one ClusterIP Service ---service per enabled app that listens on a port, flaresolverr included; recyclarr serves no port in daemon mode and gets none. Each ingress points at its own app's service. All containers share gluetun's network namespace, so the services differ only in their port - they provide stable per-app DNS names, not network separation. The StatefulSet's governing serviceName no longer names a removed object. --- README.md | 3 +- freesailarr/templates/_helpers.tpl | 47 +++++++++++---- .../templates/freesailarr.ingress.yaml | 53 +++++++++------- .../templates/freesailarr.service.yaml | 25 +++++--- freesailarr/templates/freesailarr.sfs.yaml | 27 ++++++++- freesailarr/values.yaml | 60 ++++++++++++++++++- 6 files changed, 169 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index b7e10ca..6176672 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Collection of some self-made helm-charts. Each top-level directory is a standalo | `apache-tika` | Apache Tika | Deployment + Service only (no ingress) | | `calibre-web-automated` | Calibre-Web Automated | StatefulSet, extra consume-PVC | | `cyberchef` | CyberChef | Stateless Deployment | -| `freesailarr` | gluetun VPN media stack (qbittorrent, prowlarr, flaresolverr, radarr, recyclarr, sonarr, seerr, bazarr) | Single-pod StatefulSet: all apps share gluetun's network namespace; config volumes via volumeClaimTemplates, media PVCs as existingClaims | +| `freesailarr` | gluetun VPN media stack (qbittorrent, prowlarr, flaresolverr, radarr, recyclarr, sonarr, seerr, bazarr) | Single-pod StatefulSet: all apps share gluetun's network namespace; config volumes via volumeClaimTemplates, media PVCs as existingClaims (optional per-category `subPath`, so one claim can serve all three categories). One ClusterIP Service and one Ingress **per app** — no global `ingress.domain`, each app has its own required host (documented ingress exception below) | | `gotenberg` | Gotenberg | Deployment + Service only (no ingress) | | `homeassistant` | Home Assistant | StatefulSet, has `NOTES.txt` and Helm tests | | `matrix-synapse` | Matrix Synapse | StatefulSet plus an nginx delegation Deployment/Service | @@ -152,6 +152,7 @@ These guidelines are **binding for every current and future chart** in this repo - `ingress.annotations` takes optional extra annotations that are merged with the cert-manager `cluster-issuer` annotation the chart always sets. - The TLS secret is named `tls---ingress`. - **Documented exception — homeassistant**: keeps its upstream-style flexible ingress values (host/tls lists, no required values); only its `ingress.className` default follows the standard (`nginx`, clearable to omit the field). +- **Documented exception — freesailarr**: the chart bundles several independently exposed apps in one pod, so it has **no** `ingress.domain`. Each app carries its own `.ingress.enabled` plus a required `.ingress.host` (required only while that app's ingress is enabled) and renders its own Ingress object `---ingress` with its own TLS secret `tls----ingress`. `clusterIssuer`, `className` and `annotations` stay global defaults under `ingress.` and are overridable per app. ### 5. Security hardening diff --git a/freesailarr/templates/_helpers.tpl b/freesailarr/templates/_helpers.tpl index d616a81..2c92c6a 100644 --- a/freesailarr/templates/_helpers.tpl +++ b/freesailarr/templates/_helpers.tpl @@ -148,17 +148,24 @@ Usage: {{- end -}} {{/* -The enabled apps that expose a web UI, as a JSON array of {name, port} dicts -(container port name == app name == ingress subdomain). Consumed by the -service, the ingress and the firewall-ports helper below: +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.uiApps" . | fromJsonArray }} + {{- $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. */}} -{{- define "freesailarr.uiApps" -}} +{{- 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) @@ -171,19 +178,33 @@ service, the ingress and the firewall-ports helper below: {{- end -}} {{/* -Comma-separated FIREWALL_INPUT_PORTS for gluetun: the UI ports of the enabled -apps (ingress traffic arrives on the pod IP), flaresolverr's 8191 when enabled -(the kubelet probes connect to the pod IP too) and 9999 for gluetun's own -health server. Without these the gluetun firewall drops the traffic. +The apps that render an Ingress: the enabled port apps whose values carry an +ingress block with enabled: true. Used by the ingress template. + + {{- $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 -}} + +{{/* +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. Without these the gluetun firewall +drops the traffic. */}} {{- define "freesailarr.firewallInputPorts" -}} {{- $ports := list -}} -{{- range $app := include "freesailarr.uiApps" . | fromJsonArray -}} +{{- range $app := include "freesailarr.portApps" . | fromJsonArray -}} {{- $ports = append $ports (int $app.port) -}} {{- end -}} -{{- if .Values.flaresolverr.enabled -}} -{{- $ports = append $ports 8191 -}} -{{- end -}} {{- $ports = append $ports 9999 -}} {{- join "," $ports -}} {{- end -}} diff --git a/freesailarr/templates/freesailarr.ingress.yaml b/freesailarr/templates/freesailarr.ingress.yaml index 0852c35..d4ddec7 100644 --- a/freesailarr/templates/freesailarr.ingress.yaml +++ b/freesailarr/templates/freesailarr.ingress.yaml @@ -1,38 +1,51 @@ -{{- $apps := list }} -{{- range $app := include "freesailarr.uiApps" . | fromJsonArray }} -{{- if (index $.Values $app.name).ingress.enabled }} -{{- $apps = append $apps $app }} -{{- end }} -{{- end }} -{{- if and .Values.ingress.enabled (gt (len $apps) 0) }} +{{/* +One Ingress object per app whose ingress is enabled, each with its own host and +its own TLS secret. The hosts are independent values - they do not have to +share a domain - and each ingress points at its own app's service. + +.ingress.host is required, but only for an app whose ingress is actually +enabled: the required call sits inside the loop over the enabled apps, so +turning an app's ingress off must never make the chart demand a host for it. +*/}} +{{- if .Values.ingress.enabled }} +{{- range $app := include "freesailarr.ingressApps" $ | fromJsonArray }} +{{- $config := (index $.Values $app.name).ingress }} +{{- /* clusterIssuer, className and annotations default to the global ingress + block and are overridable per app; an explicitly set per-app value wins + even when it is empty, hence the nil check instead of "default". */}} +{{- $clusterIssuer := $.Values.ingress.clusterIssuer }} +{{- if not (kindIs "invalid" $config.clusterIssuer) }}{{- $clusterIssuer = $config.clusterIssuer }}{{- end }} +{{- $className := $.Values.ingress.className }} +{{- if not (kindIs "invalid" $config.className) }}{{- $className = $config.className }}{{- end }} +{{- $annotations := $.Values.ingress.annotations }} +{{- if not (kindIs "invalid" $config.annotations) }}{{- $annotations = $config.annotations }}{{- end }} +{{- $host := required (printf "%s.ingress.host must be provided while %s.ingress.enabled is true" $app.name $app.name) $config.host }} +--- apiVersion: networking.k8s.io/v1 kind: Ingress metadata: - name: {{ .Release.Name }}-{{ .Chart.Name }}-ingress + name: {{ $.Release.Name }}-{{ $.Chart.Name }}-{{ $app.name }}-ingress annotations: - cert-manager.io/cluster-issuer: {{ required "A Cluster-Issuer must be provided" .Values.ingress.clusterIssuer }} - {{- with .Values.ingress.annotations }} + cert-manager.io/cluster-issuer: {{ required "A Cluster-Issuer must be provided" $clusterIssuer }} + {{- with $annotations }} {{- toYaml . | nindent 4 }} {{- end }} spec: - ingressClassName: {{ .Values.ingress.className }} + ingressClassName: {{ $className }} rules: - {{- range $apps }} - - host: {{ .name }}.{{ required "A Domain/Host must be provided" $.Values.ingress.domain }} + - host: {{ $host | quote }} http: paths: - backend: service: - name: {{ $.Release.Name }}-{{ $.Chart.Name }}-service + name: {{ $.Release.Name }}-{{ $.Chart.Name }}-{{ $app.name }}-service port: - name: {{ .name }} + name: {{ $app.name }} path: / pathType: Prefix - {{- end }} tls: - hosts: - {{- range $apps }} - - {{ .name }}.{{ $.Values.ingress.domain }} - {{- end }} - secretName: tls-{{ .Release.Name }}-{{ .Chart.Name }}-ingress + - {{ $host | quote }} + secretName: tls-{{ $.Release.Name }}-{{ $.Chart.Name }}-{{ $app.name }}-ingress +{{- end }} {{- end }} diff --git a/freesailarr/templates/freesailarr.service.yaml b/freesailarr/templates/freesailarr.service.yaml index cb0ef5c..863acc4 100644 --- a/freesailarr/templates/freesailarr.service.yaml +++ b/freesailarr/templates/freesailarr.service.yaml @@ -1,18 +1,25 @@ -{{- $apps := include "freesailarr.uiApps" . | fromJsonArray }} -{{- if gt (len $apps) 0 }} +{{/* +One ClusterIP service per enabled app container that listens on a port +(recyclarr has none in daemon mode, so it gets no service). + +All containers share gluetun's network namespace, so every one of these +services resolves to the same pod IP and they differ only in their port. Their +purpose is a stable, per-app cluster-internal DNS name - each ingress points at +its own app's service - not network separation between the apps. +*/}} +{{- range $app := include "freesailarr.portApps" $ | fromJsonArray }} +--- apiVersion: v1 kind: Service metadata: - name: {{ .Release.Name }}-{{ .Chart.Name }}-service + name: {{ $.Release.Name }}-{{ $.Chart.Name }}-{{ $app.name }}-service spec: selector: - app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs + app: {{ $.Release.Name }}-{{ $.Chart.Name }}-sfs ports: - {{- range $apps }} - protocol: TCP - port: {{ int .port }} - targetPort: {{ .name }} - name: {{ .name }} - {{- end }} + port: {{ int $app.port }} + targetPort: {{ $app.name }} + name: {{ $app.name }} type: ClusterIP {{- end }} diff --git a/freesailarr/templates/freesailarr.sfs.yaml b/freesailarr/templates/freesailarr.sfs.yaml index 3235310..9eff982 100644 --- a/freesailarr/templates/freesailarr.sfs.yaml +++ b/freesailarr/templates/freesailarr.sfs.yaml @@ -6,7 +6,11 @@ metadata: app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs spec: replicas: {{ include "freesailarr.replicas" (dict "scaleDown" .Values.scaleDown "replicas" .Values.replicas) }} - serviceName: {{ .Release.Name }}-{{ .Chart.Name }}-service + # 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 @@ -200,6 +204,9 @@ spec: name: qbittorrent-config - mountPath: /downloads name: media-downloads + {{- with .Values.media.downloads.subPath }} + subPath: {{ . | quote }} + {{- end }} {{- end }} {{- if .Values.prowlarr.enabled }} - name: prowlarr @@ -293,8 +300,14 @@ spec: name: radarr-config - mountPath: /movies name: media-movies + {{- with .Values.media.movies.subPath }} + subPath: {{ . | quote }} + {{- end }} - mountPath: /downloads name: media-downloads + {{- with .Values.media.downloads.subPath }} + subPath: {{ . | quote }} + {{- end }} {{- end }} {{- if .Values.recyclarr.enabled }} - name: recyclarr @@ -357,8 +370,14 @@ spec: name: sonarr-config - mountPath: /tv name: media-tv + {{- with .Values.media.tv.subPath }} + subPath: {{ . | quote }} + {{- end }} - mountPath: /downloads name: media-downloads + {{- with .Values.media.downloads.subPath }} + subPath: {{ . | quote }} + {{- end }} {{- end }} {{- if .Values.seerr.enabled }} - name: seerr @@ -421,8 +440,14 @@ spec: name: bazarr-config - mountPath: /movies name: media-movies + {{- with .Values.media.movies.subPath }} + subPath: {{ . | quote }} + {{- end }} - mountPath: /tv name: media-tv + {{- with .Values.media.tv.subPath }} + subPath: {{ . | quote }} + {{- end }} {{- end }} restartPolicy: Always volumes: diff --git a/freesailarr/values.yaml b/freesailarr/values.yaml index 64aa8ab..d845200 100644 --- a/freesailarr/values.yaml +++ b/freesailarr/values.yaml @@ -57,20 +57,40 @@ storage: # downloads: qbittorrent, radarr, sonarr # movies: radarr, bazarr # tv: sonarr, bazarr +# +# subPath mounts a sub-directory of the claim instead of its root (null = the +# claim root, i.e. one claim per category). The same existingClaim may be used +# for all three categories with different subPaths - that is more than a +# convenience: Radarr and Sonarr import finished downloads into the library by +# *moving* them, and a move within one filesystem can be a hardlink or rename +# instead of a copy. On one volume that costs no second copy of the data and no +# copy time; across volume boundaries a hardlink is impossible, so every import +# duplicates the file and the download and the library each hold a full copy +# until the torrent is removed. Example: one claim, subPaths downloads / +# movies / tv-shows. media: downloads: existingClaim: null + subPath: null movies: existingClaim: null + subPath: null tv: existingClaim: null + subPath: null +# Ingress defaults. There is deliberately no global domain: every app carries +# its own .ingress.host, so the hosts are independent of each other and +# may live in completely different domains. clusterIssuer, className and +# annotations are the defaults for all apps and can be overridden per app in +# the .ingress block (an explicitly set per-app value always wins). ingress: + # Master switch: false suppresses every app ingress regardless of the + # per-app toggles. enabled: true clusterIssuer: null - domain: null className: nginx - # Extra annotations for the Ingress; merged with the cert-manager + # Extra annotations for the Ingress objects; merged with the cert-manager # cluster-issuer annotation the chart always sets. annotations: {} @@ -157,8 +177,19 @@ qbittorrent: image: repository: linuxserver/qbittorrent tag: 5.2.1 + # Every app gets its own Ingress object (named ---ingress) + # with its own TLS secret tls----ingress, so the apps can + # be exposed independently and under unrelated hosts. + # host is required while enabled is true and has no default - an app whose + # ingress is switched off never demands one. clusterIssuer, className and + # annotations may be set here to override the global ingress block. + # (Same block for prowlarr, radarr, sonarr, seerr and bazarr below.) ingress: enabled: true + host: null + # clusterIssuer: null + # className: null + # annotations: {} storage: size: 1Gi # Network interface qBittorrent binds its BitTorrent session to - gluetun's @@ -258,8 +289,13 @@ prowlarr: image: repository: linuxserver/prowlarr tag: 2.3.5 + # See the qbittorrent ingress comment. ingress: enabled: true + host: null + # clusterIssuer: null + # className: null + # annotations: {} storage: size: 1Gi env: [] @@ -366,8 +402,13 @@ radarr: image: repository: linuxserver/radarr tag: 6.1.1 + # See the qbittorrent ingress comment. ingress: enabled: true + host: null + # clusterIssuer: null + # className: null + # annotations: {} storage: size: 1Gi env: [] @@ -471,8 +512,13 @@ sonarr: image: repository: linuxserver/sonarr tag: 4.0.17 + # See the qbittorrent ingress comment. ingress: enabled: true + host: null + # clusterIssuer: null + # className: null + # annotations: {} storage: size: 1Gi env: [] @@ -523,8 +569,13 @@ seerr: image: repository: ghcr.io/seerr-team/seerr tag: v3.3.0 + # See the qbittorrent ingress comment. ingress: enabled: true + host: null + # clusterIssuer: null + # className: null + # annotations: {} storage: size: 1Gi env: [] @@ -574,8 +625,13 @@ bazarr: image: repository: linuxserver/bazarr tag: 1.5.6 + # See the qbittorrent ingress comment. ingress: enabled: true + host: null + # clusterIssuer: null + # className: null + # annotations: {} storage: size: 1Gi env: [] From 70c7106fe8e7baa4480ca7a3d585fe0fe735b42c Mon Sep 17 00:00:00 2001 From: jklein Date: Mon, 24 Aug 2026 13:51:29 +0200 Subject: [PATCH 13/19] Give the Trivy scan render values for freesailarr ci/run-trivy-config.sh renders every changed chart before scanning it and exits non-zero when the render fails - which it does for freesailarr, because the chart is excluded from the install-test and therefore has no ci/freesailarr/test-values.yaml to fall back on. The scan job would go red without ever scanning anything. The file supplies only the values the chart declares required (secret ref, cluster issuer, the three media claims and one ingress host per app, since the chart has no global ingress.domain) and nothing that would alter the security posture the scan is meant to look at. --- ci/trivy/values/freesailarr.yaml | 39 ++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 ci/trivy/values/freesailarr.yaml diff --git a/ci/trivy/values/freesailarr.yaml b/ci/trivy/values/freesailarr.yaml new file mode 100644 index 0000000..aaf50f5 --- /dev/null +++ b/ci/trivy/values/freesailarr.yaml @@ -0,0 +1,39 @@ +# Render values for the misconfiguration scan only (ci/run-trivy-config.sh). +# +# freesailarr has no ci/freesailarr/test-values.yaml because it is excluded from +# the install-test (gluetun never becomes healthy without a real Wireguard key), +# but it is still scanned - the chart runs nine containers with several +# documented hardening deviations, which is exactly what the scan should see. +# +# Only the values the chart declares `required` - nothing that changes the +# security posture (no securityContext, image, capability or resource overrides). +# The ingress hosts are per app since the chart has no global ingress.domain. +secrets: + gluetunSecretRef: op://Vault/scan/placeholder +ingress: + clusterIssuer: letsencrypt +media: + downloads: + existingClaim: scan-downloads + movies: + existingClaim: scan-movies + tv: + existingClaim: scan-tv +qbittorrent: + ingress: + host: qbittorrent.scan.example.com +prowlarr: + ingress: + host: prowlarr.scan.example.com +radarr: + ingress: + host: radarr.scan.example.com +sonarr: + ingress: + host: sonarr.scan.example.com +seerr: + ingress: + host: seerr.scan.example.com +bazarr: + ingress: + host: bazarr.scan.example.com From bc6577f32180d43ef930cfb701608938dcc16149 Mon Sep 17 00:00:00 2001 From: jklein Date: Mon, 24 Aug 2026 14:08:24 +0200 Subject: [PATCH 14/19] Document what the media subPaths actually buy The runtime test contradicted the assumption behind the change. Kubernetes turns every volumeMount into its own bind mount, and link()/rename() across two mount points fails with EXDEV even when both sides are the same claim on the same device: in the radarr container /downloads and /movies both report device 65025 and resolve to the same PV directory, yet linking between them fails with "Cross-device link", while the identical link succeeds when the claim root is mounted once and the categories are sub-directories under it. It is not a property of subPath - two plain mounts of the same claim without any subPath fail the same way, so the chart never supported cross-category hardlinks and this change does not regress anything. But the comment claimed a benefit the chart does not deliver, so it now states the measurement instead: subPath buys the layout (one claim, one sub-directory per category), not hardlinked imports. Getting those needs a single mount containing all categories, which is a change to the mount layout rather than a values setting. --- freesailarr/values.yaml | 36 +++++++++++++++++++++++++++--------- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/freesailarr/values.yaml b/freesailarr/values.yaml index d845200..5507c51 100644 --- a/freesailarr/values.yaml +++ b/freesailarr/values.yaml @@ -59,15 +59,33 @@ storage: # tv: sonarr, bazarr # # subPath mounts a sub-directory of the claim instead of its root (null = the -# claim root, i.e. one claim per category). The same existingClaim may be used -# for all three categories with different subPaths - that is more than a -# convenience: Radarr and Sonarr import finished downloads into the library by -# *moving* them, and a move within one filesystem can be a hardlink or rename -# instead of a copy. On one volume that costs no second copy of the data and no -# copy time; across volume boundaries a hardlink is impossible, so every import -# duplicates the file and the download and the library each hold a full copy -# until the torrent is removed. Example: one claim, subPaths downloads / -# movies / tv-shows. +# claim root, i.e. one claim per category). The same existingClaim may back all +# three categories with different subPaths - one library volume, one +# sub-directory per category, e.g. downloads / movies / tv-shows. +# +# What this buys and what it does NOT buy - measured in a k3d cluster against +# this chart, because the usual argument for a shared volume is hardlinking: +# - It buys the layout: one claim to provision, back up and share with +# Jellyfin, with the categories as sibling directories instead of three +# separate volumes. +# - It does NOT buy hardlinked imports. Radarr and Sonarr import by moving, +# and a move within one filesystem can be a hardlink or a rename instead of +# a copy - but Kubernetes turns every volumeMount into its own bind mount, +# and link()/rename() across two mount points fails with EXDEV even when +# both sides are the same claim on the same device. Measured: /downloads and +# /movies in the radarr container both report device 65025 and resolve to +# the same PV directory, yet `ln /downloads/f /movies/f` fails with +# "Cross-device link", while the identical link succeeds when the claim root +# is mounted once and both categories are sub-directories under it. +# This is not a property of subPath: two plain mounts of the same claim +# without any subPath fail exactly the same way, so the chart has never +# supported cross-category hardlinks. +# - Consequence today: an import still copies, so download and library each +# hold a full copy until the torrent is removed. +# Getting hardlinks would mean giving the apps a single mount that contains all +# categories (the "one /data mount" layout the *arr guides recommend) instead of +# one mount per category - a separate change to the chart's mount layout, not a +# values setting. media: downloads: existingClaim: null From e4f70bc8564ec7efed18794b5438a2b223f2eb61 Mon Sep 17 00:00:00 2001 From: jklein Date: Mon, 24 Aug 2026 14:38:03 +0200 Subject: [PATCH 15/19] Correct two firewall claims the runtime test disproved FIREWALL_INPUT_PORTS does not open the app ports inside a pod network. gluetun detects the pod's own subnet and inserts an INPUT accept for it ahead of the per-port rules, and every packet arriving at the pod IP matches that rule by destination. Measured with the packet counters: 50 fresh requests moved the subnet rule from 306 to 356 packets while every --dport rule stayed at 0 since container start. A port that listens but is not in the list (gluetun's control server on 8000) is reachable from another pod all the same. The variable is kept - it is the explicit statement of which ports are meant to be reachable, and the subnet detection is gluetun's behaviour rather than a guarantee of this chart - but the comments no longer claim the traffic would be dropped without it. Second correction, same run: the /tmp/gluetun emptyDir fixed the "open ... permission denied" failure, but gluetun also chowns the file, which needs CAP_CHOWN. With all capabilities but NET_ADMIN dropped that logs "chown /tmp/gluetun/ip: operation not permitted" on every VPN restart. The file is written and the public IP is persisted, so this is a log line rather than a failure, and granting CHOWN to silence it would widen the container's capabilities for nothing. Noted where the mount is declared. --- freesailarr/templates/_helpers.tpl | 14 ++++++++++++-- freesailarr/templates/freesailarr.sfs.yaml | 17 ++++++++++++++--- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/freesailarr/templates/_helpers.tpl b/freesailarr/templates/_helpers.tpl index 2c92c6a..64e7a41 100644 --- a/freesailarr/templates/_helpers.tpl +++ b/freesailarr/templates/_helpers.tpl @@ -197,8 +197,18 @@ ingress block with enabled: true. Used by the ingress template. {{/* 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. Without these the gluetun firewall -drops the traffic. +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 -}} diff --git a/freesailarr/templates/freesailarr.sfs.yaml b/freesailarr/templates/freesailarr.sfs.yaml index 9eff982..d2d220e 100644 --- a/freesailarr/templates/freesailarr.sfs.yaml +++ b/freesailarr/templates/freesailarr.sfs.yaml @@ -133,9 +133,12 @@ spec: - name: FIREWALL_OUTBOUND_SUBNETS value: {{ . | quote }} {{- end }} - # Open the firewall for the UI/probe ports of the enabled apps and - # the health server - kubelet probes and ingress traffic arrive on - # the pod IP and would be dropped otherwise. + # 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 @@ -169,6 +172,14 @@ spec: # 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 }} From af5f9ed79bcbfd220b118a70475f7b49eeea8d6d Mon Sep 17 00:00:00 2001 From: jklein Date: Mon, 24 Aug 2026 15:01:34 +0200 Subject: [PATCH 16/19] Pin gluetun in values too, so Renovate proposes it there The chart bundles nine independently versioned upstreams, and eight of them were already pinned in values.yaml while only gluetun took its tag from appVersion. That split had no upside: it made gluetun the one image Renovate could not reach through the built-in helm-values manager, and it implied a "main app" the stack does not have. gluetun now carries gluetun.image.repository/tag like every other image, and the StatefulSet renders it from there. No tag comes from appVersion any more. Renovate picks all nine up through helm-values - the manager the repo already relies on for sidecar images - and ci/renovate-bump-chart.sh bumps the chart version with each update. Consequences, all documented where they bite: - renovate.json5 deliberately gets no app-version block for this chart. One would either duplicate the helm-values updates or rewrite an appVersion that selects no image. The absence is commented so it does not read as an oversight. - freesailarr leaves the change-app-version.yml choice list. That workflow rewrites appVersion and republishes, which for this chart would change no deployed image at all. - appVersion becomes a hand-maintained marker for the gluetun line. Since renovate-bump-chart.sh bumps the patch level on an unchanged appVersion, a gluetun update has to be paired with a hand edit of appVersion, or the +up suffix stops describing what the chart deploys. Noted in Chart.yaml, values.yaml and README. --- .github/workflows/change-app-version.yml | 6 +++++- README.md | 6 +++++- freesailarr/Chart.yaml | 7 ++++++ freesailarr/templates/freesailarr.sfs.yaml | 2 +- freesailarr/values.yaml | 25 +++++++++++++++++----- renovate.json5 | 9 ++++++++ 6 files changed, 47 insertions(+), 8 deletions(-) diff --git a/.github/workflows/change-app-version.yml b/.github/workflows/change-app-version.yml index 1c1aaea..d04ce0c 100644 --- a/.github/workflows/change-app-version.yml +++ b/.github/workflows/change-app-version.yml @@ -35,7 +35,11 @@ on: - minecraft-router - minecraft-server - rss-bridge - - freesailarr + # freesailarr is deliberately absent: none of its nine images takes + # its tag from appVersion (they are all pinned in its values.yaml), + # so this workflow would rewrite appVersion and republish the chart + # without changing a single deployed image. Update its images through + # a Renovate PR, and its appVersion by hand. workflow_call: inputs: app-version: diff --git a/README.md b/README.md index 6176672..e034f85 100644 --- a/README.md +++ b/README.md @@ -66,10 +66,13 @@ What Renovate keeps current: | --- | --- | | Chart app versions (`appVersion:` in `Chart.yaml`) | One `customManagers` entry per chart, hard-coding the image repository | | Sidecar/init images in `values.yaml` (nginx, guacd, busybox, alpine) | Built-in `helm-values` manager | +| **All** images of `freesailarr`, including its main one | Built-in `helm-values` manager — the chart pins every tag in `values.yaml` and has no app-version block | | GitHub Actions | Built-in `github-actions` manager | | Tool versions pinned as workflow env vars (`TRIVY_VERSION`) | `# renovate: datasource=… depName=…` comment above the line | -Each chart renders its main image as `:{{ .Chart.AppVersion }}` — repository in the template, version in `Chart.yaml`. Renovate cannot join two files, so the mapping lives in `renovate.json5`, one block per chart. It is deliberately *not* a `# renovate:` comment inside each `Chart.yaml`: that would touch all chart directories and force a version bump plus a publish per chart for a pure comment change. Charts whose tags carry a flavour or date suffix (`apache-tika`, `minecraft-server`, `rss-bridge`, `teamspeak-server`) override `versioning` in their block; the reasoning sits next to each one. +Each chart renders its main image as `:{{ .Chart.AppVersion }}` — repository in the template, version in `Chart.yaml`. Renovate cannot join two files, so the mapping lives in `renovate.json5`, one block per chart. It is deliberately *not* a `# renovate:` comment inside each `Chart.yaml`: that would touch all chart directories and force a version bump plus a publish per chart for a pure comment change. Charts whose tags carry a flavour or date suffix (`apache-tika`, `minecraft-server`, `rss-bridge`, `teamspeak-server`) override `versioning` in their block; the reasoning sits next to each one. `freesailarr` has no block on purpose — see the images exception above; a block would either duplicate the `helm-values` updates or rewrite an `appVersion` that selects no image. + +A consequence of pinning every tag in `values.yaml`: `ci/renovate-bump-chart.sh` sees an unchanged `appVersion` and bumps the chart **patch**, so the `+up` suffix keeps naming the old marker version. That is intended for the eight app images, but it means a **gluetun** update must be paired with a hand edit of `appVersion` (and thus the suffix), otherwise the chart version stops describing what is deployed. One dependency is constrained beyond its versioning: **nginx** (the Synapse delegation sidecar) is limited to **even minors** via `allowedVersions`. nginx numbers its release branches by minor — even is stable, odd is mainline — and the chart belongs on stable. Patch updates inside the stable branch still flow through normally. The constraint tolerates a variant suffix (`1.30.4-alpine`) on purpose: docker versioning only ever offers candidates carrying the current tag's suffix, so a suffix-blind constraint would silently freeze nginx the day the sidecar moves to a slimmer base image — a switch to `1.30.4-alpine` that #84 has commissioned a check on — with no error and no warning. @@ -143,6 +146,7 @@ These guidelines are **binding for every current and future chart** in this repo ### 3. Images - The main app image tag comes from `{{ .Chart.AppVersion }}` — never hardcoded. +- **Documented exception — freesailarr**: the chart bundles nine independently versioned upstreams, so none of them is "the" app version. **No** tag comes from `appVersion`; all nine images are pinned as `.image.repository` / `.image.tag` in `values.yaml`, which is also what puts every one of them under the built-in `helm-values` manager. Its `appVersion` is a hand-maintained marker for the gluetun line and is updated together with `gluetun.image.tag`; consequently the chart has **no** block in `renovate.json5` and is **not** in the `change-app-version.yml` choice list. - Auxiliary images (sidecars, init containers, secondary workloads — e.g. the delegation nginx in matrix-synapse, guacd in patchmon, busybox init/consume images in paperless-ngx) are pinned via values instead of being hardcoded in templates, and their pinned versions are reviewed alongside every app-version update round. ### 4. Ingress diff --git a/freesailarr/Chart.yaml b/freesailarr/Chart.yaml index a2b3389..98e889c 100644 --- a/freesailarr/Chart.yaml +++ b/freesailarr/Chart.yaml @@ -6,6 +6,13 @@ icon: https://raw.githubusercontent.com/qdm12/gluetun/master/doc/logo_256.png type: application version: 1.0.0+upv3.41.1 +# Hand-maintained marker for the gluetun line this chart is built around - it +# is NOT the source of any image tag. All nine images, gluetun included, are +# pinned in values.yaml so Renovate's helm-values manager can propose them +# there; consequently this chart has no app-version block in renovate.json5 and +# nothing rewrites this field automatically. Update it by hand together with +# gluetun.image.tag, otherwise the +up suffix of version: stops describing what +# the chart deploys. appVersion: "v3.41.1" maintainers: diff --git a/freesailarr/templates/freesailarr.sfs.yaml b/freesailarr/templates/freesailarr.sfs.yaml index d2d220e..4a45414 100644 --- a/freesailarr/templates/freesailarr.sfs.yaml +++ b/freesailarr/templates/freesailarr.sfs.yaml @@ -86,7 +86,7 @@ spec: # internet only through the Wireguard tunnel (gluetun's firewall acts # as kill switch). - name: gluetun - image: "qmcgaw/gluetun:{{ .Chart.AppVersion }}" + 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 diff --git a/freesailarr/values.yaml b/freesailarr/values.yaml index 5507c51..a0eb083 100644 --- a/freesailarr/values.yaml +++ b/freesailarr/values.yaml @@ -4,10 +4,21 @@ # the Wireguard tunnel. # # Image pinning (documented deviation from the Chart.AppVersion standard): -# appVersion pins the gluetun image only. All other app images are pinned via -# values (.image.repository / .image.tag) because the stack combines -# nine independently versioned upstream projects; the pinned tags are reviewed -# alongside every app-version update round, like side images in other charts. +# NO image tag comes from appVersion. All nine images, gluetun included, are +# pinned here via .image.repository / .image.tag, because the stack +# combines nine independently versioned upstream projects and none of them is +# "the" one app version. +# +# That is also what puts every image under Renovate: the built-in helm-values +# manager reads image.repository/image.tag pairs out of this file and proposes +# one update per image, and ci/renovate-bump-chart.sh bumps the chart version +# along with it. A chart whose main image came from appVersion would instead +# need a hard-coded block in renovate.json5 - freesailarr deliberately has none. +# +# Chart.yaml's appVersion is therefore a hand-maintained marker for the gluetun +# line this chart is built around, not the source of any tag. Keep it in step +# with gluetun.image.tag below when you accept a gluetun update, otherwise the +# +up suffix of the chart version drifts away from what is actually deployed. # Scale the whole chart down without uninstalling it: scaleDown: true renders # every Deployment/StatefulSet with replicas: 0 (and suspends CronJobs) and @@ -113,7 +124,11 @@ ingress: annotations: {} gluetun: - # Image tag comes from Chart.AppVersion (gluetun is the chart's main app). + # Pinned here like every other image in this chart, so Renovate's helm-values + # manager proposes gluetun updates too. Docker Hub tags carry a v prefix. + image: + repository: qmcgaw/gluetun + tag: v3.41.1 vpn: serviceProvider: mullvad type: wireguard diff --git a/renovate.json5 b/renovate.json5 index 2f6d880..e400c1f 100644 --- a/renovate.json5 +++ b/renovate.json5 @@ -148,6 +148,15 @@ // mapping here leaves the charts untouched. // // When adding a chart, add a block here — see README "Creating a new chart". + // + // Exception, deliberately no block: `freesailarr`. It bundles nine + // independently versioned upstreams, so none of them is "the" app version + // and no image tag comes from appVersion — all nine are pinned as + // image.repository/image.tag in its values.yaml, where the built-in + // helm-values manager already finds them and proposes one update per + // image. Adding a block here would either duplicate those updates or + // rewrite an appVersion that selects nothing. Its appVersion is a + // hand-maintained marker; see freesailarr/Chart.yaml. { description: "apache-tika: four-part version plus flavour suffix (3.3.1.0-full); the compatibility group keeps the chart on -full.", From 09fe3c2be6b9bfe171a50c3858aa115aa341b996 Mon Sep 17 00:00:00 2001 From: jklein Date: Mon, 24 Aug 2026 15:10:14 +0200 Subject: [PATCH 17/19] Decouple appVersion from gluetun in wording and value The previous commit moved every image tag into values.yaml but left appVersion described as a marker "for the gluetun line", to be updated together with gluetun.image.tag. That still asserts a coupling the chart does not have, and the value v3.41.1 confirmed it at a glance. appVersion is now 1.0.0 (version 1.0.0+up1.0.0): the chart's own version marker, freely chosen and tied to no upstream. The texts say that instead of the old coupling in Chart.yaml, values.yaml and the two README passages. The README's remaining true statement is kept but generalized - renovate-bump-chart bumps the patch level on an unchanged appVersion for all nine images alike, which is not a gluetun special case. Also corrected on the way: the busybox init image comment claimed it was "pinned independently of appVersion (which tracks gluetun)". Neither half holds any more - appVersion tracks nothing, and every image in the chart is pinned the same way. No functional change: the rendered gluetun image is qmcgaw/gluetun:v3.41.1 before and after. --- README.md | 4 ++-- freesailarr/Chart.yaml | 20 +++++++++++--------- freesailarr/values.yaml | 12 ++++++------ 3 files changed, 19 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index e034f85..5bd52b8 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ What Renovate keeps current: Each chart renders its main image as `:{{ .Chart.AppVersion }}` — repository in the template, version in `Chart.yaml`. Renovate cannot join two files, so the mapping lives in `renovate.json5`, one block per chart. It is deliberately *not* a `# renovate:` comment inside each `Chart.yaml`: that would touch all chart directories and force a version bump plus a publish per chart for a pure comment change. Charts whose tags carry a flavour or date suffix (`apache-tika`, `minecraft-server`, `rss-bridge`, `teamspeak-server`) override `versioning` in their block; the reasoning sits next to each one. `freesailarr` has no block on purpose — see the images exception above; a block would either duplicate the `helm-values` updates or rewrite an `appVersion` that selects no image. -A consequence of pinning every tag in `values.yaml`: `ci/renovate-bump-chart.sh` sees an unchanged `appVersion` and bumps the chart **patch**, so the `+up` suffix keeps naming the old marker version. That is intended for the eight app images, but it means a **gluetun** update must be paired with a hand edit of `appVersion` (and thus the suffix), otherwise the chart version stops describing what is deployed. +A consequence of pinning every tag in `values.yaml`: `ci/renovate-bump-chart.sh` sees an unchanged `appVersion` and bumps the chart **patch**. That holds for all nine images alike — none of them is special — and it is the intended outcome, since `appVersion` here is the chart's own marker rather than a tracked upstream version. One dependency is constrained beyond its versioning: **nginx** (the Synapse delegation sidecar) is limited to **even minors** via `allowedVersions`. nginx numbers its release branches by minor — even is stable, odd is mainline — and the chart belongs on stable. Patch updates inside the stable branch still flow through normally. The constraint tolerates a variant suffix (`1.30.4-alpine`) on purpose: docker versioning only ever offers candidates carrying the current tag's suffix, so a suffix-blind constraint would silently freeze nginx the day the sidecar moves to a slimmer base image — a switch to `1.30.4-alpine` that #84 has commissioned a check on — with no error and no warning. @@ -146,7 +146,7 @@ These guidelines are **binding for every current and future chart** in this repo ### 3. Images - The main app image tag comes from `{{ .Chart.AppVersion }}` — never hardcoded. -- **Documented exception — freesailarr**: the chart bundles nine independently versioned upstreams, so none of them is "the" app version. **No** tag comes from `appVersion`; all nine images are pinned as `.image.repository` / `.image.tag` in `values.yaml`, which is also what puts every one of them under the built-in `helm-values` manager. Its `appVersion` is a hand-maintained marker for the gluetun line and is updated together with `gluetun.image.tag`; consequently the chart has **no** block in `renovate.json5` and is **not** in the `change-app-version.yml` choice list. +- **Documented exception — freesailarr**: the chart bundles nine independently versioned upstreams, so none of them is "the" app version. **No** tag comes from `appVersion`; all nine images are pinned as `.image.repository` / `.image.tag` in `values.yaml`, which is also what puts every one of them under the built-in `helm-values` manager. Its `appVersion` is the chart's own version marker, freely chosen and tied to no upstream — raised by hand when a new edition of the chart is marked as a whole. Consequently the chart has **no** block in `renovate.json5` and is **not** in the `change-app-version.yml` choice list. - Auxiliary images (sidecars, init containers, secondary workloads — e.g. the delegation nginx in matrix-synapse, guacd in patchmon, busybox init/consume images in paperless-ngx) are pinned via values instead of being hardcoded in templates, and their pinned versions are reviewed alongside every app-version update round. ### 4. Ingress diff --git a/freesailarr/Chart.yaml b/freesailarr/Chart.yaml index 98e889c..95c686a 100644 --- a/freesailarr/Chart.yaml +++ b/freesailarr/Chart.yaml @@ -5,15 +5,17 @@ icon: https://raw.githubusercontent.com/qdm12/gluetun/master/doc/logo_256.png type: application -version: 1.0.0+upv3.41.1 -# Hand-maintained marker for the gluetun line this chart is built around - it -# is NOT the source of any image tag. All nine images, gluetun included, are -# pinned in values.yaml so Renovate's helm-values manager can propose them -# there; consequently this chart has no app-version block in renovate.json5 and -# nothing rewrites this field automatically. Update it by hand together with -# gluetun.image.tag, otherwise the +up suffix of version: stops describing what -# the chart deploys. -appVersion: "v3.41.1" +version: 1.0.0+up1.0.0 +# The chart's own version marker, freely chosen and tied to no upstream. The +# stack bundles nine independently versioned projects, so there is no single +# "app version" to track here. +# +# It is NOT the source of any image tag: all nine images are pinned in +# values.yaml, where Renovate's helm-values manager proposes them. Nothing +# rewrites this field automatically - the chart has no app-version block in +# renovate.json5 and is not in the change-app-version.yml choice list. Raise it +# by hand when you want to mark a new edition of the chart as a whole. +appVersion: "1.0.0" maintainers: - name: jklein diff --git a/freesailarr/values.yaml b/freesailarr/values.yaml index a0eb083..4f086db 100644 --- a/freesailarr/values.yaml +++ b/freesailarr/values.yaml @@ -15,10 +15,10 @@ # along with it. A chart whose main image came from appVersion would instead # need a hard-coded block in renovate.json5 - freesailarr deliberately has none. # -# Chart.yaml's appVersion is therefore a hand-maintained marker for the gluetun -# line this chart is built around, not the source of any tag. Keep it in step -# with gluetun.image.tag below when you accept a gluetun update, otherwise the -# +up suffix of the chart version drifts away from what is actually deployed. +# Chart.yaml's appVersion is therefore the chart's own version marker, freely +# chosen and tied to no upstream - not the source of any tag here. Nothing +# rewrites it automatically; raise it by hand when you want to mark a new +# edition of the chart as a whole. # Scale the whole chart down without uninstalling it: scaleDown: true renders # every Deployment/StatefulSet with replicas: 0 (and suspends CronJobs) and @@ -242,8 +242,8 @@ qbittorrent: # persisted qBittorrent config before the app starts. Runs as puid/pgid so # the config file keeps the ownership the app expects. configInit: - # Auxiliary busybox image. Pinned independently of appVersion (which - # tracks gluetun); reviewed alongside every app-version update round. + # Auxiliary busybox image for the init container, pinned here like every + # other image in this chart and proposed by Renovate the same way. image: repository: busybox tag: "1.38.0" From 9cdbe097f6c4090eb86f2f1dd2e605ad94cdfb52 Mon Sep 17 00:00:00 2001 From: jklein Date: Mon, 24 Aug 2026 15:23:35 +0200 Subject: [PATCH 18/19] Qualify the Docker Hub image names, drop the gluetun branding main qualified five charts' image references in 6c73ef8 on the same day this chart would otherwise arrive carrying the pattern that was just cleared out. The seven Docker Hub names now say where they come from: qmcgaw/gluetun, the five linuxserver.io images and the busybox init image, the last as docker.io/library/busybox since it is an official image. The three ghcr.io names were already qualified - checked rather than assumed. Verified with Renovate 44.41.0 in the official container (RENOVATE_PLATFORM= local, RENOVATE_DRY_RUN=lookup) that the prefixed form still resolves under the built-in helm-values manager, since that manager is the only thing keeping these images updated: it extracts all ten, datasource docker, no skipReason and no warnings, and returns concrete candidates for eight of them. A control run against the unqualified form produced byte-identical results, so the prefix changes nothing about what Renovate offers. Trivy's trusted-registry check (KSV-0125) drops from ten findings to none, which is what the registry list in ci/trivy/data/ksv0125.yaml documents as the point of writing images this way. Chart metadata: the icon was gluetun's logo and the description opened with a list of four of the nine projects, both suggesting a "main app" the stack does not have. The description now names none of them, and the icon field is omitted rather than filled with another project's logo - every other chart's icon is its one upstream's, and this chart has no such upstream. helm lint --strict notes "icon is recommended" as INFO and still exits 0. Rendered output differs in exactly the seven image prefixes and nothing else. --- freesailarr/Chart.yaml | 7 +++++-- freesailarr/values.yaml | 14 +++++++------- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/freesailarr/Chart.yaml b/freesailarr/Chart.yaml index 95c686a..814a1cf 100644 --- a/freesailarr/Chart.yaml +++ b/freesailarr/Chart.yaml @@ -1,7 +1,10 @@ apiVersion: v2 name: freesailarr -description: A Helm chart to deploy a Wireguard-tunneled media automation stack (gluetun, qbittorrent, the *arrs, seerr) on Kubernetes -icon: https://raw.githubusercontent.com/qdm12/gluetun/master/doc/logo_256.png +description: A Helm chart to deploy a Wireguard-tunneled media automation stack on Kubernetes - a VPN gateway with indexer, downloader, library managers, subtitle fetcher and request front end in one pod, all reaching the internet only through the tunnel +# No icon on purpose: the stack has no single upstream, and every other chart's +# icon is its one project's logo. Picking any of the nine here would suggest +# the same false "main app" the versioning deliberately avoids. The field is +# optional, so it is omitted rather than filled with an arbitrary choice. type: application diff --git a/freesailarr/values.yaml b/freesailarr/values.yaml index 4f086db..45b9b50 100644 --- a/freesailarr/values.yaml +++ b/freesailarr/values.yaml @@ -127,7 +127,7 @@ gluetun: # Pinned here like every other image in this chart, so Renovate's helm-values # manager proposes gluetun updates too. Docker Hub tags carry a v prefix. image: - repository: qmcgaw/gluetun + repository: docker.io/qmcgaw/gluetun tag: v3.41.1 vpn: serviceProvider: mullvad @@ -208,7 +208,7 @@ gluetun: qbittorrent: enabled: true image: - repository: linuxserver/qbittorrent + repository: docker.io/linuxserver/qbittorrent tag: 5.2.1 # Every app gets its own Ingress object (named ---ingress) # with its own TLS secret tls----ingress, so the apps can @@ -245,7 +245,7 @@ qbittorrent: # Auxiliary busybox image for the init container, pinned here like every # other image in this chart and proposed by Renovate the same way. image: - repository: busybox + repository: docker.io/library/busybox tag: "1.38.0" resources: limitFactor: 3 @@ -320,7 +320,7 @@ qbittorrent: prowlarr: enabled: true image: - repository: linuxserver/prowlarr + repository: docker.io/linuxserver/prowlarr tag: 2.3.5 # See the qbittorrent ingress comment. ingress: @@ -433,7 +433,7 @@ flaresolverr: radarr: enabled: true image: - repository: linuxserver/radarr + repository: docker.io/linuxserver/radarr tag: 6.1.1 # See the qbittorrent ingress comment. ingress: @@ -543,7 +543,7 @@ recyclarr: sonarr: enabled: true image: - repository: linuxserver/sonarr + repository: docker.io/linuxserver/sonarr tag: 4.0.17 # See the qbittorrent ingress comment. ingress: @@ -656,7 +656,7 @@ seerr: bazarr: enabled: true image: - repository: linuxserver/bazarr + repository: docker.io/linuxserver/bazarr tag: 1.5.6 # See the qbittorrent ingress comment. ingress: From 020e54d1ff357ffa71465e7e39e8551af369d5f8 Mon Sep 17 00:00:00 2001 From: jklein Date: Mon, 24 Aug 2026 15:37:10 +0200 Subject: [PATCH 19/19] Write the busybox image without the library/ namespace docker.io/busybox and docker.io/library/busybox resolve to the same image on Docker Hub, and the short form is the spelling the rest of the fleet will use when the remaining unqualified official images (paperless-ngx, matrix-synapse, minecraft-server) are qualified in the #84 follow-up. Verified: the rendered output differs in exactly this one line, and a Renovate lookup against the real chart files (44.41.0, official container, dry-run lookup) still extracts all ten images through helm-values with no skipReason and no warnings - docker.io/busybox included, whose candidate list stays empty because 1.38.0 is current. --- freesailarr/values.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/freesailarr/values.yaml b/freesailarr/values.yaml index 45b9b50..9b22037 100644 --- a/freesailarr/values.yaml +++ b/freesailarr/values.yaml @@ -245,7 +245,7 @@ qbittorrent: # Auxiliary busybox image for the init container, pinned here like every # other image in this chart and proposed by Renovate the same way. image: - repository: docker.io/library/busybox + repository: docker.io/busybox tag: "1.38.0" resources: limitFactor: 3