From 66682a05a4d3a5e7e56a165f2740e3e498c63bf1 Mon Sep 17 00:00:00 2001 From: Zbynek Roubalik Date: Mon, 7 Sep 2026 19:40:26 +0200 Subject: [PATCH 01/10] feat: run offline recommendation analyzer Signed-off-by: Zbynek Roubalik --- README.md | 43 +++ internal/cli/analyze/recommendations.go | 215 +++++++++++++++ internal/cli/analyze/recommendations_test.go | 267 +++++++++++++++++++ internal/cli/run.go | 6 + 4 files changed, 531 insertions(+) create mode 100644 internal/cli/analyze/recommendations.go create mode 100644 internal/cli/analyze/recommendations_test.go diff --git a/README.md b/README.md index dc7fecd..0a721b4 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 ` + Runs a separately installed `kedify-analyzer` against a versioned 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,47 @@ 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 versioned normalized snapshot request and prints the +analyzer's JSON result unchanged. The request envelope is: + +```json +{ + "protocolVersion": "kedify-analyzer/v1", + "input": { + "schemaVersion": "resource-analysis-input/v1", + "observedIntervalHours": 24, + "containers": [] + }, + "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 - +``` + +Install a matching `kedify-analyzer` release before entering an air-gapped +environment and verify its published SHA-256 checksum. The CLI resolves an explicit +`--analyzer` path first, then `kedify-analyzer` beside the CLI executable, then +`kedify-analyzer` on `PATH`: + +```bash +./bin/kedify analyze recommendations ./snapshot-request.json \ + --analyzer ./tools/kedify-analyzer +``` + +The command requires no Kedify token, makes no SaaS request, and never downloads an +analyzer. It accepts only protocol `kedify-analyzer/v1`, input/output schemas +`resource-analysis-input/v1` and `resource-analysis-output/v1`, and engine version +`1`. CPU `aggregatedUsage` in the request must already reflect the policy's `max` or +`percentile` selection. Analyzer diagnostics stay on `stderr`; the validated JSON +result is the only `stdout` output. + ## Authentication Generate a Kedify API token at: diff --git a/internal/cli/analyze/recommendations.go b/internal/cli/analyze/recommendations.go new file mode 100644 index 0000000..e3360a4 --- /dev/null +++ b/internal/cli/analyze/recommendations.go @@ -0,0 +1,215 @@ +package analyze + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + + clictx "github.com/kedify/cli/internal/cli/context" + clierrors "github.com/kedify/cli/internal/errors" +) + +const ( + analyzerProtocolVersion = "kedify-analyzer/v1" + inputSchemaVersion = "resource-analysis-input/v1" + outputSchemaVersion = "resource-analysis-output/v1" + engineVersion = "1" + maxSnapshotBytes = 16 << 20 +) + +type RecommendationsCmd struct { + Snapshot string `arg:"" name:"snapshot-request" help:"Path to a versioned normalized snapshot request, or - for stdin."` + Analyzer string `name:"analyzer" help:"Path to the kedify-analyzer executable. Overrides sibling and PATH discovery." placeholder:"PATH"` +} + +type requestMetadata struct { + ProtocolVersion string `json:"protocolVersion"` + Input struct { + SchemaVersion string `json:"schemaVersion"` + } `json:"input"` +} + +type responseMetadata struct { + ProtocolVersion string `json:"protocolVersion"` + AnalyzerVersion string `json:"analyzerVersion"` + EngineVersion string `json:"engineVersion"` + InputSchemaVersion string `json:"inputSchemaVersion"` + OutputSchemaVersion string `json:"outputSchemaVersion"` + Output *outputMetadata `json:"output"` +} + +type outputMetadata struct { + SchemaVersion string `json:"schemaVersion"` + DetectorVersion string `json:"detectorVersion"` +} + +func (c *RecommendationsCmd) Run(ctx *clictx.Context) error { + request, err := readSnapshot(c.Snapshot, ctx.Stdin) + if err != nil { + return err + } + if err := validateRequest(request); err != nil { + return err + } + + cliExecutable, _ := os.Executable() + analyzer, err := discoverAnalyzer(c.Analyzer, cliExecutable, exec.LookPath) + if err != nil { + return err + } + + command := exec.Command(analyzer) // #nosec G204 -- the executable is selected explicitly or from trusted local discovery; no shell is used. + command.Stdin = bytes.NewReader(request) + command.Stderr = ctx.Stderr + var response bytes.Buffer + command.Stdout = &response + if err := command.Run(); err != nil { + var exitError *exec.ExitError + if errors.As(err, &exitError) { + return &clierrors.CommandResultError{ExitCode: exitError.ExitCode()} + } + return fmt.Errorf("start analyzer %q: %w", analyzer, err) + } + + if err := validateResponse(response.Bytes()); err != nil { + return fmt.Errorf("incompatible analyzer %q: %w", analyzer, err) + } + if _, err := io.Copy(ctx.Stdout, bytes.NewReader(response.Bytes())); err != nil { + return fmt.Errorf("write analyzer result: %w", err) + } + return nil +} + +func readSnapshot(path string, stdin io.Reader) ([]byte, error) { + if path == "-" { + request, err := readSnapshotFrom(stdin) + if err != nil { + return nil, fmt.Errorf("read snapshot request from stdin: %w", err) + } + return request, 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() }() + + request, err := readSnapshotFrom(file) + if err != nil { + return nil, fmt.Errorf("read snapshot request %q: %w", path, err) + } + return request, nil +} + +func readSnapshotFrom(reader io.Reader) ([]byte, error) { + request, err := io.ReadAll(io.LimitReader(reader, maxSnapshotBytes+1)) + if err != nil { + return nil, err + } + if len(request) > maxSnapshotBytes { + return nil, fmt.Errorf("request exceeds %d-byte limit", maxSnapshotBytes) + } + return request, nil +} + +func validateRequest(data []byte) error { + var metadata requestMetadata + if err := json.Unmarshal(data, &metadata); err != nil { + return fmt.Errorf("invalid snapshot request JSON: %w", err) + } + if metadata.ProtocolVersion != analyzerProtocolVersion { + return fmt.Errorf("snapshot request uses protocolVersion %q; this CLI requires %q", metadata.ProtocolVersion, analyzerProtocolVersion) + } + if metadata.Input.SchemaVersion != inputSchemaVersion { + return fmt.Errorf("snapshot request uses input schema %q; this CLI requires %q", metadata.Input.SchemaVersion, inputSchemaVersion) + } + return nil +} + +func validateResponse(data []byte) error { + var metadata responseMetadata + if err := json.Unmarshal(data, &metadata); err != nil { + return fmt.Errorf("invalid JSON response: %w", err) + } + if metadata.ProtocolVersion != analyzerProtocolVersion { + return fmt.Errorf("unsupported protocolVersion %q; expected %q", metadata.ProtocolVersion, analyzerProtocolVersion) + } + if strings.TrimSpace(metadata.AnalyzerVersion) == "" { + return errors.New("response is missing analyzerVersion") + } + if metadata.EngineVersion != engineVersion { + return fmt.Errorf("unsupported engineVersion %q; expected %q", metadata.EngineVersion, engineVersion) + } + if metadata.InputSchemaVersion != inputSchemaVersion { + return fmt.Errorf("unsupported inputSchemaVersion %q; expected %q", metadata.InputSchemaVersion, inputSchemaVersion) + } + if metadata.OutputSchemaVersion != outputSchemaVersion { + return fmt.Errorf("unsupported outputSchemaVersion %q; expected %q", metadata.OutputSchemaVersion, outputSchemaVersion) + } + if metadata.Output == nil { + return errors.New("response is missing output") + } + if metadata.Output.SchemaVersion != outputSchemaVersion { + return fmt.Errorf("output uses schemaVersion %q; expected %q", metadata.Output.SchemaVersion, outputSchemaVersion) + } + if metadata.Output.DetectorVersion != engineVersion { + return fmt.Errorf("output uses detectorVersion %q; expected %q", metadata.Output.DetectorVersion, engineVersion) + } + return nil +} + +func discoverAnalyzer(override, cliExecutable string, lookPath func(string) (string, error)) (string, error) { + if strings.TrimSpace(override) != "" { + path, err := filepath.Abs(filepath.Clean(override)) + if err != nil { + return "", fmt.Errorf("resolve --analyzer %q: %w", override, err) + } + if err := validateExecutable(path); err != nil { + return "", fmt.Errorf("--analyzer %q is not executable: %w", override, err) + } + return path, nil + } + + name := analyzerName() + if cliExecutable != "" { + sibling := filepath.Join(filepath.Dir(cliExecutable), name) + if err := validateExecutable(sibling); err == nil { + return sibling, nil + } + } + + path, err := lookPath(name) + if err != nil { + return "", fmt.Errorf("%s not found; install a matching analyzer beside kedify, add it to PATH, or pass --analyzer", name) + } + return path, nil +} + +func analyzerName() string { + if runtime.GOOS == "windows" { + return "kedify-analyzer.exe" + } + return "kedify-analyzer" +} + +func validateExecutable(path string) error { + info, err := os.Stat(path) + if err != nil { + return err + } + if !info.Mode().IsRegular() { + return errors.New("not a regular file") + } + if runtime.GOOS != "windows" && info.Mode().Perm()&0o111 == 0 { + return errors.New("execute permission is not set") + } + return nil +} diff --git a/internal/cli/analyze/recommendations_test.go b/internal/cli/analyze/recommendations_test.go new file mode 100644 index 0000000..f147829 --- /dev/null +++ b/internal/cli/analyze/recommendations_test.go @@ -0,0 +1,267 @@ +package analyze + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "runtime" + "strconv" + "strings" + "testing" + + clictx "github.com/kedify/cli/internal/cli/context" + clierrors "github.com/kedify/cli/internal/errors" +) + +const ( + helperModeEnv = "KEDIFY_TEST_ANALYZER_MODE" + helperExpectedEnv = "KEDIFY_TEST_ANALYZER_REQUEST" + helperExitInternal = 1 + helperExitInvalid = 2 + validSnapshotRequest = `{"protocolVersion":"kedify-analyzer/v1","input":{"schemaVersion":"resource-analysis-input/v1","observedIntervalHours":1,"containers":[]},"policy":{}}` + validAnalyzerOutput = `{"protocolVersion":"kedify-analyzer/v1","analyzerVersion":"test","engineVersion":"1","inputSchemaVersion":"resource-analysis-input/v1","outputSchemaVersion":"resource-analysis-output/v1","output":{"schemaVersion":"resource-analysis-output/v1","detectorVersion":"1"}}` + "\n" +) + +func TestMain(m *testing.M) { + mode := os.Getenv(helperModeEnv) + if mode == "" { + os.Exit(m.Run()) + } + + request, err := io.ReadAll(os.Stdin) + if err != nil || string(request) != os.Getenv(helperExpectedEnv) { + _, _ = fmt.Fprintln(os.Stderr, "fake analyzer received an unexpected request") + os.Exit(helperExitInvalid) + } + + switch mode { + case "success": + if _, err := io.WriteString(os.Stdout, validAnalyzerOutput); err != nil { + os.Exit(helperExitInternal) + } + os.Exit(0) + case "exit-1", "exit-2": + exitCode, _ := strconv.Atoi(strings.TrimPrefix(mode, "exit-")) + _, _ = fmt.Fprintf(os.Stderr, "fake analyzer failed with code %d\n", exitCode) + os.Exit(exitCode) + default: + _, _ = fmt.Fprintln(os.Stderr, "unknown fake analyzer mode") + os.Exit(helperExitInternal) + } +} + +func TestRecommendationsRunsAnalyzerWithStdinSnapshot(t *testing.T) { + stdout, stderr := &bytes.Buffer{}, &bytes.Buffer{} + err := runWithFakeAnalyzer(t, "success", bytes.NewBufferString(validSnapshotRequest), stdout, stderr) + if err != nil { + t.Fatalf("Run() error = %v", err) + } + if stdout.String() != validAnalyzerOutput { + t.Fatalf("stdout = %q, want %q", stdout.String(), validAnalyzerOutput) + } + if stderr.Len() != 0 { + t.Fatalf("stderr = %q, want empty", stderr.String()) + } +} + +func TestRecommendationsPreservesAnalyzerExitCodesAndStderr(t *testing.T) { + for _, exitCode := range []int{helperExitInternal, helperExitInvalid} { + t.Run(strconv.Itoa(exitCode), func(t *testing.T) { + stdout, stderr := &bytes.Buffer{}, &bytes.Buffer{} + err := runWithFakeAnalyzer(t, fmt.Sprintf("exit-%d", exitCode), bytes.NewBufferString(validSnapshotRequest), stdout, stderr) + var resultError *clierrors.CommandResultError + if !errors.As(err, &resultError) || resultError.ExitCode != exitCode { + t.Fatalf("Run() error = %#v, want command exit code %d", err, exitCode) + } + if stdout.Len() != 0 { + t.Fatalf("stdout = %q, want empty", stdout.String()) + } + if !strings.Contains(stderr.String(), fmt.Sprintf("failed with code %d", exitCode)) { + t.Fatalf("stderr = %q, want analyzer diagnostic", stderr.String()) + } + }) + } +} + +func TestRecommendationsReportsShortStdoutWrite(t *testing.T) { + err := runWithFakeAnalyzer(t, "success", bytes.NewBufferString(validSnapshotRequest), shortWriter{}, &bytes.Buffer{}) + if !errors.Is(err, io.ErrShortWrite) { + t.Fatalf("Run() error = %v, want %v", err, io.ErrShortWrite) + } +} + +func TestRequestValidation(t *testing.T) { + tests := []struct { + name string + data string + want string + }{ + {name: "malformed", data: `{`, want: "invalid snapshot request JSON"}, + {name: "protocol", data: `{"protocolVersion":"kedify-analyzer/v2","input":{"schemaVersion":"resource-analysis-input/v1"}}`, want: `requires "kedify-analyzer/v1"`}, + {name: "input schema", data: `{"protocolVersion":"kedify-analyzer/v1","input":{"schemaVersion":"resource-analysis-input/v2"}}`, want: `requires "resource-analysis-input/v1"`}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if err := validateRequest([]byte(test.data)); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("validateRequest() error = %v, want substring %q", err, test.want) + } + }) + } +} + +func TestResponseValidation(t *testing.T) { + tests := []struct { + name string + mutate func(*responseMetadata) + want string + }{ + {name: "protocol", mutate: func(response *responseMetadata) { response.ProtocolVersion = "kedify-analyzer/v2" }, want: `expected "kedify-analyzer/v1"`}, + {name: "analyzer version", mutate: func(response *responseMetadata) { response.AnalyzerVersion = "" }, want: "missing analyzerVersion"}, + {name: "engine", mutate: func(response *responseMetadata) { response.EngineVersion = "2" }, want: `expected "1"`}, + {name: "input schema", mutate: func(response *responseMetadata) { response.InputSchemaVersion = "resource-analysis-input/v2" }, want: `expected "resource-analysis-input/v1"`}, + {name: "output schema", mutate: func(response *responseMetadata) { response.OutputSchemaVersion = "resource-analysis-output/v2" }, want: `expected "resource-analysis-output/v1"`}, + {name: "missing output", mutate: func(response *responseMetadata) { response.Output = nil }, want: "missing output"}, + {name: "nested output schema", mutate: func(response *responseMetadata) { response.Output.SchemaVersion = "resource-analysis-output/v2" }, want: `expected "resource-analysis-output/v1"`}, + {name: "detector", mutate: func(response *responseMetadata) { response.Output.DetectorVersion = "2" }, want: `expected "1"`}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + response := validResponseMetadata() + test.mutate(&response) + data, err := json.Marshal(response) + if err != nil { + t.Fatal(err) + } + if err := validateResponse(data); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("validateResponse() error = %v, want substring %q", err, test.want) + } + }) + } + + if err := validateResponse([]byte(`{`)); err == nil || !strings.Contains(err.Error(), "invalid JSON response") { + t.Fatalf("validateResponse() malformed JSON error = %v", err) + } +} + +func TestReadSnapshotSupportsFilesAndRejectsOversizedInput(t *testing.T) { + path := filepath.Join(t.TempDir(), "snapshot.json") + if err := os.WriteFile(path, []byte(validSnapshotRequest), 0o600); err != nil { + t.Fatal(err) + } + got, err := readSnapshot(path, bytes.NewReader(nil)) + if err != nil { + t.Fatalf("readSnapshot() error = %v", err) + } + if string(got) != validSnapshotRequest { + t.Fatalf("readSnapshot() = %q, want request", got) + } + + _, err = readSnapshot("-", bytes.NewReader(bytes.Repeat([]byte(" "), maxSnapshotBytes+1))) + if err == nil || !strings.Contains(err.Error(), "request exceeds 16777216-byte limit") { + t.Fatalf("readSnapshot() oversized error = %v", err) + } +} + +func TestDiscoverAnalyzerOrder(t *testing.T) { + t.Run("explicit relative file", func(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + name := "custom-analyzer" + if runtime.GOOS == "windows" { + name += ".exe" + } + writeExecutable(t, name) + got, err := discoverAnalyzer(name, "", func(string) (string, error) { + t.Fatal("PATH discovery must not run for an explicit analyzer") + return "", nil + }) + if err != nil { + t.Fatalf("discoverAnalyzer() error = %v", err) + } + want, _ := filepath.Abs(name) + if got != want { + t.Fatalf("discoverAnalyzer() = %q, want absolute path %q", got, want) + } + }) + + t.Run("sibling before PATH", func(t *testing.T) { + dir := t.TempDir() + sibling := filepath.Join(dir, analyzerName()) + writeExecutable(t, sibling) + got, err := discoverAnalyzer("", filepath.Join(dir, "kedify"), func(string) (string, error) { + t.Fatal("PATH discovery must not run when a sibling analyzer exists") + return "", nil + }) + if err != nil || got != sibling { + t.Fatalf("discoverAnalyzer() = %q, %v; want %q", got, err, sibling) + } + }) + + t.Run("PATH fallback", func(t *testing.T) { + path := filepath.Join(t.TempDir(), analyzerName()) + got, err := discoverAnalyzer("", "", func(name string) (string, error) { + if name != analyzerName() { + t.Fatalf("LookPath(%q), want %q", name, analyzerName()) + } + return path, nil + }) + if err != nil || got != path { + t.Fatalf("discoverAnalyzer() = %q, %v; want %q", got, err, path) + } + }) + + t.Run("missing", func(t *testing.T) { + _, err := discoverAnalyzer("", "", func(string) (string, error) { + return "", errors.New("not found") + }) + if err == nil || !strings.Contains(err.Error(), "install a matching analyzer") || !strings.Contains(err.Error(), "--analyzer") { + t.Fatalf("discoverAnalyzer() error = %v", err) + } + }) +} + +func runWithFakeAnalyzer(t *testing.T, mode string, stdin io.Reader, stdout, stderr io.Writer) error { + t.Helper() + t.Setenv(helperModeEnv, mode) + t.Setenv(helperExpectedEnv, validSnapshotRequest) + executable, err := os.Executable() + if err != nil { + t.Fatal(err) + } + return (&RecommendationsCmd{Snapshot: "-", Analyzer: executable}).Run(&clictx.Context{ + Stdin: stdin, + Stdout: stdout, + Stderr: stderr, + }) +} + +func validResponseMetadata() responseMetadata { + return responseMetadata{ + ProtocolVersion: analyzerProtocolVersion, + AnalyzerVersion: "test", + EngineVersion: engineVersion, + InputSchemaVersion: inputSchemaVersion, + OutputSchemaVersion: outputSchemaVersion, + Output: &outputMetadata{ + SchemaVersion: outputSchemaVersion, + DetectorVersion: engineVersion, + }, + } +} + +func writeExecutable(t *testing.T, path string) { + t.Helper() + if err := os.WriteFile(path, []byte("test"), 0o700); err != nil { + t.Fatal(err) + } +} + +type shortWriter struct{} + +func (shortWriter) Write(data []byte) (int, error) { + return len(data) / 2, nil +} 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."` From 27bc98858022411c08e77e3963882caafe8fbf19 Mon Sep 17 00:00:00 2001 From: Zbynek Roubalik Date: Mon, 7 Sep 2026 19:45:59 +0200 Subject: [PATCH 02/10] fix: bound analyzer response output Signed-off-by: Zbynek Roubalik --- README.md | 3 +- internal/cli/analyze/recommendations.go | 40 +++++++++++++++++--- internal/cli/analyze/recommendations_test.go | 25 ++++++++++++ 3 files changed, 61 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 0a721b4..744535d 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,8 @@ analyzer. It accepts only protocol `kedify-analyzer/v1`, input/output schemas `resource-analysis-input/v1` and `resource-analysis-output/v1`, and engine version `1`. CPU `aggregatedUsage` in the request must already reflect the policy's `max` or `percentile` selection. Analyzer diagnostics stay on `stderr`; the validated JSON -result is the only `stdout` output. +result is the only `stdout` output. Requests over 16 MiB and analyzer responses over +64 MiB are rejected. ## Authentication diff --git a/internal/cli/analyze/recommendations.go b/internal/cli/analyze/recommendations.go index e3360a4..98a7a58 100644 --- a/internal/cli/analyze/recommendations.go +++ b/internal/cli/analyze/recommendations.go @@ -17,11 +17,12 @@ import ( ) const ( - analyzerProtocolVersion = "kedify-analyzer/v1" - inputSchemaVersion = "resource-analysis-input/v1" - outputSchemaVersion = "resource-analysis-output/v1" - engineVersion = "1" - maxSnapshotBytes = 16 << 20 + analyzerProtocolVersion = "kedify-analyzer/v1" + inputSchemaVersion = "resource-analysis-input/v1" + outputSchemaVersion = "resource-analysis-output/v1" + engineVersion = "1" + maxSnapshotBytes = 16 << 20 + maxAnalyzerResponseBytes = 64 << 20 ) type RecommendationsCmd struct { @@ -68,7 +69,7 @@ func (c *RecommendationsCmd) Run(ctx *clictx.Context) error { command := exec.Command(analyzer) // #nosec G204 -- the executable is selected explicitly or from trusted local discovery; no shell is used. command.Stdin = bytes.NewReader(request) command.Stderr = ctx.Stderr - var response bytes.Buffer + response := cappedBuffer{limit: maxAnalyzerResponseBytes} command.Stdout = &response if err := command.Run(); err != nil { var exitError *exec.ExitError @@ -77,6 +78,9 @@ func (c *RecommendationsCmd) Run(ctx *clictx.Context) error { } return fmt.Errorf("start analyzer %q: %w", analyzer, err) } + if response.exceeded { + return fmt.Errorf("analyzer response exceeds %d-byte limit", maxAnalyzerResponseBytes) + } if err := validateResponse(response.Bytes()); err != nil { return fmt.Errorf("incompatible analyzer %q: %w", analyzer, err) @@ -87,6 +91,30 @@ func (c *RecommendationsCmd) Run(ctx *clictx.Context) error { return nil } +type cappedBuffer struct { + buffer bytes.Buffer + limit int + exceeded bool +} + +func (b *cappedBuffer) Write(data []byte) (int, error) { + remaining := b.limit - b.buffer.Len() + if remaining > len(data) { + remaining = len(data) + } + if remaining > 0 { + _, _ = b.buffer.Write(data[:remaining]) + } + if remaining < len(data) { + b.exceeded = true + } + return len(data), nil +} + +func (b *cappedBuffer) Bytes() []byte { + return b.buffer.Bytes() +} + func readSnapshot(path string, stdin io.Reader) ([]byte, error) { if path == "-" { request, err := readSnapshotFrom(stdin) diff --git a/internal/cli/analyze/recommendations_test.go b/internal/cli/analyze/recommendations_test.go index f147829..702af1e 100644 --- a/internal/cli/analyze/recommendations_test.go +++ b/internal/cli/analyze/recommendations_test.go @@ -44,6 +44,11 @@ func TestMain(m *testing.M) { os.Exit(helperExitInternal) } os.Exit(0) + case "oversized-output": + if _, err := io.CopyN(os.Stdout, repeatingReader{}, maxAnalyzerResponseBytes+1); err != nil { + os.Exit(helperExitInternal) + } + os.Exit(0) case "exit-1", "exit-2": exitCode, _ := strconv.Atoi(strings.TrimPrefix(mode, "exit-")) _, _ = fmt.Fprintf(os.Stderr, "fake analyzer failed with code %d\n", exitCode) @@ -94,6 +99,17 @@ func TestRecommendationsReportsShortStdoutWrite(t *testing.T) { } } +func TestRecommendationsRejectsOversizedAnalyzerOutput(t *testing.T) { + stdout := &bytes.Buffer{} + err := runWithFakeAnalyzer(t, "oversized-output", bytes.NewBufferString(validSnapshotRequest), stdout, &bytes.Buffer{}) + if err == nil || !strings.Contains(err.Error(), "analyzer response exceeds 67108864-byte limit") { + t.Fatalf("Run() error = %v", err) + } + if stdout.Len() != 0 { + t.Fatalf("stdout = %q, want empty", stdout.String()) + } +} + func TestRequestValidation(t *testing.T) { tests := []struct { name string @@ -265,3 +281,12 @@ type shortWriter struct{} func (shortWriter) Write(data []byte) (int, error) { return len(data) / 2, nil } + +type repeatingReader struct{} + +func (repeatingReader) Read(data []byte) (int, error) { + for i := range data { + data[i] = 'x' + } + return len(data), nil +} From e42544e200fa0c07ee37a90d7ce9365f24abf662 Mon Sep 17 00:00:00 2001 From: Zbynek Roubalik Date: Mon, 7 Sep 2026 19:51:11 +0200 Subject: [PATCH 03/10] ci: match staticcheck to Go 1.26 Signed-off-by: Zbynek Roubalik --- .github/workflows/pr-check.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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: | From ec6cb024585bc1a10fb4f3ce7d3b5ee5a5635438 Mon Sep 17 00:00:00 2001 From: Zbynek Roubalik Date: Mon, 7 Sep 2026 19:55:18 +0200 Subject: [PATCH 04/10] fix: stop excessive analyzer output Signed-off-by: Zbynek Roubalik --- internal/cli/analyze/recommendations.go | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/internal/cli/analyze/recommendations.go b/internal/cli/analyze/recommendations.go index 98a7a58..4bd331a 100644 --- a/internal/cli/analyze/recommendations.go +++ b/internal/cli/analyze/recommendations.go @@ -71,15 +71,16 @@ func (c *RecommendationsCmd) Run(ctx *clictx.Context) error { command.Stderr = ctx.Stderr response := cappedBuffer{limit: maxAnalyzerResponseBytes} command.Stdout = &response - if err := command.Run(); err != nil { + runErr := command.Run() + if response.exceeded { + return fmt.Errorf("analyzer response exceeds %d-byte limit", maxAnalyzerResponseBytes) + } + if runErr != nil { var exitError *exec.ExitError - if errors.As(err, &exitError) { + if errors.As(runErr, &exitError) { return &clierrors.CommandResultError{ExitCode: exitError.ExitCode()} } - return fmt.Errorf("start analyzer %q: %w", analyzer, err) - } - if response.exceeded { - return fmt.Errorf("analyzer response exceeds %d-byte limit", maxAnalyzerResponseBytes) + return fmt.Errorf("run analyzer %q: %w", analyzer, runErr) } if err := validateResponse(response.Bytes()); err != nil { @@ -107,6 +108,7 @@ func (b *cappedBuffer) Write(data []byte) (int, error) { } if remaining < len(data) { b.exceeded = true + return remaining, io.ErrShortWrite } return len(data), nil } From 9f3447088232d75682005e478ae235b1699b14ae Mon Sep 17 00:00:00 2001 From: Zbynek Roubalik Date: Mon, 7 Sep 2026 19:57:05 +0200 Subject: [PATCH 05/10] fix: keep draining excessive analyzer output Signed-off-by: Zbynek Roubalik --- internal/cli/analyze/recommendations.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/cli/analyze/recommendations.go b/internal/cli/analyze/recommendations.go index 4bd331a..235e1cc 100644 --- a/internal/cli/analyze/recommendations.go +++ b/internal/cli/analyze/recommendations.go @@ -108,7 +108,6 @@ func (b *cappedBuffer) Write(data []byte) (int, error) { } if remaining < len(data) { b.exceeded = true - return remaining, io.ErrShortWrite } return len(data), nil } From 5e9460be83901b2b64a3780edc5134bb2e6f8fd9 Mon Sep 17 00:00:00 2001 From: Zbynek Roubalik Date: Mon, 7 Sep 2026 20:00:28 +0200 Subject: [PATCH 06/10] fix: handle analyzer signal termination Signed-off-by: Zbynek Roubalik --- internal/cli/analyze/recommendations.go | 5 ++++- internal/cli/analyze/recommendations_test.go | 20 ++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/internal/cli/analyze/recommendations.go b/internal/cli/analyze/recommendations.go index 235e1cc..786b83b 100644 --- a/internal/cli/analyze/recommendations.go +++ b/internal/cli/analyze/recommendations.go @@ -78,7 +78,10 @@ func (c *RecommendationsCmd) Run(ctx *clictx.Context) error { if runErr != nil { var exitError *exec.ExitError if errors.As(runErr, &exitError) { - return &clierrors.CommandResultError{ExitCode: exitError.ExitCode()} + if exitCode := exitError.ExitCode(); exitCode >= 0 { + return &clierrors.CommandResultError{ExitCode: exitCode} + } + return fmt.Errorf("analyzer %q terminated: %w", analyzer, runErr) } return fmt.Errorf("run analyzer %q: %w", analyzer, runErr) } diff --git a/internal/cli/analyze/recommendations_test.go b/internal/cli/analyze/recommendations_test.go index 702af1e..2b7a88d 100644 --- a/internal/cli/analyze/recommendations_test.go +++ b/internal/cli/analyze/recommendations_test.go @@ -49,6 +49,12 @@ func TestMain(m *testing.M) { os.Exit(helperExitInternal) } os.Exit(0) + case "signal": + process, err := os.FindProcess(os.Getpid()) + if err != nil || process.Kill() != nil { + os.Exit(helperExitInternal) + } + os.Exit(helperExitInternal) case "exit-1", "exit-2": exitCode, _ := strconv.Atoi(strings.TrimPrefix(mode, "exit-")) _, _ = fmt.Fprintf(os.Stderr, "fake analyzer failed with code %d\n", exitCode) @@ -92,6 +98,20 @@ func TestRecommendationsPreservesAnalyzerExitCodesAndStderr(t *testing.T) { } } +func TestRecommendationsReportsAnalyzerSignalAsOperationalError(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows reports Process.Kill with a numeric exit code") + } + err := runWithFakeAnalyzer(t, "signal", bytes.NewBufferString(validSnapshotRequest), &bytes.Buffer{}, &bytes.Buffer{}) + var resultError *clierrors.CommandResultError + if errors.As(err, &resultError) { + t.Fatalf("Run() returned child exit code %d for a signal", resultError.ExitCode) + } + if err == nil || !strings.Contains(err.Error(), "terminated") { + t.Fatalf("Run() error = %v, want termination error", err) + } +} + func TestRecommendationsReportsShortStdoutWrite(t *testing.T) { err := runWithFakeAnalyzer(t, "success", bytes.NewBufferString(validSnapshotRequest), shortWriter{}, &bytes.Buffer{}) if !errors.Is(err, io.ErrShortWrite) { From 0e3f6e9670da2b507371ab7ab230b29fe4d5b88c Mon Sep 17 00:00:00 2001 From: Zbynek Roubalik Date: Mon, 7 Sep 2026 20:06:10 +0200 Subject: [PATCH 07/10] Preserve analyzer exit code for oversized output Signed-off-by: Zbynek Roubalik --- internal/cli/analyze/recommendations.go | 6 +++--- internal/cli/analyze/recommendations_test.go | 17 +++++++++++++++++ 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/internal/cli/analyze/recommendations.go b/internal/cli/analyze/recommendations.go index 786b83b..6d2470b 100644 --- a/internal/cli/analyze/recommendations.go +++ b/internal/cli/analyze/recommendations.go @@ -72,9 +72,6 @@ func (c *RecommendationsCmd) Run(ctx *clictx.Context) error { response := cappedBuffer{limit: maxAnalyzerResponseBytes} command.Stdout = &response runErr := command.Run() - if response.exceeded { - return fmt.Errorf("analyzer response exceeds %d-byte limit", maxAnalyzerResponseBytes) - } if runErr != nil { var exitError *exec.ExitError if errors.As(runErr, &exitError) { @@ -85,6 +82,9 @@ func (c *RecommendationsCmd) Run(ctx *clictx.Context) error { } return fmt.Errorf("run analyzer %q: %w", analyzer, runErr) } + if response.exceeded { + return fmt.Errorf("analyzer response exceeds %d-byte limit", maxAnalyzerResponseBytes) + } if err := validateResponse(response.Bytes()); err != nil { return fmt.Errorf("incompatible analyzer %q: %w", analyzer, err) diff --git a/internal/cli/analyze/recommendations_test.go b/internal/cli/analyze/recommendations_test.go index 2b7a88d..bb9e26c 100644 --- a/internal/cli/analyze/recommendations_test.go +++ b/internal/cli/analyze/recommendations_test.go @@ -49,6 +49,11 @@ func TestMain(m *testing.M) { os.Exit(helperExitInternal) } os.Exit(0) + case "oversized-output-exit-2": + if _, err := io.CopyN(os.Stdout, repeatingReader{}, maxAnalyzerResponseBytes+1); err != nil { + os.Exit(helperExitInternal) + } + os.Exit(helperExitInvalid) case "signal": process, err := os.FindProcess(os.Getpid()) if err != nil || process.Kill() != nil { @@ -130,6 +135,18 @@ func TestRecommendationsRejectsOversizedAnalyzerOutput(t *testing.T) { } } +func TestRecommendationsPreservesExitCodeWithOversizedAnalyzerOutput(t *testing.T) { + stdout := &bytes.Buffer{} + err := runWithFakeAnalyzer(t, "oversized-output-exit-2", bytes.NewBufferString(validSnapshotRequest), stdout, &bytes.Buffer{}) + var resultError *clierrors.CommandResultError + if !errors.As(err, &resultError) || resultError.ExitCode != helperExitInvalid { + t.Fatalf("Run() error = %#v, want command exit code %d", err, helperExitInvalid) + } + if stdout.Len() != 0 { + t.Fatalf("stdout = %q, want empty", stdout.String()) + } +} + func TestRequestValidation(t *testing.T) { tests := []struct { name string From 28d3c2e6b55899b037d2195e9e2a6f0b79a34a97 Mon Sep 17 00:00:00 2001 From: Zbynek Roubalik Date: Tue, 8 Sep 2026 12:33:20 +0200 Subject: [PATCH 08/10] Link the public analysis engine directly Signed-off-by: Zbynek Roubalik --- README.md | 30 +- go.mod | 3 +- go.sum | 2 + internal/cli/analyze/recommendations.go | 213 ++---------- internal/cli/analyze/recommendations_test.go | 328 ++++--------------- 5 files changed, 99 insertions(+), 477 deletions(-) diff --git a/README.md b/README.md index 744535d..16f599c 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ The CLI currently focuses on authentication, cluster inspection, and applying re - `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 ` - Runs a separately installed `kedify-analyzer` against a versioned normalized snapshot without using Kedify SaaS. + 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 @@ -51,12 +51,11 @@ The binary will be available at `./bin/kedify`. ### Offline recommendation analysis -The offline command consumes a versioned normalized snapshot request and prints the -analyzer's JSON result unchanged. The request envelope is: +The offline command consumes a normalized snapshot and runs the same analysis engine +used by Kedify services. The request is: ```json { - "protocolVersion": "kedify-analyzer/v1", "input": { "schemaVersion": "resource-analysis-input/v1", "observedIntervalHours": 24, @@ -73,23 +72,12 @@ Run it from a file or standard input: cat ./snapshot-request.json | ./bin/kedify analyze recommendations - ``` -Install a matching `kedify-analyzer` release before entering an air-gapped -environment and verify its published SHA-256 checksum. The CLI resolves an explicit -`--analyzer` path first, then `kedify-analyzer` beside the CLI executable, then -`kedify-analyzer` on `PATH`: - -```bash -./bin/kedify analyze recommendations ./snapshot-request.json \ - --analyzer ./tools/kedify-analyzer -``` - -The command requires no Kedify token, makes no SaaS request, and never downloads an -analyzer. It accepts only protocol `kedify-analyzer/v1`, input/output schemas -`resource-analysis-input/v1` and `resource-analysis-output/v1`, and engine version -`1`. CPU `aggregatedUsage` in the request must already reflect the policy's `max` or -`percentile` selection. Analyzer diagnostics stay on `stderr`; the validated JSON -result is the only `stdout` output. Requests over 16 MiB and analyzer responses over -64 MiB are rejected. +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. ## Authentication diff --git a/go.mod b/go.mod index 07188b7..ded3815 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.0.0-20260907141451-4247f97d42f1 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..51fda73 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.0.0-20260907141451-4247f97d42f1 h1:If61rCsZBVSX0zq2g58H4ptxzGv8D1Efw/3xsbfQaNI= +github.com/kedify/recommender v0.0.0-20260907141451-4247f97d42f1/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 index 6d2470b..d3815a1 100644 --- a/internal/cli/analyze/recommendations.go +++ b/internal/cli/analyze/recommendations.go @@ -3,129 +3,56 @@ package analyze import ( "bytes" "encoding/json" - "errors" "fmt" "io" "os" - "os/exec" "path/filepath" - "runtime" - "strings" + + "github.com/kedify/recommender/analysis" clictx "github.com/kedify/cli/internal/cli/context" - clierrors "github.com/kedify/cli/internal/errors" ) -const ( - analyzerProtocolVersion = "kedify-analyzer/v1" - inputSchemaVersion = "resource-analysis-input/v1" - outputSchemaVersion = "resource-analysis-output/v1" - engineVersion = "1" - maxSnapshotBytes = 16 << 20 - maxAnalyzerResponseBytes = 64 << 20 -) +const maxSnapshotBytes = 16 << 20 type RecommendationsCmd struct { - Snapshot string `arg:"" name:"snapshot-request" help:"Path to a versioned normalized snapshot request, or - for stdin."` - Analyzer string `name:"analyzer" help:"Path to the kedify-analyzer executable. Overrides sibling and PATH discovery." placeholder:"PATH"` -} - -type requestMetadata struct { - ProtocolVersion string `json:"protocolVersion"` - Input struct { - SchemaVersion string `json:"schemaVersion"` - } `json:"input"` + Snapshot string `arg:"" name:"snapshot-request" help:"Path to a normalized snapshot request, or - for stdin."` } -type responseMetadata struct { - ProtocolVersion string `json:"protocolVersion"` - AnalyzerVersion string `json:"analyzerVersion"` - EngineVersion string `json:"engineVersion"` - InputSchemaVersion string `json:"inputSchemaVersion"` - OutputSchemaVersion string `json:"outputSchemaVersion"` - Output *outputMetadata `json:"output"` -} - -type outputMetadata struct { - SchemaVersion string `json:"schemaVersion"` - DetectorVersion string `json:"detectorVersion"` +type snapshotRequest struct { + Input analysis.Input `json:"input"` + Policy analysis.Policy `json:"policy"` } func (c *RecommendationsCmd) Run(ctx *clictx.Context) error { - request, err := readSnapshot(c.Snapshot, ctx.Stdin) + data, err := readSnapshot(c.Snapshot, ctx.Stdin) if err != nil { return err } - if err := validateRequest(request); err != nil { - return err - } - cliExecutable, _ := os.Executable() - analyzer, err := discoverAnalyzer(c.Analyzer, cliExecutable, exec.LookPath) + request, err := decodeSnapshot(data) if err != nil { return err } - command := exec.Command(analyzer) // #nosec G204 -- the executable is selected explicitly or from trusted local discovery; no shell is used. - command.Stdin = bytes.NewReader(request) - command.Stderr = ctx.Stderr - response := cappedBuffer{limit: maxAnalyzerResponseBytes} - command.Stdout = &response - runErr := command.Run() - if runErr != nil { - var exitError *exec.ExitError - if errors.As(runErr, &exitError) { - if exitCode := exitError.ExitCode(); exitCode >= 0 { - return &clierrors.CommandResultError{ExitCode: exitCode} - } - return fmt.Errorf("analyzer %q terminated: %w", analyzer, runErr) - } - return fmt.Errorf("run analyzer %q: %w", analyzer, runErr) - } - if response.exceeded { - return fmt.Errorf("analyzer response exceeds %d-byte limit", maxAnalyzerResponseBytes) + result, err := analysis.Analyze(request.Input, request.Policy) + if err != nil { + return fmt.Errorf("analyze snapshot: %w", err) } - if err := validateResponse(response.Bytes()); err != nil { - return fmt.Errorf("incompatible analyzer %q: %w", analyzer, err) - } - if _, err := io.Copy(ctx.Stdout, bytes.NewReader(response.Bytes())); err != nil { - return fmt.Errorf("write analyzer result: %w", err) + if err := json.NewEncoder(ctx.Stdout).Encode(result); err != nil { + return fmt.Errorf("write analysis result: %w", err) } return nil } -type cappedBuffer struct { - buffer bytes.Buffer - limit int - exceeded bool -} - -func (b *cappedBuffer) Write(data []byte) (int, error) { - remaining := b.limit - b.buffer.Len() - if remaining > len(data) { - remaining = len(data) - } - if remaining > 0 { - _, _ = b.buffer.Write(data[:remaining]) - } - if remaining < len(data) { - b.exceeded = true - } - return len(data), nil -} - -func (b *cappedBuffer) Bytes() []byte { - return b.buffer.Bytes() -} - func readSnapshot(path string, stdin io.Reader) ([]byte, error) { if path == "-" { - request, err := readSnapshotFrom(stdin) + data, err := readSnapshotFrom(stdin) if err != nil { return nil, fmt.Errorf("read snapshot request from stdin: %w", err) } - return request, nil + return data, nil } file, err := os.Open(filepath.Clean(path)) // #nosec G304 -- the snapshot path is explicit user input. @@ -134,114 +61,34 @@ func readSnapshot(path string, stdin io.Reader) ([]byte, error) { } defer func() { _ = file.Close() }() - request, err := readSnapshotFrom(file) + data, err := readSnapshotFrom(file) if err != nil { return nil, fmt.Errorf("read snapshot request %q: %w", path, err) } - return request, nil + return data, nil } func readSnapshotFrom(reader io.Reader) ([]byte, error) { - request, err := io.ReadAll(io.LimitReader(reader, maxSnapshotBytes+1)) + data, err := io.ReadAll(io.LimitReader(reader, maxSnapshotBytes+1)) if err != nil { return nil, err } - if len(request) > maxSnapshotBytes { + if len(data) > maxSnapshotBytes { return nil, fmt.Errorf("request exceeds %d-byte limit", maxSnapshotBytes) } - return request, nil -} - -func validateRequest(data []byte) error { - var metadata requestMetadata - if err := json.Unmarshal(data, &metadata); err != nil { - return fmt.Errorf("invalid snapshot request JSON: %w", err) - } - if metadata.ProtocolVersion != analyzerProtocolVersion { - return fmt.Errorf("snapshot request uses protocolVersion %q; this CLI requires %q", metadata.ProtocolVersion, analyzerProtocolVersion) - } - if metadata.Input.SchemaVersion != inputSchemaVersion { - return fmt.Errorf("snapshot request uses input schema %q; this CLI requires %q", metadata.Input.SchemaVersion, inputSchemaVersion) - } - return nil -} - -func validateResponse(data []byte) error { - var metadata responseMetadata - if err := json.Unmarshal(data, &metadata); err != nil { - return fmt.Errorf("invalid JSON response: %w", err) - } - if metadata.ProtocolVersion != analyzerProtocolVersion { - return fmt.Errorf("unsupported protocolVersion %q; expected %q", metadata.ProtocolVersion, analyzerProtocolVersion) - } - if strings.TrimSpace(metadata.AnalyzerVersion) == "" { - return errors.New("response is missing analyzerVersion") - } - if metadata.EngineVersion != engineVersion { - return fmt.Errorf("unsupported engineVersion %q; expected %q", metadata.EngineVersion, engineVersion) - } - if metadata.InputSchemaVersion != inputSchemaVersion { - return fmt.Errorf("unsupported inputSchemaVersion %q; expected %q", metadata.InputSchemaVersion, inputSchemaVersion) - } - if metadata.OutputSchemaVersion != outputSchemaVersion { - return fmt.Errorf("unsupported outputSchemaVersion %q; expected %q", metadata.OutputSchemaVersion, outputSchemaVersion) - } - if metadata.Output == nil { - return errors.New("response is missing output") - } - if metadata.Output.SchemaVersion != outputSchemaVersion { - return fmt.Errorf("output uses schemaVersion %q; expected %q", metadata.Output.SchemaVersion, outputSchemaVersion) - } - if metadata.Output.DetectorVersion != engineVersion { - return fmt.Errorf("output uses detectorVersion %q; expected %q", metadata.Output.DetectorVersion, engineVersion) - } - return nil -} - -func discoverAnalyzer(override, cliExecutable string, lookPath func(string) (string, error)) (string, error) { - if strings.TrimSpace(override) != "" { - path, err := filepath.Abs(filepath.Clean(override)) - if err != nil { - return "", fmt.Errorf("resolve --analyzer %q: %w", override, err) - } - if err := validateExecutable(path); err != nil { - return "", fmt.Errorf("--analyzer %q is not executable: %w", override, err) - } - return path, nil - } - - name := analyzerName() - if cliExecutable != "" { - sibling := filepath.Join(filepath.Dir(cliExecutable), name) - if err := validateExecutable(sibling); err == nil { - return sibling, nil - } - } - - path, err := lookPath(name) - if err != nil { - return "", fmt.Errorf("%s not found; install a matching analyzer beside kedify, add it to PATH, or pass --analyzer", name) - } - return path, nil + return data, nil } -func analyzerName() string { - if runtime.GOOS == "windows" { - return "kedify-analyzer.exe" - } - return "kedify-analyzer" -} +func decodeSnapshot(data []byte) (snapshotRequest, error) { + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() -func validateExecutable(path string) error { - info, err := os.Stat(path) - if err != nil { - return err + var request snapshotRequest + if err := decoder.Decode(&request); err != nil { + return snapshotRequest{}, fmt.Errorf("invalid snapshot request: %w", err) } - if !info.Mode().IsRegular() { - return errors.New("not a regular file") + if err := decoder.Decode(&struct{}{}); err != io.EOF { + return snapshotRequest{}, fmt.Errorf("invalid snapshot request: expected one JSON object") } - if runtime.GOOS != "windows" && info.Mode().Perm()&0o111 == 0 { - return errors.New("execute permission is not set") - } - return nil + return request, nil } diff --git a/internal/cli/analyze/recommendations_test.go b/internal/cli/analyze/recommendations_test.go index bb9e26c..630b56c 100644 --- a/internal/cli/analyze/recommendations_test.go +++ b/internal/cli/analyze/recommendations_test.go @@ -4,326 +4,110 @@ import ( "bytes" "encoding/json" "errors" - "fmt" "io" "os" "path/filepath" - "runtime" - "strconv" "strings" "testing" - clictx "github.com/kedify/cli/internal/cli/context" - clierrors "github.com/kedify/cli/internal/errors" -) + "github.com/kedify/recommender/analysis" -const ( - helperModeEnv = "KEDIFY_TEST_ANALYZER_MODE" - helperExpectedEnv = "KEDIFY_TEST_ANALYZER_REQUEST" - helperExitInternal = 1 - helperExitInvalid = 2 - validSnapshotRequest = `{"protocolVersion":"kedify-analyzer/v1","input":{"schemaVersion":"resource-analysis-input/v1","observedIntervalHours":1,"containers":[]},"policy":{}}` - validAnalyzerOutput = `{"protocolVersion":"kedify-analyzer/v1","analyzerVersion":"test","engineVersion":"1","inputSchemaVersion":"resource-analysis-input/v1","outputSchemaVersion":"resource-analysis-output/v1","output":{"schemaVersion":"resource-analysis-output/v1","detectorVersion":"1"}}` + "\n" + clictx "github.com/kedify/cli/internal/cli/context" ) -func TestMain(m *testing.M) { - mode := os.Getenv(helperModeEnv) - if mode == "" { - os.Exit(m.Run()) - } - - request, err := io.ReadAll(os.Stdin) - if err != nil || string(request) != os.Getenv(helperExpectedEnv) { - _, _ = fmt.Fprintln(os.Stderr, "fake analyzer received an unexpected request") - os.Exit(helperExitInvalid) - } +const validSnapshotRequest = `{"input":{"schemaVersion":"resource-analysis-input/v1","observedIntervalHours":24,"containers":[]},"policy":{}}` - switch mode { - case "success": - if _, err := io.WriteString(os.Stdout, validAnalyzerOutput); err != nil { - os.Exit(helperExitInternal) - } - os.Exit(0) - case "oversized-output": - if _, err := io.CopyN(os.Stdout, repeatingReader{}, maxAnalyzerResponseBytes+1); err != nil { - os.Exit(helperExitInternal) - } - os.Exit(0) - case "oversized-output-exit-2": - if _, err := io.CopyN(os.Stdout, repeatingReader{}, maxAnalyzerResponseBytes+1); err != nil { - os.Exit(helperExitInternal) - } - os.Exit(helperExitInvalid) - case "signal": - process, err := os.FindProcess(os.Getpid()) - if err != nil || process.Kill() != nil { - os.Exit(helperExitInternal) - } - os.Exit(helperExitInternal) - case "exit-1", "exit-2": - exitCode, _ := strconv.Atoi(strings.TrimPrefix(mode, "exit-")) - _, _ = fmt.Fprintf(os.Stderr, "fake analyzer failed with code %d\n", exitCode) - os.Exit(exitCode) - default: - _, _ = fmt.Fprintln(os.Stderr, "unknown fake analyzer mode") - os.Exit(helperExitInternal) - } -} - -func TestRecommendationsRunsAnalyzerWithStdinSnapshot(t *testing.T) { - stdout, stderr := &bytes.Buffer{}, &bytes.Buffer{} - err := runWithFakeAnalyzer(t, "success", bytes.NewBufferString(validSnapshotRequest), stdout, stderr) +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) } - if stdout.String() != validAnalyzerOutput { - t.Fatalf("stdout = %q, want %q", stdout.String(), validAnalyzerOutput) - } - if stderr.Len() != 0 { - t.Fatalf("stderr = %q, want empty", stderr.String()) - } -} -func TestRecommendationsPreservesAnalyzerExitCodesAndStderr(t *testing.T) { - for _, exitCode := range []int{helperExitInternal, helperExitInvalid} { - t.Run(strconv.Itoa(exitCode), func(t *testing.T) { - stdout, stderr := &bytes.Buffer{}, &bytes.Buffer{} - err := runWithFakeAnalyzer(t, fmt.Sprintf("exit-%d", exitCode), bytes.NewBufferString(validSnapshotRequest), stdout, stderr) - var resultError *clierrors.CommandResultError - if !errors.As(err, &resultError) || resultError.ExitCode != exitCode { - t.Fatalf("Run() error = %#v, want command exit code %d", err, exitCode) - } - if stdout.Len() != 0 { - t.Fatalf("stdout = %q, want empty", stdout.String()) - } - if !strings.Contains(stderr.String(), fmt.Sprintf("failed with code %d", exitCode)) { - t.Fatalf("stderr = %q, want analyzer diagnostic", stderr.String()) - } - }) + var result analysis.Output + if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { + t.Fatalf("decode output: %v", err) } -} - -func TestRecommendationsReportsAnalyzerSignalAsOperationalError(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("Windows reports Process.Kill with a numeric exit code") + if result.SchemaVersion != analysis.OutputSchemaVersion { + t.Fatalf("schemaVersion = %q, want %q", result.SchemaVersion, analysis.OutputSchemaVersion) } - err := runWithFakeAnalyzer(t, "signal", bytes.NewBufferString(validSnapshotRequest), &bytes.Buffer{}, &bytes.Buffer{}) - var resultError *clierrors.CommandResultError - if errors.As(err, &resultError) { - t.Fatalf("Run() returned child exit code %d for a signal", resultError.ExitCode) + if result.DetectorVersion != analysis.ResourceRightSizeDetectorVersion { + t.Fatalf("detectorVersion = %q, want %q", result.DetectorVersion, analysis.ResourceRightSizeDetectorVersion) } - if err == nil || !strings.Contains(err.Error(), "terminated") { - t.Fatalf("Run() error = %v, want termination error", err) + if result.PolicyVersion == "" { + t.Fatal("policyVersion is empty") } -} - -func TestRecommendationsReportsShortStdoutWrite(t *testing.T) { - err := runWithFakeAnalyzer(t, "success", bytes.NewBufferString(validSnapshotRequest), shortWriter{}, &bytes.Buffer{}) - if !errors.Is(err, io.ErrShortWrite) { - t.Fatalf("Run() error = %v, want %v", err, io.ErrShortWrite) + if result.Results == nil || len(result.Results) != 0 { + t.Fatalf("results = %#v, want an empty array", result.Results) } } -func TestRecommendationsRejectsOversizedAnalyzerOutput(t *testing.T) { - stdout := &bytes.Buffer{} - err := runWithFakeAnalyzer(t, "oversized-output", bytes.NewBufferString(validSnapshotRequest), stdout, &bytes.Buffer{}) - if err == nil || !strings.Contains(err.Error(), "analyzer response exceeds 67108864-byte limit") { - t.Fatalf("Run() error = %v", err) - } - if stdout.Len() != 0 { - t.Fatalf("stdout = %q, want empty", stdout.String()) +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) } -} -func TestRecommendationsPreservesExitCodeWithOversizedAnalyzerOutput(t *testing.T) { - stdout := &bytes.Buffer{} - err := runWithFakeAnalyzer(t, "oversized-output-exit-2", bytes.NewBufferString(validSnapshotRequest), stdout, &bytes.Buffer{}) - var resultError *clierrors.CommandResultError - if !errors.As(err, &resultError) || resultError.ExitCode != helperExitInvalid { - t.Fatalf("Run() error = %#v, want command exit code %d", err, helperExitInvalid) - } - if stdout.Len() != 0 { - t.Fatalf("stdout = %q, want empty", stdout.String()) + err := (&RecommendationsCmd{Snapshot: path}).Run(&clictx.Context{Stdout: io.Discard}) + if err != nil { + t.Fatalf("Run() error = %v", err) } } -func TestRequestValidation(t *testing.T) { +func TestRecommendationsRejectsInvalidSnapshot(t *testing.T) { tests := []struct { name string data string want string }{ - {name: "malformed", data: `{`, want: "invalid snapshot request JSON"}, - {name: "protocol", data: `{"protocolVersion":"kedify-analyzer/v2","input":{"schemaVersion":"resource-analysis-input/v1"}}`, want: `requires "kedify-analyzer/v1"`}, - {name: "input schema", data: `{"protocolVersion":"kedify-analyzer/v1","input":{"schemaVersion":"resource-analysis-input/v2"}}`, want: `requires "resource-analysis-input/v1"`}, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - if err := validateRequest([]byte(test.data)); err == nil || !strings.Contains(err.Error(), test.want) { - t.Fatalf("validateRequest() error = %v, want substring %q", err, test.want) - } - }) + {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"}, } -} -func TestResponseValidation(t *testing.T) { - tests := []struct { - name string - mutate func(*responseMetadata) - want string - }{ - {name: "protocol", mutate: func(response *responseMetadata) { response.ProtocolVersion = "kedify-analyzer/v2" }, want: `expected "kedify-analyzer/v1"`}, - {name: "analyzer version", mutate: func(response *responseMetadata) { response.AnalyzerVersion = "" }, want: "missing analyzerVersion"}, - {name: "engine", mutate: func(response *responseMetadata) { response.EngineVersion = "2" }, want: `expected "1"`}, - {name: "input schema", mutate: func(response *responseMetadata) { response.InputSchemaVersion = "resource-analysis-input/v2" }, want: `expected "resource-analysis-input/v1"`}, - {name: "output schema", mutate: func(response *responseMetadata) { response.OutputSchemaVersion = "resource-analysis-output/v2" }, want: `expected "resource-analysis-output/v1"`}, - {name: "missing output", mutate: func(response *responseMetadata) { response.Output = nil }, want: "missing output"}, - {name: "nested output schema", mutate: func(response *responseMetadata) { response.Output.SchemaVersion = "resource-analysis-output/v2" }, want: `expected "resource-analysis-output/v1"`}, - {name: "detector", mutate: func(response *responseMetadata) { response.Output.DetectorVersion = "2" }, want: `expected "1"`}, - } for _, test := range tests { t.Run(test.name, func(t *testing.T) { - response := validResponseMetadata() - test.mutate(&response) - data, err := json.Marshal(response) - if err != nil { - t.Fatal(err) - } - if err := validateResponse(data); err == nil || !strings.Contains(err.Error(), test.want) { - t.Fatalf("validateResponse() error = %v, want substring %q", err, test.want) + 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) } }) } - - if err := validateResponse([]byte(`{`)); err == nil || !strings.Contains(err.Error(), "invalid JSON response") { - t.Fatalf("validateResponse() malformed JSON error = %v", err) - } } -func TestReadSnapshotSupportsFilesAndRejectsOversizedInput(t *testing.T) { - path := filepath.Join(t.TempDir(), "snapshot.json") - if err := os.WriteFile(path, []byte(validSnapshotRequest), 0o600); err != nil { - t.Fatal(err) - } - got, err := readSnapshot(path, bytes.NewReader(nil)) - if err != nil { - t.Fatalf("readSnapshot() error = %v", err) - } - if string(got) != validSnapshotRequest { - t.Fatalf("readSnapshot() = %q, want request", got) - } - - _, err = readSnapshot("-", bytes.NewReader(bytes.Repeat([]byte(" "), maxSnapshotBytes+1))) +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("readSnapshot() oversized error = %v", err) + t.Fatalf("Run() error = %v", err) } } -func TestDiscoverAnalyzerOrder(t *testing.T) { - t.Run("explicit relative file", func(t *testing.T) { - dir := t.TempDir() - t.Chdir(dir) - name := "custom-analyzer" - if runtime.GOOS == "windows" { - name += ".exe" - } - writeExecutable(t, name) - got, err := discoverAnalyzer(name, "", func(string) (string, error) { - t.Fatal("PATH discovery must not run for an explicit analyzer") - return "", nil - }) - if err != nil { - t.Fatalf("discoverAnalyzer() error = %v", err) - } - want, _ := filepath.Abs(name) - if got != want { - t.Fatalf("discoverAnalyzer() = %q, want absolute path %q", got, want) - } - }) - - t.Run("sibling before PATH", func(t *testing.T) { - dir := t.TempDir() - sibling := filepath.Join(dir, analyzerName()) - writeExecutable(t, sibling) - got, err := discoverAnalyzer("", filepath.Join(dir, "kedify"), func(string) (string, error) { - t.Fatal("PATH discovery must not run when a sibling analyzer exists") - return "", nil - }) - if err != nil || got != sibling { - t.Fatalf("discoverAnalyzer() = %q, %v; want %q", got, err, sibling) - } +func TestRecommendationsReportsOutputFailure(t *testing.T) { + want := errors.New("write failed") + err := (&RecommendationsCmd{Snapshot: "-"}).Run(&clictx.Context{ + Stdin: strings.NewReader(validSnapshotRequest), + Stdout: errorWriter{err: want}, }) - - t.Run("PATH fallback", func(t *testing.T) { - path := filepath.Join(t.TempDir(), analyzerName()) - got, err := discoverAnalyzer("", "", func(name string) (string, error) { - if name != analyzerName() { - t.Fatalf("LookPath(%q), want %q", name, analyzerName()) - } - return path, nil - }) - if err != nil || got != path { - t.Fatalf("discoverAnalyzer() = %q, %v; want %q", got, err, path) - } - }) - - t.Run("missing", func(t *testing.T) { - _, err := discoverAnalyzer("", "", func(string) (string, error) { - return "", errors.New("not found") - }) - if err == nil || !strings.Contains(err.Error(), "install a matching analyzer") || !strings.Contains(err.Error(), "--analyzer") { - t.Fatalf("discoverAnalyzer() error = %v", err) - } - }) -} - -func runWithFakeAnalyzer(t *testing.T, mode string, stdin io.Reader, stdout, stderr io.Writer) error { - t.Helper() - t.Setenv(helperModeEnv, mode) - t.Setenv(helperExpectedEnv, validSnapshotRequest) - executable, err := os.Executable() - if err != nil { - t.Fatal(err) - } - return (&RecommendationsCmd{Snapshot: "-", Analyzer: executable}).Run(&clictx.Context{ - Stdin: stdin, - Stdout: stdout, - Stderr: stderr, - }) -} - -func validResponseMetadata() responseMetadata { - return responseMetadata{ - ProtocolVersion: analyzerProtocolVersion, - AnalyzerVersion: "test", - EngineVersion: engineVersion, - InputSchemaVersion: inputSchemaVersion, - OutputSchemaVersion: outputSchemaVersion, - Output: &outputMetadata{ - SchemaVersion: outputSchemaVersion, - DetectorVersion: engineVersion, - }, - } -} - -func writeExecutable(t *testing.T, path string) { - t.Helper() - if err := os.WriteFile(path, []byte("test"), 0o700); err != nil { - t.Fatal(err) + if !errors.Is(err, want) { + t.Fatalf("Run() error = %v, want %v", err, want) } } -type shortWriter struct{} - -func (shortWriter) Write(data []byte) (int, error) { - return len(data) / 2, nil +type errorWriter struct { + err error } -type repeatingReader struct{} - -func (repeatingReader) Read(data []byte) (int, error) { - for i := range data { - data[i] = 'x' - } - return len(data), nil +func (w errorWriter) Write([]byte) (int, error) { + return 0, w.err } From 03927d69c9b29d14e8b4930887e17b5da3e37cac Mon Sep 17 00:00:00 2001 From: Zbynek Roubalik Date: Tue, 8 Sep 2026 12:45:30 +0200 Subject: [PATCH 09/10] Document offline analysis input Signed-off-by: Zbynek Roubalik --- README.md | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 16f599c..c549cd3 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,26 @@ used by Kedify services. The request is: "input": { "schemaVersion": "resource-analysis-input/v1", "observedIntervalHours": 24, - "containers": [] + "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": {} } @@ -77,7 +96,9 @@ 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. +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 From c01412068207cfe85e4761e1b8dfdf162e5df4ce Mon Sep 17 00:00:00 2001 From: Zbynek Roubalik Date: Tue, 8 Sep 2026 13:03:33 +0200 Subject: [PATCH 10/10] Pin recommender v0.1.0 Signed-off-by: Zbynek Roubalik --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index ded3815..ceae3c3 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +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.0.0-20260907141451-4247f97d42f1 + 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 51fda73..f0a1e14 100644 --- a/go.sum +++ b/go.sum @@ -64,8 +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.0.0-20260907141451-4247f97d42f1 h1:If61rCsZBVSX0zq2g58H4ptxzGv8D1Efw/3xsbfQaNI= -github.com/kedify/recommender v0.0.0-20260907141451-4247f97d42f1/go.mod h1:EveD3V5VWf0RNMvsmFLk/Y3o8i8rJ3rCDi0pwPOQm4k= +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=