refactor(provisioner/terraform): extract Terraform addresses (#30261)

Move Coder's custom Terraform address parsing out of the parent
terraform package into a reusable `tfaddr` package. The package parses
managed-resource addresses and module-instance paths, and produces
configuration-address candidates from Terraform references for plan
graph lookup.

Retain the custom HCL traversal parser because Terraform Core's
address parser is internal and `go-terraform-address` rejects valid
Unicode identifiers.  Update script-order selector parsing to use the
extracted package.

Refs:
https://linear.app/codercom/issue/PLAT-545
This commit is contained in:
George K
2026-10-01 19:52:46 -07:00
committed by GitHub
parent 15382ebd5d
commit 3f95f430f9
5 changed files with 715 additions and 392 deletions
+40 -51
View File
@@ -7,13 +7,13 @@ import (
"slices"
"strings"
"github.com/hashicorp/hcl/v2"
tfjson "github.com/hashicorp/terraform-json"
"github.com/mitchellh/mapstructure"
"github.com/zclconf/go-cty/cty"
"golang.org/x/xerrors"
stringutil "github.com/coder/coder/v2/coderd/util/strings"
"github.com/coder/coder/v2/provisioner/terraform/tfaddr"
)
type scriptOrderSelectorKind int
@@ -58,56 +58,44 @@ func invalidScriptOrderSelectorError(raw string) error {
// parseScriptOrderSelector currently limits selectors to scripts in
// the declaring module and whole child module calls.
func parseScriptOrderSelector(raw string) (scriptOrderSelector, error) {
traversal, err := parseTerraformAddressTraversal(raw)
modulePath, moduleErr := tfaddr.ParseModulePath(raw)
if moduleErr == nil {
steps := modulePath.Steps()
if len(steps) != 1 {
return scriptOrderSelector{}, invalidScriptOrderSelectorError(raw)
}
if steps[0].InstanceKey() != cty.NilVal {
return scriptOrderSelector{}, xerrors.Errorf(
"module selector %q must select all module instances", raw,
)
}
return scriptOrderSelector{
kind: scriptOrderSelectorModule,
name: steps[0].Name(),
}, nil
}
// A selector that is not a module path may be a managed resource.
address, err := tfaddr.ParseManagedResourceAddress(raw)
if err != nil {
return scriptOrderSelector{},
xerrors.Errorf("parse script order selector %q: %w", raw, err)
}
if len(traversal) < 2 {
if address.ModulePath().String() != "" {
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 position != len(traversal) {
return scriptOrderSelector{}, invalidScriptOrderSelectorError(raw)
}
switch root.Name {
switch address.ResourceType() {
case "coder_script":
return scriptOrderSelector{
kind: scriptOrderSelectorScript,
name: name.Name,
instanceKey: instanceKey,
}, nil
case "module":
if instanceKey != cty.NilVal {
return scriptOrderSelector{}, xerrors.Errorf("module selector %q must select all module instances", raw)
}
return scriptOrderSelector{
kind: scriptOrderSelectorModule,
name: name.Name,
name: address.ResourceName(),
instanceKey: address.InstanceKey(),
}, nil
default:
return scriptOrderSelector{}, xerrors.Errorf("script order selector %q must select a coder_script or module", raw)
return scriptOrderSelector{}, xerrors.Errorf(
"script order selector %q must select a coder_script or module", raw,
)
}
}
@@ -256,8 +244,8 @@ func configModuleForAddress(
if err != nil {
return nil, err
}
for _, step := range modulePath.steps {
call := module.ModuleCalls[step.name]
for _, step := range modulePath.Steps() {
call := module.ModuleCalls[step.Name()]
if call == nil || call.Module == nil {
return nil, errScriptOrderConfigModuleNotFound
}
@@ -287,7 +275,7 @@ func resolveScriptOrderScriptSelector(
return err
}
if selector.instanceKey != cty.NilVal &&
!terraformInstanceKeysEqual(address.instanceKey, selector.instanceKey) {
!tfaddr.InstanceKeysEqual(address.InstanceKey(), selector.instanceKey) {
continue
}
resolved[resource.Address] = struct{}{}
@@ -311,7 +299,8 @@ func resolveScriptOrderModuleSelector(
if err != nil {
return err
}
if len(modulePath.steps) == 0 || modulePath.steps[len(modulePath.steps)-1].name != selector.name {
steps := modulePath.Steps()
if len(steps) == 0 || steps[len(steps)-1].Name() != selector.name {
continue
}
if err := collectModuleCoderScriptAddresses(child, resolved); err != nil {
@@ -352,25 +341,25 @@ func collectModuleCoderScriptAddresses(
// dependencies to the wrong resource.
func parseStateResourceAddress(
module *tfjson.StateModule, resource *tfjson.StateResource,
) (*terraformManagedResourceAddress, error) {
address, err := parseTerraformManagedResourceAddress(resource.Address)
) (*tfaddr.ManagedResourceAddress, error) {
address, err := tfaddr.ParseManagedResourceAddress(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.resourceType != resource.Type ||
address.resourceName != 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
}
func parseStateModuleAddress(address string) (terraformModulePath, error) {
parsed, err := parseTerraformModulePath(address)
func parseStateModuleAddress(address string) (tfaddr.ModulePath, error) {
parsed, err := tfaddr.ParseModulePath(address)
if err != nil {
return terraformModulePath{}, xerrors.Errorf("parse module address %q: %w", address, err)
return tfaddr.ModulePath{}, xerrors.Errorf("parse module address %q: %w", address, err)
}
return parsed, nil
}
-209
View File
@@ -1,209 +0,0 @@
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)
}
@@ -1,132 +0,0 @@
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)
})
}
}
+402
View File
@@ -0,0 +1,402 @@
// Package tfaddr parses the Terraform address forms Coder's Terraform
// provisioner needs: managed-resource addresses, module-instance
// paths, and configuration references. It supports concrete count and
// for_each instance keys, but not every Terraform address form.
//
// Terraform Core's address parser is internal, and go-terraform-address
// does not support all valid HCL identifiers, including Unicode.
package tfaddr
import (
"iter"
"slices"
"strings"
"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"
)
const maxConfigurationReferenceAddressParts = 256
// ManagedResourceAddress is an absolute (rooted at the Terraform root
// module) managed-resource address.
type ManagedResourceAddress struct {
modulePath ModulePath
resourceType string
resourceName string
instanceKey cty.Value
}
// ModulePath returns the evaluated module path containing the resource.
func (a ManagedResourceAddress) ModulePath() ModulePath {
return a.modulePath
}
// ResourceType returns the resource type.
func (a ManagedResourceAddress) ResourceType() string {
return a.resourceType
}
// ResourceName returns the resource name.
func (a ManagedResourceAddress) ResourceName() string {
return a.resourceName
}
// InstanceKey returns the count or for_each instance key. An unindexed
// resource returns cty.NilVal.
func (a ManagedResourceAddress) InstanceKey() cty.Value {
return a.instanceKey
}
// ConfigurationAddress returns the address without module or resource
// instance keys.
func (a ManagedResourceAddress) ConfigurationAddress() string {
var address strings.Builder
if moduleAddress := a.modulePath.ConfigurationAddress(); moduleAddress != "" {
_, _ = address.WriteString(moduleAddress)
_ = address.WriteByte('.')
}
_, _ = address.WriteString(a.resourceType)
_ = address.WriteByte('.')
_, _ = address.WriteString(a.resourceName)
return address.String()
}
// ModulePath is an absolute evaluated path containing only module
// calls. The zero value identifies the root module.
type ModulePath struct {
raw string
steps []ModuleStep
}
// String returns the evaluated module path, including instance keys.
func (p ModulePath) String() string {
return p.raw
}
// ConfigurationAddress returns the module path without instance keys.
func (p ModulePath) ConfigurationAddress() string {
var address strings.Builder
for index, step := range p.steps {
if index > 0 {
_ = address.WriteByte('.')
}
_, _ = address.WriteString("module.")
_, _ = address.WriteString(step.name)
}
return address.String()
}
// Steps returns a copy of the ordered module steps.
func (p ModulePath) Steps() []ModuleStep {
return slices.Clone(p.steps)
}
// ModuleStep is one evaluated module call in a ModulePath.
type ModuleStep struct {
name string
instanceKey cty.Value
}
// Name returns the module call name.
func (s ModuleStep) Name() string {
return s.name
}
// InstanceKey returns the count or for_each instance key. An unindexed module
// call returns cty.NilVal.
func (s ModuleStep) InstanceKey() cty.Value {
return s.instanceKey
}
// ConfigurationReference represents a reference to a Terraform value
// with instance keys removed.
type ConfigurationReference struct {
address string
partEndByteOffsets []int
// For a module reference, moduleOutputAddress caches the Terraform graph
// address of its named output. It is empty for other references.
moduleOutputAddress string
}
// ConfigurationAddress returns the reference without instance keys.
func (r ConfigurationReference) ConfigurationAddress() string {
return r.address
}
// ConfigurationAddresses returns possible configuration addresses for the
// reference, prefixed by declaringModuleAddress. The declaring module address
// must not contain instance keys; use an empty string for the root module.
func (r ConfigurationReference) ConfigurationAddresses(
declaringModuleAddress string,
) iter.Seq[string] {
if r.address == "" {
return func(func(string) bool) {}
}
return func(yield func(string) bool) {
// Terraform plan graph nodes name child-module outputs as
// module.<name>.output.<output>, while references omit "output".
if r.moduleOutputAddress != "" {
address := qualifyConfigurationAddress(
declaringModuleAddress, r.moduleOutputAddress,
)
if !yield(address) {
return
}
}
address := qualifyConfigurationAddress(declaringModuleAddress, r.address)
prefixLength := 0
if declaringModuleAddress != "" {
prefixLength = len(declaringModuleAddress) + 1
}
for end := len(r.partEndByteOffsets) - 1; end >= 1; end-- {
if !yield(address[:prefixLength+r.partEndByteOffsets[end]]) {
return
}
}
}
}
// ParseManagedResourceAddress parses an absolute managed-resource
// address. It accepts unindexed addresses and concrete count or
// for_each instance keys.
func ParseManagedResourceAddress(raw string) (ManagedResourceAddress, error) {
traversal, err := parseAddressTraversal(raw)
if err != nil {
return ManagedResourceAddress{}, err
}
modulePath, position, err := parseModulePathPrefix(raw, traversal)
if err != nil {
return ManagedResourceAddress{}, err
}
if position+1 >= len(traversal) {
return ManagedResourceAddress{}, xerrors.New("resource address must contain a resource type and name")
}
resourceType, ok := traversalName(traversal[position])
if !ok {
return ManagedResourceAddress{}, xerrors.New("resource address must contain a resource type")
}
resourceName, ok := traversal[position+1].(hcl.TraverseAttr)
if !ok {
return ManagedResourceAddress{}, 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 = parseInstanceKey(index.Key)
if err != nil {
return ManagedResourceAddress{}, xerrors.Errorf(
"parse resource %q instance key: %w", resourceName.Name, err,
)
}
position++
}
}
if position != len(traversal) {
return ManagedResourceAddress{}, xerrors.New("resource address contains unsupported traversal steps")
}
return ManagedResourceAddress{
modulePath: modulePath,
resourceType: resourceType,
resourceName: resourceName.Name,
instanceKey: instanceKey,
}, nil
}
// ParseModulePath parses an absolute path containing only module
// calls. An empty path identifies the root module.
func ParseModulePath(raw string) (ModulePath, error) {
if raw == "" {
return ModulePath{}, nil
}
traversal, err := parseAddressTraversal(raw)
if err != nil {
return ModulePath{}, err
}
path, position, err := parseModulePathPrefix(raw, traversal)
if err != nil {
return ModulePath{}, err
}
if position != len(traversal) {
return ModulePath{}, xerrors.New("module path must contain only module calls")
}
return path, nil
}
// ParseConfigurationReference parses a Terraform value reference for
// configuration-address lookup.
func ParseConfigurationReference(raw string) (ConfigurationReference, error) {
traversal, err := parseAddressTraversal(raw)
if err != nil {
return ConfigurationReference{},
xerrors.Errorf("parse expression reference: %w", err)
}
parts := make([]string, 0, len(traversal))
for _, traverser := range traversal {
name, ok := traversalName(traverser)
if ok {
parts = append(parts, name)
continue
}
if _, ok := traverser.(hcl.TraverseIndex); ok {
continue
}
return ConfigurationReference{},
xerrors.New("reference contains unsupported traversal steps")
}
if len(parts) > maxConfigurationReferenceAddressParts {
return ConfigurationReference{}, xerrors.Errorf(
"reference contains more than %d address parts",
maxConfigurationReferenceAddressParts,
)
}
if len(parts) < 2 {
return ConfigurationReference{}, nil
}
var address strings.Builder
partEndByteOffsets := make([]int, 0, len(parts))
for index, part := range parts {
if index > 0 {
_ = address.WriteByte('.')
}
_, _ = address.WriteString(part)
partEndByteOffsets = append(partEndByteOffsets, address.Len())
}
var moduleOutputAddress string
if len(parts) >= 3 && parts[0] == "module" {
moduleOutputAddress = strings.Join(
[]string{parts[0], parts[1], "output", parts[2]}, ".",
)
}
return ConfigurationReference{
address: address.String(),
partEndByteOffsets: partEndByteOffsets,
moduleOutputAddress: moduleOutputAddress,
}, nil
}
// InstanceKeysEqual reports whether two parsed instance keys are equal.
func InstanceKeysEqual(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)
}
func parseModulePathPrefix(
raw string, traversal hcl.Traversal,
) (ModulePath, int, error) {
var (
pathEnd int
steps []ModuleStep
position int
)
for position < len(traversal) {
name, ok := traversalName(traversal[position])
if !ok || name != "module" {
break
}
if position+1 >= len(traversal) {
return ModulePath{}, 0, xerrors.New("module prefix must be followed by a module name")
}
moduleName, ok := traversal[position+1].(hcl.TraverseAttr)
if !ok {
return ModulePath{}, 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 := parseInstanceKey(index.Key)
if err != nil {
return ModulePath{}, 0, xerrors.Errorf(
"parse module %q instance key: %w", moduleName.Name, err,
)
}
instanceKey = parsedInstanceKey
position++
}
}
steps = append(steps, ModuleStep{
name: moduleName.Name,
instanceKey: instanceKey,
})
pathEnd = traversal[position-1].SourceRange().End.Byte
}
var path string
if pathEnd > 0 {
path = raw[:pathEnd]
}
return ModulePath{raw: path, steps: steps}, position, nil
}
func parseAddressTraversal(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 traversalName(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 parseInstanceKey(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 qualifyConfigurationAddress(prefix, address string) string {
if prefix == "" {
return address
}
return prefix + "." + address
}
@@ -0,0 +1,273 @@
package tfaddr_test
import (
"slices"
"strings"
"testing"
"github.com/stretchr/testify/require"
"github.com/zclconf/go-cty/cty"
"github.com/coder/coder/v2/provisioner/terraform/tfaddr"
)
func TestParseManagedResourceAddress(t *testing.T) {
t.Parallel()
for _, test := range []struct {
name string
raw string
moduleAddress string
moduleConfiguration string
moduleSteps []moduleStep
resourceType string
resourceName string
instanceKey cty.Value
configurationAddress string
}{
{
name: "RootUnicodeResource",
raw: "coder_script.π",
instanceKey: cty.NilVal,
resourceType: "coder_script",
resourceName: "π",
configurationAddress: "coder_script.π",
},
{
name: "NestedModulesAndInstances",
raw: `module.开发["环境"].module.inner[2].docker_container.工作区["api"]`,
moduleAddress: `module.开发["环境"].module.inner[2]`,
moduleConfiguration: "module.开发.module.inner",
moduleSteps: []moduleStep{
{name: "开发", instanceKey: cty.StringVal("环境")},
{name: "inner", instanceKey: cty.NumberIntVal(2)},
},
resourceType: "docker_container",
resourceName: "工作区",
instanceKey: cty.StringVal("api"),
configurationAddress: "module.开发.module.inner.docker_container.工作区",
},
} {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
address, err := tfaddr.ParseManagedResourceAddress(test.raw)
require.NoError(t, err)
require.Equal(t, test.moduleAddress, address.ModulePath().String())
require.Equal(t, test.moduleConfiguration, address.ModulePath().ConfigurationAddress())
requireModuleSteps(t, test.moduleSteps, address.ModulePath().Steps())
require.Equal(t, test.resourceType, address.ResourceType())
require.Equal(t, test.resourceName, address.ResourceName())
require.True(t, tfaddr.InstanceKeysEqual(test.instanceKey, address.InstanceKey()))
require.Equal(t, test.configurationAddress, address.ConfigurationAddress())
})
}
}
func TestParseManagedResourceAddressNormalizesCountKey(t *testing.T) {
t.Parallel()
address, err := tfaddr.ParseManagedResourceAddress("coder_script.setup[2.0]")
require.NoError(t, err)
require.True(t, address.InstanceKey().RawEquals(cty.NumberIntVal(2)))
}
func TestParseManagedResourceAddressRejectsInvalidInput(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]",
`module.bootstrap["api"]`,
} {
t.Run(address, func(t *testing.T) {
t.Parallel()
_, err := tfaddr.ParseManagedResourceAddress(address)
require.Error(t, err)
})
}
}
func TestParseModulePath(t *testing.T) {
t.Parallel()
for _, test := range []struct {
name string
raw string
configurationAddress string
steps []moduleStep
}{
{
name: "Root",
},
{
name: "NestedUnicodeModules",
raw: `module.开发["环境"].module.inner[2]`,
configurationAddress: "module.开发.module.inner",
steps: []moduleStep{
{name: "开发", instanceKey: cty.StringVal("环境")},
{name: "inner", instanceKey: cty.NumberIntVal(2)},
},
},
} {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
path, err := tfaddr.ParseModulePath(test.raw)
require.NoError(t, err)
require.Equal(t, test.raw, path.String())
require.Equal(t, test.configurationAddress, path.ConfigurationAddress())
requireModuleSteps(t, test.steps, path.Steps())
})
}
}
func TestModulePathStepsReturnsCopy(t *testing.T) {
t.Parallel()
path, err := tfaddr.ParseModulePath(`module.outer[0].module.inner["api"]`)
require.NoError(t, err)
steps := path.Steps()
require.Len(t, steps, 2)
steps[0] = steps[1]
require.Equal(t, "outer", path.Steps()[0].Name())
}
func TestParseModulePathRejectsInvalidInput(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 := tfaddr.ParseModulePath(address)
require.Error(t, err)
})
}
}
func TestConfigurationReferenceAddresses(t *testing.T) {
t.Parallel()
for _, test := range []struct {
name string
modulePrefix string
reference string
expected []string
}{
{
name: "ResourceAttribute",
modulePrefix: "module.开发",
reference: `coder_devcontainer.repo["api"].subagent_id`,
expected: []string{
"module.开发.coder_devcontainer.repo.subagent_id",
"module.开发.coder_devcontainer.repo",
},
},
{
name: "Variable",
modulePrefix: "module.开发",
reference: "var.agent_id",
expected: []string{"module.开发.var.agent_id"},
},
{
name: "ModuleOutput",
modulePrefix: "module.开发",
reference: `module.runtime["primary"].agent.id`,
expected: []string{
"module.开发.module.runtime.output.agent",
"module.开发.module.runtime.agent.id",
"module.开发.module.runtime.agent",
"module.开发.module.runtime",
},
},
} {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
reference, err := tfaddr.ParseConfigurationReference(test.reference)
require.NoError(t, err)
require.Equal(t, test.expected, slices.Collect(
reference.ConfigurationAddresses(test.modulePrefix),
))
})
}
}
func TestConfigurationReferenceConfigurationAddress(t *testing.T) {
t.Parallel()
for _, test := range []struct {
name string
reference string
expected string
}{
{
name: "ManagedResourceInstance",
reference: "coder_agent.main[0]",
expected: "coder_agent.main",
},
{
name: "NestedModuleAndResourceInstances",
reference: `module.runtime["api"].module.child[1].coder_agent.main["blue"]`,
expected: "module.runtime.module.child.coder_agent.main",
},
{
name: "DataResourceInstances",
reference: `module.runtime["api"].data.coder_script_order.order[0]`,
expected: "module.runtime.data.coder_script_order.order",
},
} {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
reference, err := tfaddr.ParseConfigurationReference(test.reference)
require.NoError(t, err)
require.Equal(t, test.expected, reference.ConfigurationAddress())
})
}
}
func TestConfigurationReferenceAddressPartLimit(t *testing.T) {
t.Parallel()
maxDepth := "local" + strings.Repeat(".child", 255)
reference, err := tfaddr.ParseConfigurationReference(maxDepth)
require.NoError(t, err)
require.Len(t, slices.Collect(reference.ConfigurationAddresses("")), 255)
tooDeep := "local" + strings.Repeat(".child", 256)
_, err = tfaddr.ParseConfigurationReference(tooDeep)
require.ErrorContains(t, err, "reference contains more than 256 address parts")
}
type moduleStep struct {
name string
instanceKey cty.Value
}
func requireModuleSteps(t *testing.T, expected []moduleStep, actual []tfaddr.ModuleStep) {
t.Helper()
require.Len(t, actual, len(expected))
for index, expectedStep := range expected {
require.Equal(t, expectedStep.name, actual[index].Name())
require.True(t, tfaddr.InstanceKeysEqual(
expectedStep.instanceKey,
actual[index].InstanceKey(),
))
}
}