Skip to content

Commit 0d30434

Browse files
committed
Simplify state management: default terraform.tfstate per stage directory
Removed centralized .terraform-state/ directory, stage-N-slug.tfstate convention, TFM-TF-003 transform, and STAN-TF-011 standard. Each stage uses the default terraform.tfstate in its own directory. Cross-stage references use relative paths (../stage-1-managed-identity/terraform.tfstate). This eliminates the #1 recurring QA failure — the AI naturally generates backend "local" {} which now just works.
1 parent 5129731 commit 0d30434

7 files changed

Lines changed: 18 additions & 177 deletions

File tree

HISTORY.rst

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,13 @@ Release History
88

99
Generation quality improvements
1010
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
11-
* **TFM-TF-003** — structured transform that fixes backend state file
12-
paths: replaces ``terraform.tfstate`` or empty ``backend "local" {}``
13-
with the correct ``stage-N-slug.tfstate`` path derived from stage
14-
context. The #1 recurring QA failure across all builds.
11+
* **Simplified state management** — removed centralized
12+
``.terraform-state/`` directory and ``stage-N-slug.tfstate`` naming
13+
convention. Each stage uses the default ``terraform.tfstate`` in its
14+
own directory. Cross-stage references use simple relative paths
15+
(``../stage-1-managed-identity/terraform.tfstate``). Removed
16+
TFM-TF-003, STAN-TF-011, and the CRITICAL STATE FILE NAMING section
17+
from TERRAFORM_PROMPT. Eliminates the #1 recurring QA failure.
1518
* **Stage context in transforms** — ``apply()`` now accepts ``stage``
1619
dict and ``stage_content`` (all files concatenated), enabling
1720
structured handlers to use stage metadata and cross-file reference
@@ -20,11 +23,10 @@ Generation quality improvements
2023
checks references across ALL stage files (via ``stage_content``),
2124
not just the file containing the declaration. Prevents false removal
2225
of remote state blocks referenced in ``locals.tf`` or ``outputs.tf``.
23-
* **35 transform unit tests** — comprehensive tests for all 7 handlers:
26+
* **29 transform unit tests** — comprehensive tests for all 6 handlers:
2427
load, apply filtering, capacityMode replacement, unused remote state
2528
(single-file and cross-file), response_export_values injection,
26-
resource group parent_id, PE removal, state path fix, and stage
27-
context integration.
29+
resource group parent_id, PE removal, and stage context integration.
2830
* **``response_export_values`` prompt strengthening** — TERRAFORM_PROMPT
2931
changed from "add when outputs reference it" to "add to EVERY
3032
azapi_resource, no exceptions." Violations section with rejected

azext_prototype/agents/builtin/iac_shared_rules.py

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -75,10 +75,6 @@
7575
role must be applied _after_ the identity stage deploys.
7676
7777
## CRITICAL: deploy.sh STATE DIRECTORY
78-
deploy.sh **MUST** create the Terraform state directory before `terraform init`:
79-
```bash
80-
STATE_DIR="$(cd "$(dirname "$0")/../../.." && pwd)/.terraform-state"
81-
mkdir -p "${STATE_DIR}"
82-
```
83-
Without this, `terraform init` fails on first run in a clean environment.
78+
Each stage stores its state as `terraform.tfstate` in its own directory.
79+
Cross-stage references use relative paths (e.g., `../stage-1-managed-identity/terraform.tfstate`).
8480
""".strip()

azext_prototype/agents/builtin/terraform_agent.py

Lines changed: 4 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -138,19 +138,14 @@ def get_system_messages(self):
138138
}
139139
}
140140
141-
backend "local" {
142-
path = "../../../.terraform-state/stage-N-slug.tfstate"
143-
# REPLACE stage-N-slug with the ACTUAL stage number and name.
144-
# Example: stage-1-managed-identity.tfstate, stage-4-networking.tfstate
145-
# NEVER use the default "terraform.tfstate" — downstream stages WILL break.
146-
}
141+
backend "local" {}
147142
}
148143
149144
provider "azapi" {}
150145
```
151146
Do NOT add `subscription_id` or `tenant_id` to the provider block. The az CLI context provides these.
152-
NEVER use `backend "local" {}` without an explicit `path` — it defaults to
153-
`terraform.tfstate` which breaks cross-stage references.
147+
Each stage uses the default `terraform.tfstate` in its own directory.
148+
Cross-stage references use relative paths: `../stage-1-managed-identity/terraform.tfstate`.
154149
155150
## CRITICAL: TAGS PLACEMENT
156151
Tags on `azapi_resource` MUST be a TOP-LEVEL attribute, NEVER inside `body`.
@@ -261,7 +256,7 @@ def get_system_messages(self):
261256
variable "stage1_state_path" {
262257
description = "Path to Stage 1 state file"
263258
type = string
264-
default = "../../../.terraform-state/stage-1-managed-identity.tfstate"
259+
default = "../stage-1-managed-identity/terraform.tfstate"
265260
}
266261
data "terraform_remote_state" "stage1" {
267262
backend = "local"
@@ -271,25 +266,6 @@ def get_system_messages(self):
271266
parent_id = data.terraform_remote_state.stage1.outputs.resource_group_id
272267
```
273268
274-
## CRITICAL: STATE FILE NAMING CONVENTION
275-
ALL stages MUST set an explicit `path` in `backend "local"`. The path MUST
276-
follow this EXACT pattern — NO EXCEPTIONS:
277-
278-
path = "../../../.terraform-state/stage-{N}-{slug}.tfstate"
279-
280-
Where {N} is the stage number and {slug} is the stage name in lowercase
281-
with hyphens. You MUST replace {N} and {slug} with the actual values.
282-
283-
Examples:
284-
Stage 1 "Managed Identity": path = "../../../.terraform-state/stage-1-managed-identity.tfstate"
285-
Stage 4 "Networking": path = "../../../.terraform-state/stage-4-networking.tfstate"
286-
Stage 8 "Cosmos DB": path = "../../../.terraform-state/stage-8-cosmos-db.tfstate"
287-
288-
VIOLATIONS THAT WILL BE REJECTED:
289-
backend "local" {} # WRONG — defaults to terraform.tfstate
290-
path = "terraform.tfstate" # WRONG — breaks cross-stage references
291-
path = "../stage-N-name/terraform.tfstate" # WRONG — must use .terraform-state/ directory
292-
293269
NEVER use variable references in backend config blocks.
294270
295271
## MANAGED IDENTITY + RBAC (MANDATORY)

azext_prototype/governance/standards/iac/terraform.yaml

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -81,8 +81,8 @@ principles:
8181
applies_to:
8282
- terraform-agent
8383
examples:
84-
- 'POC: omit backend block entirely (local state by default)'
85-
- 'POC multi-stage: backend "local" { path = "../.terraform-state/stage1.tfstate" }'
84+
- 'POC: backend "local" {} — default terraform.tfstate in stage directory'
85+
- 'Cross-stage ref: ../stage-1-managed-identity/terraform.tfstate'
8686
- 'Production: backend "azurerm" { resource_group_name = "terraform-state-rg"; storage_account_name = "tfstate12345"; container_name
8787
= "tfstate"; key = "stage1.tfstate" }'
8888
rationale: Proper state management prevents cross-stage conflicts and ensures reliable deployments.
@@ -121,14 +121,3 @@ principles:
121121
- resource "azapi_resource" "cosmosdb_role_assignment" { type = "Microsoft.DocumentDB/databaseAccounts/sqlRoleAssignments@2024-05-15";
122122
body = { properties = { principalId = azapi_resource.managed_identity.output.properties.principalId } } }
123123
rationale: Complete outputs enable downstream stages to reference resources without hardcoding.
124-
- id: STAN-TF-011
125-
description: 'State File Naming: The backend local block must specify an explicit path using the pattern stage-N-slug.tfstate
126-
(e.g., stage-1-managed-identity.tfstate). Never use the default terraform.tfstate. Downstream stages reference upstream
127-
state via terraform_remote_state with these paths — a mismatch silently reads empty state.'
128-
applies_to:
129-
- terraform-agent
130-
examples:
131-
- 'backend "local" { path = "stage-1-managed-identity.tfstate" }'
132-
- 'backend "local" { path = "stage-4-networking.tfstate" }'
133-
- 'WRONG: backend "local" {} — defaults to terraform.tfstate'
134-
rationale: Consistent state file naming enables cross-stage references via terraform_remote_state without path guessing.

azext_prototype/governance/transforms/__init__.py

Lines changed: 0 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -449,51 +449,11 @@ def _inject(match: re.Match) -> str: # type: ignore[type-arg]
449449
return new_content
450450

451451

452-
def _fix_state_path(content: str, stage: dict | None = None) -> str:
453-
"""Fix backend state file path to follow stage-N-slug.tfstate convention.
454-
455-
Replaces:
456-
- ``backend "local" {}`` (empty, defaults to terraform.tfstate)
457-
- ``path = "terraform.tfstate"``
458-
- ``path = "...terraform.tfstate"`` (wrong path)
459-
460-
With the correct ``path = "../../../.terraform-state/stage-N-slug.tfstate"``.
461-
Requires ``stage`` context to derive the correct path.
462-
"""
463-
if not stage:
464-
return content
465-
466-
stage_num = stage.get("stage")
467-
stage_name = stage.get("name", "")
468-
if not stage_num or not stage_name:
469-
return content
470-
471-
slug = stage_name.lower().replace(" ", "-")
472-
correct_path = f"../../../.terraform-state/stage-{stage_num}-{slug}.tfstate"
473-
474-
result = content
475-
476-
# Fix empty backend "local" {} — inject path
477-
empty_backend = re.compile(r'backend\s+"local"\s*\{\s*\}', re.DOTALL)
478-
if empty_backend.search(result):
479-
result = empty_backend.sub(f'backend "local" {{\n path = "{correct_path}"\n }}', result)
480-
logger.debug("Fixed empty backend local with path %s", correct_path)
481-
482-
# Fix wrong path values
483-
wrong_path = re.compile(r'path\s*=\s*"[^"]*terraform\.tfstate"')
484-
if wrong_path.search(result):
485-
result = wrong_path.sub(f'path = "{correct_path}"', result)
486-
logger.debug("Fixed terraform.tfstate path to %s", correct_path)
487-
488-
return result
489-
490-
491452
_STRUCTURED_HANDLERS: dict[str, Callable] = {
492453
"remove_unused_remote_state": _remove_unused_remote_state,
493454
"remove_private_endpoint_resources": _remove_private_endpoint_resources,
494455
"add_response_export_values": _add_response_export_values,
495456
"add_resource_group_parent_id": _add_resource_group_parent_id,
496-
"fix_state_path": _fix_state_path,
497457
}
498458

499459

azext_prototype/governance/transforms/iac/terraform-state-path.transform.yaml

Lines changed: 0 additions & 18 deletions
This file was deleted.

tests/test_transforms.py

Lines changed: 1 addition & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
from azext_prototype.governance.transforms import (
66
_add_resource_group_parent_id,
77
_add_response_export_values,
8-
_fix_state_path,
98
_remove_private_endpoint_resources,
109
_remove_unused_remote_state,
1110
apply,
@@ -50,7 +49,7 @@ def test_structured_transforms_have_handler(self):
5049

5150
def test_known_transforms_exist(self):
5251
ids = {t.id for t in load()}
53-
expected = {"TFM-LA-001", "TFM-CDB-001", "TFM-TF-001", "TFM-TF-002", "TFM-TF-003", "TFM-RG-001", "TFM-NET-001"}
52+
expected = {"TFM-LA-001", "TFM-CDB-001", "TFM-TF-001", "TFM-TF-002", "TFM-RG-001", "TFM-NET-001"}
5453
for eid in expected:
5554
assert eid in ids, f"Expected transform {eid} not found"
5655

@@ -355,75 +354,12 @@ def test_empty_content_returns_unchanged(self):
355354
assert result == ""
356355

357356

358-
# ------------------------------------------------------------------
359-
# _fix_state_path (TFM-TF-003)
360-
# ------------------------------------------------------------------
361-
362-
363-
class TestFixStatePath:
364-
def test_fixes_empty_backend(self):
365-
content = """terraform {
366-
required_version = ">= 1.9.0"
367-
backend "local" {}
368-
}
369-
"""
370-
result = _fix_state_path(content, stage={"stage": 4, "name": "Networking"})
371-
assert "stage-4-networking.tfstate" in result
372-
assert "terraform.tfstate" not in result
373-
374-
def test_fixes_wrong_path(self):
375-
content = """terraform {
376-
backend "local" {
377-
path = "terraform.tfstate"
378-
}
379-
}
380-
"""
381-
result = _fix_state_path(content, stage={"stage": 7, "name": "Azure SQL"})
382-
assert "stage-7-azure-sql.tfstate" in result
383-
384-
def test_preserves_correct_path(self):
385-
content = """terraform {
386-
backend "local" {
387-
path = "../../../.terraform-state/stage-4-networking.tfstate"
388-
}
389-
}
390-
"""
391-
result = _fix_state_path(content, stage={"stage": 4, "name": "Networking"})
392-
assert result == content
393-
394-
def test_no_stage_returns_unchanged(self):
395-
content = 'backend "local" {}'
396-
result = _fix_state_path(content, stage=None)
397-
assert result == content
398-
399-
def test_slug_generation(self):
400-
content = 'backend "local" {}'
401-
result = _fix_state_path(content, stage={"stage": 12, "name": "Container Apps Environment"})
402-
assert "stage-12-container-apps-environment.tfstate" in result
403-
404-
405357
# ------------------------------------------------------------------
406358
# apply() — integration with stage context
407359
# ------------------------------------------------------------------
408360

409361

410362
class TestApplyWithStageContext:
411-
def test_state_path_fix_uses_stage(self):
412-
content = """terraform {
413-
backend "local" {
414-
path = "terraform.tfstate"
415-
}
416-
}
417-
"""
418-
result, ids = apply(
419-
content,
420-
services=[],
421-
iac_tool="terraform",
422-
stage={"stage": 5, "name": "Container Registry"},
423-
)
424-
assert "TFM-TF-003" in ids
425-
assert "stage-5-container-registry.tfstate" in result
426-
427363
def test_cross_file_remote_state_preserved(self):
428364
main_tf = """data "terraform_remote_state" "stage1" {
429365
backend = "local"

0 commit comments

Comments
 (0)