diff --git a/pkg/controller/external_async_tfpluginfw.go b/pkg/controller/external_async_tfpluginfw.go index b889c77bf..d2ce20248 100644 --- a/pkg/controller/external_async_tfpluginfw.go +++ b/pkg/controller/external_async_tfpluginfw.go @@ -208,7 +208,7 @@ func (n *terraformPluginFrameworkAsyncExternalClient) Create(_ context.Context, return managed.ExternalCreation{}, errors.Errorf("%s operation that started at %s is still running", n.opTracker.LastOperation.Type, n.opTracker.LastOperation.StartTime().String()) } - ctx, cancel := context.WithDeadline(context.Background(), n.opTracker.LastOperation.StartTime().Add(defaultAsyncTimeout)) + ctx, cancel := context.WithDeadline(context.Background(), n.opTracker.LastOperation.StartTime().Add(asyncTimeout(n.config, "create"))) // We deep-copy the managed resource to prevent a data race between the // goroutine we are about to start below and the managed reconciler. // Please see: https://github.com/crossplane/upjet/issues/472 @@ -254,7 +254,7 @@ func (n *terraformPluginFrameworkAsyncExternalClient) Update(_ context.Context, return managed.ExternalUpdate{}, errors.Errorf("%s operation that started at %s is still running", n.opTracker.LastOperation.Type, n.opTracker.LastOperation.StartTime().String()) } - ctx, cancel := context.WithDeadline(context.Background(), n.opTracker.LastOperation.StartTime().Add(defaultAsyncTimeout)) + ctx, cancel := context.WithDeadline(context.Background(), n.opTracker.LastOperation.StartTime().Add(asyncTimeout(n.config, "update"))) // We deep-copy the managed resource to prevent a data race between the // goroutine we are about to start below and the managed reconciler. // Please see: https://github.com/crossplane/upjet/issues/472 @@ -304,7 +304,7 @@ func (n *terraformPluginFrameworkAsyncExternalClient) Delete(_ context.Context, return managed.ExternalDelete{}, errors.Errorf("%s operation that started at %s is still running", n.opTracker.LastOperation.Type, n.opTracker.LastOperation.StartTime().String()) } - ctx, cancel := context.WithDeadline(context.Background(), n.opTracker.LastOperation.StartTime().Add(defaultAsyncTimeout)) + ctx, cancel := context.WithDeadline(context.Background(), n.opTracker.LastOperation.StartTime().Add(asyncTimeout(n.config, "delete"))) go func() { // The order of deferred functions, executed last-in-first-out, is // significant. The context should be canceled last, because it is diff --git a/pkg/controller/external_async_tfpluginsdk.go b/pkg/controller/external_async_tfpluginsdk.go index d49f33e1f..567f856ef 100644 --- a/pkg/controller/external_async_tfpluginsdk.go +++ b/pkg/controller/external_async_tfpluginsdk.go @@ -27,6 +27,29 @@ import ( var defaultAsyncTimeout = 1 * time.Hour +// asyncTimeout returns the deadline duration for the given async operation +// ("create", "update" or "delete"). It returns the corresponding +// config.Resource.OperationTimeouts value if configured (non-zero), +// and defaultAsyncTimeout otherwise. +func asyncTimeout(cfg *config.Resource, op string) time.Duration { + if cfg == nil { + return defaultAsyncTimeout + } + var d time.Duration + switch op { + case "create": + d = cfg.OperationTimeouts.Create + case "update": + d = cfg.OperationTimeouts.Update + case "delete": + d = cfg.OperationTimeouts.Delete + } + if d <= 0 { + return defaultAsyncTimeout + } + return d +} + // TerraformPluginSDKAsyncConnector is a managed reconciler Connecter // implementation for reconciling Terraform plugin SDK v2 based // resources. @@ -142,7 +165,7 @@ func (n *terraformPluginSDKAsyncExternal) Create(_ context.Context, mg xpresourc return managed.ExternalCreation{}, errors.Errorf("%s operation that started at %s is still running", n.opTracker.LastOperation.Type, n.opTracker.LastOperation.StartTime().String()) } - ctx, cancel := context.WithDeadline(context.Background(), n.opTracker.LastOperation.StartTime().Add(defaultAsyncTimeout)) + ctx, cancel := context.WithDeadline(context.Background(), n.opTracker.LastOperation.StartTime().Add(asyncTimeout(n.config, "create"))) // We deep-copy the managed resource to prevent a data race between the // goroutine we are about to start below and the managed reconciler. // Please see: https://github.com/crossplane/upjet/issues/472 @@ -188,7 +211,7 @@ func (n *terraformPluginSDKAsyncExternal) Update(_ context.Context, mg xpresourc return managed.ExternalUpdate{}, errors.Errorf("%s operation that started at %s is still running", n.opTracker.LastOperation.Type, n.opTracker.LastOperation.StartTime().String()) } - ctx, cancel := context.WithDeadline(context.Background(), n.opTracker.LastOperation.StartTime().Add(defaultAsyncTimeout)) + ctx, cancel := context.WithDeadline(context.Background(), n.opTracker.LastOperation.StartTime().Add(asyncTimeout(n.config, "update"))) // We deep-copy the managed resource to prevent a data race between the // goroutine we are about to start below and the managed reconciler. // Please see: https://github.com/crossplane/upjet/issues/472 @@ -238,7 +261,7 @@ func (n *terraformPluginSDKAsyncExternal) Delete(_ context.Context, mg xpresourc return managed.ExternalDelete{}, errors.Errorf("%s operation that started at %s is still running", n.opTracker.LastOperation.Type, n.opTracker.LastOperation.StartTime().String()) } - ctx, cancel := context.WithDeadline(context.Background(), n.opTracker.LastOperation.StartTime().Add(defaultAsyncTimeout)) + ctx, cancel := context.WithDeadline(context.Background(), n.opTracker.LastOperation.StartTime().Add(asyncTimeout(n.config, "delete"))) go func() { // The order of deferred functions, executed last-in-first-out, is // significant. The context should be canceled last, because it is diff --git a/pkg/controller/external_async_timeout_test.go b/pkg/controller/external_async_timeout_test.go new file mode 100644 index 000000000..ce4f6104c --- /dev/null +++ b/pkg/controller/external_async_timeout_test.go @@ -0,0 +1,37 @@ +// SPDX-FileCopyrightText: 2026 The Crossplane Authors +// +// SPDX-License-Identifier: Apache-2.0 + +package controller + +import ( + "testing" + "time" + + "github.com/crossplane/upjet/v2/pkg/config" +) + +func TestAsyncTimeout(t *testing.T) { + cfg := &config.Resource{OperationTimeouts: config.OperationTimeouts{ + Create: 3 * time.Hour, + Delete: 2 * time.Hour, + }} + cases := map[string]struct { + cfg *config.Resource + op string + want time.Duration + }{ + "NilConfig": {cfg: nil, op: "create", want: defaultAsyncTimeout}, + "CreateConfigured": {cfg: cfg, op: "create", want: 3 * time.Hour}, + "DeleteConfigured": {cfg: cfg, op: "delete", want: 2 * time.Hour}, + "UpdateUnset": {cfg: cfg, op: "update", want: defaultAsyncTimeout}, + "UnknownOp": {cfg: cfg, op: "read", want: defaultAsyncTimeout}, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + if got := asyncTimeout(tc.cfg, tc.op); got != tc.want { + t.Errorf("asyncTimeout(%q) = %v, want %v", tc.op, got, tc.want) + } + }) + } +}