You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
We need every AWS resource in the incubator account to be either managed by Terraform, deliberately documented as unmanaged, or deleted, because devops#199 found 282 resources that no code accounts for and there is currently no way to tell an abandoned resource from a load-bearing one.
Action Items
Tracked as sub-issues. This issue closes when they do.
The sub-issues divide the 282 unmanaged resources by disposition rather than by AWS service, because the governing question is per-project ("is this still wanted?") rather than per-type. Three dispositions are available and every resource must reach one of them:
import into incubator/terraform or devops-security/terraform;
delete, where nothing depends on the resource;
document as intentionally unmanaged, where AWS or the design makes management impossible or pointless.
Resources/Instructions
devops#199 — the script and the enumerated list of 282 unmanaged resources. Regenerate at any time with ./scripts/aws-terraform-coverage.ps1 -ListArns.
AWS account 035866691871 (incubator), regions us-west-2 and us-east-1.
This epic resumes The Terraform Migration Epic incubator#20, "The Terraform Migration Epic." That epic was closed with the HomeUniteUs migration item still unchecked while ballotnav, civictechindex, VRMS and people-depot were ticked. Live AWS confirms exactly that: home-unite-us is the one project whose production stack never moved, which is sub-issue 6 here.
Migrate from Terragrunt to Terraform #81 — "Migrate from Terragrunt to Terraform," the V2 rewrite that produced most of the leftovers. Also closed with an unchecked action item.
Only two Terraform states are real: incubator and devops-security. s3://hlfa-incubator-terragrunt holds 26 abandoned state files from the Terragrunt era; the .hcl configuration that produced them was deleted by the V2 rewrite, so they cannot be planned or destroyed and are cleanup, not a management layer. They remain useful as a record of what each resource was originally for — read them before deleting anything, which is why the state-backend cleanup is sequenced last.
All counts, resource names and API behaviours in the sub-issues were observed on 2026-08-28 and will drift. Re-check rather than trusting them.
Importing existing resources into Terraform
Several sub-issues here import a resource that already exists rather than creating one. That is a different and sharper operation than normal Terraform work, and the notes below apply to all of them.
Use import blocks, not the terraform import command. Both repositories pin required_version = "~> 1.12", so config-driven import is available. An import block lives in a .tf file, appears in the pull request, and shows up in the plan that CI posts as a PR comment — so it gets reviewed like any other change. The terraform import CLI command instead writes to the shared remote state immediately from whoever's laptop runs it, with no review and no record in the repository. A block looks like this:
import {
to = module.ecr_backend.aws_ecr_repository.this
id = "civictechindex-backend-prod"
}
Let Terraform write the configuration for you. Run terraform plan -generate-config-out=generated.tf and it emits HCL for every import block that has no matching resource yet. The output is verbose and includes defaults and read-only attributes, so treat it as a starting point to trim rather than something to commit as-is. It is still far faster and more accurate than hand-writing a resource block to match a live one.
The import ID is rarely the ARN, and each resource type differs. This is the most common reason an import fails. Every resource's page in the Terraform AWS provider registry documentation has an "Import" section giving the exact format. For the types this epic touches: aws_ecr_repository takes the repository name, aws_security_group takes the sg- id, aws_ecs_service takes cluster-name/service-name, aws_cloudwatch_log_group takes the log group name, aws_iam_user takes the user name while aws_iam_policy takes the full ARN, aws_lb_target_group takes the ARN, aws_route53_zone takes the zone id, and aws_route53_record takes ZONEID_recordname_TYPE.
Read the plan for replacement before merging — this is the dangerous one. A successful import that plans must be replaced or forces replacement means the configuration does not match the live resource, and applying it will destroy and recreate the real thing. For an ECR repository that means losing every image; for a hosted zone it means new nameservers and a DNS outage. Merging to the default branch runs terraform apply with auto_approve: true, so there is no second gate after review — the PR plan is the last chance to catch it. The target state is a plan that adds the resource to state and changes nothing else, aside from tags.
Expect the plan to add tags, and use that. Both providers set default_tags, so an imported resource will show managed-by being added. That diff is normal and is the mechanism by which the resource starts reporting as managed in devops#199's coverage report. Until an apply actually runs, the resource stays untagged and the report still calls it unmanaged, so verify with terraform state list immediately after import and treat the coverage report as the post-apply check.
Import parents before children, and remember children are separate resources. Security group rules are not pulled in with their security group; in AWS provider v4 and later a bucket's policy, versioning and encryption are each their own resource rather than attributes of aws_s3_bucket. Import one logical group per pull request — batching many makes it impossible to tell which one caused a failure.
To back an import out, use a removed block rather than terraform state rm, for the same reason as above: it is reviewable and it runs through CI. Setting lifecycle { destroy = false } inside it drops the resource from state while leaving the real infrastructure untouched.
Two local gotchas. Check aws sts get-caller-identity before running anything, because the aws provider block pins neither account nor region and takes both from the ambient environment. And the plan workflow assumes the incubator-tf-plan role, which is read-only and separate from the apply role — if a plan fails on permissions for a resource type nobody has imported before, that role is the first place to look. Note also that the terraform-docs job pushes a README commit back to your branch, so pull before pushing again.
Overview
We need every AWS resource in the incubator account to be either managed by Terraform, deliberately documented as unmanaged, or deleted, because devops#199 found 282 resources that no code accounts for and there is currently no way to tell an abandoned resource from a load-bearing one.
Action Items
Tracked as sub-issues. This issue closes when they do.
The sub-issues divide the 282 unmanaged resources by disposition rather than by AWS service, because the governing question is per-project ("is this still wanted?") rather than per-type. Three dispositions are available and every resource must reach one of them:
incubator/terraformordevops-security/terraform;Resources/Instructions
./scripts/aws-terraform-coverage.ps1 -ListArns.035866691871(incubator), regionsus-west-2andus-east-1.incubatoranddevops-security.s3://hlfa-incubator-terragruntholds 26 abandoned state files from the Terragrunt era; the.hclconfiguration that produced them was deleted by the V2 rewrite, so they cannot be planned or destroyed and are cleanup, not a management layer. They remain useful as a record of what each resource was originally for — read them before deleting anything, which is why the state-backend cleanup is sequenced last.Importing existing resources into Terraform
Several sub-issues here import a resource that already exists rather than creating one. That is a different and sharper operation than normal Terraform work, and the notes below apply to all of them.
Use
importblocks, not theterraform importcommand. Both repositories pinrequired_version = "~> 1.12", so config-driven import is available. Animportblock lives in a.tffile, appears in the pull request, and shows up in the plan that CI posts as a PR comment — so it gets reviewed like any other change. Theterraform importCLI command instead writes to the shared remote state immediately from whoever's laptop runs it, with no review and no record in the repository. A block looks like this:Let Terraform write the configuration for you. Run
terraform plan -generate-config-out=generated.tfand it emits HCL for everyimportblock that has no matching resource yet. The output is verbose and includes defaults and read-only attributes, so treat it as a starting point to trim rather than something to commit as-is. It is still far faster and more accurate than hand-writing a resource block to match a live one.The import ID is rarely the ARN, and each resource type differs. This is the most common reason an import fails. Every resource's page in the Terraform AWS provider registry documentation has an "Import" section giving the exact format. For the types this epic touches:
aws_ecr_repositorytakes the repository name,aws_security_grouptakes thesg-id,aws_ecs_servicetakescluster-name/service-name,aws_cloudwatch_log_grouptakes the log group name,aws_iam_usertakes the user name whileaws_iam_policytakes the full ARN,aws_lb_target_grouptakes the ARN,aws_route53_zonetakes the zone id, andaws_route53_recordtakesZONEID_recordname_TYPE.Read the plan for replacement before merging — this is the dangerous one. A successful import that plans
must be replacedorforces replacementmeans the configuration does not match the live resource, and applying it will destroy and recreate the real thing. For an ECR repository that means losing every image; for a hosted zone it means new nameservers and a DNS outage. Merging to the default branch runsterraform applywithauto_approve: true, so there is no second gate after review — the PR plan is the last chance to catch it. The target state is a plan that adds the resource to state and changes nothing else, aside from tags.Expect the plan to add tags, and use that. Both providers set
default_tags, so an imported resource will showmanaged-bybeing added. That diff is normal and is the mechanism by which the resource starts reporting as managed in devops#199's coverage report. Until an apply actually runs, the resource stays untagged and the report still calls it unmanaged, so verify withterraform state listimmediately after import and treat the coverage report as the post-apply check.Import parents before children, and remember children are separate resources. Security group rules are not pulled in with their security group; in AWS provider v4 and later a bucket's policy, versioning and encryption are each their own resource rather than attributes of
aws_s3_bucket. Import one logical group per pull request — batching many makes it impossible to tell which one caused a failure.To back an import out, use a
removedblock rather thanterraform state rm, for the same reason as above: it is reviewable and it runs through CI. Settinglifecycle { destroy = false }inside it drops the resource from state while leaving the real infrastructure untouched.Two local gotchas. Check
aws sts get-caller-identitybefore running anything, because theawsprovider block pins neither account nor region and takes both from the ambient environment. And the plan workflow assumes theincubator-tf-planrole, which is read-only and separate from the apply role — if a plan fails on permissions for a resource type nobody has imported before, that role is the first place to look. Note also that theterraform-docsjob pushes a README commit back to your branch, so pull before pushing again.