From ae365bbf81cca4980f99c9c03aeb2e208e1ed33c Mon Sep 17 00:00:00 2001 From: Martin Atkins Date: Mon, 29 Apr 2024 11:49:07 -0700 Subject: [PATCH 01/23] providers: New Interface methods for ephemeral resource types --- .../builtin/providers/terraform/provider.go | 17 +- internal/plugin/grpc_provider.go | 49 +++++ internal/plugin6/grpc_provider.go | 49 +++++ internal/provider-simple-v6/provider.go | 18 ++ internal/provider-simple/provider.go | 18 ++ internal/providers/ephemeral.go | 187 ++++++++++++++++++ internal/providers/mock.go | 42 ++++ internal/providers/provider.go | 13 ++ internal/providers/testing/provider_mock.go | 82 ++++++++ internal/refactoring/mock_provider.go | 12 ++ .../internal/stackeval/stubs/errored.go | 28 +++ .../internal/stackeval/stubs/unknown.go | 34 ++++ 12 files changed, 548 insertions(+), 1 deletion(-) create mode 100644 internal/providers/ephemeral.go diff --git a/internal/builtin/providers/terraform/provider.go b/internal/builtin/providers/terraform/provider.go index 9c6af3240273..627c0e3235e8 100644 --- a/internal/builtin/providers/terraform/provider.go +++ b/internal/builtin/providers/terraform/provider.go @@ -169,7 +169,7 @@ func (p *Provider) ImportResourceState(req providers.ImportResourceStateRequest) return importDataStore(req) } - panic("unimplemented - terraform_remote_state has no resources") + panic("unimplemented - terraform.io/builtin/terraform has no managed resource types") } // MoveResourceState requests that the given resource be moved. @@ -191,6 +191,21 @@ func (p *Provider) ValidateResourceConfig(req providers.ValidateResourceConfigRe return validateDataStoreResourceConfig(req) } +// CloseEphemeral implements providers.Interface. +func (p *Provider) CloseEphemeral(providers.CloseEphemeralRequest) providers.CloseEphemeralResponse { + panic("unimplemented - terraform.io/builtin/terraform has no ephemeral resource types") +} + +// OpenEphemeral implements providers.Interface. +func (p *Provider) OpenEphemeral(providers.OpenEphemeralRequest) providers.OpenEphemeralResponse { + panic("unimplemented - terraform.io/builtin/terraform has no ephemeral resource types") +} + +// RenewEphemeral implements providers.Interface. +func (p *Provider) RenewEphemeral(providers.RenewEphemeralRequest) providers.RenewEphemeralResponse { + panic("unimplemented - terraform.io/builtin/terraform has no ephemeral resource types") +} + // CallFunction would call a function contributed by this provider, but this // provider has no functions and so this function just panics. func (p *Provider) CallFunction(req providers.CallFunctionRequest) providers.CallFunctionResponse { diff --git a/internal/plugin/grpc_provider.go b/internal/plugin/grpc_provider.go index b3b15a0b530e..613720a397c9 100644 --- a/internal/plugin/grpc_provider.go +++ b/internal/plugin/grpc_provider.go @@ -21,6 +21,7 @@ import ( "github.com/hashicorp/terraform/internal/logging" "github.com/hashicorp/terraform/internal/plugin/convert" "github.com/hashicorp/terraform/internal/providers" + "github.com/hashicorp/terraform/internal/tfdiags" proto "github.com/hashicorp/terraform/internal/tfplugin5" ) @@ -767,6 +768,54 @@ func (p *GRPCProvider) ReadDataSource(r providers.ReadDataSourceRequest) (resp p return resp } +func (p *GRPCProvider) OpenEphemeral(r providers.OpenEphemeralRequest) (resp providers.OpenEphemeralResponse) { + logger.Trace("GRPCProvider: OpenEphemeral") + + // There is not yet any support for plugin-based providers to offer + // ephemeral resource types. We should not be able to get here because + // a GRPCProvider should never advertise in its schema that it supports + // ephemeral resource types. + resp.Diagnostics = resp.Diagnostics.Append(tfdiags.AttributeValue( + tfdiags.Error, + "Ephemeral resources not supported", + "This provider does not offer any ephemeral resource types.", + nil, + )) + return resp +} + +func (p *GRPCProvider) RenewEphemeral(r providers.RenewEphemeralRequest) (resp providers.RenewEphemeralResponse) { + logger.Trace("GRPCProvider: RenewEphemeral") + + // There is not yet any support for plugin-based providers to offer + // ephemeral resource types. We should not be able to get here because + // a GRPCProvider should never advertise in its schema that it supports + // ephemeral resource types. + resp.Diagnostics = resp.Diagnostics.Append(tfdiags.AttributeValue( + tfdiags.Error, + "Ephemeral resources not supported", + "This provider does not offer any ephemeral resource types.", + nil, + )) + return resp +} + +func (p *GRPCProvider) CloseEphemeral(r providers.CloseEphemeralRequest) (resp providers.CloseEphemeralResponse) { + logger.Trace("GRPCProvider: CloseEphemeral") + + // There is not yet any support for plugin-based providers to offer + // ephemeral resource types. We should not be able to get here because + // a GRPCProvider should never advertise in its schema that it supports + // ephemeral resource types. + resp.Diagnostics = resp.Diagnostics.Append(tfdiags.AttributeValue( + tfdiags.Error, + "Ephemeral resources not supported", + "This provider does not offer any ephemeral resource types.", + nil, + )) + return resp +} + func (p *GRPCProvider) CallFunction(r providers.CallFunctionRequest) (resp providers.CallFunctionResponse) { logger.Trace("GRPCProvider", "CallFunction", r.FunctionName) diff --git a/internal/plugin6/grpc_provider.go b/internal/plugin6/grpc_provider.go index 52b620f8b458..d54e9ad73da2 100644 --- a/internal/plugin6/grpc_provider.go +++ b/internal/plugin6/grpc_provider.go @@ -21,6 +21,7 @@ import ( "github.com/hashicorp/terraform/internal/logging" "github.com/hashicorp/terraform/internal/plugin6/convert" "github.com/hashicorp/terraform/internal/providers" + "github.com/hashicorp/terraform/internal/tfdiags" proto6 "github.com/hashicorp/terraform/internal/tfplugin6" ) @@ -756,6 +757,54 @@ func (p *GRPCProvider) ReadDataSource(r providers.ReadDataSourceRequest) (resp p return resp } +func (p *GRPCProvider) OpenEphemeral(r providers.OpenEphemeralRequest) (resp providers.OpenEphemeralResponse) { + logger.Trace("GRPCProvider.v6", "OpenEphemeral", r.TypeName) + + // There is not yet any support for plugin-based providers to offer + // ephemeral resource types. We should not be able to get here because + // a GRPCProvider should never advertise in its schema that it supports + // ephemeral resource types. + resp.Diagnostics = resp.Diagnostics.Append(tfdiags.AttributeValue( + tfdiags.Error, + "Ephemeral resources not supported", + "This provider does not offer any ephemeral resource types.", + nil, + )) + return resp +} + +func (p *GRPCProvider) RenewEphemeral(r providers.RenewEphemeralRequest) (resp providers.RenewEphemeralResponse) { + logger.Trace("GRPCProvider.v6", "RenewEphemeral", r.TypeName) + + // There is not yet any support for plugin-based providers to offer + // ephemeral resource types. We should not be able to get here because + // a GRPCProvider should never advertise in its schema that it supports + // ephemeral resource types. + resp.Diagnostics = resp.Diagnostics.Append(tfdiags.AttributeValue( + tfdiags.Error, + "Ephemeral resources not supported", + "This provider does not offer any ephemeral resource types.", + nil, + )) + return resp +} + +func (p *GRPCProvider) CloseEphemeral(r providers.CloseEphemeralRequest) (resp providers.CloseEphemeralResponse) { + logger.Trace("GRPCProvider.v6", "CloseEphemeral", r.TypeName) + + // There is not yet any support for plugin-based providers to offer + // ephemeral resource types. We should not be able to get here because + // a GRPCProvider should never advertise in its schema that it supports + // ephemeral resource types. + resp.Diagnostics = resp.Diagnostics.Append(tfdiags.AttributeValue( + tfdiags.Error, + "Ephemeral resources not supported", + "This provider does not offer any ephemeral resource types.", + nil, + )) + return resp +} + func (p *GRPCProvider) CallFunction(r providers.CallFunctionRequest) (resp providers.CallFunctionResponse) { logger.Trace("GRPCProvider.v6", "CallFunction", r.FunctionName) diff --git a/internal/provider-simple-v6/provider.go b/internal/provider-simple-v6/provider.go index 833468f2822b..a7b23bfb379e 100644 --- a/internal/provider-simple-v6/provider.go +++ b/internal/provider-simple-v6/provider.go @@ -171,6 +171,24 @@ func (s simple) ReadDataSource(req providers.ReadDataSourceRequest) (resp provid return resp } +func (s simple) OpenEphemeral(providers.OpenEphemeralRequest) providers.OpenEphemeralResponse { + // Our schema doesn't include any ephemeral resource types, so it should be + // impossible to get in here. + panic("OpenEphemeral on provider that didn't declare any ephemeral resource types") +} + +func (s simple) RenewEphemeral(providers.RenewEphemeralRequest) providers.RenewEphemeralResponse { + // Our schema doesn't include any ephemeral resource types, so it should be + // impossible to get in here. + panic("RenewEphemeral on provider that didn't declare any ephemeral resource types") +} + +func (s simple) CloseEphemeral(providers.CloseEphemeralRequest) providers.CloseEphemeralResponse { + // Our schema doesn't include any ephemeral resource types, so it should be + // impossible to get in here. + panic("CloseEphemeral on provider that didn't declare any ephemeral resource types") +} + func (s simple) CallFunction(req providers.CallFunctionRequest) (resp providers.CallFunctionResponse) { if req.FunctionName != "noop" { resp.Err = fmt.Errorf("CallFunction for undefined function %q", req.FunctionName) diff --git a/internal/provider-simple/provider.go b/internal/provider-simple/provider.go index ebd14ab54a93..a5c44ee05d86 100644 --- a/internal/provider-simple/provider.go +++ b/internal/provider-simple/provider.go @@ -144,6 +144,24 @@ func (s simple) ReadDataSource(req providers.ReadDataSourceRequest) (resp provid return resp } +func (s simple) OpenEphemeral(providers.OpenEphemeralRequest) providers.OpenEphemeralResponse { + // Our schema doesn't include any ephemeral resource types, so it should be + // impossible to get in here. + panic("OpenEphemeral on provider that didn't declare any ephemeral resource types") +} + +func (s simple) RenewEphemeral(providers.RenewEphemeralRequest) providers.RenewEphemeralResponse { + // Our schema doesn't include any ephemeral resource types, so it should be + // impossible to get in here. + panic("RenewEphemeral on provider that didn't declare any ephemeral resource types") +} + +func (s simple) CloseEphemeral(providers.CloseEphemeralRequest) providers.CloseEphemeralResponse { + // Our schema doesn't include any ephemeral resource types, so it should be + // impossible to get in here. + panic("CloseEphemeral on provider that didn't declare any ephemeral resource types") +} + func (s simple) CallFunction(req providers.CallFunctionRequest) (resp providers.CallFunctionResponse) { // Our schema doesn't include any functions, so it should be impossible // to get in here. diff --git a/internal/providers/ephemeral.go b/internal/providers/ephemeral.go new file mode 100644 index 000000000000..39c5a7c981f7 --- /dev/null +++ b/internal/providers/ephemeral.go @@ -0,0 +1,187 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package providers + +import ( + "time" + + "github.com/hashicorp/terraform/internal/tfdiags" + "github.com/zclconf/go-cty/cty" +) + +// OpenEphemeralRequest represents the arguments for the OpenEphemeral +// operation on a provider. +type OpenEphemeralRequest struct { + // TypeName is the type of ephemeral resource to open. This should + // only be one of the type names previously reported in the provider's + // schema. + TypeName string + + // Config is an object-typed value representing the configuration for + // the ephemeral resource instance that the caller is trying to open. + // + // The object type of this value always conforms to the resource type + // schema's implied type, and uses null values to represent attributes + // that were not explicitly assigned in the configuration block. + // Computed-only attributes are always null in the configuration, because + // they can be set only in the reponse. + Config cty.Value +} + +// OpenEphemeralRequest represents the response from an OpenEphemeral +// operation on a provider. +type OpenEphemeralResponse struct { + // Deferred, if present, signals that the provider doesn't have enough + // information to open this ephemeral resource instance. + // + // This implies that any other side-effect-performing object must have + // its planning deferred if its planning operation indirectly depends on + // this ephemeral resource result. For example, if a provider configuration + // refers to an ephemeral resource whose opening is deferred then the + // affected provider configuration must not be instantiated and any + // resource instances that belong to it must have their planning immediately + // deferred. + Deferred *Deferred + + // Result is an object-typed value representing the newly-opened session + // with the opened ephemeral object. + // + // The object type of this value always conforms to the resource type + // schema's implied type. Unknown values are forbidden unless the + // Deferred field is set, in which case the Result represents the provider's + // best approximation of the final object using unknown values in any + // location where a final value cannot be predicted. + Result cty.Value + + // InternalContext is any internal data needed by the provider to + // perform a subsequent [Interface.CloseEphemeral] request for the same + // object. The provider may choose any encoding format to represent the + // needed data, because Terraform Core treats this field as opaque. + // + // Providers should aim to keep this data relatively compact to minimize + // overhead. Although Terraform Core does not enforce a specific limit + // just for this field, it would be very unusual for the internal context + // to be more than 256 bytes in size, and in most cases it should be + // on the order of only tens of bytes. For example, a lease ID for the + // remote system is a reasonable thing to encode here. + // + // Because ephemeral resource instances never outlive a single Terraform + // Core phase, it's guaranteed that a CloseEphemeral request will be + // received by exactly the same plugin instance that returned this + // value, and so it's valid for this to refer to in-memory state belonging + // to the provider instance. + InternalContext []byte + + // Renew, if set, signals that the opened object has an inherent expration + // time and so must be "renewed" if Terraform needs to use it beyond that + // expiration time. + // + // If a provider sets this field then it may receive a subsequent + // [Interface.RenewEphemeral] call, if Terraform expects to need the + // object beyond the expiration time. + Renew *EphemeralRenew + + // Diagnostics describes any problems encountered while opening the + // ephemeral resource. If this contains errors then the other response + // fields must be assumed invalid. + Diagnostics tfdiags.Diagnostics +} + +// EphemeralRenew describes when and how Terraform Core must request renewal +// of an ephemeral resource instance in order to continue using it. +type EphemeralRenew struct { + // ExpireTime is the deadline before which Terraform must renew the + // ephemeral resource instance. Terraform will make the renew request + // at least one minute before the expiration time. + ExpireTime time.Time + + // InternalContext is any internal data needed by the provider to + // perform a subsequent [Interface.RenewEphemeral] request. The provider + // may choose any encoding format to represent the needed data, because + // Terraform Core treats this field as opaque. + // + // Providers should aim to keep this data relatively compact to minimize + // overhead. Although Terraform Core does not enforce a specific limit + // just for this field, it would be very unusual for the internal context + // to be more than 256 bytes in size, and in most cases it should be + // on the order of only tens of bytes. For example, a lease ID for the + // remote system is a reasonable thing to encode here. + // + // Because ephemeral resource instances never outlive a single Terraform + // Core phase, it's guaranteed that a RenewEphemeral request will be + // received by exactly the same plugin instance that previously handled + // the OpenEphemeral or RenewEphemeral request that produced this internal + // context, and so it's valid for this to refer to in-memory state in the + // provider object. + InternalContext []byte +} + +// RenewEphemeralRequest represents the arguments for the RenewEphemeral +// operation on a provider. +type RenewEphemeralRequest struct { + // TypeName is the type of ephemeral resource being renewed. This should + // only be one of the type names previously sent in a successful + // [OpenEphemeralRequest]. + TypeName string + + // InternalContext echoes verbatim the value from the field of the same + // name from the most recent [EphemeralRenew] object, received from either + // an [OpenEphemeralResponse] or a [RenewEphemeralResponse] object. + InternalContext []byte +} + +// RenewEphemeralRequest represents the response from a RenewEphemeral +// operation on a provider. +type RenewEphemeralResponse struct { + // RenewAgain, if set, describes a new expiration deadline for the + // object, possibly causing a further call to [Interface.RenewEphemeral] + // if Terraform needs to exceed the updated deadline. + // + // If this is not set then Terraform Core will not make any further + // renewal requests for the remaining life of the object. + RenewAgain *EphemeralRenew + + // Diagnostics describes any problems encountered while renewing the + // ephemeral resource instance. If this contains errors then the other + // response fields must be assumed invalid. + // + // Because renewals happen asynchronously from other uses of the + // ephemeral object, it's unspecified whether a renewal error will block + // any specific usage of the object. For example, a request using the + // object might already be in progress when a renewal error occurs, + // in which case that other request might also fail trying to use a + // now-invalid object, or it might by chance succeed in completing its + // operation before the ephemeral object truly expires. + Diagnostics tfdiags.Diagnostics +} + +// CloseEphemeralRequest represents the arguments for the CloseEphemeral +// operation on a provider. +type CloseEphemeralRequest struct { + // TypeName is the type of ephemeral resource being closed. This should + // only be one of the type names previously sent in a successful + // [OpenEphemeralRequest]. + TypeName string + + // InternalContext echoes verbatim the value from the field of the same + // name from the corresponding [OpenEphemeralResponse] object. + InternalContext []byte +} + +// CloseEphemeralRequest represents the response from a CloseEphemeral +// operation on a provider. +type CloseEphemeralResponse struct { + // Diagnostics describes any problems encountered while closing the + // ephemeral resource instance. If this contains errors then the other + // response fields must be assumed invalid. + // + // If closing an ephemeral resource instance fails then it's unspecified + // whether a corresponding remote object remains valid or not. + // + // Providers should make a best effort to treat the closure of an + // already-expired ephemeral object as a success in order to exhibit + // idemponent behavior for closing, but some remote systems do not allow + // distinguishing that case from other error conditions. + Diagnostics tfdiags.Diagnostics +} diff --git a/internal/providers/mock.go b/internal/providers/mock.go index 950066bd2f2a..3301cc769295 100644 --- a/internal/providers/mock.go +++ b/internal/providers/mock.go @@ -289,6 +289,48 @@ func (m *Mock) ReadDataSource(request ReadDataSourceRequest) ReadDataSourceRespo return response } +func (m *Mock) OpenEphemeral(OpenEphemeralRequest) OpenEphemeralResponse { + // 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 OpenEphemeralResponse{ + Diagnostics: diags, + } +} + +func (m *Mock) RenewEphemeral(RenewEphemeralRequest) RenewEphemeralResponse { + // 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 RenewEphemeralResponse{ + Diagnostics: diags, + } +} + +func (m *Mock) CloseEphemeral(CloseEphemeralRequest) CloseEphemeralResponse { + // 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 CloseEphemeralResponse{ + Diagnostics: diags, + } +} + func (m *Mock) CallFunction(request CallFunctionRequest) CallFunctionResponse { return m.Provider.CallFunction(request) } diff --git a/internal/providers/provider.go b/internal/providers/provider.go index b40847948c16..b1561e4f8ccc 100644 --- a/internal/providers/provider.go +++ b/internal/providers/provider.go @@ -74,6 +74,15 @@ type Interface interface { // ReadDataSource returns the data source's current state. ReadDataSource(ReadDataSourceRequest) ReadDataSourceResponse + // OpenEphemeral opens an ephemeral resource instance. + OpenEphemeral(OpenEphemeralRequest) OpenEphemeralResponse + // RenewEphemeral extends the validity of a previously-opened ephemeral + // resource instance. + RenewEphemeral(RenewEphemeralRequest) RenewEphemeralResponse + // CloseEphemeral closes an ephemeral resource instance, with the intent + // of rendering it invalid as soon as possible. + CloseEphemeral(CloseEphemeralRequest) CloseEphemeralResponse + // CallFunction calls a provider-contributed function. CallFunction(CallFunctionRequest) CallFunctionResponse @@ -99,6 +108,10 @@ type GetProviderSchemaResponse struct { // DataSources maps the data source name to that data source's schema. DataSources map[string]Schema + // EphemeralResourceTypes maps the name of an ephemeral resource type + // to its schema. + EphemeralResourceTypes map[string]Schema + // Functions maps from local function name (not including an namespace // prefix) to the declaration of a function. Functions map[string]FunctionDecl diff --git a/internal/providers/testing/provider_mock.go b/internal/providers/testing/provider_mock.go index a4118d5a1bce..28f62e24ba8b 100644 --- a/internal/providers/testing/provider_mock.go +++ b/internal/providers/testing/provider_mock.go @@ -94,6 +94,19 @@ type MockProvider struct { ReadDataSourceRequest providers.ReadDataSourceRequest ReadDataSourceFn func(providers.ReadDataSourceRequest) providers.ReadDataSourceResponse + OpenEphemeralCalled bool + OpenEphemeralResponse *providers.OpenEphemeralResponse + OpenEphemeralRequest providers.OpenEphemeralRequest + OpenEphemeralFn func(providers.OpenEphemeralRequest) providers.OpenEphemeralResponse + RenewEphemeralCalled bool + RenewEphemeralResponse *providers.RenewEphemeralResponse + RenewEphemeralRequest providers.RenewEphemeralRequest + RenewEphemeralFn func(providers.RenewEphemeralRequest) providers.RenewEphemeralResponse + CloseEphemeralCalled bool + CloseEphemeralResponse *providers.CloseEphemeralResponse + CloseEphemeralRequest providers.CloseEphemeralRequest + CloseEphemeralFn func(providers.CloseEphemeralRequest) providers.CloseEphemeralResponse + CallFunctionCalled bool CallFunctionResponse providers.CallFunctionResponse CallFunctionRequest providers.CallFunctionRequest @@ -553,6 +566,75 @@ func (p *MockProvider) ReadDataSource(r providers.ReadDataSourceRequest) (resp p return resp } +func (p *MockProvider) OpenEphemeral(r providers.OpenEphemeralRequest) (resp providers.OpenEphemeralResponse) { + p.Lock() + defer p.Unlock() + + if !p.ConfigureProviderCalled { + resp.Diagnostics = resp.Diagnostics.Append(fmt.Errorf("Configure not called before OpenEphemeral %q", r.TypeName)) + return resp + } + + p.OpenEphemeralCalled = true + p.OpenEphemeralRequest = r + + if p.OpenEphemeralFn != nil { + return p.OpenEphemeralFn(r) + } + + if p.OpenEphemeralResponse != nil { + resp = *p.OpenEphemeralResponse + } + + return resp +} + +func (p *MockProvider) RenewEphemeral(r providers.RenewEphemeralRequest) (resp providers.RenewEphemeralResponse) { + p.Lock() + defer p.Unlock() + + if !p.ConfigureProviderCalled { + resp.Diagnostics = resp.Diagnostics.Append(fmt.Errorf("Configure not called before RenewEphemeral %q", r.TypeName)) + return resp + } + + p.RenewEphemeralCalled = true + p.RenewEphemeralRequest = r + + if p.RenewEphemeralFn != nil { + return p.RenewEphemeralFn(r) + } + + if p.RenewEphemeralResponse != nil { + resp = *p.RenewEphemeralResponse + } + + return resp +} + +func (p *MockProvider) CloseEphemeral(r providers.CloseEphemeralRequest) (resp providers.CloseEphemeralResponse) { + p.Lock() + defer p.Unlock() + + if !p.ConfigureProviderCalled { + resp.Diagnostics = resp.Diagnostics.Append(fmt.Errorf("Configure not called before CloseEphemeral %q", r.TypeName)) + return resp + } + + p.CloseEphemeralCalled = true + p.CloseEphemeralRequest = r + + if p.CloseEphemeralFn != nil { + return p.CloseEphemeralFn(r) + } + + if p.CloseEphemeralResponse != nil { + resp = *p.CloseEphemeralResponse + } + + return resp +} + func (p *MockProvider) CallFunction(r providers.CallFunctionRequest) providers.CallFunctionResponse { p.Lock() defer p.Unlock() diff --git a/internal/refactoring/mock_provider.go b/internal/refactoring/mock_provider.go index 0b302cfdd066..09b5c44557f6 100644 --- a/internal/refactoring/mock_provider.go +++ b/internal/refactoring/mock_provider.go @@ -85,6 +85,18 @@ func (provider *mockProvider) ReadDataSource(providers.ReadDataSourceRequest) pr panic("not implemented in mock") } +func (provider *mockProvider) OpenEphemeral(providers.OpenEphemeralRequest) providers.OpenEphemeralResponse { + panic("not implemented in mock") +} + +func (provider *mockProvider) RenewEphemeral(providers.RenewEphemeralRequest) providers.RenewEphemeralResponse { + panic("not implemented in mock") +} + +func (provider *mockProvider) CloseEphemeral(providers.CloseEphemeralRequest) providers.CloseEphemeralResponse { + panic("not implemented in mock") +} + func (provider *mockProvider) CallFunction(providers.CallFunctionRequest) providers.CallFunctionResponse { panic("not implemented in mock") } diff --git a/internal/stacks/stackruntime/internal/stackeval/stubs/errored.go b/internal/stacks/stackruntime/internal/stackeval/stubs/errored.go index fe29c229e032..ad9737e7dc07 100644 --- a/internal/stacks/stackruntime/internal/stackeval/stubs/errored.go +++ b/internal/stacks/stackruntime/internal/stackeval/stubs/errored.go @@ -124,6 +124,34 @@ func (p *ErroredProvider) ReadResource(req providers.ReadResourceRequest) provid } } +// OpenEphemeral implements providers.Interface. +func (p *ErroredProvider) OpenEphemeral(providers.OpenEphemeralRequest) providers.OpenEphemeralResponse { + var diags tfdiags.Diagnostics + diags = diags.Append(tfdiags.AttributeValue( + tfdiags.Error, + "Provider configuration is invalid", + "Cannot open this ephemeral resource instance because its associated provider configuration is invalid.", + nil, // nil attribute path means the overall configuration block + )) + return providers.OpenEphemeralResponse{ + Diagnostics: diags, + } +} + +// RenewEphemeral implements providers.Interface. +func (p *ErroredProvider) RenewEphemeral(providers.RenewEphemeralRequest) providers.RenewEphemeralResponse { + // We don't have anything to do here because OpenEphemeral didn't really + // actually "open" anything. + return providers.RenewEphemeralResponse{} +} + +// CloseEphemeral implements providers.Interface. +func (p *ErroredProvider) CloseEphemeral(providers.CloseEphemeralRequest) providers.CloseEphemeralResponse { + // We don't have anything to do here because OpenEphemeral didn't really + // actually "open" anything. + return providers.CloseEphemeralResponse{} +} + // Stop implements providers.Interface. func (p *ErroredProvider) Stop() error { // This stub provider never actually does any real work, so there's nothing diff --git a/internal/stacks/stackruntime/internal/stackeval/stubs/unknown.go b/internal/stacks/stackruntime/internal/stackeval/stubs/unknown.go index cbc76b663da4..99f164ff48e2 100644 --- a/internal/stacks/stackruntime/internal/stackeval/stubs/unknown.go +++ b/internal/stacks/stackruntime/internal/stackeval/stubs/unknown.go @@ -222,6 +222,40 @@ func (u *unknownProvider) ReadDataSource(request providers.ReadDataSourceRequest } } +// OpenEphemeral implements providers.Interface. +func (u *unknownProvider) OpenEphemeral(providers.OpenEphemeralRequest) providers.OpenEphemeralResponse { + // TODO: Once there's a definition for how deferred actions ought to work + // for ephemeral resource instances, make this report that this one needs + // to be deferred if the client announced that it supports deferral. + // + // For now this is just always an error, because ephemeral resources are + // just a prototype being developed concurrently with deferred actions. + var diags tfdiags.Diagnostics + diags = diags.Append(tfdiags.AttributeValue( + tfdiags.Error, + "Provider configuration is unknown", + "Cannot open this resource instance because its associated provider configuration is unknown.", + nil, // nil attribute path means the overall configuration block + )) + return providers.OpenEphemeralResponse{ + Diagnostics: diags, + } +} + +// RenewEphemeral implements providers.Interface. +func (u *unknownProvider) RenewEphemeral(providers.RenewEphemeralRequest) providers.RenewEphemeralResponse { + // We don't have anything to do here because OpenEphemeral didn't really + // actually "open" anything. + return providers.RenewEphemeralResponse{} +} + +// CloseEphemeral implements providers.Interface. +func (u *unknownProvider) CloseEphemeral(providers.CloseEphemeralRequest) providers.CloseEphemeralResponse { + // We don't have anything to do here because OpenEphemeral didn't really + // actually "open" anything. + return providers.CloseEphemeralResponse{} +} + func (u *unknownProvider) CallFunction(request providers.CallFunctionRequest) providers.CallFunctionResponse { // This is offline functionality, so we can hand it off to the unconfigured // client. From a44ff406d264a31e0d5f39c961d4b8294272ca62 Mon Sep 17 00:00:00 2001 From: Martin Atkins Date: Mon, 29 Apr 2024 14:14:15 -0700 Subject: [PATCH 02/23] addrs: EphemeralResourceMode This is the new resource mode for ephemeral resources. --- internal/addrs/resource.go | 4 ++++ internal/addrs/resourcemode_string.go | 12 +++++++++--- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/internal/addrs/resource.go b/internal/addrs/resource.go index 57f48587344b..1c81fe5dae2d 100644 --- a/internal/addrs/resource.go +++ b/internal/addrs/resource.go @@ -505,6 +505,10 @@ const ( // DataResourceMode indicates a data resource, as defined by // "data" blocks in configuration. DataResourceMode ResourceMode = 'D' + + // EphemeralResourceMode indicates an ephemeral resource, as defined by + // "ephemeral" blocks in configuration. + EphemeralResourceMode ResourceMode = 'E' ) // AbsResourceInstanceObject represents one of the specific remote objects diff --git a/internal/addrs/resourcemode_string.go b/internal/addrs/resourcemode_string.go index 0b5c33f8ee28..a2b727a9b95a 100644 --- a/internal/addrs/resourcemode_string.go +++ b/internal/addrs/resourcemode_string.go @@ -11,20 +11,26 @@ func _() { _ = x[InvalidResourceMode-0] _ = x[ManagedResourceMode-77] _ = x[DataResourceMode-68] + _ = x[EphemeralResourceMode-69] } const ( _ResourceMode_name_0 = "InvalidResourceMode" - _ResourceMode_name_1 = "DataResourceMode" + _ResourceMode_name_1 = "DataResourceModeEphemeralResourceMode" _ResourceMode_name_2 = "ManagedResourceMode" ) +var ( + _ResourceMode_index_1 = [...]uint8{0, 16, 37} +) + func (i ResourceMode) String() string { switch { case i == 0: return _ResourceMode_name_0 - case i == 68: - return _ResourceMode_name_1 + case 68 <= i && i <= 69: + i -= 68 + return _ResourceMode_name_1[_ResourceMode_index_1[i]:_ResourceMode_index_1[i+1]] case i == 77: return _ResourceMode_name_2 default: From 2ab614ccf9a8053ade858da2e4c35be56fda16b3 Mon Sep 17 00:00:00 2001 From: Martin Atkins Date: Mon, 29 Apr 2024 15:16:44 -0700 Subject: [PATCH 03/23] configs: Experimental support for ephemeral resources Ephemeral resources, declared using "ephemeral" blocks, represent objects that are instantiated only for the duration of a single Terraform phase, and are intended for uses such as temporary network tunnels or time-limited leases of sensitive values from stores such as HashiCorp Vault. --- internal/configs/config.go | 8 ++ internal/configs/experiments.go | 8 ++ internal/configs/module.go | 42 +++++++- internal/configs/parser_config.go | 11 +++ internal/configs/resource.go | 158 ++++++++++++++++++++++++++++++ 5 files changed, 223 insertions(+), 4 deletions(-) diff --git a/internal/configs/config.go b/internal/configs/config.go index 25c0e292fb24..54a33cceaa9c 100644 --- a/internal/configs/config.go +++ b/internal/configs/config.go @@ -466,6 +466,14 @@ func (c *Config) addProviderRequirements(reqs providerreqs.Requirements, recurse } reqs[fqn] = nil } + for _, rc := range c.Module.EphemeralResources { + fqn := rc.Provider + if _, exists := reqs[fqn]; exists { + // Explicit dependency already present + continue + } + reqs[fqn] = nil + } // Import blocks that are generating config may have a custom provider // meta-argument. Like the provider meta-argument used in resource blocks, diff --git a/internal/configs/experiments.go b/internal/configs/experiments.go index d3fd6a4e5915..1c4bf4c61fce 100644 --- a/internal/configs/experiments.go +++ b/internal/configs/experiments.go @@ -229,6 +229,14 @@ func checkModuleExperiments(m *Module) hcl.Diagnostics { }) } } + for _, rc := range m.EphemeralResources { + diags = append(diags, &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Ephemeral resources are experimental", + Detail: "This feature is currently an opt-in experiment, subject to change in future releases based on feedback.\n\nActivate the feature for this module by adding ephemeral_values to the list of active experiments.", + Subject: rc.DeclRange.Ptr(), + }) + } } return diags diff --git a/internal/configs/module.go b/internal/configs/module.go index d33edc6bc40c..41aa8d75d56c 100644 --- a/internal/configs/module.go +++ b/internal/configs/module.go @@ -46,8 +46,9 @@ type Module struct { ModuleCalls map[string]*ModuleCall - ManagedResources map[string]*Resource - DataResources map[string]*Resource + ManagedResources map[string]*Resource + DataResources map[string]*Resource + EphemeralResources map[string]*Resource Moved []*Moved Removed []*Removed @@ -86,8 +87,9 @@ type File struct { ModuleCalls []*ModuleCall - ManagedResources []*Resource - DataResources []*Resource + ManagedResources []*Resource + DataResources []*Resource + EphemeralResources []*Resource Moved []*Moved Removed []*Removed @@ -125,6 +127,7 @@ func NewModule(primaryFiles, overrideFiles []*File) (*Module, hcl.Diagnostics) { ModuleCalls: map[string]*ModuleCall{}, ManagedResources: map[string]*Resource{}, DataResources: map[string]*Resource{}, + EphemeralResources: map[string]*Resource{}, Checks: map[string]*Check{}, ProviderMetas: map[addrs.Provider]*ProviderMeta{}, Tests: map[string]*TestFile{}, @@ -192,6 +195,8 @@ func (m *Module) ResourceByAddr(addr addrs.Resource) *Resource { return m.ManagedResources[key] case addrs.DataResourceMode: return m.DataResources[key] + case addrs.EphemeralResourceMode: + return m.EphemeralResources[key] default: return nil } @@ -372,6 +377,35 @@ func (m *Module) appendFile(file *File) hcl.Diagnostics { m.DataResources[key] = r } + for _, r := range file.EphemeralResources { + key := r.moduleUniqueKey() + if existing, exists := m.EphemeralResources[key]; exists { + diags = append(diags, &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: fmt.Sprintf("Duplicate ephemeral %q configuration", existing.Type), + Detail: fmt.Sprintf("A %s ephemeral resource named %q was already declared at %s. Resource names must be unique per type in each module.", existing.Type, existing.Name, existing.DeclRange), + Subject: &r.DeclRange, + }) + continue + } + m.EphemeralResources[key] = r + + // set the provider FQN for the resource + if r.ProviderConfigRef != nil { + r.Provider = m.ProviderForLocalConfig(r.ProviderConfigAddr()) + } else { + // an invalid resource name (for e.g. "null resource" instead of + // "null_resource") can cause a panic down the line in addrs: + // https://github.com/hashicorp/terraform/issues/25560 + implied, err := addrs.ParseProviderPart(r.Addr().ImpliedProvider()) + if err == nil { + r.Provider = m.ImpliedProviderForUnqualifiedType(implied) + } + // We don't return a diagnostic because the invalid resource name + // will already have been caught. + } + } + for _, c := range file.Checks { if c.DataResource != nil { key := c.DataResource.moduleUniqueKey() diff --git a/internal/configs/parser_config.go b/internal/configs/parser_config.go index 83148bfe3159..c85b8d856622 100644 --- a/internal/configs/parser_config.go +++ b/internal/configs/parser_config.go @@ -193,6 +193,13 @@ func parseConfigFile(body hcl.Body, diags hcl.Diagnostics, override, allowExperi file.DataResources = append(file.DataResources, cfg) } + case "ephemeral": + cfg, cfgDiags := decodeEphemeralBlock(block, override) + diags = append(diags, cfgDiags...) + if cfg != nil { + file.EphemeralResources = append(file.EphemeralResources, cfg) + } + case "moved": cfg, cfgDiags := decodeMovedBlock(block) diags = append(diags, cfgDiags...) @@ -308,6 +315,10 @@ var configFileSchema = &hcl.BodySchema{ Type: "data", LabelNames: []string{"type", "name"}, }, + { + Type: "ephemeral", + LabelNames: []string{"type", "name"}, + }, { Type: "moved", }, diff --git a/internal/configs/resource.go b/internal/configs/resource.go index 99c6a9e6fef5..2e6f2ea2e29c 100644 --- a/internal/configs/resource.go +++ b/internal/configs/resource.go @@ -534,6 +534,155 @@ func decodeDataBlock(block *hcl.Block, override, nested bool) (*Resource, hcl.Di return r, diags } +func decodeEphemeralBlock(block *hcl.Block, override bool) (*Resource, hcl.Diagnostics) { + var diags hcl.Diagnostics + r := &Resource{ + Mode: addrs.DataResourceMode, + Type: block.Labels[0], + Name: block.Labels[1], + DeclRange: block.DefRange, + TypeRange: block.LabelRanges[0], + } + + content, remain, moreDiags := block.Body.PartialContent(ephemeralBlockSchema) + diags = append(diags, moreDiags...) + r.Config = remain + + if !hclsyntax.ValidIdentifier(r.Type) { + diags = append(diags, &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid ephemeral resource type", + Detail: badIdentifierDetail, + Subject: &block.LabelRanges[0], + }) + } + if !hclsyntax.ValidIdentifier(r.Name) { + diags = append(diags, &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid ephemeral resource name", + Detail: badIdentifierDetail, + Subject: &block.LabelRanges[1], + }) + } + + if attr, exists := content.Attributes["count"]; exists { + r.Count = attr.Expr + } + + if attr, exists := content.Attributes["for_each"]; exists { + r.ForEach = attr.Expr + // Cannot have count and for_each on the same ephemeral block + if r.Count != nil { + diags = append(diags, &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: `Invalid combination of "count" and "for_each"`, + Detail: `The "count" and "for_each" meta-arguments are mutually-exclusive, only one should be used to be explicit about the number of resources to be created.`, + Subject: &attr.NameRange, + }) + } + } + + if attr, exists := content.Attributes["provider"]; exists { + var providerDiags hcl.Diagnostics + r.ProviderConfigRef, providerDiags = decodeProviderConfigRef(attr.Expr, "provider") + diags = append(diags, providerDiags...) + } + + if attr, exists := content.Attributes["depends_on"]; exists { + deps, depsDiags := decodeDependsOn(attr) + diags = append(diags, depsDiags...) + r.DependsOn = append(r.DependsOn, deps...) + } + + var seenEscapeBlock *hcl.Block + var seenLifecycle *hcl.Block + for _, block := range content.Blocks { + switch block.Type { + + case "_": + if seenEscapeBlock != nil { + diags = append(diags, &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Duplicate escaping block", + Detail: fmt.Sprintf( + "The special block type \"_\" can be used to force particular arguments to be interpreted as resource-type-specific rather than as meta-arguments, but each data block can have only one such block. The first escaping block was at %s.", + seenEscapeBlock.DefRange, + ), + Subject: &block.DefRange, + }) + continue + } + seenEscapeBlock = block + + // When there's an escaping block its content merges with the + // existing config we extracted earlier, so later decoding + // will see a blend of both. + r.Config = hcl.MergeBodies([]hcl.Body{r.Config, block.Body}) + + case "lifecycle": + if seenLifecycle != nil { + diags = append(diags, &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Duplicate lifecycle block", + Detail: fmt.Sprintf("This resource already has a lifecycle block at %s.", seenLifecycle.DefRange), + Subject: block.DefRange.Ptr(), + }) + continue + } + seenLifecycle = block + + lcContent, lcDiags := block.Body.Content(resourceLifecycleBlockSchema) + diags = append(diags, lcDiags...) + + // All of the attributes defined for resource lifecycle are for + // managed resources only, so we can emit a common error message + // for any given attributes that HCL accepted. + for name, attr := range lcContent.Attributes { + diags = append(diags, &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid ephemeral resource lifecycle argument", + Detail: fmt.Sprintf("The lifecycle argument %q is defined only for managed resources (\"resource\" blocks), and is not valid for ephemeral resources.", name), + Subject: attr.NameRange.Ptr(), + }) + } + + for _, block := range lcContent.Blocks { + switch block.Type { + case "precondition", "postcondition": + cr, moreDiags := decodeCheckRuleBlock(block, override) + diags = append(diags, moreDiags...) + + moreDiags = cr.validateSelfReferences(block.Type, r.Addr()) + diags = append(diags, moreDiags...) + + switch block.Type { + case "precondition": + r.Preconditions = append(r.Preconditions, cr) + case "postcondition": + r.Postconditions = append(r.Postconditions, cr) + } + default: + // The cases above should be exhaustive for all block types + // defined in the lifecycle schema, so this shouldn't happen. + panic(fmt.Sprintf("unexpected lifecycle sub-block type %q", block.Type)) + } + } + + default: + // Any other block types are ones we're reserving for future use, + // but don't have any defined meaning today. + diags = append(diags, &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Reserved block type name in ephemeral block", + Detail: fmt.Sprintf("The block type name %q is reserved for use by Terraform in a future version.", block.Type), + Subject: block.TypeRange.Ptr(), + }) + } + } + + return r, diags +} + // decodeReplaceTriggeredBy decodes and does basic validation of the // replace_triggered_by expressions, ensuring they only contains references to // a single resource, and the only extra variables are count.index or each.key. @@ -783,6 +932,15 @@ var dataBlockSchema = &hcl.BodySchema{ }, } +var ephemeralBlockSchema = &hcl.BodySchema{ + Attributes: commonResourceAttributes, + Blocks: []hcl.BlockHeaderSchema{ + {Type: "lifecycle"}, + {Type: "locals"}, // reserved for future use + {Type: "_"}, // meta-argument escaping block + }, +} + var resourceLifecycleBlockSchema = &hcl.BodySchema{ // We tell HCL that these elements are all valid for both "resource" // and "data" lifecycle blocks, but the rules are actually more restrictive From bf8c419c8652c7660cc247d8563a650e2bbb1884 Mon Sep 17 00:00:00 2001 From: Martin Atkins Date: Mon, 29 Apr 2024 16:15:58 -0700 Subject: [PATCH 04/23] terraform provider: terraform_random_number ephemeral resource type Similar to terraform_data, this is really just here to use as a placeholder when one needs an ephemeral resource for some reason but doesn't need any specific one. This might get removed before the ephemeral_values experiment gets stabilized. For now it's here to use as an initial testing vehicle since we don't have any mechanism for offering experimental features in the provider plugin protocol, whereas this provider is not a plugin. --- .../providers/terraform/ephemeral_random.go | 45 +++++++++++++++++++ .../builtin/providers/terraform/provider.go | 39 ++++++++++++---- internal/configs/resource.go | 2 +- 3 files changed, 76 insertions(+), 10 deletions(-) create mode 100644 internal/builtin/providers/terraform/ephemeral_random.go diff --git a/internal/builtin/providers/terraform/ephemeral_random.go b/internal/builtin/providers/terraform/ephemeral_random.go new file mode 100644 index 000000000000..a8529ea51fcf --- /dev/null +++ b/internal/builtin/providers/terraform/ephemeral_random.go @@ -0,0 +1,45 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package terraform + +import ( + "math/rand" + + "github.com/zclconf/go-cty/cty" + + "github.com/hashicorp/terraform/internal/configs/configschema" + "github.com/hashicorp/terraform/internal/providers" +) + +func ephemeralRandomNumberSchema() providers.Schema { + return providers.Schema{ + Block: &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "value": {Type: cty.String, Computed: true}, + }, + }, + } +} + +func openEphemeralRandomNumber(req providers.OpenEphemeralRequest) providers.OpenEphemeralResponse { + result := rand.NormFloat64() + return providers.OpenEphemeralResponse{ + Result: cty.ObjectVal(map[string]cty.Value{ + "value": cty.NumberFloatVal(result), + }), + } +} + +func renewEphemeralRandomNumber(req providers.RenewEphemeralRequest) providers.RenewEphemeralResponse { + // This resource type does not need renewing, but if we get asked to do + // it for some reason then we'll just say it succeeded. + return providers.RenewEphemeralResponse{} +} + +func closeEphemeralRandomNumber(req providers.CloseEphemeralRequest) providers.CloseEphemeralResponse { + // This resource type does not need closing because it isn't really + // backed by any long-lived object, so we'll just say that closing it + // succeeded even though we aren't really doing anything. + return providers.CloseEphemeralResponse{} +} diff --git a/internal/builtin/providers/terraform/provider.go b/internal/builtin/providers/terraform/provider.go index 627c0e3235e8..0112085980a3 100644 --- a/internal/builtin/providers/terraform/provider.go +++ b/internal/builtin/providers/terraform/provider.go @@ -32,6 +32,9 @@ func (p *Provider) GetProviderSchema() providers.GetProviderSchemaResponse { ResourceTypes: map[string]providers.Schema{ "terraform_data": dataStoreResourceSchema(), }, + EphemeralResourceTypes: map[string]providers.Schema{ + "terraform_random_number": ephemeralRandomNumberSchema(), + }, Functions: map[string]providers.FunctionDecl{ "encode_tfvars": { Summary: "Produce a string representation of an object using the same syntax as for `.tfvars` files", @@ -191,19 +194,37 @@ func (p *Provider) ValidateResourceConfig(req providers.ValidateResourceConfigRe return validateDataStoreResourceConfig(req) } -// CloseEphemeral implements providers.Interface. -func (p *Provider) CloseEphemeral(providers.CloseEphemeralRequest) providers.CloseEphemeralResponse { - panic("unimplemented - terraform.io/builtin/terraform has no ephemeral resource types") -} - // OpenEphemeral implements providers.Interface. -func (p *Provider) OpenEphemeral(providers.OpenEphemeralRequest) providers.OpenEphemeralResponse { - panic("unimplemented - terraform.io/builtin/terraform has no ephemeral resource types") +func (p *Provider) OpenEphemeral(req providers.OpenEphemeralRequest) providers.OpenEphemeralResponse { + if req.TypeName != "terraform_random_number" { + // This should not happen + var resp providers.OpenEphemeralResponse + resp.Diagnostics.Append(fmt.Errorf("unsupported ephemeral resource type %q", req.TypeName)) + return resp + } + return openEphemeralRandomNumber(req) } // RenewEphemeral implements providers.Interface. -func (p *Provider) RenewEphemeral(providers.RenewEphemeralRequest) providers.RenewEphemeralResponse { - panic("unimplemented - terraform.io/builtin/terraform has no ephemeral resource types") +func (p *Provider) RenewEphemeral(req providers.RenewEphemeralRequest) providers.RenewEphemeralResponse { + if req.TypeName != "terraform_random_number" { + // This should not happen + var resp providers.RenewEphemeralResponse + resp.Diagnostics.Append(fmt.Errorf("unsupported ephemeral resource type %q", req.TypeName)) + return resp + } + return renewEphemeralRandomNumber(req) +} + +// CloseEphemeral implements providers.Interface. +func (p *Provider) CloseEphemeral(req providers.CloseEphemeralRequest) providers.CloseEphemeralResponse { + if req.TypeName != "terraform_random_number" { + // This should not happen + var resp providers.CloseEphemeralResponse + resp.Diagnostics.Append(fmt.Errorf("unsupported ephemeral resource type %q", req.TypeName)) + return resp + } + return closeEphemeralRandomNumber(req) } // CallFunction would call a function contributed by this provider, but this diff --git a/internal/configs/resource.go b/internal/configs/resource.go index 2e6f2ea2e29c..ace34624c64d 100644 --- a/internal/configs/resource.go +++ b/internal/configs/resource.go @@ -537,7 +537,7 @@ func decodeDataBlock(block *hcl.Block, override, nested bool) (*Resource, hcl.Di func decodeEphemeralBlock(block *hcl.Block, override bool) (*Resource, hcl.Diagnostics) { var diags hcl.Diagnostics r := &Resource{ - Mode: addrs.DataResourceMode, + Mode: addrs.EphemeralResourceMode, Type: block.Labels[0], Name: block.Labels[1], DeclRange: block.DefRange, From b7b8a4c2a04616926feb5650562f01b4b593ceb4 Mon Sep 17 00:00:00 2001 From: Martin Atkins Date: Tue, 30 Apr 2024 09:42:54 -0700 Subject: [PATCH 05/23] addrs: ParseRef and ParseTarget support ephemeral resource addresses This change is not shippable as-is because it changes the interpretation of any reference starting with "ephemeral.", which would previously have referred to a managed resource type belonging to a provider whose local name is "ephemeral". Therefore this initial attempt is only for prototyping purposes and would need to be modified in some way in order to be shippable. It will presumably need some sort of opt-in within the calling module so that the old interpretation can be preserved by default. --- internal/addrs/parse_ref.go | 62 +++++++++++++++--- internal/addrs/parse_ref_test.go | 98 +++++++++++++++++++++++++++++ internal/addrs/parse_target.go | 15 ++++- internal/addrs/parse_target_test.go | 60 ++++++++++++++++++ internal/addrs/resource.go | 2 + 5 files changed, 225 insertions(+), 12 deletions(-) diff --git a/internal/addrs/parse_ref.go b/internal/addrs/parse_ref.go index b77b5f869468..2e9b107470d1 100644 --- a/internal/addrs/parse_ref.go +++ b/internal/addrs/parse_ref.go @@ -226,6 +226,19 @@ func parseRef(traversal hcl.Traversal) (*Reference, tfdiags.Diagnostics) { remain := traversal[1:] // trim off "data" so we can use our shared resource reference parser return parseResourceRef(DataResourceMode, rootRange, remain) + case "ephemeral": + if len(traversal) < 3 { + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid reference", + Detail: `The "ephemeral" object must be followed by two attribute names: the ephemeral resource type and the resource name.`, + Subject: traversal.SourceRange().Ptr(), + }) + return nil, diags + } + remain := traversal[1:] // trim off "ephemeral" so we can use our shared resource reference parser + return parseResourceRef(EphemeralResourceMode, rootRange, remain) + case "resource": // This is an alias for the normal case of just using a managed resource // type as a top-level symbol, which will serve as an escape mechanism @@ -396,13 +409,40 @@ func parseResourceRef(mode ResourceMode, startRange hcl.Range, traversal hcl.Tra case hcl.TraverseAttr: typeName = tt.Name default: - // If it isn't a TraverseRoot then it must be a "data" reference. - diags = diags.Append(&hcl.Diagnostic{ - Severity: hcl.DiagError, - Summary: "Invalid reference", - Detail: `The "data" object does not support this operation.`, - Subject: traversal[0].SourceRange().Ptr(), - }) + switch mode { + case ManagedResourceMode: + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid reference", + Detail: `The "resource" object does not support this operation.`, + Subject: traversal[0].SourceRange().Ptr(), + }) + case DataResourceMode: + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid reference", + Detail: `The "data" object does not support this operation.`, + Subject: traversal[0].SourceRange().Ptr(), + }) + case EphemeralResourceMode: + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid reference", + Detail: `The "ephemeral" object does not support this operation.`, + Subject: traversal[0].SourceRange().Ptr(), + }) + default: + // Shouldn't get here because the above should be exhaustive for + // all of the resource modes. But we'll still return a + // minimally-passable error message so that the won't totally + // misbehave if we forget to update this in future. + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid reference", + Detail: `The left operand does not support this operation.`, + Subject: traversal[0].SourceRange().Ptr(), + }) + } return nil, diags } @@ -411,14 +451,16 @@ func parseResourceRef(mode ResourceMode, startRange hcl.Range, traversal hcl.Tra var what string switch mode { case DataResourceMode: - what = "data source" + what = "a data source" + case EphemeralResourceMode: + what = "an ephemeral resource type" default: - what = "resource type" + what = "a resource type" } diags = diags.Append(&hcl.Diagnostic{ Severity: hcl.DiagError, Summary: "Invalid reference", - Detail: fmt.Sprintf(`A reference to a %s must be followed by at least one attribute access, specifying the resource name.`, what), + Detail: fmt.Sprintf(`A reference to %s must be followed by at least one attribute access, specifying the resource name.`, what), Subject: traversal[1].SourceRange().Ptr(), }) return nil, diags diff --git a/internal/addrs/parse_ref_test.go b/internal/addrs/parse_ref_test.go index 1441c64ac96e..cbe40285a61c 100644 --- a/internal/addrs/parse_ref_test.go +++ b/internal/addrs/parse_ref_test.go @@ -363,6 +363,104 @@ func TestParseRef(t *testing.T) { `The "data" object must be followed by two attribute names: the data source type and the resource name.`, }, + // ephemeral + { + `ephemeral.external.foo`, + &Reference{ + Subject: Resource{ + Mode: EphemeralResourceMode, + Type: "external", + Name: "foo", + }, + SourceRange: tfdiags.SourceRange{ + Start: tfdiags.SourcePos{Line: 1, Column: 1, Byte: 0}, + End: tfdiags.SourcePos{Line: 1, Column: 23, Byte: 22}, + }, + }, + ``, + }, + { + `ephemeral.external.foo.bar`, + &Reference{ + Subject: ResourceInstance{ + Resource: Resource{ + Mode: EphemeralResourceMode, + Type: "external", + Name: "foo", + }, + }, + SourceRange: tfdiags.SourceRange{ + Start: tfdiags.SourcePos{Line: 1, Column: 1, Byte: 0}, + End: tfdiags.SourcePos{Line: 1, Column: 23, Byte: 22}, + }, + Remaining: hcl.Traversal{ + hcl.TraverseAttr{ + Name: "bar", + SrcRange: hcl.Range{ + Start: hcl.Pos{Line: 1, Column: 23, Byte: 22}, + End: hcl.Pos{Line: 1, Column: 27, Byte: 26}, + }, + }, + }, + }, + ``, + }, + { + `ephemeral.external.foo["baz"].bar`, + &Reference{ + Subject: ResourceInstance{ + Resource: Resource{ + Mode: EphemeralResourceMode, + Type: "external", + Name: "foo", + }, + Key: StringKey("baz"), + }, + SourceRange: tfdiags.SourceRange{ + Start: tfdiags.SourcePos{Line: 1, Column: 1, Byte: 0}, + End: tfdiags.SourcePos{Line: 1, Column: 30, Byte: 29}, + }, + Remaining: hcl.Traversal{ + hcl.TraverseAttr{ + Name: "bar", + SrcRange: hcl.Range{ + Start: hcl.Pos{Line: 1, Column: 30, Byte: 29}, + End: hcl.Pos{Line: 1, Column: 34, Byte: 33}, + }, + }, + }, + }, + ``, + }, + { + `ephemeral.external.foo["baz"]`, + &Reference{ + Subject: ResourceInstance{ + Resource: Resource{ + Mode: EphemeralResourceMode, + Type: "external", + Name: "foo", + }, + Key: StringKey("baz"), + }, + SourceRange: tfdiags.SourceRange{ + Start: tfdiags.SourcePos{Line: 1, Column: 1, Byte: 0}, + End: tfdiags.SourcePos{Line: 1, Column: 30, Byte: 29}, + }, + }, + ``, + }, + { + `ephemeral`, + nil, + `The "ephemeral" object must be followed by two attribute names: the ephemeral resource type and the resource name.`, + }, + { + `ephemeral.external`, + nil, + `The "ephemeral" object must be followed by two attribute names: the ephemeral resource type and the resource name.`, + }, + // local { `local.foo`, diff --git a/internal/addrs/parse_target.go b/internal/addrs/parse_target.go index 04f9ea5225cb..12d651de3444 100644 --- a/internal/addrs/parse_target.go +++ b/internal/addrs/parse_target.go @@ -159,10 +159,14 @@ func parseResourceInstanceUnderModule(moduleAddr ModuleInstance, remain hcl.Trav var diags tfdiags.Diagnostics mode := ManagedResourceMode - if remain.RootName() == "data" { + switch remain.RootName() { + case "data": mode = DataResourceMode remain = remain[1:] - } else if remain.RootName() == "resource" { + case "ephemeral": + mode = EphemeralResourceMode + remain = remain[1:] + case "resource": // Starting a resource address with "resource" is optional, so we'll // just ignore it. remain = remain[1:] @@ -200,6 +204,13 @@ func parseResourceInstanceUnderModule(moduleAddr ModuleInstance, remain hcl.Trav Detail: "A data source name is required.", Subject: remain[0].SourceRange().Ptr(), }) + case EphemeralResourceMode: + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid address", + Detail: "An ephemeral resource type name is required.", + Subject: remain[0].SourceRange().Ptr(), + }) default: panic("unknown mode") } diff --git a/internal/addrs/parse_target_test.go b/internal/addrs/parse_target_test.go index 3c6257bc729f..eb0cabea87da 100644 --- a/internal/addrs/parse_target_test.go +++ b/internal/addrs/parse_target_test.go @@ -165,6 +165,45 @@ func TestParseTarget(t *testing.T) { }, ``, }, + { + `ephemeral.aws_instance.foo`, + &Target{ + Subject: AbsResource{ + Resource: Resource{ + Mode: EphemeralResourceMode, + Type: "aws_instance", + Name: "foo", + }, + Module: RootModuleInstance, + }, + SourceRange: tfdiags.SourceRange{ + Start: tfdiags.SourcePos{Line: 1, Column: 1, Byte: 0}, + End: tfdiags.SourcePos{Line: 1, Column: 27, Byte: 26}, + }, + }, + ``, + }, + { + `ephemeral.aws_instance.foo[1]`, + &Target{ + Subject: AbsResourceInstance{ + Resource: ResourceInstance{ + Resource: Resource{ + Mode: EphemeralResourceMode, + Type: "aws_instance", + Name: "foo", + }, + Key: IntKey(1), + }, + Module: RootModuleInstance, + }, + SourceRange: tfdiags.SourceRange{ + Start: tfdiags.SourcePos{Line: 1, Column: 1, Byte: 0}, + End: tfdiags.SourcePos{Line: 1, Column: 30, Byte: 29}, + }, + }, + ``, + }, { `module.foo.aws_instance.bar`, &Target{ @@ -271,6 +310,27 @@ func TestParseTarget(t *testing.T) { }, ``, }, + { + `module.foo.module.bar.ephemeral.aws_instance.baz`, + &Target{ + Subject: AbsResource{ + Resource: Resource{ + Mode: EphemeralResourceMode, + Type: "aws_instance", + Name: "baz", + }, + Module: ModuleInstance{ + {Name: "foo"}, + {Name: "bar"}, + }, + }, + SourceRange: tfdiags.SourceRange{ + Start: tfdiags.SourcePos{Line: 1, Column: 1, Byte: 0}, + End: tfdiags.SourcePos{Line: 1, Column: 49, Byte: 48}, + }, + }, + ``, + }, { `module.foo.module.bar[0].data.aws_instance.baz`, &Target{ diff --git a/internal/addrs/resource.go b/internal/addrs/resource.go index 1c81fe5dae2d..6e4d3a8360df 100644 --- a/internal/addrs/resource.go +++ b/internal/addrs/resource.go @@ -27,6 +27,8 @@ func (r Resource) String() string { return fmt.Sprintf("%s.%s", r.Type, r.Name) case DataResourceMode: return fmt.Sprintf("data.%s.%s", r.Type, r.Name) + case EphemeralResourceMode: + return fmt.Sprintf("ephemeral.%s.%s", r.Type, r.Name) default: // Should never happen, but we'll return a string here rather than // crashing just in case it does. From 7f71696423cd2095b85c441322c5a6fa517753cc Mon Sep 17 00:00:00 2001 From: Martin Atkins Date: Tue, 30 Apr 2024 11:09:06 -0700 Subject: [PATCH 06/23] terraform: Add ephemeral resources to the graph, and validate refs This is not yet sufficient to actually open/renew/close ephemeral resource instances, and so as of this commit a module including ephemeral resources will misbehave. Further work in subsequent commits. --- internal/providers/schemas.go | 3 +++ internal/terraform/evaluate_valid.go | 23 +++++++++++++++---- internal/terraform/evaluate_valid_test.go | 13 +++++++++++ .../static-validate-refs.tf | 8 +++++++ internal/terraform/transform_config.go | 5 +++- 5 files changed, 47 insertions(+), 5 deletions(-) diff --git a/internal/providers/schemas.go b/internal/providers/schemas.go index 965d54e3ba26..1419888e83a1 100644 --- a/internal/providers/schemas.go +++ b/internal/providers/schemas.go @@ -23,6 +23,9 @@ func (ss ProviderSchema) SchemaForResourceType(mode addrs.ResourceMode, typeName case addrs.DataResourceMode: // Data resources don't have schema versions right now, since state is discarded for each refresh return ss.DataSources[typeName].Block, 0 + case addrs.EphemeralResourceMode: + // Ephemeral resources don't have schema versions because their objects never outlive a single phase + return ss.EphemeralResourceTypes[typeName].Block, 0 default: // Shouldn't happen, because the above cases are comprehensive. return nil, 0 diff --git a/internal/terraform/evaluate_valid.go b/internal/terraform/evaluate_valid.go index 50b122f9eb50..402ec2b7e30a 100644 --- a/internal/terraform/evaluate_valid.go +++ b/internal/terraform/evaluate_valid.go @@ -197,11 +197,15 @@ func staticValidateResourceReference(modCfg *configs.Config, addr addrs.Resource var diags tfdiags.Diagnostics var modeAdjective string + modeArticleUpper := "A" switch addr.Mode { case addrs.ManagedResourceMode: modeAdjective = "managed" case addrs.DataResourceMode: modeAdjective = "data" + case addrs.EphemeralResourceMode: + modeAdjective = "ephemeral" + modeArticleUpper = "An" default: // should never happen modeAdjective = "" @@ -223,8 +227,14 @@ func staticValidateResourceReference(modCfg *configs.Config, addr addrs.Resource diags = diags.Append(&hcl.Diagnostic{ Severity: hcl.DiagError, Summary: `Reference to undeclared resource`, - Detail: fmt.Sprintf(`A %s resource %q %q has not been declared in %s.%s`, modeAdjective, addr.Type, addr.Name, moduleConfigDisplayAddr(modCfg.Path), suggestion), - Subject: rng.ToHCL().Ptr(), + Detail: fmt.Sprintf( + `%s %s resource %q %q has not been declared in %s.%s`, + modeArticleUpper, modeAdjective, + addr.Type, addr.Name, + moduleConfigDisplayAddr(modCfg.Path), + suggestion, + ), + Subject: rng.ToHCL().Ptr(), }) return diags } @@ -259,8 +269,13 @@ func staticValidateResourceReference(modCfg *configs.Config, addr addrs.Resource diags = diags.Append(&hcl.Diagnostic{ Severity: hcl.DiagError, Summary: `Invalid resource type`, - Detail: fmt.Sprintf(`A %s resource type %q is not supported by provider %q.`, modeAdjective, addr.Type, providerFqn.String()), - Subject: rng.ToHCL().Ptr(), + Detail: fmt.Sprintf( + `%s %s resource type %q is not supported by provider %q.`, + modeArticleUpper, modeAdjective, + addr.Type, + providerFqn.String(), + ), + Subject: rng.ToHCL().Ptr(), }) return diags } diff --git a/internal/terraform/evaluate_valid_test.go b/internal/terraform/evaluate_valid_test.go index c715beb796b4..b3bac4d7c4e9 100644 --- a/internal/terraform/evaluate_valid_test.go +++ b/internal/terraform/evaluate_valid_test.go @@ -74,6 +74,14 @@ For example, to correlate with indices of a referring resource, use: Ref: "data.boop_data.boop_nested", WantErr: `Reference to scoped resource: The referenced data resource "boop_data" "boop_nested" is not available from this context.`, }, + { + Ref: "ephemeral.beep.boop", + WantErr: ``, + }, + { + Ref: "ephemeral.beep.nonexistant", + WantErr: `Reference to undeclared resource: An ephemeral resource "beep" "nonexistant" has not been declared in the root module.`, + }, { Ref: "data.boop_data.boop_nested", WantErr: ``, @@ -119,6 +127,11 @@ For example, to correlate with indices of a referring resource, use: }, }, }, + EphemeralResourceTypes: map[string]providers.Schema{ + "beep": { + Block: &configschema.Block{}, + }, + }, }, }), } diff --git a/internal/terraform/testdata/static-validate-refs/static-validate-refs.tf b/internal/terraform/testdata/static-validate-refs/static-validate-refs.tf index 2f71e21713d6..7887b57f48ec 100644 --- a/internal/terraform/testdata/static-validate-refs/static-validate-refs.tf +++ b/internal/terraform/testdata/static-validate-refs/static-validate-refs.tf @@ -4,6 +4,10 @@ terraform { source = "foobar/beep" # intentional mismatch between local name and type } } + # TODO: Remove this if ephemeral values / resources get stabilized. If this + # experiment is removed without stabilization, also remove the + # "ephemeral" block below and the test cases it's supporting. + experiments = [ephemeral_values] } resource "aws_instance" "no_count" { @@ -22,6 +26,10 @@ resource "boop_whatever" "nope" { data "beep" "boop" { } +ephemeral "beep" "boop" { + provider = boop +} + check "foo" { data "boop_data" "boop_nested" {} diff --git a/internal/terraform/transform_config.go b/internal/terraform/transform_config.go index b09f63cb4f3c..af2456da9432 100644 --- a/internal/terraform/transform_config.go +++ b/internal/terraform/transform_config.go @@ -96,13 +96,16 @@ func (t *ConfigTransformer) transformSingle(g *Graph, config *configs.Config) er module := config.Module log.Printf("[TRACE] ConfigTransformer: Starting for path: %v", path) - allResources := make([]*configs.Resource, 0, len(module.ManagedResources)+len(module.DataResources)) + allResources := make([]*configs.Resource, 0, len(module.ManagedResources)+len(module.DataResources)+len(module.EphemeralResources)) for _, r := range module.ManagedResources { allResources = append(allResources, r) } for _, r := range module.DataResources { allResources = append(allResources, r) } + for _, r := range module.EphemeralResources { + allResources = append(allResources, r) + } // Take a copy of the import targets, so we can edit them as we go. // Only include import targets that are targeting the current module. From ed135c73a58a35aeb1b27577d2f1f4cdcb3ecca5 Mon Sep 17 00:00:00 2001 From: Martin Atkins Date: Tue, 30 Apr 2024 11:24:58 -0700 Subject: [PATCH 07/23] lang: Basic awareness of ephemeral resource evaluation There is not yet the needed support in the concrete evaluation data implementation, but this at least now knows to call it and collect the results. --- internal/lang/eval.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/internal/lang/eval.go b/internal/lang/eval.go index 27e32220639e..5086a2ab90e8 100644 --- a/internal/lang/eval.go +++ b/internal/lang/eval.go @@ -283,6 +283,7 @@ func (s *Scope) evalContext(refs []*addrs.Reference, selfAddr addrs.Referenceabl // that's redundant in the process of populating our values map. dataResources := map[string]map[string]cty.Value{} managedResources := map[string]map[string]cty.Value{} + ephemeralResources := map[string]map[string]cty.Value{} wholeModules := map[string]cty.Value{} inputVariables := map[string]cty.Value{} localValues := map[string]cty.Value{} @@ -365,6 +366,8 @@ func (s *Scope) evalContext(refs []*addrs.Reference, selfAddr addrs.Referenceabl into = managedResources case addrs.DataResourceMode: into = dataResources + case addrs.EphemeralResourceMode: + into = ephemeralResources default: panic(fmt.Errorf("unsupported ResourceMode %s", subj.Mode)) } @@ -443,8 +446,8 @@ func (s *Scope) evalContext(refs []*addrs.Reference, selfAddr addrs.Referenceabl vals[k] = v } vals["resource"] = cty.ObjectVal(buildResourceObjects(managedResources)) - vals["data"] = cty.ObjectVal(buildResourceObjects(dataResources)) + vals["ephemeral"] = cty.ObjectVal(buildResourceObjects(ephemeralResources)) vals["module"] = cty.ObjectVal(wholeModules) vals["var"] = cty.ObjectVal(inputVariables) vals["local"] = cty.ObjectVal(localValues) From c9c4e9b9e652dd501b87cda971fb18812a63db97 Mon Sep 17 00:00:00 2001 From: Martin Atkins Date: Tue, 30 Apr 2024 11:25:44 -0700 Subject: [PATCH 08/23] terraform: Don't panic when visiting ephemeral resource nodes We don't yet do anything useful when we get there, but we do at least fail in a vaguely-graceful way. --- .../terraform/node_resource_plan_instance.go | 19 ++++++++++++ .../transform_attach_config_resource.go | 30 +++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/internal/terraform/node_resource_plan_instance.go b/internal/terraform/node_resource_plan_instance.go index d8801f43bf34..4420de94b92f 100644 --- a/internal/terraform/node_resource_plan_instance.go +++ b/internal/terraform/node_resource_plan_instance.go @@ -76,6 +76,8 @@ func (n *NodePlannableResourceInstance) Execute(ctx EvalContext, op walkOperatio return n.managedResourceExecute(ctx) case addrs.DataResourceMode: return n.dataResourceExecute(ctx) + case addrs.EphemeralResourceMode: + return n.ephemeralResourceExecute(ctx) default: panic(fmt.Errorf("unsupported resource mode %s", n.Config.Mode)) } @@ -505,6 +507,23 @@ func (n *NodePlannableResourceInstance) managedResourceExecute(ctx EvalContext) return diags } +func (n *NodePlannableResourceInstance) ephemeralResourceExecute(ctx EvalContext) (diags tfdiags.Diagnostics) { + config := n.Config + addr := n.ResourceInstanceAddr() + var diagRng *hcl.Range + if config != nil { + diagRng = config.DeclRange.Ptr() + } + + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Ephemeral resources not yet supported", + Detail: fmt.Sprintf("There is not yet any implementation of planning for %s.", addr), + Subject: diagRng, + }) + return diags +} + // replaceTriggered checks if this instance needs to be replace due to a change // in a replace_triggered_by reference. If replacement is required, the // instance address is added to forceReplace diff --git a/internal/terraform/transform_attach_config_resource.go b/internal/terraform/transform_attach_config_resource.go index cbd8d57f2e46..2f3bdc2a0d7f 100644 --- a/internal/terraform/transform_attach_config_resource.go +++ b/internal/terraform/transform_attach_config_resource.go @@ -86,6 +86,36 @@ func (t *AttachResourceConfigTransformer) Transform(g *Graph) error { // configuration we already attached above. arn.AttachResourceConfig(nil, r) } + + for _, r := range config.Module.EphemeralResources { + rAddr := r.Addr() + + if rAddr != addr.Resource { + // Not the same resource + continue + } + + log.Printf("[TRACE] AttachResourceConfigTransformer: attaching to %q (%T) config from %#v", dag.VertexName(v), v, r.DeclRange) + arn.AttachResourceConfig(r, nil) + + // attach the provider_meta info + if gnapmc, ok := v.(GraphNodeAttachProviderMetaConfigs); ok { + log.Printf("[TRACE] AttachResourceConfigTransformer: attaching provider meta configs to %s", dag.VertexName(v)) + if config == nil { + log.Printf("[TRACE] AttachResourceConfigTransformer: no config set on the transformer for %s", dag.VertexName(v)) + continue + } + if config.Module == nil { + log.Printf("[TRACE] AttachResourceConfigTransformer: no module in config for %s", dag.VertexName(v)) + continue + } + if config.Module.ProviderMetas == nil { + log.Printf("[TRACE] AttachResourceConfigTransformer: no provider metas defined for %s", dag.VertexName(v)) + continue + } + gnapmc.AttachProviderMetaConfigs(config.Module.ProviderMetas) + } + } } return nil From 8bb989b9e845b14616949988e18687d2e4e7b865 Mon Sep 17 00:00:00 2001 From: Martin Atkins Date: Tue, 30 Apr 2024 17:01:00 -0700 Subject: [PATCH 09/23] terraform: Graph nodes for closing ephemeral resource instances For now these graph nodes don't actually do anything, but the graph shape is at least plausible for what we'll need. --- internal/terraform/graph_builder_apply.go | 4 + internal/terraform/graph_builder_plan.go | 12 ++ internal/terraform/node_output.go | 20 +- internal/terraform/node_resource_ephemeral.go | 29 +++ .../transform_ephemeral_resource_close.go | 191 ++++++++++++++++++ 5 files changed, 250 insertions(+), 6 deletions(-) create mode 100644 internal/terraform/node_resource_ephemeral.go create mode 100644 internal/terraform/transform_ephemeral_resource_close.go diff --git a/internal/terraform/graph_builder_apply.go b/internal/terraform/graph_builder_apply.go index 863c2be686ca..2648cac03862 100644 --- a/internal/terraform/graph_builder_apply.go +++ b/internal/terraform/graph_builder_apply.go @@ -219,6 +219,10 @@ func (b *ApplyGraphBuilder) Steps() []GraphTransformer { // Close opened plugin connections &CloseProviderTransformer{}, + // Close any ephemeral resource instances and prune nodes for + // ephemeral resources that aren't being consumed by anything. + &ephemeralResourceCloseTransformer{op: walkApply}, + // close the root module &CloseRootModuleTransformer{}, diff --git a/internal/terraform/graph_builder_plan.go b/internal/terraform/graph_builder_plan.go index ba7bc7507cd6..83b9d0e00454 100644 --- a/internal/terraform/graph_builder_plan.go +++ b/internal/terraform/graph_builder_plan.go @@ -119,15 +119,23 @@ func (b *PlanGraphBuilder) Build(path addrs.ModuleInstance) (*Graph, tfdiags.Dia // See GraphBuilder func (b *PlanGraphBuilder) Steps() []GraphTransformer { + // simplerOperation chooses from a reduced set of possibilities + // for situations that don't need to distinguish the phases as + // filely as a raw walkOperation value does. + var simplerOperation walkOperation switch b.Operation { case walkPlan: b.initPlan() + simplerOperation = walkPlan case walkPlanDestroy: b.initDestroy() + simplerOperation = walkPlan case walkValidate: b.initValidate() + simplerOperation = walkValidate case walkImport: b.initImport() + simplerOperation = walkPlan default: panic("invalid plan operation: " + b.Operation.String()) } @@ -262,6 +270,10 @@ func (b *PlanGraphBuilder) Steps() []GraphTransformer { // Close opened plugin connections &CloseProviderTransformer{}, + // Close any ephemeral resource instances and prune nodes for + // ephemeral resources that aren't being consumed by anything. + &ephemeralResourceCloseTransformer{op: simplerOperation}, + // Close the root module &CloseRootModuleTransformer{}, diff --git a/internal/terraform/node_output.go b/internal/terraform/node_output.go index 8a0cc779a63f..f55a31521ee6 100644 --- a/internal/terraform/node_output.go +++ b/internal/terraform/node_output.go @@ -46,12 +46,13 @@ type nodeExpandOutput struct { } var ( - _ GraphNodeReferenceable = (*nodeExpandOutput)(nil) - _ GraphNodeReferencer = (*nodeExpandOutput)(nil) - _ GraphNodeReferenceOutside = (*nodeExpandOutput)(nil) - _ GraphNodeDynamicExpandable = (*nodeExpandOutput)(nil) - _ graphNodeTemporaryValue = (*nodeExpandOutput)(nil) - _ graphNodeExpandsInstances = (*nodeExpandOutput)(nil) + _ GraphNodeReferenceable = (*nodeExpandOutput)(nil) + _ GraphNodeReferencer = (*nodeExpandOutput)(nil) + _ GraphNodeReferenceOutside = (*nodeExpandOutput)(nil) + _ GraphNodeDynamicExpandable = (*nodeExpandOutput)(nil) + _ graphNodeTemporaryValue = (*nodeExpandOutput)(nil) + _ graphNodeExpandsInstances = (*nodeExpandOutput)(nil) + _ graphNodeEphemeralResourceConsumer = (*nodeExpandOutput)(nil) ) func (n *nodeExpandOutput) expandsInstances() {} @@ -213,6 +214,13 @@ func (n *nodeExpandOutput) References() []*addrs.Reference { return referencesForOutput(n.Config) } +// requiredEphemeralResources implements graphNodeEphemeralResourceConsumer. +func (n *nodeExpandOutput) requiredEphemeralResources(op walkOperation) addrs.Set[addrs.ConfigResource] { + // The consumed ephemeral resources are defined entirely by expression + // references. + return requiredEphemeralResourcesForReferencer(n) +} + func (n *nodeExpandOutput) getOverrideValue(inst addrs.ModuleInstance) cty.Value { // First check if we have any overrides at all, this is a shorthand for // "are we running terraform test". diff --git a/internal/terraform/node_resource_ephemeral.go b/internal/terraform/node_resource_ephemeral.go new file mode 100644 index 000000000000..4d6f85138465 --- /dev/null +++ b/internal/terraform/node_resource_ephemeral.go @@ -0,0 +1,29 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package terraform + +import ( + "github.com/hashicorp/terraform/internal/addrs" +) + +// nodeEphemeralResourceClose is the node type for closing the previously-opened +// instances of a particular ephemeral resource. +// +// Although ephemeral resource instances will always all get closed once a +// graph walk has completed anyway, the inclusion of explicit nodes for this +// allows closing ephemeral resource instances more promptly after all work +// that uses them has been completed, rather than always just waiting until +// the end of the graph walk. +// +// This is scoped to config-level resources rather than dynamic resource +// instances as a concession to allow using the same node type in both the plan +// and apply graphs, where the former only deals in whole resources while the +// latter contains individual instances. +type nodeEphemeralResourceClose struct { + addr addrs.ConfigResource +} + +func (n *nodeEphemeralResourceClose) Name() string { + return n.addr.String() + " (close)" +} diff --git a/internal/terraform/transform_ephemeral_resource_close.go b/internal/terraform/transform_ephemeral_resource_close.go new file mode 100644 index 000000000000..2d1687571bd5 --- /dev/null +++ b/internal/terraform/transform_ephemeral_resource_close.go @@ -0,0 +1,191 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package terraform + +import ( + "fmt" + "log" + + "github.com/hashicorp/terraform/internal/addrs" + "github.com/hashicorp/terraform/internal/collections" + "github.com/hashicorp/terraform/internal/dag" +) + +// graphNodeEphemeralResourceConsumer is implemented by graph node types that +// can validly refer to ephemeral resources, to announce which ephemeral +// resources they each depend on. +// +// This is used to decide the dependencies for [nodeEphemeralResourceClose] +// nodes. +type graphNodeEphemeralResourceConsumer interface { + // requiredEphemeralResources returns a set of all of the ephemeral + // resources that the receiver directly depends on when performing + // the given walk operation. + // + // Although the addrs package types can't constrain this statically, + // this method should return only addresses of mode + // [addrs.EphemeralResourceMode]. Resources of any other mode are invalid + // to return. + // + // walkOperation is normalized for implementation simplicity: it can be + // either [walkPlan] or [walkApply], and no other type. + requiredEphemeralResources(op walkOperation) addrs.Set[addrs.ConfigResource] +} + +// requiredEphemeralResourcesForReferencer is a helper for implementing +// [graphNodeEphemeralResourceConsumer] for any node type which implements +// [GraphNodeReferencer] and whose reported references can entirely describe +// the needed ephemeral resources. +func requiredEphemeralResourcesForReferencer[T GraphNodeReferencer](n T) addrs.Set[addrs.ConfigResource] { + moduleAddr := n.ModulePath() + refs := n.References() + if len(refs) == 0 { + return nil + } + ret := addrs.MakeSet[addrs.ConfigResource]() + for _, ref := range refs { + var resourceAddr addrs.Resource + switch refAddr := ref.Subject.(type) { + case addrs.Resource: + resourceAddr = refAddr + case addrs.ResourceInstance: + resourceAddr = refAddr.Resource + default: + continue + } + if resourceAddr.Mode != addrs.EphemeralResourceMode { + continue // we only care about ephemeral resources here + } + ret.Add(resourceAddr.InModule(moduleAddr)) + } + return ret +} + +// ephemeralResourceCloseTransformer is a graph transformer that inserts +// a [nodeEphemeralResourceClose] node for each ephemeral resource whose "open" +// is represented by at least one existing node, and arranges for the close +// node to depend on the open node and on any other node that consumes the +// relevant ephemeral resource. +// +// This transformer also prunes nodes for any ephemeral resources that have +// no consumers for the given walk operation. In particular this means that +// Terraform will not open any instances of an ephemeral resource that is +// only used in resource provisioners if the graph is not being built for the +// apply phase, because only the apply phase actually executes provisioners. +// +// This transformer must run after any other transformer that might introduce +// an ephemeral resource node into the graph, or that might given an existing +// node information it needs to properly announce any ephemeral resources it +// consumes. +type ephemeralResourceCloseTransformer struct { + // op must be one of walkValidate, walkPlan, or walkApply. For other walk + // operations, choose walkApply if the walk will execute resource + // provisioners or walkPlan otherwise. + // + // if op is walkValidate then this transformer does absolutely nothing, + // because we don't open or close ephemeral resources during the validate + // walk. + op walkOperation +} + +func (t *ephemeralResourceCloseTransformer) Transform(g *Graph) error { + if t.op != walkApply && t.op != walkPlan { + // Nothing to do for any other walks, because only plan-like or + // apply-like walks actually open ephemeral resource instances. + return nil + } + + // We'll freeze the set of vertices we started with so that we can + // visit it multiple times while we're modifying the graph. + verts := g.Vertices() + + // First we'll find all of the ephemeral resources that already have + // at least one node in the graph, and we'll assume those are all + // "open" nodes. Each distinct ephemeral resource address gets one + // close node that depends on all of the nodes that might open instances + // of it. + openNodes := addrs.MakeMap[addrs.ConfigResource, collections.Set[dag.Vertex]]() + closeNodes := addrs.MakeMap[addrs.ConfigResource, *nodeEphemeralResourceClose]() + for _, v := range verts { + v, ok := v.(GraphNodeConfigResource) + if !ok { + continue + } + addr := v.ResourceAddr() + if addr.Resource.Mode != addrs.EphemeralResourceMode { + continue + } + if !openNodes.Has(addr) { + openNodes.Put(addr, collections.NewSetCmp[dag.Vertex]()) + } + openNodes.Get(addr).Add(v) + + if !closeNodes.Has(addr) { + closeNode := &nodeEphemeralResourceClose{ + addr: addr, + } + closeNodes.Put(addr, closeNode) + log.Printf("[TRACE] ephemeralResourceCloseTransformer: adding close node for %s", addr) + g.Add(closeNode) + } + closeNode := closeNodes.Get(addr) + + // The close node depends on the open node, because we can't + // close an ephemeral resource instance until we've opened it. + g.Connect(dag.BasicEdge(closeNode, v)) + } + + consumerCount := addrs.MakeMap[addrs.ConfigResource, int]() + for _, v := range verts { + v, ok := v.(graphNodeEphemeralResourceConsumer) + if !ok { + continue + } + for _, consumedAddr := range v.requiredEphemeralResources(t.op) { + if consumedAddr.Resource.Mode != addrs.EphemeralResourceMode { + // Should not happen: correct implementations of + // [graphNodeEphemeralResourceConsumer] only return + // ephemeral resource addresses. + panic(fmt.Sprintf("node %s incorrectly reported %s as an ephemeral resource", dag.VertexName(v), consumedAddr)) + } + closeNode := closeNodes.Get(consumedAddr) + if closeNode == nil { + // Suggests that there's a reference to an ephemeral resource + // that isn't declared, which is invalid but it's not this + // transformer's responsibility to detect that invalidity, + // so we'll just ignore it. + log.Printf("[TRACE] ephemeralResourceCloseTransformer: %s refers to undeclared ephemeral resource %s", dag.VertexName(v), consumedAddr) + continue + } + consumerCount.Put(consumedAddr, consumerCount.Get(consumedAddr)+1) + + // The close node depends on anything that consumes instances of + // the ephemeral resource, because we mustn't close it while + // other components are still using it. + g.Connect(dag.BasicEdge(closeNode, v)) + } + } + + // Finally, if we found any ephemeral resources that don't have any + // consumers then we'll prune out all of their open and close nodes + // to avoid redundantly opening and closing something that we aren't + // going to use anyway. + // (We don't use this transformer in the validate walk, + for _, elem := range openNodes.Elems { + if consumerCount.Get(elem.Key) == 0 { + for _, v := range elem.Value.Elems() { + log.Printf("[TRACE] ephemeralResourceCloseTransformer: pruning %s because it has no consumers", dag.VertexName(v)) + g.Remove(v) + } + } + } + for _, elem := range closeNodes.Elems { + if consumerCount.Get(elem.Key) == 0 { + log.Printf("[TRACE] ephemeralResourceCloseTransformer: pruning %s because it has no consumers", dag.VertexName(elem.Value)) + g.Remove(elem.Value) + } + } + + return nil +} From 12d066c3c3b4fdbe435f595c99b484e30e3a3da3 Mon Sep 17 00:00:00 2001 From: Martin Atkins Date: Wed, 1 May 2024 17:01:20 -0700 Subject: [PATCH 10/23] resources/ephemeral: A place to track ephemeral resource instances --- .../ephemeral/ephemeral_resource_instance.go | 31 +++ .../ephemeral/ephemeral_resources.go | 229 ++++++++++++++++++ 2 files changed, 260 insertions(+) create mode 100644 internal/resources/ephemeral/ephemeral_resource_instance.go create mode 100644 internal/resources/ephemeral/ephemeral_resources.go diff --git a/internal/resources/ephemeral/ephemeral_resource_instance.go b/internal/resources/ephemeral/ephemeral_resource_instance.go new file mode 100644 index 000000000000..63b557b75dc4 --- /dev/null +++ b/internal/resources/ephemeral/ephemeral_resource_instance.go @@ -0,0 +1,31 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package ephemeral + +import ( + "context" + + "github.com/hashicorp/terraform/internal/providers" + "github.com/hashicorp/terraform/internal/tfdiags" +) + +// ResourceInstance is an interface that must be implemented for each +// active ephemeral resource instance to determine how it should be renewed +// and eventually closed. +type ResourceInstance interface { + // Renew attempts to extend the life of the remote object associated with + // this resource instance, optionally returning a new renewal request to be + // passed to a subsequent call to this method. + // + // If the object's life is not extended successfully then Renew returns + // error diagnostics explaining why not, and future requests that might + // have made use of the object will fail. + Renew(ctx context.Context, req providers.EphemeralRenew) (nextRenew *providers.EphemeralRenew, diags tfdiags.Diagnostics) + + // Close proactively ends the life of the remote object associated with + // this resource instance, if possible. For example, if the remote object + // is a temporary lease for a dynamically-generated secret then this + // might end that lease and thus cause the secret to be promptly revoked. + Close(ctx context.Context) tfdiags.Diagnostics +} diff --git a/internal/resources/ephemeral/ephemeral_resources.go b/internal/resources/ephemeral/ephemeral_resources.go new file mode 100644 index 000000000000..6e159eece5da --- /dev/null +++ b/internal/resources/ephemeral/ephemeral_resources.go @@ -0,0 +1,229 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package ephemeral + +import ( + "context" + "fmt" + "sync" + "time" + + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/terraform/internal/addrs" + "github.com/hashicorp/terraform/internal/providers" + "github.com/hashicorp/terraform/internal/tfdiags" + + "github.com/zclconf/go-cty/cty" +) + +// Resources is a tracking structure for active instances of ephemeral +// resources. +// +// The lifecycle of an ephemeral resource instance is quite different than +// other resource modes because it's live for at most the duration of a single +// graph walk, and because it might need periodic "renewing" in order to +// remain live for the necessary duration. +type Resources struct { + active addrs.Map[addrs.ConfigResource, addrs.Map[addrs.AbsResourceInstance, *resourceInstanceInternal]] + mu sync.Mutex +} + +func NewResources() *Resources { + return &Resources{ + active: addrs.MakeMap[addrs.ConfigResource, addrs.Map[addrs.AbsResourceInstance, *resourceInstanceInternal]](), + } +} + +type ResourceInstanceRegistration struct { + Value cty.Value + ConfigBody hcl.Body + Impl ResourceInstance + FirstRenewal *providers.EphemeralRenew +} + +func (r *Resources) RegisterInstance(ctx context.Context, addr addrs.AbsResourceInstance, reg ResourceInstanceRegistration) { + if addr.Resource.Resource.Mode != addrs.EphemeralResourceMode { + panic(fmt.Sprintf("can't register %s as an ephemeral resource instance", addr)) + } + + r.mu.Lock() + defer r.mu.Unlock() + + configAddr := addr.ConfigResource() + if !r.active.Has(configAddr) { + r.active.Put(configAddr, addrs.MakeMap[addrs.AbsResourceInstance, *resourceInstanceInternal]()) + } + ri := &resourceInstanceInternal{ + value: reg.Value, + configBody: reg.ConfigBody, + impl: reg.Impl, + renewCancel: noopCancel, + } + if reg.FirstRenewal != nil { + ctx, cancel := context.WithCancel(ctx) + ri.renewCancel = cancel + go ri.handleRenewal(ctx, reg.FirstRenewal) + } + r.active.Get(configAddr).Put(addr, ri) +} + +func (r *Resources) InstanceValue(addr addrs.AbsResourceInstance) (val cty.Value, live bool) { + r.mu.Lock() + defer r.mu.Unlock() + + configAddr := addr.ConfigResource() + insts, ok := r.active.GetOk(configAddr) + if !ok { + return cty.DynamicVal, false + } + inst, ok := insts.GetOk(addr) + if !ok { + return cty.DynamicVal, false + } + // If renewal has failed then we can't assume that the object is still + // live, but we can still return the original value regardless. + return inst.value, !inst.renewDiags.HasErrors() +} + +// CloseInstances shuts down any live ephemeral resource instances that are +// associated with the given resource address. +// +// This is the "happy path" way to shut down ephemeral resource instances, +// intended to be called during the visit to a graph node that depends on +// all other nodes that might make use of the instances of this ephemeral +// resource. +// +// The runtime should also eventually call [Resources.Close] once the graph +// walk is complete, to catch any stragglers that we didn't reach for +// piecemeal shutdown, e.g. due to errors during the graph walk. +func (r *Resources) CloseInstances(ctx context.Context, configAddr addrs.ConfigResource) tfdiags.Diagnostics { + r.mu.Lock() + defer r.mu.Unlock() + // TODO: Can we somehow avoid holding the lock for the entire duration? + // Closing an instance is likely to perform a network request, so this + // could potentially take a while and block other work from starting. + + var diags tfdiags.Diagnostics + for _, elem := range r.active.Get(configAddr).Elems { + moreDiags := elem.Value.close(ctx) + diags = diags.Append(moreDiags.InConfigBody(elem.Value.configBody, elem.Key.String())) + } + + // Stop tracking the objects we've just closed, so that we know we don't + // still need to close them. + r.active.Remove(configAddr) + + return diags +} + +// Close shuts down any ephemeral resource instances that are still running +// at the time of the call. +// +// This is intended to catch any "stragglers" that we weren't able to clean +// up during the graph walk, such as if an error prevents us from reaching +// the cleanup node. +func (r *Resources) Close(ctx context.Context) tfdiags.Diagnostics { + // FIXME: The following really ought to take into account dependency + // relationships between what's still running, because it's possible + // that one ephemeral resource depends on another ephemeral resource + // to operate correctly, such as if the HashiCorp Vault provider is + // accessing a secret lease through an SSH tunnel: closing the SSH tunnel + // before closing the Vault secret lease will make the Vault API + // unreachable. + // + // We'll just ignore that for now since this is just a prototype anyway. + + r.mu.Lock() + defer r.mu.Unlock() + + // We might be closing due to a context cancellation, but we still need + // to be able to make non-canceled Close requests. + ctx = context.WithoutCancel(ctx) + + var diags tfdiags.Diagnostics + for _, elem := range r.active.Elems { + for _, elem := range elem.Value.Elems { + moreDiags := elem.Value.close(ctx) + diags = diags.Append(moreDiags.InConfigBody(elem.Value.configBody, elem.Key.String())) + } + } + r.active = addrs.MakeMap[addrs.ConfigResource, addrs.Map[addrs.AbsResourceInstance, *resourceInstanceInternal]]() + return diags +} + +type resourceInstanceInternal struct { + value cty.Value + configBody hcl.Body + impl ResourceInstance + + renewCancel func() + renewDiags tfdiags.Diagnostics + renewMu sync.Mutex // hold when accessing renewCancel/renewDiags, and while actually renewing +} + +// close halts this instance's asynchronous renewal loop, if any, and then +// calls Close on the resource instance's implementation object. +// +// The returned diagnostics are contextual diagnostics that should have +// [tfdiags.Diagnostics.WithConfigBody] called on them before returning to +// a context-unaware caller. +func (r *resourceInstanceInternal) close(ctx context.Context) tfdiags.Diagnostics { + var diags tfdiags.Diagnostics + + // Stop renewing, if indeed we are. If we previously saw any errors during + // renewing then they finally get returned here, to be reported along with + // any errors during close. + r.renewMu.Lock() + r.renewCancel() + diags = diags.Append(r.renewDiags) + r.renewDiags = nil // just to avoid any risk of double-reporting + r.renewMu.Unlock() + + // FIXME: If renewal failed earlier then it's pretty likely that closing + // would fail too. For now this is assuming that it's the provider's + // own responsibility to remember that it previously failed a renewal + // and to avoid returning redundant errors from close, but perhaps we'll + // revisit that in later work. + diags = diags.Append(r.impl.Close(context.WithoutCancel(ctx))) + + return diags +} + +func (r *resourceInstanceInternal) handleRenewal(ctx context.Context, firstRenewal *providers.EphemeralRenew) { + t := time.NewTimer(time.Until(firstRenewal.ExpireTime.Add(-60 * time.Second))) + nextRenew := firstRenewal + for { + select { + case <-t.C: + // It's time to renew + r.renewMu.Lock() + anotherRenew, diags := r.impl.Renew(ctx, *nextRenew) + r.renewDiags.Append(diags) + if diags.HasErrors() { + // If renewal fails then we'll stop trying to renew. + r.renewCancel = noopCancel + r.renewMu.Unlock() + return + } + if anotherRenew == nil { + // If we don't have another round of renew to do then we'll stop. + r.renewCancel = noopCancel + r.renewMu.Unlock() + return + } + nextRenew = anotherRenew + t.Reset(time.Until(anotherRenew.ExpireTime.Add(-60 * time.Second))) + r.renewMu.Unlock() + case <-ctx.Done(): + // If we're cancelled then we'll halt renewing immediately. + r.renewMu.Lock() + t.Stop() + r.renewCancel = noopCancel + r.renewMu.Unlock() + return // we don't need to run this loop anymore + } + } +} + +func noopCancel() {} From 3273bffcc4b69efbcd0f88dcecf6b923563f5e61 Mon Sep 17 00:00:00 2001 From: Martin Atkins Date: Fri, 3 May 2024 14:29:37 -0700 Subject: [PATCH 11/23] terraform: Plumb in an ephemeral.Resources object for each graph walk --- internal/terraform/context_walk.go | 2 ++ internal/terraform/eval_context.go | 5 +++ internal/terraform/eval_context_builtin.go | 40 +++++++++++++--------- internal/terraform/eval_context_mock.go | 9 +++++ internal/terraform/graph_walk_context.go | 19 +++++----- 5 files changed, 50 insertions(+), 25 deletions(-) diff --git a/internal/terraform/context_walk.go b/internal/terraform/context_walk.go index 4cf70b676945..5f1026fa618e 100644 --- a/internal/terraform/context_walk.go +++ b/internal/terraform/context_walk.go @@ -17,6 +17,7 @@ import ( "github.com/hashicorp/terraform/internal/plans/deferring" "github.com/hashicorp/terraform/internal/providers" "github.com/hashicorp/terraform/internal/refactoring" + "github.com/hashicorp/terraform/internal/resources/ephemeral" "github.com/hashicorp/terraform/internal/states" "github.com/hashicorp/terraform/internal/tfdiags" ) @@ -184,6 +185,7 @@ func (c *Context) graphWalker(graph *Graph, operation walkOperation, opts *graph NamedValues: namedvals.NewState(), Deferrals: deferred, Checks: checkState, + EphemeralResources: ephemeral.NewResources(), InstanceExpander: instances.NewExpander(opts.Overrides), ExternalProviderConfigs: opts.ExternalProviderConfigs, MoveResults: opts.MoveResults, diff --git a/internal/terraform/eval_context.go b/internal/terraform/eval_context.go index ae784b779926..66c5dd4f809d 100644 --- a/internal/terraform/eval_context.go +++ b/internal/terraform/eval_context.go @@ -21,6 +21,7 @@ import ( "github.com/hashicorp/terraform/internal/providers" "github.com/hashicorp/terraform/internal/provisioners" "github.com/hashicorp/terraform/internal/refactoring" + "github.com/hashicorp/terraform/internal/resources/ephemeral" "github.com/hashicorp/terraform/internal/states" "github.com/hashicorp/terraform/internal/tfdiags" ) @@ -169,6 +170,10 @@ type EvalContext interface { // meaningful comparison with RefreshState. PrevRunState() *states.SyncState + // EphemeralResources returns a helper object for tracking active + // instances of ephemeral resources declared in the configuration. + EphemeralResources() *ephemeral.Resources + // InstanceExpander returns a helper object for tracking the expansion of // graph nodes during the plan phase in response to "count" and "for_each" // arguments. diff --git a/internal/terraform/eval_context_builtin.go b/internal/terraform/eval_context_builtin.go index d5c3df9ee5eb..67799e872701 100644 --- a/internal/terraform/eval_context_builtin.go +++ b/internal/terraform/eval_context_builtin.go @@ -27,6 +27,7 @@ import ( "github.com/hashicorp/terraform/internal/providers" "github.com/hashicorp/terraform/internal/provisioners" "github.com/hashicorp/terraform/internal/refactoring" + "github.com/hashicorp/terraform/internal/resources/ephemeral" "github.com/hashicorp/terraform/internal/states" "github.com/hashicorp/terraform/internal/tfdiags" "github.com/hashicorp/terraform/version" @@ -68,23 +69,24 @@ type BuiltinEvalContext struct { // DeferralsValue is the object returned by [BuiltinEvalContext.Deferrals]. DeferralsValue *deferring.Deferred - Hooks []Hook - InputValue UIInput - ProviderCache map[string]providers.Interface - ProviderFuncCache map[string]providers.Interface - ProviderFuncResults *providers.FunctionResults - ProviderInputConfig map[string]map[string]cty.Value - ProviderLock *sync.Mutex - ProvisionerCache map[string]provisioners.Interface - ProvisionerLock *sync.Mutex - ChangesValue *plans.ChangesSync - StateValue *states.SyncState - ChecksValue *checks.State - RefreshStateValue *states.SyncState - PrevRunStateValue *states.SyncState - InstanceExpanderValue *instances.Expander - MoveResultsValue refactoring.MoveResults - OverrideValues *mocking.Overrides + Hooks []Hook + InputValue UIInput + ProviderCache map[string]providers.Interface + ProviderFuncCache map[string]providers.Interface + ProviderFuncResults *providers.FunctionResults + ProviderInputConfig map[string]map[string]cty.Value + ProviderLock *sync.Mutex + ProvisionerCache map[string]provisioners.Interface + ProvisionerLock *sync.Mutex + ChangesValue *plans.ChangesSync + StateValue *states.SyncState + ChecksValue *checks.State + RefreshStateValue *states.SyncState + PrevRunStateValue *states.SyncState + EphemeralResourcesValue *ephemeral.Resources + InstanceExpanderValue *instances.Expander + MoveResultsValue refactoring.MoveResults + OverrideValues *mocking.Overrides } // BuiltinEvalContext implements EvalContext @@ -612,6 +614,10 @@ func (ctx *BuiltinEvalContext) PrevRunState() *states.SyncState { return ctx.PrevRunStateValue } +func (ctx *BuiltinEvalContext) EphemeralResources() *ephemeral.Resources { + return ctx.EphemeralResourcesValue +} + func (ctx *BuiltinEvalContext) InstanceExpander() *instances.Expander { return ctx.InstanceExpanderValue } diff --git a/internal/terraform/eval_context_mock.go b/internal/terraform/eval_context_mock.go index cabb21003064..56439bbd3eac 100644 --- a/internal/terraform/eval_context_mock.go +++ b/internal/terraform/eval_context_mock.go @@ -23,6 +23,7 @@ import ( "github.com/hashicorp/terraform/internal/providers" "github.com/hashicorp/terraform/internal/provisioners" "github.com/hashicorp/terraform/internal/refactoring" + "github.com/hashicorp/terraform/internal/resources/ephemeral" "github.com/hashicorp/terraform/internal/states" "github.com/hashicorp/terraform/internal/tfdiags" ) @@ -147,6 +148,9 @@ type MockEvalContext struct { MoveResultsCalled bool MoveResultsResults refactoring.MoveResults + EphemeralResourcesCalled bool + EphemeralResourcesResources *ephemeral.Resources + InstanceExpanderCalled bool InstanceExpanderExpander *instances.Expander @@ -393,6 +397,11 @@ func (c *MockEvalContext) MoveResults() refactoring.MoveResults { return c.MoveResultsResults } +func (c *MockEvalContext) EphemeralResources() *ephemeral.Resources { + c.EphemeralResourcesCalled = true + return c.EphemeralResourcesResources +} + func (c *MockEvalContext) InstanceExpander() *instances.Expander { c.InstanceExpanderCalled = true return c.InstanceExpanderExpander diff --git a/internal/terraform/graph_walk_context.go b/internal/terraform/graph_walk_context.go index 7324b12defa8..2abda08b2b43 100644 --- a/internal/terraform/graph_walk_context.go +++ b/internal/terraform/graph_walk_context.go @@ -21,6 +21,7 @@ import ( "github.com/hashicorp/terraform/internal/providers" "github.com/hashicorp/terraform/internal/provisioners" "github.com/hashicorp/terraform/internal/refactoring" + "github.com/hashicorp/terraform/internal/resources/ephemeral" "github.com/hashicorp/terraform/internal/states" "github.com/hashicorp/terraform/internal/tfdiags" ) @@ -32,14 +33,15 @@ type ContextGraphWalker struct { // Configurable values Context *Context - State *states.SyncState // Used for safe concurrent access to state - RefreshState *states.SyncState // Used for safe concurrent access to state - PrevRunState *states.SyncState // Used for safe concurrent access to state - Changes *plans.ChangesSync // Used for safe concurrent writes to changes - Checks *checks.State // Used for safe concurrent writes of checkable objects and their check results - NamedValues *namedvals.State // Tracks evaluation of input variables, local values, and output values - InstanceExpander *instances.Expander // Tracks our gradual expansion of module and resource instances - Deferrals *deferring.Deferred // Tracks any deferred actions + State *states.SyncState // Used for safe concurrent access to state + RefreshState *states.SyncState // Used for safe concurrent access to state + PrevRunState *states.SyncState // Used for safe concurrent access to state + Changes *plans.ChangesSync // Used for safe concurrent writes to changes + Checks *checks.State // Used for safe concurrent writes of checkable objects and their check results + NamedValues *namedvals.State // Tracks evaluation of input variables, local values, and output values + EphemeralResources *ephemeral.Resources // Tracks active instances of ephemeral resources + InstanceExpander *instances.Expander // Tracks our gradual expansion of module and resource instances + Deferrals *deferring.Deferred // Tracks any deferred actions Imports []configs.Import MoveResults refactoring.MoveResults // Read-only record of earlier processing of move statements Operation walkOperation @@ -111,6 +113,7 @@ func (w *ContextGraphWalker) EvalContext() EvalContext { StopContext: w.StopContext, Hooks: w.Context.hooks, InputValue: w.Context.uiInput, + EphemeralResourcesValue: w.EphemeralResources, InstanceExpanderValue: w.InstanceExpander, Plugins: w.Context.plugins, ExternalProviderConfigs: w.ExternalProviderConfigs, From cd50bfab891072a424bfb84d4ac4b2799bae5137 Mon Sep 17 00:00:00 2001 From: Martin Atkins Date: Fri, 3 May 2024 14:45:33 -0700 Subject: [PATCH 12/23] terraform: "Close" the graph walker when a graph walker is complete We now need to clean up any straggling ephemeral resource instances before we complete each graph walk, and ephemeral resource instances are ultimately owned by the graph walker, so the graph walker now has a Close method that's responsible for cleaning up anything that the walker owns which needs to be explicitly closed at the end of a walk. --- internal/terraform/context_apply.go | 1 + internal/terraform/context_eval.go | 5 ++++- internal/terraform/context_plan.go | 1 + internal/terraform/context_validate.go | 5 +---- internal/terraform/graph_walk.go | 2 ++ internal/terraform/graph_walk_context.go | 12 ++++++++++++ 6 files changed, 21 insertions(+), 5 deletions(-) diff --git a/internal/terraform/context_apply.go b/internal/terraform/context_apply.go index e29a7e8a7c62..5f8fbea5139d 100644 --- a/internal/terraform/context_apply.go +++ b/internal/terraform/context_apply.go @@ -236,6 +236,7 @@ Note that the -target option is not suitable for routine use, and is provided on // expressions, like in "terraform console" or the test harness. evalScope := evalScopeFromGraphWalk(walker, addrs.RootModuleInstance) + diags = diags.Append(walker.Close()) return newState, evalScope, diags } diff --git a/internal/terraform/context_eval.go b/internal/terraform/context_eval.go index c3f54df240a1..679135372e5d 100644 --- a/internal/terraform/context_eval.go +++ b/internal/terraform/context_eval.go @@ -103,7 +103,10 @@ func (c *Context) Eval(config *configs.Config, state *states.State, moduleAddr a walker = c.graphWalker(graph, walkEval, walkOpts) } - return evalScopeFromGraphWalk(walker, moduleAddr), diags + scope := evalScopeFromGraphWalk(walker, moduleAddr) + + diags = diags.Append(walker.Close()) + return scope, diags } // evalScopeFromGraphWalk takes a [ContextGraphWalker] that was already used diff --git a/internal/terraform/context_plan.go b/internal/terraform/context_plan.go index 03faa63d39d2..62d201d27ebe 100644 --- a/internal/terraform/context_plan.go +++ b/internal/terraform/context_plan.go @@ -825,6 +825,7 @@ func (c *Context) planWalk(config *configs.Config, prevRunState *states.State, o // expressions, like in "terraform console" or the test harness. evalScope := evalScopeFromGraphWalk(walker, addrs.RootModuleInstance) + diags = diags.Append(walker.Close()) return plan, evalScope, diags } diff --git a/internal/terraform/context_validate.go b/internal/terraform/context_validate.go index 025e2ea827e7..444518612eaf 100644 --- a/internal/terraform/context_validate.go +++ b/internal/terraform/context_validate.go @@ -116,9 +116,6 @@ func (c *Context) Validate(config *configs.Config, opts *ValidateOpts) tfdiags.D }) diags = diags.Append(walker.NonFatalDiagnostics) diags = diags.Append(walkDiags) - if walkDiags.HasErrors() { - return diags - } - + diags = diags.Append(walker.Close()) return diags } diff --git a/internal/terraform/graph_walk.go b/internal/terraform/graph_walk.go index 09886fc043c5..146a50d637fb 100644 --- a/internal/terraform/graph_walk.go +++ b/internal/terraform/graph_walk.go @@ -14,6 +14,7 @@ type GraphWalker interface { enterScope(evalContextScope) EvalContext exitScope(evalContextScope) Execute(EvalContext, GraphNodeExecutable) tfdiags.Diagnostics + Close() tfdiags.Diagnostics } // NullGraphWalker is a GraphWalker implementation that does nothing. @@ -25,3 +26,4 @@ func (NullGraphWalker) EvalContext() EvalContext func (NullGraphWalker) enterScope(evalContextScope) EvalContext { return new(MockEvalContext) } func (NullGraphWalker) exitScope(evalContextScope) {} func (NullGraphWalker) Execute(EvalContext, GraphNodeExecutable) tfdiags.Diagnostics { return nil } +func (NullGraphWalker) Close() tfdiags.Diagnostics { return nil } diff --git a/internal/terraform/graph_walk_context.go b/internal/terraform/graph_walk_context.go index 2abda08b2b43..85de1f27fdf9 100644 --- a/internal/terraform/graph_walk_context.go +++ b/internal/terraform/graph_walk_context.go @@ -155,3 +155,15 @@ func (w *ContextGraphWalker) Execute(ctx EvalContext, n GraphNodeExecutable) tfd return n.Execute(ctx, w.Operation) } + +func (w *ContextGraphWalker) Close() tfdiags.Diagnostics { + var diags tfdiags.Diagnostics + if er := w.EphemeralResources; er != nil { + // FIXME: The graph walk bits all long predate Go's context.Context + // and so we don't have a general ambient context.Context for the + // overall operation. Hopefully one day we do, and then it could + // be passed in here. + diags = diags.Append(er.Close(context.TODO())) + } + return diags +} From ff85c35df6b63d93b613b0c643e9be8466ec270c Mon Sep 17 00:00:00 2001 From: Martin Atkins Date: Fri, 3 May 2024 16:21:24 -0700 Subject: [PATCH 13/23] terraform: Open and close ephemeral resource instances during graph walk --- internal/terraform/node_resource_ephemeral.go | 162 ++++++++++++++++++ .../terraform/node_resource_plan_instance.go | 17 +- 2 files changed, 166 insertions(+), 13 deletions(-) diff --git a/internal/terraform/node_resource_ephemeral.go b/internal/terraform/node_resource_ephemeral.go index 4d6f85138465..3540d5130f50 100644 --- a/internal/terraform/node_resource_ephemeral.go +++ b/internal/terraform/node_resource_ephemeral.go @@ -4,9 +4,126 @@ package terraform import ( + "context" + "fmt" + "log" + + "github.com/zclconf/go-cty/cty" + "github.com/hashicorp/terraform/internal/addrs" + "github.com/hashicorp/terraform/internal/configs" + "github.com/hashicorp/terraform/internal/lang/marks" + "github.com/hashicorp/terraform/internal/plans/objchange" + "github.com/hashicorp/terraform/internal/providers" + "github.com/hashicorp/terraform/internal/resources/ephemeral" + "github.com/hashicorp/terraform/internal/tfdiags" ) +type ephemeralResourceInput struct { + addr addrs.AbsResourceInstance + config *configs.Resource + providerConfig addrs.AbsProviderConfig +} + +// ephemeralResourceOpen implements the "open" step of the ephemeral resource +// instance lifecycle, which behaves the same way in both the plan and apply +// walks. +func ephemeralResourceOpen(ctx EvalContext, inp ephemeralResourceInput) tfdiags.Diagnostics { + log.Printf("[TRACE] ephemeralResourceOpen: opening %s", inp.addr) + var diags tfdiags.Diagnostics + + provider, providerSchema, err := getProvider(ctx, inp.providerConfig) + if err != nil { + diags = diags.Append(err) + return diags + } + + config := inp.config + schema, _ := providerSchema.SchemaForResourceAddr(inp.addr.ContainingResource().Resource) + if schema == nil { + // Should be caught during validation, so we don't bother with a pretty error here + diags = diags.Append( + fmt.Errorf("provider %q does not support data source %q", + inp.providerConfig, inp.addr.ContainingResource().Resource.Type, + ), + ) + return diags + } + + resources := ctx.EphemeralResources() + allInsts := ctx.InstanceExpander() + keyData := allInsts.GetResourceInstanceRepetitionData(inp.addr) + + checkDiags := evalCheckRules( + addrs.ResourcePrecondition, + config.Preconditions, + ctx, inp.addr, keyData, + tfdiags.Error, + ) + diags = diags.Append(checkDiags) + if diags.HasErrors() { + return diags // failed preconditions prevent further evaluation + } + + configVal, _, configDiags := ctx.EvaluateBlock(config.Config, schema, nil, keyData) + diags = diags.Append(configDiags) + if diags.HasErrors() { + return diags + } + unmarkedConfigVal, configMarks := configVal.UnmarkDeepWithPaths() + + // TODO: Call provider.ValidateEphemeralConfig, once such a thing exists. + // For prototype we'll just let OpenEphemeral be the first line of validation. + + resp := provider.OpenEphemeral(providers.OpenEphemeralRequest{ + TypeName: inp.addr.ContainingResource().Resource.Type, + Config: unmarkedConfigVal, + }) + if resp.Deferred != nil { + // FIXME: Actually implement this. (Skipped for prototype only because + // deferred changes is under development concurrently with this + // prototype, and so don't want to conflict.) + diags = diags.Append(fmt.Errorf("don't support deferral of ephemeral resource instances yet")) + } + diags = diags.Append(resp.Diagnostics.InConfigBody(config.Config, inp.addr.String())) + if diags.HasErrors() { + return diags + } + resultVal := resp.Result.MarkWithPaths(configMarks) + + errs := objchange.AssertPlanValid(schema, cty.NullVal(schema.ImpliedType()), configVal, resultVal) + for _, err := range errs { + // FIXME: Should turn these errors into suitable diagnostics. + diags = diags.Append(err) + } + if diags.HasErrors() { + return diags + } + + // FIXME: Ideally anything that was set in the configuration to a + // non-ephemeral value should remain non-ephemeral in the result. For + // the sake of initial prototyping though, we'll just make the entire + // object ephemeral. + resultVal = resultVal.Mark(marks.Ephemeral) + + impl := &ephemeralResourceInstImpl{ + addr: inp.addr, + provider: provider, + closeData: resp.InternalContext, + } + // TODO: What can we use as a signal to cancel the context we're passing + // in here, so that the object will stop renewing things when we start + // shutting down? + resources.RegisterInstance(context.TODO(), inp.addr, ephemeral.ResourceInstanceRegistration{ + Value: resultVal, + ConfigBody: config.Config, + Impl: impl, + FirstRenewal: resp.Renew, + }) + + return diags +} + // nodeEphemeralResourceClose is the node type for closing the previously-opened // instances of a particular ephemeral resource. // @@ -24,6 +141,51 @@ type nodeEphemeralResourceClose struct { addr addrs.ConfigResource } +var _ GraphNodeExecutable = (*nodeEphemeralResourceClose)(nil) +var _ GraphNodeModulePath = (*nodeEphemeralResourceClose)(nil) + func (n *nodeEphemeralResourceClose) Name() string { return n.addr.String() + " (close)" } + +// ModulePath implements GraphNodeModulePath. +func (n *nodeEphemeralResourceClose) ModulePath() addrs.Module { + return n.addr.Module +} + +// Execute implements GraphNodeExecutable. +func (n *nodeEphemeralResourceClose) Execute(ctx EvalContext, op walkOperation) tfdiags.Diagnostics { + log.Printf("[TRACE] nodeEphemeralResourceClose: closing all instances of %s", n.addr) + resources := ctx.EphemeralResources() + return resources.CloseInstances(context.TODO(), n.addr) +} + +// ephemeralResourceInstImpl implements ephemeral.ResourceInstance as an +// adapter to the relevant provider API calls. +type ephemeralResourceInstImpl struct { + addr addrs.AbsResourceInstance + provider providers.Interface + closeData []byte +} + +var _ ephemeral.ResourceInstance = (*ephemeralResourceInstImpl)(nil) + +// Close implements ephemeral.ResourceInstance. +func (impl *ephemeralResourceInstImpl) Close(ctx context.Context) tfdiags.Diagnostics { + log.Printf("[TRACE] ephemeralResourceInstImpl: closing %s", impl.addr) + resp := impl.provider.CloseEphemeral(providers.CloseEphemeralRequest{ + TypeName: impl.addr.Resource.Resource.Type, + InternalContext: impl.closeData, + }) + return resp.Diagnostics +} + +// Renew implements ephemeral.ResourceInstance. +func (impl *ephemeralResourceInstImpl) Renew(ctx context.Context, req providers.EphemeralRenew) (nextRenew *providers.EphemeralRenew, diags tfdiags.Diagnostics) { + log.Printf("[TRACE] ephemeralResourceInstImpl: renewing %s", impl.addr) + resp := impl.provider.RenewEphemeral(providers.RenewEphemeralRequest{ + TypeName: impl.addr.Resource.Resource.Type, + InternalContext: req.InternalContext, + }) + return resp.RenewAgain, resp.Diagnostics +} diff --git a/internal/terraform/node_resource_plan_instance.go b/internal/terraform/node_resource_plan_instance.go index 4420de94b92f..d647a73d886c 100644 --- a/internal/terraform/node_resource_plan_instance.go +++ b/internal/terraform/node_resource_plan_instance.go @@ -508,20 +508,11 @@ func (n *NodePlannableResourceInstance) managedResourceExecute(ctx EvalContext) } func (n *NodePlannableResourceInstance) ephemeralResourceExecute(ctx EvalContext) (diags tfdiags.Diagnostics) { - config := n.Config - addr := n.ResourceInstanceAddr() - var diagRng *hcl.Range - if config != nil { - diagRng = config.DeclRange.Ptr() - } - - diags = diags.Append(&hcl.Diagnostic{ - Severity: hcl.DiagError, - Summary: "Ephemeral resources not yet supported", - Detail: fmt.Sprintf("There is not yet any implementation of planning for %s.", addr), - Subject: diagRng, + return ephemeralResourceOpen(ctx, ephemeralResourceInput{ + addr: n.Addr, + config: n.Config, + providerConfig: n.ResolvedProvider, }) - return diags } // replaceTriggered checks if this instance needs to be replace due to a change From dc922449be3cb122723e323ef1cb5898af8662bc Mon Sep 17 00:00:00 2001 From: Martin Atkins Date: Mon, 6 May 2024 11:22:30 -0700 Subject: [PATCH 14/23] terraform: Close provider after ephemeral resources closed Because ephemeralResourceCloseTransformer runs very late in the transform sequence, it's too late to get provider open and close nodes associated with it automatically. We don't actually need to worry about the provider _open_ dependency because our close node always depends on all of our open nodes and they will in turn depend on the provider open they need. But for close we need to delay closing the provider until all of the associated ephemeral resources have been closed, so we need to do a little fixup: If any of particular ephemeral resource's open nodes have provider close nodes depending on them, those provider close nodes should also depend on the ephemeral resource close node. That then describes that the provider should remain open for as long as at least one ephemeral resource instance owned by that provider remains live, which makes it okay for us to do our periodic background renew requests and our final close requests. --- .../transform_ephemeral_resource_close.go | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/internal/terraform/transform_ephemeral_resource_close.go b/internal/terraform/transform_ephemeral_resource_close.go index 2d1687571bd5..96e09fa41ad6 100644 --- a/internal/terraform/transform_ephemeral_resource_close.go +++ b/internal/terraform/transform_ephemeral_resource_close.go @@ -167,6 +167,44 @@ func (t *ephemeralResourceCloseTransformer) Transform(g *Graph) error { } } + // Because this graph transformer runs very late in the sequence, we'll + // also need to do some work to make sure the close node is associated + // with the same provider as the open nodes; the open nodes get that + // dealt with by earlier transformers, and we can't benefit directly from + // that here but we can at least make use of the results of that earlier + // work. + // + // The idea here is that each open node should have a graphNodeCloseProvider + // depending on it, and we're going to just connect them all up to + // also depend on the corresponding ephemeral value close node, assuming + // that the earlier provider-close wiring knew what it was doing and so we + // don't need to sweat the details too much in here. + for _, elem := range closeNodes.Elems { + configAddr := elem.Key + closeNode := elem.Value + for _, openNode := range openNodes.Get(configAddr).Elems() { + for _, dependent := range g.UpEdges(openNode).List() { + // FIXME: Ugh... testing for a concrete node type rather than + // an interface isn't great here. But as long as ephemeral + // values is just a prototype it's not desirable to go on + // a big refactoring spree, so we'll just live with it. + // + // If you're here considering how to turn this prototype into + // shippable code, _please_ do something about this because + // tight coupling with specific concrete node types has + // historically been a maintenence hazard. + if v, ok := dependent.(*graphNodeCloseProvider); ok { + // any "close provider" node that depends on any of our + // opens should also depend on our close, because if + // a provider needs to be running to open then it needs + // to be running to close too. + log.Printf("[TRACE] ephemeralResourceCloseTransformer: %s must run after %s", dag.VertexName(v), dag.VertexName(closeNode)) + g.Connect(dag.BasicEdge(v, closeNode)) + } + } + } + } + // Finally, if we found any ephemeral resources that don't have any // consumers then we'll prune out all of their open and close nodes // to avoid redundantly opening and closing something that we aren't From e2e82bed6ed27dc8531d4686e45552124e457c0a Mon Sep 17 00:00:00 2001 From: Martin Atkins Date: Mon, 6 May 2024 11:49:47 -0700 Subject: [PATCH 15/23] terraform: Use GraphNodeReferencer directly for ephemeral resource analysis Previously we had a special interface graphNodeEphemeralResourceConsumer and a helper for implementing it in terms of GraphNodeReferencer, but for the moment we'll just use GraphNodeReferencer directly with that helper because that gives us broad coverage across many node types without having to make such sprawling changes just to support a prototype. The separated interface design might return later if we discover a need for a node to report that it uses an ephemeral resource without actually including any expression references for it, but we'll wait to see if that additional complexity is actually needed. --- internal/terraform/node_output.go | 20 ++--- internal/terraform/node_resource_validate.go | 3 + .../transform_ephemeral_resource_close.go | 83 +++++++------------ 3 files changed, 40 insertions(+), 66 deletions(-) diff --git a/internal/terraform/node_output.go b/internal/terraform/node_output.go index f55a31521ee6..8a0cc779a63f 100644 --- a/internal/terraform/node_output.go +++ b/internal/terraform/node_output.go @@ -46,13 +46,12 @@ type nodeExpandOutput struct { } var ( - _ GraphNodeReferenceable = (*nodeExpandOutput)(nil) - _ GraphNodeReferencer = (*nodeExpandOutput)(nil) - _ GraphNodeReferenceOutside = (*nodeExpandOutput)(nil) - _ GraphNodeDynamicExpandable = (*nodeExpandOutput)(nil) - _ graphNodeTemporaryValue = (*nodeExpandOutput)(nil) - _ graphNodeExpandsInstances = (*nodeExpandOutput)(nil) - _ graphNodeEphemeralResourceConsumer = (*nodeExpandOutput)(nil) + _ GraphNodeReferenceable = (*nodeExpandOutput)(nil) + _ GraphNodeReferencer = (*nodeExpandOutput)(nil) + _ GraphNodeReferenceOutside = (*nodeExpandOutput)(nil) + _ GraphNodeDynamicExpandable = (*nodeExpandOutput)(nil) + _ graphNodeTemporaryValue = (*nodeExpandOutput)(nil) + _ graphNodeExpandsInstances = (*nodeExpandOutput)(nil) ) func (n *nodeExpandOutput) expandsInstances() {} @@ -214,13 +213,6 @@ func (n *nodeExpandOutput) References() []*addrs.Reference { return referencesForOutput(n.Config) } -// requiredEphemeralResources implements graphNodeEphemeralResourceConsumer. -func (n *nodeExpandOutput) requiredEphemeralResources(op walkOperation) addrs.Set[addrs.ConfigResource] { - // The consumed ephemeral resources are defined entirely by expression - // references. - return requiredEphemeralResourcesForReferencer(n) -} - func (n *nodeExpandOutput) getOverrideValue(inst addrs.ModuleInstance) cty.Value { // First check if we have any overrides at all, this is a shorthand for // "are we running terraform test". diff --git a/internal/terraform/node_resource_validate.go b/internal/terraform/node_resource_validate.go index 995670f2e0a8..f45fe7b1c1a8 100644 --- a/internal/terraform/node_resource_validate.go +++ b/internal/terraform/node_resource_validate.go @@ -613,6 +613,9 @@ func validateResourceForbiddenEphemeralValues(ctx EvalContext, value cty.Value, diags = diags.Append(tfdiags.AttributeValue( tfdiags.Error, "Invalid use of ephemeral value", + // FIXME: This message should differ if the referrer is a data + // resource. In that case the reason is "because data resource + // instances must persist from plan to apply". "Ephemeral values are not valid in resource arguments, because resource instances must persist between Terraform phases.", path, )) diff --git a/internal/terraform/transform_ephemeral_resource_close.go b/internal/terraform/transform_ephemeral_resource_close.go index 96e09fa41ad6..25aea67ed3d1 100644 --- a/internal/terraform/transform_ephemeral_resource_close.go +++ b/internal/terraform/transform_ephemeral_resource_close.go @@ -12,56 +12,6 @@ import ( "github.com/hashicorp/terraform/internal/dag" ) -// graphNodeEphemeralResourceConsumer is implemented by graph node types that -// can validly refer to ephemeral resources, to announce which ephemeral -// resources they each depend on. -// -// This is used to decide the dependencies for [nodeEphemeralResourceClose] -// nodes. -type graphNodeEphemeralResourceConsumer interface { - // requiredEphemeralResources returns a set of all of the ephemeral - // resources that the receiver directly depends on when performing - // the given walk operation. - // - // Although the addrs package types can't constrain this statically, - // this method should return only addresses of mode - // [addrs.EphemeralResourceMode]. Resources of any other mode are invalid - // to return. - // - // walkOperation is normalized for implementation simplicity: it can be - // either [walkPlan] or [walkApply], and no other type. - requiredEphemeralResources(op walkOperation) addrs.Set[addrs.ConfigResource] -} - -// requiredEphemeralResourcesForReferencer is a helper for implementing -// [graphNodeEphemeralResourceConsumer] for any node type which implements -// [GraphNodeReferencer] and whose reported references can entirely describe -// the needed ephemeral resources. -func requiredEphemeralResourcesForReferencer[T GraphNodeReferencer](n T) addrs.Set[addrs.ConfigResource] { - moduleAddr := n.ModulePath() - refs := n.References() - if len(refs) == 0 { - return nil - } - ret := addrs.MakeSet[addrs.ConfigResource]() - for _, ref := range refs { - var resourceAddr addrs.Resource - switch refAddr := ref.Subject.(type) { - case addrs.Resource: - resourceAddr = refAddr - case addrs.ResourceInstance: - resourceAddr = refAddr.Resource - default: - continue - } - if resourceAddr.Mode != addrs.EphemeralResourceMode { - continue // we only care about ephemeral resources here - } - ret.Add(resourceAddr.InModule(moduleAddr)) - } - return ret -} - // ephemeralResourceCloseTransformer is a graph transformer that inserts // a [nodeEphemeralResourceClose] node for each ephemeral resource whose "open" // is represented by at least one existing node, and arranges for the close @@ -138,11 +88,11 @@ func (t *ephemeralResourceCloseTransformer) Transform(g *Graph) error { consumerCount := addrs.MakeMap[addrs.ConfigResource, int]() for _, v := range verts { - v, ok := v.(graphNodeEphemeralResourceConsumer) + v, ok := v.(GraphNodeReferencer) if !ok { continue } - for _, consumedAddr := range v.requiredEphemeralResources(t.op) { + for _, consumedAddr := range requiredEphemeralResourcesForReferencer(v) { if consumedAddr.Resource.Mode != addrs.EphemeralResourceMode { // Should not happen: correct implementations of // [graphNodeEphemeralResourceConsumer] only return @@ -227,3 +177,32 @@ func (t *ephemeralResourceCloseTransformer) Transform(g *Graph) error { return nil } + +// requiredEphemeralResourcesForReferencer is a helper for implementing +// [graphNodeEphemeralResourceConsumer] for any node type which implements +// [GraphNodeReferencer] and whose reported references can entirely describe +// the needed ephemeral resources. +func requiredEphemeralResourcesForReferencer[T GraphNodeReferencer](n T) addrs.Set[addrs.ConfigResource] { + moduleAddr := n.ModulePath() + refs := n.References() + if len(refs) == 0 { + return nil + } + ret := addrs.MakeSet[addrs.ConfigResource]() + for _, ref := range refs { + var resourceAddr addrs.Resource + switch refAddr := ref.Subject.(type) { + case addrs.Resource: + resourceAddr = refAddr + case addrs.ResourceInstance: + resourceAddr = refAddr.Resource + default: + continue + } + if resourceAddr.Mode != addrs.EphemeralResourceMode { + continue // we only care about ephemeral resources here + } + ret.Add(resourceAddr.InModule(moduleAddr)) + } + return ret +} From f91173487cff9cdd7960daa433fc0997fd639d3e Mon Sep 17 00:00:00 2001 From: Martin Atkins Date: Mon, 6 May 2024 11:52:34 -0700 Subject: [PATCH 16/23] terraform: Expression evaluator can deal with ephemeral resource refs Ephemeral resources work quite differently than managed or data resources in that their instances live only in memory and are never persisted, and in that we need to handle the possibility of the object having become invalid by the time we're evaluating a reference expression. Since we're just prototyping ephemeral resources for now, this works as a totally separate codepath in the evaluator. The resource reference handling in the evaluator is long overdue for being reworked so that it doesn't depend so directly on the implementation details of how we keep track of resources, and the new ephemeral codepath is perhaps a simplified example of what that might look like in future, but for now it's used only for ephemeral resources to limit the invasiveness of this prototype. --- internal/terraform/evaluate.go | 107 +++++++++++++++++++++++ internal/terraform/graph_walk_context.go | 21 ++--- 2 files changed, 118 insertions(+), 10 deletions(-) diff --git a/internal/terraform/evaluate.go b/internal/terraform/evaluate.go index 6c762ad932b7..8a6af5bf0f21 100644 --- a/internal/terraform/evaluate.go +++ b/internal/terraform/evaluate.go @@ -21,6 +21,7 @@ import ( "github.com/hashicorp/terraform/internal/namedvals" "github.com/hashicorp/terraform/internal/plans" "github.com/hashicorp/terraform/internal/plans/deferring" + "github.com/hashicorp/terraform/internal/resources/ephemeral" "github.com/hashicorp/terraform/internal/states" "github.com/hashicorp/terraform/internal/tfdiags" ) @@ -46,6 +47,10 @@ type Evaluator struct { // we're evaluating expressions that refer to it. Instances *instances.Expander + // EphemeralResources tracks the currently-open instances of any ephemeral + // resources. + EphemeralResources *ephemeral.Resources + // NamedValues is where we keep the values of already-evaluated input // variables, local values, and output values. NamedValues *namedvals.State @@ -558,6 +563,19 @@ func (d *evaluationStateData) GetResource(addr addrs.Resource, rng tfdiags.Sourc } ty := schema.ImpliedType() + if addr.Mode == addrs.EphemeralResourceMode { + // FIXME: Ephemeral resources need very different handling. For + // prototype purposes we just branch off into an entirely separate + // codepath here, but in a real implementation it would be nice + // to find some way to refactor this so that the following code + // is not so tethered to the current implementation details and + // instead has a more abstract idea of first determining what + // instances the resource has (using d.Evaluator.Instances.ResourceInstanceKeys) + // and then retrieving the value for each instance to assemble into the + // result, using some per-resource-mode logic maintained elsewhere. + return d.getEphemeralResource(addr, rng, schema, config) + } + rs := d.Evaluator.State.Resource(addr.Absolute(d.ModulePath)) if rs == nil { @@ -771,6 +789,95 @@ func (d *evaluationStateData) GetResource(addr addrs.Resource, rng tfdiags.Sourc return ret, diags } +func (d *evaluationStateData) getEphemeralResource(addr addrs.Resource, rng tfdiags.SourceRange, schema *configschema.Block, config *configs.Resource) (cty.Value, tfdiags.Diagnostics) { + var diags tfdiags.Diagnostics + + if d.Operation == walkValidate { + // Ephemeral instances are never live during the validate walk + return cty.DynamicVal.Mark(marks.Ephemeral), diags + } + + absAddr := addr.Absolute(d.ModulePath) + keyType, keys, haveUnknownKeys := d.Evaluator.Instances.ResourceInstanceKeys(absAddr) + if haveUnknownKeys { + // We can probably do better than totally unknown at least for a + // single-instance resource, but we'll just keep it simple for now. + // Result must be marked as ephemeral so that we can still catch + // attempts to use the results in non-ephemeral locations, so that + // the operator doesn't end up trapped with an error on a subsequent + // plan/apply round. + return cty.DynamicVal.Mark(marks.Ephemeral), diags + } + + ephems := d.Evaluator.EphemeralResources + getInstValue := func(addr addrs.AbsResourceInstance) (cty.Value, tfdiags.Diagnostics) { + var diags tfdiags.Diagnostics + val, isLive := ephems.InstanceValue(addr) + if !isLive { + // If the instance is no longer "live" by the time we're accessing + // it then that suggests that it needed renewal and renewal has + // failed, and so the object's value is no longer usable. We'll + // still return the value in case it's somehow useful for diagnosis, + // but we return an error to prevent further evaluation of whatever + // other expression depended on the liveness of this object. + // + // This error message is written on the assumption that it will + // always appear alongside the provider's renewal error, but that'll + // be exposed only once the (now-zombied) ephemeral resource is + // eventually closed, so that we can avoid returning the same error + // multiple times. + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Ephemeral resource instance has expired", + Detail: fmt.Sprintf( + "The remote object for %s is no longer available due to a renewal error, so Terraform cannot evaluate this expression.", + addr, + ), + Subject: rng.ToHCL().Ptr(), + }) + } + if val == cty.NilVal { + val = cty.DynamicVal.Mark(marks.Ephemeral) + } + return val, diags + } + + switch keyType { + case addrs.NoKeyType: + // For "no key" we're returning just a single object representing + // the single instance of this resource. + instVal, moreDiags := getInstValue(absAddr.Instance(addrs.NoKey)) + diags = diags.Append(moreDiags) + return instVal, diags + case addrs.IntKeyType: + // For integer keys we're returning a tuple-typed value whose + // indices are the keys. + elems := make([]cty.Value, len(keys)) + for _, key := range keys { + idx := int(key.(addrs.IntKey)) + instAddr := absAddr.Instance(key) + instVal, moreDiags := getInstValue(instAddr) + diags = diags.Append(moreDiags) + elems[idx] = instVal + } + return cty.TupleVal(elems), diags + case addrs.StringKeyType: + // For string keys we're returning an object-typed value whose + // attributes are the keys. + attrs := make(map[string]cty.Value, len(keys)) + for _, key := range keys { + attrName := string(key.(addrs.StringKey)) + instAddr := absAddr.Instance(key) + instVal, moreDiags := getInstValue(instAddr) + diags = diags.Append(moreDiags) + attrs[attrName] = instVal + } + return cty.ObjectVal(attrs), diags + default: + panic(fmt.Sprintf("unhandled instance key type %#v", keyType)) + } +} + func (d *evaluationStateData) getResourceSchema(addr addrs.Resource, providerAddr addrs.Provider) *configschema.Block { schema, _, err := d.Evaluator.Plugins.ResourceTypeSchema(providerAddr, addr.Mode, addr.Type) if err != nil { diff --git a/internal/terraform/graph_walk_context.go b/internal/terraform/graph_walk_context.go index 85de1f27fdf9..b9405a6bf1d1 100644 --- a/internal/terraform/graph_walk_context.go +++ b/internal/terraform/graph_walk_context.go @@ -97,16 +97,17 @@ func (w *ContextGraphWalker) EvalContext() EvalContext { // so that we can safely run multiple evaluations at once across // different modules. evaluator := &Evaluator{ - Meta: w.Context.meta, - Config: w.Config, - Operation: w.Operation, - State: w.State, - Changes: w.Changes, - Plugins: w.Context.plugins, - Instances: w.InstanceExpander, - NamedValues: w.NamedValues, - Deferrals: w.Deferrals, - PlanTimestamp: w.PlanTimestamp, + Meta: w.Context.meta, + Config: w.Config, + Operation: w.Operation, + State: w.State, + Changes: w.Changes, + Plugins: w.Context.plugins, + Instances: w.InstanceExpander, + EphemeralResources: w.EphemeralResources, + NamedValues: w.NamedValues, + Deferrals: w.Deferrals, + PlanTimestamp: w.PlanTimestamp, } ctx := &BuiltinEvalContext{ From c8f8563e469d7e8b311f5d120de915b81bbb0f0b Mon Sep 17 00:00:00 2001 From: Martin Atkins Date: Tue, 7 May 2024 12:03:39 -0700 Subject: [PATCH 17/23] terraform: Never prune "unused" ephemeral resource nodes I'm honestly not really sure yet how to explain _why_ ephemeral resource nodes are getting pruned when they shouldn't; for the sake of prototyping this is just a hard-coded special exception to just not consider them at all in the pruneUnusedNodesTransformer. The later ephemeralResourceCloseTransformer has its own logic for deciding that an ephemeral resource isn't actually needed in the current graph and pruning both their open and close nodes, so these will still get pruned but it will happen in different circumstances and based on a later form of the graph with more nodes and edges already present, thus preventing some cases of ephemeral resources being pruned when they shouldn't be. --- internal/terraform/transform_destroy_edge.go | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/internal/terraform/transform_destroy_edge.go b/internal/terraform/transform_destroy_edge.go index 0e223ae722b1..540358c50f56 100644 --- a/internal/terraform/transform_destroy_edge.go +++ b/internal/terraform/transform_destroy_edge.go @@ -346,6 +346,25 @@ func (t *pruneUnusedNodesTransformer) Transform(g *Graph) error { } case graphNodeExpandsInstances: + // FIXME: A hacky special case :( + // + // Ephemeral resources can be needed during the apply phase + // even if only ephemeral output values or other similar + // temporary objects refer to them. + // ephemeralResourceCloseTransformer has its own specialized + // logic for pruning truly-unused ephemeral resources once + // other transforms (including this one) are done, so we + // never want to prune them here. + // + // However, it would be better to find a more reasoned + // way to express why ephemeral resources need special + // treatment here and introduce a less-tightly-coupled + // way to express it, if ephemeral values cease to be just + // a prototype. + if n, ok := n.(GraphNodeConfigResource); ok && n.ResourceAddr().Resource.Mode == addrs.EphemeralResourceMode { + return + } + // Any nodes that expand instances are kept when their // instances may need to be evaluated. for _, v := range g.UpEdges(n) { From 80c2ef1335c90d582b1587583962244a7cab27c6 Mon Sep 17 00:00:00 2001 From: Martin Atkins Date: Tue, 7 May 2024 14:37:39 -0700 Subject: [PATCH 18/23] plans+states: Reject attempts to persist ephemeral resources The modules runtime should always use a different strategy to keep track of live ephemeral resource instances, and should never persist them in the plan or state. These checks are here just to reduce the risk that a bug in the modules runtime could inadvertently result in an ephemeral resource instance being persisted. This is a bit of a "defense-in-depth" strategy, because the state and plan types all have most of their fields exported and so we can't be sure that all modifications will go through the mutation methods. --- internal/addrs/resource.go | 24 +++++++++++++++ internal/plans/changes.go | 28 ++++++++++++++++++ internal/plans/changes_sync.go | 5 ++++ internal/plans/planfile/tfplan.go | 3 ++ internal/states/state.go | 29 +++++++++++++++++++ internal/states/statefile/version3_upgrade.go | 4 +++ internal/states/statefile/version4.go | 8 +++++ internal/states/sync.go | 18 ++++++++++++ 8 files changed, 119 insertions(+) diff --git a/internal/addrs/resource.go b/internal/addrs/resource.go index 6e4d3a8360df..71942ff78806 100644 --- a/internal/addrs/resource.go +++ b/internal/addrs/resource.go @@ -513,6 +513,30 @@ const ( EphemeralResourceMode ResourceMode = 'E' ) +// PersistsBetweenRounds returns true only if resource instances of this mode +// persist in the Terraform state from one plan/apply round to the next. +func (m ResourceMode) PersistsBetweenRounds() bool { + switch m { + case EphemeralResourceMode: + return false + default: + return true + } +} + +// PersistsPlanToApply returns true only if resource instances of this mode +// can have planned actions that are decided during the plan phase and then +// carried out during the apply phase, meaning that they must be recorded +// as part of a saved plan. +func (m ResourceMode) PersistsPlanToApply() bool { + switch m { + case EphemeralResourceMode: + return false + default: + return true + } +} + // AbsResourceInstanceObject represents one of the specific remote objects // associated with a resource instance. // diff --git a/internal/plans/changes.go b/internal/plans/changes.go index 7c43da97293a..9bd683b27ace 100644 --- a/internal/plans/changes.go +++ b/internal/plans/changes.go @@ -63,6 +63,8 @@ func (c *Changes) Empty() bool { // resource instance of the given address, if any. Returns nil if no change is // planned. func (c *Changes) ResourceInstance(addr addrs.AbsResourceInstance) *ResourceInstanceChangeSrc { + assertPlannableResource(addr.Resource.Resource) + for _, rc := range c.Resources { if rc.Addr.Equal(addr) && rc.DeposedKey == states.NotDeposed { return rc @@ -77,6 +79,8 @@ func (c *Changes) ResourceInstance(addr addrs.AbsResourceInstance) *ResourceInst // of the resource instances of the given address, if any. Returns nil if no // changes are planned. func (c *Changes) InstancesForAbsResource(addr addrs.AbsResource) []*ResourceInstanceChangeSrc { + assertPlannableResource(addr.Resource) + var changes []*ResourceInstanceChangeSrc for _, rc := range c.Resources { resAddr := rc.Addr.ContainingResource() @@ -92,6 +96,8 @@ func (c *Changes) InstancesForAbsResource(addr addrs.AbsResource) []*ResourceIns // of the resource instances of the given address, if any. Returns nil if no // changes are planned. func (c *Changes) InstancesForConfigResource(addr addrs.ConfigResource) []*ResourceInstanceChangeSrc { + assertPlannableResource(addr.Resource) + var changes []*ResourceInstanceChangeSrc for _, rc := range c.Resources { resAddr := rc.Addr.ContainingResource().Config() @@ -107,6 +113,8 @@ func (c *Changes) InstancesForConfigResource(addr addrs.ConfigResource) []*Resou // the resource instance of the given address, if any. Returns nil if no change // is planned. func (c *Changes) ResourceInstanceDeposed(addr addrs.AbsResourceInstance, key states.DeposedKey) *ResourceInstanceChangeSrc { + assertPlannableResource(addr.Resource.Resource) + for _, rc := range c.Resources { if rc.Addr.Equal(addr) && rc.DeposedKey == key { return rc @@ -257,6 +265,13 @@ type ResourceInstanceChange struct { // serialized so it can be written to a plan file. Pass the implied type of the // corresponding resource type schema for correct operation. func (rc *ResourceInstanceChange) Encode(ty cty.Type) (*ResourceInstanceChangeSrc, error) { + // The following assertion shouldn't be able to fail if callers add + // their ResourceInstanceChange objects only by methods on the [Changes] + // type, but the relevant fields are all exported so this will belatedly + // catch anything that was added/modified directly, at least preventing it + // from being incorrectly encoded. + assertPlannableResource(rc.Addr.Resource.Resource) + cs, err := rc.Change.Encode(ty) if err != nil { return nil, err @@ -618,3 +633,16 @@ func (c *Change) Encode(ty cty.Type) (*ChangeSrc, error) { GeneratedConfig: c.GeneratedConfig, }, nil } + +// assertPlannableResource panics if the given address describes a resource +// which cannot have planned actions. +// +// Currently this panics only if the resource mode is not one whose instances +// can have planned actions that will be acted on only once a plan is applied. +func assertPlannableResource(addr addrs.Resource) { + if mode := addr.Mode; !mode.PersistsBetweenRounds() { + // This is always a bug in the caller: they should only call this + // method for resources of modes that persist between rounds. + panic(fmt.Sprintf("resources of mode %s do not persist in Terraform state", mode)) + } +} diff --git a/internal/plans/changes_sync.go b/internal/plans/changes_sync.go index 67e7502763bf..2f8f89f66644 100644 --- a/internal/plans/changes_sync.go +++ b/internal/plans/changes_sync.go @@ -32,6 +32,7 @@ func (cs *ChangesSync) AppendResourceInstanceChange(changeSrc *ResourceInstanceC if cs == nil { panic("AppendResourceInstanceChange on nil ChangesSync") } + assertPlannableResource(changeSrc.Addr.Resource.Resource) cs.lock.Lock() defer cs.lock.Unlock() @@ -53,6 +54,7 @@ func (cs *ChangesSync) GetResourceInstanceChange(addr addrs.AbsResourceInstance, if cs == nil { panic("GetResourceInstanceChange on nil ChangesSync") } + assertPlannableResource(addr.Resource.Resource) cs.lock.Lock() defer cs.lock.Unlock() @@ -76,6 +78,7 @@ func (cs *ChangesSync) GetChangesForConfigResource(addr addrs.ConfigResource) [] if cs == nil { panic("GetChangesForConfigResource on nil ChangesSync") } + assertPlannableResource(addr.Resource) cs.lock.Lock() defer cs.lock.Unlock() var changes []*ResourceInstanceChangeSrc @@ -97,6 +100,7 @@ func (cs *ChangesSync) GetChangesForAbsResource(addr addrs.AbsResource) []*Resou if cs == nil { panic("GetChangesForAbsResource on nil ChangesSync") } + assertPlannableResource(addr.Resource) cs.lock.Lock() defer cs.lock.Unlock() var changes []*ResourceInstanceChangeSrc @@ -113,6 +117,7 @@ func (cs *ChangesSync) RemoveResourceInstanceChange(addr addrs.AbsResourceInstan if cs == nil { panic("RemoveResourceInstanceChange on nil ChangesSync") } + assertPlannableResource(addr.Resource.Resource) cs.lock.Lock() defer cs.lock.Unlock() diff --git a/internal/plans/planfile/tfplan.go b/internal/plans/planfile/tfplan.go index 94e5aa373578..a6c1743cd3f7 100644 --- a/internal/plans/planfile/tfplan.go +++ b/internal/plans/planfile/tfplan.go @@ -698,6 +698,9 @@ func resourceChangeToTfplan(change *plans.ResourceInstanceChangeSrc) (*planproto change.PrevRunAddr = change.Addr } + if mode := change.Addr.Resource.Resource.Mode; !mode.PersistsPlanToApply() { + panic(fmt.Sprintf("instance of resource with mode %s cannot have planned actions", mode)) + } ret.Addr = change.Addr.String() ret.PrevRunAddr = change.PrevRunAddr.String() if ret.PrevRunAddr == ret.Addr { diff --git a/internal/states/state.go b/internal/states/state.go index 0fb1e71a8239..c5605e4015f6 100644 --- a/internal/states/state.go +++ b/internal/states/state.go @@ -176,6 +176,7 @@ func (s *State) HasManagedResourceInstanceObjects() bool { // Resource returns the state for the resource with the given address, or nil // if no such resource is tracked in the state. func (s *State) Resource(addr addrs.AbsResource) *Resource { + assertPersistableResource(addr.Resource) ms := s.Module(addr.Module) if ms == nil { return nil @@ -185,6 +186,7 @@ func (s *State) Resource(addr addrs.AbsResource) *Resource { // Resources returns the set of resources that match the given configuration path. func (s *State) Resources(addr addrs.ConfigResource) []*Resource { + assertPersistableResource(addr.Resource) var ret []*Resource for _, m := range s.ModuleInstances(addr.Module) { r := m.Resource(addr.Resource) @@ -260,6 +262,7 @@ func (s *State) allResourceInstanceObjectAddrs(keepAddr func(addr addrs.AbsResou // ResourceInstance returns the state for the resource instance with the given // address, or nil if no such resource is tracked in the state. func (s *State) ResourceInstance(addr addrs.AbsResourceInstance) *ResourceInstance { + assertPersistableResource(addr.Resource.Resource) if s == nil { panic("State.ResourceInstance on nil *State") } @@ -273,6 +276,7 @@ func (s *State) ResourceInstance(addr addrs.AbsResourceInstance) *ResourceInstan // ResourceInstance returns the (encoded) state for the resource instance object // with the given address, or nil if no such object is tracked in the state. func (s *State) ResourceInstanceObjectSrc(addr addrs.AbsResourceInstanceObject) *ResourceInstanceObjectSrc { + assertPersistableResource(addr.ResourceInstance.Resource.Resource) if s == nil { panic("State.ResourceInstanceObjectSrc on nil *State") } @@ -408,6 +412,9 @@ func (s *State) SyncWrapper() *SyncState { // responsibility to verify the validity of the move (for example, that the src // and dst are compatible types). func (s *State) MoveAbsResource(src, dst addrs.AbsResource) { + assertPersistableResource(src.Resource) + assertPersistableResource(dst.Resource) + // verify that the src address exists and the dst address does not rs := s.Resource(src) if rs == nil { @@ -439,6 +446,9 @@ func (s *State) MoveAbsResource(src, dst addrs.AbsResource) { // or not the move occurred. This function will panic if either the src does not // exist or the dst does exist (but not both). func (s *State) MaybeMoveAbsResource(src, dst addrs.AbsResource) bool { + assertPersistableResource(src.Resource) + assertPersistableResource(dst.Resource) + // Get the source and destinatation addresses from state. rs := s.Resource(src) ds := s.Resource(dst) @@ -465,6 +475,9 @@ func (s *State) MaybeMoveAbsResource(src, dst addrs.AbsResource) bool { // the caller's responsibility to verify the validity of the move (for example, // that the src and dst are compatible types). func (s *State) MoveAbsResourceInstance(src, dst addrs.AbsResourceInstance) { + assertPersistableResource(src.Resource.Resource) + assertPersistableResource(dst.Resource.Resource) + srcInstanceState := s.ResourceInstance(src) if srcInstanceState == nil { panic(fmt.Sprintf("no state for src address %s", src.String())) @@ -513,6 +526,9 @@ func (s *State) MoveAbsResourceInstance(src, dst addrs.AbsResourceInstance) { // value indicates whether or not the move occured. This function will panic if // either the src does not exist or the dst does exist (but not both). func (s *State) MaybeMoveAbsResourceInstance(src, dst addrs.AbsResourceInstance) bool { + assertPersistableResource(src.Resource.Resource) + assertPersistableResource(dst.Resource.Resource) + // get the src and dst resource instances from state rs := s.ResourceInstance(src) ds := s.ResourceInstance(dst) @@ -636,3 +652,16 @@ func (s *State) MoveModule(src, dst addrs.AbsModuleCall) { s.MoveModuleInstance(ms.Addr, newInst) } } + +// assertPersistableResource panics if the given address describes a resource +// which should not be persisted in state. +// +// Currently this panics only if the resource mode is not one whose instances +// are expected to persist between plan/apply rounds. +func assertPersistableResource(addr addrs.Resource) { + if mode := addr.Mode; !mode.PersistsBetweenRounds() { + // This is always a bug in the caller: they should only call this + // method for resources of modes that persist between rounds. + panic(fmt.Sprintf("resources of mode %s do not persist in Terraform state", mode)) + } +} diff --git a/internal/states/statefile/version3_upgrade.go b/internal/states/statefile/version3_upgrade.go index d84a5ecaae3d..ef4dd77ab757 100644 --- a/internal/states/statefile/version3_upgrade.go +++ b/internal/states/statefile/version3_upgrade.go @@ -91,6 +91,10 @@ func upgradeStateV3ToV4(old *stateV3) (*stateV4, error) { case addrs.DataResourceMode: modeStr = "data" default: + // NOTE: the above cases intentionally cover only the subset + // of modes where mode.PersistsBetweenRounds() would return + // true, because it's a bug for any others to end up in the + // state. return nil, fmt.Errorf("state contains resource %s with an unsupported resource mode %#v", resAddr, resAddr.Mode) } diff --git a/internal/states/statefile/version4.go b/internal/states/statefile/version4.go index 518f8fb9ece5..e775c9cba16c 100644 --- a/internal/states/statefile/version4.go +++ b/internal/states/statefile/version4.go @@ -68,6 +68,10 @@ func prepareStateV4(sV4 *stateV4) (*File, tfdiags.Diagnostics) { case "data": rAddr.Mode = addrs.DataResourceMode default: + // NOTE: the above cases intentionally cover only the subset + // of modes where rAddr.Mode.PersistsBetweenRounds() would return + // true, because it's a bug for any others to end up in the + // state. diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, "Invalid resource mode in state", @@ -368,6 +372,10 @@ func writeStateV4(file *File, w io.Writer) tfdiags.Diagnostics { case addrs.DataResourceMode: mode = "data" default: + // NOTE: the above cases intentionally cover only the subset + // of modes where mode.PersistsBetweenRounds() would return + // true, because it's a bug for any others to end up in the + // state. diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, "Failed to serialize resource in state", diff --git a/internal/states/sync.go b/internal/states/sync.go index f4cad3e0d6d7..d3765c88278d 100644 --- a/internal/states/sync.go +++ b/internal/states/sync.go @@ -150,6 +150,7 @@ func (s *SyncState) ResourceInstance(addr addrs.AbsResourceInstance) *ResourceIn // The return value is a pointer to a copy of the object, which the caller may // then freely access and mutate. func (s *SyncState) ResourceInstanceObject(addr addrs.AbsResourceInstance, dk DeposedKey) *ResourceInstanceObjectSrc { + assertPersistableResource(addr.Resource.Resource) s.lock.RLock() defer s.lock.RUnlock() @@ -164,6 +165,7 @@ func (s *SyncState) ResourceInstanceObject(addr addrs.AbsResourceInstance, dk De // the given address, creating the containing module state and resource state // as a side-effect if not already present. func (s *SyncState) SetResourceProvider(addr addrs.AbsResource, provider addrs.AbsProviderConfig) { + assertPersistableResource(addr.Resource) defer s.beginWrite()() ms := s.state.EnsureModule(addr.Module) @@ -176,6 +178,7 @@ func (s *SyncState) SetResourceProvider(addr addrs.AbsResource, provider addrs.A // but that is not enforced by this method. (Use RemoveResourceIfEmpty instead // to safely check first.) func (s *SyncState) RemoveResource(addr addrs.AbsResource) { + assertPersistableResource(addr.Resource) defer s.beginWrite()() ms := s.state.EnsureModule(addr.Module) @@ -231,6 +234,7 @@ func (s *SyncState) RemoveResourceIfEmpty(addr addrs.AbsResource) bool { // If the containing module for this resource or the resource itself are not // already tracked in state then they will be added as a side-effect. func (s *SyncState) SetResourceInstanceCurrent(addr addrs.AbsResourceInstance, obj *ResourceInstanceObjectSrc, provider addrs.AbsProviderConfig) { + assertPersistableResource(addr.Resource.Resource) defer s.beginWrite()() ms := s.state.EnsureModule(addr.Module) @@ -262,6 +266,7 @@ func (s *SyncState) SetResourceInstanceCurrent(addr addrs.AbsResourceInstance, o // If the containing module for this resource or the resource itself are not // already tracked in state then they will be added as a side-effect. func (s *SyncState) SetResourceInstanceDeposed(addr addrs.AbsResourceInstance, key DeposedKey, obj *ResourceInstanceObjectSrc, provider addrs.AbsProviderConfig) { + assertPersistableResource(addr.Resource.Resource) defer s.beginWrite()() ms := s.state.EnsureModule(addr.Module) @@ -281,6 +286,7 @@ func (s *SyncState) SetResourceInstanceDeposed(addr addrs.AbsResourceInstance, k // given instance, and so NotDeposed will be returned without modifying the // state at all. func (s *SyncState) DeposeResourceInstanceObject(addr addrs.AbsResourceInstance) DeposedKey { + assertPersistableResource(addr.Resource.Resource) defer s.beginWrite()() ms := s.state.Module(addr.Module) @@ -296,6 +302,7 @@ func (s *SyncState) DeposeResourceInstanceObject(addr addrs.AbsResourceInstance) // that there aren't any races to use a particular key; this method will panic // if the given key is already in use. func (s *SyncState) DeposeResourceInstanceObjectForceKey(addr addrs.AbsResourceInstance, forcedKey DeposedKey) { + assertPersistableResource(addr.Resource.Resource) defer s.beginWrite()() if forcedKey == NotDeposed { @@ -314,6 +321,7 @@ func (s *SyncState) DeposeResourceInstanceObjectForceKey(addr addrs.AbsResourceI // ForgetResourceInstanceAll removes the record of all objects associated with // the specified resource instance, if present. If not present, this is a no-op. func (s *SyncState) ForgetResourceInstanceAll(addr addrs.AbsResourceInstance) { + assertPersistableResource(addr.Resource.Resource) defer s.beginWrite()() ms := s.state.Module(addr.Module) @@ -327,6 +335,7 @@ func (s *SyncState) ForgetResourceInstanceAll(addr addrs.AbsResourceInstance) { // ForgetResourceInstanceDeposed removes the record of the deposed object with // the given address and key, if present. If not present, this is a no-op. func (s *SyncState) ForgetResourceInstanceDeposed(addr addrs.AbsResourceInstance, key DeposedKey) { + assertPersistableResource(addr.Resource.Resource) defer s.beginWrite()() ms := s.state.Module(addr.Module) @@ -345,6 +354,7 @@ func (s *SyncState) ForgetResourceInstanceDeposed(addr addrs.AbsResourceInstance // Returns true if the object was restored to current, or false if no change // was made at all. func (s *SyncState) MaybeRestoreResourceInstanceDeposed(addr addrs.AbsResourceInstance, key DeposedKey) bool { + assertPersistableResource(addr.Resource.Resource) defer s.beginWrite()() if key == NotDeposed { @@ -491,24 +501,32 @@ func (s *SyncState) maybePruneModule(addr addrs.ModuleInstance) { } func (s *SyncState) MoveAbsResource(src, dst addrs.AbsResource) { + assertPersistableResource(src.Resource) + assertPersistableResource(dst.Resource) defer s.beginWrite()() s.state.MoveAbsResource(src, dst) } func (s *SyncState) MaybeMoveAbsResource(src, dst addrs.AbsResource) bool { + assertPersistableResource(src.Resource) + assertPersistableResource(dst.Resource) defer s.beginWrite()() return s.state.MaybeMoveAbsResource(src, dst) } func (s *SyncState) MoveResourceInstance(src, dst addrs.AbsResourceInstance) { + assertPersistableResource(src.Resource.Resource) + assertPersistableResource(dst.Resource.Resource) defer s.beginWrite()() s.state.MoveAbsResourceInstance(src, dst) } func (s *SyncState) MaybeMoveResourceInstance(src, dst addrs.AbsResourceInstance) bool { + assertPersistableResource(src.Resource.Resource) + assertPersistableResource(dst.Resource.Resource) defer s.beginWrite()() return s.state.MaybeMoveAbsResourceInstance(src, dst) From ee08babaa74f0b98aefaab3a3f7c446a40341e99 Mon Sep 17 00:00:00 2001 From: Martin Atkins Date: Tue, 7 May 2024 15:55:26 -0700 Subject: [PATCH 19/23] terraform: Don't try to write ephemeral resources to plan or state This is just enough to skip writing and reading ephemeral resources and their instances in the plan and state, so that we can reach the code that manages them in their own separate data structure. This relies on the new idea of some resource modes not being persisted between rounds and not being persisted from plan to apply, although for now EphemeralResourceMode is the only mode that doesn't do both of those things. --- internal/terraform/node_resource_abstract.go | 16 +++++-- internal/terraform/node_resource_apply.go | 43 +++++++++++++++++++ .../terraform/node_resource_apply_instance.go | 22 +++++++--- internal/terraform/node_resource_plan.go | 24 ++++++----- internal/terraform/transform_attach_state.go | 4 ++ internal/terraform/transform_orphan_count.go | 6 +++ 6 files changed, 96 insertions(+), 19 deletions(-) diff --git a/internal/terraform/node_resource_abstract.go b/internal/terraform/node_resource_abstract.go index b5f940d87780..1d562b28646c 100644 --- a/internal/terraform/node_resource_abstract.go +++ b/internal/terraform/node_resource_abstract.go @@ -398,6 +398,10 @@ func (n *NodeAbstractResource) DotNode(name string, opts *dag.DotOpts) *dag.DotN // eval is the only change we get to set the resource "each mode" to list // in that case, allowing expression evaluation to see it as a zero-element list // rather than as not set at all. +// +// The name of this method has grown to be a bit of a misnomer, since it's +// responsible both for updating the state _and_ for recording the "expansion" +// of the resource in the EvalContext's InstanceExpander. func (n *NodeAbstractResource) writeResourceState(ctx EvalContext, addr addrs.AbsResource) (diags tfdiags.Diagnostics) { state := ctx.State() @@ -422,7 +426,9 @@ func (n *NodeAbstractResource) writeResourceState(ctx EvalContext, addr addrs.Ab return diags } - state.SetResourceProvider(addr, n.ResolvedProvider) + if addr.Resource.Mode.PersistsBetweenRounds() { + state.SetResourceProvider(addr, n.ResolvedProvider) + } if count >= 0 { expander.SetResourceCount(addr.Module, n.Addr.Resource, count) } else { @@ -439,7 +445,9 @@ func (n *NodeAbstractResource) writeResourceState(ctx EvalContext, addr addrs.Ab // This method takes care of all of the business logic of updating this // while ensuring that any existing instances are preserved, etc. - state.SetResourceProvider(addr, n.ResolvedProvider) + if addr.Resource.Mode.PersistsBetweenRounds() { + state.SetResourceProvider(addr, n.ResolvedProvider) + } if known { expander.SetResourceForEach(addr.Module, n.Addr.Resource, forEach) } else { @@ -447,7 +455,9 @@ func (n *NodeAbstractResource) writeResourceState(ctx EvalContext, addr addrs.Ab } default: - state.SetResourceProvider(addr, n.ResolvedProvider) + if addr.Resource.Mode.PersistsBetweenRounds() { + state.SetResourceProvider(addr, n.ResolvedProvider) + } expander.SetResourceSingle(addr.Module, n.Addr.Resource) } diff --git a/internal/terraform/node_resource_apply.go b/internal/terraform/node_resource_apply.go index ff9492cc55b1..5c1d1677458f 100644 --- a/internal/terraform/node_resource_apply.go +++ b/internal/terraform/node_resource_apply.go @@ -4,6 +4,8 @@ package terraform import ( + "log" + "github.com/hashicorp/terraform/internal/addrs" "github.com/hashicorp/terraform/internal/tfdiags" ) @@ -24,6 +26,7 @@ var ( _ GraphNodeConfigResource = (*nodeExpandApplyableResource)(nil) _ GraphNodeAttachResourceConfig = (*nodeExpandApplyableResource)(nil) _ graphNodeExpandsInstances = (*nodeExpandApplyableResource)(nil) + _ GraphNodeDynamicExpandable = (*nodeExpandApplyableResource)(nil) _ GraphNodeTargetable = (*nodeExpandApplyableResource)(nil) ) @@ -59,3 +62,43 @@ func (n *nodeExpandApplyableResource) Execute(globalCtx EvalContext, op walkOper return diags } + +func (n *nodeExpandApplyableResource) DynamicExpand(globalCtx EvalContext) (*Graph, tfdiags.Diagnostics) { + var diags tfdiags.Diagnostics + g := &Graph{} + + if n.Addr.Resource.Mode.PersistsPlanToApply() { + // We don't need to do anything for resources of modes that use + // plannable actions, because our toplevel apply graph already + // includes the expanded instances of those based on the plan diff. + addRootNodeToGraph(g) + return g, diags + } + + // For resources of modes that _don't_ persist from plan to apply, we'll + // generate the nodes representing instances dynamically here to mimic + // what we would've done during the plan walk. + expander := globalCtx.InstanceExpander() + for _, modInstAddr := range expander.ExpandModule(n.ModulePath(), false) { + resourceAddr := n.Addr.Resource.Absolute(modInstAddr) + for _, instAddr := range expander.ExpandResource(resourceAddr) { + // FIXME: The code we use to do the similar thing in the plan phase + // is not really shaped well to be reused here, so this is just a + // bare-minimum thing to get close enough for the sake of prototyping + // ephemeral resources. We should probably do this in a different + // way if we make a real implementation. + log.Printf("[TRACE] nodeExpandApplyableResource: adding node for %s", instAddr) + instN := &NodeApplyableResourceInstance{ + NodeAbstractResourceInstance: NewNodeAbstractResourceInstance(instAddr), + } + instN.Config = n.Config + instN.ResolvedProvider = n.ResolvedProvider + instN.Schema = n.Schema + instN.SchemaVersion = n.SchemaVersion + g.Add(instN) + } + } + + addRootNodeToGraph(g) + return g, diags +} diff --git a/internal/terraform/node_resource_apply_instance.go b/internal/terraform/node_resource_apply_instance.go index c1421148a4dc..6fbbb7b27f1f 100644 --- a/internal/terraform/node_resource_apply_instance.go +++ b/internal/terraform/node_resource_apply_instance.go @@ -121,11 +121,13 @@ func (n *NodeApplyableResourceInstance) Execute(ctx EvalContext, op walkOperatio // If there is no config, and there is no change, then we have nothing // to do and the change was left in the plan for informational // purposes only. - changes := ctx.Changes() - csrc := changes.GetResourceInstanceChange(n.ResourceInstanceAddr(), addrs.NotDeposed) - if csrc == nil || csrc.Action == plans.NoOp { - log.Printf("[DEBUG] NodeApplyableResourceInstance: No config or planned change recorded for %s", n.Addr) - return nil + if n.Addr.Resource.Resource.Mode.PersistsPlanToApply() { + changes := ctx.Changes() + csrc := changes.GetResourceInstanceChange(n.ResourceInstanceAddr(), addrs.NotDeposed) + if csrc == nil || csrc.Action == plans.NoOp { + log.Printf("[DEBUG] NodeApplyableResourceInstance: No config or planned change recorded for %s", n.Addr) + return nil + } } diags = diags.Append(tfdiags.Sourceless( @@ -145,6 +147,8 @@ func (n *NodeApplyableResourceInstance) Execute(ctx EvalContext, op walkOperatio return n.managedResourceExecute(ctx) case addrs.DataResourceMode: return n.dataResourceExecute(ctx) + case addrs.EphemeralResourceMode: + return n.ephemeralResourceExecute(ctx) default: panic(fmt.Errorf("unsupported resource mode %s", n.Config.Mode)) } @@ -406,6 +410,14 @@ func (n *NodeApplyableResourceInstance) managedResourcePostconditions(ctx EvalCo return diags.Append(checkDiags) } +func (n *NodeApplyableResourceInstance) ephemeralResourceExecute(ctx EvalContext) tfdiags.Diagnostics { + return ephemeralResourceOpen(ctx, ephemeralResourceInput{ + addr: n.Addr, + config: n.Config, + providerConfig: n.ResolvedProvider, + }) +} + // checkPlannedChange produces errors if the _actual_ expected value is not // compatible with what was recorded in the plan. // diff --git a/internal/terraform/node_resource_plan.go b/internal/terraform/node_resource_plan.go index 34a49ccf47f1..1dde51ea88af 100644 --- a/internal/terraform/node_resource_plan.go +++ b/internal/terraform/node_resource_plan.go @@ -295,18 +295,20 @@ func (n *nodeExpandPlannableResource) dynamicExpand(ctx EvalContext, moduleInsta state := ctx.State().Lock() var orphans []*states.Resource - for _, res := range state.Resources(n.Addr) { - found := false - for _, m := range moduleInstances { - if m.Equal(res.Addr.Module) { - found = true - break + if n.Addr.Resource.Mode.PersistsBetweenRounds() { // only persisted resources can have "orphans" + for _, res := range state.Resources(n.Addr) { + found := false + for _, m := range moduleInstances { + if m.Equal(res.Addr.Module) { + found = true + break + } + } + // The module instance of the resource in the state doesn't exist + // in the current config, so this whole resource is orphaned. + if !found { + orphans = append(orphans, res) } - } - // The module instance of the resource in the state doesn't exist - // in the current config, so this whole resource is orphaned. - if !found { - orphans = append(orphans, res) } } diff --git a/internal/terraform/transform_attach_state.go b/internal/terraform/transform_attach_state.go index 762da2d87332..ec544b348078 100644 --- a/internal/terraform/transform_attach_state.go +++ b/internal/terraform/transform_attach_state.go @@ -46,6 +46,10 @@ func (t *AttachStateTransformer) Transform(g *Graph) error { continue } addr := an.ResourceInstanceAddr() + if !addr.Resource.Resource.Mode.PersistsBetweenRounds() { + // Non-persisting resources never have any state to attach. + continue + } rs := t.State.Resource(addr.ContainingResource()) if rs == nil { diff --git a/internal/terraform/transform_orphan_count.go b/internal/terraform/transform_orphan_count.go index a6a46a11c912..c4f591752c55 100644 --- a/internal/terraform/transform_orphan_count.go +++ b/internal/terraform/transform_orphan_count.go @@ -27,6 +27,12 @@ type OrphanResourceInstanceCountTransformer struct { } func (t *OrphanResourceInstanceCountTransformer) Transform(g *Graph) error { + if !t.Addr.Resource.Mode.PersistsBetweenRounds() { + // A resource of a mode that does not persist cannot possibly have + // orphan instances. + return nil + } + rs := t.State.Resource(t.Addr) if rs == nil { return nil // Resource doesn't exist in state, so nothing to do! From b8fe93e99eedd16c848e9d16ecfa5ceef79f4f6b Mon Sep 17 00:00:00 2001 From: Martin Atkins Date: Fri, 10 May 2024 13:10:39 -0700 Subject: [PATCH 20/23] builtin/providers/terraform: Prepare for more ephemeral resource types Instead of a test for whether the type name is different than the one we expect, we'll use a switch statement. This does nothing for now, but a future commit will add a new ephemeral resource type that's intended only for prototyping, exploiting the fact that this particular provider can offer ephemeral resource types without us first extending the provider plugin protocol with that concept. --- .../builtin/providers/terraform/provider.go | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/internal/builtin/providers/terraform/provider.go b/internal/builtin/providers/terraform/provider.go index 0112085980a3..5523cb53b549 100644 --- a/internal/builtin/providers/terraform/provider.go +++ b/internal/builtin/providers/terraform/provider.go @@ -196,35 +196,41 @@ func (p *Provider) ValidateResourceConfig(req providers.ValidateResourceConfigRe // OpenEphemeral implements providers.Interface. func (p *Provider) OpenEphemeral(req providers.OpenEphemeralRequest) providers.OpenEphemeralResponse { - if req.TypeName != "terraform_random_number" { + switch req.TypeName { + case "terraform_random_number": + return openEphemeralRandomNumber(req) + default: // This should not happen var resp providers.OpenEphemeralResponse resp.Diagnostics.Append(fmt.Errorf("unsupported ephemeral resource type %q", req.TypeName)) return resp } - return openEphemeralRandomNumber(req) } // RenewEphemeral implements providers.Interface. func (p *Provider) RenewEphemeral(req providers.RenewEphemeralRequest) providers.RenewEphemeralResponse { - if req.TypeName != "terraform_random_number" { + switch req.TypeName { + case "terraform_random_number": + return renewEphemeralRandomNumber(req) + default: // This should not happen var resp providers.RenewEphemeralResponse resp.Diagnostics.Append(fmt.Errorf("unsupported ephemeral resource type %q", req.TypeName)) return resp } - return renewEphemeralRandomNumber(req) } // CloseEphemeral implements providers.Interface. func (p *Provider) CloseEphemeral(req providers.CloseEphemeralRequest) providers.CloseEphemeralResponse { - if req.TypeName != "terraform_random_number" { + switch req.TypeName { + case "terraform_random_number": + return closeEphemeralRandomNumber(req) + default: // This should not happen var resp providers.CloseEphemeralResponse resp.Diagnostics.Append(fmt.Errorf("unsupported ephemeral resource type %q", req.TypeName)) return resp } - return closeEphemeralRandomNumber(req) } // CallFunction would call a function contributed by this provider, but this From a268b25c2a748d2037413e5be8fca042eb72ad9d Mon Sep 17 00:00:00 2001 From: Martin Atkins Date: Fri, 10 May 2024 16:05:51 -0700 Subject: [PATCH 21/23] terraform: Better error message for inconsistent ephemeral resource result --- internal/terraform/node_resource_ephemeral.go | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/internal/terraform/node_resource_ephemeral.go b/internal/terraform/node_resource_ephemeral.go index 3540d5130f50..ff95e9238720 100644 --- a/internal/terraform/node_resource_ephemeral.go +++ b/internal/terraform/node_resource_ephemeral.go @@ -93,8 +93,16 @@ func ephemeralResourceOpen(ctx EvalContext, inp ephemeralResourceInput) tfdiags. errs := objchange.AssertPlanValid(schema, cty.NullVal(schema.ImpliedType()), configVal, resultVal) for _, err := range errs { - // FIXME: Should turn these errors into suitable diagnostics. - diags = diags.Append(err) + diags = diags.Append(tfdiags.AttributeValue( + tfdiags.Error, + "Provider produced invalid ephemeral resource instance", + fmt.Sprintf( + "The provider for %s produced an inconsistent result: %s.", + inp.addr.Resource.Resource.Type, + tfdiags.FormatError(err), + ), + nil, + )).InConfigBody(config.Config, inp.addr.String()) } if diags.HasErrors() { return diags From 9ac6952267cdf4c9c0bd71de1181faa7f86835bf Mon Sep 17 00:00:00 2001 From: Martin Atkins Date: Fri, 10 May 2024 17:06:21 -0700 Subject: [PATCH 22/23] terraform: Ephemeral resource close comes after provider close When a provider configuration is using an ephemeral resource, we need the closure of the resource instances to depend on the closure of the provider instance because otherwise we'll leave the ephemeral resource instance live only long enough to configure the provider, and that's useless for taking any other actions with the provider after it's been configured. --- .../transform_ephemeral_resource_close.go | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/internal/terraform/transform_ephemeral_resource_close.go b/internal/terraform/transform_ephemeral_resource_close.go index 25aea67ed3d1..03a60e66240b 100644 --- a/internal/terraform/transform_ephemeral_resource_close.go +++ b/internal/terraform/transform_ephemeral_resource_close.go @@ -92,6 +92,23 @@ func (t *ephemeralResourceCloseTransformer) Transform(g *Graph) error { if !ok { continue } + // If "v" is representing a provider configuration then we also need + // our close node to depend on its close node, so that an ephemeral + // resource instance that a provider instance is depending on will + // remain alive for the provider instance's entire lifetime, rather + // than just while it's configuring itself. + var vClose dag.Vertex + if vP, ok := v.(GraphNodeProvider); ok { + providerAddr := vP.ProviderAddr() + for _, maybeVClose := range verts { + if maybeVClose, ok := maybeVClose.(GraphNodeCloseProvider); ok { + if maybeVClose.CloseProviderAddr().Equal(providerAddr) { + vClose = maybeVClose + break + } + } + } + } for _, consumedAddr := range requiredEphemeralResourcesForReferencer(v) { if consumedAddr.Resource.Mode != addrs.EphemeralResourceMode { // Should not happen: correct implementations of @@ -113,7 +130,12 @@ func (t *ephemeralResourceCloseTransformer) Transform(g *Graph) error { // The close node depends on anything that consumes instances of // the ephemeral resource, because we mustn't close it while // other components are still using it. + log.Printf("[TRACE] ephemeralResourceCloseTransformer: %s must wait for %s", dag.VertexName(closeNode), v) g.Connect(dag.BasicEdge(closeNode, v)) + if vClose != nil { + log.Printf("[TRACE] ephemeralResourceCloseTransformer: %s must wait for %s", dag.VertexName(closeNode), vClose) + g.Connect(dag.BasicEdge(closeNode, vClose)) + } } } From 41fa1e1400d9eeb422bd3199848fc192bf6a183a Mon Sep 17 00:00:00 2001 From: Martin Atkins Date: Fri, 10 May 2024 16:06:09 -0700 Subject: [PATCH 23/23] builtin/providers/terraform: terraform_ssh_tunnels ephemeral resource type This is here only for the purposes of prototyping ephemeral resources. If we move forward with a "real" implementation then something like this would be better placed in a separate SSH provider, rather than built into Terraform CLI itself. This is just a basic implementation to get started with. It's probably not very robust and will probably need fixes and additions in future commits. --- .../terraform/ephemeral_ssh_tunnels.go | 419 ++++++++++++++++++ .../builtin/providers/terraform/provider.go | 7 + 2 files changed, 426 insertions(+) create mode 100644 internal/builtin/providers/terraform/ephemeral_ssh_tunnels.go diff --git a/internal/builtin/providers/terraform/ephemeral_ssh_tunnels.go b/internal/builtin/providers/terraform/ephemeral_ssh_tunnels.go new file mode 100644 index 000000000000..eca3c94860fa --- /dev/null +++ b/internal/builtin/providers/terraform/ephemeral_ssh_tunnels.go @@ -0,0 +1,419 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package terraform + +import ( + "bytes" + "context" + "encoding/binary" + "fmt" + "io" + "log" + "net" + "sync" + "unsafe" + + "github.com/zclconf/go-cty/cty" + "golang.org/x/crypto/ssh" + + "github.com/hashicorp/terraform/internal/configs/configschema" + "github.com/hashicorp/terraform/internal/providers" + "github.com/hashicorp/terraform/internal/tfdiags" +) + +func ephemeralSSHTunnelsSchema() providers.Schema { + return providers.Schema{ + Block: &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "server": {Type: cty.String, Required: true}, + "username": {Type: cty.String, Required: true}, + + "auth_methods": { + Type: cty.List( + // This object type is acting like a sum type rather + // than a product type, requiring that exactly one + // of its attributes is set to decide which member + // to instantiate. + cty.Object(map[string]cty.Type{ + "password": cty.String, + // TODO: SSH keys, etc + }), + ), + Required: true, + }, + + "tcp_to_remote": { + Type: cty.Map(cty.Object(map[string]cty.Type{ + "local_host": cty.String, + "local_port": cty.String, + "local": cty.String, + "remote": cty.String, + })), + Computed: true, + }, + "tcp_from_remote": { + Type: cty.Map(cty.Object(map[string]cty.Type{ + "remote_port": cty.String, + })), + Computed: true, + }, + }, + BlockTypes: map[string]*configschema.NestedBlock{ + "tcp_local_to_remote": { + Nesting: configschema.NestingMap, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "remote": {Type: cty.String, Required: true}, + "local": {Type: cty.String, Optional: true}, + }, + }, + }, + "tcp_remote_to_local": { + Nesting: configschema.NestingMap, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "remote": {Type: cty.String, Required: true}, + "local": {Type: cty.String, Required: true}, + }, + }, + }, + }, + }, + } +} + +type ephemeralSSHTunnelsConns struct { + // Keys here are the addresses of the corresponding ephemeralSSHTunnelState + // objects. This probably isn't a good idea in the long run, but it's + // fine for a prototype. + active map[uintptr]*ephemeralSSHTunnelConn + mu sync.Mutex +} + +type ephemeralSSHTunnelConn struct { + client *ssh.Client + closeListeners func() +} + +var ephemeralSSHTunnels ephemeralSSHTunnelsConns + +func init() { + ephemeralSSHTunnels.mu.Lock() + ephemeralSSHTunnels.active = make(map[uintptr]*ephemeralSSHTunnelConn) + ephemeralSSHTunnels.mu.Unlock() +} + +func openEphemeralSSHTunnels(req providers.OpenEphemeralRequest) providers.OpenEphemeralResponse { + log.Printf("[TRACE] terraform_ssh_tunnels: opening connection") + var resp providers.OpenEphemeralResponse + + serverAddr, clientConfig, diags := makeEphemeralSSHTunnelClientConfig(req.Config) + resp.Diagnostics = resp.Diagnostics.Append(diags) + if diags.HasErrors() { + return resp + } + log.Printf("[DEBUG] terraform_ssh_tunnels: connecting to %s as %q", serverAddr, clientConfig.User) + client, err := ssh.Dial("tcp", serverAddr, clientConfig) + if err != nil { + resp.Diagnostics = resp.Diagnostics.Append(tfdiags.AttributeValue( + tfdiags.Error, + "Can't connect to SSH server", + fmt.Sprintf("Failed to connect to SSH server to establish tunnels: %s.", err), + nil, // A number of different arguments could potentially cause a connection failure + )) + return resp + } + + ephemeralSSHTunnels.mu.Lock() + defer ephemeralSSHTunnels.mu.Unlock() + + conn := &ephemeralSSHTunnelConn{ + client: client, + } + connID := uintptr(unsafe.Pointer(conn)) + ephemeralSSHTunnels.active[connID] = conn + + var intCtx bytes.Buffer + intCtx.Grow(8) + binary.Write(&intCtx, binary.LittleEndian, uint64(connID)) + resp.InternalContext = intCtx.Bytes() + + tcpToRemoteVals := map[string]cty.Value{} + tcpFromRemoteVals := map[string]cty.Value{} + + ctx, cancel := context.WithCancel(context.Background()) + conn.closeListeners = cancel + for it := req.Config.GetAttr("tcp_local_to_remote").ElementIterator(); it.Next(); { + keyVal, configVal := it.Element() + key := keyVal.AsString() + log.Printf("[TRACE] terraform_ssh_tunnels: tcp_local_to_remote %q", key) + + // FIXME: The following is not robust against unknown values and + // other such oddities. + remoteAddr := configVal.GetAttr("remote").AsString() + + listener, err := (&net.ListenConfig{}).Listen(ctx, "tcp", "127.0.0.1:0") + if err != nil { + resp.Diagnostics = resp.Diagnostics.Append(tfdiags.AttributeValue( + tfdiags.Error, + "Can't open TCP listen port", + fmt.Sprintf("Failed to open local TCP listen port for tcp_local_to_remote %q: %s.", key, err), + cty.GetAttrPath("tcp_local_to_remote").IndexString(key), + )) + continue + } + + go sshTunnelLocalToRemote(ctx, listener, client, remoteAddr) + + tcpToRemoteVals[key] = cty.ObjectVal(map[string]cty.Value{ + "local_host": cty.NullVal(cty.String), // TODO: Populate + "local_port": cty.NullVal(cty.String), // TODO: Populate + "local": cty.StringVal(listener.Addr().String()), + "remote": cty.NullVal(cty.String), // TODO: Populate + }) + } + for it := req.Config.GetAttr("tcp_remote_to_local").ElementIterator(); it.Next(); { + log.Printf("[TRACE] terraform_ssh_tunnels: tcp_remote_to_local") + + // TODO: Implement + } + + var tcpToRemoteVal cty.Value + if len(tcpToRemoteVals) != 0 { + tcpToRemoteVal = cty.MapVal(tcpToRemoteVals) + } else { + tcpToRemoteVal = cty.MapValEmpty(cty.Object(map[string]cty.Type{ + "local_host": cty.String, + "local_port": cty.String, + "local": cty.String, + "remote": cty.String, + })) + } + var tcpFromRemoteVal cty.Value + if len(tcpFromRemoteVals) != 0 { + tcpFromRemoteVal = cty.MapVal(tcpFromRemoteVals) + } else { + tcpFromRemoteVal = cty.MapValEmpty(cty.Object(map[string]cty.Type{ + "remote_port": cty.String, + })) + } + + resp.Result = cty.ObjectVal(map[string]cty.Value{ + "server": req.Config.GetAttr("server"), + "username": req.Config.GetAttr("username"), + "auth_methods": req.Config.GetAttr("auth_methods"), + "tcp_local_to_remote": req.Config.GetAttr("tcp_local_to_remote"), + "tcp_remote_to_local": req.Config.GetAttr("tcp_remote_to_local"), + + "tcp_to_remote": tcpToRemoteVal, + "tcp_from_remote": tcpFromRemoteVal, + }) + + return resp +} + +func renewEphemeralSSHTunnels(req providers.RenewEphemeralRequest) providers.RenewEphemeralResponse { + // SSH tunnel connections don't need to be explicitly renewed, so this + // should never get called. (The SSH library handles keepalives internally + // itself, without our help.) + return providers.RenewEphemeralResponse{} +} + +func closeEphemeralSSHTunnels(req providers.CloseEphemeralRequest) providers.CloseEphemeralResponse { + log.Printf("[TRACE] terraform_ssh_tunnels: closing connection") + var resp providers.CloseEphemeralResponse + + intCtx := bytes.NewReader(req.InternalContext) + var connIDInt uint64 + if err := binary.Read(intCtx, binary.LittleEndian, &connIDInt); err != nil { + // Should not get here if the client is behaving correctly, because + // we should only get InternalContext values that we returned previously + // from [openEphemeralSSHTunnels]. + resp.Diagnostics = resp.Diagnostics.Append(err) + return resp + } + connID := uintptr(connIDInt) + + ephemeralSSHTunnels.mu.Lock() + defer ephemeralSSHTunnels.mu.Unlock() + + conn, ok := ephemeralSSHTunnels.active[connID] + if !ok { + // Should not get here because client should only pass InternalContext + // values that we returned previously from [openEphemeralSSHTunnels]. + resp.Diagnostics = resp.Diagnostics.Append(fmt.Errorf("trying to close unknown connection %#v", connID)) + return resp + } + + if conn.closeListeners != nil { + conn.closeListeners() + } + + err := conn.client.Close() + if err != nil { + // Perhaps the connection already got terminated exceptionally before + // we got around to closing it? + resp.Diagnostics = resp.Diagnostics.Append(tfdiags.AttributeValue( + tfdiags.Error, + "Could not close SSH connection", + fmt.Sprintf("Failed to close tunnel SSH connection: %s.", err), + nil, + )) + } + // We'll delete it even if we failed to close it, because we're not going + // to get any opportunity to do anything with it again anyway, and it + // seems to be somehow broken. + delete(ephemeralSSHTunnels.active, connID) + + return resp +} + +func makeEphemeralSSHTunnelClientConfig(configVal cty.Value) (serverAddr string, clientConfig *ssh.ClientConfig, diags tfdiags.Diagnostics) { + clientConfig = &ssh.ClientConfig{} + + // FIXME: In a real implementation we ought to constrain this better, + // such as by having the configuration include a set of allowed host + // keys. + clientConfig.HostKeyCallback = ssh.InsecureIgnoreHostKey() + + if serverVal := configVal.GetAttr("server"); serverVal.IsKnown() { + serverAddr = serverVal.AsString() + } else { + // FIXME: Terrible error message just for prototype. + // In a real implementation we would hopefully be able to "defer" + // this, but deferred actions is being implemented concurrently with + // this prototype and so this is best to avoid conflicting with that + // other project. + diags = diags.Append(tfdiags.AttributeValue( + tfdiags.Error, + "SSH server address not known", + "The SSH server address is derived from a value that isn't known yet.", + cty.GetAttrPath("server"), + )) + } + if usernameVal := configVal.GetAttr("username"); usernameVal.IsKnown() { + clientConfig.User = usernameVal.AsString() + } else { + // FIXME: Terrible error message just for prototype. + diags = diags.Append(tfdiags.AttributeValue( + tfdiags.Error, + "SSH username not known", + "The username is derived from a value that isn't known yet.", + cty.GetAttrPath("server"), + )) + } + + if authMethodsVal := configVal.GetAttr("auth_methods"); authMethodsVal.IsWhollyKnown() { + for it := authMethodsVal.ElementIterator(); it.Next(); { + idx, authMethodObj := it.Element() + if authMethodObj.IsNull() { + continue // FIXME: should probably be an error, actually + } + + // The following makes sure that exactly one attribute is set + // and checks which it is. This pattern treats the object type + // as a sum type rather than as a product type. + var attrName string + var attrVal cty.Value + for n := range authMethodObj.Type().AttributeTypes() { + val := authMethodObj.GetAttr(n) + if val.IsNull() { + continue + } + if attrName != "" { + diags = diags.Append(tfdiags.AttributeValue( + tfdiags.Error, + "Ambiguous auth method selection", + fmt.Sprintf("Cannot set both %q and %q.", attrName, n), + cty.GetAttrPath("auth_methods").Index(idx), + )) + continue + } + attrName = n + attrVal = val + } + if attrName == "" { + diags = diags.Append(tfdiags.AttributeValue( + tfdiags.Error, + "No auth method selection", + "Must set one of the possible attributes to select the auth method type.", + cty.GetAttrPath("auth_methods").Index(idx), + )) + continue + } + + switch attrName { + case "password": + if attrVal.IsNull() { + diags = diags.Append(tfdiags.AttributeValue( + tfdiags.Error, + "Password cannot be null", + "When authenticating using a password, the password must be specified.", + cty.GetAttrPath("auth_methods").Index(idx).GetAttr("password"), + )) + continue + } + clientConfig.Auth = append(clientConfig.Auth, ssh.Password(attrVal.AsString())) + } + } + } else { + // FIXME: Terrible error message just for prototype. + diags = diags.Append(tfdiags.AttributeValue( + tfdiags.Error, + "SSH server auth methods not known", + "The auth_methods structure contains unknown values.", + cty.GetAttrPath("auth_methods"), + )) + } + + return serverAddr, clientConfig, diags +} + +func sshTunnelLocalToRemote(ctx context.Context, listener net.Listener, sshClient *ssh.Client, remoteAddr string) { + log.Printf("[TRACE] terraform_ssh_tunnels: forwarding connections from %s to %s", listener.Addr(), remoteAddr) + + for { + localConn, err := listener.Accept() + if err != nil { + log.Printf("[DEBUG] terraform_ssh_tunnels: error accepting connection from %s: %s", listener.Addr(), err) + break + } + + remoteConn, err := sshClient.DialContext(ctx, "tcp", remoteAddr) + if err != nil { + localConn.Close() + log.Printf("[DEBUG] terraform_ssh_tunnels: error opening connection to %s: %s", remoteAddr, err) + break + } + + localConnTCP := localConn.(*net.TCPConn) + + // If we managed to open both connections then we just need to pass + // arbitrary bytes between them for as long as they're both open. + go func() { + var wg sync.WaitGroup + + log.Printf("[TRACE] terraform_ssh_tunnels: tunnel connection %s->%s: open", localConnTCP.LocalAddr(), remoteConn.RemoteAddr()) + + wg.Add(2) + go func() { + io.Copy(localConnTCP, remoteConn) + localConnTCP.CloseWrite() + wg.Done() + }() + go func() { + io.Copy(remoteConn, localConnTCP) + // SSH tunnel client conn doesn't support CloseWrite, so + // we can't signal that nothing more is coming on that one. + wg.Done() + }() + + wg.Wait() + localConnTCP.Close() + remoteConn.Close() + + log.Printf("[TRACE] terraform_ssh_tunnels: tunnel connection %s->%s: closed", localConnTCP.LocalAddr(), remoteConn.RemoteAddr()) + }() + } +} diff --git a/internal/builtin/providers/terraform/provider.go b/internal/builtin/providers/terraform/provider.go index 5523cb53b549..2ebd35286a05 100644 --- a/internal/builtin/providers/terraform/provider.go +++ b/internal/builtin/providers/terraform/provider.go @@ -34,6 +34,7 @@ func (p *Provider) GetProviderSchema() providers.GetProviderSchemaResponse { }, EphemeralResourceTypes: map[string]providers.Schema{ "terraform_random_number": ephemeralRandomNumberSchema(), + "terraform_ssh_tunnels": ephemeralSSHTunnelsSchema(), }, Functions: map[string]providers.FunctionDecl{ "encode_tfvars": { @@ -199,6 +200,8 @@ func (p *Provider) OpenEphemeral(req providers.OpenEphemeralRequest) providers.O switch req.TypeName { case "terraform_random_number": return openEphemeralRandomNumber(req) + case "terraform_ssh_tunnels": + return openEphemeralSSHTunnels(req) default: // This should not happen var resp providers.OpenEphemeralResponse @@ -212,6 +215,8 @@ func (p *Provider) RenewEphemeral(req providers.RenewEphemeralRequest) providers switch req.TypeName { case "terraform_random_number": return renewEphemeralRandomNumber(req) + case "terraform_ssh_tunnels": + return renewEphemeralSSHTunnels(req) default: // This should not happen var resp providers.RenewEphemeralResponse @@ -225,6 +230,8 @@ func (p *Provider) CloseEphemeral(req providers.CloseEphemeralRequest) providers switch req.TypeName { case "terraform_random_number": return closeEphemeralRandomNumber(req) + case "terraform_ssh_tunnels": + return closeEphemeralSSHTunnels(req) default: // This should not happen var resp providers.CloseEphemeralResponse