diff --git a/.changelog/3158.txt b/.changelog/3158.txt new file mode 100644 index 0000000000..8c89ec9a89 --- /dev/null +++ b/.changelog/3158.txt @@ -0,0 +1,3 @@ +```release-note:improvement +cli: Add consul-k8s proxy stats command line interface that outputs the localhost:19000/stats of envoy in the pod +``` \ No newline at end of file diff --git a/cli/cmd/proxy/stats/command.go b/cli/cmd/proxy/stats/command.go new file mode 100644 index 0000000000..5c2c5b1bea --- /dev/null +++ b/cli/cmd/proxy/stats/command.go @@ -0,0 +1,222 @@ +package stats + +import ( + "errors" + "fmt" + "github.com/hashicorp/consul-k8s/cli/common" + "github.com/hashicorp/consul-k8s/cli/common/flag" + "github.com/hashicorp/consul-k8s/cli/common/terminal" + "github.com/hashicorp/consul-k8s/cli/helm" + helmCLI "helm.sh/helm/v3/pkg/cli" + "io" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + "net/http" + "strconv" + "strings" + "sync" +) + +const envoyAdminPort = 19000 + +type StatsCommand struct { + *common.BaseCommand + + helmActionsRunner helm.HelmActionsRunner + + kubernetes kubernetes.Interface + + restConfig *rest.Config + + set *flag.Sets + + flagKubeConfig string + flagKubeContext string + flagNamespace string + flagPod string + + once sync.Once + help string +} + +func (c *StatsCommand) init() { + c.set = flag.NewSets() + if c.helmActionsRunner == nil { + c.helmActionsRunner = &helm.ActionRunner{} + } + + f := c.set.NewSet("Command Options") + f.StringVar(&flag.StringVar{ + Name: "namespace", + Target: &c.flagNamespace, + Usage: "The namespace where the target Pod can be found.", + Aliases: []string{"n"}, + }) + + f = c.set.NewSet("Global Options") + f.StringVar(&flag.StringVar{ + Name: "kubeconfig", + Aliases: []string{"c"}, + Target: &c.flagKubeConfig, + Default: "", + Usage: "Path to kubeconfig file.", + }) + f.StringVar(&flag.StringVar{ + Name: "context", + Target: &c.flagKubeContext, + Default: "", + Usage: "Kubernetes context to use.", + }) + + c.help = c.set.Help() +} + +// validateFlags checks the command line flags and values for errors. +func (c *StatsCommand) validateFlags() error { + if len(c.set.Args()) > 0 { + return errors.New("should have no non-flag arguments") + } + return nil +} + +func (c *StatsCommand) Run(args []string) int { + c.once.Do(c.init) + + if err := c.parseFlags(args); err != nil { + c.UI.Output(err.Error(), terminal.WithErrorStyle()) + c.UI.Output("\n" + c.Help()) + return 1 + } + + if err := c.validateFlags(); err != nil { + c.UI.Output(err.Error()) + return 1 + } + + if c.flagPod == "" { + c.UI.Output("pod name is required") + return 1 + } + + // helmCLI.New() will create a settings object which is used by the Helm Go SDK calls. + settings := helmCLI.New() + if c.flagKubeConfig != "" { + settings.KubeConfig = c.flagKubeConfig + } + if c.flagKubeContext != "" { + settings.KubeContext = c.flagKubeContext + } + + if c.flagNamespace == "" { + c.flagNamespace = settings.Namespace() + } + + if err := c.setupKubeClient(settings); err != nil { + c.UI.Output(err.Error(), terminal.WithErrorStyle()) + return 1 + } + + if c.restConfig == nil { + var err error + if c.restConfig, err = settings.RESTClientGetter().ToRESTConfig(); err != nil { + c.UI.Output("error setting rest config") + return 1 + } + } + + pf := common.PortForward{ + Namespace: c.flagNamespace, + PodName: c.flagPod, + RemotePort: envoyAdminPort, + KubeClient: c.kubernetes, + RestConfig: c.restConfig, + } + + stats, err := c.getEnvoyStats(&pf) + if err != nil { + c.UI.Output("error fetching envoy stats %v", err, terminal.WithErrorStyle()) + return 1 + } + + c.UI.Output(stats) + return 0 + +} + +func (c *StatsCommand) getEnvoyStats(pf common.PortForwarder) (string, error) { + _, err := pf.Open(c.Ctx) + if err != nil { + return "", fmt.Errorf("error port forwarding %s", err) + } + defer pf.Close() + + resp, err := http.Get(fmt.Sprintf("http://localhost:%s/stats", strconv.Itoa(pf.GetLocalPort()))) + if err != nil { + return "", fmt.Errorf("error hitting stats endpoint of envoy %s", err) + } + + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("error reading body of http response %s", err) + } + + defer func(Body io.ReadCloser) { + _ = Body.Close() + }(resp.Body) + + return string(bodyBytes), nil +} + +// setupKubeClient to use for non Helm SDK calls to the Kubernetes API The Helm SDK will use +// settings.RESTClientGetter for its calls as well, so this will use a consistent method to +// target the right cluster for both Helm SDK and non Helm SDK calls. +func (c *StatsCommand) setupKubeClient(settings *helmCLI.EnvSettings) error { + if c.kubernetes == nil { + restConfig, err := settings.RESTClientGetter().ToRESTConfig() + if err != nil { + c.UI.Output("Error retrieving Kubernetes authentication: %v", err, terminal.WithErrorStyle()) + return err + } + c.kubernetes, err = kubernetes.NewForConfig(restConfig) + if err != nil { + c.UI.Output("Error initializing Kubernetes client: %v", err, terminal.WithErrorStyle()) + return err + } + } + + return nil +} + +func (c *StatsCommand) parseFlags(args []string) error { + // Separate positional arguments from keyed arguments. + var positional []string + for _, arg := range args { + if strings.HasPrefix(arg, "-") { + break + } + positional = append(positional, arg) + } + keyed := args[len(positional):] + + if len(positional) != 1 { + return fmt.Errorf("exactly one positional argument is required: ") + } + c.flagPod = positional[0] + + if err := c.set.Parse(keyed); err != nil { + return err + } + + return nil +} + +// Help returns a description of the command and how it is used. +func (c *StatsCommand) Help() string { + c.once.Do(c.init) + return c.Synopsis() + "\n\nUsage: consul-k8s proxy stats pod-name -n namespace [flags]\n\n" + c.help +} + +// Synopsis returns a one-line command summary. +func (c *StatsCommand) Synopsis() string { + return "Display Envoy stats for a proxy" +} diff --git a/cli/cmd/proxy/stats/command_test.go b/cli/cmd/proxy/stats/command_test.go new file mode 100644 index 0000000000..a50b67c078 --- /dev/null +++ b/cli/cmd/proxy/stats/command_test.go @@ -0,0 +1,157 @@ +package stats + +import ( + "bytes" + "context" + "github.com/hashicorp/consul-k8s/cli/common" + "github.com/hashicorp/consul-k8s/cli/common/terminal" + "github.com/hashicorp/go-hclog" + "github.com/stretchr/testify/require" + "io" + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/fake" + "net/http" + "os" + "strconv" + "testing" +) + +func TestFlagParsing(t *testing.T) { + cases := map[string]struct { + args []string + out int + }{ + "No args, should fail": { + args: []string{}, + out: 1, + }, + "Nonexistent flag passed, -foo bar, should fail": { + args: []string{"-foo", "bar"}, + out: 1, + }, + "Invalid argument passed, -namespace notaname, should fail": { + args: []string{"-namespace", "notaname"}, + out: 1, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + c := setupCommand(new(bytes.Buffer)) + c.kubernetes = fake.NewSimpleClientset() + out := c.Run(tc.args) + require.Equal(t, tc.out, out) + }) + } +} +func setupCommand(buf io.Writer) *StatsCommand { + // Log at a test level to standard out. + log := hclog.New(&hclog.LoggerOptions{ + Name: "test", + Level: hclog.Debug, + Output: os.Stdout, + }) + + // Setup and initialize the command struct + command := &StatsCommand{ + BaseCommand: &common.BaseCommand{ + Log: log, + UI: terminal.NewUI(context.Background(), buf), + }, + } + command.init() + + return command +} + +type MockPortForwarder struct { +} + +func (mpf *MockPortForwarder) Open(ctx context.Context) (string, error) { + return "localhost:" + strconv.Itoa(envoyAdminPort), nil +} + +func (mpf *MockPortForwarder) Close() { + //noop +} + +func (mpf *MockPortForwarder) GetLocalPort() int { + return envoyAdminPort +} + +func TestEnvoyStats(t *testing.T) { + cases := map[string]struct { + namespace string + pods []v1.Pod + }{ + "Sidecar Pods": { + namespace: "default", + pods: []v1.Pod{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "pod1", + Namespace: "default", + Labels: map[string]string{ + "consul.hashicorp.com/connect-inject-status": "injected", + }, + }, + }, + }, + }, + "Pods in consul namespaces": { + namespace: "consul", + pods: []v1.Pod{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "api-gateway", + Namespace: "consul", + Labels: map[string]string{ + "api-gateway.consul.hashicorp.com/managed": "true", + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "pod1", + Namespace: "consul", + Labels: map[string]string{ + "consul.hashicorp.com/connect-inject-status": "injected", + }, + }, + }, + }, + }, + } + + srv := startHttpServer(envoyAdminPort) + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + c := setupCommand(new(bytes.Buffer)) + c.kubernetes = fake.NewSimpleClientset(&v1.PodList{Items: tc.pods}) + c.flagNamespace = tc.namespace + for _, pod := range tc.pods { + c.flagPod = pod.Name + mpf := &MockPortForwarder{} + resp, err := c.getEnvoyStats(mpf) + require.NoError(t, err) + require.Equal(t, resp, "Envoy Stats") + } + }) + } + srv.Shutdown(context.Background()) +} + +func startHttpServer(port int) *http.Server { + srv := &http.Server{Addr: ":" + strconv.Itoa(port)} + + http.HandleFunc("/stats", func(w http.ResponseWriter, r *http.Request) { + io.WriteString(w, "Envoy Stats") + }) + + go func() { + srv.ListenAndServe() + }() + + return srv +} diff --git a/cli/commands.go b/cli/commands.go index 1c58e75cf8..513bd6d9d7 100644 --- a/cli/commands.go +++ b/cli/commands.go @@ -5,6 +5,7 @@ package main import ( "context" + "github.com/hashicorp/consul-k8s/cli/cmd/proxy/stats" "github.com/hashicorp/consul-k8s/cli/cmd/config" config_read "github.com/hashicorp/consul-k8s/cli/cmd/config/read" @@ -81,6 +82,11 @@ func initializeCommands(ctx context.Context, log hclog.Logger) (*common.BaseComm BaseCommand: baseCommand, }, nil }, + "proxy stats": func() (cli.Command, error) { + return &stats.StatsCommand{ + BaseCommand: baseCommand, + }, nil + }, "config": func() (cli.Command, error) { return &config.ConfigCommand{ BaseCommand: baseCommand, diff --git a/cli/common/envoy/http_test.go b/cli/common/envoy/http_test.go index 961046100e..197bb62e84 100644 --- a/cli/common/envoy/http_test.go +++ b/cli/common/envoy/http_test.go @@ -8,6 +8,7 @@ import ( "context" "encoding/json" "fmt" + "k8s.io/apimachinery/pkg/util/rand" "net/http" "net/http/httptest" "os" @@ -528,6 +529,7 @@ type mockPortForwarder struct { func (m *mockPortForwarder) Open(ctx context.Context) (string, error) { return m.openBehavior(ctx) } func (m *mockPortForwarder) Close() {} +func (m *mockPortForwarder) GetLocalPort() int { return int(rand.Int63nRange(0, 65535)) } func testLogConfig() map[string]string { cfg := make(map[string]string, len(EnvoyLoggers)) diff --git a/cli/common/portforward.go b/cli/common/portforward.go index 79536706d9..3b0e030ac1 100644 --- a/cli/common/portforward.go +++ b/cli/common/portforward.go @@ -47,6 +47,7 @@ type PortForward struct { type PortForwarder interface { Open(context.Context) (string, error) Close() + GetLocalPort() int } // forwarder is an interface which can be used for opening a port forward session. @@ -117,6 +118,10 @@ func (pf *PortForward) Close() { close(pf.stopChan) } +func (pf *PortForward) GetLocalPort() int { + return pf.localPort +} + // allocateLocalPort looks for an open port on localhost and sets it to the // localPort field. func (pf *PortForward) allocateLocalPort() error {