go-doc/data/docs/go-bananas-deployment.md

554 lines
20 KiB
Markdown
Raw Permalink Normal View History

2026-10-07 15:05:48 +02:00
<!-- go-doc:generated:begin source=go-bananas:docs/deployment.md signature=7d8b696ba971c1cbd2e19854d26de304 generated=2026-10-05 18:14 UTC -->
Imported from docs/deployment.md in the go-bananas repository.
---
# Deployment
Running go-bananas, from a laptop to a production cluster.
---
## Contents
- [The deployment checklist](#the-deployment-checklist)
- [Configuration](#configuration)
- [Authentication](#authentication)
- [Helm](#helm)
- [The production checklist](#the-production-checklist)
- [Scaling](#scaling)
- [Backup and restore](#backup-and-restore)
- [Behind an ingress](#behind-an-ingress)
- [Monitoring](#monitoring)
- [Upgrading](#upgrading)
- [Troubleshooting](#troubleshooting)
---
## The deployment checklist
Four things must be right. Everything else has a working default.
1. **`config.publicBaseUrl`** — the externally reachable origin, no trailing slash.
It builds the OIDC redirect URI, the absolute URLs in the runtime manifest and the
HSTS decision. Wrong means a login loop.
2. **`identity.mode`** — `oidc` in production. `dev` and `disabled` are refused by the
application outside a development environment.
3. **The OIDC client is registered** with that exact redirect URI:
`{publicBaseUrl}/auth/callback`.
4. **`identity.oidc.existingSecret`** — a Secret you manage, holding a session secret
of at least 32 bytes and the provider's client secret.
The Helm chart refuses to render a manifest it knows is wrong, so items 1–2 fail at
`helm template` time rather than at run time.
---
## Configuration
Everything is an environment variable. Both `BANANAS_` and `GO_BANANAS_` prefixes are
accepted, so a ConfigMap can be mounted under a distinct prefix without colliding
with the pod's own environment. That is what makes one image usable across
environments.
### Server
| Variable | Default | Notes |
| --- | --- | --- |
| `BANANAS_ENVIRONMENT` | `development` | `production` disables every development affordance |
| `BANANAS_HTTP_ADDR` | `:8080` | |
| `BANANAS_PUBLIC_BASE_URL` | `http://localhost:8080` | No trailing slash. Required in practice |
| `BANANAS_BASE_PATH` | — | For a path-based ingress |
| `BANANAS_LOG_LEVEL` | `info` | `debug`, `info`, `warn`, `error` |
| `BANANAS_LOG_FORMAT` | `text` | `json` in production |
| `BANANAS_HTTP_READ_TIMEOUT` | `15s` | |
| `BANANAS_HTTP_WRITE_TIMEOUT` | `30s` | Generous: this is the hot asset path |
| `BANANAS_SHUTDOWN_TIMEOUT` | `25s` | Must be below the pod's grace period |
### Storage
| Variable | Default | Notes |
| --- | --- | --- |
| `BANANAS_DATABASE_PATH` | `./data/go-bananas.db` | |
| `BANANAS_DB_MAX_CONNS` | `8` | Small on purpose. SQLite serialises writers |
| `BANANAS_DB_BUSY_TIMEOUT` | `5s` | How long a blocked writer waits |
| `BANANAS_SEED_ON_START` | `true` | Reconciles the built-in themes |
### Runtime
| Variable | Default | Notes |
| --- | --- | --- |
| `BANANAS_RUNTIME_PUBLIC` | `true` | The runtime API requires no credential by design |
| `BANANAS_RUNTIME_CACHE_CONTROL` | `public, max-age=60, s-maxage=300, stale-while-revalidate=86400` | |
| `BANANAS_RUNTIME_CDN_BASE_URL` | — | Absolute base for the manifest, when assets come from a CDN |
| `BANANAS_RATE_LIMIT_PER_MINUTE` | `3000` | High: the pattern is one request per page view |
### Network
| Variable | Default | Notes |
| --- | --- | --- |
| `BANANAS_TRUSTED_PROXIES` | — | CIDRs whose `X-Forwarded-For` is believed. Empty means forwarding headers are ignored entirely |
| `BANANAS_CORS_ALLOWED_ORIGINS` | — | For the admin API only. The runtime API is public by design |
| `BANANAS_ENABLE_METRICS` | `true` | |
| `BANANAS_ENABLE_PROFILER` | `false` | A network-reachable profiler is a DoS surface. Pair with a NetworkPolicy |
### Authentication
| Variable | Default | Notes |
| --- | --- | --- |
| `BANANAS_AUTH_MODE` | `dev` | `oidc`, `dev`, `none`. The latter two are refused outside development |
| `BANANAS_SESSION_SECRET` | ephemeral in dev | At least 32 bytes. Rotating it logs out everyone |
| `BANANAS_SESSION_TTL` | `8h` | |
| `BANANAS_SESSION_SECURE` | `true` outside dev | |
| `BANANAS_SESSION_SAMESITE` | `Lax` | `None` requires `Secure` |
| `BANANAS_OIDC_ISSUER` | — | Must match the `iss` claim exactly |
| `BANANAS_OIDC_CLIENT_ID` | — | |
| `BANANAS_OIDC_CLIENT_SECRET` | — | From a Secret, never a ConfigMap |
| `BANANAS_OIDC_SCOPES` | `profile,email,groups` | `groups` is what the role mapping reads |
| `BANANAS_OIDC_ADMIN_GROUPS` | — | Comma separated |
| `BANANAS_OIDC_EDITOR_GROUPS` | — | |
| `BANANAS_OIDC_VIEWER_GROUPS` | — | |
| `BANANAS_OIDC_REDIRECT_URL` | derived | Override only if the provider has a different URI registered |
| `BANANAS_OIDC_SKIP_TLS_VERIFY` | `false` | Development only. Refused in production |
Full reference in `internal/config/config.go`, which is the single source of truth and
carries the reasoning for each default.
---
## Authentication
### The three modes
| Mode | Behaviour | Allowed in production |
| --- | --- | --- |
| `oidc` | OpenID Connect against a real provider | **Yes. This is the mode.** |
| `dev` | Every request is a local administrator | No. Refused by the application |
| `none` | Every request is granted administrator access | No. Refused by the application |
The refusal is in the code, not in documentation, and it is a hard `fail` in the
constructor — an accidentally deployed bypass is a total authentication bypass, so it
is made impossible to construct.
### The shared development realm
The chart defaults to `https://auth-dev.urpi.be/realms/urpi`, so a development cluster
works with no identity configuration.
The realm path is part of the issuer. The value must match the `iss` claim in the ID
token **exactly**, including a trailing slash if the provider emits one. A mismatch
here is the single most common cause of `invalid issuer` at the callback, and it is
worth checking first when a login loop appears.
### Registering the client
Redirect URIs, exact:
```
https://themes.example.com/auth/callback
https://bananas-dev.example.com/auth/callback
```
Post-logout redirect, if the provider requires one:
```
https://themes.example.com/
```
Web origins, for a cross-origin admin UI (same-origin needs none):
```
https://app.example.com
```
### Group-to-role mapping
```yaml
identity:
oidc:
adminGroups: [platform-admins]
editorGroups: [design-team]
viewerGroups: [everyone]
```
The most privileged matching group wins. **A user in no configured group gets
viewer.** That is deliberate: a mapping mistake must fail closed.
Group names are realm-specific and are deliberately not guessed by the chart. Read
them from the provider:
```bash
curl -s "$ISSUER/protocol/openid-connect/certs" | jq -r '.keys[].kid'
# or, with a token:
curl -s -H "Authorization: Bearer $TOKEN" "$ISSUER/admin/realms/urpi/groups" | jq '.[].name'
```
### A note on sessions
The session is a signed cookie with no server-side record
([ADR 0003](adr/0003-stateful-sessions.md)). Concretely:
- any replica authenticates any request, so a rollout logs nobody out;
- a stolen session cannot be revoked before it expires — bounded by
`BANANAS_SESSION_TTL`, eight hours by default;
- full invalidation means rotating the session secret, which logs out everyone.
For a design-token service this is the right trade. If you need per-session
revocation, or your organisation's policy requires it, this is the one component to
replace.
---
## Helm
```bash
helm upgrade --install go-bananas oci://ghcr.io/urpi/charts/go-bananas \
--namespace go-bananas --create-namespace \
--set config.publicBaseUrl=https://themes.example.com \
--set identity.oidc.issuer=https://auth.example.com/realms/urpi \
--set identity.oidc.clientId=go-bananas \
--set identity.oidc.existingSecret=go-bananas-auth \
--set identity.oidc.adminGroups[0]=platform-admins \
--set ingress.enabled=true \
--set ingress.hosts[0].host=themes.example.com \
--set ingress.tls[0].secretName=go-bananas-tls \
--set ingress.tls[0].hosts[0]=themes.example.com \
--wait --timeout 5m
```
### The Secret
```bash
kubectl create secret generic go-bananas-auth \
--namespace go-bananas \
--from-literal=session-secret="$(openssl rand -base64 48)" \
--from-literal=oidc-client-secret="$CLIENT_SECRET_FROM_YOUR_SECRET_MANAGER"
```
Key names are configurable through `identity.sessionSecretKey`,
`identity.oidc.clientSecretKey` and `identity.oidc.existingSecretKeys`, for a
Secret managed by an external secrets operator.
A chart-managed Secret (`identity.oidc.generateSecret=true`) generates the session key
and preserves it across upgrades by reading the live Secret back — so an upgrade does
not sign everyone out. It **cannot** generate the OIDC client secret, because that has
to come from the provider. A generated Secret therefore has an empty client secret,
which means the deployment fails to authenticate until one is supplied. For anything
real, use `existingSecret`.
### Validation at render time
The chart fails rather than producing a manifest it knows is wrong:
| Misconfiguration | Message |
| --- | --- |
| `publicBaseUrl` empty | Required; names what it is used for |
| `publicBaseUrl` has a trailing slash | The exact acceptable form |
| `shutdownTimeoutMs` ≥ grace period | Kubernetes would SIGKILL mid-shutdown |
| `identity.mode` unknown | The three valid modes |
| `identity.mode=oidc`, no issuer | Required, with the development-realm form |
| `identity.mode=oidc`, no secret | Names both ways to supply one |
| Both `existingSecret` and `generateSecret` | Mutually exclusive |
| `skipTLSVerify` with `environment: production` | Refused, with the reason |
| Any value outside the schema | The JSON-schema path |
Failing at render time means a bad release never reaches the API server: there is no
half-created Deployment and no `CrashLoopBackOff` to diagnose at 3am.
### Render it before you apply it
```bash
make helm-template # defaults
make helm-template-auth # configured for OIDC
helm lint deploy/helm/go-bananas --strict
```
---
## The production checklist
- [ ] `config.environment: production`
- [ ] `config.publicBaseUrl` is the real HTTPS origin, no trailing slash
- [ ] `identity.mode: oidc`
- [ ] The OIDC client registered with `{publicBaseUrl}/auth/callback`
- [ ] `identity.oidc.issuer` verified against the provider's discovery document
- [ ] Group-to-role mapping populated and **tested with a real account** in each role
- [ ] `identity.oidc.existingSecret` set; the session secret is at least 32 random bytes
- [ ] Ingress with TLS; `service.sessionSecure: true`
- [ ] `config.trustedProxies` set to the ingress controller, so the audit log records real client addresses
- [ ] Persistence enabled; a snapshot scheduled
- [ ] `networkPolicy.enabled: true`
- [ ] Resources set, with a memory limit
- [ ] Prometheus scraping `/metrics`
- [ ] A backup job that copies the SQLite file
- [ ] `make helm-template` reviewed
### Verify a deployment
```bash
kubectl --namespace go-bananas get pods -l app.kubernetes.io/instance=go-bananas
kubectl --namespace go-bananas port-forward svc/go-bananas 8080:80
curl -s localhost:8080/readyz | jq # "status": "ready"
curl -s localhost:8080/rt/v1/manifest.json | jq '.themes[].slug'
curl -sI 'localhost:8080/rt/v1/theme.css?slug=midnight' | grep -i x-theme-version
```
Then open the admin interface and confirm a real account can sign in and see a theme.
---
## Scaling
### Read scaling: put a CDN in front
The runtime is the hot path and it is entirely cacheable. Immutable-by-URL responses
mean a CDN is a complete answer for read scaling.
```yaml
config:
runtimeCdnBaseUrl: https://cdn.example.com/themes
```
The manifest then hands consumers the CDN URL, so they never reach the service at all.
### The one real constraint: one writer
SQLite on a local volume is single-node. Three shapes:
**One replica, one volume.** The recommended production shape. One replica serves a
very large number of consumers, because after the render cache a request is a map
lookup.
**Many replicas, a shared filesystem.** Works with NFS, CephFS or a ReadWriteMany
volume — anything that permits concurrent mounts and shared mmap, which WAL mode
requires. Expect lower throughput than local disk; measure before choosing it.
```yaml
persistence:
type: persistentVolumeClaim
accessMode: ReadWriteMany
size: 2Gi
storageClassName: nfs-client
deployment:
replicaCount: 3
```
The chart annotates the Deployment when a ReadWriteOnce volume is combined with more
than one replica, because that combination cannot work: only one pod mounts the
volume and the others crash-loop.
### If you outgrow SQLite
Move to PostgreSQL. The change is contained to `internal/store` — the only package
with SQL — and its interface is the set of methods it exposes rather than SQLite's
features. [ADR 0001](adr/0001-embedded-sqlite.md) covers when that point arrives.
---
## Backup and restore
A backup is a copy of one file, and the supported way to take it is the `backup`
subcommand:
```bash
kubectl exec -n go-bananas deploy/go-bananas -- \
/usr/local/bin/go-bananas backup --to /srv/data/snapshots/$(date -u +%Y%m%dT%H%M%SZ).db
```
It uses SQLite's `VACUUM INTO`, which reads through the database's own backup
machinery. Two consequences matter:
- **It is safe against a running service.** The snapshot is consistent even while
the application is serving writes, and it does not block them for the length of
the copy. There is no need to scale to zero.
- **Do not substitute `cp` for it.** Copying a SQLite file while a write is in
flight produces a torn copy: the header can describe one set of pages while the
file holds another. The result frequently opens without complaint and is corrupt.
Copying the `-wal` file alongside it is worse, because the two files are then
captured at different instants and the pair may not correspond to any point in
time.
The command refuses to overwrite an existing file, so a stale snapshot can never be
mistaken for a fresh one, and it will not snapshot an in-memory database, which has
nothing to preserve.
Run it from cron, a Kubernetes CronJob, or by hand. When you do it by hand, the
output path is printed in full so you can confirm where the file went.
### Restoring
```bash
kubectl scale deploy/go-bananas -n go-bananas --replicas=0
kubectl cp ./backup.db go-bananas-0:/srv/data/go-bananas.db
kubectl delete pod -n go-bananas -l app.kubernetes.io/instance=go-bananas
kubectl scale deploy/go-bananas -n go-bananas --replicas=1
```
A restored database serves immediately. The built-in themes are reconciled at
start-up, and a built-in theme an operator had edited is recognised by its version and
left alone.
Scale to zero before replacing the file. Copying over a database that another
process has open produces exactly the torn copy described above, and the running
service will keep writing to the file you just replaced.
### Retention
The audit log is the only unbounded table. Everything else is bounded by the number of
themes and their versions. Prune it on a schedule:
```sql
DELETE FROM audit_log WHERE at < datetime('now', '-180 days');
```
The chart's snapshot CronJob (`persistence.snapshot.enabled`) writes to
`<mountPath>/snapshots` on the same volume, so a snapshot survives the pod that took
it. Restore from there with the same `backup` machinery in reverse.
---
## Behind an ingress
### Path-based
```yaml
config:
publicBaseUrl: https://example.com/themes
basePath: /themes
```
Every route, including the OIDC redirect, is prefixed. The provider must be registered
with `https://example.com/themes/auth/callback`.
### Correct client addresses
`X-Forwarded-For` is believed only from a configured proxy. Without this, any client
can set it and spoof its address in the audit log.
```yaml
config:
trustedProxies:
- 10.42.0.0/16 # the ingress controller's node or pod CIDR
```
### TLS
Terminate at the ingress. `service.sessionSecure: true` marks the session cookie
`Secure`, and HSTS is advertised only when `publicBaseUrl` is `https://`.
Behind a proxy that terminates TLS and forwards `X-Forwarded-Proto`, the service
derives scheme from `publicBaseUrl`, not from the header, which is the correct trust
boundary: the operator states the truth in configuration rather than a client
asserting it.
---
## Monitoring
### Metrics
| Metric | Type | Alert when |
| --- | --- | --- |
| `bananas_http_requests_total` | counter | Rate collapses, or 5xx ratio climbs |
| `bananas_http_request_duration_seconds` | histogram | p99 above ~250 ms |
| `bananas_cache_hits_total` / `_misses_total` | counter | Hit rate below ~80% after warm-up |
| `bananas_go_memory_bytes` | gauge | Approaches the limit |
| `bananas_uptime_seconds` | gauge | Resets: the process restarted |
A sustained cache miss rate means a publish loop, a version-keyed cache storm, or
more themes than expected. It is the first thing to look at when latency rises.
### Logs
One JSON line per request, carrying `requestId`, `route`, `status`, `duration`,
`clientIp`, `subject` and `query`. The `subject` field is the authenticated identity,
so "who did this" needs no second query.
```bash
kubectl logs -n go-bananas -l app.kubernetes.io/instance=go-bananas \
| jq 'select(.status >= 500)'
```
### Probes
| Path | Use | Touches the database |
| --- | --- | --- |
| `/livez` | Liveness | No |
| `/healthz` | Liveness, with build information | No |
| `/readyz` | Readiness | **Yes** |
The split is deliberate. A liveness probe that touched the database would have the
kubelet restart a process that is otherwise healthy, turning a dependency blip into
an outage. Readiness does check the database, because a pod that cannot reach it
cannot serve.
---
## Upgrading
```bash
helm upgrade go-bananas oci://ghcr.io/urpi/charts/go-bananas \
--namespace go-bananas \
--set image.tag=1.4.2 \
--wait --timeout 5m
```
Migrations apply at start-up and are idempotent. The built-in themes are reconciled:
missing ones are created, and ones an operator has edited are recognised by version
and left alone.
Enable `persistence.snapshot.enabled` for a scheduled snapshot. It is a CronJob
that runs before each upgrade, and it costs a few megabytes.
### Rolling back
```bash
helm rollback go-bananas 3 --namespace go-bananas --wait
```
Safe, because the schema is additive and migrations are idempotent. If a future change
is destructive, it gets a snapshot step in the chart and an entry in
[the runbook](operations.md).
---
## Troubleshooting
**A login loop.** The redirect URI does not match what the provider has registered.
Check in this order:
1. `{publicBaseUrl}/auth/callback` in the provider's client configuration.
2. `publicBaseUrl` has no trailing slash and matches the scheme exactly.
3. `basePath` is reflected in the registered URI.
4. `identity.oidc.issuer` matches the discovery document's `issuer` field **exactly**.
```bash
kubectl exec -n go-bananas deploy/go-bananas -- \
wget -qO- "$ISSUER/.well-known/openid-configuration" | jq -r .issuer
```
**`invalid issuer`.** The `iss` claim in the ID token differs from the configured
issuer. Usually a trailing slash, or a realm path that is not the one configured.
**`no such user in the roles mapping`.** Not an error this service produces: a user in
no configured group gets viewer, silently. Check the `groups` claim is present —
`groups` must be in `OIDC_SCOPES`.
**A pod stuck in `CrashLoopBackOff`.** Almost always configuration. The first log line
is the reason; every configuration problem is reported in full at start-up.
**`SQLITE_BUSY`.** A writer is waiting for the lock. Raise
`config.database.busyTimeoutMs`, or check for a publish loop.
**Replicas crash-looping with a ReadWriteOnce volume.** Exactly one pod can mount it.
Set `deployment.replicaCount: 1`, or use a filesystem that permits concurrent mounts.
**A theme looks wrong in one application.** Compare `X-Theme-Version` across
applications. A CDN or browser holding an old version explains it; the runtime
manifest's `baseUrl` will show whether a CDN is in the path.
**The stylesheet is stale after a publish.** Expected within one `s-maxage` window
(300 s by default). To force it, lower `config.runtimeCacheControl` or purge the CDN.
<!-- go-doc:generated:end -->