From 2559f0a3dbaf97ee51affc2543d9804dc62f7023 Mon Sep 17 00:00:00 2001 From: Sarah French <15078782+SarahFrench@users.noreply.github.com> Date: Wed, 11 Jun 2025 15:10:26 +0100 Subject: [PATCH] Update backend state file so it can describe PSS state (#37179) * Split code for backend state file vs backend state * Rename BackendState to BackendConfigState * Spelling error * Add `StateStorageConfigState` struct as new implementation of new `ConfigState[T any]` interface. * Split tests for backend state file vs backend config state structs * Rename StateStorageConfigState to StateStoreConfigState * Clarify test name, add comments * Add tests for StateStoreConfigState methods * Add test showing state_store in JSON is parsed correctly * Add detection of malformed backend state files that contain both backend and state_store fields * Add validation that stops a backend state file being written if it will contain state for both backend and state_store blocks * Rename `state_storage` to `state_store` * Rename `state_storage` to `state_store` in filenames * Move`ConfigState` to its own file * Fix test name, remove whitespace * Update `StateStoreConfigState` comment using review suggestion * Update error message to no longer allude to the environment TF is being run in * Update the state_store state to use `version.Version` and an adapted version of `tfaddr.Provider` for marshalling version and source data * Update test helper so it doesn't accidentally supply validation in tests * Add protection against saving an empty backend state file * Remove direct testing of (s *Source) MarshalText() and UnmarshalText() methods * Add Validate method to StateStoreConfigState, use in backend state encoding logic * Refactor to use new features in registry dependency --- internal/cloud/migration.go | 2 +- internal/cloud/migration_test.go | 4 +- internal/command/command_test.go | 4 +- internal/command/meta.go | 2 +- internal/command/meta_backend.go | 8 +- .../command/workdir/backend_config_state.go | 95 ++++++++++ .../workdir/backend_config_state_test.go | 93 ++++++++++ internal/command/workdir/backend_state.go | 119 ++++-------- .../command/workdir/backend_state_test.go | 173 +++++++++++++----- internal/command/workdir/config_state.go | 19 ++ .../workdir/statestore_config_state.go | 131 +++++++++++++ .../workdir/statestore_config_state_test.go | 105 +++++++++++ internal/command/workdir/testing.go | 32 ++++ 13 files changed, 648 insertions(+), 139 deletions(-) create mode 100644 internal/command/workdir/backend_config_state.go create mode 100644 internal/command/workdir/backend_config_state_test.go create mode 100644 internal/command/workdir/config_state.go create mode 100644 internal/command/workdir/statestore_config_state.go create mode 100644 internal/command/workdir/statestore_config_state_test.go create mode 100644 internal/command/workdir/testing.go diff --git a/internal/cloud/migration.go b/internal/cloud/migration.go index af6dbb5fc2..6130ae1aed 100644 --- a/internal/cloud/migration.go +++ b/internal/cloud/migration.go @@ -48,7 +48,7 @@ const ( // the way we currently model working directory settings and config, so its // signature probably won't survive any non-trivial refactoring of how // the CLI layer thinks about backends/state storage. -func DetectConfigChangeType(wdState *workdir.BackendState, config *configs.Backend, haveLocalStates bool) ConfigChangeMode { +func DetectConfigChangeType(wdState *workdir.BackendConfigState, config *configs.Backend, haveLocalStates bool) ConfigChangeMode { // Although externally the cloud integration isn't really a "backend", // internally we treat it a bit like one just to preserve all of our // existing interfaces that assume backends. "cloud" is the placeholder diff --git a/internal/cloud/migration_test.go b/internal/cloud/migration_test.go index bfc83dae1a..162ddffc58 100644 --- a/internal/cloud/migration_test.go +++ b/internal/cloud/migration_test.go @@ -101,10 +101,10 @@ func TestDetectConfigChangeType(t *testing.T) { for name, test := range tests { t.Run(name, func(t *testing.T) { - var state *workdir.BackendState + var state *workdir.BackendConfigState var config *configs.Backend if test.stateType != "" { - state = &workdir.BackendState{ + state = &workdir.BackendConfigState{ Type: test.stateType, // everything else is irrelevant for our purposes here } diff --git a/internal/command/command_test.go b/internal/command/command_test.go index f9437c989d..420b677d34 100644 --- a/internal/command/command_test.go +++ b/internal/command/command_test.go @@ -839,7 +839,7 @@ func testBackendState(t *testing.T, s *states.State, c int) (*workdir.BackendSta hash := backendConfig.Hash(configSchema) state := workdir.NewBackendStateFile() - state.Backend = &workdir.BackendState{ + state.Backend = &workdir.BackendConfigState{ Type: "http", ConfigRaw: json.RawMessage(fmt.Sprintf(`{"address":%q}`, srv.URL)), Hash: uint64(hash), @@ -877,7 +877,7 @@ func testRemoteState(t *testing.T, s *states.State, c int) (*workdir.BackendStat retState := workdir.NewBackendStateFile() srv := httptest.NewServer(http.HandlerFunc(cb)) - b := &workdir.BackendState{ + b := &workdir.BackendConfigState{ Type: "http", } b.SetConfig(cty.ObjectVal(map[string]cty.Value{ diff --git a/internal/command/meta.go b/internal/command/meta.go index f7b7313409..d1d0bbb513 100644 --- a/internal/command/meta.go +++ b/internal/command/meta.go @@ -202,7 +202,7 @@ type Meta struct { configLoader *configload.Loader // backendState is the currently active backend state - backendState *workdir.BackendState + backendState *workdir.BackendConfigState // Variables for the context (private) variableArgs arguments.FlagNameValueSlice diff --git a/internal/command/meta_backend.go b/internal/command/meta_backend.go index d4e0234a81..43debbf911 100644 --- a/internal/command/meta_backend.go +++ b/internal/command/meta_backend.go @@ -202,7 +202,7 @@ func (m *Meta) Backend(opts *BackendOpts) (backendrun.OperationsBackend, tfdiags // with inside backendFromConfig, because we still need that codepath // to be able to recognize the lack of a config as distinct from // explicitly setting local until we do some more refactoring here. - m.backendState = &workdir.BackendState{ + m.backendState = &workdir.BackendConfigState{ Type: "local", ConfigRaw: json.RawMessage("{}"), } @@ -1057,7 +1057,7 @@ func (m *Meta) backend_C_r_s(c *configs.Backend, cHash int, sMgr *clistate.Local if s == nil { s = workdir.NewBackendStateFile() } - s.Backend = &workdir.BackendState{ + s.Backend = &workdir.BackendConfigState{ Type: c.Type, ConfigRaw: json.RawMessage(configJSON), Hash: uint64(cHash), @@ -1202,7 +1202,7 @@ func (m *Meta) backend_C_r_S_changed(c *configs.Backend, cHash int, sMgr *clista if s == nil { s = workdir.NewBackendStateFile() } - s.Backend = &workdir.BackendState{ + s.Backend = &workdir.BackendConfigState{ Type: c.Type, ConfigRaw: json.RawMessage(configJSON), Hash: uint64(cHash), @@ -1325,7 +1325,7 @@ func (m *Meta) updateSavedBackendHash(cHash int, sMgr *clistate.LocalState) tfdi // this function will conservatively assume that migration is required, // expecting that the migration code will subsequently deal with the same // errors. -func (m *Meta) backendConfigNeedsMigration(c *configs.Backend, s *workdir.BackendState) bool { +func (m *Meta) backendConfigNeedsMigration(c *configs.Backend, s *workdir.BackendConfigState) bool { if s == nil || s.Empty() { log.Print("[TRACE] backendConfigNeedsMigration: no cached config, so migration is required") return true diff --git a/internal/command/workdir/backend_config_state.go b/internal/command/workdir/backend_config_state.go new file mode 100644 index 0000000000..fb8521089b --- /dev/null +++ b/internal/command/workdir/backend_config_state.go @@ -0,0 +1,95 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package workdir + +import ( + "encoding/json" + "fmt" + + "github.com/zclconf/go-cty/cty" + ctyjson "github.com/zclconf/go-cty/cty/json" + + "github.com/hashicorp/terraform/internal/configs/configschema" + "github.com/hashicorp/terraform/internal/plans" +) + +var _ ConfigState[BackendConfigState] = &BackendConfigState{} + +// BackendConfigState describes the physical storage format for the backend state +// in a working directory, and provides the lowest-level API for decoding it. +type BackendConfigState struct { + Type string `json:"type"` // Backend type + ConfigRaw json.RawMessage `json:"config"` // Backend raw config + Hash uint64 `json:"hash"` // Hash of portion of configuration from config files +} + +// Empty returns true if there is no active backend. +// +// In practice this typically means that the working directory is using the +// implied local backend, but that decision is made by the caller. +func (s *BackendConfigState) Empty() bool { + return s == nil || s.Type == "" +} + +// Config decodes the type-specific configuration object using the provided +// schema and returns the result as a cty.Value. +// +// An error is returned if the stored configuration does not conform to the +// given schema, or is otherwise invalid. +func (s *BackendConfigState) Config(schema *configschema.Block) (cty.Value, error) { + ty := schema.ImpliedType() + if s == nil { + return cty.NullVal(ty), nil + } + return ctyjson.Unmarshal(s.ConfigRaw, ty) +} + +// SetConfig replaces (in-place) the type-specific configuration object using +// the provided value and associated schema. +// +// An error is returned if the given value does not conform to the implied +// type of the schema. +func (s *BackendConfigState) SetConfig(val cty.Value, schema *configschema.Block) error { + ty := schema.ImpliedType() + buf, err := ctyjson.Marshal(val, ty) + if err != nil { + return err + } + s.ConfigRaw = buf + return nil +} + +// ForPlan produces an alternative representation of the receiver that is +// suitable for storing in a plan. The current workspace must additionally +// be provided, to be stored alongside the backend configuration. +// +// The backend configuration schema is required in order to properly +// encode the backend-specific configuration settings. +func (s *BackendConfigState) ForPlan(schema *configschema.Block, workspaceName string) (*plans.Backend, error) { + if s == nil { + return nil, nil + } + + configVal, err := s.Config(schema) + if err != nil { + return nil, fmt.Errorf("failed to decode backend config: %w", err) + } + return plans.NewBackend(s.Type, configVal, schema, workspaceName) +} + +func (s *BackendConfigState) DeepCopy() *BackendConfigState { + if s == nil { + return nil + } + ret := &BackendConfigState{ + Type: s.Type, + Hash: s.Hash, + } + + if s.ConfigRaw != nil { + ret.ConfigRaw = make([]byte, len(s.ConfigRaw)) + copy(ret.ConfigRaw, s.ConfigRaw) + } + return ret +} diff --git a/internal/command/workdir/backend_config_state_test.go b/internal/command/workdir/backend_config_state_test.go new file mode 100644 index 0000000000..d3447046fa --- /dev/null +++ b/internal/command/workdir/backend_config_state_test.go @@ -0,0 +1,93 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package workdir + +import ( + "bytes" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform/internal/configs/configschema" + "github.com/zclconf/go-cty-debug/ctydebug" + "github.com/zclconf/go-cty/cty" +) + +func TestParseBackendConfigState_Config_SetConfig(t *testing.T) { + // This test only really covers the happy path because Config/SetConfig is + // largely just a thin wrapper around configschema's "ImpliedType" and + // cty's json unmarshal/marshal and both of those are well-tested elsewhere. + + s := &BackendConfigState{ + Type: "whatever", + ConfigRaw: []byte(`{ + "foo": "bar" + }`), + } + + schema := &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "foo": { + Type: cty.String, + Optional: true, + }, + }, + } + // Test Config method + got, err := s.Config(schema) + want := cty.ObjectVal(map[string]cty.Value{ + "foo": cty.StringVal("bar"), + }) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + if diff := cmp.Diff(want, got, ctydebug.CmpOptions); diff != "" { + t.Errorf("wrong result\n%s", diff) + } + + // Test SetConfig method + err = s.SetConfig(cty.ObjectVal(map[string]cty.Value{ + "foo": cty.StringVal("baz"), + }), schema) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + gotRaw := s.ConfigRaw + wantRaw := []byte(`{"foo":"baz"}`) + if !bytes.Equal(wantRaw, gotRaw) { + t.Errorf("wrong raw config after encode\ngot: %s\nwant: %s", gotRaw, wantRaw) + } +} + +func TestParseBackendStateConfig_Empty(t *testing.T) { + + // Populated BackendConfigState isn't empty + s := &BackendConfigState{ + Type: "whatever", + ConfigRaw: []byte(`{ + "foo": "bar" + }`), + } + + isEmpty := s.Empty() + if isEmpty { + t.Fatalf("expected config to not be reported as empty, but got empty=%v", isEmpty) + } + + // Zero values BackendConfigState is empty + s = &BackendConfigState{} + + isEmpty = s.Empty() + if isEmpty != true { + t.Fatalf("expected config to be reported as empty, but got empty=%v", isEmpty) + } + + // nil BackendConfigState is empty + s = nil + + isEmpty = s.Empty() + if isEmpty != true { + t.Fatalf("expected config to be reported as empty, but got empty=%v", isEmpty) + } +} diff --git a/internal/command/workdir/backend_state.go b/internal/command/workdir/backend_state.go index 128bdba58e..2829291f45 100644 --- a/internal/command/workdir/backend_state.go +++ b/internal/command/workdir/backend_state.go @@ -7,11 +7,6 @@ import ( "encoding/json" "fmt" - "github.com/zclconf/go-cty/cty" - ctyjson "github.com/zclconf/go-cty/cty/json" - - "github.com/hashicorp/terraform/internal/configs/configschema" - "github.com/hashicorp/terraform/internal/plans" "github.com/hashicorp/terraform/version" ) @@ -32,9 +27,16 @@ type BackendStateFile struct { TFVersion string `json:"terraform_version,omitempty"` // Backend tracks the configuration for the backend in use with - // this state. This is used to track any changes in the backend - // configuration. - Backend *BackendState `json:"backend,omitempty"` + // this state. This is used to track any changes in the `backend` + // block's configuration. + // Note: this also used to tracking changes in the `cloud` block + Backend *BackendConfigState `json:"backend,omitempty"` + + // StateStore tracks the configuration for a state store in use + // with this state. This is used to track any changes in the `state_store` + // block's configuration or associated data about the provider facilitating + // state storage + StateStore *StateStoreConfigState `json:"state_store,omitempty"` // This is here just so we can sniff for the unlikely-but-possible // situation that someone is trying to use modern Terraform with a @@ -63,7 +65,7 @@ func NewBackendStateFile() *BackendStateFile { // of an unsupported format version. // // This does not immediately decode the embedded backend config, and so -// it's possible that a subsequent call to [BackendState.Config] will +// it's possible that a subsequent call to [BackendConfigState.Config] will // return further errors even if this call succeeds. func ParseBackendStateFile(src []byte) (*BackendStateFile, error) { // To avoid any weird collisions with as-yet-unknown future versions of @@ -105,6 +107,9 @@ func ParseBackendStateFile(src []byte) (*BackendStateFile, error) { // This error message assumes that's the case. return nil, fmt.Errorf("this working directory uses legacy remote state and so must first be upgraded using Terraform v0.9") } + if stateFile.Backend != nil && stateFile.StateStore != nil { + return nil, fmt.Errorf("encountered a malformed backend state file that contains state for both a 'backend' and a 'state_store' block") + } return &stateFile, nil } @@ -112,6 +117,24 @@ func ParseBackendStateFile(src []byte) (*BackendStateFile, error) { func EncodeBackendStateFile(f *BackendStateFile) ([]byte, error) { f.Version = 3 // we only support version 3 f.TFVersion = version.SemVer.String() + + switch { + case f.Backend != nil && f.StateStore != nil: + return nil, fmt.Errorf("attempted to encode a malformed backend state file; it contains state for both a 'backend' and a 'state_store' block. This is a bug in Terraform and should be reported.") + case f.Backend == nil && f.StateStore == nil: + // This is valid - if the user has a backend state file and an implied local backend in use + // the backend state file exists but has no Backend data. + case f.Backend != nil: + // Not implementing anything here - risk of breaking changes + case f.StateStore != nil: + err := f.StateStore.Validate() + if err != nil { + return nil, err + } + default: + panic("error when determining whether backend state file was valid. This is a bug in Terraform and should be reported.") + } + return json.MarshalIndent(f, "", " ") } @@ -132,81 +155,3 @@ func (f *BackendStateFile) DeepCopy() *BackendStateFile { } return ret } - -// BackendState describes the physical storage format for the backend state -// in a working directory, and provides the lowest-level API for decoding it. -type BackendState struct { - Type string `json:"type"` // Backend type - ConfigRaw json.RawMessage `json:"config"` // Backend raw config - Hash uint64 `json:"hash"` // Hash of portion of configuration from config files -} - -// Empty returns true if there is no active backend. -// -// In practice this typically means that the working directory is using the -// implied local backend, but that decision is made by the caller. -func (s *BackendState) Empty() bool { - return s == nil || s.Type == "" -} - -// Config decodes the type-specific configuration object using the provided -// schema and returns the result as a cty.Value. -// -// An error is returned if the stored configuration does not conform to the -// given schema, or is otherwise invalid. -func (s *BackendState) Config(schema *configschema.Block) (cty.Value, error) { - ty := schema.ImpliedType() - if s == nil { - return cty.NullVal(ty), nil - } - return ctyjson.Unmarshal(s.ConfigRaw, ty) -} - -// SetConfig replaces (in-place) the type-specific configuration object using -// the provided value and associated schema. -// -// An error is returned if the given value does not conform to the implied -// type of the schema. -func (s *BackendState) SetConfig(val cty.Value, schema *configschema.Block) error { - ty := schema.ImpliedType() - buf, err := ctyjson.Marshal(val, ty) - if err != nil { - return err - } - s.ConfigRaw = buf - return nil -} - -// ForPlan produces an alternative representation of the reciever that is -// suitable for storing in a plan. The current workspace must additionally -// be provided, to be stored alongside the backend configuration. -// -// The backend configuration schema is required in order to properly -// encode the backend-specific configuration settings. -func (s *BackendState) ForPlan(schema *configschema.Block, workspaceName string) (*plans.Backend, error) { - if s == nil { - return nil, nil - } - - configVal, err := s.Config(schema) - if err != nil { - return nil, fmt.Errorf("failed to decode backend config: %w", err) - } - return plans.NewBackend(s.Type, configVal, schema, workspaceName) -} - -func (s *BackendState) DeepCopy() *BackendState { - if s == nil { - return nil - } - ret := &BackendState{ - Type: s.Type, - Hash: s.Hash, - } - - if s.ConfigRaw != nil { - ret.ConfigRaw = make([]byte, len(s.ConfigRaw)) - copy(ret.ConfigRaw, s.ConfigRaw) - } - return ret -} diff --git a/internal/command/workdir/backend_state_test.go b/internal/command/workdir/backend_state_test.go index f2e9675a62..adeeef9c05 100644 --- a/internal/command/workdir/backend_state_test.go +++ b/internal/command/workdir/backend_state_test.go @@ -4,15 +4,11 @@ package workdir import ( - "bytes" "encoding/json" + "strings" "testing" "github.com/google/go-cmp/cmp" - "github.com/zclconf/go-cty-debug/ctydebug" - "github.com/zclconf/go-cty/cty" - - "github.com/hashicorp/terraform/internal/configs/configschema" ) func TestParseBackendStateFile(t *testing.T) { @@ -65,12 +61,63 @@ func TestParseBackendStateFile(t *testing.T) { Want: &BackendStateFile{ Version: 3, TFVersion: "0.8.0", - Backend: &BackendState{ + Backend: &BackendConfigState{ Type: "treasure_chest_buried_on_a_remote_island", ConfigRaw: json.RawMessage("{}"), }, }, }, + "active state_store": { + Input: `{ + "version": 3, + "terraform_version": "9.9.9", + "state_store": { + "type": "foobar_baz", + "config": { + "provider": "foobar", + "bucket": "my-bucket" + }, + "provider": { + "version": "1.2.3", + "source": "registry.terraform.io/my-org/foobar" + } + } + }`, + Want: &BackendStateFile{ + Version: 3, + TFVersion: "9.9.9", + StateStore: &StateStoreConfigState{ + Type: "foobar_baz", + Provider: getTestProviderState(t, "1.2.3", "registry.terraform.io", "my-org", "foobar"), + ConfigRaw: json.RawMessage(`{ + "provider": "foobar", + "bucket": "my-bucket" + }`), + }, + }, + }, + "detection of malformed state: conflicting 'backend' and 'state_store' sections": { + Input: `{ + "version": 3, + "terraform_version": "9.9.9", + "backend": { + "type": "treasure_chest_buried_on_a_remote_island", + "config": {} + }, + "state_store": { + "type": "foobar_baz", + "config": { + "provider": "foobar", + "bucket": "my-bucket" + }, + "provider": { + "version": "1.2.3", + "source": "registry.terraform.io/my-org/foobar" + } + } + }`, + WantErr: `encountered a malformed backend state file that contains state for both a 'backend' and a 'state_store' block`, + }, } for name, test := range tests { @@ -97,47 +144,89 @@ func TestParseBackendStateFile(t *testing.T) { } } -func ParseBackendStateConfig(t *testing.T) { - // This test only really covers the happy path because Config/SetConfig is - // largely just a thin wrapper around configschema's "ImpliedType" and - // cty's json unmarshal/marshal and both of those are well-tested elsewhere. +func TestEncodeBackendStateFile(t *testing.T) { - s := &BackendState{ - Type: "whatever", - ConfigRaw: []byte(`{ - "foo": "bar" - }`), - } - - schema := &configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "foo": { - Type: cty.String, - Optional: true, + tests := map[string]struct { + Input *BackendStateFile + Want []byte + WantErr string + }{ + "it returns an error when neither backend nor state_store config state are present": { + Input: &BackendStateFile{}, + Want: []byte("{\n \"version\": 3,\n \"terraform_version\": \"1.13.0\"\n}"), + }, + "it returns an error when the provider source's hostname is missing": { + Input: &BackendStateFile{ + StateStore: &StateStoreConfigState{ + Type: "foobar_baz", + Provider: getTestProviderState(t, "1.2.3", "", "my-org", "foobar"), + ConfigRaw: json.RawMessage([]byte(`{"foo":"bar"}`)), + Hash: 123, + }, }, + WantErr: `state store is not valid: Unknown hostname: Expected hostname in the provider address to be set`, + }, + "it returns an error when the provider source's hostname and namespace are missing ": { + Input: &BackendStateFile{ + StateStore: &StateStoreConfigState{ + Type: "foobar_baz", + Provider: getTestProviderState(t, "1.2.3", "", "", "foobar"), + ConfigRaw: json.RawMessage([]byte(`{"foo":"bar"}`)), + Hash: 123, + }, + }, + WantErr: `state store is not valid: Unknown hostname: Expected hostname in the provider address to be set`, + }, + "it returns an error when the provider source is completely missing ": { + Input: &BackendStateFile{ + StateStore: &StateStoreConfigState{ + Type: "foobar_baz", + Provider: getTestProviderState(t, "1.2.3", "", "", ""), + ConfigRaw: json.RawMessage([]byte(`{"foo":"bar"}`)), + Hash: 123, + }, + }, + WantErr: `state store is not valid: Empty provider address: Expected address composed of hostname, provider namespace and name`, + }, + "it returns an error when both backend and state_store config state are present": { + Input: &BackendStateFile{ + Backend: &BackendConfigState{ + Type: "foobar", + ConfigRaw: json.RawMessage([]byte(`{"foo":"bar"}`)), + Hash: 123, + }, + StateStore: &StateStoreConfigState{ + Type: "foobar_baz", + Provider: getTestProviderState(t, "1.2.3", "registry.terraform.io", "my-org", "foobar"), + ConfigRaw: json.RawMessage([]byte(`{"foo":"bar"}`)), + Hash: 123, + }, + }, + WantErr: `attempted to encode a malformed backend state file; it contains state for both a 'backend' and a 'state_store' block`, }, - } - got, err := s.Config(schema) - want := cty.ObjectVal(map[string]cty.Value{ - "foo": cty.StringVal("bar"), - }) - if err != nil { - t.Fatalf("unexpected error: %s", err) - } - if diff := cmp.Diff(want, got, ctydebug.CmpOptions); diff != "" { - t.Errorf("wrong result\n%s", diff) } - err = s.SetConfig(cty.ObjectVal(map[string]cty.Value{ - "foo": cty.StringVal("baz"), - }), schema) - if err != nil { - t.Fatalf("unexpected error: %s", err) - } + for name, test := range tests { + t.Run(name, func(t *testing.T) { + got, err := EncodeBackendStateFile(test.Input) + + if test.WantErr != "" { + if err == nil { + t.Fatalf("unexpected success\nwant error: %s", test.WantErr) + } + if !strings.Contains(err.Error(), test.WantErr) { + t.Errorf("wrong error\ngot: %s\nwant: %s", err.Error(), test.WantErr) + } + return + } + + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + if diff := cmp.Diff(test.Want, got); diff != "" { + t.Errorf("wrong result\n%s", diff) + } + }) - gotRaw := s.ConfigRaw - wantRaw := []byte(`{"foo":"baz"}`) - if !bytes.Equal(wantRaw, gotRaw) { - t.Errorf("wrong raw config after encode\ngot: %s\nwant: %s", gotRaw, wantRaw) } } diff --git a/internal/command/workdir/config_state.go b/internal/command/workdir/config_state.go new file mode 100644 index 0000000000..f674ba78fd --- /dev/null +++ b/internal/command/workdir/config_state.go @@ -0,0 +1,19 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package workdir + +import ( + "github.com/hashicorp/terraform/internal/configs/configschema" + "github.com/hashicorp/terraform/internal/plans" + "github.com/zclconf/go-cty/cty" +) + +// ConfigState describes a configuration block, and is used to make that config block stateful. +type ConfigState[T any] interface { + Empty() bool + Config(*configschema.Block) (cty.Value, error) + SetConfig(cty.Value, *configschema.Block) error + ForPlan(*configschema.Block, string) (*plans.Backend, error) + DeepCopy() *T +} diff --git a/internal/command/workdir/statestore_config_state.go b/internal/command/workdir/statestore_config_state.go new file mode 100644 index 0000000000..a2a9be0beb --- /dev/null +++ b/internal/command/workdir/statestore_config_state.go @@ -0,0 +1,131 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package workdir + +import ( + "encoding/json" + "fmt" + + version "github.com/hashicorp/go-version" + tfaddr "github.com/hashicorp/terraform-registry-address" + "github.com/hashicorp/terraform/internal/configs/configschema" + "github.com/hashicorp/terraform/internal/plans" + "github.com/zclconf/go-cty/cty" + ctyjson "github.com/zclconf/go-cty/cty/json" +) + +var _ ConfigState[StateStoreConfigState] = &StateStoreConfigState{} + +// StateStoreConfigState describes the physical storage format for the state store +type StateStoreConfigState struct { + Type string `json:"type"` // State store type name + Provider *Provider `json:"provider"` // Details about the state-storage provider + ConfigRaw json.RawMessage `json:"config"` // state_store block raw config, barring provider details + Hash uint64 `json:"hash"` // Hash of portion of configuration from config files +} + +// Provider is used in the StateStoreConfigState struct to describe the provider that's used for pluggable +// state storage. The data inside should mirror an entry in the dependency lock file. +// This is NOT state of a `provider` configuration block, or an entry in `required_providers`. +type Provider struct { + Version *version.Version `json:"version"` // The specific provider version used for the state store. Should be set using a getproviders.Version, etc. + Source tfaddr.Provider `json:"source"` // The FQN/fully-qualified name of the provider. +} + +// Empty returns true if there is no active state store. +func (s *StateStoreConfigState) Empty() bool { + return s == nil || s.Type == "" +} + +// Validate returns true if there are no missing expected values, and +// important values have been validated, e.g. FQNs. When the config is +// invalid an error will be returned. +func (s *StateStoreConfigState) Validate() error { + + // Are any bits of data totally missing? + if s.Empty() { + return fmt.Errorf("state store is not valid: data is empty") + } + if s.Provider == nil { + return fmt.Errorf("state store is not valid: provider data is missing") + } + if s.Provider.Version == nil { + return fmt.Errorf("state store is not valid: version data is missing") + } + if s.ConfigRaw == nil { + return fmt.Errorf("attempted to encode a malformed backend state file; state_store configuration data is missing") + } + + // Validity of data that is there + err := s.Provider.Source.Validate() + if err != nil { + return fmt.Errorf("state store is not valid: %w", err) + } + + return nil +} + +// Config decodes the type-specific configuration object using the provided +// schema and returns the result as a cty.Value. +// +// An error is returned if the stored configuration does not conform to the +// given schema, or is otherwise invalid. +func (s *StateStoreConfigState) Config(schema *configschema.Block) (cty.Value, error) { + ty := schema.ImpliedType() + if s == nil { + return cty.NullVal(ty), nil + } + return ctyjson.Unmarshal(s.ConfigRaw, ty) +} + +// SetConfig replaces (in-place) the type-specific configuration object using +// the provided value and associated schema. +// +// An error is returned if the given value does not conform to the implied +// type of the schema. +func (s *StateStoreConfigState) SetConfig(val cty.Value, schema *configschema.Block) error { + ty := schema.ImpliedType() + buf, err := ctyjson.Marshal(val, ty) + if err != nil { + return err + } + s.ConfigRaw = buf + return nil +} + +// ForPlan produces an alternative representation of the receiver that is +// suitable for storing in a plan. The current workspace must additionally +// be provided, to be stored alongside the state store configuration. +// +// The state_store configuration schema is required in order to properly +// encode the state store-specific configuration settings. +func (s *StateStoreConfigState) ForPlan(schema *configschema.Block, workspaceName string) (*plans.Backend, error) { + if s == nil { + return nil, nil + } + // TODO + // What should a pluggable state store look like in a plan? + return nil, nil +} + +func (s *StateStoreConfigState) DeepCopy() *StateStoreConfigState { + if s == nil { + return nil + } + provider := &Provider{ + Version: s.Provider.Version, + Source: s.Provider.Source, + } + ret := &StateStoreConfigState{ + Type: s.Type, + Provider: provider, + Hash: s.Hash, + } + + if s.ConfigRaw != nil { + ret.ConfigRaw = make([]byte, len(s.ConfigRaw)) + copy(ret.ConfigRaw, s.ConfigRaw) + } + return ret +} diff --git a/internal/command/workdir/statestore_config_state_test.go b/internal/command/workdir/statestore_config_state_test.go new file mode 100644 index 0000000000..d6cfc862a5 --- /dev/null +++ b/internal/command/workdir/statestore_config_state_test.go @@ -0,0 +1,105 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package workdir + +import ( + "bytes" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform/internal/configs/configschema" + "github.com/zclconf/go-cty-debug/ctydebug" + "github.com/zclconf/go-cty/cty" +) + +func TestParseStateStoreConfigState_Config_SetConfig(t *testing.T) { + // This test only really covers the happy path because Config/SetConfig is + // largely just a thin wrapper around configschema's "ImpliedType" and + // cty's json unmarshal/marshal and both of those are well-tested elsewhere. + + s := &StateStoreConfigState{ + Type: "whatever", + ConfigRaw: []byte(`{ + "provider": "foobar", + "foo": "bar" + }`), + Provider: getTestProviderState(t, "1.2.3", "registry.terraform.io", "my-org", "foobar"), + Hash: 12345, + } + + schema := &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "provider": { + Type: cty.String, + Required: true, + }, + "foo": { + Type: cty.String, + Optional: true, + }, + }, + } + + // Test Config method + got, err := s.Config(schema) + want := cty.ObjectVal(map[string]cty.Value{ + "provider": cty.StringVal("foobar"), + "foo": cty.StringVal("bar"), + }) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + if diff := cmp.Diff(want, got, ctydebug.CmpOptions); diff != "" { + t.Errorf("wrong result\n%s", diff) + } + + // Test SetConfig method + err = s.SetConfig(cty.ObjectVal(map[string]cty.Value{ + "provider": cty.StringVal("foobar"), + "foo": cty.StringVal("baz"), + }), schema) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + gotRaw := s.ConfigRaw + wantRaw := []byte(`{"foo":"baz","provider":"foobar"}`) + if !bytes.Equal(wantRaw, gotRaw) { + t.Errorf("wrong raw config after encode\ngot: %s\nwant: %s", gotRaw, wantRaw) + } +} + +func TestParseStateStoreConfigState_Empty(t *testing.T) { + // Populated StateStoreConfigState isn't empty + s := &StateStoreConfigState{ + Type: "whatever", + ConfigRaw: []byte(`{ + "provider": "foobar", + "foo": "bar" + }`), + Provider: getTestProviderState(t, "1.2.3", "registry.terraform.io", "my-org", "foobar"), + Hash: 12345, + } + + isEmpty := s.Empty() + if isEmpty { + t.Fatalf("expected config to not be reported as empty, but got empty=%v", isEmpty) + } + + // Zero valued StateStoreConfigState is empty + s = &StateStoreConfigState{} + + isEmpty = s.Empty() + if isEmpty != true { + t.Fatalf("expected config to be reported as empty, but got empty=%v", isEmpty) + } + + // nil StateStoreConfigState is empty + s = nil + + isEmpty = s.Empty() + if isEmpty != true { + t.Fatalf("expected config to be reported as empty, but got empty=%v", isEmpty) + } +} diff --git a/internal/command/workdir/testing.go b/internal/command/workdir/testing.go new file mode 100644 index 0000000000..2bb5ad495d --- /dev/null +++ b/internal/command/workdir/testing.go @@ -0,0 +1,32 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package workdir + +import ( + "testing" + + version "github.com/hashicorp/go-version" + tfaddr "github.com/hashicorp/terraform-registry-address" + svchost "github.com/hashicorp/terraform-svchost" +) + +// getTestProviderState is a test helper that returns a state representation +// of a provider used for managing state via pluggable state storage. +func getTestProviderState(t *testing.T, semVer, hostname, namespace, typeName string) *Provider { + t.Helper() + + ver, err := version.NewSemver(semVer) + if err != nil { + t.Fatalf("test setup failed when creating version.Version: %s", err) + } + + return &Provider{ + Version: ver, + Source: tfaddr.Provider{ + Hostname: svchost.Hostname(hostname), + Namespace: namespace, + Type: typeName, + }, + } +}