Skip to content

Knead Time

Knead Time is a static pizza-dough calculator on GitHub Pages. Its step reminders cannot come from the page — iOS wakes a web app's service worker only for a server-sent push — so this is the one server it has: a small FastAPI service that holds a phone's push subscription and the times of its hands-on steps, and sends a Web Push notification when each comes due. The source and the image live in the knead-time repository, so like Flowscape it deploys as plain manifests around our own image, per Conventions → Official upstream sources only.

At a glance

URL kneadtime.k8s.wlkr.ch, an API under /v1; the app that calls it is kneadtime.pizza
Authentication None. Browsers on kneadtime.pizza call it directly; CORS admits that origin only
Storage None on the pod
Database CloudNativePG Cluster kneadtime-db
Dials The browsers' push services, over HTTPS
Files kneadtime/

State

State Where Rebuildable?
Push subscriptions and the reminders queued for them PostgreSQL, via CloudNativePG Only by every phone tapping Remind me again; a lost database means one missed bake, not a lost account
The VAPID private key OpenBao kv/kneadtime/config, through an ExternalSecret No — rotating it retires every subscription taken with the old key

Configuration

Setting Why
Plain manifests, no chart One process, one port, one database; the service README lists the environment and that is all there is
The namespace enforces restricted The image runs as uid 65532, writes nothing and needs no capability; CloudNativePG's pods declare restricted-compliant contexts of their own
One replica, the default RollingUpdate The dispatcher that sends due reminders runs inside the process and claims due rows with FOR UPDATE SKIP LOCKED, so the two copies that overlap during a rollout never send the same reminder; nothing on the pod needs a Recreate gap
A Cluster, not SQLite on a PVC Conventions → PostgreSQL is always a CloudNativePG Cluster; DATABASE_URL is the uri key the operator writes to kneadtime-db-app
VAPID_PRIVATE_KEY from OpenBao A P-256 key in PEM, generated by make bao-secrets because a Password generator cannot produce one, which is why rule 7's PushSecret path does not apply; the service derives the public half at startup and serves it, so the static site carries no key
VAPID_SUBJECT is the site URL The contact a push service may use; a URL rather than a mailbox keeps an address out of a manifest
ALLOWED_ORIGINS is the site Only pages loaded from kneadtime.pizza may call the API from a browser; PR previews of the app live under the same origin
FORWARDED_ALLOW_IPS is the pod CIDR Envoy is the only thing that reaches the pod, and it appends the client's address to X-Forwarded-For; uvicorn trusts that header from the pod network only, so the service's per-address rate limit (ratelimit.py) counts the phone, not the Gateway
HTTPRoute targets the app's own Service Nobody logs in — browsers call the API on behalf of a phone that already gave its subscription. The Dependency-Track shape, not the Flowscape one
HTTPRoute matches /v1 only The app serves its API, its probes and /metrics on one port; the browser calls only /v1/vapid and /v1/schedules (src/lib/push/api.ts in the app repository), and the counters describe usage that is nobody else's business
revisionHistoryLimit: 2 Superseded ReplicaSets keep their Trivy config-audit reports until garbage collection; the default keeps ten

Network policy

The rules in kneadtime/networkpolicy.yaml, and why each is there. The shape is Conventions → Ship a CiliumNetworkPolicy.

Rule Why
Ingress from the namespace, from host and remote-node; egress to the namespace The rules every policy carries: What every policy contains
Ingress on 8080 from ingress The Gateway reaches the pod directly; there is no outpost to skip
Ingress from Prometheus on 8080 and 9187, GET /metrics only The app's counters and the database's exporter
Ingress from cnpg-system on 8000 The operator reads the instance manager's status
Egress to the four push-service names on 443 Apple, Google, Mozilla and Microsoft are where subscriptions live. The service refuses any other host at request time, so this list and the code's allow-list are one decision taken twice
The instance pods reach kube-apiserver The CloudNativePG instance manager reports its status there. The object store for WAL and base backups is the platform's cnpg-backups policy, see CloudNativePG → Backups

Secrets

kv/kneadtime/config holds one key, vapid-private-key, written by make bao-secrets in the platform repository. Overwriting it is gated behind a typed confirmation there: the push services answer 401 or 403 for every subscription taken with the old key, the service deletes them, and every phone has to opt in again.

Usage

Nothing to operate by hand. A phone with the app on its Home Screen taps Remind me on this device in the plan's menu; the receipt notification that arrives at once is the end-to-end check.

curl -s https://kneadtime.k8s.wlkr.ch/v1/vapid     # the public key, from outside the LAN

Health check

kubectl -n kneadtime get pods,cluster,externalsecret,httproute
kubectl -n kneadtime port-forward svc/kneadtime 8080:80 &
curl -s localhost:8080/metrics | grep kneadtime_push

kneadtime_push_sent_total moves when a reminder goes out; gone_total counts subscriptions the push services refused.

Pitfalls

  • The image has to be public on GHCR. The cluster has no pull secret for ghcr.io/janwelker; a version published while the package was private stays ImagePullBackOff until the package's visibility is changed.
  • readyz is the database. A pod that is up but not ready is a database that is not reachable; check the Cluster before the pod.
  • Nothing here sends on its own schedule. A reminder that did not arrive is either a subscription the push service retired (gone_total rose, the phone has to opt in again) or a phone whose Home Screen icon was removed, which drops the subscription on the device side.