fix: pipe context/namespace lists to fzf via stdin

Interactive mode was launching fzf and handing it the parent TTY on fd
0, then relying on fzf to re-exec kubectx via FZF_DEFAULT_COMMAND to
obtain the candidate list. fzf never received the list on its stdin, so
on systems where fzf did not honor FZF_DEFAULT_COMMAND the picker showed
no items and kubectx exited with 'you did not choose any of the
options'.

Build the candidate list in-process and pipe it directly to fzf's
stdin. This removes the self-re-exec, the $SHELL -c dependency, and the
TTY-plumbing hack on fd 0.

Fixes #500
This commit is contained in:
Ahmet Alp Balkan
2026-07-17 17:04:49 -07:00
parent c8393ea883
commit 17df3085a1
11 changed files with 361 additions and 38 deletions

View File

@@ -35,7 +35,7 @@ func (op UnsupportedOp) Run(_, _ io.Writer) error {
func parseArgs(argv []string) Op {
if len(argv) == 0 {
if cmdutil.IsInteractiveMode(os.Stdout) {
return InteractiveSwitchOp{SelfCmd: os.Args[0]}
return InteractiveSwitchOp{}
}
return ListOp{}
}
@@ -43,7 +43,7 @@ func parseArgs(argv []string) Op {
if argv[0] == "--readonly" || argv[0] == "-r" {
if len(argv) == 1 {
if cmdutil.IsInteractiveMode(os.Stdout) {
return InteractiveReadonlyShellOp{SelfCmd: os.Args[0]}
return InteractiveReadonlyShellOp{}
}
return UnsupportedOp{Err: fmt.Errorf("'%s' requires a context name argument (or fzf for interactive mode)", argv[0])}
}
@@ -56,7 +56,7 @@ func parseArgs(argv []string) Op {
if argv[0] == "--shell" || argv[0] == "-s" {
if len(argv) == 1 {
if cmdutil.IsInteractiveMode(os.Stdout) {
return InteractiveShellOp{SelfCmd: os.Args[0]}
return InteractiveShellOp{}
}
return UnsupportedOp{Err: fmt.Errorf("'%s' requires a context name argument (or fzf for interactive mode)", argv[0])}
}
@@ -69,7 +69,7 @@ func parseArgs(argv []string) Op {
if argv[0] == "-d" {
if len(argv) == 1 {
if cmdutil.IsInteractiveMode(os.Stdout) {
return InteractiveDeleteOp{SelfCmd: os.Args[0]}
return InteractiveDeleteOp{}
} else {
return UnsupportedOp{Err: fmt.Errorf("'-d' needs arguments")}
}

View File

@@ -29,13 +29,9 @@ import (
"github.com/ahmetb/kubectx/internal/printer"
)
type InteractiveSwitchOp struct {
SelfCmd string
}
type InteractiveSwitchOp struct{}
type InteractiveDeleteOp struct {
SelfCmd string
}
type InteractiveDeleteOp struct{}
func (op InteractiveSwitchOp) Run(_, stderr io.Writer) error {
if err := checkIsolatedMode(); err != nil {
@@ -60,14 +56,18 @@ func (op InteractiveSwitchOp) Run(_, stderr io.Writer) error {
return errors.New("no contexts found in the kubeconfig file")
}
var in bytes.Buffer
if err := formatContextList(kc, &in); err != nil {
return err
}
cmd := exec.Command("fzf", "--ansi", "--no-preview")
var out bytes.Buffer
cmd.Stdin = os.Stdin
cmd.Stdin = &in
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
@@ -110,14 +110,18 @@ func (op InteractiveDeleteOp) Run(_, stderr io.Writer) error {
return errors.New("no contexts found in config")
}
var in bytes.Buffer
if err := formatContextList(kc, &in); err != nil {
return err
}
cmd := exec.Command("fzf", "--ansi", "--no-preview")
var out bytes.Buffer
cmd.Stdin = os.Stdin
cmd.Stdin = &in
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

183
cmd/kubectx/fzf_test.go Normal file
View File

@@ -0,0 +1,183 @@
// 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 main
import (
"bytes"
"io"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/ahmetb/kubectx/internal/kubeconfig"
)
// writeTestKubeconfig writes a kubeconfig with contexts ctx-a (current)
// and ctx-b into a temp dir and returns its path.
func writeTestKubeconfig(t *testing.T) string {
t.Helper()
const cfg = `apiVersion: v1
kind: Config
current-context: ctx-a
contexts:
- name: ctx-a
context: { cluster: cluster-a, user: user-a }
- name: ctx-b
context: { cluster: cluster-b, user: user-b }
clusters:
- name: cluster-a
cluster: { server: https://example.invalid }
- name: cluster-b
cluster: { server: https://example.invalid }
users:
- name: user-a
user: { token: fake }
- name: user-b
user: { token: fake }
`
p := filepath.Join(t.TempDir(), "kubeconfig")
if err := os.WriteFile(p, []byte(cfg), 0644); err != nil {
t.Fatalf("write kubeconfig: %v", err)
}
return p
}
// installFakeFzf installs a fake `fzf` in a temp bin dir that copies its
// stdin to the file at $KUBECTX_TEST_FZF_STDIN and echoes $KUBECTX_TEST_FZF_OUT
// to stdout. It prepends the dir to PATH. The test reads the recorded file to
// verify the candidate list was piped to fzf stdin.
func installFakeFzf(t *testing.T) {
t.Helper()
if runtime.GOOS == "windows" {
t.Skip("fake fzf shell script unsupported on windows")
}
binDir := t.TempDir()
const script = `#!/bin/sh
cat > "$KUBECTX_TEST_FZF_STDIN"
printf '%s\n' "$KUBECTX_TEST_FZF_OUT"
`
if err := os.WriteFile(filepath.Join(binDir, "fzf"), []byte(script), 0755); err != nil {
t.Fatalf("write fake fzf: %v", err)
}
t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
}
func Test_formatContextList(t *testing.T) {
t.Setenv("KUBECONFIG", writeTestKubeconfig(t))
kc := new(kubeconfig.Kubeconfig).WithLoader(kubeconfig.DefaultLoader)
defer kc.Close()
if err := kc.Parse(); err != nil {
t.Fatalf("parse: %v", err)
}
var out bytes.Buffer
if err := formatContextList(kc, &out); err != nil {
t.Fatalf("formatContextList: %v", err)
}
got := out.String()
lines := strings.Split(strings.TrimRight(got, "\n"), "\n")
if len(lines) != 2 {
t.Fatalf("expected 2 lines, got %d: %q", len(lines), got)
}
for _, l := range lines {
// strip any ANSI color escapes from the active-context line
name := strings.Trim(l, "\x1b[0;m123456789")
if name != "ctx-a" && name != "ctx-b" {
t.Errorf("unexpected line %q", l)
}
}
}
func TestInteractiveSwitchOp_pipesListToFzfStdin(t *testing.T) {
cfg := writeTestKubeconfig(t)
t.Setenv("KUBECONFIG", cfg)
installFakeFzf(t)
stdinRec := filepath.Join(t.TempDir(), "fzf-stdin.txt")
t.Setenv("KUBECTX_TEST_FZF_STDIN", stdinRec)
t.Setenv("KUBECTX_TEST_FZF_OUT", "ctx-b")
var stderr bytes.Buffer
if err := (InteractiveSwitchOp{}).Run(io.Discard, &stderr); err != nil {
t.Fatalf("InteractiveSwitchOp.Run: %v", err)
}
rec, err := os.ReadFile(stdinRec)
if err != nil {
t.Fatalf("read fzf stdin capture: %v", err)
}
if !strings.Contains(string(rec), "ctx-a") || !strings.Contains(string(rec), "ctx-b") {
t.Fatalf("fzf stdin did not contain context list; got %q", string(rec))
}
// switchContext should have switched to ctx-b
kc := new(kubeconfig.Kubeconfig).WithLoader(kubeconfig.DefaultLoader)
defer kc.Close()
if err := kc.Parse(); err != nil {
t.Fatalf("re-parse: %v", err)
}
cur, err := kc.GetCurrentContext()
if err != nil {
t.Fatalf("get current: %v", err)
}
if cur != "ctx-b" {
t.Errorf("current-context = %q, want %q", cur, "ctx-b")
}
}
func TestInteractiveDeleteOp_pipesListToFzfStdin(t *testing.T) {
cfg := writeTestKubeconfig(t)
t.Setenv("KUBECONFIG", cfg)
installFakeFzf(t)
stdinRec := filepath.Join(t.TempDir(), "fzf-stdin.txt")
t.Setenv("KUBECTX_TEST_FZF_STDIN", stdinRec)
t.Setenv("KUBECTX_TEST_FZF_OUT", "ctx-b")
var stderr bytes.Buffer
if err := (InteractiveDeleteOp{}).Run(io.Discard, &stderr); err != nil {
t.Fatalf("InteractiveDeleteOp.Run: %v", err)
}
rec, err := os.ReadFile(stdinRec)
if err != nil {
t.Fatalf("read fzf stdin capture: %v", err)
}
if !strings.Contains(string(rec), "ctx-a") || !strings.Contains(string(rec), "ctx-b") {
t.Fatalf("fzf stdin did not contain context list; got %q", string(rec))
}
// deleteContext should have removed ctx-b
kc := new(kubeconfig.Kubeconfig).WithLoader(kubeconfig.DefaultLoader)
defer kc.Close()
if err := kc.Parse(); err != nil {
t.Fatalf("re-parse: %v", err)
}
names, err := kc.ContextNames()
if err != nil {
t.Fatalf("context names: %v", err)
}
for _, n := range names {
if n == "ctx-b" {
t.Errorf("ctx-b should have been deleted; remaining contexts: %v", names)
}
}
if len(names) != 1 || names[0] != "ctx-a" {
t.Errorf("expected only ctx-a to remain; got %v", names)
}
}

View File

@@ -41,7 +41,14 @@ func (_ ListOp) Run(stdout, stderr io.Writer) error {
}
return fmt.Errorf("kubeconfig error: %w", err)
}
return formatContextList(kc, stdout)
}
// formatContextList writes the sorted, current-context-highlighted list of
// contexts from kc to w (one per line). The format mirrors what ListOp.Run
// prints so that the interactive fzf picker shows the same list the user would
// see from `kubectx | fzf`.
func formatContextList(kc *kubeconfig.Kubeconfig, w io.Writer) error {
ctxs, err := kc.ContextNames()
if err != nil {
return fmt.Errorf("failed to get context names: %w", err)
@@ -57,7 +64,7 @@ func (_ ListOp) Run(stdout, stderr io.Writer) error {
if c == cur {
s = printer.ActiveItemColor.Sprint(c)
}
fmt.Fprintf(stdout, "%s\n", s)
fmt.Fprintf(w, "%s\n", s)
}
return nil
}

View File

@@ -15,9 +15,7 @@ import (
)
// InteractiveReadonlyShellOp launches fzf to pick a context, then starts a readonly shell.
type InteractiveReadonlyShellOp struct {
SelfCmd string
}
type InteractiveReadonlyShellOp struct{}
// ReadonlyShellOp starts a read-only sub-shell for a context.
type ReadonlyShellOp struct {
@@ -25,7 +23,7 @@ type ReadonlyShellOp struct {
}
func (op InteractiveReadonlyShellOp) Run(_, stderr io.Writer) error {
choice, err := fzfPickContext(op.SelfCmd, stderr)
choice, err := fzfPickContext(stderr)
if err != nil || choice == "" {
return err
}

View File

@@ -13,9 +13,7 @@ import (
)
// InteractiveShellOp launches fzf to pick a context, then starts an isolated shell.
type InteractiveShellOp struct {
SelfCmd string
}
type InteractiveShellOp struct{}
// ShellOp indicates intention to start a scoped sub-shell for a context.
type ShellOp struct {
@@ -23,7 +21,7 @@ type ShellOp struct {
}
func (op InteractiveShellOp) Run(_, stderr io.Writer) error {
choice, err := fzfPickContext(op.SelfCmd, stderr)
choice, err := fzfPickContext(stderr)
if err != nil || choice == "" {
return err
}

View File

@@ -115,7 +115,7 @@ func (s *shellSession) run(stderr io.Writer) error {
}
// fzfPickContext launches fzf for interactive context selection.
func fzfPickContext(selfCmd string, stderr io.Writer) (string, error) {
func fzfPickContext(stderr io.Writer) (string, error) {
if err := checkIsolatedMode(); err != nil {
return "", err
}
@@ -138,14 +138,18 @@ func fzfPickContext(selfCmd string, stderr io.Writer) (string, error) {
return "", errors.New("no contexts found in the kubeconfig file")
}
var in bytes.Buffer
if err := formatContextList(kc, &in); err != nil {
return "", err
}
cmd := exec.Command("fzf", "--ansi", "--no-preview")
var out bytes.Buffer
cmd.Stdin = os.Stdin
cmd.Stdin = &in
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

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

@@ -29,9 +29,7 @@ import (
"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 {
@@ -46,22 +44,21 @@ 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)
var in bytes.Buffer
if err := formatNamespaceList(kc, &in); err != nil {
return err
}
if len(ctxNames) == 0 {
return errors.New("no contexts found in the kubeconfig file")
if in.Len() == 0 {
return errors.New("no namespaces found")
}
cmd := exec.Command("fzf", "--ansi", "--no-preview")
var out bytes.Buffer
cmd.Stdin = os.Stdin
cmd.Stdin = &in
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

125
cmd/kubens/fzf_test.go Normal file
View File

@@ -0,0 +1,125 @@
// 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 main
import (
"bytes"
"io"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/ahmetb/kubectx/internal/kubeconfig"
)
func writeTestKubeconfigWithNamespace(t *testing.T) string {
t.Helper()
const cfg = `apiVersion: v1
kind: Config
current-context: ctx-a
contexts:
- name: ctx-a
context: { cluster: cluster-a, user: user-a, namespace: ns1 }
clusters:
- name: cluster-a
cluster: { server: https://example.invalid }
users:
- name: user-a
user: { token: fake }
`
p := filepath.Join(t.TempDir(), "kubeconfig")
if err := os.WriteFile(p, []byte(cfg), 0644); err != nil {
t.Fatalf("write kubeconfig: %v", err)
}
return p
}
func installKubensFakeFzf(t *testing.T) {
t.Helper()
if runtime.GOOS == "windows" {
t.Skip("fake fzf shell script unsupported on windows")
}
binDir := t.TempDir()
const script = `#!/bin/sh
cat > "$KUBECTX_TEST_FZF_STDIN"
printf '%s\n' "$KUBECTX_TEST_FZF_OUT"
`
if err := os.WriteFile(filepath.Join(binDir, "fzf"), []byte(script), 0755); err != nil {
t.Fatalf("write fake fzf: %v", err)
}
t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
}
func Test_formatNamespaceList(t *testing.T) {
t.Setenv("KUBECONFIG", writeTestKubeconfigWithNamespace(t))
t.Setenv("_MOCK_NAMESPACES", "1")
kc := new(kubeconfig.Kubeconfig).WithLoader(kubeconfig.DefaultLoader)
defer kc.Close()
if err := kc.Parse(); err != nil {
t.Fatalf("parse: %v", err)
}
var out bytes.Buffer
if err := formatNamespaceList(kc, &out); err != nil {
t.Fatalf("formatNamespaceList: %v", err)
}
got := out.String()
for _, want := range []string{"ns1", "ns2"} {
if !strings.Contains(got, want) {
t.Errorf("expected list to contain %q; got %q", want, got)
}
}
}
func TestInteractiveSwitchOp_pipesNamespaceListToFzfStdin(t *testing.T) {
t.Setenv("KUBECONFIG", writeTestKubeconfigWithNamespace(t))
t.Setenv("_MOCK_NAMESPACES", "1")
installKubensFakeFzf(t)
stdinRec := filepath.Join(t.TempDir(), "fzf-stdin.txt")
t.Setenv("KUBECTX_TEST_FZF_STDIN", stdinRec)
// pick ns2 (not the current ns1) so we exercise the switch path
t.Setenv("KUBECTX_TEST_FZF_OUT", "ns2")
var stderr bytes.Buffer
if err := (InteractiveSwitchOp{}).Run(io.Discard, &stderr); err != nil {
t.Fatalf("InteractiveSwitchOp.Run: %v", err)
}
rec, err := os.ReadFile(stdinRec)
if err != nil {
t.Fatalf("read fzf stdin capture: %v", err)
}
if !strings.Contains(string(rec), "ns1") || !strings.Contains(string(rec), "ns2") {
t.Fatalf("fzf stdin did not contain namespace list; got %q", string(rec))
}
// switchNamespace should have set ns2 as the active namespace
kc := new(kubeconfig.Kubeconfig).WithLoader(kubeconfig.DefaultLoader)
defer kc.Close()
if err := kc.Parse(); err != nil {
t.Fatalf("re-parse: %v", err)
}
ns, err := kc.NamespaceOfContext("ctx-a")
if err != nil {
t.Fatalf("namespace of context: %v", err)
}
if ns != "ns2" {
t.Errorf("active namespace = %q, want %q", ns, "ns2")
}
}

View File

@@ -40,7 +40,14 @@ func (op ListOp) Run(stdout, stderr io.Writer) error {
if err := kc.Parse(); err != nil {
return fmt.Errorf("kubeconfig error: %w", err)
}
return formatNamespaceList(kc, stdout)
}
// formatNamespaceList writes the sorted, current-namespace-highlighted list
// of namespaces available in the current context to w (one per line). It
// mirrors what ListOp.Run prints so the interactive fzf picker shows the same
// list the user would see from `kubens | fzf`.
func formatNamespaceList(kc *kubeconfig.Kubeconfig, w io.Writer) error {
ctx, err := kc.GetCurrentContext()
if err != nil {
return fmt.Errorf("failed to get current context: %w", err)
@@ -63,7 +70,7 @@ func (op ListOp) Run(stdout, stderr io.Writer) error {
if c == curNs {
s = printer.ActiveItemColor.Sprint(c)
}
fmt.Fprintf(stdout, "%s\n", s)
fmt.Fprintf(w, "%s\n", s)
}
return nil
}