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/
23 changes: 23 additions & 0 deletions 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 Down Expand Up @@ -878,6 +897,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.
45 changes: 45 additions & 0 deletions internal/sync/meta.go
Original file line number Diff line number Diff line change
Expand Up @@ -158,3 +158,48 @@ 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. Names and
// namespaces are hashed because they can exceed the label value limit or contain invalid
// characters; the identifier is validated to be alphanumeric by the API, so it is used verbatim.
// The agent name (when set) is included so that each agent only ever prunes its own copies.
func relatedCopyLabels(primary ctrlruntimeclient.Object, identifier, agentName string) map[string]string {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The label set is {primary-name-hash, primary-namespace-hash, identifier, agent}, and the prune List is typed only by projected Kind — nothing encodes the primary's GVK or the owning PublishedResource. Two PublishedResources that project copies to the same Kind, reuse the same identifier, and have primaries sharing name+namespace produce byte-identical labels, so one primary's prune selector matches the other's copies (verified: identical hashes, selector matches). Consider adding the PublishedResource name and/or primary GVK to the provenance labels.

set := map[string]string{
relatedPrimaryNameHashLabel: crypto.Hash(primary.GetName()),
relatedIdentifierLabel: identifier,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Identifier is used verbatim as a label value here, and the comment above says it's "validated to be alphanumeric by the API" — but there's no such validation: the CRD schema for identifier is bare type: string (no maxLength/pattern) and the Go field carries no kubebuilder markers.

I ran this branch's relatedCopyLabels() output through the apiserver's own metav1validation.ValidateLabels:

  • 63-char identifier → valid; 64-char → rejected (must be no more than 63 bytes)
  • connection/details, connection details, -credsrejected by the label-value regex

So an identifier the CRD accepts can make the copy's labels invalid, and the apply/create then fails — sync silently breaks for that related resource. (New failure surface specifically for origin: kcp; origin: service was already constrained via the related-resources.syncagent.kcp.io/<identifier>.N annotation key.) Suggest hashing the identifier like name/namespace, or adding +kubebuilder:validation:MaxLength=63 + an alphanumeric Pattern to Identifier — and fixing the comment.

}

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

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

return set
}

// 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, identifier, agentName string) labels.Selector {
return labels.SelectorFromSet(relatedCopyLabels(primary, identifier, agentName))
}
28 changes: 28 additions & 0 deletions internal/sync/object_syncer.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,14 @@ type objectSyncer struct {
blockSourceDeletion bool
// whether or not to place sync-related metadata on the destination object
metadataOnDestination bool
// destLabels are additional labels stamped onto the destination object on every apply.
// Used to attach related-resource provenance (owning primary + identifier) to copies so
// that they can later be enumerated and pruned. These are applied additively and never
// clobber labels the copy already carries.
destLabels map[string]string
// destAnnotations are additional annotations stamped onto the destination object on every
// apply; the human-facing counterpart to destLabels.
destAnnotations map[string]string
// optional mutations for both directions of the sync
mutator mutation.Mutator
// stateStore is capable of remembering the state of a Kubernetes object
Expand Down Expand Up @@ -420,6 +428,9 @@ func (s *objectSyncer) applyServerSide(ctx context.Context, log *zap.SugaredLogg
s.labelWithAgent(desired)
}

// stamp any additional provenance labels/annotations (e.g. related-resource ownership)
s.ensureDestMetadata(desired)

// do not claim ownership of subresource content via the main resource
// apply; status is reconciled separately and "scale" is never written.
s.removeSubresources(desired)
Expand Down Expand Up @@ -503,6 +514,9 @@ func (s *objectSyncer) ensureDestinationObject(ctx context.Context, log *zap.Sug
s.labelWithAgent(destObj)
}

// stamp any additional provenance labels/annotations (e.g. related-resource ownership)
s.ensureDestMetadata(destObj)

// finally, we can create the destination object
objectLog := log.With("dest-object", newObjectKey(destObj, dest.clusterName, logicalcluster.None))
objectLog.Debugw("Creating destination object…")
Expand Down Expand Up @@ -551,6 +565,7 @@ func (s *objectSyncer) adoptExistingDestinationObject(ctx context.Context, log *
ensureAnnotations(existingDestObj, sourceKey.Annotations())

s.labelWithAgent(existingDestObj)
s.ensureDestMetadata(existingDestObj)

if err := dest.client.Update(ctx, existingDestObj); err != nil {
return fmt.Errorf("failed to upsert current destination object labels: %w", err)
Expand Down Expand Up @@ -654,3 +669,16 @@ func (s *objectSyncer) labelWithAgent(obj *unstructured.Unstructured) {
ensureLabels(obj, map[string]string{agentNameLabel: s.agentName})
}
}

// ensureDestMetadata stamps the syncer's additional destination labels/annotations onto the given
// object. It is additive (it never removes labels the object already carries) and is used to
// attach related-resource provenance to destination copies.
func (s *objectSyncer) ensureDestMetadata(obj *unstructured.Unstructured) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ensureDestMetadata runs from applyServerSide, ensureDestinationObject (create), and adoptExistingDestinationObject, but not from syncObjectSpec — the client-side-merge update path, which is the default (--enable-server-side-apply defaults to false). New copies get stamped at birth, so this is fine going forward; but copies that already exist when a user enables MatchOrigin/OnPrimaryDeletion are updated in place and never receive the provenance labels, so they're never enumerated for pruning — exactly the mid-life-orphan reclaim this PR targets. Worth re-stamping in the update path or documenting the limitation.

if len(s.destLabels) > 0 {
ensureLabels(obj, s.destLabels)
}

if len(s.destAnnotations) > 0 {
ensureAnnotations(obj, s.destAnnotations)
}
}
Loading