From 6e72db4e6f3bb96d2c242e7b7f46844b87e5e260 Mon Sep 17 00:00:00 2001 From: Sarah French Date: Wed, 26 Nov 2025 18:29:15 +0000 Subject: [PATCH 1/4] test: Update test helper to set Workspace in planfile description of Backend, so the planfile's Backend struct isn't flagged as empty. --- internal/command/command_test.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/internal/command/command_test.go b/internal/command/command_test.go index 2c0486dca33c..7c0609388b45 100644 --- a/internal/command/command_test.go +++ b/internal/command/command_test.go @@ -192,8 +192,9 @@ func testPlan(t *testing.T) *plans.Plan { // This is just a placeholder so that the plan file can be written // out. Caller may wish to override it to something more "real" // where the plan will actually be subsequently applied. - Type: "local", - Config: backendConfigRaw, + Type: "local", + Config: backendConfigRaw, + Workspace: "default", }, Changes: plans.NewChangesSrc(), From 03935d5dc270c7ffc2e7cdc8cf0a072c765d8903 Mon Sep 17 00:00:00 2001 From: Sarah French Date: Thu, 27 Nov 2025 15:01:57 +0000 Subject: [PATCH 2/4] test: Update tests so that all fields in the plan's representation of the Backend config are set --- internal/backend/local/backend_local_test.go | 5 ++- internal/backend/local/backend_plan_test.go | 40 ++++++++++++-------- internal/command/apply_test.go | 5 ++- internal/command/graph_test.go | 5 ++- 4 files changed, 33 insertions(+), 22 deletions(-) diff --git a/internal/backend/local/backend_local_test.go b/internal/backend/local/backend_local_test.go index 2e84d01f6a64..83c0c17cbd9b 100644 --- a/internal/backend/local/backend_local_test.go +++ b/internal/backend/local/backend_local_test.go @@ -161,8 +161,9 @@ func TestLocalRun_stalePlan(t *testing.T) { UIMode: plans.NormalMode, Changes: plans.NewChangesSrc(), Backend: &plans.Backend{ - Type: "local", - Config: backendConfigRaw, + Type: "local", + Config: backendConfigRaw, + Workspace: "default", }, PrevRunState: states.NewState(), PriorState: states.NewState(), diff --git a/internal/backend/local/backend_plan_test.go b/internal/backend/local/backend_plan_test.go index 1f6a10f25024..f7ecb0f6f4bd 100644 --- a/internal/backend/local/backend_plan_test.go +++ b/internal/backend/local/backend_plan_test.go @@ -207,8 +207,9 @@ func TestLocal_planOutputsChanged(t *testing.T) { } op.PlanOutBackend = &plans.Backend{ // Just a placeholder so that we can generate a valid plan file. - Type: "local", - Config: cfgRaw, + Type: "local", + Config: cfgRaw, + Workspace: "default", } run, err := b.Operation(context.Background(), op) if err != nil { @@ -263,8 +264,9 @@ func TestLocal_planModuleOutputsChanged(t *testing.T) { t.Fatal(err) } op.PlanOutBackend = &plans.Backend{ - Type: "local", - Config: cfgRaw, + Type: "local", + Config: cfgRaw, + Workspace: "default", } run, err := b.Operation(context.Background(), op) if err != nil { @@ -305,8 +307,9 @@ func TestLocal_planTainted(t *testing.T) { } op.PlanOutBackend = &plans.Backend{ // Just a placeholder so that we can generate a valid plan file. - Type: "local", - Config: cfgRaw, + Type: "local", + Config: cfgRaw, + Workspace: "default", } run, err := b.Operation(context.Background(), op) if err != nil { @@ -384,8 +387,9 @@ func TestLocal_planDeposedOnly(t *testing.T) { } op.PlanOutBackend = &plans.Backend{ // Just a placeholder so that we can generate a valid plan file. - Type: "local", - Config: cfgRaw, + Type: "local", + Config: cfgRaw, + Workspace: "default", } run, err := b.Operation(context.Background(), op) if err != nil { @@ -475,8 +479,9 @@ func TestLocal_planTainted_createBeforeDestroy(t *testing.T) { } op.PlanOutBackend = &plans.Backend{ // Just a placeholder so that we can generate a valid plan file. - Type: "local", - Config: cfgRaw, + Type: "local", + Config: cfgRaw, + Workspace: "default", } run, err := b.Operation(context.Background(), op) if err != nil { @@ -566,8 +571,9 @@ func TestLocal_planDestroy(t *testing.T) { } op.PlanOutBackend = &plans.Backend{ // Just a placeholder so that we can generate a valid plan file. - Type: "local", - Config: cfgRaw, + Type: "local", + Config: cfgRaw, + Workspace: "default", } run, err := b.Operation(context.Background(), op) @@ -618,8 +624,9 @@ func TestLocal_planDestroy_withDataSources(t *testing.T) { } op.PlanOutBackend = &plans.Backend{ // Just a placeholder so that we can generate a valid plan file. - Type: "local", - Config: cfgRaw, + Type: "local", + Config: cfgRaw, + Workspace: "default", } run, err := b.Operation(context.Background(), op) @@ -690,8 +697,9 @@ func TestLocal_planOutPathNoChange(t *testing.T) { } op.PlanOutBackend = &plans.Backend{ // Just a placeholder so that we can generate a valid plan file. - Type: "local", - Config: cfgRaw, + Type: "local", + Config: cfgRaw, + Workspace: "default", } op.PlanRefresh = true diff --git a/internal/command/apply_test.go b/internal/command/apply_test.go index 2e7e22a8500d..b3c91dbef438 100644 --- a/internal/command/apply_test.go +++ b/internal/command/apply_test.go @@ -1087,8 +1087,9 @@ func TestApply_plan_remoteState(t *testing.T) { } planPath := testPlanFile(t, snap, state, &plans.Plan{ Backend: &plans.Backend{ - Type: "http", - Config: backendConfigRaw, + Type: "http", + Config: backendConfigRaw, + Workspace: "default", }, Changes: plans.NewChangesSrc(), }) diff --git a/internal/command/graph_test.go b/internal/command/graph_test.go index dc98f74c71f7..8193614329de 100644 --- a/internal/command/graph_test.go +++ b/internal/command/graph_test.go @@ -329,8 +329,9 @@ func TestGraph_applyPhaseSavedPlan(t *testing.T) { // Doesn't actually matter since we aren't going to activate the backend // for this command anyway, but we need something here for the plan // file writer to succeed. - Type: "placeholder", - Config: emptyObj, + Type: "placeholder", + Config: emptyObj, + Workspace: "default", } _, configSnap := testModuleWithSnapshot(t, "graph") From 4c76e2ae896d0a0887d5857479119b2a9c0ed067 Mon Sep 17 00:00:00 2001 From: Sarah French Date: Fri, 14 Nov 2025 12:40:37 +0000 Subject: [PATCH 3/4] feat: Add `Validate` method for asserting that a plan's description of a backend or state store is complete The alternative approach would be to change the existing `Backend` field in the `Plan` struct to be a pointer. I'm open to either option, but the approach of using an `Empty` method matches existing work in the `workdir` package when inspecting the backend state file, and that seems a similar use-case to inspecting the plan file. --- internal/plans/plan.go | 61 +++++++++++- internal/plans/plan_test.go | 186 +++++++++++++++++++++++++++++++++++- 2 files changed, 242 insertions(+), 5 deletions(-) diff --git a/internal/plans/plan.go b/internal/plans/plan.go index 0995c0f1caf3..9fe4f0c84731 100644 --- a/internal/plans/plan.go +++ b/internal/plans/plan.go @@ -4,6 +4,8 @@ package plans import ( + "errors" + "fmt" "sort" "time" @@ -256,6 +258,22 @@ func NewBackend(typeName string, config cty.Value, configSchema *configschema.Bl }, nil } +func (b *Backend) Validate() error { + if b == nil { + return errors.New("plan contains a nil Backend") + } + if b.Type == "" { + return fmt.Errorf("plan's description of a backend has an unset Type: %#v", b) + } + if b.Workspace == "" { + return fmt.Errorf("plan's description of a backend has the Workspace unset: %#v", b) + } + if len(b.Config) == 0 { + return fmt.Errorf("plan's description of a backend includes no Config: %#v", b) + } + return nil +} + // StateStore represents the state store-related configuration and other data as it // existed when a plan was created. type StateStore struct { @@ -264,8 +282,8 @@ type StateStore struct { Provider *Provider - // Config is the configuration of the state store, whose schema is obtained - // from the host provider's GetProviderSchema response. + // Config is the configuration of the state store, excluding the nested provider block. + // The schema is determined by the state store's type and data received via GetProviderSchema RPC. Config DynamicValue // Workspace is the name of the workspace that was active when the plan @@ -277,15 +295,50 @@ type StateStore struct { Workspace string } +func (s *StateStore) Validate() error { + if s == nil { + return errors.New("plan contains a nil StateStore") + } + if s.Type == "" { + return fmt.Errorf("plan's description of a state store has an unset Type: %#v", s) + } + if len(s.Config) == 0 { + return fmt.Errorf("plan's description of a state store includes no Config: %#v", s) + } + if err := s.Provider.Validate(); err != nil { + return err + } + if s.Workspace == "" { + return fmt.Errorf("plan's description of a state store has an unset Workspace: %#v", s) + } + return nil +} + type Provider struct { Version *version.Version // The specific provider version used for the state store. Should be set using a getproviders.Version, etc. Source *tfaddr.Provider // The FQN/fully-qualified name of the provider. - // Config is the configuration of the state store, whose schema is obtained - // from the host provider's GetProviderSchema response. + // Config is the configuration of the provider block nested within state_store. + // The schema is determined by data received via GetProviderSchema RPC. Config DynamicValue } +func (p *Provider) Validate() error { + if p == nil { + return errors.New("plan's description of a state store contains a nil Provider") + } + if p.Version == nil { + return fmt.Errorf("plan's description of a state store contains a nil provider Version: %#v", p) + } + if p.Source == nil { + return fmt.Errorf("plan's description of a state store contains a nil provider Source: %#v", p) + } + if len(p.Config) == 0 { + return fmt.Errorf("plan's description of a state store includes no provider Config: %#v", p) + } + return nil +} + func NewStateStore(typeName string, ver *version.Version, source *tfaddr.Provider, storeConfig cty.Value, storeSchema *configschema.Block, providerConfig cty.Value, providerSchema *configschema.Block, workspaceName string) (*StateStore, error) { sdv, err := NewDynamicValue(storeConfig, storeSchema.ImpliedType()) if err != nil { diff --git a/internal/plans/plan_test.go b/internal/plans/plan_test.go index 4b23aa7a3301..0d2ee401f012 100644 --- a/internal/plans/plan_test.go +++ b/internal/plans/plan_test.go @@ -7,9 +7,11 @@ import ( "testing" "github.com/go-test/deep" + "github.com/zclconf/go-cty/cty" + version "github.com/hashicorp/go-version" "github.com/hashicorp/terraform/internal/addrs" - "github.com/zclconf/go-cty/cty" + "github.com/hashicorp/terraform/internal/configs/configschema" ) func TestProviderAddrs(t *testing.T) { @@ -105,6 +107,188 @@ func TestProviderAddrs(t *testing.T) { } } +func TestBackend_Validate(t *testing.T) { + + typeName := "foobar" + workspace := "default" + config := cty.ObjectVal(map[string]cty.Value{ + "bool": cty.BoolVal(true), + }) + schema := &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "bool": { + Type: cty.Bool, + }, + }, + } + + // Not-empty cases + t.Run("backend is not valid if all values are set", func(t *testing.T) { + b, err := NewBackend(typeName, config, schema, workspace) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + if err := b.Validate(); err != nil { + t.Fatalf("expected the Backend to be valid, given all input values were provided: %s", err) + } + }) + t.Run("backend is not empty if the schema contains no attributes or blocks", func(t *testing.T) { + emptyConfig := cty.ObjectVal(map[string]cty.Value{}) + emptySchema := &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + // No attributes + }, + } + b, err := NewBackend(typeName, emptyConfig, emptySchema, workspace) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + if err := b.Validate(); err != nil { + t.Fatalf("expected the Backend to be valid, as empty schemas should be tolerated: %s", err) + } + }) + + // Empty cases + t.Run("backend is empty if type name is missing", func(t *testing.T) { + b, err := NewBackend("", config, schema, workspace) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + if err := b.Validate(); err == nil { + t.Fatalf("expected the Backend to be invalid, given the type being unset: %#v", b) + } + }) + t.Run("backend is empty if workspace name is missing", func(t *testing.T) { + b, err := NewBackend(typeName, config, schema, "") + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + if err := b.Validate(); err == nil { + t.Fatalf("expected the Backend to be invalid, given the type being unset: %#v", b) + } + }) +} + +func TestStateStore_Validate(t *testing.T) { + typeName := "test_store" + providerVersion := version.Must(version.NewSemver("1.2.3")) + source := addrs.MustParseProviderSourceString("hashicorp/test") + workspace := "default" + config := cty.ObjectVal(map[string]cty.Value{ + "bool": cty.BoolVal(true), + }) + schema := &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "bool": { + Type: cty.Bool, + }, + }, + } + + // Not-empty cases + t.Run("state store is not empty if all values are set", func(t *testing.T) { + s, err := NewStateStore(typeName, providerVersion, &source, config, schema, config, schema, workspace) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + if err := s.Validate(); err != nil { + t.Fatalf("expected the StateStore to be valid, given all input values were provided: %s", err) + } + }) + t.Run("state store is not empty if the state store config is present but contains all null values", func(t *testing.T) { + nullConfig := cty.ObjectVal(map[string]cty.Value{ + "bool": cty.NullVal(cty.Bool), + }) + s, err := NewStateStore(typeName, providerVersion, &source, nullConfig, schema, config, schema, workspace) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + if err := s.Validate(); err != nil { + t.Fatalf("expected the StateStore to be valid, despite the state store config containing only null values: %s", err) + } + }) + t.Run("state store is not empty if the provider config is present but contains all null values", func(t *testing.T) { + nullConfig := cty.ObjectVal(map[string]cty.Value{ + "bool": cty.NullVal(cty.Bool), + }) + s, err := NewStateStore(typeName, providerVersion, &source, nullConfig, schema, config, schema, workspace) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + if err := s.Validate(); err != nil { + t.Fatalf("expected the StateStore to be valid, despite the provider config containing only null values: %s", err) + } + }) + t.Run("state store is not incorrectly identified as empty if the state store's schema contains no attributes or blocks", func(t *testing.T) { + emptyConfig := cty.ObjectVal(map[string]cty.Value{}) + emptySchema := &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + // No attributes + }, + } + s, err := NewStateStore(typeName, providerVersion, &source, emptyConfig, emptySchema, config, schema, workspace) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + if err := s.Validate(); err != nil { + t.Fatalf("expected the StateStore to be valid, as empty schemas should be tolerated: %s", err) + } + }) + t.Run("state store is not incorrectly identified as empty if the provider's schema contains no attributes or blocks", func(t *testing.T) { + emptyConfig := cty.ObjectVal(map[string]cty.Value{}) + emptySchema := &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + // No attributes + }, + } + s, err := NewStateStore(typeName, providerVersion, &source, config, schema, emptyConfig, emptySchema, workspace) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + if err := s.Validate(); err != nil { + t.Fatalf("expected the StateStore to be valid, as empty schemas should be tolerated: %s", err) + } + }) + + // Empty cases + t.Run("state store is empty if the type is missing", func(t *testing.T) { + s, err := NewStateStore("", providerVersion, &source, config, schema, config, schema, workspace) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + if err := s.Validate(); err == nil { + t.Fatalf("expected the StateStore to be invalid, given the type name is missing: %s", err) + } + }) + t.Run("state store is empty if the provider version is missing", func(t *testing.T) { + s, err := NewStateStore(typeName, nil, &source, config, schema, config, schema, workspace) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + if err := s.Validate(); err == nil { + t.Fatalf("expected the StateStore to be invalid, given the version is missing: %s", err) + } + }) + t.Run("state store is empty if the provider source is missing", func(t *testing.T) { + s, err := NewStateStore(typeName, providerVersion, nil, config, schema, config, schema, workspace) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + if err := s.Validate(); err == nil { + t.Fatalf("expected the StateStore to be invalid, given the version is missing: %s", err) + } + }) + t.Run("state store is empty if the workspace name is missing", func(t *testing.T) { + s, err := NewStateStore(typeName, providerVersion, &source, cty.NilVal, schema, config, schema, "") + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + if err := s.Validate(); err == nil { + t.Fatalf("expected the StateStore to be invalid, given the workspace name is missing: %s", err) + } + }) +} + // Module outputs should not effect the result of Empty func TestModuleOutputChangesEmpty(t *testing.T) { changes := &ChangesSrc{ From 45bac8404d6e2812834094d2bddcb56908ca4905 Mon Sep 17 00:00:00 2001 From: Sarah French Date: Thu, 27 Nov 2025 18:55:32 +0000 Subject: [PATCH 4/4] feat: Make Terraform assert that the description of a backend or state store is complete when reading or writing a plan file --- internal/plans/planfile/tfplan.go | 16 ++ internal/plans/planfile/tfplan_test.go | 231 ++++++++++++++++++++++++- 2 files changed, 246 insertions(+), 1 deletion(-) diff --git a/internal/plans/planfile/tfplan.go b/internal/plans/planfile/tfplan.go index ade7b52dc5cb..ae872a066f77 100644 --- a/internal/plans/planfile/tfplan.go +++ b/internal/plans/planfile/tfplan.go @@ -227,6 +227,10 @@ func readTfplan(r io.Reader) (*plans.Plan, error) { Config: config, Workspace: rawBackend.Workspace, } + err = plan.Backend.Validate() + if err != nil { + return nil, fmt.Errorf("plan describes an invalid backend: %w", err) + } case rawPlan.StateStore != nil: rawStateStore := rawPlan.StateStore @@ -256,6 +260,10 @@ func readTfplan(r io.Reader) (*plans.Plan, error) { Config: storeConfig, Workspace: rawStateStore.Workspace, } + err = plan.StateStore.Validate() + if err != nil { + return nil, fmt.Errorf("plan describes an invalid state store: %w", err) + } } if plan.Timestamp, err = time.Parse(time.RFC3339, rawPlan.Timestamp); err != nil { @@ -755,12 +763,20 @@ func writeTfplan(plan *plans.Plan, w io.Writer) error { // should never have both a backend and state_store populated. return fmt.Errorf("plan contains both backend and state_store configurations, only one is expected") case plan.Backend != nil: + err := plan.Backend.Validate() + if err != nil { + return fmt.Errorf("plan describes an invalid backend: %w", err) + } rawPlan.Backend = &planproto.Backend{ Type: plan.Backend.Type, Config: valueToTfplan(plan.Backend.Config), Workspace: plan.Backend.Workspace, } case plan.StateStore != nil: + err := plan.StateStore.Validate() + if err != nil { + return fmt.Errorf("plan describes an invalid state store: %w", err) + } rawPlan.StateStore = &planproto.StateStore{ Type: plan.StateStore.Type, Provider: &planproto.Provider{ diff --git a/internal/plans/planfile/tfplan_test.go b/internal/plans/planfile/tfplan_test.go index 698bd6b1b341..6942a592a96f 100644 --- a/internal/plans/planfile/tfplan_test.go +++ b/internal/plans/planfile/tfplan_test.go @@ -5,6 +5,7 @@ package planfile import ( "bytes" + "strings" "testing" "github.com/go-test/deep" @@ -165,6 +166,234 @@ func Test_writeTfplan_validation(t *testing.T) { }(), wantWriteErrMsg: "plan contains both backend and state_store configurations, only one is expected", }, + "error when state store lacks a provider source": { + plan: func() *plans.Plan { + rawPlan := examplePlanForTest(t) + // remove backend from example plan + rawPlan.Backend = nil + + // Add state store with missing data + ver, err := version.NewVersion("9.9.9") + if err != nil { + t.Fatalf("error encountered during test setup: %s", err) + } + rawPlan.StateStore = &plans.StateStore{ + Type: "foo_bar", + Provider: &plans.Provider{ + Version: ver, + // Source: omitted + Config: mustNewDynamicValue( + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.StringVal("bar"), + }), + cty.Object(map[string]cty.Type{ + "foo": cty.String, + }), + ), + }, + Config: mustNewDynamicValue( + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.StringVal("bar"), + }), + cty.Object(map[string]cty.Type{ + "foo": cty.String, + }), + ), + Workspace: "default", + } + return rawPlan + }(), + wantWriteErrMsg: "contains a nil provider Source", + }, + "error when state store lacks a provider version": { + plan: func() *plans.Plan { + rawPlan := examplePlanForTest(t) + // remove backend from example plan + rawPlan.Backend = nil + + // Add state store with missing data + rawPlan.StateStore = &plans.StateStore{ + Type: "foo_bar", + Provider: &plans.Provider{ + // Version: omitted + Source: &tfaddr.Provider{ + Hostname: tfaddr.DefaultProviderRegistryHost, + Namespace: "foobar", + Type: "foo", + }, + Config: mustNewDynamicValue( + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.StringVal("bar"), + }), + cty.Object(map[string]cty.Type{ + "foo": cty.String, + }), + ), + }, + Config: mustNewDynamicValue( + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.StringVal("bar"), + }), + cty.Object(map[string]cty.Type{ + "foo": cty.String, + }), + ), + Workspace: "default", + } + return rawPlan + }(), + wantWriteErrMsg: "contains a nil provider Version", + }, + "error when state store lacks provider config": { + plan: func() *plans.Plan { + rawPlan := examplePlanForTest(t) + // remove backend from example plan + rawPlan.Backend = nil + + // Add state store with missing data + ver, err := version.NewVersion("9.9.9") + if err != nil { + t.Fatalf("error encountered during test setup: %s", err) + } + rawPlan.StateStore = &plans.StateStore{ + Type: "foo_bar", + Provider: &plans.Provider{ + Version: ver, + Source: &tfaddr.Provider{ + Hostname: tfaddr.DefaultProviderRegistryHost, + Namespace: "foobar", + Type: "foo", + }, + // Config: omitted + }, + Config: mustNewDynamicValue( + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.StringVal("bar"), + }), + cty.Object(map[string]cty.Type{ + "foo": cty.String, + }), + ), + Workspace: "default", + } + return rawPlan + }(), + wantWriteErrMsg: "includes no provider Config", + }, + "error when state store lacks a type": { + plan: func() *plans.Plan { + rawPlan := examplePlanForTest(t) + // remove backend from example plan + rawPlan.Backend = nil + + // Add state store with missing data + ver, err := version.NewVersion("9.9.9") + if err != nil { + t.Fatalf("error encountered during test setup: %s", err) + } + rawPlan.StateStore = &plans.StateStore{ + // Type: omitted + Provider: &plans.Provider{ + Version: ver, + Source: &tfaddr.Provider{ + Hostname: tfaddr.DefaultProviderRegistryHost, + Namespace: "foobar", + Type: "foo", + }, + }, + Config: mustNewDynamicValue( + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.StringVal("bar"), + }), + cty.Object(map[string]cty.Type{ + "foo": cty.String, + }), + ), + Workspace: "default", + } + return rawPlan + }(), + wantWriteErrMsg: "state store has an unset Type", + }, + "error when state store lacks config": { + plan: func() *plans.Plan { + rawPlan := examplePlanForTest(t) + // remove backend from example plan + rawPlan.Backend = nil + + // Add state store with missing data + ver, err := version.NewVersion("9.9.9") + if err != nil { + t.Fatalf("error encountered during test setup: %s", err) + } + rawPlan.StateStore = &plans.StateStore{ + Type: "foo_bar", + Provider: &plans.Provider{ + Version: ver, + Source: &tfaddr.Provider{ + Hostname: tfaddr.DefaultProviderRegistryHost, + Namespace: "foobar", + Type: "foo", + }, + Config: mustNewDynamicValue( + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.StringVal("bar"), + }), + cty.Object(map[string]cty.Type{ + "foo": cty.String, + }), + ), + }, + // Config: omitted + Workspace: "default", + } + return rawPlan + }(), + wantWriteErrMsg: "state store includes no Config", + }, + "error when state store lacks a workspace": { + plan: func() *plans.Plan { + rawPlan := examplePlanForTest(t) + // remove backend from example plan + rawPlan.Backend = nil + + // Add state store with missing data + ver, err := version.NewVersion("9.9.9") + if err != nil { + t.Fatalf("error encountered during test setup: %s", err) + } + rawPlan.StateStore = &plans.StateStore{ + Type: "foo_bar", + Provider: &plans.Provider{ + Version: ver, + Source: &tfaddr.Provider{ + Hostname: tfaddr.DefaultProviderRegistryHost, + Namespace: "foobar", + Type: "foo", + }, + Config: mustNewDynamicValue( + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.StringVal("bar"), + }), + cty.Object(map[string]cty.Type{ + "foo": cty.String, + }), + ), + }, + Config: mustNewDynamicValue( + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.StringVal("bar"), + }), + cty.Object(map[string]cty.Type{ + "foo": cty.String, + }), + ), + // Workspace: omitted + } + return rawPlan + }(), + wantWriteErrMsg: "state store has an unset Workspace", + }, } for tn, tc := range cases { @@ -174,7 +403,7 @@ func Test_writeTfplan_validation(t *testing.T) { if err == nil { t.Fatal("this test expects an error but got none") } - if err.Error() != tc.wantWriteErrMsg { + if !strings.Contains(err.Error(), tc.wantWriteErrMsg) { t.Fatalf("unexpected error message: wanted %q, got %q", tc.wantWriteErrMsg, err) } })