Repository: JanDeDobbeleer/oh-my-posh
Pull request #1273 (2021-11-21 16:18:47)

feat: Enable kubectl segment to read kubeconfig
### Prerequisites

- [x] I have read and understood the `CONTRIBUTING` guide
- [x] The commit message follows the [conventional commits][cc] guidelines
- [x] Tests for the changes have been added (for bug fixes/features)
- [x] Docs have been added / updated (for bug fixes/features)

### Description
This extends the kubectl segment to optionally not rely on the
kubectl command and instead to directly parse kubeconfig files like
kubectl does. This is meant as a performance optimization similar
to how the git segment can determine the current branch itself
without calling to git. Especially on Windows and in the presence
other factors slowing process creation like AntiVirus this
can make shells using the segment considerably more responsive.

On my Windows 10 laptop execution time of the segment drops from
around 100ms to 0ms (Write-PoshDebug) noticeably speeding up my
prompt. On another machine with an overly aggressive AV segment
execution goes from seconds to a few milliseconds.
The latter example is admittedly ridiculous but it is what I measure.

The functionality is enabled using the new parse_kubeconfig prop.
It defaults to false to prevent breaking existing users in case
there are any unanticipated behavioral changes.

Additionally the new template properties Cluster and User were
added as they are easily available and helpful in kubectl
setups with more elaborate configuration.
<!---

Tips:

If you're not comfortable with working with Git, we're working a guide (https://ohmyposh.dev/docs/contributing_git) to help you out.
Oh My Posh advises GitKraken (https://www.gitkraken.com/invite/nQmDPR9D) as your preferred cross platform Git GUI power tool.

-->

[cc]: https://www.conventionalcommits.org/en/v1.0.0/#summary


hacst: I tried to address all the review comments and failing checks. Unfortunately I had to force push to be able to clear the commit message linter error. Sorry about that.
JanDeDobbeleer: @hacst that's fine, I force push all the time. GitHub should handle that.
JanDeDobbeleer: One question remains
hacst: Addressed test feedback. PTAL.

Patch:
diff --git a/docs/docs/segment-kubectl.md b/docs/docs/segment-kubectl.md
index 9ccdf5ca..749d478f 100644
--- a/docs/docs/segment-kubectl.md
+++ b/docs/docs/segment-kubectl.md
@@ -29,11 +29,15 @@ Display the currently active Kubernetes context name and namespace name.
 - template: `string` - A go [text/template][go-text-template] template extended with [sprig][sprig] utilizing the
 properties below - defaults to `{{.Context}}{{if .Namespace}} :: {{.Namespace}}{{end}}`
 - display_error: `boolean` - show the error context when failing to retrieve the kubectl information - defaults to `false`
+- parse_kubeconfig: `boolean` - parse kubeconfig files instead of calling out to kubectl to improve
+performance - defaults to `false`
 
 ## Template Properties
 
 - `.Context`: `string` - the current kubectl context
-- `.Namespace`: `string` - the current kubectl namespace
+- `.Namespace`: `string` - the current kubectl context namespace
+- `.User`: `string` - the current kubectl context user
+- `.Cluster`: `string` - the current kubectl context cluster
 
 ## Tips
 
diff --git a/src/go.mod b/src/go.mod
index 864b7c68..6fae79ac 100644
--- a/src/go.mod
+++ b/src/go.mod
@@ -35,6 +35,8 @@ require (
 	howett.net/plist v0.0.0-20201203080718-1454fab16a06 // indirect
 )
 
+require gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b
+
 require (
 	github.com/Masterminds/goutils v1.1.1 // indirect
 	github.com/Masterminds/semver/v3 v3.1.1 // indirect
@@ -54,7 +56,6 @@ require (
 	github.com/tklauser/numcpus v0.3.0 // indirect
 	github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 // indirect
 	gopkg.in/yaml.v2 v2.4.0 // indirect
-	gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect
 )
 
 replace github.com/distatus/battery v0.10.0 => github.com/JanDeDobbeleer/battery v0.10.0-2
diff --git a/src/segment_kubectl.go b/src/segment_kubectl.go
index aa5478f9..0a875824 100644
--- a/src/segment_kubectl.go
+++ b/src/segment_kubectl.go
@@ -1,14 +1,34 @@
 package main
 
 import (
+	"path/filepath"
 	"strings"
+
+	"gopkg.in/yaml.v3"
 )
 
+// Whether to use kubectl or read kubeconfig ourselves
+const ParseKubeConfig Property = "parse_kubeconfig"
+
 type kubectl struct {
-	props     properties
-	env       environmentInfo
-	Context   string
-	Namespace string
+	props   properties
+	env     environmentInfo
+	Context string
+	KubeConfigContext
+}
+
+type KubeConfigContext struct {
+	Cluster   string `yaml:"cluster"`
+	User      string `yaml:"user"`
+	Namespace string `yaml:"namespace"`
+}
+
+type KubeConfig struct {
+	CurrentContext string `yaml:"current-context"`
+	Contexts       []struct {
+		Context KubeConfigContext `yaml:"context"`
+		Name    string            `yaml:"name"`
+	} `yaml:"contexts"`
 }
 
 func (k *kubectl) string() string {
@@ -31,15 +51,69 @@ func (k *kubectl) init(props properties, env environmentInfo) {
 }
 
 func (k *kubectl) enabled() bool {
+	parseKubeConfig := k.props.getBool(ParseKubeConfig, false)
+	if parseKubeConfig {
+		return k.doParseKubeConfig()
+	}
+	return k.doCallKubectl()
+}
+
+func (k *kubectl) doParseKubeConfig() bool {
+	// Follow kubectl search rules (see https://kubernetes.io/docs/concepts/configuration/organize-cluster-access-kubeconfig/#the-kubeconfig-environment-variable)
+	// TL;DR: KUBECONFIG can contain a list of files. If it's empty ~/.kube/config is used. First file in list wins when merging keys.
+	kubeconfigs := filepath.SplitList(k.env.getenv("KUBECONFIG"))
+	if len(kubeconfigs) == 0 {
+		kubeconfigs = []string{filepath.Join(k.env.homeDir(), ".kube/config")}
+	}
+	contexts := make(map[string]KubeConfigContext)
+	k.Context = ""
+	for _, kubeconfig := range kubeconfigs {
+		if len(kubeconfig) == 0 {
+			continue
+		}
+
+		content := k.env.getFileContent(kubeconfig)
+
+		var config KubeConfig
+		err := yaml.Unmarshal([]byte(content), &config)
+		if err != nil {
+			continue
+		}
+
+		for _, context := range config.Contexts {
+			if _, exists := contexts[context.Name]; !exists {
+				contexts[context.Name] = context.Context
+			}
+		}
+
+		if len(k.Context) == 0 {
+			k.Context = config.CurrentContext
+		}
+
+		context, exists := contexts[k.Context]
+		if exists {
+			k.KubeConfigContext = context
+			return true
+		}
+	}
+
+	displayError := k.props.getBool(DisplayError, false)
+	if !displayError {
+		return false
+	}
+	k.setError("KUBECONFIG ERR")
+	return true
+}
+
+func (k *kubectl) doCallKubectl() bool {
 	cmd := "kubectl"
 	if !k.env.hasCommand(cmd) {
 		return false
 	}
-	result, err := k.env.runCommand(cmd, "config", "view", "--minify", "--output", "jsonpath={..current-context},{..namespace}")
+	result, err := k.env.runCommand(cmd, "config", "view", "--minify", "--output", "jsonpath={..current-context},{..namespace},{..context.user},{..context.cluster}")
 	displayError := k.props.getBool(DisplayError, false)
 	if err != nil && displayError {
-		k.Context = "KUBECTL ERR"
-		k.Namespace = k.Context
+		k.setError("KUBECTL ERR")
 		return true
 	}
 	if err != nil {
@@ -49,5 +123,16 @@ func (k *kubectl) enabled() bool {
 	values := strings.Split(result, ",")
 	k.Context = values[0]
 	k.Namespace = values[1]
-	return k.Context != ""
+	k.User = values[2]
+	k.Cluster = values[3]
+	return len(k.Context) > 0
+}
+
+func (k *kubectl) setError(message string) {
+	if len(k.Context) == 0 {
+		k.Context = message
+	}
+	k.Namespace = message
+	k.User = message
+	k.Cluster = message
 }
diff --git a/themes/schema.json b/themes/schema.json
index 0c4fd851..030e762e 100644
--- a/themes/schema.json
+++ b/themes/schema.json
@@ -866,6 +866,12 @@
                     "title": "Display Error",
                     "description": "Show the error context when failing to retrieve the kubectl information",
                     "default": false
+                  },
+                  "parse_kubeconfig": {
+                    "type": "boolean",
+                    "title": "Parse kubeconfig",
+                    "description": "Parse kubeconfig files instead of calling out to kubectl to improve performance.",
+                    "default": false
                   }
                 }
               }


Test patch:
diff --git a/src/segment_kubectl_test.go b/src/segment_kubectl_test.go
index 489f82c1..9af7e04d 100644
--- a/src/segment_kubectl_test.go
+++ b/src/segment_kubectl_test.go
@@ -1,24 +1,32 @@
 package main
 
 import (
+	"path/filepath"
 	"testing"
 
 	"github.com/stretchr/testify/assert"
 )
 
 type kubectlArgs struct {
-	kubectlExists bool
-	kubectlErr    bool
-	template      string
-	displayError  bool
-	context       string
-	namespace     string
+	kubectlExists       bool
+	kubectlErr          bool
+	kubeconfig          string
+	parseKubeConfig     bool
+	template            string
+	displayError        bool
+	kubectlOutContext   string
+	kubectlOutNamespace string
+	kubectlOutUser      string
+	kubectlOutCluster   string
+	files               map[string]string
 }
 
+const testKubectlAllInfoTemplate = "{{.Context}} :: {{.Namespace}} :: {{.User}} :: {{.Cluster}}"
+
 func bootStrapKubectlTest(args *kubectlArgs) *kubectl {
 	env := new(MockedEnvironment)
 	env.On("hasCommand", "kubectl").Return(args.kubectlExists)
-	kubectlOut := args.context + "," + args.namespace
+	kubectlOut := args.kubectlOutContext + "," + args.kubectlOutNamespace + "," + args.kubectlOutUser + "," + args.kubectlOutCluster
 	var kubectlErr error
 	if args.kubectlErr {
 		kubectlErr = &commandError{
@@ -26,12 +34,21 @@ func bootStrapKubectlTest(args *kubectlArgs) *kubectl {
 			exitCode: 1,
 		}
 	}
-	env.On("runCommand", "kubectl", []string{"config", "view", "--minify", "--output", "jsonpath={..current-context},{..namespace}"}).Return(kubectlOut, kubectlErr)
+	env.On("runCommand", "kubectl",
+		[]string{"config", "view", "--minify", "--output", "jsonpath={..current-context},{..namespace},{..context.user},{..context.cluster}"}).Return(kubectlOut, kubectlErr)
+
+	env.On("getenv", "KUBECONFIG").Return(args.kubeconfig)
+	for path, content := range args.files {
+		env.On("getFileContent", path).Return(content)
+	}
+	env.On("homeDir", nil).Return("testhome")
+
 	k := &kubectl{
 		env: env,
 		props: map[Property]interface{}{
 			SegmentTemplate: args.template,
 			DisplayError:    args.displayError,
+			ParseKubeConfig: args.parseKubeConfig,
 		},
 	}
 	return k
@@ -39,37 +56,108 @@ func bootStrapKubectlTest(args *kubectlArgs) *kubectl {
 
 func TestKubectlSegment(t *testing.T) {
 	standardTemplate := "{{.Context}}{{if .Namespace}} :: {{.Namespace}}{{end}}"
+	lsep := string(filepath.ListSeparator)
+
 	cases := []struct {
 		Case            string
 		Template        string
 		DisplayError    bool
 		KubectlExists   bool
+		Kubeconfig      string
+		ParseKubeConfig bool
 		Context         string
 		Namespace       string
+		User            string
+		Cluster         string
 		KubectlErr      bool
 		ExpectedEnabled bool
 		ExpectedString  string
+		Files           map[string]string
 	}{
 		{Case: "disabled", Template: standardTemplate, KubectlExists: false, Context: "aaa", Namespace: "bbb", ExpectedString: "", ExpectedEnabled: false},
 		{Case: "normal", Template: standardTemplate, KubectlExists: true, Context: "aaa", Namespace: "bbb", ExpectedString: "aaa :: bbb", ExpectedEnabled: true},
+		{Case: "all information", Template: testKubectlAllInfoTemplate, KubectlExists: true, Context: "aaa", Namespace: "bbb", User: "ccc", Cluster: "ddd",
+			ExpectedString: "aaa :: bbb :: ccc :: ddd", ExpectedEnabled: true},
 		{Case: "no namespace", Template: standardTemplate, KubectlExists: true, Context: "aaa", Namespace: "", ExpectedString: "aaa", ExpectedEnabled: true},
 		{Case: "kubectl error", Template: standardTemplate, DisplayError: true, KubectlExists: true, Context: "aaa", Namespace: "bbb", KubectlErr: true,
 			ExpectedString: "KUBECTL ERR :: KUBECTL ERR", ExpectedEnabled: true},
-		{Case: "kubectl error hidden", Template: standardTemplate, DisplayError: false, KubectlExists: true, Context: "aaa", Namespace: "bbb", KubectlErr: true,
-			ExpectedString: "", ExpectedEnabled: false},
+		{Case: "kubectl error hidden", Template: standardTemplate, DisplayError: false, KubectlExists: true, Context: "aaa", Namespace: "bbb", KubectlErr: true, ExpectedEnabled: false},
+		{Case: "kubeconfig home", Template: testKubectlAllInfoTemplate, ParseKubeConfig: true, Files: testKubeConfigFiles, ExpectedString: "aaa :: bbb :: ccc :: ddd",
+			ExpectedEnabled: true},
+		{Case: "kubeconfig multiple current marker first", Template: testKubectlAllInfoTemplate, ParseKubeConfig: true,
+			Kubeconfig: "" + lsep + "currentcontextmarker" + lsep + "contextdefinition" + lsep + "contextredefinition",
+			Files:      testKubeConfigFiles, ExpectedString: "ctx :: ns :: usr :: cl", ExpectedEnabled: true},
+		{Case: "kubeconfig multiple context first", Template: testKubectlAllInfoTemplate, ParseKubeConfig: true,
+			Kubeconfig: "contextdefinition" + lsep + "contextredefinition" + lsep + "currentcontextmarker" + lsep,
+			Files:      testKubeConfigFiles, ExpectedString: "ctx :: ns :: usr :: cl", ExpectedEnabled: true},
+		{Case: "kubeconfig error hidden", Template: testKubectlAllInfoTemplate, ParseKubeConfig: true, Kubeconfig: "invalid", Files: testKubeConfigFiles, ExpectedEnabled: false},
+		{Case: "kubeconfig error", Template: testKubectlAllInfoTemplate, ParseKubeConfig: true,
+			Kubeconfig: "invalid", Files: testKubeConfigFiles, DisplayError: true,
+			ExpectedString: "KUBECONFIG ERR :: KUBECONFIG ERR :: KUBECONFIG ERR :: KUBECONFIG ERR", ExpectedEnabled: true},
+		{Case: "kubeconfig incomplete", Template: testKubectlAllInfoTemplate, ParseKubeConfig: true,
+			Kubeconfig: "currentcontextmarker" + lsep + "contextdefinitionincomplete",
+			Files:      testKubeConfigFiles, ExpectedString: "ctx ::  ::  :: ", ExpectedEnabled: true},
 	}
 
 	for _, tc := range cases {
 		args := &kubectlArgs{
-			kubectlExists: tc.KubectlExists,
-			template:      tc.Template,
-			displayError:  tc.DisplayError,
-			context:       tc.Context,
-			namespace:     tc.Namespace,
-			kubectlErr:    tc.KubectlErr,
+			kubectlExists:       tc.KubectlExists,
+			template:            tc.Template,
+			displayError:        tc.DisplayError,
+			kubectlOutContext:   tc.Context,
+			kubectlOutNamespace: tc.Namespace,
+			kubectlOutUser:      tc.User,
+			kubectlOutCluster:   tc.Cluster,
+			kubectlErr:          tc.KubectlErr,
+			parseKubeConfig:     tc.ParseKubeConfig,
+			files:               tc.Files,
+			kubeconfig:          tc.Kubeconfig,
 		}
 		kubectl := bootStrapKubectlTest(args)
 		assert.Equal(t, tc.ExpectedEnabled, kubectl.enabled(), tc.Case)
-		assert.Equal(t, tc.ExpectedString, kubectl.string(), tc.Case)
+		if tc.ExpectedEnabled {
+			assert.Equal(t, tc.ExpectedString, kubectl.string(), tc.Case)
+		}
 	}
 }
+
+var testKubeConfigFiles = map[string]string{
+	filepath.Join("testhome", ".kube/config"): `
+apiVersion: v1
+contexts:
+  - context:
+      cluster: ddd
+      user: ccc
+      namespace: bbb
+    name: aaa
+current-context: aaa
+`,
+	"contextdefinition": `
+apiVersion: v1
+contexts:
+  - context:
+      cluster: cl
+      user: usr
+      namespace: ns
+    name: ctx
+`,
+	"currentcontextmarker": `
+apiVersion: v1
+current-context: ctx
+`,
+	"invalid": "this is not yaml",
+	"contextdefinitionincomplete": `
+apiVersion: v1
+contexts:
+  - name: ctx
+`,
+	"contextredefinition": `
+apiVersion: v1
+contexts:
+  - context:
+      cluster: wrongcl
+      user: wrongu
+      namespace: wrongns
+    name: ctx
+`,
+}