Translate a CloudFormation stack to Terraform with an AI assistant, then prove — not assume — parity with an offline validate/plan harness and a resource-by-resource review. The template sets two service-mapping traps that AI translators reliably fall into; your job is to catch both and document them.
Rajesh's Meridian engagement standardizes on Terraform; the client's incumbent shipped everything as CloudFormation. Format translation is exactly the kind of work AI makes 10x faster — and it is also where "compiles and plans clean" quietly diverges from "does the same thing". The parity gate is the difference between a migration and a regression.
~120 minutes hands-on · Core tier (maps to module M4). Cost: ₹0 — everything runs offline against a mock AWS provider (the same trick as the DevOps track's verification-pipeline lab); no cloud account, no credentials.
Install Terraform ≥ 1.5 (or OpenTofu). Create iac-parity/ with source/stack.yaml (the two blocks below joined into one file — split here only for readability) and an empty translated/ directory.
source/stack.yaml, part 1 — network + storage:
AWSTemplateFormatVersion: "2010-09-09"
Description: Meridian docs-ingest stack (translation source)
Resources:
IngestVpc:
Type: AWS::EC2::VPC
Properties:
CidrBlock: 10.61.0.0/24
EnableDnsSupport: true
EnableDnsHostnames: true
Tags: [{ Key: Name, Value: meridian-ingest }]
WorkerSubnet:
Type: AWS::EC2::Subnet
Properties:
VpcId: !Ref IngestVpc
CidrBlock: 10.61.0.0/26
AvailabilityZone: us-east-1a
DocsBucket:
Type: AWS::S3::Bucket
Properties:
BucketName: meridian-docs-ingest-lab
VersioningConfiguration: { Status: Enabled }
source/stack.yaml, part 2 — IAM + Lambda (append under Resources:):
WorkerRole:
Type: AWS::IAM::Role
Properties:
RoleName: meridian-ingest-worker
AssumeRolePolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Principal: { Service: lambda.amazonaws.com }
Action: sts:AssumeRole
Policies:
- PolicyName: docs-read
PolicyDocument:
Version: "2012-10-17"
Statement:
- Sid: ListTheBucket
Effect: Allow
Action: ["s3:ListBucket"]
Resource: !GetAtt DocsBucket.Arn
- Sid: ReadObjects
Effect: Allow
Action: ["s3:GetObject", "s3:GetObjectVersion"]
Resource: !Sub "${DocsBucket.Arn}/*"
IngestFn:
Type: AWS::Lambda::Function
Properties:
FunctionName: meridian-ingest-fn
Runtime: python3.12
Handler: index.handler
Role: !GetAtt WorkerRole.Arn
Timeout: 120
Code:
ZipFile: |
import json
def handler(event, context):
keys = [r["s3"]["object"]["key"]
for r in event.get("Records", [])]
print(json.dumps({"ingested": keys}))
return {"count": len(keys)}
IngestPermission:
Type: AWS::Lambda::Permission
Properties:
FunctionName: !Ref IngestFn
Action: lambda:InvokeFunction
Principal: s3.amazonaws.com
SourceArn: !GetAtt DocsBucket.Arn
translated/provider.tf — the offline harness (create-only plans never call AWS APIs, so this runs with fake keys):
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = { source = "hashicorp/aws", version = "~> 5.0" }
}
}
provider "aws" {
region = "us-east-1"
access_key = "mock"
secret_key = "mock"
skip_credentials_validation = true
skip_requesting_account_id = true
skip_metadata_api_check = true
}
Before translating, build parity-checklist.md by hand: one row per CFN resource (6 rows) with columns CFN logical id · type · load-bearing properties · TF equivalent · parity status. Note anything that is CloudFormation-native rather than AWS-native — intrinsics (!Sub, !GetAtt), and any property where CloudFormation itself does work at deploy time rather than merely passing configuration through. Those rows are where translations die.
✅ Checkpoint: checklist with 6 rows; at least one row flagged "CFN-native behavior — watch closely".
Give your assistant the full stack.yaml: "Translate to Terraform for provider aws ~> 5.0. Preserve names, properties, and IAM semantics exactly. Flag anything that has no direct equivalent instead of improvising." Save the output into translated/ alongside the harness. Do not fix anything yet — the harness gets first look. (₹0 model note: a local ollama run qwen2.5-coder handles this translation; frontier models fall into the same traps, which is the point.)
✅ Checkpoint: translated .tf files on disk, untouched, alongside provider.tf.
In translated/: terraform init -input=false, terraform validate, then terraform plan -input=false. Fix errors one at a time, logging each in trap-notes.md with what the AI wrote versus what the provider actually accepts. Expect friction around how the Lambda's code gets packaged: CloudFormation's ZipFile is deploy-time magic with no direct Terraform argument, and translators tend to invent one. The clean solution stays offline: an archive_file data source zipping a local index.py you extract from the template. Iterate until plan succeeds and shows all six resources.
✅ Checkpoint: terraform plan succeeds, Plan: 6 to add, with the Lambda packaged via a real local zip.
A green plan proves the Terraform is valid, not that it is equivalent. Walk the checklist row by row against the plan output (terraform plan -no-color > plan.txt helps). Give the IAM role special hostility: extract the inline policy JSON from the plan and set it beside the CFN policy document. Check: number of statements, actions per statement, and — critically — which resource ARN shape each action is attached to. Also confirm versioning survived on the bucket, the Lambda permission's source ARN matches, and timeout/runtime carried over. Every discrepancy goes in trap-notes.md with severity and the exact fix.
IAM is where silent translation drift does real damage — a policy that merges statements or reshapes ARNs can pass validate, plan, and even deploy, then fail (or over-permit) only at runtime.
✅ Checkpoint: policy-JSON side-by-side diff exists; checklist has a parity verdict on all 6 rows.
Repair everything found, re-run validate/plan, and write parity-report.md: the six-row verdict table, the two traps with a one-paragraph autopsy each (what the AI did, why it was plausible, which gate caught it — mechanical or human review), and one sentence on what you would automate next time (e.g., policy-JSON diffing as a scripted gate). If your AI happened to dodge a trap, document the dodge — knowing why a prompt avoided a failure is as valuable as catching it.
✅ Checkpoint: final plan green; parity-report.md names both traps with gate attribution.
Translation parity has three tiers — parses, plans, behaves the same — and only the first two are mechanical. The parity review of IAM semantics and deploy-time behaviors is the architect's gate, and it is not optional.
terraform validate and terraform plan both green, 6 resources, fully offline.The iac-parity/ folder: translated stack, harness, parity-checklist.md, trap-notes.md, parity-report.md. Self-grading rubric (pass = 7/9):
Client IaC under a restrictive MSA: the harness, validate, plan, and policy diffing are all local OSS and see no LLM. For the translation step use the client-approved tenancy or a local coder model via Ollama, and pre-redact account IDs, bucket names, and CIDRs (structure, not values, drives translation quality). The parity report — with traps documented — is precisely the artifact that makes AI-assisted translation auditable under client governance.
init fails offline: the provider download needs internet once; everything after is local.plan demands credentials: a skip_* flag was lost from provider.tf, or the translation added a data source that calls AWS — replace it with a literal.index.py next to the tf files and use data "archive_file" with source_file and output_path; it is entirely local.aws_lambda_permission.Trap 1 — CFN-only intrinsic behavior: Code: ZipFile. CloudFormation zips inline code at deploy time; aws_lambda_function has no inline-code argument. AI translations typically hallucinate a zip_file/code argument (validate catches it) or point filename at a raw .py or a nonexistent zip (plan or apply catches it). Correct fix: extract the handler to index.py, package with data "archive_file" (type = "zip", source_file, output_path), and set filename + source_code_hash from it — fully offline.
Trap 2 — IAM policy drift on resource ARN shapes. The CFN policy is deliberately split: s3:ListBucket against the bucket ARN, and s3:GetObject/GetObjectVersion against arn…/* (objects). Translators commonly collapse this into one statement with a single resource — either everything on ${arn}/* (ListBucket silently stops working: it is a bucket-level action) or everything on the bucket ARN (object reads break) — or "helpfully" grant both actions on both resources (over-permissive). validate and plan pass in all three cases; only the human policy diff catches it. Correct fix: preserve two statements with the original action-to-ARN-shape mapping.