diff --git a/pkg/dubboctl/cmd/dashboard.go b/pkg/dubboctl/cmd/dashboard.go new file mode 100644 index 000000000..e15531922 --- /dev/null +++ b/pkg/dubboctl/cmd/dashboard.go @@ -0,0 +1,37 @@ +// Licensed to the Apache Software Foundation (ASF) under one or more +// contributor license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright ownership. +// The ASF licenses this file to You under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmd + +import ( + "github.com/apache/dubbo-admin/pkg/dubboctl/cmd/subcmd" + "github.com/spf13/cobra" +) + +func addDashboard(rootCmd *cobra.Command) { + dashboardCmd := &cobra.Command{ + Use: "dashboard", + Short: "Commands related to control plane components dashboards", + Long: "Commands help user to open control plane components dashboards directly. Now support Admin, Grafana, Nacos, Prometheus, Skywalking and Zipkin", + } + subcmd.ConfigDashboardAdminCmd(dashboardCmd) + subcmd.ConfigDashboardGrafanaCmd(dashboardCmd) + subcmd.ConfigDashboardNacosCmd(dashboardCmd) + subcmd.ConfigDashboardPrometheusCmd(dashboardCmd) + subcmd.ConfigDashboardSkywalkingCmd(dashboardCmd) + subcmd.ConfigDashboardZipkinCmd(dashboardCmd) + + rootCmd.AddCommand(dashboardCmd) +} diff --git a/pkg/dubboctl/cmd/profile.go b/pkg/dubboctl/cmd/profile.go index e0c3534b9..bafb1f4f8 100644 --- a/pkg/dubboctl/cmd/profile.go +++ b/pkg/dubboctl/cmd/profile.go @@ -26,8 +26,8 @@ func addProfile(rootCmd *cobra.Command) { Short: "Commands related to profiles", Long: "Commands help user to list and describe profiles", } - subcmd.ConfigProfileListArgs(profileCmd) - subcmd.ConfigProfileDiffArgs(profileCmd) + subcmd.ConfigProfileListCmd(profileCmd) + subcmd.ConfigProfileDiffCmd(profileCmd) rootCmd.AddCommand(profileCmd) } diff --git a/pkg/dubboctl/cmd/root.go b/pkg/dubboctl/cmd/root.go index c19f87527..f4a3f3efe 100644 --- a/pkg/dubboctl/cmd/root.go +++ b/pkg/dubboctl/cmd/root.go @@ -41,4 +41,5 @@ func getRootCmd(args []string) *cobra.Command { func addSubCommands(rootCmd *cobra.Command) { addManifest(rootCmd) addProfile(rootCmd) + addDashboard(rootCmd) } diff --git a/pkg/dubboctl/cmd/subcmd/dashboard_all_cmds.go b/pkg/dubboctl/cmd/subcmd/dashboard_all_cmds.go new file mode 100644 index 000000000..704bee89d --- /dev/null +++ b/pkg/dubboctl/cmd/subcmd/dashboard_all_cmds.go @@ -0,0 +1,229 @@ +// Licensed to the Apache Software Foundation (ASF) under one or more +// contributor license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright ownership. +// The ASF licenses this file to You under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package subcmd + +import ( + "context" + "fmt" + "github.com/apache/dubbo-admin/pkg/dubboctl/identifier" + "github.com/apache/dubbo-admin/pkg/dubboctl/internal/kube" + "github.com/apache/dubbo-admin/pkg/dubboctl/internal/operator" + "github.com/apache/dubbo-admin/pkg/logger" + "github.com/spf13/cobra" + "go.uber.org/zap/zapcore" + "io" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + "net" + "os" + "os/exec" + "os/signal" + "runtime" + "strconv" + "strings" +) + +var ( + // todo: think about a efficient way to change selectors and ports when yaml files change + // ports are coming from /deploy/charts and /deploy/kubernetes + ComponentPortMap = map[operator.ComponentName]int{ + operator.Admin: 8080, + operator.Grafana: 3000, + operator.Nacos: 8848, + operator.Prometheus: 9090, + operator.Skywalking: 8080, + operator.Zipkin: 9411, + } + // selectors are coming from /deploy/charts and /deploy/kubernetes + ComponentSelectorMap = map[operator.ComponentName]string{ + operator.Admin: "app.kubernetes.io/name=dubbo-admin", + operator.Grafana: "app.kubernetes.io/name=grafana", + operator.Nacos: "app.kubernetes.io/name=nacos", + operator.Prometheus: "app=prometheus", + operator.Skywalking: "app=skywalking, component=ui", + operator.Zipkin: "app.kubernetes.io/name=zipkin", + } +) + +type DashboardCommonArgs struct { + port int + host string + openBrowser bool + namespace string + KubeConfigPath string + // selected cluster info of kubeconfig + Context string +} + +func (dca *DashboardCommonArgs) setDefault() { + if dca == nil { + return + } + if dca.host == "" { + dca.host = "127.0.0.1" + } + if dca.namespace == "" { + dca.namespace = identifier.DubboSystemNamespace + } +} + +func commonDashboardCmd(baseCmd *cobra.Command, compName operator.ComponentName) { + nameStr := string(compName) + lowerNameStr := strings.ToLower(nameStr) + dcArgs := &DashboardCommonArgs{} + cmd := &cobra.Command{ + Use: lowerNameStr, + Short: fmt.Sprintf("create PortForward between local address and target component %s pod. open browser by default", nameStr), + Example: fmt.Sprintf(` # create PortForward in 127.0.0.1:%d and open browser directly + dubboctl dashboard %s + # specify port + dubboctl dashboard %s --port 8888 + # do not open browser + dubboctl dashboard %s --openBrowser false + # specify namespace of Admin + dubboctl dashboard %s --namespace user_specified +`, ComponentPortMap[compName], lowerNameStr, lowerNameStr, lowerNameStr, lowerNameStr), + RunE: func(cmd *cobra.Command, args []string) error { + logger.InitCmdSugar(zapcore.AddSync(cmd.OutOrStdout())) + dcArgs.setDefault() + if err := portForward(dcArgs, compName, cmd.OutOrStdout()); err != nil { + return err + } + return nil + }, + } + cmd.PersistentFlags().IntVarP(&dcArgs.port, "port", "p", 0, + fmt.Sprintf("local port to listen on. If not set, it would be same as the default port of component %s", nameStr)) + cmd.PersistentFlags().StringVarP(&dcArgs.host, "host", "", "", + "local host to bind. If not set, it would be 127.0.0.1") + // openBrowser is default behaviour + cmd.PersistentFlags().BoolVarP(&dcArgs.openBrowser, "openBrowser", "", true, + "whether to open browser automatically") + cmd.PersistentFlags().StringVarP(&dcArgs.namespace, "namespace", "n", "", + fmt.Sprintf("namespace in which component %s is located", nameStr)) + cmd.PersistentFlags().StringVarP(&dcArgs.KubeConfigPath, "kubeConfig", "", "", + "Path to kubeConfig") + cmd.PersistentFlags().StringVarP(&dcArgs.Context, "context", "", "", + "Context in kubeConfig to use") + + baseCmd.AddCommand(cmd) +} + +func ConfigDashboardAdminCmd(baseCmd *cobra.Command) { + commonDashboardCmd(baseCmd, operator.Admin) +} + +func ConfigDashboardGrafanaCmd(baseCmd *cobra.Command) { + commonDashboardCmd(baseCmd, operator.Grafana) +} + +func ConfigDashboardNacosCmd(baseCmd *cobra.Command) { + commonDashboardCmd(baseCmd, operator.Nacos) +} + +func ConfigDashboardPrometheusCmd(baseCmd *cobra.Command) { + commonDashboardCmd(baseCmd, operator.Prometheus) +} + +func ConfigDashboardSkywalkingCmd(baseCmd *cobra.Command) { + commonDashboardCmd(baseCmd, operator.Skywalking) +} + +func ConfigDashboardZipkinCmd(baseCmd *cobra.Command) { + commonDashboardCmd(baseCmd, operator.Zipkin) +} + +func portForward(args *DashboardCommonArgs, compName operator.ComponentName, writer io.Writer) error { + // process args + var podPort int + podPort = ComponentPortMap[compName] + if args.port == 0 { + args.port = podPort + } + + // prepare PortForward args + labelSelector := ComponentSelectorMap[compName] + cfg, err := kube.BuildConfig(args.KubeConfigPath, args.Context) + if err != nil { + return fmt.Errorf("build kube config failed, err: %s", err) + } + // todo: unify kube client + cli, err := kubernetes.NewForConfig(cfg) + if err != nil { + return fmt.Errorf("create kube RESTClient failed, err: %s", err) + } + pods, err := cli.CoreV1().Pods(args.namespace).List(context.Background(), metav1.ListOptions{ + LabelSelector: labelSelector, + }) + if len(pods.Items) < 1 { + return fmt.Errorf("no %s pods found", string(compName)) + } + // use name of the first pod + podName := pods.Items[0].Name + + pf, err := kube.NewPortForward(podName, args.namespace, args.host, args.port, podPort, cfg) + if err != nil { + return fmt.Errorf("create PortForward failed, err: %s", err) + } + if err := pf.Run(); err != nil { + pf.Stop() + return fmt.Errorf("PortForward running failed, err: %s", err) + } + + logger.CmdSugar().Infof("PortForward to %s pod is running", podName) + + // wait for interrupt + go func() { + signals := make(chan os.Signal, 1) + signal.Notify(signals, os.Interrupt) + defer signal.Stop(signals) + <-signals + logger.CmdSugar().Info("PortForward stops") + pf.Stop() + }() + + if args.openBrowser { + address := net.JoinHostPort(args.host, strconv.Itoa(args.port)) + url := "http://" + address + openBrowser(url, writer) + } + + pf.Wait() + + return nil +} + +// openBrowser uses syscall from different runtime +func openBrowser(url string, writer io.Writer) { + var err error + + fmt.Fprintf(writer, "open browser in %s\n", url) + + switch runtime.GOOS { + case "linux": + err = exec.Command("xdg-open", url).Start() + case "windows": + err = exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start() + case "darwin": + err = exec.Command("open", url).Start() + default: + fmt.Fprintf(writer, "unsupported platform %q. pls open %s in your browser.\n", runtime.GOOS, url) + } + + if err != nil { + fmt.Fprintf(writer, "open browser failed; open %s in your browser.\n", url) + } +} diff --git a/pkg/dubboctl/cmd/subcmd/manifest_install.go b/pkg/dubboctl/cmd/subcmd/manifest_install.go index 23aad1d45..566f453ad 100644 --- a/pkg/dubboctl/cmd/subcmd/manifest_install.go +++ b/pkg/dubboctl/cmd/subcmd/manifest_install.go @@ -58,10 +58,10 @@ func ConfigManifestInstallCmd(baseCmd *cobra.Command) { }, } addManifestGenerateFlags(miCmd, mgArgs) - miCmd.PersistentFlags().StringVarP(&miArgs.KubeConfigPath, "kubeconfig", "", "", - "Path to kubeconfig") + miCmd.PersistentFlags().StringVarP(&miArgs.KubeConfigPath, "kubeConfig", "", "", + "Path to kubeConfig") miCmd.PersistentFlags().StringVarP(&miArgs.Context, "context", "", "", - "Context in kubeconfig to use") + "Context in kubeConfig to use") baseCmd.AddCommand(miCmd) } diff --git a/pkg/dubboctl/cmd/subcmd/profile_diff.go b/pkg/dubboctl/cmd/subcmd/profile_diff.go index 1a864387b..e193bd445 100644 --- a/pkg/dubboctl/cmd/subcmd/profile_diff.go +++ b/pkg/dubboctl/cmd/subcmd/profile_diff.go @@ -40,7 +40,7 @@ func (pda *ProfileDiffArgs) setDefault() { } } -func ConfigProfileDiffArgs(baseCmd *cobra.Command) { +func ConfigProfileDiffCmd(baseCmd *cobra.Command) { pdArgs := &ProfileDiffArgs{} pdCmd := &cobra.Command{ Use: "diff", diff --git a/pkg/dubboctl/cmd/subcmd/profile_list.go b/pkg/dubboctl/cmd/subcmd/profile_list.go index dd1701725..858f0f166 100644 --- a/pkg/dubboctl/cmd/subcmd/profile_list.go +++ b/pkg/dubboctl/cmd/subcmd/profile_list.go @@ -40,7 +40,7 @@ func (pla *ProfileListArgs) setDefault() { } } -func ConfigProfileListArgs(baseCmd *cobra.Command) { +func ConfigProfileListCmd(baseCmd *cobra.Command) { plArgs := &ProfileListArgs{} plCmd := &cobra.Command{ Use: "list", diff --git a/pkg/dubboctl/internal/kube/client.go b/pkg/dubboctl/internal/kube/client.go index 876be1553..59dbe4db9 100644 --- a/pkg/dubboctl/internal/kube/client.go +++ b/pkg/dubboctl/internal/kube/client.go @@ -31,8 +31,8 @@ import ( // CtlClient wraps controller-runtime client and is used by dubboctl type CtlClient struct { - client.Client opts *CtlClientOptions + client.Client } type CtlClientOptions struct { diff --git a/pkg/dubboctl/internal/kube/common.go b/pkg/dubboctl/internal/kube/common.go index 148ce6d72..5ac915984 100644 --- a/pkg/dubboctl/internal/kube/common.go +++ b/pkg/dubboctl/internal/kube/common.go @@ -17,14 +17,18 @@ package kube import ( "fmt" + "k8s.io/apimachinery/pkg/runtime/serializer" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" "os" "strconv" "strings" jsonpatch "github.com/evanphx/json-patch/v5" + corev1 "k8s.io/api/core/v1" v1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" + kubescheme "k8s.io/client-go/kubernetes/scheme" "k8s.io/client-go/rest" "k8s.io/client-go/tools/clientcmd" "k8s.io/kubectl/pkg/scheme" @@ -135,10 +139,33 @@ func BuildConfig(kubecfgPath string, ctx string) (*rest.Config, error) { if err != nil { return nil, err } - // setDefaults(cfg) + setDefaults(cfg) return cfg, nil } -func setDefaults(cfg *rest.Config) { - // todo:// add schema +func setDefaults(config *rest.Config) *rest.Config { + if config.GroupVersion == nil || config.GroupVersion.Empty() { + config.GroupVersion = &corev1.SchemeGroupVersion + } + if len(config.APIPath) == 0 { + if len(config.GroupVersion.Group) == 0 { + config.APIPath = "/api" + } else { + config.APIPath = "/apis" + } + } + if len(config.ContentType) == 0 { + config.ContentType = runtime.ContentTypeJSON + } + if config.NegotiatedSerializer == nil { + config.NegotiatedSerializer = serializer.NewCodecFactory(dubboScheme()).WithoutConversion() + } + + return config +} + +func dubboScheme() *runtime.Scheme { + newScheme := runtime.NewScheme() + utilruntime.Must(kubescheme.AddToScheme(newScheme)) + return newScheme } diff --git a/pkg/dubboctl/internal/kube/port_foward.go b/pkg/dubboctl/internal/kube/port_foward.go new file mode 100644 index 000000000..ad9e064d3 --- /dev/null +++ b/pkg/dubboctl/internal/kube/port_foward.go @@ -0,0 +1,152 @@ +// Licensed to the Apache Software Foundation (ASF) under one or more +// contributor license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright ownership. +// The ASF licenses this file to You under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package kube + +import ( + "context" + "fmt" + "github.com/apache/dubbo-admin/pkg/logger" + "io" + v1 "k8s.io/api/core/v1" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/portforward" + "k8s.io/client-go/transport/spdy" + "net/http" + "net/url" + "os" +) + +type PortForward struct { + podName string + namespace string + // localAddress and localPort form a local listening address, eg: localhost:8080 + localAddress string + localPort int + // port of the target pod + podPort int + stopCh chan struct{} + cfg *rest.Config + client *rest.RESTClient +} + +func (pf *PortForward) Run() error { + readyCh := make(chan struct{}, 1) + errCh := make(chan error, 1) + // start a goroutine to process portforward + go func() { + for { + select { + case <-pf.stopCh: + return + default: + // fail-fast is target pod is not running + if err := pf.inspectPodStatus(); err != nil { + errCh <- err + return + } + fwReq := pf.client.Post().Resource("pods").Namespace(pf.namespace).Name(pf.podName).SubResource("portforward") + kubeFw, err := pf.createKubePortForwarder(fwReq.URL(), readyCh) + if err != nil { + errCh <- err + return + } + // for lost connection to target pod scenario, ForwardPorts would return nil. + // so we put portforward processing in a loop. it would retry until user interrupts. + if err = kubeFw.ForwardPorts(); err != nil { + errCh <- err + return + } + logger.CmdSugar().Infof("lost connection to %s pod", pf.podName) + } + } + }() + + select { + case <-readyCh: + return nil + case err := <-errCh: + return fmt.Errorf("running portforward failed, err: %s", err) + } +} + +// inspectPodStatus check status of the target pod. If this pod is not running, fail fast. +func (pf *PortForward) inspectPodStatus() error { + podReq := pf.client.Get().Resource("pods").Namespace(pf.namespace).Name(pf.podName) + logger.CmdSugar().Info(podReq.URL().String()) + obj, err := podReq.Do(context.Background()).Get() + if err != nil { + return fmt.Errorf("get information of pod %s in %s namespace failed, err: %s", pf.podName, pf.namespace, err) + } + pod, ok := obj.(*v1.Pod) + if !ok { + return fmt.Errorf("wanted pod but got %T", obj) + } + if pod.Status.Phase != v1.PodRunning { + return fmt.Errorf("pod %s is not running. now it is %s", pf.podName, pod.Status.Phase) + } + return nil +} + +// createKubePortForwarder makes use of kube api to create PortForwarder. +// It needs readyCh to tell PortForward that kube PortForwarder is ready. +func (pf *PortForward) createKubePortForwarder(reqUrl *url.URL, readyCh chan struct{}) (*portforward.PortForwarder, error) { + trans, upgrader, err := spdy.RoundTripperFor(pf.cfg) + if err != nil { + return nil, fmt.Errorf("creating spdy RoundTripper failed, err: %s", err) + } + dialer := spdy.NewDialer(upgrader, &http.Client{Transport: trans}, "POST", reqUrl) + fw, err := portforward.NewOnAddresses( + dialer, + []string{pf.localAddress}, + []string{fmt.Sprintf("%d:%d", pf.localPort, pf.podPort)}, + pf.stopCh, + readyCh, + io.Discard, + os.Stderr, + ) + if err != nil { + return nil, fmt.Errorf("creating kube portforward failed, err: %s", err) + } + return fw, nil +} + +// Stop close stopCh and free up resources +func (pf *PortForward) Stop() { + close(pf.stopCh) +} + +// Wait wait for closing stopCh which means that Stop function is the only way to trigger +func (pf *PortForward) Wait() { + <-pf.stopCh +} + +func NewPortForward(podName, namespace, localAddress string, localPort, podPort int, cfg *rest.Config) (*PortForward, error) { + pf := &PortForward{ + podName: podName, + namespace: namespace, + localAddress: localAddress, + localPort: localPort, + podPort: podPort, + stopCh: make(chan struct{}), + cfg: cfg, + } + cli, err := rest.RESTClientFor(cfg) + if err != nil { + return nil, err + } + pf.client = cli + return pf, nil +}