From a37588c2febd0da6284845ba8e8840af7f30239d Mon Sep 17 00:00:00 2001 From: jklein Date: Mon, 24 Aug 2026 16:06:20 +0200 Subject: [PATCH 1/2] freesailarr: schema, null-safe fallbacks, seccompProfile, NOTES.txt Retrofits the repo-wide groundwork that #59 deliberately left out to keep the reviewed scope. Chart 2.0.0+up1.0.0 - a major, because seccompProfile changes runtime behaviour; appVersion stays the freely chosen marker 1.0.0. values.schema.json (#68). The largest schema in the repo: nine app blocks with probes, resources, security contexts, env and image. additionalProperties is false on every chart-owned level; the objects that go verbatim into the pod spec stay open. It rejects a mistyped app name, a mistyped key at any depth and ingress.domain - the key #59 removed, which until now would have been accepted in silence. Null safety (#99), both halves, because they cover different cases. The defaultedDict helper catches a key WITH a chart default, where Helm's coalescing deletes the null before the schema ever sees it; the widened ["object","null"] types catch a key WITHOUT one, where the null survives into validation. Measured on the old chart: radarr.securityContext: null rendered "securityContext: null" - the whole hardening gone - and securityContext.capabilities: null dropped the capability block entirely, so the container kept every default capability instead of dropping ALL. Both render green. The fallback table reproduces THIS chart's defaults including its deviations: the five linuxserver.io containers keep runAsNonRoot unset and a writable root filesystem so their s6 init can drop to PUID/PGID, flaresolverr keeps its numerically pinned uid, gluetun keeps NET_ADMIN, and recyclarr keeps its exec probes. Restoring the "clean" standard there would put the linuxserver containers back on the image's uid 911 - the failure calibre-web-automated hit. The sync between table and values.yaml is not left to discipline: rendering with every block of the silent class explicitly set to null is byte-identical to rendering the defaults, which is how a drift shows up. seccompProfile RuntimeDefault (#84) in the pod context, so it covers the init container too. Trivy's KSV-0104 drops from ten findings to none. NOTES.txt (#43) covering what actually bites: the pod is not Ready until the tunnel is up, the two fields the 1Password item needs, the warning when firewallOutboundSubnets is unset, the immutable volumeClaimTemplates, and the measured fact that imports do not hardlink across the media categories. Also, from point 5 of the plan: checksum/config does not apply - the chart has no ConfigMap - but checksum/secret-ref does, and was missing. Helm never sees the secret's contents, yet it does see which op:// item the OnePasswordItem points at, so repointing secrets.gluetunSecretRef now restarts the pod instead of leaving the old credentials running. README: the #43 status line was stale (it still claimed only homeassistant ships NOTES.txt), and the mandatory-icon rule now records freesailarr as a documented exception - #59 dropped the icon and did not amend the rule. --- README.md | 4 +- freesailarr/Chart.yaml | 2 +- freesailarr/templates/NOTES.txt | 106 +++ freesailarr/templates/_helpers.tpl | 494 ++++++++++++++ freesailarr/templates/freesailarr.sfs.yaml | 113 ++-- freesailarr/values.schema.json | 730 +++++++++++++++++++++ freesailarr/values.yaml | 12 + 7 files changed, 1404 insertions(+), 57 deletions(-) create mode 100644 freesailarr/templates/NOTES.txt create mode 100644 freesailarr/values.schema.json diff --git a/README.md b/README.md index 2e7fc12..64e688f 100644 --- a/README.md +++ b/README.md @@ -247,8 +247,8 @@ Reference implementation: `template-chart/values.schema.json` and `template-char ### 13. Everything else -- `icon:` is mandatory in every `Chart.yaml`. +- `icon:` is mandatory in every `Chart.yaml`. **Documented exception — freesailarr**: it bundles nine independently versioned projects, so every other chart's rule (the icon is its one upstream's logo) has no answer here, and picking one of the nine would imply a "main app" the stack does not have. The field is omitted; `helm lint --strict` reports `icon is recommended` as INFO and still exits 0. - `helm lint --strict` must pass without findings. -- `NOTES.txt` for every chart is planned (#43); currently only homeassistant ships one. +- `NOTES.txt` for every chart is planned (#43); seven charts ship one so far (`apache-tika`, `cyberchef`, `freesailarr`, `gotenberg`, `homeassistant`, `patchmon`, `rss-bridge`). - CI: every installable chart has fixtures under `ci//` (`test-values.yaml`, optionally `fixtures.yaml`/`settings.env`) or a justified entry in `ci/excluded-charts.txt` — the install-test fails if both are missing. - Charts have no dependencies (`charts/` directories are empty). diff --git a/freesailarr/Chart.yaml b/freesailarr/Chart.yaml index 814a1cf..e7b5f84 100644 --- a/freesailarr/Chart.yaml +++ b/freesailarr/Chart.yaml @@ -8,7 +8,7 @@ description: A Helm chart to deploy a Wireguard-tunneled media automation stack type: application -version: 1.0.0+up1.0.0 +version: 2.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. diff --git a/freesailarr/templates/NOTES.txt b/freesailarr/templates/NOTES.txt new file mode 100644 index 0000000..e8e6976 --- /dev/null +++ b/freesailarr/templates/NOTES.txt @@ -0,0 +1,106 @@ +{{- $name := printf "%s-%s" .Release.Name .Chart.Name -}} +{{- $replicas := include "freesailarr.replicas" (dict "scaleDown" .Values.scaleDown "replicas" .Values.replicas) -}} +{{- /* The effective (null-safe) startup probe, so the budget below matches the + container even when the block has been erased (#99). */ -}} +{{- $startup := fromYaml (include "freesailarr.probeFor" (dict "root" $ "app" "gluetun" "kind" "startupProbe" "given" .Values.gluetun.startupProbe)) -}} +{{- $budget := mul (int $startup.periodSeconds) (int $startup.failureThreshold) -}} +{{- $ingressApps := include "freesailarr.ingressApps" . | fromJsonArray -}} +{{- $portApps := include "freesailarr.portApps" . | fromJsonArray -}} +freesailarr — release {{ .Release.Name }} in namespace {{ .Release.Namespace }}. +Nine containers in ONE pod, sharing gluetun's network namespace. + +NOTHING IS READY UNTIL THE TUNNEL IS UP + gluetun's readiness probe is its health server, which answers 200 only while + the Wireguard tunnel is actually working. So "pod not Ready" almost always + means "no tunnel", not "app broken". The startup budget is {{ $budget }}s before + Kubernetes starts killing the container: + + kubectl -n {{ .Release.Namespace }} logs {{ $name }}-sfs-0 -c gluetun -f + + While gluetun is down its firewall keeps blocking every app container's + egress. That is the kill switch doing its job — the apps stay up and simply + reach nothing. + +THE 1PASSWORD ITEM AND THE TWO FIELDS IT MUST CONTAIN + secrets.gluetunSecretRef -> {{ .Values.secrets.gluetunSecretRef | default "(not set)" }} + wireguard-private-key + wireguard-addresses + + A missing field does not fail the install: gluetun starts with the variable + empty and never completes a handshake. + + kubectl -n {{ .Release.Namespace }} get onepassworditem {{ $name }}-gluetun-secret +{{- if not .Values.gluetun.vpn.firewallOutboundSubnets }} + +SET gluetun.vpn.firewallOutboundSubnets — IT IS EMPTY RIGHT NOW + Without it the gluetun firewall blocks cluster-internal egress as well: + lookups against cluster DNS and seerr reaching an in-cluster Jellyfin both + fail, in a way that looks like an application bug. Set it to the cluster pod + and service CIDRs, e.g. "10.42.0.0/16,10.43.0.0/16" on k3s. +{{- end }} + +ACCESS +{{- if and .Values.ingress.enabled $ingressApps }} +{{- range $ingressApps }} +{{- $cfg := (index $.Values .name).ingress }} + {{ .name }}: https://{{ $cfg.host }} +{{- end }} + + Each app has its OWN Ingress and its own TLS secret; while cert-manager is + still issuing, the browser warns: + + kubectl -n {{ .Release.Namespace }} get certificate +{{- else }} + No Ingress is rendered{{ if not .Values.ingress.enabled }} (ingress.enabled is false){{ end }}. Reach an app through its own service: +{{- range $portApps }} + + kubectl -n {{ $.Release.Namespace }} port-forward svc/{{ $name }}-{{ .name }}-service {{ .port }}:{{ .port }} +{{- end }} +{{- end }} +{{- if .Values.qbittorrent.enabled }} + + qBittorrent's WebUI rejects a Host header whose port differs from its own + ({{ "8080" }}) with a plain 401. A port-forward on a different local port therefore + looks like a login failure — forward 8080:8080, or go through the ingress. +{{- end }} + +EVERY SERVICE POINTS AT THE SAME POD IP + One ClusterIP service per app{{ if .Values.recyclarr.enabled }} (recyclarr has none — no port in daemon mode){{ end }}, but all + containers share gluetun's network namespace, so the services differ only in + their port. They give you stable per-app DNS names, NOT network separation + between the apps. + +TOGGLING AN APP OFF AND ON LATER NEEDS ONE MANUAL STEP + The config volumes come from volumeClaimTemplates, and those are immutable. + Changing any .enabled after the first install makes the upgrade fail + until the StatefulSet is recreated — the data survives: + + kubectl -n {{ .Release.Namespace }} delete statefulset {{ $name }}-sfs --cascade=orphan + +THE MEDIA CLAIMS ARE YOURS, THE CONFIG VOLUMES ARE THE CHART'S + media..existingClaim is mounted, never created, and is meant to be + shared with your Jellyfin. One claim can serve all three categories via + media..subPath. + + Measured, so you do not plan around something that does not happen: Radarr + and Sonarr do NOT hardlink between the categories, not even on one claim. + Kubernetes makes every volumeMount its own bind mount and link() across two + mount points fails with EXDEV regardless of subPath, so an import copies. + + The containers write as {{ .Values.puid }}:{{ .Values.pgid }}. If the claims already hold data + owned by someone else, fix the ownership before the first start — the pod + will not do it for you (fsGroupChangePolicy is OnRootMismatch, deliberately, + so a large library is not recursively re-chowned on every start). +{{- if eq $replicas "0" }} + +SCALED TO ZERO + {{ if .Values.scaleDown }}scaleDown: true{{ else }}replicas: 0{{ end }} — the StatefulSet runs with replicas: 0. PVCs, secrets, + services{{ if .Values.ingress.enabled }} and ingresses{{ end }} stay in place; set it back and upgrade to bring the + stack up again. +{{- else if ne $replicas "1" }} + +MORE THAN ONE REPLICA IS NOT MEANINGFUL + replicas is {{ $replicas }}. Each replica would run its own isolated stack on its own + config volumes, all of them fighting over the same media claims and the same + VPN credentials. +{{- end }} diff --git a/freesailarr/templates/_helpers.tpl b/freesailarr/templates/_helpers.tpl index 64e7a41..47e236b 100644 --- a/freesailarr/templates/_helpers.tpl +++ b/freesailarr/templates/_helpers.tpl @@ -218,3 +218,497 @@ alive. It is not what opens the ports today. {{- $ports = append $ports 9999 -}} {{- join "," $ports -}} {{- end -}} + +{{/* +Recursive merge of an override onto defaults where null never deletes and set +values always win, including false and 0 (issue #99). + +Input: dict "defaults" "given" "path" . + +NOTE: like the resources helper above, this is duplicated into every chart's +templates/_helpers.tpl with the chart's name as define prefix. The reference +implementation lives in template-chart. +*/}} +{{- define "freesailarr.defaultedDict" -}} +{{- $defaults := .defaults | default dict -}} +{{- $given := .given -}} +{{- $path := .path | default "value" -}} +{{- if kindIs "invalid" $given -}} +{{- $given = dict -}} +{{- end -}} +{{- if not (kindIs "map" $given) -}} +{{- fail (printf "%s must be a mapping or null, got %s (%v)" $path (kindOf $given) $given) -}} +{{- end -}} +{{- $out := deepCopy $defaults -}} +{{- range $key, $value := $given -}} +{{- if not (kindIs "invalid" $value) -}} +{{- $default := index $defaults $key -}} +{{- if and (kindIs "map" $value) (kindIs "map" $default) -}} +{{- $_ := set $out $key (fromYaml (include "freesailarr.defaultedDict" (dict "defaults" $default "given" $value "path" (printf "%s.%s" $path $key)))) -}} +{{- else if kindIs "map" $default -}} +{{- fail (printf "%s.%s must be a mapping or null, got %s (%v)" $path $key (kindOf $value) $value) -}} +{{- else -}} +{{- $_ := set $out $key $value -}} +{{- end -}} +{{- end -}} +{{- end -}} +{{- toYaml $out -}} +{{- end -}} + +{{/* +Pick one sub-block out of a values block that may itself be null. +Input: dict "block" "key" "path" . +Needed because ".Values..securityContext" can itself be erased, and +indexing into nil inside a template is not an error but silently yields +nothing. +*/}} +{{- define "freesailarr.subBlock" -}} +{{- $block := .block -}} +{{- if kindIs "invalid" $block -}} +{{- $block = dict -}} +{{- end -}} +{{- if not (kindIs "map" $block) -}} +{{- fail (printf "%s must be a mapping or null, got %s (%v)" .path (kindOf $block) $block) -}} +{{- end -}} +{{- toYaml (dict "value" (index $block .key)) -}} +{{- end -}} + +{{/* +The chart's own defaults for every block of the "silent class" from issue #99 - +security contexts, probes and resources. An override that erases one of these +blocks (`key:` with no children, i.e. null) would otherwise render a container +without hardening, without a probe or without resource accounting: it comes up, +reports green and is quietly weaker. The accessors below merge the user's block +onto these defaults with freesailarr.defaultedDict, where null never deletes. + +These MUST stay in sync with values.yaml, which remains the documented, +user-facing place for them; this copy is only the fallback. The sync is not left +to discipline: rendering the chart with every one of these blocks explicitly set +to null must produce byte-identical output to rendering it with the defaults, +which is how the table was verified and how a drift shows up. + +Two rules this table exists to honour (README, "Chart style guidelines"): + + - It reproduces THIS chart's defaults, deviations included, not the repo + standard. The five linuxserver.io containers deliberately run without + runAsNonRoot and with a writable root filesystem, because their s6-overlay + init starts as root and drops to PUID/PGID; "restoring" the clean standard + here would leave them on the image's built-in uid 911 and make the existing + media volumes inaccessible - the exact failure calibre-web-automated hit. + flaresolverr's numerically pinned uid/gid 1000 and gluetun's NET_ADMIN are + reproduced for the same reason. + - The probe defaults carry their CONTEXT, not just timings: every httpGet + keeps its path and port, and recyclarr - which serves no HTTP port in + daemon mode - keeps its exec command. A probe default without them is worse + than no default. +*/}} +{{- define "freesailarr.fallbacks" -}} +gluetun: + livenessProbe: + httpGet: + path: / + port: 9999 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 6 + readinessProbe: + httpGet: + path: / + port: 9999 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + startupProbe: + httpGet: + path: / + port: 9999 + periodSeconds: 5 + timeoutSeconds: 5 + failureThreshold: 30 + resources: + limitFactor: 3 + requests: + cpu: 0.1 + memory: 128Mi + ephemeralStorage: 50Mi + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: false + capabilities: + drop: + - ALL + add: + - NET_ADMIN +qbittorrent: + livenessProbe: + httpGet: + path: / + port: 8080 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + readinessProbe: + httpGet: + path: / + port: 8080 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + startupProbe: + httpGet: + path: / + port: 8080 + periodSeconds: 5 + timeoutSeconds: 5 + failureThreshold: 30 + resources: + limitFactor: 3 + requests: + cpu: 0.2 + memory: 512Mi + ephemeralStorage: 100Mi + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: false + capabilities: + drop: + - ALL + add: + - CHOWN + - DAC_OVERRIDE + - FOWNER + - SETGID + - SETUID +prowlarr: + livenessProbe: + httpGet: + path: /ping + port: 9696 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /ping + port: 9696 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + startupProbe: + httpGet: + path: /ping + port: 9696 + periodSeconds: 5 + timeoutSeconds: 5 + failureThreshold: 30 + resources: + limitFactor: 3 + requests: + cpu: 0.1 + memory: 256Mi + ephemeralStorage: 100Mi + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: false + capabilities: + drop: + - ALL + add: + - CHOWN + - DAC_OVERRIDE + - FOWNER + - SETGID + - SETUID +flaresolverr: + livenessProbe: + httpGet: + path: /health + port: 8191 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /health + port: 8191 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + startupProbe: + httpGet: + path: /health + port: 8191 + periodSeconds: 5 + timeoutSeconds: 5 + failureThreshold: 30 + resources: + limitFactor: 3 + requests: + cpu: 0.2 + memory: 512Mi + ephemeralStorage: 200Mi + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: false + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 + capabilities: + drop: + - ALL +radarr: + livenessProbe: + httpGet: + path: /ping + port: 7878 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /ping + port: 7878 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + startupProbe: + httpGet: + path: /ping + port: 7878 + periodSeconds: 5 + timeoutSeconds: 5 + failureThreshold: 30 + resources: + limitFactor: 3 + requests: + cpu: 0.1 + memory: 256Mi + ephemeralStorage: 100Mi + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: false + capabilities: + drop: + - ALL + add: + - CHOWN + - DAC_OVERRIDE + - FOWNER + - SETGID + - SETUID +recyclarr: + livenessProbe: + exec: + command: + - recyclarr + - --version + periodSeconds: 30 + timeoutSeconds: 10 + failureThreshold: 3 + readinessProbe: + exec: + command: + - recyclarr + - --version + periodSeconds: 30 + timeoutSeconds: 10 + failureThreshold: 3 + startupProbe: + exec: + command: + - recyclarr + - --version + periodSeconds: 5 + timeoutSeconds: 10 + failureThreshold: 30 + resources: + limitFactor: 3 + requests: + cpu: 0.05 + memory: 128Mi + ephemeralStorage: 50Mi + securityContext: + runAsUser: {{ .Values.puid }} + runAsGroup: {{ .Values.pgid }} + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + runAsNonRoot: true + capabilities: + drop: + - ALL +sonarr: + livenessProbe: + httpGet: + path: /ping + port: 8989 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /ping + port: 8989 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + startupProbe: + httpGet: + path: /ping + port: 8989 + periodSeconds: 5 + timeoutSeconds: 5 + failureThreshold: 30 + resources: + limitFactor: 3 + requests: + cpu: 0.1 + memory: 256Mi + ephemeralStorage: 100Mi + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: false + capabilities: + drop: + - ALL + add: + - CHOWN + - DAC_OVERRIDE + - FOWNER + - SETGID + - SETUID +seerr: + livenessProbe: + httpGet: + path: /api/v1/settings/public + port: 5055 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /api/v1/settings/public + port: 5055 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + startupProbe: + httpGet: + path: /api/v1/settings/public + port: 5055 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 30 + resources: + limitFactor: 3 + requests: + cpu: 0.1 + memory: 256Mi + ephemeralStorage: 100Mi + securityContext: + runAsUser: {{ .Values.puid }} + runAsGroup: {{ .Values.pgid }} + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + runAsNonRoot: true + capabilities: + drop: + - ALL +bazarr: + livenessProbe: + httpGet: + path: / + port: 6767 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + readinessProbe: + httpGet: + path: / + port: 6767 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + startupProbe: + httpGet: + path: / + port: 6767 + periodSeconds: 5 + timeoutSeconds: 5 + failureThreshold: 30 + resources: + limitFactor: 3 + requests: + cpu: 0.1 + memory: 256Mi + ephemeralStorage: 100Mi + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: false + capabilities: + drop: + - ALL + add: + - CHOWN + - DAC_OVERRIDE + - FOWNER + - SETGID + - SETUID +configInit: + resources: + limitFactor: 3 + requests: + cpu: 10m + memory: 16Mi + ephemeralStorage: 1Mi + securityContext: + runAsUser: {{ .Values.puid }} + runAsGroup: {{ .Values.pgid }} + runAsNonRoot: true + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: + - ALL +{{- end -}} + +{{/* +The effective pod security context. Separate from the per-container table +because it is the one block shared by all nine containers. +*/}} +{{- define "freesailarr.podSecurityContext" -}} +{{- $given := (fromYaml (include "freesailarr.subBlock" (dict "block" .Values.securityContext "key" "pod" "path" "securityContext"))).value -}} +{{- $defaults := dict + "fsGroup" 1000 + "fsGroupChangePolicy" "OnRootMismatch" + "seccompProfile" (dict "type" "RuntimeDefault") -}} +{{- include "freesailarr.defaultedDict" (dict "defaults" $defaults "given" $given "path" "securityContext.pod") -}} +{{- end -}} + +{{/* +The effective probe / security context / resources for one container. + +Input: dict "root" $ "app" "given" + and, for probes, "kind" . + +"given" is passed in rather than derived from "app" because the qbittorrent init +container's blocks live under qbittorrent.configInit, not under a top-level key +of the same name. +*/}} +{{- define "freesailarr.probeFor" -}} +{{- $fb := fromYaml (include "freesailarr.fallbacks" .root) -}} +{{- $defaults := index $fb .app .kind -}} +{{- include "freesailarr.defaultedDict" (dict "defaults" $defaults "given" .given "path" (printf "%s.%s" .app .kind)) -}} +{{- end -}} + +{{- define "freesailarr.securityContextFor" -}} +{{- $fb := fromYaml (include "freesailarr.fallbacks" .root) -}} +{{- $defaults := index $fb .app "securityContext" -}} +{{- include "freesailarr.defaultedDict" (dict "defaults" $defaults "given" .given "path" (printf "%s.securityContext" .app)) -}} +{{- end -}} + +{{- define "freesailarr.resourcesFor" -}} +{{- $fb := fromYaml (include "freesailarr.fallbacks" .root) -}} +{{- $defaults := index $fb .app "resources" -}} +{{- $merged := fromYaml (include "freesailarr.defaultedDict" (dict "defaults" $defaults "given" .given "path" (printf "%s.resources" .app))) -}} +{{- include "freesailarr.resources" $merged -}} +{{- end -}} diff --git a/freesailarr/templates/freesailarr.sfs.yaml b/freesailarr/templates/freesailarr.sfs.yaml index 4a45414..d896f3c 100644 --- a/freesailarr/templates/freesailarr.sfs.yaml +++ b/freesailarr/templates/freesailarr.sfs.yaml @@ -17,12 +17,23 @@ spec: template: metadata: name: {{ .Release.Name }}-{{ .Chart.Name }}-sfs + annotations: + # There is no ConfigMap in this chart, so checksum/config does not + # apply. What does apply is the op:// path: Helm never sees the secret's + # contents - the 1Password operator fills the Secret in the cluster - + # but it does see which item the OnePasswordItem points at. Repointing + # secrets.gluetunSecretRef at a different vault item therefore takes + # effect instead of silently leaving the old credentials in the running + # pod. The name differs from checksum/config on purpose: that one + # promises "the config changed", this one only "the config source + # changed" (#82). + checksum/secret-ref: {{ include (print $.Template.BasePath "/freesailarr-gluetun.secret.yaml") . | sha256sum }} labels: app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs spec: automountServiceAccountToken: false securityContext: - {{- toYaml .Values.securityContext.pod | nindent 8 }} + {{- include "freesailarr.podSecurityContext" . | nindent 8 }} {{- if and .Values.qbittorrent.enabled .Values.qbittorrent.networkInterface }} initContainers: # qBittorrent has no environment variable for the session's network @@ -71,11 +82,9 @@ spec: - name: QBT_INTERFACE value: {{ .Values.qbittorrent.networkInterface | quote }} resources: - {{- include "freesailarr.resources" .Values.qbittorrent.configInit.resources | nindent 12 }} + {{- include "freesailarr.resourcesFor" (dict "root" $ "app" "configInit" "given" .Values.qbittorrent.configInit.resources) | nindent 12 }} securityContext: - runAsUser: {{ .Values.puid }} - runAsGroup: {{ .Values.pgid }} - {{- toYaml .Values.qbittorrent.configInit.securityContext | nindent 12 }} + {{- include "freesailarr.securityContextFor" (dict "root" $ "app" "configInit" "given" .Values.qbittorrent.configInit.securityContext) | nindent 12 }} volumeMounts: - mountPath: /config name: qbittorrent-config @@ -151,15 +160,15 @@ spec: protocol: TCP name: health livenessProbe: - {{- toYaml .Values.gluetun.livenessProbe | nindent 12 }} + {{- include "freesailarr.probeFor" (dict "root" $ "app" "gluetun" "kind" "livenessProbe" "given" .Values.gluetun.livenessProbe) | nindent 12 }} readinessProbe: - {{- toYaml .Values.gluetun.readinessProbe | nindent 12 }} + {{- include "freesailarr.probeFor" (dict "root" $ "app" "gluetun" "kind" "readinessProbe" "given" .Values.gluetun.readinessProbe) | nindent 12 }} startupProbe: - {{- toYaml .Values.gluetun.startupProbe | nindent 12 }} + {{- include "freesailarr.probeFor" (dict "root" $ "app" "gluetun" "kind" "startupProbe" "given" .Values.gluetun.startupProbe) | nindent 12 }} resources: - {{- include "freesailarr.resources" .Values.gluetun.resources | nindent 12 }} + {{- include "freesailarr.resourcesFor" (dict "root" $ "app" "gluetun" "given" .Values.gluetun.resources) | nindent 12 }} securityContext: - {{- toYaml .Values.gluetun.securityContext | nindent 12 }} + {{- include "freesailarr.securityContextFor" (dict "root" $ "app" "gluetun" "given" .Values.gluetun.securityContext) | nindent 12 }} volumeMounts: - mountPath: /gluetun name: gluetun-config @@ -201,15 +210,15 @@ spec: protocol: TCP name: qbittorrent livenessProbe: - {{- toYaml .Values.qbittorrent.livenessProbe | nindent 12 }} + {{- include "freesailarr.probeFor" (dict "root" $ "app" "qbittorrent" "kind" "livenessProbe" "given" .Values.qbittorrent.livenessProbe) | nindent 12 }} readinessProbe: - {{- toYaml .Values.qbittorrent.readinessProbe | nindent 12 }} + {{- include "freesailarr.probeFor" (dict "root" $ "app" "qbittorrent" "kind" "readinessProbe" "given" .Values.qbittorrent.readinessProbe) | nindent 12 }} startupProbe: - {{- toYaml .Values.qbittorrent.startupProbe | nindent 12 }} + {{- include "freesailarr.probeFor" (dict "root" $ "app" "qbittorrent" "kind" "startupProbe" "given" .Values.qbittorrent.startupProbe) | nindent 12 }} resources: - {{- include "freesailarr.resources" .Values.qbittorrent.resources | nindent 12 }} + {{- include "freesailarr.resourcesFor" (dict "root" $ "app" "qbittorrent" "given" .Values.qbittorrent.resources) | nindent 12 }} securityContext: - {{- toYaml .Values.qbittorrent.securityContext | nindent 12 }} + {{- include "freesailarr.securityContextFor" (dict "root" $ "app" "qbittorrent" "given" .Values.qbittorrent.securityContext) | nindent 12 }} volumeMounts: - mountPath: /config name: qbittorrent-config @@ -236,15 +245,15 @@ spec: protocol: TCP name: prowlarr livenessProbe: - {{- toYaml .Values.prowlarr.livenessProbe | nindent 12 }} + {{- include "freesailarr.probeFor" (dict "root" $ "app" "prowlarr" "kind" "livenessProbe" "given" .Values.prowlarr.livenessProbe) | nindent 12 }} readinessProbe: - {{- toYaml .Values.prowlarr.readinessProbe | nindent 12 }} + {{- include "freesailarr.probeFor" (dict "root" $ "app" "prowlarr" "kind" "readinessProbe" "given" .Values.prowlarr.readinessProbe) | nindent 12 }} startupProbe: - {{- toYaml .Values.prowlarr.startupProbe | nindent 12 }} + {{- include "freesailarr.probeFor" (dict "root" $ "app" "prowlarr" "kind" "startupProbe" "given" .Values.prowlarr.startupProbe) | nindent 12 }} resources: - {{- include "freesailarr.resources" .Values.prowlarr.resources | nindent 12 }} + {{- include "freesailarr.resourcesFor" (dict "root" $ "app" "prowlarr" "given" .Values.prowlarr.resources) | nindent 12 }} securityContext: - {{- toYaml .Values.prowlarr.securityContext | nindent 12 }} + {{- include "freesailarr.securityContextFor" (dict "root" $ "app" "prowlarr" "given" .Values.prowlarr.securityContext) | nindent 12 }} volumeMounts: - mountPath: /config name: prowlarr-config @@ -262,15 +271,15 @@ spec: protocol: TCP name: flaresolverr livenessProbe: - {{- toYaml .Values.flaresolverr.livenessProbe | nindent 12 }} + {{- include "freesailarr.probeFor" (dict "root" $ "app" "flaresolverr" "kind" "livenessProbe" "given" .Values.flaresolverr.livenessProbe) | nindent 12 }} readinessProbe: - {{- toYaml .Values.flaresolverr.readinessProbe | nindent 12 }} + {{- include "freesailarr.probeFor" (dict "root" $ "app" "flaresolverr" "kind" "readinessProbe" "given" .Values.flaresolverr.readinessProbe) | nindent 12 }} startupProbe: - {{- toYaml .Values.flaresolverr.startupProbe | nindent 12 }} + {{- include "freesailarr.probeFor" (dict "root" $ "app" "flaresolverr" "kind" "startupProbe" "given" .Values.flaresolverr.startupProbe) | nindent 12 }} resources: - {{- include "freesailarr.resources" .Values.flaresolverr.resources | nindent 12 }} + {{- include "freesailarr.resourcesFor" (dict "root" $ "app" "flaresolverr" "given" .Values.flaresolverr.resources) | nindent 12 }} securityContext: - {{- toYaml .Values.flaresolverr.securityContext | nindent 12 }} + {{- include "freesailarr.securityContextFor" (dict "root" $ "app" "flaresolverr" "given" .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 @@ -297,15 +306,15 @@ spec: protocol: TCP name: radarr livenessProbe: - {{- toYaml .Values.radarr.livenessProbe | nindent 12 }} + {{- include "freesailarr.probeFor" (dict "root" $ "app" "radarr" "kind" "livenessProbe" "given" .Values.radarr.livenessProbe) | nindent 12 }} readinessProbe: - {{- toYaml .Values.radarr.readinessProbe | nindent 12 }} + {{- include "freesailarr.probeFor" (dict "root" $ "app" "radarr" "kind" "readinessProbe" "given" .Values.radarr.readinessProbe) | nindent 12 }} startupProbe: - {{- toYaml .Values.radarr.startupProbe | nindent 12 }} + {{- include "freesailarr.probeFor" (dict "root" $ "app" "radarr" "kind" "startupProbe" "given" .Values.radarr.startupProbe) | nindent 12 }} resources: - {{- include "freesailarr.resources" .Values.radarr.resources | nindent 12 }} + {{- include "freesailarr.resourcesFor" (dict "root" $ "app" "radarr" "given" .Values.radarr.resources) | nindent 12 }} securityContext: - {{- toYaml .Values.radarr.securityContext | nindent 12 }} + {{- include "freesailarr.securityContextFor" (dict "root" $ "app" "radarr" "given" .Values.radarr.securityContext) | nindent 12 }} volumeMounts: - mountPath: /config name: radarr-config @@ -331,17 +340,15 @@ spec: value: "true" {{- include "freesailarr.env" (dict "root" $ "env" .Values.recyclarr.env) | nindent 12 }} livenessProbe: - {{- toYaml .Values.recyclarr.livenessProbe | nindent 12 }} + {{- include "freesailarr.probeFor" (dict "root" $ "app" "recyclarr" "kind" "livenessProbe" "given" .Values.recyclarr.livenessProbe) | nindent 12 }} readinessProbe: - {{- toYaml .Values.recyclarr.readinessProbe | nindent 12 }} + {{- include "freesailarr.probeFor" (dict "root" $ "app" "recyclarr" "kind" "readinessProbe" "given" .Values.recyclarr.readinessProbe) | nindent 12 }} startupProbe: - {{- toYaml .Values.recyclarr.startupProbe | nindent 12 }} + {{- include "freesailarr.probeFor" (dict "root" $ "app" "recyclarr" "kind" "startupProbe" "given" .Values.recyclarr.startupProbe) | nindent 12 }} resources: - {{- include "freesailarr.resources" .Values.recyclarr.resources | nindent 12 }} + {{- include "freesailarr.resourcesFor" (dict "root" $ "app" "recyclarr" "given" .Values.recyclarr.resources) | nindent 12 }} securityContext: - runAsUser: {{ .Values.puid }} - runAsGroup: {{ .Values.pgid }} - {{- toYaml .Values.recyclarr.securityContext | nindent 12 }} + {{- include "freesailarr.securityContextFor" (dict "root" $ "app" "recyclarr" "given" .Values.recyclarr.securityContext) | nindent 12 }} volumeMounts: - mountPath: /config name: recyclarr-config @@ -367,15 +374,15 @@ spec: protocol: TCP name: sonarr livenessProbe: - {{- toYaml .Values.sonarr.livenessProbe | nindent 12 }} + {{- include "freesailarr.probeFor" (dict "root" $ "app" "sonarr" "kind" "livenessProbe" "given" .Values.sonarr.livenessProbe) | nindent 12 }} readinessProbe: - {{- toYaml .Values.sonarr.readinessProbe | nindent 12 }} + {{- include "freesailarr.probeFor" (dict "root" $ "app" "sonarr" "kind" "readinessProbe" "given" .Values.sonarr.readinessProbe) | nindent 12 }} startupProbe: - {{- toYaml .Values.sonarr.startupProbe | nindent 12 }} + {{- include "freesailarr.probeFor" (dict "root" $ "app" "sonarr" "kind" "startupProbe" "given" .Values.sonarr.startupProbe) | nindent 12 }} resources: - {{- include "freesailarr.resources" .Values.sonarr.resources | nindent 12 }} + {{- include "freesailarr.resourcesFor" (dict "root" $ "app" "sonarr" "given" .Values.sonarr.resources) | nindent 12 }} securityContext: - {{- toYaml .Values.sonarr.securityContext | nindent 12 }} + {{- include "freesailarr.securityContextFor" (dict "root" $ "app" "sonarr" "given" .Values.sonarr.securityContext) | nindent 12 }} volumeMounts: - mountPath: /config name: sonarr-config @@ -403,17 +410,15 @@ spec: protocol: TCP name: seerr livenessProbe: - {{- toYaml .Values.seerr.livenessProbe | nindent 12 }} + {{- include "freesailarr.probeFor" (dict "root" $ "app" "seerr" "kind" "livenessProbe" "given" .Values.seerr.livenessProbe) | nindent 12 }} readinessProbe: - {{- toYaml .Values.seerr.readinessProbe | nindent 12 }} + {{- include "freesailarr.probeFor" (dict "root" $ "app" "seerr" "kind" "readinessProbe" "given" .Values.seerr.readinessProbe) | nindent 12 }} startupProbe: - {{- toYaml .Values.seerr.startupProbe | nindent 12 }} + {{- include "freesailarr.probeFor" (dict "root" $ "app" "seerr" "kind" "startupProbe" "given" .Values.seerr.startupProbe) | nindent 12 }} resources: - {{- include "freesailarr.resources" .Values.seerr.resources | nindent 12 }} + {{- include "freesailarr.resourcesFor" (dict "root" $ "app" "seerr" "given" .Values.seerr.resources) | nindent 12 }} securityContext: - runAsUser: {{ .Values.puid }} - runAsGroup: {{ .Values.pgid }} - {{- toYaml .Values.seerr.securityContext | nindent 12 }} + {{- include "freesailarr.securityContextFor" (dict "root" $ "app" "seerr" "given" .Values.seerr.securityContext) | nindent 12 }} volumeMounts: - mountPath: /app/config name: seerr-config @@ -437,15 +442,15 @@ spec: protocol: TCP name: bazarr livenessProbe: - {{- toYaml .Values.bazarr.livenessProbe | nindent 12 }} + {{- include "freesailarr.probeFor" (dict "root" $ "app" "bazarr" "kind" "livenessProbe" "given" .Values.bazarr.livenessProbe) | nindent 12 }} readinessProbe: - {{- toYaml .Values.bazarr.readinessProbe | nindent 12 }} + {{- include "freesailarr.probeFor" (dict "root" $ "app" "bazarr" "kind" "readinessProbe" "given" .Values.bazarr.readinessProbe) | nindent 12 }} startupProbe: - {{- toYaml .Values.bazarr.startupProbe | nindent 12 }} + {{- include "freesailarr.probeFor" (dict "root" $ "app" "bazarr" "kind" "startupProbe" "given" .Values.bazarr.startupProbe) | nindent 12 }} resources: - {{- include "freesailarr.resources" .Values.bazarr.resources | nindent 12 }} + {{- include "freesailarr.resourcesFor" (dict "root" $ "app" "bazarr" "given" .Values.bazarr.resources) | nindent 12 }} securityContext: - {{- toYaml .Values.bazarr.securityContext | nindent 12 }} + {{- include "freesailarr.securityContextFor" (dict "root" $ "app" "bazarr" "given" .Values.bazarr.securityContext) | nindent 12 }} volumeMounts: - mountPath: /config name: bazarr-config diff --git a/freesailarr/values.schema.json b/freesailarr/values.schema.json new file mode 100644 index 0000000..1a73c9d --- /dev/null +++ b/freesailarr/values.schema.json @@ -0,0 +1,730 @@ +{ + "$schema": "https://json-schema.org/draft-07/schema#", + "title": "freesailarr values", + "description": "Values schema (issue #68). additionalProperties is false on every chart-owned object level, so a key the chart does not read is rejected at render time instead of being silently ignored. The objects that go verbatim into the pod spec - probes, security contexts, free-form annotations, global - stay open, because the chart does not interpret their contents. The hardening, probe and resources blocks additionally accept null: an override may erase them, and the chart then falls back to its own defaults (issue #99) rather than rendering a container without hardening, without a probe or without resource accounting.", + "type": "object", + "additionalProperties": false, + "properties": { + "global": { + "description": "Helm's conventional cross-chart values. Unused by this chart, kept open so an umbrella install cannot break on it.", + "type": "object" + }, + "scaleDown": { + "type": "boolean" + }, + "replicas": { + "type": "integer", + "minimum": 0 + }, + "secrets": { + "type": "object", + "additionalProperties": false, + "properties": { + "gluetunSecretRef": { + "description": "op:// item path for the gluetun VPN credentials. The item must carry the fields wireguard-private-key and wireguard-addresses; the template reads exactly those names.", + "type": [ + "string", + "null" + ] + } + } + }, + "puid": { + "description": "User the linuxserver.io containers drop to after their s6-overlay init, and runAsUser for seerr and recyclarr.", + "type": "integer" + }, + "pgid": { + "type": "integer" + }, + "timezone": { + "type": [ + "string", + "null" + ] + }, + "securityContext": { + "$ref": "#/definitions/podSecurityContextBlock" + }, + "storage": { + "description": "StorageClass for the config volumeClaimTemplates. Deliberately NOT null-safe: an erased block yields an invalid claim template, which fails loudly, and issue #99 covers only the silent class.", + "type": "object", + "additionalProperties": false, + "properties": { + "storageClass": { + "type": [ + "string", + "null" + ] + } + } + }, + "media": { + "type": "object", + "additionalProperties": false, + "properties": { + "downloads": { + "type": "object", + "additionalProperties": false, + "properties": { + "existingClaim": { + "type": [ + "string", + "null" + ] + }, + "subPath": { + "description": "Sub-directory of the claim to mount instead of its root. null means the claim root.", + "type": [ + "string", + "null" + ] + } + } + }, + "movies": { + "type": "object", + "additionalProperties": false, + "properties": { + "existingClaim": { + "type": [ + "string", + "null" + ] + }, + "subPath": { + "description": "Sub-directory of the claim to mount instead of its root. null means the claim root.", + "type": [ + "string", + "null" + ] + } + } + }, + "tv": { + "type": "object", + "additionalProperties": false, + "properties": { + "existingClaim": { + "type": [ + "string", + "null" + ] + }, + "subPath": { + "description": "Sub-directory of the claim to mount instead of its root. null means the claim root.", + "type": [ + "string", + "null" + ] + } + } + } + } + }, + "ingress": { + "description": "Defaults for the per-app ingresses. There is deliberately no global domain - each app carries its own host.", + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { + "description": "Master switch; false suppresses every app ingress regardless of the per-app toggles.", + "type": "boolean" + }, + "clusterIssuer": { + "type": [ + "string", + "null" + ] + }, + "className": { + "type": [ + "string", + "null" + ] + }, + "annotations": { + "description": "Extra Ingress annotations, merged with the cert-manager annotation the chart always sets. Free-form keys by nature, so this object stays open.", + "type": "object" + } + } + }, + "gluetun": { + "type": "object", + "additionalProperties": false, + "properties": { + "image": { + "$ref": "#/definitions/image" + }, + "vpn": { + "type": "object", + "additionalProperties": false, + "properties": { + "serviceProvider": { + "type": [ + "string", + "null" + ] + }, + "type": { + "type": [ + "string", + "null" + ] + }, + "serverCities": { + "type": [ + "string", + "null" + ] + }, + "serverCountries": { + "type": [ + "string", + "null" + ] + }, + "dnsUpstreamResolvers": { + "type": [ + "string", + "null" + ] + }, + "firewallOutboundSubnets": { + "description": "Comma-separated CIDRs the gluetun firewall allows outbound outside the tunnel. Set to the cluster pod and service CIDRs.", + "type": [ + "string", + "null" + ] + } + } + }, + "storage": { + "$ref": "#/definitions/appStorage" + }, + "env": { + "$ref": "#/definitions/env" + }, + "livenessProbe": { + "$ref": "#/definitions/probe" + }, + "readinessProbe": { + "$ref": "#/definitions/probe" + }, + "startupProbe": { + "$ref": "#/definitions/probe" + }, + "resources": { + "$ref": "#/definitions/resources" + }, + "securityContext": { + "$ref": "#/definitions/containerSecurityContext" + } + } + }, + "qbittorrent": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean" + }, + "image": { + "$ref": "#/definitions/image" + }, + "ingress": { + "$ref": "#/definitions/appIngress" + }, + "storage": { + "$ref": "#/definitions/appStorage" + }, + "networkInterface": { + "description": "Interface the BitTorrent session binds to (gluetun's tun device). null or \"\" removes the init container that seeds it and leaves the binding unset - which lets libtorrent announce past the tunnel.", + "type": [ + "string", + "null" + ] + }, + "configInit": { + "type": "object", + "additionalProperties": false, + "properties": { + "image": { + "$ref": "#/definitions/image" + }, + "resources": { + "$ref": "#/definitions/resources" + }, + "securityContext": { + "$ref": "#/definitions/containerSecurityContext" + } + } + }, + "env": { + "$ref": "#/definitions/env" + }, + "livenessProbe": { + "$ref": "#/definitions/probe" + }, + "readinessProbe": { + "$ref": "#/definitions/probe" + }, + "startupProbe": { + "$ref": "#/definitions/probe" + }, + "resources": { + "$ref": "#/definitions/resources" + }, + "securityContext": { + "$ref": "#/definitions/containerSecurityContext" + } + } + }, + "prowlarr": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean" + }, + "image": { + "$ref": "#/definitions/image" + }, + "ingress": { + "$ref": "#/definitions/appIngress" + }, + "storage": { + "$ref": "#/definitions/appStorage" + }, + "env": { + "$ref": "#/definitions/env" + }, + "livenessProbe": { + "$ref": "#/definitions/probe" + }, + "readinessProbe": { + "$ref": "#/definitions/probe" + }, + "startupProbe": { + "$ref": "#/definitions/probe" + }, + "resources": { + "$ref": "#/definitions/resources" + }, + "securityContext": { + "$ref": "#/definitions/containerSecurityContext" + } + } + }, + "flaresolverr": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean" + }, + "image": { + "$ref": "#/definitions/image" + }, + "env": { + "$ref": "#/definitions/env" + }, + "livenessProbe": { + "$ref": "#/definitions/probe" + }, + "readinessProbe": { + "$ref": "#/definitions/probe" + }, + "startupProbe": { + "$ref": "#/definitions/probe" + }, + "resources": { + "$ref": "#/definitions/resources" + }, + "securityContext": { + "$ref": "#/definitions/containerSecurityContext" + } + } + }, + "radarr": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean" + }, + "image": { + "$ref": "#/definitions/image" + }, + "ingress": { + "$ref": "#/definitions/appIngress" + }, + "storage": { + "$ref": "#/definitions/appStorage" + }, + "env": { + "$ref": "#/definitions/env" + }, + "livenessProbe": { + "$ref": "#/definitions/probe" + }, + "readinessProbe": { + "$ref": "#/definitions/probe" + }, + "startupProbe": { + "$ref": "#/definitions/probe" + }, + "resources": { + "$ref": "#/definitions/resources" + }, + "securityContext": { + "$ref": "#/definitions/containerSecurityContext" + } + } + }, + "recyclarr": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean" + }, + "image": { + "$ref": "#/definitions/image" + }, + "storage": { + "$ref": "#/definitions/appStorage" + }, + "env": { + "$ref": "#/definitions/env" + }, + "livenessProbe": { + "$ref": "#/definitions/probe" + }, + "readinessProbe": { + "$ref": "#/definitions/probe" + }, + "startupProbe": { + "$ref": "#/definitions/probe" + }, + "resources": { + "$ref": "#/definitions/resources" + }, + "securityContext": { + "$ref": "#/definitions/containerSecurityContext" + } + } + }, + "sonarr": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean" + }, + "image": { + "$ref": "#/definitions/image" + }, + "ingress": { + "$ref": "#/definitions/appIngress" + }, + "storage": { + "$ref": "#/definitions/appStorage" + }, + "env": { + "$ref": "#/definitions/env" + }, + "livenessProbe": { + "$ref": "#/definitions/probe" + }, + "readinessProbe": { + "$ref": "#/definitions/probe" + }, + "startupProbe": { + "$ref": "#/definitions/probe" + }, + "resources": { + "$ref": "#/definitions/resources" + }, + "securityContext": { + "$ref": "#/definitions/containerSecurityContext" + } + } + }, + "seerr": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean" + }, + "image": { + "$ref": "#/definitions/image" + }, + "ingress": { + "$ref": "#/definitions/appIngress" + }, + "storage": { + "$ref": "#/definitions/appStorage" + }, + "env": { + "$ref": "#/definitions/env" + }, + "livenessProbe": { + "$ref": "#/definitions/probe" + }, + "readinessProbe": { + "$ref": "#/definitions/probe" + }, + "startupProbe": { + "$ref": "#/definitions/probe" + }, + "resources": { + "$ref": "#/definitions/resources" + }, + "securityContext": { + "$ref": "#/definitions/containerSecurityContext" + } + } + }, + "bazarr": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean" + }, + "image": { + "$ref": "#/definitions/image" + }, + "ingress": { + "$ref": "#/definitions/appIngress" + }, + "storage": { + "$ref": "#/definitions/appStorage" + }, + "env": { + "$ref": "#/definitions/env" + }, + "livenessProbe": { + "$ref": "#/definitions/probe" + }, + "readinessProbe": { + "$ref": "#/definitions/probe" + }, + "startupProbe": { + "$ref": "#/definitions/probe" + }, + "resources": { + "$ref": "#/definitions/resources" + }, + "securityContext": { + "$ref": "#/definitions/containerSecurityContext" + } + } + } + }, + "definitions": { + "image": { + "description": "Every image in this chart is pinned here, gluetun included; no tag comes from appVersion. This is also what puts them all under Renovate's helm-values manager, which reads exactly these two keys.", + "type": "object", + "additionalProperties": false, + "properties": { + "repository": { + "type": "string" + }, + "tag": { + "type": [ + "string", + "number" + ] + } + } + }, + "appIngress": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean" + }, + "host": { + "description": "Required while this app's ingress is enabled, and never demanded when it is off.", + "type": [ + "string", + "null" + ] + }, + "clusterIssuer": { + "type": [ + "string", + "null" + ] + }, + "className": { + "type": [ + "string", + "null" + ] + }, + "annotations": { + "description": "Overrides the global ingress annotations for this app; free-form, so kept open.", + "type": [ + "object", + "null" + ] + } + } + }, + "appStorage": { + "description": "Size of this app's config volumeClaimTemplate. Not null-safe on purpose: an erased size yields an invalid claim template and fails loudly.", + "type": "object", + "additionalProperties": false, + "properties": { + "size": { + "type": [ + "string", + "null" + ] + } + } + }, + "resources": { + "description": "May be null / empty: the chart then falls back to this container's own default requests and computed limits (issue #99).", + "type": [ + "object", + "null" + ], + "additionalProperties": false, + "properties": { + "limitFactor": { + "type": "number", + "exclusiveMinimum": 0 + }, + "requests": { + "$ref": "#/definitions/resourceList" + }, + "limits": { + "$ref": "#/definitions/resourceList" + } + } + }, + "resourceList": { + "description": "Both spellings of the ephemeral-storage key are accepted because the resources helper normalizes camelCase to the Kubernetes key.", + "type": [ + "object", + "null" + ], + "additionalProperties": false, + "properties": { + "cpu": { + "type": [ + "string", + "number", + "null" + ] + }, + "memory": { + "type": [ + "string", + "number", + "null" + ] + }, + "ephemeral-storage": { + "type": [ + "string", + "number", + "null" + ] + }, + "ephemeralStorage": { + "type": [ + "string", + "number", + "null" + ] + } + } + }, + "env": { + "type": [ + "array", + "null" + ], + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + }, + "value": { + "type": "string" + }, + "valueFrom": { + "type": "object", + "additionalProperties": false, + "properties": { + "secretKeyRef": { + "$ref": "#/definitions/keyRef" + }, + "configMapKeyRef": { + "$ref": "#/definitions/keyRef" + } + } + } + } + } + }, + "keyRef": { + "type": "object", + "additionalProperties": false, + "required": [ + "name", + "key" + ], + "properties": { + "name": { + "type": "string" + }, + "key": { + "type": "string" + } + } + }, + "podSecurityContextBlock": { + "description": "May be null / empty at any level: the chart then falls back to its pod hardening defaults (issue #99), seccompProfile included.", + "type": [ + "object", + "null" + ], + "additionalProperties": false, + "properties": { + "pod": { + "description": "Merged onto the chart's pod hardening defaults; kept open because the chart does not interpret the individual fields.", + "type": [ + "object", + "null" + ] + } + } + }, + "containerSecurityContext": { + "description": "Merged onto THIS container's hardening defaults; kept open because the chart does not interpret the individual fields. May be null / empty, in which case those defaults apply (issue #99). The fallbacks reproduce this chart's deliberate deviations - the linuxserver.io containers start as root and keep a writable root filesystem so their s6 init can drop to PUID/PGID, and restoring the 'clean' standard there would leave them on the image's uid 911.", + "type": [ + "object", + "null" + ] + }, + "probe": { + "description": "Merged onto this container's probe defaults; kept open because the chart does not interpret the individual fields. May be null / empty, which means the defaults apply (issue #99) rather than the probe disappearing. The defaults carry their context - path and port, and recyclarr's exec command, since it serves no HTTP port.", + "type": [ + "object", + "null" + ] + } + } +} diff --git a/freesailarr/values.yaml b/freesailarr/values.yaml index 9b22037..b432f1e 100644 --- a/freesailarr/values.yaml +++ b/freesailarr/values.yaml @@ -54,9 +54,21 @@ securityContext: # group-writable for the non-root containers; fsGroupChangePolicy # OnRootMismatch avoids recursively re-chowning the (large, shared) media # claims on every pod start. + # seccompProfile RuntimeDefault applies the container runtime's default + # syscall filter to every container in the pod, init containers included, and + # is set at pod level so a container added later inherits it without the + # value being repeated. It is worth naming what it has to survive here: + # gluetun runs as root with NET_ADMIN, creates a tun device and programs + # iptables, and the s6-overlay init of the five linuxserver.io images starts + # as root and drops to PUID/PGID via CHOWN/SETUID/SETGID. Both were verified + # against a real tunnel under this filter - see the chart's PR. + # A container that genuinely needs a different filter can still override it + # under .securityContext, which wins over the pod value. pod: fsGroup: 1000 fsGroupChangePolicy: OnRootMismatch + seccompProfile: + type: RuntimeDefault storage: # StorageClass for all config volumeClaimTemplates (null = cluster default). From d9c7878afe3bb8318210264a28911617d1c393a3 Mon Sep 17 00:00:00 2001 From: jklein Date: Mon, 24 Aug 2026 16:27:53 +0200 Subject: [PATCH 2/2] README: bound the icon exception to its criterion The previous wording named freesailarr as a documented exception but closed on "the field is omitted, lint still exits 0", which reads as a general licence to drop the icon. The rule is now stated with its criterion and its reach: the exception exists because the chart has no single upstream whose logo the icon could be, it is the only chart in the repo without one, and a single-upstream chart missing an icon is a missing icon rather than an exception. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 64e688f..70ee0fd 100644 --- a/README.md +++ b/README.md @@ -247,7 +247,7 @@ Reference implementation: `template-chart/values.schema.json` and `template-char ### 13. Everything else -- `icon:` is mandatory in every `Chart.yaml`. **Documented exception — freesailarr**: it bundles nine independently versioned projects, so every other chart's rule (the icon is its one upstream's logo) has no answer here, and picking one of the nine would imply a "main app" the stack does not have. The field is omitted; `helm lint --strict` reports `icon is recommended` as INFO and still exits 0. +- `icon:` is mandatory in every `Chart.yaml`. **Documented exception — freesailarr, the only chart without one**: the rule everywhere else is "the icon is this chart's one upstream's logo", and a chart bundling nine independently versioned projects has no such upstream — picking one of the nine would imply a "main app" the stack deliberately does not have. That is the whole criterion, and it applies to no other chart in the repo: a single-upstream chart without an icon is a missing icon, not an exception. The omission costs an INFO line in `helm lint --strict` (`icon is recommended`), which still exits 0. - `helm lint --strict` must pass without findings. - `NOTES.txt` for every chart is planned (#43); seven charts ship one so far (`apache-tika`, `cyberchef`, `freesailarr`, `gotenberg`, `homeassistant`, `patchmon`, `rss-bridge`). - CI: every installable chart has fixtures under `ci//` (`test-values.yaml`, optionally `fixtures.yaml`/`settings.env`) or a justified entry in `ci/excluded-charts.txt` — the install-test fails if both are missing.