Merge pull request #132 from jk-cluster/feat/rss-bridge-chart

rss-bridge: new chart
This commit is contained in:
jklein authored and GitHub committed 2026-08-23 21:42:31 +00:00
commit ed3bc2a48a
15 files changed
+1063

No files matched your search

+1
View File
@@ -34,6 +34,7 @@ on:
- voucher-vault
- minecraft-router
- minecraft-server
- rss-bridge
workflow_call:
inputs:
app-version:
+15
View File
@@ -0,0 +1,15 @@
name: Publish RSS-Bridge Helm Chart
on:
workflow_dispatch:
push:
branches:
- main
paths:
- rss-bridge/**
jobs:
publish-rss-bridge:
name: Publish RSS-Bridge Helm Chart
uses: ./.github/workflows/publish-helm-chart.yml
with:
chart-name: 'rss-bridge'
secrets: inherit
+1
View File
@@ -18,6 +18,7 @@ Collection of some self-made helm-charts. Each top-level directory is a standalo
| `paperless-ngx` | Paperless-ngx | Largest chart: consume CronJob, SMB-connector PV/PVC, pre/post-consumption scripts ConfigMap |
| `patchmon` | PatchMon | Two Deployments (frontend + backend), OIDC secret |
| `rallly` | Rallly | Deployment, DB/OIDC/SMTP secrets |
| `rss-bridge` | RSS-Bridge | Deployment + whitelist ConfigMap; `ingress.hosts` is a required list of host + optional `tlsSecretName`, so several hosts can keep their own existing certificates (documented deviation from the one-TLS-secret rule, see its `values.yaml`) |
| `samba` | Samba | StatefulSet, users secret + config ConfigMap |
| `satisfactory` | Satisfactory game server | StatefulSet, no ingress |
| `teamspeak-server` | TeamSpeak server | StatefulSet, no ingress |
+24
View File
@@ -0,0 +1,24 @@
# rss-bridge: stateless, no secrets and no infrastructure to stand up, so only
# the required ingress values are set. The Ingress just has to apply - there is
# no ingress-nginx/cert-manager in the CI cluster.
#
# Two hosts share a TLS secret name and a third has its own, so the install
# test renders the secret-grouping helper as well. That helper is what keeps
# the three existing certificates of the takeover installation alive, and it is
# not exercised by the chart defaults - an install test that never rendered it
# would not notice if it broke.
ingress:
clusterIssuer: ci
hosts:
- host: rss-bridge.ci.local
tlsSecretName: tls-ci-shared-ingress
- host: rssbridge.ci.local
tlsSecretName: tls-ci-shared-ingress
- host: rss-bridge.ci.example
tlsSecretName: tls-ci-second-ingress
# A concrete list rather than the default "*", so the ConfigMap render and the
# subPath mount are covered by something the app has to parse.
whitelist:
- Youtube
- Heise
+5
View File
@@ -108,3 +108,8 @@ misconfigurations:
- id: AVD-KSV-0119
paths: ["**/homeassistant.yaml"]
statement: "NET_RAW is the capability in question and is added on purpose for device discovery; see AVD-KSV-0022."
# --- rss-bridge: port 80 is fixed by the image ----------------------------
- id: AVD-KSV-0117
paths: ["**/rss-bridge.yaml"]
statement: "nginx inside the image listens on 80, and the only lever the entrypoint offers (HTTP_PORT) is a sed against /etc/nginx/sites-enabled/default, which a read-only root filesystem rejects - so the alternative to port 80 is giving up readOnlyRootFilesystem. The chart does not merely rely on the kubelet's ip_unprivileged_port_start=0 default, it pins the sysctl in the pod securityContext, so the bind does not depend on node configuration. Measured in k3d with only that value changed: at 1024 nginx fails with 'bind() to 0.0.0.0:80 failed (13: Permission denied)', and adding NET_BIND_SERVICE does NOT fix it (for a non-root process the capability lands in the permitted but not the effective set) - which is why this chart adds no capability, unlike samba. Justified in rss-bridge/values.yaml above securityContext.pod.sysctls."
+23
View File
@@ -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/
+12
View File
@@ -0,0 +1,12 @@
apiVersion: v2
name: rss-bridge
description: A Helm chart to deploy RSS-Bridge on Kubernetes
icon: https://raw.githubusercontent.com/RSS-Bridge/rss-bridge/master/static/logo_300px.png
type: application
version: 1.0.0+up2025.06.03
appVersion: "2025-06-03"
maintainers:
- name: jklein
+42
View File
@@ -0,0 +1,42 @@
RSS-Bridge {{ .Chart.AppVersion }} was installed as release "{{ .Release.Name }}".
{{ if .Values.ingress.enabled -}}
Reachable at:
{{ range $entry := .Values.ingress.hosts }} https://{{ $entry.host }}
{{ end }}
Each host serves the same instance. Certificates are issued by the
cert-manager cluster issuer "{{ .Values.ingress.clusterIssuer }}" into these secrets:
{{ range $secret := (fromYaml (include "rss-bridge.tlsGroups" (dict "hosts" .Values.ingress.hosts "root" $))).order }} {{ $secret }}
{{ end }}
{{- else -}}
The Ingress is disabled (ingress.enabled=false). Reach the instance with:
kubectl --namespace {{ .Release.Namespace }} port-forward svc/{{ .Release.Name }}-{{ .Chart.Name }}-service 8080:80
and open http://localhost:8080
{{- end }}
Enabled bridges ({{ len .Values.whitelist }} entr{{ if eq (len .Values.whitelist) 1 }}y{{ else }}ies{{ end }} in whitelist.txt):
{{ range $entry := .Values.whitelist }} {{ $entry }}
{{ end }}
{{- if has "*" .Values.whitelist }}
"*" enables every bridge the image ships. Narrow it down by listing bridge
names in the "whitelist" value.
{{- end }}
Useful to know:
* Health endpoint: /?action=health - a fixed JSON document that touches
neither the cache nor any remote site. All three probes use it.
* Changing the "whitelist" value rolls the pod: the deployment carries a
checksum over the ConfigMap, because a subPath mount would otherwise never
reach the running container.
* The feed cache lives in an emptyDir at /app/cache and is therefore
deliberately lost on every restart. RSS-Bridge refills it on demand; it
holds only fetched feeds, no configuration and no user data.
* The container runs read-only and non-root as uid 33 (www-data).
{{- if .Values.scaleDown }}
WARNING: scaleDown is true, so this release is running with 0 replicas.
Set scaleDown=false to bring it back up.
{{- end }}
+318
View File
@@ -0,0 +1,318 @@
{{/*
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).
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. template-chart holds the reference implementation.
*/}}
{{- define "rss-bridge.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 "rss-bridge.resourceKey" $key) $value -}}
{{- end -}}
{{- end -}}
{{- $limits := dict -}}
{{- range $key, $value := ($resources.limits | default dict) -}}
{{- if not (kindIs "invalid" $value) -}}
{{- $_ := set $limits (include "rss-bridge.resourceKey" $key) $value -}}
{{- end -}}
{{- end -}}
{{- range $key, $request := $requests -}}
{{- if and (ne $key "cpu") (not (hasKey $limits $key)) -}}
{{- $_ := set $limits $key (include "rss-bridge.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 "rss-bridge.resourceKey" -}}
{{- if eq . "ephemeralStorage" -}}ephemeral-storage{{- else -}}{{ . }}{{- end -}}
{{- end -}}
{{/*
Multiply a Kubernetes quantity by a factor, keeping its suffix.
Input: dict "quantity" <string|number> "factor" <float64>.
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 "rss-bridge.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.
*/}}
{{- define "rss-bridge.replicas" -}}
{{- if .scaleDown -}}
0
{{- else if not (kindIs "invalid" .replicas) -}}
{{- .replicas -}}
{{- else -}}
1
{{- end -}}
{{- end -}}
{{/*
Merge a user-supplied values sub-tree onto a dict of chart defaults so that a
null / empty override falls back to the defaults instead of erasing them.
Input: dict "defaults" <dict> "given" <dict|nil> "path" <string, optional
values path used in error messages>. Output: YAML of the merged dict (consume
it with fromYaml).
Why this exists (issues #61 and #99): Helm merges values maps recursively, but
an override key written without children - e.g. a leftover
securityContext:
after deleting its last remaining sub-key - is YAML null. Where the chart
defines a default, Helm treats that null as a deletion of the default; the
template then renders "securityContext: null": no readOnlyRootFilesystem, no
dropped capabilities, no allowPrivilegeEscalation: false. The workload starts
and reports healthy while being unhardened, so nothing points at the mistake.
The same shape erases a probe (it disappears) or the resources block (no
requests, no computed limits). This helper defines the opposite semantics: an
empty block means "chart defaults apply".
Rules:
- a nil "given" is treated as an empty dict (all defaults apply).
- a key whose override value is null keeps the default (nulls never delete).
- a key present in both as a map is merged recursively, so a partially
filled block only overrides the keys it actually sets.
- a scalar override for a key the defaults define as a map is rejected with
a message naming the values path, instead of failing later with a template
field error.
- any other set value wins as given, including an explicit false or 0 -
Helm's "default" and sprig's "mergeOverwrite" would both swallow those
zero values, hence the explicit kindIs "invalid" nil check.
- keys only present in the override are passed through unchanged.
*/}}
{{- define "rss-bridge.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 "rss-bridge.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" <dict|nil> "key" <string> "path" <string>.
Returns YAML of dict "value" <the sub-block, may be nil>, after rejecting a
non-mapping block with a message that names the values path. Needed because
".Values.rssBridge.securityContext" can itself be erased, and indexing into nil
inside a template is not an error but silently yields nothing.
*/}}
{{- define "rss-bridge.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 effective security contexts, probes and resources (issue #99).
The defaults below MUST stay in sync with the corresponding blocks in
values.yaml, which stays the documented, user-facing place for them; this copy
is only the fallback for the case where an override has erased the block.
Two chart-specific facts a rebuilt default must not lose:
- uid/gid 33 (www-data) is not a stylistic choice. The image ships /app and
everything in it owned by www-data, and both nginx and php-fpm are
configured for that user. A "cleaner" high uid would leave the app unable
to read its own bridge sources.
- the ip_unprivileged_port_start sysctl is what lets a non-root nginx bind
port 80, and this chart has no choice about that port - see the comment on
it in values.yaml. Dropping it from a rebuilt default does not fail at
render time; it fails on the first node whose default differs.
- the probes address the port BY NAME (rss-bridge-port) and use the app's
own health action. A default that drops the query string would probe the
1.5 MB front page instead of a 49-byte JSON response, and a default that
drops the port name would not resolve at all.
*/}}
{{- define "rss-bridge.podSecurityContext" -}}
{{- $given := (fromYaml (include "rss-bridge.subBlock" (dict "block" . "key" "pod" "path" "rssBridge.securityContext"))).value -}}
{{- $defaults := dict
"runAsNonRoot" true
"runAsUser" 33
"runAsGroup" 33
"fsGroup" 33
"fsGroupChangePolicy" "Always"
"seccompProfile" (dict "type" "RuntimeDefault")
"sysctls" (list (dict "name" "net.ipv4.ip_unprivileged_port_start" "value" "0")) -}}
{{- include "rss-bridge.defaultedDict" (dict "defaults" $defaults "given" $given "path" "rssBridge.securityContext.pod") -}}
{{- end -}}
{{- define "rss-bridge.containerSecurityContext" -}}
{{- $given := (fromYaml (include "rss-bridge.subBlock" (dict "block" . "key" "container" "path" "rssBridge.securityContext"))).value -}}
{{- $defaults := dict
"allowPrivilegeEscalation" false
"readOnlyRootFilesystem" true
"runAsNonRoot" true
"capabilities" (dict "drop" (list "ALL")) -}}
{{- include "rss-bridge.defaultedDict" (dict "defaults" $defaults "given" $given "path" "rssBridge.securityContext.container") -}}
{{- end -}}
{{/*
The probe target, shared by all three probes.
"/?action=health" is RSS-Bridge's own health action: it renders a fixed JSON
document and touches neither the file cache nor any remote site, so no outage
of a scraped website and no unwritable cache directory can restart the pod
(guideline 3). The front page "/" would be a bad choice for the opposite
reason - it enumerates every bridge and answers with ~1.5 MB.
*/}}
{{- define "rss-bridge.probeHttpGet" -}}
{{- dict "path" "/?action=health" "port" "rss-bridge-port" | toYaml -}}
{{- end -}}
{{- define "rss-bridge.livenessProbe" -}}
{{- $defaults := dict
"httpGet" (fromYaml (include "rss-bridge.probeHttpGet" .))
"periodSeconds" 10
"timeoutSeconds" 5
"failureThreshold" 3 -}}
{{- include "rss-bridge.defaultedDict" (dict "defaults" $defaults "given" . "path" "rssBridge.livenessProbe") -}}
{{- end -}}
{{- define "rss-bridge.readinessProbe" -}}
{{- $defaults := dict
"httpGet" (fromYaml (include "rss-bridge.probeHttpGet" .))
"periodSeconds" 10
"timeoutSeconds" 5
"failureThreshold" 3 -}}
{{- include "rss-bridge.defaultedDict" (dict "defaults" $defaults "given" . "path" "rssBridge.readinessProbe") -}}
{{- end -}}
{{- define "rss-bridge.startupProbe" -}}
{{- $defaults := dict
"httpGet" (fromYaml (include "rss-bridge.probeHttpGet" .))
"periodSeconds" 5
"timeoutSeconds" 5
"failureThreshold" 30 -}}
{{- include "rss-bridge.defaultedDict" (dict "defaults" $defaults "given" . "path" "rssBridge.startupProbe") -}}
{{- end -}}
{{/*
The effective resources block, fed into the resources helper above so that an
erased "resources:" still yields the chart's requests and computed limits
instead of a container without any resource accounting.
The explicit limits are part of the default on purpose - see the measurements
in values.yaml. Reproducing only the requests here would let limitFactor
compute a 384Mi memory limit, which is below the measured concurrent peak.
*/}}
{{- define "rss-bridge.effectiveResources" -}}
{{- $defaults := dict
"limitFactor" 3
"requests" (dict "cpu" 0.1 "memory" "128Mi" "ephemeral-storage" "64Mi")
"limits" (dict "memory" "512Mi" "ephemeral-storage" "2Gi") -}}
{{- $merged := fromYaml (include "rss-bridge.defaultedDict" (dict "defaults" $defaults "given" . "path" "rssBridge.resources")) -}}
{{- include "rss-bridge.resources" $merged -}}
{{- end -}}
{{/*
Group the configured ingress hosts by their TLS secret name.
Returns YAML of dict "order" <list of secret names, in first-appearance order>
and "groups" <secret name -> list of hosts>. Hosts that share a secret name end
up in one tls entry (one certificate with several SANs); hosts with distinct
names keep one certificate each, which is what the existing installation needs
(see the TLS comment in values.yaml).
First-appearance order rather than sorted order, so the rendered tls list can
be compared line by line against the manifest the chart replaces.
*/}}
{{- define "rss-bridge.tlsGroups" -}}
{{- $root := .root -}}
{{- $default := printf "tls-%s-%s-ingress" $root.Release.Name $root.Chart.Name -}}
{{- $groups := dict -}}
{{- $order := list -}}
{{- range $entry := .hosts -}}
{{- $host := required "Every ingress.hosts entry needs a host (ingress.hosts[].host)" $entry.host -}}
{{- $secret := $entry.tlsSecretName | default $default -}}
{{- if not (hasKey $groups $secret) -}}
{{- $_ := set $groups $secret (list) -}}
{{- $order = append $order $secret -}}
{{- end -}}
{{- $_ := set $groups $secret (append (index $groups $secret) $host) -}}
{{- end -}}
{{- toYaml (dict "order" $order "groups" $groups) -}}
{{- end -}}
@@ -0,0 +1,11 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: "{{ .Release.Name }}-{{ .Chart.Name }}-configmap"
data:
# One bridge name per line. The application trims every line and drops empty
# ones, so the block scalar style is not load-bearing.
whitelist.txt: |-
{{- range $entry := required "A bridge whitelist must be provided (whitelist)" .Values.whitelist }}
{{ $entry }}
{{- end }}
@@ -0,0 +1,140 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-deployment
labels:
app: {{ .Release.Name }}-{{ .Chart.Name }}-deployment
spec:
replicas: {{ include "rss-bridge.replicas" (dict "scaleDown" .Values.scaleDown "replicas" .Values.rssBridge.replicas) }}
selector:
matchLabels:
app: {{ .Release.Name }}-{{ .Chart.Name }}-deployment
template:
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-deployment
labels:
app: {{ .Release.Name }}-{{ .Chart.Name }}-deployment
annotations:
# Hash of the rendered ConfigMap: a changed whitelist changes the pod
# template and thus rolls the pods. Without it a config change stays
# invisible to the running container - whitelist.txt is a subPath
# mount, which the kubelet never updates, and RSS-Bridge reads the file
# once at request bootstrap from the path it was started with.
checksum/config: {{ include (print $.Template.BasePath "/rss-bridge.configmap.yaml") . | sha256sum }}
spec:
automountServiceAccountToken: false
securityContext:
{{- include "rss-bridge.podSecurityContext" .Values.rssBridge.securityContext | nindent 8 }}
containers:
- name: {{ .Release.Name }}-{{ .Chart.Name }}-deployment
# Registry written out in full on purpose: an unqualified Docker Hub
# reference puts "rssbridge" in the registry position, which says
# nothing about where the image comes from and is reported as an
# untrusted registry (AVD-KSV-0125). See ci/trivy/data/ksv0125.yaml.
image: "docker.io/rssbridge/rss-bridge:{{ .Chart.AppVersion }}"
imagePullPolicy: IfNotPresent
env:
{{- range $value := .Values.rssBridge.env }}
{{- if $value.value }}
- name: {{ $value.name | quote }}
# quote is required, not cosmetic: a value like "5" or "true"
# renders unquoted as a YAML number/bool, and the API server then
# rejects the whole manifest with "cannot unmarshal number into
# Go struct field EnvVar.value of type string". RSS-Bridge takes
# numeric settings this way (RSSBRIDGE_http_timeout), so this is
# reachable with the very first env entry someone adds.
value: {{ tpl $value.value $ | quote }}
{{- else if $value.valueFrom }}
{{- if $value.valueFrom.secretKeyRef }}
- name: {{ $value.name | quote }}
valueFrom:
secretKeyRef:
name: {{ tpl $value.valueFrom.secretKeyRef.name $ | quote }}
key: {{ tpl $value.valueFrom.secretKeyRef.key $ | quote }}
{{- else if $value.valueFrom.configMapKeyRef }}
- name: {{ $value.name | quote }}
valueFrom:
configMapKeyRef:
name: {{ tpl $value.valueFrom.configMapKeyRef.name $ | quote }}
key: {{ tpl $value.valueFrom.configMapKeyRef.key $ | quote }}
{{- end }}
{{- end }}
{{- end }}
ports:
# The port NAME is load-bearing and must not be renamed: the
# Service selects it by name (targetPort: rss-bridge-port), and so
# do the probes.
- containerPort: 80
protocol: TCP
name: rss-bridge-port
# Probes, resources and both security contexts go through the
# null-safe helpers (#99): an erased block means "chart defaults
# apply" instead of a vanished probe or an unhardened container.
# Never wrap these in an "if" - a guard hides the erased case
# completely instead of rendering a visible null.
livenessProbe:
{{- include "rss-bridge.livenessProbe" .Values.rssBridge.livenessProbe | nindent 12 }}
readinessProbe:
{{- include "rss-bridge.readinessProbe" .Values.rssBridge.readinessProbe | nindent 12 }}
startupProbe:
{{- include "rss-bridge.startupProbe" .Values.rssBridge.startupProbe | nindent 12 }}
resources:
{{- include "rss-bridge.effectiveResources" .Values.rssBridge.resources | nindent 12 }}
securityContext:
{{- include "rss-bridge.containerSecurityContext" .Values.rssBridge.securityContext | nindent 12 }}
volumeMounts:
- name: whitelist
mountPath: /app/whitelist.txt
subPath: whitelist.txt
readOnly: true
# The four writable paths below are what keeps
# readOnlyRootFilesystem: true possible. They are image facts
# rather than user configuration, so they are not values.
#
# They were found empirically, not guessed: the image was run with
# a writable root filesystem, exercised across every whitelisted
# bridge, and its diff layer read afterwards. Three of the four
# showed up that way. /tmp did NOT - PHP's opcache creates its lock
# file there and unlinks it immediately, so it never appears in a
# filesystem diff and only surfaced when the hardened container
# died at startup with "Unable to create lock file: Read-only file
# system". Stopping at the first writable path found would have
# shipped a chart that cannot start.
- name: cache
# RSS-Bridge's file cache. Not optional: the app checks the
# directory with is_writable() and throws on every feed request
# if it is not - while /?action=health still answers 200, so the
# probes would report a healthy pod that serves no feeds.
mountPath: /app/cache
- name: tmp
# PHP opcache lock file (created and immediately unlinked) and
# PHP's default upload temp dir.
mountPath: /tmp
- name: run
# nginx.pid
mountPath: /run
- name: run-php
# php-fpm's pid file and its fastcgi socket. Nested inside /run
# on purpose: the emptyDir above does not contain the php
# subdirectory the pool config expects, and php-fpm does not
# create it.
mountPath: /run/php
- name: nginx-lib
# nginx creates its body/fastcgi/proxy/scgi/uwsgi temp
# directories here at startup.
mountPath: /var/lib/nginx
volumes:
- name: whitelist
configMap:
name: "{{ .Release.Name }}-{{ .Chart.Name }}-configmap"
- name: cache
emptyDir: {}
- name: tmp
emptyDir: {}
- name: run
emptyDir: {}
- name: run-php
emptyDir: {}
- name: nginx-lib
emptyDir: {}
restartPolicy: Always
@@ -0,0 +1,42 @@
{{- if .Values.ingress.enabled }}
{{- $hosts := required "At least one ingress host must be provided (ingress.hosts)" .Values.ingress.hosts }}
{{- /* helm lint reports a failed required() but keeps rendering with an empty
string, which is not iterable. Anything but a list is coerced away here
so lint reports the missing value instead of a template stack trace; a
real render never reaches this, because required() aborts on nil and the
schema rejects every non-array. */ -}}
{{- if not (kindIs "slice" $hosts) }}{{- $hosts = list }}{{- end }}
{{- $tls := fromYaml (include "rss-bridge.tlsGroups" (dict "hosts" $hosts "root" $)) }}
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 $entry := $hosts }}
- host: {{ $entry.host }}
http:
paths:
- backend:
service:
name: {{ $.Release.Name }}-{{ $.Chart.Name }}-service
port:
number: 80
path: /
pathType: Prefix
{{- end }}
tls:
{{- range $secret := $tls.order }}
- hosts:
{{- range $host := index $tls.groups $secret }}
- {{ $host }}
{{- end }}
secretName: {{ $secret }}
{{- end }}
{{- end }}
@@ -0,0 +1,15 @@
apiVersion: v1
kind: Service
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-service
spec:
selector:
app: {{ .Release.Name }}-{{ .Chart.Name }}-deployment
ports:
# Port 80 and the named targetPort are kept exactly as the manifest this
# chart replaces had them, so the takeover does not change how traffic
# reaches the container.
- protocol: TCP
port: 80
targetPort: rss-bridge-port
type: ClusterIP
+191
View File
@@ -0,0 +1,191 @@
{
"$schema": "https://json-schema.org/draft-07/schema#",
"title": "rss-bridge values",
"description": "Strict 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. Objects that go verbatim into the Kubernetes pod spec (probes, security contexts, global, free-form annotations) stay open, because the chart does not interpret their contents. The hardening, probe and resources blocks additionally accept null - the chart's null-safe helpers turn an erased block into 'chart defaults apply' (issue #99), and the widened types are what lets that null reach the helper instead of aborting validation.",
"type": "object",
"additionalProperties": false,
"required": ["whitelist"],
"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"
},
"whitelist": {
"description": "Bridge names rendered into whitelist.txt, one per line. The single entry \"*\" enables every bridge. An empty list would disable every bridge, which is a silent way to serve nothing, so at least one entry is required.",
"type": "array",
"minItems": 1,
"items": {
"type": "string",
"minLength": 1
}
},
"rssBridge": {
"type": "object",
"additionalProperties": false,
"properties": {
"replicas": {
"type": "integer",
"minimum": 0
},
"resources": {
"$ref": "#/definitions/resources"
},
"livenessProbe": {
"$ref": "#/definitions/probe"
},
"readinessProbe": {
"$ref": "#/definitions/probe"
},
"startupProbe": {
"$ref": "#/definitions/probe"
},
"securityContext": {
"$ref": "#/definitions/securityContext"
},
"env": {
"$ref": "#/definitions/env"
}
}
},
"ingress": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"description": "Gates the whole Ingress template (issue #93). Nothing outside the Ingress template reads an ingress value in this chart - the container takes no host in its environment - so switching it off frees every value below (guideline 1, checked rather than assumed).",
"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"
},
"hosts": {
"description": "Hosts this Ingress serves. Each entry carries a host and an optional tlsSecretName; entries sharing a secret name are grouped into one tls entry. Null is accepted by the schema so the template's required() produces the readable error instead of a schema violation.",
"type": ["array", "null"],
"items": {
"type": "object",
"additionalProperties": false,
"required": ["host"],
"properties": {
"host": {
"type": "string",
"minLength": 1
},
"tlsSecretName": {
"description": "Overrides the conventional tls-<release>-<chart>-ingress name for this host. Set only where an existing certificate must keep its secret name - see the deviation note in values.yaml.",
"type": ["string", "null"]
}
}
}
}
}
}
},
"definitions": {
"resources": {
"description": "May be null / empty: the chart then falls back to its default requests AND its explicit limits (issue #99). Reproducing only the requests would let limitFactor compute a memory limit below the measured peak.",
"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"
}
}
},
"securityContext": {
"description": "May be null / empty at any level: the chart then falls back to its hardening defaults (issue #99) instead of rendering an unhardened workload. The fallback reproduces THIS chart's defaults, including uid/gid 33 - the image owns /app as www-data, so a 'cleaner' high uid would leave the app unable to read its own bridge sources (guideline 4).",
"type": ["object", "null"],
"additionalProperties": false,
"properties": {
"pod": {
"description": "Merged onto the chart's pod hardening defaults and rendered as the pod's securityContext; kept open because the chart does not interpret the individual fields.",
"type": ["object", "null"]
},
"container": {
"description": "Merged onto the chart's container hardening defaults and rendered as the container's securityContext; kept open because the chart does not interpret the individual fields.",
"type": ["object", "null"]
}
}
},
"probe": {
"description": "Merged onto the chart's probe defaults and rendered as a container probe; 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 chart's default carries the probe's context - the named port rss-bridge-port and the /?action=health query string - not just its timings (guideline 5).",
"type": ["object", "null"]
}
}
}
+223
View File
@@ -0,0 +1,223 @@
# 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
# The bridges RSS-Bridge offers, rendered into the ConfigMap as whitelist.txt
# and mounted at /app/whitelist.txt.
#
# A list rather than a blob of file content: the file format is one bridge name
# per line, and the application trims and filters every line before use
# (lib/Configuration.php), so nothing is lost by modelling it as YAML - while a
# hardcoded file body in the template could not be changed without editing the
# chart.
#
# The single entry "*" is RSS-Bridge's own wildcard and enables every bridge.
# It is the default here because it is exactly what the image does when no
# whitelist.txt exists at all, so a default install behaves like stock
# RSS-Bridge instead of silently serving nothing. Installations that want a
# narrower set list the bridge names explicitly:
#
# whitelist:
# - Youtube
# - Heise
#
# Changing this value rolls the pod - the deployment carries a checksum over
# the rendered ConfigMap (see rss-bridge.deployment.yaml). Without that the
# change would not reach the running container at all: the whitelist is a
# subPath mount, which the kubelet never refreshes.
whitelist:
- "*"
rssBridge:
# Replica count for this workload; 0 is allowed and scales it down.
# The chart-global scaleDown flag takes precedence.
replicas: 1
# Verified against the image, not assumed (issue #128).
#
# The image ships no USER and runs everything as root by default. It does,
# however, run fully hardened once its writable paths are provided: nginx and
# php-fpm both start, the front page renders and real bridges fetch feeds.
#
# uid/gid 33 is www-data, and it is not interchangeable: /app and every file
# in it is owned by www-data in the image, nginx.conf declares "user
# www-data" and the php-fpm pool declares "user = www-data". Both emit a
# harmless notice that those directives are ignored when the master process
# is not root, and then serve normally as uid 33.
securityContext:
pod:
runAsNonRoot: true
runAsUser: 33
runAsGroup: 33
fsGroup: 33
fsGroupChangePolicy: Always
seccompProfile:
type: RuntimeDefault
# Why this chart listens on the privileged port 80, and why that is safe.
#
# nginx is configured for port 80 inside the image. The only lever the
# entrypoint offers is HTTP_PORT, which runs a sed against
# /etc/nginx/sites-enabled/default - impossible with a read-only root
# filesystem. So the choice is port 80 or readOnlyRootFilesystem, and
# port 80 is the cheaper of the two. (The Service selects the port by
# NAME, so the number is invisible to it either way.)
#
# Kubernetes sets net.ipv4.ip_unprivileged_port_start=0 in the pod
# network namespace, so binding 80 needs no capability at all. But that
# is a runtime default, not a guarantee, and relying on it silently is
# exactly the trap the template-chart warns about - so the chart pins the
# value instead of inheriting it.
#
# Measured in k3d (k3s v1.35.5), same pod spec, only this value changed:
# sysctl 1024, capabilities.drop [ALL] -> nginx: bind() to
# 0.0.0.0:80 failed (13: Permission denied)
# sysctl 1024, plus NET_BIND_SERVICE added -> identical failure.
# Adding the capability does NOT help: for a non-root process it
# lands in the permitted set but not the effective/ambient one, so
# it buys nothing here. That is why this chart does not add it.
# sysctl 0 (this default) -> binds, HTTP 200.
#
# Note how that failure looks without probes: the entrypoint starts nginx
# in the background and then execs php-fpm, so a failed bind leaves a
# container that reports Running with no web server at all. The probes on
# /?action=health turn that silent failure into a pod that never becomes
# Ready.
sysctls:
- name: net.ipv4.ip_unprivileged_port_start
value: "0"
container:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
runAsNonRoot: true
capabilities:
drop:
- ALL
# All three probes hit RSS-Bridge's own health action, which renders a fixed
# 49-byte JSON document without touching the file cache or any remote site.
# That keeps liveness free of external dependencies (guideline 3): a scraped
# website going down, or a cache directory that is not writable, must not
# restart the pod.
#
# Deliberately not "/": the front page enumerates every enabled bridge and
# answers with roughly 1.5 MB, which is a wasteful thing to fetch every ten
# seconds forever.
livenessProbe:
httpGet:
path: /?action=health
port: rss-bridge-port
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /?action=health
port: rss-bridge-port
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
# The startup probe gates liveness/readiness, so those need no
# initialDelaySeconds. Startup budget = 5s * 30 = 150s, which is generous:
# the container is ready in about two seconds (nginx plus php-fpm, no
# migrations, no warm-up).
startupProbe:
httpGet:
path: /?action=health
port: rss-bridge-port
periodSeconds: 5
timeoutSeconds: 5
failureThreshold: 30
# Extra environment variables. Values are rendered through tpl; the value,
# secretKeyRef and configMapKeyRef forms are supported.
#
# RSS-Bridge maps RSSBRIDGE_<section>_<key> onto its config, so for example
# "RSSBRIDGE_system_timezone: Europe/Berlin" works here.
env: []
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".
limitFactor: 3
requests:
cpu: 0.1
# Measured with the shipped image: about 59 MiB resident while idle and
# roughly 89 MiB after serving feeds, so the previous 20Mi request was
# a third of what the workload actually occupies around the clock.
memory: 128Mi
# The previous manifest requested 2Gi of ephemeral storage - identical to
# its limit - and thereby reserved two gigabytes of node scheduling
# capacity for a workload that does not come close to using it.
#
# Everything RSS-Bridge writes was enumerated by running the image with a
# writable root filesystem, exercising every whitelisted bridge and then
# reading the container's diff layer. The only path that grows is the
# feed cache under /app/cache: 17 entries and 2.2 MB after fetching all
# five bridges, largest single entry 196 KB, and the application purges
# it on its own (FileCache enable_purge). /run and /var/lib/nginx add
# under 100 KB together.
#
# 64Mi is therefore roughly thirty times the measured footprint while
# freeing 97% of the old reservation. The limit below stays large, so
# the decoupling costs no safety margin (same shape as gotenberg, #106).
ephemeralStorage: 64Mi
limits:
# Set explicitly rather than left to limitFactor x request.
#
# memory: the manifest this chart replaces capped the container at
# 100Mi, but five concurrent feed fetches were measured peaking at
# 119 MiB - the old limit was already below the real peak, and php-fpm
# is configured for up to five workers with a 128M PHP memory_limit
# each. 512Mi covers the measured peak with room to spare and still
# bounds a runaway bridge.
memory: 512Mi
# Unchanged from the previous manifest on purpose: a large cap costs
# nothing (it reserves no capacity) and guarantees this change cannot
# make eviction more likely than it is today.
ephemeralStorage: 2Gi
# No CPU limit by default (repo standard): CPU is compressible, so a limit
# only throttles. The previous manifest capped CPU at 0.5.
ingress:
# Gates the whole Ingress template. Checked in both directions per guideline
# 1: nothing outside this template reads an ingress value - the container
# takes no domain in its environment and nginx serves as default_server
# without a server_name - so switching the Ingress off frees every value
# below, and none of them needs a separate required().
enabled: true
clusterIssuer: null
className: nginx
# Extra annotations for the Ingress; merged with the cert-manager
# cluster-issuer annotation the chart always sets.
annotations: {}
# The hosts this Ingress serves. Required - the chart ships no default,
# because hostnames are installation data and have no business in a chart.
#
# Each entry is a host plus an OPTIONAL tlsSecretName:
#
# hosts:
# - host: rss-bridge.example.com
# - host: feeds.example.com
# tlsSecretName: tls-legacy-feeds-ingress
#
# Entries sharing a tlsSecretName are grouped into one tls entry, so they
# share a single certificate with several SANs. Entries with distinct names
# get one certificate each. Omitting tlsSecretName falls back to the repo
# convention, tls-<release>-<chart>-ingress.
#
# DOCUMENTED DEVIATION - why the secret name is configurable at all:
#
# The repo convention (guideline 4) is exactly one TLS secret per Ingress,
# named tls-<release>-<chart>-ingress. The installation this chart takes over
# has grown three separate secrets, one per host, from three years of
# kubectl apply. Renaming them would not be a cosmetic cleanup: cert-manager
# keys certificates by secret name, so three new names mean three fresh
# issuances, and the service is unreachable on each host until its
# certificate is issued. That is a self-inflicted outage plus three
# avoidable Let's Encrypt issuances, for a deployment that has been running
# untouched for over a year.
#
# Making host and secret name per-entry values keeps the existing names in
# place today and turns the eventual consolidation onto one certificate - or
# the retirement of the legacy hostnames - into a values change instead of a
# template change (the explicit ask from cluster-config#33).
hosts: null