From 4823394e0e37b022ed9a262dc92ad986e95dbd12 Mon Sep 17 00:00:00 2001 From: Gabriel Radureau Date: Tue, 23 Jun 2026 21:58:36 +0200 Subject: [PATCH] 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 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 --- vibe/guidebooks/README.md | 1 + vibe/guidebooks/applications/README.md | 118 ++++++++++++ vibe/guidebooks/applications/url-shortener.md | 172 ++++++++++++++++++ vibe/guidebooks/applications/webapp.md | 155 ++++++++++++++++ vibe/guidebooks/lab-ecosystem/01-factory.md | 4 + 5 files changed, 450 insertions(+) create mode 100644 vibe/guidebooks/applications/README.md create mode 100644 vibe/guidebooks/applications/url-shortener.md create mode 100644 vibe/guidebooks/applications/webapp.md diff --git a/vibe/guidebooks/README.md b/vibe/guidebooks/README.md index 5f62593..55e1ff2 100644 --- a/vibe/guidebooks/README.md +++ b/vibe/guidebooks/README.md @@ -38,6 +38,7 @@ flowchart LR | [Factory provisioning](factory-provisioning/README.md) | Deep dive into how factory provisions everything: Ansible playbooks + roles and OpenTofu | ✅ Active | | [Tools](tools/README.md) | Deep dive into the lab platform services in the `tools` namespace (Vault+VSO, Prometheus, Grafana, CrowdSec, poolers, Redis, Plausible, ClickHouse) | ✅ Active | | [CMS](cms/README.md) | Deep dive into the public Nuxt site arcodange.fr + its Cloudflare DNS/tunnel/Turnstile and Zoho email IaC | ✅ Active | +| [Applications](applications/README.md) | The deployed apps and the common pattern they share — webapp (Go + Postgres) and url-shortener (Rust + SQLite); erp has its own guidebook | ✅ Active | ## Rules to contribute diff --git a/vibe/guidebooks/applications/README.md b/vibe/guidebooks/applications/README.md new file mode 100644 index 0000000..29c4ffc --- /dev/null +++ b/vibe/guidebooks/applications/README.md @@ -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 `` 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 `` 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 `` 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//` | ✅ 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 `` + a Kubernetes auth role bound to the `` 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 `` name: + +| Application field | Value | Source | +|---|---|---| +| `repoURL` | `https://gitea.arcodange.lab//` | `` + optional org override | +| `path` | `chart` | fixed convention | +| `namespace` | `` (`CreateNamespace=true`) | `` | +| `syncPolicy` | `automated` with `prune: true` + `selfHeal: true` | app-of-apps default | + +The same `` 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** | `.arcodange.fr` | `web` | `kube-system-crowdsec@kubernetescrd` (CrowdSec bouncer) | terminated at the edge | the **Cloudflared tunnel** — the public web entrypoint | +| **Internal** | `.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
Dockerfile + chart/ + iac/ + .gitea/workflows"]:::src + IMG["image pushed
gitea registry <org>/<app>"]:::store + VAULT["tofu apply
Vault role + Postgres role"]:::store + ARGO["ArgoCD
deploys chart (ns <app>)"]:::proc + POD["pod
Postgres via pgbouncer + VSO
OR SQLite on Longhorn PVC"]:::proc + TR["Traefik ingress
.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 `/`. +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 `` namespace, deriving `repoURL`/`path`/`namespace` from the `` 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 `` 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 + `_role` the webapp archetype depends on. +- [naming-conventions](../lab-ecosystem/naming-conventions.md) — the `` 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. diff --git a/vibe/guidebooks/applications/url-shortener.md b/vibe/guidebooks/applications/url-shortener.md new file mode 100644 index 0000000..d351aa8 --- /dev/null +++ b/vibe/guidebooks/applications/url-shortener.md @@ -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:` from Docker Hub (`latest` and the discovered version) | +| 4. Retag | `docker tag` to `gitea.arcodange.lab/:` | +| 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
sintan1729/chhoto-url"] + ci["Gitea Actions
dockerimage.yaml (mirror)"] + reg["Gitea registry
gitea.arcodange.lab/
arcodange-org/url-shortener"] + argo["ArgoCD + Helm chart"] + pod["Pod (replicaCount 1)
actix on :4567"] + pvc["Longhorn PVC (RWO, 128Mi)
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). diff --git a/vibe/guidebooks/applications/webapp.md b/vibe/guidebooks/applications/webapp.md new file mode 100644 index 0000000..2cb6900 --- /dev/null +++ b/vibe/guidebooks/applications/webapp.md @@ -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/` | + +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) `` 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 `` 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. diff --git a/vibe/guidebooks/lab-ecosystem/01-factory.md b/vibe/guidebooks/lab-ecosystem/01-factory.md index a8cc875..b28b5b3 100644 --- a/vibe/guidebooks/lab-ecosystem/01-factory.md +++ b/vibe/guidebooks/lab-ecosystem/01-factory.md @@ -52,6 +52,9 @@ The tested power-cut recovery sequence (Longhorn restore → Vault unseal → VS A Helm chart whose `templates/apps.yaml` loops over `values.gitea_applications` and emits one `Application` CRD per app. Each Application derives everything from the app name: `repoURL = https://gitea.arcodange.lab//`, `path = chart`, `namespace = ` (`CreateNamespace=true`), with `syncPolicy.automated` `prune: true` + `selfHeal: true` by default. +> [!TIP] +> **Deeper dive:** the [Applications guidebook](../applications/README.md) maps what these `Application` CRDs deploy — the common app-repo pattern (Dockerfile + `chart/` + optional `iac/` + CI) every app in the list below shares, and the two archetypes (Go + Postgres vs Rust + SQLite). + | App | Org override | Image Updater | |---|---|---| | `url-shortener` | — | — | @@ -113,6 +116,7 @@ flowchart LR ## Cross-references - [Lab ecosystem hub](README.md) — the whole-lab map this page sits under. +- [Applications guidebook](../applications/README.md) — the apps ArgoCD's app-of-apps deploys: the common app-repo pattern and the Go+Postgres / Rust+SQLite archetypes. - [02 · tools](02-tools.md) — what ArgoCD deploys into the `tools` namespace (incl. pgbouncer that consumes the PG `user_lookup()`). - [03 · cms](03-cms.md) — the CMS edge that `iac/cloudflare.tf` and `iac/ovh.tf` wire up. - [naming-conventions.md](naming-conventions.md) — the `` join key these pillars share.