Skip to content

Commit 23e8eb2

Browse files
scout/advise: first iteration of the src scout advise subcommand (#988)
1 parent 7fc6627 commit 23e8eb2

File tree

14 files changed

+924
-529
lines changed

14 files changed

+924
-529
lines changed

cmd/src/scout_advise.go

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"flag"
6+
"fmt"
7+
"path/filepath"
8+
9+
"github.com/docker/docker/client"
10+
"github.com/sourcegraph/sourcegraph/lib/errors"
11+
"github.com/sourcegraph/src-cli/internal/scout/advise"
12+
"k8s.io/client-go/kubernetes"
13+
"k8s.io/client-go/tools/clientcmd"
14+
"k8s.io/client-go/util/homedir"
15+
metricsv "k8s.io/metrics/pkg/client/clientset/versioned"
16+
)
17+
18+
func init() {
19+
cmdUsage := `'src scout advise' is a tool that makes resource allocation recommendations. Based on current usage.
20+
Part of the EXPERIMENTAL "src scout" tool.
21+
22+
Examples
23+
Make recommendations for all pods in a kubernetes deployment of Sourcegraph.
24+
$ src scout advise
25+
26+
Make recommendations for all containers in a Docker deployment of Sourcegraph.
27+
$ src scout advise
28+
29+
Make recommendations for specific pod:
30+
$ src scout advise --pod <podname>
31+
32+
Make recommendations for specific container:
33+
$ src scout advise --container <containername>
34+
35+
Add namespace if using namespace in a Kubernetes cluster
36+
$ src scout advise --namespace <namespace>
37+
`
38+
39+
flagSet := flag.NewFlagSet("advise", flag.ExitOnError)
40+
usage := func() {
41+
fmt.Fprintf(flag.CommandLine.Output(), "Usage of 'src scout %s':\n", flagSet.Name())
42+
flagSet.PrintDefaults()
43+
fmt.Println(cmdUsage)
44+
}
45+
46+
var (
47+
kubeConfig *string
48+
namespace = flagSet.String("namespace", "", "(optional) specify the kubernetes namespace to use")
49+
pod = flagSet.String("pod", "", "(optional) specify a single pod")
50+
container = flagSet.String("container", "", "(optional) specify a single container")
51+
docker = flagSet.Bool("docker", false, "(optional) using docker deployment")
52+
)
53+
54+
if home := homedir.HomeDir(); home != "" {
55+
kubeConfig = flagSet.String(
56+
"kubeconfig",
57+
filepath.Join(home, ".kube", "config"),
58+
"(optional) absolute path to the kubeconfig file",
59+
)
60+
} else {
61+
kubeConfig = flagSet.String("kubeconfig", "", "absolute path to the kubeconfig file")
62+
}
63+
64+
handler := func(args []string) error {
65+
if err := flagSet.Parse(args); err != nil {
66+
return err
67+
}
68+
69+
config, err := clientcmd.BuildConfigFromFlags("", *kubeConfig)
70+
if err != nil {
71+
return errors.Wrap(err, "failed to load .kube config: ")
72+
}
73+
74+
clientSet, err := kubernetes.NewForConfig(config)
75+
if err != nil {
76+
return errors.Wrap(err, "failed to initiate kubernetes client: ")
77+
}
78+
79+
metricsClient, err := metricsv.NewForConfig(config)
80+
if err != nil {
81+
return errors.Wrap(err, "failed to initiate metrics client")
82+
}
83+
84+
var options []advise.Option
85+
86+
if *namespace != "" {
87+
options = append(options, advise.WithNamespace(*namespace))
88+
}
89+
if *pod != "" {
90+
options = append(options, advise.WithPod(*pod))
91+
}
92+
if *container != "" || *docker {
93+
if *container != "" {
94+
options = append(options, advise.WithContainer(*container))
95+
}
96+
97+
dockerClient, err := client.NewClientWithOpts(client.FromEnv)
98+
if err != nil {
99+
return errors.Wrap(err, "error creating docker client: ")
100+
}
101+
102+
return advise.Docker(context.Background(), *dockerClient, options...)
103+
}
104+
105+
return advise.K8s(
106+
context.Background(),
107+
clientSet,
108+
metricsClient,
109+
config,
110+
options...,
111+
)
112+
}
113+
114+
scoutCommands = append(scoutCommands, &command{
115+
flagSet: flagSet,
116+
handler: handler,
117+
usageFunc: usage,
118+
})
119+
120+
}

internal/scout/advise/advise.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package advise
2+
3+
import "github.com/sourcegraph/src-cli/internal/scout"
4+
5+
type Option = func(config *scout.Config)
6+
7+
const (
8+
OVER_100 = "\t%s %s: Your %s usage is over 100%% (%.2f%%). Add more %s."
9+
OVER_80 = "\t%s %s: Your %s usage is over 80%% (%.2f%%). Consider raising limits."
10+
OVER_40 = "\t%s %s: Your %s usage is under 80%% (%.2f%%). Keep %s allocation the same."
11+
UNDER_40 = "\t%s %s: Your %s usage is under 40%% (%.2f%%). Consider lowering limits."
12+
)
13+
14+
func WithNamespace(namespace string) Option {
15+
return func(config *scout.Config) {
16+
config.Namespace = namespace
17+
}
18+
}
19+
20+
func WithPod(podname string) Option {
21+
return func(config *scout.Config) {
22+
config.Pod = podname
23+
}
24+
}
25+
26+
func WithContainer(containerName string) Option {
27+
return func(config *scout.Config) {
28+
config.Container = containerName
29+
}
30+
}

internal/scout/advise/docker.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package advise
2+
3+
import (
4+
"context"
5+
"fmt"
6+
7+
"github.com/docker/docker/api/types"
8+
"github.com/docker/docker/client"
9+
"github.com/sourcegraph/sourcegraph/lib/errors"
10+
"github.com/sourcegraph/src-cli/internal/scout"
11+
)
12+
13+
func Docker(ctx context.Context, client client.Client, opts ...Option) error {
14+
cfg := &scout.Config{
15+
Namespace: "default",
16+
Docker: true,
17+
Pod: "",
18+
Container: "",
19+
Spy: false,
20+
DockerClient: &client,
21+
}
22+
23+
for _, opt := range opts {
24+
opt(cfg)
25+
}
26+
27+
containers, err := client.ContainerList(ctx, types.ContainerListOptions{})
28+
if err != nil {
29+
return errors.Wrap(err, "could not get list of containers")
30+
}
31+
32+
PrintContainers(containers)
33+
return nil
34+
}
35+
36+
func PrintContainers(containers []types.Container) {
37+
for _, c := range containers {
38+
fmt.Println(c.Image)
39+
}
40+
}

internal/scout/advise/k8s.go

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
package advise
2+
3+
import (
4+
"context"
5+
"fmt"
6+
7+
"github.com/sourcegraph/sourcegraph/lib/errors"
8+
"github.com/sourcegraph/src-cli/internal/scout"
9+
"github.com/sourcegraph/src-cli/internal/scout/kube"
10+
v1 "k8s.io/api/core/v1"
11+
"k8s.io/client-go/kubernetes"
12+
"k8s.io/client-go/rest"
13+
metricsv "k8s.io/metrics/pkg/client/clientset/versioned"
14+
)
15+
16+
func K8s(
17+
ctx context.Context,
18+
k8sClient *kubernetes.Clientset,
19+
metricsClient *metricsv.Clientset,
20+
restConfig *rest.Config,
21+
opts ...Option,
22+
) error {
23+
cfg := &scout.Config{
24+
Namespace: "default",
25+
Pod: "",
26+
Container: "",
27+
Spy: false,
28+
Docker: false,
29+
RestConfig: restConfig,
30+
K8sClient: k8sClient,
31+
DockerClient: nil,
32+
MetricsClient: metricsClient,
33+
}
34+
35+
for _, opt := range opts {
36+
opt(cfg)
37+
}
38+
39+
pods, err := kube.GetPods(ctx, cfg)
40+
if err != nil {
41+
return errors.Wrap(err, "could not get list of pods")
42+
}
43+
44+
if cfg.Pod != "" {
45+
pod, err := kube.GetPod(cfg.Pod, pods)
46+
if err != nil {
47+
return errors.Wrap(err, "could not get pod")
48+
}
49+
50+
err = Advise(ctx, cfg, pod)
51+
if err != nil {
52+
return errors.Wrap(err, "could not advise")
53+
}
54+
return nil
55+
}
56+
57+
for _, pod := range pods {
58+
err = Advise(ctx, cfg, pod)
59+
if err != nil {
60+
return errors.Wrap(err, "could not advise")
61+
}
62+
}
63+
64+
return nil
65+
}
66+
67+
func Advise(ctx context.Context, cfg *scout.Config, pod v1.Pod) error {
68+
var advice []string
69+
usageMetrics, err := getUsageMetrics(ctx, cfg, pod)
70+
if err != nil {
71+
return errors.Wrap(err, "could not get usage metrics")
72+
}
73+
74+
for _, metrics := range usageMetrics {
75+
cpuAdvice := checkUsage(metrics.CpuUsage, "CPU", metrics.ContainerName, pod.Name)
76+
advice = append(advice, cpuAdvice)
77+
78+
memoryAdvice := checkUsage(metrics.MemoryUsage, "memory", metrics.ContainerName, pod.Name)
79+
advice = append(advice, memoryAdvice)
80+
81+
if metrics.Storage != nil {
82+
storageAdvice := checkUsage(metrics.StorageUsage, "storage", metrics.ContainerName, pod.Name)
83+
advice = append(advice, storageAdvice)
84+
}
85+
86+
fmt.Println(scout.EmojiFingerPointRight, pod.Name)
87+
for _, msg := range advice {
88+
fmt.Println(msg)
89+
}
90+
}
91+
92+
return nil
93+
}
94+
95+
func getUsageMetrics(ctx context.Context, cfg *scout.Config, pod v1.Pod) ([]scout.UsageStats, error) {
96+
var usages []scout.UsageStats
97+
var usage scout.UsageStats
98+
podMetrics, err := kube.GetPodMetrics(ctx, cfg, pod)
99+
if err != nil {
100+
return usages, errors.Wrap(err, "while attempting to fetch pod metrics")
101+
}
102+
103+
containerMetrics := &scout.ContainerMetrics{
104+
PodName: cfg.Pod,
105+
Limits: map[string]scout.Resources{},
106+
}
107+
108+
if err = kube.AddLimits(ctx, cfg, &pod, containerMetrics); err != nil {
109+
return usages, errors.Wrap(err, "failed to get get container metrics")
110+
}
111+
112+
for _, container := range podMetrics.Containers {
113+
usage, err = kube.GetUsage(ctx, cfg, *containerMetrics, pod, container)
114+
if err != nil {
115+
return usages, errors.Wrapf(err, "could not compile usages data for row: %s\n", container.Name)
116+
}
117+
usages = append(usages, usage)
118+
}
119+
120+
return usages, nil
121+
}
122+
123+
func checkUsage(usage float64, resourceType, container, pod string) string {
124+
var message string
125+
126+
switch {
127+
case usage >= 100:
128+
message = fmt.Sprintf(
129+
OVER_100,
130+
scout.FlashingLightEmoji,
131+
container,
132+
resourceType,
133+
usage,
134+
resourceType,
135+
)
136+
case usage >= 80 && usage < 100:
137+
message = fmt.Sprintf(
138+
OVER_80,
139+
scout.WarningSign,
140+
container,
141+
resourceType,
142+
usage,
143+
)
144+
case usage >= 40 && usage < 80:
145+
message = fmt.Sprintf(
146+
OVER_40,
147+
scout.SuccessEmoji,
148+
container,
149+
resourceType,
150+
usage,
151+
resourceType,
152+
)
153+
default:
154+
message = fmt.Sprintf(
155+
UNDER_40,
156+
scout.WarningSign,
157+
container,
158+
resourceType,
159+
usage,
160+
)
161+
}
162+
163+
return message
164+
}

internal/scout/constants.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package scout
2+
3+
const (
4+
ABillion float64 = 1_000_000_000
5+
EmojiFingerPointRight = "👉"
6+
FlashingLightEmoji = "🚨"
7+
SuccessEmoji = "✅"
8+
WarningSign = "⚠️ " // why does this need an extra space to align?!?!
9+
)

internal/scout/helpers.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package scout
2+
3+
// contains checks if a string slice contains a given value.
4+
func Contains(slice []string, value string) bool {
5+
for _, v := range slice {
6+
if v == value {
7+
return true
8+
}
9+
}
10+
return false
11+
}
12+
13+
// getPercentage calculates the percentage of x in relation to y.
14+
func GetPercentage(x, y float64) float64 {
15+
if x == 0 {
16+
return 0
17+
}
18+
19+
if y == 0 {
20+
return 0
21+
}
22+
23+
return x * 100 / y
24+
}

0 commit comments

Comments
 (0)