From bd5dbf43bde1dd035567e4c60073a5974b17553c Mon Sep 17 00:00:00 2001 From: Ondra Kupka Date: Fri, 16 Jan 2026 16:55:24 +0100 Subject: [PATCH 1/2] oc login: Implement --context flag The flag can be used to tell oc what context name to use when updating kubeconfig. If a matching context already exists, it is replaced altogether, including the linked cluster and authInfo. This means that the configured cluster and authInfo names are also reused. --- pkg/cli/login/login.go | 4 + pkg/cli/login/loginoptions.go | 43 ++++++- pkg/cli/login/loginoptions_test.go | 184 +++++++++++++++++++++++++++++ 3 files changed, 230 insertions(+), 1 deletion(-) 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)) + } + }) + } +} From 5d1a3333323060875ee99a1a21a8c40e7760d94f Mon Sep 17 00:00:00 2001 From: Ondra Kupka Date: Mon, 19 Jan 2026 11:20:46 +0100 Subject: [PATCH 2/2] oc project: Enable custom context names When --context or the current context contain a custom context name, reuse that context if it matches the current --server value instead of always expecting the context match the generated context name. The change is entirely backwards compatible. When the current context matches the generated context name, the old functionality is retained. --- pkg/cli/project/project.go | 48 ++++++++++++++++++++++++++++++-------- 1 file changed, 38 insertions(+), 10 deletions(-) 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 {