diff --git a/README.md b/README.md index b7e10ca..6176672 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Collection of some self-made helm-charts. Each top-level directory is a standalo | `apache-tika` | Apache Tika | Deployment + Service only (no ingress) | | `calibre-web-automated` | Calibre-Web Automated | StatefulSet, extra consume-PVC | | `cyberchef` | CyberChef | Stateless Deployment | -| `freesailarr` | gluetun VPN media stack (qbittorrent, prowlarr, flaresolverr, radarr, recyclarr, sonarr, seerr, bazarr) | Single-pod StatefulSet: all apps share gluetun's network namespace; config volumes via volumeClaimTemplates, media PVCs as existingClaims | +| `freesailarr` | gluetun VPN media stack (qbittorrent, prowlarr, flaresolverr, radarr, recyclarr, sonarr, seerr, bazarr) | Single-pod StatefulSet: all apps share gluetun's network namespace; config volumes via volumeClaimTemplates, media PVCs as existingClaims (optional per-category `subPath`, so one claim can serve all three categories). One ClusterIP Service and one Ingress **per app** — no global `ingress.domain`, each app has its own required host (documented ingress exception below) | | `gotenberg` | Gotenberg | Deployment + Service only (no ingress) | | `homeassistant` | Home Assistant | StatefulSet, has `NOTES.txt` and Helm tests | | `matrix-synapse` | Matrix Synapse | StatefulSet plus an nginx delegation Deployment/Service | @@ -152,6 +152,7 @@ These guidelines are **binding for every current and future chart** in this repo - `ingress.annotations` takes optional extra annotations that are merged with the cert-manager `cluster-issuer` annotation the chart always sets. - The TLS secret is named `tls---ingress`. - **Documented exception — homeassistant**: keeps its upstream-style flexible ingress values (host/tls lists, no required values); only its `ingress.className` default follows the standard (`nginx`, clearable to omit the field). +- **Documented exception — freesailarr**: the chart bundles several independently exposed apps in one pod, so it has **no** `ingress.domain`. Each app carries its own `.ingress.enabled` plus a required `.ingress.host` (required only while that app's ingress is enabled) and renders its own Ingress object `---ingress` with its own TLS secret `tls----ingress`. `clusterIssuer`, `className` and `annotations` stay global defaults under `ingress.` and are overridable per app. ### 5. Security hardening diff --git a/freesailarr/templates/_helpers.tpl b/freesailarr/templates/_helpers.tpl index d616a81..2c92c6a 100644 --- a/freesailarr/templates/_helpers.tpl +++ b/freesailarr/templates/_helpers.tpl @@ -148,17 +148,24 @@ Usage: {{- end -}} {{/* -The enabled apps that expose a web UI, as a JSON array of {name, port} dicts -(container port name == app name == ingress subdomain). Consumed by the -service, the ingress and the firewall-ports helper below: +The enabled app containers that listen on a port, as a JSON array of +{name, port} dicts (container port name == app name == service name suffix). +Consumed by the per-app services, the per-app ingresses and the firewall-ports +helper below: - {{- $apps := include "freesailarr.uiApps" . | fromJsonArray }} + {{- $apps := include "freesailarr.portApps" . | fromJsonArray }} + +recyclarr is deliberately absent: in daemon mode it serves no port, so it gets +no container port, no service and no ingress. flaresolverr is present - it +listens on 8191 and therefore gets a service - but it has no ingress block in +values and is never exposed from outside the cluster. */}} -{{- define "freesailarr.uiApps" -}} +{{- define "freesailarr.portApps" -}} {{- $apps := list -}} {{- range $app := list (dict "name" "qbittorrent" "port" 8080) (dict "name" "prowlarr" "port" 9696) + (dict "name" "flaresolverr" "port" 8191) (dict "name" "radarr" "port" 7878) (dict "name" "sonarr" "port" 8989) (dict "name" "seerr" "port" 5055) @@ -171,19 +178,33 @@ service, the ingress and the firewall-ports helper below: {{- end -}} {{/* -Comma-separated FIREWALL_INPUT_PORTS for gluetun: the UI ports of the enabled -apps (ingress traffic arrives on the pod IP), flaresolverr's 8191 when enabled -(the kubelet probes connect to the pod IP too) and 9999 for gluetun's own -health server. Without these the gluetun firewall drops the traffic. +The apps that render an Ingress: the enabled port apps whose values carry an +ingress block with enabled: true. Used by the ingress template. + + {{- $apps := include "freesailarr.ingressApps" . | fromJsonArray }} +*/}} +{{- define "freesailarr.ingressApps" -}} +{{- $apps := list -}} +{{- range $app := include "freesailarr.portApps" . | fromJsonArray -}} +{{- $config := index $.Values $app.name -}} +{{- if and $config.ingress $config.ingress.enabled -}} +{{- $apps = append $apps $app -}} +{{- end -}} +{{- end -}} +{{- toJson $apps -}} +{{- end -}} + +{{/* +Comma-separated FIREWALL_INPUT_PORTS for gluetun: the ports of the enabled app +containers (ingress traffic and the kubelet probes both arrive on the pod IP) +plus 9999 for gluetun's own health server. Without these the gluetun firewall +drops the traffic. */}} {{- define "freesailarr.firewallInputPorts" -}} {{- $ports := list -}} -{{- range $app := include "freesailarr.uiApps" . | fromJsonArray -}} +{{- range $app := include "freesailarr.portApps" . | fromJsonArray -}} {{- $ports = append $ports (int $app.port) -}} {{- end -}} -{{- if .Values.flaresolverr.enabled -}} -{{- $ports = append $ports 8191 -}} -{{- end -}} {{- $ports = append $ports 9999 -}} {{- join "," $ports -}} {{- end -}} diff --git a/freesailarr/templates/freesailarr.ingress.yaml b/freesailarr/templates/freesailarr.ingress.yaml index 0852c35..d4ddec7 100644 --- a/freesailarr/templates/freesailarr.ingress.yaml +++ b/freesailarr/templates/freesailarr.ingress.yaml @@ -1,38 +1,51 @@ -{{- $apps := list }} -{{- range $app := include "freesailarr.uiApps" . | fromJsonArray }} -{{- if (index $.Values $app.name).ingress.enabled }} -{{- $apps = append $apps $app }} -{{- end }} -{{- end }} -{{- if and .Values.ingress.enabled (gt (len $apps) 0) }} +{{/* +One Ingress object per app whose ingress is enabled, each with its own host and +its own TLS secret. The hosts are independent values - they do not have to +share a domain - and each ingress points at its own app's service. + +.ingress.host is required, but only for an app whose ingress is actually +enabled: the required call sits inside the loop over the enabled apps, so +turning an app's ingress off must never make the chart demand a host for it. +*/}} +{{- if .Values.ingress.enabled }} +{{- range $app := include "freesailarr.ingressApps" $ | fromJsonArray }} +{{- $config := (index $.Values $app.name).ingress }} +{{- /* clusterIssuer, className and annotations default to the global ingress + block and are overridable per app; an explicitly set per-app value wins + even when it is empty, hence the nil check instead of "default". */}} +{{- $clusterIssuer := $.Values.ingress.clusterIssuer }} +{{- if not (kindIs "invalid" $config.clusterIssuer) }}{{- $clusterIssuer = $config.clusterIssuer }}{{- end }} +{{- $className := $.Values.ingress.className }} +{{- if not (kindIs "invalid" $config.className) }}{{- $className = $config.className }}{{- end }} +{{- $annotations := $.Values.ingress.annotations }} +{{- if not (kindIs "invalid" $config.annotations) }}{{- $annotations = $config.annotations }}{{- end }} +{{- $host := required (printf "%s.ingress.host must be provided while %s.ingress.enabled is true" $app.name $app.name) $config.host }} +--- apiVersion: networking.k8s.io/v1 kind: Ingress metadata: - name: {{ .Release.Name }}-{{ .Chart.Name }}-ingress + name: {{ $.Release.Name }}-{{ $.Chart.Name }}-{{ $app.name }}-ingress annotations: - cert-manager.io/cluster-issuer: {{ required "A Cluster-Issuer must be provided" .Values.ingress.clusterIssuer }} - {{- with .Values.ingress.annotations }} + cert-manager.io/cluster-issuer: {{ required "A Cluster-Issuer must be provided" $clusterIssuer }} + {{- with $annotations }} {{- toYaml . | nindent 4 }} {{- end }} spec: - ingressClassName: {{ .Values.ingress.className }} + ingressClassName: {{ $className }} rules: - {{- range $apps }} - - host: {{ .name }}.{{ required "A Domain/Host must be provided" $.Values.ingress.domain }} + - host: {{ $host | quote }} http: paths: - backend: service: - name: {{ $.Release.Name }}-{{ $.Chart.Name }}-service + name: {{ $.Release.Name }}-{{ $.Chart.Name }}-{{ $app.name }}-service port: - name: {{ .name }} + name: {{ $app.name }} path: / pathType: Prefix - {{- end }} tls: - hosts: - {{- range $apps }} - - {{ .name }}.{{ $.Values.ingress.domain }} - {{- end }} - secretName: tls-{{ .Release.Name }}-{{ .Chart.Name }}-ingress + - {{ $host | quote }} + secretName: tls-{{ $.Release.Name }}-{{ $.Chart.Name }}-{{ $app.name }}-ingress +{{- end }} {{- end }} diff --git a/freesailarr/templates/freesailarr.service.yaml b/freesailarr/templates/freesailarr.service.yaml index cb0ef5c..863acc4 100644 --- a/freesailarr/templates/freesailarr.service.yaml +++ b/freesailarr/templates/freesailarr.service.yaml @@ -1,18 +1,25 @@ -{{- $apps := include "freesailarr.uiApps" . | fromJsonArray }} -{{- if gt (len $apps) 0 }} +{{/* +One ClusterIP service per enabled app container that listens on a port +(recyclarr has none in daemon mode, so it gets no service). + +All containers share gluetun's network namespace, so every one of these +services resolves to the same pod IP and they differ only in their port. Their +purpose is a stable, per-app cluster-internal DNS name - each ingress points at +its own app's service - not network separation between the apps. +*/}} +{{- range $app := include "freesailarr.portApps" $ | fromJsonArray }} +--- apiVersion: v1 kind: Service metadata: - name: {{ .Release.Name }}-{{ .Chart.Name }}-service + name: {{ $.Release.Name }}-{{ $.Chart.Name }}-{{ $app.name }}-service spec: selector: - app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs + app: {{ $.Release.Name }}-{{ $.Chart.Name }}-sfs ports: - {{- range $apps }} - protocol: TCP - port: {{ int .port }} - targetPort: {{ .name }} - name: {{ .name }} - {{- end }} + port: {{ int $app.port }} + targetPort: {{ $app.name }} + name: {{ $app.name }} type: ClusterIP {{- end }} diff --git a/freesailarr/templates/freesailarr.sfs.yaml b/freesailarr/templates/freesailarr.sfs.yaml index 3235310..9eff982 100644 --- a/freesailarr/templates/freesailarr.sfs.yaml +++ b/freesailarr/templates/freesailarr.sfs.yaml @@ -6,7 +6,11 @@ metadata: app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs spec: replicas: {{ include "freesailarr.replicas" (dict "scaleDown" .Values.scaleDown "replicas" .Values.replicas) }} - serviceName: {{ .Release.Name }}-{{ .Chart.Name }}-service + # Governing service name. Each app has its own service (see + # freesailarr.service.yaml); nothing consumes the per-pod DNS name a + # governing service would provide, so the chart deliberately creates no + # object under this name - the field only has to be set. + serviceName: {{ .Release.Name }}-{{ .Chart.Name }}-sfs selector: matchLabels: app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs @@ -200,6 +204,9 @@ spec: name: qbittorrent-config - mountPath: /downloads name: media-downloads + {{- with .Values.media.downloads.subPath }} + subPath: {{ . | quote }} + {{- end }} {{- end }} {{- if .Values.prowlarr.enabled }} - name: prowlarr @@ -293,8 +300,14 @@ spec: name: radarr-config - mountPath: /movies name: media-movies + {{- with .Values.media.movies.subPath }} + subPath: {{ . | quote }} + {{- end }} - mountPath: /downloads name: media-downloads + {{- with .Values.media.downloads.subPath }} + subPath: {{ . | quote }} + {{- end }} {{- end }} {{- if .Values.recyclarr.enabled }} - name: recyclarr @@ -357,8 +370,14 @@ spec: name: sonarr-config - mountPath: /tv name: media-tv + {{- with .Values.media.tv.subPath }} + subPath: {{ . | quote }} + {{- end }} - mountPath: /downloads name: media-downloads + {{- with .Values.media.downloads.subPath }} + subPath: {{ . | quote }} + {{- end }} {{- end }} {{- if .Values.seerr.enabled }} - name: seerr @@ -421,8 +440,14 @@ spec: name: bazarr-config - mountPath: /movies name: media-movies + {{- with .Values.media.movies.subPath }} + subPath: {{ . | quote }} + {{- end }} - mountPath: /tv name: media-tv + {{- with .Values.media.tv.subPath }} + subPath: {{ . | quote }} + {{- end }} {{- end }} restartPolicy: Always volumes: diff --git a/freesailarr/values.yaml b/freesailarr/values.yaml index 64aa8ab..d845200 100644 --- a/freesailarr/values.yaml +++ b/freesailarr/values.yaml @@ -57,20 +57,40 @@ storage: # downloads: qbittorrent, radarr, sonarr # movies: radarr, bazarr # tv: sonarr, bazarr +# +# subPath mounts a sub-directory of the claim instead of its root (null = the +# claim root, i.e. one claim per category). The same existingClaim may be used +# for all three categories with different subPaths - that is more than a +# convenience: Radarr and Sonarr import finished downloads into the library by +# *moving* them, and a move within one filesystem can be a hardlink or rename +# instead of a copy. On one volume that costs no second copy of the data and no +# copy time; across volume boundaries a hardlink is impossible, so every import +# duplicates the file and the download and the library each hold a full copy +# until the torrent is removed. Example: one claim, subPaths downloads / +# movies / tv-shows. media: downloads: existingClaim: null + subPath: null movies: existingClaim: null + subPath: null tv: existingClaim: null + subPath: null +# Ingress defaults. There is deliberately no global domain: every app carries +# its own .ingress.host, so the hosts are independent of each other and +# may live in completely different domains. clusterIssuer, className and +# annotations are the defaults for all apps and can be overridden per app in +# the .ingress block (an explicitly set per-app value always wins). ingress: + # Master switch: false suppresses every app ingress regardless of the + # per-app toggles. enabled: true clusterIssuer: null - domain: null className: nginx - # Extra annotations for the Ingress; merged with the cert-manager + # Extra annotations for the Ingress objects; merged with the cert-manager # cluster-issuer annotation the chart always sets. annotations: {} @@ -157,8 +177,19 @@ qbittorrent: image: repository: linuxserver/qbittorrent tag: 5.2.1 + # Every app gets its own Ingress object (named ---ingress) + # with its own TLS secret tls----ingress, so the apps can + # be exposed independently and under unrelated hosts. + # host is required while enabled is true and has no default - an app whose + # ingress is switched off never demands one. clusterIssuer, className and + # annotations may be set here to override the global ingress block. + # (Same block for prowlarr, radarr, sonarr, seerr and bazarr below.) ingress: enabled: true + host: null + # clusterIssuer: null + # className: null + # annotations: {} storage: size: 1Gi # Network interface qBittorrent binds its BitTorrent session to - gluetun's @@ -258,8 +289,13 @@ prowlarr: image: repository: linuxserver/prowlarr tag: 2.3.5 + # See the qbittorrent ingress comment. ingress: enabled: true + host: null + # clusterIssuer: null + # className: null + # annotations: {} storage: size: 1Gi env: [] @@ -366,8 +402,13 @@ radarr: image: repository: linuxserver/radarr tag: 6.1.1 + # See the qbittorrent ingress comment. ingress: enabled: true + host: null + # clusterIssuer: null + # className: null + # annotations: {} storage: size: 1Gi env: [] @@ -471,8 +512,13 @@ sonarr: image: repository: linuxserver/sonarr tag: 4.0.17 + # See the qbittorrent ingress comment. ingress: enabled: true + host: null + # clusterIssuer: null + # className: null + # annotations: {} storage: size: 1Gi env: [] @@ -523,8 +569,13 @@ seerr: image: repository: ghcr.io/seerr-team/seerr tag: v3.3.0 + # See the qbittorrent ingress comment. ingress: enabled: true + host: null + # clusterIssuer: null + # className: null + # annotations: {} storage: size: 1Gi env: [] @@ -574,8 +625,13 @@ bazarr: image: repository: linuxserver/bazarr tag: 1.5.6 + # See the qbittorrent ingress comment. ingress: enabled: true + host: null + # clusterIssuer: null + # className: null + # annotations: {} storage: size: 1Gi env: []