mirror of https://github.com/hashicorp/terraform
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 dependencystack-cli-download-in-plugin-cache
parent
af07bd388e
commit
2559f0a3db
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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…
Reference in new issue