Skip to content

Commit b26d42f

Browse files
committed
feat: loading in plan state data only loads once per module
module state is loaded into root context with index merging as appropriate
1 parent 8429fee commit b26d42f

File tree

8 files changed

+1146
-23
lines changed

8 files changed

+1146
-23
lines changed

hclext/merge.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,48 @@ func MergeObjects(a, b cty.Value) cty.Value {
2424
func isNotEmptyObject(val cty.Value) bool {
2525
return !val.IsNull() && val.IsKnown() && val.Type().IsObjectType() && val.LengthInt() > 0
2626
}
27+
28+
func MergeWithTupleElement(list cty.Value, idx int, val cty.Value) cty.Value {
29+
if list.IsNull() ||
30+
!list.Type().IsTupleType() ||
31+
list.LengthInt() <= idx {
32+
return InsertTupleElement(list, idx, val)
33+
}
34+
35+
existingElement := list.Index(cty.NumberIntVal(int64(idx)))
36+
merged := MergeObjects(existingElement, val)
37+
return InsertTupleElement(list, idx, merged)
38+
}
39+
40+
// InsertTupleElement inserts a value into a tuple at the specified index.
41+
// If the idx is outside the bounds of the list, it grows the tuple to
42+
// the new size, and fills in `cty.NilVal` for the missing elements.
43+
//
44+
// This function will not panic. If the list value is not a list, it will
45+
// be replaced with an empty list.
46+
func InsertTupleElement(list cty.Value, idx int, val cty.Value) cty.Value {
47+
if list.IsNull() || !list.Type().IsTupleType() {
48+
// better than a panic
49+
list = cty.EmptyTupleVal
50+
}
51+
52+
if idx < 0 {
53+
// Nothing to do?
54+
return list
55+
}
56+
57+
newList := make([]cty.Value, max(idx+1, list.LengthInt()))
58+
for i := 0; i < len(newList); i++ {
59+
newList[i] = cty.NilVal // Always insert a nil by default
60+
61+
if i < list.LengthInt() { // keep the original
62+
newList[i] = list.Index(cty.NumberIntVal(int64(i)))
63+
}
64+
65+
if i == idx { // add the new value
66+
newList[i] = val
67+
}
68+
}
69+
70+
return cty.TupleVal(newList)
71+
}

plan.go

Lines changed: 112 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import (
1515
tfjson "github.com/hashicorp/terraform-json"
1616
"github.com/zclconf/go-cty/cty"
1717
"github.com/zclconf/go-cty/cty/gocty"
18+
19+
"github.com/coder/preview/hclext"
1820
)
1921

2022
func PlanJSONHook(dfs fs.FS, input Input) (func(ctx *tfcontext.Context, blocks terraform.Blocks, inputVars map[string]cty.Value), error) {
@@ -31,29 +33,45 @@ func PlanJSONHook(dfs fs.FS, input Input) (func(ctx *tfcontext.Context, blocks t
3133
return nil, fmt.Errorf("unable to parse plan JSON: %w", err)
3234
}
3335

34-
var _ = plan
3536
return func(ctx *tfcontext.Context, blocks terraform.Blocks, inputVars map[string]cty.Value) {
37+
loaded := make(map[*tfjson.StateModule]bool)
38+
3639
// Do not recurse to child blocks.
3740
// TODO: Only load into the single parent context for the module.
41+
// And do not load context for a module more than once
3842
for _, block := range blocks {
43+
// TODO: Maybe switch to the 'configuration' block
3944
planMod := priorPlanModule(plan, block)
4045
if planMod == nil {
4146
continue
4247
}
4348

44-
// TODO: Nested blocks might have an issue here with the correct context.
45-
// We want the "module context", which is the parent of the top level
46-
// block. Maybe there is a way to discover what that is via some
47-
// var set in the context.
48-
err = loadResourcesToContext(block.Context().Parent(), planMod.Resources)
49+
if loaded[planMod] {
50+
// No need to load this module into state again
51+
continue
52+
}
53+
54+
rootCtx := block.Context()
55+
for {
56+
if rootCtx.Parent() != nil {
57+
rootCtx = rootCtx.Parent()
58+
continue
59+
}
60+
break
61+
}
62+
63+
// Load state into the context
64+
err := loadResourcesToContext(rootCtx, planMod.Resources)
4965
if err != nil {
5066
// TODO: Somehow handle this error
5167
panic(fmt.Sprintf("unable to load resources to context: %v", err))
5268
}
69+
loaded[planMod] = true
5370
}
5471
}, nil
5572
}
5673

74+
// priorPlanModule returns the state data of the module a given block is in.
5775
func priorPlanModule(plan *tfjson.Plan, block *terraform.Block) *tfjson.StateModule {
5876
if !block.InModule() {
5977
return plan.PriorState.Values.RootModule
@@ -85,6 +103,22 @@ func priorPlanModule(plan *tfjson.Plan, block *terraform.Block) *tfjson.StateMod
85103
return current
86104
}
87105

106+
func matchingBlock(block *terraform.Block, planMod *tfjson.StateModule) *tfjson.StateResource {
107+
ref := block.Reference()
108+
matchKey := keyMatcher(ref.RawKey())
109+
110+
for _, resource := range planMod.Resources {
111+
if ref.BlockType().ShortName() == string(resource.Mode) &&
112+
ref.TypeLabel() == resource.Type &&
113+
ref.NameLabel() == resource.Name &&
114+
matchKey(resource.Index) {
115+
116+
return resource
117+
}
118+
}
119+
return nil
120+
}
121+
88122
func loadResourcesToContext(ctx *tfcontext.Context, resources []*tfjson.StateResource) error {
89123
for _, resource := range resources {
90124
if resource.Mode != "data" {
@@ -96,12 +130,35 @@ func loadResourcesToContext(ctx *tfcontext.Context, resources []*tfjson.StateRes
96130
continue
97131
}
98132

133+
path := []string{string(resource.Mode), resource.Type, resource.Name}
134+
135+
// Always merge with any existing values
136+
existing := ctx.Get(path...)
137+
99138
val, err := toCtyValue(resource.AttributeValues)
100139
if err != nil {
101140
return fmt.Errorf("unable to determine value of resource %q: %w", resource.Address, err)
102141
}
103142

104-
ctx.Set(val, string(resource.Mode), resource.Type, resource.Name)
143+
var merged cty.Value
144+
switch resource.Index.(type) {
145+
case int, int32, int64, float32, float64:
146+
asInt, ok := toInt(resource.Index)
147+
if !ok {
148+
return fmt.Errorf("unable to convert index '%v' to int", resource.Index)
149+
}
150+
151+
if !existing.Type().IsTupleType() {
152+
continue
153+
}
154+
merged = hclext.MergeWithTupleElement(existing, int(asInt), val)
155+
case nil:
156+
merged = hclext.MergeObjects(existing, val)
157+
default:
158+
return fmt.Errorf("unsupported index type %T", resource.Index)
159+
}
160+
161+
ctx.Set(merged, string(resource.Mode), resource.Type, resource.Name)
105162
}
106163
return nil
107164
}
@@ -172,3 +229,51 @@ func TrivyParsePlanJSON(reader io.Reader) (*tfjson.Plan, error) {
172229

173230
return nil, err
174231
}
232+
233+
func keyMatcher(key cty.Value) func(to any) bool {
234+
switch {
235+
case key.Type().Equals(cty.Number):
236+
idx, _ := key.AsBigFloat().Int64()
237+
return func(to any) bool {
238+
asInt, ok := toInt(to)
239+
return ok && asInt == idx
240+
}
241+
242+
case key.Type().Equals(cty.String):
243+
// TODO: handle key strings
244+
}
245+
246+
return func(to any) bool {
247+
return true
248+
}
249+
}
250+
251+
func toInt(to any) (int64, bool) {
252+
switch typed := to.(type) {
253+
case uint:
254+
return int64(typed), true
255+
case uint8:
256+
return int64(typed), true
257+
case uint16:
258+
return int64(typed), true
259+
case uint32:
260+
return int64(typed), true
261+
case uint64:
262+
return int64(typed), true
263+
case int:
264+
return int64(typed), true
265+
case int8:
266+
return int64(typed), true
267+
case int16:
268+
return int64(typed), true
269+
case int32:
270+
return int64(typed), true
271+
case int64:
272+
return typed, true
273+
case float32:
274+
return int64(typed), true
275+
case float64:
276+
return int64(typed), true
277+
}
278+
return 0, false
279+
}

preview_test.go

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -156,11 +156,18 @@ func Test_Extract(t *testing.T) {
156156
dir: "module",
157157
expTags: map[string]string{},
158158
unknownTags: []string{},
159-
input: preview.Input{},
159+
input: preview.Input{
160+
PlanJSONPath: "plan.json",
161+
ParameterValues: map[string]string{
162+
"extra": "foobar",
163+
},
164+
},
160165
params: map[string]assertParam{
161166
"jetbrains_ide": ap().
162167
optVals("CL", "GO", "IU", "PY", "WS").
163168
value("GO"),
169+
"extra": ap().
170+
value("foobar"),
164171
},
165172
},
166173
{
@@ -285,16 +292,28 @@ func Test_Extract(t *testing.T) {
285292
dir: "demo_flat",
286293
expTags: map[string]string{
287294
"cluster": "confidential",
295+
"hash": "52bb4d943694f2f5867a251780f85e5a68906787b4ffa3157e29b9ef510b1a97",
288296
},
289297
input: preview.Input{
290-
PlanJSONPath: "",
291-
ParameterValues: map[string]string{},
298+
PlanJSONPath: "plan.json",
299+
ParameterValues: map[string]string{
300+
"hash": "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9",
301+
},
292302
Owner: types.WorkspaceOwner{
293303
Groups: []string{"admin"},
294304
},
295305
},
296306
unknownTags: []string{},
297-
params: map[string]assertParam{},
307+
params: map[string]assertParam{
308+
"hash": ap().
309+
value("b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"),
310+
"security_level": ap(),
311+
"region": ap(),
312+
"cpu": ap(),
313+
"browser": ap(),
314+
"team": ap().optVals("frontend", "backend", "fullstack"),
315+
"jetbrains_ide": ap(),
316+
},
298317
},
299318
{
300319
name: "count",
@@ -367,8 +386,6 @@ func Test_Extract(t *testing.T) {
367386
}
368387

369388
dirFs := os.DirFS(filepath.Join("testdata", tc.dir))
370-
//a, b := fs.ReadDir(dirFs, ".")
371-
//fmt.Println(a, b)
372389

373390
output, diags := preview.Preview(context.Background(), tc.input, dirFs)
374391
if tc.failPreview {

testdata/demo_flat/jetbrains_ide/main.tf

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -253,14 +253,15 @@ locals {
253253
}
254254
}
255255

256-
icon = local.jetbrains_ides[data.coder_parameter.jetbrains_ide.value].icon
257-
json_data = var.latest ? jsondecode(data.http.jetbrains_ide_versions[data.coder_parameter.jetbrains_ide.value].response_body) : {}
256+
first_val = jsondecode(data.coder_parameter.jetbrains_ide.value)[0]
257+
icon = local.jetbrains_ides[local.first_val].icon
258+
json_data = var.latest ? jsondecode(data.http.jetbrains_ide_versions[local.first_val].response_body) : {}
258259
key = var.latest ? keys(local.json_data)[0] : ""
259-
display_name = local.jetbrains_ides[data.coder_parameter.jetbrains_ide.value].name
260-
identifier = data.coder_parameter.jetbrains_ide.value
261-
download_link = var.latest ? local.json_data[local.key][0].downloads.linux.link : local.jetbrains_ides[data.coder_parameter.jetbrains_ide.value].download_link
262-
build_number = var.latest ? local.json_data[local.key][0].build : local.jetbrains_ides[data.coder_parameter.jetbrains_ide.value].build_number
263-
version = var.latest ? local.json_data[local.key][0].version : var.jetbrains_ide_versions[data.coder_parameter.jetbrains_ide.value].version
260+
display_name = local.jetbrains_ides[local.first_val].name
261+
identifier = local.first_val
262+
download_link = var.latest ? local.json_data[local.key][0].downloads.linux.link : local.jetbrains_ides[local.first_val].download_link
263+
build_number = var.latest ? local.json_data[local.key][0].build : local.jetbrains_ides[local.first_val].build_number
264+
version = var.latest ? local.json_data[local.key][0].version : var.jetbrains_ide_versions[local.first_val].version
264265
}
265266

266267
data "coder_parameter" "jetbrains_ide" {

testdata/demo_flat/main.tf

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@ data coder_workspace_owner "me" {}
2222
module "jetbrains_gateway" {
2323
count = 1
2424
source = "./jetbrains_ide"
25-
version = "1.0.28"
2625
agent_id = "random"
2726
folder = "/home/coder/example"
2827
jetbrains_ides = local.teams[data.coder_parameter.team.value].codes

testdata/demo_flat/parameters.tf

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
data "coder_parameter" "team" {
2-
name = "Team"
2+
name = "team"
3+
display_name = "Team"
34
description = "Which team are you on?"
45
type = "string"
56
default = "fullstack"
@@ -120,7 +121,7 @@ data "docker_registry_image" "coder" {
120121
}
121122

122123
data "coder_parameter" "region" {
123-
name = "Region"
124+
name = "region"
124125
display_name = "Region"
125126
description = "What region are you in?"
126127
form_type = "dropdown"

testdata/module/main.tf

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,14 @@ terraform {
33
coder = {
44
source = "coder/coder"
55
}
6+
docker = {
7+
source = "kreuzwerker/docker"
8+
version = "3.0.2"
9+
}
610
}
711
}
812

13+
914
module "jetbrains_gateway" {
1015
count = 1
1116
source = "registry.coder.com/modules/jetbrains-gateway/coder"
@@ -17,8 +22,22 @@ module "jetbrains_gateway" {
1722
default = "GO"
1823
}
1924

25+
data "coder_parameter" "extra" {
26+
count = 1
27+
name = "extra"
28+
display_name = "Extra Param"
29+
description = "A param to throw into the mix."
30+
type = "string"
31+
default = trimprefix(data.docker_registry_image.coder[1].sha256_digest, "sha256:")
32+
}
33+
2034
data "coder_workspace" "me" {}
2135
resource "coder_agent" "main" {
2236
arch = "amd64"
2337
os = "linux"
2438
}
39+
40+
data "docker_registry_image" "coder" {
41+
count = 2
42+
name = count.index == 0 ? "ghcr.io/coder/coder:latest": "ghcr.io/coder/coder:v2.20.1"
43+
}

0 commit comments

Comments
 (0)