modules: Phase A of multi-env — add env/envs parameter to app_roles and app_policy #2

Merged
arcodange merged 1 commits from claude/multi-env-modules into main 2026-06-19 19:22:30 +02:00
Owner

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 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.namelocal.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

  • 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 Barcodange-org/factory changes (postgres/iac schema, argocd/templates, runbook docs). Will follow once this PR is merged.
  • Phase Darcodange-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

## 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)
arcodange added 1 commit 2026-06-15 14:29:24 +02:00
modules: add env/envs parameter to app_roles + app_policy (multi-env)
All checks were successful
Helm Charts / Detect changed charts (push) Successful in 23s
Helm Charts / Detect changed charts (pull_request) Successful in 22s
Helm Charts / Library charts tool (push) Has been skipped
Helm Charts / Library charts tool (pull_request) Has been skipped
Helm Charts / Application charts pgcat (push) Has been skipped
Helm Charts / Application charts pgcat (pull_request) Has been skipped
a3e121b468
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) <noreply@anthropic.com>
arcodange force-pushed claude/multi-env-modules from 399cf38fb4 to a3e121b468 2026-06-15 14:29:24 +02:00 Compare
arcodange merged commit 01de97853d into main 2026-06-19 19:22:30 +02:00
arcodange deleted branch claude/multi-env-modules 2026-06-19 19:22:31 +02:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: arcodange-org/tools#2