diff --git a/pkg/devspace/kubectl/util.go b/pkg/devspace/kubectl/util.go index 9b363a696..553e4ec81 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,33 @@ 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. +// +// 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 { + 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-") { 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)) + }) +}