Do not handle yet the deferred signal from providers for actions other than open ephemeral resource

Signed-off-by: Andrei Ciobanu <andrei.ciobanu@opentofu.org>
This commit is contained in:
Andrei Ciobanu
2025-12-16 13:10:35 +02:00
parent 0256de5c4d
commit bcd0c575c5
10 changed files with 12 additions and 374 deletions

View File

@@ -34,14 +34,6 @@ type GRPCProviderPlugin struct {
}
var clientCapabilities = &proto.ClientCapabilities{
// DeferralAllowed tells the provider that it is allowed to respond to
// all of the various post-configuration requests (as described by the
// [providers.Configured] interface) by reporting that the request
// must be "deferred" because there isn't yet enough information to
// satisfy the request. Setting this means that we need to be prepared
// for there to be a "deferred" object in the response from various
// other provider RPC functions.
DeferralAllowed: true,
// WriteOnlyAttributesAllowed indicates that the current system version
// supports write-only attributes.
// This enables the SDK to run specific validations and enable the
@@ -502,11 +494,6 @@ func (p *GRPCProvider) ReadResource(ctx context.Context, r providers.ReadResourc
return resp
}
resp.Diagnostics = resp.Diagnostics.Append(convert.ProtoToDiagnostics(protoResp.Diagnostics))
if protoDeferred := protoResp.Deferred; protoDeferred != nil {
reason := convert.DeferralReasonFromProto(protoDeferred.Reason)
resp.Diagnostics = resp.Diagnostics.Append(providers.NewDeferralDiagnostic(reason))
return resp
}
state, err := decodeDynamicValue(protoResp.NewState, resSchema.Block.ImpliedType())
if err != nil {
@@ -589,11 +576,6 @@ func (p *GRPCProvider) PlanResourceChange(ctx context.Context, r providers.PlanR
return resp
}
resp.Diagnostics = resp.Diagnostics.Append(convert.ProtoToDiagnostics(protoResp.Diagnostics))
if protoDeferred := protoResp.Deferred; protoDeferred != nil {
reason := convert.DeferralReasonFromProto(protoDeferred.Reason)
resp.Diagnostics = resp.Diagnostics.Append(providers.NewDeferralDiagnostic(reason))
return resp
}
state, err := decodeDynamicValue(protoResp.PlannedState, resSchema.Block.ImpliedType())
if err != nil {
@@ -716,11 +698,6 @@ func (p *GRPCProvider) ImportResourceState(ctx context.Context, r providers.Impo
return resp
}
resp.Diagnostics = resp.Diagnostics.Append(convert.ProtoToDiagnostics(protoResp.Diagnostics))
if protoDeferred := protoResp.Deferred; protoDeferred != nil {
reason := convert.DeferralReasonFromProto(protoDeferred.Reason)
resp.Diagnostics = resp.Diagnostics.Append(providers.NewDeferralDiagnostic(reason))
return resp
}
for _, imported := range protoResp.ImportedResources {
resource := providers.ImportedResource{
@@ -839,11 +816,6 @@ func (p *GRPCProvider) ReadDataSource(ctx context.Context, r providers.ReadDataS
return resp
}
resp.Diagnostics = resp.Diagnostics.Append(convert.ProtoToDiagnostics(protoResp.Diagnostics))
if protoDeferred := protoResp.Deferred; protoDeferred != nil {
reason := convert.DeferralReasonFromProto(protoDeferred.Reason)
resp.Diagnostics = resp.Diagnostics.Append(providers.NewDeferralDiagnostic(reason))
return resp
}
state, err := decodeDynamicValue(protoResp.State, dataSchema.Block.ImpliedType())
if err != nil {
@@ -881,7 +853,10 @@ func (p *GRPCProvider) OpenEphemeralResource(ctx context.Context, r providers.Op
Config: &proto.DynamicValue{
Msgpack: config,
},
ClientCapabilities: clientCapabilities,
ClientCapabilities: &proto.ClientCapabilities{
DeferralAllowed: true,
WriteOnlyAttributesAllowed: clientCapabilities.WriteOnlyAttributesAllowed,
},
}
protoResp, err := p.client.OpenEphemeralResource(ctx, protoReq)

View File

@@ -14,7 +14,6 @@ import (
"testing"
"time"
"github.com/davecgh/go-spew/spew"
"github.com/google/go-cmp/cmp"
"github.com/zclconf/go-cty/cty"
"github.com/zclconf/go-cty/cty/msgpack"
@@ -826,52 +825,6 @@ func TestGRPCProvider_PlanResourceChange(t *testing.T) {
}
}
func TestGRPCProvider_PlanResourceChange_deferred(t *testing.T) {
client := mockProviderClient(t)
p := &GRPCProvider{
client: client,
}
client.EXPECT().PlanResourceChange(
gomock.Any(),
gomock.Any(),
).Return(&proto.PlanResourceChange_Response{
PlannedState: &proto.DynamicValue{
Msgpack: []byte("\x81\xa4attr\xa3bar"),
},
Deferred: &proto.Deferred{
Reason: proto.Deferred_PROVIDER_CONFIG_UNKNOWN,
},
}, nil)
resp := p.PlanResourceChange(t.Context(), providers.PlanResourceChangeRequest{
TypeName: "resource",
PriorState: cty.ObjectVal(map[string]cty.Value{
"attr": cty.StringVal("foo"),
}),
ProposedNewState: cty.ObjectVal(map[string]cty.Value{
"attr": cty.StringVal("bar"),
}),
Config: cty.ObjectVal(map[string]cty.Value{
"attr": cty.StringVal("bar"),
}),
})
if len(resp.Diagnostics) != 1 {
t.Fatal("wrong number of diagnostics; want one\n" + spew.Sdump(resp.Diagnostics))
}
desc := resp.Diagnostics[0].Description()
if got, want := desc.Summary, `Provider configuration is incomplete`; got != want {
t.Errorf("wrong error summary\ngot: %s\nwant: %s", got, want)
}
if got, want := desc.Detail, `The provider was unable to work with this resource because the associated provider configuration makes use of values from other resources that will not be known until after apply.`; got != want {
t.Errorf("wrong error detail\ngot: %s\nwant: %s", got, want)
}
if !providers.IsDeferralDiagnostic(resp.Diagnostics[0]) {
t.Errorf("diagnostic is not marked as being a \"deferral diagnostic\"")
}
}
func TestGRPCProvider_PlanResourceChangeJSON(t *testing.T) {
client := mockProviderClient(t)
p := &GRPCProvider{

View File

@@ -34,14 +34,6 @@ type GRPCProviderPlugin struct {
}
var clientCapabilities = &proto6.ClientCapabilities{
// DeferralAllowed tells the provider that it is allowed to respond to
// all of the various post-configuration requests (as described by the
// [providers.Configured] interface) by reporting that the request
// must be "deferred" because there isn't yet enough information to
// satisfy the request. Setting this means that we need to be prepared
// for there to be a "deferred" object in the response from various
// other provider RPC functions.
DeferralAllowed: true,
// WriteOnlyAttributesAllowed indicates that the current system version
// supports write-only attributes.
// This enables the SDK to run specific validations and enable the
@@ -491,11 +483,6 @@ func (p *GRPCProvider) ReadResource(ctx context.Context, r providers.ReadResourc
return resp
}
resp.Diagnostics = resp.Diagnostics.Append(convert.ProtoToDiagnostics(protoResp.Diagnostics))
if protoDeferred := protoResp.Deferred; protoDeferred != nil {
reason := convert.DeferralReasonFromProto(protoDeferred.Reason)
resp.Diagnostics = resp.Diagnostics.Append(providers.NewDeferralDiagnostic(reason))
return resp
}
state, err := decodeDynamicValue(protoResp.NewState, resSchema.Block.ImpliedType())
if err != nil {
@@ -578,11 +565,6 @@ func (p *GRPCProvider) PlanResourceChange(ctx context.Context, r providers.PlanR
return resp
}
resp.Diagnostics = resp.Diagnostics.Append(convert.ProtoToDiagnostics(protoResp.Diagnostics))
if protoDeferred := protoResp.Deferred; protoDeferred != nil {
reason := convert.DeferralReasonFromProto(protoDeferred.Reason)
resp.Diagnostics = resp.Diagnostics.Append(providers.NewDeferralDiagnostic(reason))
return resp
}
state, err := decodeDynamicValue(protoResp.PlannedState, resSchema.Block.ImpliedType())
if err != nil {
@@ -705,11 +687,6 @@ func (p *GRPCProvider) ImportResourceState(ctx context.Context, r providers.Impo
return resp
}
resp.Diagnostics = resp.Diagnostics.Append(convert.ProtoToDiagnostics(protoResp.Diagnostics))
if protoDeferred := protoResp.Deferred; protoDeferred != nil {
reason := convert.DeferralReasonFromProto(protoDeferred.Reason)
resp.Diagnostics = resp.Diagnostics.Append(providers.NewDeferralDiagnostic(reason))
return resp
}
for _, imported := range protoResp.ImportedResources {
resource := providers.ImportedResource{
@@ -828,11 +805,6 @@ func (p *GRPCProvider) ReadDataSource(ctx context.Context, r providers.ReadDataS
return resp
}
resp.Diagnostics = resp.Diagnostics.Append(convert.ProtoToDiagnostics(protoResp.Diagnostics))
if protoDeferred := protoResp.Deferred; protoDeferred != nil {
reason := convert.DeferralReasonFromProto(protoDeferred.Reason)
resp.Diagnostics = resp.Diagnostics.Append(providers.NewDeferralDiagnostic(reason))
return resp
}
state, err := decodeDynamicValue(protoResp.State, dataSchema.Block.ImpliedType())
if err != nil {
@@ -870,7 +842,10 @@ func (p *GRPCProvider) OpenEphemeralResource(ctx context.Context, r providers.Op
Config: &proto6.DynamicValue{
Msgpack: config,
},
ClientCapabilities: clientCapabilities,
ClientCapabilities: &proto6.ClientCapabilities{
DeferralAllowed: true,
WriteOnlyAttributesAllowed: clientCapabilities.WriteOnlyAttributesAllowed,
},
}
protoResp, err := p.client.OpenEphemeralResource(ctx, protoReq)

View File

@@ -833,52 +833,6 @@ func TestGRPCProvider_PlanResourceChange(t *testing.T) {
}
}
func TestGRPCProvider_PlanResourceChange_deferred(t *testing.T) {
client := mockProviderClient(t)
p := &GRPCProvider{
client: client,
}
client.EXPECT().PlanResourceChange(
gomock.Any(),
gomock.Any(),
).Return(&proto.PlanResourceChange_Response{
PlannedState: &proto.DynamicValue{
Msgpack: []byte("\x81\xa4attr\xa3bar"),
},
Deferred: &proto.Deferred{
Reason: proto.Deferred_PROVIDER_CONFIG_UNKNOWN,
},
}, nil)
resp := p.PlanResourceChange(t.Context(), providers.PlanResourceChangeRequest{
TypeName: "resource",
PriorState: cty.ObjectVal(map[string]cty.Value{
"attr": cty.StringVal("foo"),
}),
ProposedNewState: cty.ObjectVal(map[string]cty.Value{
"attr": cty.StringVal("bar"),
}),
Config: cty.ObjectVal(map[string]cty.Value{
"attr": cty.StringVal("bar"),
}),
})
if len(resp.Diagnostics) != 1 {
t.Fatal("wrong number of diagnostics; want one\n" + spew.Sdump(resp.Diagnostics))
}
desc := resp.Diagnostics[0].Description()
if got, want := desc.Summary, `Provider configuration is incomplete`; got != want {
t.Errorf("wrong error summary\ngot: %s\nwant: %s", got, want)
}
if got, want := desc.Detail, `The provider was unable to work with this resource because the associated provider configuration makes use of values from other resources that will not be known until after apply.`; got != want {
t.Errorf("wrong error detail\ngot: %s\nwant: %s", got, want)
}
if !providers.IsDeferralDiagnostic(resp.Diagnostics[0]) {
t.Errorf("diagnostic is not marked as being a \"deferral diagnostic\"")
}
}
func TestGRPCProvider_PlanResourceChangeJSON(t *testing.T) {
client := mockProviderClient(t)
p := &GRPCProvider{

View File

@@ -5064,97 +5064,6 @@ func TestContext2Plan_dataSourceReadPlanError(t *testing.T) {
}
}
func TestContext2Plan_providerDefersPlanning(t *testing.T) {
m := testModuleInline(t, map[string]string{
"main.tf": `
resource "test" "test" {
}
`,
})
tests := []struct {
DeferralReason providers.DeferralReason
WantDiagSummary, WantDiagDetail string
}{
{
DeferralReason: providers.DeferredBecauseProviderConfigUnknown,
WantDiagSummary: `Provider configuration is incomplete`,
WantDiagDetail: `The provider was unable to work with this resource because the associated provider configuration makes use of values from other resources that will not be known until after apply.
To work around this, use the planning option -exclude="test.test" to first apply without this object, and then apply normally to converge.`,
},
{
DeferralReason: providers.DeferredBecauseResourceConfigUnknown,
WantDiagSummary: `Resource configuration is incomplete`,
WantDiagDetail: `The provider was unable to act on this resource configuration because it makes use of values from other resources that will not be known until after apply.
To work around this, use the planning option -exclude="test.test" to first apply without this object, and then apply normally to converge.`,
},
{
// This one is currently a generic fallback message because it's
// unclear what this reason is intended to mean and no providers
// are using it yet at the time of writing.
DeferralReason: providers.DeferredBecausePrereqAbsent,
WantDiagSummary: `Operation cannot be completed yet`,
WantDiagDetail: `The provider reported that it is not able to perform the requested operation until more information is available.
To work around this, use the planning option -exclude="test.test" to first apply without this object, and then apply normally to converge.`,
},
{
// This special reason is the one we use if a provider returns
// a later-added reason that the current OpenTofu version doesn't
// know about.
DeferralReason: providers.DeferredReasonUnknown,
WantDiagSummary: `Operation cannot be completed yet`,
WantDiagDetail: `The provider reported that it is not able to perform the requested operation until more information is available.
To work around this, use the planning option -exclude="test.test" to first apply without this object, and then apply normally to converge.`,
},
}
for _, test := range tests {
t.Run(test.DeferralReason.String(), func(t *testing.T) {
provider := &MockProvider{
GetProviderSchemaResponse: &providers.GetProviderSchemaResponse{
ResourceTypes: map[string]providers.Schema{
"test": {
Block: &configschema.Block{},
},
},
},
PlanResourceChangeFn: func(req providers.PlanResourceChangeRequest) providers.PlanResourceChangeResponse {
var diags tfdiags.Diagnostics
diags = diags.Append(providers.NewDeferralDiagnostic(
test.DeferralReason,
))
return providers.PlanResourceChangeResponse{
Diagnostics: diags,
}
},
}
tofuCtx := testContext2(t, &ContextOpts{
Providers: map[addrs.Provider]providers.Factory{
addrs.NewDefaultProvider("test"): testProviderFuncFixed(provider),
},
})
_, diags := tofuCtx.Plan(t.Context(), m, states.NewState(), DefaultPlanOpts)
if !diags.HasErrors() {
t.Fatal("plan succeeded; want an error")
}
if len(diags) != 1 {
t.Fatal("wrong number of diagnostics; want one\n" + spew.Sdump(diags.ForRPC()))
}
desc := diags[0].Description()
if got, want := test.WantDiagSummary, desc.Summary; got != want {
t.Errorf("wrong error summary\ngot: %s\nwant: %s", got, want)
}
if got, want := test.WantDiagDetail, desc.Detail; got != want {
t.Errorf("wrong error detail\ngot: %s\nwant: %s", got, want)
}
})
}
}
func TestContext2Plan_ignoredMarkedValue(t *testing.T) {
m := testModuleInline(t, map[string]string{
"main.tf": `

View File

@@ -3257,63 +3257,6 @@ func (n *NodeAbstractResourceInstance) getProvider(ctx context.Context, evalCtx
return underlyingProvider, schema, err
}
func maybeImproveResourceInstanceDiagnostics(diags tfdiags.Diagnostics, excludeAddr addrs.Targetable) tfdiags.Diagnostics {
// We defer allocating a new diagnostics array until we know we need to
// change something, because most of the time we'll just be returning
// the given diagnostics verbatim.
var ret tfdiags.Diagnostics
for i, diag := range diags {
if excludeAddr != nil && providers.IsDeferralDiagnostic(diag) {
// We've found a diagnostic we want to change, so we'll allocate
// a new diagnostics array if we didn't already.
if ret == nil {
ret = make(tfdiags.Diagnostics, len(diags))
copy(ret, diags)
}
// FIXME: The following is a hack to slightly modify the diagnostic
// with an extra paragraph of detail content. If this becomes a
// more common need elsewhere then we should find a less clunky way
// to do this, probably with a new feature in tfdiags.
desc := diag.Description()
src := diag.Source()
extraDetail := fmt.Sprintf(
// FIXME: This should use a technique similar to evalchecks.commandLineArgumentsSuggestion
// to generate appropriate quoting/escaping of the address for the current platform.
"\n\nTo work around this, use the planning option -exclude=%q to first apply without this object, and then apply normally to converge.",
excludeAddr.String(),
)
newDiag := &hcl.Diagnostic{
Severity: diag.Severity().ToHCL(),
Summary: desc.Summary,
Detail: desc.Detail + extraDetail,
}
if src.Subject != nil {
newDiag.Subject = src.Subject.ToHCL().Ptr()
}
if src.Context != nil {
newDiag.Context = src.Context.ToHCL().Ptr()
}
// The following is a little awkward because of how tfdiags is
// designed: we need to "append" a new diagnostic over the
// one we're trying to replace so that tfdiags has an opportunity
// to transform it, so we'll make a zero-length slice whose
// capacity covers the one element we're trying to replace.
appendTo := ret[i : i : i+1]
appendTo = appendTo.Append(newDiag)
// appendTo.Append isn't _actually_ required to use the
// capacity we gave it (that's an implementation detail)
// so just to make sure we'll copy from what was returned
// into the final slot. This is likely to be a no-op in most
// cases.
ret[i] = appendTo[0]
}
}
if ret == nil { // We didn't change anything
return diags
}
return ret
}
func (n *NodeAbstractResourceInstance) applyEphemeralResource(ctx context.Context, evalCtx EvalContext) (*states.ResourceInstanceObject, instances.RepetitionData, tfdiags.Diagnostics) {
var diags tfdiags.Diagnostics
var keyData instances.RepetitionData

View File

@@ -15,9 +15,7 @@ import (
"github.com/opentofu/opentofu/internal/addrs"
"github.com/opentofu/opentofu/internal/configs"
"github.com/opentofu/opentofu/internal/configs/configschema"
"github.com/opentofu/opentofu/internal/providers"
"github.com/opentofu/opentofu/internal/states"
"github.com/opentofu/opentofu/internal/tfdiags"
)
func TestNodeAbstractResourceInstanceProvider(t *testing.T) {
@@ -268,72 +266,3 @@ func TestFilterResourceProvisioners(t *testing.T) {
})
}
}
func TestMaybeImproveResourceInstanceDiagnostics(t *testing.T) {
// This test is focused mainly on whether
// maybeImproveResourceInstanceDiagnostics is able to correctly identify
// deferral-related diagnostics and transform them, while keeping
// other unrelated diagnostics intact and unmodified.
// TestContext2Plan_providerDefersPlanning tests that the effect of
// this function is exposed externally when a provider's PlanResourceChange
// method returns a suitable diagnostic.
var input tfdiags.Diagnostics
input = input.Append(tfdiags.Sourceless(
tfdiags.Warning,
"This is not a deferral-related diagnostic",
"This one should not be modified at all.",
))
input = input.Append(providers.NewDeferralDiagnostic(providers.DeferredBecauseProviderConfigUnknown))
input = input.Append(tfdiags.Sourceless(
tfdiags.Error,
"This is not a deferral-related diagnostic",
"This one should not be modified at all.",
))
input = input.Append(providers.NewDeferralDiagnostic(providers.DeferredBecauseResourceConfigUnknown))
input = input.Append(tfdiags.Sourceless(
tfdiags.Error,
"This is not a deferral-related diagnostic either",
"Leave this one alone too.",
))
// We'll use ForRPC here just to make the diagnostics easier to compare,
// since we care primarily about their description test here.
got := maybeImproveResourceInstanceDiagnostics(input, mustAbsResourceAddr("foo.bar").Instance(addrs.IntKey(1))).ForRPC()
var want tfdiags.Diagnostics
want = want.Append(tfdiags.Sourceless(
tfdiags.Warning,
"This is not a deferral-related diagnostic",
"This one should not be modified at all.",
))
want = want.Append(tfdiags.Sourceless(
tfdiags.Error,
"Provider configuration is incomplete",
`The provider was unable to work with this resource because the associated provider configuration makes use of values from other resources that will not be known until after apply.
To work around this, use the planning option -exclude="foo.bar[1]" to first apply without this object, and then apply normally to converge.`,
))
want = want.Append(tfdiags.Sourceless(
tfdiags.Error,
"This is not a deferral-related diagnostic",
"This one should not be modified at all.",
))
want = want.Append(tfdiags.Sourceless(
tfdiags.Error,
"Resource configuration is incomplete",
`The provider was unable to act on this resource configuration because it makes use of values from other resources that will not be known until after apply.
To work around this, use the planning option -exclude="foo.bar[1]" to first apply without this object, and then apply normally to converge.`,
))
want = want.Append(tfdiags.Sourceless(
tfdiags.Error,
"This is not a deferral-related diagnostic either",
"Leave this one alone too.",
))
want = want.ForRPC()
if diff := cmp.Diff(want, got); diff != "" {
t.Error("wrong result\n" + diff)
}
}

View File

@@ -121,7 +121,7 @@ func (n *graphNodeImportState) Execute(ctx context.Context, evalCtx EvalContext,
TypeName: n.Addr.Resource.Resource.Type,
ID: n.ID,
})
diags = diags.Append(maybeImproveResourceInstanceDiagnostics(resp.Diagnostics, n.Addr))
diags = diags.Append(resp.Diagnostics)
if diags.HasErrors() {
return diags
}

View File

@@ -351,7 +351,7 @@ func (n *NodePlannableResourceInstance) managedResourceExecute(ctx context.Conte
// The import process handles its own refresh
if !n.skipRefresh && !importing {
s, refreshDiags := n.refresh(ctx, evalCtx, states.NotDeposed, instanceRefreshState)
diags = diags.Append(maybeImproveResourceInstanceDiagnostics(refreshDiags, addr))
diags = diags.Append(refreshDiags)
if diags.HasErrors() {
return diags
}
@@ -389,7 +389,7 @@ func (n *NodePlannableResourceInstance) managedResourceExecute(ctx context.Conte
change, instancePlanState, repeatData, planDiags := n.plan(
ctx, evalCtx, nil, instanceRefreshState, n.ForceCreateBeforeDestroy, n.forceReplace,
)
diags = diags.Append(maybeImproveResourceInstanceDiagnostics(planDiags, addr))
diags = diags.Append(planDiags)
if diags.HasErrors() {
// If we are importing and generating a configuration, we need to
// ensure the change is written out so the configuration can be

View File

@@ -111,7 +111,7 @@ func upgradeResourceStateTransform(args stateTransformArgs) (cty.Value, []byte,
}
resp := args.provider.UpgradeResourceState(context.TODO(), req)
diags := maybeImproveResourceInstanceDiagnostics(resp.Diagnostics, args.currentAddr)
diags := resp.Diagnostics
if diags.HasErrors() {
log.Printf("[TRACE] upgradeResourceStateTransform: failed - address: %s", args.currentAddr)
return cty.NilVal, nil, diags