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

NET 6409 #3158

Merged
merged 17 commits into from
Nov 8, 2023
Merged
3 changes: 3 additions & 0 deletions .changelog/3158.txt
Original file line number Diff line number Diff line change
@@ -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
```
222 changes: 222 additions & 0 deletions cli/cmd/proxy/stats/command.go
Original file line number Diff line number Diff line change
@@ -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.",
})
absolutelightning marked this conversation as resolved.
Show resolved Hide resolved

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: <pod-name>")
}
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"
}
157 changes: 157 additions & 0 deletions cli/cmd/proxy/stats/command_test.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading