diff --git a/internal/providers/mock.go b/internal/providers/mock.go new file mode 100644 index 0000000000..f955c1b51b --- /dev/null +++ b/internal/providers/mock.go @@ -0,0 +1,231 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package providers + +import ( + "fmt" + + "github.com/zclconf/go-cty/cty" + + "github.com/hashicorp/terraform/internal/configs" + "github.com/hashicorp/terraform/internal/moduletest/mocking" + "github.com/hashicorp/terraform/internal/tfdiags" +) + +var _ Interface = (*Mock)(nil) + +// Mock is a mock provider that can be used by Terraform authors during test +// executions. +// +// The mock provider wraps an instance of an actual provider so it can return +// the correct schema and validate the configuration accurately. But, it +// intercepts calls to create resources or read data sources and instead reads +// and write the data to/from the state directly instead of needing to +// communicate with actual cloud providers. +// +// Callers can also specify the configs.MockData field to provide some preset +// data to return for any computed fields within the provider schema. The +// provider will make up random / junk data for any computed fields for which +// preset data is not available. +type Mock struct { + Provider Interface + Data *configs.MockData + + schema *GetProviderSchemaResponse +} + +func (m *Mock) GetProviderSchema() GetProviderSchemaResponse { + if m.schema == nil { + // Cache the schema, it's not changing. + schema := m.Provider.GetProviderSchema() + m.schema = &schema + } + return *m.schema +} + +func (m *Mock) ValidateProviderConfig(request ValidateProviderConfigRequest) (response ValidateProviderConfigResponse) { + // The config for the mocked providers is consistent, and validated when we + // parse the HCL directly. So we'll just make no change here. + return ValidateProviderConfigResponse{ + PreparedConfig: request.Config, + } +} + +func (m *Mock) ValidateResourceConfig(request ValidateResourceConfigRequest) ValidateResourceConfigResponse { + // We'll just pass this through to the underlying provider. The mock should + // support the same resource syntax as the original provider. + return m.Provider.ValidateResourceConfig(request) +} + +func (m *Mock) ValidateDataResourceConfig(request ValidateDataResourceConfigRequest) ValidateDataResourceConfigResponse { + // We'll just pass this through to the underlying provider. The mock should + // support the same data source syntax as the original provider. + return m.Provider.ValidateDataResourceConfig(request) +} + +func (m *Mock) UpgradeResourceState(request UpgradeResourceStateRequest) UpgradeResourceStateResponse { + // It's unlikely this will ever be called on a mocked provider, given they + // can only execute from inside tests. But we don't need to anything special + // here, let's just have the original provider handle it. + return m.Provider.UpgradeResourceState(request) +} + +func (m *Mock) ConfigureProvider(request ConfigureProviderRequest) (response ConfigureProviderResponse) { + // Do nothing here, we don't have anything to configure within the mocked + // providers and we don't want to call the original providers from here as + // they may try to talk to their underlying cloud providers. + return response +} + +func (m *Mock) Stop() error { + // Just stop the original resource. + return m.Provider.Stop() +} + +func (m *Mock) ReadResource(request ReadResourceRequest) ReadResourceResponse { + // For a mocked provider, reading a resource is just reading it from the + // state. So we'll return what we have. + // TODO(liamcervante): Can we do more than just say the state of resources + // never changes? What if we recomputed the values, so we can have drift + // if the value in the mocked data has changed? + return ReadResourceResponse{ + NewState: request.PriorState, + } +} + +func (m *Mock) PlanResourceChange(request PlanResourceChangeRequest) PlanResourceChangeResponse { + if request.ProposedNewState.IsNull() { + // Then we are deleting this resource - we don't need to do anything. + return PlanResourceChangeResponse{ + PlannedState: request.ProposedNewState, + PlannedPrivate: []byte("destroy"), + } + } + + if request.PriorState.IsNull() { + // Then we are creating this resource - we need to populate the computed + // null fields with unknowns so Terraform will render them properly. + + var response PlanResourceChangeResponse + + schema := m.GetProviderSchema() + response.Diagnostics = response.Diagnostics.Append(schema.Diagnostics) + if schema.Diagnostics.HasErrors() { + // We couldn't retrieve the schema for some reason, so the mock + // provider can't really function. + return response + } + + resource, exists := schema.ResourceTypes[request.TypeName] + if !exists { + // This means something has gone wrong much earlier, we should have + // failed a validation somewhere if a resource type doesn't exist. + panic(fmt.Errorf("failed to retrieve schema for resource %s", request.TypeName)) + } + + value, diags := mocking.PlanComputedValuesForResource(request.ProposedNewState, resource.Block) + response.Diagnostics = response.Diagnostics.Append(diags) + response.PlannedState = value + response.PlannedPrivate = []byte("create") + return response + } + + // Otherwise, we're just doing a simple update and we don't need to populate + // any values for that. + return PlanResourceChangeResponse{ + PlannedState: request.ProposedNewState, + PlannedPrivate: []byte("update"), + } +} + +func (m *Mock) ApplyResourceChange(request ApplyResourceChangeRequest) ApplyResourceChangeResponse { + switch string(request.PlannedPrivate) { + case "create": + // A new resource that we've created might have computed fields we need + // to populate. + + var response ApplyResourceChangeResponse + + schema := m.GetProviderSchema() + response.Diagnostics = response.Diagnostics.Append(schema.Diagnostics) + if schema.Diagnostics.HasErrors() { + // We couldn't retrieve the schema for some reason, so the mock + // provider can't really function. + return response + } + + resource, exists := schema.ResourceTypes[request.TypeName] + if !exists { + // This means something has gone wrong much earlier, we should have + // failed a validation somewhere if a resource type doesn't exist. + panic(fmt.Errorf("failed to retrieve schema for resource %s", request.TypeName)) + } + + replacement := mocking.ReplacementValue{ + Value: cty.NilVal, // If we have no data then we use cty.NilVal. + } + if mockedResource, exists := m.Data.MockResources[request.TypeName]; exists { + replacement.Value = mockedResource.Defaults + replacement.Range = mockedResource.DefaultsRange + } + + value, diags := mocking.ApplyComputedValuesForResource(request.PlannedState, replacement, resource.Block) + response.Diagnostics = response.Diagnostics.Append(diags) + response.NewState = value + return response + + default: + // For update or destroy operations, we don't have to create any values + // so we'll just return the planned state directly. + return ApplyResourceChangeResponse{ + NewState: request.PlannedState, + } + } +} + +func (m *Mock) ImportResourceState(request ImportResourceStateRequest) (response ImportResourceStateResponse) { + // Given mock providers only execute from within the test framework and it + // doesn't make a lot of sense why someone would want to import something + // during a test, we just don't support this at the moment. + // TODO(liamcervante): Find use cases for this? The existing syntax for + // mocks does make this possible but let's find a reason to do it first. + response.Diagnostics = response.Diagnostics.Append(tfdiags.Sourceless(tfdiags.Error, "Invalid import request", "Cannot import resources from mock providers.")) + return response +} + +func (m *Mock) ReadDataSource(request ReadDataSourceRequest) ReadDataSourceResponse { + var response ReadDataSourceResponse + + schema := m.GetProviderSchema() + response.Diagnostics = response.Diagnostics.Append(schema.Diagnostics) + if schema.Diagnostics.HasErrors() { + // We couldn't retrieve the schema for some reason, so the mock + // provider can't really function. + return response + } + + datasource, exists := schema.DataSources[request.TypeName] + if !exists { + // This means something has gone wrong much earlier, we should have + // failed a validation somewhere if a data source type doesn't exist. + panic(fmt.Errorf("failed to retrieve schema for data source %s", request.TypeName)) + } + + mockedData := mocking.ReplacementValue{ + Value: cty.NilVal, // If we have no mocked data we use cty.NilVal. + } + if mockedDataSource, exists := m.Data.MockDataSources[request.TypeName]; exists { + mockedData.Value = mockedDataSource.Defaults + mockedData.Range = mockedDataSource.DefaultsRange + } + + value, diags := mocking.ComputedValuesForDataSource(request.Config, mockedData, datasource.Block) + response.Diagnostics = response.Diagnostics.Append(diags) + response.State = value + return response +} + +func (m *Mock) Close() error { + return m.Provider.Close() +} diff --git a/internal/terraform/context_apply2_test.go b/internal/terraform/context_apply2_test.go index 11db793140..fcd0dab5f0 100644 --- a/internal/terraform/context_apply2_test.go +++ b/internal/terraform/context_apply2_test.go @@ -18,6 +18,7 @@ import ( "github.com/hashicorp/terraform/internal/addrs" "github.com/hashicorp/terraform/internal/checks" + "github.com/hashicorp/terraform/internal/configs" "github.com/hashicorp/terraform/internal/configs/configschema" "github.com/hashicorp/terraform/internal/lang/marks" "github.com/hashicorp/terraform/internal/plans" @@ -2324,3 +2325,134 @@ locals { t.Errorf("expected local value to be \"foo\" but was \"%s\"", module.LocalValues["local_value"].AsString()) } } + +func TestContext2Apply_mockProvider(t *testing.T) { + m := testModuleInline(t, map[string]string{ + "main.tf": ` +provider "test" {} + +data "test_object" "foo" {} + +resource "test_object" "foo" { + value = data.test_object.foo.output +} +`, + }) + + // Manually mark the provider config as being mocked. + m.Module.ProviderConfigs["test"].Mock = true + m.Module.ProviderConfigs["test"].MockData = &configs.MockData{ + MockDataSources: map[string]*configs.MockResource{ + "test_object": { + Mode: addrs.DataResourceMode, + Type: "test_object", + Defaults: cty.ObjectVal(map[string]cty.Value{ + "output": cty.StringVal("expected data output"), + }), + }, + }, + MockResources: map[string]*configs.MockResource{ + "test_object": { + Mode: addrs.ManagedResourceMode, + Type: "test_object", + Defaults: cty.ObjectVal(map[string]cty.Value{ + "output": cty.StringVal("expected resource output"), + }), + }, + }, + } + + testProvider := &MockProvider{ + GetProviderSchemaResponse: &providers.GetProviderSchemaResponse{ + ResourceTypes: map[string]providers.Schema{ + "test_object": { + Block: &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "value": { + Type: cty.String, + Required: true, + }, + "output": { + Type: cty.String, + Computed: true, + }, + }, + }, + }, + }, + DataSources: map[string]providers.Schema{ + "test_object": { + Block: &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "output": { + Type: cty.String, + Computed: true, + }, + }, + }, + }, + }, + }, + } + + reachedReadDataSourceFn := false + reachedPlanResourceChangeFn := false + reachedApplyResourceChangeFn := false + testProvider.ReadDataSourceFn = func(request providers.ReadDataSourceRequest) (resp providers.ReadDataSourceResponse) { + reachedReadDataSourceFn = true + cfg := request.Config.AsValueMap() + cfg["output"] = cty.StringVal("unexpected data output") + resp.State = cty.ObjectVal(cfg) + return resp + } + testProvider.PlanResourceChangeFn = func(request providers.PlanResourceChangeRequest) (resp providers.PlanResourceChangeResponse) { + reachedPlanResourceChangeFn = true + cfg := request.Config.AsValueMap() + cfg["output"] = cty.UnknownVal(cty.String) + resp.PlannedState = cty.ObjectVal(cfg) + return resp + } + testProvider.ApplyResourceChangeFn = func(request providers.ApplyResourceChangeRequest) (resp providers.ApplyResourceChangeResponse) { + reachedApplyResourceChangeFn = true + cfg := request.Config.AsValueMap() + cfg["output"] = cty.StringVal("unexpected resource output") + resp.NewState = cty.ObjectVal(cfg) + return resp + } + + ctx := testContext2(t, &ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("test"): testProviderFuncFixed(testProvider), + }, + }) + + plan, diags := ctx.Plan(m, states.NewState(), &PlanOpts{ + Mode: plans.NormalMode, + }) + if diags.HasErrors() { + t.Fatalf("expected no errors, but got %s", diags) + } + + state, diags := ctx.Apply(plan, m) + if diags.HasErrors() { + t.Fatalf("expected no errors, but got %s", diags) + } + + // Check we never made it to the actual provider. + if reachedReadDataSourceFn { + t.Errorf("read the data source in the provider when it should have been mocked") + } + if reachedPlanResourceChangeFn { + t.Errorf("planned the resource in the provider when it should have been mocked") + } + if reachedApplyResourceChangeFn { + t.Errorf("applied the resource in the provider when it should have been mocked") + } + + // Check we got the right data back from our mocked provider. + instance := state.ResourceInstance(mustResourceInstanceAddr("test_object.foo")) + expected := "{\"output\":\"expected resource output\",\"value\":\"expected data output\"}" + if diff := cmp.Diff(string(instance.Current.AttrsJSON), expected); len(diff) > 0 { + t.Errorf("expected:\n%s\nactual:\n%s\ndiff:\n%s", expected, string(instance.Current.AttrsJSON), diff) + } +} diff --git a/internal/terraform/eval_context.go b/internal/terraform/eval_context.go index cb1735218c..f2728e7af1 100644 --- a/internal/terraform/eval_context.go +++ b/internal/terraform/eval_context.go @@ -5,8 +5,11 @@ package terraform import ( "github.com/hashicorp/hcl/v2" + "github.com/zclconf/go-cty/cty" + "github.com/hashicorp/terraform/internal/addrs" "github.com/hashicorp/terraform/internal/checks" + "github.com/hashicorp/terraform/internal/configs" "github.com/hashicorp/terraform/internal/configs/configschema" "github.com/hashicorp/terraform/internal/instances" "github.com/hashicorp/terraform/internal/lang" @@ -16,7 +19,6 @@ import ( "github.com/hashicorp/terraform/internal/refactoring" "github.com/hashicorp/terraform/internal/states" "github.com/hashicorp/terraform/internal/tfdiags" - "github.com/zclconf/go-cty/cty" ) // EvalContext is the interface that is given to eval nodes to execute. @@ -41,7 +43,7 @@ type EvalContext interface { // It is an error to initialize the same provider more than once. This // method will panic if the module instance address of the given provider // configuration does not match the Path() of the EvalContext. - InitProvider(addr addrs.AbsProviderConfig) (providers.Interface, error) + InitProvider(addr addrs.AbsProviderConfig, configs *configs.Provider) (providers.Interface, error) // Provider gets the provider instance with the given address (already // initialized) or returns nil if the provider isn't initialized. diff --git a/internal/terraform/eval_context_builtin.go b/internal/terraform/eval_context_builtin.go index 36b896a875..5d5bce9886 100644 --- a/internal/terraform/eval_context_builtin.go +++ b/internal/terraform/eval_context_builtin.go @@ -14,6 +14,7 @@ import ( "github.com/hashicorp/terraform/internal/addrs" "github.com/hashicorp/terraform/internal/checks" + "github.com/hashicorp/terraform/internal/configs" "github.com/hashicorp/terraform/internal/configs/configschema" "github.com/hashicorp/terraform/internal/instances" "github.com/hashicorp/terraform/internal/lang" @@ -118,7 +119,7 @@ func (ctx *BuiltinEvalContext) Input() UIInput { return ctx.InputValue } -func (ctx *BuiltinEvalContext) InitProvider(addr addrs.AbsProviderConfig) (providers.Interface, error) { +func (ctx *BuiltinEvalContext) InitProvider(addr addrs.AbsProviderConfig, config *configs.Provider) (providers.Interface, error) { // If we already initialized, it is an error if p := ctx.Provider(addr); p != nil { return nil, fmt.Errorf("%s is already initialized", addr) @@ -137,6 +138,17 @@ func (ctx *BuiltinEvalContext) InitProvider(addr addrs.AbsProviderConfig) (provi } log.Printf("[TRACE] BuiltinEvalContext: Initialized %q provider for %s", addr.String(), addr) + + // The config might be nil, if there was no config block defined for this + // provider. + if config != nil && config.Mock { + log.Printf("[TRACE] BuiltinEvalContext: Mocked %q provider for %s", addr.String(), addr) + p = &providers.Mock{ + Provider: p, + Data: config.MockData, + } + } + ctx.ProviderCache[key] = p return p, nil diff --git a/internal/terraform/eval_context_builtin_test.go b/internal/terraform/eval_context_builtin_test.go index bfcc546f4b..47199ae78b 100644 --- a/internal/terraform/eval_context_builtin_test.go +++ b/internal/terraform/eval_context_builtin_test.go @@ -8,9 +8,11 @@ import ( "sync" "testing" + "github.com/zclconf/go-cty/cty" + "github.com/hashicorp/terraform/internal/addrs" + "github.com/hashicorp/terraform/internal/configs" "github.com/hashicorp/terraform/internal/providers" - "github.com/zclconf/go-cty/cty" ) func TestBuiltinEvalContextProviderInput(t *testing.T) { @@ -75,15 +77,27 @@ func TestBuildingEvalContextInitProvider(t *testing.T) { Provider: addrs.NewDefaultProvider("test"), Alias: "foo", } + providerAddrMock := addrs.AbsProviderConfig{ + Module: addrs.RootModule, + Provider: addrs.NewDefaultProvider("test"), + Alias: "mock", + } - _, err := ctx.InitProvider(providerAddrDefault) + _, err := ctx.InitProvider(providerAddrDefault, nil) if err != nil { t.Fatalf("error initializing provider test: %s", err) } - _, err = ctx.InitProvider(providerAddrAlias) + _, err = ctx.InitProvider(providerAddrAlias, nil) if err != nil { t.Fatalf("error initializing provider test.foo: %s", err) } + + _, err = ctx.InitProvider(providerAddrMock, &configs.Provider{ + Mock: true, + }) + if err != nil { + t.Fatalf("error initializing provider test.mock: %s", err) + } } func testBuiltinEvalContext(t *testing.T) *BuiltinEvalContext { diff --git a/internal/terraform/eval_context_mock.go b/internal/terraform/eval_context_mock.go index 19829feaf6..d01a4fb113 100644 --- a/internal/terraform/eval_context_mock.go +++ b/internal/terraform/eval_context_mock.go @@ -6,8 +6,12 @@ package terraform import ( "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/hcldec" + "github.com/zclconf/go-cty/cty" + "github.com/zclconf/go-cty/cty/convert" + "github.com/hashicorp/terraform/internal/addrs" "github.com/hashicorp/terraform/internal/checks" + "github.com/hashicorp/terraform/internal/configs" "github.com/hashicorp/terraform/internal/configs/configschema" "github.com/hashicorp/terraform/internal/instances" "github.com/hashicorp/terraform/internal/lang" @@ -17,8 +21,6 @@ import ( "github.com/hashicorp/terraform/internal/refactoring" "github.com/hashicorp/terraform/internal/states" "github.com/hashicorp/terraform/internal/tfdiags" - "github.com/zclconf/go-cty/cty" - "github.com/zclconf/go-cty/cty/convert" ) // MockEvalContext is a mock version of EvalContext that can be used @@ -177,7 +179,7 @@ func (c *MockEvalContext) Input() UIInput { return c.InputInput } -func (c *MockEvalContext) InitProvider(addr addrs.AbsProviderConfig) (providers.Interface, error) { +func (c *MockEvalContext) InitProvider(addr addrs.AbsProviderConfig, _ *configs.Provider) (providers.Interface, error) { c.InitProviderCalled = true c.InitProviderType = addr.String() c.InitProviderAddr = addr diff --git a/internal/terraform/node_provider.go b/internal/terraform/node_provider.go index 36e8e3a5c0..ce2715a09a 100644 --- a/internal/terraform/node_provider.go +++ b/internal/terraform/node_provider.go @@ -8,10 +8,11 @@ import ( "log" "github.com/hashicorp/hcl/v2" + "github.com/zclconf/go-cty/cty" + "github.com/hashicorp/terraform/internal/configs/configschema" "github.com/hashicorp/terraform/internal/providers" "github.com/hashicorp/terraform/internal/tfdiags" - "github.com/zclconf/go-cty/cty" ) // NodeApplyableProvider represents a provider during an apply. @@ -25,7 +26,7 @@ var ( // GraphNodeExecutable func (n *NodeApplyableProvider) Execute(ctx EvalContext, op walkOperation) (diags tfdiags.Diagnostics) { - _, err := ctx.InitProvider(n.Addr) + _, err := ctx.InitProvider(n.Addr, n.Config) diags = diags.Append(err) if diags.HasErrors() { return diags diff --git a/internal/terraform/node_provider_eval.go b/internal/terraform/node_provider_eval.go index 98c8946a31..87dd299ede 100644 --- a/internal/terraform/node_provider_eval.go +++ b/internal/terraform/node_provider_eval.go @@ -17,6 +17,6 @@ var _ GraphNodeExecutable = (*NodeEvalableProvider)(nil) // GraphNodeExecutable func (n *NodeEvalableProvider) Execute(ctx EvalContext, op walkOperation) (diags tfdiags.Diagnostics) { - _, err := ctx.InitProvider(n.Addr) + _, err := ctx.InitProvider(n.Addr, n.Config) return diags.Append(err) }