From 27965d3071d468aa672fe833bf4767c3a68e1bd3 Mon Sep 17 00:00:00 2001 From: Amine HADRI Date: Thu, 16 Jul 2026 19:37:40 +0200 Subject: [PATCH] sync: support configurable deletion propagation Use an annotation on kcp source objects to select the propagation policy for deleting service-cluster copies. Keep background propagation as the default and resolve kcp-origin related resources from their own annotations. Signed-off-by: Amine HADRI --- .../publish-resources/technical-details.md | 13 + internal/sync/deletion_policy.go | 63 ++++ internal/sync/deletion_policy_test.go | 94 +++++ internal/sync/metadata.go | 1 + internal/sync/object_syncer.go | 8 +- internal/sync/object_syncer_test.go | 78 +++++ internal/sync/syncer.go | 3 + internal/sync/syncer_related.go | 3 + internal/sync/syncer_test.go | 10 +- test/e2e/sync/deletion_propagation_test.go | 330 ++++++++++++++++++ 10 files changed, 597 insertions(+), 6 deletions(-) create mode 100644 internal/sync/deletion_policy.go create mode 100644 internal/sync/deletion_policy_test.go create mode 100644 test/e2e/sync/deletion_propagation_test.go diff --git a/docs/content/publish-resources/technical-details.md b/docs/content/publish-resources/technical-details.md index 4c0a2e1d..04c673a6 100644 --- a/docs/content/publish-resources/technical-details.md +++ b/docs/content/publish-resources/technical-details.md @@ -58,6 +58,19 @@ is the only real evidence in the kcp side that the Sync Agent is even doing thin (source) object is deleted, the corresponding local object is deleted as well. Once the local object is gone, the finalizer is removed from the source object. +By default, the local object is deleted with background propagation. A different policy can be +selected by annotating the kcp object before deleting it: + +```bash +kubectl annotate \ + syncagent.kcp.io/deletion-propagation-policy=foreground +``` + +Supported values are `background`, `foreground`, and `orphan`. A missing or invalid value defaults +to `background`. The annotation controls deletion in the service cluster independently of the +policy used to delete the kcp object. For kcp-origin related resources, annotate each related object +with the policy to use for its local copy. + ### Phase 3: Ensure Object Existence We have a source object and now need to create the destination. This chart shows what's happening. diff --git a/internal/sync/deletion_policy.go b/internal/sync/deletion_policy.go new file mode 100644 index 00000000..86607620 --- /dev/null +++ b/internal/sync/deletion_policy.go @@ -0,0 +1,63 @@ +/* +Copyright 2026 The KCP Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package sync + +import ( + syncagentv1alpha1 "github.com/kcp-dev/api-syncagent/sdk/apis/syncagent/v1alpha1" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const ( + deletionPropagationPolicyAnnotation = "syncagent.kcp.io/deletion-propagation-policy" + deletionPropagationBackground = "background" + deletionPropagationForeground = "foreground" + deletionPropagationOrphan = "orphan" +) + +// deletionPropagationPolicy resolves the policy specified for the +// service-cluster copy. Missing or unsupported values mean background deletion. +func deletionPropagationPolicy(obj metav1.Object) metav1.DeletionPropagation { + switch obj.GetAnnotations()[deletionPropagationPolicyAnnotation] { + case deletionPropagationForeground: + return metav1.DeletePropagationForeground + case deletionPropagationOrphan: + return metav1.DeletePropagationOrphan + default: + return metav1.DeletePropagationBackground + } +} + +func relatedDeletionPropagationPolicy( + origin syncagentv1alpha1.RelatedResourceOrigin, + obj metav1.Object, +) metav1.DeletionPropagation { + if origin != syncagentv1alpha1.RelatedResourceOriginKcp { + return metav1.DeletePropagationBackground + } + + return deletionPropagationPolicy(obj) +} + +func normalizeDeletionPropagationPolicy(policy metav1.DeletionPropagation) metav1.DeletionPropagation { + switch policy { + case metav1.DeletePropagationForeground, metav1.DeletePropagationOrphan: + return policy + default: + return metav1.DeletePropagationBackground + } +} diff --git a/internal/sync/deletion_policy_test.go b/internal/sync/deletion_policy_test.go new file mode 100644 index 00000000..9bae7063 --- /dev/null +++ b/internal/sync/deletion_policy_test.go @@ -0,0 +1,94 @@ +/* +Copyright 2026 The KCP Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package sync + +import ( + "testing" + + syncagentv1alpha1 "github.com/kcp-dev/api-syncagent/sdk/apis/syncagent/v1alpha1" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +func TestDeletionPropagationPolicy(t *testing.T) { + testcases := []struct { + name string + policy string + expected metav1.DeletionPropagation + }{ + { + name: "foreground annotation", + policy: deletionPropagationForeground, + expected: metav1.DeletePropagationForeground, + }, + { + name: "orphan annotation", + policy: deletionPropagationOrphan, + expected: metav1.DeletePropagationOrphan, + }, + { + name: "background annotation", + policy: deletionPropagationBackground, + expected: metav1.DeletePropagationBackground, + }, + { + name: "missing annotation defaults to background", + expected: metav1.DeletePropagationBackground, + }, + { + name: "unknown annotation defaults to background", + policy: "unknown", + expected: metav1.DeletePropagationBackground, + }, + } + + for _, testcase := range testcases { + t.Run(testcase.name, func(t *testing.T) { + obj := &unstructured.Unstructured{} + if testcase.policy != "" { + obj.SetAnnotations(map[string]string{deletionPropagationPolicyAnnotation: testcase.policy}) + } + + got := deletionPropagationPolicy(obj) + if got != testcase.expected { + t.Fatalf("expected %q, got %q", testcase.expected, got) + } + }) + } +} + +func TestRelatedDeletionPropagationPolicy(t *testing.T) { + obj := &unstructured.Unstructured{} + obj.SetAnnotations(map[string]string{ + deletionPropagationPolicyAnnotation: deletionPropagationForeground, + }) + + if got := relatedDeletionPropagationPolicy( + syncagentv1alpha1.RelatedResourceOriginKcp, + obj, + ); got != metav1.DeletePropagationForeground { + t.Fatalf("expected kcp-origin related object to use foreground, got %q", got) + } + + if got := relatedDeletionPropagationPolicy( + syncagentv1alpha1.RelatedResourceOriginService, + obj, + ); got != metav1.DeletePropagationBackground { + t.Fatalf("expected service-origin related object to use background, got %q", got) + } +} diff --git a/internal/sync/metadata.go b/internal/sync/metadata.go index ca262cd4..88d6884e 100644 --- a/internal/sync/metadata.go +++ b/internal/sync/metadata.go @@ -112,6 +112,7 @@ func filterUnsyncableLabels(original labels.Set) labels.Set { var unsyncableAnnotations = sets.New( "kcp.io/cluster", "kubectl.kubernetes.io/last-applied-configuration", + deletionPropagationPolicyAnnotation, remoteObjectNamespaceAnnotation, remoteObjectNameAnnotation, remoteObjectWorkspacePathAnnotation, diff --git a/internal/sync/object_syncer.go b/internal/sync/object_syncer.go index cabbae03..52f26cc8 100644 --- a/internal/sync/object_syncer.go +++ b/internal/sync/object_syncer.go @@ -31,6 +31,7 @@ import ( corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" @@ -69,6 +70,8 @@ type objectSyncer struct { // being deleted; used to clean up related resources when the primary object // is being deleted. forceDelete bool + // deletionPropagationPolicy is used when deleting the destination object. + deletionPropagationPolicy metav1.DeletionPropagation // useServerSideApply switches the syncer from client-side merge patches // (backed by a last-known-state secret) to Kubernetes Server-Side Apply // using a stable field manager. SSA preserves fields owned by other @@ -596,8 +599,9 @@ func (s *objectSyncer) handleDeletion(ctx context.Context, log *zap.SugaredLogge if dest.object != nil { if dest.object.GetDeletionTimestamp() == nil { log.Debugw("Deleting destination object…", "dest-object", newObjectKey(dest.object, dest.clusterName, logicalcluster.None)) - s.recordEvent(ctx, source, dest, corev1.EventTypeNormal, "ObjectCleanup", "Object deletion has been started and will progress in the background.") - if err := dest.client.Delete(ctx, dest.object); err != nil { + s.recordEvent(ctx, source, dest, corev1.EventTypeNormal, "ObjectCleanup", "Object deletion has been started.") + if err := dest.client.Delete(ctx, dest.object, + ctrlruntimeclient.PropagationPolicy(normalizeDeletionPropagationPolicy(s.deletionPropagationPolicy))); err != nil { return false, fmt.Errorf("failed to delete destination object: %w", err) } } diff --git a/internal/sync/object_syncer_test.go b/internal/sync/object_syncer_test.go index 3fdb40d3..ab7895d0 100644 --- a/internal/sync/object_syncer_test.go +++ b/internal/sync/object_syncer_test.go @@ -24,8 +24,11 @@ import ( "github.com/kcp-dev/logicalcluster/v3" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/client-go/tools/record" + ctrlruntimeclient "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/interceptor" ) // fakeStateStore is a minimal ObjectStateStore for unit tests. @@ -55,6 +58,81 @@ func makeUnstructuredWithStatus(name, namespace string, status map[string]interf return obj } +func TestHandleDeletionUsesPropagationPolicy(t *testing.T) { + testcases := []struct { + name string + policy metav1.DeletionPropagation + expected metav1.DeletionPropagation + }{ + { + name: "foreground", + policy: metav1.DeletePropagationForeground, + expected: metav1.DeletePropagationForeground, + }, + { + name: "orphan", + policy: metav1.DeletePropagationOrphan, + expected: metav1.DeletePropagationOrphan, + }, + { + name: "background", + policy: metav1.DeletePropagationBackground, + expected: metav1.DeletePropagationBackground, + }, + { + name: "background by default", + expected: metav1.DeletePropagationBackground, + }, + } + + for _, testcase := range testcases { + t.Run(testcase.name, func(t *testing.T) { + destination := makeUnstructuredWithStatus("destination", "default", nil) + + var got *metav1.DeletionPropagation + destinationClient := newFakeClientBuilder(). + WithObjects(destination). + WithInterceptorFuncs(interceptor.Funcs{ + Delete: func(ctx context.Context, client ctrlruntimeclient.WithWatch, obj ctrlruntimeclient.Object, options ...ctrlruntimeclient.DeleteOption) error { + deleteOptions := &ctrlruntimeclient.DeleteOptions{} + for _, option := range options { + option.ApplyToDelete(deleteOptions) + } + got = deleteOptions.PropagationPolicy + return client.Delete(ctx, obj, options...) + }, + }). + Build() + + source := makeUnstructuredWithStatus("source", "default", nil) + source.SetFinalizers([]string{deletionFinalizer}) + now := metav1.Now() + source.SetDeletionTimestamp(&now) + + syncer := objectSyncer{ + blockSourceDeletion: true, + deletionPropagationPolicy: testcase.policy, + eventObjSide: syncSideSource, + } + ctx := WithEventRecorder(t.Context(), record.NewFakeRecorder(1)) + + requeue, err := syncer.handleDeletion(ctx, zap.NewNop().Sugar(), syncSide{object: source}, syncSide{ + client: destinationClient, + object: destination, + }) + if err != nil { + t.Fatalf("handleDeletion returned an error: %v", err) + } + if !requeue { + t.Fatal("handleDeletion did not request a requeue") + } + if got == nil || *got != testcase.expected { + t.Fatalf("expected %q propagation policy, got %v", testcase.expected, got) + } + }) + } +} + func TestSyncObjectStatusForward(t *testing.T) { log := zap.NewNop().Sugar() diff --git a/internal/sync/syncer.go b/internal/sync/syncer.go index 0c51207a..19cc6c83 100644 --- a/internal/sync/syncer.go +++ b/internal/sync/syncer.go @@ -191,6 +191,7 @@ func (s *ResourceSyncer) Process(ctx context.Context, remoteObj *unstructured.Un // object state; this allows the code to create meaningful patches and not overwrite // fields that were defaulted by the kube-apiserver or a mutating webhook stateStore := s.newObjectStateStore(sourceSide, destSide) + deletionPolicy := deletionPropagationPolicy(remoteObj) syncer := objectSyncer{ // The primary object should be labelled with the agent name. @@ -205,6 +206,8 @@ func (s *ResourceSyncer) Process(ctx context.Context, remoteObj *unstructured.Un // perform cleanup on the service cluster side when the source object // in kcp is deleted blockSourceDeletion: true, + // Apply the service-cluster deletion policy specified on the kcp source. + deletionPropagationPolicy: deletionPolicy, // use the configured mutations from the PublishedResource mutator: s.primaryMutator, // make sure the syncer can remember the current state of any object diff --git a/internal/sync/syncer_related.go b/internal/sync/syncer_related.go index 84a059ee..f8d961f1 100644 --- a/internal/sync/syncer_related.go +++ b/internal/sync/syncer_related.go @@ -132,6 +132,7 @@ func (s *ResourceSyncer) processRelatedResource(ctx context.Context, log *zap.Su // have a useless copy once the main object has been cleared up) OR the admin explicitly opted // into the cleanup procedure to ensure that copies of the related object are removed. forceDelete := primaryDeleting && (relRes.Cleanup || relRes.Origin == syncagentv1alpha1.RelatedResourceOriginKcp) + deletionPolicy := relatedDeletionPropagationPolicy(relRes.Origin, resolved.original) // When status sync is enabled, include "status" in subresources so it is stripped from // the spec patch (avoiding a no-op write on resources that have a status subresource). @@ -173,6 +174,8 @@ func (s *ResourceSyncer) processRelatedResource(ctx context.Context, log *zap.Su eventObjSide: eventObjSide, // force deletion of related resources when the primary object is being deleted forceDelete: forceDelete, + // Kcp-origin objects can request how their service-cluster copies are deleted. + deletionPropagationPolicy: deletionPolicy, // propagate the SSA mode chosen for the primary syncer to keep // behavior consistent across the whole resource graph useServerSideApply: s.useServerSideApply, diff --git a/internal/sync/syncer_test.go b/internal/sync/syncer_test.go index 75055e81..78495f0e 100644 --- a/internal/sync/syncer_test.go +++ b/internal/sync/syncer_test.go @@ -501,8 +501,9 @@ func TestSyncerProcessingSingleResourceWithoutStatus(t *testing.T) { deletionFinalizer, }, Annotations: map[string]string{ - "existing-annotation": "new-annotation-value", - "new-annotation": "hei-verden", + deletionPropagationPolicyAnnotation: deletionPropagationForeground, + "existing-annotation": "new-annotation-value", + "new-annotation": "hei-verden", }, Labels: map[string]string{ remoteObjectClusterLabel: "this-should-be-ignored", @@ -542,8 +543,9 @@ func TestSyncerProcessingSingleResourceWithoutStatus(t *testing.T) { }, // syncer does not strip remote objects of bad metadata, so it remains Annotations: map[string]string{ - "existing-annotation": "new-annotation-value", - "new-annotation": "hei-verden", + deletionPropagationPolicyAnnotation: deletionPropagationForeground, + "existing-annotation": "new-annotation-value", + "new-annotation": "hei-verden", }, Labels: map[string]string{ remoteObjectClusterLabel: "this-should-be-ignored", diff --git a/test/e2e/sync/deletion_propagation_test.go b/test/e2e/sync/deletion_propagation_test.go new file mode 100644 index 00000000..1dd09547 --- /dev/null +++ b/test/e2e/sync/deletion_propagation_test.go @@ -0,0 +1,330 @@ +//go:build e2e + +/* +Copyright 2026 The KCP Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package sync + +import ( + "context" + "testing" + "time" + + "github.com/go-logr/logr" + + syncagentv1alpha1 "github.com/kcp-dev/api-syncagent/sdk/apis/syncagent/v1alpha1" + "github.com/kcp-dev/api-syncagent/test/utils" + + "github.com/kcp-dev/logicalcluster/v3" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/wait" + ctrlruntime "sigs.k8s.io/controller-runtime" + ctrlruntimeclient "sigs.k8s.io/controller-runtime/pkg/client" +) + +const ( + deletionPropagationTestAnnotation = "syncagent.kcp.io/deletion-propagation-policy" + deletionPropagationTestForeground = "foreground" + deletionPropagationTestOrphan = "orphan" +) + +func TestPrimaryDeletionPropagationPolicy(t *testing.T) { + ctx := t.Context() + ctrlruntime.SetLogger(logr.Discard()) + + const ( + apiExportName = "kcp.example.com" + orgWorkspace = "primary-deletion-propagation" + ) + orgKubconfig := utils.CreateOrganization(t, ctx, orgWorkspace, apiExportName) + envtestKubeconfig, envtestClient, _ := utils.RunEnvtest(t, []string{"test/crds/crontab.yaml"}) + + if err := envtestClient.Create(ctx, deletionPropagationPublishedResource(false)); err != nil { + t.Fatalf("Failed to create PublishedResource: %v", err) + } + + utils.RunAgent(ctx, t, "bob", orgKubconfig, envtestKubeconfig, apiExportName, "") + teamClient := deletionPropagationTeamClient(t, ctx, orgWorkspace) + + crontab := deletionPropagationCronTab("primary") + if err := teamClient.Create(ctx, crontab); err != nil { + t.Fatalf("Failed to create CronTab: %v", err) + } + + serviceCrontab := waitForServiceCronTab(t, ctx, envtestClient, crontab.GetName()) + setDeletionPropagationAnnotation(t, ctx, teamClient, crontab, deletionPropagationTestForeground) + + if err := teamClient.Delete( + ctx, + crontab, + ctrlruntimeclient.PropagationPolicy(metav1.DeletePropagationBackground), + ); err != nil { + t.Fatalf("Failed to delete CronTab: %v", err) + } + + waitForForegroundDeletion( + t, + ctx, + envtestClient, + serviceCrontab, + ) +} + +func TestKcpRelatedCleanupUsesItsOwnDeletionPropagationPolicy(t *testing.T) { + ctx := t.Context() + ctrlruntime.SetLogger(logr.Discard()) + + const ( + apiExportName = "kcp.example.com" + orgWorkspace = "related-deletion-propagation" + ) + orgKubconfig := utils.CreateOrganization(t, ctx, orgWorkspace, apiExportName) + envtestKubeconfig, envtestClient, _ := utils.RunEnvtest(t, []string{"test/crds/crontab.yaml"}) + + if err := envtestClient.Create(ctx, deletionPropagationPublishedResource(true)); err != nil { + t.Fatalf("Failed to create PublishedResource: %v", err) + } + + utils.RunAgent(ctx, t, "bob", orgKubconfig, envtestKubeconfig, apiExportName, "") + teamClient := deletionPropagationTeamClient(t, ctx, orgWorkspace) + + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "credentials", + Namespace: "default", + }, + StringData: map[string]string{"password": "hunter2"}, + } + if err := teamClient.Create(ctx, secret); err != nil { + t.Fatalf("Failed to create related Secret: %v", err) + } + + crontab := deletionPropagationCronTab("primary") + if err := teamClient.Create(ctx, crontab); err != nil { + t.Fatalf("Failed to create CronTab: %v", err) + } + + waitForServiceCronTab(t, ctx, envtestClient, crontab.GetName()) + serviceSecret := waitForServiceSecret(t, ctx, envtestClient, secret.GetName()) + + setDeletionPropagationAnnotation(t, ctx, teamClient, secret, deletionPropagationTestForeground) + setDeletionPropagationAnnotation(t, ctx, teamClient, crontab, deletionPropagationTestOrphan) + + if err := teamClient.Delete( + ctx, + crontab, + ctrlruntimeclient.PropagationPolicy(metav1.DeletePropagationBackground), + ); err != nil { + t.Fatalf("Failed to delete CronTab: %v", err) + } + + // Related cleanup uses the annotation stored on the related kcp source. + waitForForegroundDeletion( + t, + ctx, + envtestClient, + serviceSecret, + ) +} + +func deletionPropagationPublishedResource(withRelatedSecret bool) *syncagentv1alpha1.PublishedResource { + pr := &syncagentv1alpha1.PublishedResource{ + ObjectMeta: metav1.ObjectMeta{Name: "publish-crontabs"}, + Spec: syncagentv1alpha1.PublishedResourceSpec{ + Resource: syncagentv1alpha1.SourceResourceDescriptor{ + APIGroup: "example.com", + Version: "v1", + Kind: "CronTab", + }, + Naming: &syncagentv1alpha1.ResourceNaming{ + Name: "{{ .Object.metadata.name }}", + Namespace: "synced-{{ .Object.metadata.namespace }}", + }, + Projection: &syncagentv1alpha1.ResourceProjection{Group: "kcp.example.com"}, + }, + } + + if withRelatedSecret { + pr.Spec.Related = []syncagentv1alpha1.RelatedResourceSpec{ + { + Identifier: "credentials", + Origin: syncagentv1alpha1.RelatedResourceOriginKcp, + Kind: "Secret", + Object: syncagentv1alpha1.RelatedResourceObject{ + RelatedResourceObjectSpec: syncagentv1alpha1.RelatedResourceObjectSpec{ + Template: &syncagentv1alpha1.TemplateExpression{Template: "credentials"}, + }, + }, + }, + } + } + + return pr +} + +func deletionPropagationTeamClient( + t *testing.T, + ctx context.Context, + orgWorkspace string, +) ctrlruntimeclient.Client { + t.Helper() + + teamClient := utils.GetKcpAdminClusterClient(t).Cluster( + logicalcluster.NewPath("root").Join(orgWorkspace).Join("team-1"), + ) + utils.WaitForBoundAPI(t, ctx, teamClient, schema.GroupVersionKind{ + Group: "kcp.example.com", Version: "v1", Kind: "CronTab", + }) + + return teamClient +} + +func deletionPropagationCronTab(name string) *unstructured.Unstructured { + crontab := &unstructured.Unstructured{} + crontab.SetAPIVersion("kcp.example.com/v1") + crontab.SetKind("CronTab") + crontab.SetNamespace("default") + crontab.SetName(name) + crontab.Object["spec"] = map[string]any{"cronSpec": "* * *"} + + return crontab +} + +func setDeletionPropagationAnnotation( + t *testing.T, + ctx context.Context, + client ctrlruntimeclient.Client, + obj ctrlruntimeclient.Object, + policy string, +) { + t.Helper() + + current := obj.DeepCopyObject().(ctrlruntimeclient.Object) + if err := client.Get(ctx, ctrlruntimeclient.ObjectKeyFromObject(obj), current); err != nil { + t.Fatalf("Failed to get object before annotating it: %v", err) + } + annotations := current.GetAnnotations() + if annotations == nil { + annotations = map[string]string{} + } + annotations[deletionPropagationTestAnnotation] = policy + current.SetAnnotations(annotations) + if err := client.Update(ctx, current); err != nil { + t.Fatalf("Failed to set deletion propagation annotation: %v", err) + } +} + +func waitForServiceCronTab( + t *testing.T, + ctx context.Context, + client ctrlruntimeclient.Client, + name string, +) *unstructured.Unstructured { + t.Helper() + + crontab := &unstructured.Unstructured{} + crontab.SetAPIVersion("example.com/v1") + crontab.SetKind("CronTab") + if err := wait.PollUntilContextTimeout( + ctx, + 500*time.Millisecond, + 30*time.Second, + false, + func(ctx context.Context) (bool, error) { + return client.Get( + ctx, + types.NamespacedName{Namespace: "synced-default", Name: name}, + crontab, + ) == nil, nil + }, + ); err != nil { + t.Fatalf("CronTab was not synced to the service cluster: %v", err) + } + + return crontab +} + +func waitForServiceSecret( + t *testing.T, + ctx context.Context, + client ctrlruntimeclient.Client, + name string, +) *corev1.Secret { + t.Helper() + + secret := &corev1.Secret{} + if err := wait.PollUntilContextTimeout( + ctx, + 500*time.Millisecond, + 30*time.Second, + false, + func(ctx context.Context) (bool, error) { + return client.Get( + ctx, + types.NamespacedName{Namespace: "synced-default", Name: name}, + secret, + ) == nil, nil + }, + ); err != nil { + t.Fatalf("related Secret was not synced to the service cluster: %v", err) + } + + return secret +} + +func waitForForegroundDeletion( + t *testing.T, + ctx context.Context, + client ctrlruntimeclient.Client, + obj ctrlruntimeclient.Object, +) { + t.Helper() + + if err := wait.PollUntilContextTimeout( + ctx, + 500*time.Millisecond, + 30*time.Second, + false, + func(ctx context.Context) (bool, error) { + current := obj.DeepCopyObject().(ctrlruntimeclient.Object) + if err := client.Get(ctx, ctrlruntimeclient.ObjectKeyFromObject(obj), current); err != nil { + return false, nil + } + if current.GetDeletionTimestamp() == nil { + return false, nil + } + + hasForegroundFinalizer := false + for _, finalizer := range current.GetFinalizers() { + if finalizer == metav1.FinalizerDeleteDependents { + hasForegroundFinalizer = true + } + if finalizer == metav1.FinalizerOrphanDependents { + return false, nil + } + } + + return hasForegroundFinalizer, nil + }, + ); err != nil { + t.Fatalf("Destination object did not enter foreground deletion: %v", err) + } +}