From ff863f4f1c105fb3604ef24f19e99be795732c96 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 15 May 2026 15:42:54 +0000 Subject: [PATCH] fix: use 2-space YAML indent in Save() to match kubectl's kubeconfig format enc.SetIndent(0) in go.yaml.in/yaml/v3 does not produce zero indentation; instead, it falls back to the library default of 4 spaces. This caused kubectx/kubens to rewrite the entire kubeconfig with 4-space indentation on every save, drastically changing the file format from the standard 2-space format that kubectl produces. The unexpected whole-file reformatting triggered kubeconfig management tools (Docker Desktop, Rancher Desktop, corporate k8s auth helpers, etc.) that watch ~/.kube/config via FSEvents and regenerate it from their source-of-truth when they detect format changes -- discarding the namespace or context change that was just written. Changing to enc.SetIndent(2) matches kubectl's standard output format and minimises the diff on each save to only the field(s) actually changed. Fixes #499. https://claude.ai/code/session_01DfqecMkM2DGSMQU3ji1hJL --- internal/kubeconfig/kubeconfig.go | 2 +- .../kubeconfig/multifile_scenario_test.go | 120 ++++++++++++++++++ internal/testutil/kubeconfigbuilder.go | 2 +- 3 files changed, 122 insertions(+), 2 deletions(-) create mode 100644 internal/kubeconfig/multifile_scenario_test.go diff --git a/internal/kubeconfig/kubeconfig.go b/internal/kubeconfig/kubeconfig.go index 275167d..bf83fa9 100644 --- a/internal/kubeconfig/kubeconfig.go +++ b/internal/kubeconfig/kubeconfig.go @@ -206,7 +206,7 @@ func (k *Kubeconfig) Save() error { return fmt.Errorf("failed to reset file %d: %w", i, err) } enc := yaml.NewEncoder(k.files[i].f) - enc.SetIndent(0) + enc.SetIndent(2) if err := enc.Encode(k.files[i].config.YNode()); err != nil { return fmt.Errorf("failed to encode file %d: %w", i, err) } diff --git a/internal/kubeconfig/multifile_scenario_test.go b/internal/kubeconfig/multifile_scenario_test.go new file mode 100644 index 0000000..3f55d03 --- /dev/null +++ b/internal/kubeconfig/multifile_scenario_test.go @@ -0,0 +1,120 @@ +package kubeconfig + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// TestSetNamespace_MultiFileScenario tests the scenario where current-context is in +// file1 but the actual context definition is in file2. +func TestSetNamespace_MultiFileScenario(t *testing.T) { + dir := t.TempDir() + f1 := filepath.Join(dir, "config1") + f2 := filepath.Join(dir, "config2") + + // File 1: has current-context but the context definition is in file 2 + os.WriteFile(f1, []byte(`apiVersion: v1 +kind: Config +current-context: ctx-in-file2 +contexts: [] +clusters: [] +users: [] +`), 0644) + + // File 2: has the actual context definition + os.WriteFile(f2, []byte(`apiVersion: v1 +kind: Config +contexts: +- context: + cluster: my-cluster + user: my-user + name: ctx-in-file2 +clusters: +- cluster: + server: https://test + name: my-cluster +users: +- name: my-user + user: {} +`), 0644) + + t.Setenv("KUBECONFIG", f1+":"+f2) + + kc := new(Kubeconfig).WithLoader(DefaultLoader) + defer kc.Close() + if err := kc.Parse(); err != nil { + t.Fatal(err) + } + + cur, err := kc.GetCurrentContext() + if err != nil { + t.Fatal(err) + } + t.Logf("Current context: %s", cur) + + if err := kc.SetNamespace("ctx-in-file2", "my-namespace"); err != nil { + t.Fatalf("SetNamespace failed: %v", err) + } + + if err := kc.Save(); err != nil { + t.Fatalf("Save failed: %v", err) + } + + c1, _ := os.ReadFile(f1) + c2, _ := os.ReadFile(f2) + t.Logf("File1 after save:\n%s", c1) + t.Logf("File2 after save:\n%s", c2) + + if !strings.Contains(string(c2), "namespace: my-namespace") { + t.Errorf("FAIL: namespace NOT saved to file2. File2:\n%s", c2) + } +} + +// TestSetNamespace_CurrentContextInFile2 tests when the current-context is defined +// in file2 (where kubectl logs "Config loaded from file: /path/to/config" for multiple files, +// it shows the first file, potentially misleading). +func TestSetNamespace_AllInSameFile(t *testing.T) { + dir := t.TempDir() + f1 := filepath.Join(dir, "config1") + + os.WriteFile(f1, []byte(`apiVersion: v1 +kind: Config +current-context: my-ctx +contexts: +- context: + cluster: my-cluster + user: my-user + name: my-ctx +clusters: +- cluster: + server: https://test + name: my-cluster +users: +- name: my-user + user: {} +`), 0644) + + t.Setenv("KUBECONFIG", f1) + + kc := new(Kubeconfig).WithLoader(DefaultLoader) + defer kc.Close() + if err := kc.Parse(); err != nil { + t.Fatal(err) + } + + if err := kc.SetNamespace("my-ctx", "my-namespace"); err != nil { + t.Fatalf("SetNamespace failed: %v", err) + } + if err := kc.Save(); err != nil { + t.Fatalf("Save failed: %v", err) + } + + content, _ := os.ReadFile(f1) + t.Logf("File after save:\n%s", content) + + if !strings.Contains(string(content), "namespace: my-namespace") { + t.Errorf("FAIL: namespace NOT in file. Content:\n%s", content) + } +} diff --git a/internal/testutil/kubeconfigbuilder.go b/internal/testutil/kubeconfigbuilder.go index 3504985..946548b 100644 --- a/internal/testutil/kubeconfigbuilder.go +++ b/internal/testutil/kubeconfigbuilder.go @@ -47,7 +47,7 @@ func (k *Kubeconfig) ToYAML(t *testing.T) string { t.Helper() var v strings.Builder enc := yaml.NewEncoder(&v) - enc.SetIndent(0) + enc.SetIndent(2) if err := enc.Encode(*k); err != nil { t.Fatalf("failed to encode mock kubeconfig: %v", err) }