Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 121 additions & 0 deletions cmd/src/scout_advise.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
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 specific pod:
$ src scout advise --pod <podname>

Add namespace if using namespace in a Kubernetes cluster
$ src scout advise --namespace <namespace>

Output advice to file
$ src scout advise --o path/to/file
`

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")
output = flagSet.String("o", "", "(optional) output advice to file")
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 *output != "" {
options = append(options, advise.WithOutput(*output))
}
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,
})

}
36 changes: 36 additions & 0 deletions internal/scout/advise/advise.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
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
}
}

func WithOutput(pathToFile string) Option {
return func(config *scout.Config) {
config.Output = pathToFile
}
}
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)
}
}
195 changes: 195 additions & 0 deletions internal/scout/advise/k8s.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
package advise

import (
"context"
"fmt"
"os"

"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: "",
Output: "",
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
}

// Advise generates resource allocation advice for a Kubernetes pod.
// The function fetches usage metrics for each container in the pod. It then
// checks the usage percentages against thresholds to determine if more or less
// of a resource is needed. Advice is generated and either printed to the console
// or output to a file depending on the cfg.Output field.
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)
}

if cfg.Output != "" {
outputToFile(ctx, cfg, pod, advice)
} else {
for _, msg := range advice {
fmt.Println(msg)
}
}
}

return nil
}

// outputToFile writes resource allocation advice for a Kubernetes pod to a file.
func outputToFile(ctx context.Context, cfg *scout.Config, pod v1.Pod, advice []string) error {
file, err := os.OpenFile(cfg.Output, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return errors.Wrap(err, "failed to open file")
}
defer file.Close()

if _, err := fmt.Fprintf(file, "- %s\n", pod.Name); err != nil {
return errors.Wrap(err, "failed to write pod name to file")
}

for _, msg := range advice {
if _, err := fmt.Fprintf(file, "%s\n", msg); err != nil {
return errors.Wrap(err, "failed to write container advice to file")
}
}
return nil
}

// getUsageMetrics generates resource usage statistics for containers in a Kubernetes pod.
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
}
Loading