From e6ac7ea552534eaebbc397e6e24c93051e3747e6 Mon Sep 17 00:00:00 2001 From: Scot Wells Date: Mon, 18 May 2026 13:48:03 -0500 Subject: [PATCH] feat: add downstreamclient package for cross-cluster resource projection Promotes the MappedNamespaceResourceStrategy pattern from network-services-operator into a shared platform library so any service can write resources to a downstream cluster without duplicating the namespace-mapping and ownership-tracking logic. Co-Authored-By: Claude Sonnet 4.6 --- pkg/downstreamclient/doc.go | 24 ++ pkg/downstreamclient/enqueue.go | 129 ++++++++++ pkg/downstreamclient/mappednamespace.go | 312 ++++++++++++++++++++++++ pkg/downstreamclient/strategy.go | 72 ++++++ 4 files changed, 537 insertions(+) create mode 100644 pkg/downstreamclient/doc.go create mode 100644 pkg/downstreamclient/enqueue.go create mode 100644 pkg/downstreamclient/mappednamespace.go create mode 100644 pkg/downstreamclient/strategy.go diff --git a/pkg/downstreamclient/doc.go b/pkg/downstreamclient/doc.go new file mode 100644 index 00000000..aa1e93b2 --- /dev/null +++ b/pkg/downstreamclient/doc.go @@ -0,0 +1,24 @@ +// Package downstreamclient provides abstractions for writing Kubernetes +// resources to downstream clusters (e.g. Karmada federation targets) as +// artifacts of upstream resources. +// +// The central abstraction is [ResourceStrategy], which decouples controller +// logic from the mechanics of where and how downstream resources are placed. +// A controller can be written as if it is writing resources into the same +// namespace as the upstream resource; the strategy handles any necessary +// namespace or name remapping transparently. +// +// # MappedNamespace strategy +// +// [MappedNamespaceResourceStrategy] implements the ns- +// convention used across the Datum platform. Upstream namespaces are looked up +// by name on the upstream cluster, and the resulting UID drives a stable +// downstream namespace name of the form "ns-". This prevents name +// collisions when aggregating resources from multiple upstream clusters into a +// single downstream API server. +// +// Ownership is tracked through anchor ConfigMaps that carry the +// meta.datumapis.com/* labels defined in this package. The +// [TypedEnqueueRequestsForUpstreamOwner] handler reads those labels to +// reconcile upstream owners when downstream objects change. +package downstreamclient diff --git a/pkg/downstreamclient/enqueue.go b/pkg/downstreamclient/enqueue.go new file mode 100644 index 00000000..88ff2bc0 --- /dev/null +++ b/pkg/downstreamclient/enqueue.go @@ -0,0 +1,129 @@ +package downstreamclient + +import ( + "context" + "fmt" + "strings" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/util/workqueue" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/cluster" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + mchandler "sigs.k8s.io/multicluster-runtime/pkg/handler" + mcreconcile "sigs.k8s.io/multicluster-runtime/pkg/reconcile" +) + +// TypedEnqueueRequestsForUpstreamOwner returns an event handler that enqueues +// reconcile requests for the upstream owner of a downstream object. +// +// The handler reads the meta.datumapis.com/* labels (see [UpstreamOwnerKindLabel] +// and friends) that [MappedNamespaceResourceStrategy.SetControllerReference] +// writes onto downstream resources. When a downstream object changes, the +// handler maps it back to the upstream owner's cluster, namespace, and name and +// enqueues a [mcreconcile.Request]. +// +// ownerType must be a registered scheme object whose Group and Kind match the +// [UpstreamOwnerGroupLabel] and [UpstreamOwnerKindLabel] values written onto +// downstream resources. The scheme is resolved per-cluster at handler +// construction time; a panic is raised if the type cannot be found. +func TypedEnqueueRequestsForUpstreamOwner[object client.Object](ownerType client.Object) mchandler.TypedEventHandlerFunc[object, mcreconcile.Request] { + return func(clusterName string, cl cluster.Cluster) handler.TypedEventHandler[object, mcreconcile.Request] { + e := &enqueueRequestForOwner[object]{ + ownerType: ownerType, + } + if err := e.parseOwnerTypeGroupKind(cl.GetScheme()); err != nil { + panic(err) + } + + return e + } +} + +// enqueueRequestForOwner is the internal typed event handler. +type enqueueRequestForOwner[object client.Object] struct { + ownerType runtime.Object + groupKind schema.GroupKind +} + +// Create implements [handler.TypedEventHandler]. +func (e *enqueueRequestForOwner[object]) Create(ctx context.Context, evt event.TypedCreateEvent[object], q workqueue.TypedRateLimitingInterface[mcreconcile.Request]) { + reqs := map[mcreconcile.Request]struct{}{} + e.getOwnerReconcileRequest(evt.Object, reqs) + for req := range reqs { + q.Add(req) + } +} + +// Update implements [handler.TypedEventHandler]. +func (e *enqueueRequestForOwner[object]) Update(ctx context.Context, evt event.TypedUpdateEvent[object], q workqueue.TypedRateLimitingInterface[mcreconcile.Request]) { + reqs := map[mcreconcile.Request]struct{}{} + e.getOwnerReconcileRequest(evt.ObjectOld, reqs) + e.getOwnerReconcileRequest(evt.ObjectNew, reqs) + for req := range reqs { + q.Add(req) + } +} + +// Delete implements [handler.TypedEventHandler]. +func (e *enqueueRequestForOwner[object]) Delete(ctx context.Context, evt event.TypedDeleteEvent[object], q workqueue.TypedRateLimitingInterface[mcreconcile.Request]) { + reqs := map[mcreconcile.Request]struct{}{} + e.getOwnerReconcileRequest(evt.Object, reqs) + for req := range reqs { + q.Add(req) + } +} + +// Generic implements [handler.TypedEventHandler]. +func (e *enqueueRequestForOwner[object]) Generic(ctx context.Context, evt event.TypedGenericEvent[object], q workqueue.TypedRateLimitingInterface[mcreconcile.Request]) { + reqs := map[mcreconcile.Request]struct{}{} + e.getOwnerReconcileRequest(evt.Object, reqs) + for req := range reqs { + q.Add(req) + } +} + +// parseOwnerTypeGroupKind resolves and caches the GroupKind of ownerType using +// the provided scheme. It returns an error if the type is not registered or is +// ambiguous. +func (e *enqueueRequestForOwner[object]) parseOwnerTypeGroupKind(scheme *runtime.Scheme) error { + kinds, _, err := scheme.ObjectKinds(e.ownerType) + if err != nil { + return err + } + if len(kinds) != 1 { + return fmt.Errorf("expected exactly 1 kind for OwnerType %T, but found %s kinds", e.ownerType, kinds) + } + e.groupKind = schema.GroupKind{Group: kinds[0].Group, Kind: kinds[0].Kind} + return nil +} + +// getOwnerReconcileRequest inspects the object's labels for upstream owner +// metadata and, when the labels match the expected GroupKind, appends a +// reconcile request to result. +func (e *enqueueRequestForOwner[object]) getOwnerReconcileRequest(obj metav1.Object, result map[mcreconcile.Request]struct{}) { + labels := obj.GetLabels() + if labels[UpstreamOwnerKindLabel] != e.groupKind.Kind || labels[UpstreamOwnerGroupLabel] != e.groupKind.Group { + return + } + + clusterName := strings.TrimPrefix( + strings.ReplaceAll(labels[UpstreamOwnerClusterNameLabel], "_", "/"), + "cluster-", + ) + + result[mcreconcile.Request{ + Request: reconcile.Request{ + NamespacedName: types.NamespacedName{ + Name: labels[UpstreamOwnerNameLabel], + Namespace: labels[UpstreamOwnerNamespaceLabel], + }, + }, + ClusterName: clusterName, + }] = struct{}{} +} diff --git a/pkg/downstreamclient/mappednamespace.go b/pkg/downstreamclient/mappednamespace.go new file mode 100644 index 00000000..579a2c41 --- /dev/null +++ b/pkg/downstreamclient/mappednamespace.go @@ -0,0 +1,312 @@ +package downstreamclient + +import ( + "context" + "fmt" + "strings" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/apiutil" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" +) + +// +kubebuilder:rbac:groups=core,resources=namespaces,verbs=get;list;watch + +var _ ResourceStrategy = &MappedNamespaceResourceStrategy{} + +// MappedNamespaceResourceStrategy implements [ResourceStrategy] using the +// ns- convention. +// +// When an upstream resource lives in namespace "my-project", this strategy +// looks up that namespace on the upstream cluster, reads its UID, and derives a +// stable downstream namespace name of the form "ns-". Resources from +// different upstream clusters can therefore coexist in the same downstream API +// server without name collisions. +// +// Ownership is tracked through anchor ConfigMaps written into the downstream +// namespace. The ConfigMap carries the meta.datumapis.com/* labels so that +// [TypedEnqueueRequestsForUpstreamOwner] can re-enqueue the upstream owner +// whenever a downstream resource changes. +// +// Construct with [NewMappedNamespaceResourceStrategy]. +type MappedNamespaceResourceStrategy struct { + upstreamClusterName string + upstreamClient client.Client + downstreamClient client.Client +} + +// NewMappedNamespaceResourceStrategy returns a [ResourceStrategy] that maps +// upstream namespaces to downstream namespaces using the ns- convention. +// +// upstreamClusterName is a stable identifier for the upstream cluster (e.g. a +// KCP path such as "root:org:project"). It is embedded in labels on downstream +// resources to allow reverse-lookup. +// +// upstreamClient is used to resolve namespace UIDs. downstreamClient is used +// for all write operations. +func NewMappedNamespaceResourceStrategy( + upstreamClusterName string, + upstreamClient client.Client, + downstreamClient client.Client, +) ResourceStrategy { + return &MappedNamespaceResourceStrategy{ + upstreamClusterName: upstreamClusterName, + upstreamClient: upstreamClient, + downstreamClient: downstreamClient, + } +} + +// GetClient returns a [client.Client] that automatically ensures the downstream +// namespace exists before each Create call and delegates all other operations +// to the underlying downstream client. +func (c *MappedNamespaceResourceStrategy) GetClient() client.Client { + return &mappedNamespaceClient{ + client: c.downstreamClient, + strategy: c, + } +} + +// ObjectMetaFromUpstreamObject returns a [metav1.ObjectMeta] with the Name +// preserved from the upstream object and the Namespace remapped to the +// downstream ns- form. The [UpstreamOwnerNamespaceLabel] is set on the +// returned ObjectMeta so the source namespace can be recovered later. +func (c *MappedNamespaceResourceStrategy) ObjectMetaFromUpstreamObject(ctx context.Context, obj metav1.Object) (metav1.ObjectMeta, error) { + downstreamNamespaceName, err := c.GetDownstreamNamespaceNameForUpstreamNamespace(ctx, obj.GetNamespace()) + if err != nil { + return metav1.ObjectMeta{}, fmt.Errorf("failed to get downstream namespace name: %w", err) + } + + return metav1.ObjectMeta{ + Name: obj.GetName(), + Namespace: downstreamNamespaceName, + Labels: map[string]string{ + UpstreamOwnerNamespaceLabel: obj.GetNamespace(), + }, + }, nil +} + +// GetDownstreamNamespaceNameForUpstreamNamespace looks up the upstream +// namespace by name and returns "ns-". +func (c *MappedNamespaceResourceStrategy) GetDownstreamNamespaceNameForUpstreamNamespace(ctx context.Context, name string) (string, error) { + namespace, err := c.getUpstreamNamespace(ctx, name) + if err != nil { + return "", fmt.Errorf("failed to get downstream namespace: %w", err) + } + + return fmt.Sprintf("ns-%s", namespace.UID), nil +} + +// SetControllerReference establishes a controller ownership relationship +// between owner and controlled in the downstream cluster. +// +// Because cross-cluster owner references are not supported by Kubernetes +// garbage collection, this method creates or retrieves an anchor ConfigMap in +// the same downstream namespace as controlled. The ConfigMap carries the +// meta.datumapis.com/* labels that identify the upstream owner, and controlled +// receives an in-cluster owner reference to that ConfigMap. Deleting the anchor +// (via [DeleteAnchorForObject]) then cascades GC to controlled. +// +// owner and controlled must both be namespaced resources. +func (c *MappedNamespaceResourceStrategy) SetControllerReference(ctx context.Context, owner, controlled metav1.Object, opts ...controllerutil.OwnerReferenceOption) error { + if owner.GetNamespace() == "" || controlled.GetNamespace() == "" { + return fmt.Errorf("cluster scoped resource controllers are not supported") + } + + gvk, err := apiutil.GVKForObject(owner.(runtime.Object), c.upstreamClient.Scheme()) + if err != nil { + return err + } + + anchorName := fmt.Sprintf("anchor-%s", owner.GetUID()) + + anchorLabels := map[string]string{ + UpstreamOwnerClusterNameLabel: fmt.Sprintf("cluster-%s", strings.ReplaceAll(c.upstreamClusterName, "/", "_")), + UpstreamOwnerGroupLabel: gvk.Group, + UpstreamOwnerKindLabel: gvk.Kind, + UpstreamOwnerNameLabel: owner.GetName(), + UpstreamOwnerNamespaceLabel: owner.GetNamespace(), + } + + downstreamClient := c.GetClient() + + var anchorConfigMap corev1.ConfigMap + err = downstreamClient.Get(ctx, client.ObjectKey{Namespace: controlled.GetNamespace(), Name: anchorName}, &anchorConfigMap) + if apierrors.IsNotFound(err) { + anchorConfigMap = corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: anchorName, + Namespace: controlled.GetNamespace(), + Labels: anchorLabels, + }, + } + if err := downstreamClient.Create(ctx, &anchorConfigMap); err != nil { + return fmt.Errorf("failed creating anchor configmap: %w", err) + } + } else if err != nil { + return fmt.Errorf("failed getting anchor configmap: %w", err) + } + + if err := controllerutil.SetOwnerReference(&anchorConfigMap, controlled, downstreamClient.Scheme(), opts...); err != nil { + return fmt.Errorf("failed setting anchor owner reference: %w", err) + } + + labels := controlled.GetLabels() + if labels == nil { + labels = map[string]string{} + } + labels[UpstreamOwnerClusterNameLabel] = anchorLabels[UpstreamOwnerClusterNameLabel] + labels[UpstreamOwnerGroupLabel] = anchorLabels[UpstreamOwnerGroupLabel] + labels[UpstreamOwnerKindLabel] = anchorLabels[UpstreamOwnerKindLabel] + labels[UpstreamOwnerNameLabel] = anchorLabels[UpstreamOwnerNameLabel] + labels[UpstreamOwnerNamespaceLabel] = anchorLabels[UpstreamOwnerNamespaceLabel] + controlled.SetLabels(labels) + + return nil +} + +// SetOwnerReference establishes a non-controller ownership relationship between +// owner and object using the downstream cluster's scheme. This does not create +// an anchor ConfigMap; it sets an in-cluster owner reference directly. +func (c *MappedNamespaceResourceStrategy) SetOwnerReference(ctx context.Context, owner, object metav1.Object, opts ...controllerutil.OwnerReferenceOption) error { + return controllerutil.SetOwnerReference(owner, object, c.downstreamClient.Scheme(), opts...) +} + +// DeleteAnchorForObject deletes the anchor ConfigMap associated with owner. +// Kubernetes garbage collection then cascades to all downstream resources that +// hold an owner reference to the anchor. +// +// If the anchor does not exist the call is a no-op. +func (c *MappedNamespaceResourceStrategy) DeleteAnchorForObject(ctx context.Context, owner client.Object) error { + anchorName := fmt.Sprintf("anchor-%s", owner.GetUID()) + + downstreamObjectMeta, err := c.ObjectMetaFromUpstreamObject(ctx, owner) + if err != nil { + return fmt.Errorf("failed to get downstream object metadata: %w", err) + } + + downstreamClient := c.GetClient() + + var configMap corev1.ConfigMap + if err := downstreamClient.Get(ctx, client.ObjectKey{Namespace: downstreamObjectMeta.Namespace, Name: anchorName}, &configMap); err != nil { + if apierrors.IsNotFound(err) { + return nil + } + return fmt.Errorf("failed getting anchor configmap: %w", err) + } + + return downstreamClient.Delete(ctx, &configMap) +} + +// getUpstreamNamespace retrieves the named namespace from the upstream cluster. +func (c *MappedNamespaceResourceStrategy) getUpstreamNamespace(ctx context.Context, name string) (*corev1.Namespace, error) { + if c.upstreamClient == nil { + return nil, fmt.Errorf("upstream client is nil") + } + + namespace := &corev1.Namespace{} + if err := c.upstreamClient.Get(ctx, client.ObjectKey{Name: name}, namespace); err != nil { + return nil, fmt.Errorf("failed to get upstream namespace: %w", err) + } + + return namespace, nil +} + +// ensureDownstreamNamespace creates or updates the downstream namespace, +// labelling it with the upstream cluster name and source namespace. +func (c *MappedNamespaceResourceStrategy) ensureDownstreamNamespace(ctx context.Context, obj metav1.Object) (*corev1.Namespace, error) { + downstreamNamespace := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: obj.GetNamespace(), + }, + } + + _, err := controllerutil.CreateOrUpdate(ctx, c.downstreamClient, downstreamNamespace, func() error { + if downstreamNamespace.Labels == nil { + downstreamNamespace.Labels = make(map[string]string) + } + + downstreamNamespace.Labels[UpstreamOwnerClusterNameLabel] = fmt.Sprintf("cluster-%s", strings.ReplaceAll(c.upstreamClusterName, "/", "_")) + + if v, ok := obj.GetLabels()[UpstreamOwnerNamespaceLabel]; ok { + downstreamNamespace.Labels[UpstreamOwnerNamespaceLabel] = v + } + + return nil + }) + if err != nil { + return nil, fmt.Errorf("failed to ensure downstream namespace: %w", err) + } + + return downstreamNamespace, nil +} + +// mappedNamespaceClient wraps a [client.Client] and ensures the downstream +// namespace exists before every Create call. +var _ client.Client = &mappedNamespaceClient{} + +type mappedNamespaceClient struct { + client client.Client + strategy *MappedNamespaceResourceStrategy +} + +func (c *mappedNamespaceClient) Create(ctx context.Context, obj client.Object, opts ...client.CreateOption) error { + if _, err := c.strategy.ensureDownstreamNamespace(ctx, obj); err != nil { + return fmt.Errorf("failed to ensure downstream namespace: %w", err) + } + + return c.client.Create(ctx, obj, opts...) +} + +func (c *mappedNamespaceClient) Delete(ctx context.Context, obj client.Object, opts ...client.DeleteOption) error { + return c.client.Delete(ctx, obj, opts...) +} + +func (c *mappedNamespaceClient) DeleteAllOf(ctx context.Context, obj client.Object, opts ...client.DeleteAllOfOption) error { + return c.client.DeleteAllOf(ctx, obj, opts...) +} + +func (c *mappedNamespaceClient) Get(ctx context.Context, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { + return c.client.Get(ctx, key, obj, opts...) +} + +func (c *mappedNamespaceClient) List(ctx context.Context, list client.ObjectList, opts ...client.ListOption) error { + return c.client.List(ctx, list, opts...) +} + +func (c *mappedNamespaceClient) Patch(ctx context.Context, obj client.Object, patch client.Patch, opts ...client.PatchOption) error { + return c.client.Patch(ctx, obj, patch, opts...) +} + +func (c *mappedNamespaceClient) Update(ctx context.Context, obj client.Object, opts ...client.UpdateOption) error { + return c.client.Update(ctx, obj, opts...) +} + +func (c *mappedNamespaceClient) GroupVersionKindFor(obj runtime.Object) (schema.GroupVersionKind, error) { + return c.client.GroupVersionKindFor(obj) +} + +func (c *mappedNamespaceClient) IsObjectNamespaced(obj runtime.Object) (bool, error) { + return c.client.IsObjectNamespaced(obj) +} + +func (c *mappedNamespaceClient) Scheme() *runtime.Scheme { + return c.client.Scheme() +} + +func (c *mappedNamespaceClient) RESTMapper() meta.RESTMapper { + return c.client.RESTMapper() +} + +func (c *mappedNamespaceClient) Status() client.SubResourceWriter { + return c.client.Status() +} + +func (c *mappedNamespaceClient) SubResource(subResource string) client.SubResourceClient { + return c.client.SubResource(subResource) +} diff --git a/pkg/downstreamclient/strategy.go b/pkg/downstreamclient/strategy.go new file mode 100644 index 00000000..98b33706 --- /dev/null +++ b/pkg/downstreamclient/strategy.go @@ -0,0 +1,72 @@ +package downstreamclient + +import ( + "context" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" +) + +// Label keys written by [MappedNamespaceResourceStrategy] to downstream +// resources and anchor ConfigMaps. These labels identify the upstream owner of +// a downstream object and enable cross-cluster garbage collection and +// reconciliation triggering. +const ( + // UpstreamOwnerClusterNameLabel records the upstream cluster that owns the + // downstream resource, encoded as "cluster-" with "/" replaced by "_". + UpstreamOwnerClusterNameLabel = "meta.datumapis.com/upstream-cluster-name" + + // UpstreamOwnerGroupLabel records the API group of the upstream owner kind. + UpstreamOwnerGroupLabel = "meta.datumapis.com/upstream-group" + + // UpstreamOwnerKindLabel records the kind name of the upstream owner. + UpstreamOwnerKindLabel = "meta.datumapis.com/upstream-kind" + + // UpstreamOwnerNameLabel records the name of the upstream owner object. + UpstreamOwnerNameLabel = "meta.datumapis.com/upstream-name" + + // UpstreamOwnerNamespaceLabel records the namespace of the upstream owner + // object (or the upstream source namespace for remapped resources). + UpstreamOwnerNamespaceLabel = "meta.datumapis.com/upstream-namespace" +) + +// ResourceStrategy reduces the burden of writing controllers that produce +// downstream resources as artifacts of upstream resources, potentially in +// separate clusters. +// +// Implementations may return a client that writes to the same namespace as the +// upstream resource, remap namespaces to avoid collisions across clusters (see +// [MappedNamespaceResourceStrategy]), or align each source cluster with a +// dedicated target cluster or workspace. +// +// Controllers written against this interface can be tested or redeployed with a +// different placement strategy without changing reconciliation logic. +type ResourceStrategy interface { + // GetClient returns a client.Client that should be used to read and write + // downstream resources. The returned client may transparently remap + // namespaces or perform other transformations. + GetClient() client.Client + + // ObjectMetaFromUpstreamObject derives the downstream ObjectMeta (Namespace + // and Name at minimum) that corresponds to the given upstream object. + ObjectMetaFromUpstreamObject(context.Context, metav1.Object) (metav1.ObjectMeta, error) + + // GetDownstreamNamespaceNameForUpstreamNamespace returns the downstream + // namespace name that corresponds to the given upstream namespace name. + GetDownstreamNamespaceNameForUpstreamNamespace(ctx context.Context, name string) (string, error) + + // SetControllerReference establishes a controller ownership relationship + // between owner and controlled in the downstream cluster, creating any + // necessary anchor objects. + SetControllerReference(context.Context, metav1.Object, metav1.Object, ...controllerutil.OwnerReferenceOption) error + + // SetOwnerReference establishes a non-controller ownership relationship + // between owner and object in the downstream cluster. + SetOwnerReference(context.Context, metav1.Object, metav1.Object, ...controllerutil.OwnerReferenceOption) error + + // DeleteAnchorForObject removes the anchor object that tracks ownership of + // the given upstream owner, which triggers garbage collection of dependent + // downstream resources. + DeleteAnchorForObject(ctx context.Context, owner client.Object) error +}