Files
kubectx/internal/kubeconfig/kubeconfig.go
Ahmet Alp Balkan e4727d38f8 Fix relative path resolution in exec credential plugins (#490)
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>
2026-03-23 10:18:48 -07:00

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
}