Skip to content

Commit b2567c7

Browse files
committed
add identity resolution
1 parent 983bd4f commit b2567c7

8 files changed

Lines changed: 346 additions & 84 deletions

File tree

v2/README.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,11 @@ v2/
4949
multi-version) are accepted; the provider stays the enforcing side.
5050
- The konnector maintains a `coordination.k8s.io/Lease` per Connection on the
5151
provider (heartbeat) — the hook a service-layer reaper keys off.
52+
- **kcp-aware cluster identity**: the provider's stable identity is the kcp
53+
`LogicalCluster` ("cluster") UID when present (a kcp workspace serves
54+
`core.kcp.io`, and has no `kube-system`), falling back to the `kube-system`
55+
namespace UID on plain Kubernetes. A provider RBAC denial on the identity read
56+
surfaces as `PermissionDenied`.
5257
- `relatedResources` sync selected Secrets/ConfigMaps in the declared direction,
5358
scoped like the binding, GC'd when they stop matching or on unbind.
5459
- The provider side is the **multicluster-runtime engaged cluster** for each
@@ -67,9 +72,7 @@ v2/
6772

6873
Known POC simplifications (tracked against the proposal): the `Mapper`
6974
extension is not implemented; OpenAPI synthesis is best-effort (fidelity limits
70-
above); cluster identity still derives from the `kube-system` namespace UID, so
71-
true kcp logical clusters (no kube-system) need an alternative identity source;
72-
and syncer stop-on-disengage + productionization (RBAC/HA/Helm) remain.
75+
above); and syncer stop-on-disengage + productionization (RBAC/HA/Helm) remain.
7376

7477
## Build
7578

v2/konnector/engine/remote/remote.go

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,10 @@ import (
2323
"fmt"
2424

2525
corev1 "k8s.io/api/core/v1"
26+
apierrors "k8s.io/apimachinery/pkg/api/errors"
27+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
2628
"k8s.io/apimachinery/pkg/runtime"
29+
"k8s.io/apimachinery/pkg/runtime/schema"
2730
"k8s.io/apimachinery/pkg/types"
2831
"k8s.io/client-go/rest"
2932
"k8s.io/client-go/tools/clientcmd"
@@ -72,18 +75,37 @@ func RestConfigFromConnection(ctx context.Context, c client.Client, conn *corev1
7275
return cfg, nil
7376
}
7477

75-
// ClusterUID returns a stable identity for the cluster behind c, derived from
76-
// the kube-system namespace UID. This works on plain Kubernetes providers.
78+
// logicalClusterGVK is kcp's per-workspace singleton identity object.
79+
var logicalClusterGVK = schema.GroupVersionKind{Group: "core.kcp.io", Version: "v1alpha1", Kind: "LogicalCluster"}
80+
81+
// ClusterUID returns a stable identity for the cluster behind c. It tries, in
82+
// order:
83+
// - the kcp LogicalCluster "cluster" object's UID (kcp / logical clusters,
84+
// which have no kube-system namespace) — preferred when present, since only
85+
// a kcp-shaped cluster serves core.kcp.io; then
86+
// - the kube-system namespace UID (plain Kubernetes).
7787
//
78-
// TODO(v2): kcp / logical-cluster providers have no kube-system namespace; the
79-
// OpenAPI path will need a different identity source (proposal gap #7).
88+
// A Forbidden error from a source is surfaced (so the caller can report
89+
// PermissionDenied) rather than silently falling through.
8090
func ClusterUID(ctx context.Context, c client.Client) (string, error) {
91+
lc := &unstructured.Unstructured{}
92+
lc.SetGroupVersionKind(logicalClusterGVK)
93+
lcErr := c.Get(ctx, types.NamespacedName{Name: "cluster"}, lc)
94+
if lcErr == nil && lc.GetUID() != "" {
95+
return string(lc.GetUID()), nil
96+
}
97+
if apierrors.IsForbidden(lcErr) {
98+
return "", lcErr
99+
}
100+
81101
var ns corev1.Namespace
82-
if err := c.Get(ctx, types.NamespacedName{Name: "kube-system"}, &ns); err != nil {
83-
return "", fmt.Errorf("getting kube-system namespace for cluster identity: %w", err)
102+
nsErr := c.Get(ctx, types.NamespacedName{Name: "kube-system"}, &ns)
103+
if nsErr == nil && ns.UID != "" {
104+
return string(ns.UID), nil
84105
}
85-
if ns.UID == "" {
86-
return "", fmt.Errorf("kube-system namespace UID is empty")
106+
if apierrors.IsForbidden(nsErr) {
107+
return "", nsErr
87108
}
88-
return string(ns.UID), nil
109+
110+
return "", fmt.Errorf("cannot determine cluster identity (no LogicalCluster: %v; no kube-system namespace: %v)", lcErr, nsErr)
89111
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
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 remote
18+
19+
import (
20+
"context"
21+
"testing"
22+
23+
"github.com/stretchr/testify/require"
24+
corev1 "k8s.io/api/core/v1"
25+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
26+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
27+
"k8s.io/apimachinery/pkg/runtime"
28+
"k8s.io/apimachinery/pkg/types"
29+
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
30+
"sigs.k8s.io/controller-runtime/pkg/client/fake"
31+
)
32+
33+
func identityScheme(t *testing.T) *runtime.Scheme {
34+
t.Helper()
35+
s := runtime.NewScheme()
36+
require.NoError(t, clientgoscheme.AddToScheme(s))
37+
// Register the kcp LogicalCluster GVK as unstructured so the fake client can
38+
// serve it.
39+
s.AddKnownTypeWithName(logicalClusterGVK, &unstructured.Unstructured{})
40+
s.AddKnownTypeWithName(logicalClusterGVK.GroupVersion().WithKind("LogicalClusterList"), &unstructured.UnstructuredList{})
41+
return s
42+
}
43+
44+
func logicalCluster(uid string) *unstructured.Unstructured {
45+
lc := &unstructured.Unstructured{}
46+
lc.SetGroupVersionKind(logicalClusterGVK)
47+
lc.SetName("cluster")
48+
lc.SetUID(types.UID(uid))
49+
return lc
50+
}
51+
52+
func TestClusterUID_KubeSystem(t *testing.T) {
53+
s := identityScheme(t)
54+
c := fake.NewClientBuilder().WithScheme(s).WithObjects(
55+
&corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "kube-system", UID: "ks-uid"}},
56+
).Build()
57+
58+
uid, err := ClusterUID(context.Background(), c)
59+
require.NoError(t, err)
60+
require.Equal(t, "ks-uid", uid, "plain Kubernetes should use the kube-system namespace UID")
61+
}
62+
63+
func TestClusterUID_KCPLogicalClusterFallback(t *testing.T) {
64+
s := identityScheme(t)
65+
// No kube-system namespace (a kcp logical cluster) — only a LogicalCluster.
66+
c := fake.NewClientBuilder().WithScheme(s).WithObjects(logicalCluster("lc-uid")).Build()
67+
68+
uid, err := ClusterUID(context.Background(), c)
69+
require.NoError(t, err)
70+
require.Equal(t, "lc-uid", uid, "a kcp logical cluster should fall back to the LogicalCluster UID")
71+
}
72+
73+
func TestClusterUID_LogicalClusterWinsWhenBothPresent(t *testing.T) {
74+
s := identityScheme(t)
75+
c := fake.NewClientBuilder().WithScheme(s).WithObjects(
76+
&corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "kube-system", UID: "ks-uid"}},
77+
logicalCluster("lc-uid"),
78+
).Build()
79+
80+
uid, err := ClusterUID(context.Background(), c)
81+
require.NoError(t, err)
82+
require.Equal(t, "lc-uid", uid, "a kcp-shaped cluster (LogicalCluster present) should prefer the LogicalCluster UID")
83+
}
84+
85+
func TestClusterUID_NoSource(t *testing.T) {
86+
s := identityScheme(t)
87+
c := fake.NewClientBuilder().WithScheme(s).Build()
88+
89+
_, err := ClusterUID(context.Background(), c)
90+
require.Error(t, err, "with neither source the identity is undeterminable")
91+
}

v2/konnector/test/e2e/framework/framework.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import (
3636
apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
3737
apierrors "k8s.io/apimachinery/pkg/api/errors"
3838
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
39+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
3940
apimachineryruntime "k8s.io/apimachinery/pkg/runtime"
4041
"k8s.io/apimachinery/pkg/runtime/schema"
4142
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
@@ -228,6 +229,58 @@ func (e *Env) CopyProviderSecret(t *testing.T, name string) *corev1.Secret {
228229
}
229230
}
230231

232+
// MakeProviderKCPLike turns the provider into a kcp-shaped cluster: it installs
233+
// a LogicalCluster CRD + the singleton "cluster" object (the kcp per-workspace
234+
// identity object). On a real kcp workspace there is no kube-system namespace,
235+
// so identity comes from the LogicalCluster; the konnector prefers it whenever
236+
// it is present (which only a kcp-shaped cluster serves), so we don't need to
237+
// remove kube-system here (envtest has no namespace controller to finalize it
238+
// away anyway). Returns the LogicalCluster's UID.
239+
func (e *Env) MakeProviderKCPLike(t *testing.T, ctx context.Context) string {
240+
t.Helper()
241+
242+
require.NoError(t, e.ProviderClient.Create(ctx, logicalClusterCRD()))
243+
lcGVR := schema.GroupVersionResource{Group: "core.kcp.io", Version: "v1alpha1", Resource: "logicalclusters"}
244+
require.Eventually(t, func() bool {
245+
_, err := e.ProviderDyn.Resource(lcGVR).List(ctx, metav1.ListOptions{})
246+
return err == nil
247+
}, 30*time.Second, 200*time.Millisecond, "provider should serve LogicalCluster")
248+
249+
lc := &unstructured.Unstructured{}
250+
lc.SetGroupVersionKind(schema.GroupVersionKind{Group: "core.kcp.io", Version: "v1alpha1", Kind: "LogicalCluster"})
251+
lc.SetName("cluster")
252+
require.NoError(t, e.ProviderClient.Create(ctx, lc))
253+
require.NoError(t, e.ProviderClient.Get(ctx, client.ObjectKey{Name: "cluster"}, lc))
254+
uid := string(lc.GetUID())
255+
require.NotEmpty(t, uid)
256+
return uid
257+
}
258+
259+
func logicalClusterCRD() *apiextensionsv1.CustomResourceDefinition {
260+
preserve := true
261+
return &apiextensionsv1.CustomResourceDefinition{
262+
ObjectMeta: metav1.ObjectMeta{Name: "logicalclusters.core.kcp.io"},
263+
Spec: apiextensionsv1.CustomResourceDefinitionSpec{
264+
Group: "core.kcp.io",
265+
Names: apiextensionsv1.CustomResourceDefinitionNames{
266+
Plural: "logicalclusters", Singular: "logicalcluster", Kind: "LogicalCluster", ListKind: "LogicalClusterList",
267+
},
268+
Scope: apiextensionsv1.ClusterScoped,
269+
Versions: []apiextensionsv1.CustomResourceDefinitionVersion{{
270+
Name: "v1alpha1", Served: true, Storage: true,
271+
Schema: &apiextensionsv1.CustomResourceValidation{
272+
OpenAPIV3Schema: &apiextensionsv1.JSONSchemaProps{
273+
Type: "object",
274+
Properties: map[string]apiextensionsv1.JSONSchemaProps{
275+
"spec": {Type: "object", XPreserveUnknownFields: &preserve},
276+
},
277+
},
278+
},
279+
}},
280+
},
281+
}
282+
}
283+
231284
// InstallExportedWidgetCRD installs the demo Widget CRD on the provider, labeled
232285
// as exported.
233286
func (e *Env) InstallExportedWidgetCRD(t *testing.T) schema.GroupVersionResource {
Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,12 @@ import (
3333
corev1alpha1 "github.com/kube-bind/kube-bind/v2/sdk/apis/core/v1alpha1"
3434
)
3535

36-
// TestSlimCoreTier2 exercises the Tier-2 "Decided" features. It shares one
37-
// envtest pair (spinning a fresh one per case exhausts envtest resources):
36+
// TestSlimCorePolicies exercises the binding/connection policy knobs. It shares
37+
// one envtest pair (spinning a fresh one per case exhausts envtest resources):
3838
// deletion-policy Orphan, updatePolicy Always, autoBind, pullPolicy All, and
3939
// PermissionDenied. The widgets-dependent cases run first, before the extra
4040
// Connections (autoBind/All) re-stamp the widgets CRD.
41-
func TestSlimCoreTier2(t *testing.T) {
41+
func TestSlimCorePolicies(t *testing.T) {
4242
env := framework.Start(t)
4343
ctx := context.Background()
4444
gvr := env.InstallExportedWidgetCRD(t)
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
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 e2e
18+
19+
import (
20+
"context"
21+
"testing"
22+
"time"
23+
24+
"github.com/stretchr/testify/require"
25+
corev1 "k8s.io/api/core/v1"
26+
apierrors "k8s.io/apimachinery/pkg/api/errors"
27+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
28+
"sigs.k8s.io/controller-runtime/pkg/client"
29+
30+
"github.com/kube-bind/kube-bind/v2/konnector/test/e2e/framework"
31+
corev1alpha1 "github.com/kube-bind/kube-bind/v2/sdk/apis/core/v1alpha1"
32+
)
33+
34+
// TestSlimCoreRelatedResources binds the Widget API with a relatedResources rule
35+
// that syncs label-selected Secrets FromProvider, and verifies sync + GC.
36+
func TestSlimCoreRelatedResources(t *testing.T) {
37+
env := framework.Start(t)
38+
ctx := context.Background()
39+
env.InstallExportedWidgetCRD(t)
40+
41+
require.NoError(t, env.ConsumerClient.Create(ctx, &corev1alpha1.Connection{
42+
ObjectMeta: metav1.ObjectMeta{Name: "demo-provider"},
43+
Spec: corev1alpha1.ConnectionSpec{
44+
KubeconfigSecretRef: corev1alpha1.SecretKeyRef{Namespace: framework.KubeBindNamespace, Name: "demo-provider-kubeconfig", Key: "kubeconfig"},
45+
Schema: corev1alpha1.SchemaPolicy{Source: corev1alpha1.SchemaSourceCRD},
46+
},
47+
}))
48+
require.NoError(t, env.ConsumerClient.Create(ctx, &corev1alpha1.ClusterBinding{
49+
ObjectMeta: metav1.ObjectMeta{Name: "widgets"},
50+
Spec: corev1alpha1.BindingSpec{
51+
ConnectionRef: corev1alpha1.ConnectionRef{Name: "demo-provider"},
52+
APIs: []corev1alpha1.APIRef{{Name: widgetCRDName}},
53+
RelatedResources: []corev1alpha1.RelatedResource{{
54+
Group: "",
55+
Resource: "secrets",
56+
Direction: corev1alpha1.FromProvider,
57+
Selector: &corev1alpha1.RelatedResourceSelector{
58+
LabelSelector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": "widget"}},
59+
},
60+
}},
61+
},
62+
}))
63+
framework.WaitForConditionTrue(t, func() ([]metav1.Condition, error) {
64+
cb := &corev1alpha1.ClusterBinding{}
65+
err := env.ConsumerClient.Get(ctx, client.ObjectKey{Name: "widgets"}, cb)
66+
return cb.Status.Conditions, err
67+
}, corev1alpha1.ConditionReady)
68+
69+
secretKey := client.ObjectKey{Namespace: "default", Name: "widget-creds"}
70+
71+
t.Run("a label-selected provider Secret syncs to the consumer", func(t *testing.T) {
72+
require.NoError(t, env.ProviderClient.Create(ctx, &corev1.Secret{
73+
ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: "widget-creds", Labels: map[string]string{"app": "widget"}},
74+
StringData: map[string]string{"token": "s3cr3t"},
75+
}))
76+
require.Eventually(t, func() bool {
77+
s := &corev1.Secret{}
78+
if err := env.ConsumerClient.Get(ctx, secretKey, s); err != nil {
79+
return false
80+
}
81+
return string(s.Data["token"]) == "s3cr3t" &&
82+
s.Labels[corev1alpha1.LabelManaged] == "true" &&
83+
s.Annotations[corev1alpha1.AnnotationRelatedBinding] != ""
84+
}, 30*time.Second, 200*time.Millisecond, "the label-selected provider Secret should sync to the consumer")
85+
})
86+
87+
t.Run("the synced copy is GC'd when it stops matching", func(t *testing.T) {
88+
s := &corev1.Secret{}
89+
require.NoError(t, env.ProviderClient.Get(ctx, secretKey, s))
90+
delete(s.Labels, "app")
91+
require.NoError(t, env.ProviderClient.Update(ctx, s))
92+
93+
require.Eventually(t, func() bool {
94+
c := &corev1.Secret{}
95+
return apierrors.IsNotFound(env.ConsumerClient.Get(ctx, secretKey, c))
96+
}, 30*time.Second, 200*time.Millisecond, "the consumer copy should be GC'd once the Secret stops matching the selector")
97+
})
98+
}

0 commit comments

Comments
 (0)