From 9126256af82b4bcffead9812acf4211edb528a39 Mon Sep 17 00:00:00 2001 From: George Katsitadze Date: Thu, 10 Sep 2026 15:21:42 -0700 Subject: [PATCH] feat(provisioner/terraform): parse and resolve script order selectors Add package-private helpers to parse script and whole-module selectors, resolve them relative to their declaring module, and expand count, for_each, and nested module instances. Distinguish declared module calls with no concrete scripts from unknown module selectors using Terraform plan configuration. These helpers are not yet wired into workspace provisioning. Selector parsing uses go-terraform-address in this PR. A follow-up replaces it with HCL traversal parsing because go-terraform-address rejects valid Unicode identifiers. Keeping that correction separate limits this PR's scope. Refs: https://linear.app/codercom/issue/PLAT-543 --- provisioner/terraform/scriptorder.go | 279 ++++++++++ .../terraform/scriptorder_internal_test.go | 480 ++++++++++++++++++ 2 files changed, 759 insertions(+) create mode 100644 provisioner/terraform/scriptorder.go create mode 100644 provisioner/terraform/scriptorder_internal_test.go diff --git a/provisioner/terraform/scriptorder.go b/provisioner/terraform/scriptorder.go new file mode 100644 index 0000000000000..980eb2680d578 --- /dev/null +++ b/provisioner/terraform/scriptorder.go @@ -0,0 +1,279 @@ +package terraform + +import ( + "maps" + "slices" + + tfaddr "github.com/hashicorp/go-terraform-address" + tfjson "github.com/hashicorp/terraform-json" + "golang.org/x/xerrors" +) + +type scriptOrderSelectorKind int + +const ( + _ scriptOrderSelectorKind = iota + scriptOrderSelectorScript + scriptOrderSelectorModule +) + +type scriptOrderSelector struct { + kind scriptOrderSelectorKind + name string + instanceKey string +} + +type scriptOrderSelectorResolution struct { + // contains the sorted, deduplicated Terraform addresses of all + // concrete scripts selected. + addresses []string + // For module selectors, distinguishes a declared module call with + // no resolved scripts from an unknown module selector. Scripts + // may resolve to no instances when a module conditionally sets + // their count to zero. + moduleCallDeclared bool +} + +// parseScriptOrderSelector currently limits selectors to scripts in +// the declaring module and whole child module calls. +func parseScriptOrderSelector(raw string) (scriptOrderSelector, error) { + address, err := tfaddr.NewAddress(raw) + if err != nil { + return scriptOrderSelector{}, xerrors.Errorf("parse script order selector %q: %w", raw, err) + } + if len(address.ModulePath) != 0 { + return scriptOrderSelector{}, xerrors.Errorf( + "script order selector %q must reference a coder_script in the declaring module or an entire direct child module call", + raw, + ) + } + + switch address.ResourceSpec.Type { + case "coder_script": + return scriptOrderSelector{ + kind: scriptOrderSelectorScript, + name: address.ResourceSpec.Name, + instanceKey: address.ResourceSpec.Index.String(), + }, nil + case "module": + if address.ResourceSpec.Index.String() != "" { + return scriptOrderSelector{}, xerrors.Errorf("module selector %q must select all module instances", raw) + } + return scriptOrderSelector{ + kind: scriptOrderSelectorModule, + name: address.ResourceSpec.Name, + }, nil + default: + return scriptOrderSelector{}, xerrors.Errorf("script order selector %q must select a coder_script or module", raw) + } +} + +// resolveScriptOrderSelector expands a selector relative to its +// declaring module. For module selectors, it also reports whether the +// module call is declared so callers can distinguish an empty module +// call from an unknown selector. +// +// `modules` is the evaluated module tree and its concrete script instances. +// `planConfig` contains declared module calls, including calls with no +// instances after evaluation. +// `selector` must have been produced by parseScriptOrderSelector. +func resolveScriptOrderSelector( + modules []*tfjson.StateModule, + planConfig *tfjson.Config, + moduleAddress string, + selector scriptOrderSelector, +) (scriptOrderSelectorResolution, error) { + if selector.kind != scriptOrderSelectorScript && selector.kind != scriptOrderSelectorModule { + return scriptOrderSelectorResolution{}, + xerrors.Errorf("unknown script order selector kind %d", selector.kind) + } + + var resolution scriptOrderSelectorResolution + if selector.kind == scriptOrderSelectorModule { + declared, err := isModuleCallInConfig(planConfig, moduleAddress, selector.name) + if err != nil { + return scriptOrderSelectorResolution{}, err + } + resolution.moduleCallDeclared = declared + } + + resolved := map[string]struct{}{} + for _, rootModule := range modules { + err := walkStateModuleTree(rootModule, func(module *tfjson.StateModule) error { + if module.Address != moduleAddress { + return nil + } + + switch selector.kind { + case scriptOrderSelectorScript: + return resolveScriptOrderScriptSelector(module, selector, resolved) + case scriptOrderSelectorModule: + return resolveScriptOrderModuleSelector(module, selector, resolved) + default: + return xerrors.Errorf("unknown script order selector kind %d", selector.kind) + } + }) + if err != nil { + return scriptOrderSelectorResolution{}, err + } + } + + resolution.addresses = slices.Sorted(maps.Keys(resolved)) + return resolution, nil +} + +// isModuleCallInConfig reports whether name is a direct child module +// call in the configuration of the declaring module instance. +func isModuleCallInConfig( + config *tfjson.Config, + declaringModuleAddress string, + name string, +) (bool, error) { + if config == nil || config.RootModule == nil { + return false, xerrors.New("terraform plan configuration is required to resolve a module selector") + } + + module := config.RootModule + if declaringModuleAddress != "" { + modulePath, err := parseStateModuleAddress(declaringModuleAddress) + if err != nil { + return false, err + } + for _, step := range modulePath { + call := module.ModuleCalls[step.Name] + if call == nil || call.Module == nil { + return false, nil + } + module = call.Module + } + } + + return module.ModuleCalls[name] != nil, nil +} + +func resolveScriptOrderScriptSelector( + module *tfjson.StateModule, + selector scriptOrderSelector, + resolved map[string]struct{}, +) error { + // An unindexed script selector expands every count and for_each + // instance; an indexed selector retains only the matching + // instance key. + for _, resource := range module.Resources { + if resource == nil || + resource.Mode != tfjson.ManagedResourceMode || + resource.Type != "coder_script" || + resource.Name != selector.name { + continue + } + + address, err := parseStateResourceAddress(module, resource) + if err != nil { + return err + } + if selector.instanceKey != "" && + address.ResourceSpec.Index.String() != selector.instanceKey { + continue + } + resolved[resource.Address] = struct{}{} + } + return nil +} + +func resolveScriptOrderModuleSelector( + module *tfjson.StateModule, + selector scriptOrderSelector, + resolved map[string]struct{}, +) error { + // An unindexed module selector expands every count and for_each + // instance of the selected child module call. + for _, child := range module.ChildModules { + if child == nil { + continue + } + + modulePath, err := parseStateModuleAddress(child.Address) + if err != nil { + return err + } + if len(modulePath) == 0 || modulePath[len(modulePath)-1].Name != selector.name { + continue + } + if err := collectModuleCoderScriptAddresses(child, resolved); err != nil { + return err + } + } + return nil +} + +func collectModuleCoderScriptAddresses( + module *tfjson.StateModule, resolved map[string]struct{}, +) error { + for _, resource := range module.Resources { + if resource == nil || + resource.Mode != tfjson.ManagedResourceMode || + resource.Type != "coder_script" { + continue + } + if _, err := parseStateResourceAddress(module, resource); err != nil { + return err + } + resolved[resource.Address] = struct{}{} + } + for _, child := range module.ChildModules { + if child == nil { + continue + } + if err := collectModuleCoderScriptAddresses(child, resolved); err != nil { + return err + } + } + return nil +} + +// parseStateResourceAddress parses a concrete resource address and +// verifies that it matches its containing state module and resource +// fields. This prevents inconsistent Terraform output from assigning +// dependencies to the wrong resource. +func parseStateResourceAddress( + module *tfjson.StateModule, resource *tfjson.StateResource, +) (*tfaddr.Address, error) { + address, err := tfaddr.NewAddress(resource.Address) + if err != nil { + return nil, xerrors.Errorf("parse Terraform resource address %q: %w", resource.Address, err) + } + // Defensive: TF should always emit an address consistent with + // these state fields. + if address.ModulePath.String() != module.Address || + address.ResourceSpec.Type != resource.Type || + address.ResourceSpec.Name != resource.Name { + return nil, xerrors.Errorf("Terraform resource address %q does not match its state fields", resource.Address) + } + return address, nil +} + +func parseStateModuleAddress(address string) (tfaddr.ModulePath, error) { + // go-terraform-address parses a module path only as part of a + // resource address, so append a placeholder resource before + // parsing it. + parsed, err := tfaddr.NewAddress(address + ".placeholder_resource.placeholder") + if err != nil { + return nil, xerrors.Errorf("parse module address %q: %w", address, err) + } + return parsed.ModulePath, nil +} + +func walkStateModuleTree(module *tfjson.StateModule, visit func(*tfjson.StateModule) error) error { + if module == nil { + return nil + } + if err := visit(module); err != nil { + return err + } + for _, child := range module.ChildModules { + if err := walkStateModuleTree(child, visit); err != nil { + return err + } + } + return nil +} diff --git a/provisioner/terraform/scriptorder_internal_test.go b/provisioner/terraform/scriptorder_internal_test.go new file mode 100644 index 0000000000000..2ff82ae95854e --- /dev/null +++ b/provisioner/terraform/scriptorder_internal_test.go @@ -0,0 +1,480 @@ +package terraform + +import ( + "testing" + + tfjson "github.com/hashicorp/terraform-json" + "github.com/stretchr/testify/require" +) + +func TestParseScriptOrderSelector(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + raw string + expected scriptOrderSelector + }{ + { + name: "ScriptUnindexed", + raw: "coder_script.setup", + expected: scriptOrderSelector{ + kind: scriptOrderSelectorScript, + name: "setup", + instanceKey: "", + }, + }, + { + name: "ScriptCountInstance", + raw: "coder_script.setup[2]", + expected: scriptOrderSelector{ + kind: scriptOrderSelectorScript, + name: "setup", + instanceKey: "2", + }, + }, + { + name: "ScriptForEachInstance", + raw: `coder_script.setup["api"]`, + expected: scriptOrderSelector{ + kind: scriptOrderSelectorScript, + name: "setup", + instanceKey: `"api"`, + }, + }, + { + name: "Module", + raw: "module.bootstrap", + expected: scriptOrderSelector{ + kind: scriptOrderSelectorModule, + name: "bootstrap", + instanceKey: "", + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + selector, err := parseScriptOrderSelector(test.raw) + require.NoError(t, err) + require.Equal(t, test.expected, selector) + }) + } +} + +func TestParseScriptOrderSelectorRejectsUnsupportedSyntax(t *testing.T) { + t.Parallel() + + for _, selector := range []string{ + "", + "coder_script", + "coder_script.setup[", + "coder_script.setup[api]", + "coder_script.setup[true]", + "coder_agent.main", + "data.coder_script.setup", + "module.bootstrap[0]", + "module.bootstrap.coder_script", + "module.bootstrap.coder_script.setup", + "module.parent.module.bootstrap", + } { + t.Run(selector, func(t *testing.T) { + t.Parallel() + + _, err := parseScriptOrderSelector(selector) + require.Error(t, err) + }) + } +} + +func TestResolveScriptOrderSelector(t *testing.T) { + t.Parallel() + + nestedDeclaringModules := []*tfjson.StateModule{{ + ChildModules: []*tfjson.StateModule{{ + Address: "module.outer", + ChildModules: []*tfjson.StateModule{{ + Address: "module.outer.module.inner", + Resources: []*tfjson.StateResource{ + managedCoderScript("module.outer.module.inner.coder_script.setup", "setup"), + }, + ChildModules: []*tfjson.StateModule{{ + Address: "module.outer.module.inner.module.bootstrap", + Resources: []*tfjson.StateResource{ + managedCoderScript("module.outer.module.inner.module.bootstrap.coder_script.install", "install"), + }, + }}, + }}, + }}, + }} + + tests := []struct { + name string + modules []*tfjson.StateModule + config *tfjson.Config + moduleAddress string + selector string + expected scriptOrderSelectorResolution + }{ + { + name: "AllMatchingManagedCountInstances", + modules: []*tfjson.StateModule{{ + Resources: []*tfjson.StateResource{ + managedCoderScript("coder_script.setup[1]", "setup"), + managedCoderScript("coder_script.other", "other"), + managedCoderScript("coder_script.setup[0]", "setup"), + dataCoderScript("data.coder_script.setup", "setup"), + }, + }}, + selector: "coder_script.setup", + expected: scriptOrderSelectorResolution{addresses: []string{ + "coder_script.setup[0]", + "coder_script.setup[1]", + }}, + }, + { + name: "OneMatchingCountInstance", + modules: []*tfjson.StateModule{{ + Resources: []*tfjson.StateResource{ + managedCoderScript("coder_script.setup[0]", "setup"), + managedCoderScript("coder_script.setup[1]", "setup"), + }, + }}, + selector: "coder_script.setup[1]", + expected: scriptOrderSelectorResolution{ + addresses: []string{"coder_script.setup[1]"}, + }, + }, + { + name: "OneMatchingForEachInstance", + modules: []*tfjson.StateModule{{ + Resources: []*tfjson.StateResource{ + managedCoderScript(`coder_script.setup["worker"]`, "setup"), + managedCoderScript(`coder_script.setup["api"]`, "setup"), + }, + }}, + selector: `coder_script.setup["api"]`, + expected: scriptOrderSelectorResolution{ + addresses: []string{`coder_script.setup["api"]`}, + }, + }, + { + name: "MissingScriptInstance", + modules: []*tfjson.StateModule{{ + Resources: []*tfjson.StateResource{ + managedCoderScript("coder_script.setup[0]", "setup"), + }, + }}, + selector: "coder_script.setup[1]", + expected: scriptOrderSelectorResolution{ + addresses: nil, + }, + }, + { + name: "ScriptRelativeToRepeatedModuleInstance", + modules: []*tfjson.StateModule{{ + Resources: []*tfjson.StateResource{ + managedCoderScript("coder_script.setup", "setup"), + }, + ChildModules: []*tfjson.StateModule{ + { + Address: `module.development["primary"]`, + Resources: []*tfjson.StateResource{ + managedCoderScript(`module.development["primary"].coder_script.setup`, "setup"), + }, + }, + { + Address: `module.development["secondary"]`, + Resources: []*tfjson.StateResource{ + managedCoderScript(`module.development["secondary"].coder_script.setup`, "setup"), + }, + }, + }, + }}, + moduleAddress: `module.development["primary"]`, + selector: "coder_script.setup", + expected: scriptOrderSelectorResolution{ + addresses: []string{`module.development["primary"].coder_script.setup`}, + }, + }, + { + name: "ScriptRelativeToNestedDeclaringModule", + modules: nestedDeclaringModules, + moduleAddress: "module.outer.module.inner", + selector: "coder_script.setup", + expected: scriptOrderSelectorResolution{ + addresses: []string{"module.outer.module.inner.coder_script.setup"}, + }, + }, + { + name: "RepeatedModulesAndDescendants", + modules: []*tfjson.StateModule{{ + ChildModules: []*tfjson.StateModule{ + { + Address: "module.bootstrap[1]", + Resources: []*tfjson.StateResource{ + managedCoderScript("module.bootstrap[1].coder_script.install", "install"), + }, + ChildModules: []*tfjson.StateModule{{ + Address: "module.bootstrap[1].module.nested", + Resources: []*tfjson.StateResource{ + managedCoderScript("module.bootstrap[1].module.nested.coder_script.configure", "configure"), + }, + }}, + }, + { + Address: "module.bootstrap[0]", + Resources: []*tfjson.StateResource{ + managedCoderScript("module.bootstrap[0].coder_script.install", "install"), + dataCoderScript("module.bootstrap[0].data.coder_script.ignored", "ignored"), + { + Address: "module.bootstrap[0].null_resource.ignored", + Mode: tfjson.ManagedResourceMode, + Type: "null_resource", + Name: "ignored", + }, + }, + }, + { + Address: "module.unrelated", + Resources: []*tfjson.StateResource{ + managedCoderScript("module.unrelated.coder_script.ignored", "ignored"), + }, + }, + }, + }}, + config: rootScriptOrderConfig("bootstrap", "unrelated"), + selector: "module.bootstrap", + expected: scriptOrderSelectorResolution{ + addresses: []string{ + "module.bootstrap[0].coder_script.install", + "module.bootstrap[1].coder_script.install", + "module.bootstrap[1].module.nested.coder_script.configure", + }, + moduleCallDeclared: true, + }, + }, + { + name: "ForEachModuleInstances", + modules: []*tfjson.StateModule{{ + ChildModules: []*tfjson.StateModule{ + { + Address: `module.bootstrap["secondary"]`, + Resources: []*tfjson.StateResource{ + managedCoderScript(`module.bootstrap["secondary"].coder_script.setup`, "setup"), + }, + }, + { + Address: `module.bootstrap["primary"]`, + Resources: []*tfjson.StateResource{ + managedCoderScript(`module.bootstrap["primary"].coder_script.setup`, "setup"), + }, + }, + }, + }}, + config: rootScriptOrderConfig("bootstrap"), + selector: "module.bootstrap", + expected: scriptOrderSelectorResolution{ + addresses: []string{ + `module.bootstrap["primary"].coder_script.setup`, + `module.bootstrap["secondary"].coder_script.setup`, + }, + moduleCallDeclared: true, + }, + }, + { + name: "ModuleRelativeToRepeatedDeclaringModule", + modules: []*tfjson.StateModule{{ + ChildModules: []*tfjson.StateModule{ + { + Address: `module.development["primary"]`, + ChildModules: []*tfjson.StateModule{{ + Address: `module.development["primary"].module.bootstrap`, + Resources: []*tfjson.StateResource{ + managedCoderScript(`module.development["primary"].module.bootstrap.coder_script.setup`, "setup"), + }, + }}, + }, + { + Address: `module.development["secondary"]`, + ChildModules: []*tfjson.StateModule{{ + Address: `module.development["secondary"].module.bootstrap`, + Resources: []*tfjson.StateResource{ + managedCoderScript(`module.development["secondary"].module.bootstrap.coder_script.setup`, "setup"), + }, + }}, + }, + }, + }}, + config: nestedScriptOrderConfig("development", "bootstrap"), + moduleAddress: `module.development["primary"]`, + selector: "module.bootstrap", + expected: scriptOrderSelectorResolution{ + addresses: []string{`module.development["primary"].module.bootstrap.coder_script.setup`}, + moduleCallDeclared: true, + }, + }, + { + name: "ModuleRelativeToNestedDeclaringModule", + modules: nestedDeclaringModules, + config: nestedScriptOrderConfig("outer", "inner", "bootstrap"), + moduleAddress: "module.outer.module.inner", + selector: "module.bootstrap", + expected: scriptOrderSelectorResolution{ + addresses: []string{"module.outer.module.inner.module.bootstrap.coder_script.install"}, + moduleCallDeclared: true, + }, + }, + { + name: "DeclaredModuleWithNoInstances", + config: rootScriptOrderConfig("bootstrap"), + selector: "module.bootstrap", + expected: scriptOrderSelectorResolution{ + addresses: nil, + moduleCallDeclared: true, + }, + }, + { + name: "DeclaredModuleWithNoScripts", + modules: []*tfjson.StateModule{{ + ChildModules: []*tfjson.StateModule{{Address: "module.bootstrap"}}, + }}, + config: rootScriptOrderConfig("bootstrap"), + selector: "module.bootstrap", + expected: scriptOrderSelectorResolution{ + addresses: nil, + moduleCallDeclared: true, + }, + }, + { + name: "UnknownModuleCall", + config: rootScriptOrderConfig("other"), + selector: "module.bootstrap", + expected: scriptOrderSelectorResolution{ + addresses: nil, + moduleCallDeclared: false, + }, + }, + { + name: "UnknownModuleCallWithStateInstances", + modules: []*tfjson.StateModule{{ + ChildModules: []*tfjson.StateModule{{ + Address: "module.bootstrap", + Resources: []*tfjson.StateResource{ + managedCoderScript("module.bootstrap.coder_script.setup", "setup"), + }, + }}, + }}, + config: rootScriptOrderConfig("other"), + selector: "module.bootstrap", + expected: scriptOrderSelectorResolution{ + addresses: []string{"module.bootstrap.coder_script.setup"}, + moduleCallDeclared: false, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + selector, err := parseScriptOrderSelector(test.selector) + require.NoError(t, err) + + resolved, err := resolveScriptOrderSelector(test.modules, test.config, test.moduleAddress, selector) + require.NoError(t, err) + require.Equal(t, test.expected, resolved) + }) + } +} + +func TestResolveScriptOrderSelectorRejectsInvalidInput(t *testing.T) { + t.Parallel() + + t.Run("MalformedScriptAddress", func(t *testing.T) { + t.Parallel() + + selector, err := parseScriptOrderSelector("coder_script.setup[0]") + require.NoError(t, err) + + _, err = resolveScriptOrderSelector([]*tfjson.StateModule{{ + Resources: []*tfjson.StateResource{managedCoderScript("not-an-address", "setup")}, + }}, nil, "", selector) + require.ErrorContains(t, err, `parse Terraform resource address "not-an-address"`) + }) + + t.Run("MalformedModuleAddress", func(t *testing.T) { + t.Parallel() + + selector, err := parseScriptOrderSelector("module.bootstrap") + require.NoError(t, err) + + _, err = resolveScriptOrderSelector([]*tfjson.StateModule{{ + ChildModules: []*tfjson.StateModule{{Address: "not-an-address"}}, + }}, rootScriptOrderConfig("bootstrap"), "", selector) + require.ErrorContains(t, err, `parse module address "not-an-address"`) + }) + + t.Run("InconsistentScriptAddress", func(t *testing.T) { + t.Parallel() + + selector, err := parseScriptOrderSelector("coder_script.setup") + require.NoError(t, err) + + _, err = resolveScriptOrderSelector([]*tfjson.StateModule{{ + Resources: []*tfjson.StateResource{managedCoderScript("coder_script.other", "setup")}, + }}, nil, "", selector) + require.ErrorContains(t, err, `Terraform resource address "coder_script.other" does not match its state fields`) + }) + + t.Run("MissingPlanConfiguration", func(t *testing.T) { + t.Parallel() + + selector, err := parseScriptOrderSelector("module.bootstrap") + require.NoError(t, err) + + _, err = resolveScriptOrderSelector(nil, nil, "", selector) + require.ErrorContains(t, err, "terraform plan configuration is required to resolve a module selector") + }) +} + +func rootScriptOrderConfig(moduleCalls ...string) *tfjson.Config { + calls := make(map[string]*tfjson.ModuleCall, len(moduleCalls)) + for _, name := range moduleCalls { + calls[name] = &tfjson.ModuleCall{Module: &tfjson.ConfigModule{}} + } + return &tfjson.Config{RootModule: &tfjson.ConfigModule{ModuleCalls: calls}} +} + +func nestedScriptOrderConfig(moduleCalls ...string) *tfjson.Config { + root := &tfjson.ConfigModule{} + module := root + for _, name := range moduleCalls { + child := &tfjson.ConfigModule{} + module.ModuleCalls = map[string]*tfjson.ModuleCall{ + name: {Module: child}, + } + module = child + } + return &tfjson.Config{RootModule: root} +} + +func managedCoderScript(address, name string) *tfjson.StateResource { + return &tfjson.StateResource{ + Address: address, + Mode: tfjson.ManagedResourceMode, + Type: "coder_script", + Name: name, + } +} + +func dataCoderScript(address, name string) *tfjson.StateResource { + return &tfjson.StateResource{ + Address: address, + Mode: tfjson.DataResourceMode, + Type: "coder_script", + Name: name, + } +}