Files
helm-charts/voucher-vault/templates/NOTES.txt
T
jklein e1adcef71f
Validate Helm Charts / Detect changed charts (pull_request) Successful in 4s
Validate Helm Charts / HTTPRoute match (pull_request) Successful in 11s
Install-test / Install-test patchmon (pull_request) Successful in 1m41s
Validate Helm Charts / Resource quantities (pull_request) Successful in 11s
Validate Helm Charts / Selector match (pull_request) Successful in 16s
Validate Helm Charts / Bump level (pull_request) Successful in 7s
Validate Helm Charts / NetworkPolicy (pull_request) Failing after 11s
Validate Helm Charts / YAML scalars (pull_request) Successful in 10s
Validate Helm Charts / Build apache-tika (pull_request) Successful in 6s
Validate Helm Charts / Build bentopdf (pull_request) Successful in 17s
Validate Helm Charts / Build bulwark (pull_request) Successful in 16s
Validate Helm Charts / Build calibre-web-automated (pull_request) Successful in 7s
Validate Helm Charts / Trivy config apache-tika (pull_request) Successful in 13s
Validate Helm Charts / Build cyberchef (pull_request) Successful in 4s
Validate Helm Charts / Build homeassistant (pull_request) Successful in 5s
Validate Helm Charts / Build gotenberg (pull_request) Successful in 6s
Validate Helm Charts / Build rss-bridge (pull_request) Successful in 5s
Validate Helm Charts / Build searxng (pull_request) Successful in 5s
Validate Helm Charts / Build sure (pull_request) Successful in 5s
Validate Helm Charts / Build template-chart (pull_request) Successful in 5s
Validate Helm Charts / Build outline (pull_request) Successful in 5s
Validate Helm Charts / Build patchmon (pull_request) Successful in 5s
Validate Helm Charts / Build rallly (pull_request) Successful in 5s
Validate Helm Charts / Build urlaubsverwaltung (pull_request) Successful in 15s
Validate Helm Charts / Build voucher-vault (pull_request) Successful in 7s
Validate Helm Charts / Build ytdlp-web-player (pull_request) Successful in 7s
Install-test / Install-test rallly (pull_request) Successful in 1m41s
Validate Helm Charts / Build zipline (pull_request) Successful in 7s
Validate Helm Charts / Trivy config bulwark (pull_request) Successful in 13s
Validate Helm Charts / Trivy config cyberchef (pull_request) Successful in 18s
Validate Helm Charts / Trivy config gotenberg (pull_request) Successful in 13s
Validate Helm Charts / Trivy config bentopdf (pull_request) Successful in 12s
Validate Helm Charts / Trivy config calibre-web-automated (pull_request) Successful in 16s
Validate Helm Charts / Trivy config homeassistant (pull_request) Successful in 12s
Validate Helm Charts / Trivy config outline (pull_request) Successful in 14s
Validate Helm Charts / Trivy config rss-bridge (pull_request) Successful in 12s
Validate Helm Charts / Trivy config searxng (pull_request) Successful in 13s
Validate Helm Charts / Trivy config sure (pull_request) Successful in 20s
Validate Helm Charts / Trivy config patchmon (pull_request) Successful in 24s
Validate Helm Charts / Trivy config rallly (pull_request) Successful in 15s
Validate Helm Charts / Trivy config template-chart (pull_request) Successful in 14s
Validate Helm Charts / Trivy config ytdlp-web-player (pull_request) Successful in 17s
Validate Helm Charts / Trivy config urlaubsverwaltung (pull_request) Successful in 15s
Validate Helm Charts / Trivy config voucher-vault (pull_request) Successful in 11s
Validate Helm Charts / Trivy config zipline (pull_request) Successful in 24s
Install-test / Install-test rss-bridge (pull_request) Successful in 1m9s
Install-test / Install-test searxng (pull_request) Successful in 1m9s
Install-test / Install-test sure (pull_request) Successful in 2m24s
Install-test / Install-test apache-tika (pull_request) Successful in 1m23s
Install-test / Install-test urlaubsverwaltung (pull_request) Successful in 1m44s
Install-test / Install-test bentopdf (pull_request) Successful in 59s
Install-test / Install-test bulwark (pull_request) Successful in 1m23s
Install-test / Install-test voucher-vault (pull_request) Successful in 1m58s
Install-test / Install-test calibre-web-automated (pull_request) Successful in 1m55s
Install-test / Install-test cyberchef (pull_request) Successful in 1m6s
Install-test / Install-test gotenberg (pull_request) Successful in 1m19s
Install-test / Install-test ytdlp-web-player (pull_request) Successful in 59s
Install-test / Install-test zipline (pull_request) Successful in 1m52s
Install-test / Install-test homeassistant (pull_request) Successful in 2m26s
Validate Helm Charts / Install-test (pull_request) Successful in 27m21s
Install-test / Install-test outline (pull_request) Successful in 2m7s
feat!: NetworkPolicy in nine more charts, on by default
bulwark, calibre-web-automated, patchmon, rallly, urlaubsverwaltung,
voucher-vault, zipline, sure, homeassistant. One major each.

These are the charts with real outbound dependencies, and they all share one
limitation that is now written into every one of them: THE EGRESS PORTS ARE THE
STANDARD ONES, NOT THE CONFIGURED ONES. Databases, caches and OIDC issuers
arrive as connection URLs or as fields inside 1Password items that the charts
never parse, so a chart cannot know where its dependency listens and cannot
validate a guess. A dependency somewhere else is blocked, and
networkPolicy.egress.extraPorts is the escape hatch. That is the upgrade risk
of this rollout, stated per chart instead of discovered per release.

Four of them differ in ways that mattered:

HOMEASSISTANT gets policyTypes: [Ingress] and NO egress section at all, and
that is the decision in the file rather than an omission. Its outbound traffic
is the union of every integration an operator enables - mDNS and SSDP
discovery, MQTT, a Zigbee or Z-Wave bridge on whatever port its firmware chose,
ESPHome, printers, speakers, inverters, cloud APIs, NTP - which no chart can
know. A cap would protect little, since the devices sit on the node's own LAN,
and would break integrations one at a time, months apart, each looking like a
flaky device rather than like a policy. So valkey's rule applies in the other
direction: policyTypes names only what there is something to restrict.

URLAUBSVERWALTUNG allows BOTH 5432 and 3306, deliberately. It is handed a full
JDBC URL inside a 1Password item and never parses it, so it cannot tell
PostgreSQL from MariaDB - and this cluster runs both. Allowing one would break
half the possible deployments on upgrade, with the symptom buried in a Java
stack trace. A small widening against a large and silent failure.

RALLLY derives its SMTP rule from rallly.smtp.enabled rather than always
rendering it: with mail switched off there is nothing to permit, and a rule
that permits nothing makes the object look stricter than it is.

BULWARK is the chart where the cap can fail most quietly. Its probes test its
own port and say nothing about the JMAP server, so a jmapServerUrl on a port
the policy does not allow yields a Ready pod serving an empty webmail. 443 is
allowed, Stalwart's in-cluster 8080 is not, and both halves of that are in
values.yaml and NOTES.txt.

PATCHMON and HOMEASSISTANT select their pods by the BARE release-and-chart
label, with no -deployment or -sfs suffix. Copying a selector from a
neighbouring chart would have matched nothing and left a policy that filters
nothing - which is precisely the first assertion of the render test.

Five CI fixtures gain networkPolicy.egress.extraPorts, and they are the only
place in the repository where the cap's cost is actually exercised: the CI dex
runs on 5556 rather than 443 (patchmon, rallly, urlaubsverwaltung,
voucher-vault) and the CI JMAP server on plain 8080 (bulwark).

Part of #5
2026-10-14 14:26:25 +00:00

272 lines
15 KiB
Plaintext

{{- $replicas := include "voucher-vault.replicas" (dict "scaleDown" .Values.scaleDown "replicas" .Values.voucherVault.replicas) -}}
{{- $startup := fromYaml (include "voucher-vault.startupProbe" .Values.voucherVault.startupProbe) -}}
{{- $pod := printf "%s-%s-sfs-0" .Release.Name .Chart.Name -}}
{{- $env := dict -}}
{{- $names := list -}}
{{- range $entry := .Values.voucherVault.env }}
{{- $names = append $names $entry.name }}
{{- if not (kindIs "invalid" $entry.value) }}{{- $_ := set $env $entry.name $entry.value }}{{- end }}
{{- end }}
{{- $missing := list -}}
{{- range $required := list "DOMAIN" "SECRET_KEY" "REDIS_URL" "DB_ENGINE" "POSTGRES_HOST" "POSTGRES_DB" }}
{{- if not (has $required $names) }}{{- $missing = append $missing $required }}{{- end }}
{{- end }}
{{- $httpRoute := .Values.httpRoute | default dict -}}
{{- $hostnames := $httpRoute.hostnames | default list -}}
{{- if not $hostnames -}}
{{- $hostnames = list .Values.ingress.domain -}}
{{- end -}}
VoucherVault {{ .Chart.AppVersion }} — release {{ .Release.Name }} in namespace {{ .Release.Namespace }}.
{{- if .Values.ingress.enabled }}
ACCESS — INGRESS MODE (ingress.enabled: true, httpRoute.enabled: false)
https://{{ .Values.ingress.domain }}
The certificate is requested by cert-manager through the "{{ .Values.ingress.clusterIssuer }}"
cluster issuer. Until it has been issued the browser shows a warning; follow it with
kubectl -n {{ .Release.Namespace }} get certificate tls-{{ .Release.Name }}-{{ .Chart.Name }}-ingress
ingress.domain is more than the Ingress host: it is the only entry Django adds
to ALLOWED_HOSTS besides 127.0.0.1, and it builds CSRF_TRUSTED_ORIGINS. Under
any other name the app answers 400 before a single view runs.
{{- else if $httpRoute.enabled }}
ACCESS — GATEWAY MODE (httpRoute.enabled: true, ingress.enabled: false)
{{- range $hostnames }}
https://{{ . }}
{{- end }}
This release renders an HTTPRoute ({{ .Release.Name }}-{{ .Chart.Name }}-httproute) and NOTHING else
of the routing path — no Gateway, no class, no Certificate. Three things
therefore belong to the cluster and not to this chart:
1. The Gateway-API CRDs. Without them this install would have failed.
2. The Gateway named in httpRoute.parentRefs
{{- range $httpRoute.parentRefs }}
{{ .name }}{{ if .namespace }} in namespace {{ .namespace }}{{ end }}{{ if .sectionName }}, listener {{ .sectionName }}{{ end }}
{{- end }}
must exist AND its listener must allow routes from this namespace
({{ .Release.Namespace }}) in allowedRoutes. A rejected attachment shows
up only in the route's status:
kubectl -n {{ .Release.Namespace }} get httproute {{ .Release.Name }}-{{ .Chart.Name }}-httproute -o yaml
Read status.parents[].conditions — "Accepted: True" and
"ResolvedRefs: True" is a working attachment. An empty status means no
controller has looked at the route at all.
3. The TLS certificate for the hostname, on the Gateway's listener.
ingress.clusterIssuer is NOT used in this mode and is not required: the
chart renders no Certificate, so there is no
tls-{{ .Release.Name }}-{{ .Chart.Name }}-ingress secret and nothing for cert-manager to do.
AND THE ONE THAT WILL ACTUALLY BITE: ALLOWED_HOSTS DOES NOT FOLLOW THE ROUTE.
Django's DOMAIN is built from ingress.domain ("{{ .Values.ingress.domain }}"), and that is the
only name besides 127.0.0.1 it accepts. A hostname this route publishes which
ingress.domain does not name is answered with 400 before a single view runs —
and all three probes stay green while it happens, because they deliberately
carry a Host header of 127.0.0.1 so that probing survives every domain
change. So set httpRoute.hostnames and ingress.domain together.
ingress.annotations is inert in this mode: the Gateway API has no annotation
channel. Nothing this chart needs lives there.
{{- else }}
ACCESS — NEITHER INGRESS NOR HTTPROUTE
ingress.enabled and httpRoute.enabled are both false, so this release has no
Ingress, no HTTPRoute and no certificate;
ingress.clusterIssuer is not needed. ingress.domain still is — it is Django's
ALLOWED_HOSTS entry ("{{ .Values.ingress.domain }}"), not just an Ingress host.
Forward the service, and keep 8000 as the LOCAL port:
kubectl -n {{ .Release.Namespace }} port-forward svc/{{ .Release.Name }}-{{ .Chart.Name }}-service 8000:80
Then open http://127.0.0.1:8000/ — not http://localhost:8000/. Only 127.0.0.1
and the domain above are in ALLOWED_HOSTS, so "localhost" answers 400; and
CSRF_TRUSTED_ORIGINS holds exactly http://127.0.0.1:8000, so any other local
port fails every POST including the login form.
{{- end }}
{{- if eq (lower (printf "%v" (index $env "SECURE_COOKIES" | default ""))) "true" }}
SECURE_COOKIES is True in voucherVault.env, so the session and CSRF cookies
carry the Secure flag. Browsers accept those from 127.0.0.1, but from no other
plain-http host: a login that silently bounces back to the form when the app
is reached over http is this, not a wrong password.
{{- end }}
Sessions expire after {{ index $env "SESSION_COOKIE_AGE" | default 30 }} minutes and at browser close: the app
multiplies SESSION_COOKIE_AGE by 60, so that value is minutes, not seconds.
{{ if eq $replicas "0" }}
SCALED TO ZERO
{{ if .Values.scaleDown }}scaleDown: true{{ else }}voucherVault.replicas: 0{{ end }} — the StatefulSet runs with replicas: 0, so the endpoint
above does not answer until you set it back and upgrade again. PVC, Secrets
and Service{{ if .Values.ingress.enabled }} and Ingress stay in place, so the certificate is not
re-issued.{{ else if $httpRoute.enabled }} and HTTPRoute stay in place, so the route keeps pointing at a
Service with no endpoints — the Gateway answers with an error of its own.{{ else }} stay in place.{{ end }}
{{ end }}
SECRETS — THE FIELDS EACH 1PASSWORD ITEM MUST CARRY
All three OnePasswordItem CRs are created unconditionally and carry an item
path only. The field names below are decided here and appear nowhere in
1Password, so they are what you would otherwise read out of the templates. A
field missing from the item does not fail this install: the operator writes a
Secret without that key, and the pod stays in CreateContainerConfigError.
{{ .Values.secrets.voucherVaultSecretRef }}
→ Secret {{ .Release.Name }}-{{ .Chart.Name }}-secret
secretKey Django's SECRET_KEY. Changing it logs everyone out.
redis-url Celery broker, e.g. redis://:PASSWORD@redis:6379/0
{{ .Values.secrets.databaseSecretRef }}
→ Secret {{ .Release.Name }}-{{ .Chart.Name }}-db-secret
host port user password database
{{ .Values.secrets.oidcSecretRef }}
→ Secret {{ .Release.Name }}-{{ .Chart.Name }}-oidc-secret
client-id client-secret
authorization-endpoint token-endpoint user-endpoint
oidc-jwks-endpoint
The OIDC item is required even if you turn OIDC_ENABLED off in voucherVault.env,
because the CR is not gated. Sign algorithm is RS256, which is why the JWKS
endpoint is a field and not an optional extra. The redirect URI to register at
the provider is
https://{{ if or .Values.ingress.enabled $httpRoute.enabled }}{{ first $hostnames }}{{ else }}example.com{{ end }}/oidc/callback/
and it carries no language prefix, unlike every other path of the app.
{{- if $missing }}
voucherVault.env HAS REPLACED THE CHART'S LIST — {{ len $missing }} REQUIRED ENTRIES ARE GONE
For this chart voucherVault.env is the container's COMPLETE environment, not
an additive block: a values file that sets it overwrites the whole list,
including the entries that wire up the secrets. Missing right now:
{{ join ", " $missing }}.
Copy the full list out of the chart's values.yaml and edit that copy.
{{- end }}
THE FIRST START PRINTS THE ONLY ADMIN PASSWORD IT WILL EVER PRINT
Against an empty database the entrypoint creates the superuser "admin" with a
randomly generated password and writes it to stdout — once, never again, and
into no Secret:
kubectl -n {{ .Release.Namespace }} logs {{ $pod }} | grep -A3 "Creating superuser"
If that line has already scrolled out of the log, reset it in place:
kubectl -n {{ .Release.Namespace }} exec -it {{ $pod }} -- python manage.py changepassword admin
With OIDC enabled the app drops its password-change pages and the login form
redirects to the provider on its own (OIDC_AUTOLOGIN). Django's own admin
login at /admin/ is not covered by that redirect — that is the way back in
when the provider is down or its item is wrong.
A POD THAT NEVER STARTS IS USUALLY WAITING FOR POSTGRES
Before it opens the HTTP port, the entrypoint loops on
"nc -z $POSTGRES_HOST $POSTGRES_PORT" with no timeout. A wrong host or port in
the database item therefore produces no error at all — the log simply stops at
[TASK] Waiting for PostgreSQL db...
until the startup probe kills the container. That budget is
{{ $startup.periodSeconds }}s x {{ $startup.failureThreshold }} failures = {{ mul $startup.periodSeconds $startup.failureThreshold }}s, and it surfaces as CrashLoopBackOff.
The other candidate is one line earlier: the entrypoint exits 1 with "[FAIL]
Insufficient permissions on bind mount volume" when /opt/app/database is not
writable for uid/gid 33. fsGroup 33 covers that on
{{ if .Values.storage.storageClass }}storage class "{{ .Values.storage.storageClass }}"{{ else }}the cluster's default storage class{{ end }}, as long as it honours fsGroup.
WHAT A READY POD DOES NOT PROVE
The probes send "Host: 127.0.0.1" on purpose: that host is always in
ALLOWED_HOSTS, so probing survives every domain change. The price is that a
wrong ingress.domain is invisible to Kubernetes — all three probes pass, the
rollout goes green, and every real request gets 400. Check it against the
domain instead:
{{ if or .Values.ingress.enabled $httpRoute.enabled }}
curl -sI https://{{ first $hostnames }}/ | head -1
{{- else }}
curl -sI http://127.0.0.1:8000/ | head -1 # through the port-forward above
{{- end }}
A 302 to a language-prefixed path is the healthy answer; 400 means the Host
does not match. Celery is the second blind spot: worker and beat are started
detached from the same entrypoint, so uwsgi answers whether or not they came
up. A wrong redis-url costs the expiry notifications and shows nothing else.
THIS RELEASE IS SINGLE-INSTANCE BY CONSTRUCTION
Uploaded files land in /opt/app/database, on the one ReadWriteOnce PVC
{{ .Release.Name }}-{{ .Chart.Name }}-pvc ({{ .Values.storage.amount }}),
which every pod of the StatefulSet mounts at the same path — and each pod runs
its own Celery beat. Raising voucherVault.replicas above 1 therefore buys
nothing: further pods stay Pending on a node that does not hold the volume,
and where they do start they send every expiry notification a second time.
UPGRADES MIGRATE, ROLLBACKS DO NOT UNMIGRATE
Every single start runs makemigrations and migrate before the server comes up,
so an app-version bump changes the schema the moment the new pod boots.
"helm rollback" puts the old image back but leaves the migrated schema in
place, and the old code then meets columns it does not know. Back up before
bumping the app version, and back up both halves: the rows live in Postgres,
the uploads on the PVC.
EXPIRY NOTIFICATIONS STAY SILENT UNTIL A USER ADDS AN APPRISE URL
EXPIRY_THRESHOLD_DAYS ({{ index $env "EXPIRY_THRESHOLD_DAYS" | default 30 }}) and EXPIRY_LAST_NOTIFICATION_DAYS ({{ index $env "EXPIRY_LAST_NOTIFICATION_DAYS" | default 7 }})
decide when an item counts as expiring, not whether anyone hears about it:
delivery runs over the Apprise URLs each user stores in their own profile, and
a profile without one is skipped without a word. The "Periodic Expiry Check"
schedule runs daily at 09:00 in the container's time zone
(TZ={{ index $env "TZ" | default "Europe/Berlin" }}) and is created ONLY while an empty database is initialized —
delete it in the admin and no later start brings it back.
{{- if .Values.networkPolicy.enabled }}
NETWORKPOLICY IS ACTIVE — WHAT IT CAPS, AND WHAT IT DOES NOT
{{ .Release.Name }}-{{ .Chart.Name }}-networkpolicy selects this workload's pods.
Inbound: TCP 8000 only. Outbound: DNS, TCP 5432 (Postgres), 6379 (Redis) and
443 (OIDC){{ with .Values.networkPolicy.egress }}{{ with .extraPorts }}, plus TCP {{ join ", " . }}{{ end }}{{ end }}.
THOSE ARE THE STANDARD PORTS, NOT YOUR CONFIGURED ONES — the connection
details live in 1Password items this chart never parses. A dependency
elsewhere is blocked; add its port to networkPolicy.egress.extraPorts.
Three limits belong in the same breath, because none of them is visible in
the object itself:
- EGRESS IS BY PORT, NEVER BY DESTINATION. A NetworkPolicy matches CIDRs and
selectors and cannot express a hostname, so every egress rule permits its
port to ANY address. That is a limit of the object, not a shortcut — this
cluster's CNI (Canal: Flannel plus Calico) has no FQDN rule either. An
outbound port the chart does not know about is BLOCKED: add it to
networkPolicy.egress.extraPorts, and expect to find out through an
application-level connection error rather than through this object.
{{- if .Values.networkPolicy.probeSources }}
- THE PROBE PATH IS RESTRICTED in this release, because
networkPolicy.probeSources is set to
{{ join ", " .Values.networkPolicy.probeSources }}
Probes come from the IP of the WORKER NODE running the pod. If none of those
CIDRs covers the node this pod lands on, the kubelet's probes are dropped
and THE POD NEVER BECOMES READY — the rollout then times out and the symptom
looks like a broken application. Compare
kubectl -n {{ .Release.Namespace }} describe pod -l app={{ .Release.Name }}-{{ .Chart.Name }}-sfs
against `kubectl get node -o wide`.
{{- else }}
- THE PROBED PORT IS OPEN INSIDE THE CLUSTER. networkPolicy.probeSources is
empty, so that rule carries no source selector at all and any pod the CNI
routes to can reach it. Not from the internet — but this is NOT pod-level
isolation. Narrowing it means listing the worker node IPs in
networkPolicy.probeSources, because the kubelet probes from the node's own
address and the chart cannot know it; a wrong guess there leaves the pod
permanently un-Ready.
{{- end }}
- IT IS ONLY A BOUNDARY WHERE THE CNI ENFORCES POLICIES. Where it does not,
this object is accepted and filters nothing, so everything reads as
configured while nothing is filtered. `kubectl -n {{ .Release.Namespace }} get networkpolicy`
shows the object exists; only a connection attempt from an unrelated
namespace shows that it bites.
Switch it off with networkPolicy.enabled: false.
{{- end }}