From ac46a0d6877eb2236b565ba37bb3a7a9f9e72d64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabien=20Castar=C3=A8de?= Date: Thu, 18 Dec 2025 12:42:30 +0100 Subject: [PATCH 1/4] Fix duplicate resource creation after pod restart for async resources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a provider pod restarts, the ephemeral Terraform workspace state stored in /tmp// is lost. For resources with UseAsync=true, this causes the Refresh operation to fail to detect existing resources, triggering duplicate resource creation. This fix detects async resources that were previously created by checking for the crossplane.io/external-create-succeeded annotation and uses Import instead of Refresh. Import reconstructs state directly from the cloud provider API, avoiding the duplicate creation issue. Signed-off-by: Fabien Castarède --- pkg/controller/external.go | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/pkg/controller/external.go b/pkg/controller/external.go index e2af1520..eb5047cc 100644 --- a/pkg/controller/external.go +++ b/pkg/controller/external.go @@ -215,6 +215,19 @@ func (e *external) Observe(ctx context.Context, mg xpresource.Managed) (managed. return e.Import(ctx, tr) } + // For async resources that were previously created, use Import instead + // of Refresh if the resource has been successfully created before. + // This prevents duplicate resource creation after provider pod restarts + // when the ephemeral workspace state in /tmp is lost. + // The external-create-succeeded annotation persists in Kubernetes and + // indicates the resource was successfully created or imported previously. + if e.config.UseAsync && meta.GetExternalName(tr) != "" { + annotations := tr.GetAnnotations() + if _, hasCreateSucceeded := annotations["crossplane.io/external-create-succeeded"]; hasCreateSucceeded { + return e.Import(ctx, tr) + } + } + res, err := e.workspace.Refresh(ctx) if err != nil { return managed.ExternalObservation{}, errors.Wrap(err, errRefresh) From 1b46234128c24726dfd64add184492fa73944066 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabien=20Castar=C3=A8de?= Date: Thu, 18 Dec 2025 15:49:23 +0100 Subject: [PATCH 2/4] Add debug logging to async resource Import fallback logic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fabien Castarède --- pkg/controller/external.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pkg/controller/external.go b/pkg/controller/external.go index eb5047cc..1266b1c3 100644 --- a/pkg/controller/external.go +++ b/pkg/controller/external.go @@ -224,8 +224,12 @@ func (e *external) Observe(ctx context.Context, mg xpresource.Managed) (managed. if e.config.UseAsync && meta.GetExternalName(tr) != "" { annotations := tr.GetAnnotations() if _, hasCreateSucceeded := annotations["crossplane.io/external-create-succeeded"]; hasCreateSucceeded { + e.logger.Debug("Using Import instead of Refresh for async resource with external-create-succeeded annotation", "external-name", meta.GetExternalName(tr)) return e.Import(ctx, tr) } + e.logger.Debug("Async resource missing external-create-succeeded annotation, using Refresh", "external-name", meta.GetExternalName(tr), "annotations", annotations) + } else { + e.logger.Debug("Not using Import fallback", "useAsync", e.config.UseAsync, "externalName", meta.GetExternalName(tr)) } res, err := e.workspace.Refresh(ctx) From 75fce1e209f9eddf2d9c5240753a5d2fb8c49e7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabien=20Castar=C3=A8de?= Date: Thu, 18 Dec 2025 15:55:52 +0100 Subject: [PATCH 3/4] Add missing meta package import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fabien Castarède --- pkg/controller/external.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/controller/external.go b/pkg/controller/external.go index 1266b1c3..50f86b39 100644 --- a/pkg/controller/external.go +++ b/pkg/controller/external.go @@ -10,6 +10,7 @@ import ( xpv1 "github.com/crossplane/crossplane-runtime/v2/apis/common/v1" "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + "github.com/crossplane/crossplane-runtime/v2/pkg/meta" "github.com/crossplane/crossplane-runtime/v2/pkg/reconciler/managed" xpresource "github.com/crossplane/crossplane-runtime/v2/pkg/resource" "github.com/pkg/errors" From d900f98d42dc902dbc24a3f2336d1ab0024d5127 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabien=20Castar=C3=A8de?= Date: Tue, 31 Mar 2026 09:52:53 +0200 Subject: [PATCH 4/4] Fix Import to check for drift using Plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Import function was always returning ResourceUpToDate: true without checking if the desired state matches the actual state. This caused updates to resources to not be detected after they were successfully created and the import fallback was used. Now Import calls workspace.Plan() to properly detect drift, just like the normal Refresh flow does. Signed-off-by: Fabien Castarède --- pkg/controller/external.go | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/pkg/controller/external.go b/pkg/controller/external.go index 50f86b39..13d966e7 100644 --- a/pkg/controller/external.go +++ b/pkg/controller/external.go @@ -521,9 +521,19 @@ func (e *external) Import(ctx context.Context, tr resource.Terraformed) (managed } tr.SetConditions(xpv1.Available()) + + // Check for drift by running terraform plan after import + plan, err := e.workspace.Plan(ctx) + if err != nil { + return managed.ExternalObservation{}, errors.Wrap(err, errPlan) + } + + resource.SetUpToDateCondition(tr, plan.UpToDate) + e.logger.Debug("Called plan after import", "upToDate", plan.UpToDate, "external-name", meta.GetExternalName(tr)) + return managed.ExternalObservation{ ResourceExists: true, - ResourceUpToDate: true, + ResourceUpToDate: plan.UpToDate, ConnectionDetails: conn, }, nil }