feat(multi-env): Phase D3 — erp iac creates erp-sandbox Vault auth + creds + KV

ADR-0002 Phase D, erp-repo layer. iac/main.tf iterates `envs = ["prod",
"sandbox"]` so the app_roles module + the admin-bootstrap resources are
materialised per environment:
  - module.app_roles["sandbox"] → auth/kubernetes/role/erp-sandbox +
    postgres/creds/erp-sandbox (dynamic role GRANTs erp_sandbox_role — the
    snake-case owner role created in factory#17 — and REVOKEs on DATABASE
    erp-sandbox; token_policies ["default","erp-sandbox"] = the policy from
    tools#3).
  - random_password.admin_initial_password["sandbox"], random_uuid.dolibarr_id
    ["sandbox"], and vault_kv_secret_v2.dolibarr_admin_setup["sandbox"]
    (kvv2/erp-sandbox/config) → the sandbox Dolibarr's own admin password +
    encryption id.

State migration via `moved` blocks: the pre-existing single-env resources are
re-keyed into the for_each map under "prod" so introducing for_each does NOT
destroy+recreate them. Critical for random_uuid.dolibarr_id (prevent_destroy =
true — prod encryption id + paid-module binding): a wrong/absent moved would
HARD-FAIL the apply rather than lose it. The module's internal moved
(role -> role[0]) chains with the module re-key. Verified the exact compound
(old-module state → new module count+moved + for_each, one apply) with two
standalone tofu plans: both show "X moved", sandbox created, 0 destroyed.

env=prod renders byte-identical to the single-env baseline (module elision
rule), so the prod erp Vault auth role, dynamic creds, admin secret + KV are
unchanged.

D3 of Phase D. D1 = factory#17 (DB+role, merged). D2 = tools#3 (Vault
policies, merged). D4 (ArgoCD Application) is next.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-28 17:26:46 +02:00
parent b4bdbe75df
commit e2fa327361

View File

@@ -1,32 +1,66 @@
locals {
app = {
name = "erp"
name = "erp"
product_name = "dolibarr" # unused
}
# Environments this app is deployed to. By the elision rule (factory runbook
# conventions.md / ADR-0002) env=prod renders identical to the single-env
# baseline; non-prod envs get a "<name>-<env>" instance id from the module.
envs = toset(["prod", "sandbox"])
}
module "app_roles" {
source = "git::ssh://git@192.168.1.202:2222/arcodange-org/tools.git//hashicorp-vault/iac/modules/app_roles?depth=1&ref=main"
name = local.app.name
source = "git::ssh://git@192.168.1.202:2222/arcodange-org/tools.git//hashicorp-vault/iac/modules/app_roles?depth=1&ref=main"
for_each = local.envs
name = local.app.name
env = each.key
}
resource "random_password" "admin_initial_password" {
length = 32
for_each = local.envs
length = 32
}
resource "random_uuid" "dolibarr_id" { # used for encryption as well as when buying modules
for_each = local.envs
lifecycle {
prevent_destroy = true
}
}
resource "vault_kv_secret_v2" "dolibarr_admin_setup" {
mount = module.app_roles.mount_paths.kvv2
name = format("%sconfig", module.app_roles.kvv2_path_prefix)
data_json = jsonencode(
{
DOLI_ADMIN_LOGIN = "admin",
DOLI_ADMIN_PASSWORD = random_password.admin_initial_password.result
DOLI_INSTANCE_UNIQUE_ID = random_uuid.dolibarr_id.result
}
for_each = local.envs
mount = module.app_roles[each.key].mount_paths.kvv2
name = format("%sconfig", module.app_roles[each.key].kvv2_path_prefix)
data_json = jsonencode(
{
DOLI_ADMIN_LOGIN = "admin",
DOLI_ADMIN_PASSWORD = random_password.admin_initial_password[each.key].result
DOLI_INSTANCE_UNIQUE_ID = random_uuid.dolibarr_id[each.key].result
}
)
}
# State migration (ADR-0002 Phase D): re-key the pre-existing single-env resources
# into the for_each map under the "prod" key so that introducing for_each does NOT
# plan a destroy+create. This is critical for random_uuid.dolibarr_id — it carries
# prevent_destroy = true (it is the prod Dolibarr encryption id + paid-module
# binding), so a missing moved block would HARD-FAIL the apply rather than silently
# losing it. The module's own internal moved (role -> role[0]) chains with the
# module re-key here.
moved {
from = module.app_roles
to = module.app_roles["prod"]
}
moved {
from = random_password.admin_initial_password
to = random_password.admin_initial_password["prod"]
}
moved {
from = random_uuid.dolibarr_id
to = random_uuid.dolibarr_id["prod"]
}
moved {
from = vault_kv_secret_v2.dolibarr_admin_setup
to = vault_kv_secret_v2.dolibarr_admin_setup["prod"]
}