Skip to content

Commit 7010fa6

Browse files
sontekJohn Anderson
andauthored
fix: unresolved JSON strings aren't valid JSON (#274)
When we have a JSON string (like container definitions) if it is unresolved we are inserting a reference string in there to what was unresolved but its not valid JSON so if anyone uses `fromjson()` on it it will fail. This detects JSON strings and formats it properly while maintaining the reference. Co-authored-by: John Anderson <[email protected]>
1 parent e28ea82 commit 7010fa6

File tree

6 files changed

+228
-3
lines changed

6 files changed

+228
-3
lines changed

gotfparse/pkg/converter/converter.go

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -294,7 +294,7 @@ func (t *terraformConverter) getAttributeValue(a *terraform.Attribute) any {
294294
}
295295

296296
if traversalExpr, isTraversal := hclAttr.Expr.(*hclsyntax.ScopeTraversalExpr); isTraversal {
297-
return t.handleScopeTraversal(traversalExpr)
297+
return t.handleScopeTraversal(traversalExpr, val)
298298
}
299299

300300
if funcExpr, isFuncCall := hclAttr.Expr.(*hclsyntax.FunctionCallExpr); isFuncCall {
@@ -332,7 +332,7 @@ func (t *terraformConverter) handleTemplateExpression(a *terraform.Attribute, te
332332
}
333333

334334
// handleScopeTraversal processes direct references to resources or attributes
335-
func (t *terraformConverter) handleScopeTraversal(traversalExpr *hclsyntax.ScopeTraversalExpr) any {
335+
func (t *terraformConverter) handleScopeTraversal(traversalExpr *hclsyntax.ScopeTraversalExpr, val cty.Value) any {
336336
// Only process if we have enough parts for a meaningful reference
337337
if len(traversalExpr.Traversal) <= 1 {
338338
return nil
@@ -382,9 +382,29 @@ func (t *terraformConverter) handleScopeTraversal(traversalExpr *hclsyntax.Scope
382382
}
383383
}
384384

385+
fullRef := refString.String()
386+
387+
// Special case: If this is an unknown JSON string (detected via refinements),
388+
// return a parseable JSON placeholder instead of a reference object.
389+
// This allows downstream tools like JMESPath's from_json() to work while
390+
// still preserving the reference information for debugging.
391+
if !val.IsKnown() && val.Type() == cty.String {
392+
if rng := val.Range(); rng.StringPrefix() != "" {
393+
prefix := rng.StringPrefix()
394+
395+
if prefix == "[" {
396+
// Return JSON array placeholder with unresolved marker
397+
return fmt.Sprintf("[{\"__unresolved__\": \"%s\"}]", fullRef)
398+
} else if prefix == "{" {
399+
// Return JSON object placeholder with unresolved marker
400+
return fmt.Sprintf("{\"__unresolved__\": \"%s\"}", fullRef)
401+
}
402+
}
403+
}
404+
385405
// Construct the reference object with all available information
386406
refObj := map[string]interface{}{
387-
"__attribute__": refString.String(),
407+
"__attribute__": fullRef,
388408
}
389409

390410
// Always add type information if available
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# Variables without defaults - makes them unresolvable at static analysis time
2+
variable "root_task_name" {
3+
type = string
4+
# NO DEFAULT - unresolvable
5+
}
6+
7+
variable "root_image" {
8+
type = string
9+
# NO DEFAULT - unresolvable
10+
}
11+
12+
# Direct module usage - this works fine
13+
module "container_direct" {
14+
source = "./module"
15+
name = "direct-container"
16+
image = "nginx:latest"
17+
}
18+
19+
resource "aws_ecs_task_definition" "direct" {
20+
family = "direct-task"
21+
network_mode = "awsvpc"
22+
requires_compatibilities = ["FARGATE"]
23+
cpu = "256"
24+
memory = "512"
25+
26+
# Direct module reference - this should work
27+
container_definitions = module.container_direct.json_encoded_list
28+
}
29+
30+
# Nested module usage - this reproduces the issue
31+
# Pass unresolvable variables to the wrapper module
32+
module "task_wrapper" {
33+
source = "./modules/task_wrapper"
34+
task_name = var.root_task_name # Unknown!
35+
image = var.root_image # Unknown!
36+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
variable "container_name" {
2+
type = string
3+
}
4+
5+
variable "map_environment" {
6+
type = map(string)
7+
default = null
8+
}
9+
10+
locals {
11+
# This pattern triggers UnknownVal: for loop inside ternary
12+
final_environment_vars = var.map_environment != null ? [
13+
for k, v in var.map_environment : {
14+
name = k
15+
value = v
16+
}
17+
] : null
18+
19+
container_definition = {
20+
name = var.container_name
21+
environment = local.final_environment_vars
22+
}
23+
24+
container_definition_without_null = {
25+
for k, v in local.container_definition :
26+
k => v
27+
if v != null
28+
}
29+
30+
final_container_definition = merge(local.container_definition_without_null, {})
31+
32+
json_map = jsonencode(local.final_container_definition)
33+
}
34+
35+
output "json_map_encoded_list" {
36+
value = "[${local.json_map}]"
37+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
variable "name" {
2+
type = string
3+
description = "Container name"
4+
}
5+
6+
variable "image" {
7+
type = string
8+
description = "Container image"
9+
}
10+
11+
locals {
12+
container_definition = {
13+
name = var.name
14+
image = var.image
15+
essential = true
16+
portMappings = [
17+
{
18+
containerPort = 80
19+
protocol = "tcp"
20+
}
21+
]
22+
}
23+
24+
# This simulates what simple modules do - direct jsonencode
25+
json_map = jsonencode(local.container_definition)
26+
}
27+
28+
output "json_encoded_list" {
29+
description = "JSON string encoded list of container definitions"
30+
value = "[${local.json_map}]"
31+
}
32+
33+
output "json_map_object" {
34+
description = "Container definition as an object"
35+
value = local.container_definition
36+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
variable "task_name" {
2+
type = string
3+
# NO DEFAULT - makes it unresolvable at static analysis time
4+
}
5+
6+
variable "image" {
7+
type = string
8+
# NO DEFAULT - makes it unresolvable at static analysis time
9+
}
10+
11+
variable "environment_vars" {
12+
type = map(string)
13+
default = {}
14+
# Has a default, but when overridden, makes things unresolvable
15+
}
16+
17+
# Nested module - testing minimal complexity to trigger UnknownVal
18+
module "container" {
19+
source = "../../minimal_module"
20+
container_name = var.task_name # Unknown!
21+
container_image = var.image # Unknown!
22+
}
23+
24+
resource "aws_ecs_task_definition" "wrapped_task" {
25+
family = var.task_name
26+
network_mode = "awsvpc"
27+
requires_compatibilities = ["FARGATE"]
28+
cpu = "512"
29+
memory = "1024"
30+
31+
# This references a nested module output (cloudposse uses json_map_encoded_list)
32+
container_definitions = module.container.json_map_encoded_list
33+
}
34+
35+
output "task_arn" {
36+
value = aws_ecs_task_definition.wrapped_task.arn
37+
}

tests/test_tfparse.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -704,3 +704,62 @@ def test_not_wholly_known_foreach(tmp_path):
704704
assert parsed["locals"][0]["current_month"] is None
705705
assert parsed["locals"][0]["last_month"] is None
706706
assert parsed["terraform_data"][0]["for_each"] is None
707+
708+
709+
def test_module_output_json_string(tmp_path):
710+
"""
711+
Test that module outputs that should be JSON strings are handled correctly.
712+
713+
For unresolvable module outputs that have JSON refinements (start with "[" or "{"),
714+
we should return a parseable JSON placeholder instead of a reference object.
715+
716+
This prevents JMESPath errors when using from_json():
717+
JMESPathTypeError: In function from_json(), invalid type for value:
718+
{'__attribute__': 'module.container.json_map_encoded_list', '__name__': 'container'},
719+
expected one of: ['string'], received: "object"
720+
"""
721+
mod_path = init_module("module-output-json-string", tmp_path, run_init=False)
722+
parsed = load_from_path(mod_path)
723+
724+
assert "aws_ecs_task_definition" in parsed
725+
assert len(parsed["aws_ecs_task_definition"]) == 2 # direct and wrapped
726+
727+
# Test 1: Direct module reference - fully resolvable
728+
direct_task = [
729+
t for t in parsed["aws_ecs_task_definition"] if t["family"] == "direct-task"
730+
][0]
731+
direct_container_defs = direct_task["container_definitions"]
732+
733+
assert isinstance(direct_container_defs, str), (
734+
f"Expected direct container_definitions to be a string (JSON-encoded), "
735+
f"but got {type(direct_container_defs).__name__}: {direct_container_defs}"
736+
)
737+
738+
import json
739+
740+
parsed_direct = json.loads(direct_container_defs)
741+
assert parsed_direct[0]["name"] == "direct-container"
742+
743+
# Test 2: Nested module reference - unresolvable, should get JSON placeholder
744+
# The wrapped task's family is also unresolvable, so find it by path
745+
wrapped_task = [
746+
t
747+
for t in parsed["aws_ecs_task_definition"]
748+
if "task_wrapper" in t.get("__tfmeta", {}).get("path", "")
749+
][0]
750+
nested_container_defs = wrapped_task["container_definitions"]
751+
752+
# Should be a string (JSON), not a reference object
753+
assert isinstance(nested_container_defs, str), (
754+
f"Expected nested container_definitions to be a string (JSON-encoded), "
755+
f"but got {type(nested_container_defs).__name__}: {nested_container_defs}"
756+
)
757+
758+
# Should be parseable JSON with __unresolved__ marker
759+
parsed_nested = json.loads(nested_container_defs)
760+
assert isinstance(parsed_nested, list), "Should be a JSON array"
761+
assert len(parsed_nested) == 1, "Should have one placeholder item"
762+
assert "__unresolved__" in parsed_nested[0], "Should have __unresolved__ marker"
763+
assert (
764+
"module.container.json_map_encoded_list" in parsed_nested[0]["__unresolved__"]
765+
), "Should preserve the reference path"

0 commit comments

Comments
 (0)