| // Copyright IBM Corp. 2014, 2026 | |
| // SPDX-License-Identifier: BUSL-1.1 | |
| package testing | |
| import ( | |
| "fmt" | |
| "path" | |
| "strings" | |
| "sync" | |
| "time" | |
| "github.com/hashicorp/go-uuid" | |
| "github.com/zclconf/go-cty/cty" | |
| "github.com/hashicorp/terraform/internal/configs/configschema" | |
| "github.com/hashicorp/terraform/internal/providers" | |
| "github.com/hashicorp/terraform/internal/providers/testing" | |
| "github.com/hashicorp/terraform/internal/tfdiags" | |
| ) | |
| var ( | |
| ProviderSchema = &providers.GetProviderSchemaResponse{ | |
| Provider: providers.Schema{ | |
| Body: &configschema.Block{ | |
| Attributes: map[string]*configschema.Attribute{ | |
| "data_prefix": {Type: cty.String, Optional: true}, | |
| "resource_prefix": {Type: cty.String, Optional: true}, | |
| }, | |
| }, | |
| }, | |
| ResourceTypes: map[string]providers.Schema{ | |
| "test_resource": { | |
| Body: &configschema.Block{ | |
| Attributes: map[string]*configschema.Attribute{ | |
| "id": {Type: cty.String, Optional: true, Computed: true}, | |
| "value": {Type: cty.String, Optional: true}, | |
| "interrupt_count": {Type: cty.Number, Optional: true}, | |
| "destroy_fail": {Type: cty.Bool, Optional: true, Computed: true}, | |
| "apply_fail": {Type: cty.Bool, Optional: true}, | |
| "create_wait_seconds": {Type: cty.Number, Optional: true}, | |
| "destroy_wait_seconds": {Type: cty.Number, Optional: true}, | |
| "write_only": {Type: cty.String, Optional: true, WriteOnly: true}, | |
| "defer": {Type: cty.Bool, Optional: true}, | |
| }, | |
| }, | |
| }, | |
| }, | |
| DataSources: map[string]providers.Schema{ | |
| "test_data_source": { | |
| Body: &configschema.Block{ | |
| Attributes: map[string]*configschema.Attribute{ | |
| "id": {Type: cty.String, Required: true}, | |
| "value": {Type: cty.String, Computed: true}, | |
| "write_only": {Type: cty.String, Optional: true, WriteOnly: true}, | |
| // We never actually reference these values from a data | |
| // source, but we have tests that use the same cty.Value | |
| // to represent a test_resource and a test_data_source | |
| // so the schemas have to match. | |
| "interrupt_count": {Type: cty.Number, Computed: true}, | |
| "destroy_fail": {Type: cty.Bool, Computed: true}, | |
| "apply_fail": {Type: cty.Bool, Optional: true}, | |
| "create_wait_seconds": {Type: cty.Number, Computed: true}, | |
| "destroy_wait_seconds": {Type: cty.Number, Computed: true}, | |
| "defer": {Type: cty.Bool, Computed: true}, | |
| }, | |
| }, | |
| }, | |
| }, | |
| EphemeralResourceTypes: map[string]providers.Schema{ | |
| "test_ephemeral_resource": { | |
| Body: &configschema.Block{ | |
| Attributes: map[string]*configschema.Attribute{ | |
| "value": { | |
| Type: cty.String, | |
| Computed: true, | |
| }, | |
| }, | |
| }, | |
| }, | |
| }, | |
| Functions: map[string]providers.FunctionDecl{ | |
| "is_true": { | |
| Parameters: []providers.FunctionParam{ | |
| { | |
| Name: "input", | |
| Type: cty.Bool, | |
| AllowNullValue: false, | |
| AllowUnknownValues: false, | |
| }, | |
| }, | |
| ReturnType: cty.Bool, | |
| }, | |
| }, | |
| } | |
| ) | |
| // TestProvider is a wrapper around terraform.MockProvider that defines dynamic | |
| // schemas, and keeps track of the resources and data sources that it contains. | |
| type TestProvider struct { | |
| Provider *testing.MockProvider | |
| data, resource cty.Value | |
| Interrupt chan<- struct{} | |
| Store *ResourceStore | |
| } | |
| // NewProvider creates a new TestProvider for use in tests. | |
| // | |
| // If you provide an empty or nil *ResourceStore argument this is equivalent to the provider | |
| // not having provisioned any remote objects prior to the test's events. | |
| // | |
| // If you provide a *ResourceStore containing values, those cty.Values represent remote objects | |
| // that the provider has 'already' provisioned and can return information about immediately in a test. | |
| func NewProvider(store *ResourceStore) *TestProvider { | |
| if store == nil { | |
| store = &ResourceStore{ | |
| Data: make(map[string]cty.Value), | |
| } | |
| } | |
| provider := &TestProvider{ | |
| Provider: new(testing.MockProvider), | |
| Store: store, | |
| } | |
| provider.Provider.GetProviderSchemaResponse = ProviderSchema | |
| provider.Provider.ConfigureProviderFn = provider.ConfigureProvider | |
| provider.Provider.PlanResourceChangeFn = provider.PlanResourceChange | |
| provider.Provider.ApplyResourceChangeFn = provider.ApplyResourceChange | |
| provider.Provider.ReadResourceFn = provider.ReadResource | |
| provider.Provider.ReadDataSourceFn = provider.ReadDataSource | |
| provider.Provider.CallFunctionFn = provider.CallFunction | |
| provider.Provider.OpenEphemeralResourceFn = provider.OpenEphemeralResource | |
| provider.Provider.CloseEphemeralResourceFn = provider.CloseEphemeralResource | |
| return provider | |
| } | |
| func (provider *TestProvider) DataPrefix() string { | |
| var prefix string | |
| if !provider.data.IsNull() && provider.data.IsKnown() { | |
| prefix = provider.data.AsString() | |
| } | |
| return prefix | |
| } | |
| func (provider *TestProvider) SetDataPrefix(prefix string) { | |
| provider.data = cty.StringVal(prefix) | |
| } | |
| func (provider *TestProvider) GetDataKey(id string) string { | |
| if !provider.data.IsNull() && provider.data.IsKnown() { | |
| return path.Join(provider.data.AsString(), id) | |
| } | |
| return id | |
| } | |
| func (provider *TestProvider) ResourcePrefix() string { | |
| var prefix string | |
| if !provider.resource.IsNull() && provider.resource.IsKnown() { | |
| prefix = provider.resource.AsString() | |
| } | |
| return prefix | |
| } | |
| func (provider *TestProvider) SetResourcePrefix(prefix string) { | |
| provider.resource = cty.StringVal(prefix) | |
| } | |
| func (provider *TestProvider) GetResourceKey(id string) string { | |
| if !provider.resource.IsNull() && provider.resource.IsKnown() { | |
| return path.Join(provider.resource.AsString(), id) | |
| } | |
| return id | |
| } | |
| func (provider *TestProvider) ResourceString() string { | |
| return provider.string(provider.ResourcePrefix()) | |
| } | |
| func (provider *TestProvider) ResourceCount() int { | |
| return provider.count(provider.ResourcePrefix()) | |
| } | |
| func (provider *TestProvider) DataSourceString() string { | |
| return provider.string(provider.DataPrefix()) | |
| } | |
| func (provider *TestProvider) DataSourceCount() int { | |
| return provider.count(provider.DataPrefix()) | |
| } | |
| func (provider *TestProvider) count(prefix string) int { | |
| defer provider.Store.beginRead()() | |
| if len(prefix) == 0 { | |
| return len(provider.Store.Data) | |
| } | |
| count := 0 | |
| for key := range provider.Store.Data { | |
| if strings.HasPrefix(key, prefix) { | |
| count++ | |
| } | |
| } | |
| return count | |
| } | |
| func (provider *TestProvider) string(prefix string) string { | |
| defer provider.Store.beginRead()() | |
| var keys []string | |
| for key := range provider.Store.Data { | |
| if strings.HasPrefix(key, prefix) { | |
| keys = append(keys, key) | |
| } | |
| } | |
| return strings.Join(keys, ", ") | |
| } | |
| func (provider *TestProvider) ConfigureProvider(request providers.ConfigureProviderRequest) providers.ConfigureProviderResponse { | |
| provider.resource = request.Config.GetAttr("resource_prefix") | |
| provider.data = request.Config.GetAttr("data_prefix") | |
| return providers.ConfigureProviderResponse{} | |
| } | |
| func (provider *TestProvider) PlanResourceChange(request providers.PlanResourceChangeRequest) providers.PlanResourceChangeResponse { | |
| if request.ProposedNewState.IsNull() { | |
| var deferred *providers.Deferred | |
| if shouldBeDeferred := request.PriorState.GetAttr("defer"); !shouldBeDeferred.IsNull() && shouldBeDeferred.True() { | |
| deferred = &providers.Deferred{ | |
| Reason: providers.DeferredReasonResourceConfigUnknown, | |
| } | |
| } | |
| // Then this is a delete operation. | |
| return providers.PlanResourceChangeResponse{ | |
| PlannedState: request.ProposedNewState, | |
| Deferred: deferred, | |
| } | |
| } | |
| resource := request.ProposedNewState | |
| if id := resource.GetAttr("id"); !id.IsKnown() || id.IsNull() { | |
| vals := resource.AsValueMap() | |
| vals["id"] = cty.UnknownVal(cty.String) | |
| resource = cty.ObjectVal(vals) | |
| } | |
| if destroyFail := resource.GetAttr("destroy_fail"); !destroyFail.IsKnown() || destroyFail.IsNull() { | |
| vals := resource.AsValueMap() | |
| vals["destroy_fail"] = cty.UnknownVal(cty.Bool) | |
| resource = cty.ObjectVal(vals) | |
| } | |
| if writeOnly := resource.GetAttr("write_only"); !writeOnly.IsNull() { | |
| vals := resource.AsValueMap() | |
| vals["write_only"] = cty.NullVal(cty.String) | |
| resource = cty.ObjectVal(vals) | |
| } | |
| var deferred *providers.Deferred | |
| if shouldBeDeferred := resource.GetAttr("defer"); !shouldBeDeferred.IsKnown() || (!shouldBeDeferred.IsNull() && shouldBeDeferred.True()) { | |
| deferred = &providers.Deferred{ | |
| Reason: providers.DeferredReasonResourceConfigUnknown, | |
| } | |
| } | |
| return providers.PlanResourceChangeResponse{ | |
| PlannedState: resource, | |
| Deferred: deferred, | |
| } | |
| } | |
| func (provider *TestProvider) ApplyResourceChange(request providers.ApplyResourceChangeRequest) providers.ApplyResourceChangeResponse { | |
| if request.PlannedState.IsNull() { | |
| // Then this is a delete operation. | |
| if destroyFail := request.PriorState.GetAttr("destroy_fail"); destroyFail.IsKnown() && destroyFail.True() { | |
| var diags tfdiags.Diagnostics | |
| diags = diags.Append(tfdiags.Sourceless(tfdiags.Error, "Failed to destroy resource", "destroy_fail is set to true")) | |
| return providers.ApplyResourceChangeResponse{ | |
| Diagnostics: diags, | |
| } | |
| } | |
| if wait := request.PriorState.GetAttr("destroy_wait_seconds"); !wait.IsNull() && wait.IsKnown() { | |
| waitTime, _ := wait.AsBigFloat().Int64() | |
| time.Sleep(time.Second * time.Duration(waitTime)) | |
| } | |
| provider.Store.Delete(provider.GetResourceKey(request.PriorState.GetAttr("id").AsString())) | |
| return providers.ApplyResourceChangeResponse{ | |
| NewState: request.PlannedState, | |
| } | |
| } | |
| resource := request.PlannedState | |
| id := resource.GetAttr("id") | |
| if !id.IsKnown() { | |
| val, err := uuid.GenerateUUID() | |
| if err != nil { | |
| panic(fmt.Errorf("failed to generate uuid: %v", err)) | |
| } | |
| id = cty.StringVal(val) | |
| vals := resource.AsValueMap() | |
| vals["id"] = id | |
| resource = cty.ObjectVal(vals) | |
| } | |
| if interrupts := resource.GetAttr("interrupt_count"); !interrupts.IsNull() && interrupts.IsKnown() && provider.Interrupt != nil { | |
| count, _ := interrupts.AsBigFloat().Int64() | |
| for ix := 0; ix < int(count); ix++ { | |
| provider.Interrupt <- struct{}{} | |
| } | |
| // Wait for a second to make sure the interrupts are processed by | |
| // Terraform before the provider finishes. This is an attempt to ensure | |
| // the output of any tests that rely on this behaviour is deterministic. | |
| time.Sleep(time.Second) | |
| } | |
| if wait := resource.GetAttr("create_wait_seconds"); !wait.IsNull() && wait.IsKnown() { | |
| waitTime, _ := wait.AsBigFloat().Int64() | |
| time.Sleep(time.Second * time.Duration(waitTime)) | |
| } | |
| if destroyFail := resource.GetAttr("destroy_fail"); !destroyFail.IsKnown() { | |
| vals := resource.AsValueMap() | |
| vals["destroy_fail"] = cty.False | |
| resource = cty.ObjectVal(vals) | |
| } | |
| if applyFail := resource.GetAttr("apply_fail"); !applyFail.IsNull() && applyFail.IsKnown() && applyFail.True() { | |
| var diags tfdiags.Diagnostics | |
| diags = diags.Append(fmt.Errorf("apply_fail is set to true")) | |
| return providers.ApplyResourceChangeResponse{ | |
| NewState: cty.NilVal, | |
| Diagnostics: diags, | |
| } | |
| } | |
| provider.Store.Put(provider.GetResourceKey(id.AsString()), resource) | |
| return providers.ApplyResourceChangeResponse{ | |
| NewState: resource, | |
| } | |
| } | |
| func (provider *TestProvider) ReadResource(request providers.ReadResourceRequest) providers.ReadResourceResponse { | |
| var diags tfdiags.Diagnostics | |
| id := request.PriorState.GetAttr("id").AsString() | |
| resource := provider.Store.Get(provider.GetResourceKey(id)) | |
| if resource == cty.NilVal { | |
| diags = diags.Append(tfdiags.Sourceless(tfdiags.Error, "not found", fmt.Sprintf("%s does not exist", id))) | |
| } | |
| return providers.ReadResourceResponse{ | |
| NewState: resource, | |
| Diagnostics: diags, | |
| } | |
| } | |
| func (provider *TestProvider) ReadDataSource(request providers.ReadDataSourceRequest) providers.ReadDataSourceResponse { | |
| var diags tfdiags.Diagnostics | |
| id := request.Config.GetAttr("id").AsString() | |
| resource := provider.Store.Get(provider.GetDataKey(id)) | |
| if resource == cty.NilVal { | |
| diags = diags.Append(tfdiags.Sourceless(tfdiags.Error, "not found", fmt.Sprintf("%s does not exist", id))) | |
| } | |
| if writeOnly := resource.GetAttr("write_only"); !writeOnly.IsNull() { | |
| vals := resource.AsValueMap() | |
| vals["write_only"] = cty.NullVal(cty.String) | |
| resource = cty.ObjectVal(vals) | |
| } | |
| return providers.ReadDataSourceResponse{ | |
| State: resource, | |
| Diagnostics: diags, | |
| } | |
| } | |
| func (provider *TestProvider) CallFunction(request providers.CallFunctionRequest) providers.CallFunctionResponse { | |
| switch request.FunctionName { | |
| case "is_true": | |
| return providers.CallFunctionResponse{ | |
| Result: request.Arguments[0], | |
| } | |
| default: | |
| return providers.CallFunctionResponse{ | |
| Err: fmt.Errorf("unknown function %q", request.FunctionName), | |
| } | |
| } | |
| } | |
| func (provider *TestProvider) OpenEphemeralResource(providers.OpenEphemeralResourceRequest) (resp providers.OpenEphemeralResourceResponse) { | |
| resp.Result = cty.ObjectVal(map[string]cty.Value{ | |
| "value": cty.StringVal("bar"), | |
| }) | |
| return resp | |
| } | |
| func (provider *TestProvider) CloseEphemeralResource(providers.CloseEphemeralResourceRequest) (resp providers.CloseEphemeralResourceResponse) { | |
| return resp | |
| } | |
| // ResourceStore manages a set of cty.Value resources that can be shared between | |
| // TestProvider providers. | |
| // | |
| // A ResourceStore represents the remote objects that a test provider is managing. | |
| // For example, when the test provider gets a ReadResource request it will search | |
| // the store for a resource with a matching ID. See (*TestProvider).ReadResource. | |
| type ResourceStore struct { | |
| mutex sync.RWMutex | |
| Data map[string]cty.Value | |
| } | |
| func (store *ResourceStore) Delete(key string) cty.Value { | |
| defer store.beginWrite()() | |
| if resource, ok := store.Data[key]; ok { | |
| delete(store.Data, key) | |
| return resource | |
| } | |
| return cty.NilVal | |
| } | |
| func (store *ResourceStore) Get(key string) cty.Value { | |
| defer store.beginRead()() | |
| return store.get(key) | |
| } | |
| func (store *ResourceStore) Put(key string, resource cty.Value) cty.Value { | |
| defer store.beginWrite()() | |
| old := store.get(key) | |
| store.Data[key] = resource | |
| return old | |
| } | |
| func (store *ResourceStore) get(key string) cty.Value { | |
| if resource, ok := store.Data[key]; ok { | |
| return resource | |
| } | |
| return cty.NilVal | |
| } | |
| func (store *ResourceStore) beginWrite() func() { | |
| store.mutex.Lock() | |
| return store.mutex.Unlock | |
| } | |
| func (store *ResourceStore) beginRead() func() { | |
| store.mutex.RLock() | |
| return store.mutex.RUnlock | |
| } |