Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use "kubectl proxy" instead of a NodePort to expose the dashboard. #3210

Merged
merged 16 commits into from
Oct 4, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 92 additions & 22 deletions cmd/minikube/cmd/dashboard.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,68 +17,138 @@ limitations under the License.
package cmd

import (
"bufio"
"fmt"
"net/http"
"os"
"text/template"
"os/exec"
"regexp"
"time"

"github.com/golang/glog"
"github.com/pkg/browser"
"github.com/pkg/errors"
"github.com/spf13/cobra"
"k8s.io/minikube/pkg/minikube/cluster"
"k8s.io/minikube/pkg/minikube/config"
"k8s.io/minikube/pkg/minikube/machine"
"k8s.io/minikube/pkg/minikube/service"

commonutil "k8s.io/minikube/pkg/util"
"k8s.io/minikube/pkg/util"
)

var (
dashboardURLMode bool
// Matches: 127.0.0.1:8001
// TODO(tstromberg): Get kubectl to implement a stable supported output format.
hostPortRe = regexp.MustCompile(`127.0.0.1:\d{4,}`)
)

// dashboardCmd represents the dashboard command
var dashboardCmd = &cobra.Command{
Use: "dashboard",
Short: "Opens/displays the kubernetes dashboard URL for your local cluster",
Long: `Opens/displays the kubernetes dashboard URL for your local cluster`,
Short: "Access the kubernetes dashboard running within the minikube cluster",
Long: `Access the kubernetes dashboard running within the minikube cluster`,
Run: func(cmd *cobra.Command, args []string) {
api, err := machine.NewAPIClient()
defer func() {
err := api.Close()
if err != nil {
glog.Warningf("Failed to close API: %v", err)
}
}()

if err != nil {
fmt.Fprintf(os.Stderr, "Error getting client: %s\n", err)
fmt.Fprintf(os.Stderr, "Error creating client: %v\n", err)
os.Exit(1)
}
defer api.Close()

cluster.EnsureMinikubeRunningOrExit(api, 1)
namespace := "kube-system"
svc := "kubernetes-dashboard"

if err = commonutil.RetryAfter(20, func() error { return service.CheckService(namespace, svc) }, 6*time.Second); err != nil {
fmt.Fprintf(os.Stderr, "Could not find finalized endpoint being pointed to by %s: %s\n", svc, err)
ns := "kube-system"
svc := "kubernetes-dashboard"
if err = util.RetryAfter(30, func() error { return service.CheckService(ns, svc) }, 1*time.Second); err != nil {
fmt.Fprintf(os.Stderr, "%s:%s is not running: %v\n", ns, svc, err)
os.Exit(1)
}

urls, err := service.GetServiceURLsForService(api, namespace, svc, template.Must(template.New("dashboardServiceFormat").Parse(defaultServiceFormatTemplate)))
p, hostPort, err := kubectlProxy()
if err != nil {
fmt.Fprintln(os.Stderr, err)
fmt.Fprintln(os.Stderr, "Check that minikube is running.")
os.Exit(1)
glog.Fatalf("kubectl proxy: %v", err)
}
if len(urls) == 0 {
errMsg := "There appears to be no url associated with dashboard, this is not expected, exiting"
glog.Infoln(errMsg)
url := dashboardURL(hostPort, ns, svc)

if err = util.RetryAfter(60, func() error { return checkURL(url) }, 1*time.Second); err != nil {
fmt.Fprintf(os.Stderr, "%s is not responding properly: %v\n", url, err)
os.Exit(1)
}

if dashboardURLMode {
fmt.Fprintln(os.Stdout, urls[0])
fmt.Fprintln(os.Stdout, url)
} else {
fmt.Fprintln(os.Stdout, "Opening kubernetes dashboard in default browser...")
browser.OpenURL(urls[0])
fmt.Fprintln(os.Stdout, fmt.Sprintf("Opening %s in your default browser...", url))
if err = browser.OpenURL(url); err != nil {
fmt.Fprintf(os.Stderr, fmt.Sprintf("failed to open browser: %v", err))
}
}

glog.Infof("Waiting forever for kubectl proxy to exit ...")
tstromberg marked this conversation as resolved.
Show resolved Hide resolved
if err = p.Wait(); err != nil {
tstromberg marked this conversation as resolved.
Show resolved Hide resolved
glog.Errorf("Wait: %v", err)
}
},
}

// kubectlProxy runs "kubectl proxy", returning host:port
func kubectlProxy() (*exec.Cmd, string, error) {
path, err := exec.LookPath("kubectl")
tstromberg marked this conversation as resolved.
Show resolved Hide resolved
if err != nil {
return nil, "", errors.Wrap(err, "kubectl not found in PATH")
}

// port=0 picks a random system port
// config.GetMachineName() respects the -p (profile) flag
cmd := exec.Command(path, "--context", config.GetMachineName(), "proxy", "--port=0")
stdoutPipe, err := cmd.StdoutPipe()
if err != nil {
return nil, "", errors.Wrap(err, "cmd stdout")
}

glog.Infof("Executing: %s %s", cmd.Path, cmd.Args)
if err := cmd.Start(); err != nil {
return nil, "", errors.Wrap(err, "proxy start")
}
reader := bufio.NewReader(stdoutPipe)
glog.Infof("proxy started, reading stdout pipe ...")
out, err := reader.ReadString('\n')
if err != nil {
return nil, "", errors.Wrap(err, "reading stdout pipe")
}
glog.Infof("proxy stdout: %s", out)
return cmd, hostPortRe.FindString(out), nil
}

// dashboardURL generates a URL for accessing the dashboard service
func dashboardURL(proxy string, ns string, svc string) string {
// Reference: https://github.com/kubernetes/dashboard/wiki/Accessing-Dashboard---1.7.X-and-above
return fmt.Sprintf("http://%s/api/v1/namespaces/%s/services/http:%s:/proxy/", proxy, ns, svc)
}

// checkURL checks if a URL returns 200 HTTP OK
func checkURL(url string) error {
resp, err := http.Get(url)
glog.Infof("%s response: %v %+v", url, err, resp)
if err != nil {
return errors.Wrap(err, "checkURL")
}
if resp.StatusCode != http.StatusOK {
return &util.RetriableError{
Err: fmt.Errorf("unexpected response code: %d", resp.StatusCode),
}
}
return nil
}

func init() {
dashboardCmd.Flags().BoolVar(&dashboardURLMode, "url", false, "Display the kubernetes dashboard in the CLI instead of opening it in the default browser")
dashboardCmd.Flags().BoolVar(&dashboardURLMode, "url", false, "Display dashboard URL instead of opening a browser")
RootCmd.AddCommand(dashboardCmd)
}
2 changes: 0 additions & 2 deletions deploy/addons/dashboard/dashboard-svc.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,8 @@ metadata:
kubernetes.io/minikube-addons: dashboard
kubernetes.io/minikube-addons-endpoint: dashboard
spec:
type: NodePort
ports:
- port: 80
targetPort: 9090
nodePort: 30000
selector:
app: kubernetes-dashboard
40 changes: 10 additions & 30 deletions pkg/minikube/service/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import (
"time"

"github.com/docker/machine/libmachine"
"github.com/golang/glog"

"github.com/pkg/browser"
"github.com/pkg/errors"
"k8s.io/api/core/v1"
Expand Down Expand Up @@ -189,45 +191,23 @@ func printURLsForService(c corev1.CoreV1Interface, ip, service, namespace string
return urls, nil
}

// CheckService waits for the specified service to be ready by returning an error until the service is up
// The check is done by polling the endpoint associated with the service and when the endpoint exists, returning no error->service-online
// CheckService checks if a service is listening on a port.
func CheckService(namespace string, service string) error {
client, err := K8s.GetCoreClient()
if err != nil {
return errors.Wrap(err, "Error getting kubernetes client")
}
services := client.Services(namespace)
err = validateService(services, service)
if err != nil {
return errors.Wrap(err, "Error validating service")
}
endpoints := client.Endpoints(namespace)
return checkEndpointReady(endpoints, service)
}

func validateService(s corev1.ServiceInterface, service string) error {
if _, err := s.Get(service, metav1.GetOptions{}); err != nil {
return errors.Wrapf(err, "Error getting service %s", service)
}
return nil
}

func checkEndpointReady(endpoints corev1.EndpointsInterface, service string) error {
endpoint, err := endpoints.Get(service, metav1.GetOptions{})
svc, err := client.Services(namespace).Get(service, metav1.GetOptions{})
if err != nil {
return &util.RetriableError{Err: errors.Errorf("Error getting endpoints for service %s", service)}
}
const notReadyMsg = "Waiting, endpoint for service is not ready yet...\n"
if len(endpoint.Subsets) == 0 {
fmt.Fprintf(os.Stderr, notReadyMsg)
return &util.RetriableError{Err: errors.New("Endpoint for service is not ready yet")}
}
for _, subset := range endpoint.Subsets {
if len(subset.Addresses) == 0 {
fmt.Fprintf(os.Stderr, notReadyMsg)
return &util.RetriableError{Err: errors.New("No endpoints for service are ready yet")}
return &util.RetriableError{
Err: errors.Wrapf(err, "Error getting service %s", service),
}
}
if len(svc.Spec.Ports) == 0 {
return fmt.Errorf("%s:%s has no ports", namespace, service)
}
glog.Infof("Found service: %+v", svc)
return nil
}

Expand Down
38 changes: 0 additions & 38 deletions pkg/minikube/service/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,44 +133,6 @@ func (e MockEndpointsInterface) Get(name string, _ metav1.GetOptions) (*v1.Endpo
return endpoint, nil
}

func TestCheckEndpointReady(t *testing.T) {
var tests = []struct {
description string
service string
err bool
}{
{
description: "Endpoint with no subsets should return an error",
service: "no-subsets",
err: true,
},
{
description: "Endpoint with no ready endpoints should return an error",
service: "not-ready",
err: true,
},
{
description: "Endpoint with at least one ready endpoint should not return an error",
service: "one-ready",
err: false,
},
}

for _, test := range tests {
test := test
t.Run(test.description, func(t *testing.T) {
t.Parallel()
err := checkEndpointReady(&MockEndpointsInterface{}, test.service)
if err != nil && !test.err {
t.Errorf("Check endpoints returned an error: %+v", err)
}
if err == nil && test.err {
t.Errorf("Check endpoints should have returned an error but returned nil")
}
})
}
}

type MockServiceInterface struct {
fake.FakeServices
ServiceList *v1.ServiceList
Expand Down
3 changes: 3 additions & 0 deletions pkg/util/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,14 +97,17 @@ func Retry(attempts int, callback func() error) (err error) {
func RetryAfter(attempts int, callback func() error, d time.Duration) (err error) {
m := MultiError{}
for i := 0; i < attempts; i++ {
glog.V(1).Infof("retry loop %d", i)
err = callback()
if err == nil {
return nil
}
m.Collect(err)
if _, ok := err.(*RetriableError); !ok {
glog.Infof("non-retriable error: %v", err)
return m.ToError()
}
glog.V(2).Infof("sleeping %s", d)
time.Sleep(d)
}
return m.ToError()
Expand Down
50 changes: 31 additions & 19 deletions test/integration/addons_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,10 @@ limitations under the License.
package integration

import (
"errors"
"fmt"
"io/ioutil"
"net"
"net/http"
"net/url"
"path/filepath"
"strings"
Expand Down Expand Up @@ -49,34 +50,45 @@ func testDashboard(t *testing.T) {
t.Parallel()
minikubeRunner := NewMinikubeRunner(t)

var u *url.URL

checkDashboard := func() error {
var err error
dashboardURL := minikubeRunner.RunCommand("dashboard --url", false)
if dashboardURL == "" {
return errors.New("error getting dashboard URL")
}
u, err = url.Parse(strings.TrimSpace(dashboardURL))
cmd, out := minikubeRunner.RunDaemon("dashboard --url")
defer func() {
err := cmd.Process.Kill()
if err != nil {
return err
t.Logf("Failed to kill mount command: %v", err)
}
return nil
}()

s, err := out.ReadString('\n')
if err != nil {
t.Fatalf("failed to read url: %v", err)
}

if err := util.Retry(t, checkDashboard, 2*time.Second, 60); err != nil {
t.Fatalf("error checking dashboard URL: %v", err)
u, err := url.Parse(strings.TrimSpace(s))
if err != nil {
t.Fatalf("failed to parse %q: %v", s, err)
}

if u.Scheme != "http" {
t.Fatalf("wrong scheme in dashboard URL, expected http, actual %s", u.Scheme)
t.Errorf("got Scheme %s, expected http", u.Scheme)
}
_, port, err := net.SplitHostPort(u.Host)
host, _, err := net.SplitHostPort(u.Host)
if err != nil {
t.Fatalf("failed to split dashboard host %s: %v", u.Host, err)
t.Fatalf("failed SplitHostPort: %v", err)
}
if host != "127.0.0.1" {
t.Errorf("got host %s, expected 127.0.0.1", host)
}
if port != "30000" {
t.Fatalf("Dashboard is exposed on wrong port, expected 30000, actual %s", port)

resp, err := http.Get(u.String())
if err != nil {
t.Fatalf("failed get: %v", err)
}
if resp.StatusCode != http.StatusOK {
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
t.Fatalf("Unable to read http response body: %v", err)
}
t.Errorf("%s returned status code %d, expected %d.\nbody:\n%s", u, resp.StatusCode, http.StatusOK, body)
}
}

Expand Down
2 changes: 1 addition & 1 deletion test/integration/mount_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ func testMounting(t *testing.T) {
defer os.RemoveAll(tempDir)

mountCmd := fmt.Sprintf("mount %s:/mount-9p", tempDir)
cmd := minikubeRunner.RunDaemon(mountCmd)
cmd, _ := minikubeRunner.RunDaemon(mountCmd)
tstromberg marked this conversation as resolved.
Show resolved Hide resolved
defer func() {
err := cmd.Process.Kill()
if err != nil {
Expand Down
Loading