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

Get envoy log level per pod #1844

Merged
merged 11 commits into from
Jan 27, 2023
337 changes: 337 additions & 0 deletions cli/cmd/proxy/loglevel/command.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,337 @@
package loglevel

import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net/http"
"strings"
"sync"

"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/posener/complete"
helmCLI "helm.sh/helm/v3/pkg/cli"
"k8s.io/apimachinery/pkg/api/validation"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
)

const (
defaultAdminPort = 19000
flagNameNamespace = "namespace"
flagNameKubeConfig = "kubeconfig"
flagNameKubeContext = "context"
)

type LoggerConfig map[string]string

var (
ErrIncorrectArgFormat = errors.New("Exactly one positional argument is required: <pod-name>")
ErrNoLoggersReturned = errors.New("No loggers were returned from Envoy")
)

var levelToColor = map[string]string{
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"trace": terminal.Green,
"debug": terminal.HiWhite,
"info": terminal.Blue,
"warning": terminal.Yellow,
"error": terminal.Red,
"critical": terminal.Magenta,
"off": "",
}

type LogLevelCommand struct {
*common.BaseCommand

kubernetes kubernetes.Interface
set *flag.Sets

// Command Flags
podName string
namespace string
kubeConfig string
kubeContext string

once sync.Once
help string
restConfig *rest.Config
logLevelFetcher func(context.Context, common.PortForwarder) (LoggerConfig, error)
}

func (l *LogLevelCommand) init() {
l.Log.ResetNamed("loglevel")
l.set = flag.NewSets()
f := l.set.NewSet("Command Options")
f.StringVar(&flag.StringVar{
Name: flagNameNamespace,
Target: &l.namespace,
Usage: "The namespace where the target Pod can be found.",
Aliases: []string{"n"},
})

f = l.set.NewSet("Global Options")
f.StringVar(&flag.StringVar{
Name: flagNameKubeConfig,
Aliases: []string{"c"},
Target: &l.kubeConfig,
Usage: "Set the path to kubeconfig file.",
})
f.StringVar(&flag.StringVar{
Name: flagNameKubeContext,
Target: &l.kubeContext,
Usage: "Set the Kubernetes context to use.",
})

l.help = l.set.Help()
}

func (l *LogLevelCommand) Run(args []string) int {
l.once.Do(l.init)
defer common.CloseWithError(l.BaseCommand)

err := l.parseFlags(args)
if err != nil {
return l.logOutputAndDie(err)
}
err = l.validateFlags()
if err != nil {
return l.logOutputAndDie(err)
}

if l.logLevelFetcher == nil {
l.logLevelFetcher = FetchLogLevel
}

err = l.initKubernetes()
if err != nil {
return l.logOutputAndDie(err)
}

adminPorts, err := l.fetchAdminPorts()
if err != nil {
return l.logOutputAndDie(err)
}

logLevels, err := l.fetchLogLevels(adminPorts)
if err != nil {
return l.logOutputAndDie(err)
}
l.outputLevels(logLevels)
return 0
}

func (l *LogLevelCommand) parseFlags(args []string) error {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so, wondering why not just do something like l.set.Parse(args) and then l.set.Args(). Something like:

func (l *LogLevelCommand) parseFlags(args []string) error {
    if err := l.set.Parse(args); err != nil {
        return err
    }

    positional := l.set.Args()
    if len(positional) != 1 {
        return ErrMissingPodName
    }

    l.podName = positional[0]
    return nil
}

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no reason in particular, this definitely reads a lot clearer

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so the flag lib doesn't actually handle parsing keyword arguments in this situation because it stops parsing args once it sees the first positional argument:

Flag parsing stops just before the first non-flag argument ("-" is a non-flag argument) or after the terminator "--".
from:
https://pkg.go.dev/flag#hdr-Command_line_flag_syntax

so we have to pull all the positional arguments out of the args slice before calling parse on them

if len(args) == 0 {
return ErrIncorrectArgFormat
}

positional := []string{}
// Separate positional args from keyed args
for _, arg := range args {
if strings.HasPrefix(arg, "-") {
break
}
positional = append(positional, arg)
}
keyed := args[len(positional):]

if len(positional) != 1 {
return ErrIncorrectArgFormat
}

l.podName = positional[0]

err := l.set.Parse(keyed)
if err != nil {
return err
}
return nil
}

func (l *LogLevelCommand) validateFlags() error {
if l.namespace == "" {
return nil
}

errs := validation.ValidateNamespaceName(l.namespace, false)
if len(errs) > 0 {
return fmt.Errorf("invalid namespace name passed for -namespace/-n: %v", strings.Join(errs, "; "))
}

return nil
}

func (l *LogLevelCommand) initKubernetes() error {
settings := helmCLI.New()
var err error

if l.kubeConfig != "" {
settings.KubeConfig = l.kubeConfig
}

if l.kubeContext != "" {
settings.KubeContext = l.kubeContext
}

if l.restConfig == nil {
l.restConfig, err = settings.RESTClientGetter().ToRESTConfig()
if err != nil {
return fmt.Errorf("error creating Kubernetes REST config %v", err)
}

}

if l.kubernetes == nil {
l.kubernetes, err = kubernetes.NewForConfig(l.restConfig)
if err != nil {
return fmt.Errorf("error creating Kubernetes client %v", err)
}
}
if l.namespace == "" {
l.namespace = settings.Namespace()
}

return nil
}

// fetchAdminPorts retrieves all admin ports for Envoy Proxies running in a pod given namespace.
func (l *LogLevelCommand) fetchAdminPorts() (map[string]int, error) {
adminPorts := make(map[string]int, 0)
pod, err := l.kubernetes.CoreV1().Pods(l.namespace).Get(l.Ctx, l.podName, metav1.GetOptions{})
if err != nil {
return adminPorts, err
}

connectService, isMultiport := pod.Annotations["consul.hashicorp.com/connect-service"]

if !isMultiport {
// Return the default port configuration.
adminPorts[l.podName] = defaultAdminPort
return adminPorts, nil
}

for idx, svc := range strings.Split(connectService, ",") {
adminPorts[svc] = defaultAdminPort + idx
}

return adminPorts, nil
}

func (l *LogLevelCommand) fetchLogLevels(adminPorts map[string]int) (map[string]LoggerConfig, error) {
loggers := make(map[string]LoggerConfig, 0)

for name, port := range adminPorts {
pf := common.PortForward{
Namespace: l.namespace,
PodName: l.podName,
RemotePort: port,
KubeClient: l.kubernetes,
RestConfig: l.restConfig,
}

logLevels, err := l.logLevelFetcher(l.Ctx, &pf)
if err != nil {
return loggers, err
}
loggers[name] = logLevels
}
return loggers, nil
}

// FetchLogLevel requests the logging endpoint from Envoy Admin Interface for a given port
// more can be read about that endpoint https://www.envoyproxy.io/docs/envoy/latest/operations/admin#post--logging
func FetchLogLevel(ctx context.Context, portForward common.PortForwarder) (LoggerConfig, error) {
endpoint, err := portForward.Open(ctx)
if err != nil {
return nil, err
}

defer portForward.Close()

// this endpoint does not support returning json, so we've gotta parse the plain text
response, err := http.Post(fmt.Sprintf("http://%s/logging", endpoint), "text/plain", bytes.NewBuffer([]byte{}))
if err != nil {
return nil, err
}

body, err := io.ReadAll(response.Body)
if err != nil {
return nil, fmt.Errorf("failed to reach envoy: %v", err)
}

if response.StatusCode >= 400 {
return nil, fmt.Errorf("call to envoy failed with status code: %d, and message: %s", response.StatusCode, body)
}

loggers := strings.Split(string(body), "\n")
if len(loggers) == 0 {
return nil, ErrNoLoggersReturned
}

logLevels := make(map[string]string)
var name string
var level string

// the first line here is just a header
for _, logger := range loggers[1:] {
jm96441n marked this conversation as resolved.
Show resolved Hide resolved
if len(logger) == 0 {
continue
}
fmt.Sscanf(logger, "%s %s", &name, &level)
name = strings.TrimRight(name, ":")
logLevels[name] = level
}

return logLevels, nil
}

func (l *LogLevelCommand) outputLevels(logLevels map[string]LoggerConfig) {
l.UI.Output(fmt.Sprintf("Envoy log configuration for %s in namespace default:", l.podName))
for n, levels := range logLevels {
l.UI.Output(fmt.Sprintf("Log Levels for %s", n), terminal.WithHeaderStyle())
table := terminal.NewTable("Name", "Level")
for name, level := range levels {
table.AddRow([]string{name, level}, []string{"", levelToColor[level]})
}
l.UI.Table(table)
l.UI.Output("")
}
}

func (l *LogLevelCommand) Help() string {
l.once.Do(l.init)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wondering what the call to init here is for? Is Help ever called without first calling Run? If so, maybe consider adding a New method that initializes the struct and calls init rather than the bare initialization in commands.go

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AFAIK it can be called before Run, I kept this pattern with the init to match the rest of the codebase tbh, I'd typically prefer to use a New command to ensure everything is initialized first prior to running but went this route to keep it in line with the rest of the repo

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, it's a quirk of this CLI library. Help can be called without first calling Run.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so if we moved to a constructor and called that in the commands.go rather than just initializing the struct we could handle that, the issue would be that we'd be instantiating all of our deps/connections even if this command wasn't called which could slow down usage for other commands (unless the CLI lazily inits the commands, which it kinda looks like it does)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nevermind what I said about slowing down initialization with deps, I was mistaken in thinking we could have access to the args and pass them to the constructor but it doesn't look like that's the case so we still init the deps in Run but are able to push creating the flags into a constructor, I put together what it would look like here, the only drawback is that we'd be deviating from the pattern in the rest of the cli

return fmt.Sprintf("%s\n\nUsage: consul-k8s proxy log <pod-name> [flags]\n\n%s", l.Synopsis(), l.help)
}

func (l *LogLevelCommand) Synopsis() string {
return "Inspect and Modify the Envoy Log configuration for a given Pod."
}

// AutocompleteFlags returns a mapping of supported flags and autocomplete
// options for this command. The map key for the Flags map should be the
// complete flag such as "-foo" or "--foo".
func (l *LogLevelCommand) AutocompleteFlags() complete.Flags {
return complete.Flags{
fmt.Sprintf("-%s", flagNameNamespace): complete.PredictNothing,
fmt.Sprintf("-%s", flagNameKubeConfig): complete.PredictFiles("*"),
fmt.Sprintf("-%s", flagNameKubeContext): complete.PredictNothing,
}
}

// AutocompleteArgs returns the argument predictor for this command.
// Since argument completion is not supported, this will return
// complete.PredictNothing.
func (l *LogLevelCommand) AutocompleteArgs() complete.Predictor {
return complete.PredictNothing
}

func (l *LogLevelCommand) logOutputAndDie(err error) int {
l.UI.Output(err.Error(), terminal.WithErrorStyle())
l.UI.Output(fmt.Sprintf("\n%s", l.Help()))
return 1
}
Loading