Files
factory/vibe/guidebooks/applications/webapp.md
Gabriel Radureau 4823394e0e docs(vibe): add applications/ guidebook (webapp + url-shortener)
Tree-docs guidebook under vibe/guidebooks/applications/ documenting the common
app pattern and two contrasting archetypes, drilling into lab-ecosystem/01-factory
(bidirectional):

- README.md  : the shared app pattern (repo = Dockerfile + chart + optional iac +
  CI; ArgoCD app-of-apps; the <app> join key; .fr vs .lab ingress conventions) +
  a two-archetype comparison.
- webapp.md  : canonical Go + Postgres exemplar (chart, VaultAuth/Static/Dynamic
  CRDs, inline iac vs the shared app_roles module, CI); notes the current nuance
  that the live pod still uses the static pgbouncer_auth DATABASE_URL.
- url-shortener.md : Rust + SQLite-on-Longhorn-RWO counterpart (single replica,
  no iac/no Vault, CI mirrors the upstream image); the power-cut recovery story.

erp is referenced in prose only (its own guidebook lands next). Sibling-repo code
via full gitea URLs; 2 mermaid diagrams MCP-validated; zero dead links.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 21:58:36 +02:00

156 lines
16 KiB
Markdown

[vibe](../../README.md) > [Guidebooks](../README.md) > [Applications](README.md) > **webapp**
# webapp
> **Status:** ✅ Active
> **Last Updated:** 2026-06-23
> **Upstream:** [Applications](README.md) · [01 · factory](../lab-ecosystem/01-factory.md) · [tools secrets-and-vso](../tools/secrets-and-vso.md)
> **Downstream:** the template every simple Postgres-backed app (`erp`, `dance-lessons-coach`) is cloned from
> **Related:** [url-shortener](url-shortener.md) · [naming-conventions](../lab-ecosystem/naming-conventions.md) · [secrets-and-vault concept](../lab-ecosystem/secrets-and-vault.md) · [factory postgres-iac](../factory-provisioning/opentofu/postgres-iac.md) · [tofu CI apply flow](../factory-provisioning/opentofu/ci-apply-flow.md) · [safe-prod-like-environment ADR](../../ADR/0001-safe-prod-like-environment.md)
`webapp` is the **canonical simple-app exemplar** — a deliberately small Go diagnostic app whose whole job is to exercise the lab's plumbing so every other Postgres-backed app can be cloned from its shape. It ships the four ingredients of the [common app pattern](README.md) (Dockerfile, `chart/`, `iac/`, `.gitea/workflows`) in their most legible form, with no business logic to read around. When you add a new simple app, this repo is the skeleton you copy.
What it actually does at runtime is a handful of probes:
| Endpoint | Purpose |
|---|---|
| `GET /` | Serves an HTML form that posts a number to `/query` |
| `GET /query?param=N` | Runs the parameterized query `SELECT 42 + $1` against Postgres and renders the result — the end-to-end "is the DB reachable and answering" check |
| `GET /liveness` | Always-`200 OK` liveness probe (no DB touch) |
| `GET /readiness` | Calls `db.Ping()`; returns `503 NOT READY` if Postgres is unreachable — so the pod only takes traffic once the DB is live |
| `GET /display-info` | Dumps the request's cookies, client IP, and headers — used to confirm the real client IP survives the ingress path |
| `GET /oauth-callback`, `/retrieve`, `/test-oauth-callback` | OAuth device-flow test endpoints (see below) — a workaround for Gitea lacking the OIDC device grant |
> [!NOTE]
> The OAuth endpoints exist because Gitea's OIDC provider only advertises `authorization_code` + `refresh_token`, not the device grant. `webapp` stands in as the redirect target: `/oauth-callback` stores the `code` keyed by the client-chosen `state` in an in-memory cache (5-minute TTL), and a CLI client then polls `/retrieve?state=…` to exchange its `state` for the `code`. `/retrieve` is IP-gated — it only answers callers in the LAN CIDRs (`192.168.0.0/16`, the IPv6 prefix, the k3s `10.42.0.0/16`) or an explicit `OAUTH_DEVICE_CODE_ALLOWED_IPS` allow-list — which is exactly why the pod must see the **real client IP** (see the `nodeSelector` note in [the chart](#2-the-chart)).
---
## 1) The app & image
A single-file Go program with two third-party dependencies, built into a tiny runtime image.
| Aspect | Value |
|---|---|
| Source | [`main.go`](https://gitea.arcodange.lab/arcodange-org/webapp/src/branch/main/main.go) — one file, `package main` |
| Module | [`go.mod`](https://gitea.arcodange.lab/arcodange-org/webapp/src/branch/main/go.mod) · `gitea.arcodange.lab/arcodange-org/webapp` · Go 1.23 |
| Postgres driver | `github.com/lib/pq` v1.10.9 — registered as the `postgres` driver; the connection string comes from `DATABASE_URL` |
| Cache | `github.com/patrickmn/go-cache` v2.1.0 — the in-memory `state → code` store for the OAuth callback (5 min default expiry, 10 min cleanup) |
| Listen port | `:8080`, plain HTTP (`net/http` default mux) — TLS is terminated upstream at Traefik |
| Query | `SELECT 42 + $1` — parameterized (`$1` bound, not interpolated) so the diagnostic endpoint is not an injection vector |
| Dockerfile | [`Dockerfile`](https://gitea.arcodange.lab/arcodange-org/webapp/src/branch/main/Dockerfile) — multistage: `golang:1.23-alpine` builder (`go build -o app .`) → `alpine:latest` runtime with `ca-certificates`, `EXPOSE 8080`, `CMD ["./app"]` |
| Image | `gitea.arcodange.lab/arcodange-org/webapp` — pushed to the Gitea container registry by CI (tags `latest` + the git ref name) |
The runtime image carries no config of its own: everything (`DATABASE_URL`, `OAUTH_ALLOWED_HOSTS`, the optional `OAUTH_DEVICE_CODE_ALLOWED_IPS`) arrives as environment variables from the chart's ConfigMap.
---
## 2) The chart
The Helm chart at [`chart/`](https://gitea.arcodange.lab/arcodange-org/webapp/src/branch/main/chart) ([`Chart.yaml`](https://gitea.arcodange.lab/arcodange-org/webapp/src/branch/main/chart/Chart.yaml): `name: webapp`, `appVersion: "latest"`) is the unit ArgoCD syncs into the `webapp` namespace. It is the boilerplate `helm create` scaffold plus the Vault CRDs and a hardcoded second ingress.
| Chart object | Template | Shape |
|---|---|---|
| **Deployment** | [`deployment.yaml`](https://gitea.arcodange.lab/arcodange-org/webapp/src/branch/main/chart/templates/deployment.yaml) | `replicaCount: 1`; `revisionHistoryLimit: 3`; one container on `containerPort: 8080`; `envFrom` the ConfigMap; liveness + readiness probes |
| **Node pinning** | `nodeSelector` in [`values.yaml`](https://gitea.arcodange.lab/arcodange-org/webapp/src/branch/main/chart/values.yaml) | `kubernetes.io/hostname: pi1` — pinned to the **network entrypoint node** so traffic avoids NAT and the pod sees the **real client IP** (load-bearing for the IP-gated `/retrieve` and for `/display-info`) |
| **ConfigMap** | [`config.yaml`](https://gitea.arcodange.lab/arcodange-org/webapp/src/branch/main/chart/templates/config.yaml) | `OAUTH_ALLOWED_HOSTS: webapp.arcodange.lab,webapp.arcodange.fr`; `DATABASE_URL: postgres://pgbouncer_auth:pgbouncer_auth@pgbouncer.tools/postgres?sslmode=disable` |
| **Public ingress** | [`ingress.yaml`](https://gitea.arcodange.lab/arcodange-org/webapp/src/branch/main/chart/templates/ingress.yaml) (values-driven) | host `webapp.arcodange.fr`, Traefik `web` entrypoint (HTTP), middleware `kube-system-crowdsec@kubernetescrd` (the CrowdSec bouncer) |
| **Internal ingress** | [`localIngress.yaml`](https://gitea.arcodange.lab/arcodange-org/webapp/src/branch/main/chart/templates/localIngress.yaml) (hardcoded manifest) | `Ingress/webapp-local`, host `webapp.arcodange.lab`, Traefik `websecure` entrypoint, `letsencrypt` certresolver, middleware `localIp@file` (LAN-only) |
| **Service** | [`service.yaml`](https://gitea.arcodange.lab/arcodange-org/webapp/src/branch/main/chart/templates/service.yaml) | `ClusterIP` on port 8080 → `http` target |
| **ServiceAccount** | [`serviceaccount.yaml`](https://gitea.arcodange.lab/arcodange-org/webapp/src/branch/main/chart/templates/serviceaccount.yaml) | created as `webapp`, token auto-mounted — the identity VSO uses to authenticate to Vault |
| **Probes** | `livenessProbe` / `readinessProbe` in [`values.yaml`](https://gitea.arcodange.lab/arcodange-org/webapp/src/branch/main/chart/values.yaml) | liveness → `/liveness` (cheap), readiness → `/readiness` (**pings the DB**), both on the `http` port |
| **HPA** | [`hpa.yaml`](https://gitea.arcodange.lab/arcodange-org/webapp/src/branch/main/chart/templates/hpa.yaml) | gated on `autoscaling.enabled`, which is `false`**HPA disabled**; the single replica is fixed |
> [!NOTE]
> The internal `.lab` ingress is shipped as a **hardcoded `localIngress.yaml`** (a plain manifest, not the templated `ingress.yaml`), and the equivalent block in `values.yaml` is left commented out. That is why webapp has two ingress *templates* but the values file only configures the `.fr` public one. The split — `web`/CrowdSec public vs `websecure`/`localIp`/letsencrypt internal — is the lab-wide [ingress convention](README.md#ingress-convention--fr-public-vs-lab-internal).
---
## 3) Vault CRDs in the chart
The chart ships the **full Vault Secrets Operator (VSO) wiring** as three CRDs. Together they let the pod authenticate to Vault as `webapp` and pull both static config and dynamic DB credentials.
| CRD | Template | What it declares |
|---|---|---|
| **VaultAuth** | [`vaultauth.yaml`](https://gitea.arcodange.lab/arcodange-org/webapp/src/branch/main/chart/templates/vaultauth.yaml) | `kubernetes` auth method on mount `kubernetes`, **role `webapp`**, ServiceAccount `webapp`, audience `vault` — the login other CRDs reference via `vaultAuthRef: auth` |
| **VaultStaticSecret** | [`vaultsecret.yaml`](https://gitea.arcodange.lab/arcodange-org/webapp/src/branch/main/chart/templates/vaultsecret.yaml) | `kv-v2` on mount `kvv2`, path **`webapp/config`** → k8s Secret **`secretkv`** (created by VSO), `refreshAfter: 30s` |
| **VaultDynamicSecret** | [`vaultdynamicsecret.yaml`](https://gitea.arcodange.lab/arcodange-org/webapp/src/branch/main/chart/templates/vaultdynamicsecret.yaml) | mount `postgres`, path **`creds/webapp`** → k8s Secret **`vso-db-credentials`** (created by VSO), with a `rolloutRestartTargets` entry on the `webapp` **Deployment** so the pod restarts when creds rotate |
The runtime path these ride — VSO reading Vault on the pod's behalf and materializing k8s Secrets — is documented in [tools secrets-and-vso](../tools/secrets-and-vso.md).
---
## 4) The key nuance — wiring shipped, live pod still on static creds
> [!NOTE]
> **webapp provisions the complete dynamic-DB-credentials path but does not yet consume it.** The chart's `VaultDynamicSecret` (path `postgres/creds/webapp` → Secret `vso-db-credentials`, with a `rolloutRestart` on the Deployment) and the matching `iac/` role together stand up the **entire** per-app dynamic-credentials machinery end to end. But the **running Deployment takes `DATABASE_URL` from the ConfigMap**, which points at the **shared static `pgbouncer_auth` user** (`postgres://pgbouncer_auth:pgbouncer_auth@pgbouncer.tools/postgres?sslmode=disable`), and it does **not** mount the `vso-db-credentials` Secret. So `webapp` demonstrates the dynamic-creds wiring in full as a reference, while its live pod runs on the shared static account. Switching the live pod to rotating per-app credentials is simply a matter of consuming the `vso-db-credentials` Secret (e.g. project its fields into `DATABASE_URL` instead of the ConfigMap value). This is the current state, by design as an exemplar — not a misconfiguration.
This is the one place to read `webapp` carefully: as a **template** it shows you every CRD and IaC resource a dynamic-creds app needs; as a **deployed workload** it is still on the shared pooler user. When you clone it for a real app, the last step is to wire the pod to `vso-db-credentials`.
---
## 5) iac/ — Vault objects declared inline
The OpenTofu under [`iac/`](https://gitea.arcodange.lab/arcodange-org/webapp/src/branch/main/iac) declares webapp's Vault objects. Unlike `erp` and `dance-lessons-coach`, which call the shared **`app_roles` module** from `tools` (see [tools secrets-and-vso](../tools/secrets-and-vso.md)), webapp declares them **inline** in `main.tf` — which is exactly why it reads as the legible reference: every resource is visible in one file rather than hidden behind a module call.
| File | Contents |
|---|---|
| [`providers.tf`](https://gitea.arcodange.lab/arcodange-org/webapp/src/branch/main/iac/providers.tf) | `vault` provider v4.4.0 at `https://vault.arcodange.lab`; authenticates via `auth_login_jwt { mount = "gitea_jwt", role = "gitea_cicd_webapp" }` (token from `TERRAFORM_VAULT_AUTH_JWT`) |
| [`backend.tf`](https://gitea.arcodange.lab/arcodange-org/webapp/src/branch/main/iac/backend.tf) | GCS backend, bucket `arcodange-tf`, prefix **`webapp/main`** |
| [`main.tf`](https://gitea.arcodange.lab/arcodange-org/webapp/src/branch/main/iac/main.tf) | three inline resources (below) |
The three resources in `main.tf`:
| Resource | Vault object | Detail |
|---|---|---|
| `vault_database_secret_backend_role.role` | `postgres/creds/webapp` | creation SQL `CREATE ROLE "{{name}}" WITH LOGIN PASSWORD … VALID UNTIL '{{expiration}}'` then **`GRANT webapp_role TO "{{name}}"`**; revocation `REVOKE ALL ON DATABASE webapp FROM …` |
| `vault_kubernetes_auth_backend_role.role` | k8s auth role `webapp` | bound to SA `webapp` + namespace `webapp`, `audience = vault`, `token_policies = ["default", "webapp"]`, `token_ttl = 3600` |
| `vault_kv_secret_v2.webapp_config` | `kvv2/webapp/config` | the KV-v2 config secret VSO reads into the `secretkv` k8s Secret |
> [!IMPORTANT]
> The `GRANT webapp_role TO …` statement depends on the **`webapp_role`** Postgres group role being created first by factory's [postgres-iac](../factory-provisioning/opentofu/postgres-iac.md). webapp's IaC mints *short-lived login roles* that inherit the privileges of that pre-existing `webapp_role`; if `webapp_role` does not exist, the dynamic-credential creation fails at grant time.
---
## 6) CI workflows
Two Gitea Actions workflows under [`.gitea/workflows/`](https://gitea.arcodange.lab/arcodange-org/webapp/src/branch/main/.gitea/workflows), each gated to the part of the repo it owns.
| Workflow | File | Trigger | What it does |
|---|---|---|---|
| **Hashicorp Vault** | [`vault.yaml`](https://gitea.arcodange.lab/arcodange-org/webapp/src/branch/main/.gitea/workflows/vault.yaml) | push / PR touching `iac/*.tf` (+ manual) | a `gitea_vault_auth` job mints the Gitea OIDC token, then a `tofu` job runs **`terraform apply`** on `iac/` with **OpenTofu 1.8.2**; reads `kvv1/google/credentials` for the GCS backend; `VAULT_CACERT` is built from the `HOMELAB_CA_CERT` secret |
| **Docker Build** | [`dockerimage.yaml`](https://gitea.arcodange.lab/arcodange-org/webapp/src/branch/main/.gitea/workflows/dockerimage.yaml) | push to `main` (ignoring `README.md`, `chart/**`) + manual | logs into the Gitea registry with `PACKAGES_TOKEN`, `docker build`, pushes **`latest`** and the **git ref-name** tag to `gitea.arcodange.lab/<repo>` |
The `vault.yaml` flow — Gitea OIDC → Vault JWT login → `tofu apply` with GCS state — is the lab-standard CI apply path described in [tofu CI apply flow](../factory-provisioning/opentofu/ci-apply-flow.md). The image is then rolled out cluster-side: ArgoCD Image Updater (factory side) watches the registry on a **digest** strategy and bumps the running Deployment when the `latest` digest changes.
---
## 7) `<app>` convention mapping for webapp
The single string `webapp` is the join key threading through every layer of the stack (the lab-wide [naming convention](../lab-ecosystem/naming-conventions.md)):
| Layer | Value for `webapp` |
|---|---|
| Gitea repo | `arcodange-org/webapp` |
| Container image | `gitea.arcodange.lab/arcodange-org/webapp` (tags `latest` + ref-name) |
| PostgreSQL | database `webapp`, group role **`webapp_role`** (from factory [postgres-iac](../factory-provisioning/opentofu/postgres-iac.md)) |
| Vault — dynamic DB | `postgres/creds/webapp` |
| Vault — KV config | `kvv2/webapp/config` |
| Vault — k8s auth role | `webapp` (policies `default`, `webapp`; SA + ns `webapp`) |
| Vault — CI JWT role | `gitea_cicd_webapp` (mount `gitea_jwt`) |
| Terraform state | GCS `arcodange-tf` prefix `webapp/main` |
| Kubernetes | namespace `webapp`, ServiceAccount `webapp` |
| ArgoCD | `Application` `webapp` (chart synced into ns `webapp`) |
| Ingress hosts | public `webapp.arcodange.fr` · internal `webapp.arcodange.lab` |
---
## Cross-references
- [url-shortener](url-shortener.md) — the **stateful contrast**: Rust + embedded SQLite on a Longhorn PVC, single replica, **no `iac/` and no Vault objects** at all. webapp (shared/scalable Postgres) vs url-shortener (self-contained single-writer SQLite) are the two archetypes of the [Applications hub](README.md).
- [tools secrets-and-vso](../tools/secrets-and-vso.md) — the VSO runtime path the Vault CRDs ride, and the shared `app_roles` module that `erp`/`dance-lessons-coach` use *instead* of webapp's inline declarations.
- [factory postgres-iac](../factory-provisioning/opentofu/postgres-iac.md) — creates the `webapp` database and `webapp_role` that webapp's `GRANT` depends on.
- [tofu CI apply flow](../factory-provisioning/opentofu/ci-apply-flow.md) — the Gitea OIDC → Vault JWT → `tofu apply` pipeline behind `vault.yaml`.
- [naming-conventions](../lab-ecosystem/naming-conventions.md) — the `<app>` join key tabulated in section 7.
- [safe-prod-like-environment ADR](../../ADR/0001-safe-prod-like-environment.md) — why a throwaway diagnostic app is still deployed prod-like, complete with dynamic-creds wiring.