Extract common shell logic into shellSession

Deduplicates code between ShellOp and ReadonlyShellOp by extracting
shared logic (context validation, kubeconfig extraction, temp file
management, shell spawning) into a shellSession struct.

Callers configure behavior via:
- printEntry/printExit callbacks for banner messages
- extraEnv for additional environment variables
- transformKubeconfig hook for kubeconfig transformation (e.g. proxy)

Also extracts fzfPickContext for shared interactive mode.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ahmet Alp Balkan
2026-03-25 11:45:50 -04:00
parent d75d282b17
commit 6f8704f966
3 changed files with 228 additions and 257 deletions

View File

@@ -1,21 +1,15 @@
package main
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"strings"
"time"
"github.com/fatih/color"
"github.com/ahmetb/kubectx/internal/cmdutil"
"github.com/ahmetb/kubectx/internal/env"
"github.com/ahmetb/kubectx/internal/kubeconfig"
"github.com/ahmetb/kubectx/internal/printer"
"github.com/ahmetb/kubectx/internal/proxy"
)
@@ -31,154 +25,69 @@ type ReadonlyShellOp struct {
}
func (op InteractiveReadonlyShellOp) Run(_, stderr io.Writer) error {
if err := checkIsolatedMode(); err != nil {
choice, err := fzfPickContext(op.SelfCmd, stderr)
if err != nil || choice == "" {
return err
}
kc := new(kubeconfig.Kubeconfig).WithLoader(kubeconfig.DefaultLoader)
defer kc.Close()
if err := kc.Parse(); err != nil {
if cmdutil.IsNotFoundErr(err) {
printer.Warning(stderr, "kubeconfig file not found")
return nil
}
return fmt.Errorf("kubeconfig error: %w", err)
}
ctxNames, err := kc.ContextNames()
if err != nil {
return fmt.Errorf("failed to get context names: %w", err)
}
if len(ctxNames) == 0 {
return errors.New("no contexts found in the kubeconfig file")
}
cmd := exec.Command("fzf", "--ansi", "--no-preview")
var out bytes.Buffer
cmd.Stdin = os.Stdin
cmd.Stderr = stderr
cmd.Stdout = &out
cmd.Env = append(os.Environ(),
fmt.Sprintf("FZF_DEFAULT_COMMAND=%s", op.SelfCmd),
fmt.Sprintf("%s=1", env.EnvForceColor))
if err := cmd.Run(); err != nil {
var exitErr *exec.ExitError
if !errors.As(err, &exitErr) {
return err
}
}
choice := strings.TrimSpace(out.String())
if choice == "" {
return errors.New("you did not choose any of the options")
}
return ReadonlyShellOp{Target: choice}.Run(nil, stderr)
}
func (op ReadonlyShellOp) 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 fmt.Errorf("kubeconfig error: %w", err)
}
exists, err := kc.ContextExists(op.Target)
if err != nil {
return fmt.Errorf("failed to check context: %w", err)
}
if !exists {
return fmt.Errorf("no context exists with the name: %q", op.Target)
}
previousCtx, err := kc.GetCurrentContext()
if err != nil {
return fmt.Errorf("failed to get current context: %w", err)
}
// Extract minimal kubeconfig for the target context.
data, err := extractMinimalKubeconfig(kubectlPath, op.Target)
if err != nil {
return fmt.Errorf("failed to extract kubeconfig for context: %w", err)
}
// Write original minified kubeconfig to temp file (used by proxy for TLS/auth).
origFile, err := os.CreateTemp("", "kubectx-readonly-orig-*.yaml")
if err != nil {
return fmt.Errorf("failed to create temp kubeconfig file: %w", err)
}
origPath := origFile.Name()
defer os.Remove(origPath)
if _, err := origFile.Write(data); err != nil {
origFile.Close()
return fmt.Errorf("failed to write temp kubeconfig: %w", err)
}
origFile.Close()
// Start the readonly proxy.
p, err := proxy.Start(proxy.Config{
KubeconfigPath: origPath,
ContextName: op.Target,
})
if err != nil {
return fmt.Errorf("failed to start readonly proxy: %w", err)
}
defer p.Shutdown(context.Background())
// Rewrite kubeconfig to point to the proxy.
rewritten, err := proxy.RewriteKubeconfig(data, p.Addr())
if err != nil {
return fmt.Errorf("failed to rewrite kubeconfig: %w", err)
}
// Write rewritten kubeconfig to a second temp file for the shell.
shellFile, err := os.CreateTemp("", "kubectx-readonly-shell-*.yaml")
if err != nil {
return fmt.Errorf("failed to create temp kubeconfig file: %w", err)
}
shellPath := shellFile.Name()
defer os.Remove(shellPath)
if _, err := shellFile.Write(rewritten); err != nil {
shellFile.Close()
return fmt.Errorf("failed to write rewritten kubeconfig: %w", err)
}
shellFile.Close()
// Give the proxy a moment to be ready.
time.Sleep(10 * time.Millisecond)
// Print entry message.
badgeColor := color.New(color.BgYellow, color.FgBlack, color.Bold)
printer.EnableOrDisableColor(badgeColor)
fmt.Fprintf(stderr, "%s kubectl context is %s in READ-ONLY mode — type 'exit' to leave.\n",
badgeColor.Sprint("[READONLY 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="+shellPath,
env.EnvIsolatedShell+"=1",
env.EnvReadonlyShell+"=1",
)
s := &shellSession{
target: op.Target,
extraEnv: []string{env.EnvReadonlyShell + "=1"},
printEntry: func(w io.Writer, ctxName string) {
fmt.Fprintf(w, "%s kubectl context is %s in READ-ONLY mode — type 'exit' to leave.\n",
badgeColor.Sprint("[READONLY SHELL]"), printer.WarningColor.Sprint(ctxName))
},
printExit: func(w io.Writer, prevCtx string) {
fmt.Fprintf(w, "%s kubectl context is now %s.\n",
badgeColor.Sprint("[READONLY SHELL EXITED]"), printer.WarningColor.Sprint(prevCtx))
},
transformKubeconfig: func(data []byte) ([]byte, func(), error) {
// Write original kubeconfig to temp file for the proxy to load TLS/auth.
origFile, err := os.CreateTemp("", "kubectx-readonly-orig-*.yaml")
if err != nil {
return nil, nil, fmt.Errorf("failed to create temp kubeconfig file: %w", err)
}
origPath := origFile.Name()
_ = cmd.Run()
if _, err := origFile.Write(data); err != nil {
origFile.Close()
os.Remove(origPath)
return nil, nil, fmt.Errorf("failed to write temp kubeconfig: %w", err)
}
origFile.Close()
// Print exit message.
fmt.Fprintf(stderr, "%s kubectl context is now %s.\n",
badgeColor.Sprint("[READONLY SHELL EXITED]"), printer.WarningColor.Sprint(previousCtx))
// Start the readonly proxy.
p, err := proxy.Start(proxy.Config{
KubeconfigPath: origPath,
ContextName: op.Target,
})
if err != nil {
os.Remove(origPath)
return nil, nil, fmt.Errorf("failed to start readonly proxy: %w", err)
}
return nil
// Rewrite kubeconfig to point to the proxy.
rewritten, err := proxy.RewriteKubeconfig(data, p.Addr())
if err != nil {
p.Shutdown(context.Background())
os.Remove(origPath)
return nil, nil, fmt.Errorf("failed to rewrite kubeconfig: %w", err)
}
time.Sleep(10 * time.Millisecond)
cleanup := func() {
p.Shutdown(context.Background())
os.Remove(origPath)
}
return rewritten, cleanup, nil
},
}
return s.run(stderr)
}

View File

@@ -1,20 +1,14 @@
package main
import (
"bytes"
"errors"
"fmt"
"io"
"os"
"os/exec"
"runtime"
"strings"
"github.com/fatih/color"
"github.com/ahmetb/kubectx/internal/cmdutil"
"github.com/ahmetb/kubectx/internal/env"
"github.com/ahmetb/kubectx/internal/kubeconfig"
"github.com/ahmetb/kubectx/internal/printer"
)
@@ -29,122 +23,29 @@ type ShellOp struct {
}
func (op InteractiveShellOp) Run(_, stderr io.Writer) error {
if err := checkIsolatedMode(); err != nil {
choice, err := fzfPickContext(op.SelfCmd, stderr)
if err != nil || choice == "" {
return err
}
kc := new(kubeconfig.Kubeconfig).WithLoader(kubeconfig.DefaultLoader)
defer kc.Close()
if err := kc.Parse(); err != nil {
if cmdutil.IsNotFoundErr(err) {
printer.Warning(stderr, "kubeconfig file not found")
return nil
}
return fmt.Errorf("kubeconfig error: %w", err)
}
ctxNames, err := kc.ContextNames()
if err != nil {
return fmt.Errorf("failed to get context names: %w", err)
}
if len(ctxNames) == 0 {
return errors.New("no contexts found in the kubeconfig file")
}
cmd := exec.Command("fzf", "--ansi", "--no-preview")
var out bytes.Buffer
cmd.Stdin = os.Stdin
cmd.Stderr = stderr
cmd.Stdout = &out
cmd.Env = append(os.Environ(),
fmt.Sprintf("FZF_DEFAULT_COMMAND=%s", op.SelfCmd),
fmt.Sprintf("%s=1", env.EnvForceColor))
if err := cmd.Run(); err != nil {
var exitErr *exec.ExitError
if !errors.As(err, &exitErr) {
return err
}
}
choice := strings.TrimSpace(out.String())
if choice == "" {
return errors.New("you did not choose any of the options")
}
return ShellOp{Target: choice}.Run(nil, stderr)
}
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 fmt.Errorf("kubeconfig error: %w", err)
}
exists, err := kc.ContextExists(op.Target)
if err != nil {
return fmt.Errorf("failed to check context: %w", err)
}
if !exists {
return fmt.Errorf("no context exists with the name: \"%s\"", op.Target)
}
previousCtx, err := kc.GetCurrentContext()
if err != nil {
return fmt.Errorf("failed to get current context: %w", err)
}
// Extract minimal kubeconfig using kubectl
data, err := extractMinimalKubeconfig(kubectlPath, op.Target)
if err != nil {
return fmt.Errorf("failed to extract kubeconfig for context: %w", err)
}
// Write to temp file
tmpFile, err := os.CreateTemp("", "kubectx-shell-*.yaml")
if err != nil {
return fmt.Errorf("failed to create temp kubeconfig file: %w", err)
}
tmpPath := tmpFile.Name()
defer os.Remove(tmpPath)
if _, err := tmpFile.Write(data); err != nil {
tmpFile.Close()
return fmt.Errorf("failed to write temp kubeconfig: %w", err)
}
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
s := &shellSession{
target: op.Target,
printEntry: func(w io.Writer, ctxName string) {
fmt.Fprintf(w, "%s kubectl context is %s in this shell — type 'exit' to leave.\n",
badgeColor.Sprint("[ISOLATED SHELL]"), printer.WarningColor.Sprint(ctxName))
},
printExit: func(w io.Writer, prevCtx string) {
fmt.Fprintf(w, "%s kubectl context is now %s.\n",
badgeColor.Sprint("[ISOLATED SHELL EXITED]"), printer.WarningColor.Sprint(prevCtx))
},
}
return s.run(stderr)
}
func resolveKubectl() (string, error) {

View File

@@ -0,0 +1,161 @@
package main
import (
"bytes"
"errors"
"fmt"
"io"
"os"
"os/exec"
"strings"
"github.com/ahmetb/kubectx/internal/cmdutil"
"github.com/ahmetb/kubectx/internal/env"
"github.com/ahmetb/kubectx/internal/kubeconfig"
"github.com/ahmetb/kubectx/internal/printer"
)
// shellSession holds the configuration for spawning an isolated sub-shell.
type shellSession struct {
target string
extraEnv []string // additional env vars beyond KUBECONFIG + KUBECTX_ISOLATED_SHELL
printEntry func(stderr io.Writer, ctxName string)
printExit func(stderr io.Writer, prevCtx string)
// transformKubeconfig optionally transforms the minified kubeconfig bytes
// before writing them to the shell's temp file. The returned cleanup func
// is called after the shell exits (e.g. to shut down a proxy).
// If nil, the kubeconfig is used as-is.
transformKubeconfig func(data []byte) (newData []byte, cleanup func(), err error)
}
func (s *shellSession) 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 fmt.Errorf("kubeconfig error: %w", err)
}
exists, err := kc.ContextExists(s.target)
if err != nil {
return fmt.Errorf("failed to check context: %w", err)
}
if !exists {
return fmt.Errorf("no context exists with the name: %q", s.target)
}
previousCtx, err := kc.GetCurrentContext()
if err != nil {
return fmt.Errorf("failed to get current context: %w", err)
}
// Extract minimal kubeconfig for the target context.
data, err := extractMinimalKubeconfig(kubectlPath, s.target)
if err != nil {
return fmt.Errorf("failed to extract kubeconfig for context: %w", err)
}
// Optionally transform the kubeconfig (e.g. rewrite for readonly proxy).
var cleanup func()
if s.transformKubeconfig != nil {
data, cleanup, err = s.transformKubeconfig(data)
if err != nil {
return err
}
if cleanup != nil {
defer cleanup()
}
}
// Write kubeconfig to temp file.
tmpFile, err := os.CreateTemp("", "kubectx-shell-*.yaml")
if err != nil {
return fmt.Errorf("failed to create temp kubeconfig file: %w", err)
}
tmpPath := tmpFile.Name()
defer os.Remove(tmpPath)
if _, err := tmpFile.Write(data); err != nil {
tmpFile.Close()
return fmt.Errorf("failed to write temp kubeconfig: %w", err)
}
tmpFile.Close()
// Print entry message.
s.printEntry(stderr, s.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(),
append([]string{
"KUBECONFIG=" + tmpPath,
env.EnvIsolatedShell + "=1",
}, s.extraEnv...)...,
)
_ = cmd.Run()
// Print exit message.
s.printExit(stderr, previousCtx)
return nil
}
// fzfPickContext launches fzf for interactive context selection.
func fzfPickContext(selfCmd string, stderr io.Writer) (string, error) {
if err := checkIsolatedMode(); err != nil {
return "", err
}
kc := new(kubeconfig.Kubeconfig).WithLoader(kubeconfig.DefaultLoader)
defer kc.Close()
if err := kc.Parse(); err != nil {
if cmdutil.IsNotFoundErr(err) {
printer.Warning(stderr, "kubeconfig file not found")
return "", nil
}
return "", fmt.Errorf("kubeconfig error: %w", err)
}
ctxNames, err := kc.ContextNames()
if err != nil {
return "", fmt.Errorf("failed to get context names: %w", err)
}
if len(ctxNames) == 0 {
return "", errors.New("no contexts found in the kubeconfig file")
}
cmd := exec.Command("fzf", "--ansi", "--no-preview")
var out bytes.Buffer
cmd.Stdin = os.Stdin
cmd.Stderr = stderr
cmd.Stdout = &out
cmd.Env = append(os.Environ(),
fmt.Sprintf("FZF_DEFAULT_COMMAND=%s", selfCmd),
fmt.Sprintf("%s=1", env.EnvForceColor))
if err := cmd.Run(); err != nil {
var exitErr *exec.ExitError
if !errors.As(err, &exitErr) {
return "", err
}
}
choice := strings.TrimSpace(out.String())
if choice == "" {
return "", errors.New("you did not choose any of the options")
}
return choice, nil
}