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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@
.DS_Store
/docs/__pycache__
.idea/
temp/
31 changes: 30 additions & 1 deletion deploy/crd/kcp.io/syncagent.kcp.io_publishedresources.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -387,7 +387,26 @@ spec:
the destination side (i.e. the original related object will not be touched, regardless of this
option). Leaving this disabled, the syncagent will only create copies of the related objects,
but never delete them itself.

Deprecated: use CleanupPolicy instead. When CleanupPolicy is empty, cleanup:true is treated
as OnPrimaryDeletion and cleanup:false as Orphan.
type: boolean
cleanupPolicy:
description: |-
CleanupPolicy controls when the syncagent deletes destination copies of this related
resource. The original object on the origin side is never deleted.

- Orphan (default): copies are never deleted by the agent.
- OnPrimaryDeletion: copies are deleted only when the primary object is deleted.
- MatchOrigin: copies are pruned as soon as their origin object no longer exists, and
all copies are deleted when the primary object is deleted.

When left empty, the deprecated Cleanup field is used to derive the effective policy.
enum:
- Orphan
- OnPrimaryDeletion
- MatchOrigin
type: string
group:
description: |-
Group is the API group of the related resource. This should be left blank for resources
Expand All @@ -398,7 +417,13 @@ spec:
Identifier is a unique name for this related resource. The name must be unique within one
PublishedResource and is the key by which consumers (end users) can identify and consume the
related resource. Common names are "connection-details" or "credentials".
The identifier must be an alphanumeric string.

The identifier is used verbatim as a label value on the synced copies of the related resource,
so it must be a valid label value: a lowercase RFC 1123 label consisting of lowercase
alphanumeric characters or '-', starting and ending with an alphanumeric character, and at
most 63 characters long.
maxLength: 63
pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$
type: string
identityHash:
description: |-
Expand Down Expand Up @@ -878,6 +903,10 @@ spec:
rule: '!has(self.identityHash) || (has(self.group) && has(self.version) && has(self.resource))'
- message: watch must be configured when origin is service and syncStatus is true
rule: '!(self.origin == ''service'' && has(self.syncStatus) && self.syncStatus) || has(self.watch)'
- message: watch must be configured when cleanupPolicy is MatchOrigin and origin is service
rule: '!(has(self.cleanupPolicy) && self.cleanupPolicy == ''MatchOrigin'' && self.origin == ''service'') || has(self.watch)'
- message: 'cleanup:true conflicts with cleanupPolicy: Orphan'
rule: '!(has(self.cleanup) && self.cleanup && has(self.cleanupPolicy) && self.cleanupPolicy == ''Orphan'')'
type: array
resource:
description: |-
Expand Down
55 changes: 55 additions & 0 deletions docs/content/publish-resources/related-resources.md
Original file line number Diff line number Diff line change
Expand Up @@ -571,3 +571,58 @@ individually. For each namespace it will again follow the configured source, may
template or reference. If again a label selector is used, it will be applied in each namespace and
the configured rewrite rule will be evaluated once per found object. In this case, `.Value` is the
name of found object.

## Cleanup

By default the agent only ever _creates_ copies of related resources; it never deletes them. This
is intentional: copies can be useful as audit trails, may have decoupled lifecycles, or may be owned
by the service provider. The behavior is controlled per related resource via the `cleanupPolicy`
field, which takes one of three values:

- `Orphan` (default): copies are never deleted by the agent.
- `OnPrimaryDeletion`: copies are deleted only when the primary object is deleted.
- `MatchOrigin`: the destination set is kept equal to the origin set — a copy is pruned as soon as
its origin object no longer exists, and all copies are deleted when the primary object is deleted.

**The original object on the origin side is never deleted, under any policy.** The agent only ever
deletes destination copies that it created itself (they carry provenance labels identifying the
owning primary object); hand-created objects are never touched.

`MatchOrigin` prunes copies while the agent reconciles the primary object. For an `origin: service`
related resource this means a `watch` must be configured, otherwise a deleted source object would
only be noticed on the next incidental reconcile of the primary. The CRD enforces this: setting
`cleanupPolicy: MatchOrigin` together with `origin: service` requires a `watch` block.

```yaml
apiVersion: syncagent.kcp.io/v1alpha1
kind: PublishedResource
metadata:
name: publish-crontabs
spec:
resource:
apiGroup: example.com
version: v1
kind: CronTab
related:
- identifier: credentials
origin: service
kind: Secret
cleanupPolicy: MatchOrigin
object:
selector:
matchLabels:
app: credentials
rewrite:
template:
template: "{{ .Value }}"
watch:
bySelector:
matchLabels:
example.com/managed: "true"
```

!!! note
The deprecated boolean `cleanup` field still works for backwards compatibility. When
`cleanupPolicy` is not set, `cleanup: true` is treated as `OnPrimaryDeletion` and `cleanup: false`
(or unset) as `Orphan`. New configurations should use `cleanupPolicy` instead; setting
`cleanup: true` together with `cleanupPolicy: Orphan` is rejected as a contradiction.
9 changes: 8 additions & 1 deletion internal/controller/sync/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ const (

type Reconciler struct {
localClient ctrlruntimeclient.Client
localAPIReader ctrlruntimeclient.Reader
remoteManager mcmanager.Manager
log *zap.SugaredLogger
remoteDummy *unstructured.Unstructured
Expand Down Expand Up @@ -120,6 +121,7 @@ func Create(
// setup the reconciler
reconciler := &Reconciler{
localClient: localManager.GetClient(),
localAPIReader: localManager.GetAPIReader(),
remoteManager: remoteManager,
log: log,
remoteDummy: remoteDummy,
Expand Down Expand Up @@ -360,6 +362,7 @@ func (r *Reconciler) Reconcile(ctx context.Context, request mcreconcile.Request)
return reconcile.Result{}, fmt.Errorf("failed to get cluster: %w", err)
}
vwClient := cl.GetClient()
vwAPIReader := cl.GetAPIReader()

remoteObj := r.remoteDummy.DeepCopy()
if err := vwClient.Get(ctx, request.NamespacedName, remoteObj); ctrlruntimeclient.IgnoreNotFound(err) != nil {
Expand Down Expand Up @@ -410,7 +413,11 @@ func (r *Reconciler) Reconcile(ctx context.Context, request mcreconcile.Request)
ctx = sync.WithWorkspacePath(ctx, logicalcluster.NewPath(path))
}

var syncerOpts []sync.ResourceSyncerOption
// pass uncached readers so a MatchOrigin prune can re-confirm an empty origin resolution
// against the API server before deleting all copies of a related resource.
syncerOpts := []sync.ResourceSyncerOption{
sync.WithLiveReaders(r.localAPIReader, vwAPIReader),
}
if r.enableServerSideApply {
syncerOpts = append(syncerOpts, sync.WithServerSideApply())
}
Expand Down
55 changes: 55 additions & 0 deletions internal/sync/meta.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ package sync

import (
"context"
"strings"

"go.uber.org/zap"

Expand Down Expand Up @@ -158,3 +159,57 @@ func (k objectKey) Annotations() labels.Set {

return s
}

// relatedCopyLabels builds the provenance labels put on a destination copy of a related resource.
// They tie the copy to its owning primary object and the related resource identifier so that all
// copies of a given (primary, identifier) can be enumerated via relatedCopySelector. The owner tuple
// — the primary's logical cluster (workspace), the owning PublishedResource and the primary's
// namespace/name — is hashed into a single related-owner label; because the prune List is
// cluster-wide on the shared destination, all of these dimensions must be part of the identity, and
// folding them into one hash makes that uniqueness inherent in the hash input (and keeps the value a
// valid label regardless of the source lengths or characters). The identifier is used verbatim (the
// API constrains it to a valid label value) and the agent name (when set) is included so that each
// agent only ever prunes its own copies.
func relatedCopyLabels(primary ctrlruntimeclient.Object, clusterName logicalcluster.Name, publishedResourceName, identifier, agentName string) map[string]string {
set := map[string]string{
relatedOwnerLabel: relatedOwnerHash(clusterName, publishedResourceName, primary.GetNamespace(), primary.GetName()),
relatedIdentifierLabel: identifier,
}

if agentName != "" {
set[agentNameLabel] = agentName
}

return set
}

// relatedOwnerHash hashes the owner tuple (cluster, PublishedResource, primary namespace, primary
// name) into a single label value. The NUL separator keeps the dimensions unambiguous, so e.g.
// cluster "a" + name "bc" cannot collide with cluster "ab" + name "c". A cluster-scoped primary has
// an empty namespace, which folds in cleanly and cannot collide with a namespaced primary (whose
// namespace is never empty).
func relatedOwnerHash(clusterName logicalcluster.Name, publishedResourceName, namespace, name string) string {
return crypto.Hash(strings.Join([]string{string(clusterName), publishedResourceName, namespace, name}, "\x00"))
}

// relatedCopyAnnotations builds the human-facing provenance annotations (plaintext primary
// namespace/name) for a destination copy of a related resource.
func relatedCopyAnnotations(primary ctrlruntimeclient.Object) map[string]string {
set := map[string]string{
relatedPrimaryNameAnnotation: primary.GetName(),
}

if namespace := primary.GetNamespace(); namespace != "" {
set[relatedPrimaryNamespaceAnnotation] = namespace
}

return set
}

// relatedCopySelector returns a label selector matching exactly the destination copies created for
// the given primary object and related resource identifier (scoped to the agent when set). Because
// it mirrors relatedCopyLabels, only objects the agent itself labelled are ever selected, so
// hand-created objects are never in scope for pruning.
func relatedCopySelector(primary ctrlruntimeclient.Object, clusterName logicalcluster.Name, publishedResourceName, identifier, agentName string) labels.Selector {
return labels.SelectorFromSet(relatedCopyLabels(primary, clusterName, publishedResourceName, identifier, agentName))
}
72 changes: 72 additions & 0 deletions internal/sync/meta_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,16 @@ limitations under the License.
package sync

import (
"strings"
"testing"

"github.com/kcp-dev/logicalcluster/v3"

metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
metav1validation "k8s.io/apimachinery/pkg/apis/meta/v1/validation"
"k8s.io/apimachinery/pkg/util/validation/field"
ctrlruntimeclient "sigs.k8s.io/controller-runtime/pkg/client"
)

func createNewObject(name, namespace string) metav1.Object {
Expand All @@ -33,6 +37,74 @@ func createNewObject(name, namespace string) metav1.Object {
return obj
}

// TestRelatedCopyLabelsAreValidLabelSet guards the invariant that lets relatedCopyLabels use the
// related-resource identifier verbatim as a label value: as long as the identifier stays within the
// bounds the CRD enforces (a lowercase RFC 1123 label of at most 63 characters), the resulting
// provenance label set must always be accepted by the API server. Otherwise the apply/create of the
// copy fails and sync silently breaks for that related resource.
func TestRelatedCopyLabelsAreValidLabelSet(t *testing.T) {
// Names and namespaces are hashed, so they can be arbitrarily long/invalid; use overlong ones to
// make sure the hashing keeps the label set valid.
longName := strings.Repeat("a", 300)

// maxIdentifier is the longest identifier the CRD accepts (63 chars, matching the pattern).
maxIdentifier := "a" + strings.Repeat("b", 61) + "c"

testcases := []struct {
name string
primary ctrlruntimeclient.Object
clusterName logicalcluster.Name
publishedResName string
identifier string
agentName string
}{
{
name: "typical namespaced primary",
primary: createNewUnstructured("my-primary", "kube-system"),
clusterName: "root",
publishedResName: "my-published-resource",
identifier: "connection-details",
agentName: "agent-1",
},
{
// clusterName is a kcp cluster ID (a colon-free hash), used verbatim like the main
// object's remote-object-cluster label; the workspace path (with colons) is never used here.
name: "max-length identifier and overlong name/namespace/published-resource",
primary: createNewUnstructured(longName, longName),
clusterName: "kvdk2spgmbld9mnc",
publishedResName: longName,
identifier: maxIdentifier,
agentName: "",
},
{
name: "cluster-scoped primary without agent name",
primary: createNewUnstructured(longName, ""),
clusterName: "abc123",
publishedResName: "another-published-resource",
identifier: "credentials",
agentName: "",
},
}

for _, testcase := range testcases {
t.Run(testcase.name, func(t *testing.T) {
set := relatedCopyLabels(testcase.primary, testcase.clusterName, testcase.publishedResName, testcase.identifier, testcase.agentName)

if errs := metav1validation.ValidateLabels(set, field.NewPath("metadata", "labels")); len(errs) > 0 {
t.Fatalf("relatedCopyLabels produced an invalid label set: %v", errs)
}
})
}
}

func createNewUnstructured(name, namespace string) ctrlruntimeclient.Object {
obj := &unstructured.Unstructured{}
obj.SetName(name)
obj.SetNamespace(namespace)

return obj
}

func TestObjectKey(t *testing.T) {
testcases := []struct {
object metav1.Object
Expand Down
Loading