Compare commits

...

1 Commits

Author SHA1 Message Date
Ahmet Alp Balkan
d56196b9c2 feat: add -s/--shell flag for scoped sub-shell
Spawns an isolated sub-shell with a minimal kubeconfig containing only
the specified context. Inside the shell, all operations (switch,
rename, delete, unset, list, current, and interactive modes) are blocked
via the KUBECTX_ISOLATED_SHELL env var. Only help and version remain
allowed. Nested scoped shells are also prevented.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 14:33:59 -07:00
15 changed files with 339 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -43,6 +43,7 @@ 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% -s, --shell <NAME> : start a shell scoped to context <NAME>
%PROG% -h,--help : show this message
%PROG% -V,--version : show version`
help = strings.ReplaceAll(help, "%PROG%", selfName())

View File

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

View File

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

View File

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

135
cmd/kubectx/shell.go Normal file
View File

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

131
cmd/kubectx/shell_test.go Normal file
View File

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

View File

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

View File

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

View File

@@ -29,4 +29,6 @@ const (
// EnvDebug describes the internal environment variable for more verbose logging.
EnvDebug = `DEBUG`
EnvIsolatedShell = "KUBECTX_ISOLATED_SHELL"
)