From 71fb758a8dc592cf0cb2c2a36a357e6f39e14a40 Mon Sep 17 00:00:00 2001 From: rumstead <37445536+rumstead@users.noreply.github.com> Date: Thu, 2 Jul 2026 15:42:35 -0400 Subject: [PATCH 1/3] feat: upgrade Golang version to 1.26 and update dependencies; enhance command handling and shell completion Signed-off-by: rumstead <37445536+rumstead@users.noreply.github.com> --- .github/workflows/ci-build.yaml | 2 +- .github/workflows/release.yaml | 4 +-- README.md | 40 ++++++++++++---------- cmd/root.go | 59 ++++++++++++++++++++++++++++++--- go.mod | 12 +++---- go.sum | 25 +++++++------- pkg/cmd/safe/types.go | 1 - pkg/exec/exec.go | 13 -------- pkg/prompt/prompt.go | 39 ++++++++++++++++++---- 9 files changed, 132 insertions(+), 63 deletions(-) diff --git a/.github/workflows/ci-build.yaml b/.github/workflows/ci-build.yaml index b8e1379..4aa9dd3 100644 --- a/.github/workflows/ci-build.yaml +++ b/.github/workflows/ci-build.yaml @@ -9,7 +9,7 @@ on: env: # Golang version to use across CI steps - GOLANG_VERSION: '1.22' + GOLANG_VERSION: '1.26' jobs: check-go: diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 6bdd563..67aa4e6 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -5,7 +5,7 @@ on: - '*.*.*' env: # Golang version to use across CI steps - GOLANG_VERSION: '1.22' + GOLANG_VERSION: '1.26' jobs: goreleaser: @@ -25,4 +25,4 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.REPO_TOKEN }} - name: Update new version in krew-index - uses: rajatjindal/krew-release-bot@v0.0.46 + uses: rajatjindal/krew-release-bot@v0.0.51 diff --git a/README.md b/README.md index efe27bc..b14e360 100644 --- a/README.md +++ b/README.md @@ -53,31 +53,37 @@ storage-provisioner 1/1 Running 20 (2d4h ago) 57d vpnkit-controller 1/1 Running 1378 (16m ago) 57d $ kubectl safe delete pod -n kube-system coredns-78fcd69978-xwdt4 -You are running a delete against context docker-desktop, continue? [yY] n +You are running a delete against context docker-desktop (namespace kube-system), continue? [yY] n I0416 14:40:50.966746 85123 root.go:52] Not running command. $ kubectl safe delete pod -n kube-system coredns-78fcd69978-xwdt4 -You are running a delete against context docker-desktop, continue? [yY] y +You are running a delete against context docker-desktop (namespace kube-system), continue? [yY] y pod "coredns-78fcd69978-xwdt4" deleted ``` ## Shell completion -You can read more the [issue](https://github.com/rumstead/kubectl-safe/issues/17) -Add the below script anywhere in your path with the executable bit set. +Shell completion works out of the box. `kubectl-safe` proxies `__complete` and `__completeNoDesc` +requests directly to `kubectl`, so completions for resources, namespaces, and flags all work +transparently with no extra setup. + +If you use an alias (e.g., `alias k="kubectl safe"`), you can wire up completions: +```shell +# bash +complete -o default -F __start_kubectl k + +# zsh +compdef k=kubectl +``` + +## Flags + +| Flag | Description | +|------|-------------| +| `--yes`, `-y` | Skip the confirmation prompt (useful for scripts/CI) | + ```shell -#!/usr/bin/env bash - -# If we are completing a flag, use Cobra's builtin completion system. -# To know if we are completing a flag we need the last argument starts with a `-` and does not contain an `=` -args=("$@") -lastArg=${args[((${#args[@]}-1))]} -if [[ "$lastArg" == -* ]]; then - if [[ "$lastArg" != *=* ]]; then - kubectl safe __complete "$@" - fi -else - kubectl __complete "$@" -fi +$ kubectl safe delete pod -n kube-system coredns-78fcd69978-xwdt4 --yes +pod "coredns-78fcd69978-xwdt4" deleted ``` ## Configuration diff --git a/cmd/root.go b/cmd/root.go index 23d76a4..91f02c3 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -18,6 +18,7 @@ package cmd import ( "os" + "strings" "github.com/spf13/cobra" "k8s.io/klog/v2" @@ -36,16 +37,21 @@ var rootCmd = &cobra.Command{ Short: "A kubectl plugin to prevent shooting yourself in the foot with edit commands", Long: "A kubectl plugin to prevent shooting yourself in the foot with edit commands", RunE: func(cmd *cobra.Command, args []string) error { - verb := "" - if len(args) > 0 { - verb = args[0] + // Handle shell completion: proxy __complete to kubectl + if len(args) > 0 && (args[0] == "__complete" || args[0] == "__completeNoDesc") { + return exec.KubeCtl(args) } + + // Parse --yes/-y flag from args before passing to kubectl + args, confirmed := extractYesFlag(args) + + verb := findVerb(args) isSafe, err := safe.IsSafe(verb, args) if err != nil { return err } - if !isSafe { - if !prompt.Confirm(verb) { + if !isSafe && !confirmed { + if !prompt.Confirm(verb, args) { klog.Info("Not running command.") os.Exit(0) } @@ -54,6 +60,49 @@ var rootCmd = &cobra.Command{ }, } +// findVerb returns the first non-flag argument (the kubectl verb). +// It skips arguments starting with "-" and their values when they use +// the "--flag value" form (as opposed to "--flag=value"). +func findVerb(args []string) string { + flagsWithValues := map[string]bool{ + "--context": true, "-n": true, "--namespace": true, + "--kubeconfig": true, "--cluster": true, "--user": true, + "--as": true, "--as-group": true, "--certificate-authority": true, + "--client-certificate": true, "--client-key": true, "--server": true, + "--token": true, "-s": true, + } + for i := 0; i < len(args); i++ { + arg := args[i] + if !strings.HasPrefix(arg, "-") { + return arg + } + // --flag=value form: skip just this arg + if strings.Contains(arg, "=") { + continue + } + // --flag value form: skip this arg and the next + if flagsWithValues[arg] && i+1 < len(args) { + i++ + } + } + return "" +} + +// extractYesFlag removes --yes or -y from the args (these are kubectl-safe flags, +// not kubectl flags) and returns the cleaned args and whether the flag was present. +func extractYesFlag(args []string) ([]string, bool) { + found := false + cleaned := make([]string, 0, len(args)) + for _, arg := range args { + if arg == "--yes" || arg == "-y" { + found = true + continue + } + cleaned = append(cleaned, arg) + } + return cleaned, found +} + // Execute adds all child commands to the root command and sets flags appropriately. // This is called by main.main(). It only needs to happen once to the rootCmd. func Execute() error { diff --git a/go.mod b/go.mod index cfaaa48..8106aae 100644 --- a/go.mod +++ b/go.mod @@ -1,14 +1,14 @@ module github.com/rumstead/kubectl-safe -go 1.22 +go 1.26 require ( - github.com/spf13/cobra v1.4.0 - k8s.io/klog/v2 v2.60.1 + github.com/spf13/cobra v1.10.2 + k8s.io/klog/v2 v2.140.0 ) require ( - github.com/go-logr/logr v1.2.0 // indirect - github.com/inconshreveable/mousetrap v1.0.0 // indirect - github.com/spf13/pflag v1.0.5 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect ) diff --git a/go.sum b/go.sum index c61a0b0..51cb1a7 100644 --- a/go.sum +++ b/go.sum @@ -1,14 +1,15 @@ -github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= -github.com/go-logr/logr v1.2.0 h1:QK40JKJyMdUDz+h+xvCsru/bJhvG0UxvePV0ufL/AcE= -github.com/go-logr/logr v1.2.0/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= -github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/spf13/cobra v1.4.0 h1:y+wJpx64xcgO1V+RcnwW0LEHxTKRi2ZDPSBjWnrg88Q= -github.com/spf13/cobra v1.4.0/go.mod h1:Wo4iy3BUC+X2Fybo0PDqwJIv3dNRiZLHQymsfxlB84g= -github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= -github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= -k8s.io/klog/v2 v2.60.1 h1:VW25q3bZx9uE3vvdL6M8ezOX79vA2Aq1nEWLqNQclHc= -k8s.io/klog/v2 v2.60.1/go.mod h1:y1WjHnz7Dj687irZUWR/WLkLc5N1YHtjLdmgWjndZn0= +k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc= +k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0= diff --git a/pkg/cmd/safe/types.go b/pkg/cmd/safe/types.go index 2f55fdf..74f2889 100644 --- a/pkg/cmd/safe/types.go +++ b/pkg/cmd/safe/types.go @@ -16,7 +16,6 @@ var ( "top": Empty, "config": Empty, "logs": Empty, - "cp": Empty, "diff": Empty, "completion": Empty, "alpha": Empty, diff --git a/pkg/exec/exec.go b/pkg/exec/exec.go index d23eca6..038ea93 100644 --- a/pkg/exec/exec.go +++ b/pkg/exec/exec.go @@ -1,22 +1,9 @@ package exec import ( - "os" "os/exec" - "syscall" ) -func KubeCtl(args []string) error { - path, err := exec.LookPath("kubectl") - if err != nil { - return err - } - // https://man7.org/linux/man-pages/man2/execve.2.html - // first arg in the argv list is the binary path - argv := append([]string{path}, args...) - return syscall.Exec(path, argv, os.Environ()) -} - func ExecCmd(binary string, args ...string) (string, error) { path, err := exec.LookPath(binary) if err != nil { diff --git a/pkg/prompt/prompt.go b/pkg/prompt/prompt.go index cac1d65..0a53aea 100644 --- a/pkg/prompt/prompt.go +++ b/pkg/prompt/prompt.go @@ -3,23 +3,50 @@ package prompt import ( "bufio" "fmt" - "github.com/rumstead/kubectl-safe/pkg/exec" - "k8s.io/klog/v2" "os" "strings" + + "github.com/rumstead/kubectl-safe/pkg/exec" + "k8s.io/klog/v2" ) -func Confirm(verb string) bool { - output, err := exec.ExecCmd("kubectl", "config", "current-context") +func Confirm(verb string, args []string) bool { + context, err := exec.ExecCmd("kubectl", "config", "current-context") if err != nil { klog.Error(err) + context = "" } - output = strings.ReplaceAll(output, "\n", "") + context = strings.TrimSpace(context) + + ns := extractNamespace(args) + reader := bufio.NewReader(os.Stdin) - fmt.Printf("You are running a %s against context %s, continue? [yY] ", verb, output) + if ns != "" { + fmt.Printf("You are running a %s against context %s (namespace %s), continue? [yY] ", verb, context, ns) + } else { + fmt.Printf("You are running a %s against context %s, continue? [yY] ", verb, context) + } input, err := reader.ReadString('\n') if err != nil { return false } return strings.ToLower(strings.TrimSpace(input)) == "y" } + +// extractNamespace pulls the namespace from -n or --namespace flags in args. +func extractNamespace(args []string) string { + for i, arg := range args { + if arg == "-n" || arg == "--namespace" { + if i+1 < len(args) { + return args[i+1] + } + } + if strings.HasPrefix(arg, "--namespace=") { + return strings.TrimPrefix(arg, "--namespace=") + } + if strings.HasPrefix(arg, "-n=") { + return strings.TrimPrefix(arg, "-n=") + } + } + return "" +} From 092c79aba70606886b339c03298eb42b6d4bf5de Mon Sep 17 00:00:00 2001 From: rumstead <37445536+rumstead@users.noreply.github.com> Date: Thu, 2 Jul 2026 15:44:55 -0400 Subject: [PATCH 2/3] feat: upgrade Golang version to 1.26 and update dependencies; enhance command handling and shell completion Signed-off-by: rumstead <37445536+rumstead@users.noreply.github.com> --- cmd/root_test.go | 61 +++++++++++++++++++++++++++++++++++++ pkg/exec/kubectl_unix.go | 24 +++++++++++++++ pkg/exec/kubectl_windows.go | 20 ++++++++++++ 3 files changed, 105 insertions(+) create mode 100644 cmd/root_test.go create mode 100644 pkg/exec/kubectl_unix.go create mode 100644 pkg/exec/kubectl_windows.go diff --git a/cmd/root_test.go b/cmd/root_test.go new file mode 100644 index 0000000..5879e1b --- /dev/null +++ b/cmd/root_test.go @@ -0,0 +1,61 @@ +package cmd + +import ( + "testing" +) + +func Test_findVerb(t *testing.T) { + tests := []struct { + name string + args []string + want string + }{ + {name: "SimpleVerb", args: []string{"get", "pods"}, want: "get"}, + {name: "FlagBeforeVerb", args: []string{"--context=prod", "delete", "pod", "foo"}, want: "delete"}, + {name: "FlagWithSpaceBeforeVerb", args: []string{"--context", "prod", "delete", "pod", "foo"}, want: "delete"}, + {name: "NamespaceFlagBeforeVerb", args: []string{"-n", "kube-system", "get", "pods"}, want: "get"}, + {name: "MultipleFlagsBeforeVerb", args: []string{"--context", "prod", "-n", "default", "apply", "-f", "foo.yaml"}, want: "apply"}, + {name: "NoArgs", args: []string{}, want: ""}, + {name: "OnlyFlags", args: []string{"--help"}, want: ""}, + {name: "BoolFlagBeforeVerb", args: []string{"--v=5", "get", "pods"}, want: "get"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := findVerb(tt.args) + if got != tt.want { + t.Errorf("findVerb() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_extractYesFlag(t *testing.T) { + tests := []struct { + name string + args []string + wantArgs []string + wantFound bool + }{ + {name: "NoYesFlag", args: []string{"delete", "pod", "foo"}, wantArgs: []string{"delete", "pod", "foo"}, wantFound: false}, + {name: "YesLong", args: []string{"--yes", "delete", "pod", "foo"}, wantArgs: []string{"delete", "pod", "foo"}, wantFound: true}, + {name: "YesShort", args: []string{"delete", "pod", "foo", "-y"}, wantArgs: []string{"delete", "pod", "foo"}, wantFound: true}, + {name: "BothYesFlags", args: []string{"--yes", "delete", "-y", "pod"}, wantArgs: []string{"delete", "pod"}, wantFound: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotArgs, gotFound := extractYesFlag(tt.args) + if gotFound != tt.wantFound { + t.Errorf("extractYesFlag() found = %v, want %v", gotFound, tt.wantFound) + } + if len(gotArgs) != len(tt.wantArgs) { + t.Errorf("extractYesFlag() args = %v, want %v", gotArgs, tt.wantArgs) + return + } + for i := range gotArgs { + if gotArgs[i] != tt.wantArgs[i] { + t.Errorf("extractYesFlag() args[%d] = %v, want %v", i, gotArgs[i], tt.wantArgs[i]) + } + } + }) + } +} diff --git a/pkg/exec/kubectl_unix.go b/pkg/exec/kubectl_unix.go new file mode 100644 index 0000000..39c31e3 --- /dev/null +++ b/pkg/exec/kubectl_unix.go @@ -0,0 +1,24 @@ +//go:build !windows + +package exec + +import ( + "os" + "os/exec" + "syscall" +) + +// KubeCtl replaces the current process with kubectl via execve(2). +// We use syscall.Exec instead of os/exec so that kubectl fully replaces this process: +// signals (Ctrl+C), exit codes, and the process name all pass through transparently +// to the caller without needing manual forwarding or leaving a parent process resident. +func KubeCtl(args []string) error { + path, err := exec.LookPath("kubectl") + if err != nil { + return err + } + // https://man7.org/linux/man-pages/man2/execve.2.html + // first arg in the argv list is the binary path + argv := append([]string{path}, args...) + return syscall.Exec(path, argv, os.Environ()) +} diff --git a/pkg/exec/kubectl_windows.go b/pkg/exec/kubectl_windows.go new file mode 100644 index 0000000..d8b4e96 --- /dev/null +++ b/pkg/exec/kubectl_windows.go @@ -0,0 +1,20 @@ +//go:build windows + +package exec + +import ( + "os" + "os/exec" +) + +func KubeCtl(args []string) error { + path, err := exec.LookPath("kubectl") + if err != nil { + return err + } + cmd := exec.Command(path, args...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} From 0a3951825b26c7cb37137aed4493379086ffd3b9 Mon Sep 17 00:00:00 2001 From: rumstead <37445536+rumstead@users.noreply.github.com> Date: Thu, 2 Jul 2026 15:48:19 -0400 Subject: [PATCH 3/3] upgrade golangci-lint Signed-off-by: rumstead <37445536+rumstead@users.noreply.github.com> --- .github/workflows/ci-build.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci-build.yaml b/.github/workflows/ci-build.yaml index 4aa9dd3..47a8cb1 100644 --- a/.github/workflows/ci-build.yaml +++ b/.github/workflows/ci-build.yaml @@ -57,9 +57,9 @@ jobs: with: go-version: ${{ env.GOLANG_VERSION }} - name: Run golangci-lint - uses: golangci/golangci-lint-action@v3 + uses: golangci/golangci-lint-action@9fae48acfc02a90574d7c304a1758ef9895495fa # v7 with: - version: v1.57.2 + version: v2.12.2 args: --timeout 10m --verbose test-go: