diff --git a/provisioner/terraform/executor.go b/provisioner/terraform/executor.go index 264647643f416..1430233b547fa 100644 --- a/provisioner/terraform/executor.go +++ b/provisioner/terraform/executor.go @@ -259,7 +259,17 @@ func (e *executor) planResources(ctx, killCtx context.Context, planfilePath stri modules = append(modules, plan.PriorState.Values.RootModule) } modules = append(modules, plan.PlannedValues.RootModule) - return ConvertState(modules, rawGraph) + + rawParameterNames, err := rawRichParameterNames(e.workdir) + if err != nil { + return nil, xerrors.Errorf("raw rich parameter names: %w", err) + } + + state, err := ConvertState(modules, rawGraph, rawParameterNames) + if err != nil { + return nil, err + } + return state, nil } // showPlan must only be called while the lock is held. @@ -366,9 +376,14 @@ func (e *executor) stateResources(ctx, killCtx context.Context) (*State, error) } converted := &State{} if state.Values != nil { + rawParameterNames, err := rawRichParameterNames(e.workdir) + if err != nil { + return nil, xerrors.Errorf("raw rich parameter names: %w", err) + } + converted, err = ConvertState([]*tfjson.StateModule{ state.Values.RootModule, - }, rawGraph) + }, rawGraph, rawParameterNames) if err != nil { return nil, err } diff --git a/provisioner/terraform/parameters.go b/provisioner/terraform/parameters.go new file mode 100644 index 0000000000000..92892d7b1746c --- /dev/null +++ b/provisioner/terraform/parameters.go @@ -0,0 +1,55 @@ +package terraform + +import ( + "fmt" + "os" + "path" + "strings" + + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclparse" +) + +var terraformWithCoderParametersSchema = &hcl.BodySchema{ + Blocks: []hcl.BlockHeaderSchema{ + { + Type: "data", + LabelNames: []string{"coder_parameter", "*"}, + }, + }, +} + +func rawRichParameterNames(workdir string) ([]string, error) { + entries, err := os.ReadDir(workdir) + if err != nil { + return nil, err + } + + var coderParameterNames []string + for _, entry := range entries { + if !strings.HasSuffix(entry.Name(), ".tf") { + continue + } + + hclFilepath := path.Join(workdir, entry.Name()) + parser := hclparse.NewParser() + parsedHCL, diags := parser.ParseHCLFile(hclFilepath) + if diags.HasErrors() { + return nil, hcl.Diagnostics{ + { + Severity: hcl.DiagError, + Summary: "Failed to parse HCL file", + Detail: fmt.Sprintf("parser.ParseHCLFile can't parse %q file", hclFilepath), + }, + } + } + + content, _, _ := parsedHCL.Body.PartialContent(terraformWithCoderParametersSchema) + for _, block := range content.Blocks { + if block.Type == "data" && block.Labels[0] == "coder_parameter" && len(block.Labels) == 2 { + coderParameterNames = append(coderParameterNames, block.Labels[1]) + } + } + } + return coderParameterNames, nil +} diff --git a/provisioner/terraform/provision_test.go b/provisioner/terraform/provision_test.go index 6def8e3e56e89..816c52d0ae295 100644 --- a/provisioner/terraform/provision_test.go +++ b/provisioner/terraform/provision_test.go @@ -328,11 +328,17 @@ func TestProvision(t *testing.T) { required_providers { coder = { source = "coder/coder" - version = "0.6.6" + version = "0.6.20" } } } + data "coder_parameter" "sample" { + name = "Sample" + type = "string" + default = "foobaz" + } + data "coder_parameter" "example" { name = "Example" type = "string" @@ -347,6 +353,10 @@ func TestProvision(t *testing.T) { }, Request: &proto.Provision_Plan{ RichParameterValues: []*proto.RichParameterValue{ + { + Name: "Sample", + Value: "foofoo", + }, { Name: "Example", Value: "foobaz", @@ -356,6 +366,18 @@ func TestProvision(t *testing.T) { Response: &proto.Provision_Response{ Type: &proto.Provision_Response_Complete{ Complete: &proto.Provision_Complete{ + Parameters: []*proto.RichParameter{ + { + Name: "Sample", + Type: "string", + DefaultValue: "foobaz", + }, + { + Name: "Example", + Type: "string", + DefaultValue: "foobar", + }, + }, Resources: []*proto.Resource{{ Name: "example", Type: "null_resource", @@ -441,6 +463,7 @@ func TestProvision(t *testing.T) { planRequest.GetPlan().Config = &proto.Provision_Config{} } planRequest.GetPlan().ParameterValues = testCase.Request.ParameterValues + planRequest.GetPlan().RichParameterValues = testCase.Request.RichParameterValues planRequest.GetPlan().GitAuthProviders = testCase.Request.GitAuthProviders if testCase.Request.Config != nil { planRequest.GetPlan().Config.State = testCase.Request.Config.State @@ -499,15 +522,20 @@ func TestProvision(t *testing.T) { } if testCase.Response != nil { + require.Equal(t, testCase.Response.GetComplete().Error, msg.GetComplete().Error) + resourcesGot, err := json.Marshal(msg.GetComplete().Resources) require.NoError(t, err) - resourcesWant, err := json.Marshal(testCase.Response.GetComplete().Resources) require.NoError(t, err) - require.Equal(t, testCase.Response.GetComplete().Error, msg.GetComplete().Error) - require.Equal(t, string(resourcesWant), string(resourcesGot)) + + parametersGot, err := json.Marshal(msg.GetComplete().Parameters) + require.NoError(t, err) + parametersWant, err := json.Marshal(testCase.Response.GetComplete().Parameters) + require.NoError(t, err) + require.Equal(t, string(parametersWant), string(parametersGot)) } break } diff --git a/provisioner/terraform/resources.go b/provisioner/terraform/resources.go index 380364d109273..5cb07eefdfb9e 100644 --- a/provisioner/terraform/resources.go +++ b/provisioner/terraform/resources.go @@ -83,7 +83,7 @@ type State struct { // ConvertState consumes Terraform state and a GraphViz representation // produced by `terraform graph` to produce resources consumable by Coder. // nolint:gocyclo -func ConvertState(modules []*tfjson.StateModule, rawGraph string) (*State, error) { +func ConvertState(modules []*tfjson.StateModule, rawGraph string, rawParameterNames []string) (*State, error) { parsedGraph, err := gographviz.ParseString(rawGraph) if err != nil { return nil, xerrors.Errorf("parse graph: %w", err) @@ -442,10 +442,7 @@ func ConvertState(modules []*tfjson.StateModule, rawGraph string) (*State, error } parameters := make([]*proto.RichParameter, 0) - for _, resource := range tfResourcesRichParameters { - if resource.Type != "coder_parameter" { - continue - } + for _, resource := range orderedRichParametersResources(tfResourcesRichParameters, rawParameterNames) { var param provider.Parameter err = mapstructure.Decode(resource.AttributeValues, ¶m) if err != nil { @@ -629,3 +626,19 @@ func findResourcesInGraph(graph *gographviz.Graph, tfResourcesByLabel map[string return graphResources } + +func orderedRichParametersResources(tfResourcesRichParameters []*tfjson.StateResource, orderedNames []string) []*tfjson.StateResource { + if len(orderedNames) == 0 { + return tfResourcesRichParameters + } + + ordered := make([]*tfjson.StateResource, len(orderedNames)) + for i, name := range orderedNames { + for _, resource := range tfResourcesRichParameters { + if resource.Name == name { + ordered[i] = resource + } + } + } + return ordered +} diff --git a/provisioner/terraform/resources_test.go b/provisioner/terraform/resources_test.go index 62c9943887c4c..13417a4bc078b 100644 --- a/provisioner/terraform/resources_test.go +++ b/provisioner/terraform/resources_test.go @@ -6,6 +6,7 @@ import ( "path/filepath" "runtime" "sort" + "strings" "testing" protobuf "github.com/golang/protobuf/proto" @@ -349,7 +350,7 @@ func TestConvertResources(t *testing.T) { // and that no errors occur! modules = append(modules, tfPlan.PlannedValues.RootModule) } - state, err := terraform.ConvertState(modules, string(tfPlanGraph)) + state, err := terraform.ConvertState(modules, string(tfPlanGraph), richParameterResourceNames(expected.parameters)) require.NoError(t, err) sortResources(state.Resources) sort.Strings(state.GitAuthProviders) @@ -402,7 +403,7 @@ func TestConvertResources(t *testing.T) { tfStateGraph, err := os.ReadFile(filepath.Join(dir, folderName+".tfstate.dot")) require.NoError(t, err) - state, err := terraform.ConvertState([]*tfjson.StateModule{tfState.Values.RootModule}, string(tfStateGraph)) + state, err := terraform.ConvertState([]*tfjson.StateModule{tfState.Values.RootModule}, string(tfStateGraph), richParameterResourceNames(expected.parameters)) require.NoError(t, err) sortResources(state.Resources) sort.Strings(state.GitAuthProviders) @@ -461,7 +462,7 @@ func TestAppSlugValidation(t *testing.T) { } } - state, err := terraform.ConvertState([]*tfjson.StateModule{tfPlan.PlannedValues.RootModule}, string(tfPlanGraph)) + state, err := terraform.ConvertState([]*tfjson.StateModule{tfPlan.PlannedValues.RootModule}, string(tfPlanGraph), nil) require.Nil(t, state) require.Error(t, err) require.ErrorContains(t, err, "invalid app slug") @@ -473,7 +474,7 @@ func TestAppSlugValidation(t *testing.T) { } } - state, err = terraform.ConvertState([]*tfjson.StateModule{tfPlan.PlannedValues.RootModule}, string(tfPlanGraph)) + state, err = terraform.ConvertState([]*tfjson.StateModule{tfPlan.PlannedValues.RootModule}, string(tfPlanGraph), nil) require.Nil(t, state) require.Error(t, err) require.ErrorContains(t, err, "duplicate app slug") @@ -523,7 +524,7 @@ func TestInstanceTypeAssociation(t *testing.T) { subgraph "root" { "[root] `+tc.ResourceType+`.dev" [label = "`+tc.ResourceType+`.dev", shape = "box"] } -}`) +}`, nil) require.NoError(t, err) require.Len(t, state.Resources, 1) require.Equal(t, state.Resources[0].GetInstanceType(), instanceType) @@ -594,7 +595,7 @@ func TestInstanceIDAssociation(t *testing.T) { "[root] `+tc.ResourceType+`.dev" -> "[root] coder_agent.dev" } } -`) +`, nil) require.NoError(t, err) require.Len(t, state.Resources, 1) require.Len(t, state.Resources[0].Agents, 1) @@ -623,3 +624,11 @@ func sortResources(resources []*proto.Resource) { }) } } + +func richParameterResourceNames(parameters []*proto.RichParameter) []string { + var names []string + for _, p := range parameters { + names = append(names, strings.ToLower(p.Name)) + } + return names +}