diff --git a/pkg/cli/login/login.go b/pkg/cli/login/login.go index f7a837c546..e7a6852314 100644 --- a/pkg/cli/login/login.go +++ b/pkg/cli/login/login.go @@ -44,6 +44,9 @@ var ( # Log in to the given server with the given credentials (will not prompt interactively) oc login localhost:8443 --username=myuser --password=mypass + # Log in to the given server as myuser and use a custom kubeconfig context name. + oc login localhost:8443 --username=myuser --context=local + # Log in to the given server through a browser oc login localhost:8443 --web --callback-port 8280 @@ -162,6 +165,7 @@ func (o *LoginOptions) Complete(f kcmdutil.Factory, cmd *cobra.Command, args []s o.Token = kcmdutil.GetFlagString(cmd, "token") o.DefaultNamespace, _, _ = f.ToRawKubeConfigLoader().Namespace() + o.Context = kcmdutil.GetFlagString(cmd, "context") o.PathOptions = kclientcmd.NewDefaultPathOptions() // we need to set explicit path if one was specified, since NewDefaultPathOptions doesn't do it for us diff --git a/pkg/cli/login/loginoptions.go b/pkg/cli/login/loginoptions.go index 75ffe281c8..83a38ee9c8 100644 --- a/pkg/cli/login/loginoptions.go +++ b/pkg/cli/login/loginoptions.go @@ -75,6 +75,7 @@ type LoginOptions struct { StartingKubeConfig *kclientcmdapi.Config DefaultNamespace string Config *restclient.Config + Context string // cert data to be used when authenticating CertFile string @@ -570,7 +571,7 @@ func (o *LoginOptions) SaveConfig() (bool, error) { return false, err } - configToWrite, err := cliconfig.MergeConfig(*o.StartingKubeConfig, *newConfig) + configToWrite, err := o.mergeConfig(*newConfig) if err != nil { return false, err } @@ -593,6 +594,46 @@ func (o *LoginOptions) SaveConfig() (bool, error) { return created, nil } +// mergeConfig merges StartingKubeConfig with additionalConfig, +// which must contain just a single context, cluster and authInfo. +func (o *LoginOptions) mergeConfig(additionalConfig kclientcmdapi.Config) (*kclientcmdapi.Config, error) { + if o.Context == "" { + return cliconfig.MergeConfig(*o.StartingKubeConfig, additionalConfig) + } + + // Set the custom context name in additionalConfig. This needs to happen in any case. + var newContext *kclientcmdapi.Context + for _, v := range additionalConfig.Contexts { + newContext = v + additionalConfig.Contexts = map[string]*kclientcmdapi.Context{ + o.Context: v, + } + } + if newContext == nil { + panic(errors.New("no context found in additionalConfig")) + } + + // If there is a matching context, replace the linked cluster and authInfo. + existingContext, ok := o.StartingKubeConfig.Contexts[o.Context] + if ok { + for _, v := range additionalConfig.Clusters { + additionalConfig.Clusters = map[string]*kclientcmdapi.Cluster{ + existingContext.Cluster: v, + } + newContext.Cluster = existingContext.Cluster + } + for _, v := range additionalConfig.AuthInfos { + additionalConfig.AuthInfos = map[string]*kclientcmdapi.AuthInfo{ + existingContext.AuthInfo: v, + } + newContext.AuthInfo = existingContext.AuthInfo + } + } + + additionalConfig.CurrentContext = o.Context + return cliconfig.MergeConfig(*o.StartingKubeConfig, additionalConfig) +} + func (o *LoginOptions) whoAmI(clientConfig *restclient.Config) (*userv1.User, error) { if o.whoAmIFunc != nil { return o.whoAmIFunc(clientConfig) diff --git a/pkg/cli/login/loginoptions_test.go b/pkg/cli/login/loginoptions_test.go index 85ba775bf2..c5cd43c95c 100644 --- a/pkg/cli/login/loginoptions_test.go +++ b/pkg/cli/login/loginoptions_test.go @@ -669,3 +669,187 @@ func newTLSServer(certString, keyString string) (*httptest.Server, error) { } return server, nil } + +func TestMergeConfig(t *testing.T) { + testCases := []struct { + name string + startingConfig *kclientcmdapi.Config + additionalConfig kclientcmdapi.Config + contextFlag string + expectedConfig *kclientcmdapi.Config + }{ + { + name: "context name unspecified", + startingConfig: &kclientcmdapi.Config{ + Clusters: map[string]*kclientcmdapi.Cluster{}, + AuthInfos: map[string]*kclientcmdapi.AuthInfo{}, + Contexts: map[string]*kclientcmdapi.Context{}, + }, + additionalConfig: kclientcmdapi.Config{ + Clusters: map[string]*kclientcmdapi.Cluster{ + "new-cluster": {Server: "https://new-server:6443"}, + }, + AuthInfos: map[string]*kclientcmdapi.AuthInfo{ + "new-user": {Token: "token"}, + }, + Contexts: map[string]*kclientcmdapi.Context{ + "new-context": {Cluster: "new-cluster", AuthInfo: "new-user", Namespace: "default"}, + }, + CurrentContext: "new-context", + }, + expectedConfig: &kclientcmdapi.Config{ + Clusters: map[string]*kclientcmdapi.Cluster{ + "new-cluster": {Server: "https://new-server:6443"}, + }, + AuthInfos: map[string]*kclientcmdapi.AuthInfo{ + "new-user": {Token: "token"}, + }, + Contexts: map[string]*kclientcmdapi.Context{ + "new-context": {Cluster: "new-cluster", AuthInfo: "new-user", Namespace: "default"}, + }, + CurrentContext: "new-context", + }, + }, + { + name: "custom context name without matching existing context", + startingConfig: &kclientcmdapi.Config{ + Clusters: map[string]*kclientcmdapi.Cluster{}, + AuthInfos: map[string]*kclientcmdapi.AuthInfo{}, + Contexts: map[string]*kclientcmdapi.Context{}, + }, + additionalConfig: kclientcmdapi.Config{ + Clusters: map[string]*kclientcmdapi.Cluster{ + "new-cluster": {Server: "https://new-server:6443"}, + }, + AuthInfos: map[string]*kclientcmdapi.AuthInfo{ + "new-user": {Token: "token"}, + }, + Contexts: map[string]*kclientcmdapi.Context{ + "auto-generated-context": {Cluster: "new-cluster", AuthInfo: "new-user", Namespace: "default"}, + }, + CurrentContext: "auto-generated-context", + }, + contextFlag: "my-custom-context", + expectedConfig: &kclientcmdapi.Config{ + Clusters: map[string]*kclientcmdapi.Cluster{ + "new-cluster": {Server: "https://new-server:6443"}, + }, + AuthInfos: map[string]*kclientcmdapi.AuthInfo{ + "new-user": {Token: "token"}, + }, + Contexts: map[string]*kclientcmdapi.Context{ + "my-custom-context": {Cluster: "new-cluster", AuthInfo: "new-user", Namespace: "default"}, + }, + CurrentContext: "my-custom-context", + }, + }, + { + name: "custom context name matches existing context", + startingConfig: &kclientcmdapi.Config{ + Clusters: map[string]*kclientcmdapi.Cluster{ + "existing-cluster": {Server: "https://old-server:6443"}, + }, + AuthInfos: map[string]*kclientcmdapi.AuthInfo{ + "existing-user": {Token: "old-token"}, + }, + Contexts: map[string]*kclientcmdapi.Context{ + "my-custom-context": {Cluster: "existing-cluster", AuthInfo: "existing-user", Namespace: "old-namespace"}, + }, + }, + additionalConfig: kclientcmdapi.Config{ + Clusters: map[string]*kclientcmdapi.Cluster{ + "new-cluster": {Server: "https://new-server:6443"}, + }, + AuthInfos: map[string]*kclientcmdapi.AuthInfo{ + "new-user": {Token: "new-token"}, + }, + Contexts: map[string]*kclientcmdapi.Context{ + "auto-generated-context": {Cluster: "new-cluster", AuthInfo: "new-user", Namespace: "default"}, + }, + CurrentContext: "auto-generated-context", + }, + contextFlag: "my-custom-context", + expectedConfig: &kclientcmdapi.Config{ + Clusters: map[string]*kclientcmdapi.Cluster{ + "existing-cluster": {Server: "https://new-server:6443"}, + }, + AuthInfos: map[string]*kclientcmdapi.AuthInfo{ + "existing-user": {Token: "new-token"}, + }, + Contexts: map[string]*kclientcmdapi.Context{ + "my-custom-context": {Cluster: "existing-cluster", AuthInfo: "existing-user", Namespace: "default"}, + }, + CurrentContext: "my-custom-context", + }, + }, + { + name: "custom context name replaces matching context but keeps other contexts", + startingConfig: &kclientcmdapi.Config{ + Clusters: map[string]*kclientcmdapi.Cluster{ + "cluster-a": {Server: "https://server-a:6443"}, + "cluster-b": {Server: "https://server-b:6443"}, + "existing-cluster": {Server: "https://old-server:6443"}, + }, + AuthInfos: map[string]*kclientcmdapi.AuthInfo{ + "user-a": {Token: "token-a"}, + "user-b": {Token: "token-b"}, + "existing-user": {Token: "old-token"}, + }, + Contexts: map[string]*kclientcmdapi.Context{ + "context-a": {Cluster: "cluster-a", AuthInfo: "user-a", Namespace: "ns-a"}, + "context-b": {Cluster: "cluster-b", AuthInfo: "user-b", Namespace: "ns-b"}, + "my-custom-context": {Cluster: "existing-cluster", AuthInfo: "existing-user", Namespace: "old-namespace"}, + }, + CurrentContext: "context-a", + }, + additionalConfig: kclientcmdapi.Config{ + Clusters: map[string]*kclientcmdapi.Cluster{ + "new-cluster": {Server: "https://new-server:6443"}, + }, + AuthInfos: map[string]*kclientcmdapi.AuthInfo{ + "new-user": {Token: "new-token"}, + }, + Contexts: map[string]*kclientcmdapi.Context{ + "auto-generated-context": {Cluster: "new-cluster", AuthInfo: "new-user", Namespace: "default"}, + }, + CurrentContext: "auto-generated-context", + }, + contextFlag: "my-custom-context", + expectedConfig: &kclientcmdapi.Config{ + Clusters: map[string]*kclientcmdapi.Cluster{ + "cluster-a": {Server: "https://server-a:6443"}, + "cluster-b": {Server: "https://server-b:6443"}, + "existing-cluster": {Server: "https://new-server:6443"}, + }, + AuthInfos: map[string]*kclientcmdapi.AuthInfo{ + "user-a": {Token: "token-a"}, + "user-b": {Token: "token-b"}, + "existing-user": {Token: "new-token"}, + }, + Contexts: map[string]*kclientcmdapi.Context{ + "context-a": {Cluster: "cluster-a", AuthInfo: "user-a", Namespace: "ns-a"}, + "context-b": {Cluster: "cluster-b", AuthInfo: "user-b", Namespace: "ns-b"}, + "my-custom-context": {Cluster: "existing-cluster", AuthInfo: "existing-user", Namespace: "default"}, + }, + CurrentContext: "my-custom-context", + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + options := &LoginOptions{ + StartingKubeConfig: tc.startingConfig, + Context: tc.contextFlag, + } + + result, err := options.mergeConfig(tc.additionalConfig) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if !cmp.Equal(result, tc.expectedConfig) { + t.Errorf("Unexpected merged config:\n%s", cmp.Diff(tc.expectedConfig, result)) + } + }) + } +} diff --git a/pkg/cli/project/project.go b/pkg/cli/project/project.go index 81750ecddd..b568a32f6a 100644 --- a/pkg/cli/project/project.go +++ b/pkg/cli/project/project.go @@ -276,19 +276,47 @@ func (o ProjectOptions) Run() error { userNameInUse = user.Name } - kubeconfig, err := cliconfig.CreateConfig(projectName, userNameInUse, o.RESTConfig) - if err != nil { - return err + // Check if the current context has a custom name (doesn't match the auto-generated pattern). + // If so, and the server matches, just update its namespace instead of creating a new context. + // This preserves custom context names set via "oc login --context=". + contextUpdated := false + if currentContext != nil { + defaultContextName := cliconfig.GetContextNickname(currentContext.Namespace, currentContext.Cluster, currentContext.AuthInfo) + if config.CurrentContext != defaultContextName { + if cluster := config.Clusters[currentContext.Cluster]; cluster != nil { + currentServer, err := cliconfig.NormalizeServerURL(cluster.Server) + if err != nil { + return fmt.Errorf("invalid server URL %q in kubeconfig: %v", cluster.Server, err) + } + targetServer, err := cliconfig.NormalizeServerURL(clientCfg.Host) + if err != nil { + return fmt.Errorf("invalid server URL %q: %v", clientCfg.Host, err) + } + if currentServer == targetServer { + currentContext.Namespace = projectName + namespaceInUse = projectName + contextInUse = config.CurrentContext + contextUpdated = true + } + } + } } - merged, err := cliconfig.MergeConfig(config, *kubeconfig) - if err != nil { - return err - } - config = *merged + if !contextUpdated { + kubeconfig, err := cliconfig.CreateConfig(projectName, userNameInUse, o.RESTConfig) + if err != nil { + return err + } - namespaceInUse = projectName - contextInUse = merged.CurrentContext + merged, err := cliconfig.MergeConfig(config, *kubeconfig) + if err != nil { + return err + } + config = *merged + + namespaceInUse = projectName + contextInUse = merged.CurrentContext + } } if err := kclientcmd.ModifyConfig(o.PathOptions, config, true); err != nil {