diff --git a/provisioner/terraform/scriptorder.go b/provisioner/terraform/scriptorder.go new file mode 100644 index 00000000000..980eb2680d5 --- /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 00000000000..2ff82ae9585 --- /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, + } +}