In Terraform plan JSON, how do you join a configuration reference back to its planned resource?
answer
- two views address things differently
- trim the traversal to the resource
- count and for_each add indices
- carry the module path with you
- index the planned side once
basics
~20 sStrip the reference down to its bare resource address, then look that address up among the planned resources. The two sides do not match literally: configuration addresses carry no count or for_each index, and module resources are nested differently in each section.
solid answer
~40 sTake the reference (`aws_security_group.web.id`), cut it back to the resource address (`aws_security_group.web`), and find that resource among the planned ones. The trap is that the two sides address things differently. In `configuration` a block appears once, unindexed; in `planned_values` it appears once per instance, as `aws_instance.web[0]` and `aws_instance.web[1]` for `count`, or `aws_instance.web["blue"]` for `for_each`. Module resources are nested under `module_calls.<name>.module` in configuration but under `child_modules` in planned values, with addresses prefixed `module.network.…`, and references inside a module are written relative to that module. So the join is: normalise both sides to a module-qualified address, drop the index, and build one address-to-resource index up front rather than rescanning. Then evaluate the rule once per expanded pair.
code
json · 14 lines{
"planned_values": { "root_module": { "resources": [
{ "address": "aws_instance.web[0]", "type": "aws_instance", "name": "web",
"index": 0, "values": { "tags": { "tier": "data" } } },
{ "address": "aws_security_group.web", "type": "aws_security_group", "name": "web",
"values": { "egress": [ { "cidr_blocks": ["0.0.0.0/0"] } ] } }
] } },
"configuration": { "root_module": { "resources": [
{ "address": "aws_instance.web", "mode": "managed", "type": "aws_instance", "name": "web",
"expressions": {
"vpc_security_group_ids": {
"references": ["aws_security_group.web.id", "aws_security_group.web"] } } }
] } }
}go deeper
Know that the same resource is addressed differently in the two sections, and that you match on the address string rather than on a provider id. Being able to point at aws_instance.web versus aws_instance.web[0] is enough at this level.
Walk through the normalisation step by step: trim the traversal, strip or ignore the index, prepend the module path, then look up. Be ready to say why one configuration block can correspond to many planned instances.
Show that you build an index once and evaluate per expanded instance, and that you classify an unresolved reference as unevaluable rather than compliant. Interviewers probe whether your join is correct on a real multi-module estate, not on a toy plan.
Own the position that join logic is shared infrastructure, not per-rule code. Every rule that reimplements address normalisation will get modules or for_each wrong differently, so the argument to make is for one reviewed traversal that all rules consume.
## The join, stated plainly You have a reference string from the `configuration` view and you want the matching entry in `planned_values`. Three normalisations stand between them. **1. Trim the traversal to a resource address.** `aws_security_group.web.id` is an attribute traversal; the resource is `aws_security_group.web`. The `references` array usually offers you the bare address alongside the traversal, so prefer that entry rather than parsing suffixes yourself. Watch for references that are not resources at all — `var.sg_id`, `local.groups`, `module.network.sg_id`, `data.aws_vpc.main.id` — because a rule that blindly treats every reference as a managed resource address will match nothing and quietly conclude the rule passed. **2. Reconcile expansion.** `configuration` holds one entry per *block*; `planned_values` holds one per *instance*. With `count = 2` the configuration side has a single `aws_instance.web`, while the planned side has `aws_instance.web[0]` and `aws_instance.web[1]`, each with its own `index` field and its own `values`. `for_each` produces string keys instead: `aws_instance.web["blue"]`. A literal string comparison therefore fails on exactly the resources that matter most, because those are usually the fleets. Two consequences: strip or ignore the index when matching, and remember that one configuration-side rule violation may correspond to N planned instances — you must evaluate the pair for every expansion, since two instances of the same block can end up joined to different targets when the argument itself is index-dependent. **3. Reconcile module nesting.** The two views nest modules differently and this is where most joins break: | view | where module resources live | address form | | --- | --- | --- | | configuration | `root_module.module_calls.<name>.module.resources` | relative, e.g. `aws_security_group.web` | | planned_values | `root_module.child_modules[].resources` | absolute, e.g. `module.network.aws_security_group.web` | A reference written inside a module is relative to that module. So while walking the configuration tree you must carry the module path with you and prepend it before matching. Skipping that step is what produces the classic false result: three modules each declare `aws_security_group.web`, your rule joins to whichever one it found first, and it reports a violation on a group in a different module — or misses a real one. ## How to structure the evaluation Do it in two passes rather than one. - **Pass one:** walk `planned_values`, including every level of `child_modules`, and build a map from module-qualified, index-stripped address to a list of the instances at that address. You now have an index rather than a tree to search. - **Pass two:** walk `configuration`, again carrying the module path. For each resource that is a candidate subject of the rule, read the relevant argument's `references`, qualify them, and look them up in the map. The two-pass shape matters for more than tidiness. A one-pass rule that searches the planned tree afresh for every reference gets quadratic on a large estate, and large estates are exactly where these rules run in a pull-request gate with a time budget. ## What the join gives you, and what it does not After the join you hold both sides of the pair: the instance's `tags` and the security group's `egress` blocks, or the trail's configuration and the log group's `retention_in_days`. Now the rule can say something true about the relationship — *this data-tier instance attaches a group with `0.0.0.0/0` egress* — instead of the two useless per-resource approximations. What the join cannot do is invent the other side. If the reference resolves to a variable whose value comes from outside the run, to a module output fed by another configuration, or to nothing at all because the argument was a literal id, the lookup returns empty. Treat an empty lookup as a distinct outcome and say so in the finding. A rule that cannot locate its subject has not proved compliance; conflating the two is how a gate accumulates rules that can never fire. ## Practical checks on your own join - Does it still work when the subject uses `count = 0`? The block exists in configuration with no planned instances at all. - Does it survive a module nested inside another module? `child_modules` recurses, and so must your walk. - Does it distinguish `mode: "managed"` from `mode: "data"` on the resource it landed on? A data source names something this configuration does not manage, so blocking the plan cannot fix it. - Does it report unresolved references, rather than dropping them?
- The reference points at var.sg_id rather than a resource. Now what?You have to resolve the indirection before you can join. A root-module variable's value shows up in the plan's top-level `variables` object; an input to a module call shows up as that call's own expression in `configuration`, which may itself be a reference you follow one more hop. If the chain ends at a literal or at a value supplied from outside the run, there is no in-plan resource to check and you should record the rule as unevaluable rather than passing it.
- Why not match on resource type and name instead of the full address?Because type and name are not unique across an estate. `aws_security_group.web` can be declared in the root module and in three child modules, and the module path is part of the resource's identity. Matching on the short form joins the subject to the wrong target, which is worse than not joining at all — you get a confident finding about the wrong resource, and the author wastes their afternoon proving you wrong.
- How does for_each change the pairing compared with count?The mechanics are the same but the keys are strings: `aws_instance.web["blue"]` instead of `aws_instance.web[0]`. Configuration still shows a single unindexed block, so one block fans out to N planned instances and the rule must be evaluated per instance. If the referenced argument varies by key, different instances legitimately join to different targets, and reporting a single aggregate verdict for the block hides which one actually violates.
saying these in an interview costs you the question
- Compares configuration and planned addresses literally, ignoring indices
- Assumes one configuration entry means one planned resource
- Drops the module path and matches on type and name
- Rescans the planned tree for every reference
- Treats an unresolved reference as a pass