stacks: add support for the removed block to .tfstacks.hcl (#35669)

pull/35670/head
Liam Cervante 1 year ago committed by GitHub
parent 7163c4b6d5
commit 36971f6ee8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -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\"].",
})
}
}

@ -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"},
},
}

@ -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"},
},
}

@ -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
}

@ -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…
Cancel
Save