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