Compare commits

..

8 Commits

Author SHA1 Message Date
Claude
2b0e4de615 kubens: use context to cancel slow-query warning timer after API returns
Replace done channel + defer with context.WithCancel so the warning
goroutine is cancelled immediately when queryNamespaces returns, not
when Run returns (which could be minutes later while user browses fzf).
Also thread the context into queryNamespaces so the k8s List call
respects it.

https://claude.ai/code/session_01XJXHq8WG22iqX8KaDb9RZz
2026-03-09 16:33:05 +00:00
Claude
f1283e7ebc kubens: pre-fetch namespaces before launching fzf, pipe via stdin
Instead of using FZF_DEFAULT_COMMAND to have fzf spawn a kubens
subprocess, InteractiveSwitchOp now queries namespaces directly and
pipes the result to fzf via an in-memory buffer. This makes the 3-second
slow-cluster warning visible to the user (printed to stderr before fzf's
TUI launches), since fzf swallows stderr from FZF_DEFAULT_COMMAND
subprocesses.

https://claude.ai/code/session_01XJXHq8WG22iqX8KaDb9RZz
2026-03-09 16:27:40 +00:00
Claude
c57909e31a kubens: show slow-listing warning after 3s during namespace list
When `kubens` is run with no args in non-interactive mode (ListOp),
start a background goroutine that prints a warning to stderr after
3 seconds if the Kubernetes API call is still in progress. The warning
advises users to switch directly with `kubens -f <ns>` to avoid the
slow list call. The goroutine is cancelled immediately if listing
completes before the timeout.

https://claude.ai/code/session_01XJXHq8WG22iqX8KaDb9RZz
2026-03-09 08:15:23 +00:00
Ahmet Alp Balkan
f500964e24 fix: skip fzf launch in kubens when no contexts exist in kubeconfig (#481)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 07:30:21 +00:00
Ahmet Alp Balkan
039f5ed1ef fix: skip fzf launch when no contexts exist in kubeconfig (#480)
Return an error early in InteractiveSwitchOp if the kubeconfig has no
contexts, instead of launching fzf with an empty list.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 06:54:27 +00:00
Ahmet Alp Balkan
81defc835f refactor: replace testutil.WithEnvVar with t.Setenv (#479)
t.Setenv is the modern standard Go testing utility (since Go 1.17) that automatically restores environment variables when the test completes. This replaces the custom testutil.WithEnvVar function which manually saved and restored env var state.

The testutil.go file is deleted as it only contained WithEnvVar. The testutil package remains for its other utilities like KubeconfigBuilder.
2026-03-08 20:26:40 -07:00
Ahmet Alp Balkan
d3576731a0 feat: add XDG_CACHE_HOME support to Go implementations (#478)
Adds a new CacheDir() function that respects the XDG_CACHE_HOME environment variable,
matching the bash scripts' behavior. kubectx and kubens now prefer XDG_CACHE_HOME when
set, falling back to $HOME/.kube otherwise. This aligns the Go implementations with
the bash scripts' cache directory logic.

Includes comprehensive tests covering all scenarios:
- XDG_CACHE_HOME set (returns the XDG value)
- XDG_CACHE_HOME unset (falls back to $HOME/.kube)
- Neither set (returns empty string)
2026-03-08 20:19:35 -07:00
Ahmet Alp Balkan
aa92c923c2 docs: simplify installation instructions into a single table (#477)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 18:01:03 -07:00
13 changed files with 139 additions and 77 deletions

View File

@@ -52,6 +52,14 @@ func (op InteractiveSwitchOp) Run(_, stderr io.Writer) error {
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

View File

@@ -24,11 +24,11 @@ import (
)
func kubectxPrevCtxFile() (string, error) {
home := cmdutil.HomeDir()
if home == "" {
dir := cmdutil.CacheDir()
if dir == "" {
return "", errors.New("HOME or USERPROFILE environment variable not set")
}
return filepath.Join(home, ".kube", "kubectx"), nil
return filepath.Join(dir, "kubectx"), nil
}
// readLastContext returns the saved previous context

View File

@@ -73,6 +73,7 @@ func Test_writeLastContext(t *testing.T) {
func Test_kubectxFilePath(t *testing.T) {
t.Setenv("HOME", filepath.FromSlash("/foo/bar"))
t.Setenv("XDG_CACHE_HOME", "")
expected := filepath.Join(filepath.FromSlash("/foo/bar"), ".kube", "kubectx")
v, err := kubectxPrevCtxFile()
@@ -84,6 +85,19 @@ func Test_kubectxFilePath(t *testing.T) {
}
}
func Test_kubectxFilePath_xdgCacheHome(t *testing.T) {
t.Setenv("XDG_CACHE_HOME", filepath.FromSlash("/tmp/xdg-cache"))
expected := filepath.Join(filepath.FromSlash("/tmp/xdg-cache"), "kubectx")
v, err := kubectxPrevCtxFile()
if err != nil {
t.Fatal(err)
}
if v != expected {
t.Fatalf("expected=\"%s\" got=\"%s\"", expected, v)
}
}
func Test_kubectxFilePath_error(t *testing.T) {
t.Setenv("HOME", "")
t.Setenv("USERPROFILE", "")

View File

@@ -38,7 +38,7 @@ func parseArgs(argv []string) Op {
if n == 0 {
if cmdutil.IsInteractiveMode(os.Stdout) {
return InteractiveSwitchOp{SelfCmd: os.Args[0]}
return InteractiveSwitchOp{}
}
return ListOp{}
}

View File

@@ -16,26 +16,23 @@ package main
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"strings"
"time"
"github.com/ahmetb/kubectx/internal/cmdutil"
"github.com/ahmetb/kubectx/internal/env"
"github.com/ahmetb/kubectx/internal/kubeconfig"
"github.com/ahmetb/kubectx/internal/printer"
)
type InteractiveSwitchOp struct {
SelfCmd string
}
type InteractiveSwitchOp struct{}
// TODO(ahmetb) This method is heavily repetitive vs kubectx/fzf.go.
func (op InteractiveSwitchOp) Run(_, stderr io.Writer) error {
// parse kubeconfig just to see if it can be loaded
kc := new(kubeconfig.Kubeconfig).WithLoader(kubeconfig.DefaultLoader)
defer kc.Close()
if err := kc.Parse(); err != nil {
@@ -46,15 +43,48 @@ func (op InteractiveSwitchOp) Run(_, stderr io.Writer) error {
return fmt.Errorf("kubeconfig error: %w", err)
}
ctx, err := kc.GetCurrentContext()
if err != nil {
return fmt.Errorf("failed to get current context: %w", err)
}
if ctx == "" {
return errors.New("current-context is not set")
}
curNs, err := kc.NamespaceOfContext(ctx)
if err != nil {
return fmt.Errorf("cannot read current namespace: %w", err)
}
ctx, cancel := context.WithCancel(context.Background())
go func() {
select {
case <-time.After(3 * time.Second):
printer.Warning(stderr, `listing namespaces is taking long, switch to a namespace directly with "kubens -f <ns>"`)
case <-ctx.Done():
}
}()
ns, err := queryNamespaces(ctx, kc)
cancel()
if err != nil {
return fmt.Errorf("could not list namespaces (is the cluster accessible?): %w", err)
}
var input bytes.Buffer
for _, c := range ns {
s := c
if c == curNs {
s = printer.ActiveItemColor.Sprint(c)
}
fmt.Fprintf(&input, "%s\n", s)
}
cmd := exec.Command("fzf", "--ansi", "--no-preview")
var out bytes.Buffer
cmd.Stdin = os.Stdin
cmd.Stdin = &input
cmd.Stderr = stderr
var out bytes.Buffer
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) {

View File

@@ -21,6 +21,7 @@ import (
"io"
"os"
"slices"
"time"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
@@ -52,7 +53,17 @@ func (op ListOp) Run(stdout, stderr io.Writer) error {
return fmt.Errorf("cannot read current namespace: %w", err)
}
ns, err := queryNamespaces(kc)
ctx, cancel := context.WithCancel(context.Background())
go func() {
select {
case <-time.After(3 * time.Second):
printer.Warning(stderr, `listing namespaces is taking long, switch to a namespace directly with "kubens -f <ns>"`)
case <-ctx.Done():
}
}()
ns, err := queryNamespaces(ctx, kc)
cancel()
if err != nil {
return fmt.Errorf("could not list namespaces (is the cluster accessible?): %w", err)
}
@@ -67,7 +78,7 @@ func (op ListOp) Run(stdout, stderr io.Writer) error {
return nil
}
func queryNamespaces(kc *kubeconfig.Kubeconfig) ([]string, error) {
func queryNamespaces(ctx context.Context, kc *kubeconfig.Kubeconfig) ([]string, error) {
if os.Getenv("_MOCK_NAMESPACES") != "" {
return []string{"ns1", "ns2"}, nil
}
@@ -81,7 +92,7 @@ func queryNamespaces(kc *kubeconfig.Kubeconfig) ([]string, error) {
var next string
for {
list, err := clientset.CoreV1().Namespaces().List(
context.Background(),
ctx,
metav1.ListOptions{
Limit: 500,
Continue: next,

View File

@@ -24,7 +24,7 @@ import (
"github.com/ahmetb/kubectx/internal/cmdutil"
)
var defaultDir = filepath.Join(cmdutil.HomeDir(), ".kube", "kubens")
var defaultDir = filepath.Join(cmdutil.CacheDir(), "kubens")
type NSFile struct {
dir string

View File

@@ -18,8 +18,6 @@ import (
"runtime"
"strings"
"testing"
"github.com/ahmetb/kubectx/internal/testutil"
)
func TestNSFile(t *testing.T) {
@@ -50,7 +48,7 @@ func TestNSFile(t *testing.T) {
}
func TestNSFile_path_windows(t *testing.T) {
defer testutil.WithEnvVar("_FORCE_GOOS", "windows")()
t.Setenv("_FORCE_GOOS", "windows")
fp := NewNSFile("a:b:c").path()
if expected := "a__b__c"; !strings.HasSuffix(fp, expected) {
@@ -68,7 +66,7 @@ func Test_isWindows(t *testing.T) {
t.Fatalf("isWindows() returned true for %s", runtime.GOOS)
}
defer testutil.WithEnvVar("_FORCE_GOOS", "windows")()
t.Setenv("_FORCE_GOOS", "windows")
if !isWindows() {
t.Fatalf("isWindows() failed to detect windows with env override.")
}

View File

@@ -17,6 +17,7 @@ package cmdutil
import (
"errors"
"os"
"path/filepath"
)
func HomeDir() string {
@@ -27,6 +28,19 @@ func HomeDir() string {
return home
}
// CacheDir returns XDG_CACHE_HOME if set, otherwise $HOME/.kube,
// matching the bash scripts' behavior: ${XDG_CACHE_HOME:-$HOME/.kube}.
func CacheDir() string {
if xdg := os.Getenv("XDG_CACHE_HOME"); xdg != "" {
return xdg
}
home := HomeDir()
if home == "" {
return ""
}
return filepath.Join(home, ".kube")
}
// IsNotFoundErr determines if the underlying error is os.IsNotExist.
func IsNotFoundErr(err error) bool {
for e := err; e != nil; e = errors.Unwrap(e) {

View File

@@ -15,9 +15,8 @@
package cmdutil
import (
"path/filepath"
"testing"
"github.com/ahmetb/kubectx/internal/testutil"
)
func Test_homeDir(t *testing.T) {
@@ -63,18 +62,40 @@ func Test_homeDir(t *testing.T) {
for _, c := range cases {
t.Run(c.name, func(tt *testing.T) {
var unsets []func()
for _, e := range c.envs {
unsets = append(unsets, testutil.WithEnvVar(e.k, e.v))
tt.Setenv(e.k, e.v)
}
got := HomeDir()
if got != c.want {
t.Errorf("expected:%q got:%q", c.want, got)
}
for _, u := range unsets {
u()
}
})
}
}
func TestCacheDir(t *testing.T) {
t.Run("XDG_CACHE_HOME set", func(t *testing.T) {
t.Setenv("XDG_CACHE_HOME", "/tmp/xdg-cache")
t.Setenv("HOME", "/home/user")
if got := CacheDir(); got != "/tmp/xdg-cache" {
t.Errorf("expected:%q got:%q", "/tmp/xdg-cache", got)
}
})
t.Run("XDG_CACHE_HOME unset, falls back to HOME/.kube", func(t *testing.T) {
t.Setenv("XDG_CACHE_HOME", "")
t.Setenv("HOME", "/home/user")
want := filepath.Join("/home/user", ".kube")
if got := CacheDir(); got != want {
t.Errorf("expected:%q got:%q", want, got)
}
})
t.Run("neither set", func(t *testing.T) {
t.Setenv("XDG_CACHE_HOME", "")
t.Setenv("HOME", "")
t.Setenv("USERPROFILE", "")
if got := CacheDir(); got != "" {
t.Errorf("expected:%q got:%q", "", got)
}
})
}

View File

@@ -15,17 +15,16 @@
package kubeconfig
import (
"github.com/ahmetb/kubectx/internal/cmdutil"
"os"
"path/filepath"
"strings"
"testing"
"github.com/ahmetb/kubectx/internal/testutil"
"github.com/ahmetb/kubectx/internal/cmdutil"
)
func Test_kubeconfigPath(t *testing.T) {
defer testutil.WithEnvVar("HOME", "/x/y/z")()
t.Setenv("HOME", "/x/y/z")
expected := filepath.FromSlash("/x/y/z/.kube/config")
got, err := kubeconfigPath()
@@ -38,9 +37,9 @@ func Test_kubeconfigPath(t *testing.T) {
}
func Test_kubeconfigPath_noEnvVars(t *testing.T) {
defer testutil.WithEnvVar("XDG_CACHE_HOME", "")()
defer testutil.WithEnvVar("HOME", "")()
defer testutil.WithEnvVar("USERPROFILE", "")()
t.Setenv("XDG_CACHE_HOME", "")
t.Setenv("HOME", "")
t.Setenv("USERPROFILE", "")
_, err := kubeconfigPath()
if err == nil {
@@ -49,7 +48,7 @@ func Test_kubeconfigPath_noEnvVars(t *testing.T) {
}
func Test_kubeconfigPath_envOvveride(t *testing.T) {
defer testutil.WithEnvVar("KUBECONFIG", "foo")()
t.Setenv("KUBECONFIG", "foo")
v, err := kubeconfigPath()
if err != nil {
@@ -62,7 +61,7 @@ func Test_kubeconfigPath_envOvveride(t *testing.T) {
func Test_kubeconfigPath_envOvverideDoesNotSupportPathSeparator(t *testing.T) {
path := strings.Join([]string{"file1", "file2"}, string(os.PathListSeparator))
defer testutil.WithEnvVar("KUBECONFIG", path)()
t.Setenv("KUBECONFIG", path)
_, err := kubeconfigPath()
if err == nil {
@@ -71,7 +70,7 @@ func Test_kubeconfigPath_envOvverideDoesNotSupportPathSeparator(t *testing.T) {
}
func TestStandardKubeconfigLoader_returnsNotFoundErr(t *testing.T) {
defer testutil.WithEnvVar("KUBECONFIG", "foo")()
t.Setenv("KUBECONFIG", "foo")
kc := new(Kubeconfig).WithLoader(DefaultLoader)
err := kc.Parse()
if err == nil {

View File

@@ -18,8 +18,6 @@ import (
"testing"
"github.com/google/go-cmp/cmp"
"github.com/ahmetb/kubectx/internal/testutil"
)
var (
@@ -27,8 +25,8 @@ var (
)
func Test_useColors_forceColors(t *testing.T) {
defer testutil.WithEnvVar("_KUBECTX_FORCE_COLOR", "1")()
defer testutil.WithEnvVar("NO_COLOR", "1")()
t.Setenv("_KUBECTX_FORCE_COLOR", "1")
t.Setenv("NO_COLOR", "1")
if v := useColors(); !cmp.Equal(v, &tr) {
t.Fatalf("expected useColors() = true; got = %v", v)
@@ -36,7 +34,7 @@ func Test_useColors_forceColors(t *testing.T) {
}
func Test_useColors_disableColors(t *testing.T) {
defer testutil.WithEnvVar("NO_COLOR", "1")()
t.Setenv("NO_COLOR", "1")
if v := useColors(); !cmp.Equal(v, &fa) {
t.Fatalf("expected useColors() = false; got = %v", v)
@@ -44,8 +42,8 @@ func Test_useColors_disableColors(t *testing.T) {
}
func Test_useColors_default(t *testing.T) {
defer testutil.WithEnvVar("NO_COLOR", "")()
defer testutil.WithEnvVar("_KUBECTX_FORCE_COLOR", "")()
t.Setenv("NO_COLOR", "")
t.Setenv("_KUBECTX_FORCE_COLOR", "")
if v := useColors(); v != nil {
t.Fatalf("expected useColors() = nil; got=%v", *v)

View File

@@ -1,31 +0,0 @@
// Copyright 2021 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package testutil
import "os"
// WithEnvVar sets an env var temporarily. Call its return value
// in defer to restore original value in env (if exists).
func WithEnvVar(key, value string) func() {
orig, ok := os.LookupEnv(key)
os.Setenv(key, value)
return func() {
if ok {
os.Setenv(key, orig)
} else {
os.Unsetenv(key)
}
}
}