Skip to content

Commit

Permalink
scout/advise: first iteration of the src scout advise subcommand (#988
Browse files Browse the repository at this point in the history
)
  • Loading branch information
jasonhawkharris authored Jun 6, 2023
1 parent 7fc6627 commit 23e8eb2
Show file tree
Hide file tree
Showing 14 changed files with 924 additions and 529 deletions.
120 changes: 120 additions & 0 deletions cmd/src/scout_advise.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package main

import (
"context"
"flag"
"fmt"
"path/filepath"

"github.com/docker/docker/client"
"github.com/sourcegraph/sourcegraph/lib/errors"
"github.com/sourcegraph/src-cli/internal/scout/advise"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/tools/clientcmd"
"k8s.io/client-go/util/homedir"
metricsv "k8s.io/metrics/pkg/client/clientset/versioned"
)

func init() {
cmdUsage := `'src scout advise' is a tool that makes resource allocation recommendations. Based on current usage.
Part of the EXPERIMENTAL "src scout" tool.
Examples
Make recommendations for all pods in a kubernetes deployment of Sourcegraph.
$ src scout advise
Make recommendations for all containers in a Docker deployment of Sourcegraph.
$ src scout advise
Make recommendations for specific pod:
$ src scout advise --pod <podname>
Make recommendations for specific container:
$ src scout advise --container <containername>
Add namespace if using namespace in a Kubernetes cluster
$ src scout advise --namespace <namespace>
`

flagSet := flag.NewFlagSet("advise", flag.ExitOnError)
usage := func() {
fmt.Fprintf(flag.CommandLine.Output(), "Usage of 'src scout %s':\n", flagSet.Name())
flagSet.PrintDefaults()
fmt.Println(cmdUsage)
}

var (
kubeConfig *string
namespace = flagSet.String("namespace", "", "(optional) specify the kubernetes namespace to use")
pod = flagSet.String("pod", "", "(optional) specify a single pod")
container = flagSet.String("container", "", "(optional) specify a single container")
docker = flagSet.Bool("docker", false, "(optional) using docker deployment")
)

if home := homedir.HomeDir(); home != "" {
kubeConfig = flagSet.String(
"kubeconfig",
filepath.Join(home, ".kube", "config"),
"(optional) absolute path to the kubeconfig file",
)
} else {
kubeConfig = flagSet.String("kubeconfig", "", "absolute path to the kubeconfig file")
}

handler := func(args []string) error {
if err := flagSet.Parse(args); err != nil {
return err
}

config, err := clientcmd.BuildConfigFromFlags("", *kubeConfig)
if err != nil {
return errors.Wrap(err, "failed to load .kube config: ")
}

clientSet, err := kubernetes.NewForConfig(config)
if err != nil {
return errors.Wrap(err, "failed to initiate kubernetes client: ")
}

metricsClient, err := metricsv.NewForConfig(config)
if err != nil {
return errors.Wrap(err, "failed to initiate metrics client")
}

var options []advise.Option

if *namespace != "" {
options = append(options, advise.WithNamespace(*namespace))
}
if *pod != "" {
options = append(options, advise.WithPod(*pod))
}
if *container != "" || *docker {
if *container != "" {
options = append(options, advise.WithContainer(*container))
}

dockerClient, err := client.NewClientWithOpts(client.FromEnv)
if err != nil {
return errors.Wrap(err, "error creating docker client: ")
}

return advise.Docker(context.Background(), *dockerClient, options...)
}

return advise.K8s(
context.Background(),
clientSet,
metricsClient,
config,
options...,
)
}

scoutCommands = append(scoutCommands, &command{
flagSet: flagSet,
handler: handler,
usageFunc: usage,
})

}
30 changes: 30 additions & 0 deletions internal/scout/advise/advise.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package advise

import "github.com/sourcegraph/src-cli/internal/scout"

type Option = func(config *scout.Config)

const (
OVER_100 = "\t%s %s: Your %s usage is over 100%% (%.2f%%). Add more %s."
OVER_80 = "\t%s %s: Your %s usage is over 80%% (%.2f%%). Consider raising limits."
OVER_40 = "\t%s %s: Your %s usage is under 80%% (%.2f%%). Keep %s allocation the same."
UNDER_40 = "\t%s %s: Your %s usage is under 40%% (%.2f%%). Consider lowering limits."
)

func WithNamespace(namespace string) Option {
return func(config *scout.Config) {
config.Namespace = namespace
}
}

func WithPod(podname string) Option {
return func(config *scout.Config) {
config.Pod = podname
}
}

func WithContainer(containerName string) Option {
return func(config *scout.Config) {
config.Container = containerName
}
}
40 changes: 40 additions & 0 deletions internal/scout/advise/docker.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package advise

import (
"context"
"fmt"

"github.com/docker/docker/api/types"
"github.com/docker/docker/client"
"github.com/sourcegraph/sourcegraph/lib/errors"
"github.com/sourcegraph/src-cli/internal/scout"
)

func Docker(ctx context.Context, client client.Client, opts ...Option) error {
cfg := &scout.Config{
Namespace: "default",
Docker: true,
Pod: "",
Container: "",
Spy: false,
DockerClient: &client,
}

for _, opt := range opts {
opt(cfg)
}

containers, err := client.ContainerList(ctx, types.ContainerListOptions{})
if err != nil {
return errors.Wrap(err, "could not get list of containers")
}

PrintContainers(containers)
return nil
}

func PrintContainers(containers []types.Container) {
for _, c := range containers {
fmt.Println(c.Image)
}
}
164 changes: 164 additions & 0 deletions internal/scout/advise/k8s.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
package advise

import (
"context"
"fmt"

"github.com/sourcegraph/sourcegraph/lib/errors"
"github.com/sourcegraph/src-cli/internal/scout"
"github.com/sourcegraph/src-cli/internal/scout/kube"
v1 "k8s.io/api/core/v1"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
metricsv "k8s.io/metrics/pkg/client/clientset/versioned"
)

func K8s(
ctx context.Context,
k8sClient *kubernetes.Clientset,
metricsClient *metricsv.Clientset,
restConfig *rest.Config,
opts ...Option,
) error {
cfg := &scout.Config{
Namespace: "default",
Pod: "",
Container: "",
Spy: false,
Docker: false,
RestConfig: restConfig,
K8sClient: k8sClient,
DockerClient: nil,
MetricsClient: metricsClient,
}

for _, opt := range opts {
opt(cfg)
}

pods, err := kube.GetPods(ctx, cfg)
if err != nil {
return errors.Wrap(err, "could not get list of pods")
}

if cfg.Pod != "" {
pod, err := kube.GetPod(cfg.Pod, pods)
if err != nil {
return errors.Wrap(err, "could not get pod")
}

err = Advise(ctx, cfg, pod)
if err != nil {
return errors.Wrap(err, "could not advise")
}
return nil
}

for _, pod := range pods {
err = Advise(ctx, cfg, pod)
if err != nil {
return errors.Wrap(err, "could not advise")
}
}

return nil
}

func Advise(ctx context.Context, cfg *scout.Config, pod v1.Pod) error {
var advice []string
usageMetrics, err := getUsageMetrics(ctx, cfg, pod)
if err != nil {
return errors.Wrap(err, "could not get usage metrics")
}

for _, metrics := range usageMetrics {
cpuAdvice := checkUsage(metrics.CpuUsage, "CPU", metrics.ContainerName, pod.Name)
advice = append(advice, cpuAdvice)

memoryAdvice := checkUsage(metrics.MemoryUsage, "memory", metrics.ContainerName, pod.Name)
advice = append(advice, memoryAdvice)

if metrics.Storage != nil {
storageAdvice := checkUsage(metrics.StorageUsage, "storage", metrics.ContainerName, pod.Name)
advice = append(advice, storageAdvice)
}

fmt.Println(scout.EmojiFingerPointRight, pod.Name)
for _, msg := range advice {
fmt.Println(msg)
}
}

return nil
}

func getUsageMetrics(ctx context.Context, cfg *scout.Config, pod v1.Pod) ([]scout.UsageStats, error) {
var usages []scout.UsageStats
var usage scout.UsageStats
podMetrics, err := kube.GetPodMetrics(ctx, cfg, pod)
if err != nil {
return usages, errors.Wrap(err, "while attempting to fetch pod metrics")
}

containerMetrics := &scout.ContainerMetrics{
PodName: cfg.Pod,
Limits: map[string]scout.Resources{},
}

if err = kube.AddLimits(ctx, cfg, &pod, containerMetrics); err != nil {
return usages, errors.Wrap(err, "failed to get get container metrics")
}

for _, container := range podMetrics.Containers {
usage, err = kube.GetUsage(ctx, cfg, *containerMetrics, pod, container)
if err != nil {
return usages, errors.Wrapf(err, "could not compile usages data for row: %s\n", container.Name)
}
usages = append(usages, usage)
}

return usages, nil
}

func checkUsage(usage float64, resourceType, container, pod string) string {
var message string

switch {
case usage >= 100:
message = fmt.Sprintf(
OVER_100,
scout.FlashingLightEmoji,
container,
resourceType,
usage,
resourceType,
)
case usage >= 80 && usage < 100:
message = fmt.Sprintf(
OVER_80,
scout.WarningSign,
container,
resourceType,
usage,
)
case usage >= 40 && usage < 80:
message = fmt.Sprintf(
OVER_40,
scout.SuccessEmoji,
container,
resourceType,
usage,
resourceType,
)
default:
message = fmt.Sprintf(
UNDER_40,
scout.WarningSign,
container,
resourceType,
usage,
)
}

return message
}
9 changes: 9 additions & 0 deletions internal/scout/constants.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package scout

const (
ABillion float64 = 1_000_000_000
EmojiFingerPointRight = "👉"
FlashingLightEmoji = "🚨"
SuccessEmoji = "✅"
WarningSign = "⚠️ " // why does this need an extra space to align?!?!
)
24 changes: 24 additions & 0 deletions internal/scout/helpers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package scout

// contains checks if a string slice contains a given value.
func Contains(slice []string, value string) bool {
for _, v := range slice {
if v == value {
return true
}
}
return false
}

// getPercentage calculates the percentage of x in relation to y.
func GetPercentage(x, y float64) float64 {
if x == 0 {
return 0
}

if y == 0 {
return 0
}

return x * 100 / y
}
Loading

0 comments on commit 23e8eb2

Please sign in to comment.