mirror of
https://github.com/hashicorp/terraform.git
synced 2026-05-28 04:03:27 -04:00
stacks: add support for the removed block to .tfstacks.hcl (#35669)
This commit is contained in:
parent
7163c4b6d5
commit
36971f6ee8
12 changed files with 906 additions and 76 deletions
137
internal/stacks/stackaddrs/removed.go
Normal file
137
internal/stacks/stackaddrs/removed.go
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
// Copyright (c) HashiCorp, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package stackaddrs
|
||||
|
||||
import (
|
||||
"github.com/hashicorp/hcl/v2"
|
||||
"github.com/hashicorp/hcl/v2/hclsyntax"
|
||||
|
||||
"github.com/hashicorp/terraform/internal/tfdiags"
|
||||
)
|
||||
|
||||
// ParseRemovedFrom parses the "from" attribute of a "removed" block in a
|
||||
// configuration and returns the address of the configuration object being
|
||||
// removed.
|
||||
//
|
||||
// In addition to the address, this function also returns a traversal that
|
||||
// represents the unparsed index within the from expression. Users can
|
||||
// optionally specify a specific index of a component to target.
|
||||
func ParseRemovedFrom(expr hcl.Expression) (Component, hcl.Expression, tfdiags.Diagnostics) {
|
||||
var component Component
|
||||
var diags tfdiags.Diagnostics
|
||||
|
||||
traversal, index, hclDiags := exprToComponentTraversal(expr)
|
||||
diags = diags.Append(hclDiags)
|
||||
if hclDiags.HasErrors() {
|
||||
return component, index, diags
|
||||
}
|
||||
|
||||
if len(traversal) < 2 {
|
||||
return component, index, diags.Append(&hcl.Diagnostic{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: "Invalid 'from' attribute",
|
||||
Detail: "The 'from' attribute must designate a component that has been removed, in the form `component.component_name` or `component.component_name[\"key\"].",
|
||||
Subject: expr.Range().Ptr(),
|
||||
})
|
||||
}
|
||||
|
||||
root, ok := traversal[0].(hcl.TraverseRoot)
|
||||
if !ok || root.Name != "component" {
|
||||
return component, index, diags.Append(&hcl.Diagnostic{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: "Invalid 'from' attribute",
|
||||
Detail: "The 'from' attribute must designate a component that has been removed, in the form `component.component_name` or `component.component_name[\"key\"].",
|
||||
Subject: expr.Range().Ptr(),
|
||||
})
|
||||
}
|
||||
|
||||
name, ok := traversal[1].(hcl.TraverseAttr)
|
||||
if !ok {
|
||||
return component, index, diags.Append(&hcl.Diagnostic{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: "Invalid 'from' attribute",
|
||||
Detail: "The 'from' attribute must designate a component that has been removed, in the form `component.component_name` or `component.component_name[\"key\"].",
|
||||
Subject: expr.Range().Ptr(),
|
||||
})
|
||||
}
|
||||
component.Name = name.Name
|
||||
|
||||
return component, index, diags
|
||||
}
|
||||
|
||||
// exprToComponentTraversal converts an HCL expression into a traversal that
|
||||
// represents the component being targeted. We have to handle parsing this
|
||||
// ourselves because removed block from arguments can contain index expressions
|
||||
// which are not supported by hcl.AbsTraversalForExpr.
|
||||
func exprToComponentTraversal(expr hcl.Expression) (hcl.Traversal, hcl.Expression, hcl.Diagnostics) {
|
||||
var diags hcl.Diagnostics
|
||||
|
||||
switch e := expr.(type) {
|
||||
case *hclsyntax.IndexExpr:
|
||||
t, d := hcl.AbsTraversalForExpr(e.Collection)
|
||||
diags = diags.Extend(d)
|
||||
if d.HasErrors() {
|
||||
return nil, nil, diags
|
||||
}
|
||||
return t, e.Key, diags
|
||||
case *hclsyntax.RelativeTraversalExpr:
|
||||
|
||||
// This is an expression of the form `component.component_name[each.key].attribute`.
|
||||
// This is invalid at the moment, as we only support direct component
|
||||
// references. We'll return our own diagnostic here.
|
||||
|
||||
return nil, nil, diags.Append(&hcl.Diagnostic{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: "Invalid 'from' attribute",
|
||||
Detail: "The 'from' attribute must designate a component that has been removed, in the form `component.component_name` or `component.component_name[\"key\"].",
|
||||
Subject: expr.Range().Ptr(),
|
||||
})
|
||||
|
||||
default:
|
||||
|
||||
// For anything else, just rely on the default traversal logic.
|
||||
|
||||
t, d := hcl.AbsTraversalForExpr(expr)
|
||||
diags = diags.Extend(d)
|
||||
if d.HasErrors() {
|
||||
return nil, nil, diags
|
||||
}
|
||||
|
||||
if len(t) < 2 {
|
||||
return nil, nil, diags.Append(&hcl.Diagnostic{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: "Invalid 'from' attribute",
|
||||
Detail: "The 'from' attribute must designate a component that has been removed, in the form `component.component_name` or `component.component_name[\"key\"].",
|
||||
Subject: expr.Range().Ptr(),
|
||||
})
|
||||
}
|
||||
|
||||
// For now, removed blocks only support direct component references.
|
||||
// ie. you can't target a resource within a component, the next check
|
||||
// ensures this is true.
|
||||
|
||||
if len(t) > 3 {
|
||||
return nil, nil, diags.Append(&hcl.Diagnostic{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: "Invalid 'from' attribute",
|
||||
Detail: "The 'from' attribute must designate a component that has been removed, in the form `component.component_name` or `component.component_name[\"key\"].",
|
||||
Subject: expr.Range().Ptr(),
|
||||
})
|
||||
}
|
||||
|
||||
if len(t) == 2 {
|
||||
return t, nil, diags
|
||||
}
|
||||
|
||||
if index, ok := t[2].(hcl.TraverseIndex); ok {
|
||||
return t[:2], hcl.StaticExpr(index.Key, index.SrcRange), diags
|
||||
}
|
||||
|
||||
return nil, nil, diags.Append(&hcl.Diagnostic{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: "Invalid 'from' attribute",
|
||||
Detail: "The 'from' attribute must designate a component that has been removed, in the form `component.component_name` or `component.component_name[\"key\"].",
|
||||
})
|
||||
}
|
||||
}
|
||||
270
internal/stacks/stackaddrs/removed_test.go
Normal file
270
internal/stacks/stackaddrs/removed_test.go
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
// Copyright (c) HashiCorp, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package stackaddrs
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/hashicorp/hcl/v2"
|
||||
"github.com/hashicorp/hcl/v2/hclsyntax"
|
||||
"github.com/zclconf/go-cty-debug/ctydebug"
|
||||
"github.com/zclconf/go-cty/cty"
|
||||
|
||||
"github.com/hashicorp/terraform/internal/tfdiags"
|
||||
)
|
||||
|
||||
func TestParseRemovedFrom(t *testing.T) {
|
||||
|
||||
mustExpr := func(t *testing.T, expr string) hcl.Expression {
|
||||
ret, diags := hclsyntax.ParseExpression([]byte(expr), "", hcl.InitialPos)
|
||||
if diags.HasErrors() {
|
||||
t.Fatal(diags.Error())
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
tcs := []struct {
|
||||
from string
|
||||
component Component
|
||||
index cty.Value
|
||||
vars map[string]cty.Value
|
||||
diags func() tfdiags.Diagnostics
|
||||
}{
|
||||
{
|
||||
from: "component.component_name",
|
||||
component: Component{
|
||||
Name: "component_name",
|
||||
},
|
||||
},
|
||||
{
|
||||
from: "component.component_name[0]",
|
||||
component: Component{
|
||||
Name: "component_name",
|
||||
},
|
||||
index: cty.NumberIntVal(0),
|
||||
},
|
||||
{
|
||||
from: "component.component_name[\"key\"]",
|
||||
component: Component{
|
||||
Name: "component_name",
|
||||
},
|
||||
index: cty.StringVal("key"),
|
||||
},
|
||||
{
|
||||
from: "component.component_name[each.key]",
|
||||
component: Component{
|
||||
Name: "component_name",
|
||||
},
|
||||
index: cty.StringVal("key"),
|
||||
vars: map[string]cty.Value{
|
||||
"each": cty.ObjectVal(map[string]cty.Value{
|
||||
"key": cty.StringVal("key"),
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
from: "component.component_name[each.value.attribute]",
|
||||
component: Component{
|
||||
Name: "component_name",
|
||||
},
|
||||
index: cty.StringVal("attribute"),
|
||||
vars: map[string]cty.Value{
|
||||
"each": cty.ObjectVal(map[string]cty.Value{
|
||||
"value": cty.ObjectVal(map[string]cty.Value{
|
||||
"attribute": cty.StringVal("attribute"),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
from: "component.component_name[each.value[\"key\"]]",
|
||||
component: Component{
|
||||
Name: "component_name",
|
||||
},
|
||||
index: cty.StringVal("key"),
|
||||
vars: map[string]cty.Value{
|
||||
"each": cty.ObjectVal(map[string]cty.Value{
|
||||
"value": cty.MapVal(map[string]cty.Value{
|
||||
"key": cty.StringVal("key"),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
from: "component.component_name[each.value[\"key\"].attribute]",
|
||||
component: Component{
|
||||
Name: "component_name",
|
||||
},
|
||||
index: cty.StringVal("attribute"),
|
||||
vars: map[string]cty.Value{
|
||||
"each": cty.ObjectVal(map[string]cty.Value{
|
||||
"value": cty.MapVal(map[string]cty.Value{
|
||||
"key": cty.ObjectVal(map[string]cty.Value{
|
||||
"attribute": cty.StringVal("attribute"),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
from: "component.component_name[each.value[local.key]]",
|
||||
component: Component{
|
||||
Name: "component_name",
|
||||
},
|
||||
index: cty.StringVal("key"),
|
||||
vars: map[string]cty.Value{
|
||||
"each": cty.ObjectVal(map[string]cty.Value{
|
||||
"value": cty.MapVal(map[string]cty.Value{
|
||||
"key": cty.StringVal("key"),
|
||||
}),
|
||||
}),
|
||||
"local": cty.ObjectVal(map[string]cty.Value{
|
||||
"key": cty.StringVal("key"),
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
from: "component.component_name[each.value[local.key].attribute]",
|
||||
component: Component{
|
||||
Name: "component_name",
|
||||
},
|
||||
index: cty.StringVal("attribute"),
|
||||
vars: map[string]cty.Value{
|
||||
"each": cty.ObjectVal(map[string]cty.Value{
|
||||
"value": cty.MapVal(map[string]cty.Value{
|
||||
"key": cty.ObjectVal(map[string]cty.Value{
|
||||
"attribute": cty.StringVal("attribute"),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
"local": cty.ObjectVal(map[string]cty.Value{
|
||||
"key": cty.StringVal("key"),
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
from: "component.component_name.attribute_key",
|
||||
diags: func() tfdiags.Diagnostics {
|
||||
var diags tfdiags.Diagnostics
|
||||
diags = diags.Append(&hcl.Diagnostic{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: "Invalid 'from' attribute",
|
||||
Detail: "The 'from' attribute must designate a component that has been removed, in the form `component.component_name` or `component.component_name[\"key\"].",
|
||||
})
|
||||
return diags
|
||||
},
|
||||
},
|
||||
{
|
||||
from: "component.component_name[0].attribute_key",
|
||||
diags: func() tfdiags.Diagnostics {
|
||||
var diags tfdiags.Diagnostics
|
||||
diags = diags.Append(&hcl.Diagnostic{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: "Invalid 'from' attribute",
|
||||
Detail: "The 'from' attribute must designate a component that has been removed, in the form `component.component_name` or `component.component_name[\"key\"].",
|
||||
})
|
||||
return diags
|
||||
},
|
||||
},
|
||||
{
|
||||
from: "component.component_name[\"key\"].attribute_key",
|
||||
diags: func() tfdiags.Diagnostics {
|
||||
var diags tfdiags.Diagnostics
|
||||
diags = diags.Append(&hcl.Diagnostic{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: "Invalid 'from' attribute",
|
||||
Detail: "The 'from' attribute must designate a component that has been removed, in the form `component.component_name` or `component.component_name[\"key\"].",
|
||||
})
|
||||
return diags
|
||||
},
|
||||
},
|
||||
{
|
||||
from: "component.component_name[each.key].attribute_key",
|
||||
diags: func() tfdiags.Diagnostics {
|
||||
var diags tfdiags.Diagnostics
|
||||
diags = diags.Append(&hcl.Diagnostic{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: "Invalid 'from' attribute",
|
||||
Detail: "The 'from' attribute must designate a component that has been removed, in the form `component.component_name` or `component.component_name[\"key\"].",
|
||||
})
|
||||
return diags
|
||||
},
|
||||
},
|
||||
{
|
||||
from: "component.component_name.attribute_key[0]",
|
||||
diags: func() tfdiags.Diagnostics {
|
||||
var diags tfdiags.Diagnostics
|
||||
diags = diags.Append(&hcl.Diagnostic{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: "Invalid 'from' attribute",
|
||||
Detail: "The 'from' attribute must designate a component that has been removed, in the form `component.component_name` or `component.component_name[\"key\"].",
|
||||
})
|
||||
return diags
|
||||
},
|
||||
},
|
||||
{
|
||||
from: "component[0].component_name",
|
||||
diags: func() tfdiags.Diagnostics {
|
||||
var diags tfdiags.Diagnostics
|
||||
diags = diags.Append(&hcl.Diagnostic{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: "Invalid 'from' attribute",
|
||||
Detail: "The 'from' attribute must designate a component that has been removed, in the form `component.component_name` or `component.component_name[\"key\"].",
|
||||
})
|
||||
return diags
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tcs {
|
||||
t.Run(tc.from, func(t *testing.T) {
|
||||
expr := mustExpr(t, tc.from)
|
||||
component, index, gotDiags := ParseRemovedFrom(expr)
|
||||
|
||||
// validate the component first
|
||||
if diff := cmp.Diff(tc.component, component); len(diff) > 0 {
|
||||
t.Errorf("unexpected result\n%s", diff)
|
||||
}
|
||||
|
||||
// validate the index
|
||||
if index == nil {
|
||||
if tc.index != cty.NilVal {
|
||||
t.Errorf("expected index but got nil")
|
||||
}
|
||||
} else {
|
||||
gotIndex, indexDiags := index.Value(&hcl.EvalContext{
|
||||
Variables: tc.vars,
|
||||
})
|
||||
if len(indexDiags) > 0 {
|
||||
t.Errorf("unexpected index diagnostics: %s", indexDiags.Error())
|
||||
}
|
||||
if diff := cmp.Diff(tc.index, gotIndex, ctydebug.CmpOptions); len(diff) > 0 {
|
||||
t.Errorf("unexpected index\n%s", diff)
|
||||
}
|
||||
}
|
||||
|
||||
// validate the diagnostics
|
||||
|
||||
var wantDiags tfdiags.Diagnostics
|
||||
if tc.diags != nil {
|
||||
wantDiags = tc.diags()
|
||||
}
|
||||
if len(gotDiags) != len(wantDiags) {
|
||||
t.Errorf("wrong number of diagnostics")
|
||||
}
|
||||
for ix, got := range gotDiags {
|
||||
want := wantDiags[ix]
|
||||
|
||||
if want.Severity() != got.Severity() {
|
||||
t.Errorf("unexpected severity: got %s, want %s", got.Severity(), want.Severity())
|
||||
}
|
||||
if diff := cmp.Diff(want.Description(), got.Description()); len(diff) > 0 {
|
||||
t.Errorf("unexpected description\n%s", diff)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -128,81 +128,9 @@ func decodeComponentBlock(block *hcl.Block) (*Component, tfdiags.Diagnostics) {
|
|||
ret.Inputs = attr.Expr
|
||||
}
|
||||
if attr, ok := content.Attributes["providers"]; ok {
|
||||
// This particular argument has some enforced static structure because
|
||||
// it's populating an inflexible part of Terraform Core's input.
|
||||
// This argument, if present, must always be an object constructor
|
||||
// whose attributes are Terraform Core-style provider configuration
|
||||
// addresses, but whose values are just arbitrary expressions for now
|
||||
// and will be resolved into specific provider configuration addresses
|
||||
// dynamically at runtime.
|
||||
pairs, hclDiags := hcl.ExprMap(attr.Expr)
|
||||
diags = diags.Append(hclDiags)
|
||||
if !hclDiags.HasErrors() {
|
||||
ret.ProviderConfigs = make(map[addrs.LocalProviderConfig]hcl.Expression, len(pairs))
|
||||
for _, pair := range pairs {
|
||||
insideAddrExpr := pair.Key
|
||||
outsideAddrExpr := pair.Value
|
||||
|
||||
traversal, hclDiags := hcl.AbsTraversalForExpr(insideAddrExpr)
|
||||
diags = diags.Append(hclDiags)
|
||||
if hclDiags.HasErrors() {
|
||||
continue
|
||||
}
|
||||
|
||||
if len(traversal) < 1 || len(traversal) > 2 {
|
||||
diags = diags.Append(&hcl.Diagnostic{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: "Invalid provider configuration reference",
|
||||
Detail: "Each item in the providers argument requires a provider local name, optionally followed by a period and then a configuration alias, matching one of the provider configuration import slots declared by the component's root module.",
|
||||
Subject: insideAddrExpr.Range().Ptr(),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
localName := traversal.RootName()
|
||||
if !hclsyntax.ValidIdentifier(localName) {
|
||||
diags = diags.Append(invalidNameDiagnostic(
|
||||
"Invalid provider local name",
|
||||
traversal[0].SourceRange(),
|
||||
))
|
||||
continue
|
||||
}
|
||||
|
||||
var alias string
|
||||
if len(traversal) > 1 {
|
||||
aliasStep, ok := traversal[1].(hcl.TraverseAttr)
|
||||
if !ok {
|
||||
diags = diags.Append(&hcl.Diagnostic{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: "Invalid provider configuration reference",
|
||||
Detail: "Provider local name must either stand alone or be followed by a period and then a configuration alias.",
|
||||
Subject: traversal[1].SourceRange().Ptr(),
|
||||
})
|
||||
continue
|
||||
}
|
||||
alias = aliasStep.Name
|
||||
}
|
||||
|
||||
addr := addrs.LocalProviderConfig{
|
||||
LocalName: localName,
|
||||
Alias: alias,
|
||||
}
|
||||
if existing, exists := ret.ProviderConfigs[addr]; exists {
|
||||
diags = diags.Append(&hcl.Diagnostic{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: "Duplicate provider configuration assignment",
|
||||
Detail: fmt.Sprintf(
|
||||
"A provider configuration for %s was already assigned at %s.",
|
||||
addr.StringCompact(), existing.Range().Ptr(),
|
||||
),
|
||||
Subject: outsideAddrExpr.Range().Ptr(),
|
||||
})
|
||||
continue
|
||||
} else {
|
||||
ret.ProviderConfigs[addr] = outsideAddrExpr
|
||||
}
|
||||
}
|
||||
}
|
||||
var providerDiags tfdiags.Diagnostics
|
||||
ret.ProviderConfigs, providerDiags = decodeProvidersAttribute(attr)
|
||||
diags = diags.Append(providerDiags)
|
||||
}
|
||||
if attr, exists := content.Attributes["depends_on"]; exists {
|
||||
ret.DependsOn, hclDiags = configs.DecodeDependsOn(attr)
|
||||
|
|
@ -281,6 +209,90 @@ func decodeSourceAddrArguments(sourceAttr, versionAttr *hcl.Attribute) (sourcead
|
|||
return sourceAddr, versionConstraints, diags
|
||||
}
|
||||
|
||||
func decodeProvidersAttribute(attr *hcl.Attribute) (map[addrs.LocalProviderConfig]hcl.Expression, tfdiags.Diagnostics) {
|
||||
var diags tfdiags.Diagnostics
|
||||
|
||||
// This particular argument has some enforced static structure because
|
||||
// it's populating an inflexible part of Terraform Core's input.
|
||||
// This argument, if present, must always be an object constructor
|
||||
// whose attributes are Terraform Core-style provider configuration
|
||||
// addresses, but whose values are just arbitrary expressions for now
|
||||
// and will be resolved into specific provider configuration addresses
|
||||
// dynamically at runtime.
|
||||
pairs, hclDiags := hcl.ExprMap(attr.Expr)
|
||||
diags = diags.Append(hclDiags)
|
||||
if hclDiags.HasErrors() {
|
||||
return nil, diags
|
||||
}
|
||||
|
||||
ret := map[addrs.LocalProviderConfig]hcl.Expression{}
|
||||
for _, pair := range pairs {
|
||||
insideAddrExpr := pair.Key
|
||||
outsideAddrExpr := pair.Value
|
||||
|
||||
traversal, hclDiags := hcl.AbsTraversalForExpr(insideAddrExpr)
|
||||
diags = diags.Append(hclDiags)
|
||||
if hclDiags.HasErrors() {
|
||||
continue
|
||||
}
|
||||
|
||||
if len(traversal) < 1 || len(traversal) > 2 {
|
||||
diags = diags.Append(&hcl.Diagnostic{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: "Invalid provider configuration reference",
|
||||
Detail: "Each item in the providers argument requires a provider local name, optionally followed by a period and then a configuration alias, matching one of the provider configuration import slots declared by the component's root module.",
|
||||
Subject: insideAddrExpr.Range().Ptr(),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
localName := traversal.RootName()
|
||||
if !hclsyntax.ValidIdentifier(localName) {
|
||||
diags = diags.Append(invalidNameDiagnostic(
|
||||
"Invalid provider local name",
|
||||
traversal[0].SourceRange(),
|
||||
))
|
||||
continue
|
||||
}
|
||||
|
||||
var alias string
|
||||
if len(traversal) > 1 {
|
||||
aliasStep, ok := traversal[1].(hcl.TraverseAttr)
|
||||
if !ok {
|
||||
diags = diags.Append(&hcl.Diagnostic{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: "Invalid provider configuration reference",
|
||||
Detail: "Provider local name must either stand alone or be followed by a period and then a configuration alias.",
|
||||
Subject: traversal[1].SourceRange().Ptr(),
|
||||
})
|
||||
continue
|
||||
}
|
||||
alias = aliasStep.Name
|
||||
}
|
||||
|
||||
addr := addrs.LocalProviderConfig{
|
||||
LocalName: localName,
|
||||
Alias: alias,
|
||||
}
|
||||
if existing, exists := ret[addr]; exists {
|
||||
diags = diags.Append(&hcl.Diagnostic{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: "Duplicate provider configuration assignment",
|
||||
Detail: fmt.Sprintf(
|
||||
"A provider configuration for %s was already assigned at %s.",
|
||||
addr.StringCompact(), existing.Range().Ptr(),
|
||||
),
|
||||
Subject: outsideAddrExpr.Range().Ptr(),
|
||||
})
|
||||
continue
|
||||
} else {
|
||||
ret[addr] = outsideAddrExpr
|
||||
}
|
||||
}
|
||||
|
||||
return ret, diags
|
||||
}
|
||||
|
||||
var componentBlockSchema = &hcl.BodySchema{
|
||||
Attributes: []hcl.AttributeSchema{
|
||||
{Name: "source", Required: true},
|
||||
|
|
|
|||
|
|
@ -12,11 +12,12 @@ import (
|
|||
"github.com/hashicorp/go-slug/sourceaddrs"
|
||||
"github.com/hashicorp/go-slug/sourcebundle"
|
||||
"github.com/hashicorp/hcl/v2"
|
||||
"github.com/zclconf/go-cty/cty"
|
||||
|
||||
"github.com/hashicorp/terraform/internal/addrs"
|
||||
"github.com/hashicorp/terraform/internal/stacks/stackconfig/stackconfigtypes"
|
||||
"github.com/hashicorp/terraform/internal/stacks/stackconfig/typeexpr"
|
||||
"github.com/hashicorp/terraform/internal/tfdiags"
|
||||
"github.com/zclconf/go-cty/cty"
|
||||
)
|
||||
|
||||
// maxEmbeddedStackNesting is an arbitrary, hopefully-reasonable limit on
|
||||
|
|
@ -190,6 +191,24 @@ func loadConfigDir(sourceAddr sourceaddrs.FinalSource, sources *sourcebundle.Bun
|
|||
cmpn.FinalSourceAddr = effectiveSourceAddr
|
||||
}
|
||||
|
||||
for _, rmvd := range stack.Removed {
|
||||
effectiveSourceAddr, err := resolveFinalSourceAddr(sourceAddr, rmvd.SourceAddr, rmvd.VersionConstraints, sources)
|
||||
if err != nil {
|
||||
diags = diags.Append(&hcl.Diagnostic{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: "Invalid source address",
|
||||
Detail: fmt.Sprintf(
|
||||
"Cannot use %q as a source address here: %s.",
|
||||
rmvd.SourceAddr, err,
|
||||
),
|
||||
Subject: rmvd.SourceAddrRange.ToHCL().Ptr(),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
rmvd.FinalSourceAddr = effectiveSourceAddr
|
||||
}
|
||||
|
||||
return ret, diags
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,13 +4,73 @@
|
|||
package stackconfig
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"github.com/hashicorp/go-slug/sourceaddrs"
|
||||
"github.com/hashicorp/go-slug/sourcebundle"
|
||||
"github.com/zclconf/go-cty/cty"
|
||||
|
||||
"github.com/hashicorp/terraform/internal/tfdiags"
|
||||
)
|
||||
|
||||
func TestLoadConfigDirErrors(t *testing.T) {
|
||||
bundle, err := sourcebundle.OpenDir("testdata/basics-bundle")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rootAddr := sourceaddrs.MustParseSource("git::https://example.com/errored.git").(sourceaddrs.RemoteSource)
|
||||
_, gotDiags := LoadConfigDir(rootAddr, bundle)
|
||||
|
||||
sort.SliceStable(gotDiags, func(i, j int) bool {
|
||||
if gotDiags[i].Severity() != gotDiags[j].Severity() {
|
||||
return gotDiags[i].Severity() < gotDiags[j].Severity()
|
||||
}
|
||||
|
||||
if gotDiags[i].Description().Summary != gotDiags[j].Description().Summary {
|
||||
return gotDiags[i].Description().Summary < gotDiags[j].Description().Summary
|
||||
}
|
||||
|
||||
return gotDiags[i].Description().Detail < gotDiags[j].Description().Detail
|
||||
})
|
||||
|
||||
wantDiags := tfdiags.Diagnostics{
|
||||
tfdiags.Sourceless(tfdiags.Error, "Component exists for removed block", "A removed block for component \"a\" was declared without an index, but a component block with the same name was declared at git::https://example.com/errored.git//main.tfstack.hcl:10,1-14.\n\nA removed block without an index indicates that the component and all instances were removed from the configuration, and this is not the case."),
|
||||
}
|
||||
|
||||
count := len(wantDiags)
|
||||
if len(gotDiags) > count {
|
||||
count = len(gotDiags)
|
||||
}
|
||||
|
||||
for i := 0; i < count; i++ {
|
||||
if i >= len(wantDiags) {
|
||||
t.Errorf("unexpected diagnostic:\n%s", gotDiags[i])
|
||||
continue
|
||||
}
|
||||
|
||||
if i >= len(gotDiags) {
|
||||
t.Errorf("missing diagnostic:\n%s", wantDiags[i])
|
||||
continue
|
||||
}
|
||||
|
||||
got, want := gotDiags[i], wantDiags[i]
|
||||
|
||||
if got, want := got.Severity(), want.Severity(); got != want {
|
||||
t.Errorf("diagnostics[%d] severity\ngot: %s\nwant: %s", i, got, want)
|
||||
}
|
||||
|
||||
if got, want := got.Description().Summary, want.Description().Summary; got != want {
|
||||
t.Errorf("diagnostics[%d] summary\ngot: %s\nwant: %s", i, got, want)
|
||||
}
|
||||
|
||||
if got, want := got.Description().Detail, want.Description().Detail; got != want {
|
||||
t.Errorf("diagnostics[%d] detail\ngot: %s\nwant: %s", i, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigDirBasics(t *testing.T) {
|
||||
bundle, err := sourcebundle.OpenDir("testdata/basics-bundle")
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import (
|
|||
"fmt"
|
||||
|
||||
"github.com/hashicorp/hcl/v2"
|
||||
|
||||
"github.com/hashicorp/terraform/internal/addrs"
|
||||
"github.com/hashicorp/terraform/internal/tfdiags"
|
||||
)
|
||||
|
|
@ -43,6 +44,10 @@ type Declarations struct {
|
|||
// particular stack configuration. Other stack configurations in the
|
||||
// overall tree might have their own provider configurations.
|
||||
ProviderConfigs map[addrs.LocalProviderConfig]*ProviderConfig
|
||||
|
||||
// Removed are the list of components that have been removed from the
|
||||
// configuration.
|
||||
Removed map[string]*Removed
|
||||
}
|
||||
|
||||
func makeDeclarations() Declarations {
|
||||
|
|
@ -53,6 +58,7 @@ func makeDeclarations() Declarations {
|
|||
LocalValues: make(map[string]*LocalValue),
|
||||
OutputValues: make(map[string]*OutputValue),
|
||||
ProviderConfigs: make(map[addrs.LocalProviderConfig]*ProviderConfig),
|
||||
Removed: make(map[string]*Removed),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -76,6 +82,26 @@ func (d *Declarations) addComponent(decl *Component) tfdiags.Diagnostics {
|
|||
return diags
|
||||
}
|
||||
|
||||
if removed, exists := d.Removed[name]; exists && removed.FromIndex == nil {
|
||||
// If a component has been removed, we should not also find it in the
|
||||
// configuration.
|
||||
//
|
||||
// If the removed block has an index, then it's possible that only a
|
||||
// specific instance was removed and not the whole thing. This is okay
|
||||
// at this point, and will be validated more later. See the addRemoved
|
||||
// method for more information.
|
||||
diags = diags.Append(&hcl.Diagnostic{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: "Component exists for removed block",
|
||||
Detail: fmt.Sprintf(
|
||||
"A removed block for component %q was declared without an index, but a component block with the same name was declared at %s.\n\nA removed block without an index indicates that the component and all instances were removed from the configuration, and this is not the case.",
|
||||
name, decl.DeclRange.ToHCL(),
|
||||
),
|
||||
Subject: removed.DeclRange.ToHCL().Ptr(),
|
||||
})
|
||||
return diags
|
||||
}
|
||||
|
||||
d.Components[name] = decl
|
||||
return diags
|
||||
}
|
||||
|
|
@ -221,6 +247,58 @@ func (d *Declarations) addProviderConfig(decl *ProviderConfig) tfdiags.Diagnosti
|
|||
return diags
|
||||
}
|
||||
|
||||
func (d *Declarations) addRemoved(decl *Removed) tfdiags.Diagnostics {
|
||||
var diags tfdiags.Diagnostics
|
||||
|
||||
if decl == nil {
|
||||
return diags
|
||||
}
|
||||
name := decl.FromComponent.Name
|
||||
|
||||
// We're going to make sure that all the removed blocks that share the same
|
||||
// FromComponent are consistent.
|
||||
if existing, exists := d.Removed[name]; exists {
|
||||
diags = diags.Append(&hcl.Diagnostic{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: "Duplicate removed block",
|
||||
Detail: fmt.Sprintf(
|
||||
"A removed block for component %q was already declared at %s.",
|
||||
name, existing.DeclRange.ToHCL(),
|
||||
),
|
||||
Subject: decl.DeclRange.ToHCL().Ptr(),
|
||||
})
|
||||
return diags
|
||||
}
|
||||
|
||||
if decl.FromIndex == nil {
|
||||
// If the removed block does not have an index, then we shouldn't also
|
||||
// have a component block with the same name. A removed block without
|
||||
// an index indicates that the component and all instances were removed
|
||||
// from the configuration.
|
||||
//
|
||||
// Note that a removed block with an index is allowed to coexist with a
|
||||
// component block with the same name, because it indicates that only
|
||||
// a specific instance was removed and not the whole thing. During the
|
||||
// validate and planning stages we will validate that the clashing
|
||||
// component and removed blocks are not both pointing to the same index.
|
||||
if component, exists := d.Components[name]; exists {
|
||||
diags = diags.Append(&hcl.Diagnostic{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: "Component exists for removed block",
|
||||
Detail: fmt.Sprintf(
|
||||
"A removed block for component %q was declared without an index, but a component block with the same name was declared at %s.\n\nA removed block without an index indicates that the component and all instances were removed from the configuration, and this is not the case.",
|
||||
name, component.DeclRange.ToHCL(),
|
||||
),
|
||||
Subject: decl.DeclRange.ToHCL().Ptr(),
|
||||
})
|
||||
return diags
|
||||
}
|
||||
}
|
||||
|
||||
d.Removed[name] = decl
|
||||
return diags
|
||||
}
|
||||
|
||||
func (d *Declarations) merge(other *Declarations) tfdiags.Diagnostics {
|
||||
var diags tfdiags.Diagnostics
|
||||
for _, decl := range other.EmbeddedStacks {
|
||||
|
|
@ -256,5 +334,11 @@ func (d *Declarations) merge(other *Declarations) tfdiags.Diagnostics {
|
|||
d.addProviderConfig(decl),
|
||||
)
|
||||
}
|
||||
for _, decl := range other.Removed {
|
||||
diags = diags.Append(
|
||||
d.addRemoved(decl),
|
||||
)
|
||||
}
|
||||
|
||||
return diags
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import (
|
|||
"github.com/hashicorp/hcl/v2"
|
||||
"github.com/hashicorp/hcl/v2/hclsyntax"
|
||||
hcljson "github.com/hashicorp/hcl/v2/json"
|
||||
|
||||
"github.com/hashicorp/terraform/internal/tfdiags"
|
||||
)
|
||||
|
||||
|
|
@ -136,6 +137,13 @@ func DecodeFileBody(body hcl.Body, fileAddr sourceaddrs.FinalSource) (*File, tfd
|
|||
ret.Declarations.addRequiredProviders(decl),
|
||||
)
|
||||
|
||||
case "removed":
|
||||
decl, moreDiags := decodeRemovedBlock(block)
|
||||
diags = diags.Append(moreDiags)
|
||||
diags = diags.Append(
|
||||
ret.Declarations.addRemoved(decl),
|
||||
)
|
||||
|
||||
default:
|
||||
// Should not get here because the cases above should be exhaustive
|
||||
// for everything declared in rootConfigSchema.
|
||||
|
|
@ -220,5 +228,6 @@ var rootConfigSchema = &hcl.BodySchema{
|
|||
{Type: "output", LabelNames: []string{"name"}},
|
||||
{Type: "provider", LabelNames: []string{"type", "name"}},
|
||||
{Type: "required_providers"},
|
||||
{Type: "removed"},
|
||||
},
|
||||
}
|
||||
|
|
|
|||
172
internal/stacks/stackconfig/removed.go
Normal file
172
internal/stacks/stackconfig/removed.go
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
// Copyright (c) HashiCorp, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package stackconfig
|
||||
|
||||
import (
|
||||
"github.com/apparentlymart/go-versions/versions/constraints"
|
||||
"github.com/hashicorp/go-slug/sourceaddrs"
|
||||
"github.com/hashicorp/hcl/v2"
|
||||
"github.com/hashicorp/hcl/v2/gohcl"
|
||||
|
||||
"github.com/hashicorp/terraform/internal/addrs"
|
||||
"github.com/hashicorp/terraform/internal/stacks/stackaddrs"
|
||||
"github.com/hashicorp/terraform/internal/tfdiags"
|
||||
)
|
||||
|
||||
// Removed represents a component that was removed from the configuration.
|
||||
//
|
||||
// Removed blocks don't have labels associated with them, instead they have
|
||||
// a "from" attribute that points directly to the old component that was
|
||||
// removed. Removed blocks can also point to component instances specifically,
|
||||
// using an index expression. The "for_each" attribute also means that the
|
||||
// "from" attribute can't always be evaluated statically.
|
||||
//
|
||||
// Removed blocks are, therefore, represented by the FromComponent and FromIndex
|
||||
// fields, which together represent the address of the removed component. The
|
||||
// FromComponent field is the address of the component itself, and the FromIndex
|
||||
// field is the index expression that will be evaluated to determine the
|
||||
// specific instance of the component that was removed.
|
||||
//
|
||||
// FromIndex can be null if either the removed block is pointing to a component
|
||||
// that was not instanced, or is pointing to all the instances of a removed
|
||||
// component.
|
||||
//
|
||||
// For this reason, multiple Removed blocks can be associated with the same
|
||||
// FromComponent, but with different FromIndex values. When the FromIndex values
|
||||
// are evaluated, during the planning stage, we will validate that the FromIndex
|
||||
// values are unique.
|
||||
type Removed struct {
|
||||
FromComponent stackaddrs.Component
|
||||
FromIndex hcl.Expression
|
||||
|
||||
SourceAddr sourceaddrs.Source
|
||||
VersionConstraints constraints.IntersectionSpec
|
||||
SourceAddrRange, VersionConstraintsRange tfdiags.SourceRange
|
||||
|
||||
// FinalSourceAddr is populated only when a configuration is loaded
|
||||
// through [LoadConfigDir], and in that case contains the finalized
|
||||
// address produced by resolving the SourceAddr field relative to
|
||||
// the address of the file where the component was declared. This
|
||||
// is the address to use if you intend to load the component's
|
||||
// root module from a source bundle.
|
||||
FinalSourceAddr sourceaddrs.FinalSource
|
||||
|
||||
ForEach hcl.Expression
|
||||
|
||||
// ProviderConfigs describes the mapping between the static provider
|
||||
// configuration slots declared in the component's root module and the
|
||||
// dynamic provider configuration objects in scope in the calling
|
||||
// stack configuration.
|
||||
//
|
||||
// This map deals with the slight schism between the stacks language's
|
||||
// treatment of provider configurations as regular values of a special
|
||||
// data type vs. the main Terraform language's treatment of provider
|
||||
// configurations as something special passed out of band from the
|
||||
// input variables. The overall structure and the map keys are fixed
|
||||
// statically during decoding, but the final provider configuration objects
|
||||
// are determined only at runtime by normal expression evaluation.
|
||||
//
|
||||
// The keys of this map refer to provider configuration slots inside
|
||||
// the module being called, but use the local names defined in the
|
||||
// calling stack configuration. The stacks language runtime will
|
||||
// translate the caller's local names into the callee's declared provider
|
||||
// configurations by using the stack configuration's table of local
|
||||
// provider names.
|
||||
ProviderConfigs map[addrs.LocalProviderConfig]hcl.Expression
|
||||
|
||||
// Destroy controls whether this removed block will actually destroy all
|
||||
// instances of resources within this component, or just removed them from
|
||||
// the state. Defaults to true.
|
||||
Destroy bool
|
||||
|
||||
DeclRange tfdiags.SourceRange
|
||||
}
|
||||
|
||||
func decodeRemovedBlock(block *hcl.Block) (*Removed, tfdiags.Diagnostics) {
|
||||
var diags tfdiags.Diagnostics
|
||||
ret := &Removed{
|
||||
DeclRange: tfdiags.SourceRangeFromHCL(block.DefRange),
|
||||
}
|
||||
|
||||
content, hclDiags := block.Body.Content(removedBlockSchema)
|
||||
diags = diags.Append(hclDiags)
|
||||
if hclDiags.HasErrors() {
|
||||
return nil, diags
|
||||
}
|
||||
|
||||
// We're splitting out the component and the index now, as we can decode and
|
||||
// analyse the component now. The index might be referencing the for_each
|
||||
// variable, which we can't decode yet.
|
||||
component, index, moreDiags := stackaddrs.ParseRemovedFrom(content.Attributes["from"].Expr)
|
||||
diags = diags.Append(moreDiags)
|
||||
if moreDiags.HasErrors() {
|
||||
return nil, diags
|
||||
}
|
||||
ret.FromComponent = component
|
||||
ret.FromIndex = index
|
||||
|
||||
sourceAddr, versionConstraints, moreDiags := decodeSourceAddrArguments(
|
||||
content.Attributes["source"],
|
||||
content.Attributes["version"],
|
||||
)
|
||||
diags = diags.Append(moreDiags)
|
||||
if moreDiags.HasErrors() {
|
||||
return nil, diags
|
||||
}
|
||||
|
||||
ret.SourceAddr = sourceAddr
|
||||
ret.VersionConstraints = versionConstraints
|
||||
ret.SourceAddrRange = tfdiags.SourceRangeFromHCL(content.Attributes["source"].Range)
|
||||
if content.Attributes["version"] != nil {
|
||||
ret.VersionConstraintsRange = tfdiags.SourceRangeFromHCL(content.Attributes["version"].Range)
|
||||
}
|
||||
// Now that we've populated the mandatory source location fields we can
|
||||
// safely return a partial ret if we encounter any further errors, as
|
||||
// long as we leave the other fields either unset or in some other
|
||||
// reasonable state for careful partial analysis.
|
||||
|
||||
if attr, ok := content.Attributes["for_each"]; ok {
|
||||
ret.ForEach = attr.Expr
|
||||
}
|
||||
if attr, ok := content.Attributes["providers"]; ok {
|
||||
var providerDiags tfdiags.Diagnostics
|
||||
ret.ProviderConfigs, providerDiags = decodeProvidersAttribute(attr)
|
||||
diags = diags.Append(providerDiags)
|
||||
}
|
||||
|
||||
ret.Destroy = true // default to true
|
||||
for _, block := range content.Blocks {
|
||||
switch block.Type {
|
||||
case "lifecycle":
|
||||
lcContent, lcDiags := block.Body.Content(removedLifecycleBlockSchema)
|
||||
diags = diags.Append(lcDiags)
|
||||
|
||||
if attr, ok := lcContent.Attributes["destroy"]; ok {
|
||||
valDiags := gohcl.DecodeExpression(attr.Expr, nil, &ret.Destroy)
|
||||
diags = diags.Append(valDiags)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ret, diags
|
||||
}
|
||||
|
||||
var removedBlockSchema = &hcl.BodySchema{
|
||||
Blocks: []hcl.BlockHeaderSchema{
|
||||
{Type: "lifecycle"},
|
||||
},
|
||||
Attributes: []hcl.AttributeSchema{
|
||||
{Name: "from", Required: true},
|
||||
{Name: "source", Required: true},
|
||||
{Name: "version", Required: false},
|
||||
{Name: "for_each", Required: false},
|
||||
{Name: "providers", Required: false},
|
||||
},
|
||||
}
|
||||
|
||||
var removedLifecycleBlockSchema = &hcl.BodySchema{
|
||||
Attributes: []hcl.AttributeSchema{
|
||||
{Name: "destroy"},
|
||||
},
|
||||
}
|
||||
17
internal/stacks/stackconfig/testdata/basics-bundle/errored/main.tf
vendored
Normal file
17
internal/stacks/stackconfig/testdata/basics-bundle/errored/main.tf
vendored
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
variable "name" {
|
||||
type = string
|
||||
}
|
||||
|
||||
resource "null_resource" "example" {
|
||||
triggers = {
|
||||
name = var.name
|
||||
}
|
||||
}
|
||||
|
||||
output "greeting" {
|
||||
value = "Hello, ${var.name}!"
|
||||
}
|
||||
|
||||
output "resource_id" {
|
||||
value = null_resource.example.id
|
||||
}
|
||||
32
internal/stacks/stackconfig/testdata/basics-bundle/errored/main.tfstack.hcl
vendored
Normal file
32
internal/stacks/stackconfig/testdata/basics-bundle/errored/main.tfstack.hcl
vendored
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
required_providers {
|
||||
null = {
|
||||
source = "hashicorp/null"
|
||||
version = "3.2.1"
|
||||
}
|
||||
}
|
||||
|
||||
provider "null" "a" {}
|
||||
|
||||
component "a" {
|
||||
source = "./"
|
||||
|
||||
inputs = {
|
||||
name = var.name
|
||||
}
|
||||
|
||||
providers = {
|
||||
null = provider.null.a
|
||||
}
|
||||
}
|
||||
|
||||
removed {
|
||||
// This is invalid, you can't reference the whole component like this if
|
||||
// the target component is still in the config.
|
||||
from = component.a
|
||||
|
||||
source = "./"
|
||||
|
||||
providers = {
|
||||
null = provider.null.a
|
||||
}
|
||||
}
|
||||
|
|
@ -24,6 +24,19 @@ component "a" {
|
|||
}
|
||||
}
|
||||
|
||||
removed {
|
||||
from = component.b
|
||||
|
||||
source = "../"
|
||||
providers = {
|
||||
null = var.provider
|
||||
}
|
||||
|
||||
lifecycle {
|
||||
destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
output "greeting" {
|
||||
type = string
|
||||
value = component.a.greeting
|
||||
|
|
|
|||
|
|
@ -10,6 +10,11 @@
|
|||
"source": "git::https://example.com/nested.git",
|
||||
"local": "nested",
|
||||
"meta": {}
|
||||
},
|
||||
{
|
||||
"source": "git::https://example.com/errored.git",
|
||||
"local": "errored",
|
||||
"meta": {}
|
||||
}
|
||||
],
|
||||
"registry": [
|
||||
|
|
|
|||
Loading…
Reference in a new issue