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,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).