diff --git a/internal/cmd/egctl/config_test.go b/internal/cmd/egctl/config_test.go index 47a70c19fde..bfeba9fa155 100644 --- a/internal/cmd/egctl/config_test.go +++ b/internal/cmd/egctl/config_test.go @@ -79,6 +79,8 @@ func (fw *fakePortForwarder) Address() string { return fmt.Sprintf("localhost:%d", fw.localPort) } +func (fw *fakePortForwarder) WaitForStop() {} + func TestExtractAllConfigDump(t *testing.T) { input, err := readInputConfig("in.all.json") require.NoError(t, err) diff --git a/internal/cmd/egctl/dashboard.go b/internal/cmd/egctl/dashboard.go new file mode 100644 index 00000000000..349662dbeda --- /dev/null +++ b/internal/cmd/egctl/dashboard.go @@ -0,0 +1,23 @@ +// Copyright Envoy Gateway Authors +// SPDX-License-Identifier: Apache-2.0 +// The full text of the Apache license is available in the LICENSE file at +// the root of the repo. + +package egctl + +import ( + "github.com/spf13/cobra" +) + +func newDashboardCommand() *cobra.Command { + c := &cobra.Command{ + Use: "dashboard", + Aliases: []string{"d"}, + Long: "Retrieve the dashboard.", + Short: "Retrieve the dashboard.", + } + + c.AddCommand(newEnvoyDashboardCmd()) + + return c +} diff --git a/internal/cmd/egctl/envoy_dashboard.go b/internal/cmd/egctl/envoy_dashboard.go new file mode 100644 index 00000000000..9b8f8bdf327 --- /dev/null +++ b/internal/cmd/egctl/envoy_dashboard.go @@ -0,0 +1,138 @@ +// Copyright Envoy Gateway Authors +// SPDX-License-Identifier: Apache-2.0 +// The full text of the Apache license is available in the LICENSE file at +// the root of the repo. + +package egctl + +import ( + "errors" + "fmt" + "io" + "os" + "os/exec" + "os/signal" + "runtime" + + "github.com/spf13/cobra" + "k8s.io/apimachinery/pkg/types" + + kube "github.com/envoyproxy/gateway/internal/kubernetes" +) + +func newEnvoyDashboardCmd() *cobra.Command { + var podName, podNamespace string + var listenPort int + + dashboardCmd := &cobra.Command{ + Use: "envoy-proxy -n ", + Short: "Retrieve Envoy admin dashboard for the specified pod", + Long: `Retrieve Envoy admin dashboard for the specified pod.`, + Example: ` # Retrieve Envoy admin dashboard for the specified pod. + egctl experimental dashboard envoy-proxy -n + + # short syntax + egctl experimental d envoy-proxy -n +`, + Aliases: []string{"d"}, + Args: func(cmd *cobra.Command, args []string) error { + if len(args) != 1 && len(labelSelectors) == 0 { + cmd.Println(cmd.UsageString()) + return fmt.Errorf("dashboard requires pod name or label selector") + } + if len(args) > 0 && len(labelSelectors) > 0 { + cmd.Println(cmd.UsageString()) + return fmt.Errorf("name cannot be provided when a selector is specified") + } + return nil + }, + RunE: func(c *cobra.Command, args []string) error { + if listenPort > 65535 || listenPort < 0 { + return fmt.Errorf("invalid port number range") + } + + kubeClient, err := getCLIClient() + if err != nil { + return err + } + if len(args) != 0 { + podName = args[0] + } + if len(labelSelectors) > 0 { + pl, err := kubeClient.PodsForSelector(podNamespace, labelSelectors...) + if err != nil { + return fmt.Errorf("not able to locate pod with selector %s: %w", labelSelectors, err) + } + if len(pl.Items) < 1 { + return errors.New("no pods found") + } + podName = pl.Items[0].Name + podNamespace = pl.Items[0].Namespace + } + + return portForward(podName, podNamespace, "http://%s", listenPort, kubeClient, c.OutOrStdout()) + }, + } + dashboardCmd.PersistentFlags().StringArrayVarP(&labelSelectors, "labels", "l", nil, "Labels to select the envoy proxy pod.") + dashboardCmd.PersistentFlags().StringVarP(&podNamespace, "namespace", "n", "envoy-gateway-system", "Namespace where envoy proxy pod are installed.") + dashboardCmd.PersistentFlags().IntVarP(&listenPort, "port", "p", 0, "Local port to listen to.") + + return dashboardCmd +} + +// portForward first tries to forward localhost:remotePort to podName:remotePort, falls back to dynamic local port +func portForward(podName, namespace, urlFormat string, listenPort int, client kube.CLIClient, writer io.Writer) error { + var fw kube.PortForwarder + meta := types.NamespacedName{ + Namespace: namespace, + Name: podName, + } + fw, err := kube.NewLocalPortForwarder(client, meta, listenPort, adminPort) + if err != nil { + return fmt.Errorf("could not build port forwarder for envoy proxy: %w", err) + } + + if err = fw.Start(); err != nil { + fw.Stop() + return fmt.Errorf("could not start port forwarder for envoy proxy: %w", err) + } + + ClosePortForwarderOnInterrupt(fw) + + openBrowser(fmt.Sprintf(urlFormat, fw.Address()), writer) + + fw.WaitForStop() + + return nil +} + +func ClosePortForwarderOnInterrupt(fw kube.PortForwarder) { + go func() { + signals := make(chan os.Signal, 1) + signal.Notify(signals, os.Interrupt) + defer signal.Stop(signals) + <-signals + fw.Stop() + }() +} + +func openBrowser(url string, writer io.Writer) { + var err error + + fmt.Fprintf(writer, "%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; open %s in your browser.\n", runtime.GOOS, url) + } + + if err != nil { + fmt.Fprintf(writer, "Failed to open browser; open %s in your browser.\n", url) + } +} diff --git a/internal/cmd/egctl/experimental.go b/internal/cmd/egctl/experimental.go index e22dc3e6ca6..ad307cc7cb3 100644 --- a/internal/cmd/egctl/experimental.go +++ b/internal/cmd/egctl/experimental.go @@ -25,6 +25,7 @@ func newExperimentalCommand() *cobra.Command { experimentalCommand.AddCommand(newTranslateCommand()) experimentalCommand.AddCommand(newStatsCommand()) experimentalCommand.AddCommand(newStatusCommand()) + experimentalCommand.AddCommand(newDashboardCommand()) return experimentalCommand } diff --git a/internal/kubernetes/port-forwarder.go b/internal/kubernetes/port-forwarder.go index a58e88ea5cd..ceb439d7721 100644 --- a/internal/kubernetes/port-forwarder.go +++ b/internal/kubernetes/port-forwarder.go @@ -24,6 +24,8 @@ type PortForwarder interface { Stop() + WaitForStop() + // Address returns the address of the local forwarded address. Address() string } @@ -128,6 +130,10 @@ func (f *localForwarder) Stop() { close(f.stopCh) } +func (f *localForwarder) WaitForStop() { + <-f.stopCh +} + func (f *localForwarder) Address() string { return fmt.Sprintf("%s:%d", netutil.DefaultLocalAddress, f.localPort) } diff --git a/site/content/en/latest/user/egctl.md b/site/content/en/latest/user/egctl.md index dba019e1cf5..8fc3e391758 100644 --- a/site/content/en/latest/user/egctl.md +++ b/site/content/en/latest/user/egctl.md @@ -795,3 +795,21 @@ product backend ResolvedRefs True ResolvedRefs [Multi-tenancy]: ../deployment-mode#multi-tenancy [EnvoyProxy]: ../../api/extension_types#envoyproxy + + +## egctl experimental dashboard + +This subcommand streamlines the process for users to access the Envoy admin dashboard. By executing the following command: + +```bash +egctl x dashboard envoy-proxy -n envoy-gateway-system envoy-engw-eg-a9c23fbb-558f94486c-82wh4 +``` + +You will see the following output: + +```bash +egctl x dashboard envoy-proxy -n envoy-gateway-system envoy-engw-eg-a9c23fbb-558f94486c-82wh4 +http://localhost:19000 +``` + +the Envoy admin dashboard will automatically open in your default web browser. This eliminates the need to manually locate and expose the admin port.