diff --git a/cmd/kubectx/delete.go b/cmd/kubectx/delete.go index e2d8b55a..722fbea5 100644 --- a/cmd/kubectx/delete.go +++ b/cmd/kubectx/delete.go @@ -26,13 +26,14 @@ import ( // DeleteOp indicates intention to delete contexts. type DeleteOp struct { Contexts []string // NAME or '.' to indicate current-context. + Cascade bool // Whether to delete (orphaned-only) users and clusters referenced in the contexts. } // deleteContexts deletes context entries one by one. func (op DeleteOp) Run(_, stderr io.Writer) error { for _, ctx := range op.Contexts { // TODO inefficency here. we open/write/close the same file many times. - deletedName, wasActiveContext, err := deleteContext(ctx) + deletedName, wasActiveContext, err := deleteContext(ctx, op.Cascade) if err != nil { return errors.Wrapf(err, "error deleting context \"%s\"", deletedName) } @@ -48,7 +49,9 @@ func (op DeleteOp) Run(_, stderr io.Writer) error { // deleteContext deletes a context entry by NAME or current-context // indicated by ".". -func deleteContext(name string) (deleteName string, wasActiveContext bool, err error) { +// The cascade flag determines whether to also delete the user and/or cluster entries referenced in the context, +// if they became orphaned by this deletion (i.e., not referenced by any other contexts). +func deleteContext(name string, cascade bool) (deleteName string, wasActiveContext bool, err error) { kc := new(kubeconfig.Kubeconfig).WithLoader(kubeconfig.DefaultLoader) defer kc.Close() if err := kc.Parse(); err != nil { @@ -59,18 +62,72 @@ func deleteContext(name string) (deleteName string, wasActiveContext bool, err e // resolve "." to a real name if name == "." { if cur == "" { - return deleteName, false, errors.New("can't use '.' as the no active context is set") + return deleteName, false, errors.New("can't use '.' as no active context is set") } - wasActiveContext = true name = cur } + wasActiveContext = name == cur + if !kc.ContextExists(name) { return name, false, errors.New("context does not exist") } + if cascade { + err = deleteContextUser(name, kc) + if err != nil { + return name, wasActiveContext, errors.Wrap(err, "failed to delete user for deleted context") + } + + err = deleteContextCluster(name, kc) + if err != nil { + return name, wasActiveContext, errors.Wrap(err, "failed to delete cluster for deleted context") + } + } + if err := kc.DeleteContextEntry(name); err != nil { return name, false, errors.Wrap(err, "failed to modify yaml doc") } + return name, wasActiveContext, errors.Wrap(kc.Save(), "failed to save modified kubeconfig file") } + +func deleteContextUser(contextName string, kc *kubeconfig.Kubeconfig) error { + userName, err := kc.UserOfContext(contextName) + if err != nil { + return errors.Wrap(err, "user not set for context") + } + + refCount, err := kc.CountUserReferences(userName) + if err != nil { + return errors.Wrap(err, "failed to retrieve reference count for user entry") + } + + if refCount == 1 { + if err := kc.DeleteUserEntry(userName); err != nil { + return errors.Wrap(err, "failed to modify yaml doc") + } + } + + return nil +} + +func deleteContextCluster(contextName string, kc *kubeconfig.Kubeconfig) error { + clusterName, err := kc.ClusterOfContext(contextName) + if err != nil { + return errors.Wrap(err, "cluster not set for context") + } + + refCount, err := kc.CountClusterReferences(clusterName) + if err != nil { + return errors.Wrap(err, "failed to retrieve reference count for cluster entry") + } + + if refCount == 1 { + if err := kc.DeleteClusterEntry(clusterName); err != nil { + return errors.Wrap(err, "failed to modify yaml doc") + } + } + + return nil +} diff --git a/cmd/kubectx/flags.go b/cmd/kubectx/flags.go index 060cd1c5..a4a68543 100644 --- a/cmd/kubectx/flags.go +++ b/cmd/kubectx/flags.go @@ -40,15 +40,16 @@ func parseArgs(argv []string) Op { return ListOp{} } - if argv[0] == "-d" { + if argv[0] == "-d" || argv[0] == "-D" { + cascade := argv[0] == "-D" if len(argv) == 1 { if cmdutil.IsInteractiveMode(os.Stdout) { - return InteractiveDeleteOp{SelfCmd: os.Args[0]} + return InteractiveDeleteOp{SelfCmd: os.Args[0], Cascade: cascade} } else { return UnsupportedOp{Err: fmt.Errorf("'-d' needs arguments")} } } - return DeleteOp{Contexts: argv[1:]} + return DeleteOp{Contexts: argv[1:], Cascade: cascade} } if len(argv) == 1 { diff --git a/cmd/kubectx/flags_test.go b/cmd/kubectx/flags_test.go index 071e3bf7..d7eebdbf 100644 --- a/cmd/kubectx/flags_test.go +++ b/cmd/kubectx/flags_test.go @@ -62,10 +62,16 @@ func Test_parseArgs_new(t *testing.T) { want: UnsupportedOp{fmt.Errorf("'-d' needs arguments")}}, {name: "delete - current context", args: []string{"-d", "."}, - want: DeleteOp{[]string{"."}}}, + want: DeleteOp{[]string{"."}, false}}, {name: "delete - multiple contexts", args: []string{"-d", ".", "a", "b"}, - want: DeleteOp{[]string{".", "a", "b"}}}, + want: DeleteOp{[]string{".", "a", "b"}, false}}, + {name: "delete cascading- current context", + args: []string{"-D", "."}, + want: DeleteOp{[]string{"."}, true}}, + {name: "delete cascading - multiple contexts", + args: []string{"-D", ".", "a", "b"}, + want: DeleteOp{[]string{".", "a", "b"}, true}}, {name: "rename context", args: []string{"a=b"}, want: RenameOp{"a", "b"}}, diff --git a/cmd/kubectx/fzf.go b/cmd/kubectx/fzf.go index 5006129d..662708d5 100644 --- a/cmd/kubectx/fzf.go +++ b/cmd/kubectx/fzf.go @@ -36,6 +36,7 @@ type InteractiveSwitchOp struct { type InteractiveDeleteOp struct { SelfCmd string + Cascade bool } func (op InteractiveSwitchOp) Run(_, stderr io.Writer) error { @@ -112,7 +113,7 @@ func (op InteractiveDeleteOp) Run(_, stderr io.Writer) error { return errors.New("you did not choose any of the options") } - name, wasActiveContext, err := deleteContext(choice) + name, wasActiveContext, err := deleteContext(choice, op.Cascade) if err != nil { return errors.Wrap(err, "failed to delete context") } diff --git a/cmd/kubectx/help.go b/cmd/kubectx/help.go index 020d2a3b..6776af2c 100644 --- a/cmd/kubectx/help.go +++ b/cmd/kubectx/help.go @@ -43,6 +43,10 @@ func printUsage(out io.Writer) error { %PROG% -d [] : delete context ('.' for current-context) %SPAC% (this command won't delete the user/cluster entry %SPAC% referenced by the context entry) + %PROG% -D [] : delete context ('.' for current-context) + %SPAC% (this command also deletes the user/cluster entry + %SPAC% referenced by the context entry, if it is no + %SPAC% longer referenced by any other context entry) %PROG% -h,--help : show this message %PROG% -V,--version : show version` help = strings.ReplaceAll(help, "%PROG%", selfName()) diff --git a/internal/kubeconfig/clusters.go b/internal/kubeconfig/clusters.go new file mode 100644 index 00000000..b73d818f --- /dev/null +++ b/internal/kubeconfig/clusters.go @@ -0,0 +1,68 @@ +package kubeconfig + +import ( + "github.com/pkg/errors" + "gopkg.in/yaml.v3" +) + +func (k *Kubeconfig) clustersNode() (*yaml.Node, error) { + clusters := valueOf(k.rootNode, "clusters") + if clusters == nil { + return nil, errors.New("\"clusters\" entry is nil") + } else if clusters.Kind != yaml.SequenceNode { + return nil, errors.New("\"clusters\" is not a sequence node") + } + return clusters, nil +} + +func (k *Kubeconfig) ClusterOfContext(contextName string) (string, error) { + ctx, err := k.contextNode(contextName) + if err != nil { + return "", err + } + + return k.clusterOfContextNode(ctx) +} + +func (k *Kubeconfig) clusterOfContextNode(contextNode *yaml.Node) (string, error) { + ctxBody := valueOf(contextNode, "context") + if ctxBody == nil { + return "", errors.New("no context field found for context entry") + } + + cluster := valueOf(ctxBody, "cluster") + if cluster == nil || cluster.Value == "" { + return "", errors.New("no cluster field found for context entry") + } + return cluster.Value, nil +} + +func (k *Kubeconfig) CountClusterReferences(clusterName string) (int, error) { + contexts, err := k.contextsNode() + if err != nil { + return 0, err + } + + count := 0 + for _, contextNode := range contexts.Content { + contextCluster, err := k.clusterOfContextNode(contextNode) + if err != nil { + return 0, err + } + if clusterName == contextCluster { + count += 1 + } + } + + return count, nil +} + +func (k *Kubeconfig) DeleteClusterEntry(deleteName string) error { + contexts, err := k.clustersNode() + if err != nil { + return err + } + + deleteNamedChildNode(contexts, deleteName) + return nil +} diff --git a/internal/kubeconfig/clusters_test.go b/internal/kubeconfig/clusters_test.go new file mode 100644 index 00000000..4de5281b --- /dev/null +++ b/internal/kubeconfig/clusters_test.go @@ -0,0 +1,124 @@ +package kubeconfig + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + + "github.com/ahmetb/kubectx/internal/testutil" +) + +func TestKubeconfig_ClusterOfContext_ctxNotFound(t *testing.T) { + kc := new(Kubeconfig).WithLoader(WithMockKubeconfigLoader(testutil.KC(). + WithCtxs(testutil.Ctx("c1")).ToYAML(t))) + if err := kc.Parse(); err != nil { + t.Fatal(err) + } + + _, err := kc.ClusterOfContext("c2") + if err == nil { + t.Fatal("expected err") + } +} + +func TestKubeconfig_ClusterOfContext(t *testing.T) { + kc := new(Kubeconfig).WithLoader(WithMockKubeconfigLoader(testutil.KC(). + WithCtxs( + testutil.Ctx("c1").Cluster("c1c1"), + testutil.Ctx("c2").Cluster("c2c2")).ToYAML(t))) + if err := kc.Parse(); err != nil { + t.Fatal(err) + } + + v1, err := kc.ClusterOfContext("c1") + if err != nil { + t.Fatal("unexpected err", err) + } + if expected := `c1c1`; v1 != expected { + t.Fatalf("c1: expected=\"%s\" got=\"%s\"", expected, v1) + } + + v2, err := kc.ClusterOfContext("c2") + if err != nil { + t.Fatal("unexpected err", err) + } + if expected := `c2c2`; v2 != expected { + t.Fatalf("c2: expected=\"%s\" got=\"%s\"", expected, v2) + } +} + +func TestKubeconfig_DeleteClusterEntry_errors(t *testing.T) { + kc := new(Kubeconfig).WithLoader(WithMockKubeconfigLoader(`[1, 2, 3]`)) + _ = kc.Parse() + err := kc.DeleteClusterEntry("foo") + if err == nil { + t.Fatal("supposed to fail on non-mapping nodes") + } + + kc = new(Kubeconfig).WithLoader(WithMockKubeconfigLoader(`a: b`)) + _ = kc.Parse() + err = kc.DeleteClusterEntry("foo") + if err == nil { + t.Fatal("supposed to fail if clusters key does not exist") + } + + kc = new(Kubeconfig).WithLoader(WithMockKubeconfigLoader(`clusters: "some string"`)) + _ = kc.Parse() + err = kc.DeleteClusterEntry("foo") + if err == nil { + t.Fatal("supposed to fail if clusters key is not an array") + } +} + +func TestKubeconfig_DeleteClusterEntry(t *testing.T) { + test := WithMockKubeconfigLoader( + testutil.KC().WithClusters( + testutil.Cluster("c1"), + testutil.Cluster("c2"), + testutil.Cluster("c3")).ToYAML(t)) + kc := new(Kubeconfig).WithLoader(test) + if err := kc.Parse(); err != nil { + t.Fatal(err) + } + if err := kc.DeleteClusterEntry("c1"); err != nil { + t.Fatal(err) + } + if err := kc.Save(); err != nil { + t.Fatal(err) + } + + expected := testutil.KC().WithClusters( + testutil.Cluster("c2"), + testutil.Cluster("c3")).ToYAML(t) + out := test.Output() + if diff := cmp.Diff(expected, out); diff != "" { + t.Fatalf("diff: %s", diff) + } +} + +func TestKubeconfig_CountClusterReferences_errors(t *testing.T) { + kc := new(Kubeconfig).WithLoader(WithMockKubeconfigLoader(testutil.KC(). + WithCtxs( + testutil.Ctx("c1").Cluster("c1c1"), + testutil.Ctx("c2").Cluster("c2c2"), + testutil.Ctx("c3").Cluster("c1c1")).ToYAML(t))) + if err := kc.Parse(); err != nil { + t.Fatal(err) + } + + count1, err := kc.CountClusterReferences("c1c1") + if err != nil { + t.Fatal("unexpected err", err) + } + if expected := 2; count1 != expected { + t.Fatalf("c1: expected=\"%d\" got=\"%d\"", expected, count1) + } + + count2, err := kc.CountClusterReferences("c2c2") + if err != nil { + t.Fatal("unexpected err", err) + } + if expected := 1; count2 != expected { + t.Fatalf("c1: expected=\"%d\" got=\"%d\"", expected, count2) + } +} diff --git a/internal/kubeconfig/contextmodify.go b/internal/kubeconfig/contextmodify.go index 178e318f..336a29f9 100644 --- a/internal/kubeconfig/contextmodify.go +++ b/internal/kubeconfig/contextmodify.go @@ -25,19 +25,7 @@ func (k *Kubeconfig) DeleteContextEntry(deleteName string) error { return err } - i := -1 - for j, ctxNode := range contexts.Content { - nameNode := valueOf(ctxNode, "name") - if nameNode != nil && nameNode.Kind == yaml.ScalarNode && nameNode.Value == deleteName { - i = j - break - } - } - if i >= 0 { - copy(contexts.Content[i:], contexts.Content[i+1:]) - contexts.Content[len(contexts.Content)-1] = nil - contexts.Content = contexts.Content[:len(contexts.Content)-1] - } + deleteNamedChildNode(contexts, deleteName) return nil } diff --git a/internal/kubeconfig/users.go b/internal/kubeconfig/users.go new file mode 100644 index 00000000..9f0a66b8 --- /dev/null +++ b/internal/kubeconfig/users.go @@ -0,0 +1,68 @@ +package kubeconfig + +import ( + "github.com/pkg/errors" + "gopkg.in/yaml.v3" +) + +func (k *Kubeconfig) usersNode() (*yaml.Node, error) { + users := valueOf(k.rootNode, "users") + if users == nil { + return nil, errors.New("\"users\" entry is nil") + } else if users.Kind != yaml.SequenceNode { + return nil, errors.New("\"users\" is not a sequence node") + } + return users, nil +} + +func (k *Kubeconfig) UserOfContext(contextName string) (string, error) { + ctx, err := k.contextNode(contextName) + if err != nil { + return "", err + } + + return k.userOfContextNode(ctx) +} + +func (k *Kubeconfig) userOfContextNode(contextNode *yaml.Node) (string, error) { + ctxBody := valueOf(contextNode, "context") + if ctxBody == nil { + return "", errors.New("no context field found for context entry") + } + + user := valueOf(ctxBody, "user") + if user == nil || user.Value == "" { + return "", errors.New("no user field found for context entry") + } + return user.Value, nil +} + +func (k *Kubeconfig) CountUserReferences(userName string) (int, error) { + contexts, err := k.contextsNode() + if err != nil { + return 0, err + } + + count := 0 + for _, contextNode := range contexts.Content { + contextUser, err := k.userOfContextNode(contextNode) + if err != nil { + return 0, err + } + if userName == contextUser { + count += 1 + } + } + + return count, nil +} + +func (k *Kubeconfig) DeleteUserEntry(deleteName string) error { + contexts, err := k.usersNode() + if err != nil { + return err + } + + deleteNamedChildNode(contexts, deleteName) + return nil +} diff --git a/internal/kubeconfig/users_test.go b/internal/kubeconfig/users_test.go new file mode 100644 index 00000000..f0c9f327 --- /dev/null +++ b/internal/kubeconfig/users_test.go @@ -0,0 +1,124 @@ +package kubeconfig + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + + "github.com/ahmetb/kubectx/internal/testutil" +) + +func TestKubeconfig_UserOfContext_ctxNotFound(t *testing.T) { + kc := new(Kubeconfig).WithLoader(WithMockKubeconfigLoader(testutil.KC(). + WithCtxs(testutil.Ctx("c1")).ToYAML(t))) + if err := kc.Parse(); err != nil { + t.Fatal(err) + } + + _, err := kc.UserOfContext("c2") + if err == nil { + t.Fatal("expected err") + } +} + +func TestKubeconfig_UserOfContext(t *testing.T) { + kc := new(Kubeconfig).WithLoader(WithMockKubeconfigLoader(testutil.KC(). + WithCtxs( + testutil.Ctx("c1").User("c1u1"), + testutil.Ctx("c2").User("c2u2")).ToYAML(t))) + if err := kc.Parse(); err != nil { + t.Fatal(err) + } + + v1, err := kc.UserOfContext("c1") + if err != nil { + t.Fatal("unexpected err", err) + } + if expected := `c1u1`; v1 != expected { + t.Fatalf("c1: expected=\"%s\" got=\"%s\"", expected, v1) + } + + v2, err := kc.UserOfContext("c2") + if err != nil { + t.Fatal("unexpected err", err) + } + if expected := `c2u2`; v2 != expected { + t.Fatalf("c2: expected=\"%s\" got=\"%s\"", expected, v2) + } +} + +func TestKubeconfig_DeleteUserEntry_errors(t *testing.T) { + kc := new(Kubeconfig).WithLoader(WithMockKubeconfigLoader(`[1, 2, 3]`)) + _ = kc.Parse() + err := kc.DeleteUserEntry("foo") + if err == nil { + t.Fatal("supposed to fail on non-mapping nodes") + } + + kc = new(Kubeconfig).WithLoader(WithMockKubeconfigLoader(`a: b`)) + _ = kc.Parse() + err = kc.DeleteUserEntry("foo") + if err == nil { + t.Fatal("supposed to fail if users key does not exist") + } + + kc = new(Kubeconfig).WithLoader(WithMockKubeconfigLoader(`users: "some string"`)) + _ = kc.Parse() + err = kc.DeleteUserEntry("foo") + if err == nil { + t.Fatal("supposed to fail if users key is not an array") + } +} + +func TestKubeconfig_DeleteUserEntry(t *testing.T) { + test := WithMockKubeconfigLoader( + testutil.KC().WithUsers( + testutil.User("u1"), + testutil.User("u2"), + testutil.User("u3")).ToYAML(t)) + kc := new(Kubeconfig).WithLoader(test) + if err := kc.Parse(); err != nil { + t.Fatal(err) + } + if err := kc.DeleteUserEntry("u1"); err != nil { + t.Fatal(err) + } + if err := kc.Save(); err != nil { + t.Fatal(err) + } + + expected := testutil.KC().WithUsers( + testutil.User("u2"), + testutil.User("u3")).ToYAML(t) + out := test.Output() + if diff := cmp.Diff(expected, out); diff != "" { + t.Fatalf("diff: %s", diff) + } +} + +func TestKubeconfig_CountUserReferences_errors(t *testing.T) { + kc := new(Kubeconfig).WithLoader(WithMockKubeconfigLoader(testutil.KC(). + WithCtxs( + testutil.Ctx("c1").User("c1u1"), + testutil.Ctx("c2").User("c2u2"), + testutil.Ctx("c3").User("c1u1")).ToYAML(t))) + if err := kc.Parse(); err != nil { + t.Fatal(err) + } + + count1, err := kc.CountUserReferences("c1u1") + if err != nil { + t.Fatal("unexpected err", err) + } + if expected := 2; count1 != expected { + t.Fatalf("c1: expected=\"%d\" got=\"%d\"", expected, count1) + } + + count2, err := kc.CountUserReferences("c2u2") + if err != nil { + t.Fatal("unexpected err", err) + } + if expected := 1; count2 != expected { + t.Fatalf("c1: expected=\"%d\" got=\"%d\"", expected, count2) + } +} diff --git a/internal/kubeconfig/yaml.go b/internal/kubeconfig/yaml.go new file mode 100644 index 00000000..6bf60319 --- /dev/null +++ b/internal/kubeconfig/yaml.go @@ -0,0 +1,22 @@ +package kubeconfig + +import ( + "gopkg.in/yaml.v3" +) + +func deleteNamedChildNode(node *yaml.Node, childName string) { + i := -1 + for j, node := range node.Content { + nameNode := valueOf(node, "name") + if nameNode != nil && nameNode.Kind == yaml.ScalarNode && nameNode.Value == childName { + i = j + break + } + } + + if i >= 0 { + copy(node.Content[i:], node.Content[i+1:]) + node.Content[len(node.Content)-1] = nil + node.Content = node.Content[:len(node.Content)-1] + } +} diff --git a/internal/testutil/kubeconfigbuilder.go b/internal/testutil/kubeconfigbuilder.go index e7dc6742..7fb0e619 100644 --- a/internal/testutil/kubeconfigbuilder.go +++ b/internal/testutil/kubeconfigbuilder.go @@ -21,15 +21,31 @@ import ( "gopkg.in/yaml.v3" ) -type Context struct { +type ContextObj struct { Name string `yaml:"name,omitempty"` Context struct { Namespace string `yaml:"namespace,omitempty"` + User string `yaml:"user,omitempty"` + Cluster string `yaml:"cluster,omitempty"` } `yaml:"context,omitempty"` } -func Ctx(name string) *Context { return &Context{Name: name} } -func (c *Context) Ns(ns string) *Context { c.Context.Namespace = ns; return c } +func Ctx(name string) *ContextObj { return &ContextObj{Name: name} } +func (c *ContextObj) Ns(ns string) *ContextObj { c.Context.Namespace = ns; return c } +func (c *ContextObj) User(user string) *ContextObj { c.Context.User = user; return c } +func (c *ContextObj) Cluster(cluster string) *ContextObj { c.Context.Cluster = cluster; return c } + +type UserObj struct { + Name string `yaml:"name,omitempty"` +} + +func User(name string) *UserObj { return &UserObj{Name: name} } + +type ClusterObj struct { + Name string `yaml:"name,omitempty"` +} + +func Cluster(name string) *ClusterObj { return &ClusterObj{Name: name} } type Kubeconfig map[string]interface{} @@ -41,7 +57,9 @@ func KC() *Kubeconfig { func (k *Kubeconfig) Set(key string, v interface{}) *Kubeconfig { (*k)[key] = v; return k } func (k *Kubeconfig) WithCurrentCtx(s string) *Kubeconfig { (*k)["current-context"] = s; return k } -func (k *Kubeconfig) WithCtxs(c ...*Context) *Kubeconfig { (*k)["contexts"] = c; return k } +func (k *Kubeconfig) WithCtxs(c ...*ContextObj) *Kubeconfig { (*k)["contexts"] = c; return k } +func (k *Kubeconfig) WithUsers(u ...*UserObj) *Kubeconfig { (*k)["users"] = u; return k } +func (k *Kubeconfig) WithClusters(c ...*ClusterObj) *Kubeconfig { (*k)["clusters"] = c; return k } func (k *Kubeconfig) ToYAML(t *testing.T) string { t.Helper() diff --git a/test/common.bash b/test/common.bash index bd617102..33e32f21 100644 --- a/test/common.bash +++ b/test/common.bash @@ -27,6 +27,14 @@ get_context() { kubectl config current-context } +get_user() { + kubectl config get-users | grep "${1}" +} + +get_cluster() { + kubectl config get-clusters | grep "${1}" +} + switch_context() { kubectl config use-context "${1}" } diff --git a/test/kubectx.bats b/test/kubectx.bats index bacb82a6..323e79da 100644 --- a/test/kubectx.bats +++ b/test/kubectx.bats @@ -230,6 +230,28 @@ load common [[ "$output" = "user2@cluster1" ]] } +@test "delete context including referenced user and cluster" { + use_config config1 + + run ${COMMAND} -D "user1@cluster1" + echo "$output" + [ "$status" -eq 0 ] + [[ -z "$(get_user user1)" ]] + [[ -z "$(get_cluster cluster1)" ]] + +} + +@test "delete context retain referenced cluster" { + use_config config2 + + run ${COMMAND} -D "user1@cluster1" + echo "$output" + [ "$status" -eq 0 ] + [[ -z "$(get_user user1)" ]] + [[ -n "$(get_user user2)" ]] + [[ -n "$(get_cluster cluster1)" ]] +} + @test "unset selected context" { use_config config2