diff --git a/.changes/v1.17/ENHANCEMENTS-20260727-182840.yaml b/.changes/v1.17/ENHANCEMENTS-20260727-182840.yaml new file mode 100644 index 000000000000..aa9673781543 --- /dev/null +++ b/.changes/v1.17/ENHANCEMENTS-20260727-182840.yaml @@ -0,0 +1,5 @@ +kind: ENHANCEMENTS +body: Add mock_provider support for ephemeral resources +time: 2026-07-27T18:28:40.309122+02:00 +custom: + Issue: "38928" diff --git a/internal/configs/mock_provider.go b/internal/configs/mock_provider.go index db653b1acd14..9959e132e30c 100644 --- a/internal/configs/mock_provider.go +++ b/internal/configs/mock_provider.go @@ -100,9 +100,10 @@ func useForPlan(content *hcl.BodyContent, def bool) (bool, hcl.Diagnostics) { // MockData packages up all the available mock and override data available to // a mocked provider. type MockData struct { - MockResources map[string]*MockResource - MockDataSources map[string]*MockResource - Overrides addrs.Map[addrs.Targetable, *Override] + MockResources map[string]*MockResource + MockDataSources map[string]*MockResource + MockEphemeralResources map[string]*MockResource + Overrides addrs.Map[addrs.Targetable, *Override] } // Merge will merge the target MockData object into the current MockData. @@ -151,6 +152,24 @@ func (data *MockData) Merge(other *MockData, skipCollisions bool) (diags hcl.Dia Subject: datasource.TypeRange.Ptr(), }) } + for name, ephemeral := range other.MockEphemeralResources { + current, exists := data.MockEphemeralResources[name] + if !exists { + data.MockEphemeralResources[name] = ephemeral + continue + } + + if skipCollisions { + continue + } + + diags = append(diags, &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Duplicate mock resource block", + Detail: fmt.Sprintf("A mock_ephemeral %q block already exists at %s.", name, current.Range), + Subject: ephemeral.TypeRange.Ptr(), + }) + } for _, elem := range other.Overrides.Elems { target, override := elem.Key, elem.Value @@ -234,14 +253,15 @@ func decodeMockDataBody(body hcl.Body, useForPlanDefault bool, source OverrideSo diags = append(diags, contentDiags...) data := &MockData{ - MockResources: make(map[string]*MockResource), - MockDataSources: make(map[string]*MockResource), - Overrides: addrs.MakeMap[addrs.Targetable, *Override](), + MockResources: make(map[string]*MockResource), + MockDataSources: make(map[string]*MockResource), + MockEphemeralResources: make(map[string]*MockResource), + Overrides: addrs.MakeMap[addrs.Targetable, *Override](), } for _, block := range content.Blocks { switch block.Type { - case "mock_resource", "mock_data": + case "mock_resource", "mock_data", "mock_ephemeral": resource, resourceDiags := decodeMockResourceBlock(block, useForPlanDefault) diags = append(diags, resourceDiags...) @@ -269,6 +289,17 @@ func decodeMockDataBody(body hcl.Body, useForPlanDefault bool, source OverrideSo continue } data.MockDataSources[resource.Type] = resource + case addrs.EphemeralResourceMode: + if previous, ok := data.MockEphemeralResources[resource.Type]; ok { + diags = append(diags, &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Duplicate mock_ephemeral block", + Detail: fmt.Sprintf("A mock_ephemeral block for %s has already been defined at %s.", resource.Type, previous.Range), + Subject: resource.TypeRange.Ptr(), + }) + continue + } + data.MockEphemeralResources[resource.Type] = resource } } case "override_resource": @@ -305,6 +336,23 @@ func decodeMockDataBody(body hcl.Body, useForPlanDefault bool, source OverrideSo } data.Overrides.Put(subject, override) } + case "override_ephemeral": + override, overrideDiags := decodeOverrideEphemeralBlock(block, useForPlanDefault, source) + diags = append(diags, overrideDiags...) + + if override != nil && override.Target != nil { + subject := override.Target.Subject + if previous, ok := data.Overrides.GetOk(subject); ok { + diags = append(diags, &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Duplicate override_ephemeral block", + Detail: fmt.Sprintf("An override_ephemeral block targeting %s has already been defined at %s.", subject, previous.Range), + Subject: override.Range.Ptr(), + }) + continue + } + data.Overrides.Put(subject, override) + } } } @@ -328,6 +376,8 @@ func decodeMockResourceBlock(block *hcl.Block, useForPlanDefault bool) (*MockRes resource.Mode = addrs.ManagedResourceMode case "mock_data": resource.Mode = addrs.DataResourceMode + case "mock_ephemeral": + resource.Mode = addrs.EphemeralResourceMode } if defaults, exists := content.Attributes["defaults"]; exists { @@ -404,6 +454,43 @@ func decodeOverrideResourceBlock(block *hcl.Block, useForPlanDefault bool, sourc return override, diags } +func decodeOverrideEphemeralBlock(block *hcl.Block, useForPlanDefault bool, source OverrideSource) (*Override, hcl.Diagnostics) { + override, diags := decodeOverrideBlock(block, "values", "override_ephemeral", useForPlanDefault, source) + + if override.Target != nil { + var mode addrs.ResourceMode + + switch override.Target.Subject.AddrType() { + case addrs.AbsResourceInstanceAddrType: + subject := override.Target.Subject.(addrs.AbsResourceInstance) + mode = subject.Resource.Resource.Mode + case addrs.AbsResourceAddrType: + subject := override.Target.Subject.(addrs.AbsResource) + mode = subject.Resource.Mode + default: + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid override target", + Detail: fmt.Sprintf("You can only target ephemeral resources from override_ephemeral blocks, not %s.", override.Target.Subject), + Subject: override.TargetRange.Ptr(), + }) + return nil, diags + } + + if mode != addrs.EphemeralResourceMode { + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid override target", + Detail: fmt.Sprintf("You can only target ephemeral resources from override_ephemeral blocks, not %s.", override.Target.Subject), + Subject: override.TargetRange.Ptr(), + }) + return nil, diags + } + } + + return override, diags +} + func decodeOverrideDataBlock(block *hcl.Block, useForPlanDefault bool, source OverrideSource) (*Override, hcl.Diagnostics) { override, diags := decodeOverrideBlock(block, "values", "override_data", useForPlanDefault, source) @@ -512,8 +599,10 @@ var mockDataSchema = &hcl.BodySchema{ Blocks: []hcl.BlockHeaderSchema{ {Type: "mock_resource", LabelNames: []string{"type"}}, {Type: "mock_data", LabelNames: []string{"type"}}, + {Type: "mock_ephemeral", LabelNames: []string{"type"}}, {Type: "override_resource"}, {Type: "override_data"}, + {Type: "override_ephemeral"}, }, } diff --git a/internal/configs/mock_provider_test.go b/internal/configs/mock_provider_test.go index 67700f4fd896..a8669c5a7848 100644 --- a/internal/configs/mock_provider_test.go +++ b/internal/configs/mock_provider_test.go @@ -232,6 +232,87 @@ func TestMockData_Merge(t *testing.T) { ), }, }, + "ephemeral_no_collisions": { + current: &MockData{ + MockResources: map[string]*MockResource{}, + MockDataSources: map[string]*MockResource{}, + MockEphemeralResources: map[string]*MockResource{ + "test_ephemeral": { + Mode: addrs.EphemeralResourceMode, + Type: "test_ephemeral", + Defaults: cty.StringVal("current"), + }, + }, + Overrides: addrs.MakeMap[addrs.Targetable, *Override](), + }, + target: &MockData{ + MockResources: map[string]*MockResource{}, + MockDataSources: map[string]*MockResource{}, + MockEphemeralResources: map[string]*MockResource{ + "test_ephemeral_two": { + Mode: addrs.EphemeralResourceMode, + Type: "test_ephemeral_two", + Defaults: cty.StringVal("target"), + }, + }, + Overrides: addrs.MakeMap[addrs.Targetable, *Override](), + }, + result: &MockData{ + MockResources: map[string]*MockResource{}, + MockDataSources: map[string]*MockResource{}, + MockEphemeralResources: map[string]*MockResource{ + "test_ephemeral": { + Mode: addrs.EphemeralResourceMode, + Type: "test_ephemeral", + Defaults: cty.StringVal("current"), + }, + "test_ephemeral_two": { + Mode: addrs.EphemeralResourceMode, + Type: "test_ephemeral_two", + Defaults: cty.StringVal("target"), + }, + }, + Overrides: addrs.MakeMap[addrs.Targetable, *Override](), + }, + }, + "ephemeral_collision_skipped": { + current: &MockData{ + MockResources: map[string]*MockResource{}, + MockDataSources: map[string]*MockResource{}, + MockEphemeralResources: map[string]*MockResource{ + "test_ephemeral": { + Mode: addrs.EphemeralResourceMode, + Type: "test_ephemeral", + Defaults: cty.StringVal("current"), + }, + }, + Overrides: addrs.MakeMap[addrs.Targetable, *Override](), + }, + target: &MockData{ + MockResources: map[string]*MockResource{}, + MockDataSources: map[string]*MockResource{}, + MockEphemeralResources: map[string]*MockResource{ + "test_ephemeral": { + Mode: addrs.EphemeralResourceMode, + Type: "test_ephemeral", + Defaults: cty.StringVal("target"), + }, + }, + Overrides: addrs.MakeMap[addrs.Targetable, *Override](), + }, + result: &MockData{ + MockResources: map[string]*MockResource{}, + MockDataSources: map[string]*MockResource{}, + MockEphemeralResources: map[string]*MockResource{ + "test_ephemeral": { + Mode: addrs.EphemeralResourceMode, + Type: "test_ephemeral", + Defaults: cty.StringVal("current"), // current wins when skipCollisions=true + }, + }, + Overrides: addrs.MakeMap[addrs.Targetable, *Override](), + }, + }, } for name, tc := range tcs { @@ -538,6 +619,25 @@ func validateMockData(t *testing.T, actual, expected *MockData) { } } + // Validate mock ephemeral resources. + + for key, actual := range actual.MockEphemeralResources { + expected, exists := expected.MockEphemeralResources[key] + if !exists { + t.Errorf("actual mock ephemeral resources contained %s but expected mock ephemeral resources did not", key) + continue + } + + validateValues(t, key, actual.Defaults, expected.Defaults) + } + + for key := range expected.MockEphemeralResources { + _, exists := actual.MockEphemeralResources[key] + if !exists { + t.Errorf("expected mock ephemeral resources contained %s but actual mock ephemeral resources did not", key) + } + } + // Validate the overrides. for _, elem := range actual.Overrides.Elems { diff --git a/internal/providers/mock.go b/internal/providers/mock.go index 8ef75df50e3f..0675f219fb98 100644 --- a/internal/providers/mock.go +++ b/internal/providers/mock.go @@ -362,59 +362,49 @@ func (m *Mock) ReadDataSource(request ReadDataSourceRequest) ReadDataSourceRespo return response } -func (m *Mock) ValidateEphemeralResourceConfig(ValidateEphemeralResourceConfigRequest) ValidateEphemeralResourceConfigResponse { - var diags tfdiags.Diagnostics - diags = diags.Append(tfdiags.AttributeValue( - tfdiags.Error, - "No ephemeral resource types in mock providers", - "The provider mocking mechanism does not yet support ephemeral resource types.", - nil, // the topmost configuration object - )) - return ValidateEphemeralResourceConfigResponse{ - Diagnostics: diags, - } +func (m *Mock) ValidateEphemeralResourceConfig(request ValidateEphemeralResourceConfigRequest) ValidateEphemeralResourceConfigResponse { + // Delegate to the underlying provider so it can validate the configuration + // against the real schema, exactly as we do for resources and data sources. + return m.Provider.ValidateEphemeralResourceConfig(request) } -func (m *Mock) OpenEphemeralResource(OpenEphemeralResourceRequest) OpenEphemeralResourceResponse { - // FIXME: Design some means to mock an ephemeral resource type. - var diags tfdiags.Diagnostics - diags = diags.Append(tfdiags.AttributeValue( - tfdiags.Error, - "No ephemeral resource types in mock providers", - "The provider mocking mechanism does not yet support ephemeral resource types.", - nil, // the topmost configuration object - )) - return OpenEphemeralResourceResponse{ - Diagnostics: diags, +func (m *Mock) OpenEphemeralResource(request OpenEphemeralResourceRequest) OpenEphemeralResourceResponse { + var response OpenEphemeralResourceResponse + + schema := m.GetProviderSchema() + response.Diagnostics = response.Diagnostics.Append(schema.Diagnostics) + if schema.Diagnostics.HasErrors() { + return response + } + + ephemeralSchema, exists := schema.EphemeralResourceTypes[request.TypeName] + if !exists { + // Should have been caught during validation. + panic(fmt.Errorf("failed to retrieve schema for ephemeral resource %s", request.TypeName)) } + + mockedData := &mocking.MockedData{ + Value: cty.NilVal, // If we have no mocked data we use cty.NilVal. + } + if mockedEphemeral, exists := m.Data.MockEphemeralResources[request.TypeName]; exists { + mockedData.Value = mockedEphemeral.Defaults + mockedData.Range = mockedEphemeral.DefaultsRange + } + + value, diags := mocking.ComputedValuesForDataSource(request.Config, mockedData, ephemeralSchema.Body) + response.Diagnostics = response.Diagnostics.Append(diags) + response.Result = value + return response } func (m *Mock) RenewEphemeralResource(RenewEphemeralResourceRequest) RenewEphemeralResourceResponse { - // FIXME: Design some means to mock an ephemeral resource type. - var diags tfdiags.Diagnostics - diags = diags.Append(tfdiags.AttributeValue( - tfdiags.Error, - "No ephemeral resource types in mock providers", - "The provider mocking mechanism does not yet support ephemeral resource types.", - nil, // the topmost configuration object - )) - return RenewEphemeralResourceResponse{ - Diagnostics: diags, - } + // Mocked ephemeral resources never expire, so renewal is a no-op. + return RenewEphemeralResourceResponse{} } func (m *Mock) CloseEphemeralResource(CloseEphemeralResourceRequest) CloseEphemeralResourceResponse { - // FIXME: Design some means to mock an ephemeral resource type. - var diags tfdiags.Diagnostics - diags = diags.Append(tfdiags.AttributeValue( - tfdiags.Error, - "No ephemeral resource types in mock providers", - "The provider mocking mechanism does not yet support ephemeral resource types.", - nil, // the topmost configuration object - )) - return CloseEphemeralResourceResponse{ - Diagnostics: diags, - } + // Nothing to close for a mocked ephemeral resource. + return CloseEphemeralResourceResponse{} } func (m *Mock) CallFunction(request CallFunctionRequest) CallFunctionResponse { diff --git a/internal/terraform/context_apply_overrides_test.go b/internal/terraform/context_apply_overrides_test.go index bdc8431ef5ea..62073995c7ab 100644 --- a/internal/terraform/context_apply_overrides_test.go +++ b/internal/terraform/context_apply_overrides_test.go @@ -711,6 +711,29 @@ resource "test_instance" "resource" { }), outputs: cty.EmptyObjectVal, }, + "ephemeral_override": { + configs: map[string]string{ + "main.tf": ` + ephemeral "test_ephemeral" "secret" { + input = "my-input" + } + + output "result" { + ephemeral = true + value = ephemeral.test_ephemeral.secret.result + } + `, + }, + overrides: mocking.OverridesForTesting(nil, func(overrides addrs.Map[addrs.Targetable, *configs.Override]) { + overrides.Put(mustResourceInstanceAddr("ephemeral.test_ephemeral.secret"), &configs.Override{ + Values: cty.ObjectVal(map[string]cty.Value{ + "result": cty.StringVal("mocked-secret"), + }), + }) + }), + // Ephemeral outputs are not stored in state; no outputs to compare. + outputs: cty.EmptyObjectVal, + }, } for name, tc := range tcs { t.Run(name, func(t *testing.T) { @@ -722,9 +745,10 @@ resource "test_instance" "resource" { }) plan, diags := ctx.Plan(cfg, states.NewState(), &PlanOpts{ - Mode: plans.NormalMode, - Overrides: tc.overrides, - GenerateConfigPath: "out.tf", + Mode: plans.NormalMode, + Overrides: tc.overrides, + GenerateConfigPath: "out.tf", + AllowRootEphemeralOutputs: true, }) if len(tc.expectedErr) > 0 { if diags.ErrWithWarnings().Error() != tc.expectedErr { @@ -737,32 +761,42 @@ resource "test_instance" "resource" { t.Fatal(diags.Err()) } - state, diags := ctx.Apply(plan, cfg, nil) + state, diags := ctx.Apply(plan, cfg, &ApplyOpts{AllowRootEphemeralOutputs: true}) if diags.HasErrors() { t.Fatal(diags.Err()) } - outputs := make(map[string]cty.Value, len(cfg.Module.Outputs)) + outputs := make(map[string]cty.Value) for _, output := range cfg.Module.Outputs { + if output.Ephemeral { + continue + } outputs[output.Name] = state.OutputValue(output.Addr().Absolute(addrs.RootModuleInstance)).Value } - actual := cty.ObjectVal(outputs) + var actual cty.Value + if len(outputs) > 0 { + actual = cty.ObjectVal(outputs) + } else { + actual = cty.EmptyObjectVal + } if !actual.RawEquals(tc.outputs) { t.Fatalf("expected:\n%s\nactual:\n%s", tc.outputs.GoString(), actual.GoString()) } _, diags = ctx.Plan(cfg, state, &PlanOpts{ - Mode: plans.RefreshOnlyMode, - Overrides: tc.overrides, + Mode: plans.RefreshOnlyMode, + AllowRootEphemeralOutputs: true, + Overrides: tc.overrides, }) if diags.HasErrors() { t.Fatal(diags.Err()) } destroyPlan, diags := ctx.Plan(cfg, state, &PlanOpts{ - Mode: plans.DestroyMode, - Overrides: tc.overrides, + Mode: plans.DestroyMode, + AllowRootEphemeralOutputs: true, + Overrides: tc.overrides, }) if diags.HasErrors() { t.Fatal(diags.Err()) @@ -824,6 +858,22 @@ var underlyingOverridesProvider = &testing_provider.MockProvider{ }, }, }, + EphemeralResourceTypes: map[string]providers.Schema{ + "test_ephemeral": { + Body: &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "input": { + Type: cty.String, + Required: true, + }, + "result": { + Type: cty.String, + Computed: true, + }, + }, + }, + }, + }, }, ReadResourceFn: func(request providers.ReadResourceRequest) providers.ReadResourceResponse { panic("ReadResourceFn called, should have been overridden.") @@ -840,4 +890,7 @@ var underlyingOverridesProvider = &testing_provider.MockProvider{ ImportResourceStateFn: func(request providers.ImportResourceStateRequest) providers.ImportResourceStateResponse { panic("ImportResourceStateFn called, should have been overridden.") }, + OpenEphemeralResourceFn: func(request providers.OpenEphemeralResourceRequest) providers.OpenEphemeralResourceResponse { + panic("OpenEphemeralResourceFn called, should have been overridden.") + }, } diff --git a/internal/terraform/node_resource_apply_instance.go b/internal/terraform/node_resource_apply_instance.go index 37c0e415e321..6a265f657533 100644 --- a/internal/terraform/node_resource_apply_instance.go +++ b/internal/terraform/node_resource_apply_instance.go @@ -108,6 +108,7 @@ func (n *NodeApplyableResourceInstance) ephemeralResourceExecute(ctx EvalContext addr: n.Addr, config: n.Config, providerConfig: n.ResolvedProvider, + override: n.override, }) return diags diff --git a/internal/terraform/node_resource_ephemeral.go b/internal/terraform/node_resource_ephemeral.go index 58b465df5859..177b26fa855d 100644 --- a/internal/terraform/node_resource_ephemeral.go +++ b/internal/terraform/node_resource_ephemeral.go @@ -13,6 +13,7 @@ import ( "github.com/hashicorp/terraform/internal/addrs" "github.com/hashicorp/terraform/internal/configs" "github.com/hashicorp/terraform/internal/lang/marks" + "github.com/hashicorp/terraform/internal/moduletest/mocking" "github.com/hashicorp/terraform/internal/plans" "github.com/hashicorp/terraform/internal/plans/objchange" "github.com/hashicorp/terraform/internal/providers" @@ -24,6 +25,7 @@ type ephemeralResourceInput struct { addr addrs.AbsResourceInstance config *configs.Resource providerConfig addrs.AbsProviderConfig + override *configs.Override } // ephemeralResourceOpen implements the "open" step of the ephemeral resource @@ -113,6 +115,26 @@ func ephemeralResourceOpen(ctx EvalContext, inp ephemeralResourceInput) (*provid return nil, diags } + // If we have an override for this ephemeral resource, apply it directly + // without calling the provider — same approach used for data sources. + if inp.override != nil { + overrideVal, overrideDiags := mocking.ComputedValuesForDataSource(configVal, &mocking.MockedData{ + Value: inp.override.Values, + Range: inp.override.Range, + }, schema.Body) + diags = diags.Append(overrideDiags) + if diags.HasErrors() { + return nil, diags + } + + resultVal := overrideVal.Mark(marks.Ephemeral) + ephemerals.RegisterInstance(ctx.StopCtx(), inp.addr, ephemeral.ResourceInstanceRegistration{ + Value: resultVal, + ConfigBody: config.Config, + }) + return nil, diags + } + validateResp := provider.ValidateEphemeralResourceConfig(providers.ValidateEphemeralResourceConfigRequest{ TypeName: inp.addr.Resource.Resource.Type, Config: unmarkedConfigVal, diff --git a/internal/terraform/node_resource_plan_instance.go b/internal/terraform/node_resource_plan_instance.go index 2bb786e8505f..aee758963eff 100644 --- a/internal/terraform/node_resource_plan_instance.go +++ b/internal/terraform/node_resource_plan_instance.go @@ -171,6 +171,7 @@ func (n *NodePlannableResourceInstance) ephemeralResourceExecute(ctx EvalContext addr: n.Addr, config: n.Config, providerConfig: n.ResolvedProvider, + override: n.override, }) if deferred != nil {