Support multiple kubeconfig files (KUBECONFIG=file1:file2:file3) (#489)

Previously, kubectx would error with "multiple files in KUBECONFIG are
currently not supported" when KUBECONFIG contained colon-separated paths.
This is a common setup where users maintain separate kubeconfig files for
different clusters/environments.

This change evolves the internal Kubeconfig struct from holding a single
file to a slice of file entries, matching kubectl's merge semantics:

- Reading current-context: first file with a non-empty value wins
- Writing current-context: always written to the first file
- Listing contexts: merged from all files, first occurrence wins for
  duplicate names
- Modifying a context (delete/rename/set-namespace): written to the
  file that owns that context
- Missing files in the KUBECONFIG list are silently skipped (matching
  kubectl behavior), but permission errors are propagated

The Loader interface already returned []ReadWriteResetCloser, so all
public method signatures remain unchanged — zero modifications needed
in cmd/kubectx/ or cmd/kubens/ callers.

Fixes #485
Fixes #211

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ahmet Alp Balkan
2026-03-23 09:28:37 -07:00
committed by GitHub
parent 8214f23123
commit eb621b4406
12 changed files with 661 additions and 105 deletions

View File

@@ -21,38 +21,38 @@ import (
)
func (k *Kubeconfig) DeleteContextEntry(deleteName string) error {
contexts, err := k.contextsNode()
_, fileIdx, err := k.contextNodeWithFileIndex(deleteName)
if err != nil {
return err
}
if err := contexts.PipeE(
contexts, err := contextsNodeOf(&k.files[fileIdx])
if err != nil {
return err
}
return contexts.PipeE(
yaml.ElementSetter{
Keys: []string{"name"},
Values: []string{deleteName},
},
); err != nil {
return err
}
return nil
)
}
// ModifyCurrentContext always writes to the first file (matching kubectl behavior).
func (k *Kubeconfig) ModifyCurrentContext(name string) error {
if err := k.config.PipeE(yaml.SetField("current-context", yaml.NewScalarRNode(name))); err != nil {
return err
if len(k.files) == 0 {
return errNoFiles
}
return nil
return k.files[0].config.PipeE(yaml.SetField("current-context", yaml.NewScalarRNode(name)))
}
func (k *Kubeconfig) ModifyContextName(old, new string) error {
context, err := k.config.Pipe(yaml.Lookup("contexts", "[name="+old+"]"))
context, _, err := k.contextNodeWithFileIndex(old)
if err != nil {
return err
}
if context == nil {
return errors.New("\"contexts\" entry is nil")
}
if err := context.PipeE(yaml.SetField("name", yaml.NewScalarRNode(new))); err != nil {
return err
}
return nil
return context.PipeE(yaml.SetField("name", yaml.NewScalarRNode(new)))
}

View File

@@ -178,3 +178,97 @@ func TestKubeconfig_ModifyContextName(t *testing.T) {
t.Fatalf("diff: %s", diff)
}
}
func TestKubeconfig_ModifyCurrentContext_MultiFile_WritesToFirst(t *testing.T) {
cfg1 := testutil.KC().WithCurrentCtx("ctx1").WithCtxs(testutil.Ctx("ctx1")).ToYAML(t)
cfg2 := testutil.KC().WithCurrentCtx("ctx2").WithCtxs(testutil.Ctx("ctx2")).ToYAML(t)
tl := WithMockMultiKubeconfigLoader(cfg1, cfg2)
kc := new(Kubeconfig).WithLoader(tl)
if err := kc.Parse(); err != nil {
t.Fatal(err)
}
if err := kc.ModifyCurrentContext("ctx2"); err != nil {
t.Fatal(err)
}
if err := kc.Save(); err != nil {
t.Fatal(err)
}
// First file should have new current-context
out0 := tl.OutputOf(0)
expected0 := testutil.KC().WithCurrentCtx("ctx2").WithCtxs(testutil.Ctx("ctx1")).ToYAML(t)
if diff := cmp.Diff(expected0, out0); diff != "" {
t.Fatalf("file 0 diff: %s", diff)
}
// Second file should be unchanged
out1 := tl.OutputOf(1)
expected1 := testutil.KC().WithCurrentCtx("ctx2").WithCtxs(testutil.Ctx("ctx2")).ToYAML(t)
if diff := cmp.Diff(expected1, out1); diff != "" {
t.Fatalf("file 1 diff: %s", diff)
}
}
func TestKubeconfig_DeleteContextEntry_MultiFile_FromCorrectFile(t *testing.T) {
cfg1 := testutil.KC().WithCtxs(testutil.Ctx("c1"), testutil.Ctx("c2")).ToYAML(t)
cfg2 := testutil.KC().WithCtxs(testutil.Ctx("c3"), testutil.Ctx("c4")).ToYAML(t)
tl := WithMockMultiKubeconfigLoader(cfg1, cfg2)
kc := new(Kubeconfig).WithLoader(tl)
if err := kc.Parse(); err != nil {
t.Fatal(err)
}
// Delete c3 which is in file 2
if err := kc.DeleteContextEntry("c3"); err != nil {
t.Fatal(err)
}
if err := kc.Save(); err != nil {
t.Fatal(err)
}
// First file should be unchanged
out0 := tl.OutputOf(0)
expected0 := testutil.KC().WithCtxs(testutil.Ctx("c1"), testutil.Ctx("c2")).ToYAML(t)
if diff := cmp.Diff(expected0, out0); diff != "" {
t.Fatalf("file 0 diff: %s", diff)
}
// Second file should have c3 removed
out1 := tl.OutputOf(1)
expected1 := testutil.KC().WithCtxs(testutil.Ctx("c4")).ToYAML(t)
if diff := cmp.Diff(expected1, out1); diff != "" {
t.Fatalf("file 1 diff: %s", diff)
}
}
func TestKubeconfig_ModifyContextName_MultiFile_InCorrectFile(t *testing.T) {
cfg1 := testutil.KC().WithCtxs(testutil.Ctx("c1")).ToYAML(t)
cfg2 := testutil.KC().WithCtxs(testutil.Ctx("c2")).ToYAML(t)
tl := WithMockMultiKubeconfigLoader(cfg1, cfg2)
kc := new(Kubeconfig).WithLoader(tl)
if err := kc.Parse(); err != nil {
t.Fatal(err)
}
// Rename c2 (in file 2) to c2-new
if err := kc.ModifyContextName("c2", "c2-new"); err != nil {
t.Fatal(err)
}
if err := kc.Save(); err != nil {
t.Fatal(err)
}
// First file should be unchanged
out0 := tl.OutputOf(0)
expected0 := testutil.KC().WithCtxs(testutil.Ctx("c1")).ToYAML(t)
if diff := cmp.Diff(expected0, out0); diff != "" {
t.Fatalf("file 0 diff: %s", diff)
}
// Second file should have c2 renamed to c2-new
out1 := tl.OutputOf(1)
expected1 := testutil.KC().WithCtxs(testutil.Ctx("c2-new")).ToYAML(t)
if diff := cmp.Diff(expected1, out1); diff != "" {
t.Fatalf("file 1 diff: %s", diff)
}
}

View File

@@ -22,8 +22,8 @@ import (
"sigs.k8s.io/kustomize/kyaml/yaml"
)
func (k *Kubeconfig) contextsNode() (*yaml.RNode, error) {
contexts, err := k.config.Pipe(yaml.Get("contexts"))
func contextsNodeOf(fe *fileEntry) (*yaml.RNode, error) {
contexts, err := fe.config.Pipe(yaml.Get("contexts"))
if err != nil {
return nil, err
}
@@ -35,32 +35,61 @@ func (k *Kubeconfig) contextsNode() (*yaml.RNode, error) {
return contexts, nil
}
// contextNodeWithFileIndex searches for a context by name across all files.
// Returns the context node and the index of the file that contains it.
// Files without a valid "contexts" sequence are skipped, but if errors occur
// during lookup they are included in the final error message.
func (k *Kubeconfig) contextNodeWithFileIndex(name string) (*yaml.RNode, int, error) {
var fileErrors []error
for i := range k.files {
contexts, err := contextsNodeOf(&k.files[i])
if err != nil {
fileErrors = append(fileErrors, fmt.Errorf("file %d: %w", i, err))
continue
}
context, err := contexts.Pipe(yaml.Lookup("[name=" + name + "]"))
if err != nil {
fileErrors = append(fileErrors, fmt.Errorf("file %d lookup: %w", i, err))
continue
}
if context != nil {
return context, i, nil
}
}
if len(fileErrors) > 0 {
return nil, -1, fmt.Errorf("context with name %q not found (errors in files: %w)",
name, errors.Join(fileErrors...))
}
return nil, -1, fmt.Errorf("context with name %q not found", name)
}
func (k *Kubeconfig) contextNode(name string) (*yaml.RNode, error) {
contexts, err := k.contextsNode()
if err != nil {
return nil, err
}
context, err := contexts.Pipe(yaml.Lookup("[name=" + name + "]"))
if err != nil {
return nil, err
}
if context == nil {
return nil, fmt.Errorf("context with name \"%s\" not found", name)
}
return context, nil
node, _, err := k.contextNodeWithFileIndex(name)
return node, err
}
func (k *Kubeconfig) ContextNames() ([]string, error) {
contexts, err := k.config.Pipe(yaml.Get("contexts"))
if err != 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, fmt.Errorf("failed to get context names: %w", err)
seen := make(map[string]bool)
var names []string
for i := range k.files {
contexts, err := k.files[i].config.Pipe(yaml.Get("contexts"))
if err != nil {
return nil, fmt.Errorf("failed to get contexts: %w", err)
}
if contexts == nil {
continue
}
fileNames, err := contexts.ElementValues("name")
if err != nil {
return nil, fmt.Errorf("failed to get context names: %w", err)
}
for _, n := range fileNames {
if !seen[n] {
seen[n] = true
names = append(names, n)
}
}
}
return names, nil
}

View File

@@ -94,3 +94,64 @@ func TestKubeconfig_CheckContextExists(t *testing.T) {
t.Fatal("c3 does not exist; but reported true")
}
}
func TestKubeconfig_ContextNames_MultiFile_Merged(t *testing.T) {
cfg1 := testutil.KC().WithCtxs(testutil.Ctx("a"), testutil.Ctx("b")).ToYAML(t)
cfg2 := testutil.KC().WithCtxs(testutil.Ctx("c"), testutil.Ctx("d")).ToYAML(t)
tl := WithMockMultiKubeconfigLoader(cfg1, cfg2)
kc := new(Kubeconfig).WithLoader(tl)
if err := kc.Parse(); err != nil {
t.Fatal(err)
}
ctx, err := kc.ContextNames()
if err != nil {
t.Fatal(err)
}
expected := []string{"a", "b", "c", "d"}
if diff := cmp.Diff(expected, ctx); diff != "" {
t.Fatalf("%s", diff)
}
}
func TestKubeconfig_ContextNames_MultiFile_DuplicateFirstWins(t *testing.T) {
cfg1 := testutil.KC().WithCtxs(testutil.Ctx("a"), testutil.Ctx("shared")).ToYAML(t)
cfg2 := testutil.KC().WithCtxs(testutil.Ctx("shared"), testutil.Ctx("b")).ToYAML(t)
tl := WithMockMultiKubeconfigLoader(cfg1, cfg2)
kc := new(Kubeconfig).WithLoader(tl)
if err := kc.Parse(); err != nil {
t.Fatal(err)
}
ctx, err := kc.ContextNames()
if err != nil {
t.Fatal(err)
}
// "shared" should appear only once
expected := []string{"a", "shared", "b"}
if diff := cmp.Diff(expected, ctx); diff != "" {
t.Fatalf("%s", diff)
}
}
func TestKubeconfig_ContextExists_MultiFile(t *testing.T) {
cfg1 := testutil.KC().WithCtxs(testutil.Ctx("c1")).ToYAML(t)
cfg2 := testutil.KC().WithCtxs(testutil.Ctx("c2")).ToYAML(t)
tl := WithMockMultiKubeconfigLoader(cfg1, cfg2)
kc := new(Kubeconfig).WithLoader(tl)
if err := kc.Parse(); err != nil {
t.Fatal(err)
}
if exists, err := kc.ContextExists("c1"); err != nil || !exists {
t.Fatal("c1 should exist")
}
if exists, err := kc.ContextExists("c2"); err != nil || !exists {
t.Fatal("c2 should exist (in second file)")
}
if exists, err := kc.ContextExists("c3"); err != nil {
t.Fatal(err)
} else if exists {
t.Fatal("c3 should not exist")
}
}

View File

@@ -20,16 +20,25 @@ import (
"sigs.k8s.io/kustomize/kyaml/yaml"
)
// GetCurrentContext returns "current-context" value in given
// kubeconfig object Node, or returns ("", nil) if not found.
// GetCurrentContext returns "current-context" value from the first file
// that has a non-empty current-context, 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 "", fmt.Errorf("failed to read current-context: %w", err)
for _, fe := range k.files {
v, err := fe.config.Pipe(yaml.Get("current-context"))
if err != nil {
return "", fmt.Errorf("failed to read current-context: %w", err)
}
if s := yaml.GetValue(v); s != "" {
return s, nil
}
}
return yaml.GetValue(v), nil
return "", nil
}
// UnsetCurrentContext clears the current-context field in the first file.
func (k *Kubeconfig) UnsetCurrentContext() error {
return k.config.PipeE(yaml.SetField("current-context", yaml.NewStringRNode("")))
if len(k.files) == 0 {
return errNoFiles
}
return k.files[0].config.PipeE(yaml.SetField("current-context", yaml.NewStringRNode("")))
}

View File

@@ -73,3 +73,68 @@ func TestKubeconfig_UnsetCurrentContext(t *testing.T) {
t.Fatalf("expected=\"%s\"; got=\"%s\"", expected, out)
}
}
func TestKubeconfig_GetCurrentContext_MultiFile_FirstNonEmpty(t *testing.T) {
cfg1 := testutil.KC().WithCtxs(testutil.Ctx("ctx1")).ToYAML(t) // no current-context
cfg2 := testutil.KC().WithCurrentCtx("ctx2").WithCtxs(testutil.Ctx("ctx2")).ToYAML(t)
tl := WithMockMultiKubeconfigLoader(cfg1, cfg2)
kc := new(Kubeconfig).WithLoader(tl)
if err := kc.Parse(); err != nil {
t.Fatal(err)
}
v, err := kc.GetCurrentContext()
if err != nil {
t.Fatal(err)
}
if v != "ctx2" {
t.Fatalf("expected=\"ctx2\"; got=\"%s\"", v)
}
}
func TestKubeconfig_GetCurrentContext_MultiFile_FirstWins(t *testing.T) {
cfg1 := testutil.KC().WithCurrentCtx("ctx1").WithCtxs(testutil.Ctx("ctx1")).ToYAML(t)
cfg2 := testutil.KC().WithCurrentCtx("ctx2").WithCtxs(testutil.Ctx("ctx2")).ToYAML(t)
tl := WithMockMultiKubeconfigLoader(cfg1, cfg2)
kc := new(Kubeconfig).WithLoader(tl)
if err := kc.Parse(); err != nil {
t.Fatal(err)
}
v, err := kc.GetCurrentContext()
if err != nil {
t.Fatal(err)
}
if v != "ctx1" {
t.Fatalf("expected=\"ctx1\"; got=\"%s\"", v)
}
}
func TestKubeconfig_UnsetCurrentContext_MultiFile(t *testing.T) {
cfg1 := testutil.KC().WithCurrentCtx("ctx1").WithCtxs(testutil.Ctx("ctx1")).ToYAML(t)
cfg2 := testutil.KC().WithCurrentCtx("ctx2").WithCtxs(testutil.Ctx("ctx2")).ToYAML(t)
tl := WithMockMultiKubeconfigLoader(cfg1, cfg2)
kc := new(Kubeconfig).WithLoader(tl)
if err := kc.Parse(); err != nil {
t.Fatal(err)
}
if err := kc.UnsetCurrentContext(); err != nil {
t.Fatal(err)
}
if err := kc.Save(); err != nil {
t.Fatal(err)
}
// After unsetting, GetCurrentContext should return ctx2 (from second file)
// Re-parse the saved output to verify
out0 := tl.OutputOf(0)
expected0 := testutil.KC().WithCurrentCtx("").WithCtxs(testutil.Ctx("ctx1")).ToYAML(t)
if out0 != expected0 {
t.Fatalf("file 0: expected=\"%s\"; got=\"%s\"", expected0, out0)
}
// Second file should be unchanged
out1 := tl.OutputOf(1)
expected1 := testutil.KC().WithCurrentCtx("ctx2").WithCtxs(testutil.Ctx("ctx2")).ToYAML(t)
if out1 != expected1 {
t.Fatalf("file 1: expected=\"%s\"; got=\"%s\"", expected1, out1)
}
}

View File

@@ -37,3 +37,39 @@ func (t *MockKubeconfigLoader) Output() string { return t.out.String() }
func WithMockKubeconfigLoader(kubecfg string) *MockKubeconfigLoader {
return &MockKubeconfigLoader{in: strings.NewReader(kubecfg)}
}
// mockFile is a single in-memory kubeconfig file for multi-file testing.
type mockFile struct {
in io.Reader
out bytes.Buffer
}
func (m *mockFile) Read(p []byte) (n int, err error) { return m.in.Read(p) }
func (m *mockFile) Write(p []byte) (n int, err error) { return m.out.Write(p) }
func (m *mockFile) Close() error { return nil }
func (m *mockFile) Reset() error { return nil }
// MockMultiKubeconfigLoader implements Loader for testing with multiple kubeconfig files.
type MockMultiKubeconfigLoader struct {
files []*mockFile
}
func (m *MockMultiKubeconfigLoader) Load() ([]ReadWriteResetCloser, error) {
out := make([]ReadWriteResetCloser, len(m.files))
for i, f := range m.files {
out[i] = f
}
return out, nil
}
func (m *MockMultiKubeconfigLoader) OutputOf(index int) string {
return m.files[index].out.String()
}
func WithMockMultiKubeconfigLoader(configs ...string) *MockMultiKubeconfigLoader {
files := make([]*mockFile, len(configs))
for i, c := range configs {
files[i] = &mockFile{in: strings.NewReader(c)}
}
return &MockMultiKubeconfigLoader{files: files}
}

View File

@@ -33,62 +33,162 @@ type Loader interface {
Load() ([]ReadWriteResetCloser, error)
}
type Kubeconfig struct {
loader Loader
type fileEntry struct {
f ReadWriteResetCloser
config *yaml.RNode
}
type Kubeconfig struct {
loader Loader
files []fileEntry
}
var errNoFiles = errors.New("no kubeconfig files loaded")
func (k *Kubeconfig) WithLoader(l Loader) *Kubeconfig {
k.loader = l
return k
}
func (k *Kubeconfig) Close() error {
if k.f == nil {
return nil
var firstErr error
for _, fe := range k.files {
if err := fe.f.Close(); err != nil && firstErr == nil {
firstErr = err
}
}
return k.f.Close()
return firstErr
}
func (k *Kubeconfig) Parse() error {
files, err := k.loader.Load()
rwcs, err := k.loader.Load()
if err != nil {
return fmt.Errorf("failed to load: %w", err)
}
// TODO since we don't support multiple kubeconfig files at the moment, there's just 1 file
f := files[0]
k.f = f
var v yaml.Node
if err := yaml.NewDecoder(f).Decode(&v); err != nil {
return fmt.Errorf("failed to decode: %w", err)
}
k.config = yaml.NewRNode(&v)
if k.config.YNode().Kind != yaml.MappingNode {
return errors.New("kubeconfig file is not a map document")
k.files = make([]fileEntry, 0, len(rwcs))
for i, f := range rwcs {
var v yaml.Node
if err := yaml.NewDecoder(f).Decode(&v); err != nil {
// Close all file handles on failure to avoid leaks.
for _, rf := range rwcs {
rf.Close()
}
return fmt.Errorf("failed to decode file %d: %w", i, err)
}
rn := yaml.NewRNode(&v)
if rn.YNode().Kind != yaml.MappingNode {
for _, rf := range rwcs {
rf.Close()
}
return fmt.Errorf("kubeconfig file %d is not a map document", i)
}
k.files = append(k.files, fileEntry{f: f, config: rn})
}
return nil
}
func (k *Kubeconfig) Bytes() ([]byte, error) {
str, err := k.config.String()
if len(k.files) == 0 {
return nil, errNoFiles
}
if len(k.files) == 1 {
str, err := k.files[0].config.String()
if err != nil {
return nil, err
}
return []byte(str), nil
}
// Build a merged config for multi-file case.
// Start with a copy of the first file's structure.
merged := k.files[0].config.Copy()
// Merge contexts, clusters, and users from all files (first wins for duplicates).
for _, key := range []string{"contexts", "clusters", "users"} {
mergedSeq, err := mergeSequences(k.files, key)
if err != nil {
return nil, fmt.Errorf("failed to merge %s: %w", key, err)
}
if mergedSeq != nil {
if err := merged.PipeE(yaml.SetField(key, mergedSeq)); err != nil {
return nil, err
}
}
}
// Use the first non-empty current-context.
cur, err := k.GetCurrentContext()
if err != nil {
return nil, fmt.Errorf("failed to get current context for merge: %w", err)
}
if cur != "" {
if err := merged.PipeE(yaml.SetField("current-context", yaml.NewScalarRNode(cur))); err != nil {
return nil, err
}
}
str, err := merged.String()
if err != nil {
return nil, err
}
return []byte(str), nil
}
func (k *Kubeconfig) Save() error {
if err := k.f.Reset(); err != nil {
return fmt.Errorf("failed to reset file: %w", err)
// mergeSequences merges a named sequence field (e.g. "contexts") across multiple files.
// The first occurrence of each entry (by "name" key) wins.
// Files where the field is missing or not a sequence are silently skipped (matching kubectl merge behavior).
func mergeSequences(files []fileEntry, field string) (*yaml.RNode, error) {
seen := make(map[string]bool)
var elements []*yaml.RNode
for _, fe := range files {
seq, err := fe.config.Pipe(yaml.Get(field))
if err != nil || seq == nil {
continue
}
if seq.YNode().Kind != yaml.SequenceNode {
continue
}
for _, elem := range seq.YNode().Content {
rn := yaml.NewRNode(elem)
name, err := rn.Pipe(yaml.Get("name"))
if err != nil || name == nil {
continue
}
n := yaml.GetValue(name)
if n != "" && seen[n] {
continue
}
seen[n] = true
elements = append(elements, rn)
}
}
enc := yaml.NewEncoder(k.f)
enc.SetIndent(0)
if err := enc.Encode(k.config.YNode()); err != nil {
return err
if len(elements) == 0 {
return nil, nil
}
return enc.Close()
seqNode := &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq"}
for _, elem := range elements {
seqNode.Content = append(seqNode.Content, elem.YNode())
}
return yaml.NewRNode(seqNode), nil
}
func (k *Kubeconfig) Save() error {
for i := range k.files {
if err := k.files[i].f.Reset(); err != nil {
return fmt.Errorf("failed to reset file %d: %w", i, err)
}
enc := yaml.NewEncoder(k.files[i].f)
enc.SetIndent(0)
if err := enc.Encode(k.files[i].config.YNode()); err != nil {
return fmt.Errorf("failed to encode file %d: %w", i, err)
}
if err := enc.Close(); err != nil {
return fmt.Errorf("failed to close encoder for file %d: %w", i, err)
}
}
return nil
}

View File

@@ -65,3 +65,47 @@ func TestSave(t *testing.T) {
t.Fatal(diff)
}
}
func TestParse_MultiFile(t *testing.T) {
cfg1 := testutil.KC().WithCurrentCtx("ctx1").WithCtxs(testutil.Ctx("ctx1")).ToYAML(t)
cfg2 := testutil.KC().WithCurrentCtx("ctx2").WithCtxs(testutil.Ctx("ctx2")).ToYAML(t)
tl := WithMockMultiKubeconfigLoader(cfg1, cfg2)
kc := new(Kubeconfig).WithLoader(tl)
defer kc.Close()
if err := kc.Parse(); err != nil {
t.Fatal(err)
}
}
func TestSave_MultiFile(t *testing.T) {
cfg1 := testutil.KC().WithCurrentCtx("ctx1").WithCtxs(testutil.Ctx("ctx1")).ToYAML(t)
cfg2 := testutil.KC().WithCtxs(testutil.Ctx("ctx2")).ToYAML(t)
tl := WithMockMultiKubeconfigLoader(cfg1, cfg2)
kc := new(Kubeconfig).WithLoader(tl)
defer kc.Close()
if err := kc.Parse(); err != nil {
t.Fatal(err)
}
// Modify current-context (writes to first file)
if err := kc.ModifyCurrentContext("ctx2"); err != nil {
t.Fatal(err)
}
if err := kc.Save(); err != nil {
t.Fatal(err)
}
// First file should have updated current-context
out0 := tl.OutputOf(0)
expected0 := testutil.KC().WithCurrentCtx("ctx2").WithCtxs(testutil.Ctx("ctx1")).ToYAML(t)
if diff := cmp.Diff(expected0, out0); diff != "" {
t.Fatalf("file 0 diff: %s", diff)
}
// Second file should be unchanged
out1 := tl.OutputOf(1)
expected1 := testutil.KC().WithCtxs(testutil.Ctx("ctx2")).ToYAML(t)
if diff := cmp.Diff(expected1, out1); diff != "" {
t.Fatalf("file 1 diff: %s", diff)
}
}

View File

@@ -32,21 +32,27 @@ type StandardKubeconfigLoader struct{}
type kubeconfigFile struct{ *os.File }
func (*StandardKubeconfigLoader) Load() ([]ReadWriteResetCloser, error) {
cfgPath, err := kubeconfigPath()
paths, err := kubeconfigPaths()
if err != nil {
return nil, fmt.Errorf("cannot determine kubeconfig path: %w", err)
}
f, err := os.OpenFile(cfgPath, os.O_RDWR, 0)
if err != nil {
if os.IsNotExist(err) {
return nil, fmt.Errorf("kubeconfig file not found: %w", err)
var files []ReadWriteResetCloser
for _, p := range paths {
f, err := os.OpenFile(p, os.O_RDWR, 0)
if err != nil {
if os.IsNotExist(err) {
continue
}
return nil, fmt.Errorf("failed to open file %q: %w", p, err)
}
return nil, fmt.Errorf("failed to open file: %w", err)
files = append(files, &kubeconfigFile{f})
}
// TODO we'll return all kubeconfig files when we start implementing multiple kubeconfig support
return []ReadWriteResetCloser{ReadWriteResetCloser(&kubeconfigFile{f})}, nil
if len(files) == 0 {
return nil, fmt.Errorf("kubeconfig file not found: %w",
&os.PathError{Op: "open", Path: paths[0], Err: os.ErrNotExist})
}
return files, nil
}
func (kf *kubeconfigFile) Reset() error {
@@ -59,21 +65,16 @@ func (kf *kubeconfigFile) Reset() error {
return nil
}
func kubeconfigPath() (string, error) {
func kubeconfigPaths() ([]string, error) {
// KUBECONFIG env var
if v := os.Getenv("KUBECONFIG"); v != "" {
list := filepath.SplitList(v)
if len(list) > 1 {
// TODO KUBECONFIG=file1:file2 currently not supported
return "", errors.New("multiple files in KUBECONFIG are currently not supported")
}
return v, nil
return filepath.SplitList(v), nil
}
// default path
home := cmdutil.HomeDir()
if home == "" {
return "", errors.New("HOME or USERPROFILE environment variable not set")
return nil, errors.New("HOME or USERPROFILE environment variable not set")
}
return filepath.Join(home, ".kube", "config"), nil
return []string{filepath.Join(home, ".kube", "config")}, nil
}

View File

@@ -23,49 +23,52 @@ import (
"github.com/ahmetb/kubectx/internal/cmdutil"
)
func Test_kubeconfigPath(t *testing.T) {
func Test_kubeconfigPaths_default(t *testing.T) {
t.Setenv("HOME", "/x/y/z")
expected := filepath.FromSlash("/x/y/z/.kube/config")
got, err := kubeconfigPath()
got, err := kubeconfigPaths()
if err != nil {
t.Fatal(err)
}
if got != expected {
t.Fatalf("got=%q expected=%q", got, expected)
if len(got) != 1 || got[0] != expected {
t.Fatalf("got=%q expected=[%q]", got, expected)
}
}
func Test_kubeconfigPath_noEnvVars(t *testing.T) {
func Test_kubeconfigPaths_noEnvVars(t *testing.T) {
t.Setenv("XDG_CACHE_HOME", "")
t.Setenv("HOME", "")
t.Setenv("USERPROFILE", "")
_, err := kubeconfigPath()
_, err := kubeconfigPaths()
if err == nil {
t.Fatalf("expected error")
}
}
func Test_kubeconfigPath_envOvveride(t *testing.T) {
func Test_kubeconfigPaths_envSingleFile(t *testing.T) {
t.Setenv("KUBECONFIG", "foo")
v, err := kubeconfigPath()
v, err := kubeconfigPaths()
if err != nil {
t.Fatal(err)
}
if expected := "foo"; v != expected {
t.Fatalf("expected=%q, got=%q", expected, v)
if len(v) != 1 || v[0] != "foo" {
t.Fatalf("expected=[\"foo\"], got=%q", v)
}
}
func Test_kubeconfigPath_envOvverideDoesNotSupportPathSeparator(t *testing.T) {
path := strings.Join([]string{"file1", "file2"}, string(os.PathListSeparator))
func Test_kubeconfigPaths_envMultipleFiles(t *testing.T) {
path := strings.Join([]string{"file1", "file2", "file3"}, string(os.PathListSeparator))
t.Setenv("KUBECONFIG", path)
_, err := kubeconfigPath()
if err == nil {
t.Fatal("expected error")
v, err := kubeconfigPaths()
if err != nil {
t.Fatal(err)
}
if len(v) != 3 || v[0] != "file1" || v[1] != "file2" || v[2] != "file3" {
t.Fatalf("expected=[file1,file2,file3], got=%q", v)
}
}
@@ -80,3 +83,67 @@ func TestStandardKubeconfigLoader_returnsNotFoundErr(t *testing.T) {
t.Fatalf("expected ENOENT error; got=%v", err)
}
}
func TestStandardKubeconfigLoader_multipleFiles_skipsMissing(t *testing.T) {
dir := t.TempDir()
existing := filepath.Join(dir, "config1")
if err := os.WriteFile(existing, []byte("apiVersion: v1\nkind: Config\ncontexts: []\n"), 0644); err != nil {
t.Fatal(err)
}
missing := filepath.Join(dir, "config2")
path := strings.Join([]string{existing, missing}, string(os.PathListSeparator))
t.Setenv("KUBECONFIG", path)
files, err := new(StandardKubeconfigLoader).Load()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(files) != 1 {
t.Fatalf("expected 1 file, got %d", len(files))
}
files[0].Close()
}
func TestStandardKubeconfigLoader_multipleFiles_allMissing(t *testing.T) {
dir := t.TempDir()
path := strings.Join([]string{
filepath.Join(dir, "missing1"),
filepath.Join(dir, "missing2"),
}, string(os.PathListSeparator))
t.Setenv("KUBECONFIG", path)
_, err := new(StandardKubeconfigLoader).Load()
if err == nil {
t.Fatal("expected error when all files missing")
}
if !cmdutil.IsNotFoundErr(err) {
t.Fatalf("expected ENOENT error; got=%v", err)
}
}
func TestStandardKubeconfigLoader_multipleFiles_loadsAll(t *testing.T) {
dir := t.TempDir()
f1 := filepath.Join(dir, "config1")
f2 := filepath.Join(dir, "config2")
if err := os.WriteFile(f1, []byte("apiVersion: v1\nkind: Config\ncontexts: []\n"), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(f2, []byte("apiVersion: v1\nkind: Config\ncontexts: []\n"), 0644); err != nil {
t.Fatal(err)
}
path := strings.Join([]string{f1, f2}, string(os.PathListSeparator))
t.Setenv("KUBECONFIG", path)
files, err := new(StandardKubeconfigLoader).Load()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(files) != 2 {
t.Fatalf("expected 2 files, got %d", len(files))
}
for _, f := range files {
f.Close()
}
}

View File

@@ -92,3 +92,53 @@ func TestKubeconfig_SetNamespace(t *testing.T) {
t.Fatal(diff)
}
}
func TestKubeconfig_NamespaceOfContext_MultiFile(t *testing.T) {
cfg1 := testutil.KC().WithCtxs(testutil.Ctx("c1").Ns("ns1")).ToYAML(t)
cfg2 := testutil.KC().WithCtxs(testutil.Ctx("c2").Ns("ns2")).ToYAML(t)
tl := WithMockMultiKubeconfigLoader(cfg1, cfg2)
kc := new(Kubeconfig).WithLoader(tl)
if err := kc.Parse(); err != nil {
t.Fatal(err)
}
v, err := kc.NamespaceOfContext("c2")
if err != nil {
t.Fatal(err)
}
if v != "ns2" {
t.Fatalf("expected=\"ns2\" got=\"%s\"", v)
}
}
func TestKubeconfig_SetNamespace_MultiFile(t *testing.T) {
cfg1 := testutil.KC().WithCtxs(testutil.Ctx("c1")).ToYAML(t)
cfg2 := testutil.KC().WithCtxs(testutil.Ctx("c2")).ToYAML(t)
tl := WithMockMultiKubeconfigLoader(cfg1, cfg2)
kc := new(Kubeconfig).WithLoader(tl)
if err := kc.Parse(); err != nil {
t.Fatal(err)
}
// Set namespace for c2 which is in second file
if err := kc.SetNamespace("c2", "my-ns"); err != nil {
t.Fatal(err)
}
if err := kc.Save(); err != nil {
t.Fatal(err)
}
// First file should be unchanged
out0 := tl.OutputOf(0)
expected0 := testutil.KC().WithCtxs(testutil.Ctx("c1")).ToYAML(t)
if diff := cmp.Diff(expected0, out0); diff != "" {
t.Fatalf("file 0 diff: %s", diff)
}
// Second file should have namespace set
out1 := tl.OutputOf(1)
expected1 := testutil.KC().WithCtxs(testutil.Ctx("c2").Ns("my-ns")).ToYAML(t)
if diff := cmp.Diff(expected1, out1); diff != "" {
t.Fatalf("file 1 diff: %s", diff)
}
}