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
stack-cli-download-in-plugin-cache
Sarah French 10 months ago committed by GitHub
parent af07bd388e
commit 2559f0a3db
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -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

@ -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
}

@ -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{

@ -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

@ -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

@ -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
}

@ -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)
}
}

@ -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
}

@ -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)
}
}

@ -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
}

@ -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
}

@ -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)
}
}

@ -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,
},
}
}
Loading…
Cancel
Save