feat: enable kubectl segment to read kubeconfig

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 like AntiVirus this
can make shells using the segment considerably more responsive.

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.
This commit is contained in:
Stefan Hacker 2021-11-21 16:02:51 +01:00 committed by Jan De Dobbeleer
parent 4d925b69ba
commit 7a73bcff0b
5 changed files with 211 additions and 27 deletions

View file

@ -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 - 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}}` 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` - 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 ## Template Properties
- `.Context`: `string` - the current kubectl context - `.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 ## Tips

View file

@ -35,6 +35,8 @@ require (
howett.net/plist v0.0.0-20201203080718-1454fab16a06 // indirect howett.net/plist v0.0.0-20201203080718-1454fab16a06 // indirect
) )
require gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b
require ( require (
github.com/Masterminds/goutils v1.1.1 // indirect github.com/Masterminds/goutils v1.1.1 // indirect
github.com/Masterminds/semver/v3 v3.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/tklauser/numcpus v0.3.0 // indirect
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 // indirect github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 // indirect
gopkg.in/yaml.v2 v2.4.0 // 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 replace github.com/distatus/battery v0.10.0 => github.com/JanDeDobbeleer/battery v0.10.0-2

View file

@ -1,14 +1,34 @@
package main package main
import ( import (
"path/filepath"
"strings" "strings"
"gopkg.in/yaml.v3"
) )
// Whether to use kubectl or read kubeconfig ourselves
const ParseKubeConfig Property = "parse_kubeconfig"
type kubectl struct { type kubectl struct {
props properties props properties
env environmentInfo env environmentInfo
Context string Context string
Namespace 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 { func (k *kubectl) string() string {
@ -31,15 +51,69 @@ func (k *kubectl) init(props properties, env environmentInfo) {
} }
func (k *kubectl) enabled() bool { 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" cmd := "kubectl"
if !k.env.hasCommand(cmd) { if !k.env.hasCommand(cmd) {
return false 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) displayError := k.props.getBool(DisplayError, false)
if err != nil && displayError { if err != nil && displayError {
k.Context = "KUBECTL ERR" k.setError("KUBECTL ERR")
k.Namespace = k.Context
return true return true
} }
if err != nil { if err != nil {
@ -49,5 +123,16 @@ func (k *kubectl) enabled() bool {
values := strings.Split(result, ",") values := strings.Split(result, ",")
k.Context = values[0] k.Context = values[0]
k.Namespace = values[1] 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
} }

View file

@ -1,6 +1,7 @@
package main package main
import ( import (
"path/filepath"
"testing" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@ -9,16 +10,23 @@ import (
type kubectlArgs struct { type kubectlArgs struct {
kubectlExists bool kubectlExists bool
kubectlErr bool kubectlErr bool
kubeconfig string
parseKubeConfig bool
template string template string
displayError bool displayError bool
context string kubectlOutContext string
namespace string kubectlOutNamespace string
kubectlOutUser string
kubectlOutCluster string
files map[string]string
} }
const testKubectlAllInfoTemplate = "{{.Context}} :: {{.Namespace}} :: {{.User}} :: {{.Cluster}}"
func bootStrapKubectlTest(args *kubectlArgs) *kubectl { func bootStrapKubectlTest(args *kubectlArgs) *kubectl {
env := new(MockedEnvironment) env := new(MockedEnvironment)
env.On("hasCommand", "kubectl").Return(args.kubectlExists) env.On("hasCommand", "kubectl").Return(args.kubectlExists)
kubectlOut := args.context + "," + args.namespace kubectlOut := args.kubectlOutContext + "," + args.kubectlOutNamespace + "," + args.kubectlOutUser + "," + args.kubectlOutCluster
var kubectlErr error var kubectlErr error
if args.kubectlErr { if args.kubectlErr {
kubectlErr = &commandError{ kubectlErr = &commandError{
@ -26,12 +34,21 @@ func bootStrapKubectlTest(args *kubectlArgs) *kubectl {
exitCode: 1, 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{ k := &kubectl{
env: env, env: env,
props: map[Property]interface{}{ props: map[Property]interface{}{
SegmentTemplate: args.template, SegmentTemplate: args.template,
DisplayError: args.displayError, DisplayError: args.displayError,
ParseKubeConfig: args.parseKubeConfig,
}, },
} }
return k return k
@ -39,24 +56,47 @@ func bootStrapKubectlTest(args *kubectlArgs) *kubectl {
func TestKubectlSegment(t *testing.T) { func TestKubectlSegment(t *testing.T) {
standardTemplate := "{{.Context}}{{if .Namespace}} :: {{.Namespace}}{{end}}" standardTemplate := "{{.Context}}{{if .Namespace}} :: {{.Namespace}}{{end}}"
lsep := string(filepath.ListSeparator)
cases := []struct { cases := []struct {
Case string Case string
Template string Template string
DisplayError bool DisplayError bool
KubectlExists bool KubectlExists bool
Kubeconfig string
ParseKubeConfig bool
Context string Context string
Namespace string Namespace string
User string
Cluster string
KubectlErr bool KubectlErr bool
ExpectedEnabled bool ExpectedEnabled bool
ExpectedString string ExpectedString string
Files map[string]string
}{ }{
{Case: "disabled", Template: standardTemplate, KubectlExists: false, Context: "aaa", Namespace: "bbb", ExpectedString: "", ExpectedEnabled: false}, {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: "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: "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, {Case: "kubectl error", Template: standardTemplate, DisplayError: true, KubectlExists: true, Context: "aaa", Namespace: "bbb", KubectlErr: true,
ExpectedString: "KUBECTL ERR :: KUBECTL ERR", ExpectedEnabled: true}, ExpectedString: "KUBECTL ERR :: KUBECTL ERR", ExpectedEnabled: true},
{Case: "kubectl error hidden", Template: standardTemplate, DisplayError: false, KubectlExists: true, Context: "aaa", Namespace: "bbb", KubectlErr: true, {Case: "kubectl error hidden", Template: standardTemplate, DisplayError: false, KubectlExists: true, Context: "aaa", Namespace: "bbb", KubectlErr: true, ExpectedEnabled: false},
ExpectedString: "", 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 { for _, tc := range cases {
@ -64,12 +104,60 @@ func TestKubectlSegment(t *testing.T) {
kubectlExists: tc.KubectlExists, kubectlExists: tc.KubectlExists,
template: tc.Template, template: tc.Template,
displayError: tc.DisplayError, displayError: tc.DisplayError,
context: tc.Context, kubectlOutContext: tc.Context,
namespace: tc.Namespace, kubectlOutNamespace: tc.Namespace,
kubectlOutUser: tc.User,
kubectlOutCluster: tc.Cluster,
kubectlErr: tc.KubectlErr, kubectlErr: tc.KubectlErr,
parseKubeConfig: tc.ParseKubeConfig,
files: tc.Files,
kubeconfig: tc.Kubeconfig,
} }
kubectl := bootStrapKubectlTest(args) kubectl := bootStrapKubectlTest(args)
assert.Equal(t, tc.ExpectedEnabled, kubectl.enabled(), tc.Case) assert.Equal(t, tc.ExpectedEnabled, kubectl.enabled(), tc.Case)
if tc.ExpectedEnabled {
assert.Equal(t, tc.ExpectedString, kubectl.string(), tc.Case) 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
`,
}

View file

@ -866,6 +866,12 @@
"title": "Display Error", "title": "Display Error",
"description": "Show the error context when failing to retrieve the kubectl information", "description": "Show the error context when failing to retrieve the kubectl information",
"default": false "default": false
},
"parse_kubeconfig": {
"type": "boolean",
"title": "Parse kubeconfig",
"description": "Parse kubeconfig files instead of calling out to kubectl to improve performance.",
"default": false
} }
} }
} }