-
Notifications
You must be signed in to change notification settings - Fork 323
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
* init * fix help and synopsis * added some tests * change log * some fixes * rename var name * tests for get envoy stats * fix tests * Update cli/cmd/envoy-stats/envoy_stats.go Co-authored-by: Thomas Eckert <[email protected]> * proxy stats command * fix command options * pr comment resolved * fix globaloptions * fix lint --------- Co-authored-by: Thomas Eckert <[email protected]>
- Loading branch information
1 parent
2b93336
commit a9a4bb5
Showing
4 changed files
with
386 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,3 +1,3 @@ | ||
```release-note:improvement | ||
cli: Add envoy-stats command line interface that outputs the localhost:19000/stats of envoy in the pod | ||
cli: Add consul-k8s proxy stats command line interface that outputs the localhost:19000/stats of envoy in the pod | ||
``` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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.", | ||
}) | ||
|
||
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" | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
Oops, something went wrong.