stacks: include resources in state when calculating required providers (#34645)

* stacks: include resources in state when calculating required providers

* also support apply time

* add copywrite headers
pull/34682/head
Liam Cervante 2 years ago committed by GitHub
parent 67d5d8f79f
commit 07f6621091
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -118,3 +118,22 @@ func (c *Component) ForModulesRuntime() (*plans.Plan, error) {
return plan, nil
}
// RequiredProviderInstances returns a description of all the provider instance
// slots that are required to satisfy the resource instances planned for this
// component.
//
// See also stackstate.State.RequiredProviderInstances and
// stackeval.ComponentConfig.RequiredProviderInstances for similar functions
// that retrieve the provider instances for a components in the config and in
// the state.
func (c *Component) RequiredProviderInstances() addrs.Set[addrs.RootProviderConfig] {
providerInstances := addrs.MakeSet[addrs.RootProviderConfig]()
for _, elem := range c.ResourceInstanceProviderConfig.Elems {
providerInstances.Add(addrs.RootProviderConfig{
Provider: elem.Value.Provider,
Alias: elem.Value.Alias,
})
}
return providerInstances
}

@ -7,6 +7,7 @@ import (
"github.com/zclconf/go-cty/cty"
"google.golang.org/protobuf/types/known/anypb"
"github.com/hashicorp/terraform/internal/addrs"
"github.com/hashicorp/terraform/internal/collections"
"github.com/hashicorp/terraform/internal/stacks/stackaddrs"
)
@ -45,3 +46,18 @@ type Plan struct {
// nested component instances from embedded stacks.
Components collections.Map[stackaddrs.AbsComponentInstance, *Component]
}
// RequiredProviderInstances returns a description of all of the provider
// instance slots that are required to satisfy the resource instances
// belonging to the given component instance.
//
// See also stackeval.ComponentConfig.RequiredProviderInstances for a similar
// function that operates on the configuration of a component instance rather
// than the plan of one.
func (p *Plan) RequiredProviderInstances(addr stackaddrs.AbsComponentInstance) addrs.Set[addrs.RootProviderConfig] {
component, ok := p.Components.GetOk(addr)
if !ok {
return addrs.MakeSet[addrs.RootProviderConfig]()
}
return component.RequiredProviderInstances()
}

@ -0,0 +1,244 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package stackruntime
import (
"context"
"encoding/json"
"fmt"
"path"
"sort"
"testing"
"time"
"github.com/google/go-cmp/cmp"
"github.com/zclconf/go-cty-debug/ctydebug"
"github.com/zclconf/go-cty/cty"
"google.golang.org/protobuf/types/known/anypb"
"github.com/hashicorp/terraform/internal/addrs"
terraformProvider "github.com/hashicorp/terraform/internal/builtin/providers/terraform"
"github.com/hashicorp/terraform/internal/providers"
"github.com/hashicorp/terraform/internal/stacks/stackaddrs"
"github.com/hashicorp/terraform/internal/stacks/stackplan"
"github.com/hashicorp/terraform/internal/stacks/stackstate"
"github.com/hashicorp/terraform/internal/states"
"github.com/hashicorp/terraform/internal/tfdiags"
)
func TestApplyWithRemovedResource(t *testing.T) {
fakePlanTimestamp, err := time.Parse(time.RFC3339, "1994-09-05T08:50:00Z")
if err != nil {
t.Fatal(err)
}
attrs := map[string]interface{}{
"id": "FE1D5830765C",
"input": map[string]interface{}{
"value": "hello",
"type": "string",
},
"output": map[string]interface{}{
"value": nil,
"type": "string",
},
"triggers_replace": nil,
}
attrsJSON, err := json.Marshal(attrs)
if err != nil {
t.Fatal(err)
}
ctx := context.Background()
cfg := loadMainBundleConfigForTest(t, path.Join("empty-component", "valid-providers"))
planReq := PlanRequest{
Config: cfg,
ProviderFactories: map[addrs.Provider]providers.Factory{
addrs.NewBuiltInProvider("terraform"): func() (providers.Interface, error) {
return terraformProvider.NewProvider(), nil
},
},
ForcePlanTimestamp: &fakePlanTimestamp,
// PrevState specifies a state with a resource that is not present in
// the current configuration. This is a common situation when a resource
// is removed from the configuration but still exists in the state.
PrevState: stackstate.NewStateBuilder().
AddResourceInstance(stackstate.NewResourceInstanceBuilder().
SetAddr(stackaddrs.AbsResourceInstanceObject{
Component: stackaddrs.AbsComponentInstance{
Stack: stackaddrs.RootStackInstance,
Item: stackaddrs.ComponentInstance{
Component: stackaddrs.Component{
Name: "self",
},
Key: addrs.NoKey,
},
},
Item: addrs.AbsResourceInstanceObject{
ResourceInstance: addrs.AbsResourceInstance{
Module: addrs.RootModuleInstance,
Resource: addrs.ResourceInstance{
Resource: addrs.Resource{
Mode: addrs.ManagedResourceMode,
Type: "terraform_data",
Name: "main",
},
Key: addrs.NoKey,
},
},
DeposedKey: addrs.NotDeposed,
},
}).
SetResourceInstanceObjectSrc(states.ResourceInstanceObjectSrc{
SchemaVersion: 0,
AttrsJSON: attrsJSON,
Status: states.ObjectReady,
}).
SetProviderAddr(addrs.AbsProviderConfig{
Module: addrs.RootModule,
Provider: addrs.MustParseProviderSourceString("terraform.io/builtin/terraform"),
})).
Build(),
}
planChangesCh := make(chan stackplan.PlannedChange)
diagsCh := make(chan tfdiags.Diagnostic)
planResp := PlanResponse{
PlannedChanges: planChangesCh,
Diagnostics: diagsCh,
}
go Plan(ctx, &planReq, &planResp)
planChanges, diags := collectPlanOutput(planChangesCh, diagsCh)
if len(diags) > 0 {
t.Fatalf("expected no diagnostics, go %s", diags.ErrWithWarnings())
}
var raw []*anypb.Any
for _, change := range planChanges {
proto, err := change.PlannedChangeProto()
if err != nil {
t.Fatal(err)
}
raw = append(raw, proto.Raw...)
}
applyReq := ApplyRequest{
Config: cfg,
RawPlan: raw,
ProviderFactories: map[addrs.Provider]providers.Factory{
addrs.NewBuiltInProvider("terraform"): func() (providers.Interface, error) {
return terraformProvider.NewProvider(), nil
},
},
}
applyChangesCh := make(chan stackstate.AppliedChange)
diagsCh = make(chan tfdiags.Diagnostic)
applyResp := ApplyResponse{
Complete: false,
AppliedChanges: applyChangesCh,
Diagnostics: diagsCh,
}
go Apply(ctx, &applyReq, &applyResp)
applyChanges, applyDiags := collectApplyOutput(applyChangesCh, diagsCh)
if len(applyDiags) > 0 {
t.Fatalf("expected no diagnostics, got %s", applyDiags.ErrWithWarnings())
}
if len(applyChanges) != 2 {
t.Fatalf("expected 2 applied changes, got %d", len(applyChanges))
}
wantChanges := []stackstate.AppliedChange{
&stackstate.AppliedChangeComponentInstance{
ComponentAddr: stackaddrs.AbsComponent{
Item: stackaddrs.Component{
Name: "self",
},
},
ComponentInstanceAddr: stackaddrs.AbsComponentInstance{
Item: stackaddrs.ComponentInstance{
Component: stackaddrs.Component{
Name: "self",
},
},
},
OutputValues: make(map[addrs.OutputValue]cty.Value),
},
&stackstate.AppliedChangeResourceInstanceObject{
ResourceInstanceObjectAddr: stackaddrs.AbsResourceInstanceObject{
Component: stackaddrs.AbsComponentInstance{
Item: stackaddrs.ComponentInstance{
Component: stackaddrs.Component{
Name: "self",
},
},
},
Item: addrs.AbsResourceInstanceObject{
ResourceInstance: addrs.AbsResourceInstance{
Resource: addrs.ResourceInstance{
Resource: addrs.Resource{
Mode: addrs.ManagedResourceMode,
Type: "terraform_data",
Name: "main",
},
},
},
},
},
NewStateSrc: nil, // Deleted, so is nil.
ProviderConfigAddr: addrs.AbsProviderConfig{
Provider: addrs.Provider{
Type: "terraform",
Namespace: "builtin",
Hostname: "terraform.io",
},
},
},
}
sort.SliceStable(applyChanges, func(i, j int) bool {
// An arbitrary sort just to make the result stable for comparison.
return fmt.Sprintf("%T", applyChanges[i]) < fmt.Sprintf("%T", applyChanges[j])
})
if diff := cmp.Diff(wantChanges, applyChanges, ctydebug.CmpOptions, cmpCollectionsSet); diff != "" {
t.Errorf("wrong changes\n%s", diff)
}
}
func collectApplyOutput(changesCh <-chan stackstate.AppliedChange, diagsCh <-chan tfdiags.Diagnostic) ([]stackstate.AppliedChange, tfdiags.Diagnostics) {
var changes []stackstate.AppliedChange
var diags tfdiags.Diagnostics
for {
select {
case change, ok := <-changesCh:
if !ok {
// The plan operation is complete but we might still have
// some buffered diagnostics to consume.
if diagsCh != nil {
for diag := range diagsCh {
diags = append(diags, diag)
}
}
return changes, diags
}
changes = append(changes, change)
case diag, ok := <-diagsCh:
if !ok {
// no more diagnostics to read
diagsCh = nil
continue
}
diags = append(diags, diag)
}
}
}

@ -220,8 +220,30 @@ func (c *ComponentInstance) CheckProviders(ctx context.Context, phase EvalPhase)
stack := c.call.Stack(ctx)
stackConfig := stack.StackConfig(ctx)
declConfigs := c.call.Declaration(ctx).ProviderConfigs
neededConfigs := c.call.Config(ctx).RequiredProviderInstances(ctx)
for _, inCalleeAddr := range neededConfigs {
// We'll iterate over the set of providers actually required for this
// operation, and make sure they have been properly declared and are
// available.
// First, gather all the providers implied by the configuration.
configProviders := c.call.Config(ctx).RequiredProviderInstances(ctx)
// Second, we also need to add any providers that were required by the
// component's previous runs and have since been removed from the config.
previousProviders := c.main.PreviousProviderInstances(c.Addr(), phase)
neededProviders := configProviders.Union(previousProviders)
for _, inCalleeAddr := range neededProviders {
providerContextString := "requires"
if !configProviders.Has(inCalleeAddr) {
// This provider was required by the previous state but is no
// longer required by the configuration, so we'll add a bit of
// extra context to the diagnostic to help the user understand
// what's going on.
providerContextString = "has resources in state that require"
}
// declConfigs is based on _local_ provider references so we'll
// need to translate based on the stack configuration's
// required_providers block.
@ -237,8 +259,8 @@ func (c *ComponentInstance) CheckProviders(ctx context.Context, phase EvalPhase)
Severity: hcl.DiagError,
Summary: "Component requires undeclared provider",
Detail: fmt.Sprintf(
"The root module for %s requires a configuration for provider %q, which isn't declared as a dependency of this stack configuration.\n\nDeclare this provider in the stack's required_providers block, and then assign a configuration for that provider in this component's \"providers\" argument.",
c.Addr(), typeAddr.ForDisplay(),
"The root module for %s %s a configuration for provider %q, which isn't declared as a dependency of this stack configuration.\n\nDeclare this provider in the stack's required_providers block, and then assign a configuration for that provider in this component's \"providers\" argument.",
c.Addr(), providerContextString, typeAddr.ForDisplay(),
),
Subject: c.call.Declaration(ctx).DeclRange.ToHCL().Ptr(),
})
@ -254,8 +276,8 @@ func (c *ComponentInstance) CheckProviders(ctx context.Context, phase EvalPhase)
Severity: hcl.DiagError,
Summary: "Missing required provider configuration",
Detail: fmt.Sprintf(
"The root module for %s requires provider configuration named %q for provider %q, which is not assigned in the component's \"providers\" argument.",
c.Addr(), localAddr.StringCompact(), typeAddr.ForDisplay(),
"The root module for %s %s a provider configuration named %q for provider %q, which is not assigned in the component's \"providers\" argument.",
c.Addr(), providerContextString, localAddr.StringCompact(), typeAddr.ForDisplay(),
),
Subject: c.call.Declaration(ctx).DeclRange.ToHCL().Ptr(),
})

@ -418,6 +418,27 @@ func (m *Main) ProviderInstance(ctx context.Context, addr stackaddrs.AbsProvider
return insts[addr.Item.Key]
}
// PreviousProviderInstances fetches the set of providers that are required
// based on the current plan or state file. They are previous in the sense that
// they're not based on the current config. So if a provider has been removed
// from the config, this function will still find it.
func (m *Main) PreviousProviderInstances(addr stackaddrs.AbsComponentInstance, phase EvalPhase) addrs.Set[addrs.RootProviderConfig] {
switch phase {
case ApplyPhase:
return m.PlanBeingApplied().RequiredProviderInstances(addr)
case PlanPhase:
return m.PlanPrevState().RequiredProviderInstances(addr)
case InspectPhase:
return m.InspectingState().RequiredProviderInstances(addr)
default:
// We don't have the required information (like a plan or a state file)
// in the other phases so we can't do anything even if we wanted to.
// In general, for the other phases we're not doing anything with the
// previous provider instances anyway, so we don't need them.
return addrs.MakeSet[addrs.RootProviderConfig]()
}
}
func (m *Main) RootVariableValue(ctx context.Context, addr stackaddrs.InputVariable, phase EvalPhase) ExternalInputValue {
switch phase {
case PlanPhase:

@ -5,8 +5,11 @@ package stackruntime
import (
"context"
"encoding/json"
"fmt"
"path"
"sort"
"strings"
"testing"
"time"
@ -24,6 +27,8 @@ import (
"github.com/hashicorp/terraform/internal/providers"
"github.com/hashicorp/terraform/internal/stacks/stackaddrs"
"github.com/hashicorp/terraform/internal/stacks/stackplan"
"github.com/hashicorp/terraform/internal/stacks/stackstate"
"github.com/hashicorp/terraform/internal/states"
"github.com/hashicorp/terraform/internal/terraform"
"github.com/hashicorp/terraform/internal/tfdiags"
"github.com/hashicorp/terraform/version"
@ -614,6 +619,117 @@ func TestPlanWithProviderConfig(t *testing.T) {
}
})
}
func TestPlanWithRemovedResource(t *testing.T) {
fakePlanTimestamp, err := time.Parse(time.RFC3339, "1994-09-05T08:50:00Z")
if err != nil {
t.Fatal(err)
}
attrs := map[string]interface{}{
"id": "FE1D5830765C",
"input": map[string]interface{}{
"value": "hello",
"type": "string",
},
"output": map[string]interface{}{
"value": nil,
"type": "string",
},
"triggers_replace": nil,
}
attrsJSON, err := json.Marshal(attrs)
if err != nil {
t.Fatal(err)
}
// We want to see that it's adding the extra context for when a provider is
// missing for a resource that's in state and not in config.
expectedDiagnostic := "has resources in state that"
tcs := make(map[string]*string)
tcs["missing-providers"] = &expectedDiagnostic
tcs["valid-providers"] = nil
for name, diag := range tcs {
t.Run(name, func(t *testing.T) {
ctx := context.Background()
cfg := loadMainBundleConfigForTest(t, path.Join("empty-component", name))
req := PlanRequest{
Config: cfg,
ProviderFactories: map[addrs.Provider]providers.Factory{
addrs.NewBuiltInProvider("terraform"): func() (providers.Interface, error) {
return terraformProvider.NewProvider(), nil
},
},
ForcePlanTimestamp: &fakePlanTimestamp,
// PrevState specifies a state with a resource that is not present in
// the current configuration. This is a common situation when a resource
// is removed from the configuration but still exists in the state.
PrevState: stackstate.NewStateBuilder().
AddResourceInstance(stackstate.NewResourceInstanceBuilder().
SetAddr(stackaddrs.AbsResourceInstanceObject{
Component: stackaddrs.AbsComponentInstance{
Stack: stackaddrs.RootStackInstance,
Item: stackaddrs.ComponentInstance{
Component: stackaddrs.Component{
Name: "self",
},
Key: addrs.NoKey,
},
},
Item: addrs.AbsResourceInstanceObject{
ResourceInstance: addrs.AbsResourceInstance{
Module: addrs.RootModuleInstance,
Resource: addrs.ResourceInstance{
Resource: addrs.Resource{
Mode: addrs.ManagedResourceMode,
Type: "terraform_data",
Name: "main",
},
Key: addrs.NoKey,
},
},
DeposedKey: addrs.NotDeposed,
},
}).
SetResourceInstanceObjectSrc(states.ResourceInstanceObjectSrc{
SchemaVersion: 0,
AttrsJSON: attrsJSON,
Status: states.ObjectReady,
}).
SetProviderAddr(addrs.AbsProviderConfig{
Module: addrs.RootModule,
Provider: addrs.MustParseProviderSourceString("terraform.io/builtin/terraform"),
})).
Build(),
}
changesCh := make(chan stackplan.PlannedChange)
diagsCh := make(chan tfdiags.Diagnostic)
resp := PlanResponse{
PlannedChanges: changesCh,
Diagnostics: diagsCh,
}
go Plan(ctx, &req, &resp)
_, diags := collectPlanOutput(changesCh, diagsCh)
if diag != nil {
if len(diags) == 0 {
t.Fatalf("expected diagnostics, got none")
}
if !strings.Contains(diags[0].Description().Detail, *diag) {
t.Fatalf("expected diagnostic %q, got %q", *diag, diags[0].Description().Detail)
}
} else if len(diags) > 0 {
t.Fatalf("unexpected diagnostics: %s", diags.ErrWithWarnings().Error())
}
})
}
}
// collectPlanOutput consumes the two output channels emitting results from
// a call to [Plan], and collects all of the data written to them before

@ -0,0 +1,7 @@
terraform {
required_providers {
terraform = {
source = "terraform.io/builtin/terraform"
}
}
}

@ -0,0 +1,13 @@
required_providers {
terraform = {
source = "terraform.io/builtin/terraform"
}
}
provider "terraform" "default" {}
component "self" {
source = "../"
providers = {}
}

@ -0,0 +1,15 @@
required_providers {
terraform = {
source = "terraform.io/builtin/terraform"
}
}
provider "terraform" "default" {}
component "self" {
source = "../"
providers = {
terraform = provider.terraform.default
}
}

@ -0,0 +1,19 @@
terraform {
required_providers {
terraform = {
source = "terraform.io/builtin/terraform"
}
}
}
resource "terraform_data" "main" {
input = "hello"
}
output "input" {
value = terraform_data.main.input
}
output "output" {
value = terraform_data.main.output
}

@ -0,0 +1,22 @@
required_providers {
terraform = {
source = "terraform.io/builtin/terraform"
}
}
provider "terraform" "default" {
}
component "self" {
source = "./"
providers = {}
}
output "obj" {
type = object({
input = string
output = string
})
value = component.self
}

@ -4,12 +4,13 @@
package stackstate
import (
"google.golang.org/protobuf/types/known/anypb"
"github.com/hashicorp/terraform/internal/addrs"
"github.com/hashicorp/terraform/internal/collections"
"github.com/hashicorp/terraform/internal/stacks/stackaddrs"
"github.com/hashicorp/terraform/internal/stacks/stackstate/statekeys"
"github.com/hashicorp/terraform/internal/states"
"google.golang.org/protobuf/types/known/anypb"
)
// State represents a previous run's state snapshot.
@ -118,6 +119,30 @@ func (s *State) ResourceInstanceObjectSrc(addr stackaddrs.AbsResourceInstanceObj
return rios.src
}
// RequiredProviderInstances returns a description of all of the provider
// instance slots that are required to satisfy the resource instances
// belonging to the given component instance.
//
// See also stackeval.ComponentConfig.RequiredProviderInstances for a similar
// function that operates on the configuration of a component instance rather
// than the state of one.
func (s *State) RequiredProviderInstances(component stackaddrs.AbsComponentInstance) addrs.Set[addrs.RootProviderConfig] {
state, ok := s.componentInstances.GetOk(component)
if !ok {
// Then we have no state for this component, which is fine.
return addrs.MakeSet[addrs.RootProviderConfig]()
}
providerInstances := addrs.MakeSet[addrs.RootProviderConfig]()
for _, elem := range state.resourceInstanceObjects.Elems {
providerInstances.Add(addrs.RootProviderConfig{
Provider: elem.Value.providerConfigAddr.Provider,
Alias: elem.Value.providerConfigAddr.Alias,
})
}
return providerInstances
}
func (s *State) resourceInstanceObjectState(addr stackaddrs.AbsResourceInstanceObject) *resourceInstanceObjectState {
cs, ok := s.componentInstances.GetOk(addr.Component)
if !ok {

@ -0,0 +1,68 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package stackstate
import (
"github.com/hashicorp/terraform/internal/addrs"
"github.com/hashicorp/terraform/internal/stacks/stackaddrs"
"github.com/hashicorp/terraform/internal/states"
)
// StateBuilder wraps State, and provides some write-only methods to update the
// state.
//
// This is generally used to build up a new state from scratch during tests.
type StateBuilder struct {
state *State
}
func NewStateBuilder() *StateBuilder {
return &StateBuilder{
state: NewState(),
}
}
// Build returns the state and invalidates the StateBuilder.
//
// You will get nil pointer exceptions if you attempt to use the builder after
// calling Build.
func (s *StateBuilder) Build() *State {
ret := s.state
s.state = nil
return ret
}
// AddResourceInstance adds a resource instance to the state.
func (s *StateBuilder) AddResourceInstance(builder *ResourceInstanceBuilder) *StateBuilder {
if builder.addr == nil || builder.src == nil || builder.providerAddr == nil {
panic("ResourceInstanceBuilder is missing required fields")
}
s.state.addResourceInstanceObject(*builder.addr, builder.src, *builder.providerAddr)
return s
}
type ResourceInstanceBuilder struct {
addr *stackaddrs.AbsResourceInstanceObject
src *states.ResourceInstanceObjectSrc
providerAddr *addrs.AbsProviderConfig
}
func NewResourceInstanceBuilder() *ResourceInstanceBuilder {
return &ResourceInstanceBuilder{}
}
func (b *ResourceInstanceBuilder) SetAddr(addr stackaddrs.AbsResourceInstanceObject) *ResourceInstanceBuilder {
b.addr = &addr
return b
}
func (b *ResourceInstanceBuilder) SetResourceInstanceObjectSrc(src states.ResourceInstanceObjectSrc) *ResourceInstanceBuilder {
b.src = &src
return b
}
func (b *ResourceInstanceBuilder) SetProviderAddr(addr addrs.AbsProviderConfig) *ResourceInstanceBuilder {
b.providerAddr = &addr
return b
}
Loading…
Cancel
Save