Skip to content

Commit 708f99a

Browse files
authored
Merge pull request #519 from mirzakopic/feat/objectmetadata
feat(konnector): annotate synced objects with source namespace and UID
2 parents a0b94dc + 7456354 commit 708f99a

8 files changed

Lines changed: 260 additions & 4 deletions

File tree

pkg/konnector/controllers/cluster/claimedresources/claimedresources_reconciler.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import (
2828
"k8s.io/apimachinery/pkg/util/runtime"
2929
"k8s.io/klog/v2"
3030

31+
konnectortypes "github.com/kube-bind/kube-bind/pkg/konnector/types"
3132
kubebindv1alpha2 "github.com/kube-bind/kube-bind/sdk/apis/kubebind/v1alpha2"
3233
)
3334

@@ -117,6 +118,8 @@ func (r *readReconciler) reconcile(ctx context.Context, providerNamespace, name
117118
logger.Info("Creating missing consumer object", "consumerNamespace", consumerNS, "consumerName", providerObj.GetName())
118119

119120
candidate := candidateFromOwnerObj(consumerNS, providerObj)
121+
konnectortypes.SetSourceMetadataAnnotations(candidate, providerObj.GetNamespace(), string(providerObj.GetUID()),
122+
konnectortypes.ProviderNamespaceAnnotationKey, konnectortypes.ProviderUIDAnnotationKey)
120123
r.makeProviderOwner(candidate)
121124

122125
if _, err := r.createConsumerObject(ctx, candidate); err != nil {
@@ -132,6 +135,8 @@ func (r *readReconciler) reconcile(ctx context.Context, providerNamespace, name
132135
}
133136

134137
candidate := candidateFromOwnerObj(consumerNS, providerObj)
138+
konnectortypes.SetSourceMetadataAnnotations(candidate, providerObj.GetNamespace(), string(providerObj.GetUID()),
139+
konnectortypes.ProviderNamespaceAnnotationKey, konnectortypes.ProviderUIDAnnotationKey)
135140
current := candidateFromOwnerObj(consumerNS, consumerObj)
136141
if !equality.Semantic.DeepEqual(candidate, current) {
137142
logger.Info("Updating consumer object data", "consumerNamespace", consumerNS, "consumerName", consumerObj.GetName())
@@ -161,6 +166,8 @@ func (r *readReconciler) reconcile(ctx context.Context, providerNamespace, name
161166
}
162167

163168
candidate := candidateFromOwnerObj(providerNamespace, ownerCandidate)
169+
konnectortypes.SetSourceMetadataAnnotations(candidate, ownerCandidate.GetNamespace(), string(ownerCandidate.GetUID()),
170+
konnectortypes.ConsumerNamespaceAnnotationKey, konnectortypes.ConsumerUIDAnnotationKey)
164171
r.makeConsumerOwner(candidate)
165172

166173
if errors.IsNotFound(providerErr) {
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
/*
2+
Copyright 2026 The Kube Bind 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 claimedresources
18+
19+
import (
20+
"testing"
21+
22+
"github.com/stretchr/testify/require"
23+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
24+
25+
konnectortypes "github.com/kube-bind/kube-bind/pkg/konnector/types"
26+
)
27+
28+
func TestSetSourceMetadataAnnotations(t *testing.T) {
29+
tests := []struct {
30+
name string
31+
obj *unstructured.Unstructured
32+
sourceNS string
33+
sourceUID string
34+
nsKey string
35+
uidKey string
36+
expectedAnnotations map[string]string
37+
}{
38+
{
39+
name: "provider annotations on empty object",
40+
obj: &unstructured.Unstructured{},
41+
sourceNS: "provider-ns",
42+
sourceUID: "provider-uid-123",
43+
nsKey: konnectortypes.ProviderNamespaceAnnotationKey,
44+
uidKey: konnectortypes.ProviderUIDAnnotationKey,
45+
expectedAnnotations: map[string]string{
46+
konnectortypes.ProviderNamespaceAnnotationKey: "provider-ns",
47+
konnectortypes.ProviderUIDAnnotationKey: "provider-uid-123",
48+
},
49+
},
50+
{
51+
name: "consumer annotations on empty object",
52+
obj: &unstructured.Unstructured{},
53+
sourceNS: "consumer-ns",
54+
sourceUID: "consumer-uid-456",
55+
nsKey: konnectortypes.ConsumerNamespaceAnnotationKey,
56+
uidKey: konnectortypes.ConsumerUIDAnnotationKey,
57+
expectedAnnotations: map[string]string{
58+
konnectortypes.ConsumerNamespaceAnnotationKey: "consumer-ns",
59+
konnectortypes.ConsumerUIDAnnotationKey: "consumer-uid-456",
60+
},
61+
},
62+
{
63+
name: "preserves existing annotations",
64+
obj: func() *unstructured.Unstructured {
65+
obj := &unstructured.Unstructured{}
66+
obj.SetAnnotations(map[string]string{
67+
"existing-key": "existing-value",
68+
})
69+
return obj
70+
}(),
71+
sourceNS: "my-ns",
72+
sourceUID: "my-uid",
73+
nsKey: konnectortypes.ProviderNamespaceAnnotationKey,
74+
uidKey: konnectortypes.ProviderUIDAnnotationKey,
75+
expectedAnnotations: map[string]string{
76+
konnectortypes.ProviderNamespaceAnnotationKey: "my-ns",
77+
konnectortypes.ProviderUIDAnnotationKey: "my-uid",
78+
"existing-key": "existing-value",
79+
},
80+
},
81+
}
82+
83+
for _, tt := range tests {
84+
t.Run(tt.name, func(t *testing.T) {
85+
t.Parallel()
86+
87+
konnectortypes.SetSourceMetadataAnnotations(tt.obj, tt.sourceNS, tt.sourceUID, tt.nsKey, tt.uidKey)
88+
89+
annotations := tt.obj.GetAnnotations()
90+
for key, expected := range tt.expectedAnnotations {
91+
require.Equal(t, expected, annotations[key], "annotation %s mismatch", key)
92+
}
93+
})
94+
}
95+
}
96+
97+
func TestCandidateFromOwnerObjPreservesKubeBindAnnotations(t *testing.T) {
98+
obj := &unstructured.Unstructured{}
99+
obj.SetAnnotations(map[string]string{
100+
konnectortypes.ProviderNamespaceAnnotationKey: "provider-ns",
101+
konnectortypes.ProviderUIDAnnotationKey: "provider-uid",
102+
konnectortypes.ConsumerNamespaceAnnotationKey: "consumer-ns",
103+
konnectortypes.ConsumerUIDAnnotationKey: "consumer-uid",
104+
"kcp.io/cluster": "should-be-stripped",
105+
"user-annotation": "should-be-kept",
106+
})
107+
obj.SetNamespace("original-ns")
108+
obj.SetName("test-obj")
109+
110+
candidate := candidateFromOwnerObj("target-ns", obj)
111+
112+
annotations := candidate.GetAnnotations()
113+
require.Equal(t, "provider-ns", annotations[konnectortypes.ProviderNamespaceAnnotationKey])
114+
require.Equal(t, "provider-uid", annotations[konnectortypes.ProviderUIDAnnotationKey])
115+
require.Equal(t, "consumer-ns", annotations[konnectortypes.ConsumerNamespaceAnnotationKey])
116+
require.Equal(t, "consumer-uid", annotations[konnectortypes.ConsumerUIDAnnotationKey])
117+
require.Equal(t, "should-be-kept", annotations["user-annotation"])
118+
_, hasKcp := annotations["kcp.io/cluster"]
119+
require.False(t, hasKcp, "kcp.io/cluster annotation should be stripped")
120+
}

pkg/konnector/controllers/cluster/serviceexport/spec/spec_reconcile.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,10 @@ func (r *reconciler) reconcile(ctx context.Context, obj *unstructured.Unstructur
106106
return err
107107
}
108108

109+
// Annotate the provider-side object with the consumer source metadata.
110+
konnectortypes.SetSourceMetadataAnnotations(upstream, obj.GetNamespace(), string(obj.GetUID()),
111+
konnectortypes.ConsumerNamespaceAnnotationKey, konnectortypes.ConsumerUIDAnnotationKey)
112+
109113
// let the isolation perform any changes it desires
110114
if err := r.isolationStrategy.MutateMetadataAndSpec(upstream, *providerKey); err != nil {
111115
return err
@@ -147,6 +151,10 @@ func (r *reconciler) reconcile(ctx context.Context, obj *unstructured.Unstructur
147151
return err
148152
}
149153

154+
// (Re)set the consumer source metadata annotations.
155+
konnectortypes.SetSourceMetadataAnnotations(upstream, obj.GetNamespace(), string(obj.GetUID()),
156+
konnectortypes.ConsumerNamespaceAnnotationKey, konnectortypes.ConsumerUIDAnnotationKey)
157+
150158
// just in case, checking for finalizer
151159
if obj, err = r.ensureDownstreamFinalizer(ctx, obj); err != nil {
152160
return err

pkg/konnector/controllers/cluster/serviceexport/spec/spec_reconcile_test.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,62 @@ func TestInjectClusterNamespace(t *testing.T) {
7777
}
7878
}
7979

80+
func TestSetSourceAnnotations(t *testing.T) {
81+
tests := []struct {
82+
name string
83+
obj *unstructured.Unstructured
84+
consumerNamespace string
85+
consumerUID string
86+
expectedAnnotations map[string]string
87+
}{
88+
{
89+
name: "no existing annotations",
90+
obj: &unstructured.Unstructured{},
91+
consumerNamespace: "my-namespace",
92+
consumerUID: "abc-123-def",
93+
expectedAnnotations: map[string]string{
94+
konnectortypes.ConsumerNamespaceAnnotationKey: "my-namespace",
95+
konnectortypes.ConsumerUIDAnnotationKey: "abc-123-def",
96+
},
97+
},
98+
{
99+
name: "with existing cluster namespace annotation",
100+
obj: newObjectWithClusterNs("kube-bind-zlp9m"),
101+
consumerNamespace: "other-namespace",
102+
consumerUID: "xyz-456-ghi",
103+
expectedAnnotations: map[string]string{
104+
konnectortypes.ConsumerNamespaceAnnotationKey: "other-namespace",
105+
konnectortypes.ConsumerUIDAnnotationKey: "xyz-456-ghi",
106+
konnectortypes.ClusterNamespaceAnnotationKey: "kube-bind-zlp9m",
107+
},
108+
},
109+
{
110+
name: "cluster-scoped object with empty namespace",
111+
obj: &unstructured.Unstructured{},
112+
consumerNamespace: "",
113+
consumerUID: "uid-789",
114+
expectedAnnotations: map[string]string{
115+
konnectortypes.ConsumerNamespaceAnnotationKey: "",
116+
konnectortypes.ConsumerUIDAnnotationKey: "uid-789",
117+
},
118+
},
119+
}
120+
121+
for _, tt := range tests {
122+
t.Run(tt.name, func(t *testing.T) {
123+
t.Parallel()
124+
125+
konnectortypes.SetSourceMetadataAnnotations(tt.obj, tt.consumerNamespace, tt.consumerUID,
126+
konnectortypes.ConsumerNamespaceAnnotationKey, konnectortypes.ConsumerUIDAnnotationKey)
127+
128+
annotations := tt.obj.GetAnnotations()
129+
for key, expected := range tt.expectedAnnotations {
130+
require.Equal(t, expected, annotations[key], "annotation %s mismatch", key)
131+
}
132+
})
133+
}
134+
}
135+
80136
func newObjectWithClusterNs(providerNamespace string) *unstructured.Unstructured {
81137
obj := &unstructured.Unstructured{}
82138
ans := map[string]string{

pkg/konnector/controllers/cluster/serviceexport/status/status_controller.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,9 @@ func NewController(
110110

111111
return obj.DeepCopy(), nil
112112
},
113+
updateConsumerObject: func(ctx context.Context, obj *unstructured.Unstructured) (*unstructured.Unstructured, error) {
114+
return consumerClient.Resource(gvr).Namespace(obj.GetNamespace()).Update(ctx, obj, metav1.UpdateOptions{})
115+
},
113116
updateConsumerObjectStatus: func(ctx context.Context, obj *unstructured.Unstructured) (*unstructured.Unstructured, error) {
114117
return consumerClient.Resource(gvr).Namespace(obj.GetNamespace()).UpdateStatus(ctx, obj, metav1.UpdateOptions{})
115118
},

pkg/konnector/controllers/cluster/serviceexport/status/status_reconcile.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,14 @@ import (
2727
"k8s.io/klog/v2"
2828

2929
"github.com/kube-bind/kube-bind/pkg/konnector/controllers/cluster/serviceexport/isolation"
30+
konnectortypes "github.com/kube-bind/kube-bind/pkg/konnector/types"
3031
)
3132

3233
type reconciler struct {
3334
isolationStrategy isolation.Strategy
3435

3536
getConsumerObject func(ns, name string) (*unstructured.Unstructured, error)
37+
updateConsumerObject func(ctx context.Context, obj *unstructured.Unstructured) (*unstructured.Unstructured, error)
3638
updateConsumerObjectStatus func(ctx context.Context, obj *unstructured.Unstructured) (*unstructured.Unstructured, error)
3739

3840
deleteProviderObject func(ctx context.Context, ns, name string) error
@@ -77,6 +79,20 @@ func (r *reconciler) reconcile(ctx context.Context, obj *unstructured.Unstructur
7779
return err
7880
}
7981

82+
// Set provider source metadata annotations on the consumer object so
83+
// the consumer side can trace where the object came from. This requires
84+
// a full object update because UpdateStatus does not persist metadata.
85+
annotated := downstream.DeepCopy()
86+
konnectortypes.SetSourceMetadataAnnotations(annotated, obj.GetNamespace(), string(obj.GetUID()),
87+
konnectortypes.ProviderNamespaceAnnotationKey, konnectortypes.ProviderUIDAnnotationKey)
88+
if !reflect.DeepEqual(downstream.GetAnnotations(), annotated.GetAnnotations()) {
89+
logger.Info("Updating downstream object provider annotations")
90+
var err error
91+
if downstream, err = r.updateConsumerObject(ctx, annotated); err != nil {
92+
return err
93+
}
94+
}
95+
8096
// let the isolation perform any changes it desires
8197
if err := r.isolationStrategy.MutateStatus(downstream, *consumerKey); err != nil {
8298
return err

pkg/konnector/types/types.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,40 @@ limitations under the License.
1616

1717
package types
1818

19+
import (
20+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
21+
)
22+
1923
// ClusterNamespaceAnnotationKey is the annotation key to identify the
2024
// cluster namespace that any synced object on the provider side belongs to.
2125
// This annotation is set on all synced objects, regardless of scope or
2226
// isolation mode.
2327
const ClusterNamespaceAnnotationKey = "kube-bind.io/cluster-namespace"
28+
29+
// ConsumerNamespaceAnnotationKey is the annotation key set on provider-side
30+
// objects to identify the consumer namespace the source object lives in.
31+
const ConsumerNamespaceAnnotationKey = "kube-bind.io/consumer-namespace"
32+
33+
// ConsumerUIDAnnotationKey is the annotation key set on provider-side objects
34+
// to uniquely identify the consumer source object.
35+
const ConsumerUIDAnnotationKey = "kube-bind.io/consumer-uid"
36+
37+
// ProviderNamespaceAnnotationKey is the annotation key set on consumer-side
38+
// objects to identify the provider namespace the source object lives in.
39+
const ProviderNamespaceAnnotationKey = "kube-bind.io/provider-namespace"
40+
41+
// ProviderUIDAnnotationKey is the annotation key set on consumer-side objects
42+
// to uniquely identify the provider source object.
43+
const ProviderUIDAnnotationKey = "kube-bind.io/provider-uid"
44+
45+
// SetSourceMetadataAnnotations sets source metadata annotations on a synced
46+
// object so the receiving side can trace where the object came from.
47+
func SetSourceMetadataAnnotations(obj *unstructured.Unstructured, sourceNS, sourceUID, nsKey, uidKey string) {
48+
annotations := obj.GetAnnotations()
49+
if annotations == nil {
50+
annotations = map[string]string{}
51+
}
52+
annotations[nsKey] = sourceNS
53+
annotations[uidKey] = sourceUID
54+
obj.SetAnnotations(annotations)
55+
}

test/e2e/bind/happy-case_test.go

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -447,6 +447,13 @@ func testHappyCase(
447447
consumer.providerContractNamespace = ourInstance.GetAnnotations()[types.ClusterNamespaceAnnotationKey]
448448
require.NotEmpty(t, consumer.providerContractNamespace, "cluster namespace annotation must always exist")
449449
require.NotEqual(t, consumer.providerContractNamespace, "unknown")
450+
451+
// Verify consumer source metadata annotations are set on the provider-side object.
452+
annotations := ourInstance.GetAnnotations()
453+
_, hasConsumerNS := annotations[types.ConsumerNamespaceAnnotationKey]
454+
require.True(t, hasConsumerNS, "consumer-namespace annotation must exist on provider object")
455+
consumerUID := annotations[types.ConsumerUIDAnnotationKey]
456+
require.NotEmpty(t, consumerUID, "consumer-uid annotation must exist on provider object")
450457
},
451458
},
452459
{
@@ -712,19 +719,26 @@ func testHappyCase(
712719
})
713720
require.NoError(t, err)
714721

722+
var consumerObj *unstructured.Unstructured
715723
require.Eventually(t, func() bool {
716-
var obj *unstructured.Unstructured
717724
var err error
718725
if consumerResourceScope == apiextensionsv1.NamespaceScoped {
719-
obj, err = consumer.consumerClient.Namespace(consumer.consumerObjectNamespace).Get(ctx, instanceName, metav1.GetOptions{})
726+
consumerObj, err = consumer.consumerClient.Namespace(consumer.consumerObjectNamespace).Get(ctx, instanceName, metav1.GetOptions{})
720727
} else {
721-
obj, err = consumer.consumerClient.Get(ctx, instanceName, metav1.GetOptions{})
728+
consumerObj, err = consumer.consumerClient.Get(ctx, instanceName, metav1.GetOptions{})
722729
}
723730
require.NoError(t, err)
724-
value, _, err := unstructured.NestedString(obj.Object, "status", "result")
731+
value, _, err := unstructured.NestedString(consumerObj.Object, "status", "result")
725732
require.NoError(t, err)
726733
return value == fmt.Sprintf("Ready to ride from %s", consumer.name)
727734
}, wait.ForeverTestTimeout, time.Millisecond*100, "waiting for the %s instance to be updated downstream for %s", serviceGVR.Resource, consumer.name)
735+
736+
// Verify provider source metadata annotations are set on the consumer-side object.
737+
consumerAnnotations := consumerObj.GetAnnotations()
738+
_, hasProviderNS := consumerAnnotations[types.ProviderNamespaceAnnotationKey]
739+
require.True(t, hasProviderNS, "provider-namespace annotation must exist on consumer object")
740+
providerUID := consumerAnnotations[types.ProviderUIDAnnotationKey]
741+
require.NotEmpty(t, providerUID, "provider-uid annotation must exist on consumer object")
728742
},
729743
},
730744

0 commit comments

Comments
 (0)