diff --git a/backend/auth/handler.go b/backend/auth/handler.go index 2eb3ecef3..c8b68f8a7 100644 --- a/backend/auth/handler.go +++ b/backend/auth/handler.go @@ -82,6 +82,7 @@ func (ah *AuthHandler) HandleAuthorize(w http.ResponseWriter, r *http.Request) { SessionID: params.SessionID, ClusterID: params.ClusterID, ClientType: ClientType(params.ClientType), + ConsumerID: params.ConsumerID, } } diff --git a/backend/auth/types.go b/backend/auth/types.go index fe65274ec..f37bf42e9 100644 --- a/backend/auth/types.go +++ b/backend/auth/types.go @@ -54,6 +54,7 @@ type AuthorizeRequest struct { SessionID string `json:"session_id" form:"session_id"` ClusterID string `json:"cluster_id" form:"cluster_id"` ClientType ClientType `json:"client_type" form:"client_type"` + ConsumerID string `json:"consumer_id" form:"consumer_id"` } type CallbackRequest struct { diff --git a/backend/client/client.go b/backend/client/client.go index adfee70fb..42505cc0e 100644 --- a/backend/client/client.go +++ b/backend/client/client.go @@ -28,6 +28,7 @@ type ClientParameters struct { ClientSideRedirectURL string SessionID string ClientType string + ConsumerID string IsClusterScoped bool } @@ -40,6 +41,7 @@ func GetQueryParams(r *http.Request) *ClientParameters { ClientSideRedirectURL: r.URL.Query().Get("client_side_redirect_url"), SessionID: r.URL.Query().Get("session_id"), ClientType: r.URL.Query().Get("client_type"), + ConsumerID: r.URL.Query().Get("consumer_id"), } p.IsClusterScoped = p.ClusterID != "" return p @@ -68,6 +70,9 @@ func (r *ClientParameters) WithParams(urlStr string) string { if r.ClientType != "" { query.Set("client_type", r.ClientType) } + if r.ConsumerID != "" { + query.Set("consumer_id", r.ConsumerID) + } parsedURL.RawQuery = query.Encode() return parsedURL.String() diff --git a/backend/config.go b/backend/config.go index a5858e402..076c849c6 100644 --- a/backend/config.go +++ b/backend/config.go @@ -17,10 +17,7 @@ limitations under the License. package backend import ( - "context" "fmt" - "net/url" - "strings" "github.com/kcp-dev/multicluster-provider/apiexport" apisv1alpha1 "github.com/kcp-dev/sdk/apis/apis/v1alpha1" @@ -49,8 +46,9 @@ type Config struct { Provider multicluster.Provider ExternalAddressGenerator kuberesources.ExternalAddressGeneratorFunc - Manager mcmanager.Manager - Scheme *runtime.Scheme + + Manager mcmanager.Manager + Scheme *runtime.Scheme ClientConfig *rest.Config } @@ -106,7 +104,7 @@ func NewConfig(options *options.CompletedOptions) (*Config, error) { return nil, fmt.Errorf("error setting up kcp provider: %w", err) } - gen, err := newKCPExternalAddressGenerator(options.ExternalAddress) + gen, err := kcpprovider.NewKCPExternalAddressGenerator(options.ExternalAddress) if err != nil { return nil, err } @@ -137,44 +135,3 @@ func NewConfig(options *options.CompletedOptions) (*Config, error) { return config, nil } - -func newKCPExternalAddressGenerator(externalAddress string) (kuberesources.ExternalAddressGeneratorFunc, error) { - var extURL *url.URL - if externalAddress != "" { - var err error - - extURL, err = url.Parse(externalAddress) - if err != nil { - return nil, fmt.Errorf("invalid --external-address: %w", err) - } - } - - return func(_ context.Context, clusterConfig *rest.Config) (string, error) { - // In kcp case, we are talking via apiexport so clientconfig will be pointing to - // https://192.168.2.166:6443/services/apiexport/root:org:ws//clusters/2p0rtkf7b697s6mj - // We need to extract host and /clusters/... part - u, err := url.Parse(clusterConfig.Host) - if err != nil { - return "", err - } - - // Extract cluster ID from the path - // Path format: /services/apiexport/root:org:ws//clusters/{cluster-id} - pathParts := strings.Split(strings.Trim(u.Path, "/"), "/") - if len(pathParts) < 6 || pathParts[4] != "clusters" { - return "", fmt.Errorf("invalid apiexport URL format") - } - - clusterID := pathParts[5] - - // Construct new URL with cluster path - var finalURL = u - if extURL != nil { - finalURL = extURL - } - - finalURL.Path = "/clusters/" + clusterID - - return finalURL.String(), nil - }, nil -} diff --git a/backend/http/handler.go b/backend/http/handler.go index 95d87f0af..596dae561 100644 --- a/backend/http/handler.go +++ b/backend/http/handler.go @@ -345,14 +345,6 @@ func (h *handler) handleBind(w http.ResponseWriter, r *http.Request) { authCtx := auth.GetAuthContext(r.Context()) state := authCtx.SessionState - kfg, err := h.kubeManager.HandleResources(r.Context(), state.Token.Subject+"#"+state.ClusterID, params.ClusterID) - if err != nil { - logger.Error(err, "failed to handle resources") - statusCode, code, details := mapErrorToCode(err) - writeErrorResponse(w, statusCode, code, "Failed to handle cluster resources", details) - return - } - // Parse JSON request body const maxBodySize = 1 << 20 // 1 MB r.Body = http.MaxBytesReader(w, r.Body, maxBodySize) @@ -368,6 +360,21 @@ func (h *handler) handleBind(w http.ResponseWriter, r *http.Request) { return } + // TODO: Move to validating admission. + if bindRequest.ClusterIdentity.Identity == "" { + logger.Error(fmt.Errorf("missing cluster identity"), "invalid bind request") + writeErrorResponse(w, http.StatusBadRequest, kubebindv1alpha2.ErrorCodeBadRequest, "Missing cluster identity in bind request", "The cluster identity must be provided in the bind request") + return + } + + kfg, err := h.kubeManager.HandleResources(r.Context(), state.Token.Subject, params.ConsumerID, params.ClusterID) + if err != nil { + logger.Error(err, "failed to handle resources") + statusCode, code, details := mapErrorToCode(err) + writeErrorResponse(w, statusCode, code, "Failed to handle cluster resources", details) + return + } + // Module consist of many resources and permissionClaims. Read it and translate to template, err := h.kubeManager.GetTemplates(r.Context(), params.ClusterID, bindRequest.TemplateRef.Name) if err != nil { diff --git a/backend/kubernetes/manager.go b/backend/kubernetes/manager.go index 35c1d4b5e..a60d7ccc3 100644 --- a/backend/kubernetes/manager.go +++ b/backend/kubernetes/manager.go @@ -83,7 +83,10 @@ func NewKubernetesManager( return m, nil } -func (m *Manager) HandleResources(ctx context.Context, identity, cluster string) ([]byte, error) { +func (m *Manager) HandleResources( + ctx context.Context, + author, identity, cluster string, +) ([]byte, error) { logger := klog.FromContext(ctx).WithValues("identity", identity) ctx = klog.NewContext(ctx, logger) @@ -107,7 +110,7 @@ func (m *Manager) HandleResources(ctx context.Context, identity, cluster string) if len(nss.Items) == 1 { ns = nss.Items[0].Name } else { - nsObj, err := kuberesources.CreateNamespace(ctx, c, m.namespacePrefix, identity) + nsObj, err := kuberesources.CreateNamespace(ctx, c, m.namespacePrefix, identity, author) if err != nil { return nil, err } diff --git a/backend/kubernetes/resources/namespace.go b/backend/kubernetes/resources/namespace.go index 83176d937..e3b80cf83 100644 --- a/backend/kubernetes/resources/namespace.go +++ b/backend/kubernetes/resources/namespace.go @@ -18,8 +18,11 @@ package resources import ( "context" + "crypto/sha256" + "fmt" "strings" + "github.com/martinlindhe/base36" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -29,6 +32,7 @@ import ( const ( IdentityAnnotationKey = "backend.kube-bind.io/identity" + AuthorAnnotationKey = "backend.kube-bind.io/author" legacyIdentityAnnotationKey = "example-backend.kube-bind.io/identity" ) @@ -53,15 +57,14 @@ func handleLegacyAnnotations(ctx context.Context, cl client.Client, namespace *c return nil } -func CreateNamespace(ctx context.Context, client client.Client, generateName, id string) (*corev1.Namespace, error) { - if !strings.HasSuffix(generateName, "-") { - generateName += "-" - } +func CreateNamespace(ctx context.Context, client client.Client, generateNamePrefix, identity, author string) (*corev1.Namespace, error) { + name := identityHash(identity) namespace := &corev1.Namespace{ ObjectMeta: metav1.ObjectMeta{ - GenerateName: generateName, + Name: fmt.Sprintf("%s-%s", generateNamePrefix, name), Annotations: map[string]string{ - IdentityAnnotationKey: id, + IdentityAnnotationKey: identity, + AuthorAnnotationKey: author, }, }, } @@ -75,14 +78,19 @@ func CreateNamespace(ctx context.Context, client client.Client, generateName, id return nil, err } - if namespace.Annotations[IdentityAnnotationKey] != id && namespace.Annotations[legacyIdentityAnnotationKey] != id { + if namespace.Annotations[IdentityAnnotationKey] != identity && namespace.Annotations[legacyIdentityAnnotationKey] != identity { return nil, errors.NewAlreadyExists(corev1.Resource("namespace"), namespace.Name) } - if err := handleLegacyAnnotations(ctx, client, namespace, id); err != nil { + if err := handleLegacyAnnotations(ctx, client, namespace, identity); err != nil { return nil, err } } return namespace, nil } + +func identityHash(userName string) string { + hash := sha256.Sum224([]byte(userName)) + return strings.ToLower(base36.EncodeBytes(hash[:8])) +} diff --git a/backend/kubernetes/resources/namespace_test.go b/backend/kubernetes/resources/namespace_test.go index b172186de..6d83ba284 100644 --- a/backend/kubernetes/resources/namespace_test.go +++ b/backend/kubernetes/resources/namespace_test.go @@ -18,47 +18,55 @@ package resources import ( "context" + "crypto/sha256" + "fmt" "strings" "testing" + "github.com/martinlindhe/base36" corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/api/errors" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" ) +func expectedIdentityHash(identity string) string { + hash := sha256.Sum224([]byte(identity)) + return strings.ToLower(base36.EncodeBytes(hash[:8])) +} + func TestCreateNamespace(t *testing.T) { scheme := runtime.NewScheme() _ = corev1.AddToScheme(scheme) tests := []struct { - name string - generateName string - id string - wantErr bool - wantNameGenerated bool - wantAnnotations map[string]string + name string + generateName string + identity string + author string + wantErr bool + wantAnnotations map[string]string }{ { - name: "create new namespace with generateName", - generateName: "test-ns", - id: "test-id-123", - wantErr: false, - wantNameGenerated: true, + name: "create new namespace with generateName", + generateName: "test-ns", + identity: "test-id-123", + author: "bob", + wantErr: false, wantAnnotations: map[string]string{ IdentityAnnotationKey: "test-id-123", + AuthorAnnotationKey: "bob", }, }, { - name: "create new namespace with generateName already ending with dash", - generateName: "test-ns-", - id: "test-id-456", - wantErr: false, - wantNameGenerated: true, + name: "create new namespace with generateName already ending with dash", + generateName: "test-ns-", + identity: "test-id-456", + author: "alice", + wantErr: false, wantAnnotations: map[string]string{ IdentityAnnotationKey: "test-id-456", + AuthorAnnotationKey: "alice", }, }, } @@ -68,7 +76,7 @@ func TestCreateNamespace(t *testing.T) { ctx := context.Background() cl := fake.NewClientBuilder().WithScheme(scheme).Build() - result, err := CreateNamespace(ctx, cl, tt.generateName, tt.id) + result, err := CreateNamespace(ctx, cl, tt.generateName, tt.identity, tt.author) if tt.wantErr { if err == nil { @@ -88,18 +96,16 @@ func TestCreateNamespace(t *testing.T) { return } - expectedGenerateNamePrefix := tt.generateName - if !strings.HasSuffix(expectedGenerateNamePrefix, "-") { - expectedGenerateNamePrefix += "-" + expectedPrefix := tt.generateName + expectedHash := expectedIdentityHash(tt.identity) + expectedName := fmt.Sprintf("%s-%s", expectedPrefix, expectedHash) + + if result.Name != expectedName { + t.Errorf("CreateNamespace() name = %v, expected %v", result.Name, expectedName) } - if tt.wantNameGenerated { - if !strings.HasPrefix(result.Name, expectedGenerateNamePrefix) { - t.Errorf("CreateNamespace() name = %v, expected to start with %v", result.Name, expectedGenerateNamePrefix) - } - if result.GenerateName != expectedGenerateNamePrefix { - t.Errorf("CreateNamespace() generateName = %v, expected %v", result.GenerateName, expectedGenerateNamePrefix) - } + if result.GenerateName != "" { + t.Errorf("CreateNamespace() generateName = %v, expected empty string", result.GenerateName) } for key, expectedValue := range tt.wantAnnotations { @@ -121,177 +127,3 @@ func TestCreateNamespace(t *testing.T) { }) } } - -func TestCreateNamespaceLegacyAnnotationHandling(t *testing.T) { - scheme := runtime.NewScheme() - _ = corev1.AddToScheme(scheme) - - tests := []struct { - name string - existingNamespace *corev1.Namespace - generateName string - id string - wantErr bool - wantErrType func(error) bool - wantLegacyMigration bool - wantAnnotations map[string]string - }{ - { - name: "namespace exists with matching current annotation - no migration needed", - existingNamespace: &corev1.Namespace{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-ns-abc123", - Annotations: map[string]string{ - IdentityAnnotationKey: "matching-id", - }, - }, - }, - generateName: "test-ns", - id: "matching-id", - wantErr: false, - wantAnnotations: map[string]string{ - IdentityAnnotationKey: "matching-id", - }, - wantLegacyMigration: false, - }, - { - name: "namespace exists with matching legacy annotation - should migrate", - existingNamespace: &corev1.Namespace{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-ns-xyz789", - Annotations: map[string]string{ - legacyIdentityAnnotationKey: "legacy-id", - }, - }, - }, - generateName: "test-ns", - id: "legacy-id", - wantErr: false, - wantAnnotations: map[string]string{ - IdentityAnnotationKey: "legacy-id", - }, - wantLegacyMigration: true, - }, - { - name: "namespace exists with both annotations, legacy matches - should migrate", - existingNamespace: &corev1.Namespace{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-ns-mixed", - Annotations: map[string]string{ - IdentityAnnotationKey: "wrong-id", - legacyIdentityAnnotationKey: "correct-id", - }, - }, - }, - generateName: "test-ns", - id: "correct-id", - wantErr: false, - wantAnnotations: map[string]string{ - IdentityAnnotationKey: "correct-id", - }, - wantLegacyMigration: true, - }, - { - name: "namespace exists with non-matching annotations - should return AlreadyExists error", - existingNamespace: &corev1.Namespace{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-ns-conflict", - Annotations: map[string]string{ - IdentityAnnotationKey: "different-id", - legacyIdentityAnnotationKey: "another-id", - }, - }, - }, - generateName: "test-ns", - id: "new-id", - wantErr: true, - wantErrType: errors.IsAlreadyExists, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - ctx := context.Background() - - interceptorClient := &alreadyExistsClient{ - Client: fake.NewClientBuilder().WithScheme(scheme).WithObjects(tt.existingNamespace).Build(), - existingNamespace: tt.existingNamespace, - } - - result, err := CreateNamespace(ctx, interceptorClient, tt.generateName, tt.id) - - if tt.wantErr { - if err == nil { - t.Errorf("CreateNamespace() expected error but got none") - return - } - if tt.wantErrType != nil && !tt.wantErrType(err) { - t.Errorf("CreateNamespace() error type mismatch, got %T: %v", err, err) - } - return - } - - if err != nil { - t.Errorf("CreateNamespace() unexpected error = %v", err) - return - } - - if result == nil { - t.Errorf("CreateNamespace() returned nil namespace") - return - } - - if result.Name != tt.existingNamespace.Name { - t.Errorf("CreateNamespace() name = %v, expected %v", result.Name, tt.existingNamespace.Name) - } - - for key, expectedValue := range tt.wantAnnotations { - if actualValue, exists := result.Annotations[key]; !exists || actualValue != expectedValue { - t.Errorf("CreateNamespace() annotation %s = %v, expected %v (exists: %v)", key, actualValue, expectedValue, exists) - } - } - - if tt.wantLegacyMigration { - if _, exists := result.Annotations[legacyIdentityAnnotationKey]; exists { - t.Errorf("CreateNamespace() legacy annotation should be removed but still exists: %v", result.Annotations) - } - } - - var actualNamespace corev1.Namespace - if err := interceptorClient.Get(ctx, client.ObjectKey{Name: result.Name}, &actualNamespace); err != nil { - t.Fatalf("Failed to get updated namespace from client: %v", err) - } - - for key, expectedValue := range tt.wantAnnotations { - if actualValue, exists := actualNamespace.Annotations[key]; !exists || actualValue != expectedValue { - t.Errorf("CreateNamespace() stored annotation %s = %v, expected %v (exists: %v)", key, actualValue, expectedValue, exists) - } - } - - if tt.wantLegacyMigration { - if _, exists := actualNamespace.Annotations[legacyIdentityAnnotationKey]; exists { - t.Errorf("CreateNamespace() legacy annotation should be removed from stored namespace but still exists: %v", actualNamespace.Annotations) - } - } - }) - } -} - -type alreadyExistsClient struct { - client.Client - existingNamespace *corev1.Namespace -} - -func (c *alreadyExistsClient) Create(ctx context.Context, obj client.Object, opts ...client.CreateOption) error { - namespace, ok := obj.(*corev1.Namespace) - if !ok { - return c.Client.Create(ctx, obj, opts...) - } - - if namespace.GenerateName != "" { - namespace.Name = c.existingNamespace.Name - return errors.NewAlreadyExists(corev1.Resource("namespace"), namespace.Name) - } - - return c.Client.Create(ctx, obj, opts...) -} diff --git a/backend/provider/kcp/provider.go b/backend/provider/kcp/provider.go index ab58b27fa..0bce16f81 100644 --- a/backend/provider/kcp/provider.go +++ b/backend/provider/kcp/provider.go @@ -18,7 +18,10 @@ package kcp import ( "context" + "fmt" "math" + "net/url" + "strings" "time" provider "github.com/kcp-dev/multicluster-provider/apiexport" @@ -29,6 +32,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/cluster" "sigs.k8s.io/multicluster-runtime/pkg/multicluster" + kuberesources "github.com/kube-bind/kube-bind/backend/kubernetes/resources" kubebindv1alpha2 "github.com/kube-bind/kube-bind/sdk/apis/kubebind/v1alpha2" ) @@ -125,3 +129,46 @@ func (a *awareWrapper) Engage(ctx context.Context, name string, cluster cluster. } return nil //nolint:govet // cancel is called in the error case only. } + +// NewKCPExternalAddressGenerator returns an ExternalAddressGeneratorFunc +// suitable for kcp-based clusters. +func NewKCPExternalAddressGenerator(externalAddress string) (kuberesources.ExternalAddressGeneratorFunc, error) { + var extURL *url.URL + if externalAddress != "" { + var err error + + extURL, err = url.Parse(externalAddress) + if err != nil { + return nil, fmt.Errorf("invalid --external-address: %w", err) + } + } + + return func(_ context.Context, clusterConfig *rest.Config) (string, error) { + // In kcp case, we are talking via apiexport so clientconfig will be pointing to + // https://192.168.2.166:6443/services/apiexport/root:org:ws//clusters/2p0rtkf7b697s6mj + // We need to extract host and /clusters/... part + u, err := url.Parse(clusterConfig.Host) + if err != nil { + return "", err + } + + // Extract cluster ID from the path + // Path format: /services/apiexport/root:org:ws//clusters/{cluster-id} + pathParts := strings.Split(strings.Trim(u.Path, "/"), "/") + if len(pathParts) < 6 || pathParts[4] != "clusters" { + return "", fmt.Errorf("invalid apiexport URL format") + } + + clusterID := pathParts[5] + + // Construct new URL with cluster path + var finalURL = u + if extURL != nil { + finalURL = extURL + } + + finalURL.Path = "/clusters/" + clusterID + + return finalURL.String(), nil + }, nil +} diff --git a/cli/go.mod b/cli/go.mod index c7b82260e..2f9b359e3 100644 --- a/cli/go.mod +++ b/cli/go.mod @@ -26,6 +26,7 @@ require ( k8s.io/client-go v0.34.2 k8s.io/component-base v0.34.2 k8s.io/klog/v2 v2.130.1 + sigs.k8s.io/controller-runtime v0.22.4 sigs.k8s.io/kind v0.30.0 sigs.k8s.io/yaml v1.6.0 ) diff --git a/cli/go.sum b/cli/go.sum index ba33b5015..b1bf05baa 100644 --- a/cli/go.sum +++ b/cli/go.sum @@ -468,6 +468,8 @@ k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8 k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= oras.land/oras-go/v2 v2.6.0 h1:X4ELRsiGkrbeox69+9tzTu492FMUu7zJQW6eJU+I2oc= oras.land/oras-go/v2 v2.6.0/go.mod h1:magiQDfG6H1O9APp+rOsvCPcW1GD2MM7vgnKY0Y+u1o= +sigs.k8s.io/controller-runtime v0.22.4 h1:GEjV7KV3TY8e+tJ2LCTxUTanW4z/FmNB7l327UfMq9A= +sigs.k8s.io/controller-runtime v0.22.4/go.mod h1:+QX1XUpTXN4mLoblf4tqr5CQcyHPAki2HLXqQMY6vh8= sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE= sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/kind v0.30.0 h1:2Xi1KFEfSMm0XDcvKnUt15ZfgRPCT0OnCBbpgh8DztY= diff --git a/cli/pkg/kubectl/base/identity.go b/cli/pkg/kubectl/base/identity.go new file mode 100644 index 000000000..e467c64c8 --- /dev/null +++ b/cli/pkg/kubectl/base/identity.go @@ -0,0 +1,41 @@ +/* +Copyright 2025 The Kube Bind Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package base + +import ( + "context" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/rest" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +func GetClusterIdentityFromNamespace(ctx context.Context, rest *rest.Config, namespaceName string) (string, error) { + cl, err := client.New(rest, client.Options{}) + if err != nil { + return "", err + } + + var namespace corev1.Namespace + err = cl.Get(ctx, types.NamespacedName{Name: namespaceName}, &namespace) + if err != nil { + return "", err + } + + return string(namespace.UID), nil +} diff --git a/cli/pkg/kubectl/bind-apiservice/cmd/cmd.go b/cli/pkg/kubectl/bind-apiservice/cmd/cmd.go index 83ecf8a7a..5522aa67f 100644 --- a/cli/pkg/kubectl/bind-apiservice/cmd/cmd.go +++ b/cli/pkg/kubectl/bind-apiservice/cmd/cmd.go @@ -82,7 +82,11 @@ func New(streams genericclioptions.IOStreams) (*cobra.Command, error) { fmt.Fprintf(streams.ErrOut, "%s\n\n", yellow("DISCLAIMER: This is a prototype. It will change in incompatible ways at any time.")) } - return opts.Run(cmd.Context()) + if err := opts.Run(cmd.Context()); err != nil { + fmt.Fprintf(streams.ErrOut, "Error: %v\n", err) + return nil + } + return nil }, } opts.AddCmdFlags(cmd) diff --git a/cli/pkg/kubectl/bind-apiservice/plugin/bind.go b/cli/pkg/kubectl/bind-apiservice/plugin/bind.go index 0ac98bc03..a2a02550f 100644 --- a/cli/pkg/kubectl/bind-apiservice/plugin/bind.go +++ b/cli/pkg/kubectl/bind-apiservice/plugin/bind.go @@ -69,14 +69,20 @@ type BindAPIServiceOptions struct { DryRun bool Template string Name string + + // ClusterIdentity is a unique identity for the cluster. + ClusterIdentity string + // clusterIdentityNamespaceName is the namespace name, from which the cluster identity will be generated. + clusterIdentityNamespaceName string } // NewBindAPIServiceOptions returns new BindAPIServiceOptions. func NewBindAPIServiceOptions(streams genericclioptions.IOStreams) *BindAPIServiceOptions { return &BindAPIServiceOptions{ - Options: base.NewOptions(streams), - Logs: logs.NewOptions(), - Print: genericclioptions.NewPrintFlags("kubectl-bind-apiservice"), + Options: base.NewOptions(streams), + Logs: logs.NewOptions(), + Print: genericclioptions.NewPrintFlags("kubectl-bind-apiservice"), + clusterIdentityNamespaceName: "kube-system", } } @@ -86,6 +92,17 @@ func (b *BindAPIServiceOptions) AddCmdFlags(cmd *cobra.Command) { logsv1.AddFlags(b.Logs, cmd.Flags()) b.Print.AddFlags(cmd) + // First block of flags are common with bind/plugin/bind.go, keep them in sync. + cmd.Flags().BoolVar(&b.SkipKonnector, "skip-konnector", b.SkipKonnector, "Skip the deployment of the konnector") + cmd.Flags().BoolVarP(&b.DryRun, "dry-run", "d", b.DryRun, "If true, only print the requests that would be sent to the service provider after authentication, without actually binding.") + cmd.Flags().StringVar(&b.KonnectorImageOverride, "konnector-image", b.KonnectorImageOverride, "The konnector image to use") + cmd.Flags().MarkHidden("konnector-image") //nolint:errcheck + cmd.Flags().StringSliceVarP(&b.KonnectorHostAlias, "konnector-host-alias", "", []string{}, "Add a host alias to the konnector pods in the format IP:hostname1,hostname2") + cmd.Flags().MarkHidden("konnector-host-alias") //nolint:errcheck + cmd.Flags().StringVarP(&b.ClusterIdentity, "cluster-identity", "", b.ClusterIdentity, "A unique identity for the cluster. If not provided, it will be generated based on the local cluster information. ") + cmd.Flags().StringVarP(&b.clusterIdentityNamespaceName, "cluster-identity-namespace", "", b.clusterIdentityNamespaceName, "The namespace name from which the cluster identity will be generated. Only used if cluster-identity is not provided.") + + // Second block of flags specific to bind-apiservice. cmd.Flags().StringVar(&b.Template, "template-name", b.Template, "A template name to use for binding") cmd.Flags().StringVar(&b.Name, "name", b.Name, "The name of the BindableResourcesRequest to create") cmd.Flags().StringVar(&b.remoteKubeconfigFile, "remote-kubeconfig", b.remoteKubeconfigFile, "A file path for a kubeconfig file to connect to the service provider cluster") @@ -93,15 +110,9 @@ func (b *BindAPIServiceOptions) AddCmdFlags(cmd *cobra.Command) { cmd.Flags().StringVar(&b.remoteKubeconfigName, "remote-kubeconfig-name", b.remoteKubeconfigNamespace, "The name of the remote kubeconfig secret to read from") cmd.Flags().StringVarP(&b.file, "file", "f", b.file, "A file with an APIServiceExportRequest manifest. Use - to read from stdin") cmd.Flags().StringVar(&b.remoteNamespace, "remote-namespace", b.remoteNamespace, "The namespace in the remote cluster where the konnector is deployed") - cmd.Flags().BoolVar(&b.SkipKonnector, "skip-konnector", b.SkipKonnector, "Skip the deployment of the konnector") cmd.Flags().BoolVar(&b.DowngradeKonnector, "downgrade-konnector", b.DowngradeKonnector, "Downgrade the konnector to the version of the kubectl-bind-apiservice binary") - cmd.Flags().StringVar(&b.KonnectorImageOverride, "konnector-image", b.KonnectorImageOverride, "The konnector image to use") - cmd.Flags().BoolVarP(&b.DryRun, "dry-run", "d", b.DryRun, "If true, only print the requests that would be sent to the service provider after authentication, without actually binding.") - cmd.Flags().MarkHidden("konnector-image") //nolint:errcheck cmd.Flags().BoolVar(&b.NoBanner, "no-banner", b.NoBanner, "Do not show the red banner") cmd.Flags().MarkHidden("no-banner") //nolint:errcheck - cmd.Flags().StringSliceVarP(&b.KonnectorHostAlias, "konnector-host-alias", "", []string{}, "Add a host alias to the konnector pods in the format IP:hostname1,hostname2") - cmd.Flags().MarkHidden("konnector-host-alias") //nolint:errcheck } // Complete ensures all fields are initialized. @@ -185,16 +196,19 @@ func (b *BindAPIServiceOptions) Run(ctx context.Context) error { // Use the shared binder to create bindings binderOpts := &BinderOptions{ - IOStreams: b.Options.IOStreams, - SkipKonnector: b.SkipKonnector, - KonnectorImageOverride: b.KonnectorImageOverride, - KonnectorHostAliasParsed: b.KonnectorHostAliasParsed, - DowngradeKonnector: b.DowngradeKonnector, - RemoteKubeconfigFile: b.remoteKubeconfigFile, - RemoteKubeconfigNamespace: b.remoteKubeconfigNamespace, - RemoteKubeconfigName: b.remoteKubeconfigName, - RemoteNamespace: b.remoteNamespace, - File: b.file, + IOStreams: b.Options.IOStreams, + SkipKonnector: b.SkipKonnector, + KonnectorImageOverride: b.KonnectorImageOverride, + KonnectorHostAliasParsed: b.KonnectorHostAliasParsed, + DowngradeKonnector: b.DowngradeKonnector, + RemoteKubeconfigFile: b.remoteKubeconfigFile, + RemoteKubeconfigNamespace: b.remoteKubeconfigNamespace, + RemoteKubeconfigName: b.remoteKubeconfigName, + RemoteNamespace: b.remoteNamespace, + ClusterIdentity: b.ClusterIdentity, + ClusterIdentityNamespaceName: b.clusterIdentityNamespaceName, + DryRun: b.DryRun, + File: b.file, } binder := NewBinder(config, binderOpts) @@ -320,6 +334,19 @@ func (b *BindAPIServiceOptions) bindTemplate(ctx context.Context) (*bindTemplate return nil, fmt.Errorf("failed to create authenticated client: %w", err) } + if b.ClusterIdentity == "" { + fmt.Fprintf(b.Options.IOStreams.ErrOut, "⚠️ Warning: Cluster identity not provided, it will be generated based on the local cluster information which may lead to unexpected results if same identity is re-used. Be warrned of the dragons! \n") + ns, err := kubeClient.CoreV1().Namespaces().Get(ctx, b.clusterIdentityNamespaceName, metav1.GetOptions{}) // just to trigger possible errors early + if err != nil { + if apierrors.IsNotFound(err) { + return nil, fmt.Errorf("namespace %q not found for cluster identity generation: %w. You can override default identity namespace with --cluster-identity-namespace flag", b.clusterIdentityNamespaceName, err) + } + return nil, fmt.Errorf("failed to get namespace %q for cluster identity generation: %w", b.clusterIdentityNamespaceName, err) + } + fmt.Fprintf(b.Options.IOStreams.ErrOut, " Using namespace %q with UID %q for cluster identity generation.\n", ns.Name, ns.UID) + b.ClusterIdentity = string(ns.UID) + } + bindRequest := &kubebindv1alpha2.BindableResourcesRequest{ ObjectMeta: metav1.ObjectMeta{ Name: b.Name, @@ -327,6 +354,9 @@ func (b *BindAPIServiceOptions) bindTemplate(ctx context.Context) (*bindTemplate TemplateRef: kubebindv1alpha2.APIServiceExportTemplateRef{ Name: b.Template, }, + ClusterIdentity: kubebindv1alpha2.ClusterIdentity{ + Identity: b.ClusterIdentity, + }, } bindResponse, err := client.Bind(ctx, bindRequest) diff --git a/cli/pkg/kubectl/bind-apiservice/plugin/binder.go b/cli/pkg/kubectl/bind-apiservice/plugin/binder.go index 0c16e5d6a..c1c81b2e5 100644 --- a/cli/pkg/kubectl/bind-apiservice/plugin/binder.go +++ b/cli/pkg/kubectl/bind-apiservice/plugin/binder.go @@ -36,17 +36,19 @@ import ( // BinderOptions contains the configuration for the shared binder type BinderOptions struct { - IOStreams genericclioptions.IOStreams - SkipKonnector bool - KonnectorImageOverride string - KonnectorHostAliasParsed []corev1.HostAlias - DowngradeKonnector bool - RemoteKubeconfigFile string - RemoteKubeconfigNamespace string - RemoteKubeconfigName string - RemoteNamespace string - File string - DryRun bool + IOStreams genericclioptions.IOStreams + SkipKonnector bool + KonnectorImageOverride string + KonnectorHostAliasParsed []corev1.HostAlias + DowngradeKonnector bool + RemoteKubeconfigFile string + RemoteKubeconfigNamespace string + RemoteKubeconfigName string + RemoteNamespace string + ClusterIdentity string + ClusterIdentityNamespaceName string + File string + DryRun bool } // Binder provides shared binding functionality for both bind and bind-apiservice commands diff --git a/cli/pkg/kubectl/bind/plugin/bind.go b/cli/pkg/kubectl/bind/plugin/bind.go index a231c305e..c0c1054de 100644 --- a/cli/pkg/kubectl/bind/plugin/bind.go +++ b/cli/pkg/kubectl/bind/plugin/bind.go @@ -74,6 +74,11 @@ type BindOptions struct { // KonnectorHostAliasParsed is a list of parsed host alias entries to add to the konnector pods. KonnectorHostAliasParsed []corev1.HostAlias + // ClusterIdentity is a unique identity for the cluster. + ClusterIdentity string + // clusterIdentityNamespaceName is the namespace name from which the cluster identity will be generated. + clusterIdentityNamespaceName string + // Runner is runs the command. It can be replaced in tests. Runner func(cmd *exec.Cmd) error @@ -87,6 +92,8 @@ func NewBindOptions(streams genericclioptions.IOStreams) *BindOptions { Logs: logs.NewOptions(), Print: genericclioptions.NewPrintFlags("kubectl-bind").WithDefaultOutput("yaml"), + clusterIdentityNamespaceName: "kube-system", + Runner: func(cmd *exec.Cmd) error { return cmd.Run() }, @@ -103,11 +110,15 @@ func (b *BindOptions) AddCmdFlags(cmd *cobra.Command) { logsv1.AddFlags(b.Logs, cmd.Flags()) b.Print.AddFlags(cmd) + // This block of flags are common with bind-apiservice/plugin/bind.go, keep them in sync. cmd.Flags().BoolVar(&b.SkipKonnector, "skip-konnector", b.SkipKonnector, "Skip the deployment of the konnector") cmd.Flags().BoolVarP(&b.DryRun, "dry-run", "d", b.DryRun, "If true, only print the requests that would be sent to the service provider after authentication, without actually binding.") cmd.Flags().StringVar(&b.KonnectorImageOverride, "konnector-image", b.KonnectorImageOverride, "The konnector image to use") + cmd.Flags().MarkHidden("konnector-image") //nolint:errcheck cmd.Flags().StringSliceVarP(&b.KonnectorHostAlias, "konnector-host-alias", "", []string{}, "Add a host alias to the konnector pods in the format IP:hostname1,hostname2") cmd.Flags().MarkHidden("konnector-host-alias") //nolint:errcheck + cmd.Flags().StringVarP(&b.ClusterIdentity, "cluster-identity", "", b.ClusterIdentity, "A unique identity for the cluster. If not provided, it will be generated based on the local cluster information. ") + cmd.Flags().StringVarP(&b.clusterIdentityNamespaceName, "cluster-identity-namespace", "", b.clusterIdentityNamespaceName, "The namespace name from which the cluster identity will be generated. Only used if cluster-identity is not provided.") } // Complete ensures all fields are initialized. @@ -173,7 +184,7 @@ func (b *BindOptions) Run(ctx context.Context, urlCh chan<- string) error { // runWithCallback creates a local callback listener and opens the UI func (b *BindOptions) runWithCallback(ctx context.Context, _ chan<- string) error { - _, err := b.Options.ClientConfig.ClientConfig() + restConfig, err := b.Options.ClientConfig.ClientConfig() if err != nil { return fmt.Errorf("failed to get client config: %w", err) } @@ -181,6 +192,11 @@ func (b *BindOptions) runWithCallback(ctx context.Context, _ chan<- string) erro // Generate session ID. It is used to verify callback. sessionID := rand.Text() + clusterIdentity, err := b.getClusterIdentity(ctx, restConfig) + if err != nil { + return fmt.Errorf("failed to get cluster identity: %w", err) + } + // Setup callback server with random port resultCh := make(chan *BindResult, 1) errCh := make(chan error, 1) @@ -192,7 +208,7 @@ func (b *BindOptions) runWithCallback(ctx context.Context, _ chan<- string) erro defer callbackServer.Close() // Build the UI URL with callback parameters - uiURL, err := b.buildUIURL(callbackPort, sessionID, b.ClusterName) + uiURL, err := b.buildUIURL(callbackPort, sessionID, b.ClusterName, clusterIdentity) if err != nil { return fmt.Errorf("failed to build UI URL: %w", err) } @@ -290,7 +306,7 @@ func (b *BindOptions) runWithCallback(ctx context.Context, _ chan<- string) erro } // buildUIURL constructs the UI URL with callback parameters -func (b *BindOptions) buildUIURL(callbackPort int, sessionID, clusterID string) (string, error) { +func (b *BindOptions) buildUIURL(callbackPort int, sessionID, backendClusterID, consumerClusterID string) (string, error) { // Parse the base URL u, err := url.Parse(b.ServerName) if err != nil { @@ -303,8 +319,11 @@ func (b *BindOptions) buildUIURL(callbackPort int, sessionID, clusterID string) values := u.Query() values.Add("session_id", sessionID) values.Add("redirect_url", redirectURL) - if clusterID != "" { - values.Add("cluster_id", clusterID) + if backendClusterID != "" { + values.Add("cluster_id", backendClusterID) + } + if consumerClusterID != "" { + values.Add("consumer_id", consumerClusterID) } u.RawQuery = values.Encode() @@ -417,3 +436,19 @@ func (b *BindOptions) bindResponseToAPIServiceBindings(ctx context.Context, conf binder := bindapiservice.NewBinder(config, binderOpts) return binder.BindFromResponse(ctx, response) } + +// getClusterIdentity returns the cluster identity, generating it from the namespace if not provided +func (b *BindOptions) getClusterIdentity(ctx context.Context, restConfig *rest.Config) (string, error) { + if b.ClusterIdentity != "" { + fmt.Fprintf(b.Options.IOStreams.ErrOut, "Manual cluster identity provided: %s. Identity will not be generated from namespace.\n", b.ClusterIdentity) + fmt.Fprintf(b.Options.IOStreams.ErrOut, "If this is not intended, please omit --cluster-identity flag to generate identity from namespace '%s'.\n\n", b.clusterIdentityNamespaceName) + fmt.Fprintf(b.Options.IOStreams.ErrOut, "Re-using same identity on multiple consumer clusters is not supported & might cause un-intended consequeces. Be aware of the dragons!") + return b.ClusterIdentity, nil + } + + identity, err := base.GetClusterIdentityFromNamespace(ctx, restConfig, b.clusterIdentityNamespaceName) + if err != nil { + return "", fmt.Errorf("failed to get cluster identity from namespace '%q': %w. Please provide static identity using --cluster-identity or set current kubectl context to consumer cluster", b.clusterIdentityNamespaceName, err) + } + return identity, nil +} diff --git a/contrib/kcp/deploy/resources/apiexport-kube-bind.io.yaml b/contrib/kcp/deploy/resources/apiexport-kube-bind.io.yaml index 6985b44e4..c652d179d 100644 --- a/contrib/kcp/deploy/resources/apiexport-kube-bind.io.yaml +++ b/contrib/kcp/deploy/resources/apiexport-kube-bind.io.yaml @@ -77,7 +77,7 @@ spec: crd: {} - group: kube-bind.io name: bindableresourcesrequests - schema: v251029-ff0a399.bindableresourcesrequests.kube-bind.io + schema: v251224-24568872.bindableresourcesrequests.kube-bind.io storage: crd: {} - group: kube-bind.io diff --git a/contrib/kcp/deploy/resources/apiresourceschema-bindableresourcesrequests.kube-bind.io.yaml b/contrib/kcp/deploy/resources/apiresourceschema-bindableresourcesrequests.kube-bind.io.yaml index d6259f254..20f1a4424 100644 --- a/contrib/kcp/deploy/resources/apiresourceschema-bindableresourcesrequests.kube-bind.io.yaml +++ b/contrib/kcp/deploy/resources/apiresourceschema-bindableresourcesrequests.kube-bind.io.yaml @@ -1,7 +1,7 @@ apiVersion: apis.kcp.io/v1alpha1 kind: APIResourceSchema metadata: - name: v251029-ff0a399.bindableresourcesrequests.kube-bind.io + name: v251224-24568872.bindableresourcesrequests.kube-bind.io spec: group: kube-bind.io names: @@ -25,6 +25,15 @@ spec: may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources type: string + clusterIdentity: + description: |- + ClusterIdentity contains information that uniquely identifies the cluster. + When doing dry run, we expect the client to fill this field in (or it will be taken from local cluster where context is available). + properties: + identity: + description: Identity is the unique identifier of the cluster. + type: string + type: object kind: description: |- Kind is a string value representing the REST resource this object represents. @@ -36,6 +45,8 @@ spec: metadata: type: object templateRef: + description: TemplateRef specifies the APIServiceExportTemplate to bind + to. properties: name: description: name is the name of the APIServiceExportTemplate to bind @@ -44,6 +55,9 @@ spec: required: - name type: object + required: + - clusterIdentity + - templateRef type: object served: true storage: true diff --git a/contrib/kcp/go.mod b/contrib/kcp/go.mod index cbf49ae18..bcb04b265 100644 --- a/contrib/kcp/go.mod +++ b/contrib/kcp/go.mod @@ -16,10 +16,8 @@ replace ( github.com/kcp-dev/sdk => github.com/kcp-dev/sdk v0.0.0-20251210172228-11364df3071c ) -// https://github.com/xrstf/mockoidc/pull/3 -replace github.com/xrstf/mockoidc => github.com/mjudeikis/mockoidc v0.0.0-20251215121937-c75f164e38b5 - require ( + github.com/google/uuid v1.6.0 github.com/kcp-dev/client-go v0.28.1-0.20251112153209-b37f4c1ff9a2 github.com/kcp-dev/kcp v0.0.0-00010101000000-000000000000 github.com/kcp-dev/logicalcluster/v3 v3.0.5 @@ -75,7 +73,6 @@ require ( github.com/google/cel-go v0.26.0 // indirect github.com/google/gnostic-models v0.7.0 // indirect github.com/google/go-cmp v0.7.0 // indirect - github.com/google/uuid v1.6.0 // indirect github.com/gorilla/mux v1.8.1 // indirect github.com/gorilla/securecookie v1.1.1 // indirect github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 // indirect diff --git a/contrib/kcp/go.sum b/contrib/kcp/go.sum index 1f54343b2..e4974e0b6 100644 --- a/contrib/kcp/go.sum +++ b/contrib/kcp/go.sum @@ -169,8 +169,6 @@ github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4 github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= github.com/martinlindhe/base36 v1.1.1 h1:1F1MZ5MGghBXDZ2KJ3QfxmiydlWOGB8HCEtkap5NkVg= github.com/martinlindhe/base36 v1.1.1/go.mod h1:vMS8PaZ5e/jV9LwFKlm0YLnXl/hpOihiBxKkIoc3g08= -github.com/mjudeikis/mockoidc v0.0.0-20251215121937-c75f164e38b5 h1:79Blv5YDyGQDi2qE2z4BJVoCJOU8vlkydvFqnBM3Wnc= -github.com/mjudeikis/mockoidc v0.0.0-20251215121937-c75f164e38b5/go.mod h1:1S3+yz/sau3wSTpmFOwg+JmOudpq6KA6vTuvmP5VReM= github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -247,6 +245,8 @@ github.com/xiang90/probing v0.0.0-20221125231312-a49e3df8f510 h1:S2dVYn90KE98chq github.com/xiang90/probing v0.0.0-20221125231312-a49e3df8f510/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ= github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0= +github.com/xrstf/mockoidc v0.0.0-20251217111820-5b7a850f338a h1:rPK2vAgG4T5k6ay5v+DVU7j2xjW/S3LWbzKpOGukdeE= +github.com/xrstf/mockoidc v0.0.0-20251217111820-5b7a850f338a/go.mod h1:1S3+yz/sau3wSTpmFOwg+JmOudpq6KA6vTuvmP5VReM= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= diff --git a/contrib/kcp/test/e2e/binding.go b/contrib/kcp/test/e2e/binding.go index 08d148617..c06ab27aa 100644 --- a/contrib/kcp/test/e2e/binding.go +++ b/contrib/kcp/test/e2e/binding.go @@ -21,6 +21,7 @@ import ( "testing" "time" + "github.com/google/uuid" "github.com/stretchr/testify/require" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/util/wait" @@ -46,6 +47,9 @@ func performBinding( // 1. Get APIServiceExportRequest from provider c := framework.GetKubeBindRestClient(t, kubeBindConfig) + identity, err := uuid.NewUUID() + require.NoError(t, err) + bindResponse, err := c.Bind(t.Context(), &kubebindv1alpha2.BindableResourcesRequest{ ObjectMeta: metav1.ObjectMeta{ Name: "test-binding", @@ -53,6 +57,9 @@ func performBinding( TemplateRef: kubebindv1alpha2.APIServiceExportTemplateRef{ Name: templateRef, }, + ClusterIdentity: kubebindv1alpha2.ClusterIdentity{ + Identity: identity.String(), + }, }) require.NoError(t, err) require.NotNil(t, bindResponse) diff --git a/deploy/charts/backend/crds/kube-bind.io_bindableresourcesrequests.yaml b/deploy/charts/backend/crds/kube-bind.io_bindableresourcesrequests.yaml index 15fbf05db..5c1210e7e 100644 --- a/deploy/charts/backend/crds/kube-bind.io_bindableresourcesrequests.yaml +++ b/deploy/charts/backend/crds/kube-bind.io_bindableresourcesrequests.yaml @@ -29,6 +29,15 @@ spec: may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources type: string + clusterIdentity: + description: |- + ClusterIdentity contains information that uniquely identifies the cluster. + When doing dry run, we expect the client to fill this field in (or it will be taken from local cluster where context is available). + properties: + identity: + description: Identity is the unique identifier of the cluster. + type: string + type: object kind: description: |- Kind is a string value representing the REST resource this object represents. @@ -40,6 +49,8 @@ spec: metadata: type: object templateRef: + description: TemplateRef specifies the APIServiceExportTemplate to bind + to. properties: name: description: name is the name of the APIServiceExportTemplate to bind @@ -48,6 +59,9 @@ spec: required: - name type: object + required: + - clusterIdentity + - templateRef type: object served: true storage: true diff --git a/deploy/crd/kube-bind.io_bindableresourcesrequests.yaml b/deploy/crd/kube-bind.io_bindableresourcesrequests.yaml index 15fbf05db..5c1210e7e 100644 --- a/deploy/crd/kube-bind.io_bindableresourcesrequests.yaml +++ b/deploy/crd/kube-bind.io_bindableresourcesrequests.yaml @@ -29,6 +29,15 @@ spec: may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources type: string + clusterIdentity: + description: |- + ClusterIdentity contains information that uniquely identifies the cluster. + When doing dry run, we expect the client to fill this field in (or it will be taken from local cluster where context is available). + properties: + identity: + description: Identity is the unique identifier of the cluster. + type: string + type: object kind: description: |- Kind is a string value representing the REST resource this object represents. @@ -40,6 +49,8 @@ spec: metadata: type: object templateRef: + description: TemplateRef specifies the APIServiceExportTemplate to bind + to. properties: name: description: name is the name of the APIServiceExportTemplate to bind @@ -48,6 +59,9 @@ spec: required: - name type: object + required: + - clusterIdentity + - templateRef type: object served: true storage: true diff --git a/docs/content/developers/dev-environments.md b/docs/content/developers/dev-environments.md index e6452e0c5..095ebcc7c 100644 --- a/docs/content/developers/dev-environments.md +++ b/docs/content/developers/dev-environments.md @@ -126,8 +126,8 @@ All the instructions assume you have already cloned the kube-bind repository and 10. Bind the thing: ```bash - ./bin/kubectl-bind login http://127.0.0.1:8080 --cluster 1xiy1uyh4qckje8z - ./bin/kubectl-bind --dry-run -o yaml > apiserviceexport.yaml + ./bin/kubectl-bind login http://127.0.0.1:8080 --cluster 1nso4d2rvleempdp + ./bin/kubectl-bind --dry-run --cluster-identity-namespace default -o yaml > apiserviceexport.yaml # Extract secret for binding process. Note that secret name is not the same as output from command above. Check secret # name by running `kubectl get secret -n kube-bind` @@ -145,7 +145,7 @@ All the instructions assume you have already cloned the kube-bind repository and Start konnector: ```bash - ./bin/konnector --lease-namespace default --kubeconfig .kcp/consumer.kubeconfig + ./bin/konnector --lease-namespace default --kubeconfig .kcp/consumer.kubeconfig --server-address ":9090" ``` Create example resources in consumer: diff --git a/docs/content/setup/kcp-setup.md b/docs/content/setup/kcp-setup.md index 610f5a5a7..f57975d89 100644 --- a/docs/content/setup/kcp-setup.md +++ b/docs/content/setup/kcp-setup.md @@ -68,7 +68,7 @@ kubectl ws create consumer --enter ```bash ./bin/kubectl-bind login http://127.0.0.1:8080 -./bin/kubectl-bind --dry-run -o yaml > apiserviceexport.yaml +./bin/kubectl-bind --cluster-identity-namespace default --dry-run -o yaml > apiserviceexport.yaml # Extract secret for binding process. Note that secret name is not the same as output from command above. Check secret # name by running `kubectl get secret -n kube-bind` diff --git a/docs/generators/cli-doc/go.mod b/docs/generators/cli-doc/go.mod index 2df64dbbb..87594a852 100644 --- a/docs/generators/cli-doc/go.mod +++ b/docs/generators/cli-doc/go.mod @@ -149,6 +149,7 @@ require ( k8s.io/kubectl v0.34.0 // indirect k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect oras.land/oras-go/v2 v2.6.0 // indirect + sigs.k8s.io/controller-runtime v0.22.4 // indirect sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect sigs.k8s.io/kind v0.30.0 // indirect sigs.k8s.io/kustomize/api v0.20.1 // indirect diff --git a/docs/generators/cli-doc/go.sum b/docs/generators/cli-doc/go.sum index 36253af08..e934e7aab 100644 --- a/docs/generators/cli-doc/go.sum +++ b/docs/generators/cli-doc/go.sum @@ -465,6 +465,8 @@ k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8 k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= oras.land/oras-go/v2 v2.6.0 h1:X4ELRsiGkrbeox69+9tzTu492FMUu7zJQW6eJU+I2oc= oras.land/oras-go/v2 v2.6.0/go.mod h1:magiQDfG6H1O9APp+rOsvCPcW1GD2MM7vgnKY0Y+u1o= +sigs.k8s.io/controller-runtime v0.22.4 h1:GEjV7KV3TY8e+tJ2LCTxUTanW4z/FmNB7l327UfMq9A= +sigs.k8s.io/controller-runtime v0.22.4/go.mod h1:+QX1XUpTXN4mLoblf4tqr5CQcyHPAki2HLXqQMY6vh8= sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE= sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/kind v0.30.0 h1:2Xi1KFEfSMm0XDcvKnUt15ZfgRPCT0OnCBbpgh8DztY= diff --git a/go.mod b/go.mod index ff27f31d4..f80daa423 100644 --- a/go.mod +++ b/go.mod @@ -25,6 +25,7 @@ require ( github.com/evanphx/json-patch/v5 v5.9.11 github.com/golang-jwt/jwt/v5 v5.3.0 github.com/google/go-cmp v0.7.0 + github.com/google/uuid v1.6.0 github.com/gorilla/mux v1.8.1 github.com/gorilla/securecookie v1.1.1 github.com/headzoo/surf v1.0.1 @@ -90,7 +91,6 @@ require ( github.com/google/btree v1.1.3 // indirect github.com/google/cel-go v0.26.0 // indirect github.com/google/gnostic-models v0.7.0 // indirect - github.com/google/uuid v1.6.0 // indirect github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 // indirect github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 // indirect diff --git a/sdk/apis/kubebind/v1alpha2/bindingresponse_types.go b/sdk/apis/kubebind/v1alpha2/bindingresponse_types.go index 5fc94e53d..75a788888 100644 --- a/sdk/apis/kubebind/v1alpha2/bindingresponse_types.go +++ b/sdk/apis/kubebind/v1alpha2/bindingresponse_types.go @@ -86,7 +86,15 @@ type BindableResourcesRequest struct { metav1.TypeMeta `json:",inline"` metav1.ObjectMeta `json:"metadata"` + // TemplateRef specifies the APIServiceExportTemplate to bind to. + // +required + // +kubebuilder:validation:Required TemplateRef APIServiceExportTemplateRef `json:"templateRef"` + // ClusterIdentity contains information that uniquely identifies the cluster. + // When doing dry run, we expect the client to fill this field in (or it will be taken from local cluster where context is available). + // +required + // +kubebuilder:validation:Required + ClusterIdentity ClusterIdentity `json:"clusterIdentity"` } type APIServiceExportTemplateRef struct { diff --git a/sdk/apis/kubebind/v1alpha2/cluster_types.go b/sdk/apis/kubebind/v1alpha2/cluster_types.go index 821f49b7a..7ebe06df3 100644 --- a/sdk/apis/kubebind/v1alpha2/cluster_types.go +++ b/sdk/apis/kubebind/v1alpha2/cluster_types.go @@ -74,6 +74,12 @@ type ClusterStatus struct { Conditions conditionsapi.Conditions `json:"conditions,omitempty"` } +// ClusterIdentity contains information that uniquely identifies the cluster. +type ClusterIdentity struct { + // Identity is the unique identifier of the cluster. + Identity string `json:"identity,omitempty"` +} + // ClusterList is the objects list that represents the Cluster. // // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object diff --git a/sdk/apis/kubebind/v1alpha2/zz_generated.deepcopy.go b/sdk/apis/kubebind/v1alpha2/zz_generated.deepcopy.go index 87fe4ea83..0c40cc6ba 100644 --- a/sdk/apis/kubebind/v1alpha2/zz_generated.deepcopy.go +++ b/sdk/apis/kubebind/v1alpha2/zz_generated.deepcopy.go @@ -757,6 +757,7 @@ func (in *BindableResourcesRequest) DeepCopyInto(out *BindableResourcesRequest) out.TypeMeta = in.TypeMeta in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) out.TemplateRef = in.TemplateRef + out.ClusterIdentity = in.ClusterIdentity return } @@ -1133,6 +1134,22 @@ func (in *ClusterBindingStatus) DeepCopy() *ClusterBindingStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ClusterIdentity) DeepCopyInto(out *ClusterIdentity) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClusterIdentity. +func (in *ClusterIdentity) DeepCopy() *ClusterIdentity { + if in == nil { + return nil + } + out := new(ClusterIdentity) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ClusterList) DeepCopyInto(out *ClusterList) { *out = *in diff --git a/test/e2e/bind/happy-case_test.go b/test/e2e/bind/happy-case_test.go index 12a8791fe..1fa8db252 100644 --- a/test/e2e/bind/happy-case_test.go +++ b/test/e2e/bind/happy-case_test.go @@ -24,6 +24,7 @@ import ( "testing" "time" + "github.com/google/uuid" "github.com/stretchr/testify/require" apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" apiextensionsclient "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset" @@ -47,7 +48,6 @@ import ( ) func TestClusterScoped(t *testing.T) { - t.Parallel() // name & test type defined by letters - cc - cluster-cluster so its easier to identify failures in the logs. testHappyCase(t, "cc-prefixed", apiextensionsv1.ClusterScoped, apiextensionsv1.ClusterScoped, kubebindv1alpha2.ClusterScope, kubebindv1alpha2.IsolationPrefixed) testHappyCase(t, "cc-none", apiextensionsv1.ClusterScoped, apiextensionsv1.ClusterScoped, kubebindv1alpha2.ClusterScope, kubebindv1alpha2.IsolationNone) @@ -55,8 +55,6 @@ func TestClusterScoped(t *testing.T) { } func TestNamespacedScoped(t *testing.T) { - t.Parallel() - testHappyCase(t, "nn", apiextensionsv1.NamespaceScoped, apiextensionsv1.NamespaceScoped, kubebindv1alpha2.NamespacedScope, "") testHappyCase(t, "nc", apiextensionsv1.NamespaceScoped, apiextensionsv1.NamespaceScoped, kubebindv1alpha2.ClusterScope, "") } @@ -72,6 +70,9 @@ func testHappyCase( ctx, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel) // Commented out to prevent cleanup of kcp assets + clusterIdentity, err := uuid.NewUUID() + require.NoError(t, err) + suffix := framework.RandomString(4) t.Logf("Creating provider workspace") @@ -189,6 +190,7 @@ func testHappyCase( step: func(t *testing.T) { c := framework.GetKubeBindRestClient(t, kubeBindConfig) var err error + bindResponse, err = c.Bind(ctx, &kubebindv1alpha2.BindableResourcesRequest{ ObjectMeta: metav1.ObjectMeta{ Name: "test-binding", @@ -196,6 +198,9 @@ func testHappyCase( TemplateRef: kubebindv1alpha2.APIServiceExportTemplateRef{ Name: templateRef, }, + ClusterIdentity: kubebindv1alpha2.ClusterIdentity{ + Identity: clusterIdentity.String(), + }, }) require.NoError(t, err) require.NotNil(t, bindResponse) diff --git a/web/src/App.vue b/web/src/App.vue index b062ce6ee..7947eedff 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -86,8 +86,9 @@ const authenticate = async () => { const cluster = route.query.cluster_id as string || '' const sessionId = route.query.session_id as string || generateSessionId() const clientSideRedirectUrl = route.query.redirect_url as string || '' + const consumerId = route.query.consumer_id as string || '' - await authService.initiateAuth(sessionId, cluster, clientSideRedirectUrl) + await authService.initiateAuth(sessionId, cluster, clientSideRedirectUrl, consumerId) } catch (error) { console.error('Authentication failed:', error) authStatus.value.error = 'Authentication failed' diff --git a/web/src/components/AlertModal.vue b/web/src/components/AlertModal.vue new file mode 100644 index 000000000..5072a6c51 --- /dev/null +++ b/web/src/components/AlertModal.vue @@ -0,0 +1,321 @@ + + + + + \ No newline at end of file diff --git a/web/src/services/auth.ts b/web/src/services/auth.ts index 10834c6eb..ef5f119d9 100644 --- a/web/src/services/auth.ts +++ b/web/src/services/auth.ts @@ -24,6 +24,7 @@ class AuthService { // Since cookies are HTTP-only, we need to check authentication by making an API call const urlParams = new URLSearchParams(window.location.search) const clusterId = urlParams.get('cluster_id') || '' + const consumerId = urlParams.get('consumer_id') || '' // Make a simple API call to check if we're authenticated const authCheckUrl = clusterId ? `/ping?cluster_id=${clusterId}` : '/ping' @@ -134,11 +135,15 @@ class AuthService { const urlParams = new URLSearchParams(window.location.search) const redirectUrl = urlParams.get('redirect_url') const sessionId = urlParams.get('session_id') + const consumerId = urlParams.get('consumer_id') if (redirectUrl) { const callbackUrl = new URL(redirectUrl) if (sessionId) { callbackUrl.searchParams.append('session_id', sessionId) } + if (consumerId) { + callbackUrl.searchParams.append('consumer_id', consumerId) + } // Add binding response data as base64 encoded query parameter const base64Response = btoa(JSON.stringify(bindingResponseData)) diff --git a/web/src/types/binding.ts b/web/src/types/binding.ts index f8393cfa4..d6b6aa49d 100644 --- a/web/src/types/binding.ts +++ b/web/src/types/binding.ts @@ -2,11 +2,17 @@ export interface BindingTemplate { name: string } +export interface ClusterIdentity { + identity: string + name?: string +} + export interface BindableResourcesRequest { metadata: { name: string } templateRef: BindingTemplate + clusterIdentity: ClusterIdentity } export interface APIServiceExportRequestResponse { diff --git a/web/src/views/Resources.vue b/web/src/views/Resources.vue index 7bf8ad053..73f0a7294 100644 --- a/web/src/views/Resources.vue +++ b/web/src/views/Resources.vue @@ -141,6 +141,16 @@ :binding-response="bindingResponse" @close="closeBindingResult" /> + + + @@ -148,10 +158,11 @@ import { ref, computed, onMounted } from 'vue' import { useRoute } from 'vue-router' import { httpClient } from '../services/http' -import type { BindableResourcesRequest, BindingResponse } from '../types/binding' +import type { BindableResourcesRequest, BindingResponse, ClusterIdentity } from '../types/binding' import { StructuredError } from '../services/http' import BindingResult from '../components/BindingResult.vue' import TemplateBindingModal from '../components/TemplateBindingModal.vue' +import AlertModal from '../components/AlertModal.vue' import { authService } from '../services/auth' interface Template { @@ -212,20 +223,37 @@ const collections = ref([]) const showBindingResult = ref(false) const selectedTemplateName = ref('') const bindingResponse = ref(null) + +// Alert modal state +const showAlert = ref(false) +const alertTitle = ref('Alert') +const alertMessage = ref('') +const alertType = ref<'error' | 'warning' | 'info' | 'success'>('error') +const alertPreserveWhitespace = ref(false) const isCliFlow = computed(() => authService.isCliFlow()) const showBindingModal = ref(false) const selectedTemplate = ref