mirror of
https://github.com/ahmetb/kubectx.git
synced 2026-05-15 03:32:02 +00:00
When kubens needs to query the Kubernetes API (e.g. to check if a namespace exists), it builds a REST client from the in-memory kubeconfig bytes using clientcmd.RESTConfigFromKubeConfig(). This function has no knowledge of the kubeconfig file's location on disk, so it cannot resolve relative paths in exec credential plugin commands (e.g. `command: ../scripts/get-token.sh`). This causes a "no such file or directory" error for users whose kubeconfig uses relative paths in exec-based authentication. The fix threads the kubeconfig file path through a new PathHinter optional interface on ReadWriteResetCloser. When a file path is available, newKubernetesClientSet now uses clientcmd.NewNonInteractiveDeferredLoadingClientConfig with ExplicitPath, which resolves relative paths relative to the kubeconfig file's directory — matching kubectl's own behavior. The old bytes-based fallback is preserved for in-memory configs (e.g. tests). Fixes #488 Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
219 lines
5.5 KiB
Go
219 lines
5.5 KiB
Go
// 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
|
|
}
|