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.
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 staysImagePullBackOffuntil the package's visibility is changed. readyzis the database. A pod that is up but not ready is a database that is not reachable; check theClusterbefore the pod.- Nothing here sends on its own schedule. A reminder that did not arrive is
either a subscription the push service retired (
gone_totalrose, the phone has to opt in again) or a phone whose Home Screen icon was removed, which drops the subscription on the device side.