mirror of
https://github.com/coder/coder.git
synced 2026-10-16 14:48:56 +08:00
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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user