From 3ccee0f62c0326bf266a3f2529dd7307f3272e16 Mon Sep 17 00:00:00 2001 From: Zvi Cahana Date: Fri, 24 Nov 2023 00:57:43 +0200 Subject: [PATCH] Implement cascading delete command (-D) --- cmd/kubectx/delete.go | 65 ++++++++++++++++++++++++-- cmd/kubectx/flags.go | 7 +-- cmd/kubectx/fzf.go | 3 +- cmd/kubectx/help.go | 4 ++ internal/kubeconfig/clusters.go | 68 ++++++++++++++++++++++++++++ internal/kubeconfig/contextmodify.go | 14 +----- internal/kubeconfig/users.go | 68 ++++++++++++++++++++++++++++ internal/kubeconfig/yaml.go | 22 +++++++++ 8 files changed, 230 insertions(+), 21 deletions(-) create mode 100644 internal/kubeconfig/clusters.go create mode 100644 internal/kubeconfig/users.go create mode 100644 internal/kubeconfig/yaml.go diff --git a/cmd/kubectx/delete.go b/cmd/kubectx/delete.go index e2d8b55..722fbea 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 060cd1c..a4a6854 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/fzf.go b/cmd/kubectx/fzf.go index 5006129..662708d 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 020d2a3..6776af2 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 0000000..b73d818 --- /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/contextmodify.go b/internal/kubeconfig/contextmodify.go index 178e318..336a29f 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 0000000..9f0a66b --- /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/yaml.go b/internal/kubeconfig/yaml.go new file mode 100644 index 0000000..6bf6031 --- /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] + } +}