Skip to content

Commit 8d183d0

Browse files
committed
Add heuristics to detect minikube cluster
1 parent df2b820 commit 8d183d0

File tree

6 files changed

+394
-44
lines changed

6 files changed

+394
-44
lines changed

pkg/skaffold/build/local/docker_test.go

+10
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,11 @@ package local
1919
import (
2020
"context"
2121
"io/ioutil"
22+
"os/exec"
2223
"path/filepath"
2324
"testing"
2425

26+
"github.com/GoogleContainerTools/skaffold/pkg/skaffold/cluster"
2527
"github.com/GoogleContainerTools/skaffold/pkg/skaffold/config"
2628
"github.com/GoogleContainerTools/skaffold/pkg/skaffold/docker"
2729
"github.com/GoogleContainerTools/skaffold/pkg/skaffold/runner/runcontext"
@@ -82,6 +84,7 @@ func TestDockerCLIBuild(t *testing.T) {
8284
"docker build . --file "+dockerfilePath+" -t tag --force-rm",
8385
test.expectedEnv,
8486
))
87+
t.Override(&cluster.GetClient, func() cluster.Client { return fakeMinikubeClient{} })
8588
t.Override(&util.OSEnviron, func() []string { return []string{"KEY=VALUE"} })
8689
t.Override(&docker.NewAPIClient, func(*runcontext.RunContext) (docker.LocalDaemon, error) {
8790
return docker.NewLocalDaemon(&testutil.FakeAPIClient{}, test.extraEnv, false, nil), nil
@@ -104,3 +107,10 @@ func TestDockerCLIBuild(t *testing.T) {
104107
})
105108
}
106109
}
110+
111+
type fakeMinikubeClient struct{}
112+
113+
func (fakeMinikubeClient) IsMinikube(kubeContext string) bool { return false }
114+
func (fakeMinikubeClient) MinikubeExec(arg ...string) (*exec.Cmd, error) {
115+
return exec.Command("minikube", arg...), nil
116+
}

pkg/skaffold/cluster/minikube.go

+206
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
/*
2+
Copyright 2020 The Skaffold Authors
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package cluster
18+
19+
import (
20+
"encoding/json"
21+
"errors"
22+
"fmt"
23+
"log"
24+
"net/url"
25+
"os"
26+
"os/exec"
27+
28+
"github.com/sirupsen/logrus"
29+
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
30+
"k8s.io/apimachinery/pkg/runtime/schema"
31+
"k8s.io/client-go/rest"
32+
33+
"github.com/GoogleContainerTools/skaffold/pkg/skaffold/constants"
34+
k8s "github.com/GoogleContainerTools/skaffold/pkg/skaffold/kubernetes/client"
35+
"github.com/GoogleContainerTools/skaffold/pkg/skaffold/kubernetes/context"
36+
"github.com/GoogleContainerTools/skaffold/pkg/skaffold/util"
37+
)
38+
39+
var GetClient = getClient
40+
41+
// To override during tests
42+
var (
43+
minikubeBinaryFunc = minikubeBinary
44+
getRestClientConfigFunc = context.GetRestClientConfig
45+
)
46+
47+
type Client interface {
48+
// IsMinikube returns true if the given kubeContext maps to a minikube cluster
49+
IsMinikube(kubeContext string) bool
50+
// MinikubeExec returns the Cmd struct to execute minikube with given arguments
51+
MinikubeExec(arg ...string) (*exec.Cmd, error)
52+
}
53+
54+
type clientImpl struct{}
55+
56+
func getClient() Client {
57+
return clientImpl{}
58+
}
59+
60+
func (clientImpl) IsMinikube(kubeContext string) bool {
61+
// short circuit if context is 'minikube'
62+
if kubeContext == constants.DefaultMinikubeContext {
63+
return true
64+
}
65+
_, err := minikubeBinaryFunc()
66+
if err != nil {
67+
logrus.Debugf("Minikube cluster not detected: %v", err)
68+
return false // minikube binary not found
69+
}
70+
71+
if ok, err := matchNodeLabel(kubeContext); err != nil {
72+
logrus.Debugf("failed to check minikube node labels: %v", err)
73+
} else if ok {
74+
logrus.Debugf("Minikube cluster detected: context %q nodes have minikube labels", kubeContext)
75+
return true
76+
}
77+
78+
if ok, err := matchProfileAndServerURL(kubeContext); err != nil {
79+
logrus.Debugf("failed to match minikube profile: %v", err)
80+
} else if ok {
81+
logrus.Debugf("Minikube cluster detected: context %q has matching profile name or server url", kubeContext)
82+
return true
83+
}
84+
logrus.Debugf("Minikube cluster not detected for context %q", kubeContext)
85+
return false
86+
}
87+
88+
func (clientImpl) MinikubeExec(arg ...string) (*exec.Cmd, error) {
89+
return minikubeExec(arg...)
90+
}
91+
92+
func minikubeExec(arg ...string) (*exec.Cmd, error) {
93+
b, err := minikubeBinaryFunc()
94+
if err != nil {
95+
return nil, fmt.Errorf("getting minikube executable: %w", err)
96+
}
97+
return exec.Command(b, arg...), nil
98+
}
99+
100+
func minikubeBinary() (string, error) {
101+
execName := "minikube"
102+
if found, _ := util.DetectWSL(); found {
103+
execName = "minikube.exe"
104+
}
105+
filename, err := exec.LookPath(execName)
106+
if err != nil {
107+
return "", errors.New("unable to find minikube executable. Please add it to PATH environment variable")
108+
}
109+
if _, err := os.Stat(filename); os.IsNotExist(err) {
110+
return "", fmt.Errorf("unable to find minikube executable. File not found %s", filename)
111+
}
112+
return filename, nil
113+
}
114+
115+
func matchNodeLabel(kubeContext string) (bool, error) {
116+
client, err := k8s.Client()
117+
if err != nil {
118+
return false, fmt.Errorf("getting Kubernetes client: %w", err)
119+
}
120+
opts := v1.ListOptions{
121+
LabelSelector: fmt.Sprintf("minikube.k8s.io/name=%s", kubeContext),
122+
Limit: 100,
123+
}
124+
l, err := client.CoreV1().Nodes().List(opts)
125+
if err != nil {
126+
return false, fmt.Errorf("listing nodes with matching label: %w", err)
127+
}
128+
return l != nil && len(l.Items) > 0, nil
129+
}
130+
131+
// matchProfileAndServerURL checks if kubecontext matches any valid minikube profile
132+
// and for selected drivers if the k8s server url is same as any of the minikube nodes IPs
133+
func matchProfileAndServerURL(kubeContext string) (bool, error) {
134+
config, err := getRestClientConfigFunc()
135+
if err != nil {
136+
return false, fmt.Errorf("getting kubernetes config: %w", err)
137+
}
138+
apiServerURL, _, err := rest.DefaultServerURL(config.Host, config.APIPath, schema.GroupVersion{}, false)
139+
140+
if err != nil {
141+
return false, fmt.Errorf("getting kubernetes server url: %w", err)
142+
}
143+
144+
logrus.Debugf("kubernetes server url: %s", apiServerURL)
145+
146+
ok, err := matchServerURLFor(kubeContext, apiServerURL)
147+
if err != nil {
148+
return false, fmt.Errorf("checking minikube node url: %w", err)
149+
}
150+
return ok, nil
151+
}
152+
153+
func matchServerURLFor(kubeContext string, serverURL *url.URL) (bool, error) {
154+
cmd, err := minikubeExec("profile", "list", "-o", "json")
155+
if err != nil {
156+
return false, fmt.Errorf("executing minikube command: %w", err)
157+
}
158+
159+
out, err := util.RunCmdOut(cmd)
160+
if err != nil {
161+
return false, fmt.Errorf("getting minikube profiles: %w", err)
162+
}
163+
164+
var data data
165+
if err = json.Unmarshal(out, &data); err != nil {
166+
log.Fatal(fmt.Errorf("failed to unmarshal data: %w", err))
167+
}
168+
169+
for _, v := range data.Valid {
170+
if v.Config.Name != kubeContext {
171+
continue
172+
}
173+
174+
if v.Config.Driver != "hyperkit" && v.Config.Driver != "virtualbox" {
175+
// Since node IPs don't match server API for other drivers we assume profile name match is enough.
176+
// TODO: Revisit once https://github.com/kubernetes/minikube/issues/6642 is fixed
177+
return true, nil
178+
}
179+
for _, n := range v.Config.Nodes {
180+
if serverURL.Host == fmt.Sprintf("%s:%d", n.IP, n.Port) {
181+
return true, nil
182+
}
183+
}
184+
}
185+
return false, nil
186+
}
187+
188+
type data struct {
189+
Valid []profile `json:"valid,omitempty"`
190+
Invalid []profile `json:"invalid,omitempty"`
191+
}
192+
193+
type profile struct {
194+
Config config
195+
}
196+
197+
type config struct {
198+
Name string
199+
Driver string
200+
Nodes []node
201+
}
202+
203+
type node struct {
204+
IP string
205+
Port int32
206+
}

pkg/skaffold/cluster/minikube_test.go

+141
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
/*
2+
Copyright 2020 The Skaffold Authors
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package cluster
18+
19+
import (
20+
"fmt"
21+
"testing"
22+
23+
v1 "k8s.io/api/core/v1"
24+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
25+
"k8s.io/client-go/kubernetes"
26+
fakeclient "k8s.io/client-go/kubernetes/fake"
27+
"k8s.io/client-go/rest"
28+
29+
kubernetesclient "github.com/GoogleContainerTools/skaffold/pkg/skaffold/kubernetes/client"
30+
"github.com/GoogleContainerTools/skaffold/pkg/skaffold/util"
31+
"github.com/GoogleContainerTools/skaffold/testutil"
32+
)
33+
34+
func TestClientImpl_IsMinikube(t *testing.T) {
35+
tests := []struct {
36+
description string
37+
kubeContext string
38+
minikubeLabels map[string]string
39+
config rest.Config
40+
minikubeProfileCmd util.Command
41+
minikubeNotInPath bool
42+
expected bool
43+
}{
44+
{
45+
description: "context is 'minikube'",
46+
kubeContext: "minikube",
47+
expected: true,
48+
},
49+
{
50+
description: "'minikube' binary not found",
51+
kubeContext: "test-cluster",
52+
minikubeNotInPath: true,
53+
expected: false,
54+
},
55+
{
56+
description: "minikube label found on cluster node",
57+
kubeContext: "test-cluster",
58+
minikubeLabels: map[string]string{"minikube.k8s.io/name": "test-cluster"},
59+
expected: true,
60+
},
61+
{
62+
description: "minikube profile name with docker driver matches kubeContext",
63+
kubeContext: "test-cluster",
64+
config: rest.Config{
65+
Host: "127.0.0.1:32768",
66+
},
67+
minikubeProfileCmd: testutil.CmdRunOut("minikube profile list -o json", fmt.Sprintf(profileStr, "test-cluster", "docker", "172.17.0.3", 8443)),
68+
expected: true,
69+
},
70+
{
71+
description: "minikube profile name with hyperkit driver node ip matches api server url",
72+
kubeContext: "test-cluster",
73+
config: rest.Config{
74+
Host: "192.168.64.10:8443",
75+
},
76+
minikubeProfileCmd: testutil.CmdRunOut("minikube profile list -o json", fmt.Sprintf(profileStr, "test-cluster", "hyperkit", "192.168.64.10", 8443)),
77+
expected: true,
78+
},
79+
{
80+
description: "minikube profile name different from kubeContext",
81+
kubeContext: "test-cluster",
82+
config: rest.Config{
83+
Host: "127.0.0.1:32768",
84+
},
85+
minikubeProfileCmd: testutil.CmdRunOut("minikube profile list -o json", fmt.Sprintf(profileStr, "test-cluster2", "docker", "172.17.0.3", 8443)),
86+
expected: false,
87+
},
88+
{
89+
description: "minikube with hyperkit driver node ip different from api server url",
90+
kubeContext: "test-cluster",
91+
config: rest.Config{
92+
Host: "192.168.64.10:8443",
93+
},
94+
minikubeProfileCmd: testutil.CmdRunOut("minikube profile list -o json", fmt.Sprintf(profileStr, "test-cluster", "hyperkit", "192.168.64.11", 8443)),
95+
expected: false,
96+
},
97+
}
98+
99+
for _, test := range tests {
100+
testutil.Run(t, test.description, func(t *testutil.T) {
101+
dep := &v1.Node{
102+
TypeMeta: metav1.TypeMeta{
103+
APIVersion: "meta/v1",
104+
Kind: "Node",
105+
},
106+
ObjectMeta: metav1.ObjectMeta{
107+
Name: test.kubeContext,
108+
Labels: test.minikubeLabels,
109+
},
110+
}
111+
// Mock Kubernetes
112+
client := fakeclient.NewSimpleClientset(dep)
113+
client.Resources = append(client.Resources, &metav1.APIResourceList{
114+
GroupVersion: dep.APIVersion,
115+
APIResources: []metav1.APIResource{{
116+
Kind: dep.Kind,
117+
Name: "nodes",
118+
}},
119+
})
120+
if test.minikubeNotInPath {
121+
t.Override(&minikubeBinaryFunc, func() (string, error) { return "", fmt.Errorf("minikube not in PATH") })
122+
} else {
123+
t.Override(&minikubeBinaryFunc, func() (string, error) { return "minikube", nil })
124+
}
125+
t.Override(&util.DefaultExecCommand, test.minikubeProfileCmd)
126+
t.Override(&kubernetesclient.Client, mockClient(client))
127+
t.Override(&getRestClientConfigFunc, func() (*rest.Config, error) { return &test.config, nil })
128+
129+
ok := GetClient().IsMinikube(test.kubeContext)
130+
t.CheckDeepEqual(test.expected, ok)
131+
})
132+
}
133+
}
134+
135+
func mockClient(m kubernetes.Interface) func() (kubernetes.Interface, error) {
136+
return func() (kubernetes.Interface, error) {
137+
return m, nil
138+
}
139+
}
140+
141+
var profileStr = `{"invalid": [],"valid": [{"Name": "minikube","Status": "Stopped","Config": {"Name": "%s","Driver": "%s","Nodes": [{"Name": "","IP": "%s","Port": %d}]}}]}`

0 commit comments

Comments
 (0)