| // Copyright (c) The OpenTofu Authors | |
| // SPDX-License-Identifier: MPL-2.0 | |
| // Copyright (c) 2023 HashiCorp, Inc. | |
| // SPDX-License-Identifier: MPL-2.0 | |
| package testing | |
| import ( | |
| "fmt" | |
| "path" | |
| "strings" | |
| "sync" | |
| "time" | |
| "github.com/hashicorp/go-uuid" | |
| "github.com/zclconf/go-cty/cty" | |
| "github.com/opentofu/opentofu/internal/configs/configschema" | |
| "github.com/opentofu/opentofu/internal/providers" | |
| "github.com/opentofu/opentofu/internal/tfdiags" | |
| "github.com/opentofu/opentofu/internal/tofu" | |
| ) | |
| var ( | |
| ProviderSchema = &providers.GetProviderSchemaResponse{ | |
| Provider: providers.Schema{ | |
| Block: &configschema.Block{ | |
| Attributes: map[string]*configschema.Attribute{ | |
| "data_prefix": {Type: cty.String, Optional: true}, | |
| "resource_prefix": {Type: cty.String, Optional: true}, | |
| "username": {Type: cty.String, Optional: true}, | |
| "password": {Type: cty.String, Optional: true}, | |
| }, | |
| BlockTypes: map[string]*configschema.NestedBlock{ | |
| "block_single": { | |
| Block: configschema.Block{ | |
| Attributes: map[string]*configschema.Attribute{ | |
| "string_attr": {Type: cty.String, Optional: true}, | |
| }, | |
| }, | |
| Nesting: configschema.NestingSingle, | |
| }, | |
| }, | |
| }, | |
| }, | |
| ResourceTypes: map[string]providers.Schema{ | |
| "test_resource": { | |
| Block: &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}, | |
| }, | |
| }, | |
| }, | |
| }, | |
| DataSources: map[string]providers.Schema{ | |
| "test_data_source": { | |
| Block: &configschema.Block{ | |
| Attributes: map[string]*configschema.Attribute{ | |
| "id": {Type: cty.String, Required: true}, | |
| "value": {Type: cty.String, Computed: true}, | |
| "interrupt_count": {Type: cty.Number, Computed: true}, | |
| }, | |
| }, | |
| }, | |
| }, | |
| // TODO ephemeral testing support - when implementing testing support for ephemeral resources, consider configuring ephemeral schema here | |
| } | |
| ) | |
| // TestProvider is a wrapper around tofu.MockProvider that defines dynamic | |
| // schemas, and keeps track of the resources and data sources that it contains. | |
| type TestProvider struct { | |
| Provider *tofu.MockProvider | |
| data, resource cty.Value | |
| Interrupt chan<- struct{} | |
| Store *ResourceStore | |
| } | |
| func NewProvider(store *ResourceStore) *TestProvider { | |
| if store == nil { | |
| store = &ResourceStore{ | |
| Data: make(map[string]cty.Value), | |
| } | |
| } | |
| provider := &TestProvider{ | |
| Provider: new(tofu.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 | |
| 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 { | |
| provider.Store.mutex.RLock() | |
| defer provider.Store.mutex.RUnlock() | |
| 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 { | |
| provider.Store.mutex.RLock() | |
| defer provider.Store.mutex.RUnlock() | |
| 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() { | |
| // Then this is a delete operation. | |
| return providers.PlanResourceChangeResponse{ | |
| PlannedState: request.ProposedNewState, | |
| } | |
| } | |
| 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) | |
| } | |
| return providers.PlanResourceChangeResponse{ | |
| PlannedState: resource, | |
| } | |
| } | |
| func (provider *TestProvider) ApplyResourceChange(request providers.ApplyResourceChangeRequest) providers.ApplyResourceChangeResponse { | |
| if request.PlannedState.IsNull() { | |
| // Then this is a delete operation. | |
| 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: %w", err)) | |
| } | |
| id = cty.StringVal(val) | |
| vals := resource.AsValueMap() | |
| vals["id"] = id | |
| resource = cty.ObjectVal(vals) | |
| } | |
| interrupts := resource.GetAttr("interrupt_count") | |
| if !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 | |
| // OpenTofu 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) | |
| } | |
| 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))) | |
| } | |
| return providers.ReadDataSourceResponse{ | |
| State: resource, | |
| Diagnostics: diags, | |
| } | |
| } | |
| // ResourceStore manages a set of cty.Value resources that can be shared between | |
| // TestProvider providers. | |
| type ResourceStore struct { | |
| mutex sync.RWMutex | |
| Data map[string]cty.Value | |
| } | |
| func (store *ResourceStore) Delete(key string) cty.Value { | |
| store.mutex.Lock() | |
| defer store.mutex.Unlock() | |
| if resource, ok := store.Data[key]; ok { | |
| delete(store.Data, key) | |
| return resource | |
| } | |
| return cty.NilVal | |
| } | |
| func (store *ResourceStore) Get(key string) cty.Value { | |
| store.mutex.RLock() | |
| defer store.mutex.RUnlock() | |
| return store.get(key) | |
| } | |
| func (store *ResourceStore) Put(key string, resource cty.Value) cty.Value { | |
| store.mutex.Lock() | |
| defer store.mutex.Unlock() | |
| 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 | |
| } |