Files
factory/vibe/guidebooks/lab-ecosystem/naming-conventions.md
Gabriel Radureau c00c4cdd5c feat(multi-env): Phase B — make factory machinery env-capable (no activation)
ADR-0002 Phase B. Makes postgres/iac, argocd, and the conventions docs
multi-environment-capable WITHOUT activating any sandbox yet — every app
stays prod-only, so this change is behaviour-neutral:
  - postgres/iac `tofu plan` is a no-op (proven: the elision flatten keys
    are bare app names, db=<app>, role=<app>_role — identical addresses)
  - the argocd apps.yaml render is byte-identical (181→181 lines, diff
    empty) since no app declares `envs`

postgres/iac:
- variables.tf: `applications` becomes set(object({name, envs=optional(["prod"])}))
- main.tf: a `local.app_instances` flatten of applications × envs keyed by the
  elided instance id (env=prod → "<app>"); per-app resources iterate it and
  reference each.key / each.value.{database,role}. For prod-only apps every
  resource address + attribute is unchanged. (main.tf also got a full
  `tofu fmt` pass — the pgbouncer function block reindents 4→2 spaces, which
  is cosmetic; the correctness gate is the CI tofu plan, not the text diff.)
- terraform.tfvars: string entries → { name = "..." } objects.

argocd/templates/apps.yaml:
- after the prod Application, a `range $app_attr.envs` loop renders one extra
  Application per non-prod env: name/namespace `<app>-<env>`, shared repoURL,
  helm.valueFiles [values.yaml, values-<env>.yaml], per-env syncPolicy override.
  Renders nothing while no app sets `envs` → prod render unchanged.

docs:
- doc/runbooks/new-web-app/conventions.md (FR, authoritative): new section
  "Plusieurs environnements pour une même app" — elision rule, suffix rule,
  snake-case owner-role exception, erp/erp-sandbox table, ADR-0002 link.
- vibe/guidebooks/lab-ecosystem/naming-conventions.md (EN mirror): the env
  coordinate section + a "Two sandbox models" section reconciling the
  separate-cluster (ADR-0001, names repeat) vs in-cluster sibling (ADR-0002,
  <env> suffix) strategies; Last Updated bumped; ADR-0002 cross-links.

Activation (erp gets envs=["prod","sandbox"] in postgres tfvars + argocd
values + erp/iac) is Phase D, gated by its own plan review.

Refs ADR-0002 (factory#15). Phase A = tools#2 (merged). Phase C = erp#11 (merged).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-28 16:28:28 +02:00

11 KiB

vibe > Guidebooks > Lab ecosystem > Naming conventions (the <app> join key)

Naming conventions — the <app> join key

Status: 🟢 Active Last Updated: 2026-06-25 Related: Lab ecosystem · Factory brick · Secrets & Vault · PRD — isolation boundary · ADR 0002 — per-application environments Upstream (source of truth): doc/runbooks/new-web-app/conventions.md (French, authoritative)

TL;DR

Every application on the platform is pinned to one kebab-case identifier — <app> (e.g. erp, webapp, url-shortener, dance-lessons-coach). That single string is reused verbatim, with no transformation, as the name of the app's Gitea repo, its PostgreSQL database and role, its Vault roles and policies, its Kubernetes namespace and ServiceAccount, its ArgoCD Application, its OpenTofu state prefix, and its DNS records. The bricks of the stack do not point at each other through explicit configuration; they wire together by guessing each other's names from <app>. Pick the name once, get it right, and the whole chain self-assembles. One typo anywhere, and the chain breaks silently.

What <app> is

<app> is a lowercase, kebab-case slug. It is the join key of the entire platform — the one value that lets a dozen otherwise-independent systems agree on which resources belong to the same application without ever exchanging a config pointer. The canonical, authoritative definition (in French) lives in the runbook: doc/runbooks/new-web-app/conventions.md. This page is the English concept summary inside the ecosystem guidebook.

The mapping — one name, every system

The table below shows how each system derives its identifier from <app>, with the erp application as the worked example.

System Identifier derived from <app> Example (erp)
Gitea repository arcodange-org/<app> arcodange-org/erp
PostgreSQL database <app> erp
PostgreSQL owner role (non-login) <app>_role erp_role
Vault dynamic DB role postgres/creds/<app> postgres/creds/erp
Vault Kubernetes auth role <app> erp
Vault runtime policy (pod) <app> erp
Vault CI/ops policy <app>-ops erp-ops
Vault CI JWT role (Gitea OIDC) gitea_cicd_<app> gitea_cicd_erp
Vault KV config path kvv2/<app>/config kvv2/erp/config
Kubernetes namespace <app> erp
Kubernetes ServiceAccount <app> erp
ArgoCD Application <app> erp
OpenTofu state prefix (GCS) <app>/main erp/main
Internal DNS <app>.arcodange.lab erp.arcodange.lab
Public DNS <app>.arcodange.fr erp.arcodange.fr

Note

The _role suffix (PG owner role) and the -ops suffix (Vault CI policy/identity group) are the only two systematic transformations of <app>. Everything else uses the bare slug. Note the suffix style differs: PostgreSQL uses an underscore (erp_role) because hyphens are awkward in SQL identifiers, whereas Vault and Kubernetes use a hyphen (erp-ops).

Why uniformity is structuring

The platform is a set of loosely-coupled bricks (Gitea, Postgres, Vault, k3s/ArgoCD, OpenTofu, DNS). They were deliberately built not to hold explicit references to one another. Instead, each brick reconstructs the names it needs from <app> at the moment it runs:

%%{init: {'theme':'base'}}%%
flowchart LR
    APP["&lt;app&gt;<br/>(one kebab-case slug)"]:::src

    APP --> GIT["Gitea repo<br/>arcodange-org/&lt;app&gt;"]:::brick
    APP --> PG["PostgreSQL<br/>db &lt;app&gt; · role &lt;app&gt;_role"]:::brick
    APP --> VAULT["Vault<br/>postgres/creds/&lt;app&gt;<br/>policy &lt;app&gt; · gitea_cicd_&lt;app&gt;"]:::brick
    APP --> K8S["Kubernetes<br/>namespace + SA &lt;app&gt;"]:::brick
    APP --> ARGO["ArgoCD<br/>Application &lt;app&gt;"]:::brick
    APP --> GCS["OpenTofu state<br/>&lt;app&gt;/main"]:::brick
    APP --> DNS["DNS<br/>&lt;app&gt;.arcodange.lab / .fr"]:::brick

    VAULT -.->|"GRANT &lt;app&gt;_role<br/>assumes PG role name"| PG
    K8S -.->|"VaultDynamicSecret reads<br/>postgres/creds/&lt;app&gt;"| VAULT
    ARGO -.->|"repoURL=.../&lt;app&gt;<br/>namespace=&lt;app&gt;"| GIT

    classDef src fill:#2563eb,stroke:#1e40af,color:#fff
    classDef brick fill:#059669,stroke:#047857,color:#fff
  1. The chosen slug <app> is the single input.
  2. From it, each brick names its own resource: Gitea names the repo arcodange-org/<app>; Postgres names the database <app> and its owner role <app>_role; Vault names the dynamic-creds role postgres/creds/<app>, the runtime policy <app>, and the CI JWT role gitea_cicd_<app>; Kubernetes names the namespace and ServiceAccount <app>; ArgoCD names the Application <app>; OpenTofu writes state under <app>/main; DNS publishes <app>.arcodange.lab and <app>.arcodange.fr.
  3. The dashed arrows are the cross-brick assumptions that make it work: the Vault app_roles module issues a dynamic PG user with GRANT <app>_role TO …, assuming the Postgres owner role is named exactly <app>_role; the chart's VaultDynamicSecret reads postgres/creds/<app>, assuming the Vault role is named exactly <app>; the ArgoCD Application derives repoURL=.../<app> and namespace=<app> from the slug alone, assuming the Gitea repo and the namespace match.
  4. None of these links is configured by hand. They hold purely because every brick was given the same <app> to reconstruct from.

The failure mode of a typo

Because the wiring is by name and not by explicit reference, nothing validates the join key end-to-end. A single divergence — my_app vs my-app, a stray capital (MyApp), an accidental plural (erps) — does not raise an error at creation time. The mismatched brick simply builds a resource under a name no one else looks for:

  • A Postgres owner role created as erp-role (hyphen) instead of erp_role → Vault's GRANT erp_role fails or grants nothing → the pod gets a DB user with no privileges.
  • A Gitea repo named erp-app instead of erp → ArgoCD's derived repoURL=.../erp 404s → the Application never syncs.
  • A namespace typo → the VaultDynamicSecret and ServiceAccount land in the wrong place → silent auth failure at pod start.

The symptom is always the same: a brick that looks provisioned but never connects, with no single component to blame. This is why the slug must be short, stable, and correct from the first step — there is no safety net downstream.

Choose a short, stable, lowercase kebab-case name up front and reuse it character-for-character. Never introduce variants (case, separators, plurals); nothing will warn you.

Multiple environments per app (the <env> coordinate)

A single application can run as several deployed instances — prod, sandbox, and so on — without becoming a separate app: same repo, same chart, same version. A second coordinate <env> extends the join key, governed by an elision rule (ADR 0002):

  • env defaults to prod, and prod elides — when env == prod no suffix is added, so every derived name is exactly the single-coordinate output of the mapping above. Existing apps are unaffected (their plan is a no-op).
  • Non-prod envs take the <app>-<env> suffix everywhere — namespace, Vault paths / roles / policies, ArgoCD Application, DNS, GCS state sub-prefix — with the one snake-case exception inherited from the _role convention: the Postgres owner role is <app>_<env>_role.
  • One repo, one chart, and one CI JWT role (gitea_cicd_<app>) serve every env; per-env differences are a values-<env>.yaml overlay.

Worked example — erp (prod, elided) and erp-sandbox:

System erp (env = prod) erp-sandbox
PostgreSQL database erp erp-sandbox
PostgreSQL owner role erp_role erp_sandbox_role
Namespace + ServiceAccount erp erp-sandbox
Vault dynamic DB creds postgres/creds/erp postgres/creds/erp-sandbox
Vault KV config kvv2/erp/config kvv2/erp-sandbox/config
ArgoCD Application erp erp-sandbox
Internal DNS erp.arcodange.lab erp-sandbox.arcodange.lab
Gitea repo / chart / CI JWT arcodange-org/erp · chart · gitea_cicd_erp shared

Two sandbox models, two naming strategies

There are two distinct ways to stand up a non-production copy, and they treat the join key differently — by design, not by accident.

  • Separate-cluster sandbox (ADR 0001) — a whole parallel universe (its own Vault, Postgres, k3s) on the control node, for rehearsing dangerous infrastructure changes. The two universes never share a backing store, so identity comes from the environment boundary, not the name: the sandbox hosts an erp named identically to production. Names repeat freely; no <env> suffix is needed, so the name-wiring stays intact and drills run against realistic identifiers.
  • In-cluster sibling instance (ADR 0002) — a second instance on the same cluster (e.g. erp-sandbox beside erp), for rehearsing application-data writes against the real API. Here there is no cluster fence to disambiguate by, so the <env> suffix is the separator: every derived name carries -sandbox to avoid colliding with prod's namespace, database, Vault paths, and DNS.

Both keep the name-wiring coherent — one by repeating the slug behind a cluster fence, the other by extending the slug with the elided <env> coordinate. See the PRD's isolation boundary for how the separate-cluster fence is drawn, and ADR 0002 for why the in-cluster sibling's blast radius stays bounded to one app's data.

See also