Find untagged resources in Terraform state
Problem statement
Finance requires every taggable cloud resource to carry owner and cost-center tags. Given the JSON from terraform show -json, list the managed resources that are missing either tag, and exit 1 if there are any.
Details that matter:
- Resources live in
values.root_module.resources, and child modules nest underchild_modules, to any depth. - Data sources (
mode: "data") are read, not owned. Skip them. - A resource type without a
tagsortags_allattribute cannot be tagged. Skip it too; the IAM policy attachment below is one. tags_allincludes the AWS provider'sdefault_tags, so when present it is what the resource really has.- An empty or blank tag value counts as missing.
tags: nullmeans no tags.
state.json
{ "format_version": "1.0", "values": { "root_module": { "resources": [ {"address": "aws_s3_bucket.logs", "mode": "managed", "type": "aws_s3_bucket", "values": {"bucket": "shop-logs", "tags": {"owner": "platform", "cost-center": "cc-102"}}}, {"address": "aws_instance.bastion", "mode": "managed", "type": "aws_instance", "values": {"instance_type": "t3.micro", "tags": {"Name": "bastion"}}}, {"address": "data.aws_ami.ubuntu", "mode": "data", "type": "aws_ami", "values": {"id": "ami-0abc", "tags": {}}}, {"address": "aws_iam_role_policy_attachment.ci", "mode": "managed", "type": "aws_iam_role_policy_attachment", "values": {"role": "ci", "policy_arn": "arn:aws:iam::aws:policy/ReadOnlyAccess"}}, {"address": "aws_sqs_queue.jobs", "mode": "managed", "type": "aws_sqs_queue", "values": {"name": "jobs", "tags": {"owner": ""}, "tags_all": {"owner": "", "cost-center": "cc-300"}}} ], "child_modules": [ {"address": "module.db", "resources": [ {"address": "module.db.aws_db_instance.main", "mode": "managed", "type": "aws_db_instance", "values": {"engine": "postgres", "tags": null}} ]} ] } }}Examples
Example 1
Input: python solution.py state.json
Output: aws_instance.bastion missing: owner, cost-center
aws_sqs_queue.jobs missing: owner
module.db.aws_db_instance.main missing: owner, cost-center
3 of 4 taggable resources are missing required tags
Explanation: The queue gets cost-center from tags_all (the provider's default tags) but its owner is empty. The AMI is a data source and the policy attachment has no tags attribute; neither is counted.
Hints
Approach
Optimal
Traverse the module tree, then filter and check each resource.
all_resourcesuses a stack instead of recursion. It pushes child modules in reverse so they come out in file order.- Skip anything whose
modeis notmanaged. - Skip resources whose
valueshas neithertagsnortags_all, because their type does not support tags. - Take the effective tags and list each required tag whose value is missing or blank after
strip().str()guards against a non-string value.
r resources times t required tags. The stack holds at most the module depth d plus pending siblings; findings (f) are the only other state.
O(r * t)Space O(d + f)import jsonimport sys REQUIRED_TAGS = ["owner", "cost-center"] def all_resources(module): """Walk root_module and every nested child_modules entry without recursion.""" stack = [module] while stack: mod = stack.pop() yield from mod.get("resources") or [] stack.extend(reversed(mod.get("child_modules") or [])) def check(state, required=REQUIRED_TAGS): root = (state.get("values") or {}).get("root_module") or {} findings, taggable = [], 0 for res in all_resources(root): if res.get("mode") != "managed": continue # data sources are read, not owned values = res.get("values") or {} if "tags" not in values and "tags_all" not in values: continue # this resource type has no tags attribute at all taggable += 1 # tags_all = tags plus the provider's default_tags, i.e. what the cloud really has tags = values.get("tags_all") or values.get("tags") or {} missing = [t for t in required if not str(tags.get(t) or "").strip()] if missing: findings.append((res.get("address", "?"), missing)) return findings, taggable def main(path): try: with open(path, encoding="utf-8") as f: state = json.load(f) except json.JSONDecodeError as e: sys.exit(f"{path}:{e.lineno}: invalid JSON: {e.msg}") findings, taggable = check(state) for address, missing in findings: print(f"{address} missing: {', '.join(missing)}") print(f"{len(findings)} of {taggable} taggable resources are missing required tags") return 1 if findings else 0 if __name__ == "__main__": sys.exit(main(sys.argv[1] if len(sys.argv) > 1 else "state.json"))Follow-up questions
- Run the check against
terraform show -json plan.out, so it fails the pull request before anything is created. Which JSON keys change? - Also require
environmentto be one ofdev,stagingorprod. - Some resource types are exempt. Add an allow-list by type and by address.
Frequently asked questions
The .tf files show what was written, not what resulted: tags can come from variables, merge() calls, modules and provider default_tags. The state (or terraform show -json on a plan) has the evaluated values. Policy tools such as OPA/conftest usually check the plan JSON for the same reason.
Tagging policy is a standard cloud governance problem. Untagged resources cannot be attributed to a team for cost or incident ownership. The question tests traversal of a real, deeply nested JSON format and edge cases that change the answer, such as data sources and tags_all.
AWS tag keys are case-sensitive, so Owner and owner are different tags, and a strict policy should flag it. A kinder check reports "found Owner, expected owner" so the fix is obvious. Decide the policy explicitly rather than lower-casing silently.