diff --git a/AGENTS.md b/AGENTS.md index 33053aa..d01169d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,3 +5,8 @@ - Keep `stdout` clean for command results and machine-readable output. - Send interactive prompts, progress messages, and other human-oriented terminal UX to `stderr`. - Preserve this split for Bubble Tea or other TUI flows so commands remain shell-friendly when `stdout` is redirected. + +## API pagination + +- When an API endpoint returns a paginated response, the CLI should read all pages automatically. +- Hide pagination mechanics from the user and print only the combined result. diff --git a/README.md b/README.md index 2e1ef8c..338b72a 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,12 @@ This repository contains an experimental `kedify` CLI built with `kong` for comm ## Current Features -- `kedify login` +- `kedify auth login` Reads a Kedify API token and stores it in the OS credential store when available, with a file fallback. +- `kedify auth token` + Prints the current auth token to stdout. - Interactive hidden token entry - When run in a terminal, `login` uses a Bubble Tea prompt and keeps the token hidden. + When run in a terminal, `auth login` uses a Bubble Tea prompt and keeps the token hidden. - Piped token input You can also provide a token via `stdin`. - CI-friendly token injection @@ -16,8 +18,12 @@ This repository contains an experimental `kedify` CLI built with `kong` for comm Calls the Kedify API and transparently reads all pages before printing the final cluster list. - `kedify get cluster [name-or-id]` Prints one cluster by name or id, and shows an interactive picker when no name is provided. +- `kedify list recommendations ` + 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. - Output formatting - `kedify list clusters` and `kedify get cluster` support `-o` and `--output` with `text`, `json`, or `yaml`. `text` is the default. + `kedify list clusters`, `kedify get cluster`, and `kedify list recommendations` support `-o` and `--output` with `text`, `json`, or `yaml`. `text` is the default. ## Build @@ -38,25 +44,31 @@ https://dashboard.dev.kedify.io/api-keys Interactive login: ```bash -./bin/kedify login +./bin/kedify auth login ``` Login with a global token flag: ```bash -./bin/kedify --token "$KEDIFY_TOKEN" login +./bin/kedify --token "$KEDIFY_TOKEN" auth login ``` Login with a positional token argument: ```bash -./bin/kedify login "$KEDIFY_TOKEN" +./bin/kedify auth login "$KEDIFY_TOKEN" +``` + +Print the current token: + +```bash +./bin/kedify auth token ``` Piped login: ```bash -printf '%s\n' "$KEDIFY_TOKEN" | ./bin/kedify login +printf '%s\n' "$KEDIFY_TOKEN" | ./bin/kedify auth login ``` Credentials are stored in: @@ -98,6 +110,47 @@ Get a cluster as JSON: ./bin/kedify get cluster my-cluster -o json ``` +List recommendations for a cluster as JSON: + +```bash +./bin/kedify list recommendations fc6af0dc-685b-4055-805d-0d3e0ead1596 -o json +``` + +Apply recommendations to a Helm values file and print the patch plan as JSON: + +```bash +./bin/kedify apply recommendations deployment/my-app \ + --namespace my-namespace \ + --chart-path ./chart \ + --values-file ./chart/values.yaml \ + --recommendations-file ./recommendations.json \ + --resources cpu-requests,memory-limits \ + --format json \ + --dry-run +``` + +Apply recommendations and write an override file: + +```bash +./bin/kedify apply recommendations deployment/my-app \ + --namespace my-namespace \ + --chart-path ./chart \ + --values-file ./chart/values.yaml \ + --recommendations-file ./recommendations.json \ + --resources cpu-requests,memory-limits \ + --format override \ + --output-file ./override-values.yaml +``` + +Notes for `apply recommendations`: + +- The command is Helm-only in v1. +- `--recommendations-file`, `--chart-path`, and `--values-file` are required. +- `--container` is optional. If omitted, the CLI matches all recommendation-bearing containers in the workload. +- All matched containers must be safely patchable for the run to succeed. +- `--output-file` is required for `--format override` unless `--dry-run` is set. +- JSON output includes top-level `containers` and per-entry `container` fields for multi-container runs. + Pick a cluster interactively: ```bash diff --git a/docs/apply-recommendation.md b/docs/apply-recommendation.md new file mode 100644 index 0000000..7ecf47d --- /dev/null +++ b/docs/apply-recommendation.md @@ -0,0 +1,261 @@ +# Apply Recommendations + +This document describes the v1 design for applying Kedify rightsizing recommendations to a user's Helm chart. + +## Goal + +The goal is to let CI fetch Kedify recommendations for Kubernetes workloads and generate a safe, reviewable change that can be proposed to the user as a pull request. + +For v1, the CLI will: + +- discover available recommendations through `kedify list recommendations` +- apply recommendations to Helm values only +- target one workload and one or more matched containers per invocation +- update up to four resource settings per matched container in a single run +- produce machine-readable output and patch artifacts suitable for CI + +PR creation is out of scope for the CLI itself. CI can open a PR separately, for example by using `gh`. + +## Command Shape + +The new command is: + +```bash +kedify apply recommendations deployment/ \ + --namespace \ + --chart-path \ + --values-file \ + --recommendations-file \ + [--container ] \ + [--resources cpu-requests,cpu-limits,memory-requests,memory-limits] \ + [--min-confidence 60] \ + [--format diff|override|json] \ + [--output-file ] \ + [--dry-run] +``` + +Notes: + +- `recommendations` is plural +- the positional target uses workload kind and workload name, for example `deployment/my-app` +- `--namespace` is required +- `--container` is optional +- `--chart-path` is required in Helm mode +- `--values-file` is required in Helm mode +- `--resources` is optional +- `--output-file` is required when `--format override` is used without `--dry-run` + +## Resource Selection + +Supported resource identifiers are: + +- `cpu-requests` +- `cpu-limits` +- `memory-requests` +- `memory-limits` + +The `--resources` flag accepts a comma-separated list: + +```bash +--resources cpu-requests,cpu-limits +``` + +If `--resources` is omitted, the CLI should apply all recommendations available for each matched container in the selected workload. + +At most four recommendations can be applied per matched container in one invocation, one for each supported resource identifier. + +## Recommendation Sources + +In v1, the command reads recommendations from a local JSON or YAML file provided via `--recommendations-file`. +Fetching recommendations directly from the Kedify API is planned, but not implemented yet. + +Only recommendations with status `waiting` are considered applicable. + +When `--container` is omitted, the CLI should: + +- inspect waiting recommendations for the selected workload +- match all containers that have waiting recommendations for the selected workload +- require every matched container to be safely patchable for the requested resources +- fail the whole run with structured reasons when any matched container cannot be patched safely + +## Confidence Threshold + +Confidence filtering is controlled by: + +```bash +--min-confidence +``` + +Rules: + +- the threshold is inclusive +- the default value is `60` +- recommendations below the threshold are not applied + +## Helm v1 Scope + +v1 is Helm-first. + +The command should: + +- render the chart as part of the workflow +- patch exactly one file provided through `--values-file` +- update Helm values only + +The command should not: + +- patch rendered manifests directly +- patch Helm templates directly +- patch multiple values files in one run + +## Matching Strategy + +The v1 matching strategy is heuristic. + +The CLI should: + +- render the Helm chart using `--chart-path` and `--values-file` +- find the rendered workload matching the requested kind, name, and namespace +- find the matching container by explicit container name when provided, or match all recommendation-bearing containers when omitted +- scan templates and values usage for matching container resource blocks + +Rules: + +- patch only when container mapping is explicit +- do not guess when multiple possible mappings exist +- when multiple containers are matched, patch all of them only if every container can be mapped safely +- fail when any matched recommendation cannot be mapped safely to the values file + +## Ambiguity and Safety + +Default ambiguity behavior is strict. + +If the CLI cannot safely map a recommendation to the values file, it should fail. + +Examples of failure conditions: + +- no matching recommendation found +- rendered workload not found +- container not found +- workload kind unsupported by the patcher +- resources are not exposed through values in an explicit way +- multiple candidate mappings exist + +Unsupported workload kinds should be reported in output even when they are not patchable in v1. + +## Output Formats + +The command supports these output formats: + +- `diff` +- `override` +- `json` + +### `diff` + +Emit a unified diff showing the change that would be made to the values file (requires `diff` on PATH). + +### `override` + +Emit a small generated Helm override values file containing only the recommended changes. + +When `--format override` is used without `--dry-run`, `--output-file` is required. + +### `json` + +Emit machine-readable patch plan or patch result JSON. + +The JSON output is not just raw recommendation data. It should describe what the patcher tried to do and what happened. + +The JSON payload currently includes: + +- top-level `container` when exactly one container is matched +- top-level `containers` when one or more containers are matched +- per-entry `container` on `matched`, `patched`, and `reasons` + +## Dry Run + +`--dry-run` means: + +- do not write files +- do not mutate the workspace +- still emit the selected output format + +Examples: + +- `--format diff --dry-run` prints the diff only +- `--format override --dry-run` prints or emits the generated override content without writing it +- `--format json --dry-run` prints the patch plan/result JSON without writing changes + +## Machine-Readable Result Semantics + +The JSON result should expose stable reason codes so CI can react consistently. + +At minimum, results should report these states: + +- `matched` +- `patched` +- `ambiguous` +- `unsupported` +- `not_found` +- `below_confidence_threshold` + +These codes should be stable and suitable for CI logic. + +## Exit Codes + +Expected v1 behavior: + +- exit `0` when patch generation succeeds +- exit non-zero when no safe mapping is found +- exit non-zero when the selected recommendation cannot be applied safely + +This is intended to make CI fail fast when the recommendation cannot be converted into a safe patch. + +## Example CI Flow + +The happy path in CI is: + +1. Run Kedify recommendation discovery. +2. Select a workload, namespace, optional container filter, and resource set. +3. Run `kedify apply recommendations ...`. +4. Generate either a diff, override file, or JSON patch result. +5. Commit the resulting change in CI. +6. Open a pull request with an external tool such as `gh`. + +## Non-Goals for v1 + +The following are out of scope for v1: + +- patching arbitrary raw YAML or Kustomize sources +- patching Helm templates directly +- editing more than one values file per invocation +- automatic PR creation inside the CLI +- applying recommendations when resource-to-values mapping is ambiguous + +## Future Direction + +After Helm v1, the same recommendation model should eventually support broader Kubernetes source layouts, including: + +- Helm charts beyond simple explicit values mapping +- plain YAML manifests +- Kustomize-based repositories +- additional workload kinds such as Jobs or StatefulSets when patching support is implemented + +## Next Task + +The next implementation task after the current Deployment-only v1 is to broaden supported workload kinds for `kedify apply recommendations`. + +The first candidates are: + +- `statefulset` +- `job` +- `cronjob` +- `daemonset` + +The immediate follow-up should be: + +- replace the hardcoded Deployment-only target check with a supported workload-kind list or switch-based dispatcher +- keep the same targeting model of kind, name, namespace, and optional container filter +- extend Helm render verification and values mapping logic to these additional workload kinds one by one +- continue to fail with a structured `unsupported` result for kinds that are not yet implemented diff --git a/internal/api/client.go b/internal/api/client.go index 9b97bce..8de7afd 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -2,6 +2,7 @@ package api import ( "encoding/json" + "errors" "fmt" "io" "net/http" @@ -12,13 +13,27 @@ import ( const requestTimeout = 30 * time.Second +var errNotPaginated = errors.New("response is not paginated") + type Client struct { httpClient *http.Client } -type clustersResponse struct { - Items []map[string]any `json:"items"` - PageInfo pageInfo `json:"pageInfo"` +type nonPaginatedPayloadError struct { + payload any +} + +func (e *nonPaginatedPayloadError) Error() string { + return errNotPaginated.Error() +} + +func (e *nonPaginatedPayloadError) Unwrap() error { + return errNotPaginated +} + +type paginatedResponse struct { + Items []any `json:"items"` + PageInfo pageInfo `json:"pageInfo"` } type pageInfo struct { @@ -33,11 +48,53 @@ func NewClient() *Client { } func (c *Client) ListClusters(apiURL, token string) ([]map[string]any, error) { - var allItems []map[string]any + items, err := c.listPaginatedItems(apiURL, token, "/clusters") + if err != nil { + return nil, err + } + + clusters := make([]map[string]any, 0, len(items)) + for _, item := range items { + cluster, ok := item.(map[string]any) + if !ok { + return nil, fmt.Errorf("parse response items: unexpected item type %T", item) + } + clusters = append(clusters, cluster) + } + + return clusters, nil +} + +func (c *Client) GetCluster(apiURL, token, clusterID string) (map[string]any, error) { + var payload map[string]any + if err := c.getJSON(apiURL, token, "/clusters/"+url.PathEscape(clusterID), &payload); err != nil { + return nil, fmt.Errorf("request cluster %s: %w", clusterID, err) + } + + return payload, nil +} + +func (c *Client) GetRecommendations(apiURL, token, clusterID string) (any, error) { + path := "/clusters/" + url.PathEscape(clusterID) + "/recommendations" + + items, err := c.listPaginatedItems(apiURL, token, path) + if err == nil { + return items, nil + } + + var payloadErr *nonPaginatedPayloadError + if errors.As(err, &payloadErr) { + return payloadErr.payload, nil + } + return nil, fmt.Errorf("request recommendations for cluster %s: %w", clusterID, err) +} + +func (c *Client) listPaginatedItems(apiURL, token, path string) ([]any, error) { + var allItems []any page := 1 for { - response, err := c.listClustersPage(apiURL, token, page) + response, err := c.listPage(apiURL, token, path, page) if err != nil { return nil, err } @@ -57,12 +114,21 @@ func (c *Client) ListClusters(apiURL, token string) ([]map[string]any, error) { return allItems, nil } -func (c *Client) GetCluster(apiURL, token, clusterID string) (map[string]any, error) { - requestURL := strings.TrimRight(apiURL, "/") + "/clusters/" + url.PathEscape(clusterID) +func (c *Client) listPage(apiURL, token, path string, page int) (paginatedResponse, error) { + requestURL, err := url.Parse(strings.TrimRight(apiURL, "/") + path) + if err != nil { + return paginatedResponse{}, fmt.Errorf("build request url: %w", err) + } - req, err := http.NewRequest(http.MethodGet, requestURL, nil) + if page > 1 { + query := requestURL.Query() + query.Set("page", fmt.Sprintf("%d", page)) + requestURL.RawQuery = query.Encode() + } + + req, err := http.NewRequest(http.MethodGet, requestURL.String(), nil) if err != nil { - return nil, fmt.Errorf("build request: %w", err) + return paginatedResponse{}, fmt.Errorf("build request: %w", err) } req.Header.Set("Authorization", "Bearer "+token) @@ -70,41 +136,63 @@ func (c *Client) GetCluster(apiURL, token, clusterID string) (map[string]any, er resp, err := c.httpClient.Do(req) if err != nil { - return nil, fmt.Errorf("request cluster %s: %w", clusterID, err) + return paginatedResponse{}, fmt.Errorf("request page %d for %s: %w", page, path, err) } body, err := readResponseBody(resp) if err != nil { - return nil, err + return paginatedResponse{}, err } if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return nil, fmt.Errorf("request failed with status %s: %s", resp.Status, strings.TrimSpace(string(body))) + return paginatedResponse{}, fmt.Errorf("request failed with status %s: %s", resp.Status, strings.TrimSpace(string(body))) } - var payload map[string]any + trimmedBody := strings.TrimSpace(string(body)) + if strings.HasPrefix(trimmedBody, "[") { + var payload []any + if err := json.Unmarshal(body, &payload); err != nil { + return paginatedResponse{}, fmt.Errorf("parse response as json: %w", err) + } + return paginatedResponse{}, &nonPaginatedPayloadError{payload: payload} + } + + var payload paginatedResponse if err := json.Unmarshal(body, &payload); err != nil { - return nil, fmt.Errorf("parse response as json: %w", err) + return paginatedResponse{}, fmt.Errorf("parse response as json: %w", err) + } + + if payload.Items == nil || payload.PageInfo.Page == 0 { + var rawPayload any + if err := json.Unmarshal(body, &rawPayload); err != nil { + return paginatedResponse{}, fmt.Errorf("parse response as json: %w", err) + } + return paginatedResponse{}, &nonPaginatedPayloadError{payload: rawPayload} } return payload, nil } -func (c *Client) listClustersPage(apiURL, token string, page int) (clustersResponse, error) { - requestURL, err := url.Parse(strings.TrimRight(apiURL, "/") + "/clusters") +func readResponseBody(resp *http.Response) ([]byte, error) { + body, err := io.ReadAll(resp.Body) if err != nil { - return clustersResponse{}, fmt.Errorf("build request url: %w", err) + _ = resp.Body.Close() + return nil, fmt.Errorf("read response: %w", err) } - if page > 1 { - query := requestURL.Query() - query.Set("page", fmt.Sprintf("%d", page)) - requestURL.RawQuery = query.Encode() + if err := resp.Body.Close(); err != nil { + return nil, fmt.Errorf("close response body: %w", err) } - req, err := http.NewRequest(http.MethodGet, requestURL.String(), nil) + return body, nil +} + +func (c *Client) getJSON(apiURL, token, path string, target any) error { + requestURL := strings.TrimRight(apiURL, "/") + path + + req, err := http.NewRequest(http.MethodGet, requestURL, nil) if err != nil { - return clustersResponse{}, fmt.Errorf("build request: %w", err) + return fmt.Errorf("build request: %w", err) } req.Header.Set("Authorization", "Bearer "+token) @@ -112,36 +200,21 @@ func (c *Client) listClustersPage(apiURL, token string, page int) (clustersRespo resp, err := c.httpClient.Do(req) if err != nil { - return clustersResponse{}, fmt.Errorf("request clusters page %d: %w", page, err) + return err } body, err := readResponseBody(resp) if err != nil { - return clustersResponse{}, err + return err } if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return clustersResponse{}, fmt.Errorf("request failed with status %s: %s", resp.Status, strings.TrimSpace(string(body))) - } - - var payload clustersResponse - if err := json.Unmarshal(body, &payload); err != nil { - return clustersResponse{}, fmt.Errorf("parse response as json: %w", err) - } - - return payload, nil -} - -func readResponseBody(resp *http.Response) ([]byte, error) { - body, err := io.ReadAll(resp.Body) - if err != nil { - _ = resp.Body.Close() - return nil, fmt.Errorf("read response: %w", err) + return fmt.Errorf("request failed with status %s: %s", resp.Status, strings.TrimSpace(string(body))) } - if err := resp.Body.Close(); err != nil { - return nil, fmt.Errorf("close response body: %w", err) + if err := json.Unmarshal(body, target); err != nil { + return fmt.Errorf("parse response as json: %w", err) } - return body, nil + return nil } diff --git a/internal/api/client_test.go b/internal/api/client_test.go index 4bb0e62..72a4394 100644 --- a/internal/api/client_test.go +++ b/internal/api/client_test.go @@ -88,3 +88,104 @@ func TestGetClusterCallsDedicatedEndpoint(t *testing.T) { t.Fatalf("cluster = %#v", cluster) } } + +func TestGetRecommendationsCallsDedicatedEndpoint(t *testing.T) { + requests := 0 + client := &Client{httpClient: &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + requests++ + if r.URL.Path != "/v1/clusters/fc6af0dc-685b-4055-805d-0d3e0ead1596/recommendations" { + t.Fatalf("path = %q", r.URL.Path) + } + + return &http.Response{ + StatusCode: http.StatusOK, + Status: "200 OK", + Body: io.NopCloser(strings.NewReader(`[{"kind":"cpu"}]`)), + Header: make(http.Header), + }, nil + })}} + + recommendations, err := client.GetRecommendations("https://api.dev.kedify.io/v1", "token", "fc6af0dc-685b-4055-805d-0d3e0ead1596") + if err != nil { + t.Fatalf("GetRecommendations() error = %v", err) + } + + items, ok := recommendations.([]any) + if !ok || len(items) != 1 { + t.Fatalf("recommendations = %#v", recommendations) + } + if requests != 1 { + t.Fatalf("requests = %d, want 1", requests) + } +} + +func TestGetRecommendationsReadsAllPages(t *testing.T) { + client := &Client{httpClient: &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.Path != "/v1/clusters/fc6af0dc-685b-4055-805d-0d3e0ead1596/recommendations" { + t.Fatalf("path = %q", r.URL.Path) + } + + page := r.URL.Query().Get("page") + var body string + switch page { + case "": + body = `{"items":[{"kind":"cpu"}],"pageInfo":{"hasNext":true,"page":1}}` + case "2": + body = `{"items":[{"kind":"memory"}],"pageInfo":{"hasNext":false,"page":2}}` + default: + return &http.Response{ + StatusCode: http.StatusBadRequest, + Status: "400 Bad Request", + Body: io.NopCloser(strings.NewReader("unexpected page")), + Header: make(http.Header), + }, nil + } + + return &http.Response{ + StatusCode: http.StatusOK, + Status: "200 OK", + Body: io.NopCloser(strings.NewReader(body)), + Header: make(http.Header), + }, nil + })}} + + recommendations, err := client.GetRecommendations("https://api.dev.kedify.io/v1", "token", "fc6af0dc-685b-4055-805d-0d3e0ead1596") + if err != nil { + t.Fatalf("GetRecommendations() error = %v", err) + } + + items, ok := recommendations.([]any) + if !ok || len(items) != 2 { + t.Fatalf("recommendations = %#v", recommendations) + } +} + +func TestGetRecommendationsFallsBackToNonPaginatedPayload(t *testing.T) { + requests := 0 + client := &Client{httpClient: &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + requests++ + if r.URL.Path != "/v1/clusters/fc6af0dc-685b-4055-805d-0d3e0ead1596/recommendations" { + t.Fatalf("path = %q", r.URL.Path) + } + + return &http.Response{ + StatusCode: http.StatusOK, + Status: "200 OK", + Body: io.NopCloser(strings.NewReader(`{"summary":{"count":1}}`)), + Header: make(http.Header), + }, nil + })}} + + recommendations, err := client.GetRecommendations("https://api.dev.kedify.io/v1", "token", "fc6af0dc-685b-4055-805d-0d3e0ead1596") + if err != nil { + t.Fatalf("GetRecommendations() error = %v", err) + } + + payload, ok := recommendations.(map[string]any) + if !ok || payload["summary"] == nil { + t.Fatalf("recommendations = %#v", recommendations) + } + if requests != 1 { + t.Fatalf("requests = %d, want 1", requests) + } +} diff --git a/internal/cli/apply_recommendations.go b/internal/cli/apply_recommendations.go new file mode 100644 index 0000000..f432a4e --- /dev/null +++ b/internal/cli/apply_recommendations.go @@ -0,0 +1,939 @@ +package cli + +import ( + "bytes" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" + + "gopkg.in/yaml.v3" +) + +var supportedRecommendationResources = []string{ + "cpu-requests", + "cpu-limits", + "memory-requests", + "memory-limits", +} + +type ApplyRecommendationsCmd struct { + Target string `arg:"" name:"target" help:"Target workload in kind/name form, for example deployment/my-app."` + Namespace string `name:"namespace" help:"Kubernetes namespace." required:""` + Container string `name:"container" help:"Container name."` + Resources string `name:"resources" help:"Comma-separated resources to apply."` + RecommendationsFile string `name:"recommendations-file" help:"Recommendations JSON or YAML file." required:""` + ChartPath string `name:"chart-path" help:"Path to the Helm chart." required:""` + ValuesFile string `name:"values-file" help:"Path to the Helm values file." required:""` + Format string `name:"format" help:"Output format." enum:"json,diff,override" default:"json"` + OutputFile string `name:"output-file" help:"Path to write override output."` + MinConfidence int `name:"min-confidence" help:"Minimum inclusive confidence threshold." default:"60"` + DryRun bool `name:"dry-run" help:"Compute output without writing files."` +} + +type recommendationEntry struct { + Kind string `json:"kind" yaml:"kind"` + Name string `json:"name" yaml:"name"` + Namespace string `json:"namespace" yaml:"namespace"` + ResourceUID string `json:"resourceUID" yaml:"resourceUID"` + Status string `json:"status" yaml:"status"` + Labels map[string]any `json:"labels" yaml:"labels"` + Raw map[string]any `json:"-" yaml:"-"` +} + +type applyRecommendationsResult struct { + Result string `json:"result"` + Target string `json:"target"` + Namespace string `json:"namespace"` + Container string `json:"container"` + Containers []string `json:"containers,omitempty"` + Format string `json:"format"` + DryRun bool `json:"dryRun"` + Matched []applyRecommendationMatch `json:"matched,omitempty"` + Patched []applyPatchedResource `json:"patched,omitempty"` + Reasons []applyRecommendationReason `json:"reasons,omitempty"` + ChangedFiles []string `json:"changedFiles,omitempty"` + OutputFile string `json:"outputFile,omitempty"` +} + +type applyRecommendationMatch struct { + Container string `json:"container,omitempty"` + Resource string `json:"resource"` + Status string `json:"status"` + Confidence int `json:"confidence"` + OriginalValue string `json:"originalValue"` + SuggestedValue string `json:"suggestedValue"` +} + +type applyPatchedResource struct { + Container string `json:"container,omitempty"` + Resource string `json:"resource"` + Path string `json:"path"` + OriginalValue string `json:"originalValue"` + SuggestedValue string `json:"suggestedValue"` +} + +type applyRecommendationReason struct { + Container string `json:"container,omitempty"` + Code string `json:"code"` + Resource string `json:"resource,omitempty"` + Message string `json:"message"` +} + +type renderedDeployment struct { + Kind string `yaml:"kind"` + Metadata struct { + Name string `yaml:"name"` + Namespace string `yaml:"namespace"` + } `yaml:"metadata"` + Spec struct { + Template struct { + Spec struct { + Containers []struct { + Name string `yaml:"name"` + } `yaml:"containers"` + } `yaml:"spec"` + } `yaml:"template"` + } `yaml:"spec"` +} + +type valuesCandidate struct { + Path []string + Node *yaml.Node +} + +type selectedContainerRecommendations struct { + Container string + Indexed map[string]recommendationEntry + DuplicateResources []string + SelectedResources []string +} + +func (c *ApplyRecommendationsCmd) Run(ctx *context) error { + if c.Format == "override" && !c.DryRun && strings.TrimSpace(c.OutputFile) == "" { + return errors.New("--output-file is required when --format override is used without --dry-run") + } + + kind, name, err := parseRecommendationTarget(c.Target) + if err != nil { + return err + } + + result := applyRecommendationsResult{ + Result: "failed", + Target: c.Target, + Namespace: c.Namespace, + Container: c.Container, + Format: c.Format, + DryRun: c.DryRun, + } + + if kind != "deployment" { + result.Reasons = append(result.Reasons, applyRecommendationReason{ + Code: "unsupported", + Message: fmt.Sprintf("workload kind %q is not supported in v1", kind), + }) + return &commandResultError{exitCode: 1, payload: result} + } + + recommendations, err := loadRecommendationsFile(c.RecommendationsFile) + if err != nil { + return err + } + + requestedResources, err := parseRequestedResources(c.Resources) + if err != nil { + return err + } + + workloadRecommendations := filterWorkloadRecommendations(recommendations, "Deployment", name, c.Namespace) + matchedContainers, err := matchedRecommendationContainers(workloadRecommendations, c.Container) + if err != nil { + var resolutionErr recommendationSelectionError + if errors.As(err, &resolutionErr) { + result.Reasons = append(result.Reasons, applyRecommendationReason{ + Code: resolutionErr.Code, + Message: resolutionErr.Message, + }) + return &commandResultError{exitCode: 1, payload: result} + } + return err + } + result.Containers = matchedContainers + if len(matchedContainers) == 1 { + result.Container = matchedContainers[0] + } + + containerSelections := make([]selectedContainerRecommendations, 0, len(matchedContainers)) + for _, container := range matchedContainers { + matchingRecommendations := filterRecommendationsByContainer(workloadRecommendations, container) + indexedRecommendations, duplicateResources := indexRecommendationsByResource(matchingRecommendations) + selectedResources := requestedResources + if len(selectedResources) == 0 { + selectedResources = availableRecommendationResources(indexedRecommendations) + } + if len(selectedResources) == 0 { + result.Reasons = append(result.Reasons, applyRecommendationReason{ + Container: container, + Code: "not_found", + Message: "no applicable recommendations found for the selected workload and container", + }) + continue + } + + containerSelections = append(containerSelections, selectedContainerRecommendations{ + Container: container, + Indexed: indexedRecommendations, + DuplicateResources: duplicateResources, + SelectedResources: selectedResources, + }) + + for _, resource := range selectedResources { + recommendation, ok := indexedRecommendations[resource] + if ok { + result.Matched = append(result.Matched, applyRecommendationMatch{ + Container: container, + Resource: resource, + Status: recommendation.Status, + Confidence: recommendationConfidence(recommendation), + OriginalValue: recommendationLabelText(recommendation, "originalValue"), + SuggestedValue: recommendationLabelText(recommendation, "suggestedValue"), + }) + } + } + + for _, resource := range selectedResources { + if slices.Contains(duplicateResources, resource) { + result.Reasons = append(result.Reasons, applyRecommendationReason{ + Container: container, + Code: "ambiguous", + Resource: resource, + Message: fmt.Sprintf("multiple recommendations matched resource %q", resource), + }) + continue + } + + recommendation, ok := indexedRecommendations[resource] + if !ok { + result.Reasons = append(result.Reasons, applyRecommendationReason{ + Container: container, + Code: "not_found", + Resource: resource, + Message: fmt.Sprintf("no waiting recommendation found for resource %q", resource), + }) + continue + } + + if recommendationConfidence(recommendation) < c.MinConfidence { + result.Reasons = append(result.Reasons, applyRecommendationReason{ + Container: container, + Code: "below_confidence_threshold", + Resource: resource, + Message: fmt.Sprintf("recommendation confidence %d is below threshold %d", recommendationConfidence(recommendation), c.MinConfidence), + }) + } + } + } + + if len(containerSelections) == 0 { + return &commandResultError{exitCode: 1, payload: result} + } + + renderedManifest, err := renderHelmChart(c.ChartPath, c.ValuesFile, c.Namespace) + if err != nil { + return err + } + + for _, selection := range containerSelections { + if !renderedDeploymentExists(renderedManifest, name, c.Namespace, selection.Container) { + result.Reasons = append(result.Reasons, applyRecommendationReason{ + Container: selection.Container, + Code: "not_found", + Message: "rendered deployment or container was not found in the Helm chart output", + }) + } + } + + valuesData, err := os.ReadFile(c.ValuesFile) // #nosec G304 -- CLI intentionally reads a user-selected Helm values file. + if err != nil { + return err + } + + var root yaml.Node + if err := yaml.Unmarshal(valuesData, &root); err != nil { + return fmt.Errorf("parse values file: %w", err) + } + + candidatesByContainer := make(map[string]valuesCandidate, len(containerSelections)) + for _, selection := range containerSelections { + candidates := findValuesCandidates(&root, name, selection.Container) + switch len(candidates) { + case 0: + result.Reasons = append(result.Reasons, applyRecommendationReason{ + Container: selection.Container, + Code: "not_found", + Message: "no explicit values mapping found for the selected workload and container", + }) + case 1: + candidatesByContainer[selection.Container] = candidates[0] + default: + result.Reasons = append(result.Reasons, applyRecommendationReason{ + Container: selection.Container, + Code: "ambiguous", + Message: "multiple explicit values mappings matched the selected workload and container", + }) + } + } + + if len(result.Reasons) > 0 { + return &commandResultError{exitCode: 1, payload: result} + } + + overrideValues := map[string]any{} + for _, selection := range containerSelections { + candidate := candidatesByContainer[selection.Container] + for _, resource := range selection.SelectedResources { + recommendation := selection.Indexed[resource] + path, err := setRecommendationValue(candidate.Node, resource, recommendationLabelText(recommendation, "suggestedValue")) + if err != nil { + result.Reasons = append(result.Reasons, applyRecommendationReason{ + Container: selection.Container, + Code: "unsupported", + Resource: resource, + Message: err.Error(), + }) + continue + } + + appendOverrideValue(overrideValues, candidate.Path, resource, recommendationLabelText(recommendation, "suggestedValue")) + result.Patched = append(result.Patched, applyPatchedResource{ + Container: selection.Container, + Resource: resource, + Path: strings.Join(append(candidate.Path, path...), "."), + OriginalValue: recommendationLabelText(recommendation, "originalValue"), + SuggestedValue: recommendationLabelText(recommendation, "suggestedValue"), + }) + } + } + + if len(result.Reasons) > 0 { + return &commandResultError{exitCode: 1, payload: result} + } + + patchedValues, err := marshalYAMLNode(&root) + if err != nil { + return err + } + + switch c.Format { + case "override": + overrideBytes, err := yaml.Marshal(overrideValues) + if err != nil { + return fmt.Errorf("marshal override output: %w", err) + } + + if !bytes.HasSuffix(overrideBytes, []byte("\n")) { + overrideBytes = append(overrideBytes, '\n') + } + + result.Result = "generated" + if c.DryRun { + result.Result = "planned" + } + if !c.DryRun { + if err := os.WriteFile(c.OutputFile, overrideBytes, 0o600); err != nil { // #nosec G304 -- CLI intentionally writes a user-selected override output file. + return fmt.Errorf("write override output: %w", err) + } + result.ChangedFiles = append(result.ChangedFiles, c.OutputFile) + result.OutputFile = c.OutputFile + } + _, err = ctx.stdout.Write(overrideBytes) + return err + case "diff": + diffOutput, err := unifiedDiff(c.ValuesFile, valuesData, patchedValues) + if err != nil { + return err + } + result.Result = "patched" + if c.DryRun { + result.Result = "planned" + } + if !c.DryRun { + if err := writePatchedValuesFile(c.ValuesFile, patchedValues); err != nil { + return err + } + result.ChangedFiles = append(result.ChangedFiles, c.ValuesFile) + } + if diffOutput == "" { + diffOutput = "\n" + } + _, err = io.WriteString(ctx.stdout, diffOutput) + return err + default: + result.Result = "patched" + if c.DryRun { + result.Result = "planned" + } + if !c.DryRun { + if err := writePatchedValuesFile(c.ValuesFile, patchedValues); err != nil { + return err + } + result.ChangedFiles = append(result.ChangedFiles, c.ValuesFile) + } + return ctx.writeOutput(ctx.stdout, result, "json") + } +} + +func parseRecommendationTarget(target string) (string, string, error) { + parts := strings.SplitN(strings.TrimSpace(target), "/", 2) + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return "", "", errors.New("target must use kind/name form, for example deployment/my-app") + } + + return strings.ToLower(parts[0]), parts[1], nil +} + +func parseRequestedResources(raw string) ([]string, error) { + if strings.TrimSpace(raw) == "" { + return nil, nil + } + + seen := make(map[string]struct{}) + resources := make([]string, 0, 4) + for _, resource := range strings.Split(raw, ",") { + resource = strings.TrimSpace(resource) + if resource == "" { + continue + } + if !slices.Contains(supportedRecommendationResources, resource) { + return nil, fmt.Errorf("unsupported resource %q", resource) + } + if _, ok := seen[resource]; ok { + continue + } + seen[resource] = struct{}{} + resources = append(resources, resource) + } + + return resources, nil +} + +func loadRecommendationsFile(path string) ([]recommendationEntry, error) { + data, err := os.ReadFile(path) // #nosec G304 -- CLI intentionally reads a user-selected recommendations file. + if err != nil { + return nil, fmt.Errorf("read recommendations file: %w", err) + } + + var list []map[string]any + if err := yaml.Unmarshal(data, &list); err == nil { + return recommendationEntriesFromMaps(list), nil + } + + var payload struct { + Items []map[string]any `json:"items" yaml:"items"` + } + if err := yaml.Unmarshal(data, &payload); err != nil { + return nil, fmt.Errorf("parse recommendations file: %w", err) + } + + return recommendationEntriesFromMaps(payload.Items), nil +} + +func recommendationEntriesFromMaps(items []map[string]any) []recommendationEntry { + recommendations := make([]recommendationEntry, 0, len(items)) + for _, item := range items { + entry := recommendationEntry{ + Kind: textValue(item["kind"]), + Name: textValue(item["name"]), + Namespace: textValue(item["namespace"]), + ResourceUID: textValue(item["resourceUID"]), + Status: textValue(item["status"]), + Labels: mapValue(item["labels"]), + Raw: item, + } + recommendations = append(recommendations, entry) + } + + return recommendations +} + +type recommendationSelectionError struct { + Code string + Message string +} + +func (e recommendationSelectionError) Error() string { + return e.Message +} + +func filterWorkloadRecommendations(recommendations []recommendationEntry, kind, name, namespace string) []recommendationEntry { + filtered := make([]recommendationEntry, 0, len(recommendations)) + for _, recommendation := range recommendations { + if recommendation.Kind != kind { + continue + } + if recommendation.Name != name { + continue + } + if recommendation.Namespace != namespace { + continue + } + if recommendation.Status != "waiting" { + continue + } + filtered = append(filtered, recommendation) + } + return filtered +} + +func filterRecommendationsByContainer(recommendations []recommendationEntry, container string) []recommendationEntry { + filtered := make([]recommendationEntry, 0, len(recommendations)) + for _, recommendation := range recommendations { + if recommendationLabelText(recommendation, "workloadContainer") != container { + continue + } + filtered = append(filtered, recommendation) + } + return filtered +} + +func matchedRecommendationContainers(recommendations []recommendationEntry, requestedContainer string) ([]string, error) { + if strings.TrimSpace(requestedContainer) != "" { + return []string{requestedContainer}, nil + } + + containerSet := map[string]struct{}{} + for _, recommendation := range recommendations { + container := recommendationLabelText(recommendation, "workloadContainer") + if container == "" { + continue + } + containerSet[container] = struct{}{} + } + + containers := make([]string, 0, len(containerSet)) + for container := range containerSet { + containers = append(containers, container) + } + + slices.Sort(containers) + if len(containers) == 0 { + return nil, recommendationSelectionError{ + Code: "not_found", + Message: "no applicable recommendations found for the selected workload", + } + } + return containers, nil +} + +func indexRecommendationsByResource(recommendations []recommendationEntry) (map[string]recommendationEntry, []string) { + indexed := make(map[string]recommendationEntry, len(recommendations)) + duplicates := make([]string, 0) + seenDuplicates := make(map[string]struct{}) + + for _, recommendation := range recommendations { + resource := recommendationResourceTarget(recommendation.ResourceUID) + if resource == "" { + continue + } + if _, exists := indexed[resource]; exists { + if _, recorded := seenDuplicates[resource]; !recorded { + duplicates = append(duplicates, resource) + seenDuplicates[resource] = struct{}{} + } + continue + } + indexed[resource] = recommendation + } + + return indexed, duplicates +} + +func availableRecommendationResources(indexed map[string]recommendationEntry) []string { + resources := make([]string, 0, len(indexed)) + for _, resource := range supportedRecommendationResources { + if _, ok := indexed[resource]; ok { + resources = append(resources, resource) + } + } + return resources +} + +func recommendationResourceTarget(resourceUID string) string { + parts := strings.Split(strings.TrimSpace(resourceUID), "/") + if len(parts) == 0 { + return "" + } + + resource := parts[len(parts)-1] + if slices.Contains(supportedRecommendationResources, resource) { + return resource + } + + return "" +} + +func recommendationConfidence(recommendation recommendationEntry) int { + value, ok := recommendation.Labels["confidence"] + if !ok { + return 0 + } + + switch v := value.(type) { + case int: + return v + case int64: + return int(v) + case float64: + return int(v) + default: + return 0 + } +} + +func recommendationLabelText(recommendation recommendationEntry, key string) string { + return textValue(recommendation.Labels[key]) +} + +func renderHelmChart(chartPath, valuesFile, namespace string) ([]byte, error) { + if _, err := exec.LookPath("helm"); err != nil { + return nil, fmt.Errorf("render helm chart: helm not found in PATH") + } + cmd := exec.Command("helm", "template", "kedify-apply", chartPath, "--namespace", namespace, "--values", valuesFile) // #nosec G204 -- executable is fixed; args are explicit CLI inputs for local Helm rendering. + output, err := cmd.CombinedOutput() + if err != nil { + return nil, fmt.Errorf("render helm chart: %w: %s", err, strings.TrimSpace(string(output))) + } + return output, nil +} + +func renderedDeploymentExists(rendered []byte, name, namespace, container string) bool { + decoder := yaml.NewDecoder(bytes.NewReader(rendered)) + for { + var manifest renderedDeployment + err := decoder.Decode(&manifest) + if errors.Is(err, io.EOF) { + return false + } + if err != nil { + return false + } + if manifest.Kind == "" { + continue + } + if manifest.Kind != "Deployment" { + continue + } + if manifest.Metadata.Name != name || manifest.Metadata.Namespace != namespace { + continue + } + for _, candidate := range manifest.Spec.Template.Spec.Containers { + if candidate.Name == container { + return true + } + } + return false + } +} + +func findValuesCandidates(root *yaml.Node, name, container string) []valuesCandidate { + if root == nil { + return nil + } + if root.Kind == yaml.DocumentNode && len(root.Content) > 0 { + return findValuesCandidates(root.Content[0], name, container) + } + + candidates := make([]valuesCandidate, 0) + visitValuesCandidates(root, nil, name, container, &candidates) + return candidates +} + +func visitValuesCandidates(node *yaml.Node, path []string, name, container string, candidates *[]valuesCandidate) { + if node == nil { + return + } + if node.Kind != yaml.MappingNode { + return + } + + if mappingString(node, "name") == name { + if mappingString(node, "containerName") == container { + if resourcesNode := mappingValue(node, "resources"); resourcesNode != nil && resourcesNode.Kind == yaml.MappingNode { + candidatePath := append([]string(nil), path...) + *candidates = append(*candidates, valuesCandidate{Path: candidatePath, Node: node}) + } + } + + appendContainerValuesCandidates(node, path, container, candidates) + } + + for i := 0; i < len(node.Content); i += 2 { + key := node.Content[i] + value := node.Content[i+1] + if value.Kind == yaml.MappingNode { + visitValuesCandidates(value, append(path, key.Value), name, container, candidates) + } + } +} + +func appendContainerValuesCandidates(workloadNode *yaml.Node, path []string, container string, candidates *[]valuesCandidate) { + containersNode := mappingValue(workloadNode, "containers") + if containersNode == nil || containersNode.Kind != yaml.MappingNode { + return + } + + for i := 0; i < len(containersNode.Content); i += 2 { + key := containersNode.Content[i] + value := containersNode.Content[i+1] + if value.Kind != yaml.MappingNode { + continue + } + + containerName := mappingString(value, "name") + if containerName == "" { + containerName = key.Value + } + if containerName != container { + continue + } + + if resourcesNode := mappingValue(value, "resources"); resourcesNode != nil && resourcesNode.Kind == yaml.MappingNode { + candidatePath := append(append([]string(nil), path...), "containers", key.Value) + *candidates = append(*candidates, valuesCandidate{Path: candidatePath, Node: value}) + } + } +} + +func setRecommendationValue(candidate *yaml.Node, resource, suggestedValue string) ([]string, error) { + path, err := valuesPathForResource(resource) + if err != nil { + return nil, err + } + + resourcesNode := mappingValue(candidate, "resources") + if resourcesNode == nil { + return nil, errors.New("resources block is not explicitly present in values mapping") + } + + current := resourcesNode + for i, segment := range path { + if current.Kind != yaml.MappingNode { + return nil, fmt.Errorf("path %q is not an explicit mapping", strings.Join(append([]string{"resources"}, path[:i]...), ".")) + } + + next := mappingValue(current, segment) + if next == nil { + next = &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"} + if i == len(path)-1 { + next = scalarNode(suggestedValue) + } + appendMappingEntry(current, segment, next) + } else if i == len(path)-1 { + *next = *scalarNode(suggestedValue) + } + current = next + } + + return append([]string{"resources"}, path...), nil +} + +func valuesPathForResource(resource string) ([]string, error) { + switch resource { + case "cpu-requests": + return []string{"requests", "cpu"}, nil + case "cpu-limits": + return []string{"limits", "cpu"}, nil + case "memory-requests": + return []string{"requests", "memory"}, nil + case "memory-limits": + return []string{"limits", "memory"}, nil + default: + return nil, fmt.Errorf("unsupported resource %q", resource) + } +} + +func appendOverrideValue(root map[string]any, candidatePath []string, resource, suggestedValue string) { + current := root + for _, segment := range candidatePath { + next, ok := current[segment].(map[string]any) + if !ok { + next = map[string]any{} + current[segment] = next + } + current = next + } + + resources, ok := current["resources"].(map[string]any) + if !ok { + resources = map[string]any{} + current["resources"] = resources + } + + switch resource { + case "cpu-requests": + overrideLeaf(resources, []string{"requests", "cpu"}, suggestedValue) + case "cpu-limits": + overrideLeaf(resources, []string{"limits", "cpu"}, suggestedValue) + case "memory-requests": + overrideLeaf(resources, []string{"requests", "memory"}, suggestedValue) + case "memory-limits": + overrideLeaf(resources, []string{"limits", "memory"}, suggestedValue) + } +} + +func overrideLeaf(root map[string]any, path []string, value string) { + current := root + for _, segment := range path[:len(path)-1] { + next, ok := current[segment].(map[string]any) + if !ok { + next = map[string]any{} + current[segment] = next + } + current = next + } + current[path[len(path)-1]] = value +} + +func marshalYAMLNode(root *yaml.Node) ([]byte, error) { + nodeToEncode := root + if root != nil && root.Kind == yaml.DocumentNode && len(root.Content) > 0 { + nodeToEncode = root.Content[0] + } + + var buf bytes.Buffer + encoder := yaml.NewEncoder(&buf) + encoder.SetIndent(2) + if err := encoder.Encode(nodeToEncode); err != nil { + _ = encoder.Close() + return nil, fmt.Errorf("marshal values file: %w", err) + } + if err := encoder.Close(); err != nil { + return nil, fmt.Errorf("close yaml encoder: %w", err) + } + return buf.Bytes(), nil +} + +func unifiedDiff(valuesFile string, original, patched []byte) (string, error) { + if _, err := exec.LookPath("diff"); err != nil { + return "", fmt.Errorf("generate unified diff: diff not found in PATH") + } + originalFile, err := writeTempDiffFile("original-values-", original) + if err != nil { + return "", err + } + defer removeTempDiffFile(originalFile) + + patchedFile, err := writeTempDiffFile("patched-values-", patched) + if err != nil { + return "", err + } + defer removeTempDiffFile(patchedFile) + + cmd := exec.Command("diff", "-u", "--label", filepath.Clean(valuesFile), "--label", filepath.Clean(valuesFile), originalFile, patchedFile) // #nosec G204 -- executable is fixed; args are controlled temp files and a user-selected values path label. + output, err := cmd.CombinedOutput() + if err == nil { + return string(output), nil + } + + var exitErr *exec.ExitError + if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 { + return string(output), nil + } + + return "", fmt.Errorf("generate unified diff: %w", err) +} + +func removeTempDiffFile(path string) { + _ = os.Remove(path) +} + +func writeTempDiffFile(prefix string, data []byte) (string, error) { + file, err := os.CreateTemp("", prefix) + if err != nil { + return "", fmt.Errorf("create temp diff file: %w", err) + } + if _, err := file.Write(data); err != nil { + _ = file.Close() + _ = os.Remove(file.Name()) + return "", fmt.Errorf("write temp diff file: %w", err) + } + if err := file.Close(); err != nil { + _ = os.Remove(file.Name()) + return "", fmt.Errorf("close temp diff file: %w", err) + } + return file.Name(), nil +} + +func writePatchedValuesFile(path string, data []byte) error { + info, err := os.Stat(path) + if err != nil { + return fmt.Errorf("stat values file: %w", err) + } + if err := os.WriteFile(path, data, info.Mode()); err != nil { // #nosec G304,G306 -- CLI intentionally updates the user-selected values file while preserving its existing mode. + return fmt.Errorf("write patched values file: %w", err) + } + return nil +} + +func mappingString(node *yaml.Node, key string) string { + value := mappingValue(node, key) + if value == nil { + return "" + } + return value.Value +} + +func mappingValue(node *yaml.Node, key string) *yaml.Node { + if node == nil || node.Kind != yaml.MappingNode { + return nil + } + for i := 0; i < len(node.Content); i += 2 { + if node.Content[i].Value == key { + return node.Content[i+1] + } + } + return nil +} + +func appendMappingEntry(node *yaml.Node, key string, value *yaml.Node) { + node.Content = append(node.Content, &yaml.Node{ + Kind: yaml.ScalarNode, + Tag: "!!str", + Value: key, + }, value) +} + +func scalarNode(value string) *yaml.Node { + return &yaml.Node{ + Kind: yaml.ScalarNode, + Tag: "!!str", + Value: value, + } +} + +func mapValue(value any) map[string]any { + if value == nil { + return map[string]any{} + } + mapped, ok := value.(map[string]any) + if !ok { + return map[string]any{} + } + return mapped +} + +func textValue(value any) string { + switch v := value.(type) { + case nil: + return "" + case string: + return v + default: + return fmt.Sprint(v) + } +} diff --git a/internal/cli/apply_recommendations_test.go b/internal/cli/apply_recommendations_test.go new file mode 100644 index 0000000..8e98105 --- /dev/null +++ b/internal/cli/apply_recommendations_test.go @@ -0,0 +1,689 @@ +package cli + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "slices" + "strings" + "testing" +) + +func TestApplyRecommendationsDiffDryRunDoesNotMutateValuesFile(t *testing.T) { + chartPath, valuesFile := copyTestChart(t) + recommendationsFile := testRecommendationsFile(t) + originalValues, err := os.ReadFile(valuesFile) + if err != nil { + t.Fatalf("ReadFile() error = %v", err) + } + + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + code := Run([]string{ + "apply", "recommendations", "deployment/keda-operator", + "--namespace", "keda", + "--container", "keda-operator", + "--chart-path", chartPath, + "--values-file", valuesFile, + "--recommendations-file", recommendationsFile, + "--resources", "cpu-requests,memory-limits", + "--min-confidence", "20", + "--format", "diff", + "--dry-run", + }, bytes.NewBuffer(nil), stdout, stderr) + + if code != 0 { + t.Fatalf("Run() exit code = %d, stdout = %q, stderr = %q", code, stdout.String(), stderr.String()) + } + if stderr.Len() != 0 { + t.Fatalf("stderr = %q, want empty", stderr.String()) + } + if !strings.Contains(stdout.String(), "- cpu: 100m") || !strings.Contains(stdout.String(), "+ cpu: 20m") { + t.Fatalf("stdout = %q, want unified diff with cpu change", stdout.String()) + } + currentValues, err := os.ReadFile(valuesFile) + if err != nil { + t.Fatalf("ReadFile() error = %v", err) + } + if string(currentValues) != string(originalValues) { + t.Fatalf("values file changed during dry-run") + } +} + +func TestApplyRecommendationsDiffPatchesValuesFile(t *testing.T) { + chartPath, valuesFile := copyTestChart(t) + recommendationsFile := testRecommendationsFile(t) + + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + code := Run([]string{ + "apply", "recommendations", "deployment/keda-operator", + "--namespace", "keda", + "--container", "keda-operator", + "--chart-path", chartPath, + "--values-file", valuesFile, + "--recommendations-file", recommendationsFile, + "--resources", "cpu-requests,memory-limits", + "--min-confidence", "20", + "--format", "diff", + }, bytes.NewBuffer(nil), stdout, stderr) + + if code != 0 { + t.Fatalf("Run() exit code = %d, stdout = %q, stderr = %q", code, stdout.String(), stderr.String()) + } + if !strings.Contains(stdout.String(), "+ memory: 138Mi") { + t.Fatalf("stdout = %q, want unified diff with memory change", stdout.String()) + } + valuesData, err := os.ReadFile(valuesFile) + if err != nil { + t.Fatalf("ReadFile() error = %v", err) + } + valuesText := string(valuesData) + for _, expected := range []string{"cpu: 20m", "memory: 138Mi"} { + if !strings.Contains(valuesText, expected) { + t.Fatalf("values file = %q, want %q", valuesText, expected) + } + } + for _, expected := range []string{"name: audit-sidecar", "cpu: 5m", "memory: 64Mi"} { + if !strings.Contains(valuesText, expected) { + t.Fatalf("values file = %q, want unchanged sidecar value %q", valuesText, expected) + } + } +} + +func TestApplyRecommendationsOverrideWritesOutputFile(t *testing.T) { + chartPath, valuesFile := copyTestChart(t) + recommendationsFile := testRecommendationsFile(t) + outputFile := filepath.Join(t.TempDir(), "override.yaml") + originalValues, err := os.ReadFile(valuesFile) + if err != nil { + t.Fatalf("ReadFile() error = %v", err) + } + + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + code := Run([]string{ + "apply", "recommendations", "deployment/kedify-agent", + "--namespace", "keda", + "--container", "manager", + "--chart-path", chartPath, + "--values-file", valuesFile, + "--recommendations-file", recommendationsFile, + "--min-confidence", "20", + "--format", "override", + "--output-file", outputFile, + }, bytes.NewBuffer(nil), stdout, stderr) + + if code != 0 { + t.Fatalf("Run() exit code = %d, stdout = %q, stderr = %q", code, stdout.String(), stderr.String()) + } + if stderr.Len() != 0 { + t.Fatalf("stderr = %q, want empty", stderr.String()) + } + overrideData, err := os.ReadFile(outputFile) + if err != nil { + t.Fatalf("ReadFile() error = %v", err) + } + overrideText := string(overrideData) + for _, expected := range []string{"kedifyAgent:", "containers:", "manager:", "memory: 50Mi", "memory: 150Mi"} { + if !strings.Contains(overrideText, expected) { + t.Fatalf("override file = %q, want %q", overrideText, expected) + } + } + if strings.Contains(overrideText, "proxy:") { + t.Fatalf("override file = %q, did not expect unrelated sidecar override", overrideText) + } + if stdout.String() != overrideText { + t.Fatalf("stdout = %q, want generated override yaml", stdout.String()) + } + currentValues, err := os.ReadFile(valuesFile) + if err != nil { + t.Fatalf("ReadFile() error = %v", err) + } + if string(currentValues) != string(originalValues) { + t.Fatalf("values file changed during override output mode") + } +} + +func TestApplyRecommendationsJSONReportsContainerScopedPath(t *testing.T) { + chartPath, valuesFile := copyTestChart(t) + recommendationsFile := testRecommendationsFile(t) + originalValues, err := os.ReadFile(valuesFile) + if err != nil { + t.Fatalf("ReadFile() error = %v", err) + } + + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + code := Run([]string{ + "apply", "recommendations", "deployment/keda-operator", + "--namespace", "keda", + "--container", "keda-operator", + "--chart-path", chartPath, + "--values-file", valuesFile, + "--recommendations-file", recommendationsFile, + "--resources", "cpu-requests,memory-limits", + "--min-confidence", "20", + "--format", "json", + "--dry-run", + }, bytes.NewBuffer(nil), stdout, stderr) + + if code != 0 { + t.Fatalf("Run() exit code = %d, stdout = %q, stderr = %q", code, stdout.String(), stderr.String()) + } + if stderr.Len() != 0 { + t.Fatalf("stderr = %q, want empty", stderr.String()) + } + + var result applyRecommendationsResult + if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { + t.Fatalf("Unmarshal() error = %v, stdout = %q", err, stdout.String()) + } + if result.Result != "planned" { + t.Fatalf("result = %#v, want planned", result) + } + if len(result.Patched) != 2 { + t.Fatalf("patched = %#v, want two patched resources", result.Patched) + } + + gotPaths := make([]string, 0, len(result.Patched)) + for _, patched := range result.Patched { + gotPaths = append(gotPaths, patched.Path) + } + for _, expected := range []string{ + "deployments.kedaOperator.containers.operator.resources.requests.cpu", + "deployments.kedaOperator.containers.operator.resources.limits.memory", + } { + if !slices.Contains(gotPaths, expected) { + t.Fatalf("patched paths = %#v, want %q", gotPaths, expected) + } + } + + currentValues, err := os.ReadFile(valuesFile) + if err != nil { + t.Fatalf("ReadFile() error = %v", err) + } + if string(currentValues) != string(originalValues) { + t.Fatalf("values file changed during dry-run json mode") + } +} + +func TestApplyRecommendationsWithoutContainerAutoResolvesSingleMatchingContainer(t *testing.T) { + chartPath, valuesFile := copyTestChart(t) + recommendationsFile := testRecommendationsFile(t) + + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + code := Run([]string{ + "apply", "recommendations", "deployment/keda-operator-metrics-apiserver", + "--namespace", "keda", + "--chart-path", chartPath, + "--values-file", valuesFile, + "--recommendations-file", recommendationsFile, + "--resources", "cpu-requests,memory-limits", + "--min-confidence", "20", + "--format", "json", + "--dry-run", + }, bytes.NewBuffer(nil), stdout, stderr) + + if code != 0 { + t.Fatalf("Run() exit code = %d, stdout = %q, stderr = %q", code, stdout.String(), stderr.String()) + } + + var result applyRecommendationsResult + if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { + t.Fatalf("Unmarshal() error = %v, stdout = %q", err, stdout.String()) + } + if result.Container != "keda-operator-metrics-apiserver" { + t.Fatalf("result container = %q, want keda-operator-metrics-apiserver", result.Container) + } + if !slices.Equal(result.Containers, []string{"keda-operator-metrics-apiserver"}) { + t.Fatalf("result containers = %#v, want single matched container", result.Containers) + } + if result.Result != "planned" { + t.Fatalf("result = %#v, want planned", result) + } +} + +func TestApplyRecommendationsWithoutContainerPatchesAllMatchedContainers(t *testing.T) { + chartPath, valuesFile := copyTestChart(t) + recommendationsFile := testRecommendationsFile(t) + originalValues, err := os.ReadFile(valuesFile) + if err != nil { + t.Fatalf("ReadFile() error = %v", err) + } + + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + code := Run([]string{ + "apply", "recommendations", "deployment/keda-operator", + "--namespace", "keda", + "--chart-path", chartPath, + "--values-file", valuesFile, + "--recommendations-file", recommendationsFile, + "--resources", "cpu-requests,memory-limits", + "--min-confidence", "20", + "--format", "json", + "--dry-run", + }, bytes.NewBuffer(nil), stdout, stderr) + + if code != 0 { + t.Fatalf("Run() exit code = %d, stdout = %q, stderr = %q", code, stdout.String(), stderr.String()) + } + + var result applyRecommendationsResult + if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { + t.Fatalf("Unmarshal() error = %v, stdout = %q", err, stdout.String()) + } + if result.Result != "planned" { + t.Fatalf("result = %#v, want planned", result) + } + if result.Container != "" { + t.Fatalf("result container = %q, want empty for multi-container match", result.Container) + } + if !slices.Equal(result.Containers, []string{"audit-sidecar", "keda-operator"}) { + t.Fatalf("result containers = %#v, want both matched containers", result.Containers) + } + if len(result.Patched) != 4 { + t.Fatalf("patched = %#v, want four patched resources", result.Patched) + } + + gotPaths := make([]string, 0, len(result.Patched)) + gotContainers := make([]string, 0, len(result.Patched)) + for _, patched := range result.Patched { + gotPaths = append(gotPaths, patched.Path) + gotContainers = append(gotContainers, patched.Container) + } + for _, expected := range []string{ + "deployments.kedaOperator.containers.operator.resources.requests.cpu", + "deployments.kedaOperator.containers.operator.resources.limits.memory", + "deployments.kedaOperator.containers.auditSidecar.resources.requests.cpu", + "deployments.kedaOperator.containers.auditSidecar.resources.limits.memory", + } { + if !slices.Contains(gotPaths, expected) { + t.Fatalf("patched paths = %#v, want %q", gotPaths, expected) + } + } + for _, expected := range []string{"audit-sidecar", "keda-operator"} { + if !slices.Contains(gotContainers, expected) { + t.Fatalf("patched containers = %#v, want %q", gotContainers, expected) + } + } + + currentValues, err := os.ReadFile(valuesFile) + if err != nil { + t.Fatalf("ReadFile() error = %v", err) + } + if string(currentValues) != string(originalValues) { + t.Fatalf("values file changed during dry-run") + } +} + +func TestApplyRecommendationsWithoutContainerFailsWhenOneMatchedContainerIsMissingRequestedResource(t *testing.T) { + chartPath, valuesFile := copyTestChart(t) + recommendationsFile := writeRecommendationsFile(t, []map[string]any{ + { + "kind": "Deployment", + "name": "keda-operator", + "namespace": "keda", + "resourceUID": "keda/deployment/keda-operator/keda-operator/cpu-requests", + "status": "waiting", + "labels": map[string]any{ + "workloadContainer": "keda-operator", + "originalValue": "100m", + "suggestedValue": "20m", + "confidence": 80, + }, + }, + { + "kind": "Deployment", + "name": "keda-operator", + "namespace": "keda", + "resourceUID": "keda/deployment/keda-operator/keda-operator/memory-limits", + "status": "waiting", + "labels": map[string]any{ + "workloadContainer": "keda-operator", + "originalValue": "1000Mi", + "suggestedValue": "138Mi", + "confidence": 80, + }, + }, + { + "kind": "Deployment", + "name": "keda-operator", + "namespace": "keda", + "resourceUID": "keda/deployment/keda-operator/audit-sidecar/cpu-requests", + "status": "waiting", + "labels": map[string]any{ + "workloadContainer": "audit-sidecar", + "originalValue": "5m", + "suggestedValue": "10m", + "confidence": 80, + }, + }, + }) + + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + code := Run([]string{ + "apply", "recommendations", "deployment/keda-operator", + "--namespace", "keda", + "--chart-path", chartPath, + "--values-file", valuesFile, + "--recommendations-file", recommendationsFile, + "--resources", "cpu-requests,memory-limits", + "--min-confidence", "20", + "--format", "json", + "--dry-run", + }, bytes.NewBuffer(nil), stdout, stderr) + + if code != 0 { + var result applyRecommendationsResult + if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { + t.Fatalf("Unmarshal() error = %v, stdout = %q", err, stdout.String()) + } + if !containsReasonCode(result.Reasons, "not_found") { + t.Fatalf("reasons = %#v, want not_found", result.Reasons) + } + if !strings.Contains(stdout.String(), "audit-sidecar") || !strings.Contains(stdout.String(), "memory-limits") { + t.Fatalf("stdout = %q, want failing container and resource details", stdout.String()) + } + return + } + t.Fatalf("Run() exit code = %d, want non-zero", code) +} + +func TestApplyRecommendationsFailsBelowConfidenceThreshold(t *testing.T) { + chartPath, valuesFile := copyTestChart(t) + recommendationsFile := testRecommendationsFile(t) + + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + code := Run([]string{ + "apply", "recommendations", "deployment/keda-operator", + "--namespace", "keda", + "--container", "keda-operator", + "--chart-path", chartPath, + "--values-file", valuesFile, + "--recommendations-file", recommendationsFile, + "--resources", "cpu-requests", + "--format", "json", + }, bytes.NewBuffer(nil), stdout, stderr) + + if code == 0 { + t.Fatalf("Run() exit code = %d, want non-zero", code) + } + if stderr.Len() != 0 { + t.Fatalf("stderr = %q, want empty", stderr.String()) + } + + var result applyRecommendationsResult + if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { + t.Fatalf("Unmarshal() error = %v, stdout = %q", err, stdout.String()) + } + if result.Result != "failed" { + t.Fatalf("result = %#v", result) + } + if !containsReasonCode(result.Reasons, "below_confidence_threshold") { + t.Fatalf("reasons = %#v, want below_confidence_threshold", result.Reasons) + } +} + +func TestApplyRecommendationsFailsWhenResourceIsMissing(t *testing.T) { + chartPath, valuesFile := copyTestChart(t) + recommendationsFile := testRecommendationsFile(t) + + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + code := Run([]string{ + "apply", "recommendations", "deployment/kedify-agent", + "--namespace", "keda", + "--container", "manager", + "--chart-path", chartPath, + "--values-file", valuesFile, + "--recommendations-file", recommendationsFile, + "--resources", "cpu-limits", + "--min-confidence", "20", + "--format", "json", + }, bytes.NewBuffer(nil), stdout, stderr) + + if code == 0 { + t.Fatalf("Run() exit code = %d, want non-zero", code) + } + + var result applyRecommendationsResult + if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { + t.Fatalf("Unmarshal() error = %v, stdout = %q", err, stdout.String()) + } + if !containsReasonCode(result.Reasons, "not_found") { + t.Fatalf("reasons = %#v, want not_found", result.Reasons) + } +} + +func TestApplyRecommendationsFailsWhenValuesMappingIsAmbiguous(t *testing.T) { + chartPath, valuesFile := copyTestChart(t) + recommendationsFile := testRecommendationsFile(t) + + valuesData, err := os.ReadFile(valuesFile) + if err != nil { + t.Fatalf("ReadFile() error = %v", err) + } + valuesData = append(valuesData, []byte(` +duplicates: + another: + name: keda-operator + containerName: keda-operator + resources: + requests: + cpu: 100m +`)...) + if err := os.WriteFile(valuesFile, valuesData, 0o644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + code := Run([]string{ + "apply", "recommendations", "deployment/keda-operator", + "--namespace", "keda", + "--container", "keda-operator", + "--chart-path", chartPath, + "--values-file", valuesFile, + "--recommendations-file", recommendationsFile, + "--resources", "cpu-requests", + "--min-confidence", "20", + "--format", "json", + }, bytes.NewBuffer(nil), stdout, stderr) + + if code == 0 { + t.Fatalf("Run() exit code = %d, want non-zero", code) + } + + var result applyRecommendationsResult + if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { + t.Fatalf("Unmarshal() error = %v, stdout = %q", err, stdout.String()) + } + if !containsReasonCode(result.Reasons, "ambiguous") { + t.Fatalf("reasons = %#v, want ambiguous", result.Reasons) + } +} + +func TestApplyRecommendationsPassesNamespaceToHelmTemplate(t *testing.T) { + chartPath := filepath.Join(t.TempDir(), "chart") + valuesFile := filepath.Join(chartPath, "values.yaml") + recommendationsFile := writeRecommendationsFile(t, []map[string]any{ + { + "kind": "Deployment", + "name": "demo", + "namespace": "custom-ns", + "resourceUID": "custom-ns/deployment/demo/demo/cpu-requests", + "status": "waiting", + "labels": map[string]any{ + "workloadContainer": "demo", + "originalValue": "100m", + "suggestedValue": "200m", + "confidence": 80, + }, + }, + }) + + writeTestChartFiles(t, chartPath, map[string]string{ + "Chart.yaml": "apiVersion: v2\nname: namespace-test\nversion: 0.1.0\n", + "values.yaml": `deployment: + name: demo + containerName: demo + resources: + requests: + cpu: 100m +`, + "templates/deployment.yaml": `apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ .Values.deployment.name }} + namespace: {{ .Release.Namespace }} +spec: + selector: + matchLabels: + app: {{ .Values.deployment.name }} + template: + metadata: + labels: + app: {{ .Values.deployment.name }} + spec: + containers: + - name: {{ .Values.deployment.containerName }} + image: registry.k8s.io/pause:3.10 + resources: +{{ toYaml .Values.deployment.resources | indent 12 }} +`, + }) + + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + code := Run([]string{ + "apply", "recommendations", "deployment/demo", + "--namespace", "custom-ns", + "--chart-path", chartPath, + "--values-file", valuesFile, + "--recommendations-file", recommendationsFile, + "--resources", "cpu-requests", + "--min-confidence", "20", + "--format", "json", + "--dry-run", + }, bytes.NewBuffer(nil), stdout, stderr) + + if code != 0 { + t.Fatalf("Run() exit code = %d, stdout = %q, stderr = %q", code, stdout.String(), stderr.String()) + } + if stderr.Len() != 0 { + t.Fatalf("stderr = %q, want empty", stderr.String()) + } + + var result applyRecommendationsResult + if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { + t.Fatalf("Unmarshal() error = %v, stdout = %q", err, stdout.String()) + } + if result.Result != "planned" { + t.Fatalf("result = %#v, want planned", result) + } + if len(result.Reasons) != 0 { + t.Fatalf("reasons = %#v, want empty", result.Reasons) + } +} + +func copyTestChart(t *testing.T) (string, string) { + t.Helper() + + sourceRoot, err := filepath.Abs("../../test/chart") + if err != nil { + t.Fatalf("Abs() error = %v", err) + } + targetRoot := filepath.Join(t.TempDir(), "chart") + copyDir(t, sourceRoot, targetRoot) + + return targetRoot, filepath.Join(targetRoot, "values.yaml") +} + +func copyDir(t *testing.T, source, target string) { + t.Helper() + + if err := os.MkdirAll(target, 0o755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + + entries, err := os.ReadDir(source) + if err != nil { + t.Fatalf("ReadDir() error = %v", err) + } + + for _, entry := range entries { + sourcePath := filepath.Join(source, entry.Name()) + targetPath := filepath.Join(target, entry.Name()) + + if entry.IsDir() { + copyDir(t, sourcePath, targetPath) + continue + } + + data, err := os.ReadFile(sourcePath) + if err != nil { + t.Fatalf("ReadFile() error = %v", err) + } + info, err := os.Stat(sourcePath) + if err != nil { + t.Fatalf("Stat() error = %v", err) + } + if err := os.WriteFile(targetPath, data, info.Mode()); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + } +} + +func testRecommendationsFile(t *testing.T) string { + t.Helper() + + path, err := filepath.Abs("../../test/recommendations.json") + if err != nil { + t.Fatalf("Abs() error = %v", err) + } + return path +} + +func writeRecommendationsFile(t *testing.T, items []map[string]any) string { + t.Helper() + + data, err := json.Marshal(items) + if err != nil { + t.Fatalf("Marshal() error = %v", err) + } + + path := filepath.Join(t.TempDir(), "recommendations.json") + if err := os.WriteFile(path, data, 0o644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + return path +} + +func writeTestChartFiles(t *testing.T, root string, files map[string]string) { + t.Helper() + + for relativePath, content := range files { + path := filepath.Join(root, relativePath) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + } +} + +func containsReasonCode(reasons []applyRecommendationReason, code string) bool { + for _, reason := range reasons { + if reason.Code == code { + return true + } + } + return false +} diff --git a/internal/cli/auth_token.go b/internal/cli/auth_token.go new file mode 100644 index 0000000..e4fe25a --- /dev/null +++ b/internal/cli/auth_token.go @@ -0,0 +1,15 @@ +package cli + +import "fmt" + +type AuthTokenCmd struct{} + +func (c *AuthTokenCmd) Run(ctx *context) error { + token, err := resolveToken(ctx) + if err != nil { + return err + } + + _, err = fmt.Fprintln(ctx.stdout, token) + return err +} diff --git a/internal/cli/commands_test.go b/internal/cli/commands_test.go index 4b6daa2..29c4bf1 100644 --- a/internal/cli/commands_test.go +++ b/internal/cli/commands_test.go @@ -30,12 +30,13 @@ func (f *fakeCredentialsStore) WriteCredentials(creds credentials) error { } type fakeClusterService struct { - clusters []map[string]any - cluster map[string]any - err error - lastURL string - lastToken string - lastID string + clusters []map[string]any + cluster map[string]any + recommendations any + err error + lastURL string + lastToken string + lastID string } func (f *fakeClusterService) ListClusters(apiURL, token string) ([]map[string]any, error) { @@ -57,6 +58,16 @@ func (f *fakeClusterService) GetCluster(apiURL, token, clusterID string) (map[st return f.cluster, nil } +func (f *fakeClusterService) GetRecommendations(apiURL, token, clusterID string) (any, error) { + f.lastURL = apiURL + f.lastToken = token + f.lastID = clusterID + if f.err != nil { + return nil, f.err + } + return f.recommendations, nil +} + func TestLoginCmdRunStoresCredentials(t *testing.T) { store := &fakeCredentialsStore{} stdout := &bytes.Buffer{} @@ -153,6 +164,49 @@ func TestLoginCmdRunIgnoresWhitespaceContextToken(t *testing.T) { } } +func TestAuthTokenCmdRunPrintsStoredToken(t *testing.T) { + store := &fakeCredentialsStore{creds: credentials{Token: "stored-token"}} + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + ctx := &context{ + stdin: bytes.NewBuffer(nil), + stdout: stdout, + stderr: stderr, + credentials: store, + } + + if err := (&AuthTokenCmd{}).Run(ctx); err != nil { + t.Fatalf("Run() error = %v", err) + } + + if stdout.String() != "stored-token\n" { + t.Fatalf("stdout = %q, want token with newline", stdout.String()) + } + if stderr.Len() != 0 { + t.Fatalf("stderr = %q, want empty", stderr.String()) + } +} + +func TestAuthTokenCmdRunPrefersContextToken(t *testing.T) { + store := &fakeCredentialsStore{creds: credentials{Token: "stored-token"}} + stdout := &bytes.Buffer{} + ctx := &context{ + stdin: bytes.NewBuffer(nil), + stdout: stdout, + stderr: &bytes.Buffer{}, + token: "override-token", + credentials: store, + } + + if err := (&AuthTokenCmd{}).Run(ctx); err != nil { + t.Fatalf("Run() error = %v", err) + } + + if stdout.String() != "override-token\n" { + t.Fatalf("stdout = %q, want override token with newline", stdout.String()) + } +} + func TestListClustersCmdRunWritesClusters(t *testing.T) { store := &fakeCredentialsStore{creds: credentials{Token: "stored-token"}} service := &fakeClusterService{ @@ -397,6 +451,88 @@ func TestGetClusterCmdRunUsesSelectorWhenNameMissing(t *testing.T) { } } +func TestListRecommendationsCmdRunWritesRecommendations(t *testing.T) { + store := &fakeCredentialsStore{creds: credentials{Token: "stored-token"}} + service := &fakeClusterService{ + recommendations: map[string]any{ + "items": []map[string]any{{"kind": "cpu"}}, + }, + } + + var gotValue any + var gotFormat string + ctx := &context{ + stdin: bytes.NewBuffer(nil), + stdout: &bytes.Buffer{}, + stderr: &bytes.Buffer{}, + apiURL: "https://api.dev.kedify.io/v1", + token: "override-token", + client: service, + credentials: store, + writeOutput: func(_ io.Writer, value any, format string) error { + gotValue = value + gotFormat = format + return nil + }, + } + + id := "fc6af0dc-685b-4055-805d-0d3e0ead1596" + if err := (&ListRecommendationsCmd{ClusterID: id, Output: "yaml"}).Run(ctx); err != nil { + t.Fatalf("Run() error = %v", err) + } + + payload, ok := gotValue.(map[string]any) + if !ok { + t.Fatalf("got output value = %#v", gotValue) + } + if gotFormat != "yaml" { + t.Fatalf("output format = %q, want %q", gotFormat, "yaml") + } + if payload["items"] == nil { + t.Fatalf("payload = %#v", payload) + } + if service.lastToken != "override-token" { + t.Fatalf("service token = %q, want %q", service.lastToken, "override-token") + } + if service.lastID != id { + t.Fatalf("service lastID = %q, want %q", service.lastID, id) + } +} + +func TestListRecommendationsCmdRunWritesResultsOnlyToStdout(t *testing.T) { + store := &fakeCredentialsStore{creds: credentials{Token: "stored-token"}} + service := &fakeClusterService{ + recommendations: []map[string]any{{"kind": "cpu"}}, + } + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + + ctx := &context{ + stdin: bytes.NewBuffer(nil), + stdout: stdout, + stderr: stderr, + apiURL: "https://api.dev.kedify.io/v1", + token: "override-token", + client: service, + credentials: store, + writeOutput: writeOutput, + } + + if err := (&ListRecommendationsCmd{ + ClusterID: "fc6af0dc-685b-4055-805d-0d3e0ead1596", + Output: "json", + }).Run(ctx); err != nil { + t.Fatalf("Run() error = %v", err) + } + + if stdout.Len() == 0 { + t.Fatal("stdout is empty, want rendered output") + } + if stderr.Len() != 0 { + t.Fatalf("stderr = %q, want empty", stderr.String()) + } +} + func TestFindClusterReturnsErrorWhenMissing(t *testing.T) { _, err := findCluster([]map[string]any{{"name": "alpha"}}, "beta") if err == nil { diff --git a/internal/cli/recommendations.go b/internal/cli/recommendations.go new file mode 100644 index 0000000..5ecd3d4 --- /dev/null +++ b/internal/cli/recommendations.go @@ -0,0 +1,20 @@ +package cli + +type ListRecommendationsCmd struct { + ClusterID string `arg:"" name:"cluster-id" help:"Cluster id."` + Output string `name:"output" short:"o" help:"Output format." enum:"text,json,yaml" default:"text"` +} + +func (c *ListRecommendationsCmd) Run(ctx *context) error { + token, err := resolveToken(ctx) + if err != nil { + return err + } + + recommendations, err := ctx.client.GetRecommendations(ctx.apiURL, token, c.ClusterID) + if err != nil { + return err + } + + return ctx.writeOutput(ctx.stdout, recommendations, c.Output) +} diff --git a/internal/cli/run.go b/internal/cli/run.go index e2c71db..bcc7635 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -1,6 +1,7 @@ package cli import ( + "errors" "fmt" "io" @@ -12,17 +13,28 @@ 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"` + Auth AuthCmd `cmd:"" help:"Authentication helpers."` + Apply ApplyCmd `cmd:"" help:"Apply Kedify recommendations."` Get GetCmd `cmd:"" help:"Get Kedify resources."` - Login LoginCmd `cmd:"" help:"Read an auth token from stdin and store it locally. Generate a token at https://dashboard.dev.kedify.io/api-keys."` List ListCmd `cmd:"" help:"List Kedify resources."` } +type AuthCmd struct { + Login 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 AuthTokenCmd `cmd:"" help:"Print the auth token."` +} + type GetCmd struct { Cluster GetClusterCmd `cmd:"" help:"Get a cluster by name or id. If no name is provided, an interactive picker is shown."` } +type ApplyCmd struct { + Recommendations ApplyRecommendationsCmd `cmd:"" help:"Apply recommendations to a Helm values file."` +} + type ListCmd struct { - Clusters ListClustersCmd `cmd:"" help:"List clusters."` + Clusters ListClustersCmd `cmd:"" help:"List clusters."` + Recommendations ListRecommendationsCmd `cmd:"" help:"List recommendations for a cluster id."` } type ListClustersCmd struct { @@ -37,6 +49,7 @@ type credentialsStore interface { type clusterService interface { ListClusters(apiURL, token string) ([]map[string]any, error) GetCluster(apiURL, token, clusterID string) (map[string]any, error) + GetRecommendations(apiURL, token, clusterID string) (any, error) } type credentials struct { @@ -89,9 +102,28 @@ func Run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { app.apiURL = cli.APIURL app.token = cli.Token if err := kctx.Run(app); err != nil { + var cmdErr *commandResultError + if errors.As(err, &cmdErr) { + if cmdErr.payload != nil { + if writeErr := app.writeOutput(stdout, cmdErr.payload, "json"); writeErr != nil { + _, _ = fmt.Fprintf(stderr, "kedify: error: %v\n", writeErr) + return 1 + } + } + return cmdErr.exitCode + } _, _ = fmt.Fprintf(stderr, "kedify: error: %v\n", err) return 1 } return 0 } + +type commandResultError struct { + exitCode int + payload any +} + +func (e *commandResultError) Error() string { + return "command failed" +} diff --git a/internal/output/output.go b/internal/output/output.go index 2ed44a8..99077cb 100644 --- a/internal/output/output.go +++ b/internal/output/output.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "io" + "slices" "strings" "time" @@ -47,15 +48,57 @@ func Write(w io.Writer, value any, format string) error { func renderText(value any) ([]byte, error) { switch v := value.(type) { + case []any: + // An empty list is ambiguous (could be clusters, recommendations, or something else), + // so fall back to YAML to avoid misleading "No clusters found" output. + if len(v) == 0 { + return yaml.Marshal(v) + } + if clusters, ok := asClusterList(v); ok { + if looksLikeClusterList(clusters) { + return renderClusterListText(clusters), nil + } + if looksLikeRecommendationList(clusters) { + return renderRecommendationListText(clusters), nil + } + } + return yaml.Marshal(v) case []map[string]any: - return renderClusterListText(v), nil + // An empty list is ambiguous (could be clusters, recommendations, or something else), + // so fall back to YAML to avoid misleading "No clusters found" output. + if len(v) == 0 { + return yaml.Marshal(v) + } + if looksLikeClusterList(v) { + return renderClusterListText(v), nil + } + if looksLikeRecommendationList(v) { + return renderRecommendationListText(v), nil + } + return yaml.Marshal(v) case map[string]any: + if !looksLikeCluster(v) { + return yaml.Marshal(v) + } return renderClusterText(v), nil default: return nil, fmt.Errorf("text output is not supported for %T", value) } } +func asClusterList(items []any) ([]map[string]any, bool) { + clusters := make([]map[string]any, 0, len(items)) + for _, item := range items { + cluster, ok := item.(map[string]any) + if !ok { + return nil, false + } + clusters = append(clusters, cluster) + } + + return clusters, true +} + func renderClusterListText(clusters []map[string]any) []byte { if len(clusters) == 0 { return []byte("No clusters found.\n") @@ -96,18 +139,38 @@ func renderClusterText(cluster map[string]any) []byte { return []byte(renderTextTable(rows)) } -func clusterTextValue(cluster map[string]any, key string) string { - value, ok := cluster[key] - if !ok { - return "" +func renderRecommendationListText(recommendations []map[string]any) []byte { + if len(recommendations) == 0 { + return []byte("No recommendations found.\n") + } + + grouped := groupRecommendations(recommendations) + rows := make([][]string, 0, len(grouped)+1) + rows = append(rows, []string{"KIND", "CONTAINER", "NAME", "NAMESPACE", "CPU REQUESTS", "CPU LIMITS", "MEMORY REQUESTS", "MEMORY LIMITS"}) + + for _, recommendation := range grouped { + rows = append(rows, []string{ + recommendation.kind, + recommendation.container, + recommendation.name, + recommendation.namespace, + recommendation.cpuRequests, + recommendation.cpuLimits, + recommendation.memoryRequests, + recommendation.memoryLimits, + }) } - text, ok := value.(string) + return []byte(renderTextTable(rows)) +} + +func clusterTextValue(cluster map[string]any, key string) string { + value, ok := cluster[key] if !ok { return "" } - return text + return textValue(value) } func fallbackClusterValue(cluster map[string]any, key, fallback string) string { @@ -211,3 +274,179 @@ func humanAge(d time.Duration) string { } return fmt.Sprintf("%dy", int(d.Hours()/(24*365))) } + +func textValue(value any) string { + switch v := value.(type) { + case nil: + return "" + case string: + return v + default: + return fmt.Sprint(v) + } +} + +func looksLikeClusterList(items []map[string]any) bool { + if len(items) == 0 { + return true + } + + for _, item := range items { + if !looksLikeCluster(item) { + return false + } + } + + return true +} + +func looksLikeRecommendationList(items []map[string]any) bool { + if len(items) == 0 { + return true + } + + for _, item := range items { + if !looksLikeRecommendation(item) { + return false + } + } + + return true +} + +func looksLikeCluster(value map[string]any) bool { + if _, ok := value["agentStatus"]; ok { + return true + } + if _, ok := value["kedaStatus"]; ok { + return true + } + if _, ok := value["createdAt"]; ok { + return true + } + + agent, ok := value["agent"].(map[string]any) + if !ok { + return false + } + + if _, ok := agent["version"]; ok { + return true + } + if _, ok := agent["kedaConfigs"]; ok { + return true + } + + return false +} + +func looksLikeRecommendation(value map[string]any) bool { + _, hasResourceUID := value["resourceUID"] + labels, _ := value["labels"].(map[string]any) + _, hasOriginalValue := labels["originalValue"] + _, hasSuggestedValue := labels["suggestedValue"] + + return hasResourceUID || hasOriginalValue || hasSuggestedValue +} + +func recommendationLabelText(recommendation map[string]any, key string) string { + labels, ok := recommendation["labels"].(map[string]any) + if !ok { + return "" + } + + return textValue(labels[key]) +} + +type recommendationRow struct { + kind string + container string + name string + namespace string + cpuRequests string + cpuLimits string + memoryRequests string + memoryLimits string +} + +func groupRecommendations(recommendations []map[string]any) []recommendationRow { + rowsByKey := make(map[string]*recommendationRow, len(recommendations)) + order := make([]string, 0, len(recommendations)) + + for _, recommendation := range recommendations { + key := recommendationGroupKey(recommendation) + row, ok := rowsByKey[key] + if !ok { + row = &recommendationRow{ + kind: textValue(recommendation["kind"]), + container: recommendationLabelText(recommendation, "workloadContainer"), + name: textValue(recommendation["name"]), + namespace: textValue(recommendation["namespace"]), + } + rowsByKey[key] = row + order = append(order, key) + } + + switch recommendationResourceTarget(recommendation) { + case "cpu-requests": + row.cpuRequests = recommendationValueChange(recommendation) + case "cpu-limits": + row.cpuLimits = recommendationValueChange(recommendation) + case "memory-requests": + row.memoryRequests = recommendationValueChange(recommendation) + case "memory-limits": + row.memoryLimits = recommendationValueChange(recommendation) + } + } + + rows := make([]recommendationRow, 0, len(order)) + for _, key := range order { + rows = append(rows, *rowsByKey[key]) + } + + return rows +} + +func recommendationGroupKey(recommendation map[string]any) string { + return strings.Join([]string{ + textValue(recommendation["kind"]), + textValue(recommendation["name"]), + textValue(recommendation["namespace"]), + recommendationLabelText(recommendation, "workloadContainer"), + }, "\x00") +} + +func recommendationResourceTarget(recommendation map[string]any) string { + resourceUID := textValue(recommendation["resourceUID"]) + if resourceUID == "" { + return "" + } + + parts := strings.Split(resourceUID, "/") + if len(parts) == 0 { + return "" + } + + last := parts[len(parts)-1] + if slices.Contains([]string{"cpu-requests", "cpu-limits", "memory-requests", "memory-limits"}, last) { + return last + } + + return "" +} + +func recommendationValueChange(recommendation map[string]any) string { + originalValue := recommendationLabelText(recommendation, "originalValue") + suggestedValue := recommendationLabelText(recommendation, "suggestedValue") + + switch { + case originalValue == "" && suggestedValue == "": + return "" + case originalValue == "": + return suggestedValue + case suggestedValue == "": + return originalValue + default: + return originalValue + " -> " + suggestedValue + } +} diff --git a/internal/output/output_test.go b/internal/output/output_test.go index 0f19871..ce4df2b 100644 --- a/internal/output/output_test.go +++ b/internal/output/output_test.go @@ -2,6 +2,8 @@ package output import ( "bytes" + "encoding/json" + "os" "strings" "testing" ) @@ -103,3 +105,247 @@ func TestWriteTextSingleCluster(t *testing.T) { t.Fatalf("unexpected padded output: %q", got) } } + +func TestWriteTextNonClusterMapFallsBackToYAML(t *testing.T) { + var out bytes.Buffer + value := map[string]any{ + "items": []any{ + map[string]any{ + "workloadName": "demo", + "cpuRequest": "100m", + }, + }, + "pageInfo": map[string]any{ + "hasNext": false, + }, + } + + if err := Write(&out, value, "text"); err != nil { + t.Fatalf("Write() error = %v", err) + } + + got := out.String() + if strings.Contains(got, "AGENT VERSION") { + t.Fatalf("unexpected cluster table output: %q", got) + } + for _, expected := range []string{"items:", "workloadName: demo", "cpuRequest: 100m", "pageInfo:"} { + if !strings.Contains(got, expected) { + t.Fatalf("expected %q in output %q", expected, got) + } + } +} + +func TestWriteTextNonClusterListFallsBackToYAML(t *testing.T) { + var out bytes.Buffer + value := []map[string]any{ + { + "kind": "cpu", + "workloadName": "demo", + }, + } + + if err := Write(&out, value, "text"); err != nil { + t.Fatalf("Write() error = %v", err) + } + + got := out.String() + if strings.Contains(got, "AGENT VERSION") { + t.Fatalf("unexpected cluster table output: %q", got) + } + for _, expected := range []string{"- kind: cpu", "workloadName: demo"} { + if !strings.Contains(got, expected) { + t.Fatalf("expected %q in output %q", expected, got) + } + } +} + +func TestWriteTextGenericNameNamespaceListFallsBackToYAML(t *testing.T) { + var out bytes.Buffer + value := []map[string]any{ + { + "name": "demo", + "namespace": "default", + "replicas": 3, + }, + } + + if err := Write(&out, value, "text"); err != nil { + t.Fatalf("Write() error = %v", err) + } + + got := out.String() + if strings.Contains(got, "CPU REQUESTS") || strings.Contains(got, "MEMORY LIMITS") { + t.Fatalf("unexpected recommendations table output: %q", got) + } + for _, expected := range []string{"- name: demo", "namespace: default", "replicas: 3"} { + if !strings.Contains(got, expected) { + t.Fatalf("expected %q in output %q", expected, got) + } + } +} + +func TestWriteTextEmptyMapListFallsBackToYAML(t *testing.T) { + var out bytes.Buffer + value := []map[string]any{} + + if err := Write(&out, value, "text"); err != nil { + t.Fatalf("Write() error = %v", err) + } + + got := out.String() + if got != "[]\n" { + t.Fatalf("output = %q, want YAML empty list", got) + } + if strings.Contains(got, "No clusters found.") { + t.Fatalf("unexpected cluster-specific output: %q", got) + } +} + +func TestWriteTextRecommendationsListUsesTable(t *testing.T) { + var out bytes.Buffer + value := []any{ + map[string]any{ + "kind": "Deployment", + "name": "demo", + "namespace": "default", + "resourceUID": "default/deployment/demo/demo/cpu-requests", + "labels": map[string]any{ + "workloadContainer": "demo-container", + "originalValue": "100m", + "suggestedValue": "200m", + }, + }, + map[string]any{ + "kind": "Deployment", + "name": "demo", + "namespace": "default", + "resourceUID": "default/deployment/demo/demo/memory-limits", + "labels": map[string]any{ + "workloadContainer": "demo-container", + "originalValue": "512Mi", + "suggestedValue": "256Mi", + }, + }, + } + + if err := Write(&out, value, "text"); err != nil { + t.Fatalf("Write() error = %v", err) + } + + got := out.String() + for _, expected := range []string{ + "KIND", + "CONTAINER", + "NAME", + "NAMESPACE", + "CPU REQUESTS", + "CPU LIMITS", + "MEMORY REQUESTS", + "MEMORY LIMITS", + "Deployment", + "demo-container", + "demo", + "default", + "100m -> 200m", + "512Mi -> 256Mi", + } { + if !strings.Contains(got, expected) { + t.Fatalf("expected %q in output %q", expected, got) + } + } + if !strings.Contains(got, "KIND") || !strings.Contains(got, "CONTAINER") || !strings.Contains(got, "MEMORY LIMITS") { + t.Fatalf("unexpected header order in output %q", got) + } + if strings.Contains(got, "AGENT VERSION") { + t.Fatalf("unexpected cluster table output: %q", got) + } + if strings.Contains(got, "- labels:") { + t.Fatalf("unexpected yaml output: %q", got) + } +} + +func TestWriteTextRecommendationsFromSampleFileUsesTable(t *testing.T) { + var items []any + + data, err := os.ReadFile("../../test/recommendations.json") + if err != nil { + t.Fatalf("ReadFile() error = %v", err) + } + if err := json.Unmarshal(data, &items); err != nil { + var payload struct { + Items []any `json:"items"` + } + if err := json.Unmarshal(data, &payload); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + items = payload.Items + } + + var out bytes.Buffer + if err := Write(&out, items, "text"); err != nil { + t.Fatalf("Write() error = %v", err) + } + + got := out.String() + for _, expected := range []string{ + "KIND", + "CONTAINER", + "NAME", + "NAMESPACE", + "CPU REQUESTS", + "CPU LIMITS", + "MEMORY REQUESTS", + "MEMORY LIMITS", + "Deployment", + "keda-add-ons-http-interceptor", + "keda-add-ons-http-interceptor", + "keda", + "250m -> 20m", + "500m -> 100m", + "20Mi -> 24Mi", + "512Mi -> 73Mi", + } { + if !strings.Contains(got, expected) { + t.Fatalf("expected %q in output %q", expected, got) + } + } + if strings.Contains(got, "waiting") || strings.Contains(got, "20\n") { + t.Fatalf("unexpected legacy recommendation columns in output %q", got) + } + if !strings.Contains(got, "keda-operator") || !strings.Contains(got, "100Mi -> 46Mi") { + t.Fatalf("expected merged workload rows in output %q", got) + } + if !strings.Contains(got, "audit-sidecar") || !strings.Contains(got, "16Mi -> 24Mi") || !strings.Contains(got, "64Mi -> 72Mi") { + t.Fatalf("expected sidecar recommendation row in output %q", got) + } + if !strings.Contains(got, "NAME") || !strings.Contains(got, "CONTAINER") || !strings.Contains(got, "MEMORY LIMITS") { + t.Fatalf("missing recommendation headers in output %q", got) + } + if !strings.Contains(got, "Deployment manager") { + t.Fatalf("expected workload container column in output %q", got) + } +} + +func TestWriteTextFlattenedGenericListFallsBackToYAML(t *testing.T) { + var out bytes.Buffer + value := []any{ + map[string]any{ + "kind": "cpu", + "workloadName": "demo", + }, + } + + if err := Write(&out, value, "text"); err != nil { + t.Fatalf("Write() error = %v", err) + } + + got := out.String() + if strings.Contains(got, "AGENT VERSION") { + t.Fatalf("unexpected cluster table output: %q", got) + } + for _, expected := range []string{"- kind: cpu", "workloadName: demo"} { + if !strings.Contains(got, expected) { + t.Fatalf("expected %q in output %q", expected, got) + } + } +} diff --git a/kedify_0.0.1_linux_amd64.tar.gz b/kedify_0.0.1_linux_amd64.tar.gz new file mode 100644 index 0000000..7a9ad76 Binary files /dev/null and b/kedify_0.0.1_linux_amd64.tar.gz differ diff --git a/test/chart/Chart.yaml b/test/chart/Chart.yaml new file mode 100644 index 0000000..eece287 --- /dev/null +++ b/test/chart/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: kedify-recommendations-test +description: Test chart for Kedify recommendation patching. +type: application +version: 0.1.0 +appVersion: "0.1.0" diff --git a/test/chart/templates/deployments.yaml b/test/chart/templates/deployments.yaml new file mode 100644 index 0000000..b7df1c3 --- /dev/null +++ b/test/chart/templates/deployments.yaml @@ -0,0 +1,129 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ .Values.deployments.kedaAddOnsHttpInterceptor.name }} + namespace: {{ .Values.namespace }} +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: {{ .Values.deployments.kedaAddOnsHttpInterceptor.name }} + template: + metadata: + labels: + app.kubernetes.io/name: {{ .Values.deployments.kedaAddOnsHttpInterceptor.name }} + spec: + containers: + - name: {{ .Values.deployments.kedaAddOnsHttpInterceptor.containerName }} + image: {{ .Values.deployments.kedaAddOnsHttpInterceptor.image }} + resources: +{{ toYaml .Values.deployments.kedaAddOnsHttpInterceptor.resources | indent 12 }} +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ .Values.deployments.kedaAddOnsHttpExternalScaler.name }} + namespace: {{ .Values.namespace }} +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: {{ .Values.deployments.kedaAddOnsHttpExternalScaler.name }} + template: + metadata: + labels: + app.kubernetes.io/name: {{ .Values.deployments.kedaAddOnsHttpExternalScaler.name }} + spec: + containers: + - name: {{ .Values.deployments.kedaAddOnsHttpExternalScaler.containerName }} + image: {{ .Values.deployments.kedaAddOnsHttpExternalScaler.image }} + resources: +{{ toYaml .Values.deployments.kedaAddOnsHttpExternalScaler.resources | indent 12 }} +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ .Values.deployments.kedaOperatorMetricsApiserver.name }} + namespace: {{ .Values.namespace }} +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: {{ .Values.deployments.kedaOperatorMetricsApiserver.name }} + template: + metadata: + labels: + app.kubernetes.io/name: {{ .Values.deployments.kedaOperatorMetricsApiserver.name }} + spec: + containers: + - name: {{ .Values.deployments.kedaOperatorMetricsApiserver.containerName }} + image: {{ .Values.deployments.kedaOperatorMetricsApiserver.image }} + resources: +{{ toYaml .Values.deployments.kedaOperatorMetricsApiserver.resources | indent 12 }} +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ .Values.deployments.kedaOperator.name }} + namespace: {{ .Values.namespace }} +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: {{ .Values.deployments.kedaOperator.name }} + template: + metadata: + labels: + app.kubernetes.io/name: {{ .Values.deployments.kedaOperator.name }} + spec: + containers: +{{- range $container := .Values.deployments.kedaOperator.containers }} + - name: {{ $container.name }} + image: {{ $container.image }} + resources: +{{ toYaml $container.resources | indent 12 }} +{{- end }} +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ .Values.deployments.kedaAdmissionWebhooks.name }} + namespace: {{ .Values.namespace }} +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: {{ .Values.deployments.kedaAdmissionWebhooks.name }} + template: + metadata: + labels: + app.kubernetes.io/name: {{ .Values.deployments.kedaAdmissionWebhooks.name }} + spec: + containers: + - name: {{ .Values.deployments.kedaAdmissionWebhooks.containerName }} + image: {{ .Values.deployments.kedaAdmissionWebhooks.image }} + resources: +{{ toYaml .Values.deployments.kedaAdmissionWebhooks.resources | indent 12 }} +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ .Values.deployments.kedifyAgent.name }} + namespace: {{ .Values.namespace }} +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: {{ .Values.deployments.kedifyAgent.name }} + template: + metadata: + labels: + app.kubernetes.io/name: {{ .Values.deployments.kedifyAgent.name }} + spec: + containers: +{{- range $container := .Values.deployments.kedifyAgent.containers }} + - name: {{ $container.name }} + image: {{ $container.image }} + resources: +{{ toYaml $container.resources | indent 12 }} +{{- end }} diff --git a/test/chart/values.yaml b/test/chart/values.yaml new file mode 100644 index 0000000..9ec92da --- /dev/null +++ b/test/chart/values.yaml @@ -0,0 +1,96 @@ +namespace: keda + +deployments: + kedaAddOnsHttpInterceptor: + name: keda-add-ons-http-interceptor + containerName: keda-add-ons-http-interceptor + image: registry.k8s.io/pause:3.10 + resources: + requests: + cpu: 250m + memory: 20Mi + limits: + cpu: 500m + memory: 512Mi + + kedaAddOnsHttpExternalScaler: + name: keda-add-ons-http-external-scaler + containerName: keda-add-ons-http-external-scaler + image: registry.k8s.io/pause:3.10 + resources: + requests: + cpu: 250m + memory: 256Mi + limits: + cpu: 500m + memory: 512Mi + + kedaOperatorMetricsApiserver: + name: keda-operator-metrics-apiserver + containerName: keda-operator-metrics-apiserver + image: registry.k8s.io/pause:3.10 + resources: + requests: + cpu: 100m + memory: 100Mi + limits: + cpu: "1" + memory: 1000Mi + + kedaOperator: + name: keda-operator + containers: + operator: + name: keda-operator + image: registry.k8s.io/pause:3.10 + resources: + requests: + cpu: 100m + memory: 100Mi + limits: + cpu: "1" + memory: 1000Mi + auditSidecar: + name: audit-sidecar + image: registry.k8s.io/pause:3.10 + resources: + requests: + cpu: 5m + memory: 16Mi + limits: + cpu: 25m + memory: 64Mi + + kedaAdmissionWebhooks: + name: keda-admission-webhooks + containerName: keda-admission-webhooks + image: registry.k8s.io/pause:3.10 + resources: + requests: + cpu: 100m + memory: 100Mi + limits: + cpu: "1" + memory: 1000Mi + + kedifyAgent: + name: kedify-agent + containers: + manager: + name: manager + image: registry.k8s.io/pause:3.10 + resources: + requests: + memory: 300Mi + limits: + memory: 1.0Gi + proxy: + name: proxy + image: registry.k8s.io/pause:3.10 + resources: + requests: + cpu: 10m + memory: 32Mi + limits: + cpu: 50m + memory: 64Mi diff --git a/test/recommendations.json b/test/recommendations.json new file mode 100644 index 0000000..939bf0c --- /dev/null +++ b/test/recommendations.json @@ -0,0 +1,600 @@ +[ + { + "clusterId": "ecf6a4d3-fc2c-403b-b70c-6de243ddfbbb", + "date": "2026-06-18T12:06:54.134883Z", + "hint": "The value was calculated as suggested value for cpu requests * ${limitsToRequestsRatio} - '20.00' x '5.0'", + "id": "0ee3f4ac-ed5d-40fb-94f8-2dd6fbef1fc6", + "kind": "Deployment", + "labels": { + "confidence": 20, + "intervalHours": 4, + "kubectlCmd": "kubectl set resources deployment keda-add-ons-http-interceptor -c keda-add-ons-http-interceptor -n keda --limits=cpu=100m", + "originalValue": "500m", + "originalValueRaw": 500, + "query": "1000 * max by(namespace, workloadKind, workloadName, container) (container_cpu_limits{kedify_agent_id=\"%s\"})", + "suggestedValue": "100m", + "suggestedValueRaw": 100, + "workloadContainer": "keda-add-ons-http-interceptor", + "workloadName": "keda-add-ons-http-interceptor" + }, + "name": "keda-add-ons-http-interceptor", + "namespace": "keda", + "resourceUID": "keda/deployment/keda-add-ons-http-interceptor/keda-add-ons-http-interceptor/cpu-limits", + "status": "waiting" + }, + { + "clusterId": "ecf6a4d3-fc2c-403b-b70c-6de243ddfbbb", + "date": "2026-06-18T12:06:54.128595Z", + "hint": "The value was calculated as suggested value for cpu requests * ${limitsToRequestsRatio} - '20.00' x '5.0'", + "id": "7f1f6ee2-b045-4721-8e45-3453cb93506c", + "kind": "Deployment", + "labels": { + "confidence": 20, + "intervalHours": 4, + "kubectlCmd": "kubectl set resources deployment keda-add-ons-http-external-scaler -c keda-add-ons-http-external-scaler -n keda --limits=cpu=100m", + "originalValue": "500m", + "originalValueRaw": 500, + "query": "1000 * max by(namespace, workloadKind, workloadName, container) (container_cpu_limits{kedify_agent_id=\"%s\"})", + "suggestedValue": "100m", + "suggestedValueRaw": 100, + "workloadContainer": "keda-add-ons-http-external-scaler", + "workloadName": "keda-add-ons-http-external-scaler" + }, + "name": "keda-add-ons-http-external-scaler", + "namespace": "keda", + "resourceUID": "keda/deployment/keda-add-ons-http-external-scaler/keda-add-ons-http-external-scaler/cpu-limits", + "status": "waiting" + }, + { + "clusterId": "ecf6a4d3-fc2c-403b-b70c-6de243ddfbbb", + "date": "2026-06-18T12:06:54.146241Z", + "hint": "The value was calculated as suggested value for cpu requests * ${limitsToRequestsRatio} - '20.00' x '5.0'", + "id": "73c35d76-342b-4b98-9e1b-2d46759f7698", + "kind": "Deployment", + "labels": { + "confidence": 20, + "intervalHours": 4, + "kubectlCmd": "kubectl set resources deployment keda-operator-metrics-apiserver -c keda-operator-metrics-apiserver -n keda --limits=cpu=100m", + "originalValue": "1", + "originalValueRaw": 1000, + "query": "1000 * max by(namespace, workloadKind, workloadName, container) (container_cpu_limits{kedify_agent_id=\"%s\"})", + "suggestedValue": "100m", + "suggestedValueRaw": 100, + "workloadContainer": "keda-operator-metrics-apiserver", + "workloadName": "keda-operator-metrics-apiserver" + }, + "name": "keda-operator-metrics-apiserver", + "namespace": "keda", + "resourceUID": "keda/deployment/keda-operator-metrics-apiserver/keda-operator-metrics-apiserver/cpu-limits", + "status": "waiting" + }, + { + "clusterId": "ecf6a4d3-fc2c-403b-b70c-6de243ddfbbb", + "date": "2026-06-18T12:06:54.142722Z", + "hint": "The value was calculated as suggested value for cpu requests * ${limitsToRequestsRatio} - '20.00' x '5.0'", + "id": "bc5c11a4-2a9d-47a7-86b1-0a8533c96ccc", + "kind": "Deployment", + "labels": { + "confidence": 20, + "intervalHours": 4, + "kubectlCmd": "kubectl set resources deployment keda-operator -c keda-operator -n keda --limits=cpu=100m", + "originalValue": "1", + "originalValueRaw": 1000, + "query": "1000 * max by(namespace, workloadKind, workloadName, container) (container_cpu_limits{kedify_agent_id=\"%s\"})", + "suggestedValue": "100m", + "suggestedValueRaw": 100, + "workloadContainer": "keda-operator", + "workloadName": "keda-operator" + }, + "name": "keda-operator", + "namespace": "keda", + "resourceUID": "keda/deployment/keda-operator/keda-operator/cpu-limits", + "status": "waiting" + }, + { + "clusterId": "ecf6a4d3-fc2c-403b-b70c-6de243ddfbbb", + "date": "2026-06-18T12:06:54.141000Z", + "hint": "The value was calculated as suggested value for cpu requests * ${limitsToRequestsRatio} - '10.00' x '5.0'", + "id": "7d8825df-6af0-40d8-a824-25b8132b6b90", + "kind": "Deployment", + "labels": { + "confidence": 20, + "intervalHours": 4, + "kubectlCmd": "kubectl set resources deployment keda-operator -c audit-sidecar -n keda --limits=cpu=50m", + "originalValue": "25m", + "originalValueRaw": 25, + "query": "1000 * max by(namespace, workloadKind, workloadName, container) (container_cpu_limits{kedify_agent_id=\"%s\"})", + "suggestedValue": "50m", + "suggestedValueRaw": 50, + "workloadContainer": "audit-sidecar", + "workloadName": "keda-operator" + }, + "name": "keda-operator", + "namespace": "keda", + "resourceUID": "keda/deployment/keda-operator/audit-sidecar/cpu-limits", + "status": "waiting" + }, + { + "clusterId": "ecf6a4d3-fc2c-403b-b70c-6de243ddfbbb", + "date": "2026-06-18T12:06:54.139061Z", + "hint": "The value was calculated as suggested value for cpu requests * ${limitsToRequestsRatio} - '20.00' x '5.0'", + "id": "45e7eb1e-d335-462b-8d72-441369d78aea", + "kind": "Deployment", + "labels": { + "confidence": 20, + "intervalHours": 4, + "kubectlCmd": "kubectl set resources deployment keda-admission-webhooks -c keda-admission-webhooks -n keda --limits=cpu=100m", + "originalValue": "1", + "originalValueRaw": 1000, + "query": "1000 * max by(namespace, workloadKind, workloadName, container) (container_cpu_limits{kedify_agent_id=\"%s\"})", + "suggestedValue": "100m", + "suggestedValueRaw": 100, + "workloadContainer": "keda-admission-webhooks", + "workloadName": "keda-admission-webhooks" + }, + "name": "keda-admission-webhooks", + "namespace": "keda", + "resourceUID": "keda/deployment/keda-admission-webhooks/keda-admission-webhooks/cpu-limits", + "status": "waiting" + }, + { + "clusterId": "ecf6a4d3-fc2c-403b-b70c-6de243ddfbbb", + "date": "2026-06-18T12:06:54.113722Z", + "hint": "This value represents the p95 reported cpu consumption (in millicores) for last 4 hours multiplied by ${headroomCoefficient}, which is equal to '3.00'. However, in this case the p95 measured value was smaller than the minimum threshold so we are using the min value: '20m'", + "id": "7e780b28-f918-480a-8787-44d398c175b8", + "kind": "Deployment", + "labels": { + "confidence": 20, + "intervalHours": 4, + "kubectlCmd": "kubectl set resources deployment keda-add-ons-http-interceptor -c keda-add-ons-http-interceptor -n keda --requests=cpu=20m", + "originalValue": "250m", + "originalValueRaw": 250, + "query": "1000 * quantile_over_time(0.95, max by(namespace, workloadKind, workloadName, container) (container_cpu_usage{kedify_agent_id=\"b99434c7-7bfa-485f-8571-47026717b9b4\"})[4h:4h]) * 3", + "suggestedValue": "20m", + "suggestedValueRaw": 20, + "workloadContainer": "keda-add-ons-http-interceptor", + "workloadName": "keda-add-ons-http-interceptor" + }, + "name": "keda-add-ons-http-interceptor", + "namespace": "keda", + "resourceUID": "keda/deployment/keda-add-ons-http-interceptor/keda-add-ons-http-interceptor/cpu-requests", + "status": "waiting" + }, + { + "clusterId": "ecf6a4d3-fc2c-403b-b70c-6de243ddfbbb", + "date": "2026-06-18T12:06:54.108209Z", + "hint": "This value represents the p95 reported cpu consumption (in millicores) for last 4 hours multiplied by ${headroomCoefficient}, which is equal to '3.00'. However, in this case the p95 measured value was smaller than the minimum threshold so we are using the min value: '20m'", + "id": "f8a1e54f-0aec-4abd-ad2e-d01ad6502f77", + "kind": "Deployment", + "labels": { + "confidence": 20, + "intervalHours": 4, + "kubectlCmd": "kubectl set resources deployment keda-add-ons-http-external-scaler -c keda-add-ons-http-external-scaler -n keda --requests=cpu=20m", + "originalValue": "250m", + "originalValueRaw": 250, + "query": "1000 * quantile_over_time(0.95, max by(namespace, workloadKind, workloadName, container) (container_cpu_usage{kedify_agent_id=\"b99434c7-7bfa-485f-8571-47026717b9b4\"})[4h:4h]) * 3", + "suggestedValue": "20m", + "suggestedValueRaw": 20, + "workloadContainer": "keda-add-ons-http-external-scaler", + "workloadName": "keda-add-ons-http-external-scaler" + }, + "name": "keda-add-ons-http-external-scaler", + "namespace": "keda", + "resourceUID": "keda/deployment/keda-add-ons-http-external-scaler/keda-add-ons-http-external-scaler/cpu-requests", + "status": "waiting" + }, + { + "clusterId": "ecf6a4d3-fc2c-403b-b70c-6de243ddfbbb", + "date": "2026-06-18T12:06:54.124907Z", + "hint": "This value represents the p95 reported cpu consumption (in millicores) for last 4 hours multiplied by ${headroomCoefficient}, which is equal to '3.00'. However, in this case the p95 measured value was smaller than the minimum threshold so we are using the min value: '20m'", + "id": "b6540e04-6f4a-4cc2-bf26-3b9d6199eb9b", + "kind": "Deployment", + "labels": { + "confidence": 20, + "intervalHours": 4, + "kubectlCmd": "kubectl set resources deployment keda-operator-metrics-apiserver -c keda-operator-metrics-apiserver -n keda --requests=cpu=20m", + "originalValue": "100m", + "originalValueRaw": 100, + "query": "1000 * quantile_over_time(0.95, max by(namespace, workloadKind, workloadName, container) (container_cpu_usage{kedify_agent_id=\"b99434c7-7bfa-485f-8571-47026717b9b4\"})[4h:4h]) * 3", + "suggestedValue": "20m", + "suggestedValueRaw": 20, + "workloadContainer": "keda-operator-metrics-apiserver", + "workloadName": "keda-operator-metrics-apiserver" + }, + "name": "keda-operator-metrics-apiserver", + "namespace": "keda", + "resourceUID": "keda/deployment/keda-operator-metrics-apiserver/keda-operator-metrics-apiserver/cpu-requests", + "status": "waiting" + }, + { + "clusterId": "ecf6a4d3-fc2c-403b-b70c-6de243ddfbbb", + "date": "2026-06-18T12:06:54.121121Z", + "hint": "This value represents the p95 reported cpu consumption (in millicores) for last 4 hours multiplied by ${headroomCoefficient}, which is equal to '3.00'. However, in this case the p95 measured value was smaller than the minimum threshold so we are using the min value: '20m'", + "id": "3234abfb-c2df-47f3-bd85-d76d3a13279b", + "kind": "Deployment", + "labels": { + "confidence": 20, + "intervalHours": 4, + "kubectlCmd": "kubectl set resources deployment keda-operator -c keda-operator -n keda --requests=cpu=20m", + "originalValue": "100m", + "originalValueRaw": 100, + "query": "1000 * quantile_over_time(0.95, max by(namespace, workloadKind, workloadName, container) (container_cpu_usage{kedify_agent_id=\"b99434c7-7bfa-485f-8571-47026717b9b4\"})[4h:4h]) * 3", + "suggestedValue": "20m", + "suggestedValueRaw": 20, + "workloadContainer": "keda-operator", + "workloadName": "keda-operator" + }, + "name": "keda-operator", + "namespace": "keda", + "resourceUID": "keda/deployment/keda-operator/keda-operator/cpu-requests", + "status": "waiting" + }, + { + "clusterId": "ecf6a4d3-fc2c-403b-b70c-6de243ddfbbb", + "date": "2026-06-18T12:06:54.120000Z", + "hint": "This value represents the p95 reported cpu consumption (in millicores) for last 4 hours multiplied by ${headroomCoefficient}, which is equal to '3.00'.", + "id": "dfb34d4b-c442-46c6-8b1d-91c72fae0139", + "kind": "Deployment", + "labels": { + "confidence": 20, + "intervalHours": 4, + "kubectlCmd": "kubectl set resources deployment keda-operator -c audit-sidecar -n keda --requests=cpu=10m", + "originalValue": "5m", + "originalValueRaw": 5, + "query": "1000 * quantile_over_time(0.95, max by(namespace, workloadKind, workloadName, container) (container_cpu_usage{kedify_agent_id=\"b99434c7-7bfa-485f-8571-47026717b9b4\"})[4h:4h]) * 3", + "suggestedValue": "10m", + "suggestedValueRaw": 10, + "workloadContainer": "audit-sidecar", + "workloadName": "keda-operator" + }, + "name": "keda-operator", + "namespace": "keda", + "resourceUID": "keda/deployment/keda-operator/audit-sidecar/cpu-requests", + "status": "waiting" + }, + { + "clusterId": "ecf6a4d3-fc2c-403b-b70c-6de243ddfbbb", + "date": "2026-06-18T12:06:54.117456Z", + "hint": "This value represents the p95 reported cpu consumption (in millicores) for last 4 hours multiplied by ${headroomCoefficient}, which is equal to '3.00'. However, in this case the p95 measured value was smaller than the minimum threshold so we are using the min value: '20m'", + "id": "b263593d-1147-48ea-a1c3-395e2a404800", + "kind": "Deployment", + "labels": { + "confidence": 20, + "intervalHours": 4, + "kubectlCmd": "kubectl set resources deployment keda-admission-webhooks -c keda-admission-webhooks -n keda --requests=cpu=20m", + "originalValue": "100m", + "originalValueRaw": 100, + "query": "1000 * quantile_over_time(0.95, max by(namespace, workloadKind, workloadName, container) (container_cpu_usage{kedify_agent_id=\"b99434c7-7bfa-485f-8571-47026717b9b4\"})[4h:4h]) * 3", + "suggestedValue": "20m", + "suggestedValueRaw": 20, + "workloadContainer": "keda-admission-webhooks", + "workloadName": "keda-admission-webhooks" + }, + "name": "keda-admission-webhooks", + "namespace": "keda", + "resourceUID": "keda/deployment/keda-admission-webhooks/keda-admission-webhooks/cpu-requests", + "status": "waiting" + }, + { + "clusterId": "ecf6a4d3-fc2c-403b-b70c-6de243ddfbbb", + "date": "2026-06-18T12:06:54.097535Z", + "hint": "The value was calculated as suggested value for memory requests * ${limitsToRequestsRatio} - '52371456.00' x '3.0'", + "id": "c6793eb5-46b3-4b1c-8358-7591b720047d", + "kind": "Deployment", + "labels": { + "confidence": 20, + "intervalHours": 4, + "kubectlCmd": "kubectl set resources deployment kedify-agent -c manager -n keda --limits=memory=150Mi", + "originalValue": "1.0Gi", + "originalValueRaw": 1073741824, + "query": "max by(namespace, workloadKind, workloadName, container) (container_memory_requests{kedify_agent_id=\"%s\"})", + "suggestedValue": "150Mi", + "suggestedValueRaw": 157114368, + "workloadContainer": "manager", + "workloadName": "kedify-agent" + }, + "name": "kedify-agent", + "namespace": "keda", + "resourceUID": "keda/deployment/kedify-agent/manager/memory-limits", + "status": "waiting" + }, + { + "clusterId": "ecf6a4d3-fc2c-403b-b70c-6de243ddfbbb", + "date": "2026-06-18T12:06:54.093617Z", + "hint": "The value was calculated as suggested value for memory requests * ${limitsToRequestsRatio} - '45676953.60' x '3.0'", + "id": "8b059be3-1f71-4e85-b1ef-f7220bee87ab", + "kind": "Deployment", + "labels": { + "confidence": 20, + "intervalHours": 4, + "kubectlCmd": "kubectl set resources deployment keda-operator-metrics-apiserver -c keda-operator-metrics-apiserver -n keda --limits=memory=131Mi", + "originalValue": "1000Mi", + "originalValueRaw": 1048576000, + "query": "max by(namespace, workloadKind, workloadName, container) (container_memory_requests{kedify_agent_id=\"%s\"})", + "suggestedValue": "131Mi", + "suggestedValueRaw": 137030860.8, + "workloadContainer": "keda-operator-metrics-apiserver", + "workloadName": "keda-operator-metrics-apiserver" + }, + "name": "keda-operator-metrics-apiserver", + "namespace": "keda", + "resourceUID": "keda/deployment/keda-operator-metrics-apiserver/keda-operator-metrics-apiserver/memory-limits", + "status": "waiting" + }, + { + "clusterId": "ecf6a4d3-fc2c-403b-b70c-6de243ddfbbb", + "date": "2026-06-18T12:06:54.089711Z", + "hint": "The value was calculated as suggested value for memory requests * ${limitsToRequestsRatio} - '48247603.20' x '3.0'", + "id": "65a4a290-ee5d-493f-9ee9-d67205d7aa12", + "kind": "Deployment", + "labels": { + "confidence": 20, + "intervalHours": 4, + "kubectlCmd": "kubectl set resources deployment keda-operator -c keda-operator -n keda --limits=memory=138Mi", + "originalValue": "1000Mi", + "originalValueRaw": 1048576000, + "query": "max by(namespace, workloadKind, workloadName, container) (container_memory_requests{kedify_agent_id=\"%s\"})", + "suggestedValue": "138Mi", + "suggestedValueRaw": 144742809.6, + "workloadContainer": "keda-operator", + "workloadName": "keda-operator" + }, + "name": "keda-operator", + "namespace": "keda", + "resourceUID": "keda/deployment/keda-operator/keda-operator/memory-limits", + "status": "waiting" + }, + { + "clusterId": "ecf6a4d3-fc2c-403b-b70c-6de243ddfbbb", + "date": "2026-06-18T12:06:54.088000Z", + "hint": "The value was calculated as suggested value for memory requests * ${limitsToRequestsRatio} - '24Mi' x '3.0'", + "id": "88e314db-a95c-4a89-b5af-1935c46cd59e", + "kind": "Deployment", + "labels": { + "confidence": 20, + "intervalHours": 4, + "kubectlCmd": "kubectl set resources deployment keda-operator -c audit-sidecar -n keda --limits=memory=72Mi", + "originalValue": "64Mi", + "originalValueRaw": 67108864, + "query": "max by(namespace, workloadKind, workloadName, container) (container_memory_requests{kedify_agent_id=\"%s\"})", + "suggestedValue": "72Mi", + "suggestedValueRaw": 75497472, + "workloadContainer": "audit-sidecar", + "workloadName": "keda-operator" + }, + "name": "keda-operator", + "namespace": "keda", + "resourceUID": "keda/deployment/keda-operator/audit-sidecar/memory-limits", + "status": "waiting" + }, + { + "clusterId": "ecf6a4d3-fc2c-403b-b70c-6de243ddfbbb", + "date": "2026-06-18T12:06:54.086138Z", + "hint": "The value was calculated as suggested value for memory requests * ${limitsToRequestsRatio} - '17286758.40' x '3.0'", + "id": "62827ccc-7b90-4fca-a9db-da420daf6a7e", + "kind": "Deployment", + "labels": { + "confidence": 20, + "intervalHours": 4, + "kubectlCmd": "kubectl set resources deployment keda-admission-webhooks -c keda-admission-webhooks -n keda --limits=memory=49Mi", + "originalValue": "1000Mi", + "originalValueRaw": 1048576000, + "query": "max by(namespace, workloadKind, workloadName, container) (container_memory_requests{kedify_agent_id=\"%s\"})", + "suggestedValue": "49Mi", + "suggestedValueRaw": 51860275.199999996, + "workloadContainer": "keda-admission-webhooks", + "workloadName": "keda-admission-webhooks" + }, + "name": "keda-admission-webhooks", + "namespace": "keda", + "resourceUID": "keda/deployment/keda-admission-webhooks/keda-admission-webhooks/memory-limits", + "status": "waiting" + }, + { + "clusterId": "ecf6a4d3-fc2c-403b-b70c-6de243ddfbbb", + "date": "2026-06-18T12:06:54.104683Z", + "hint": "The value was calculated as suggested value for memory requests * ${limitsToRequestsRatio} - '25622937.60' x '3.0'", + "id": "9470549c-a457-4fc1-aa7a-ff722580e017", + "kind": "Deployment", + "labels": { + "confidence": 20, + "intervalHours": 4, + "kubectlCmd": "kubectl set resources deployment keda-add-ons-http-interceptor -c keda-add-ons-http-interceptor -n keda --limits=memory=73Mi", + "originalValue": "512Mi", + "originalValueRaw": 536870912, + "query": "max by(namespace, workloadKind, workloadName, container) (container_memory_requests{kedify_agent_id=\"%s\"})", + "suggestedValue": "73Mi", + "suggestedValueRaw": 76868812.8, + "workloadContainer": "keda-add-ons-http-interceptor", + "workloadName": "keda-add-ons-http-interceptor" + }, + "name": "keda-add-ons-http-interceptor", + "namespace": "keda", + "resourceUID": "keda/deployment/keda-add-ons-http-interceptor/keda-add-ons-http-interceptor/memory-limits", + "status": "waiting" + }, + { + "clusterId": "ecf6a4d3-fc2c-403b-b70c-6de243ddfbbb", + "date": "2026-06-18T12:06:54.100996Z", + "hint": "The value was calculated as suggested value for memory requests * ${limitsToRequestsRatio} - '22619750.40' x '3.0'", + "id": "ea81175e-df1f-4e30-9a51-cd9e760cd299", + "kind": "Deployment", + "labels": { + "confidence": 20, + "intervalHours": 4, + "kubectlCmd": "kubectl set resources deployment keda-add-ons-http-external-scaler -c keda-add-ons-http-external-scaler -n keda --limits=memory=65Mi", + "originalValue": "512Mi", + "originalValueRaw": 536870912, + "query": "max by(namespace, workloadKind, workloadName, container) (container_memory_requests{kedify_agent_id=\"%s\"})", + "suggestedValue": "65Mi", + "suggestedValueRaw": 67859251.19999999, + "workloadContainer": "keda-add-ons-http-external-scaler", + "workloadName": "keda-add-ons-http-external-scaler" + }, + "name": "keda-add-ons-http-external-scaler", + "namespace": "keda", + "resourceUID": "keda/deployment/keda-add-ons-http-external-scaler/keda-add-ons-http-external-scaler/memory-limits", + "status": "waiting" + }, + { + "clusterId": "ecf6a4d3-fc2c-403b-b70c-6de243ddfbbb", + "date": "2026-06-18T12:06:54.073251Z", + "hint": "This value represents the maximum reported memory consumption for last 4 hours multiplied by ${headroomCoefficient}, which is equal to '1.20'.", + "id": "bc1bad86-e788-44b7-a1fa-9dcc0eb6c4a0", + "kind": "Deployment", + "labels": { + "confidence": 20, + "intervalHours": 4, + "kubectlCmd": "kubectl set resources deployment kedify-agent -c manager -n keda --requests=memory=50Mi", + "originalValue": "300Mi", + "originalValueRaw": 314572800, + "query": "max_over_time(max by(namespace, workloadKind, workloadName, container) (container_memory_usage{kedify_agent_id=\"b99434c7-7bfa-485f-8571-47026717b9b4\"})[4h:4h]) * 1.2", + "suggestedValue": "50Mi", + "suggestedValueRaw": 52371456, + "workloadContainer": "manager", + "workloadName": "kedify-agent" + }, + "name": "kedify-agent", + "namespace": "keda", + "resourceUID": "keda/deployment/kedify-agent/manager/memory-requests", + "status": "waiting" + }, + { + "clusterId": "ecf6a4d3-fc2c-403b-b70c-6de243ddfbbb", + "date": "2026-06-18T12:06:54.06967Z", + "hint": "This value represents the maximum reported memory consumption for last 4 hours multiplied by ${headroomCoefficient}, which is equal to '1.20'.", + "id": "1d3a6187-9f2c-45cd-9e23-18f58f05b0fa", + "kind": "Deployment", + "labels": { + "confidence": 20, + "intervalHours": 4, + "kubectlCmd": "kubectl set resources deployment keda-operator-metrics-apiserver -c keda-operator-metrics-apiserver -n keda --requests=memory=44Mi", + "originalValue": "100Mi", + "originalValueRaw": 104857600, + "query": "max_over_time(max by(namespace, workloadKind, workloadName, container) (container_memory_usage{kedify_agent_id=\"b99434c7-7bfa-485f-8571-47026717b9b4\"})[4h:4h]) * 1.2", + "suggestedValue": "44Mi", + "suggestedValueRaw": 45676953.6, + "workloadContainer": "keda-operator-metrics-apiserver", + "workloadName": "keda-operator-metrics-apiserver" + }, + "name": "keda-operator-metrics-apiserver", + "namespace": "keda", + "resourceUID": "keda/deployment/keda-operator-metrics-apiserver/keda-operator-metrics-apiserver/memory-requests", + "status": "waiting" + }, + { + "clusterId": "ecf6a4d3-fc2c-403b-b70c-6de243ddfbbb", + "date": "2026-06-18T12:06:54.065884Z", + "hint": "This value represents the maximum reported memory consumption for last 4 hours multiplied by ${headroomCoefficient}, which is equal to '1.20'.", + "id": "34ee2a3e-bc7a-4396-82f4-301db9af1d4a", + "kind": "Deployment", + "labels": { + "confidence": 20, + "intervalHours": 4, + "kubectlCmd": "kubectl set resources deployment keda-operator -c keda-operator -n keda --requests=memory=46Mi", + "originalValue": "100Mi", + "originalValueRaw": 104857600, + "query": "max_over_time(max by(namespace, workloadKind, workloadName, container) (container_memory_usage{kedify_agent_id=\"b99434c7-7bfa-485f-8571-47026717b9b4\"})[4h:4h]) * 1.2", + "suggestedValue": "46Mi", + "suggestedValueRaw": 48247603.199999996, + "workloadContainer": "keda-operator", + "workloadName": "keda-operator" + }, + "name": "keda-operator", + "namespace": "keda", + "resourceUID": "keda/deployment/keda-operator/keda-operator/memory-requests", + "status": "waiting" + }, + { + "clusterId": "ecf6a4d3-fc2c-403b-b70c-6de243ddfbbb", + "date": "2026-06-18T12:06:54.064000Z", + "hint": "This value represents the maximum reported memory consumption for last 4 hours multiplied by ${headroomCoefficient}, which is equal to '1.20'.", + "id": "4ec76428-a26a-4271-99d0-3a639f0e2ab2", + "kind": "Deployment", + "labels": { + "confidence": 20, + "intervalHours": 4, + "kubectlCmd": "kubectl set resources deployment keda-operator -c audit-sidecar -n keda --requests=memory=24Mi", + "originalValue": "16Mi", + "originalValueRaw": 16777216, + "query": "max_over_time(max by(namespace, workloadKind, workloadName, container) (container_memory_usage{kedify_agent_id=\"b99434c7-7bfa-485f-8571-47026717b9b4\"})[4h:4h]) * 1.2", + "suggestedValue": "24Mi", + "suggestedValueRaw": 25165824, + "workloadContainer": "audit-sidecar", + "workloadName": "keda-operator" + }, + "name": "keda-operator", + "namespace": "keda", + "resourceUID": "keda/deployment/keda-operator/audit-sidecar/memory-requests", + "status": "waiting" + }, + { + "clusterId": "ecf6a4d3-fc2c-403b-b70c-6de243ddfbbb", + "date": "2026-06-18T12:06:54.062062Z", + "hint": "This value represents the maximum reported memory consumption for last 4 hours multiplied by ${headroomCoefficient}, which is equal to '1.20'.", + "id": "25f856aa-30a6-419e-bf34-1d960b54b332", + "kind": "Deployment", + "labels": { + "confidence": 20, + "intervalHours": 4, + "kubectlCmd": "kubectl set resources deployment keda-admission-webhooks -c keda-admission-webhooks -n keda --requests=memory=16Mi", + "originalValue": "100Mi", + "originalValueRaw": 104857600, + "query": "max_over_time(max by(namespace, workloadKind, workloadName, container) (container_memory_usage{kedify_agent_id=\"b99434c7-7bfa-485f-8571-47026717b9b4\"})[4h:4h]) * 1.2", + "suggestedValue": "16Mi", + "suggestedValueRaw": 17286758.4, + "workloadContainer": "keda-admission-webhooks", + "workloadName": "keda-admission-webhooks" + }, + "name": "keda-admission-webhooks", + "namespace": "keda", + "resourceUID": "keda/deployment/keda-admission-webhooks/keda-admission-webhooks/memory-requests", + "status": "waiting" + }, + { + "clusterId": "ecf6a4d3-fc2c-403b-b70c-6de243ddfbbb", + "date": "2026-06-18T12:06:54.081806Z", + "hint": "This value represents the maximum reported memory consumption for last 4 hours multiplied by ${headroomCoefficient}, which is equal to '1.20'.", + "id": "590b8091-0623-4da2-ad45-10962c8a9948", + "kind": "Deployment", + "labels": { + "confidence": 20, + "intervalHours": 4, + "kubectlCmd": "kubectl set resources deployment keda-add-ons-http-interceptor -c keda-add-ons-http-interceptor -n keda --requests=memory=24Mi", + "originalValue": "20Mi", + "originalValueRaw": 20971520, + "query": "max_over_time(max by(namespace, workloadKind, workloadName, container) (container_memory_usage{kedify_agent_id=\"b99434c7-7bfa-485f-8571-47026717b9b4\"})[4h:4h]) * 1.2", + "suggestedValue": "24Mi", + "suggestedValueRaw": 25622937.599999998, + "workloadContainer": "keda-add-ons-http-interceptor", + "workloadName": "keda-add-ons-http-interceptor" + }, + "name": "keda-add-ons-http-interceptor", + "namespace": "keda", + "resourceUID": "keda/deployment/keda-add-ons-http-interceptor/keda-add-ons-http-interceptor/memory-requests", + "status": "waiting" + }, + { + "clusterId": "ecf6a4d3-fc2c-403b-b70c-6de243ddfbbb", + "date": "2026-06-18T12:06:54.077133Z", + "hint": "This value represents the maximum reported memory consumption for last 4 hours multiplied by ${headroomCoefficient}, which is equal to '1.20'.", + "id": "fe6d8cc6-ea6b-445e-9a46-c2d886f1cce5", + "kind": "Deployment", + "labels": { + "confidence": 20, + "intervalHours": 4, + "kubectlCmd": "kubectl set resources deployment keda-add-ons-http-external-scaler -c keda-add-ons-http-external-scaler -n keda --requests=memory=22Mi", + "originalValue": "256Mi", + "originalValueRaw": 268435456, + "query": "max_over_time(max by(namespace, workloadKind, workloadName, container) (container_memory_usage{kedify_agent_id=\"b99434c7-7bfa-485f-8571-47026717b9b4\"})[4h:4h]) * 1.2", + "suggestedValue": "22Mi", + "suggestedValueRaw": 22619750.4, + "workloadContainer": "keda-add-ons-http-external-scaler", + "workloadName": "keda-add-ons-http-external-scaler" + }, + "name": "keda-add-ons-http-external-scaler", + "namespace": "keda", + "resourceUID": "keda/deployment/keda-add-ons-http-external-scaler/keda-add-ons-http-external-scaler/memory-requests", + "status": "waiting" + } +]