ksw (Kubeconfig SWitcher) is a small CLI tool I built to help me work with multiple Kubernetes contexts across different terminal windows. It creates isolated shell sessions, each using a different context, so I can have one terminal connected to production and another to staging without them interfering with each other. I use it every day at work.
It's a tiny project. I don't know if anyone else is using it besides me, but I wanted to improve it anyway. When I started working on this refactor, I had one clear goal: make the process tree as flat as possible. The nested shell problem was bothering me, and I wanted to fix it, even if there were catches along the way.
The Nested Shell Problem
The original implementation spawned a subprocess for each ksw invocation:
you@machine$ ksw production
└─ ksw process (waiting)
└─ zsh
$ ksw staging
└─ ksw process (waiting)
└─ zsh
Each switch added another level. The KSW_LEVEL
environment variable tracked how deep you were. It worked, but it felt wrong. I wanted a flat process tree with minimal nesting.
First Attempt: Parent-Managed Switching
My first thought was: what if the child shell exits and delegates the switching to the parent ksw process? The parent could detect the exit, switch the context, and spawn a new shell.
I quickly abandoned this idea. It was too complex. Coordinating between parent and child, passing signals or exit codes to communicate the desired context, handling edge cases. The complexity wasn't worth it.
(Side note: I might revisit this inter-process delegation approach later just for the sake of it. It's an interesting problem even if the simpler solution works fine.)
Discovery: syscall.Exec
Then I found syscall.Exec()
. Instead of spawning a subprocess, it replaces the current process entirely. The ksw process doesn't wait around, it becomes the shell.
// Replace ksw process with shell
if err := syscall.Exec(shell, []string{shell}, os.Environ()); err != nil {
return fmt.Errorf("failed to exec shell: %w", err)
}
// This line never executes - ksw is gone
Great! Now the process tree looks like:
you@machine$ ksw production
└─ zsh
But what about switching contexts? If you run ksw staging
from within that shell:
you@machine$ ksw production
└─ zsh
$ ksw staging
└─ zsh
Still nested, but shallower. No idle ksw processes sitting around at each level. All ksw processes get replaced by shells at the same depth. Better, but not quite there yet.
The Workaround: Shell exec Command
I realized you could use the shell's built-in exec
command:
production$ exec ksw staging
This replaces the current shell process with ksw, which then replaces itself with a new shell for the staging context. No nesting, but requiring users to type exec ksw
instead of just ksw
felt clunky. Not a good experience.
The Breakthrough: In-Place Updates
Then it hit me: if the current shell is already in a ksw session, I don't need a new shell at all. Each ksw session uses an isolated temporary kubeconfig file, that's the whole point of ksw. Other terminals aren't affected.
So why not just update that temp file in place?
// In main.go - detect if already in a session
if os.Getenv("KSW_KUBECONFIG_ORIGINAL") != "" {
return switchContext(contextName)
}
// switchContext overwrites the existing temp file
func switchContext(contextName string) error {
kubeconfigOriginal := os.Getenv("KSW_KUBECONFIG_ORIGINAL")
existingKubeconfig := os.Getenv("KSW_KUBECONFIG")
b, err := generateKubeconfig(kubeconfigOriginal, contextName)
if err != nil {
return err
}
// Overwrite existing temp file with new context
if err := os.WriteFile(existingKubeconfig, b, 0600); err != nil {
return err
}
logf("switched to context %s", contextName)
return nil
}
Since kubectl reads the KUBECONFIG
environment variable on every invocation, it immediately sees the new context. No new process, no nesting, just an updated file.
Now the flow is:
you@machine$ ksw production
└─ zsh (KUBECONFIG=/tmp/production.xyz.yaml)
$ ksw staging # Updates /tmp/production.xyz.yaml in-place
$ kubectl get pods # Reads the updated file, sees staging context
Same shell, same process, different context. Flat process tree achieved.
The Result
The combination of syscall.Exec()
for initial sessions and in-place updates for context switching solved both problems (see the full implementation):
- First time: ksw replaces itself with your shell, no idle process
- Context switching: ksw updates the temp file and returns, no new shell, no nesting
The KSW_LEVEL
tracking is gone. The nested shell problem is gone. The process tree stays flat.
Trade-offs
There's one trade-off: temp file cleanup now relies on the OS cleaning up /tmp
. Previously, the ksw process could delete the temp file when the subprocess exited. In practice, this hasn't been an issue. The files are tiny (~1-2KB) and OS temp cleanup handles them fine.
What I Learned
Sometimes the best solution comes from realizing you don't need to do something at all. I was so focused on how to make process spawning better that I almost missed the obvious: when you're already in a ksw session, you don't need another process. Just update the file.
The syscall.Exec()
change made the architecture cleaner, but the real win was recognizing that in-place updates were possible because of ksw's isolated temp file design. The solution was already there in the architecture. I just needed to see it.
Try It Out
If you work with multiple Kubernetes contexts, give ksw a try:
brew install chickenzord/tap/ksw
ksw my-context
Switch contexts as many times as you want, your process tree stays flat.
ksw is open source at github.com/chickenzord/ksw. The exec refactor was implemented in PR #19.
Top comments (0)