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>
This commit is contained in:
2026-06-23 21:58:36 +02:00
parent 548dacfc44
commit 4823394e0e
5 changed files with 450 additions and 0 deletions

View File

@@ -0,0 +1,118 @@
[vibe](../../README.md) > [Guidebooks](../README.md) > **Applications**
# Applications
> **Status:** ✅ Active
> **Last Updated:** 2026-06-23
> **Upstream:** [Lab ecosystem hub](../lab-ecosystem/README.md) · [01 · factory](../lab-ecosystem/01-factory.md)
> **Downstream:** [webapp](webapp.md) · [url-shortener](url-shortener.md)
> **Related:** [naming-conventions](../lab-ecosystem/naming-conventions.md) · [tools secrets-and-vso](../tools/secrets-and-vso.md) · [factory postgres-iac](../factory-provisioning/opentofu/postgres-iac.md) · [safe-prod-like-environment ADR](../../ADR/0001-safe-prod-like-environment.md)
This guidebook maps the **deployed applications** — the workloads ArgoCD runs in their own `<app>` namespace — and, more importantly, the **single repeatable pattern** every one of them follows. Once you know the pattern, every app reads as a variation on the same skeleton: a Gitea repo whose contents (Dockerfile + Helm chart + optional Vault IaC + CI) and whose `<app>` name fully determine how it builds, deploys, gets its secrets, and is reached from the network.
Two apps are presented in depth as the canonical archetypes: [webapp](webapp.md) (Go + external Postgres) and [url-shortener](url-shortener.md) (Rust + embedded SQLite). Other apps in the cluster — `erp`, `dance-lessons-coach`, `telegram-gateway`, `plausible` — instantiate the same pattern; `erp` has its own guidebook (forthcoming) and is not linked here yet.
## The common app pattern
Every application is a self-contained Gitea repo under the `arcodange-org` (or `arcodange`) org that carries the same four ingredients. The `<app>` name — the repo name — is the join key that threads through all of them (see the [naming-conventions concept](../lab-ecosystem/naming-conventions.md)).
| Ingredient | Path in the app repo | What it is | Required? |
|---|---|---|---|
| **Dockerfile** | `Dockerfile` | Multi-stage build producing the runtime image, pushed to the Gitea container registry as `gitea.arcodange.lab/<org>/<app>` | ✅ always |
| **Helm chart** | `chart/` | `Chart.yaml` + `values.yaml` + `templates/` (deployment, service, ingress, serviceaccount, hpa, config, NOTES, optional PVC, optional Vault CRDs) — the unit ArgoCD syncs | ✅ always |
| **Vault IaC** | `iac/` | OpenTofu that declares the app's Vault objects: a Postgres dynamic-secret role keyed on `<app>` + a Kubernetes auth role bound to the `<app>` ServiceAccount. The canonical form pulls the [`app_roles` module](../tools/secrets-and-vso.md) from `tools`; the privileged `app_policy` half is declared centrally so the app repo never holds it | 🟡 only apps needing Postgres / Vault KV |
| **CI workflows** | `.gitea/workflows/` | A `dockerimage` job that builds + pushes the image on every `main` push, and (when `iac/` exists) a `vault` job that runs `tofu apply` against Vault, gated to changes under `iac/*.tf` | ✅ image build · 🟡 vault apply |
### How a chart becomes a running app
Factory's [ArgoCD app-of-apps](../lab-ecosystem/01-factory.md) emits **one `Application` CRD per app**, and every field is derived mechanically from the `<app>` name:
| Application field | Value | Source |
|---|---|---|
| `repoURL` | `https://gitea.arcodange.lab/<org>/<app>` | `<app>` + optional org override |
| `path` | `chart` | fixed convention |
| `namespace` | `<app>` (`CreateNamespace=true`) | `<app>` |
| `syncPolicy` | `automated` with `prune: true` + `selfHeal: true` | app-of-apps default |
The same `<app>` name is also the Postgres database/role name, the Vault role name, the KV path prefix, and the ServiceAccount name — one string keying the whole stack. See [naming-conventions](../lab-ecosystem/naming-conventions.md).
### Ingress convention — `.fr` public vs `.lab` internal
Every app that serves HTTP exposes itself through two Traefik ingresses with a fixed split by domain suffix:
| Ingress | Domain | Traefik entrypoint | Middlewares | TLS / cert | Reached via |
|---|---|---|---|---|---|
| **Public** | `<app>.arcodange.fr` | `web` | `kube-system-crowdsec@kubernetescrd` (CrowdSec bouncer) | terminated at the edge | the **Cloudflared tunnel** — the public web entrypoint |
| **Internal** | `<app>.arcodange.lab` | `websecure` | `localIp@file` (LAN-only allow-list) | a cert from **either** the Traefik `letsencrypt` resolver **or** cert-manager's `step-issuer` (`StepClusterIssuer`) | the LAN directly |
> [!NOTE]
> The two archetypes differ only in cert mechanism, not in the convention: webapp's internal ingress carries the `letsencrypt` certresolver annotations, while url-shortener's internal ingress requests its cert from cert-manager's `step-issuer`. Both still ride `websecure` + `localIp@file`; both still expose a `.fr` twin behind the CrowdSec middleware.
## Two archetypes compared
The deployed apps fall into two shapes. Pick the matching archetype's page when adding or modifying an app.
| Aspect | [webapp](webapp.md) | [url-shortener](url-shortener.md) |
|---|---|---|
| Language / build | **Go** (golang:1.23 → alpine runtime) | **Rust** (cargo-chef → `scratch` runtime) |
| State | **External Postgres**, reached through the `tools` **pgbouncer** pooler with credentials delivered by **VSO** | **Embedded SQLite** on a `/data` file |
| Persistence | none in-cluster (DB lives on `pi2`) | a **Longhorn RWO PVC** (`storageClassName: longhorn`, `helm.sh/resource-policy: keep`) mounted at `/data` |
| Replicas | **scalable** (stateless pods; HPA-ready) | **single** — RWO volume cannot be shared across pods |
| `iac/` + Vault | **yes** — declares a Postgres dynamic-secret role + a k8s auth role; pod consumes **dynamic, rotating** DB creds via `VaultAuth` + `VaultDynamicSecret` + `VaultStaticSecret` CRDs | **none** — no Vault objects, no DB role |
| Recovery | restore from the **PostgreSQL backup** (factory `05_backup``/mnt/backups`) | **Longhorn block-device recovery** of the PVC (raw replica `.img` files) — see [ansible recover](../factory-provisioning/ansible/06-recover.md) |
The choice is essentially *"shared/scalable state that survives a single node"* (Postgres, webapp shape) versus *"self-contained single-writer state co-located with the pod"* (SQLite-on-Longhorn, url-shortener shape). The trade-off and why both are kept prod-like is recorded in the [safe-prod-like-environment ADR](../../ADR/0001-safe-prod-like-environment.md).
## Generic app lifecycle
```mermaid
%%{init: {'theme': 'base'}}%%
flowchart LR
classDef src fill:#2563eb,stroke:#1e40af,color:#fff
classDef proc fill:#059669,stroke:#047857,color:#fff
classDef store fill:#7c3aed,stroke:#6d28d9,color:#fff
classDef net fill:#b45309,stroke:#92400e,color:#fff
REPO["app repo<br>Dockerfile + chart/ + iac/ + .gitea/workflows"]:::src
IMG["image pushed<br>gitea registry &lt;org&gt;/&lt;app&gt;"]:::store
VAULT["tofu apply<br>Vault role + Postgres role"]:::store
ARGO["ArgoCD<br>deploys chart (ns &lt;app&gt;)"]:::proc
POD["pod<br>Postgres via pgbouncer + VSO<br>OR SQLite on Longhorn PVC"]:::proc
TR["Traefik ingress<br>.fr public / .lab internal"]:::net
REPO -- "dockerimage CI" --> IMG
REPO -- "vault CI (iac apps only)" --> VAULT
IMG --> ARGO
VAULT -. "creds for DB apps" .- POD
ARGO --> POD
POD --> TR
```
1. The **app repo** holds the four ingredients: a Dockerfile, a `chart/`, an optional `iac/`, and `.gitea/workflows`.
2. On a push to `main`, the **dockerimage** workflow builds the image and pushes it to the Gitea container registry as `<org>/<app>`.
3. For apps with an `iac/`, the **vault** workflow runs `tofu apply` to declare the app's Postgres dynamic-secret role and Kubernetes auth role in Vault.
4. **ArgoCD** (factory's app-of-apps) syncs the chart into the `<app>` namespace, deriving `repoURL`/`path`/`namespace` from the `<app>` name.
5. The **pod** comes up; a Postgres-backed app receives rotating DB credentials through pgbouncer + VSO, while a SQLite-backed app mounts its Longhorn PVC at `/data`.
6. **Traefik** publishes the pod through two ingresses: the `.fr` public route (CrowdSec middleware, via the Cloudflared tunnel) and the `.lab` internal route (`websecure` + `localIp` + a letsencrypt/step-issuer cert).
## Index
| Page | Archetype | Status |
|---|---|---|
| [webapp](webapp.md) | Canonical **Go + external Postgres** exemplar — `iac/` + Vault dynamic creds, scalable stateless pods | ✅ Active |
| [url-shortener](url-shortener.md) | **Rust + embedded SQLite** counterpart — single replica on a Longhorn RWO PVC, no Vault | ✅ Active |
`erp` and the other apps (`dance-lessons-coach`, `telegram-gateway`, `plausible`) follow the same pattern; `erp` will be cross-linked here once its dedicated guidebook ships.
## Maintenance rule
> [!IMPORTANT]
> **When an app's repo changes shape, its page here changes in the same PR.** If you alter the chart structure, the ingress convention, the Vault wiring, the persistence model, or the CI workflows of a deployed app, update this hub and the relevant archetype page in the same change. A reference map that drifts from the real `chart/` and `iac/` sends agents confidently down dead paths.
## Cross-references
- [01 · factory](../lab-ecosystem/01-factory.md) — the ArgoCD app-of-apps that emits one `Application` per app on this page.
- [tools secrets-and-vso](../tools/secrets-and-vso.md) — the `app_policy` + `app_roles` module pair that turns `<app>` into Vault policies, roles, and CI identities; the VSO runtime path the Postgres archetype rides.
- [factory postgres-iac](../factory-provisioning/opentofu/postgres-iac.md) — the per-app PostgreSQL database + `<app>_role` the webapp archetype depends on.
- [naming-conventions](../lab-ecosystem/naming-conventions.md) — the `<app>` join key that threads through repo, image, namespace, DB, Vault role, and ServiceAccount.
- [safe-prod-like-environment ADR](../../ADR/0001-safe-prod-like-environment.md) — why the lab keeps apps deployed prod-like and the state/recovery trade-offs behind the two archetypes.

View File

@@ -0,0 +1,172 @@
[vibe](../../README.md) > [Guidebooks](../README.md) > [Applications](README.md) > **url-shortener**
# url-shortener (Chhoto URL)
> **Status:** ✅ active · **Last Updated:** 2026-06-23
> **Upstream:** [Applications index](README.md) · [lab-ecosystem hub](../lab-ecosystem/README.md)
> **Downstream:** [storage-and-recovery](../lab-ecosystem/storage-and-recovery.md) · [Ansible recover playbooks](../factory-provisioning/ansible/06-recover.md)
> **Related:** [webapp](webapp.md) (the stateless counterpart) · [naming-conventions](../lab-ecosystem/naming-conventions.md) · [secrets-and-vault](../lab-ecosystem/secrets-and-vault.md)
`url-shortener` is the lab's **stateful** application — the deliberate mirror image of [webapp](webapp.md). Where webapp is a horizontally-scalable, Postgres-backed, Vault-credentialed reference app, `url-shortener` is a single-pod, SQLite-on-a-disk service that proves the lab can run a genuinely stateful workload on Longhorn block storage and recover it after a crash.
The application itself is **Chhoto URL**, a tiny Rust/[actix-web](https://gitea.arcodange.lab/arcodange-org/url-shortener/src/branch/main/actix/Cargo.toml) URL shortener, mirrored into the lab from the upstream project [`sintan1729/chhoto-url`](https://github.com/SinTan1729/chhoto-url). The lab does not fork the source — it mirrors the published Docker image (see [§5 CI](#5-ci--mirror-not-build)) — but keeps a copy of the source and a custom Helm chart in [the `url-shortener` repo](https://gitea.arcodange.lab/arcodange-org/url-shortener/src/branch/main) so the lab owns its packaging.
> [!NOTE]
> A second public web app, the **erp** application, exists in the ecosystem but does not yet have a guidebook page; it is mentioned here in prose only and will be cross-linked once its guidebook ships.
---
## 1. The app & image
Chhoto URL is a single self-contained binary: an `actix-web` HTTP server with a bundled SQLite engine and a plain static frontend. There is no separate database process and no application runtime to install.
| Aspect | Detail | Source |
| --- | --- | --- |
| Language / framework | Rust, [`actix-web`](https://gitea.arcodange.lab/arcodange-org/url-shortener/src/branch/main/actix/src/main.rs) `4.5.x` | [`actix/Cargo.toml`](https://gitea.arcodange.lab/arcodange-org/url-shortener/src/branch/main/actix/Cargo.toml) |
| Database driver | [`rusqlite`](https://gitea.arcodange.lab/arcodange-org/url-shortener/src/branch/main/actix/src/database.rs) with the `bundled` feature — SQLite is compiled **into** the binary | `Cargo.toml` |
| Sessions | `actix-session` cookie store; the session key is regenerated at boot, so a restart invalidates all logins | [`actix/src/main.rs`](https://gitea.arcodange.lab/arcodange-org/url-shortener/src/branch/main/actix/src/main.rs) |
| Frontend | Plain HTML/CSS/JS served by `actix-files` from [`resources/`](https://gitea.arcodange.lab/arcodange-org/url-shortener/src/branch/main/resources) (`index.html`, `static/styles.css`, `static/script.js`, `static/404.html`) — no build step, no SPA framework | `resources/` |
| Listen port | `4567` (overridable via the `port` env var) | `actix/src/main.rs` |
| Slug generation | Adjective-name `Pair` or `UID` styles; the lab forces `UID` (see [§3](#3-the-chart)) | `actix/src/utils.rs` |
### Image build
| Image | Base / approach | Result | File |
| --- | --- | --- | --- |
| `Dockerfile` | `cargo-chef` dependency caching → musl static build (`x86_64-unknown-linux-musl`) → `FROM scratch`, copying only the binary + `resources/` | A single-arch, ~6 MB image with **no** OS, shell, or libc | [`Dockerfile`](https://gitea.arcodange.lab/arcodange-org/url-shortener/src/branch/main/Dockerfile) |
| `Dockerfile.multiarch` | Per-arch `FROM scratch` stages selected by `TARGETARCH`, copying pre-built musl binaries | `amd64` / `arm64` / `armv7` images from a local cross-compile | [`Dockerfile.multiarch`](https://gitea.arcodange.lab/arcodange-org/url-shortener/src/branch/main/Dockerfile.multiarch) |
| `compose.yaml` | Pulls the upstream `sintan1729/chhoto-url:latest`, binds `4567`, mounts a `db` volume at the SQLite path | Local-dev only — not the deployed topology | [`compose.yaml`](https://gitea.arcodange.lab/arcodange-org/url-shortener/src/branch/main/compose.yaml) |
The `FROM scratch` design is what makes the storage story so stark: the container has nothing **except** the SQLite file on its mounted volume. All durable state is one file.
---
## 2. Storage — the key contrast
This is the section that distinguishes `url-shortener` from every other lab app. The entire database is a single SQLite file, `/data/urls.sqlite`, living on a Longhorn PersistentVolumeClaim.
| PVC field | Value | Why it matters |
| --- | --- | --- |
| `accessModes` | `ReadWriteOnce` (RWO) | Only **one** node can mount the volume at a time |
| `resources.requests.storage` | `128Mi` | A URL table is tiny; this is generous |
| `storageClassName` | `longhorn` | Replicated block storage; the source of durability |
| `annotations` | `helm.sh/resource-policy: keep` | The PVC (and its data) **survives `helm uninstall`** |
| Mount | `/data` → SQLite at `/data/urls.sqlite` | Set by the ConfigMap `db_url` ([§3](#3-the-chart)) |
Source: [`chart/templates/pvc.yaml`](https://gitea.arcodange.lab/arcodange-org/url-shortener/src/branch/main/chart/templates/pvc.yaml) and the volume mount in [`chart/templates/deployment.yaml`](https://gitea.arcodange.lab/arcodange-org/url-shortener/src/branch/main/chart/templates/deployment.yaml).
Because the volume is **RWO**, `replicaCount` is hard-pinned to `1`. A second pod cannot mount the same volume, so there is **no HA and no rolling update** — the next pod can only start after the previous one has released the disk. This is the exact data shape that was reconstructed during the **2026-04-13 power-cut** incident via Longhorn block-device recovery (see [§6](#6-recovery)).
---
## 3. The chart
`url-shortener` ships its own Helm chart at [`chart/`](https://gitea.arcodange.lab/arcodange-org/url-shortener/src/branch/main/chart). It is deployed by ArgoCD like every other lab app, following the [naming conventions](../lab-ecosystem/naming-conventions.md).
| Concern | Setting | Source |
| --- | --- | --- |
| Image | `gitea.arcodange.lab/arcodange-org/url-shortener`, tag defaults to `.Chart.AppVersion` (`6.5.3`) | [`values.yaml`](https://gitea.arcodange.lab/arcodange-org/url-shortener/src/branch/main/chart/values.yaml), [`Chart.yaml`](https://gitea.arcodange.lab/arcodange-org/url-shortener/src/branch/main/chart/Chart.yaml) |
| Service | `ClusterIP`, port `4567` | [`service.yaml`](https://gitea.arcodange.lab/arcodange-org/url-shortener/src/branch/main/chart/templates/service.yaml) |
| Internal ingress | Host `url.arcodange.lab` on `websecure`; TLS via cert-manager `StepClusterIssuer` (`step-issuer`); `localIp@file` middleware (LAN-only) | [`values.yaml`](https://gitea.arcodange.lab/arcodange-org/url-shortener/src/branch/main/chart/values.yaml) |
| Public ingress | Host derived from the internal host by the `.lab → .fr` substitution in `_helpers.tpl`; `PathRegexp` matcher (`/[^/]+`) so it intercepts shortlink redirects; **no `localIp`** (it carries the `crowdsec` middleware instead, since the public path must be reachable) | [`public-ingress.yaml`](https://gitea.arcodange.lab/arcodange-org/url-shortener/src/branch/main/chart/templates/public-ingress.yaml), [`_helpers.tpl`](https://gitea.arcodange.lab/arcodange-org/url-shortener/src/branch/main/chart/templates/_helpers.tpl) |
| ConfigMap | `db_url=/data/urls.sqlite`, `site_url` = the `.fr` FQDN, `slug_style=UID`, `slug_length=4` | [`config.yaml`](https://gitea.arcodange.lab/arcodange-org/url-shortener/src/branch/main/chart/templates/config.yaml) |
| Probes | Liveness + readiness HTTP `GET /` on the `http` port | [`values.yaml`](https://gitea.arcodange.lab/arcodange-org/url-shortener/src/branch/main/chart/values.yaml) |
| Autoscaling | **Disabled.** `maxReplicas > 1` would fail under RWO (a second pod cannot mount the volume) | [`hpa.yaml`](https://gitea.arcodange.lab/arcodange-org/url-shortener/src/branch/main/chart/templates/hpa.yaml), [`values.yaml`](https://gitea.arcodange.lab/arcodange-org/url-shortener/src/branch/main/chart/values.yaml) |
> [!NOTE]
> The `_helpers.tpl` `url-shortener.fqdn` template builds the public `site_url` by taking the first internal ingress host and running `replace ".lab" ".fr"` — so the same value drives the internal `url.arcodange.lab` and the public `.fr` redirect domain. This is the same `.lab ↔ .fr` split documented in [naming-conventions](../lab-ecosystem/naming-conventions.md).
---
## 4. No iac/, no Vault — a deliberate deviation
`url-shortener` has **no `iac/` directory and no Vault CRDs**, and this is intentional, not an oversight.
| Convention (see [webapp](webapp.md)) | Why url-shortener skips it |
| --- | --- |
| `iac/` OpenTofu module declaring a Postgres role | There is no Postgres. SQLite is embedded; there is no database server to provision. |
| Vault `app_roles` + VSO-synced dynamic credentials | There are no credentials to issue — the app talks to a local file, not a networked database. See [secrets-and-vault](../lab-ecosystem/secrets-and-vault.md) for the pattern url-shortener opts out of. |
For a SQLite-on-a-file app, **the Helm chart *is* the IaC**: the PVC, the ConfigMap, and the ingress fully describe the deployable surface. There is no second provisioning tier. Compare with [webapp](webapp.md), where the Postgres role and Vault role are first-class infrastructure objects managed outside the chart.
---
## 5. CI — mirror, not build
The single workflow, [`.gitea/workflows/dockerimage.yaml`](https://gitea.arcodange.lab/arcodange-org/url-shortener/src/branch/main/.gitea/workflows/dockerimage.yaml), does **not** build the local `actix/` source. It mirrors the upstream image.
| Step | Action |
| --- | --- |
| 1. Discover version | `wget` the upstream `Cargo.toml` from `SinTan1729/chhoto-url` on GitHub and parse `version` |
| 2. Login | Authenticate to `gitea.arcodange.lab` using the `PACKAGES_TOKEN` secret |
| 3. Pull | `docker pull sintan1729/chhoto-url:<tag>` from Docker Hub (`latest` and the discovered version) |
| 4. Retag | `docker tag` to `gitea.arcodange.lab/<repo>:<tag>` |
| 5. Push | `docker push` both `latest` and the version tag to the Gitea registry |
So the in-repo `actix/` source and the `Dockerfile`/`Dockerfile.multiarch` exist for **reference and local cross-compilation** (`Makefile` targets `build-release` / `docker-release` use `cross` for `amd64` / `arm64` / `armv7`), but the cluster runs the **mirrored upstream image**. The multi-arch build is a manual, local-developer flow — it is not run in CI.
---
## 6. Recovery
`url-shortener`'s SQLite-on-RWO is the canonical example that the lab's recovery tooling targets. When a node dies mid-write or the cluster loses power, the durable artifact to reconstruct is exactly this kind of Longhorn block volume.
- The concept and policy live in [storage-and-recovery](../lab-ecosystem/storage-and-recovery.md).
- The mechanics live in the [Ansible recover playbooks](../factory-provisioning/ansible/06-recover.md): the `longhorn_data.yml` block-device recovery flow reconstructs precisely this single-file SQLite volume, which is what was recovered in the **2026-04-13** power-cut.
> [!WARNING]
> Single replica + RWO means **downtime on any pod move**: a node drain, an upgrade, or a reschedule cannot overlap the old and new pods — the new one waits for the disk to detach. There is **no application-level redundancy**. The only durability is **Longhorn volume replication plus backups**; if the volume is lost and unbacked, the URL database is gone. Treat backup health as the single point of failure for this app.
---
## 7. Deviations from convention (vs webapp)
A side-by-side of where `url-shortener` (stateful) departs from [webapp](webapp.md) (the stateless reference):
| Dimension | webapp | url-shortener |
| --- | --- | --- |
| Database | PostgreSQL (external server) | SQLite (embedded, single file) |
| Vault / secrets | `app_roles` + VSO-synced dynamic creds | **None** — no networked credentials |
| `iac/` directory | Yes (Postgres role, Vault role) | **None** — the Helm chart is the IaC |
| Replicas | Scalable (HPA-eligible) | **1, hard-pinned** (RWO forbids more) |
| Rolling update / HA | Yes | **No** — single pod, downtime on move |
| CI | Builds the app source | **Mirrors** the upstream image |
| Recovery shape | Postgres backup/restore | Longhorn block-device recovery |
---
## 8. Deploy + storage path
```mermaid
%%{init: {'theme':'base'}}%%
flowchart LR
upstream["Docker Hub<br>sintan1729/chhoto-url"]
ci["Gitea Actions<br>dockerimage.yaml (mirror)"]
reg["Gitea registry<br>gitea.arcodange.lab/<br>arcodange-org/url-shortener"]
argo["ArgoCD + Helm chart"]
pod["Pod (replicaCount 1)<br>actix on :4567"]
pvc["Longhorn PVC (RWO, 128Mi)<br>keep policy"]
db["/data/urls.sqlite"]
upstream -- "pull + retag" --> ci
ci -- "push latest + version" --> reg
reg -- "image ref" --> argo
argo -- "deploy" --> pod
pod -- "mounts (RWO)" --> pvc
pvc -- "holds" --> db
classDef box fill:#1f2933,stroke:#7b8794,color:#f5f7fa;
class upstream,ci,reg,argo,pod,pvc,db box;
```
1. **Upstream** — the canonical `sintan1729/chhoto-url` image is published to Docker Hub by the original maintainer.
2. **CI mirror** — the lab's `dockerimage.yaml` workflow pulls that image, retags it, and pushes it to the Gitea registry (it does not build from `actix/`).
3. **Gitea registry**`gitea.arcodange.lab/arcodange-org/url-shortener` holds both `latest` and the version tag.
4. **ArgoCD + Helm** — the chart references the registry image (tag defaults to `appVersion`) and renders the Deployment, Service, ingresses, ConfigMap, and PVC.
5. **Pod** — a single `actix` pod listens on `4567`; HPA and rolling updates are off.
6. **Longhorn PVC** — the pod mounts the RWO volume at `/data`; only one pod can hold it.
7. **SQLite file** — all durable state is the single `/data/urls.sqlite` file, which is what [Longhorn block-device recovery](../factory-provisioning/ansible/06-recover.md) reconstructs.
---
See also: [webapp](webapp.md) (the stateless, Postgres-backed contrast) · [Applications index](README.md) · [naming-conventions](../lab-ecosystem/naming-conventions.md) · [storage-and-recovery](../lab-ecosystem/storage-and-recovery.md).

View File

@@ -0,0 +1,155 @@
[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.