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
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

View file

@ -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

View file

@ -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
}

View file

@ -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
`,
}

View file

@ -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
}
}
}