From 79b593ed55dfc65bbf38f1a2cbd5b230b4821802 Mon Sep 17 00:00:00 2001 From: Ben Mosher Date: Thu, 16 Apr 2026 15:56:03 -0400 Subject: [PATCH 1/3] fix: recover from panic in IsMinikubeKubernetes when kubeconfig extensions are plain strings Some tools (e.g. Teleport) write kubeconfig cluster extensions as raw YAML strings rather than structured objects. When devspace calls runtime.DefaultUnstructuredConverter.ToUnstructured() on such an extension, the k8s apimachinery reflection layer panics instead of returning an error: panic: reflect.Set: value of type string is not assignable to type map[string]interface {} Extract the per-extension check into a helper (isMinikubeExtension) that uses recover() to catch the panic and treat unparseable extensions as non-minikube, allowing the build to proceed normally. --- pkg/devspace/kubectl/util.go | 31 ++++++++++++++++++++++++------- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/pkg/devspace/kubectl/util.go b/pkg/devspace/kubectl/util.go index 9b363a696..b21b7da3e 100644 --- a/pkg/devspace/kubectl/util.go +++ b/pkg/devspace/kubectl/util.go @@ -255,13 +255,8 @@ func IsMinikubeKubernetes(kubeClient Client) bool { if rawConfig, err := kubeClient.ClientConfig().RawConfig(); err == nil { clusters := rawConfig.Clusters[rawConfig.Contexts[rawConfig.CurrentContext].Cluster] for _, extension := range clusters.Extensions { - ext, err := runtime.DefaultUnstructuredConverter.ToUnstructured(extension) - if err == nil { - if provider, ok := ext["provider"].(string); ok { - if provider == minikubeProvider { - return true - } - } + if isMinikubeExtension(extension) { + return true } } } @@ -269,6 +264,28 @@ func IsMinikubeKubernetes(kubeClient Client) bool { return false } +// isMinikubeExtension safely checks whether a kubeconfig cluster extension +// identifies the cluster as a minikube provider. +// +// Some tools (e.g. Teleport) write extension values as plain YAML strings +// rather than structured objects. runtime.DefaultUnstructuredConverter.ToUnstructured +// panics on such values via reflection ("reflect.Set: value of type string is +// not assignable to type map[string]interface {}") rather than returning an +// error, so we recover() and treat unparseable extensions as non-minikube. +func isMinikubeExtension(extension runtime.Object) (result bool) { + defer func() { + if recover() != nil { + result = false + } + }() + ext, err := runtime.DefaultUnstructuredConverter.ToUnstructured(extension) + if err != nil { + return false + } + provider, ok := ext["provider"].(string) + return ok && provider == minikubeProvider +} + // GetKindContext returns the kind cluster name func GetKindContext(context string) string { if !strings.HasPrefix(context, "kind-") { From ce1f4efdd7d80865c10a22ac122b5f0217452267 Mon Sep 17 00:00:00 2001 From: Ben Mosher Date: Thu, 16 Apr 2026 16:02:03 -0400 Subject: [PATCH 2/3] test: add TestIsMinikubeKubernetes covering string-valued extension panic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers the cases for IsMinikubeKubernetes: - nil client / nil ClientConfig - context name match ("minikube") - structured extension with minikube provider - structured extension with a different provider - string-valued extension (e.g. written by Teleport) — must not panic --- pkg/devspace/kubectl/util_test.go | 132 ++++++++++++++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 pkg/devspace/kubectl/util_test.go diff --git a/pkg/devspace/kubectl/util_test.go b/pkg/devspace/kubectl/util_test.go new file mode 100644 index 000000000..cb0764119 --- /dev/null +++ b/pkg/devspace/kubectl/util_test.go @@ -0,0 +1,132 @@ +package kubectl + +import ( + "context" + "io" + "testing" + + "github.com/loft-sh/devspace/pkg/util/kubeconfig" + "gotest.tools/assert" + k8sv1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" +) + +// minimalClient implements kubectl.Client with only the two methods that +// IsMinikubeKubernetes actually calls. All other methods panic if invoked. +type minimalClient struct { + context string + clientConfig clientcmd.ClientConfig +} + +func (c *minimalClient) CurrentContext() string { return c.context } +func (c *minimalClient) ClientConfig() clientcmd.ClientConfig { return c.clientConfig } +func (c *minimalClient) KubeClient() kubernetes.Interface { panic("not implemented") } +func (c *minimalClient) Namespace() string { panic("not implemented") } +func (c *minimalClient) RestConfig() *rest.Config { panic("not implemented") } +func (c *minimalClient) KubeConfigLoader() kubeconfig.Loader { panic("not implemented") } +func (c *minimalClient) IsInCluster() bool { panic("not implemented") } +func (c *minimalClient) CopyFromReader(_ context.Context, _ *k8sv1.Pod, _, _ string, _ io.Reader) error { + panic("not implemented") +} +func (c *minimalClient) Copy(_ context.Context, _ *k8sv1.Pod, _, _, _ string, _ []string) error { + panic("not implemented") +} +func (c *minimalClient) ExecStream(_ context.Context, _ *ExecStreamOptions) error { + panic("not implemented") +} +func (c *minimalClient) ExecBuffered(_ context.Context, _ *k8sv1.Pod, _ string, _ []string, _ io.Reader) ([]byte, []byte, error) { + panic("not implemented") +} +func (c *minimalClient) ExecBufferedCombined(_ context.Context, _ *k8sv1.Pod, _ string, _ []string, _ io.Reader) ([]byte, error) { + panic("not implemented") +} +func (c *minimalClient) GenericRequest(_ context.Context, _ *GenericRequestOptions) (string, error) { + panic("not implemented") +} +func (c *minimalClient) ReadLogs(_ context.Context, _, _, _ string, _ bool, _ *int64) (string, error) { + panic("not implemented") +} +func (c *minimalClient) Logs(_ context.Context, _, _, _ string, _ bool, _ *int64, _ bool) (io.ReadCloser, error) { + panic("not implemented") +} +func (c *minimalClient) EnsureNamespace(_ context.Context, _ string, _ interface{ Debug(args ...interface{}) }) error { + panic("not implemented") +} + +// makeClient builds a test Client whose current context points at a cluster +// with the given extensions map. +func makeClient(contextName string, extensions map[string]runtime.Object) *minimalClient { + apiCfg := clientcmdapi.NewConfig() + apiCfg.Clusters[contextName] = &clientcmdapi.Cluster{ + Server: "https://example.test:6443", + Extensions: extensions, + } + apiCfg.Contexts[contextName] = &clientcmdapi.Context{ + Cluster: contextName, + } + apiCfg.CurrentContext = contextName + + cfg := clientcmd.NewNonInteractiveClientConfig( + *apiCfg, + contextName, + &clientcmd.ConfigOverrides{}, + nil, + ) + return &minimalClient{context: contextName, clientConfig: cfg} +} + +func TestIsMinikubeKubernetes(t *testing.T) { + t.Run("nil client returns false", func(t *testing.T) { + assert.Equal(t, false, IsMinikubeKubernetes(nil)) + }) + + t.Run("nil ClientConfig returns false", func(t *testing.T) { + c := &minimalClient{context: "some-cluster", clientConfig: nil} + assert.Equal(t, false, IsMinikubeKubernetes(c)) + }) + + t.Run("context named 'minikube' returns true", func(t *testing.T) { + c := makeClient(minikubeContext, nil) + assert.Equal(t, true, IsMinikubeKubernetes(c)) + }) + + t.Run("non-minikube context with no extensions returns false", func(t *testing.T) { + c := makeClient("my-cluster", nil) + assert.Equal(t, false, IsMinikubeKubernetes(c)) + }) + + t.Run("cluster extension with minikube provider returns true", func(t *testing.T) { + ext := &runtime.Unknown{ + Raw: []byte(`{"provider":"minikube.sigs.k8s.io"}`), + ContentType: runtime.ContentTypeJSON, + } + c := makeClient("my-cluster", map[string]runtime.Object{minikubeProvider: ext}) + assert.Equal(t, true, IsMinikubeKubernetes(c)) + }) + + t.Run("cluster extension with different provider returns false", func(t *testing.T) { + ext := &runtime.Unknown{ + Raw: []byte(`{"provider":"some-other-provider"}`), + ContentType: runtime.ContentTypeJSON, + } + c := makeClient("my-cluster", map[string]runtime.Object{"some-other-provider": ext}) + assert.Equal(t, false, IsMinikubeKubernetes(c)) + }) + + // Some tools (e.g. Teleport) serialise kubeconfig extensions as plain YAML + // strings rather than structured objects. runtime.ToUnstructured panics on + // these via reflection instead of returning an error. isMinikubeExtension + // must recover gracefully and return false. + t.Run("string-valued extension does not panic and returns false", func(t *testing.T) { + ext := &runtime.Unknown{ + Raw: []byte(`"my-cluster-name"`), // bare JSON string, not an object + ContentType: runtime.ContentTypeJSON, + } + c := makeClient("my-cluster", map[string]runtime.Object{"example.dev/cluster-name": ext}) + assert.Equal(t, false, IsMinikubeKubernetes(c)) + }) +} From 0ce6070ee98fc6eb794160e3a4e9f983ab64568a Mon Sep 17 00:00:00 2001 From: Ben Mosher Date: Thu, 16 Apr 2026 16:28:36 -0400 Subject: [PATCH 3/3] fix: add //go:noinline to isMinikubeExtension to ensure recover() works Without noinline, newer Go versions inline isMinikubeExtension into its caller, which means the deferred recover() loses its own stack frame and cannot catch the panic from ToUnstructured. --- pkg/devspace/kubectl/util.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pkg/devspace/kubectl/util.go b/pkg/devspace/kubectl/util.go index b21b7da3e..553e4ec81 100644 --- a/pkg/devspace/kubectl/util.go +++ b/pkg/devspace/kubectl/util.go @@ -272,6 +272,11 @@ func IsMinikubeKubernetes(kubeClient Client) bool { // panics on such values via reflection ("reflect.Set: value of type string is // not assignable to type map[string]interface {}") rather than returning an // error, so we recover() and treat unparseable extensions as non-minikube. +// +// noinline is required: if the compiler inlines this function into its caller, +// the deferred recover() loses its stack frame and cannot catch the panic. +// +//go:noinline func isMinikubeExtension(extension runtime.Object) (result bool) { defer func() { if recover() != nil {