Compare commits

..

9 Commits

Author SHA1 Message Date
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
Ahmet Alp Balkan
bb9592d770 chore: update goreleaser config and release workflow (#475)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 17:44:31 -07:00
Ahmet Alp Balkan
0d800e1367 fix: improve error handling and resource management in kubeconfig (#476)
This change addresses eight key improvements to the kubectx/kubens codebase:

Resource Management Fixes:
- Fix use-after-close bugs where Kubeconfig was accessed after Close()
- Fix resource leaks on error paths by ensuring defer kc.Close() is called
- Fix YAML encoder not being closed after Encode(), causing buffered data loss

API Design Improvements:
- Change ContextNames() to return ([]string, error) instead of silently returning
  nil on error, making parse failures distinguishable from empty results
- Change GetCurrentContext() to return (string, error) instead of returning ""
  for both "not set" and parse error cases
- Update all 16 call sites across cmd/kubectx and cmd/kubens packages to handle
  the new error returns while preserving backward-compatible behavior

Error Handling:
- Add explicit error handling for printer.Success() calls in 5+ locations
  by prefixing unchecked calls with _ =

Performance:
- Add slice pre-allocation in namespace list pagination using slices.Grow()
  before append loops, reducing allocations when fetching 500+ item batches

All changes maintain backward compatibility for missing kubeconfig keys while
improving error transparency and resource safety.
2026-03-08 17:44:18 -07:00
Ahmet Alp Balkan
860e09775b refactor: modernize Go codebase for Go 1.25 (#473)
Modernize the codebase to use idiomatic Go 1.25 patterns, removing deprecated APIs and reducing external dependencies.

- Replace deprecated `io/ioutil` with `os.ReadFile`, `os.WriteFile`, `os.MkdirTemp`, `os.CreateTemp`
- Replace `interface{}` with `any` (Go 1.18+)
- Remove `github.com/pkg/errors` dependency entirely, using stdlib `fmt.Errorf` with `%w` and `errors.New`
- Use `errors.As()` instead of direct type assertions on error values
- Use `strings.Cut()` for delimiter parsing instead of `strings.Split` + length check
- Use `slices.Contains()` for linear search in `ContextExists()`
- Use `t.Setenv()` and `t.TempDir()` in tests instead of manual env save/restore and temp dir cleanup
- Delete unused `internal/testutil/tempfile.go` helper
- Update GitHub Actions CI and release workflows from Go 1.22 to 1.25

Net result: -70 lines, 1 fewer external dependency.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 17:07:59 -07:00
Ahmet Alp Balkan
189100f2b6 ci: add workflow to warn PRs modifying frozen bash scripts (#474)
Adds a GitHub Actions workflow that comments on PRs modifying the root kubectx or kubens bash scripts
The comment uses a [!WARNING] alert to inform contributors that bash implementations are frozen and to propose changes to the Go implementation instead
Skips the comment if the PR author is ahmetb

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 16:49:55 -07:00
31 changed files with 297 additions and 260 deletions

35
.github/workflows/bash-frozen.yml vendored Normal file
View File

@@ -0,0 +1,35 @@
name: Bash scripts frozen
on:
pull_request:
paths:
- 'kubectx'
- 'kubens'
jobs:
comment:
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- name: Comment on PR if author is not ahmetb
if: github.event.pull_request.user.login != 'ahmetb'
uses: actions/github-script@v7
with:
script: |
const body = [
'> [!WARNING]',
'> **This PR will not be merged.**',
'>',
'> The bash implementation of `kubectx` and `kubens` is **frozen** and is provided only for convenience.',
'> We are not accepting any improvements to the bash scripts.',
'>',
'> Please propose your improvements to the **Go implementation** instead.',
].join('\n');
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: body
});

View File

@@ -24,7 +24,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@master
uses: actions/checkout@v4
- run: git fetch --tags
- name: Setup Go
uses: actions/setup-go@v6

View File

@@ -15,7 +15,7 @@
# limitations under the License.
# This is an example goreleaser.yaml file with some sane defaults.
# Make sure to check the documentation at http://goreleaser.com
# Make sure to check the documentation at https://goreleaser.com
version: 2
before:
@@ -69,11 +69,11 @@ archives:
{{- else -}}v{{- . -}}
{{- end -}}
{{- end -}}
builds:
ids:
- kubectx
format_overrides:
- goos: windows
format: zip
formats: [zip]
files: ["LICENSE"]
- id: kubens-archive
name_template: |-
@@ -89,11 +89,11 @@ archives:
{{- else -}}v{{- . -}}
{{- end -}}
{{- end -}}
builds:
ids:
- kubens
format_overrides:
- goos: windows
format: zip
formats: [zip]
files: ["LICENSE"]
checksum:
name_template: "checksums.txt"
@@ -111,7 +111,7 @@ snapcrafts:
kubens is a tool to switch between Kubernetes namespaces (and configure them for kubectl) easily.
grade: stable
confinement: classic
base: core20
base: core24
apps:
kubectx:
command: kubectx

172
README.md
View File

@@ -72,139 +72,23 @@ names anymore.
## Installation
Stable versions of `kubectx` and `kubens` are small bash scripts that you
can find in this repository.
| Package manager | Command |
|---|---|
| [Homebrew](https://brew.sh/) (macOS & Linux) | `brew install kubectx` |
| [MacPorts](https://www.macports.org) (macOS) | `sudo port install kubectx` |
| apt (Debian/Ubuntu) | `sudo apt install kubectx` |
| pacman (Arch Linux) | `sudo pacman -S kubectx` |
| [Chocolatey](https://chocolatey.org/) (Windows) | `choco install kubens kubectx` |
| [Scoop](https://scoop.sh/) (Windows) | `scoop bucket add main && scoop install main/kubens main/kubectx` |
| [winget](https://learn.microsoft.com/en-us/windows/package-manager/) (Windows) | `winget install --id ahmetb.kubectx && winget install --id ahmetb.kubens` |
| [Krew](https://github.com/kubernetes-sigs/krew/) (kubectl plugin) | `kubectl krew install ctx && kubectl krew install ns` |
Starting with v0.9.0, `kubectx` and `kubens` **are now rewritten in Go**. They
should work the same way (and we'll keep the bash-based implementations around)
but the new features will be added to the new Go programs. Please help us test
this new Go implementation by downloading the binaries from the [**Releases page
&rarr;**](https://github.com/ahmetb/kubectx/releases)
Alternatively, download binaries from the [**Releases page &rarr;**](https://github.com/ahmetb/kubectx/releases) and add them to somewhere in your `PATH`.
**Installation options:**
<details>
<summary>Shell completion scripts</summary>
- [as kubectl plugins (macOS & Linux)](#kubectl-plugins-macos-and-linux)
- [with Homebrew (macOS & Linux)](#homebrew-macos-and-linux)
- [with MacPorts (macOS)](#macports-macos)
- [with apt (Debian)](#apt-debian)
- [with pacman (Arch Linux)](#pacman-arch-linux)
- [with Chocolatey (Windows)](#windows-installation-using-chocolatey)
- [Windows Installation (using Scoop)](#windows-installation-using-scoop)
- [with winget (Windows)](#windows-installation-using-winget)
- [manually (macOS & Linux)](#manual-installation-macos-and-linux)
If you like to add context/namespace information to your shell prompt (`$PS1`),
you can try out [kube-ps1].
[kube-ps1]: https://github.com/jonmosco/kube-ps1
### Kubectl Plugins (macOS and Linux)
You can install and use the [Krew](https://github.com/kubernetes-sigs/krew/) kubectl
plugin manager to get `kubectx` and `kubens`.
**Note:** This will not install the shell completion scripts. If you want them,
*choose another installation method
or install the scripts [manually](#manual-installation-macos-and-linux).
```sh
kubectl krew install ctx
kubectl krew install ns
```
After installing, the tools will be available as `kubectl ctx` and `kubectl ns`.
### Homebrew (macOS and Linux)
If you use [Homebrew](https://brew.sh/) you can install like this:
```sh
brew install kubectx
```
This command will set up bash/zsh/fish completion scripts automatically. Make sure you [configure your shell](https://docs.brew.sh/Shell-Completion) to load completions for installed Homebrew formulas.
### MacPorts (macOS)
If you use [MacPorts](https://www.macports.org) you can install like this:
```sh
sudo port install kubectx
```
### apt (Debian)
``` bash
sudo apt install kubectx
```
Newer versions might be available on repos like
[Debian Buster (testing)](https://packages.debian.org/buster/kubectx),
[Sid (unstable)](https://packages.debian.org/sid/kubectx)
(_if you are unfamiliar with the Debian release process and how to enable
testing/unstable repos, check out the
[Debian Wiki](https://wiki.debian.org/DebianReleases)_):
### pacman (Arch Linux)
Available as official Arch Linux package. Install it via:
```bash
sudo pacman -S kubectx
```
### Windows Installation (using Chocolatey)
Available as packages on [Chocolatey](https://chocolatey.org/why-chocolatey)
```pwsh
choco install kubens kubectx
```
### Windows Installation (using Scoop)
Available as packages on [Scoop](https://scoop.sh/)
```pwsh
scoop bucket add main
scoop install main/kubens main/kubectx
```
### Windows Installation (using winget)
Available as packages on [winget](https://learn.microsoft.com/en-us/windows/package-manager/)
```pwsh
winget install --id ahmetb.kubectx
winget install --id ahmetb.kubens
```
### Manual Installation (macOS and Linux)
Since `kubectx` and `kubens` are written in Bash, you should be able to install
them to any POSIX environment that has Bash installed.
- Download the `kubectx`, and `kubens` scripts.
- Either:
- save them all to somewhere in your `PATH`,
- or save them to a directory, then create symlinks to `kubectx`/`kubens` from
somewhere in your `PATH`, like `/usr/local/bin`
- Make `kubectx` and `kubens` executable (`chmod +x ...`)
Example installation steps:
``` bash
sudo git clone https://github.com/ahmetb/kubectx /opt/kubectx
sudo ln -s /opt/kubectx/kubectx /usr/local/bin/kubectx
sudo ln -s /opt/kubectx/kubens /usr/local/bin/kubens
```
If you also want to have shell completions, pick an installation method for the
[completion scripts](completion/) that fits your system best: [`zsh` with
`antibody`](#completion-scripts-for-zsh-with-antibody), [plain
`zsh`](#completion-scripts-for-plain-zsh),
[`bash`](#completion-scripts-for-bash) or
[`fish`](#completion-scripts-for-fish).
#### Completion scripts for `zsh` with [antibody](https://getantibody.github.io)
#### zsh (with [antibody](https://getantibody.github.io))
Add this line to your [Plugins File](https://getantibody.github.io/usage/) (e.g.
`~/.zsh_plugins.txt`):
@@ -217,9 +101,9 @@ Depending on your setup, you might or might not need to call `compinit` or
`autoload -U compinit && compinit` in your `~/.zshrc` after you load the Plugins
file. If you use [oh-my-zsh](https://github.com/ohmyzsh/ohmyzsh), load the
completions before you load `oh-my-zsh` because `oh-my-zsh` will call
`compinit`.
`compinit`.
#### Completion scripts for plain `zsh`
#### zsh (plain)
The completion scripts have to be in a path that belongs to `$fpath`. Either
link or copy them to an existing folder.
@@ -244,7 +128,7 @@ depending on the `$fpath` of your zsh installation.
In case of errors, calling `compaudit` might help.
#### Completion scripts for `bash`
#### bash
```bash
git clone https://github.com/ahmetb/kubectx.git ~/.kubectx
@@ -259,7 +143,7 @@ export PATH=~/.kubectx:\$PATH
EOF
```
#### Completion scripts for `fish`
#### fish
```fish
mkdir -p ~/.config/fish/completions
@@ -267,6 +151,12 @@ ln -s /opt/kubectx/completion/kubectx.fish ~/.config/fish/completions/
ln -s /opt/kubectx/completion/kubens.fish ~/.config/fish/completions/
```
</details>
> [!NOTE]
> Tip: Show context/namespace in your shell prompt with [oh-my-posh](https://ohmyposh.dev/) or
> simply with [kube-ps1](https://github.com/jonmosco/kube-ps1).
-----
### Interactive mode
@@ -277,12 +167,12 @@ with fuzzy searching, you just need to [install
![kubectx interactive search with fzf](img/kubectx-interactive.gif)
If you have `fzf` installed, but want to opt out of using this feature, set the
environment variable `KUBECTX_IGNORE_FZF=1`.
If you want to keep `fzf` interactive mode but need the default behavior of the
command, you can do it by piping the output to another command (e.g. `kubectx |
cat `).
Caveats:
- If you have `fzf` installed, but want to opt out of using this feature, set the
environment variable `KUBECTX_IGNORE_FZF=1`.
- If you want to keep `fzf` interactive mode but need the default behavior of the
command, you can do it by piping the output to another command (e.g. `kubectx |
cat `).
-----
@@ -306,7 +196,7 @@ Colors in the output can be disabled by setting the
If you liked `kubectx`, you may like my
[`kubectl-aliases`](https://github.com/ahmetb/kubectl-aliases) project, too. I
recommend pairing kubectx and kubens with [fzf](#interactive-mode) and
[kube-ps1].
[kube-ps1](https://github.com/jonmosco/kube-ps1).
#### Stargazers over time

View File

@@ -35,12 +35,14 @@ func (_op CurrentOp) Run(stdout, _ io.Writer) error {
return fmt.Errorf("kubeconfig error: %w", err)
}
v := kc.GetCurrentContext()
v, err := kc.GetCurrentContext()
if err != nil {
return fmt.Errorf("failed to get current context: %w", err)
}
if v == "" {
return errors.New("current-context is not set")
}
_, err := fmt.Fprintln(stdout, v)
if err != nil {
if _, err := fmt.Fprintln(stdout, v); err != nil {
return fmt.Errorf("write error: %w", err)
}
return nil

View File

@@ -44,7 +44,7 @@ func (op DeleteOp) Run(_, stderr io.Writer) error {
selfName())
}
printer.Success(stderr, `Deleted context %s.`, printer.SuccessColor.Sprint(deletedName))
_ = printer.Success(stderr, `Deleted context %s.`, printer.SuccessColor.Sprint(deletedName))
}
return nil
}
@@ -58,7 +58,10 @@ func deleteContext(name string) (deleteName string, wasActiveContext bool, err e
return deleteName, false, fmt.Errorf("kubeconfig error: %w", err)
}
cur := kc.GetCurrentContext()
cur, err := kc.GetCurrentContext()
if err != nil {
return deleteName, false, fmt.Errorf("failed to get current context: %w", err)
}
// resolve "." to a real name
if name == "." {
if cur == "" {
@@ -68,7 +71,11 @@ func deleteContext(name string) (deleteName string, wasActiveContext bool, err e
name = cur
}
if !kc.ContextExists(name) {
exists, err := kc.ContextExists(name)
if err != nil {
return name, false, fmt.Errorf("failed to check context: %w", err)
}
if !exists {
return name, false, errors.New("context does not exist")
}

View File

@@ -43,6 +43,7 @@ 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 {
if cmdutil.IsNotFoundErr(err) {
printer.Warning(stderr, "kubeconfig file not found")
@@ -50,7 +51,14 @@ func (op InteractiveSwitchOp) Run(_, stderr io.Writer) error {
}
return fmt.Errorf("kubeconfig error: %w", err)
}
kc.Close()
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
@@ -75,7 +83,7 @@ func (op InteractiveSwitchOp) Run(_, stderr io.Writer) error {
if err != nil {
return fmt.Errorf("failed to switch context: %w", err)
}
printer.Success(stderr, "Switched to context \"%s\".", printer.SuccessColor.Sprint(name))
_ = printer.Success(stderr, "Switched to context \"%s\".", printer.SuccessColor.Sprint(name))
return nil
}
@@ -85,6 +93,7 @@ func (op InteractiveDeleteOp) 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 {
if cmdutil.IsNotFoundErr(err) {
printer.Warning(stderr, "kubeconfig file not found")
@@ -92,9 +101,12 @@ func (op InteractiveDeleteOp) Run(_, stderr io.Writer) error {
}
return fmt.Errorf("kubeconfig error: %w", err)
}
kc.Close()
if len(kc.ContextNames()) == 0 {
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 config")
}
@@ -129,7 +141,7 @@ func (op InteractiveDeleteOp) Run(_, stderr io.Writer) error {
selfName())
}
printer.Success(stderr, `Deleted context %s.`, printer.SuccessColor.Sprint(name))
_ = printer.Success(stderr, `Deleted context %s.`, printer.SuccessColor.Sprint(name))
return nil
}

View File

@@ -19,6 +19,6 @@ func checkIsolatedMode() error {
return fmt.Errorf("you are in a locked single-context shell, use 'exit' to leave")
}
cur := kc.GetCurrentContext()
cur, _ := kc.GetCurrentContext()
return fmt.Errorf("you are in a locked single-context shell (\"%s\"), use 'exit' to leave", cur)
}

View File

@@ -42,10 +42,16 @@ func (_ ListOp) Run(stdout, stderr io.Writer) error {
return fmt.Errorf("kubeconfig error: %w", err)
}
ctxs := kc.ContextNames()
ctxs, err := kc.ContextNames()
if err != nil {
return fmt.Errorf("failed to get context names: %w", err)
}
natsort.Sort(ctxs)
cur := kc.GetCurrentContext()
cur, err := kc.GetCurrentContext()
if err != nil {
return fmt.Errorf("failed to get current context: %w", err)
}
for _, c := range ctxs {
s := c
if c == cur {

View File

@@ -52,16 +52,27 @@ func (op RenameOp) Run(_, stderr io.Writer) error {
return fmt.Errorf("kubeconfig error: %w", err)
}
cur := kc.GetCurrentContext()
cur, err := kc.GetCurrentContext()
if err != nil {
return fmt.Errorf("failed to get current context: %w", err)
}
if op.Old == "." {
op.Old = cur
}
if !kc.ContextExists(op.Old) {
oldExists, err := kc.ContextExists(op.Old)
if err != nil {
return fmt.Errorf("failed to check context: %w", err)
}
if !oldExists {
return fmt.Errorf("context \"%s\" not found, can't rename it", op.Old)
}
if kc.ContextExists(op.New) {
newExists, err := kc.ContextExists(op.New)
if err != nil {
return fmt.Errorf("failed to check context: %w", err)
}
if newExists {
printer.Warning(stderr, "context \"%s\" exists, overwriting it.", op.New)
if err := kc.DeleteContextEntry(op.New); err != nil {
return fmt.Errorf("failed to delete new context to overwrite it: %w", err)
@@ -79,7 +90,7 @@ func (op RenameOp) Run(_, stderr io.Writer) error {
if err := kc.Save(); err != nil {
return fmt.Errorf("failed to save modified kubeconfig: %w", err)
}
printer.Success(stderr, "Context %s renamed to %s.",
_ = printer.Success(stderr, "Context %s renamed to %s.",
printer.SuccessColor.Sprint(op.Old),
printer.SuccessColor.Sprint(op.New))
return nil

View File

@@ -35,10 +35,17 @@ func (op ShellOp) Run(_, stderr io.Writer) error {
if err := kc.Parse(); err != nil {
return fmt.Errorf("kubeconfig error: %w", err)
}
if !kc.ContextExists(op.Target) {
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 := kc.GetCurrentContext()
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)

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

@@ -61,8 +61,15 @@ func switchContext(name string) (string, error) {
return "", fmt.Errorf("kubeconfig error: %w", err)
}
prev := kc.GetCurrentContext()
if !kc.ContextExists(name) {
prev, err := kc.GetCurrentContext()
if err != nil {
return "", fmt.Errorf("failed to get current context: %w", err)
}
exists, err := kc.ContextExists(name)
if err != nil {
return "", fmt.Errorf("failed to check context: %w", err)
}
if !exists {
return "", fmt.Errorf("no context exists with the name: \"%s\"", name)
}
if err := kc.ModifyCurrentContext(name); err != nil {

View File

@@ -31,7 +31,10 @@ func (c CurrentOp) Run(stdout, _ io.Writer) error {
return fmt.Errorf("kubeconfig error: %w", err)
}
ctx := kc.GetCurrentContext()
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")
}

View File

@@ -37,6 +37,7 @@ type InteractiveSwitchOp struct {
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 {
if cmdutil.IsNotFoundErr(err) {
printer.Warning(stderr, "kubeconfig file not found")
@@ -44,7 +45,14 @@ func (op InteractiveSwitchOp) Run(_, stderr io.Writer) error {
}
return fmt.Errorf("kubeconfig error: %w", err)
}
defer kc.Close()
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
@@ -69,6 +77,6 @@ func (op InteractiveSwitchOp) Run(_, stderr io.Writer) error {
if err != nil {
return fmt.Errorf("failed to switch namespace: %w", err)
}
printer.Success(stderr, "Active namespace is \"%s\".", printer.SuccessColor.Sprint(name))
_ = printer.Success(stderr, "Active namespace is \"%s\".", printer.SuccessColor.Sprint(name))
return nil
}

View File

@@ -20,6 +20,7 @@ import (
"fmt"
"io"
"os"
"slices"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
@@ -39,7 +40,10 @@ func (op ListOp) Run(stdout, stderr io.Writer) error {
return fmt.Errorf("kubeconfig error: %w", err)
}
ctx := kc.GetCurrentContext()
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")
}
@@ -86,6 +90,7 @@ func queryNamespaces(kc *kubeconfig.Kubeconfig) ([]string, error) {
return nil, fmt.Errorf("failed to list namespaces from k8s API: %w", err)
}
next = list.Continue
out = slices.Grow(out, len(list.Items))
for _, it := range list.Items {
out = append(out, it.Name)
}

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

@@ -49,7 +49,10 @@ func (s SwitchOp) Run(_, stderr io.Writer) error {
}
func switchNamespace(kc *kubeconfig.Kubeconfig, ns string, force bool) (string, error) {
ctx := kc.GetCurrentContext()
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")
}

View File

@@ -42,7 +42,10 @@ func (_ UnsetOp) Run(_, stderr io.Writer) error {
}
func clearNamespace(kc *kubeconfig.Kubeconfig) (string, error) {
ctx := kc.GetCurrentContext()
ctx, err := kc.GetCurrentContext()
if err != nil {
return "", fmt.Errorf("failed to get current context: %w", err)
}
ns := "default"
if ctx == "" {
return "", errors.New("current-context is not set")

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

@@ -50,18 +50,25 @@ func (k *Kubeconfig) contextNode(name string) (*yaml.RNode, error) {
return context, nil
}
func (k *Kubeconfig) ContextNames() []string {
func (k *Kubeconfig) ContextNames() ([]string, error) {
contexts, err := k.config.Pipe(yaml.Get("contexts"))
if err != nil {
return nil
return nil, fmt.Errorf("failed to get contexts: %w", err)
}
if contexts == nil {
return nil, nil
}
names, err := contexts.ElementValues("name")
if err != nil {
return nil
return nil, fmt.Errorf("failed to get context names: %w", err)
}
return names
return names, nil
}
func (k *Kubeconfig) ContextExists(name string) bool {
return slices.Contains(k.ContextNames(), name)
func (k *Kubeconfig) ContextExists(name string) (bool, error) {
names, err := k.ContextNames()
if err != nil {
return false, err
}
return slices.Contains(names, name), nil
}

View File

@@ -33,7 +33,10 @@ func TestKubeconfig_ContextNames(t *testing.T) {
t.Fatal(err)
}
ctx := kc.ContextNames()
ctx, err := kc.ContextNames()
if err != nil {
t.Fatal(err)
}
expected := []string{"abc", "def", "ghi"}
if diff := cmp.Diff(expected, ctx); diff != "" {
t.Fatalf("%s", diff)
@@ -46,7 +49,10 @@ func TestKubeconfig_ContextNames_noContextsEntry(t *testing.T) {
if err := kc.Parse(); err != nil {
t.Fatal(err)
}
ctx := kc.ContextNames()
ctx, err := kc.ContextNames()
if err != nil {
t.Fatal(err)
}
var expected []string = nil
if diff := cmp.Diff(expected, ctx); diff != "" {
t.Fatalf("%s", diff)
@@ -59,10 +65,9 @@ func TestKubeconfig_ContextNames_nonArrayContextsEntry(t *testing.T) {
if err := kc.Parse(); err != nil {
t.Fatal(err)
}
ctx := kc.ContextNames()
var expected []string = nil
if diff := cmp.Diff(expected, ctx); diff != "" {
t.Fatalf("%s", diff)
_, err := kc.ContextNames()
if err == nil {
t.Fatal("expected error for non-array contexts entry")
}
}
@@ -77,13 +82,15 @@ func TestKubeconfig_CheckContextExists(t *testing.T) {
t.Fatal(err)
}
if !kc.ContextExists("c1") {
if exists, err := kc.ContextExists("c1"); err != nil || !exists {
t.Fatal("c1 actually exists; reported false")
}
if !kc.ContextExists("c2") {
if exists, err := kc.ContextExists("c2"); err != nil || !exists {
t.Fatal("c2 actually exists; reported false")
}
if kc.ContextExists("c3") {
if exists, err := kc.ContextExists("c3"); err != nil {
t.Fatal(err)
} else if exists {
t.Fatal("c3 does not exist; but reported true")
}
}

View File

@@ -15,17 +15,19 @@
package kubeconfig
import (
"fmt"
"sigs.k8s.io/kustomize/kyaml/yaml"
)
// GetCurrentContext returns "current-context" value in given
// kubeconfig object Node, or returns "" if not found.
func (k *Kubeconfig) GetCurrentContext() string {
// kubeconfig object Node, or returns ("", nil) if not found.
func (k *Kubeconfig) GetCurrentContext() (string, error) {
v, err := k.config.Pipe(yaml.Get("current-context"))
if err != nil {
return ""
return "", fmt.Errorf("failed to read current-context: %w", err)
}
return yaml.GetValue(v)
return yaml.GetValue(v), nil
}
func (k *Kubeconfig) UnsetCurrentContext() error {

View File

@@ -26,7 +26,10 @@ func TestKubeconfig_GetCurrentContext(t *testing.T) {
if err := kc.Parse(); err != nil {
t.Fatal(err)
}
v := kc.GetCurrentContext()
v, err := kc.GetCurrentContext()
if err != nil {
t.Fatal(err)
}
expected := "foo"
if v != expected {
@@ -40,7 +43,10 @@ func TestKubeconfig_GetCurrentContext_missingField(t *testing.T) {
if err := kc.Parse(); err != nil {
t.Fatal(err)
}
v := kc.GetCurrentContext()
v, err := kc.GetCurrentContext()
if err != nil {
t.Fatal(err)
}
expected := ""
if v != expected {

View File

@@ -87,5 +87,8 @@ func (k *Kubeconfig) Save() error {
}
enc := yaml.NewEncoder(k.f)
enc.SetIndent(0)
return enc.Encode(k.config.YNode())
if err := enc.Encode(k.config.YNode()); err != nil {
return err
}
return enc.Close()
}

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)
}
}
}