diff --git a/README.md b/README.md index 051012b..89e3612 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,9 @@ Switched to context "minikube". $ kubectx - Switched to context "oregon". +# start an "isolated shell" that only has a single context +$ kubectx -s minikube + # rename context $ kubectx dublin=gke_ahmetb_europe-west1-b_dublin Context "gke_ahmetb_europe-west1-b_dublin" renamed to "dublin". diff --git a/cmd/kubectx/current.go b/cmd/kubectx/current.go index 4bfcb7c..5b82e9c 100644 --- a/cmd/kubectx/current.go +++ b/cmd/kubectx/current.go @@ -27,6 +27,9 @@ import ( type CurrentOp struct{} func (_op CurrentOp) Run(stdout, _ io.Writer) error { + if err := checkIsolatedMode(); err != nil { + return err + } kc := new(kubeconfig.Kubeconfig).WithLoader(kubeconfig.DefaultLoader) defer kc.Close() if err := kc.Parse(); err != nil { diff --git a/cmd/kubectx/delete.go b/cmd/kubectx/delete.go index ca5747b..260b336 100644 --- a/cmd/kubectx/delete.go +++ b/cmd/kubectx/delete.go @@ -30,6 +30,9 @@ type DeleteOp struct { // deleteContexts deletes context entries one by one. func (op DeleteOp) Run(_, stderr io.Writer) error { + if err := checkIsolatedMode(); err != nil { + return err + } for _, ctx := range op.Contexts { // TODO inefficiency here. we open/write/close the same file many times. deletedName, wasActiveContext, err := deleteContext(ctx) diff --git a/cmd/kubectx/flags.go b/cmd/kubectx/flags.go index 060cd1c..b44e294 100644 --- a/cmd/kubectx/flags.go +++ b/cmd/kubectx/flags.go @@ -40,6 +40,13 @@ func parseArgs(argv []string) Op { return ListOp{} } + if argv[0] == "--shell" || argv[0] == "-s" { + if len(argv) != 2 { + return UnsupportedOp{Err: fmt.Errorf("'%s' requires exactly one context name argument", argv[0])} + } + return ShellOp{Target: argv[1]} + } + if argv[0] == "-d" { if len(argv) == 1 { if cmdutil.IsInteractiveMode(os.Stdout) { diff --git a/cmd/kubectx/flags_test.go b/cmd/kubectx/flags_test.go index 071e3bf..ebed349 100644 --- a/cmd/kubectx/flags_test.go +++ b/cmd/kubectx/flags_test.go @@ -72,6 +72,18 @@ func Test_parseArgs_new(t *testing.T) { {name: "rename context with old=current", args: []string{"a=."}, want: RenameOp{"a", "."}}, + {name: "shell shorthand", + args: []string{"-s", "prod"}, + want: ShellOp{Target: "prod"}}, + {name: "shell long form", + args: []string{"--shell", "prod"}, + want: ShellOp{Target: "prod"}}, + {name: "shell without context name", + args: []string{"-s"}, + want: UnsupportedOp{Err: fmt.Errorf("'-s' requires exactly one context name argument")}}, + {name: "shell with too many args", + args: []string{"--shell", "a", "b"}, + want: UnsupportedOp{Err: fmt.Errorf("'--shell' requires exactly one context name argument")}}, {name: "unrecognized flag", args: []string{"-x"}, want: UnsupportedOp{Err: fmt.Errorf("unsupported option '-x'")}}, diff --git a/cmd/kubectx/fzf.go b/cmd/kubectx/fzf.go index 5006129..087516b 100644 --- a/cmd/kubectx/fzf.go +++ b/cmd/kubectx/fzf.go @@ -39,6 +39,9 @@ type InteractiveDeleteOp struct { } func (op InteractiveSwitchOp) Run(_, stderr io.Writer) error { + if err := checkIsolatedMode(); err != nil { + return err + } // parse kubeconfig just to see if it can be loaded kc := new(kubeconfig.Kubeconfig).WithLoader(kubeconfig.DefaultLoader) if err := kc.Parse(); err != nil { @@ -77,6 +80,9 @@ func (op InteractiveSwitchOp) Run(_, stderr io.Writer) error { } func (op InteractiveDeleteOp) Run(_, stderr io.Writer) error { + if err := checkIsolatedMode(); err != nil { + return err + } // parse kubeconfig just to see if it can be loaded kc := new(kubeconfig.Kubeconfig).WithLoader(kubeconfig.DefaultLoader) if err := kc.Parse(); err != nil { diff --git a/cmd/kubectx/help.go b/cmd/kubectx/help.go index 020d2a3..95a636f 100644 --- a/cmd/kubectx/help.go +++ b/cmd/kubectx/help.go @@ -43,6 +43,7 @@ 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% -s, --shell : start a shell scoped to context %PROG% -h,--help : show this message %PROG% -V,--version : show version` help = strings.ReplaceAll(help, "%PROG%", selfName()) diff --git a/cmd/kubectx/isolated_shell_guard.go b/cmd/kubectx/isolated_shell_guard.go new file mode 100644 index 0000000..bb8153d --- /dev/null +++ b/cmd/kubectx/isolated_shell_guard.go @@ -0,0 +1,24 @@ +package main + +import ( + "fmt" + "os" + + "github.com/ahmetb/kubectx/internal/env" + "github.com/ahmetb/kubectx/internal/kubeconfig" +) + +func checkIsolatedMode() error { + if os.Getenv(env.EnvIsolatedShell) != "1" { + return nil + } + + kc := new(kubeconfig.Kubeconfig).WithLoader(kubeconfig.DefaultLoader) + defer kc.Close() + if err := kc.Parse(); err != nil { + return fmt.Errorf("you are in a locked single-context shell, use 'exit' to leave") + } + + cur := kc.GetCurrentContext() + return fmt.Errorf("you are in a locked single-context shell (\"%s\"), use 'exit' to leave", cur) +} diff --git a/cmd/kubectx/list.go b/cmd/kubectx/list.go index f3893f1..d8b84f6 100644 --- a/cmd/kubectx/list.go +++ b/cmd/kubectx/list.go @@ -30,6 +30,9 @@ import ( type ListOp struct{} func (_ ListOp) Run(stdout, stderr io.Writer) error { + if err := checkIsolatedMode(); err != nil { + return err + } kc := new(kubeconfig.Kubeconfig).WithLoader(kubeconfig.DefaultLoader) defer kc.Close() if err := kc.Parse(); err != nil { diff --git a/cmd/kubectx/rename.go b/cmd/kubectx/rename.go index 6450f39..32ddf96 100644 --- a/cmd/kubectx/rename.go +++ b/cmd/kubectx/rename.go @@ -48,6 +48,9 @@ func parseRenameSyntax(v string) (string, string, bool) { // to the "new" value. If the old refers to the current-context, // current-context preference is also updated. func (op RenameOp) Run(_, stderr io.Writer) error { + if err := checkIsolatedMode(); err != nil { + return err + } kc := new(kubeconfig.Kubeconfig).WithLoader(kubeconfig.DefaultLoader) defer kc.Close() if err := kc.Parse(); err != nil { diff --git a/cmd/kubectx/shell.go b/cmd/kubectx/shell.go new file mode 100644 index 0000000..a5d84e4 --- /dev/null +++ b/cmd/kubectx/shell.go @@ -0,0 +1,135 @@ +package main + +import ( + "fmt" + "io" + "os" + "os/exec" + "runtime" + + "github.com/fatih/color" + "github.com/pkg/errors" + + "github.com/ahmetb/kubectx/internal/env" + "github.com/ahmetb/kubectx/internal/kubeconfig" + "github.com/ahmetb/kubectx/internal/printer" +) + +// ShellOp indicates intention to start a scoped sub-shell for a context. +type ShellOp struct { + Target string +} + +func (op ShellOp) Run(_, stderr io.Writer) error { + if err := checkIsolatedMode(); err != nil { + return err + } + + kubectlPath, err := resolveKubectl() + if err != nil { + return err + } + + // Verify context exists and get current context for exit message + kc := new(kubeconfig.Kubeconfig).WithLoader(kubeconfig.DefaultLoader) + defer kc.Close() + if err := kc.Parse(); err != nil { + return errors.Wrap(err, "kubeconfig error") + } + if !kc.ContextExists(op.Target) { + return fmt.Errorf("no context exists with the name: \"%s\"", op.Target) + } + previousCtx := kc.GetCurrentContext() + + // Extract minimal kubeconfig using kubectl + data, err := extractMinimalKubeconfig(kubectlPath, op.Target) + if err != nil { + return errors.Wrap(err, "failed to extract kubeconfig for context") + } + + // Write to temp file + tmpFile, err := os.CreateTemp("", "kubectx-shell-*.yaml") + if err != nil { + return errors.Wrap(err, "failed to create temp kubeconfig file") + } + tmpPath := tmpFile.Name() + defer os.Remove(tmpPath) + + if _, err := tmpFile.Write(data); err != nil { + tmpFile.Close() + return errors.Wrap(err, "failed to write temp kubeconfig") + } + tmpFile.Close() + + // Print entry message + badgeColor := color.New(color.BgRed, color.FgWhite, color.Bold) + printer.EnableOrDisableColor(badgeColor) + fmt.Fprintf(stderr, "%s kubectl context is %s in this shell — type 'exit' to leave.\n", + badgeColor.Sprint("[ISOLATED SHELL]"), printer.WarningColor.Sprint(op.Target)) + + // Detect and start shell + shellBin := detectShell() + cmd := exec.Command(shellBin) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Env = append(os.Environ(), + "KUBECONFIG="+tmpPath, + env.EnvIsolatedShell+"=1", + ) + + _ = cmd.Run() + + // Print exit message + fmt.Fprintf(stderr, "%s kubectl context is now %s.\n", + badgeColor.Sprint("[ISOLATED SHELL EXITED]"), printer.WarningColor.Sprint(previousCtx)) + + return nil +} + +func resolveKubectl() (string, error) { + if v := os.Getenv("KUBECTL"); v != "" { + return v, nil + } + path, err := exec.LookPath("kubectl") + if err != nil { + return "", fmt.Errorf("kubectl is required for --shell but was not found in PATH") + } + return path, nil +} + +func extractMinimalKubeconfig(kubectlPath, contextName string) ([]byte, error) { + cmd := exec.Command(kubectlPath, "config", "view", "--minify", "--flatten", + "--context", contextName) + cmd.Env = os.Environ() + data, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("kubectl config view failed: %w", err) + } + return data, nil +} + +func detectShell() string { + if runtime.GOOS == "windows" { + // cmd.exe always sets the PROMPT env var, so if it is present + // we can reliably assume we are running inside cmd.exe. + if os.Getenv("PROMPT") != "" { + return "cmd.exe" + } + // Otherwise assume PowerShell. PSModulePath is always set on + // Windows regardless of the shell, so it cannot be used as a + // discriminator; however the absence of PROMPT is a strong + // enough signal that we are in a PowerShell session. + if pwsh, err := exec.LookPath("pwsh"); err == nil { + return pwsh + } + if powershell, err := exec.LookPath("powershell"); err == nil { + return powershell + } + return "cmd.exe" + } + if v := os.Getenv("SHELL"); v != "" { + return v + } + return "/bin/sh" +} diff --git a/cmd/kubectx/shell_test.go b/cmd/kubectx/shell_test.go new file mode 100644 index 0000000..34156ca --- /dev/null +++ b/cmd/kubectx/shell_test.go @@ -0,0 +1,131 @@ +package main + +import ( + "bytes" + "os" + "runtime" + "testing" + + "github.com/ahmetb/kubectx/internal/env" +) + +func Test_detectShell_unix(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("skipping unix shell detection test on windows") + } + + tests := []struct { + name string + shellEnv string + want string + }{ + { + name: "SHELL env set", + shellEnv: "/bin/zsh", + want: "/bin/zsh", + }, + { + name: "SHELL env empty, falls back to /bin/sh", + shellEnv: "", + want: "/bin/sh", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + orig := os.Getenv("SHELL") + defer os.Setenv("SHELL", orig) + + os.Setenv("SHELL", tt.shellEnv) + if tt.shellEnv == "" { + os.Unsetenv("SHELL") + } + + got := detectShell() + if got != tt.want { + t.Errorf("detectShell() = %q, want %q", got, tt.want) + } + }) + } +} + +func Test_ShellOp_blockedWhenNested(t *testing.T) { + // Simulate being inside an isolated shell + orig := os.Getenv(env.EnvIsolatedShell) + defer os.Setenv(env.EnvIsolatedShell, orig) + os.Setenv(env.EnvIsolatedShell, "1") + + op := ShellOp{Target: "some-context"} + var stdout, stderr bytes.Buffer + err := op.Run(&stdout, &stderr) + + if err == nil { + t.Fatal("expected error when running ShellOp inside isolated shell, got nil") + } + + want := "locked single-context shell to" + if !bytes.Contains([]byte(err.Error()), []byte(want)) { + // The error may not contain the context name if kubeconfig is not available, + // but it should still be blocked + want2 := "locked single-context shell" + if !bytes.Contains([]byte(err.Error()), []byte(want2)) { + t.Errorf("error message %q does not contain %q", err.Error(), want2) + } + } +} + +func Test_resolveKubectl_envVar(t *testing.T) { + orig := os.Getenv("KUBECTL") + defer os.Setenv("KUBECTL", orig) + + os.Setenv("KUBECTL", "/custom/path/kubectl") + got, err := resolveKubectl() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "/custom/path/kubectl" { + t.Errorf("resolveKubectl() = %q, want %q", got, "/custom/path/kubectl") + } +} + +func Test_resolveKubectl_inPath(t *testing.T) { + orig := os.Getenv("KUBECTL") + defer os.Setenv("KUBECTL", orig) + os.Unsetenv("KUBECTL") + + // kubectl should be findable in PATH on most dev machines + got, err := resolveKubectl() + if err != nil { + t.Skip("kubectl not in PATH, skipping") + } + if got == "" { + t.Error("resolveKubectl() returned empty string") + } +} + +func Test_checkIsolatedMode_notSet(t *testing.T) { + orig := os.Getenv(env.EnvIsolatedShell) + defer os.Setenv(env.EnvIsolatedShell, orig) + os.Unsetenv(env.EnvIsolatedShell) + + err := checkIsolatedMode() + if err != nil { + t.Errorf("expected nil error when not in isolated mode, got: %v", err) + } +} + +func Test_checkIsolatedMode_set(t *testing.T) { + orig := os.Getenv(env.EnvIsolatedShell) + defer os.Setenv(env.EnvIsolatedShell, orig) + os.Setenv(env.EnvIsolatedShell, "1") + + err := checkIsolatedMode() + if err == nil { + t.Fatal("expected error when in isolated mode, got nil") + } + + want := "locked single-context shell" + if !bytes.Contains([]byte(err.Error()), []byte(want)) { + t.Errorf("error message %q does not contain %q", err.Error(), want) + } +} diff --git a/cmd/kubectx/switch.go b/cmd/kubectx/switch.go index 5818d7c..b245747 100644 --- a/cmd/kubectx/switch.go +++ b/cmd/kubectx/switch.go @@ -29,6 +29,9 @@ type SwitchOp struct { } func (op SwitchOp) Run(_, stderr io.Writer) error { + if err := checkIsolatedMode(); err != nil { + return err + } var newCtx string var err error if op.Target == "-" { diff --git a/cmd/kubectx/unset.go b/cmd/kubectx/unset.go index ba79323..f22e922 100644 --- a/cmd/kubectx/unset.go +++ b/cmd/kubectx/unset.go @@ -27,6 +27,9 @@ import ( type UnsetOp struct{} func (_ UnsetOp) Run(_, stderr io.Writer) error { + if err := checkIsolatedMode(); err != nil { + return err + } kc := new(kubeconfig.Kubeconfig).WithLoader(kubeconfig.DefaultLoader) defer kc.Close() if err := kc.Parse(); err != nil { diff --git a/internal/env/constants.go b/internal/env/constants.go index c4fd5ba..c348030 100644 --- a/internal/env/constants.go +++ b/internal/env/constants.go @@ -29,4 +29,6 @@ const ( // EnvDebug describes the internal environment variable for more verbose logging. EnvDebug = `DEBUG` + + EnvIsolatedShell = "KUBECTX_ISOLATED_SHELL" )