diff --git a/.github/workflows/change-app-version.yml b/.github/workflows/change-app-version.yml index c116759..d04ce0c 100644 --- a/.github/workflows/change-app-version.yml +++ b/.github/workflows/change-app-version.yml @@ -35,6 +35,11 @@ on: - minecraft-router - minecraft-server - rss-bridge + # 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/.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 1a41622..c2a5556 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 (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 | @@ -65,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**. 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. @@ -142,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 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 @@ -151,6 +156,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/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/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 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..814a1cf --- /dev/null +++ b/freesailarr/Chart.yaml @@ -0,0 +1,24 @@ +apiVersion: v2 +name: freesailarr +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 + +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/templates/_helpers.tpl b/freesailarr/templates/_helpers.tpl new file mode 100644 index 0000000..64e7a41 --- /dev/null +++ b/freesailarr/templates/_helpers.tpl @@ -0,0 +1,220 @@ +{{/* +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 app containers that listen on a port, as a JSON array of +{name, port} dicts (container port name == app name == service name suffix). +Consumed by the per-app services, the per-app ingresses and the firewall-ports +helper below: + + {{- $apps := include "freesailarr.portApps" . | fromJsonArray }} + +recyclarr is deliberately absent: in daemon mode it serves no port, so it gets +no container port, no service and no ingress. flaresolverr is present - it +listens on 8191 and therefore gets a service - but it has no ingress block in +values and is never exposed from outside the cluster. +*/}} +{{- define "freesailarr.portApps" -}} +{{- $apps := list -}} +{{- range $app := list + (dict "name" "qbittorrent" "port" 8080) + (dict "name" "prowlarr" "port" 9696) + (dict "name" "flaresolverr" "port" 8191) + (dict "name" "radarr" "port" 7878) + (dict "name" "sonarr" "port" 8989) + (dict "name" "seerr" "port" 5055) + (dict "name" "bazarr" "port" 6767) -}} +{{- if (index $.Values $app.name).enabled -}} +{{- $apps = append $apps $app -}} +{{- end -}} +{{- end -}} +{{- toJson $apps -}} +{{- end -}} + +{{/* +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. + +Measured caveat: inside a pod network these rules never actually match. gluetun +detects the pod's own subnet and inserts +"-A INPUT -d -i eth0 -j ACCEPT" ahead of the per-port rules, and +every packet arriving at the pod IP matches it by destination - the packet +counters on the --dport rules stayed at 0 while the subnet rule absorbed the +traffic. The declaration is kept as an explicit statement of which ports are +meant to be reachable, and because that subnet detection is gluetun's, not a +guarantee of the chart's: a CNI that hands out a /32 or a future gluetun that +drops the rule would make these the only thing keeping probes and ingress +alive. It is not what opens the ports today. +*/}} +{{- define "freesailarr.firewallInputPorts" -}} +{{- $ports := list -}} +{{- range $app := include "freesailarr.portApps" . | fromJsonArray -}} +{{- $ports = append $ports (int $app.port) -}} +{{- 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..d4ddec7 --- /dev/null +++ b/freesailarr/templates/freesailarr.ingress.yaml @@ -0,0 +1,51 @@ +{{/* +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 }}-{{ $app.name }}-ingress + annotations: + cert-manager.io/cluster-issuer: {{ required "A Cluster-Issuer must be provided" $clusterIssuer }} + {{- with $annotations }} + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + ingressClassName: {{ $className }} + rules: + - host: {{ $host | quote }} + http: + paths: + - backend: + service: + name: {{ $.Release.Name }}-{{ $.Chart.Name }}-{{ $app.name }}-service + port: + name: {{ $app.name }} + path: / + pathType: Prefix + tls: + - hosts: + - {{ $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 new file mode 100644 index 0000000..863acc4 --- /dev/null +++ b/freesailarr/templates/freesailarr.service.yaml @@ -0,0 +1,25 @@ +{{/* +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 }}-{{ $app.name }}-service +spec: + selector: + app: {{ $.Release.Name }}-{{ $.Chart.Name }}-sfs + ports: + - protocol: TCP + 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 new file mode 100644 index 0000000..4a45414 --- /dev/null +++ b/freesailarr/templates/freesailarr.sfs.yaml @@ -0,0 +1,524 @@ +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: {{ .Release.Name }}-{{ .Chart.Name }}-sfs + labels: + app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs +spec: + replicas: {{ include "freesailarr.replicas" (dict "scaleDown" .Values.scaleDown "replicas" .Values.replicas) }} + # Governing service name. Each app has its own service (see + # freesailarr.service.yaml); nothing consumes the per-pod DNS name a + # governing service would provide, so the chart deliberately creates no + # object under this name - the field only has to be set. + serviceName: {{ .Release.Name }}-{{ .Chart.Name }}-sfs + selector: + matchLabels: + app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs + template: + metadata: + name: {{ .Release.Name }}-{{ .Chart.Name }}-sfs + labels: + app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs + 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 + # internet only through the Wireguard tunnel (gluetun's firewall acts + # as kill switch). + - name: gluetun + image: "{{ .Values.gluetun.image.repository }}:{{ .Values.gluetun.image.tag }}" + imagePullPolicy: IfNotPresent + # An uncleanly exited gluetun (crash, OOM killer, node issue) leaves + # its ip rule, routing table and tun device behind in the shared pod + # network namespace, and the restarted container then fails forever + # ("adding ip rule 101 ... file exists" / silent tunnel breakage) + # while the kill switch blocks all egress. The upstream-recommended + # postStart hook is racy (it runs concurrently with the entrypoint + # and can delete the rule the new instance just created), so the + # stale state is cleaned synchronously before gluetun starts. + command: + - /bin/sh + - -c + - 'while ip rule del table 51820 2>/dev/null; do :; done; while ip -6 rule del table 51820 2>/dev/null; do :; done; ip link del tun0 2>/dev/null; exec /gluetun-entrypoint' + env: + - name: TZ + value: {{ .Values.timezone | quote }} + - name: VPN_SERVICE_PROVIDER + value: {{ .Values.gluetun.vpn.serviceProvider | quote }} + - name: VPN_TYPE + value: {{ .Values.gluetun.vpn.type | quote }} + - name: WIREGUARD_PRIVATE_KEY + valueFrom: + secretKeyRef: + name: {{ .Release.Name }}-{{ .Chart.Name }}-gluetun-secret + key: wireguard-private-key + - name: WIREGUARD_ADDRESSES + valueFrom: + secretKeyRef: + name: {{ .Release.Name }}-{{ .Chart.Name }}-gluetun-secret + key: wireguard-addresses + {{- with .Values.gluetun.vpn.serverCities }} + - name: SERVER_CITIES + value: {{ . | quote }} + {{- end }} + {{- with .Values.gluetun.vpn.serverCountries }} + - name: SERVER_COUNTRIES + value: {{ . | quote }} + {{- end }} + {{- with .Values.gluetun.vpn.dnsUpstreamResolvers }} + - name: DNS_UPSTREAM_RESOLVERS + value: {{ . | quote }} + {{- end }} + {{- with .Values.gluetun.vpn.firewallOutboundSubnets }} + - name: FIREWALL_OUTBOUND_SUBNETS + value: {{ . | quote }} + {{- end }} + # Declare the UI/probe ports of the enabled apps and the health + # server as reachable. Note that inside a pod network gluetun's own + # pod-subnet rule already accepts this traffic and these per-port + # rules never match (measured - see the helper's comment); they are + # the explicit statement of intent and the fallback if that subnet + # detection ever stops covering the pod IP. + - name: FIREWALL_INPUT_PORTS + value: {{ include "freesailarr.firewallInputPorts" . | quote }} + # Bind the health server to all interfaces so the kubelet HTTP + # probes (which connect to the pod IP, not localhost) reach it. + - name: HEALTH_SERVER_ADDRESS + value: ":9999" + {{- include "freesailarr.env" (dict "root" $ "env" .Values.gluetun.env) | nindent 12 }} + ports: + - containerPort: 9999 + protocol: TCP + name: health + livenessProbe: + {{- 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 + # gluetun creates /tmp/gluetun (its public-IP cache) mode 0644, + # i.e. without the execute bit it needs to enter its own + # directory. It runs as root but with all capabilities except + # NET_ADMIN dropped, so DAC_OVERRIDE no longer papers over that + # and every write fails with "clearing public IP data: open + # /tmp/gluetun/ip: permission denied". An emptyDir provides the + # path with usable permissions. + # Remaining, cosmetic: gluetun also chowns the file, which needs + # CAP_CHOWN. With all capabilities but NET_ADMIN dropped that call + # is denied and logs "clearing public IP data: chown + # /tmp/gluetun/ip: operation not permitted" on every VPN restart. + # The file itself is written and the public IP is persisted + # (verified at runtime), so this is a log line, not a failure - + # granting CHOWN just to silence it would widen the container's + # capabilities for nothing. + - mountPath: /tmp/gluetun + name: gluetun-tmp + {{- if .Values.qbittorrent.enabled }} + - name: qbittorrent + image: "{{ .Values.qbittorrent.image.repository }}:{{ .Values.qbittorrent.image.tag }}" + imagePullPolicy: IfNotPresent + env: + - name: PUID + value: {{ .Values.puid | quote }} + - name: PGID + value: {{ .Values.pgid | quote }} + - name: TZ + value: {{ .Values.timezone | quote }} + - name: WEBUI_PORT + value: "8080" + {{- include "freesailarr.env" (dict "root" $ "env" .Values.qbittorrent.env) | nindent 12 }} + ports: + - containerPort: 8080 + protocol: TCP + name: qbittorrent + livenessProbe: + {{- 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 + {{- with .Values.media.downloads.subPath }} + subPath: {{ . | quote }} + {{- end }} + {{- end }} + {{- if .Values.prowlarr.enabled }} + - name: prowlarr + image: "{{ .Values.prowlarr.image.repository }}:{{ .Values.prowlarr.image.tag }}" + imagePullPolicy: IfNotPresent + env: + - name: PUID + value: {{ .Values.puid | quote }} + - name: PGID + value: {{ .Values.pgid | quote }} + - name: TZ + value: {{ .Values.timezone | quote }} + {{- 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 + {{- 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 + {{- 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 + 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 + {{- 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 + 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 + {{- 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: + # The tun device gluetun needs to create the Wireguard tunnel. + - name: dev-net-tun + hostPath: + path: /dev/net/tun + type: CharDevice + # Writable /tmp/gluetun for gluetun's public-IP cache (see the mount). + - name: gluetun-tmp + emptyDir: {} + {{- 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.flaresolverr.enabled }} + - name: flaresolverr-tmp + emptyDir: {} + - name: flaresolverr-shm + emptyDir: + medium: Memory + sizeLimit: 512Mi + {{- end }} + {{- if .Values.recyclarr.enabled }} + - name: recyclarr-tmp + emptyDir: {} + {{- end }} + {{- if .Values.seerr.enabled }} + - name: seerr-tmp + emptyDir: {} + {{- end }} + # 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..9b22037 --- /dev/null +++ b/freesailarr/values.yaml @@ -0,0 +1,712 @@ +# 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): +# 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 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 +# 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 +# +# 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 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 + 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 + className: nginx + # Extra annotations for the Ingress objects; merged with the cert-manager + # cluster-issuer annotation the chart always sets. + annotations: {} + +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: docker.io/qmcgaw/gluetun + tag: v3.41.1 + 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. + # 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: 6 + 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: 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 + # 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 + # 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 for the init container, pinned here like every + # other image in this chart and proposed by Renovate the same way. + image: + repository: docker.io/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: + 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 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). + # (Same block for all linuxserver.io containers: qbittorrent, prowlarr, + # radarr, sonarr, bazarr.) + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: false + capabilities: + drop: + - ALL + add: + - CHOWN + - DAC_OVERRIDE + - FOWNER + - SETGID + - SETUID + +prowlarr: + enabled: true + image: + repository: docker.io/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: [] + # 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: false + 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 + # 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: false + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 + capabilities: + drop: + - ALL + +radarr: + enabled: true + image: + repository: docker.io/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: [] + 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: false + 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: docker.io/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: [] + 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: false + capabilities: + drop: + - ALL + add: + - CHOWN + - DAC_OVERRIDE + - FOWNER + - SETGID + - SETUID + +seerr: + enabled: true + 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: [] + # /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: docker.io/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: [] + # 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: false + capabilities: + drop: + - ALL + add: + - CHOWN + - DAC_OVERRIDE + - FOWNER + - SETGID + - SETUID 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.",