diff --git a/.github/workflows/pr-check.yaml b/.github/workflows/pr-check.yaml index 248160d..889e515 100644 --- a/.github/workflows/pr-check.yaml +++ b/.github/workflows/pr-check.yaml @@ -57,7 +57,8 @@ jobs: - name: Run staticcheck uses: dominikh/staticcheck-action@v1 with: - version: "v0.6.1" + version: "v0.7.0" + install-go: false - name: Run vulncheck run: | diff --git a/README.md b/README.md index dc7fecd..c549cd3 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,8 @@ The CLI currently focuses on authentication, cluster inspection, and applying re Prints the recommendations payload for a cluster id. - `kedify apply recommendations ` Applies recommendations from a saved JSON or YAML file to a Helm values file and can emit `json`, `diff`, or `override` output. +- `kedify analyze recommendations ` + Generates recommendations from a normalized snapshot without using Kedify SaaS. - `kedify metrics` Opens an interactive Prometheus metric explorer, builds and validates a PromQL query, previews it as an ASCII graph, and generates YAML for a `ScaledObject`, a `MetricPredictor`, or both. Creating the resources in Kubernetes is an explicit opt-in. - Output formatting @@ -47,6 +49,57 @@ The binary will be available at `./bin/kedify`. - `make` - `kubectl` when using Prometheus discovery, port-forwarding, or resource creation +### Offline recommendation analysis + +The offline command consumes a normalized snapshot and runs the same analysis engine +used by Kedify services. The request is: + +```json +{ + "input": { + "schemaVersion": "resource-analysis-input/v1", + "observedIntervalHours": 24, + "containers": [ + { + "target": { + "namespace": "default", + "kind": "Deployment", + "name": "checkout", + "container": "api" + }, + "cpu": { + "aggregatedUsage": {"available": true, "value": 275}, + "currentRequest": {"available": true, "value": 200}, + "currentLimit": {"available": true, "value": 500} + }, + "memory": { + "aggregatedUsage": {"available": true, "value": 268435456}, + "currentRequest": {"available": true, "value": 134217728}, + "currentLimit": {"available": true, "value": 536870912} + } + } + ] + }, + "policy": {} +} +``` + +Run it from a file or standard input: + +```bash +./bin/kedify analyze recommendations ./snapshot-request.json +cat ./snapshot-request.json | ./bin/kedify analyze recommendations - +``` + +The analysis engine is included in the `kedify` binary. The command requires no +Kedify token, makes no network request, and does not need a separate runtime or +download. CPU `aggregatedUsage` in the request must already reflect the policy's +`max` or `percentile` selection. The result JSON includes the output schema, +detector, effective policy, evidence, data quality, and recommendations. Requests +over 16 MiB are rejected. CPU values are millicores and memory values are bytes. +Set `available` to `false` for a missing signal; an available value of `0` is a +measured zero. + ## Authentication Generate a Kedify API token at: diff --git a/go.mod b/go.mod index 07188b7..ceae3c3 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/kedify/cli -go 1.26 +go 1.26.5 require ( github.com/alecthomas/kong v1.12.1 @@ -10,6 +10,7 @@ require ( github.com/charmbracelet/lipgloss v1.1.0 github.com/google/uuid v1.6.0 github.com/guptarohit/asciigraph v0.7.3 + github.com/kedify/recommender v0.1.0 github.com/zalando/go-keyring v0.2.8 gopkg.in/yaml.v3 v3.0.1 ) diff --git a/go.sum b/go.sum index 91067d1..f0a1e14 100644 --- a/go.sum +++ b/go.sum @@ -64,6 +64,8 @@ github.com/guptarohit/asciigraph v0.7.3 h1:p05XDDn7cBTWiBqWb30mrwxd6oU0claAjqeyt github.com/guptarohit/asciigraph v0.7.3/go.mod h1:dYl5wwK4gNsnFf9Zp+l06rFiDZ5YtXM6x7SRWZ3KGag= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/kedify/recommender v0.1.0 h1:uXUPC3s7pzNIARkAilOl3zQLoDLbQIbBl0PRvrSspVk= +github.com/kedify/recommender v0.1.0/go.mod h1:EveD3V5VWf0RNMvsmFLk/Y3o8i8rJ3rCDi0pwPOQm4k= github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= diff --git a/internal/cli/analyze/recommendations.go b/internal/cli/analyze/recommendations.go new file mode 100644 index 0000000..d3815a1 --- /dev/null +++ b/internal/cli/analyze/recommendations.go @@ -0,0 +1,94 @@ +package analyze + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + + "github.com/kedify/recommender/analysis" + + clictx "github.com/kedify/cli/internal/cli/context" +) + +const maxSnapshotBytes = 16 << 20 + +type RecommendationsCmd struct { + Snapshot string `arg:"" name:"snapshot-request" help:"Path to a normalized snapshot request, or - for stdin."` +} + +type snapshotRequest struct { + Input analysis.Input `json:"input"` + Policy analysis.Policy `json:"policy"` +} + +func (c *RecommendationsCmd) Run(ctx *clictx.Context) error { + data, err := readSnapshot(c.Snapshot, ctx.Stdin) + if err != nil { + return err + } + + request, err := decodeSnapshot(data) + if err != nil { + return err + } + + result, err := analysis.Analyze(request.Input, request.Policy) + if err != nil { + return fmt.Errorf("analyze snapshot: %w", err) + } + + if err := json.NewEncoder(ctx.Stdout).Encode(result); err != nil { + return fmt.Errorf("write analysis result: %w", err) + } + return nil +} + +func readSnapshot(path string, stdin io.Reader) ([]byte, error) { + if path == "-" { + data, err := readSnapshotFrom(stdin) + if err != nil { + return nil, fmt.Errorf("read snapshot request from stdin: %w", err) + } + return data, nil + } + + file, err := os.Open(filepath.Clean(path)) // #nosec G304 -- the snapshot path is explicit user input. + if err != nil { + return nil, fmt.Errorf("read snapshot request %q: %w", path, err) + } + defer func() { _ = file.Close() }() + + data, err := readSnapshotFrom(file) + if err != nil { + return nil, fmt.Errorf("read snapshot request %q: %w", path, err) + } + return data, nil +} + +func readSnapshotFrom(reader io.Reader) ([]byte, error) { + data, err := io.ReadAll(io.LimitReader(reader, maxSnapshotBytes+1)) + if err != nil { + return nil, err + } + if len(data) > maxSnapshotBytes { + return nil, fmt.Errorf("request exceeds %d-byte limit", maxSnapshotBytes) + } + return data, nil +} + +func decodeSnapshot(data []byte) (snapshotRequest, error) { + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + + var request snapshotRequest + if err := decoder.Decode(&request); err != nil { + return snapshotRequest{}, fmt.Errorf("invalid snapshot request: %w", err) + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + return snapshotRequest{}, fmt.Errorf("invalid snapshot request: expected one JSON object") + } + return request, nil +} diff --git a/internal/cli/analyze/recommendations_test.go b/internal/cli/analyze/recommendations_test.go new file mode 100644 index 0000000..630b56c --- /dev/null +++ b/internal/cli/analyze/recommendations_test.go @@ -0,0 +1,113 @@ +package analyze + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/kedify/recommender/analysis" + + clictx "github.com/kedify/cli/internal/cli/context" +) + +const validSnapshotRequest = `{"input":{"schemaVersion":"resource-analysis-input/v1","observedIntervalHours":24,"containers":[]},"policy":{}}` + +func TestRecommendationsAnalyzesSnapshotFromStdin(t *testing.T) { + stdout := &bytes.Buffer{} + err := (&RecommendationsCmd{Snapshot: "-"}).Run(&clictx.Context{ + Stdin: strings.NewReader(validSnapshotRequest), + Stdout: stdout, + }) + if err != nil { + t.Fatalf("Run() error = %v", err) + } + + var result analysis.Output + if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { + t.Fatalf("decode output: %v", err) + } + if result.SchemaVersion != analysis.OutputSchemaVersion { + t.Fatalf("schemaVersion = %q, want %q", result.SchemaVersion, analysis.OutputSchemaVersion) + } + if result.DetectorVersion != analysis.ResourceRightSizeDetectorVersion { + t.Fatalf("detectorVersion = %q, want %q", result.DetectorVersion, analysis.ResourceRightSizeDetectorVersion) + } + if result.PolicyVersion == "" { + t.Fatal("policyVersion is empty") + } + if result.Results == nil || len(result.Results) != 0 { + t.Fatalf("results = %#v, want an empty array", result.Results) + } +} + +func TestRecommendationsReadsSnapshotFromFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "snapshot.json") + if err := os.WriteFile(path, []byte(validSnapshotRequest), 0o600); err != nil { + t.Fatal(err) + } + + err := (&RecommendationsCmd{Snapshot: path}).Run(&clictx.Context{Stdout: io.Discard}) + if err != nil { + t.Fatalf("Run() error = %v", err) + } +} + +func TestRecommendationsRejectsInvalidSnapshot(t *testing.T) { + tests := []struct { + name string + data string + want string + }{ + {name: "malformed JSON", data: `{`, want: "invalid snapshot request"}, + {name: "unknown field", data: `{"unknown":true}`, want: "unknown field"}, + {name: "multiple objects", data: validSnapshotRequest + `{}`, want: "expected one JSON object"}, + {name: "unsupported schema", data: `{"input":{"schemaVersion":"resource-analysis-input/v2","observedIntervalHours":24},"policy":{}}`, want: "unsupported input schema version"}, + {name: "invalid interval", data: `{"input":{"schemaVersion":"resource-analysis-input/v1"},"policy":{}}`, want: "observedIntervalHours must be greater than 0"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := (&RecommendationsCmd{Snapshot: "-"}).Run(&clictx.Context{ + Stdin: strings.NewReader(test.data), + Stdout: io.Discard, + }) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("Run() error = %v, want substring %q", err, test.want) + } + }) + } +} + +func TestRecommendationsRejectsOversizedSnapshot(t *testing.T) { + err := (&RecommendationsCmd{Snapshot: "-"}).Run(&clictx.Context{ + Stdin: bytes.NewReader(bytes.Repeat([]byte(" "), maxSnapshotBytes+1)), + Stdout: io.Discard, + }) + if err == nil || !strings.Contains(err.Error(), "request exceeds 16777216-byte limit") { + t.Fatalf("Run() error = %v", err) + } +} + +func TestRecommendationsReportsOutputFailure(t *testing.T) { + want := errors.New("write failed") + err := (&RecommendationsCmd{Snapshot: "-"}).Run(&clictx.Context{ + Stdin: strings.NewReader(validSnapshotRequest), + Stdout: errorWriter{err: want}, + }) + if !errors.Is(err, want) { + t.Fatalf("Run() error = %v, want %v", err, want) + } +} + +type errorWriter struct { + err error +} + +func (w errorWriter) Write([]byte) (int, error) { + return 0, w.err +} diff --git a/internal/cli/run.go b/internal/cli/run.go index 72b32dd..4a874b0 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -8,6 +8,7 @@ import ( "github.com/alecthomas/kong" "github.com/kedify/cli/internal/api" + "github.com/kedify/cli/internal/cli/analyze" "github.com/kedify/cli/internal/cli/apply" "github.com/kedify/cli/internal/cli/auth" clictx "github.com/kedify/cli/internal/cli/context" @@ -24,6 +25,7 @@ import ( type CLI struct { APIURL string `name:"apiurl" help:"Base URL for the Kedify API." default:"https://api.dev.kedify.io/v1" env:"KEDIFY_API_URL"` Token string `name:"token" help:"Kedify API token." env:"KEDIFY_TOKEN"` + Analyze AnalyzeCmd `cmd:"" help:"Analyze local Insights data."` Auth AuthCmd `cmd:"" help:"Authentication helpers."` Apply ApplyCmd `cmd:"" help:"Apply Kedify recommendations."` Delete DeleteCmd `cmd:"" help:"Delete Kedify resources."` @@ -32,6 +34,10 @@ type CLI struct { Metrics metrics.MetricsCmd `cmd:"" help:"Explore Prometheus metrics and generate autoscaling manifests."` } +type AnalyzeCmd struct { + Recommendations analyze.RecommendationsCmd `cmd:"" help:"Generate recommendations from a normalized snapshot request."` +} + type AuthCmd struct { Login auth.LoginCmd `cmd:"" help:"Read an auth token from stdin and store it locally. Generate a token at https://dashboard.dev.kedify.io/api-keys."` Token auth.AuthTokenCmd `cmd:"" help:"Print the auth token."`