diff --git a/internal/stacks/stackaddrs/removed.go b/internal/stacks/stackaddrs/removed.go new file mode 100644 index 0000000000..d71da104df --- /dev/null +++ b/internal/stacks/stackaddrs/removed.go @@ -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\"].", + }) + } +} diff --git a/internal/stacks/stackaddrs/removed_test.go b/internal/stacks/stackaddrs/removed_test.go new file mode 100644 index 0000000000..f000a54196 --- /dev/null +++ b/internal/stacks/stackaddrs/removed_test.go @@ -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) + } + } + }) + } + +} diff --git a/internal/stacks/stackconfig/component.go b/internal/stacks/stackconfig/component.go index 52274d503a..dc2bba7e3d 100644 --- a/internal/stacks/stackconfig/component.go +++ b/internal/stacks/stackconfig/component.go @@ -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}, diff --git a/internal/stacks/stackconfig/config.go b/internal/stacks/stackconfig/config.go index 402895a21f..dfd33d21d3 100644 --- a/internal/stacks/stackconfig/config.go +++ b/internal/stacks/stackconfig/config.go @@ -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 } diff --git a/internal/stacks/stackconfig/config_test.go b/internal/stacks/stackconfig/config_test.go index 02a2957fc5..e21a4105e4 100644 --- a/internal/stacks/stackconfig/config_test.go +++ b/internal/stacks/stackconfig/config_test.go @@ -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 { diff --git a/internal/stacks/stackconfig/declarations.go b/internal/stacks/stackconfig/declarations.go index f0222618cf..13167f9bfe 100644 --- a/internal/stacks/stackconfig/declarations.go +++ b/internal/stacks/stackconfig/declarations.go @@ -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 } diff --git a/internal/stacks/stackconfig/file.go b/internal/stacks/stackconfig/file.go index e3e1d6f337..260d9323e4 100644 --- a/internal/stacks/stackconfig/file.go +++ b/internal/stacks/stackconfig/file.go @@ -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"}, }, } diff --git a/internal/stacks/stackconfig/removed.go b/internal/stacks/stackconfig/removed.go new file mode 100644 index 0000000000..b65f1f1d46 --- /dev/null +++ b/internal/stacks/stackconfig/removed.go @@ -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"}, + }, +} diff --git a/internal/stacks/stackconfig/testdata/basics-bundle/errored/main.tf b/internal/stacks/stackconfig/testdata/basics-bundle/errored/main.tf new file mode 100644 index 0000000000..5ab385503e --- /dev/null +++ b/internal/stacks/stackconfig/testdata/basics-bundle/errored/main.tf @@ -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 +} diff --git a/internal/stacks/stackconfig/testdata/basics-bundle/errored/main.tfstack.hcl b/internal/stacks/stackconfig/testdata/basics-bundle/errored/main.tfstack.hcl new file mode 100644 index 0000000000..ea5e907f8a --- /dev/null +++ b/internal/stacks/stackconfig/testdata/basics-bundle/errored/main.tfstack.hcl @@ -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 + } +} diff --git a/internal/stacks/stackconfig/testdata/basics-bundle/nested/subdir/main.tfstack.hcl b/internal/stacks/stackconfig/testdata/basics-bundle/nested/subdir/main.tfstack.hcl index e008899b42..a4efeb44e2 100644 --- a/internal/stacks/stackconfig/testdata/basics-bundle/nested/subdir/main.tfstack.hcl +++ b/internal/stacks/stackconfig/testdata/basics-bundle/nested/subdir/main.tfstack.hcl @@ -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 diff --git a/internal/stacks/stackconfig/testdata/basics-bundle/terraform-sources.json b/internal/stacks/stackconfig/testdata/basics-bundle/terraform-sources.json index 46f747517c..b40f78defd 100644 --- a/internal/stacks/stackconfig/testdata/basics-bundle/terraform-sources.json +++ b/internal/stacks/stackconfig/testdata/basics-bundle/terraform-sources.json @@ -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": [