diff --git a/internal/backend/pluggable/pluggable.go b/internal/backend/pluggable/pluggable.go new file mode 100644 index 0000000000..2b7fd8f98d --- /dev/null +++ b/internal/backend/pluggable/pluggable.go @@ -0,0 +1,140 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package pluggable + +import ( + "errors" + + "github.com/hashicorp/terraform/internal/backend" + "github.com/hashicorp/terraform/internal/configs/configschema" + "github.com/hashicorp/terraform/internal/providers" + "github.com/hashicorp/terraform/internal/states/remote" + "github.com/hashicorp/terraform/internal/states/statemgr" + "github.com/hashicorp/terraform/internal/tfdiags" + "github.com/zclconf/go-cty/cty" +) + +// NewPluggable returns an instance of the backend.Backend interface that +// contains a provider interface. These are the assumptions about that +// provider: +// +// * The provider implements at least one state store. +// * The provider has already been configured before using NewPluggable. +// +// The state store could also be configured prior to using NewPluggable, +// or it could be configured using the relevant backend.Backend methods. +// +// By wrapping a configured provider in a Pluggable we allow calling code +// to use the provider's gRPC methods when interacting with state. +func NewPluggable(p providers.Interface, typeName string) (backend.Backend, error) { + if p == nil { + return nil, errors.New("Attempted to initialize pluggable state with a nil provider interface. This is a bug in Terraform and should be reported") + } + if typeName == "" { + return nil, errors.New("Attempted to initialize pluggable state with an empty string identifier for the state store. This is a bug in Terraform and should be reported") + } + + return &Pluggable{ + provider: p, + typeName: typeName, + }, nil +} + +var _ backend.Backend = &Pluggable{} + +type Pluggable struct { + provider providers.Interface + typeName string +} + +// ConfigSchema returns the schema for the state store implementation +// name provided when the Pluggable was constructed. +// +// ConfigSchema implements backend.Backend +func (p *Pluggable) ConfigSchema() *configschema.Block { + schemaResp := p.provider.GetProviderSchema() + if len(schemaResp.StateStores) == 0 { + // No state stores + return nil + } + val, ok := schemaResp.StateStores[p.typeName] + if !ok { + // Cannot find state store with that type + return nil + } + + // State store type exists + return val.Body +} + +// PrepareConfig validates configuration for the state store in +// the state storage provider. The configuration sent from Terraform core +// will not include any values from environment variables; it is the +// provider's responsibility to access any environment variables +// to get the complete set of configuration prior to validating it. +// +// PrepareConfig implements backend.Backend +func (p *Pluggable) PrepareConfig(config cty.Value) (cty.Value, tfdiags.Diagnostics) { + req := providers.ValidateStateStoreConfigRequest{ + TypeName: p.typeName, + Config: config, + } + resp := p.provider.ValidateStateStoreConfig(req) + return config, resp.Diagnostics +} + +// Configure configures the state store in the state storage provider. +// Calling code is expected to have already validated the config using +// the PrepareConfig method. +// +// It is the provider's responsibility to access any environment variables +// set by the user to get the complete set of configuration. +// +// Configure implements backend.Backend +func (p *Pluggable) Configure(config cty.Value) tfdiags.Diagnostics { + req := providers.ConfigureStateStoreRequest{ + TypeName: p.typeName, + Config: config, + } + resp := p.provider.ConfigureStateStore(req) + return resp.Diagnostics +} + +// Workspaces returns a list of all states/CE workspaces that the backend.Backend +// can find, given how it is configured. For example returning a list of differently +// -named files in a blob storage service. +// +// Workspace implements backend.Backend +func (p *Pluggable) Workspaces() ([]string, error) { + req := providers.GetStatesRequest{ + TypeName: p.typeName, + } + resp := p.provider.GetStates(req) + + return resp.States, resp.Diagnostics.Err() +} + +// DeleteWorkspace deletes the state file for the named workspace. +// The state storage provider is expected to return error diagnostics +// if the workspace doesn't exist or it is unable to be deleted. +// +// DeleteWorkspace implements backend.Backend +func (p *Pluggable) DeleteWorkspace(workspace string, force bool) error { + req := providers.DeleteStateRequest{ + TypeName: p.typeName, + StateId: workspace, + } + resp := p.provider.DeleteState(req) + return resp.Diagnostics.Err() +} + +// StateMgr returns a state manager that uses gRPC to communicate with the +// state storage provider to interact with state. +// +// StateMgr implements backend.Backend +func (p *Pluggable) StateMgr(workspace string) (statemgr.Full, error) { + // repackages the provider's methods inside a state manager, + // to be passed to the calling code that expects a statemgr.Full + return remote.NewRemoteGRPC(p.provider, p.typeName, workspace), nil +} diff --git a/internal/backend/pluggable/pluggable_test.go b/internal/backend/pluggable/pluggable_test.go new file mode 100644 index 0000000000..b60c535171 --- /dev/null +++ b/internal/backend/pluggable/pluggable_test.go @@ -0,0 +1,400 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package pluggable + +import ( + "errors" + "maps" + "slices" + "strings" + "testing" + + "github.com/hashicorp/terraform/internal/configs/configschema" + "github.com/hashicorp/terraform/internal/providers" + testing_provider "github.com/hashicorp/terraform/internal/providers/testing" + "github.com/zclconf/go-cty/cty" +) + +func TestNewPluggable(t *testing.T) { + cases := map[string]struct { + provider providers.Interface + typeName string + + wantError string + }{ + "no error when inputs are provided": { + provider: &testing_provider.MockProvider{}, + typeName: "foo_bar", + }, + "no error when store name has underscores": { + provider: &testing_provider.MockProvider{}, + // foo provider containing fizz_buzz store + typeName: "foo_fizz_buzz", + }, + "error when store type not provided": { + provider: &testing_provider.MockProvider{}, + typeName: "", + wantError: "Attempted to initialize pluggable state with an empty string identifier for the state store.", + }, + "error when provider interface is nil": { + provider: nil, + typeName: "foo_bar", + wantError: "Attempted to initialize pluggable state with a nil provider interface.", + }, + } + + for tn, tc := range cases { + t.Run(tn, func(t *testing.T) { + _, err := NewPluggable(tc.provider, tc.typeName) + if err != nil { + if tc.wantError == "" { + t.Fatalf("unexpected error: %s", err) + } + if !strings.Contains(err.Error(), tc.wantError) { + t.Fatalf("expected error %q but got %q", tc.wantError, err) + } + return + } + if err == nil && tc.wantError != "" { + t.Fatalf("expected error %q but got none", tc.wantError) + } + }) + } +} + +func TestPluggable_ConfigSchema(t *testing.T) { + + p := &testing_provider.MockProvider{ + GetProviderSchemaResponse: &providers.GetProviderSchemaResponse{ + Provider: providers.Schema{}, + DataSources: map[string]providers.Schema{}, + ResourceTypes: map[string]providers.Schema{}, + ListResourceTypes: map[string]providers.Schema{}, + StateStores: map[string]providers.Schema{ + // This imagines a provider called foo that contains + // two pluggable state store implementations, called + // bar and baz. + // It's accurate to include the prefixed provider name + // in the keys of schema maps + "foo_bar": { + Body: &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "bar": { + Type: cty.String, + Required: true, + }, + }, + }, + }, + "foo_baz": { + Body: &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "baz": { + Type: cty.String, + Required: true, + }, + }, + }, + }, + }, + }, + } + + cases := map[string]struct { + provider providers.Interface + typeName string + + expectedAttrName string + expectNil bool + }{ + "returns expected schema - bar store": { + provider: p, + typeName: "foo_bar", + expectedAttrName: "bar", + }, + "returns expected schema - baz store": { + provider: p, + typeName: "foo_baz", + expectedAttrName: "baz", + }, + "returns nil if there isn't a store with a matching name": { + provider: p, + typeName: "foo_not_implemented", + expectNil: true, + }, + "returns nil if no state stores are implemented in the provider": { + provider: &testing_provider.MockProvider{}, + typeName: "foo_bar", + expectNil: true, + }, + } + + for tn, tc := range cases { + t.Run(tn, func(t *testing.T) { + p, err := NewPluggable(tc.provider, tc.typeName) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + s := p.ConfigSchema() + if mock, ok := tc.provider.(*testing_provider.MockProvider); ok { + if !mock.GetProviderSchemaCalled { + t.Fatal("expected mock's GetProviderSchema method to have been called") + } + } + if s == nil { + if !tc.expectNil { + t.Fatal("ConfigSchema returned an unexpected nil schema") + } + return + } + if val := s.Attributes[tc.expectedAttrName]; val == nil { + t.Fatalf("expected the returned schema to include an attr called %q, but it was missing. Schema contains attrs: %v", + tc.expectedAttrName, + slices.Sorted(maps.Keys(s.Attributes))) + } + }) + } +} + +func TestPluggable_PrepareConfig(t *testing.T) { + fooBar := "foo_bar" + cases := map[string]struct { + provider providers.Interface + typeName string + config cty.Value + + wantError string + }{ + "when config is deemed valid there are no diagnostics": { + provider: &testing_provider.MockProvider{ + ConfigureProviderCalled: true, + ValidateStateStoreConfigFn: func(req providers.ValidateStateStoreConfigRequest) providers.ValidateStateStoreConfigResponse { + // if validation is ok, response has no diags + return providers.ValidateStateStoreConfigResponse{} + }, + }, + typeName: fooBar, + }, + "errors are returned, and expected arguments are in the request": { + provider: &testing_provider.MockProvider{ + ConfigureProviderCalled: true, + ValidateStateStoreConfigFn: func(req providers.ValidateStateStoreConfigRequest) providers.ValidateStateStoreConfigResponse { + // Are the right values being put into the incoming request? + if req.TypeName != fooBar || req.Config != cty.True { + t.Fatalf("expected provider ValidateStateStoreConfig method to receive TypeName %q and Config %q, instead got TypeName %q and Config %q", + fooBar, + cty.True, + req.TypeName, + req.Config) + } + + // Force an error, to see it makes it back to the invoked method ok + resp := providers.ValidateStateStoreConfigResponse{} + resp.Diagnostics = resp.Diagnostics.Append(errors.New("error diagnostic raised from mock")) + return resp + }, + }, + typeName: fooBar, + config: cty.BoolVal(true), + wantError: "error diagnostic raised from mock", + }, + } + + for tn, tc := range cases { + t.Run(tn, func(t *testing.T) { + p, err := NewPluggable(tc.provider, tc.typeName) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + _, diags := p.PrepareConfig(tc.config) + if mock, ok := tc.provider.(*testing_provider.MockProvider); ok { + if !mock.ValidateStateStoreConfigCalled { + t.Fatal("expected mock's ValidateStateStoreConfig method to have been called") + } + } + if diags.HasErrors() { + if tc.wantError == "" { + t.Fatalf("unexpected error: %s", diags.Err()) + } + if !strings.Contains(diags.Err().Error(), tc.wantError) { + t.Fatalf("expected error %q but got: %q", tc.wantError, diags.Err()) + } + return + } + if !diags.HasErrors() && tc.wantError != "" { + t.Fatal("expected an error but got none") + } + }) + } +} + +func TestPluggable_Configure(t *testing.T) { + + // Arrange mocks + typeName := "foo_bar" + wantError := "error diagnostic raised from mock" + mock := &testing_provider.MockProvider{ + ConfigureProviderCalled: true, + ValidateStateStoreConfigCalled: true, + ConfigureStateStoreFn: func(req providers.ConfigureStateStoreRequest) providers.ConfigureStateStoreResponse { + if req.TypeName != typeName || req.Config != cty.True { + t.Fatalf("expected provider ConfigureStateStore method to receive TypeName %q and Config %q, instead got TypeName %q and Config %q", + typeName, + cty.True, + req.TypeName, + req.Config) + } + + resp := providers.ConfigureStateStoreResponse{} + resp.Diagnostics = resp.Diagnostics.Append(errors.New(wantError)) + return resp + }, + } + + // Make Pluggable and invoke Configure + p, err := NewPluggable(mock, typeName) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + // This isn't representative of true values used with the method, but is sufficient + // for testing that the mock receives the provided value as expected. + config := cty.BoolVal(true) + diags := p.Configure(config) + + // Assertions + if !mock.ValidateStateStoreConfigCalled { + t.Fatal("expected mock's ValidateStateStoreConfig method to have been called") + } + if !diags.HasErrors() { + t.Fatal("expected an error but got none") + } + if !strings.Contains(diags.Err().Error(), wantError) { + t.Fatalf("expected error %q but got: %q", wantError, diags.Err()) + } +} + +func TestPluggable_Workspaces(t *testing.T) { + fooBar := "foo_bar" + cases := map[string]struct { + provider providers.Interface + expectedWorkspaces []string + wantError string + }{ + "returned workspaces match what's returned from the store": { + // and "default" isn't included by default + provider: &testing_provider.MockProvider{ + ConfigureProviderCalled: true, + ValidateStateStoreConfigCalled: true, + ConfigureStateStoreCalled: true, + GetStatesFn: func(req providers.GetStatesRequest) providers.GetStatesResponse { + workspaces := []string{"abcd", "efg"} + resp := providers.GetStatesResponse{ + States: workspaces, + } + return resp + }, + }, + expectedWorkspaces: []string{"abcd", "efg"}, + }, + "errors are returned, and expected arguments are in the request": { + provider: &testing_provider.MockProvider{ + ConfigureProviderCalled: true, + ValidateStateStoreConfigCalled: true, + ConfigureStateStoreCalled: true, + GetStatesFn: func(req providers.GetStatesRequest) providers.GetStatesResponse { + if req.TypeName != fooBar { + t.Fatalf("expected provider GetStates method to receive TypeName %q, instead got TypeName %q", + fooBar, + req.TypeName) + } + resp := providers.GetStatesResponse{} + resp.Diagnostics = resp.Diagnostics.Append(errors.New("error diagnostic raised from mock")) + return resp + }, + }, + wantError: "error diagnostic raised from mock", + }, + } + + for tn, tc := range cases { + t.Run(tn, func(t *testing.T) { + p, err := NewPluggable(tc.provider, fooBar) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + workspaces, err := p.Workspaces() + if mock, ok := tc.provider.(*testing_provider.MockProvider); ok { + if !mock.GetStatesCalled { + t.Fatal("expected mock's GetStates method to have been called") + } + } + if err != nil { + if tc.wantError == "" { + t.Fatalf("unexpected error: %s", err) + } + if !strings.Contains(err.Error(), tc.wantError) { + t.Fatalf("expected error %q but got: %q", tc.wantError, err) + } + return + } + + if tc.wantError != "" { + t.Fatal("expected an error but got none") + } + + if slices.Compare(workspaces, tc.expectedWorkspaces) != 0 { + t.Fatalf("expected workspaces %v, got %v", tc.expectedWorkspaces, workspaces) + } + }) + } +} + +func TestPluggable_DeleteWorkspace(t *testing.T) { + + // Arrange mocks + typeName := "foo_bar" + stateId := "my-state" + mock := &testing_provider.MockProvider{ + ConfigureProviderCalled: true, + ValidateStateStoreConfigCalled: true, + ConfigureStateStoreCalled: true, + DeleteStateFn: func(req providers.DeleteStateRequest) providers.DeleteStateResponse { + if req.TypeName != typeName || req.StateId != stateId { + t.Fatalf("expected provider DeleteState method to receive TypeName %q and StateId %q, instead got TypeName %q and StateId %q", + typeName, + stateId, + req.TypeName, + req.StateId, + ) + } + resp := providers.DeleteStateResponse{} + resp.Diagnostics = resp.Diagnostics.Append(errors.New("error diagnostic raised from mock")) + return resp + }, + } + + // Make Pluggable and invoke DeleteWorkspace + p, err := NewPluggable(mock, typeName) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + err = p.DeleteWorkspace(stateId, false) + + // Assertions + if !mock.DeleteStateCalled { + t.Fatal("expected mock's DeleteState method to have been called") + } + + if err == nil { + t.Fatal("test is expected to return an error, but there isn't one") + } + wantError := "error diagnostic raised from mock" + if !strings.Contains(err.Error(), wantError) { + t.Fatalf("expected error %q but got: %q", wantError, err) + } +} diff --git a/internal/backend/remote-state/consul/go.sum b/internal/backend/remote-state/consul/go.sum index 8cac1b9a89..811435a316 100644 --- a/internal/backend/remote-state/consul/go.sum +++ b/internal/backend/remote-state/consul/go.sum @@ -347,6 +347,11 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= github.com/ulikunitz/xz v0.5.10 h1:t92gobL9l3HE202wg3rlk19F6X+JOxl9BBrCCMYEYd8= github.com/ulikunitz/xz v0.5.10/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= +github.com/vmihailenco/msgpack v4.0.4+incompatible h1:dSLoQfGFAo3F6OoNhwUmLwVgaUXK79GlxNBwueZn0xI= +github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= +github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= +github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= +github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= diff --git a/internal/backend/remote-state/cos/go.sum b/internal/backend/remote-state/cos/go.sum index 818a4172a6..8468307e23 100644 --- a/internal/backend/remote-state/cos/go.sum +++ b/internal/backend/remote-state/cos/go.sum @@ -249,6 +249,11 @@ github.com/tencentyun/cos-go-sdk-v5 v0.7.42 h1:Up1704BJjI5orycXKjpVpvuOInt9GC5pq github.com/tencentyun/cos-go-sdk-v5 v0.7.42/go.mod h1:LUFnaqRmGk6pEHOaRmdn2dCZR2j0cSsM5xowWFPTPao= github.com/ulikunitz/xz v0.5.10 h1:t92gobL9l3HE202wg3rlk19F6X+JOxl9BBrCCMYEYd8= github.com/ulikunitz/xz v0.5.10/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= +github.com/vmihailenco/msgpack v4.0.4+incompatible h1:dSLoQfGFAo3F6OoNhwUmLwVgaUXK79GlxNBwueZn0xI= +github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= +github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= +github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= +github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= diff --git a/internal/backend/remote-state/gcs/go.sum b/internal/backend/remote-state/gcs/go.sum index 0dc3bb3f5b..220b894fe6 100644 --- a/internal/backend/remote-state/gcs/go.sum +++ b/internal/backend/remote-state/gcs/go.sum @@ -241,6 +241,11 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/ulikunitz/xz v0.5.10 h1:t92gobL9l3HE202wg3rlk19F6X+JOxl9BBrCCMYEYd8= github.com/ulikunitz/xz v0.5.10/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= +github.com/vmihailenco/msgpack v4.0.4+incompatible h1:dSLoQfGFAo3F6OoNhwUmLwVgaUXK79GlxNBwueZn0xI= +github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= +github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= +github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= +github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= diff --git a/internal/backend/remote-state/kubernetes/go.sum b/internal/backend/remote-state/kubernetes/go.sum index c75222e0d6..6c97cba80b 100644 --- a/internal/backend/remote-state/kubernetes/go.sum +++ b/internal/backend/remote-state/kubernetes/go.sum @@ -284,6 +284,11 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/ulikunitz/xz v0.5.10 h1:t92gobL9l3HE202wg3rlk19F6X+JOxl9BBrCCMYEYd8= github.com/ulikunitz/xz v0.5.10/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= +github.com/vmihailenco/msgpack v4.0.4+incompatible h1:dSLoQfGFAo3F6OoNhwUmLwVgaUXK79GlxNBwueZn0xI= +github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= +github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= +github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= +github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= diff --git a/internal/backend/remote-state/oci/go.sum b/internal/backend/remote-state/oci/go.sum index 0d4aed2232..7ac0dd30d5 100644 --- a/internal/backend/remote-state/oci/go.sum +++ b/internal/backend/remote-state/oci/go.sum @@ -245,6 +245,11 @@ github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsT github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/ulikunitz/xz v0.5.10 h1:t92gobL9l3HE202wg3rlk19F6X+JOxl9BBrCCMYEYd8= github.com/ulikunitz/xz v0.5.10/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= +github.com/vmihailenco/msgpack v4.0.4+incompatible h1:dSLoQfGFAo3F6OoNhwUmLwVgaUXK79GlxNBwueZn0xI= +github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= +github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= +github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= +github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= diff --git a/internal/backend/remote-state/oss/go.sum b/internal/backend/remote-state/oss/go.sum index 2248026050..462365158d 100644 --- a/internal/backend/remote-state/oss/go.sum +++ b/internal/backend/remote-state/oss/go.sum @@ -266,6 +266,11 @@ github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcU github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/ulikunitz/xz v0.5.10 h1:t92gobL9l3HE202wg3rlk19F6X+JOxl9BBrCCMYEYd8= github.com/ulikunitz/xz v0.5.10/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= +github.com/vmihailenco/msgpack v4.0.4+incompatible h1:dSLoQfGFAo3F6OoNhwUmLwVgaUXK79GlxNBwueZn0xI= +github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= +github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= +github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= +github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= diff --git a/internal/backend/remote-state/pg/go.sum b/internal/backend/remote-state/pg/go.sum index f8c7b20b5c..87c16c1acc 100644 --- a/internal/backend/remote-state/pg/go.sum +++ b/internal/backend/remote-state/pg/go.sum @@ -223,6 +223,11 @@ github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5 github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/ulikunitz/xz v0.5.10 h1:t92gobL9l3HE202wg3rlk19F6X+JOxl9BBrCCMYEYd8= github.com/ulikunitz/xz v0.5.10/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= +github.com/vmihailenco/msgpack v4.0.4+incompatible h1:dSLoQfGFAo3F6OoNhwUmLwVgaUXK79GlxNBwueZn0xI= +github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= +github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= +github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= +github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= diff --git a/internal/backend/remote-state/s3/go.sum b/internal/backend/remote-state/s3/go.sum index ed3c45b2bd..50de6c4317 100644 --- a/internal/backend/remote-state/s3/go.sum +++ b/internal/backend/remote-state/s3/go.sum @@ -289,6 +289,11 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/ulikunitz/xz v0.5.10 h1:t92gobL9l3HE202wg3rlk19F6X+JOxl9BBrCCMYEYd8= github.com/ulikunitz/xz v0.5.10/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= +github.com/vmihailenco/msgpack v4.0.4+incompatible h1:dSLoQfGFAo3F6OoNhwUmLwVgaUXK79GlxNBwueZn0xI= +github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= +github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= +github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= +github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= diff --git a/internal/providers/testing/provider_mock.go b/internal/providers/testing/provider_mock.go index bc5b3ddf06..f89538a2c2 100644 --- a/internal/providers/testing/provider_mock.go +++ b/internal/providers/testing/provider_mock.go @@ -871,6 +871,16 @@ func (p *MockProvider) ValidateStateStoreConfig(r providers.ValidateStateStoreCo return resp } + if p.ValidateStateStoreConfigResponse != nil { + return *p.ValidateStateStoreConfigResponse + } + + if p.ValidateStateStoreConfigFn != nil { + return p.ValidateStateStoreConfigFn(r) + } + + // In the absence of any custom logic, we do basic validation of the received config against the schema. + // // Marshall the value to replicate behavior by the GRPC protocol, // and return any relevant errors storeSchema, ok := p.getProviderSchema().StateStores[r.TypeName] @@ -879,20 +889,12 @@ func (p *MockProvider) ValidateStateStoreConfig(r providers.ValidateStateStoreCo return resp } - if p.ValidateStateStoreConfigResponse != nil { - return *p.ValidateStateStoreConfigResponse - } - _, err := msgpack.Marshal(r.Config, storeSchema.Body.ImpliedType()) if err != nil { resp.Diagnostics = resp.Diagnostics.Append(err) return resp } - if p.ValidateStateStoreConfigFn != nil { - return p.ValidateStateStoreConfigFn(r) - } - return resp } @@ -908,6 +910,12 @@ func (p *MockProvider) ConfigureStateStore(r providers.ConfigureStateStoreReques return resp } + if p.ConfigureStateStoreFn != nil { + return p.ConfigureStateStoreFn(r) + } + + // In the absence of any custom logic, we do the logic below. + // // Marshall the value to replicate behavior by the GRPC protocol, // and return any relevant errors storeSchema, ok := p.getProviderSchema().StateStores[r.TypeName] @@ -926,10 +934,6 @@ func (p *MockProvider) ConfigureStateStore(r providers.ConfigureStateStoreReques return resp } - if p.ConfigureStateStoreFn != nil { - return p.ConfigureStateStoreFn(r) - } - return resp } @@ -980,9 +984,6 @@ func (p *MockProvider) DeleteState(r providers.DeleteStateRequest) (resp provide if !p.ConfigureStateStoreCalled { resp.Diagnostics = resp.Diagnostics.Append(fmt.Errorf("ConfigureStateStore not called before DeleteState %q", r.TypeName)) } - if resp.Diagnostics.HasErrors() { - return resp - } if p.DeleteStateResponse != nil { return *p.DeleteStateResponse diff --git a/internal/states/remote/remote_grpc.go b/internal/states/remote/remote_grpc.go new file mode 100644 index 0000000000..996cda9887 --- /dev/null +++ b/internal/states/remote/remote_grpc.go @@ -0,0 +1,98 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package remote + +import ( + "github.com/hashicorp/terraform/internal/providers" + "github.com/hashicorp/terraform/internal/states/statemgr" +) + +// NewRemoteGRPC returns a remote state manager (remote.State) containing +// an implementation of remote.Client that allows Terraform to interact with +// a provider implementing pluggable state storage. +// +// The remote.Client implementation's methods invoke the provider's RPC +// methods to perform tasks like reading in state, locking, etc. +// +// NewRemoteGRPC requires these arguments to create the remote.Client: +// 1) the provider interface, needed to call gRPC methods +// 2) the name of the state storage implementation in the provider +// 3) the name of the state/the active workspace +func NewRemoteGRPC(provider providers.Interface, typeName string, stateId string) statemgr.Full { + mgr := &State{ + Client: &grpcClient{ + provider: provider, + typeName: typeName, + stateId: stateId, + }, + } + return mgr +} + +var ( + _ Client = &grpcClient{} + _ ClientLocker = &grpcClient{} +) + +// grpcClient acts like a client to enable the State state manager +// to communicate with a provider that implements pluggable state +// storage via gRPC. +// +// The calling code needs to provide information about the store's name +// and the name of the state (i.e. CE workspace) to use, as these are +// arguments required in gRPC requests. +type grpcClient struct { + provider providers.Interface + typeName string // the state storage implementation's name + stateId string +} + +// Get invokes the ReadStateBytes gRPC method in the plugin protocol +// and returns a copy of the downloaded state data. +// +// Implementation of remote.Client +func (g *grpcClient) Get() (*Payload, error) { + panic("not implemented yet") +} + +// Put invokes the WriteStateBytes gRPC method in the plugin protocol +// and to transfer state data to the remote location. +// +// Implementation of remote.Client +func (g *grpcClient) Put(state []byte) error { + panic("not implemented yet") +} + +// Delete invokes the DeleteState gRPC method in the plugin protocol +// to delete a named state in the remote location. +// +// NOTE: this is included to fulfil an interface, but deletion of +// workspaces is actually achieved through the backend.Backend +// interface's DeleteWorkspace method. +// +// Implementation of remote.Client +func (g *grpcClient) Delete() error { + req := providers.DeleteStateRequest{ + TypeName: g.typeName, + StateId: g.stateId, + } + resp := g.provider.DeleteState(req) + return resp.Diagnostics.Err() +} + +// Lock invokes the LockState gRPC method in the plugin protocol +// to lock a named state in the remote location. +// +// Implementation of remote.Client +func (g *grpcClient) Lock(*statemgr.LockInfo) (string, error) { + panic("not implemented yet") +} + +// Unlock invokes the UnlockState gRPC method in the plugin protocol +// to release a named lock on a specific state in the remote location. +// +// Implementation of remote.Client +func (g *grpcClient) Unlock(id string) error { + panic("not implemented yet") +} diff --git a/internal/states/remote/remote_grpc_test.go b/internal/states/remote/remote_grpc_test.go new file mode 100644 index 0000000000..f8645d3da2 --- /dev/null +++ b/internal/states/remote/remote_grpc_test.go @@ -0,0 +1,61 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package remote + +import ( + "testing" + + "github.com/hashicorp/terraform/internal/providers" + testing_provider "github.com/hashicorp/terraform/internal/providers/testing" +) + +// Testing grpcClient's Delete method. +// This method is needed to implement the remote.Client interface, but +// this is not invoked by the remote state manager (remote.State) that +// wil contain the client. +// +// In future we should remove the need for a Delete method in +// remote.Client, but for now it is implemented and tested. +func Test_grpcClient_Delete(t *testing.T) { + typeName := "foo_bar" // state store 'bar' in provider 'foo' + stateId := "production" + + provider := testing_provider.MockProvider{ + // Mock a provider and internal state store that + // have both been configured + ConfigureProviderCalled: true, + ConfigureStateStoreCalled: true, + + // Check values received by the provider from the Delete method. + DeleteStateFn: func(req providers.DeleteStateRequest) providers.DeleteStateResponse { + if req.TypeName != typeName || req.StateId != stateId { + t.Fatalf("expected provider DeleteState method to receive TypeName %q and StateId %q, instead got TypeName %q and StateId %q", + typeName, + stateId, + req.TypeName, + req.StateId) + } + return providers.DeleteStateResponse{ + // no diags + } + }, + } + + // Delete isn't accessible via a statemgr.Full, so we don't use NewRemoteGRPC. + // See comment above test for more information. + c := grpcClient{ + provider: &provider, + typeName: typeName, + stateId: stateId, + } + + err := c.Delete() + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + if !provider.DeleteStateCalled { + t.Fatal("expected Delete method to call DeleteState method on underlying provider, but it has not been called") + } +}