Practical infra coding

Find untagged resources in Terraform state

mediumCSV, JSON and YAML

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 under child_modules, to any depth.
  • Data sources (mode: "data") are read, not owned. Skip them.
  • A resource type without a tags or tags_all attribute cannot be tagged. Skip it too; the IAM policy attachment below is one.
  • tags_all includes the AWS provider's default_tags, so when present it is what the resource really has.
  • An empty or blank tag value counts as missing. tags: null means no tags.

state.json

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.

  1. all_resources uses a stack instead of recursion. It pushes child modules in reverse so they come out in file order.
  2. Skip anything whose mode is not managed.
  3. Skip resources whose values has neither tags nor tags_all, because their type does not support tags.
  4. 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.

ComplexityTime O(r * t)Space O(d + f)
Python
import json
import 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 environment to be one of dev, staging or prod.
  • 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.