mirror of https://github.com/hashicorp/terraform
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
342 lines
11 KiB
342 lines
11 KiB
// Copyright (c) HashiCorp, Inc.
|
|
// SPDX-License-Identifier: BUSL-1.1
|
|
|
|
package stackruntime
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"sort"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/go-cmp/cmp"
|
|
"github.com/hashicorp/terraform/internal/addrs"
|
|
terraformProvider "github.com/hashicorp/terraform/internal/builtin/providers/terraform"
|
|
"github.com/hashicorp/terraform/internal/configs/configschema"
|
|
"github.com/hashicorp/terraform/internal/plans"
|
|
"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/terraform"
|
|
"github.com/hashicorp/terraform/internal/tfdiags"
|
|
"github.com/hashicorp/terraform/version"
|
|
"github.com/zclconf/go-cty-debug/ctydebug"
|
|
"github.com/zclconf/go-cty/cty"
|
|
)
|
|
|
|
func TestPlanWithSingleResource(t *testing.T) {
|
|
ctx := context.Background()
|
|
cfg := loadMainBundleConfigForTest(t, "with-single-resource")
|
|
|
|
fakePlanTimestamp, err := time.Parse(time.RFC3339, "1994-09-05T08:50:00Z")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
changesCh := make(chan stackplan.PlannedChange, 8)
|
|
diagsCh := make(chan tfdiags.Diagnostic, 2)
|
|
req := PlanRequest{
|
|
Config: cfg,
|
|
ProviderFactories: map[addrs.Provider]providers.Factory{
|
|
addrs.NewBuiltInProvider("terraform"): func() (providers.Interface, error) {
|
|
return terraformProvider.NewProvider(), nil
|
|
},
|
|
},
|
|
|
|
ForcePlanTimestamp: &fakePlanTimestamp,
|
|
}
|
|
resp := PlanResponse{
|
|
PlannedChanges: changesCh,
|
|
Diagnostics: diagsCh,
|
|
}
|
|
|
|
go Plan(ctx, &req, &resp)
|
|
gotChanges, diags := collectPlanOutput(changesCh, diagsCh)
|
|
|
|
if len(diags) != 0 {
|
|
t.Errorf("unexpected diagnostics\n%s", diags.ErrWithWarnings().Error())
|
|
}
|
|
|
|
// The order of emission for our planned changes is unspecified since it
|
|
// depends on how the various goroutines get scheduled, and so we'll
|
|
// arbitrarily sort gotChanges lexically by the name of the change type
|
|
// so that we have some dependable order to diff against below.
|
|
sort.Slice(gotChanges, func(i, j int) bool {
|
|
ic := gotChanges[i]
|
|
jc := gotChanges[j]
|
|
return fmt.Sprintf("%T", ic) < fmt.Sprintf("%T", jc)
|
|
})
|
|
|
|
wantChanges := []stackplan.PlannedChange{
|
|
&stackplan.PlannedChangeApplyable{
|
|
Applyable: true,
|
|
},
|
|
&stackplan.PlannedChangeComponentInstance{
|
|
Addr: stackaddrs.Absolute(
|
|
stackaddrs.RootStackInstance,
|
|
stackaddrs.ComponentInstance{
|
|
Component: stackaddrs.Component{Name: "self"},
|
|
},
|
|
),
|
|
Action: plans.Create,
|
|
PlannedInputValues: make(map[string]plans.DynamicValue),
|
|
PlanTimestamp: fakePlanTimestamp,
|
|
},
|
|
&stackplan.PlannedChangeHeader{
|
|
TerraformVersion: version.SemVer,
|
|
},
|
|
&stackplan.PlannedChangeOutputValue{
|
|
Addr: stackaddrs.OutputValue{Name: "obj"},
|
|
Action: plans.Create,
|
|
OldValue: mustPlanDynamicValue(cty.NullVal(cty.DynamicPseudoType)),
|
|
NewValue: mustPlanDynamicValue(cty.ObjectVal(map[string]cty.Value{
|
|
"input": cty.StringVal("hello"),
|
|
"output": cty.UnknownVal(cty.String),
|
|
})),
|
|
},
|
|
&stackplan.PlannedChangeResourceInstancePlanned{
|
|
ResourceInstanceObjectAddr: stackaddrs.AbsResourceInstanceObject{
|
|
Component: stackaddrs.Absolute(
|
|
stackaddrs.RootStackInstance,
|
|
stackaddrs.ComponentInstance{
|
|
Component: stackaddrs.Component{Name: "self"},
|
|
},
|
|
),
|
|
Item: addrs.AbsResourceInstanceObject{
|
|
ResourceInstance: addrs.Resource{
|
|
Mode: addrs.ManagedResourceMode,
|
|
Type: "terraform_data",
|
|
Name: "main",
|
|
}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance),
|
|
},
|
|
},
|
|
ProviderConfigAddr: addrs.AbsProviderConfig{
|
|
Module: addrs.RootModule,
|
|
Provider: addrs.MustParseProviderSourceString("terraform.io/builtin/terraform"),
|
|
},
|
|
ChangeSrc: &plans.ResourceInstanceChangeSrc{
|
|
Addr: addrs.Resource{
|
|
Mode: addrs.ManagedResourceMode,
|
|
Type: "terraform_data",
|
|
Name: "main",
|
|
}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance),
|
|
PrevRunAddr: addrs.Resource{
|
|
Mode: addrs.ManagedResourceMode,
|
|
Type: "terraform_data",
|
|
Name: "main",
|
|
}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance),
|
|
ProviderAddr: addrs.AbsProviderConfig{
|
|
Module: addrs.RootModule,
|
|
Provider: addrs.NewBuiltInProvider("terraform"),
|
|
},
|
|
ChangeSrc: plans.ChangeSrc{
|
|
Action: plans.Create,
|
|
Before: mustPlanDynamicValue(cty.NullVal(cty.DynamicPseudoType)),
|
|
After: plans.DynamicValue{
|
|
// This is an object conforming to the terraform_data
|
|
// resource type's schema.
|
|
//
|
|
// FIXME: Should write this a different way that is
|
|
// scrutable and won't break each time something gets
|
|
// added to the terraform_data schema. (We can't use
|
|
// mustPlanDynamicValue here because the resource type
|
|
// uses DynamicPseudoType attributes, which require
|
|
// explicitly-typed encoding.)
|
|
0x84, 0xa2, 0x69, 0x64, 0xc7, 0x03, 0x0c, 0x81,
|
|
0x01, 0xc2, 0xa5, 0x69, 0x6e, 0x70, 0x75, 0x74,
|
|
0x92, 0xc4, 0x08, 0x22, 0x73, 0x74, 0x72, 0x69,
|
|
0x6e, 0x67, 0x22, 0xa5, 0x68, 0x65, 0x6c, 0x6c,
|
|
0x6f, 0xa6, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74,
|
|
0x92, 0xc4, 0x08, 0x22, 0x73, 0x74, 0x72, 0x69,
|
|
0x6e, 0x67, 0x22, 0xd4, 0x00, 0x00, 0xb0, 0x74,
|
|
0x72, 0x69, 0x67, 0x67, 0x65, 0x72, 0x73, 0x5f,
|
|
0x72, 0x65, 0x70, 0x6c, 0x61, 0x63, 0x65, 0xc0,
|
|
},
|
|
},
|
|
},
|
|
|
|
// The following is schema for the real terraform_data resource
|
|
// type from the real terraform.io/builtin/terraform provider
|
|
// maintained elsewhere in this codebase. If that schema changes
|
|
// in future then this should change to match it.
|
|
Schema: &configschema.Block{
|
|
Attributes: map[string]*configschema.Attribute{
|
|
"input": {Type: cty.DynamicPseudoType, Optional: true},
|
|
"output": {Type: cty.DynamicPseudoType, Computed: true},
|
|
"triggers_replace": {Type: cty.DynamicPseudoType, Optional: true},
|
|
"id": {Type: cty.String, Computed: true},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
if diff := cmp.Diff(wantChanges, gotChanges, ctydebug.CmpOptions); diff != "" {
|
|
t.Errorf("wrong changes\n%s", diff)
|
|
}
|
|
}
|
|
|
|
func TestPlanVariableOutputRoundtripNested(t *testing.T) {
|
|
ctx := context.Background()
|
|
cfg := loadMainBundleConfigForTest(t, "variable-output-roundtrip-nested")
|
|
|
|
changesCh := make(chan stackplan.PlannedChange, 8)
|
|
diagsCh := make(chan tfdiags.Diagnostic, 2)
|
|
req := PlanRequest{
|
|
Config: cfg,
|
|
}
|
|
resp := PlanResponse{
|
|
PlannedChanges: changesCh,
|
|
Diagnostics: diagsCh,
|
|
}
|
|
|
|
go Plan(ctx, &req, &resp)
|
|
gotChanges, diags := collectPlanOutput(changesCh, diagsCh)
|
|
|
|
if len(diags) != 0 {
|
|
t.Errorf("unexpected diagnostics\n%s", diags.ErrWithWarnings().Error())
|
|
}
|
|
|
|
wantChanges := []stackplan.PlannedChange{
|
|
&stackplan.PlannedChangeApplyable{
|
|
Applyable: true,
|
|
},
|
|
&stackplan.PlannedChangeHeader{
|
|
TerraformVersion: version.SemVer,
|
|
},
|
|
&stackplan.PlannedChangeOutputValue{
|
|
Addr: stackaddrs.OutputValue{Name: "msg"},
|
|
Action: plans.Create,
|
|
OldValue: plans.DynamicValue{0xc0}, // MessagePack nil
|
|
NewValue: plans.DynamicValue([]byte("\xa7default")), // MessagePack string "default"
|
|
},
|
|
&stackplan.PlannedChangeRootInputValue{
|
|
Addr: stackaddrs.InputVariable{
|
|
Name: "msg",
|
|
},
|
|
Value: cty.NullVal(cty.String),
|
|
},
|
|
}
|
|
sort.SliceStable(gotChanges, func(i, j int) bool {
|
|
// An arbitrary sort just to make the result stable for comparison.
|
|
return fmt.Sprintf("%T", gotChanges[i]) < fmt.Sprintf("%T", gotChanges[j])
|
|
})
|
|
|
|
if diff := cmp.Diff(wantChanges, gotChanges, ctydebug.CmpOptions); diff != "" {
|
|
t.Errorf("wrong changes\n%s", diff)
|
|
}
|
|
}
|
|
|
|
func TestPlanWithProviderConfig(t *testing.T) {
|
|
ctx := context.Background()
|
|
cfg := loadMainBundleConfigForTest(t, "with-provider-config")
|
|
providerAddr := addrs.MustParseProviderSourceString("example.com/test/test")
|
|
providerSchema := &providers.GetProviderSchemaResponse{
|
|
Provider: providers.Schema{
|
|
Block: &configschema.Block{
|
|
Attributes: map[string]*configschema.Attribute{
|
|
"name": {
|
|
Type: cty.String,
|
|
Required: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
inputVarAddr := stackaddrs.InputVariable{Name: "name"}
|
|
fakeSrcRng := tfdiags.SourceRange{
|
|
Filename: "fake-source",
|
|
}
|
|
|
|
t.Run("valid", func(t *testing.T) {
|
|
changesCh := make(chan stackplan.PlannedChange, 8)
|
|
diagsCh := make(chan tfdiags.Diagnostic, 2)
|
|
|
|
// FIXME: The MockProvider type is still lurking in
|
|
// the terraform package; it would make more sense for
|
|
// it to be providers.Mock, in the providers package.
|
|
provider := &terraform.MockProvider{
|
|
GetProviderSchemaResponse: providerSchema,
|
|
ValidateProviderConfigResponse: &providers.ValidateProviderConfigResponse{},
|
|
ConfigureProviderResponse: &providers.ConfigureProviderResponse{},
|
|
}
|
|
|
|
req := PlanRequest{
|
|
Config: cfg,
|
|
InputValues: map[stackaddrs.InputVariable]ExternalInputValue{
|
|
inputVarAddr: {
|
|
Value: cty.StringVal("Jackson"),
|
|
DefRange: fakeSrcRng,
|
|
},
|
|
},
|
|
ProviderFactories: map[addrs.Provider]providers.Factory{
|
|
providerAddr: func() (providers.Interface, error) {
|
|
return provider, nil
|
|
},
|
|
},
|
|
}
|
|
resp := PlanResponse{
|
|
PlannedChanges: changesCh,
|
|
Diagnostics: diagsCh,
|
|
}
|
|
go Plan(ctx, &req, &resp)
|
|
_, diags := collectPlanOutput(changesCh, diagsCh)
|
|
if len(diags) != 0 {
|
|
t.Errorf("unexpected diagnostics\n%s", diags.ErrWithWarnings().Error())
|
|
}
|
|
|
|
if !provider.ValidateProviderConfigCalled {
|
|
t.Error("ValidateProviderConfig wasn't called")
|
|
} else {
|
|
req := provider.ValidateProviderConfigRequest
|
|
if got, want := req.Config.GetAttr("name"), cty.StringVal("Jackson"); !got.RawEquals(want) {
|
|
t.Errorf("wrong name in ValidateProviderConfig\ngot: %#v\nwant: %#v", got, want)
|
|
}
|
|
}
|
|
if !provider.ConfigureProviderCalled {
|
|
t.Error("ConfigureProvider wasn't called")
|
|
} else {
|
|
req := provider.ConfigureProviderRequest
|
|
if got, want := req.Config.GetAttr("name"), cty.StringVal("Jackson"); !got.RawEquals(want) {
|
|
t.Errorf("wrong name in ConfigureProvider\ngot: %#v\nwant: %#v", got, want)
|
|
}
|
|
}
|
|
if !provider.CloseCalled {
|
|
t.Error("provider wasn't closed")
|
|
}
|
|
})
|
|
}
|
|
|
|
// collectPlanOutput consumes the two output channels emitting results from
|
|
// a call to [Plan], and collects all of the data written to them before
|
|
// returning once changesCh has been closed by the sender to indicate that
|
|
// the planning process is complete.
|
|
func collectPlanOutput(changesCh <-chan stackplan.PlannedChange, diagsCh <-chan tfdiags.Diagnostic) ([]stackplan.PlannedChange, tfdiags.Diagnostics) {
|
|
var changes []stackplan.PlannedChange
|
|
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)
|
|
}
|
|
}
|
|
}
|