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:
172
vibe/guidebooks/applications/url-shortener.md
Normal file
172
vibe/guidebooks/applications/url-shortener.md
Normal 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).
|
||||
Reference in New Issue
Block a user