This commit is contained in:
Zvi Cahana 2023-12-25 00:47:37 -08:00 committed by GitHub
commit a7e8cfe4db
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 538 additions and 27 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -43,6 +43,10 @@ func printUsage(out io.Writer) error {
%PROG% -d <NAME> [<NAME...>] : delete context <NAME> ('.' for current-context)
%SPAC% (this command won't delete the user/cluster entry
%SPAC% referenced by the context entry)
%PROG% -D <NAME> [<NAME...>] : delete context <NAME> ('.' 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())

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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