Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions docs/content/publish-resources/technical-details.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <resource> <name> \
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.
Expand Down
63 changes: 63 additions & 0 deletions internal/sync/deletion_policy.go
Original file line number Diff line number Diff line change
@@ -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
}
}
94 changes: 94 additions & 0 deletions internal/sync/deletion_policy_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
1 change: 1 addition & 0 deletions internal/sync/metadata.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
8 changes: 6 additions & 2 deletions internal/sync/object_syncer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}
Expand Down
78 changes: 78 additions & 0 deletions internal/sync/object_syncer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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()

Expand Down
3 changes: 3 additions & 0 deletions internal/sync/syncer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
3 changes: 3 additions & 0 deletions internal/sync/syncer_related.go
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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,
Expand Down
10 changes: 6 additions & 4 deletions internal/sync/syncer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
Loading
Loading