Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/ci-build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ on:

env:
# Golang version to use across CI steps
GOLANG_VERSION: '1.22'
GOLANG_VERSION: '1.26'

jobs:
check-go:
Expand Down Expand Up @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ on:
- '*.*.*'
env:
# Golang version to use across CI steps
GOLANG_VERSION: '1.22'
GOLANG_VERSION: '1.26'

jobs:
goreleaser:
Expand All @@ -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
40 changes: 23 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
59 changes: 54 additions & 5 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ package cmd

import (
"os"
"strings"

"github.com/spf13/cobra"
"k8s.io/klog/v2"
Expand All @@ -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)
}
Expand All @@ -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 {
Expand Down
61 changes: 61 additions & 0 deletions cmd/root_test.go
Original file line number Diff line number Diff line change
@@ -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])
}
}
})
}
}
12 changes: 6 additions & 6 deletions go.mod
Original file line number Diff line number Diff line change
@@ -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
)
25 changes: 13 additions & 12 deletions go.sum
Original file line number Diff line number Diff line change
@@ -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=
1 change: 0 additions & 1 deletion pkg/cmd/safe/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ var (
"top": Empty,
"config": Empty,
"logs": Empty,
"cp": Empty,
"diff": Empty,
"completion": Empty,
"alpha": Empty,
Expand Down
13 changes: 0 additions & 13 deletions pkg/exec/exec.go
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
24 changes: 24 additions & 0 deletions pkg/exec/kubectl_unix.go
Original file line number Diff line number Diff line change
@@ -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())
}
20 changes: 20 additions & 0 deletions pkg/exec/kubectl_windows.go
Original file line number Diff line number Diff line change
@@ -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()
}
Loading
Loading