Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 17 additions & 3 deletions config/crd/bases/operator.kcp.io_kubeconfigs.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -56,15 +56,17 @@ spec:
clusterRoleBindings:
properties:
cluster:
description: Cluster can be either a cluster name or a workspace
path.
description: |-
Cluster can be either a cluster name or a workspace path.

Deprecated: Use spec.targetWorkspace instead. This field is kept for backward
compatibility but cannot be set together with spec.targetWorkspace.
type: string
clusterRoles:
items:
type: string
type: array
required:
- cluster
- clusterRoles
type: object
required:
Expand Down Expand Up @@ -353,6 +355,14 @@ spec:
type: object
x-kubernetes-map-type: atomic
type: object
targetWorkspace:
description: |-
TargetWorkspace specifies the workspace path this kubeconfig targets.
Used in the generated kubeconfig server URL and as the default RBAC provisioning target.
Accepts kcp workspace paths like "root:org:team".
Defaults to "root" if unset.
pattern: ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(:[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$
type: string
username:
description: Username defines the username embedded in the TLS certificate
generated for this kubeconfig.
Expand All @@ -368,6 +378,10 @@ spec:
- username
- validity
type: object
x-kubernetes-validations:
- message: Cannot set both targetWorkspace and authorization.clusterRoleBindings.cluster.
Use targetWorkspace only.
rule: '!(has(self.targetWorkspace) && has(self.authorization) && has(self.authorization.clusterRoleBindings.cluster))'
status:
description: KubeconfigStatus defines the observed state of Kubeconfig
properties:
Expand Down
14 changes: 7 additions & 7 deletions internal/client/clients.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,29 +33,29 @@ import (
)

// NewRootShardClient returns a new client for talking to the kcp root shard service directly.
func NewRootShardClient(ctx context.Context, c ctrlruntimeclient.Client, rootShard *operatorv1alpha1.RootShard, cluster logicalcluster.Name, scheme *runtime.Scheme) (ctrlruntimeclient.Client, error) {
func NewRootShardClient(ctx context.Context, c ctrlruntimeclient.Client, rootShard *operatorv1alpha1.RootShard, cluster logicalcluster.Path, scheme *runtime.Scheme) (ctrlruntimeclient.Client, error) {
baseUrl := fmt.Sprintf("https://%s.%s.svc.cluster.local:6443", resources.GetRootShardServiceName(rootShard), rootShard.Namespace)

if !cluster.Empty() {
baseUrl = fmt.Sprintf("%s/clusters/%s", baseUrl, cluster.String())
baseUrl += cluster.RequestPath()
}

return newClient(ctx, c, baseUrl, scheme, rootShard)
}

// NewRootShardClient returns a new client that connects to the operator's internal front-proxy.
func NewRootShardProxyClient(ctx context.Context, c ctrlruntimeclient.Client, rootShard *operatorv1alpha1.RootShard, cluster logicalcluster.Name, scheme *runtime.Scheme) (ctrlruntimeclient.Client, error) {
// NewRootShardProxyClient returns a new client that connects to the operator's internal front-proxy.
func NewRootShardProxyClient(ctx context.Context, c ctrlruntimeclient.Client, rootShard *operatorv1alpha1.RootShard, cluster logicalcluster.Path, scheme *runtime.Scheme) (ctrlruntimeclient.Client, error) {
baseUrl := fmt.Sprintf("https://%s.%s.svc.cluster.local:6443", resources.GetRootShardProxyServiceName(rootShard), rootShard.Namespace)

if !cluster.Empty() {
baseUrl = fmt.Sprintf("%s/clusters/%s", baseUrl, cluster.String())
baseUrl += cluster.RequestPath()
}

return newClient(ctx, c, baseUrl, scheme, rootShard)
}

// NewShardClient returns a new client for talking to a kcp shard service directly.
func NewShardClient(ctx context.Context, c ctrlruntimeclient.Client, shard *operatorv1alpha1.Shard, cluster logicalcluster.Name, scheme *runtime.Scheme) (ctrlruntimeclient.Client, error) {
func NewShardClient(ctx context.Context, c ctrlruntimeclient.Client, shard *operatorv1alpha1.Shard, cluster logicalcluster.Path, scheme *runtime.Scheme) (ctrlruntimeclient.Client, error) {
rootShard, err := getRootShardForShard(ctx, c, shard)
if err != nil {
return nil, fmt.Errorf("failed to determine effective RootShard: %w", err)
Expand All @@ -64,7 +64,7 @@ func NewShardClient(ctx context.Context, c ctrlruntimeclient.Client, shard *oper
baseUrl := fmt.Sprintf("https://%s.%s.svc.cluster.local:6443", resources.GetShardServiceName(shard), shard.Namespace)

if !cluster.Empty() {
baseUrl = fmt.Sprintf("%s/clusters/%s", baseUrl, cluster.String())
baseUrl += cluster.RequestPath()
}

return newClient(ctx, c, baseUrl, scheme, rootShard)
Expand Down
2 changes: 1 addition & 1 deletion internal/client/frontproxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ import (
// type of shard, the client will also directly connect to that shard, but for Kubeconfigs using
// a FrontProxy, the client will instead use the operator-internal front-proxy (which specifically
// does not drop groups/permissions).
func NewInternalKubeconfigClient(ctx context.Context, c ctrlruntimeclient.Client, kubeconfig *operatorv1alpha1.Kubeconfig, cluster logicalcluster.Name, scheme *runtime.Scheme) (ctrlruntimeclient.Client, error) {
func NewInternalKubeconfigClient(ctx context.Context, c ctrlruntimeclient.Client, kubeconfig *operatorv1alpha1.Kubeconfig, cluster logicalcluster.Path, scheme *runtime.Scheme) (ctrlruntimeclient.Client, error) {
target := kubeconfig.Spec.Target

switch {
Expand Down
8 changes: 4 additions & 4 deletions internal/controller/kubeconfig-rbac/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,8 @@ func (r *KubeconfigRBACReconciler) reconcile(ctx context.Context, config *operat
if auth := config.Status.Authorization; auth != nil {
oldCluster = auth.ProvisionedCluster
}
if auth := config.Spec.Authorization; auth != nil {
newCluster = auth.ClusterRoleBindings.Cluster
if config.Spec.Authorization != nil {
newCluster = config.GetRBACTargetWorkspace().String()
}

// All `return nil` here are because the Kubeconfig has been modified and will be requeued anyway.
Expand Down Expand Up @@ -132,7 +132,7 @@ func (r *KubeconfigRBACReconciler) reconcile(ctx context.Context, config *operat
}

func (r *KubeconfigRBACReconciler) reconcileBindings(ctx context.Context, kc *operatorv1alpha1.Kubeconfig) error {
targetClient, err := client.NewInternalKubeconfigClient(ctx, r.Client, kc, logicalcluster.Name(kc.Spec.Authorization.ClusterRoleBindings.Cluster), nil)
targetClient, err := client.NewInternalKubeconfigClient(ctx, r.Client, kc, kc.GetRBACTargetWorkspace(), nil)
if err != nil {
return fmt.Errorf("failed to create client to kubeconfig target: %w", err)
}
Expand Down Expand Up @@ -207,7 +207,7 @@ func (r *KubeconfigRBACReconciler) unprovisionCluster(ctx context.Context, kc *o
return nil
}

targetClient, err := client.NewInternalKubeconfigClient(ctx, r.Client, kc, logicalcluster.Name(cluster), nil)
targetClient, err := client.NewInternalKubeconfigClient(ctx, r.Client, kc, logicalcluster.NewPath(cluster), nil)
if err != nil {
return fmt.Errorf("failed to create client to kubeconfig target: %w", err)
}
Expand Down
2 changes: 1 addition & 1 deletion internal/controller/shard/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -338,7 +338,7 @@ func (r *ShardReconciler) handleDeletion(ctx context.Context, s *operatorv1alpha
}

// Create client to root shard
kcpClient, err := client.NewRootShardClient(ctx, r.Client, rootShard, logicalcluster.Name("root"), r.Scheme)
kcpClient, err := client.NewRootShardClient(ctx, r.Client, rootShard, logicalcluster.NewPath("root"), r.Scheme)
if err != nil {
return nil, fmt.Errorf("failed to create root shard client: %w", err)
}
Expand Down
6 changes: 3 additions & 3 deletions internal/resources/kubeconfig/secret.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ func KubeconfigSecretReconciler(
}

serverURL := resources.GetRootShardBaseURL(rootShard)
defaultURL, err := url.JoinPath(serverURL, "clusters", "root")
defaultURL, err := url.JoinPath(serverURL, kubeconfig.GetTargetWorkspace().RequestPath())
if err != nil {
return nil, err
}
Expand All @@ -105,7 +105,7 @@ func KubeconfigSecretReconciler(
}

serverURL := resources.GetShardBaseURL(shard)
defaultURL, err := url.JoinPath(serverURL, "clusters", "root")
defaultURL, err := url.JoinPath(serverURL, kubeconfig.GetTargetWorkspace().RequestPath())
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -137,7 +137,7 @@ func KubeconfigSecretReconciler(
serverURL = fmt.Sprintf("https://%s:%d", rootShard.Spec.External.Hostname, rootShard.Spec.External.Port)
}

defaultURL, err := url.JoinPath(serverURL, "clusters", "root")
defaultURL, err := url.JoinPath(serverURL, kubeconfig.GetTargetWorkspace().RequestPath())
if err != nil {
return nil, err
}
Expand Down
42 changes: 41 additions & 1 deletion sdk/apis/operator/v1alpha1/kubeconfig_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,26 @@ package v1alpha1
import (
"fmt"

"github.com/kcp-dev/logicalcluster/v3"

corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)

// KubeconfigSpec defines the desired state of Kubeconfig.
// +kubebuilder:validation:XValidation:rule="!(has(self.targetWorkspace) && has(self.authorization) && has(self.authorization.clusterRoleBindings.cluster))",message="Cannot set both targetWorkspace and authorization.clusterRoleBindings.cluster. Use targetWorkspace only."
type KubeconfigSpec struct {
// Target configures which kcp-operator object this kubeconfig should be generated for (shard or front-proxy).
Target KubeconfigTarget `json:"target"`

// TargetWorkspace specifies the workspace path this kubeconfig targets.
// Used in the generated kubeconfig server URL and as the default RBAC provisioning target.
// Accepts kcp workspace paths like "root:org:team".
// Defaults to "root" if unset.
// +optional
// +kubebuilder:validation:Pattern=`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(:[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$`
TargetWorkspace string `json:"targetWorkspace,omitempty"`

// Username defines the username embedded in the TLS certificate generated for this kubeconfig.
Username string `json:"username"`
// Username defines the groups embedded in the TLS certificate generated for this kubeconfig.
Expand Down Expand Up @@ -59,7 +70,12 @@ type KubeconfigAuthorization struct {

type KubeconfigClusterRoleBindings struct {
// Cluster can be either a cluster name or a workspace path.
Cluster string `json:"cluster"`
//
// Deprecated: Use spec.targetWorkspace instead. This field is kept for backward
// compatibility but cannot be set together with spec.targetWorkspace.
// +optional
Cluster string `json:"cluster,omitempty"`

ClusterRoles []string `json:"clusterRoles"`
}

Expand Down Expand Up @@ -109,6 +125,30 @@ func (k *Kubeconfig) GetCertificateName() string {
return fmt.Sprintf("kubeconfig-cert-%s", k.Name)
}

// GetTargetWorkspace returns the workspace path for the generated kubeconfig URL.
// Only spec.targetWorkspace is considered; defaults to "root".
// The deprecated authorization.clusterRoleBindings.cluster field does NOT influence
// the URL to avoid breaking existing users.
func (k *Kubeconfig) GetTargetWorkspace() logicalcluster.Path {
if k.Spec.TargetWorkspace != "" {
return logicalcluster.NewPath(k.Spec.TargetWorkspace)
}
return logicalcluster.NewPath("root")
}

// GetRBACTargetWorkspace returns the workspace path for RBAC provisioning.
// It checks spec.targetWorkspace first, then falls back to
// spec.authorization.clusterRoleBindings.cluster (deprecated), and defaults to "root".
func (k *Kubeconfig) GetRBACTargetWorkspace() logicalcluster.Path {
if k.Spec.TargetWorkspace != "" {
return logicalcluster.NewPath(k.Spec.TargetWorkspace)
}
if auth := k.Spec.Authorization; auth != nil && auth.ClusterRoleBindings.Cluster != "" {
return logicalcluster.NewPath(auth.ClusterRoleBindings.Cluster)
}
return logicalcluster.NewPath("root")
}

// +kubebuilder:object:root=true

// KubeconfigList contains a list of Kubeconfig
Expand Down
144 changes: 144 additions & 0 deletions sdk/apis/operator/v1alpha1/kubeconfig_types_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
/*
Copyright 2026 The kcp 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 v1alpha1

import (
"testing"

"github.com/kcp-dev/logicalcluster/v3"
)

func TestGetTargetWorkspace(t *testing.T) {
tests := []struct {
name string
kc *Kubeconfig
expected logicalcluster.Path
}{
{
name: "targetWorkspace set",
kc: &Kubeconfig{
Spec: KubeconfigSpec{
TargetWorkspace: "root:org:team",
},
},
expected: logicalcluster.NewPath("root:org:team"),
},
{
name: "deprecated cluster does NOT affect URL",
kc: &Kubeconfig{
Spec: KubeconfigSpec{
Authorization: &KubeconfigAuthorization{
ClusterRoleBindings: KubeconfigClusterRoleBindings{
Cluster: "root:legacy:workspace",
},
},
},
},
expected: logicalcluster.NewPath("root"),
},
{
name: "both empty defaults to root",
kc: &Kubeconfig{
Spec: KubeconfigSpec{},
},
expected: logicalcluster.NewPath("root"),
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tt.kc.GetTargetWorkspace()
if !got.Equal(tt.expected) {
t.Errorf("got %q, want %q", got, tt.expected)
}
})
}
}

func TestGetRBACTargetWorkspace(t *testing.T) {
tests := []struct {
name string
kc *Kubeconfig
expected logicalcluster.Path
}{
{
name: "targetWorkspace set",
kc: &Kubeconfig{
Spec: KubeconfigSpec{
TargetWorkspace: "root:org:team",
},
},
expected: logicalcluster.NewPath("root:org:team"),
},
{
name: "targetWorkspace empty, deprecated cluster set",
kc: &Kubeconfig{
Spec: KubeconfigSpec{
Authorization: &KubeconfigAuthorization{
ClusterRoleBindings: KubeconfigClusterRoleBindings{
Cluster: "root:legacy:workspace",
},
},
},
},
expected: logicalcluster.NewPath("root:legacy:workspace"),
},
{
name: "both empty defaults to root",
kc: &Kubeconfig{
Spec: KubeconfigSpec{},
},
expected: logicalcluster.NewPath("root"),
},
{
name: "authorization set without cluster defaults to root",
kc: &Kubeconfig{
Spec: KubeconfigSpec{
Authorization: &KubeconfigAuthorization{
ClusterRoleBindings: KubeconfigClusterRoleBindings{
ClusterRoles: []string{"admin"},
},
},
},
},
expected: logicalcluster.NewPath("root"),
},
{
name: "targetWorkspace takes precedence over deprecated cluster",
kc: &Kubeconfig{
Spec: KubeconfigSpec{
TargetWorkspace: "root:new",
Authorization: &KubeconfigAuthorization{
ClusterRoleBindings: KubeconfigClusterRoleBindings{
Cluster: "root:old",
},
},
},
},
expected: logicalcluster.NewPath("root:new"),
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tt.kc.GetRBACTargetWorkspace()
if !got.Equal(tt.expected) {
t.Errorf("got %q, want %q", got, tt.expected)
}
})
}
}
Loading
Loading