From 800a19801744a0675639230c255a22144f97a5b3 Mon Sep 17 00:00:00 2001 From: jklein Date: Sat, 29 Aug 2026 00:02:51 +0200 Subject: [PATCH 1/2] voucher-vault: keep large integer quantities out of exponential notation The null-safe fallback helpers pass values through fromYaml, which parses every number as float64. Go's "%v" switches float64 to exponential notation from about 1e6 upwards, so a bare byte count like 1048576 reached multiplyQuantity as "1.048576e+06" and the render aborted with unsupported resource quantity suffix "e+06" in "1.048576e+06" Both the input and the non-integer product now go through a formatNumber helper that forces decimal notation and trims the padding zeros; strings such as 512Mi pass through untouched. This is the canonical fix agreed for all charts - multiplyQuantity is the same copy everywhere, so deviating here would create drift instead of a solution. Measured against ci/voucher-vault/test-values.yaml on both request keys that get a computed limit (memory, ephemeral-storage): 1048576 renders a limit of "3145728" instead of aborting, and the fallback case 65536 is unchanged at 196608. Part of #157 --- voucher-vault/templates/_helpers.tpl | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/voucher-vault/templates/_helpers.tpl b/voucher-vault/templates/_helpers.tpl index 6099f50..9c49673 100644 --- a/voucher-vault/templates/_helpers.tpl +++ b/voucher-vault/templates/_helpers.tpl @@ -67,7 +67,7 @@ Handles plain numbers ("2", "0.5", 1073741824), decimal ("1.5Gi"), binary results are printed without a decimal point. */}} {{- define "voucher-vault.multiplyQuantity" -}} -{{- $quantity := printf "%v" .quantity -}} +{{- $quantity := include "voucher-vault.formatNumber" .quantity -}} {{- $number := regexFind "^[0-9]+(\\.[0-9]+)?" $quantity -}} {{- if not $number -}} {{- fail (printf "cannot parse resource quantity %q" $quantity) -}} @@ -80,7 +80,24 @@ results are printed without a decimal point. {{- if eq $product (floor $product) -}} {{- printf "%d%s" (int64 $product) $suffix -}} {{- else -}} -{{- printf "%v%s" $product $suffix -}} +{{- printf "%s%s" (include "voucher-vault.formatNumber" $product) $suffix -}} +{{- end -}} +{{- end -}} + +{{/* +Render a number as a plain decimal string, never in exponential notation. + +Why this exists (issue #157): the null-safe fallback helpers pass values +through fromYaml, which parses every number as float64. Go's "%v" switches +float64 to exponential notation from about 1e6 upwards, so a bare byte count +like 1048576 became "1.048576e+06" - not a valid Kubernetes quantity, and the +render aborted. Strings ("512Mi") are passed through untouched. +*/}} +{{- define "voucher-vault.formatNumber" -}} +{{- if kindIs "float64" . -}} +{{- regexReplaceAll "\\.?0+$" (printf "%.10f" .) "" -}} +{{- else -}} +{{- printf "%v" . -}} {{- end -}} {{- end -}} From e0541a0352b6024918974e7a1ed5ac440012d548 Mon Sep 17 00:00:00 2001 From: jklein Date: Sat, 29 Aug 2026 00:03:06 +0200 Subject: [PATCH 2/2] voucher-vault: add a NOTES.txt worth reading twice (6.1.0+up1.30.1) The notes print what an operator would otherwise have to dig out of the templates or out of the image, not a restatement of the values. The centrepiece is the secret inventory: the three OnePasswordItem CRs carry item paths only, so the field names each item must contain are decided in this chart and appear nowhere in 1Password. A missing field does not fail the install - the operator writes a Secret without that key and the pod stays in CreateContainerConfigError. The warnings are the ones this chart earns, all read out of the running image rather than assumed: - The first start against an empty database creates the superuser "admin" with a random password and prints it to stdout exactly once. With OIDC on, the app drops its password-change pages and the login form redirects to the provider by itself, so /admin/ is the documented way back in. - The probes send Host: 127.0.0.1, which is always in ALLOWED_HOSTS. A wrong ingress.domain is therefore invisible: probes pass, the rollout goes green, and every real request gets 400. - The entrypoint waits for Postgres in an nc loop with no timeout before opening the HTTP port, so a wrong database host produces no error at all - just a log that stops, until the startup probe budget runs out. - Celery worker and beat are started detached from the same entrypoint; uwsgi answers whether or not they survived, so a wrong redis-url costs the expiry notifications silently. - Every start runs makemigrations and migrate, and helm rollback does not reverse them. - Expiry notifications need a per-user Apprise URL, and the beat schedule is created only while an empty database is initialized. Conditional blocks cover the ingress and no-ingress access paths, the scaled-down state, the storage class, and the case where voucherVault.env has replaced the chart's full env list instead of extending it. The startup budget and the replica state are read through the chart's own helpers. No real domain appears anywhere; the one place an example is needed uses example.com. Minor bump, purely additive: a NOTES.txt changes no rendered object. Part of #43 --- voucher-vault/Chart.yaml | 2 +- voucher-vault/templates/NOTES.txt | 174 ++++++++++++++++++++++++++++++ 2 files changed, 175 insertions(+), 1 deletion(-) create mode 100644 voucher-vault/templates/NOTES.txt diff --git a/voucher-vault/Chart.yaml b/voucher-vault/Chart.yaml index 36a1da0..ab1e08b 100644 --- a/voucher-vault/Chart.yaml +++ b/voucher-vault/Chart.yaml @@ -5,7 +5,7 @@ icon: https://raw.githubusercontent.com/l4rm4nd/VoucherVault/main/myapp/static/a type: application -version: 6.0.0+up1.30.1 +version: 6.1.0+up1.30.1 appVersion: "1.30.1" maintainers: diff --git a/voucher-vault/templates/NOTES.txt b/voucher-vault/templates/NOTES.txt new file mode 100644 index 0000000..1491041 --- /dev/null +++ b/voucher-vault/templates/NOTES.txt @@ -0,0 +1,174 @@ +{{- $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 }} +VoucherVault {{ .Chart.AppVersion }} — release {{ .Release.Name }} in namespace {{ .Release.Namespace }}. + +{{- if .Values.ingress.enabled }} + +ACCESS + 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 }} + +ACCESS — NO INGRESS + ingress.enabled is false, so this release has no Ingress 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 }} 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 .Values.ingress.enabled }}{{ .Values.ingress.domain }}{{ 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 .Values.ingress.enabled }} + curl -sI https://{{ .Values.ingress.domain }}/ | 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.