Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion gotfparse/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -116,4 +116,4 @@ require (
k8s.io/utils v0.0.0-20260319190234-28399d86e0b5 // indirect
)

replace github.com/aquasecurity/trivy => github.com/cloud-custodian/trivy v0.0.0-20260918214643-0ed990bcb292
replace github.com/aquasecurity/trivy => github.com/ezaz-khan-apex/trivy v0.0.0-20261006151129-a72c9d7e6e17
4 changes: 2 additions & 2 deletions gotfparse/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -100,8 +100,6 @@ github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK3
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloud-custodian/trivy v0.0.0-20260918214643-0ed990bcb292 h1:DkxCy8Z5gyqtU8AY4BIpDCdFGPdg5ZyMhDxvJCNw/lQ=
github.com/cloud-custodian/trivy v0.0.0-20260918214643-0ed990bcb292/go.mod h1:Wf+eykRx2CxQsmo0e0gy8t7byAVbj6FpevuipHcodCc=
github.com/cloudflare/circl v1.6.4 h1:pOXuDTCEYyzydgUpQ0CQz3LsinKjiSk6nNP5Lt5K64U=
github.com/cloudflare/circl v1.6.4/go.mod h1:YxarevkLlbaHuWsxG6vmYNWBEsSp4pnp7j+4VljMavY=
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik=
Expand Down Expand Up @@ -143,6 +141,8 @@ github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4=
github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds=
github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0=
github.com/ezaz-khan-apex/trivy v0.0.0-20261006151129-a72c9d7e6e17 h1:x6O1hb8qUnqNFqNjm+aabuIKgI3tpCxWOlqGQE3kbPI=
github.com/ezaz-khan-apex/trivy v0.0.0-20261006151129-a72c9d7e6e17/go.mod h1:Wf+eykRx2CxQsmo0e0gy8t7byAVbj6FpevuipHcodCc=
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
Expand Down
88 changes: 88 additions & 0 deletions gotfparse/pkg/converter/converter.go
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,10 @@ func (t *terraformConverter) getAttributeValue(a *terraform.Attribute) any {
if funcExpr, isFuncCall := hclAttr.Expr.(*hclsyntax.FunctionCallExpr); isFuncCall {
return t.handleFunctionCall(funcExpr)
}

if partial, ok := t.handlePartialCollection(hclAttr.Expr, val); ok {
return partial
}
}

// Try to convert the value to a native type
Expand Down Expand Up @@ -444,6 +448,90 @@ func (t *terraformConverter) handleScopeTraversal(traversalExpr *hclsyntax.Scope
}

// handleFunctionCall processes function call expressions
// handlePartialCollection converts a list or object literal whose value is known
// but not wholly known element by element, so an element that is a reference to
// an unknown value keeps that reference ({"__attribute__": ...}) instead of
// collapsing to null alongside its known siblings. ok is false when the
// expression is not such a literal, leaving the caller's conversion in place.
func (t *terraformConverter) handlePartialCollection(expr hcl.Expression, val cty.Value) (any, bool) {
if !val.IsKnown() || val.IsNull() || val.IsMarked() {
return nil, false
}
switch e := expr.(type) {
case *hclsyntax.TupleConsExpr:
if !(val.Type().IsTupleType() || val.Type().IsListType()) || val.LengthInt() != len(e.Exprs) {
return nil, false
}
out := make([]any, len(e.Exprs))
for i, item := range e.Exprs {
out[i] = t.partialElement(item, val.Index(cty.NumberIntVal(int64(i))))
}
return out, true
case *hclsyntax.ObjectConsExpr:
if !(val.Type().IsObjectType() || val.Type().IsMapType()) {
return nil, false
}
out := make(map[string]any, len(e.Items))
for _, item := range e.Items {
key, literal := literalObjectKey(item.KeyExpr)
if !literal || !val.Type().IsObjectType() && !val.HasIndex(cty.StringVal(key)).True() ||
val.Type().IsObjectType() && !val.Type().HasAttribute(key) {
return nil, false
}
var elem cty.Value
if val.Type().IsObjectType() {
elem = val.GetAttr(key)
} else {
elem = val.Index(cty.StringVal(key))
}
out[key] = t.partialElement(item.ValueExpr, elem)
}
return out, true
}
return nil, false
}

// partialElement converts one element of a partially known collection literal.
func (t *terraformConverter) partialElement(expr hcl.Expression, val cty.Value) any {
if val.IsWhollyKnown() {
if raw, ok := convertCtyToNativeValue(val); ok {
return raw
}
return nil
}
if traversalExpr, ok := expr.(*hclsyntax.ScopeTraversalExpr); ok {
return t.handleScopeTraversal(traversalExpr, val)
}
if partial, ok := t.handlePartialCollection(expr, val); ok {
return partial
}
if raw, ok := convertCtyToNativeValue(val); ok {
return raw
}
return nil
}

// literalObjectKey returns an object constructor key written as a literal name
// or string; a computed key such as (var.x) is not literal.
func literalObjectKey(keyExpr hclsyntax.Expression) (string, bool) {
if wrapped, ok := keyExpr.(*hclsyntax.ObjectConsKeyExpr); ok {
if wrapped.ForceNonLiteral {
return "", false
}
if traversal, ok := wrapped.Wrapped.(*hclsyntax.ScopeTraversalExpr); ok && len(traversal.Traversal) == 1 {
return traversal.Traversal.RootName(), true
}
keyExpr = wrapped.Wrapped
}
if template, ok := keyExpr.(*hclsyntax.TemplateExpr); ok && len(template.Parts) == 1 {
keyExpr = template.Parts[0]
}
if lit, ok := keyExpr.(*hclsyntax.LiteralValueExpr); ok && lit.Val.Type() == cty.String {
return lit.Val.AsString(), true
}
return "", false
}

func (t *terraformConverter) handleFunctionCall(funcExpr *hclsyntax.FunctionCallExpr) any {
logger.Debug("Function call detected", "name", funcExpr.Name, "argCount", len(funcExpr.Args))

Expand Down
17 changes: 17 additions & 0 deletions tests/terraform/for-each-local-unknown-values/main.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
resource "google_compute_subnetwork" "s" {
name = "s"
}

locals {
# The keys are known now; every value holds an attribute known only after apply.
nodes = {
a = { subnet = google_compute_subnetwork.s.self_link }
b = { subnet = google_compute_subnetwork.s.self_link }
}
}

module "node" {
for_each = local.nodes
source = "./node"
hostname = each.key
}
1 change: 1 addition & 0 deletions tests/terraform/for-each-local-unknown-values/node/main.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
variable "hostname" {}
17 changes: 17 additions & 0 deletions tests/terraform/partial-collection-references/main.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
resource "aws_iam_role" "app" {
name = "app"
}

locals {
group = "arn:aws:iam::123456789012:group/admins"
}

module "policy" {
source = "./policy"
# One element is known now, the other only after apply.
principals = [local.group, aws_iam_role.app.unique_id]
grants = {
reader = aws_iam_role.app.unique_id
owner = local.group
}
}
2 changes: 2 additions & 0 deletions tests/terraform/partial-collection-references/policy/main.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
variable "principals" {}
variable "grants" {}
25 changes: 25 additions & 0 deletions tests/test_tfparse.py
Original file line number Diff line number Diff line change
Expand Up @@ -843,3 +843,28 @@ def test_module_output_json_string(tmp_path):
assert (
"module.container.json_map_encoded_list" in parsed_nested[0]["__unresolved__"]
), "Should preserve the reference path"


def test_partial_collection_keeps_unknown_references(tmp_path):
"""A list or object literal with one apply-time element keeps its known
siblings and reports the unknown element as a reference, not null."""
mod_path = init_module("partial-collection-references", tmp_path, run_init=False)
parsed = load_from_path(mod_path)
policy = next(m for m in parsed["module"] if m["__tfmeta"]["label"] == "policy")

group = "arn:aws:iam::123456789012:group/admins"
assert policy["principals"][0] == group
assert policy["principals"][1]["__attribute__"] == "aws_iam_role.app.unique_id"
assert policy["grants"]["owner"] == group
assert policy["grants"]["reader"]["__attribute__"] == "aws_iam_role.app.unique_id"


def test_for_each_over_local_with_unknown_values(tmp_path):
"""A for_each whose keys are known expands even when every value holds an
apply-time attribute, as Terraform does; the deferral used to leave it
unexpanded with each.key unbound."""
mod_path = init_module("for-each-local-unknown-values", tmp_path, run_init=False)
parsed = load_from_path(mod_path)
nodes = [m for m in parsed["module"] if m["__tfmeta"]["label"].startswith("node")]
assert sorted(m["__tfmeta"]["label"] for m in nodes) == ['node["a"]', 'node["b"]']
assert sorted(m["hostname"] for m in nodes) == ["a", "b"]
Loading