// 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 kubeconfig import ( "errors" "fmt" "io" "sigs.k8s.io/kustomize/kyaml/yaml" ) type ReadWriteResetCloser interface { io.ReadWriteCloser // Reset truncates the file and seeks to the beginning of the file. Reset() error } // PathHinter is optionally implemented by ReadWriteResetCloser to indicate // the file path of the underlying kubeconfig file. This is used to resolve // relative paths (e.g. in exec credential plugins) when building a REST client. type PathHinter interface { Path() string } type Loader interface { Load() ([]ReadWriteResetCloser, error) } type fileEntry struct { f ReadWriteResetCloser path string 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 { var firstErr error for _, fe := range k.files { if err := fe.f.Close(); err != nil && firstErr == nil { firstErr = err } } return firstErr } func (k *Kubeconfig) Parse() error { rwcs, err := k.loader.Load() if err != nil { return fmt.Errorf("failed to load: %w", err) } 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) } var p string if ph, ok := f.(PathHinter); ok { p = ph.Path() } k.files = append(k.files, fileEntry{f: f, path: p, config: rn}) } return nil } // ConfigPaths returns the file paths of all loaded kubeconfig files. // Returns nil if the kubeconfig was not loaded from files (e.g. in tests). func (k *Kubeconfig) ConfigPaths() []string { var paths []string for _, fe := range k.files { if fe.path != "" { paths = append(paths, fe.path) } } return paths } func (k *Kubeconfig) Bytes() ([]byte, error) { 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 } // 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) } } if len(elements) == 0 { return nil, nil } 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 }