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>
116 lines
3.5 KiB
HCL
116 lines
3.5 KiB
HCL
locals {
|
||
# Flatten applications × envs into per-instance objects, keyed by the elided
|
||
# instance id (ADR-0002 elision rule): env=prod → "<app>", else "<app>-<env>".
|
||
# The Postgres owner role stays snake-case: "<app>_role" (prod) / "<app>_<env>_role".
|
||
# For a prod-only app the key equals "<app>", database equals "<app>", and role
|
||
# equals "<app>_role" — identical to the previous set(string) for_each, so every
|
||
# resource address and attribute is unchanged (a no-op plan).
|
||
app_instances = merge([
|
||
for app in var.applications : {
|
||
for env in app.envs :
|
||
(env == "prod" ? app.name : "${app.name}-${env}") => {
|
||
database = env == "prod" ? app.name : "${app.name}-${env}"
|
||
role = env == "prod" ? "${app.name}_role" : "${app.name}_${env}_role"
|
||
}
|
||
}
|
||
]...)
|
||
}
|
||
|
||
resource "random_password" "credentials_editor" {
|
||
length = 24
|
||
override_special = "-:!+<>"
|
||
}
|
||
|
||
resource "postgresql_role" "credentials_editor" {
|
||
name = "credentials_editor"
|
||
login = true
|
||
password = random_password.credentials_editor.result
|
||
create_role = true
|
||
lifecycle {
|
||
ignore_changes = [
|
||
roles,
|
||
]
|
||
}
|
||
}
|
||
|
||
resource "vault_kv_secret" "postgres_admin_credentials" {
|
||
path = "kvv1/postgres/credentials_editor/credentials"
|
||
data_json = jsonencode({
|
||
username = postgresql_role.credentials_editor.name
|
||
password = postgresql_role.credentials_editor.password
|
||
})
|
||
}
|
||
|
||
resource "postgresql_role" "app_role" {
|
||
for_each = local.app_instances
|
||
name = each.value.role
|
||
login = false
|
||
}
|
||
resource "postgresql_grant_role" "credentials_editor_app_role" {
|
||
for_each = local.app_instances
|
||
role = postgresql_role.credentials_editor.name
|
||
grant_role = postgresql_role.app_role[each.key].name
|
||
with_admin_option = true
|
||
}
|
||
resource "postgresql_database" "app_db" {
|
||
for_each = local.app_instances
|
||
name = each.value.database
|
||
owner = postgresql_role.app_role[each.key].name
|
||
template = "template0"
|
||
alter_object_ownership = true
|
||
}
|
||
resource "postgresql_function" "pgbouncer_user_lookup" {
|
||
for_each = local.app_instances
|
||
name = "user_lookup"
|
||
database = postgresql_database.app_db[each.key].name
|
||
arg {
|
||
mode = "IN"
|
||
name = "i_username"
|
||
type = "text"
|
||
}
|
||
arg {
|
||
mode = "OUT"
|
||
name = "uname"
|
||
type = "text"
|
||
}
|
||
arg {
|
||
mode = "OUT"
|
||
name = "phash"
|
||
type = "text"
|
||
}
|
||
returns = "record"
|
||
language = "plpgsql"
|
||
body = <<-EOF
|
||
BEGIN
|
||
SELECT usename, passwd FROM pg_catalog.pg_shadow
|
||
WHERE usename = i_username INTO uname, phash;
|
||
RETURN;
|
||
END;
|
||
EOF
|
||
parallel = "SAFE"
|
||
security_definer = true
|
||
}
|
||
resource "postgresql_grant" "pgbouncer_user_lookup_public_revoke" {
|
||
for_each = local.app_instances
|
||
database = postgresql_function.pgbouncer_user_lookup[each.key].database
|
||
role = "public"
|
||
schema = "public"
|
||
object_type = "function"
|
||
objects = [
|
||
postgresql_function.pgbouncer_user_lookup[each.key].name,
|
||
]
|
||
privileges = []
|
||
}
|
||
resource "postgresql_grant" "pgbouncer_user_lookup" {
|
||
depends_on = [postgresql_grant.pgbouncer_user_lookup_public_revoke] # can't do both in parallel
|
||
for_each = local.app_instances
|
||
database = postgresql_function.pgbouncer_user_lookup[each.key].database
|
||
role = "pgbouncer_auth"
|
||
schema = "public"
|
||
object_type = "function"
|
||
objects = [
|
||
postgresql_function.pgbouncer_user_lookup[each.key].name,
|
||
]
|
||
privileges = ["EXECUTE"]
|
||
}
|