Skip to content

Commit 14bea92

Browse files
committed
Watch and sync changes to related resources
change copyright year of related handlers On-behalf-of: SAP <iskren.pertov@sap.com> Signed-off-by: Iskren Petrov <iskren@kubermatic.com>
1 parent 2db6919 commit 14bea92

File tree

6 files changed

+321
-0
lines changed

6 files changed

+321
-0
lines changed

deploy/crd/kcp.io/syncagent.kcp.io_publishedresources.yaml

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -778,6 +778,35 @@ spec:
778778
Version is the API version of the related resource. This can be left blank to automatically
779779
use the preferred version.
780780
type: string
781+
watch:
782+
description: |-
783+
Watch configures how the agent identifies the owning primary object when a related
784+
resource with origin: kcp changes. When set, the agent sets up a watch on the related
785+
resource type and uses the configured rule to enqueue the correct primary object.
786+
Without this field, changes to origin:kcp related resources do not trigger reconciliation.
787+
properties:
788+
byLabel:
789+
additionalProperties:
790+
type: string
791+
description: |-
792+
ByLabel configures the watch handler to list primary objects matching a label selector
793+
derived from the changed object. Each map key is a label key on the primary object;
794+
each value is a Go template expression evaluated with the changed object available as
795+
.watchObject (with fields .name, .namespace, .labels).
796+
type: object
797+
byOwner:
798+
description: |-
799+
ByOwner configures the watch handler to inspect the OwnerReferences of the changed
800+
object. When an OwnerReference with the given Kind is found, the referenced owner
801+
is enqueued as the primary object.
802+
properties:
803+
kind:
804+
description: Kind is the Kind to look for in the OwnerReferences of the changed related object.
805+
type: string
806+
required:
807+
- kind
808+
type: object
809+
type: object
781810
required:
782811
- identifier
783812
- object

hack/tools.checksums

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,5 @@ kube-apiserver|GOARCH=arm64;GOOS=linux|6ade6c2646e2c01fde1095407452afc2b65e89d6d
1111
kubectl|GOARCH=amd64;GOOS=linux|9591f3d75e1581f3f7392e6ad119aab2f28ae7d6c6e083dc5d22469667f27253
1212
kubectl|GOARCH=arm64;GOOS=linux|95df604e914941f3172a93fa8feeb1a1a50f4011dfbe0c01e01b660afc8f9b85
1313
yq|GOARCH=amd64;GOOS=linux|0c2b24e645b57d8e7c0566d18643a6d4f5580feeea3878127354a46f2a1e4598
14+
yq|GOARCH=arm64;GOOS=darwin|164e10e5f7df62990e4f3823205e7ea42ba5660523a428df07c7386c0b62e3d9
1415
yq|GOARCH=arm64;GOOS=linux|9477ac3cc447b6c083986129e35af8122eb2b938fe55c9c3e40436fb966e5813

internal/controller/sync/controller.go

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,12 @@ import (
3939
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
4040
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
4141
"k8s.io/apimachinery/pkg/labels"
42+
"k8s.io/apimachinery/pkg/runtime/schema"
4243
"k8s.io/apimachinery/pkg/types"
44+
"k8s.io/apimachinery/pkg/util/sets"
4345
"k8s.io/utils/ptr"
4446
ctrlruntimeclient "sigs.k8s.io/controller-runtime/pkg/client"
47+
"sigs.k8s.io/controller-runtime/pkg/cluster"
4548
"sigs.k8s.io/controller-runtime/pkg/handler"
4649
"sigs.k8s.io/controller-runtime/pkg/manager"
4750
"sigs.k8s.io/controller-runtime/pkg/predicate"
@@ -161,6 +164,73 @@ func Create(
161164
return nil, fmt.Errorf("failed to setup local-side watch: %w", err)
162165
}
163166

167+
// Watch origin:kcp related resources so that changes to them trigger reconciliation
168+
// of the owning primary object. Only related resources with a Watch config are covered.
169+
watchedGVKs := sets.New[schema.GroupVersionKind]()
170+
for _, relRes := range pubRes.Spec.Related {
171+
if relRes.Origin != syncagentv1alpha1.RelatedResourceOriginKcp || relRes.Watch == nil {
172+
continue
173+
}
174+
175+
gvr := schema.GroupVersionResource{
176+
Group: relRes.Group,
177+
Version: relRes.Version,
178+
Resource: relRes.Resource,
179+
}
180+
181+
// Use the local REST mapper to determine the Kind.
182+
gvk, err := localManager.GetRESTMapper().KindFor(gvr)
183+
if err != nil {
184+
log.Warnw("Failed to determine Kind for origin:kcp related resource, skipping watch", "gvr", gvr, "error", err)
185+
continue
186+
}
187+
188+
// Deduplicate: only set up one watch per GVK.
189+
if watchedGVKs.Has(gvk) {
190+
continue
191+
}
192+
watchedGVKs.Insert(gvk)
193+
194+
relatedDummy := &unstructured.Unstructured{}
195+
relatedDummy.SetGroupVersionKind(gvk)
196+
197+
var enqueueForRelated mchandler.TypedEventHandlerFunc[*unstructured.Unstructured, mcreconcile.Request]
198+
199+
switch {
200+
case relRes.Watch.ByOwner != nil:
201+
ownerKind := relRes.Watch.ByOwner.Kind
202+
enqueueForRelated = func(clusterName string, _ cluster.Cluster) handler.TypedEventHandler[*unstructured.Unstructured, mcreconcile.Request] {
203+
return &byOwnerEventHandler{
204+
clusterName: clusterName,
205+
ownerKind: ownerKind,
206+
}
207+
}
208+
209+
case relRes.Watch.ByLabel != nil:
210+
labelTemplates := relRes.Watch.ByLabel
211+
primaryDummy := remoteDummy.DeepCopy()
212+
enqueueForRelated = func(clusterName string, cl cluster.Cluster) handler.TypedEventHandler[*unstructured.Unstructured, mcreconcile.Request] {
213+
return &byLabelEventHandler{
214+
clusterName: clusterName,
215+
client: cl.GetClient(),
216+
primaryDummy: primaryDummy,
217+
labelTemplates: labelTemplates,
218+
log: log,
219+
}
220+
}
221+
222+
default:
223+
log.Warnw("origin:kcp related resource has Watch set but neither byOwner nor byLabel configured, skipping", "gvk", gvk)
224+
continue
225+
}
226+
227+
if err := c.MultiClusterWatch(mcsource.TypedKind(relatedDummy, enqueueForRelated)); err != nil {
228+
return nil, fmt.Errorf("failed to setup watch for origin:kcp related resource %v: %w", gvk, err)
229+
}
230+
231+
log.Infow("Set up watch for origin:kcp related resource", "gvk", gvk)
232+
}
233+
164234
log.Info("Done setting up unmanaged controller.")
165235

166236
return c, nil
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
/*
2+
Copyright 2026 The KCP Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package sync
18+
19+
import (
20+
"context"
21+
22+
"go.uber.org/zap"
23+
24+
"github.com/kcp-dev/api-syncagent/internal/sync/templating"
25+
26+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
27+
"k8s.io/apimachinery/pkg/types"
28+
"k8s.io/client-go/util/workqueue"
29+
ctrlruntimeclient "sigs.k8s.io/controller-runtime/pkg/client"
30+
"sigs.k8s.io/controller-runtime/pkg/event"
31+
"sigs.k8s.io/controller-runtime/pkg/reconcile"
32+
mcreconcile "sigs.k8s.io/multicluster-runtime/pkg/reconcile"
33+
)
34+
35+
// byOwnerEventHandler enqueues the primary object by inspecting the OwnerReferences
36+
// of the changed related object and finding one with the configured Kind.
37+
type byOwnerEventHandler struct {
38+
clusterName string
39+
ownerKind string
40+
}
41+
42+
func (h *byOwnerEventHandler) Create(_ context.Context, evt event.TypedCreateEvent[*unstructured.Unstructured], q workqueue.TypedRateLimitingInterface[mcreconcile.Request]) {
43+
h.enqueue(evt.Object, q)
44+
}
45+
46+
func (h *byOwnerEventHandler) Update(_ context.Context, evt event.TypedUpdateEvent[*unstructured.Unstructured], q workqueue.TypedRateLimitingInterface[mcreconcile.Request]) {
47+
h.enqueue(evt.ObjectNew, q)
48+
}
49+
50+
func (h *byOwnerEventHandler) Delete(_ context.Context, evt event.TypedDeleteEvent[*unstructured.Unstructured], q workqueue.TypedRateLimitingInterface[mcreconcile.Request]) {
51+
h.enqueue(evt.Object, q)
52+
}
53+
54+
func (h *byOwnerEventHandler) Generic(_ context.Context, evt event.TypedGenericEvent[*unstructured.Unstructured], q workqueue.TypedRateLimitingInterface[mcreconcile.Request]) {
55+
h.enqueue(evt.Object, q)
56+
}
57+
58+
func (h *byOwnerEventHandler) enqueue(obj *unstructured.Unstructured, q workqueue.TypedRateLimitingInterface[mcreconcile.Request]) {
59+
for _, ref := range obj.GetOwnerReferences() {
60+
if ref.Kind == h.ownerKind {
61+
q.Add(mcreconcile.Request{
62+
ClusterName: h.clusterName,
63+
Request: reconcile.Request{
64+
NamespacedName: types.NamespacedName{
65+
Namespace: obj.GetNamespace(),
66+
Name: ref.Name,
67+
},
68+
},
69+
})
70+
return
71+
}
72+
}
73+
}
74+
75+
// byLabelEventHandler enqueues primary objects by evaluating label templates against
76+
// the changed related object and listing primaries matching the resulting label selector.
77+
type byLabelEventHandler struct {
78+
clusterName string
79+
client ctrlruntimeclient.Client
80+
primaryDummy *unstructured.Unstructured
81+
labelTemplates map[string]string
82+
log *zap.SugaredLogger
83+
}
84+
85+
func (h *byLabelEventHandler) Create(ctx context.Context, evt event.TypedCreateEvent[*unstructured.Unstructured], q workqueue.TypedRateLimitingInterface[mcreconcile.Request]) {
86+
h.enqueue(ctx, evt.Object, q)
87+
}
88+
89+
func (h *byLabelEventHandler) Update(ctx context.Context, evt event.TypedUpdateEvent[*unstructured.Unstructured], q workqueue.TypedRateLimitingInterface[mcreconcile.Request]) {
90+
h.enqueue(ctx, evt.ObjectNew, q)
91+
}
92+
93+
func (h *byLabelEventHandler) Delete(ctx context.Context, evt event.TypedDeleteEvent[*unstructured.Unstructured], q workqueue.TypedRateLimitingInterface[mcreconcile.Request]) {
94+
h.enqueue(ctx, evt.Object, q)
95+
}
96+
97+
func (h *byLabelEventHandler) Generic(ctx context.Context, evt event.TypedGenericEvent[*unstructured.Unstructured], q workqueue.TypedRateLimitingInterface[mcreconcile.Request]) {
98+
h.enqueue(ctx, evt.Object, q)
99+
}
100+
101+
func (h *byLabelEventHandler) enqueue(ctx context.Context, obj *unstructured.Unstructured, q workqueue.TypedRateLimitingInterface[mcreconcile.Request]) {
102+
// Build the template context using the changed related object.
103+
data := map[string]any{
104+
"watchObject": map[string]any{
105+
"name": obj.GetName(),
106+
"namespace": obj.GetNamespace(),
107+
"labels": obj.GetLabels(),
108+
},
109+
}
110+
111+
// Evaluate each label template to build the selector.
112+
matchingLabels := ctrlruntimeclient.MatchingLabels{}
113+
for key, tpl := range h.labelTemplates {
114+
value, err := templating.Render(tpl, data)
115+
if err != nil {
116+
h.log.Warnw("Failed to evaluate byLabel template", "key", key, "template", tpl, "error", err)
117+
return
118+
}
119+
matchingLabels[key] = value
120+
}
121+
122+
// List primary objects matching the derived label selector.
123+
primaryList := &unstructured.UnstructuredList{}
124+
primaryList.SetAPIVersion(h.primaryDummy.GetAPIVersion())
125+
primaryList.SetKind(h.primaryDummy.GetKind() + "List")
126+
127+
if err := h.client.List(ctx, primaryList, matchingLabels); err != nil {
128+
h.log.Warnw("Failed to list primary objects for byLabel watch", "selector", matchingLabels, "error", err)
129+
return
130+
}
131+
132+
for i := range primaryList.Items {
133+
primary := &primaryList.Items[i]
134+
q.Add(mcreconcile.Request{
135+
ClusterName: h.clusterName,
136+
Request: reconcile.Request{
137+
NamespacedName: types.NamespacedName{
138+
Namespace: primary.GetNamespace(),
139+
Name: primary.GetName(),
140+
},
141+
},
142+
})
143+
}
144+
}

sdk/apis/syncagent/v1alpha1/published_resource.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,36 @@ type RelatedResourceSpec struct {
256256
// Mutation configures optional transformation rules for the related resource.
257257
// Status mutations are only performed when the related resource originates in kcp.
258258
Mutation *ResourceMutationSpec `json:"mutation,omitempty"`
259+
260+
// Watch configures how the agent identifies the owning primary object when a related
261+
// resource with origin: kcp changes. When set, the agent sets up a watch on the related
262+
// resource type and uses the configured rule to enqueue the correct primary object.
263+
// Without this field, changes to origin:kcp related resources do not trigger reconciliation.
264+
Watch *RelatedResourceWatch `json:"watch,omitempty"`
265+
}
266+
267+
// RelatedResourceWatch configures how the watch handler maps a changed related resource
268+
// back to its owning primary object.
269+
// Exactly one of ByOwner or ByLabel must be set.
270+
type RelatedResourceWatch struct {
271+
// ByOwner configures the watch handler to inspect the OwnerReferences of the changed
272+
// object. When an OwnerReference with the given Kind is found, the referenced owner
273+
// is enqueued as the primary object.
274+
// +optional
275+
ByOwner *RelatedResourceWatchByOwner `json:"byOwner,omitempty"`
276+
277+
// ByLabel configures the watch handler to list primary objects matching a label selector
278+
// derived from the changed object. Each map key is a label key on the primary object;
279+
// each value is a Go template expression evaluated with the changed object available as
280+
// .watchObject (with fields .name, .namespace, .labels).
281+
// +optional
282+
ByLabel map[string]string `json:"byLabel,omitempty"`
283+
}
284+
285+
// RelatedResourceWatchByOwner configures reverse lookup via OwnerReferences.
286+
type RelatedResourceWatchByOwner struct {
287+
// Kind is the Kind to look for in the OwnerReferences of the changed related object.
288+
Kind string `json:"kind"`
259289
}
260290

261291
// RelatedResourceProjection describes how the source GVK of a related resource (i.e.

sdk/apis/syncagent/v1alpha1/zz_generated.deepcopy.go

Lines changed: 47 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)