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>
This commit is contained in:
Ahmet Alp Balkan
2026-03-23 10:18:48 -07:00
committed by GitHub
parent 6780ceed84
commit e4727d38f8
3 changed files with 57 additions and 7 deletions

View File

@@ -25,6 +25,7 @@ import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
_ "k8s.io/client-go/plugin/pkg/client/auth"
"k8s.io/client-go/rest"
"k8s.io/client-go/tools/clientcmd"
"github.com/ahmetb/kubectx/internal/kubeconfig"
@@ -102,11 +103,31 @@ func queryNamespaces(kc *kubeconfig.Kubeconfig) ([]string, error) {
}
func newKubernetesClientSet(kc *kubeconfig.Kubeconfig) (*kubernetes.Clientset, error) {
b, err := kc.Bytes()
if err != nil {
return nil, fmt.Errorf("failed to convert in-memory kubeconfig to yaml: %w", err)
var cfg *rest.Config
var err error
if paths := kc.ConfigPaths(); len(paths) > 0 {
// Load from file paths so that client-go resolves relative paths
// (e.g. in exec credential plugins) relative to the kubeconfig directory.
//
// TODO: This re-reads and re-parses the kubeconfig files from disk via
// client-go, duplicating work already done by our kyaml-based loader.
// A better approach would be to extract the current context/cluster/user
// entries from the already-parsed multi-file kubeconfig and normalize
// relative paths in memory based on which file each entry was read from.
cfg, err = clientcmd.NewNonInteractiveDeferredLoadingClientConfig(
&clientcmd.ClientConfigLoadingRules{Precedence: paths},
&clientcmd.ConfigOverrides{},
).ClientConfig()
} else {
// Fallback for in-memory configs (e.g. tests).
var b []byte
b, err = kc.Bytes()
if err != nil {
return nil, fmt.Errorf("failed to convert in-memory kubeconfig to yaml: %w", err)
}
cfg, err = clientcmd.RESTConfigFromKubeConfig(b)
}
cfg, err := clientcmd.RESTConfigFromKubeConfig(b)
if err != nil {
return nil, fmt.Errorf("failed to initialize config: %w", err)
}

View File

@@ -29,12 +29,20 @@ type ReadWriteResetCloser interface {
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
}
@@ -83,11 +91,27 @@ func (k *Kubeconfig) Parse() error {
}
return fmt.Errorf("kubeconfig file %d is not a map document", i)
}
k.files = append(k.files, fileEntry{f: f, config: rn})
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

View File

@@ -29,7 +29,12 @@ var (
type StandardKubeconfigLoader struct{}
type kubeconfigFile struct{ *os.File }
type kubeconfigFile struct {
*os.File
path string
}
func (kf *kubeconfigFile) Path() string { return kf.path }
func (*StandardKubeconfigLoader) Load() ([]ReadWriteResetCloser, error) {
paths, err := kubeconfigPaths()
@@ -46,7 +51,7 @@ func (*StandardKubeconfigLoader) Load() ([]ReadWriteResetCloser, error) {
}
return nil, fmt.Errorf("failed to open file %q: %w", p, err)
}
files = append(files, &kubeconfigFile{f})
files = append(files, &kubeconfigFile{File: f, path: p})
}
if len(files) == 0 {
return nil, fmt.Errorf("kubeconfig file not found: %w",