Skip to content
Open
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
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ go 1.24.0
require (
github.com/onsi/ginkgo v1.16.5
github.com/onsi/gomega v1.36.1
gopkg.in/yaml.v3 v3.0.1
k8s.io/api v0.33.0
k8s.io/apimachinery v0.33.0
k8s.io/client-go v0.33.0
Expand Down Expand Up @@ -53,7 +54,6 @@ require (
gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
k8s.io/apiextensions-apiserver v0.32.1 // indirect
k8s.io/gengo/v2 v2.0.0-20240911193312-2b36238f13e9 // indirect
k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff // indirect
Expand Down
81 changes: 69 additions & 12 deletions pkg/credentials/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,22 +8,34 @@ import (
"net/url"
"os"

"gopkg.in/yaml.v3"
"k8s.io/client-go/rest"
clientcmdapi "k8s.io/client-go/tools/clientcmd/api"
clientcmdlatest "k8s.io/client-go/tools/clientcmd/api/latest"
"k8s.io/klog/v2"
"sigs.k8s.io/cluster-inventory-api/apis/v1alpha1"
)

// client.authentication.k8s.io/exec is a reserved extension key defined by the Kubernetes
// client authentication API (SIG Auth), not by the ClusterProfile API.
// Reference:
// https://kubernetes.io/docs/reference/config-api/client-authentication.v1beta1/#client-authentication-k8s-io-v1beta1-Cluster
const clusterExtensionKey = "client.authentication.k8s.io/exec"
const (
// client.authentication.k8s.io/exec is a reserved extension key defined by the Kubernetes
// client authentication API (SIG Auth), not by the ClusterProfile API.
// Reference:
// https://kubernetes.io/docs/reference/config-api/client-authentication.v1beta1/#client-authentication-k8s-io-v1beta1-Cluster
clusterExecExtensionKey = "client.authentication.k8s.io/exec"

// additionalCLIArgsExtensionKey and additionalEnvVarsExtensionKey are
// two reserved extensions defined in KEP 5339, which allows users to pass in (usually cluster-specific)
// additional command-line arguments and environment variables to the exec plugin from
// the ClusterProfile API side.
additionalCLIArgsExtensionKey = "multicluster.x-k8s.io/clusterprofiles/auth/exec/additional-args"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would suggest

clusterprofiles.multicluster.x-k8s.io/exec/additional-args
clusterprofiles.multicluster.x-k8s.io/exec/additional-envs

additionalEnvVarsExtensionKey = "multicluster.x-k8s.io/clusterprofiles/auth/exec/additional-envs"
)

type Provider struct {
Name string `json:"name"`
ExecConfig *clientcmdapi.ExecConfig `json:"execConfig"`
Name string `json:"name"`
ExecConfig *clientcmdapi.ExecConfig `json:"execConfig"`
AllowProfileSourcedCLIArgs bool `json:"allowProfileSourcedCLIArgs,omitempty"`
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of boolean, how do you think if we can set a this as Merge, Replace, or Ignore ?

AllowProfileSourcedEnvVars bool `json:"allowProfileSourcedEnvVars,omitempty"`
}

type CredentialsProvider struct {
Expand Down Expand Up @@ -68,11 +80,53 @@ func (cp *CredentialsProvider) BuildConfigFromCP(clusterprofile *v1alpha1.Cluste
}

// 2. Get Exec Config
execConfig := cp.getExecConfigFromConfig(clusterAccessor.Name)
execConfig, allowProfileSourcedCLIArgs, allowProfileSourcedEnvVars := cp.getExecConfigAndFlagsFromConfig(clusterAccessor.Name)
if execConfig == nil {
return nil, fmt.Errorf("no exec credentials found for provider %q", clusterAccessor.Name)
}

// 3. Add additional CLI arguments and environment variables from cluster extensions if allowed.
for idx := range clusterAccessor.Cluster.Extensions {
ext := &clusterAccessor.Cluster.Extensions[idx]

switch ext.Name {
case additionalCLIArgsExtensionKey:
if allowProfileSourcedCLIArgs {
var additionalArgs []string
if err := yaml.Unmarshal(ext.Extension.Raw, &additionalArgs); err != nil {
return nil, fmt.Errorf("failed to unmarshal additional CLI args extension: %w", err)
}
execConfig.Args = append(execConfig.Args, additionalArgs...)
}
case additionalEnvVarsExtensionKey:
if allowProfileSourcedEnvVars {
var envVars map[string]string
if err := yaml.Unmarshal(ext.Extension.Raw, &envVars); err != nil {
return nil, fmt.Errorf("failed to unmarshal additional env vars extension: %w", err)
}

// Add existing environment variables. Note that if the same variable is specified twice
// in both the extension data and the execConfig data, the value in the extension data takes precedence.
for idx := range execConfig.Env {
env := &execConfig.Env[idx]
if _, exists := envVars[env.Name]; !exists {
envVars[env.Name] = env.Value
}
}

// Write the merged list back to the execConfig.
envVarList := make([]clientcmdapi.ExecEnvVar, 0, len(envVars))
for name, value := range envVars {
envVarList = append(envVarList, clientcmdapi.ExecEnvVar{
Name: name,
Value: value,
})
}
execConfig.Env = envVarList
}
}
}

// 3. build resulting rest.Config
config := &rest.Config{
Host: clusterAccessor.Cluster.Server,
Expand All @@ -94,25 +148,28 @@ func (cp *CredentialsProvider) BuildConfigFromCP(clusterprofile *v1alpha1.Cluste
Env: execConfig.Env,
InteractiveMode: "Never",
ProvideClusterInfo: execConfig.ProvideClusterInfo,
Config: execConfig.Config,
}

// Propagate reserved extension into ExecCredential.Spec.Cluster.Config if present
internalCluster := clientcmdapi.NewCluster()
if err := clientcmdlatest.Scheme.Convert(&clusterAccessor.Cluster, internalCluster, nil); err != nil {
return nil, fmt.Errorf("failed to convert v1 Cluster to internal: %w", err)
}
config.ExecProvider.Config = internalCluster.Extensions[clusterExtensionKey]
if extData, ok := internalCluster.Extensions[clusterExecExtensionKey]; ok {
config.ExecProvider.Config = extData
}

return config, nil
}

func (cp *CredentialsProvider) getExecConfigFromConfig(providerName string) *clientcmdapi.ExecConfig {
func (cp *CredentialsProvider) getExecConfigAndFlagsFromConfig(providerName string) (*clientcmdapi.ExecConfig, bool, bool) {
for _, provider := range cp.Providers {
if provider.Name == providerName {
return provider.ExecConfig
return provider.ExecConfig, provider.AllowProfileSourcedCLIArgs, provider.AllowProfileSourcedEnvVars
}
}
return nil
return nil, false, false
}

// getClusterAccessorFromClusterProfile returns the first AccessProvider from the ClusterProfile
Expand Down
111 changes: 106 additions & 5 deletions pkg/credentials/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@ import (

"github.com/onsi/ginkgo"
"github.com/onsi/gomega"
"gopkg.in/yaml.v3"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
clientauthenticationv1 "k8s.io/client-go/pkg/apis/clientauthentication/v1"
clientcmdapi "k8s.io/client-go/tools/clientcmd/api"
clientcmdv1 "k8s.io/client-go/tools/clientcmd/api/v1"
Expand Down Expand Up @@ -65,6 +67,8 @@ var _ = ginkgo.Describe("CredentialsProvider", func() {
Args: []string{"arg3"},
APIVersion: "client.authentication.k8s.io/v1beta1",
},
AllowProfileSourcedCLIArgs: true,
AllowProfileSourcedEnvVars: true,
},
}
credentialsProvider = New(testProviders)
Expand Down Expand Up @@ -150,6 +154,8 @@ var _ = ginkgo.Describe("CredentialsProvider", func() {
gomega.Expect(cp.Providers).To(gomega.HaveLen(1))
gomega.Expect(cp.Providers[0].Name).To(gomega.Equal("gkeFleet"))
gomega.Expect(cp.Providers[0].ExecConfig.Command).To(gomega.Equal("gke-gcloud-auth-plugin"))
gomega.Expect(cp.Providers[0].AllowProfileSourcedCLIArgs).To(gomega.Equal(false))
gomega.Expect(cp.Providers[0].AllowProfileSourcedEnvVars).To(gomega.Equal(false))
})

ginkgo.It("should return an error when file does not exist", func() {
Expand Down Expand Up @@ -185,26 +191,43 @@ var _ = ginkgo.Describe("CredentialsProvider", func() {

ginkgo.Describe("getExecConfigFromConfig", func() {
ginkgo.It("should return the correct ExecConfig for existing provider", func() {
execConfig := credentialsProvider.getExecConfigFromConfig("test-provider-1")
execConfig, allowProfileSourcedCLIArgs, allowProfileSourcedEnvVars := credentialsProvider.getExecConfigAndFlagsFromConfig("test-provider-1")
gomega.Expect(execConfig).NotTo(gomega.BeNil())
gomega.Expect(execConfig.Command).To(gomega.Equal("test-command-1"))
gomega.Expect(execConfig.Args).To(gomega.Equal([]string{"arg1", "arg2"}))
gomega.Expect(allowProfileSourcedCLIArgs).To(gomega.Equal(false))
gomega.Expect(allowProfileSourcedEnvVars).To(gomega.Equal(false))
})

ginkgo.It("should return the correct ExecConfig for another existing provider", func() {
execConfig, allowProfileSourcedCLIArgs, allowProfileSourcedEnvVars := credentialsProvider.getExecConfigAndFlagsFromConfig("test-provider-2")
gomega.Expect(execConfig).NotTo(gomega.BeNil())
gomega.Expect(execConfig.Command).To(gomega.Equal("test-command-2"))
gomega.Expect(execConfig.Args).To(gomega.Equal([]string{"arg3"}))
gomega.Expect(allowProfileSourcedCLIArgs).To(gomega.Equal(true))
gomega.Expect(allowProfileSourcedEnvVars).To(gomega.Equal(true))
})

ginkgo.It("should return nil for non-existing provider", func() {
execConfig := credentialsProvider.getExecConfigFromConfig("non-existent-provider")
execConfig, allowProfileSourcedCLIArgs, allowProfileSourcedEnvVars := credentialsProvider.getExecConfigAndFlagsFromConfig("non-existent-provider")
gomega.Expect(execConfig).To(gomega.BeNil())
gomega.Expect(allowProfileSourcedCLIArgs).To(gomega.Equal(false))
gomega.Expect(allowProfileSourcedEnvVars).To(gomega.Equal(false))
})

ginkgo.It("should return nil for empty provider name", func() {
execConfig := credentialsProvider.getExecConfigFromConfig("")
execConfig, allowProfileSourcedCLIArgs, allowProfileSourcedEnvVars := credentialsProvider.getExecConfigAndFlagsFromConfig("")
gomega.Expect(execConfig).To(gomega.BeNil())
gomega.Expect(allowProfileSourcedCLIArgs).To(gomega.Equal(false))
gomega.Expect(allowProfileSourcedEnvVars).To(gomega.Equal(false))
})

ginkgo.It("should handle CredentialsProvider with no providers", func() {
emptyCP := New([]Provider{})
execConfig := emptyCP.getExecConfigFromConfig("any-provider")
execConfig, allowProfileSourcedCLIArgs, allowProfileSourcedEnvVars := emptyCP.getExecConfigAndFlagsFromConfig("any-provider")
gomega.Expect(execConfig).To(gomega.BeNil())
gomega.Expect(allowProfileSourcedCLIArgs).To(gomega.Equal(false))
gomega.Expect(allowProfileSourcedEnvVars).To(gomega.Equal(false))
})
})

Expand Down Expand Up @@ -285,6 +308,8 @@ var _ = ginkgo.Describe("CredentialsProvider", func() {
ginkgo.Describe("BuildConfigFromCP", func() {
var clusterProfile *v1alpha1.ClusterProfile

additionalCLIArgsData, _ := yaml.Marshal([]string{"--audience", "audience"})
additionalEnvVarsData, _ := yaml.Marshal(map[string]string{"CLIENT_ID": "client-id", "TENANT_ID": "tenant-id"})
ginkgo.BeforeEach(func() {
clusterProfile = &v1alpha1.ClusterProfile{
ObjectMeta: metav1.ObjectMeta{
Expand All @@ -298,6 +323,26 @@ var _ = ginkgo.Describe("CredentialsProvider", func() {
Server: "https://test-server.com",
CertificateAuthorityData: []byte("test-ca-data"),
ProxyURL: "http://proxy.example.com",
Extensions: []clientcmdv1.NamedExtension{
{
Name: clusterExecExtensionKey,
Extension: runtime.RawExtension{
Raw: []byte("arbitrary-data"),
},
},
{
Name: additionalCLIArgsExtensionKey,
Extension: runtime.RawExtension{
Raw: additionalCLIArgsData,
},
},
{
Name: additionalEnvVarsExtensionKey,
Extension: runtime.RawExtension{
Raw: additionalEnvVarsData,
},
},
},
},
},
},
Expand Down Expand Up @@ -327,7 +372,7 @@ var _ = ginkgo.Describe("CredentialsProvider", func() {
gomega.Expect(err.Error()).To(gomega.ContainSubstring("no exec credentials found for provider"))
})

ginkgo.It("should build config successfully", func() {
ginkgo.It("should build config successfully (no additional CLI args/env vars)", func() {
cred := clientauthenticationv1.ExecCredential{
TypeMeta: metav1.TypeMeta{
APIVersion: "client.authentication.k8s.io/v1",
Expand Down Expand Up @@ -355,6 +400,62 @@ var _ = ginkgo.Describe("CredentialsProvider", func() {
gomega.Expect(config).NotTo(gomega.BeNil())
gomega.Expect(config.Host).To(gomega.Equal("https://test-server.com"))
gomega.Expect(config.TLSClientConfig.CAData).To(gomega.Equal([]byte("test-ca-data")))
gomega.Expect(config.ExecProvider.Command).To(gomega.Equal("cat"))
gomega.Expect(config.ExecProvider.Args).To(gomega.Equal([]string{testFile}))
})

ginkgo.It("should build config successfully (with additional CLI args/env vars)", func() {
cred := clientauthenticationv1.ExecCredential{
TypeMeta: metav1.TypeMeta{
APIVersion: "client.authentication.k8s.io/v1",
Kind: "ExecCredential",
},
Status: &clientauthenticationv1.ExecCredentialStatus{
Token: "test-token",
},
}
jsonData, err := json.Marshal(cred)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
testFile := filepath.Join(tempDir, "test-config.json")
err = os.WriteFile(testFile, jsonData, 0644)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
execCP := New([]Provider{
{
Name: "test-provider-1",
ExecConfig: &clientcmdapi.ExecConfig{
APIVersion: "client.authentication.k8s.io/v1",
Command: "cat",
Args: []string{testFile},
Env: []clientcmdapi.ExecEnvVar{
{
Name: "CLIENT_ID",
Value: "None",
},
},
},
AllowProfileSourcedCLIArgs: true,
AllowProfileSourcedEnvVars: true,
},
})

config, err := execCP.BuildConfigFromCP(clusterProfile)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Expect(config).NotTo(gomega.BeNil())
gomega.Expect(config.Host).To(gomega.Equal("https://test-server.com"))
gomega.Expect(config.TLSClientConfig.CAData).To(gomega.Equal([]byte("test-ca-data")))
gomega.Expect(config.ExecProvider.Command).To(gomega.Equal("cat"))
gomega.Expect(config.ExecProvider.Args).To(gomega.Equal([]string{testFile, "--audience", "audience"}))
gomega.Expect(len(config.ExecProvider.Env)).To(gomega.Equal(2))
gomega.Expect(config.ExecProvider.Env).To(gomega.ContainElements(
clientcmdapi.ExecEnvVar{
Name: "CLIENT_ID",
Value: "client-id",
},
clientcmdapi.ExecEnvVar{
Name: "TENANT_ID",
Value: "tenant-id",
},
))
})
})
})
File renamed without changes.
Loading