diff --git a/provisioner/terraform/scriptorder.go b/provisioner/terraform/scriptorder.go index 980eb2680d578..c76960df3ab51 100644 --- a/provisioner/terraform/scriptorder.go +++ b/provisioner/terraform/scriptorder.go @@ -4,8 +4,9 @@ import ( "maps" "slices" - tfaddr "github.com/hashicorp/go-terraform-address" + "github.com/hashicorp/hcl/v2" tfjson "github.com/hashicorp/terraform-json" + "github.com/zclconf/go-cty/cty" "golang.org/x/xerrors" ) @@ -20,7 +21,7 @@ const ( type scriptOrderSelector struct { kind scriptOrderSelectorKind name string - instanceKey string + instanceKey cty.Value } type scriptOrderSelectorResolution struct { @@ -34,34 +35,64 @@ type scriptOrderSelectorResolution struct { moduleCallDeclared bool } +func invalidScriptOrderSelectorError(raw string) error { + return xerrors.Errorf( + "script order selector %q must reference a coder_script in the "+ + "declaring module or an entire direct child module call", + raw, + ) +} + // 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) + traversal, err := parseTerraformAddressTraversal(raw) if err != nil { - return scriptOrderSelector{}, xerrors.Errorf("parse script order selector %q: %w", raw, err) + return scriptOrderSelector{}, + xerrors.Errorf("parse script order selector %q: %w", raw, err) + } + if len(traversal) < 2 { + return scriptOrderSelector{}, invalidScriptOrderSelectorError(raw) + } + + root, rootOK := traversal[0].(hcl.TraverseRoot) + name, nameOK := traversal[1].(hcl.TraverseAttr) + if !rootOK || !nameOK { + return scriptOrderSelector{}, invalidScriptOrderSelectorError(raw) + } + + instanceKey := cty.NilVal + position := 2 + if position < len(traversal) { + index, ok := traversal[position].(hcl.TraverseIndex) + if !ok { + return scriptOrderSelector{}, invalidScriptOrderSelectorError(raw) + } + instanceKey, err = parseTerraformInstanceKey(index.Key) + if err != nil { + return scriptOrderSelector{}, + xerrors.Errorf("parse script order selector %q instance key: %w", raw, err) + } + position++ } - 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, - ) + if position != len(traversal) { + return scriptOrderSelector{}, invalidScriptOrderSelectorError(raw) } - switch address.ResourceSpec.Type { + switch root.Name { case "coder_script": return scriptOrderSelector{ kind: scriptOrderSelectorScript, - name: address.ResourceSpec.Name, - instanceKey: address.ResourceSpec.Index.String(), + name: name.Name, + instanceKey: instanceKey, }, nil case "module": - if address.ResourceSpec.Index.String() != "" { + if instanceKey != cty.NilVal { return scriptOrderSelector{}, xerrors.Errorf("module selector %q must select all module instances", raw) } return scriptOrderSelector{ kind: scriptOrderSelectorModule, - name: address.ResourceSpec.Name, + name: name.Name, }, nil default: return scriptOrderSelector{}, xerrors.Errorf("script order selector %q must select a coder_script or module", raw) @@ -139,8 +170,8 @@ func isModuleCallInConfig( if err != nil { return false, err } - for _, step := range modulePath { - call := module.ModuleCalls[step.Name] + for _, step := range modulePath.steps { + call := module.ModuleCalls[step.name] if call == nil || call.Module == nil { return false, nil } @@ -171,8 +202,8 @@ func resolveScriptOrderScriptSelector( if err != nil { return err } - if selector.instanceKey != "" && - address.ResourceSpec.Index.String() != selector.instanceKey { + if selector.instanceKey != cty.NilVal && + !terraformInstanceKeysEqual(address.instanceKey, selector.instanceKey) { continue } resolved[resource.Address] = struct{}{} @@ -196,7 +227,7 @@ func resolveScriptOrderModuleSelector( if err != nil { return err } - if len(modulePath) == 0 || modulePath[len(modulePath)-1].Name != selector.name { + if len(modulePath.steps) == 0 || modulePath.steps[len(modulePath.steps)-1].name != selector.name { continue } if err := collectModuleCoderScriptAddresses(child, resolved); err != nil { @@ -237,30 +268,27 @@ func collectModuleCoderScriptAddresses( // dependencies to the wrong resource. func parseStateResourceAddress( module *tfjson.StateModule, resource *tfjson.StateResource, -) (*tfaddr.Address, error) { - address, err := tfaddr.NewAddress(resource.Address) +) (*terraformManagedResourceAddress, error) { + address, err := parseTerraformManagedResourceAddress(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 { + if address.modulePath.String() != module.Address || + address.resourceType != resource.Type || + address.resourceName != resource.Name { return nil, xerrors.Errorf("Terraform resource address %q does not match its state fields", resource.Address) } - return address, nil + 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") +func parseStateModuleAddress(address string) (terraformModulePath, error) { + parsed, err := parseTerraformModulePath(address) if err != nil { - return nil, xerrors.Errorf("parse module address %q: %w", address, err) + return terraformModulePath{}, xerrors.Errorf("parse module address %q: %w", address, err) } - return parsed.ModulePath, nil + return parsed, nil } func walkStateModuleTree(module *tfjson.StateModule, visit func(*tfjson.StateModule) error) error { diff --git a/provisioner/terraform/scriptorder_internal_test.go b/provisioner/terraform/scriptorder_internal_test.go index 2ff82ae95854e..b4e6041189464 100644 --- a/provisioner/terraform/scriptorder_internal_test.go +++ b/provisioner/terraform/scriptorder_internal_test.go @@ -5,6 +5,7 @@ import ( tfjson "github.com/hashicorp/terraform-json" "github.com/stretchr/testify/require" + "github.com/zclconf/go-cty/cty" ) func TestParseScriptOrderSelector(t *testing.T) { @@ -21,7 +22,7 @@ func TestParseScriptOrderSelector(t *testing.T) { expected: scriptOrderSelector{ kind: scriptOrderSelectorScript, name: "setup", - instanceKey: "", + instanceKey: cty.NilVal, }, }, { @@ -30,7 +31,7 @@ func TestParseScriptOrderSelector(t *testing.T) { expected: scriptOrderSelector{ kind: scriptOrderSelectorScript, name: "setup", - instanceKey: "2", + instanceKey: cty.NumberIntVal(2), }, }, { @@ -39,7 +40,16 @@ func TestParseScriptOrderSelector(t *testing.T) { expected: scriptOrderSelector{ kind: scriptOrderSelectorScript, name: "setup", - instanceKey: `"api"`, + instanceKey: cty.StringVal("api"), + }, + }, + { + name: "ScriptUnicode", + raw: "coder_script.π", + expected: scriptOrderSelector{ + kind: scriptOrderSelectorScript, + name: "π", + instanceKey: cty.NilVal, }, }, { @@ -48,7 +58,16 @@ func TestParseScriptOrderSelector(t *testing.T) { expected: scriptOrderSelector{ kind: scriptOrderSelectorModule, name: "bootstrap", - instanceKey: "", + instanceKey: cty.NilVal, + }, + }, + { + name: "ModuleUnicode", + raw: "module.开发", + expected: scriptOrderSelector{ + kind: scriptOrderSelectorModule, + name: "开发", + instanceKey: cty.NilVal, }, }, } @@ -70,6 +89,7 @@ func TestParseScriptOrderSelectorRejectsUnsupportedSyntax(t *testing.T) { for _, selector := range []string{ "", "coder_script", + "coder_script[0]", "coder_script.setup[", "coder_script.setup[api]", "coder_script.setup[true]", @@ -160,6 +180,18 @@ func TestResolveScriptOrderSelector(t *testing.T) { addresses: []string{`coder_script.setup["api"]`}, }, }, + { + name: "EscapedForEachInstanceKey", + modules: []*tfjson.StateModule{{ + Resources: []*tfjson.StateResource{ + managedCoderScript(`coder_script.setup["api"]`, "setup"), + }, + }}, + selector: `coder_script.setup["\u0061pi"]`, + expected: scriptOrderSelectorResolution{ + addresses: []string{`coder_script.setup["api"]`}, + }, + }, { name: "MissingScriptInstance", modules: []*tfjson.StateModule{{ @@ -208,6 +240,22 @@ func TestResolveScriptOrderSelector(t *testing.T) { addresses: []string{"module.outer.module.inner.coder_script.setup"}, }, }, + { + name: "ScriptRelativeToUnicodeDeclaringModule", + modules: []*tfjson.StateModule{{ + ChildModules: []*tfjson.StateModule{{ + Address: "module.开发", + Resources: []*tfjson.StateResource{ + managedCoderScript("module.开发.coder_script.π", "π"), + }, + }}, + }}, + moduleAddress: "module.开发", + selector: "coder_script.π", + expected: scriptOrderSelectorResolution{ + addresses: []string{"module.开发.coder_script.π"}, + }, + }, { name: "RepeatedModulesAndDescendants", modules: []*tfjson.StateModule{{ @@ -284,6 +332,23 @@ func TestResolveScriptOrderSelector(t *testing.T) { moduleCallDeclared: true, }, }, + { + name: "UnicodeModule", + modules: []*tfjson.StateModule{{ + ChildModules: []*tfjson.StateModule{{ + Address: "module.开发", + Resources: []*tfjson.StateResource{ + managedCoderScript("module.开发.coder_script.setup", "setup"), + }, + }}, + }}, + config: rootScriptOrderConfig("开发"), + selector: "module.开发", + expected: scriptOrderSelectorResolution{ + addresses: []string{"module.开发.coder_script.setup"}, + moduleCallDeclared: true, + }, + }, { name: "ModuleRelativeToRepeatedDeclaringModule", modules: []*tfjson.StateModule{{ diff --git a/provisioner/terraform/terraformaddress.go b/provisioner/terraform/terraformaddress.go new file mode 100644 index 0000000000000..d78f4546bb713 --- /dev/null +++ b/provisioner/terraform/terraformaddress.go @@ -0,0 +1,209 @@ +package terraform + +import ( + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclsyntax" + "github.com/zclconf/go-cty/cty" + "github.com/zclconf/go-cty/cty/gocty" + "golang.org/x/xerrors" +) + +// Terraform Core's address parser is internal, while go-terraform-address +// does not support all valid HCL identifiers, including Unicode. These helpers +// implement the address forms currently needed by Coder's Terraform +// provisioner: managed-resource addresses and module-instance paths, including +// count and for_each instance keys. +type terraformModulePath struct { + raw string + steps []terraformModulePathStep +} + +func (p terraformModulePath) String() string { + return p.raw +} + +type terraformModulePathStep struct { + name string + instanceKey cty.Value +} + +type terraformManagedResourceAddress struct { + modulePath terraformModulePath + resourceType string + resourceName string + instanceKey cty.Value +} + +// parseTerraformManagedResourceAddress parses the absolute address of a +// managed resource. It accepts unindexed addresses and concrete count or +// for_each instance keys. +func parseTerraformManagedResourceAddress(raw string) (terraformManagedResourceAddress, error) { + traversal, err := parseTerraformAddressTraversal(raw) + if err != nil { + return terraformManagedResourceAddress{}, err + } + + modulePath, position, err := parseTerraformModulePathPrefix(raw, traversal) + if err != nil { + return terraformManagedResourceAddress{}, err + } + + if position+1 >= len(traversal) { + return terraformManagedResourceAddress{}, xerrors.New("resource address must contain a resource type and name") + } + resourceType, ok := terraformTraversalName(traversal[position]) + if !ok { + return terraformManagedResourceAddress{}, xerrors.New("resource address must contain a resource type") + } + resourceName, ok := traversal[position+1].(hcl.TraverseAttr) + if !ok { + return terraformManagedResourceAddress{}, xerrors.New("resource type must be followed by a resource name") + } + position += 2 + + instanceKey := cty.NilVal + if position < len(traversal) { + if index, ok := traversal[position].(hcl.TraverseIndex); ok { + instanceKey, err = parseTerraformInstanceKey(index.Key) + if err != nil { + return terraformManagedResourceAddress{}, xerrors.Errorf( + "parse resource %q instance key: %w", resourceName.Name, err, + ) + } + position++ + } + } + if position != len(traversal) { + return terraformManagedResourceAddress{}, xerrors.New("resource address contains unsupported traversal steps") + } + + return terraformManagedResourceAddress{ + modulePath: modulePath, + resourceType: resourceType, + resourceName: resourceName.Name, + instanceKey: instanceKey, + }, nil +} + +// parseTerraformModulePath parses an absolute path containing only module +// calls. An empty path identifies the root module. +func parseTerraformModulePath(raw string) (terraformModulePath, error) { + if raw == "" { + return terraformModulePath{}, nil + } + + traversal, err := parseTerraformAddressTraversal(raw) + if err != nil { + return terraformModulePath{}, err + } + + path, position, err := parseTerraformModulePathPrefix(raw, traversal) + if err != nil { + return terraformModulePath{}, err + } + if position != len(traversal) { + return terraformModulePath{}, xerrors.New("module path must contain only module calls") + } + return path, nil +} + +func parseTerraformModulePathPrefix( + raw string, traversal hcl.Traversal, +) (terraformModulePath, int, error) { + var ( + pathEnd int + steps []terraformModulePathStep + position int + ) + for position < len(traversal) { + name, ok := terraformTraversalName(traversal[position]) + if !ok || name != "module" { + break + } + if position+1 >= len(traversal) { + return terraformModulePath{}, 0, xerrors.New("module prefix must be followed by a module name") + } + + moduleName, ok := traversal[position+1].(hcl.TraverseAttr) + if !ok { + return terraformModulePath{}, 0, xerrors.New("module prefix must be followed by a module name") + } + position += 2 + + instanceKey := cty.NilVal + if position < len(traversal) { + if index, ok := traversal[position].(hcl.TraverseIndex); ok { + parsedInstanceKey, err := parseTerraformInstanceKey(index.Key) + if err != nil { + return terraformModulePath{}, 0, xerrors.Errorf("parse module %q instance key: %w", moduleName.Name, err) + } + instanceKey = parsedInstanceKey + position++ + } + } + + steps = append(steps, terraformModulePathStep{ + name: moduleName.Name, + instanceKey: instanceKey, + }) + pathEnd = traversal[position-1].SourceRange().End.Byte + } + + var path string + if pathEnd > 0 { + path = raw[:pathEnd] + } + return terraformModulePath{raw: path, steps: steps}, position, nil +} + +func parseTerraformAddressTraversal(raw string) (hcl.Traversal, error) { + traversal, diagnostics := hclsyntax.ParseTraversalAbs( + []byte(raw), "terraform-address", hcl.InitialPos, + ) + if diagnostics.HasErrors() { + return nil, + xerrors.Errorf("invalid Terraform address syntax: %s", diagnostics.Error()) + } + return traversal, nil +} + +func terraformTraversalName(traverser hcl.Traverser) (string, bool) { + switch traverser := traverser.(type) { + case hcl.TraverseRoot: + return traverser.Name, true + case hcl.TraverseAttr: + return traverser.Name, true + default: + return "", false + } +} + +func parseTerraformInstanceKey(key cty.Value) (cty.Value, error) { + switch key.Type() { + case cty.String: + return key, nil + case cty.Number: + var index int + if err := gocty.FromCtyValue(key, &index); err != nil { + return cty.NilVal, xerrors.Errorf("instance key must be an integer: %w", err) + } + if index < 0 { + return cty.NilVal, xerrors.New("instance key must not be negative") + } + // Normalize the validated integer because equivalent HCL + // numbers can have different internal representations and + // fail structural test comparisons. + return cty.NumberIntVal(int64(index)), nil + default: + return cty.NilVal, xerrors.New("instance key must be a string or integer") + } +} + +func terraformInstanceKeysEqual(left, right cty.Value) bool { + // cty does not permit operations on NilVal, so compare with the + // sentinel before calling RawEquals. + if left == cty.NilVal || right == cty.NilVal { + return left == cty.NilVal && right == cty.NilVal + } + return left.RawEquals(right) +} diff --git a/provisioner/terraform/terraformaddress_internal_test.go b/provisioner/terraform/terraformaddress_internal_test.go new file mode 100644 index 0000000000000..e3532eb12fe0f --- /dev/null +++ b/provisioner/terraform/terraformaddress_internal_test.go @@ -0,0 +1,132 @@ +package terraform + +import ( + "testing" + + "github.com/stretchr/testify/require" + "github.com/zclconf/go-cty/cty" +) + +func TestParseTerraformManagedResourceAddress(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + raw string + expected terraformManagedResourceAddress + }{ + { + name: "UnicodeResource", + raw: "coder_script.π", + expected: terraformManagedResourceAddress{ + modulePath: terraformModulePath{}, + resourceType: "coder_script", + resourceName: "π", + instanceKey: cty.NilVal, + }, + }, + { + name: "NestedModulesAndInstances", + raw: `module.开发["环境"].module.inner[2].docker_container.工作区["api"]`, + expected: terraformManagedResourceAddress{ + modulePath: terraformModulePath{ + raw: `module.开发["环境"].module.inner[2]`, + steps: []terraformModulePathStep{ + {name: "开发", instanceKey: cty.StringVal("环境")}, + {name: "inner", instanceKey: cty.NumberIntVal(2)}, + }, + }, + resourceType: "docker_container", + resourceName: "工作区", + instanceKey: cty.StringVal("api"), + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + actual, err := parseTerraformManagedResourceAddress(test.raw) + require.NoError(t, err) + require.Equal(t, test.expected, actual) + }) + } +} + +func TestParseTerraformManagedResourceAddressRejectsInvalidInput(t *testing.T) { + t.Parallel() + + for _, address := range []string{ + "", + "coder_script", + "coder_script.setup.extra", + "coder_script.setup[true]", + "coder_script.setup[-1]", + "coder_script.setup[0][1]", + "module.bootstrap", + "module.bootstrap[0]", + } { + t.Run(address, func(t *testing.T) { + t.Parallel() + + _, err := parseTerraformManagedResourceAddress(address) + require.Error(t, err) + }) + } +} + +func TestParseTerraformModulePath(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + raw string + expected terraformModulePath + }{ + { + name: "Root", + raw: "", + expected: terraformModulePath{}, + }, + { + name: "NestedUnicodeModules", + raw: `module.开发["环境"].module.inner[2]`, + expected: terraformModulePath{ + raw: `module.开发["环境"].module.inner[2]`, + steps: []terraformModulePathStep{ + {name: "开发", instanceKey: cty.StringVal("环境")}, + {name: "inner", instanceKey: cty.NumberIntVal(2)}, + }, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + actual, err := parseTerraformModulePath(test.raw) + require.NoError(t, err) + require.Equal(t, test.expected, actual) + }) + } +} + +func TestParseTerraformModulePathRejectsInvalidInput(t *testing.T) { + t.Parallel() + + for _, address := range []string{ + "module", + "module.bootstrap.coder_script.setup", + "module.bootstrap[true]", + "coder_script.setup", + } { + t.Run(address, func(t *testing.T) { + t.Parallel() + + _, err := parseTerraformModulePath(address) + require.Error(t, err) + }) + } +}