mirror of
https://github.com/ahmetb/kubectx.git
synced 2026-03-10 16:02:14 +00:00
Compare commits
1 Commits
master
...
abalkan/is
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d56196b9c2 |
@@ -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".
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'")}},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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())
|
||||
|
||||
24
cmd/kubectx/isolated_shell_guard.go
Normal file
24
cmd/kubectx/isolated_shell_guard.go
Normal 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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
135
cmd/kubectx/shell.go
Normal 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
131
cmd/kubectx/shell_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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 == "-" {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
2
internal/env/constants.go
vendored
2
internal/env/constants.go
vendored
@@ -29,4 +29,6 @@ const (
|
||||
|
||||
// EnvDebug describes the internal environment variable for more verbose logging.
|
||||
EnvDebug = `DEBUG`
|
||||
|
||||
EnvIsolatedShell = "KUBECTX_ISOLATED_SHELL"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user