From 1f4bf797ff8d5fa8fbc222bedf401ac1f705e1a5 Mon Sep 17 00:00:00 2001 From: James Bardin Date: Wed, 10 Dec 2025 10:40:49 -0500 Subject: [PATCH 1/4] add ability to forward PlannedPrivate data Terraform providers sometimes want to be able to plan a computed field which may not be deterministically generated. For example, a random GUID could be generated during plan, making the overall Terraform plan more precise, but there is currently no way to carry that planned value forward to apply. The problem is that there are always two entirely independent plans for every resource change. Terraform compares these two plans for consistency, so a provider may not return a different computed value, but there is no way for the provider to see what the prior planned value was. This means that a planned value which is not reproducible from the given inputs is not plan-able at all. This PR allows Terraform to take the private data from the first plan, which is usually discarded, and send it along in the second plan request. The provider can then use that data to recreate any previously planned values, either because they are stored directly, or contain enough seed data to recreate the same result. --- docs/plugin-protocol/tfplugin6.proto | 10 ++++++++-- internal/plugin6/grpc_provider.go | 2 ++ internal/providers/provider.go | 10 ++++++++++ internal/terraform/context_plan_test.go | 1 + internal/terraform/eval_context_builtin.go | 1 + internal/terraform/node_resource_abstract_instance.go | 7 +++++-- 6 files changed, 27 insertions(+), 4 deletions(-) diff --git a/docs/plugin-protocol/tfplugin6.proto b/docs/plugin-protocol/tfplugin6.proto index 1b2b0ed08c..66b471697b 100644 --- a/docs/plugin-protocol/tfplugin6.proto +++ b/docs/plugin-protocol/tfplugin6.proto @@ -1,9 +1,9 @@ // Copyright IBM Corp. 2014, 2026 // SPDX-License-Identifier: MPL-2.0 -// Terraform Plugin RPC protocol version 6.10 +// Terraform Plugin RPC protocol version 6.11 // -// This file defines version 6.10 of the RPC protocol. To implement a plugin +// This file defines version 6.11 of the RPC protocol. To implement a plugin // against this protocol, copy this definition into your own codebase and // use protoc to generate stubs for your target language. // @@ -312,6 +312,11 @@ message ClientCapabilities { // The write_only_attributes_allowed capability signals that the client // is able to handle write_only attributes for managed resources. bool write_only_attributes_allowed = 2; + + // store_planned_private indicates that the client will store the private data + // returned with an initial plan, and send it back to the provider as + // PlannedPrivate data in a subsequent plan request. + bool store_planned_private = 3; } // Deferred is a message that indicates that change is deferred for a reason. @@ -643,6 +648,7 @@ message PlanResourceChange { DynamicValue provider_meta = 6; ClientCapabilities client_capabilities = 7; ResourceIdentityData prior_identity = 8; + bytes planned_private = 9; } message Response { diff --git a/internal/plugin6/grpc_provider.go b/internal/plugin6/grpc_provider.go index 9da603a247..2b2c2f394c 100644 --- a/internal/plugin6/grpc_provider.go +++ b/internal/plugin6/grpc_provider.go @@ -673,6 +673,7 @@ func (p *GRPCProvider) PlanResourceChange(r providers.PlanResourceChangeRequest) ProposedNewState: &proto6.DynamicValue{Msgpack: propMP}, PriorPrivate: r.PriorPrivate, ClientCapabilities: clientCapabilitiesToProto(r.ClientCapabilities), + PlannedPrivate: r.PlannedPrivate, } if metaSchema.Body != nil { @@ -2071,6 +2072,7 @@ func clientCapabilitiesToProto(c providers.ClientCapabilities) *proto6.ClientCap return &proto6.ClientCapabilities{ DeferralAllowed: c.DeferralAllowed, WriteOnlyAttributesAllowed: c.WriteOnlyAttributesAllowed, + StorePlannedPrivate: c.StorePlannedPrivate, } } diff --git a/internal/providers/provider.go b/internal/providers/provider.go index e52de56289..011294ebfc 100644 --- a/internal/providers/provider.go +++ b/internal/providers/provider.go @@ -307,6 +307,11 @@ type ClientCapabilities struct { // The write_only_attributes_allowed capability signals that the client // is able to handle write_only attributes for managed resources. WriteOnlyAttributesAllowed bool + + // StorePlannedPrivate indicates that the client is will store private data + // returned from PlanResourceChange, and return it with the final + // PlanResourceChange call. + StorePlannedPrivate bool } type ValidateProviderConfigRequest struct { @@ -556,6 +561,11 @@ type PlanResourceChangeRequest struct { // provider during the last apply. PriorPrivate []byte + // PlannedPrivate is the private data stored from the the last plan. + // PlannedPrivate will only be supplied in the plan immediately preceding an + // ApplyResourceChange call. + PlannedPrivate []byte + // ProviderMeta is the configuration for the provider_meta block for the // module and provider this resource belongs to. Its use is defined by // each provider, and it should not be used without coordination with diff --git a/internal/terraform/context_plan_test.go b/internal/terraform/context_plan_test.go index 0e5e73682e..22f715e5ce 100644 --- a/internal/terraform/context_plan_test.go +++ b/internal/terraform/context_plan_test.go @@ -1748,6 +1748,7 @@ func TestContext2Plan_blockNestingGroup(t *testing.T) { ClientCapabilities: providers.ClientCapabilities{ DeferralAllowed: false, WriteOnlyAttributesAllowed: true, + StorePlannedPrivate: true, }, } if !cmp.Equal(got, want, valueTrans) { diff --git a/internal/terraform/eval_context_builtin.go b/internal/terraform/eval_context_builtin.go index 30f174680f..09590c253b 100644 --- a/internal/terraform/eval_context_builtin.go +++ b/internal/terraform/eval_context_builtin.go @@ -660,6 +660,7 @@ func (ctx *BuiltinEvalContext) ClientCapabilities() providers.ClientCapabilities return providers.ClientCapabilities{ DeferralAllowed: ctx.Deferrals().DeferralAllowed(), WriteOnlyAttributesAllowed: true, + StorePlannedPrivate: true, } } diff --git a/internal/terraform/node_resource_abstract_instance.go b/internal/terraform/node_resource_abstract_instance.go index 187f8dfb59..a2bccb0bbe 100644 --- a/internal/terraform/node_resource_abstract_instance.go +++ b/internal/terraform/node_resource_abstract_instance.go @@ -835,10 +835,12 @@ func (n *NodeAbstractResourceInstance) plan( if n.preDestroyRefresh { checkRuleSeverity = tfdiags.Warning } - + var plannedPrivate []byte if plannedChange != nil { // If we already planned the action, we stick to that plan createBeforeDestroy = plannedChange.Action == plans.CreateThenDelete + + plannedPrivate = plannedChange.Private } // Evaluate the configuration @@ -991,6 +993,7 @@ func (n *NodeAbstractResourceInstance) plan( ProviderMeta: metaConfigVal, ClientCapabilities: ctx.ClientCapabilities(), PriorIdentity: priorIdentity, + PlannedPrivate: plannedPrivate, }) // If we don't support deferrals, but the provider reports a deferral and does not // emit any error level diagnostics, we should emit an error. @@ -1012,7 +1015,7 @@ func (n *NodeAbstractResourceInstance) plan( } plannedNewVal := resp.PlannedState - plannedPrivate := resp.PlannedPrivate + plannedPrivate = resp.PlannedPrivate plannedIdentity := resp.PlannedIdentity // These checks are only relevant if the provider is not deferring the From f28c6ee74f6278130f365b6101e0f1ec9fd0df28 Mon Sep 17 00:00:00 2001 From: James Bardin Date: Wed, 10 Dec 2025 13:36:18 -0500 Subject: [PATCH 2/4] make protobuf --- internal/tfplugin6/tfplugin6.pb.go | 39 +++++++++++++++++++------ internal/tfplugin6/tfplugin6_grpc.pb.go | 4 +-- 2 files changed, 32 insertions(+), 11 deletions(-) diff --git a/internal/tfplugin6/tfplugin6.pb.go b/internal/tfplugin6/tfplugin6.pb.go index e0df6a375b..2a23491061 100644 --- a/internal/tfplugin6/tfplugin6.pb.go +++ b/internal/tfplugin6/tfplugin6.pb.go @@ -1,9 +1,9 @@ // Copyright IBM Corp. 2014, 2026 // SPDX-License-Identifier: MPL-2.0 -// Terraform Plugin RPC protocol version 6.10 +// Terraform Plugin RPC protocol version 6.11 // -// This file defines version 6.10 of the RPC protocol. To implement a plugin +// This file defines version 6.11 of the RPC protocol. To implement a plugin // against this protocol, copy this definition into your own codebase and // use protoc to generate stubs for your target language. // @@ -1040,8 +1040,12 @@ type ClientCapabilities struct { // The write_only_attributes_allowed capability signals that the client // is able to handle write_only attributes for managed resources. WriteOnlyAttributesAllowed bool `protobuf:"varint,2,opt,name=write_only_attributes_allowed,json=writeOnlyAttributesAllowed,proto3" json:"write_only_attributes_allowed,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // store_planned_private indicates that the client will store the private data + // returned with an initial plan, and send it back to the provider as + // PlannedPrivate data in a subsequent plan request. + StorePlannedPrivate bool `protobuf:"varint,3,opt,name=store_planned_private,json=storePlannedPrivate,proto3" json:"store_planned_private,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ClientCapabilities) Reset() { @@ -1088,6 +1092,13 @@ func (x *ClientCapabilities) GetWriteOnlyAttributesAllowed() bool { return false } +func (x *ClientCapabilities) GetStorePlannedPrivate() bool { + if x != nil { + return x.StorePlannedPrivate + } + return false +} + // Deferred is a message that indicates that change is deferred for a reason. type Deferred struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -4998,6 +5009,7 @@ type PlanResourceChange_Request struct { ProviderMeta *DynamicValue `protobuf:"bytes,6,opt,name=provider_meta,json=providerMeta,proto3" json:"provider_meta,omitempty"` ClientCapabilities *ClientCapabilities `protobuf:"bytes,7,opt,name=client_capabilities,json=clientCapabilities,proto3" json:"client_capabilities,omitempty"` PriorIdentity *ResourceIdentityData `protobuf:"bytes,8,opt,name=prior_identity,json=priorIdentity,proto3" json:"prior_identity,omitempty"` + PlannedPrivate []byte `protobuf:"bytes,9,opt,name=planned_private,json=plannedPrivate,proto3" json:"planned_private,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -5088,6 +5100,13 @@ func (x *PlanResourceChange_Request) GetPriorIdentity() *ResourceIdentityData { return nil } +func (x *PlanResourceChange_Request) GetPlannedPrivate() []byte { + if x != nil { + return x.PlannedPrivate + } + return nil +} + type PlanResourceChange_Response struct { state protoimpl.MessageState `protogen:"open.v1"` PlannedState *DynamicValue `protobuf:"bytes,1,opt,name=planned_state,json=plannedState,proto3" json:"planned_state,omitempty"` @@ -8220,10 +8239,11 @@ const file_tfplugin6_proto_rawDesc = "" + "\fplan_destroy\x18\x01 \x01(\bR\vplanDestroy\x12?\n" + "\x1cget_provider_schema_optional\x18\x02 \x01(\bR\x19getProviderSchemaOptional\x12.\n" + "\x13move_resource_state\x18\x03 \x01(\bR\x11moveResourceState\x128\n" + - "\x18generate_resource_config\x18\x04 \x01(\bR\x16generateResourceConfig\"\x82\x01\n" + + "\x18generate_resource_config\x18\x04 \x01(\bR\x16generateResourceConfig\"\xb6\x01\n" + "\x12ClientCapabilities\x12)\n" + "\x10deferral_allowed\x18\x01 \x01(\bR\x0fdeferralAllowed\x12A\n" + - "\x1dwrite_only_attributes_allowed\x18\x02 \x01(\bR\x1awriteOnlyAttributesAllowed\"\xa2\x01\n" + + "\x1dwrite_only_attributes_allowed\x18\x02 \x01(\bR\x1awriteOnlyAttributesAllowed\x122\n" + + "\x15store_planned_private\x18\x03 \x01(\bR\x13storePlannedPrivate\"\xa2\x01\n" + "\bDeferred\x122\n" + "\x06reason\x18\x01 \x01(\x0e2\x1a.tfplugin6.Deferred.ReasonR\x06reason\"b\n" + "\x06Reason\x12\v\n" + @@ -8361,8 +8381,8 @@ const file_tfplugin6_proto_rawDesc = "" + "\vdiagnostics\x18\x02 \x03(\v2\x15.tfplugin6.DiagnosticR\vdiagnostics\x12\x18\n" + "\aprivate\x18\x03 \x01(\fR\aprivate\x12/\n" + "\bdeferred\x18\x04 \x01(\v2\x13.tfplugin6.DeferredR\bdeferred\x12B\n" + - "\fnew_identity\x18\x05 \x01(\v2\x1f.tfplugin6.ResourceIdentityDataR\vnewIdentity\"\x87\a\n" + - "\x12PlanResourceChange\x1a\xd3\x03\n" + + "\fnew_identity\x18\x05 \x01(\v2\x1f.tfplugin6.ResourceIdentityDataR\vnewIdentity\"\xb0\a\n" + + "\x12PlanResourceChange\x1a\xfc\x03\n" + "\aRequest\x12\x1b\n" + "\ttype_name\x18\x01 \x01(\tR\btypeName\x128\n" + "\vprior_state\x18\x02 \x01(\v2\x17.tfplugin6.DynamicValueR\n" + @@ -8372,7 +8392,8 @@ const file_tfplugin6_proto_rawDesc = "" + "\rprior_private\x18\x05 \x01(\fR\fpriorPrivate\x12<\n" + "\rprovider_meta\x18\x06 \x01(\v2\x17.tfplugin6.DynamicValueR\fproviderMeta\x12N\n" + "\x13client_capabilities\x18\a \x01(\v2\x1d.tfplugin6.ClientCapabilitiesR\x12clientCapabilities\x12F\n" + - "\x0eprior_identity\x18\b \x01(\v2\x1f.tfplugin6.ResourceIdentityDataR\rpriorIdentity\x1a\x9a\x03\n" + + "\x0eprior_identity\x18\b \x01(\v2\x1f.tfplugin6.ResourceIdentityDataR\rpriorIdentity\x12'\n" + + "\x0fplanned_private\x18\t \x01(\fR\x0eplannedPrivate\x1a\x9a\x03\n" + "\bResponse\x12<\n" + "\rplanned_state\x18\x01 \x01(\v2\x17.tfplugin6.DynamicValueR\fplannedState\x12C\n" + "\x10requires_replace\x18\x02 \x03(\v2\x18.tfplugin6.AttributePathR\x0frequiresReplace\x12'\n" + diff --git a/internal/tfplugin6/tfplugin6_grpc.pb.go b/internal/tfplugin6/tfplugin6_grpc.pb.go index 2012d1e6bb..9654a95e8b 100644 --- a/internal/tfplugin6/tfplugin6_grpc.pb.go +++ b/internal/tfplugin6/tfplugin6_grpc.pb.go @@ -1,9 +1,9 @@ // Copyright IBM Corp. 2014, 2026 // SPDX-License-Identifier: MPL-2.0 -// Terraform Plugin RPC protocol version 6.10 +// Terraform Plugin RPC protocol version 6.11 // -// This file defines version 6.10 of the RPC protocol. To implement a plugin +// This file defines version 6.11 of the RPC protocol. To implement a plugin // against this protocol, copy this definition into your own codebase and // use protoc to generate stubs for your target language. // From d21b2f885c4f3817561d76a44da4335a9f9b7dd1 Mon Sep 17 00:00:00 2001 From: James Bardin Date: Wed, 17 Dec 2025 11:34:14 -0500 Subject: [PATCH 3/4] CHANGELOG --- .changes/v1.16/NEW FEATURES-20251217-113349.yaml | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changes/v1.16/NEW FEATURES-20251217-113349.yaml diff --git a/.changes/v1.16/NEW FEATURES-20251217-113349.yaml b/.changes/v1.16/NEW FEATURES-20251217-113349.yaml new file mode 100644 index 0000000000..5799b36a15 --- /dev/null +++ b/.changes/v1.16/NEW FEATURES-20251217-113349.yaml @@ -0,0 +1,5 @@ +kind: NEW FEATURES +body: Store PlannedPrivate data for providers +time: 2025-12-17T11:33:49.911997-05:00 +custom: + Issue: "37986" From 7f653e1548c3a93c6616854e49ecc0ffe5910bd0 Mon Sep 17 00:00:00 2001 From: James Bardin Date: Wed, 25 Mar 2026 08:06:52 -0400 Subject: [PATCH 4/4] full apply test for private plan data --- internal/terraform/context_apply2_test.go | 61 +++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/internal/terraform/context_apply2_test.go b/internal/terraform/context_apply2_test.go index 2d534ea878..022ae53d28 100644 --- a/internal/terraform/context_apply2_test.go +++ b/internal/terraform/context_apply2_test.go @@ -7,6 +7,7 @@ import ( "bytes" "errors" "fmt" + "math/rand" "path/filepath" "sort" "strings" @@ -4892,3 +4893,63 @@ func TestContext2Apply_outputWithTypeContraint(t *testing.T) { } } } + +func TestContext2Apply_storedPrivatePlanData(t *testing.T) { + m := testModuleInline(t, map[string]string{ + "main.tf": ` +resource "test_resource" "foo" { +} + +resource "test_resource" "bar" { + value = test_resource.foo.computed +} +`, + }) + + p := testProvider("test") + p.GetProviderSchemaResponse = getProviderSchemaResponseFromProviderSchema(&providerSchema{ + ResourceTypes: map[string]*configschema.Block{ + "test_resource": { + Attributes: map[string]*configschema.Attribute{ + "id": {Type: cty.String, Computed: true}, + "value": {Type: cty.String, Optional: true}, + "computed": {Type: cty.String, Computed: true}, + }, + }, + }, + }) + + // make sure we can correctly re-plan a value which was stored in the + // PlannedPrivate data from our initial plan + p.PlanResourceChangeFn = func(req providers.PlanResourceChangeRequest) (resp providers.PlanResourceChangeResponse) { + planned := req.ProposedNewState.AsValueMap() + if req.PlannedPrivate != nil { + // fetch the originally planned random string + planned["computed"] = cty.StringVal(string(req.PlannedPrivate)) + } else { + // this is our first plan, so generate a new computed value + s := fmt.Sprintf("%d", rand.Int()) + planned["computed"] = cty.StringVal(s) + resp.PlannedPrivate = []byte(s) + } + + planned["id"] = cty.UnknownVal(cty.String) + resp.PlannedState = cty.ObjectVal(planned) + return resp + } + + ctx := testContext2(t, &ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("test"): testProviderFuncFixed(p), + }, + }) + + plan, diags := ctx.Plan(m, states.NewState(), DefaultPlanOpts) + tfdiags.AssertNoErrors(t, diags) + + // we don't need to try and determine what the correct random value was, if + // the planing was incorrect apply would fail with "Provider produced + // inconsistent final plan" + _, diags = ctx.Apply(plan, m, nil) + tfdiags.AssertNoErrors(t, diags) +}