Phase A of the multi-environment evolution agreed in the erp design thread. Both Hashicorp Vault modules gain an optional env coordinate that defaults to "prod"; by the elision rule, env=prod produces the existing single-env derived names character-for-character.
Every existing app's tofu plan should be a no-op. Backwards-compat reasoning detailed below.
What changes
app_roles (called per-instance by each app's iac/main.tf):
variables.tf: add optional env = "prod"
main.tf: compute local.instance via the elision rule (env == "prod" ? local.name : "${local.name}-${local.env}") + local.owner_role (snake-case <name>_<env>_role for the Postgres owner)
main.tf: substitute local.name → local.instance in all derived names (dynamic role name, k8s role name, SA bindings, token_policies)
outputs.tf: add env + instance outputs; kvv2_path_prefix now derives from local.instance (== local.name when env=prod → backwards-compat)
app_policy (called per-repo with a list of envs):
variables.tf: add optional envs = ["prod"]
main.tf: compute local.instances (one per env, with elision) and local.non_prod_instances
main.tf: refactor the kvv2 ops rules to dynamic blocks iterating local.instances. Preserves the original rule order (data → delete → undelete → destroy → metadata), so a prod-only app renders a byte-identical policy document
main.tf: allowed_parameter blocks for the k8s role's bound_service_account_names, bound_service_account_namespaces, and token_policies now use comprehensions over local.instances. For prod-only with instances=[local.name], output matches the original 1-element arrays
main.tf: keep vault_policy.app (the env=prod runtime policy) at its original address; add vault_policy.app_non_prod via for_each over non_prod_instances for the other envs. For prod-only apps, the for_each set is empty → no new resources
variables.tf: add envs = optional(list(string), ["prod"]) to the applications set-of-objects type
main.tf: pass envs = each.value.envs through to the app_policies module
Backwards-compatibility guarantee (per-app)
For an app {name = "erp"} (no envs declared → defaults to ["prod"]):
app_roles (called as module "app_roles" { name = "erp" } with no env → defaults to "prod"):
local.instance == local.name == "erp" → every local.name substitution produces the same string
local.owner_role == "erp_role" → unchanged
All resources render byte-identical
app_policy (called from this repo's main.tf with envs = ["prod"]):
local.instances == ["erp"] → all comprehensions produce 1-element arrays equal to the old static values
local.non_prod_instances == [] → for_each creates no app_non_prod resources
The 5 dynamic "rule" blocks for kvv2 paths iterate once with rule.value == "erp", producing the original 5 rules in the original order
The CI policy's allowed_parameter for bound_service_account_names was [jsonencode(concat([var.name], var.service_account_names))]. Now: [for inst in local.instances : jsonencode(local.per_instance_sa_names[inst])]. For prod-only with instances=[local.name] and var.name lowercase: same single jsonencoded string. ✓
The CI policy's allowed_parameter for token_policies was [jsonencode(["default", local.name]), jsonencode([local.name, "default"])]. Now: flatten([for inst in local.instances : [jsonencode(["default", inst]), jsonencode([inst, "default"])]]). For prod-only: same 2-element list. ✓
Verification
tofu validate passes locally
CI tofu plan shows no diff against any existing app (this PR's CI run is the gate — please verify before merging)
What's NOT in this PR
Phase B — arcodange-org/factory changes (postgres/iac schema, argocd/templates, runbook docs). Will follow once this PR is merged.
Phase D — arcodange-org/erp/iac/main.tf for_each + actually activating the sandbox. Follows Phases A + B.
Phase E — DNS + ArgoCD Application registration for erp-sandbox. Follows Phase D.
Reviewer guidance
The high-leverage check is the CI tofu plan — every existing app's policies must be unchanged. If the plan reports any diff, please share so we can either tighten the dynamic-block behavior or add a moved block.
## Summary
Phase A of the multi-environment evolution agreed in the [erp design thread](https://gitea.arcodange.lab/arcodange-org/erp/pulls/11). Both Hashicorp Vault modules gain an optional env coordinate that defaults to `"prod"`; by the elision rule, env=prod produces the existing single-env derived names character-for-character.
**Every existing app's `tofu plan` should be a no-op.** Backwards-compat reasoning detailed below.
### What changes
**`app_roles`** (called per-instance by each app's `iac/main.tf`):
- `variables.tf`: add optional `env = "prod"`
- `main.tf`: compute `local.instance` via the elision rule (`env == "prod" ? local.name : "${local.name}-${local.env}"`) + `local.owner_role` (snake-case `<name>_<env>_role` for the Postgres owner)
- `main.tf`: substitute `local.name` → `local.instance` in all derived names (dynamic role name, k8s role name, SA bindings, token_policies)
- `outputs.tf`: add `env` + `instance` outputs; `kvv2_path_prefix` now derives from `local.instance` (== `local.name` when env=prod → backwards-compat)
**`app_policy`** (called per-repo with a list of envs):
- `variables.tf`: add optional `envs = ["prod"]`
- `main.tf`: compute `local.instances` (one per env, with elision) and `local.non_prod_instances`
- `main.tf`: **refactor the kvv2 ops rules to dynamic blocks** iterating `local.instances`. **Preserves the original rule order** (data → delete → undelete → destroy → metadata), so a prod-only app renders a byte-identical policy document
- `main.tf`: `allowed_parameter` blocks for the k8s role's `bound_service_account_names`, `bound_service_account_namespaces`, and `token_policies` now use comprehensions over `local.instances`. For prod-only with `instances=[local.name]`, output matches the original 1-element arrays
- `main.tf`: keep `vault_policy.app` (the env=prod runtime policy) at its original address; add `vault_policy.app_non_prod` via `for_each` over `non_prod_instances` for the other envs. For prod-only apps, the for_each set is empty → no new resources
**Top-level wiring (`hashicorp-vault/iac/main.tf` + `variables.tf`):**
- `variables.tf`: add `envs = optional(list(string), ["prod"])` to the `applications` set-of-objects type
- `main.tf`: pass `envs = each.value.envs` through to the `app_policies` module
### Backwards-compatibility guarantee (per-app)
For an app `{name = "erp"}` (no `envs` declared → defaults to `["prod"]`):
1. `app_roles` (called as `module "app_roles" { name = "erp" }` with no `env` → defaults to `"prod"`):
- `local.instance == local.name == "erp"` → every `local.name` substitution produces the same string
- `local.owner_role == "erp_role"` → unchanged
- All resources render byte-identical
2. `app_policy` (called from this repo's `main.tf` with `envs = ["prod"]`):
- `local.instances == ["erp"]` → all comprehensions produce 1-element arrays equal to the old static values
- `local.non_prod_instances == []` → `for_each` creates no `app_non_prod` resources
- The 5 `dynamic "rule"` blocks for kvv2 paths iterate once with `rule.value == "erp"`, producing the original 5 rules in the original order
3. The CI policy's `allowed_parameter` for `bound_service_account_names` was `[jsonencode(concat([var.name], var.service_account_names))]`. Now: `[for inst in local.instances : jsonencode(local.per_instance_sa_names[inst])]`. For prod-only with `instances=[local.name]` and `var.name` lowercase: same single jsonencoded string. ✓
4. The CI policy's `allowed_parameter` for `token_policies` was `[jsonencode(["default", local.name]), jsonencode([local.name, "default"])]`. Now: `flatten([for inst in local.instances : [jsonencode(["default", inst]), jsonencode([inst, "default"])]])`. For prod-only: same 2-element list. ✓
### Verification
- [x] `tofu validate` passes locally
- [ ] CI `tofu plan` shows no diff against any existing app (this PR's CI run is the gate — please verify before merging)
### What's NOT in this PR
- **Phase B** — `arcodange-org/factory` changes (`postgres/iac` schema, `argocd/templates`, runbook docs). Will follow once this PR is merged.
- **Phase D** — `arcodange-org/erp/iac/main.tf` for_each + actually activating the sandbox. Follows Phases A + B.
- **Phase E** — DNS + ArgoCD Application registration for `erp-sandbox`. Follows Phase D.
### Reviewer guidance
The high-leverage check is the **CI `tofu plan`** — every existing app's policies must be unchanged. If the plan reports any diff, please share so we can either tighten the dynamic-block behavior or add a `moved` block.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Phase A of the multi-environment evolution agreed in the erp repo design
thread. Both modules gain an optional env coordinate that defaults to
"prod"; by the elision rule, env=prod produces the existing single-env
derived names character-for-character, so every existing app's tofu plan
is a no-op.
app_roles (per-instance module — caller iterates over envs):
- variables.tf: add optional env = "prod"
- main.tf: compute local.instance via elision rule + local.owner_role
(snake-case <name>_<env>_role for the Postgres owner). The name/env/
database locals are grouped so fmt keeps the existing `name` alignment
(no whitespace churn on unchanged keys).
- main.tf: substitute local.name -> local.instance / local.owner_role in
the dynamic role name, k8s role name, SA bindings, token_policies
- outputs.tf: add env + instance outputs; kvv2_path_prefix derives from
local.instance (== local.name when env=prod → backwards-compat)
app_policy (per-repo module — accepts list of envs):
- variables.tf: add optional envs = ["prod"]
- main.tf: compute local.instances + local.non_prod_instances; remove the
now-dead bound_service_account_* alias locals (the allowed_parameter
blocks build their values from per_instance_sa_* maps instead)
- main.tf: kvv2 ops rules become dynamic blocks iterating local.instances
in the original order (data, delete, undelete, destroy, metadata), so a
prod-only app renders a byte-identical policy document
- main.tf: allowed_parameter for bound_service_account_* + token_policies
use comprehensions over local.instances (1-element → identical to old
static values for prod-only apps)
- main.tf: keep vault_policy.app (env=prod runtime policy) at its original
address; add vault_policy.app_non_prod via for_each over non_prod_instances
(empty set for prod-only apps → no new resources)
Top-level wiring:
- iac/variables.tf: add envs = optional(list(string), ["prod"]) to the
applications set(object) type
- iac/main.tf: pass envs = each.value.envs to app_policies
Verified: `tofu fmt -check` clean on all touched files, `tofu validate`
passes. Backwards-compat reasoning for the no-op plan is in the PR body.
Phase B (factory postgres iac + argocd + runbook docs) and Phase D
(erp iac/main.tf for_each + activate sandbox) follow in their own PRs.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Summary
Phase A of the multi-environment evolution agreed in the erp design thread. Both Hashicorp Vault modules gain an optional env coordinate that defaults to
"prod"; by the elision rule, env=prod produces the existing single-env derived names character-for-character.Every existing app's
tofu planshould be a no-op. Backwards-compat reasoning detailed below.What changes
app_roles(called per-instance by each app'siac/main.tf):variables.tf: add optionalenv = "prod"main.tf: computelocal.instancevia the elision rule (env == "prod" ? local.name : "${local.name}-${local.env}") +local.owner_role(snake-case<name>_<env>_rolefor the Postgres owner)main.tf: substitutelocal.name→local.instancein all derived names (dynamic role name, k8s role name, SA bindings, token_policies)outputs.tf: addenv+instanceoutputs;kvv2_path_prefixnow derives fromlocal.instance(==local.namewhen env=prod → backwards-compat)app_policy(called per-repo with a list of envs):variables.tf: add optionalenvs = ["prod"]main.tf: computelocal.instances(one per env, with elision) andlocal.non_prod_instancesmain.tf: refactor the kvv2 ops rules to dynamic blocks iteratinglocal.instances. Preserves the original rule order (data → delete → undelete → destroy → metadata), so a prod-only app renders a byte-identical policy documentmain.tf:allowed_parameterblocks for the k8s role'sbound_service_account_names,bound_service_account_namespaces, andtoken_policiesnow use comprehensions overlocal.instances. For prod-only withinstances=[local.name], output matches the original 1-element arraysmain.tf: keepvault_policy.app(the env=prod runtime policy) at its original address; addvault_policy.app_non_prodviafor_eachovernon_prod_instancesfor the other envs. For prod-only apps, the for_each set is empty → no new resourcesTop-level wiring (
hashicorp-vault/iac/main.tf+variables.tf):variables.tf: addenvs = optional(list(string), ["prod"])to theapplicationsset-of-objects typemain.tf: passenvs = each.value.envsthrough to theapp_policiesmoduleBackwards-compatibility guarantee (per-app)
For an app
{name = "erp"}(noenvsdeclared → defaults to["prod"]):app_roles(called asmodule "app_roles" { name = "erp" }with noenv→ defaults to"prod"):local.instance == local.name == "erp"→ everylocal.namesubstitution produces the same stringlocal.owner_role == "erp_role"→ unchangedapp_policy(called from this repo'smain.tfwithenvs = ["prod"]):local.instances == ["erp"]→ all comprehensions produce 1-element arrays equal to the old static valueslocal.non_prod_instances == []→for_eachcreates noapp_non_prodresourcesdynamic "rule"blocks for kvv2 paths iterate once withrule.value == "erp", producing the original 5 rules in the original orderallowed_parameterforbound_service_account_nameswas[jsonencode(concat([var.name], var.service_account_names))]. Now:[for inst in local.instances : jsonencode(local.per_instance_sa_names[inst])]. For prod-only withinstances=[local.name]andvar.namelowercase: same single jsonencoded string. ✓allowed_parameterfortoken_policieswas[jsonencode(["default", local.name]), jsonencode([local.name, "default"])]. Now:flatten([for inst in local.instances : [jsonencode(["default", inst]), jsonencode([inst, "default"])]]). For prod-only: same 2-element list. ✓Verification
tofu validatepasses locallytofu planshows no diff against any existing app (this PR's CI run is the gate — please verify before merging)What's NOT in this PR
arcodange-org/factorychanges (postgres/iacschema,argocd/templates, runbook docs). Will follow once this PR is merged.arcodange-org/erp/iac/main.tffor_each + actually activating the sandbox. Follows Phases A + B.erp-sandbox. Follows Phase D.Reviewer guidance
The high-leverage check is the CI
tofu plan— every existing app's policies must be unchanged. If the plan reports any diff, please share so we can either tighten the dynamic-block behavior or add amovedblock.🤖 Generated with Claude Code
399cf38fb4toa3e121b468