Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
3 changes: 2 additions & 1 deletion .github/workflows/pr-check.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
53 changes: 53 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <kind/name>`
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 <snapshot-request>`
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
Expand All @@ -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:
Expand Down
3 changes: 2 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module github.com/kedify/cli

go 1.26
go 1.26.5

require (
github.com/alecthomas/kong v1.12.1
Expand All @@ -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
)
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
94 changes: 94 additions & 0 deletions internal/cli/analyze/recommendations.go
Original file line number Diff line number Diff line change
@@ -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
}
113 changes: 113 additions & 0 deletions internal/cli/analyze/recommendations_test.go
Original file line number Diff line number Diff line change
@@ -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
}
6 changes: 6 additions & 0 deletions internal/cli/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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."`
Expand All @@ -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."`
Expand Down
Loading