From be28c962ed27c1368b0409217168ed7fda49db7c Mon Sep 17 00:00:00 2001 From: Jirka Kremser Date: Tue, 28 Jul 2026 17:20:48 +0200 Subject: [PATCH 1/2] Add metrics explorer sub-command Signed-off-by: Jirka Kremser --- README.md | 60 ++ go.mod | 8 + go.sum | 32 + internal/cli/metrics/metrics.go | 468 +++++++++ internal/cli/metrics/metrics_test.go | 260 +++++ internal/cli/run.go | 16 +- internal/cli/run_test.go | 59 ++ internal/kubernetes/client.go | 516 ++++++++++ internal/kubernetes/client_test.go | 253 +++++ internal/metrics/resources.go | 165 ++++ internal/metrics/resources_test.go | 97 ++ internal/prometheus/client.go | 288 ++++++ internal/prometheus/client_test.go | 197 ++++ internal/tui/metrics.go | 1235 ++++++++++++++++++++++++ internal/tui/metrics_test.go | 499 ++++++++++ internal/tui/prompt.go | 306 ++++++ internal/tui/resource_creation.go | 161 +++ internal/tui/resource_creation_test.go | 68 ++ internal/tui/spinner.go | 104 ++ internal/tui/spinner_test.go | 71 ++ internal/tui/theme.go | 65 ++ internal/tui/theme_test.go | 41 + internal/tui/yaml_editor.go | 162 ++++ internal/tui/yaml_editor_test.go | 63 ++ 24 files changed, 5187 insertions(+), 7 deletions(-) create mode 100644 internal/cli/metrics/metrics.go create mode 100644 internal/cli/metrics/metrics_test.go create mode 100644 internal/cli/run_test.go create mode 100644 internal/kubernetes/client.go create mode 100644 internal/kubernetes/client_test.go create mode 100644 internal/metrics/resources.go create mode 100644 internal/metrics/resources_test.go create mode 100644 internal/prometheus/client.go create mode 100644 internal/prometheus/client_test.go create mode 100644 internal/tui/metrics.go create mode 100644 internal/tui/metrics_test.go create mode 100644 internal/tui/prompt.go create mode 100644 internal/tui/resource_creation.go create mode 100644 internal/tui/resource_creation_test.go create mode 100644 internal/tui/spinner.go create mode 100644 internal/tui/spinner_test.go create mode 100644 internal/tui/theme.go create mode 100644 internal/tui/theme_test.go create mode 100644 internal/tui/yaml_editor.go create mode 100644 internal/tui/yaml_editor_test.go diff --git a/README.md b/README.md index c7a0ae5..dc7fecd 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,8 @@ The CLI currently focuses on authentication, cluster inspection, and applying re Prints the recommendations payload for a cluster id. - `kedify apply recommendations ` Applies recommendations from a saved JSON or YAML file to a Helm values file and can emit `json`, `diff`, or `override` output. +- `kedify metrics` + Opens an interactive Prometheus metric explorer, builds and validates a PromQL query, previews it as an ASCII graph, and generates YAML for a `ScaledObject`, a `MetricPredictor`, or both. Creating the resources in Kubernetes is an explicit opt-in. - Output formatting `kedify list clusters`, `kedify get cluster`, and `kedify list recommendations` support `-o` and `--output` with `text`, `json`, or `yaml`. `text` is the default. `kedify delete cluster` prints its confirmation message to `stderr` and keeps `stdout` empty for shell-friendly usage. @@ -43,6 +45,7 @@ The binary will be available at `./bin/kedify`. - Go toolchain version from `go.mod` - `make` +- `kubectl` when using Prometheus discovery, port-forwarding, or resource creation ## Authentication @@ -165,6 +168,63 @@ Apply recommendations and write an override file: --output-file ./override-values.yaml ``` +Explore metrics using Kubernetes service discovery: + +```bash +./bin/kedify metrics +``` + +Connect directly to Prometheus and generate manifests for a specific namespace: + +```bash +./bin/kedify metrics \ + --server=http://localhost:9090 \ + --namespace=my-app +``` + +Preselect parts of the interactive flow: + +```bash +# Discover Prometheus, use the first matching service, and port-forward it. +./bin/kedify metrics --disco --filter=memory_ + +# Use a specific kubectl context and kubeconfig for discovery and port-forwarding. +./bin/kedify metrics \ + --disco \ + --context=staging \ + --kubeconfig=./config/staging.kubeconfig + +# Validate this query, select a horizon, and visualize it. +./bin/kedify metrics \ + --server=http://localhost:9090 \ + --query='sum(foobar)' \ + --visualize + +# Skip both choices and immediately load the 3-day graph. +./bin/kedify metrics \ + --server=http://localhost:9090 \ + --query='sum(foobar)' \ + --visualize \ + --horizon=3d \ + --print +``` + +`--server` and `--disco` are mutually exclusive. `--visualize` requires `--query`, and `--horizon` requires `--visualize`. Supported horizon values are `6h`, `1d`, `3d`, `1w`, and `30d`. `--context` and `--kubeconfig` are forwarded to every `kubectl` invocation made by the metrics command. A value passed through `--filter` remains editable in the metric browser. Before generated resources are printed, the CLI offers to open a line-numbered YAML editor; `--print` skips this final question and prints directly. + +The metrics explorer: + +- discovers Prometheus services and Mimir gateways from the active kubeconfig using common labels and can manage a temporary `kubectl port-forward`; Mimir API requests use its `/prometheus` prefix and `X-Scope-OrgID: kedify-agent`; +- retrieves metric names with the `{__name__=~".+"}` selector; +- supports arrow and Page Up/Page Down navigation, plus case-insensitive substring filtering as you type; +- shows label names and values as you drill into a metric; +- starts from a single-series `sum(metric{label="value"})` expression and lets you edit and validate the final PromQL; +- can graph the last 6 hours, day, 3 days, week, or month of samples in the terminal, using a range-appropriate Prometheus query step; +- generates a `kedify-otel` `ScaledObject` with a default target value of `1`, and prompts for its scale target; +- generates a `MetricPredictor` with a one-week Prometheus range at a `30s` step so it has historical samples for initial training; +- prints the selected resource manifests by default and only runs `kubectl create` when the creation checkbox is enabled and a final review of the exact YAML, active Kubernetes context, and target namespace is confirmed. + +Interactive terminal UX, progress, and optional creation confirmations are written to `stderr`. If no resource manifest is selected, the completed PromQL expression is printed to `stdout`; otherwise, the generated YAML is printed to `stdout`. + Notes for `apply recommendations`: - The command is Helm-only in v1. diff --git a/go.mod b/go.mod index a1964bf..07188b7 100644 --- a/go.mod +++ b/go.mod @@ -4,28 +4,36 @@ go 1.26 require ( github.com/alecthomas/kong v1.12.1 + github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/huh v1.0.0 github.com/charmbracelet/lipgloss v1.1.0 github.com/google/uuid v1.6.0 + github.com/guptarohit/asciigraph v0.7.3 github.com/zalando/go-keyring v0.2.8 gopkg.in/yaml.v3 v3.0.1 ) require ( + github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/catppuccin/go v0.3.0 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect github.com/charmbracelet/x/ansi v0.11.7 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/danieljoos/wincred v1.2.3 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect github.com/lucasb-eyer/go-colorful v1.4.0 // indirect github.com/mattn/go-isatty v0.0.22 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.24 // indirect + github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect diff --git a/go.sum b/go.sum index a99c701..91067d1 100644 --- a/go.sum +++ b/go.sum @@ -1,37 +1,67 @@ +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/kong v1.12.1 h1:iq6aMJDcFYP9uFrLdsiZQ2ZMmcshduyGv4Pek0MQPW0= github.com/alecthomas/kong v1.12.1/go.mod h1:p2vqieVMeTAnaC83txKtXe8FLke2X07aruPWXyMPQrU= github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= +github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= +github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= +github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= +github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= +github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= +github.com/charmbracelet/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw= +github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ= github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U= +github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI= +github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4= github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/guptarohit/asciigraph v0.7.3 h1:p05XDDn7cBTWiBqWb30mrwxd6oU0claAjqeytllnsPY= +github.com/guptarohit/asciigraph v0.7.3/go.mod h1:dYl5wwK4gNsnFf9Zp+l06rFiDZ5YtXM6x7SRWZ3KGag= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= @@ -42,6 +72,8 @@ github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2J github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU= github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= +github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= diff --git a/internal/cli/metrics/metrics.go b/internal/cli/metrics/metrics.go new file mode 100644 index 0000000..699c3a9 --- /dev/null +++ b/internal/cli/metrics/metrics.go @@ -0,0 +1,468 @@ +package metrics + +import ( + "context" + "errors" + "fmt" + "io" + "regexp" + "strings" + "time" + + clictx "github.com/kedify/cli/internal/cli/context" + "github.com/kedify/cli/internal/kubernetes" + metricresources "github.com/kedify/cli/internal/metrics" + "github.com/kedify/cli/internal/prometheus" + "github.com/kedify/cli/internal/tui" +) + +const requestTimeout = 30 * time.Second + +var dnsNamePattern = regexp.MustCompile(`^[a-z0-9](?:[-a-z0-9]*[a-z0-9])?$`) + +type MetricsCmd struct { + Server string `name:"server" help:"Prometheus server URL. When omitted, choose Kubernetes discovery or enter a URL interactively." placeholder:"URL" xor:"prometheus-source"` + Disco bool `name:"disco" help:"Discover Prometheus in Kubernetes, select the first service, and start port-forwarding without prompting." xor:"prometheus-source"` + Context string `name:"context" help:"Kubernetes context forwarded to kubectl for discovery, port-forwarding, and resource creation." placeholder:"NAME"` + Kubeconfig string `name:"kubeconfig" help:"Kubeconfig path forwarded to kubectl for discovery, port-forwarding, and resource creation." placeholder:"PATH"` + Namespace string `name:"namespace" short:"n" help:"Namespace for resources created at the end of the session. Defaults to the active kubeconfig namespace."` + Filter string `name:"filter" help:"Initial editable substring filter for the metric list." placeholder:"TEXT"` + Query string `name:"query" help:"PromQL query to validate instead of opening the metric selector." placeholder:"PROMQL"` + Visualize bool `name:"visualize" help:"With --query, skip the visualize-or-continue prompt and select a time horizon."` + Horizon string `name:"horizon" help:"With --query and --visualize, immediately graph a 6h, 1d, 3d, 1w, or 30d horizon." placeholder:"DURATION"` + Print bool `name:"print" help:"Print generated YAML without asking whether to edit it first."` +} + +type prometheusEndpoint struct { + sessionURL string + clusterURL string + mimir bool +} + +type resourceCreator interface { + Create(context.Context, []byte) ([]string, error) +} + +func (c *MetricsCmd) Run(app *clictx.Context) error { + if err := c.Validate(); err != nil { + return err + } + if err := tui.RequireInteractive(app.Stdin); err != nil { + return err + } + + kubeClient := kubernetes.NewClientWithOptions(kubernetes.ClientOptions{ + Context: c.Context, + Kubeconfig: c.Kubeconfig, + }) + runCtx, cancel := context.WithCancel(context.Background()) + defer cancel() + + endpoint, portForward, err := c.resolveServer(runCtx, app, kubeClient) + if err != nil { + return err + } + if portForward != nil { + defer func() { + _ = portForward.Stop() + }() + } + + var client *prometheus.Client + if endpoint.mimir { + client, err = prometheus.NewMimirClient(endpoint.sessionURL) + } else { + client, err = prometheus.NewClient(endpoint.sessionURL) + } + if err != nil { + return err + } + + var metricNames []string + if strings.TrimSpace(c.Query) == "" { + metricNames, err = tui.RunWithSpinner( + app.Stderr, + fmt.Sprintf("Loading metrics from %s…", endpoint.sessionURL), + func() ([]string, error) { + requestCtx, requestCancel := context.WithTimeout(runCtx, requestTimeout) + defer requestCancel() + return client.MetricNames(requestCtx) + }, + ) + if err != nil { + return err + } + if len(metricNames) == 0 { + return errors.New("Prometheus returned no active metrics for selector {__name__=~\".+\"}") + } + } + + result, err := tui.RunMetricsExplorer( + app.Stdin, + app.Stdout, + app.Stderr, + client, + metricNames, + tui.MetricsExplorerOptions{ + Filter: c.Filter, + Query: c.Query, + Visualize: c.Visualize, + Horizon: c.Horizon, + }, + ) + if err != nil { + return err + } + if !result.GenerateScaledObject && !result.GenerateMetricPredictor { + _, err := fmt.Fprintln(app.Stdout, result.Query) + return err + } + + namespace := strings.TrimSpace(c.Namespace) + if namespace == "" { + namespaceCtx, namespaceCancel := context.WithTimeout(runCtx, requestTimeout) + namespace = kubeClient.CurrentNamespace(namespaceCtx) + namespaceCancel() + } + if err := validateKubernetesName(namespace); err != nil { + return fmt.Errorf("invalid namespace %q: %w", namespace, err) + } + + options := metricresources.ResourceOptions{ + Query: result.Query, + PrometheusURL: endpoint.clusterURL, + Namespace: namespace, + } + if result.GenerateScaledObject { + options.ScaledObjectName, err = tui.PromptText( + app.Stdin, + app.Stdout, + app.Stderr, + "ScaledObject name", + "The resource will be created in namespace "+namespace+".", + "metric-scaler", + validateKubernetesName, + ) + if err != nil { + return err + } + options.ScaleTargetName, err = tui.PromptText( + app.Stdin, + app.Stdout, + app.Stderr, + "Scale target name", + "Name of the Deployment or other scalable workload referenced by spec.scaleTargetRef.", + options.ScaledObjectName, + validateKubernetesName, + ) + if err != nil { + return err + } + } + + if result.GenerateMetricPredictor { + rangeEnd := time.Now().UTC() + options.PrometheusStart = rangeEnd.Add(-7 * 24 * time.Hour).Format(time.RFC3339) + options.PrometheusEnd = rangeEnd.Format(time.RFC3339) + options.PrometheusStep = "30s" + options.MetricPredictorName, err = tui.PromptText( + app.Stdin, + app.Stdout, + app.Stderr, + "MetricPredictor name", + "The resource will be created in namespace "+namespace+".", + "metric-predictor", + validateKubernetesName, + ) + if err != nil { + return err + } + if kubernetes.IsLocalAddress(options.PrometheusURL) { + options.PrometheusURL, err = tui.PromptText( + app.Stdin, + app.Stdout, + app.Stderr, + "Prometheus URL for MetricPredictor", + "This URL must be reachable from inside the Kubernetes cluster; localhost usually is not.", + options.PrometheusURL, + validatePrometheusURL, + ) + if err != nil { + return err + } + } + } + + manifest, err := metricresources.BuildResources(options) + if err != nil { + return err + } + manifest, err = c.editManifestIfRequested(app, manifest) + if err != nil { + return err + } + createResources := result.CreateResources + if createResources { + contextCtx, contextCancel := context.WithTimeout(runCtx, requestTimeout) + currentContext, contextErr := kubeClient.CurrentContext(contextCtx) + contextCancel() + if contextErr != nil { + return contextErr + } + createResources, err = tui.ConfirmResourceCreation( + app.Stdin, + app.Stdout, + app.Stderr, + string(manifest), + currentContext, + namespace, + ) + if err != nil { + return err + } + } + return outputResources( + runCtx, + app.Stdout, + app.Stderr, + kubeClient, + namespace, + manifest, + createResources, + ) +} + +func (c *MetricsCmd) editManifestIfRequested(app *clictx.Context, manifest []byte) ([]byte, error) { + if c.Print { + return manifest, nil + } + + edit, err := tui.Confirm( + app.Stdin, + app.Stdout, + app.Stderr, + "Edit generated YAML before printing?", + "The edited YAML will also be used if 'create in k8s cluster' is enabled.", + false, + ) + if err != nil { + return nil, err + } + if !edit { + return manifest, nil + } + + edited, err := tui.EditYAML(app.Stdin, app.Stdout, app.Stderr, string(manifest)) + if err != nil { + return nil, err + } + return []byte(edited), nil +} + +func (c *MetricsCmd) Validate() error { + if strings.TrimSpace(c.Server) != "" && c.Disco { + return errors.New("--server and --disco cannot be used together") + } + if c.Visualize && strings.TrimSpace(c.Query) == "" { + return errors.New("--visualize requires --query") + } + if horizon := strings.TrimSpace(c.Horizon); horizon != "" { + if !c.Visualize { + return errors.New("--horizon requires --visualize") + } + switch horizon { + case "6h", "1d", "3d", "1w", "30d": + default: + return fmt.Errorf("unsupported --horizon %q; use 6h, 1d, 3d, 1w, or 30d", horizon) + } + } + return nil +} + +func outputResources( + ctx context.Context, + stdout, stderr io.Writer, + creator resourceCreator, + namespace string, + manifest []byte, + create bool, +) error { + written, err := stdout.Write(manifest) + if err != nil { + return err + } + if written != len(manifest) { + return io.ErrShortWrite + } + if !create { + return nil + } + + _, _ = fmt.Fprintf(stderr, "Creating resources in namespace %s…\n", namespace) + createCtx, cancel := context.WithTimeout(ctx, requestTimeout) + created, err := creator.Create(createCtx, manifest) + cancel() + if err != nil { + return err + } + for _, resource := range created { + if _, err := fmt.Fprintf(stderr, "Created %s\n", resource); err != nil { + return err + } + } + return nil +} + +func (c *MetricsCmd) resolveServer( + ctx context.Context, + app *clictx.Context, + kubeClient *kubernetes.Client, +) (prometheusEndpoint, *kubernetes.PortForward, error) { + if strings.TrimSpace(c.Server) != "" { + server, err := prometheus.NormalizeURL(c.Server) + if err != nil { + return prometheusEndpoint{}, nil, err + } + return directPrometheusEndpoint(server), nil, nil + } + + if !c.Disco { + choice, err := tui.Select( + app.Stdin, + app.Stdout, + app.Stderr, + "Connect to Prometheus", + "Use the active kubeconfig to discover a service, or enter a URL.", + []string{"Discover in Kubernetes", "Enter Prometheus URL"}, + ) + if err != nil { + return prometheusEndpoint{}, nil, err + } + if choice == 1 { + endpoint, err := promptServerURL(app) + return endpoint, nil, err + } + } + + services, err := tui.RunWithSpinner( + app.Stderr, + "Discovering Prometheus services with the active kubeconfig…", + func() ([]kubernetes.PrometheusService, error) { + discoveryCtx, cancel := context.WithTimeout(ctx, requestTimeout) + defer cancel() + return kubeClient.DiscoverPrometheusServices(discoveryCtx) + }, + ) + if err != nil { + return prometheusEndpoint{}, nil, err + } + if len(services) == 0 { + if c.Disco { + return prometheusEndpoint{}, nil, errors.New("no Kubernetes services matched a known Prometheus label selector") + } + _, _ = fmt.Fprintln(app.Stderr, "No services matched a known Prometheus label selector; enter the server URL instead.") + endpoint, promptErr := promptServerURL(app) + return endpoint, nil, promptErr + } + + selectedIndex := 0 + if !c.Disco { + options := make([]string, len(services)) + for index, service := range services { + options[index] = service.DisplayName() + " (" + service.MatchedLabels + ")" + } + var err error + selectedIndex, err = tui.Select( + app.Stdin, + app.Stdout, + app.Stderr, + "Select Prometheus service", + "Services matching known Prometheus labels.", + options, + ) + if err != nil { + return prometheusEndpoint{}, nil, err + } + } + service := services[selectedIndex] + + if !c.Disco { + approved, err := tui.Confirm( + app.Stdin, + app.Stdout, + app.Stderr, + "Start a kubectl port-forward?", + fmt.Sprintf("%s will be forwarded to a random local port for this session.", service.DisplayName()), + true, + ) + if err != nil { + return prometheusEndpoint{}, nil, err + } + if !approved { + endpoint, promptErr := promptServerURL(app) + return endpoint, nil, promptErr + } + } + + portForward, err := tui.RunWithSpinner( + app.Stderr, + fmt.Sprintf("Starting port-forward to %s…", service.DisplayName()), + func() (*kubernetes.PortForward, error) { + return kubeClient.StartPortForward(ctx, service) + }, + ) + if err != nil { + return prometheusEndpoint{}, nil, err + } + return prometheusEndpoint{ + sessionURL: portForward.LocalURL, + clusterURL: service.ClusterURL(), + mimir: service.Mimir, + }, portForward, nil +} + +func promptServerURL(app *clictx.Context) (prometheusEndpoint, error) { + server, err := tui.PromptText( + app.Stdin, + app.Stdout, + app.Stderr, + "Prometheus server URL", + "Example: http://localhost:9090", + "http://localhost:9090", + validatePrometheusURL, + ) + if err != nil { + return prometheusEndpoint{}, err + } + normalized, err := prometheus.NormalizeURL(server) + if err != nil { + return prometheusEndpoint{}, err + } + return directPrometheusEndpoint(normalized), nil +} + +func directPrometheusEndpoint(server string) prometheusEndpoint { + return prometheusEndpoint{ + sessionURL: server, + clusterURL: server, + mimir: strings.HasSuffix(strings.TrimRight(server, "/"), "/prometheus"), + } +} + +func validatePrometheusURL(value string) error { + _, err := prometheus.NormalizeURL(value) + return err +} + +func validateKubernetesName(value string) error { + if len(value) == 0 { + return errors.New("name is required") + } + if len(value) > 253 { + return errors.New("name must be at most 253 characters") + } + if !dnsNamePattern.MatchString(value) { + return errors.New("use lowercase letters, numbers, and hyphens; start and end with a letter or number") + } + return nil +} diff --git a/internal/cli/metrics/metrics_test.go b/internal/cli/metrics/metrics_test.go new file mode 100644 index 0000000..785b022 --- /dev/null +++ b/internal/cli/metrics/metrics_test.go @@ -0,0 +1,260 @@ +package metrics + +import ( + "bytes" + "context" + "os" + "path/filepath" + "strings" + "testing" + + clictx "github.com/kedify/cli/internal/cli/context" + "github.com/kedify/cli/internal/kubernetes" +) + +type fakeResourceCreator struct { + calls int + manifest []byte + names []string + err error +} + +func (f *fakeResourceCreator) Create(_ context.Context, manifest []byte) ([]string, error) { + f.calls++ + f.manifest = append([]byte(nil), manifest...) + return f.names, f.err +} + +func TestMetricsCmdRequiresInteractiveTerminalBeforeNetworkAccess(t *testing.T) { + cmd := MetricsCmd{Server: "http://127.0.0.1:1"} + err := cmd.Run(&clictx.Context{ + Stdin: bytes.NewBuffer(nil), + Stdout: &bytes.Buffer{}, + Stderr: &bytes.Buffer{}, + }) + if err == nil || !strings.Contains(err.Error(), "interactive terminal") { + t.Fatalf("Run() error = %v", err) + } +} + +func TestMetricsCmdValidatesAutomationFlagCombinations(t *testing.T) { + tests := []struct { + name string + cmd MetricsCmd + want string + }{ + { + name: "server and discovery", + cmd: MetricsCmd{Server: "http://localhost:9090", Disco: true}, + want: "--server and --disco", + }, + { + name: "visualize without query", + cmd: MetricsCmd{Visualize: true}, + want: "--visualize requires --query", + }, + { + name: "horizon without visualize", + cmd: MetricsCmd{Query: "up", Horizon: "3d"}, + want: "--horizon requires --visualize", + }, + { + name: "unsupported horizon", + cmd: MetricsCmd{Query: "up", Visualize: true, Horizon: "2d"}, + want: "unsupported --horizon", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := test.cmd.Validate() + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("Validate() error = %v, want %q", err, test.want) + } + }) + } + + valid := MetricsCmd{ + Disco: true, + Filter: "memory_", + Query: "sum(foobar)", + Visualize: true, + Horizon: "3d", + } + if err := valid.Validate(); err != nil { + t.Fatalf("Validate() error = %v", err) + } +} + +func TestMetricsCmdDiscoUsesFirstServiceAndStartsPortForward(t *testing.T) { + dir := t.TempDir() + kubectl := filepath.Join(dir, "kubectl") + script := `#!/bin/sh +case "$1" in +get) + printf '%s' '{"items":[ + {"metadata":{"name":"z-prom","namespace":"z","labels":{"app":"prometheus-server"}},"spec":{"ports":[{"name":"web","port":9090}]}}, + {"metadata":{"name":"a-prom","namespace":"a","labels":{"app":"rancher-monitoring-prometheus"}},"spec":{"ports":[{"name":"https","port":443}]}} + ]}' + ;; +port-forward) + printf '%s\n' 'Forwarding from 127.0.0.1:45678 -> 443' >&2 + exec sleep 60 + ;; +*) + exit 1 + ;; +esac +` + if err := os.WriteFile(kubectl, []byte(script), 0o700); err != nil { + t.Fatalf("write fake kubectl: %v", err) + } + + stderr := &bytes.Buffer{} + cmd := MetricsCmd{Disco: true} + endpoint, forward, err := cmd.resolveServer( + context.Background(), + &clictx.Context{ + Stdin: bytes.NewBuffer(nil), + Stdout: &bytes.Buffer{}, + Stderr: stderr, + }, + kubernetes.NewClientWithBinary(kubectl), + ) + if err != nil { + t.Fatalf("resolveServer() error = %v", err) + } + t.Cleanup(func() { + if err := forward.Stop(); err != nil { + t.Errorf("Stop() error = %v", err) + } + }) + + if endpoint.sessionURL != "https://127.0.0.1:45678" { + t.Fatalf("session URL = %q", endpoint.sessionURL) + } + if endpoint.clusterURL != "https://a-prom.a.svc.cluster.local:443" { + t.Fatalf("cluster URL = %q", endpoint.clusterURL) + } + if endpoint.mimir { + t.Fatal("regular Prometheus endpoint was marked as Mimir") + } + if !strings.Contains(stderr.String(), "Starting port-forward to a/a-prom") { + t.Fatalf("stderr = %q", stderr.String()) + } +} + +func TestDirectPrometheusEndpointDetectsMimirPrefix(t *testing.T) { + tests := []struct { + server string + mimir bool + }{ + {server: "http://prometheus.monitoring:9090", mimir: false}, + {server: "http://mimir-gateway.mimir/prometheus", mimir: true}, + {server: "http://mimir-gateway.mimir/prometheus/", mimir: true}, + } + + for _, test := range tests { + endpoint := directPrometheusEndpoint(test.server) + if endpoint.mimir != test.mimir { + t.Fatalf("directPrometheusEndpoint(%q).mimir = %t, want %t", test.server, endpoint.mimir, test.mimir) + } + } +} + +func TestMetricsCmdPrintSkipsManifestEditPrompt(t *testing.T) { + manifest := []byte("kind: ScaledObject\n") + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + cmd := MetricsCmd{Print: true} + + result, err := cmd.editManifestIfRequested( + &clictx.Context{ + Stdin: bytes.NewBuffer(nil), + Stdout: stdout, + Stderr: stderr, + }, + manifest, + ) + if err != nil { + t.Fatalf("editManifestIfRequested() error = %v", err) + } + if string(result) != string(manifest) { + t.Fatalf("manifest = %q, want %q", result, manifest) + } + if stdout.Len() != 0 || stderr.Len() != 0 { + t.Fatalf("stdout = %q, stderr = %q, want no prompt output", stdout.String(), stderr.String()) + } +} + +func TestValidateKubernetesName(t *testing.T) { + for _, name := range []string{"demo", "metric-predictor", "a1"} { + if err := validateKubernetesName(name); err != nil { + t.Fatalf("validateKubernetesName(%q) error = %v", name, err) + } + } + for _, name := range []string{"", "-demo", "Demo", "demo_", "demo-"} { + if err := validateKubernetesName(name); err == nil { + t.Fatalf("validateKubernetesName(%q) returned nil error", name) + } + } +} + +func TestOutputResourcesPrintsYAMLWithoutCreatingByDefault(t *testing.T) { + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + creator := &fakeResourceCreator{} + manifest := []byte("kind: ScaledObject\n") + + err := outputResources( + context.Background(), + stdout, + stderr, + creator, + "default", + manifest, + false, + ) + if err != nil { + t.Fatalf("outputResources() error = %v", err) + } + if stdout.String() != string(manifest) { + t.Fatalf("stdout = %q, want YAML", stdout.String()) + } + if stderr.Len() != 0 { + t.Fatalf("stderr = %q, want empty", stderr.String()) + } + if creator.calls != 0 { + t.Fatalf("Create() calls = %d, want 0", creator.calls) + } +} + +func TestOutputResourcesCreatesOnlyWhenExplicitlyEnabled(t *testing.T) { + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + creator := &fakeResourceCreator{ + names: []string{"scaledobject.keda.sh/demo"}, + } + manifest := []byte("kind: ScaledObject\n") + + err := outputResources( + context.Background(), + stdout, + stderr, + creator, + "apps", + manifest, + true, + ) + if err != nil { + t.Fatalf("outputResources() error = %v", err) + } + if stdout.String() != string(manifest) { + t.Fatalf("stdout = %q, want YAML", stdout.String()) + } + if creator.calls != 1 { + t.Fatalf("Create() calls = %d, want 1", creator.calls) + } + if !strings.Contains(stderr.String(), "Created scaledobject.keda.sh/demo") { + t.Fatalf("stderr = %q", stderr.String()) + } +} diff --git a/internal/cli/run.go b/internal/cli/run.go index 8cf372d..72b32dd 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -14,6 +14,7 @@ import ( "github.com/kedify/cli/internal/cli/delete" "github.com/kedify/cli/internal/cli/get" "github.com/kedify/cli/internal/cli/list" + "github.com/kedify/cli/internal/cli/metrics" clierrors "github.com/kedify/cli/internal/errors" "github.com/kedify/cli/internal/output" "github.com/kedify/cli/internal/service" @@ -21,13 +22,14 @@ 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."` - Delete DeleteCmd `cmd:"" help:"Delete Kedify resources."` - Get GetCmd `cmd:"" help:"Get Kedify resources."` - List ListCmd `cmd:"" help:"List Kedify resources."` + 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."` + Delete DeleteCmd `cmd:"" help:"Delete Kedify resources."` + Get GetCmd `cmd:"" help:"Get Kedify resources."` + List ListCmd `cmd:"" help:"List Kedify resources."` + Metrics metrics.MetricsCmd `cmd:"" help:"Explore Prometheus metrics and generate autoscaling manifests."` } type AuthCmd struct { diff --git a/internal/cli/run_test.go b/internal/cli/run_test.go new file mode 100644 index 0000000..495c25b --- /dev/null +++ b/internal/cli/run_test.go @@ -0,0 +1,59 @@ +package cli + +import ( + "bytes" + "strings" + "testing" +) + +func TestMetricsCommandKeepsInteractiveErrorsOffStdout(t *testing.T) { + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + + exitCode := Run( + []string{"metrics", "--server=http://127.0.0.1:1"}, + bytes.NewBuffer(nil), + stdout, + stderr, + ) + if exitCode != 1 { + t.Fatalf("Run() exit code = %d, want 1", exitCode) + } + if stdout.Len() != 0 { + t.Fatalf("stdout = %q, want empty", stdout.String()) + } + if !strings.Contains(stderr.String(), "interactive terminal") { + t.Fatalf("stderr = %q", stderr.String()) + } +} + +func TestMetricsCommandAcceptsLessInteractiveFlags(t *testing.T) { + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + + exitCode := Run( + []string{ + "metrics", + "--server=http://127.0.0.1:1", + "--filter=memory_", + "--query=sum(foobar)", + "--visualize", + "--horizon=3d", + "--print", + "--context=staging", + "--kubeconfig=./testdata/kubeconfig", + }, + bytes.NewBuffer(nil), + stdout, + stderr, + ) + if exitCode != 1 { + t.Fatalf("Run() exit code = %d, want 1", exitCode) + } + if stdout.Len() != 0 { + t.Fatalf("stdout = %q, want empty", stdout.String()) + } + if !strings.Contains(stderr.String(), "interactive terminal") { + t.Fatalf("stderr = %q", stderr.String()) + } +} diff --git a/internal/kubernetes/client.go b/internal/kubernetes/client.go new file mode 100644 index 0000000..98e2c26 --- /dev/null +++ b/internal/kubernetes/client.go @@ -0,0 +1,516 @@ +package kubernetes + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/url" + "os/exec" + "regexp" + "sort" + "strconv" + "strings" + "sync" + "time" +) + +const ( + defaultKubectl = "kubectl" + portForwardTimeout = 15 * time.Second + portForwardStopLimit = 3 * time.Second +) + +var prometheusServiceLabelSelectors = []prometheusServiceLabelSelector{ + { + labels: map[string]string{ + "app.kubernetes.io/name": "mimir", + "app.kubernetes.io/component": "gateway", + }, + display: "app.kubernetes.io/component=gateway,app.kubernetes.io/name=mimir", + pathPrefix: "/prometheus", + preferredPort: 80, + mimir: true, + }, + {labels: map[string]string{"app": "kube-prometheus-stack-prometheus"}, display: "app=kube-prometheus-stack-prometheus"}, + {labels: map[string]string{"app.kubernetes.io/component": "server", "app.kubernetes.io/name": "prometheus"}, display: "app.kubernetes.io/component=server,app.kubernetes.io/name=prometheus"}, + {labels: map[string]string{"app": "prometheus", "component": "server"}, display: "app=prometheus,component=server"}, + {labels: map[string]string{"app": "prometheus-server"}, display: "app=prometheus-server"}, + {labels: map[string]string{"app": "prometheus-operator-prometheus"}, display: "app=prometheus-operator-prometheus"}, + {labels: map[string]string{"app": "rancher-monitoring-prometheus"}, display: "app=rancher-monitoring-prometheus"}, + {labels: map[string]string{"app": "prometheus-prometheus"}, display: "app=prometheus-prometheus"}, +} + +var forwardingPattern = regexp.MustCompile(`Forwarding from (?:127\.0\.0\.1|\[::1\]):([0-9]+)`) + +type prometheusServiceLabelSelector struct { + labels map[string]string + display string + pathPrefix string + preferredPort int + mimir bool +} + +type Client struct { + binary string + context string + kubeconfig string +} + +type ClientOptions struct { + Context string + Kubeconfig string +} + +type PrometheusService struct { + Name string + Namespace string + Port int + PortName string + Scheme string + MatchedLabels string + PathPrefix string + Mimir bool +} + +func (s PrometheusService) DisplayName() string { + port := strconv.Itoa(s.Port) + if s.PortName != "" { + port = s.PortName + ":" + port + } + return s.Namespace + "/" + s.Name + " " + port +} + +func (s PrometheusService) ClusterURL() string { + return fmt.Sprintf( + "%s://%s.%s.svc.cluster.local:%d%s", + s.Scheme, + s.Name, + s.Namespace, + s.Port, + s.normalizedPathPrefix(), + ) +} + +func (s PrometheusService) normalizedPathPrefix() string { + pathPrefix := strings.Trim(strings.TrimSpace(s.PathPrefix), "/") + if pathPrefix == "" { + return "" + } + return "/" + pathPrefix +} + +type PortForward struct { + LocalURL string + + cancel context.CancelFunc + cmd *exec.Cmd + done chan error + + mu sync.Mutex + diagnostics []string +} + +type serviceList struct { + Items []service `json:"items"` +} + +type service struct { + Metadata struct { + Name string `json:"name"` + Namespace string `json:"namespace"` + Labels map[string]string `json:"labels"` + } `json:"metadata"` + Spec struct { + Ports []servicePort `json:"ports"` + } `json:"spec"` +} + +type servicePort struct { + Name string `json:"name"` + Port int `json:"port"` +} + +func NewClient() *Client { + return NewClientWithOptions(ClientOptions{}) +} + +func NewClientWithOptions(options ClientOptions) *Client { + return newClient(defaultKubectl, options) +} + +func NewClientWithBinary(binary string) *Client { + return NewClientWithBinaryAndOptions(binary, ClientOptions{}) +} + +func NewClientWithBinaryAndOptions(binary string, options ClientOptions) *Client { + return newClient(binary, options) +} + +func newClient(binary string, options ClientOptions) *Client { + return &Client{ + binary: binary, + context: options.Context, + kubeconfig: options.Kubeconfig, + } +} + +func (c *Client) DiscoverPrometheusServices(ctx context.Context) ([]PrometheusService, error) { + output, err := c.run(ctx, nil, "get", "services", "--all-namespaces", "-o", "json") + if err != nil { + return nil, fmt.Errorf("discover Prometheus services: %w", err) + } + + var list serviceList + if err := json.Unmarshal(output, &list); err != nil { + return nil, fmt.Errorf("decode Kubernetes services: %w", err) + } + + matches := make([]PrometheusService, 0) + for _, item := range list.Items { + selector, ok := matchingPrometheusServiceSelector(item.Metadata.Labels) + if !ok { + continue + } + + port, ok := bestServicePortForSelector(item.Spec.Ports, selector) + if !ok { + continue + } + matches = append(matches, PrometheusService{ + Name: item.Metadata.Name, + Namespace: item.Metadata.Namespace, + Port: port.Port, + PortName: port.Name, + Scheme: serviceScheme(port), + MatchedLabels: selector.display, + PathPrefix: selector.pathPrefix, + Mimir: selector.mimir, + }) + } + + sort.Slice(matches, func(i, j int) bool { + if matches[i].Namespace == matches[j].Namespace { + return matches[i].Name < matches[j].Name + } + return matches[i].Namespace < matches[j].Namespace + }) + return matches, nil +} + +func (c *Client) CurrentNamespace(ctx context.Context) string { + output, err := c.run(ctx, nil, "config", "view", "--minify", "-o", "jsonpath={..namespace}") + if err != nil { + return "default" + } + namespace := strings.TrimSpace(string(output)) + if namespace == "" { + return "default" + } + return namespace +} + +func (c *Client) CurrentContext(ctx context.Context) (string, error) { + if c.context != "" { + return c.context, nil + } + + output, err := c.run(ctx, nil, "config", "current-context") + if err != nil { + return "", fmt.Errorf("get current Kubernetes context: %w", err) + } + currentContext := strings.TrimSpace(string(output)) + if currentContext == "" { + return "", errors.New("current Kubernetes context is empty") + } + return currentContext, nil +} + +func (c *Client) StartPortForward(parent context.Context, service PrometheusService) (*PortForward, error) { + ctx, cancel := context.WithCancel(parent) + target := "service/" + service.Name + mapping := ":" + strconv.Itoa(service.Port) + args := c.commandArgs("port-forward", "--namespace", service.Namespace, target, mapping) + cmd := exec.CommandContext(ctx, c.binary, args...) // #nosec G204 -- kubectl is fixed by the caller and arguments are passed without a shell. + + stdout, err := cmd.StdoutPipe() + if err != nil { + cancel() + return nil, fmt.Errorf("capture kubectl port-forward stdout: %w", err) + } + stderr, err := cmd.StderrPipe() + if err != nil { + cancel() + return nil, fmt.Errorf("capture kubectl port-forward stderr: %w", err) + } + + forward := &PortForward{ + cancel: cancel, + cmd: cmd, + done: make(chan error, 1), + } + ready := make(chan int, 1) + lines := make(chan string, 16) + + if err := cmd.Start(); err != nil { + cancel() + return nil, fmt.Errorf("start kubectl port-forward: %w", err) + } + + var readers sync.WaitGroup + readers.Add(2) + go forward.scanOutput(stdout, ready, lines, &readers) + go forward.scanOutput(stderr, ready, lines, &readers) + go func() { + err := cmd.Wait() + readers.Wait() + forward.done <- err + close(forward.done) + }() + + timer := time.NewTimer(portForwardTimeout) + defer timer.Stop() + + for { + select { + case port := <-ready: + forward.LocalURL = fmt.Sprintf( + "%s://127.0.0.1:%d%s", + service.Scheme, + port, + service.normalizedPathPrefix(), + ) + return forward, nil + case err := <-forward.done: + cancel() + return nil, fmt.Errorf("kubectl port-forward stopped before it was ready: %s", portForwardError(err, forward.Diagnostics())) + case line := <-lines: + forward.addDiagnostic(line) + case <-timer.C: + cancel() + return nil, fmt.Errorf("timed out waiting for kubectl port-forward: %s", strings.Join(forward.Diagnostics(), "; ")) + case <-parent.Done(): + cancel() + return nil, parent.Err() + } + } +} + +func (c *Client) Create(ctx context.Context, manifest []byte) ([]string, error) { + output, err := c.run(ctx, manifest, "create", "-f", "-", "-o", "name") + if err != nil { + return nil, fmt.Errorf("create Kubernetes resources: %w", err) + } + + lines := strings.Split(strings.TrimSpace(string(output)), "\n") + resources := make([]string, 0, len(lines)) + for _, line := range lines { + if line = strings.TrimSpace(line); line != "" { + resources = append(resources, line) + } + } + return resources, nil +} + +func (p *PortForward) Stop() error { + if p == nil || p.cancel == nil { + return nil + } + p.cancel() + + timer := time.NewTimer(portForwardStopLimit) + defer timer.Stop() + select { + case err := <-p.done: + if err == nil || errors.Is(err, context.Canceled) { + return nil + } + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return nil + } + return err + case <-timer.C: + if p.cmd != nil && p.cmd.Process != nil { + _ = p.cmd.Process.Kill() + } + return nil + } +} + +func (p *PortForward) Diagnostics() []string { + p.mu.Lock() + defer p.mu.Unlock() + return append([]string(nil), p.diagnostics...) +} + +func (p *PortForward) scanOutput(reader io.Reader, ready chan<- int, lines chan<- string, done *sync.WaitGroup) { + defer done.Done() + scanner := bufio.NewScanner(reader) + for scanner.Scan() { + line := scanner.Text() + if match := forwardingPattern.FindStringSubmatch(line); len(match) == 2 { + port, err := strconv.Atoi(match[1]) + if err == nil { + select { + case ready <- port: + default: + } + } + } + select { + case lines <- line: + default: + p.addDiagnostic(line) + } + } + if err := scanner.Err(); err != nil { + p.addDiagnostic(err.Error()) + } +} + +func (p *PortForward) addDiagnostic(line string) { + line = strings.TrimSpace(line) + if line == "" { + return + } + p.mu.Lock() + defer p.mu.Unlock() + if len(p.diagnostics) == 20 { + copy(p.diagnostics, p.diagnostics[1:]) + p.diagnostics = p.diagnostics[:19] + } + p.diagnostics = append(p.diagnostics, line) +} + +func (c *Client) run(ctx context.Context, stdin []byte, args ...string) ([]byte, error) { + cmd := exec.CommandContext(ctx, c.binary, c.commandArgs(args...)...) // #nosec G204 -- kubectl is fixed by the caller and arguments are passed without a shell. + if stdin != nil { + cmd.Stdin = bytes.NewReader(stdin) + } + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + detail := strings.TrimSpace(stderr.String()) + if detail != "" { + return nil, fmt.Errorf("%w: %s", err, detail) + } + return nil, err + } + return stdout.Bytes(), nil +} + +func (c *Client) commandArgs(args ...string) []string { + commandArgs := make([]string, 0, len(args)+4) + if c.context != "" { + commandArgs = append(commandArgs, "--context", c.context) + } + if c.kubeconfig != "" { + commandArgs = append(commandArgs, "--kubeconfig", c.kubeconfig) + } + return append(commandArgs, args...) +} + +func matchingSelector(labels map[string]string) string { + selector, ok := matchingPrometheusServiceSelector(labels) + if !ok { + return "" + } + return selector.display +} + +func matchingPrometheusServiceSelector(labels map[string]string) (prometheusServiceLabelSelector, bool) { + for _, selector := range prometheusServiceLabelSelectors { + matches := true + for key, want := range selector.labels { + if labels[key] != want { + matches = false + break + } + } + if matches { + return selector, true + } + } + return prometheusServiceLabelSelector{}, false +} + +func bestServicePortForSelector( + ports []servicePort, + selector prometheusServiceLabelSelector, +) (servicePort, bool) { + if selector.preferredPort != 0 { + for _, port := range ports { + if port.Port == selector.preferredPort { + return port, true + } + } + } + return bestServicePort(ports) +} + +func bestServicePort(ports []servicePort) (servicePort, bool) { + if len(ports) == 0 { + return servicePort{}, false + } + + score := func(port servicePort) int { + name := strings.ToLower(port.Name) + switch { + case port.Port == 9090: + return 100 + case name == "web" || name == "http-web": + return 90 + case name == "http" || name == "prometheus": + return 80 + case strings.Contains(name, "web") || strings.Contains(name, "http"): + return 70 + default: + return 0 + } + } + + best := ports[0] + bestScore := score(best) + for _, port := range ports[1:] { + if candidate := score(port); candidate > bestScore { + best = port + bestScore = candidate + } + } + return best, true +} + +func serviceScheme(port servicePort) string { + name := strings.ToLower(port.Name) + if port.Port == 443 || strings.Contains(name, "https") { + return "https" + } + return "http" +} + +func portForwardError(err error, diagnostics []string) string { + parts := diagnostics + if err != nil { + parts = append(parts, err.Error()) + } + if len(parts) == 0 { + return "unknown error" + } + return strings.Join(parts, "; ") +} + +func IsLocalAddress(rawURL string) bool { + parsed, err := url.Parse(rawURL) + if err != nil { + return false + } + host := parsed.Hostname() + return host == "localhost" || net.ParseIP(host).IsLoopback() +} diff --git a/internal/kubernetes/client_test.go b/internal/kubernetes/client_test.go new file mode 100644 index 0000000..b3f3d4d --- /dev/null +++ b/internal/kubernetes/client_test.go @@ -0,0 +1,253 @@ +package kubernetes + +import ( + "context" + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +func TestMatchingSelectorRecognizesConfiguredPrometheusLabels(t *testing.T) { + tests := []struct { + labels map[string]string + want string + }{ + { + labels: map[string]string{"app": "kube-prometheus-stack-prometheus"}, + want: "app=kube-prometheus-stack-prometheus", + }, + { + labels: map[string]string{ + "app.kubernetes.io/component": "server", + "app.kubernetes.io/name": "prometheus", + "extra": "kept", + }, + want: "app.kubernetes.io/component=server,app.kubernetes.io/name=prometheus", + }, + { + labels: map[string]string{ + "app.kubernetes.io/component": "gateway", + "app.kubernetes.io/name": "mimir", + "app.kubernetes.io/instance": "mimir", + }, + want: "app.kubernetes.io/component=gateway,app.kubernetes.io/name=mimir", + }, + { + labels: map[string]string{ + "app.kubernetes.io/component": "querier", + "app.kubernetes.io/name": "mimir", + }, + want: "", + }, + { + labels: map[string]string{"app": "not-prometheus"}, + want: "", + }, + } + + for _, test := range tests { + if got := matchingSelector(test.labels); got != test.want { + t.Fatalf("matchingSelector(%v) = %q, want %q", test.labels, got, test.want) + } + } +} + +func TestBestServicePortPrefersPrometheusAndHTTPPorts(t *testing.T) { + port, ok := bestServicePort([]servicePort{ + {Name: "grpc", Port: 4317}, + {Name: "http", Port: 8080}, + {Name: "web", Port: 9090}, + }) + if !ok { + t.Fatal("bestServicePort() ok = false") + } + if port.Port != 9090 { + t.Fatalf("bestServicePort() = %#v, want port 9090", port) + } +} + +func TestDiscoverPrometheusServicesFiltersSortsAndBuildsURLs(t *testing.T) { + dir := t.TempDir() + kubectl := filepath.Join(dir, "kubectl") + script := `#!/bin/sh +printf '%s' '{"items":[ + {"metadata":{"name":"z-prom","namespace":"z","labels":{"app":"prometheus-server"}},"spec":{"ports":[{"name":"web","port":9090}]}}, + {"metadata":{"name":"ignored","namespace":"a","labels":{"app":"other"}},"spec":{"ports":[{"port":9090}]}}, + {"metadata":{"name":"a-prom","namespace":"a","labels":{"app":"rancher-monitoring-prometheus"}},"spec":{"ports":[{"name":"https","port":443}]}}, + {"metadata":{"name":"mimir-gateway","namespace":"mimir","labels":{"app.kubernetes.io/name":"mimir","app.kubernetes.io/component":"gateway"}},"spec":{"ports":[{"name":"http-metrics","port":8080},{"name":"gateway","port":80}]}} +]}' +` + if err := os.WriteFile(kubectl, []byte(script), 0o700); err != nil { + t.Fatalf("write fake kubectl: %v", err) + } + + services, err := NewClientWithBinary(kubectl).DiscoverPrometheusServices(context.Background()) + if err != nil { + t.Fatalf("DiscoverPrometheusServices() error = %v", err) + } + if len(services) != 3 { + t.Fatalf("services = %#v", services) + } + if services[0].Name != "a-prom" || services[1].Name != "mimir-gateway" || services[2].Name != "z-prom" { + t.Fatalf("services are not sorted: %#v", services) + } + if got := services[0].ClusterURL(); got != "https://a-prom.a.svc.cluster.local:443" { + t.Fatalf("ClusterURL() = %q", got) + } + if got := services[1].ClusterURL(); got != "http://mimir-gateway.mimir.svc.cluster.local:80/prometheus" { + t.Fatalf("Mimir ClusterURL() = %q", got) + } + if services[1].Port != 80 { + t.Fatalf("Mimir port = %d, want 80", services[1].Port) + } + if !services[1].Mimir { + t.Fatal("Mimir gateway was not marked as Mimir") + } +} + +func TestCurrentContextReturnsActiveKubernetesContext(t *testing.T) { + dir := t.TempDir() + kubectl := filepath.Join(dir, "kubectl") + script := "#!/bin/sh\nprintf '%s\\n' 'production-eu'\n" + if err := os.WriteFile(kubectl, []byte(script), 0o700); err != nil { + t.Fatalf("write fake kubectl: %v", err) + } + + currentContext, err := NewClientWithBinary(kubectl).CurrentContext(context.Background()) + if err != nil { + t.Fatalf("CurrentContext() error = %v", err) + } + if currentContext != "production-eu" { + t.Fatalf("CurrentContext() = %q, want production-eu", currentContext) + } +} + +func TestCurrentContextRejectsEmptyContext(t *testing.T) { + dir := t.TempDir() + kubectl := filepath.Join(dir, "kubectl") + script := "#!/bin/sh\nexit 0\n" + if err := os.WriteFile(kubectl, []byte(script), 0o700); err != nil { + t.Fatalf("write fake kubectl: %v", err) + } + + _, err := NewClientWithBinary(kubectl).CurrentContext(context.Background()) + if err == nil || !strings.Contains(err.Error(), "context is empty") { + t.Fatalf("CurrentContext() error = %v", err) + } +} + +func TestCurrentContextReturnsExplicitContextWithoutRunningKubectl(t *testing.T) { + client := NewClientWithBinaryAndOptions( + filepath.Join(t.TempDir(), "missing-kubectl"), + ClientOptions{Context: "staging"}, + ) + + currentContext, err := client.CurrentContext(context.Background()) + if err != nil { + t.Fatalf("CurrentContext() error = %v", err) + } + if currentContext != "staging" { + t.Fatalf("CurrentContext() = %q, want staging", currentContext) + } +} + +func TestCreatePassesManifestOnStdinAndReturnsNames(t *testing.T) { + dir := t.TempDir() + kubectl := filepath.Join(dir, "kubectl") + captured := filepath.Join(dir, "manifest") + capturedArgs := filepath.Join(dir, "args") + script := "#!/bin/sh\nprintf '%s\\n' \"$@\" > " + capturedArgs + "\ncat > " + captured + "\nprintf '%s\\n' 'scaledobject.keda.sh/demo' 'metricpredictor.keda.kedify.io/demo'\n" + if err := os.WriteFile(kubectl, []byte(script), 0o700); err != nil { + t.Fatalf("write fake kubectl: %v", err) + } + + manifest := []byte("kind: ScaledObject\n") + names, err := NewClientWithBinaryAndOptions(kubectl, ClientOptions{ + Context: "staging", + Kubeconfig: "./config/staging.kubeconfig", + }).Create(context.Background(), manifest) + if err != nil { + t.Fatalf("Create() error = %v", err) + } + want := []string{"scaledobject.keda.sh/demo", "metricpredictor.keda.kedify.io/demo"} + if !reflect.DeepEqual(names, want) { + t.Fatalf("Create() = %#v, want %#v", names, want) + } + gotManifest, err := os.ReadFile(captured) + if err != nil { + t.Fatalf("read captured manifest: %v", err) + } + if string(gotManifest) != string(manifest) { + t.Fatalf("manifest = %q, want %q", gotManifest, manifest) + } + assertCapturedArgs(t, capturedArgs, []string{ + "--context", "staging", + "--kubeconfig", "./config/staging.kubeconfig", + "create", "-f", "-", "-o", "name", + }) +} + +func TestStartPortForwardReadsRandomPortAndStopsProcess(t *testing.T) { + dir := t.TempDir() + kubectl := filepath.Join(dir, "kubectl") + capturedArgs := filepath.Join(dir, "args") + script := `#!/bin/sh +printf '%s\n' "$@" > ` + capturedArgs + ` +printf '%s\n' 'Forwarding from 127.0.0.1:45678 -> 9090' >&2 +exec sleep 60 +` + if err := os.WriteFile(kubectl, []byte(script), 0o700); err != nil { + t.Fatalf("write fake kubectl: %v", err) + } + + forward, err := NewClientWithBinaryAndOptions(kubectl, ClientOptions{ + Context: "staging", + Kubeconfig: "./config/staging.kubeconfig", + }).StartPortForward(context.Background(), PrometheusService{ + Name: "mimir-gateway", + Namespace: "mimir", + Port: 80, + Scheme: "http", + PathPrefix: "/prometheus", + }) + if err != nil { + t.Fatalf("StartPortForward() error = %v", err) + } + if forward.LocalURL != "http://127.0.0.1:45678/prometheus" { + t.Fatalf("LocalURL = %q", forward.LocalURL) + } + if err := forward.Stop(); err != nil { + t.Fatalf("Stop() error = %v", err) + } + assertCapturedArgs(t, capturedArgs, []string{ + "--context", "staging", + "--kubeconfig", "./config/staging.kubeconfig", + "port-forward", "--namespace", "mimir", "service/mimir-gateway", ":80", + }) +} + +func TestIsLocalAddress(t *testing.T) { + for _, value := range []string{"http://localhost:9090", "http://127.0.0.1:1234", "http://[::1]:9090"} { + if !IsLocalAddress(value) { + t.Fatalf("IsLocalAddress(%q) = false", value) + } + } + if IsLocalAddress("http://prometheus.monitoring.svc:9090") { + t.Fatal("cluster service URL considered local") + } +} + +func assertCapturedArgs(t *testing.T, path string, want []string) { + t.Helper() + + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read captured arguments: %v", err) + } + got := strings.Split(strings.TrimSpace(string(data)), "\n") + if !reflect.DeepEqual(got, want) { + t.Fatalf("kubectl arguments = %#v, want %#v", got, want) + } +} diff --git a/internal/metrics/resources.go b/internal/metrics/resources.go new file mode 100644 index 0000000..d7f2122 --- /dev/null +++ b/internal/metrics/resources.go @@ -0,0 +1,165 @@ +package metrics + +import ( + "bytes" + "fmt" + "strings" + + "gopkg.in/yaml.v3" +) + +const ( + DefaultScalerAddress = "keda-otel-scaler.keda.svc:4318" + DefaultTargetValue = "1" +) + +type ResourceOptions struct { + Query string + PrometheusURL string + Namespace string + ScaledObjectName string + ScaleTargetName string + MetricPredictorName string + PrometheusStart string + PrometheusEnd string + PrometheusStep string +} + +type objectMeta struct { + Name string `yaml:"name"` + Namespace string `yaml:"namespace,omitempty"` +} + +type scaledObject struct { + APIVersion string `yaml:"apiVersion"` + Kind string `yaml:"kind"` + Metadata objectMeta `yaml:"metadata"` + Spec scaledObjectSpec `yaml:"spec"` +} + +type scaledObjectSpec struct { + ScaleTargetRef scaleTargetRef `yaml:"scaleTargetRef"` + Triggers []trigger `yaml:"triggers"` +} + +type scaleTargetRef struct { + Name string `yaml:"name"` +} + +type trigger struct { + Type string `yaml:"type"` + Metadata map[string]string `yaml:"metadata"` +} + +type metricPredictor struct { + APIVersion string `yaml:"apiVersion"` + Kind string `yaml:"kind"` + Metadata objectMeta `yaml:"metadata"` + Spec metricPredictorSpec `yaml:"spec"` +} + +type metricPredictorSpec struct { + Source metricPredictorSource `yaml:"source"` +} + +type metricPredictorSource struct { + OneShotPrometheus prometheusSource `yaml:"oneShotPrometheus"` +} + +type prometheusSource struct { + URL string `yaml:"url"` + Query string `yaml:"query"` + Start string `yaml:"start,omitempty"` + End string `yaml:"end,omitempty"` + Step string `yaml:"step,omitempty"` + Timeout string `yaml:"timeout,omitempty"` +} + +func BuildResources(options ResourceOptions) ([]byte, error) { + if strings.TrimSpace(options.Query) == "" { + return nil, fmt.Errorf("PromQL query is required") + } + if options.Namespace == "" { + options.Namespace = "default" + } + + resources := make([]any, 0, 2) + if options.ScaledObjectName != "" { + if options.ScaleTargetName == "" { + return nil, fmt.Errorf("scale target name is required for ScaledObject %q", options.ScaledObjectName) + } + resources = append(resources, scaledObject{ + APIVersion: "keda.sh/v1alpha1", + Kind: "ScaledObject", + Metadata: objectMeta{ + Name: options.ScaledObjectName, + Namespace: options.Namespace, + }, + Spec: scaledObjectSpec{ + ScaleTargetRef: scaleTargetRef{Name: options.ScaleTargetName}, + Triggers: []trigger{{ + Type: "kedify-otel", + Metadata: map[string]string{ + "metricQuery": options.Query, + "scalerAddress": DefaultScalerAddress, + "targetValue": DefaultTargetValue, + }, + }}, + }, + }) + } + + if options.MetricPredictorName != "" { + if strings.TrimSpace(options.PrometheusURL) == "" { + return nil, fmt.Errorf("Prometheus URL is required for MetricPredictor %q", options.MetricPredictorName) + } + rangeFields := 0 + for _, field := range []string{options.PrometheusStart, options.PrometheusEnd, options.PrometheusStep} { + if field != "" { + rangeFields++ + } + } + if rangeFields != 0 && rangeFields != 3 { + return nil, fmt.Errorf("Prometheus start, end, and step must be specified together") + } + resources = append(resources, metricPredictor{ + APIVersion: "keda.kedify.io/v1alpha1", + Kind: "MetricPredictor", + Metadata: objectMeta{ + Name: options.MetricPredictorName, + Namespace: options.Namespace, + }, + Spec: metricPredictorSpec{ + Source: metricPredictorSource{ + OneShotPrometheus: prometheusSource{ + URL: options.PrometheusURL, + Query: options.Query, + Start: options.PrometheusStart, + End: options.PrometheusEnd, + Step: options.PrometheusStep, + Timeout: "30s", + }, + }, + }, + }) + } + + if len(resources) == 0 { + return nil, nil + } + + var output bytes.Buffer + for index, resource := range resources { + data, err := yaml.Marshal(resource) + if err != nil { + return nil, fmt.Errorf("encode Kubernetes resource: %w", err) + } + if index > 0 { + output.WriteString("---\n") + } + if _, err := output.Write(data); err != nil { + return nil, fmt.Errorf("write Kubernetes resource: %w", err) + } + } + return output.Bytes(), nil +} diff --git a/internal/metrics/resources_test.go b/internal/metrics/resources_test.go new file mode 100644 index 0000000..75cacf5 --- /dev/null +++ b/internal/metrics/resources_test.go @@ -0,0 +1,97 @@ +package metrics + +import ( + "bytes" + "io" + "testing" + + "gopkg.in/yaml.v3" +) + +func TestBuildResourcesCreatesScaledObjectAndMetricPredictor(t *testing.T) { + data, err := BuildResources(ResourceOptions{ + Query: `sum(http_requests_total{method="GET"})`, + PrometheusURL: "http://prometheus.monitoring.svc.cluster.local:9090", + Namespace: "apps", + ScaledObjectName: "web-scaler", + ScaleTargetName: "web", + MetricPredictorName: "web-predictor", + PrometheusStart: "2026-07-20T12:00:00Z", + PrometheusEnd: "2026-07-27T12:00:00Z", + PrometheusStep: "30s", + }) + if err != nil { + t.Fatalf("BuildResources() error = %v", err) + } + + decoder := yaml.NewDecoder(bytes.NewReader(data)) + var resources []map[string]any + for { + var resource map[string]any + err := decoder.Decode(&resource) + if err == io.EOF { + break + } + if err != nil { + t.Fatalf("decode resource: %v\n%s", err, data) + } + if len(resource) > 0 { + resources = append(resources, resource) + } + } + if len(resources) != 2 { + t.Fatalf("decoded %d resources, want 2\n%s", len(resources), data) + } + + scaledObject := resources[0] + if scaledObject["apiVersion"] != "keda.sh/v1alpha1" || scaledObject["kind"] != "ScaledObject" { + t.Fatalf("ScaledObject header = %#v", scaledObject) + } + scaledSpec := scaledObject["spec"].(map[string]any) + triggers := scaledSpec["triggers"].([]any) + trigger := triggers[0].(map[string]any) + if trigger["type"] != "kedify-otel" { + t.Fatalf("trigger type = %#v", trigger["type"]) + } + metadata := trigger["metadata"].(map[string]any) + if metadata["metricQuery"] != `sum(http_requests_total{method="GET"})` { + t.Fatalf("metricQuery = %#v", metadata["metricQuery"]) + } + + predictor := resources[1] + if predictor["apiVersion"] != "keda.kedify.io/v1alpha1" || predictor["kind"] != "MetricPredictor" { + t.Fatalf("MetricPredictor header = %#v", predictor) + } + predictorSpec := predictor["spec"].(map[string]any) + source := predictorSpec["source"].(map[string]any) + prometheusSource := source["oneShotPrometheus"].(map[string]any) + if prometheusSource["url"] != "http://prometheus.monitoring.svc.cluster.local:9090" { + t.Fatalf("MetricPredictor URL = %#v", prometheusSource["url"]) + } + if prometheusSource["query"] != `sum(http_requests_total{method="GET"})` { + t.Fatalf("MetricPredictor query = %#v", prometheusSource["query"]) + } + if prometheusSource["start"] != "2026-07-20T12:00:00Z" || + prometheusSource["end"] != "2026-07-27T12:00:00Z" || + prometheusSource["step"] != "30s" { + t.Fatalf("MetricPredictor range = %#v", prometheusSource) + } +} + +func TestBuildResourcesValidatesRequiredFields(t *testing.T) { + if _, err := BuildResources(ResourceOptions{}); err == nil { + t.Fatal("empty options returned nil error") + } + if _, err := BuildResources(ResourceOptions{ + Query: "up", + ScaledObjectName: "demo", + }); err == nil { + t.Fatal("missing scale target returned nil error") + } + if _, err := BuildResources(ResourceOptions{ + Query: "up", + MetricPredictorName: "demo", + }); err == nil { + t.Fatal("missing Prometheus URL returned nil error") + } +} diff --git a/internal/prometheus/client.go b/internal/prometheus/client.go new file mode 100644 index 0000000..e9d66df --- /dev/null +++ b/internal/prometheus/client.go @@ -0,0 +1,288 @@ +package prometheus + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "sort" + "strconv" + "strings" + "time" +) + +const ( + defaultTimeout = 30 * time.Second + allMetrics = `{__name__=~".+"}` + mimirScopeOrgIDHeader = "X-Scope-OrgID" + mimirScopeOrgID = "kedify-agent" +) + +type Client struct { + baseURL *url.URL + httpClient *http.Client + scopeOrgID string +} + +type Series struct { + Labels map[string]string + Points []Point +} + +type Point struct { + Timestamp float64 + Value float64 +} + +type apiResponse struct { + Status string `json:"status"` + Data json.RawMessage `json:"data"` + ErrorType string `json:"errorType"` + Error string `json:"error"` +} + +type queryData struct { + ResultType string `json:"resultType"` + Result json.RawMessage `json:"result"` +} + +type matrixSeries struct { + Metric map[string]string `json:"metric"` + Values [][]json.RawMessage `json:"values"` +} + +func NewClient(rawURL string) (*Client, error) { + return NewClientWithHTTP(rawURL, &http.Client{Timeout: defaultTimeout}) +} + +func NewMimirClient(rawURL string) (*Client, error) { + return NewMimirClientWithHTTP(rawURL, &http.Client{Timeout: defaultTimeout}) +} + +func NewClientWithHTTP(rawURL string, httpClient *http.Client) (*Client, error) { + return newClientWithHTTP(rawURL, httpClient, "") +} + +func NewMimirClientWithHTTP(rawURL string, httpClient *http.Client) (*Client, error) { + return newClientWithHTTP(rawURL, httpClient, mimirScopeOrgID) +} + +func newClientWithHTTP(rawURL string, httpClient *http.Client, scopeOrgID string) (*Client, error) { + normalized, err := NormalizeURL(rawURL) + if err != nil { + return nil, err + } + if httpClient == nil { + httpClient = &http.Client{Timeout: defaultTimeout} + } + + baseURL, err := url.Parse(normalized) + if err != nil { + return nil, fmt.Errorf("parse Prometheus URL: %w", err) + } + + return &Client{ + baseURL: baseURL, + httpClient: httpClient, + scopeOrgID: scopeOrgID, + }, nil +} + +func NormalizeURL(rawURL string) (string, error) { + rawURL = strings.TrimSpace(rawURL) + if rawURL == "" { + return "", errors.New("Prometheus server URL is required") + } + if !strings.Contains(rawURL, "://") { + rawURL = "http://" + rawURL + } + + parsed, err := url.Parse(rawURL) + if err != nil { + return "", fmt.Errorf("parse Prometheus URL: %w", err) + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return "", fmt.Errorf("Prometheus URL scheme must be http or https, got %q", parsed.Scheme) + } + if parsed.Host == "" { + return "", errors.New("Prometheus server URL must include a host") + } + if parsed.RawQuery != "" || parsed.Fragment != "" { + return "", errors.New("Prometheus server URL must not include a query or fragment") + } + parsed.Path = strings.TrimRight(parsed.Path, "/") + + return parsed.String(), nil +} + +func (c *Client) MetricNames(ctx context.Context) ([]string, error) { + var names []string + if err := c.get( + ctx, + "/api/v1/label/__name__/values", + url.Values{"match[]": []string{allMetrics}}, + &names, + ); err != nil { + return nil, fmt.Errorf("list Prometheus metric names: %w", err) + } + sort.Strings(names) + return names, nil +} + +func (c *Client) LabelNames(ctx context.Context, selector string) ([]string, error) { + var labels []string + if err := c.get(ctx, "/api/v1/labels", url.Values{"match[]": []string{selector}}, &labels); err != nil { + return nil, fmt.Errorf("list labels for %q: %w", selector, err) + } + + filtered := labels[:0] + for _, label := range labels { + if label != "__name__" { + filtered = append(filtered, label) + } + } + sort.Strings(filtered) + return filtered, nil +} + +func (c *Client) LabelValues(ctx context.Context, label, selector string) ([]string, error) { + var values []string + path := "/api/v1/label/" + url.PathEscape(label) + "/values" + if err := c.get(ctx, path, url.Values{"match[]": []string{selector}}, &values); err != nil { + return nil, fmt.Errorf("list values for label %q: %w", label, err) + } + + sort.Strings(values) + return values, nil +} + +func (c *Client) ValidateQuery(ctx context.Context, query string) error { + var data queryData + if err := c.get(ctx, "/api/v1/query", url.Values{"query": []string{query}}, &data); err != nil { + return fmt.Errorf("validate PromQL query: %w", err) + } + return nil +} + +func (c *Client) RangeQuery(ctx context.Context, query string, start, end time.Time, step time.Duration) ([]Series, error) { + if step <= 0 { + return nil, errors.New("Prometheus range query step must be greater than zero") + } + + params := url.Values{ + "query": []string{query}, + "start": []string{start.UTC().Format(time.RFC3339)}, + "end": []string{end.UTC().Format(time.RFC3339)}, + "step": []string{strconv.FormatFloat(step.Seconds(), 'f', -1, 64)}, + } + + var data queryData + if err := c.get(ctx, "/api/v1/query_range", params, &data); err != nil { + return nil, fmt.Errorf("query Prometheus range: %w", err) + } + if data.ResultType != "matrix" { + return nil, fmt.Errorf("range query returned %q, want matrix", data.ResultType) + } + + var rawSeries []matrixSeries + if err := json.Unmarshal(data.Result, &rawSeries); err != nil { + return nil, fmt.Errorf("decode Prometheus matrix: %w", err) + } + + series := make([]Series, 0, len(rawSeries)) + for _, raw := range rawSeries { + points := make([]Point, 0, len(raw.Values)) + for _, value := range raw.Values { + if len(value) != 2 { + continue + } + + var timestamp float64 + if err := json.Unmarshal(value[0], ×tamp); err != nil { + return nil, fmt.Errorf("decode Prometheus sample timestamp: %w", err) + } + + var text string + if err := json.Unmarshal(value[1], &text); err != nil { + return nil, fmt.Errorf("decode Prometheus sample value: %w", err) + } + number, err := strconv.ParseFloat(text, 64) + if err != nil { + continue + } + points = append(points, Point{Timestamp: timestamp, Value: number}) + } + if len(points) > 0 { + series = append(series, Series{Labels: raw.Metric, Points: points}) + } + } + return series, nil +} + +func BuildSelector(metric string, labels map[string]string) string { + if len(labels) == 0 { + return metric + } + + keys := make([]string, 0, len(labels)) + for key := range labels { + keys = append(keys, key) + } + sort.Strings(keys) + + matchers := make([]string, 0, len(keys)) + for _, key := range keys { + matchers = append(matchers, key+"="+strconv.Quote(labels[key])) + } + return metric + "{" + strings.Join(matchers, ",") + "}" +} + +func (c *Client) get(ctx context.Context, path string, params url.Values, target any) error { + endpoint := *c.baseURL + endpoint.Path = strings.TrimRight(endpoint.Path, "/") + path + endpoint.RawQuery = params.Encode() + + request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil) + if err != nil { + return fmt.Errorf("create Prometheus request: %w", err) + } + if c.scopeOrgID != "" { + request.Header.Set(mimirScopeOrgIDHeader, c.scopeOrgID) + } + + response, err := c.httpClient.Do(request) + if err != nil { + return err + } + defer response.Body.Close() + + body, err := io.ReadAll(io.LimitReader(response.Body, 32<<20)) + if err != nil { + return fmt.Errorf("read Prometheus response: %w", err) + } + if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { + return fmt.Errorf("Prometheus returned HTTP %d: %s", response.StatusCode, strings.TrimSpace(string(body))) + } + + var envelope apiResponse + if err := json.Unmarshal(body, &envelope); err != nil { + return fmt.Errorf("decode Prometheus response: %w", err) + } + if envelope.Status != "success" { + detail := strings.TrimSpace(envelope.Error) + if detail == "" { + detail = "unknown API error" + } + if envelope.ErrorType != "" { + detail = envelope.ErrorType + ": " + detail + } + return errors.New(detail) + } + if err := json.Unmarshal(envelope.Data, target); err != nil { + return fmt.Errorf("decode Prometheus data: %w", err) + } + return nil +} diff --git a/internal/prometheus/client_test.go b/internal/prometheus/client_test.go new file mode 100644 index 0000000..6a010a5 --- /dev/null +++ b/internal/prometheus/client_test.go @@ -0,0 +1,197 @@ +package prometheus + +import ( + "context" + "io" + "net/http" + "reflect" + "strings" + "testing" + "time" +) + +func TestMetricNamesUsesLabelValuesWithSeriesSelector(t *testing.T) { + client := testClient(t, "http://prometheus.test/prometheus/", func(r *http.Request) string { + if r.URL.Path != "/prometheus/api/v1/label/__name__/values" { + t.Fatalf("path = %q", r.URL.Path) + } + if got := r.URL.Query().Get("match[]"); got != allMetrics { + t.Fatalf("match[] = %q, want %q", got, allMetrics) + } + return `{"status":"success","data":["up","requests_total"]}` + }) + + names, err := client.MetricNames(context.Background()) + if err != nil { + t.Fatalf("MetricNames() error = %v", err) + } + want := []string{"requests_total", "up"} + if !reflect.DeepEqual(names, want) { + t.Fatalf("MetricNames() = %#v, want %#v", names, want) + } +} + +func TestLabelNamesAndValuesUsePrometheusMetadataEndpoints(t *testing.T) { + var calls int + client := testClient(t, "http://prometheus.test", func(r *http.Request) string { + calls++ + switch r.URL.Path { + case "/api/v1/labels": + if got := r.URL.Query().Get("match[]"); got != "http_requests_total" { + t.Fatalf("labels match[] = %q", got) + } + return `{"status":"success","data":["zone","__name__","method"]}` + case "/api/v1/label/method/values": + if got := r.URL.Query().Get("match[]"); got != `http_requests_total{zone="eu"}` { + t.Fatalf("values match[] = %q", got) + } + return `{"status":"success","data":["POST","GET"]}` + default: + t.Fatalf("unexpected path %q", r.URL.Path) + return "" + } + }) + labels, err := client.LabelNames(context.Background(), "http_requests_total") + if err != nil { + t.Fatalf("LabelNames() error = %v", err) + } + if want := []string{"method", "zone"}; !reflect.DeepEqual(labels, want) { + t.Fatalf("LabelNames() = %#v, want %#v", labels, want) + } + + values, err := client.LabelValues(context.Background(), "method", `http_requests_total{zone="eu"}`) + if err != nil { + t.Fatalf("LabelValues() error = %v", err) + } + if want := []string{"GET", "POST"}; !reflect.DeepEqual(values, want) { + t.Fatalf("LabelValues() = %#v, want %#v", values, want) + } + if calls != 2 { + t.Fatalf("calls = %d, want 2", calls) + } +} + +func TestValidateQueryReturnsPrometheusAPIError(t *testing.T) { + client := testClient(t, "http://prometheus.test", func(r *http.Request) string { + if got := r.Header.Get(mimirScopeOrgIDHeader); got != "" { + t.Fatalf("%s = %q for regular Prometheus", mimirScopeOrgIDHeader, got) + } + return `{"status":"error","errorType":"bad_data","error":"parse error"}` + }) + err := client.ValidateQuery(context.Background(), "not valid(") + if err == nil || err.Error() != "validate PromQL query: bad_data: parse error" { + t.Fatalf("ValidateQuery() error = %v", err) + } +} + +func TestValidateQueryUsesMimirPrefixAndScopeOrgID(t *testing.T) { + client := testMimirClient(t, "http://mimir-gateway.test/prometheus", func(r *http.Request) string { + if r.URL.Path != "/prometheus/api/v1/query" { + t.Fatalf("path = %q, want /prometheus/api/v1/query", r.URL.Path) + } + if got := r.Header.Get(mimirScopeOrgIDHeader); got != mimirScopeOrgID { + t.Fatalf("%s = %q, want %q", mimirScopeOrgIDHeader, got, mimirScopeOrgID) + } + if got := r.URL.Query().Get("query"); got != "sum(up)" { + t.Fatalf("query = %q, want sum(up)", got) + } + return `{"status":"success","data":{"resultType":"vector","result":[]}}` + }) + + if err := client.ValidateQuery(context.Background(), "sum(up)"); err != nil { + t.Fatalf("ValidateQuery() error = %v", err) + } +} + +func TestRangeQueryDecodesMatrix(t *testing.T) { + client := testClient(t, "http://prometheus.test", func(r *http.Request) string { + if got := r.URL.Query().Get("step"); got != "30" { + t.Fatalf("step = %q, want 30", got) + } + return `{"status":"success","data":{"resultType":"matrix","result":[{"metric":{"__name__":"up","job":"prometheus"},"values":[[10,"1"],[40,"2.5"],[70,"stale"]]}]}}` + }) + series, err := client.RangeQuery( + context.Background(), + "up", + time.Unix(10, 0), + time.Unix(70, 0), + 30*time.Second, + ) + if err != nil { + t.Fatalf("RangeQuery() error = %v", err) + } + if len(series) != 1 || len(series[0].Points) != 2 { + t.Fatalf("RangeQuery() = %#v", series) + } + if series[0].Points[1].Value != 2.5 { + t.Fatalf("second value = %v, want 2.5", series[0].Points[1].Value) + } +} + +func TestBuildSelectorSortsAndQuotesLabels(t *testing.T) { + got := BuildSelector("requests_total", map[string]string{ + "zone": "eu-west", + "method": `G"ET`, + }) + want := `requests_total{method="G\"ET",zone="eu-west"}` + if got != want { + t.Fatalf("BuildSelector() = %q, want %q", got, want) + } +} + +func TestNormalizeURL(t *testing.T) { + got, err := NormalizeURL("prometheus.monitoring:9090/") + if err != nil { + t.Fatalf("NormalizeURL() error = %v", err) + } + if got != "http://prometheus.monitoring:9090" { + t.Fatalf("NormalizeURL() = %q", got) + } + + for _, value := range []string{"", "ftp://prometheus", "http:///missing-host", "http://prometheus?x=1"} { + if _, err := NormalizeURL(value); err == nil { + t.Fatalf("NormalizeURL(%q) returned nil error", value) + } + } +} + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { + return f(request) +} + +func testClient(t *testing.T, baseURL string, response func(*http.Request) string) *Client { + t.Helper() + httpClient := testHTTPClient(response) + client, err := NewClientWithHTTP(baseURL, httpClient) + if err != nil { + t.Fatalf("NewClientWithHTTP() error = %v", err) + } + return client +} + +func testMimirClient(t *testing.T, baseURL string, response func(*http.Request) string) *Client { + t.Helper() + httpClient := testHTTPClient(response) + client, err := NewMimirClientWithHTTP(baseURL, httpClient) + if err != nil { + t.Fatalf("NewMimirClientWithHTTP() error = %v", err) + } + return client +} + +func testHTTPClient(response func(*http.Request) string) *http.Client { + httpClient := &http.Client{ + Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { + body := response(request) + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(body)), + Request: request, + }, nil + }), + } + return httpClient +} diff --git a/internal/tui/metrics.go b/internal/tui/metrics.go new file mode 100644 index 0000000..701d7a1 --- /dev/null +++ b/internal/tui/metrics.go @@ -0,0 +1,1235 @@ +package tui + +import ( + "context" + "errors" + "fmt" + "io" + "math" + "regexp" + "sort" + "strings" + "time" + "unicode/utf8" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/guptarohit/asciigraph" + + "github.com/kedify/cli/internal/prometheus" +) + +const ( + metricsPane = iota + labelsPane + valuesPane +) + +const ( + browseStage = iota + editStage + previewStage + horizonStage + graphLoadingStage + graphStage + targetsStage +) + +type metricHorizon struct { + argument string + label string + duration time.Duration + step time.Duration +} + +var metricHorizons = [...]metricHorizon{ + {argument: "6h", label: "last 6 hours", duration: 6 * time.Hour, step: time.Minute}, + {argument: "1d", label: "last day", duration: 24 * time.Hour, step: 5 * time.Minute}, + {argument: "3d", label: "last 3 days", duration: 3 * 24 * time.Hour, step: 15 * time.Minute}, + {argument: "1w", label: "last week", duration: 7 * 24 * time.Hour, step: 30 * time.Minute}, + {argument: "30d", label: "last month", duration: 30 * 24 * time.Hour, step: 2 * time.Hour}, +} + +var ( + promQLIdentifierPattern = regexp.MustCompile(`[a-zA-Z_:][a-zA-Z0-9_:]*`) + promQLLabelListPattern = regexp.MustCompile(`(?i)\b(?:by|without|on|ignoring|group_left|group_right)\s*\([^)]*\)`) +) + +var ( + metricsTitleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("86")) + metricsActiveStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("86")) + metricsSelectedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("229")).Background(lipgloss.Color("62")) + metricsHintStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241")) + metricsErrorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("196")) + metricsQueryStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("229")) +) + +type MetricsClient interface { + LabelNames(context.Context, string) ([]string, error) + LabelValues(context.Context, string, string) ([]string, error) + ValidateQuery(context.Context, string) error + RangeQuery(context.Context, string, time.Time, time.Time, time.Duration) ([]prometheus.Series, error) +} + +type MetricsResult struct { + Query string + GenerateScaledObject bool + GenerateMetricPredictor bool + CreateResources bool +} + +type MetricsExplorerOptions struct { + Filter string + Query string + Visualize bool + Horizon string +} + +type metricsModel struct { + client MetricsClient + + metrics []string + filtered []string + metricCursor int + labels []string + labelCursor int + labelValues []string + valueCursor int + selected map[string]string + pane int + stage int + filter string + valueFilter string + searchMode bool + loading bool + err string + editor lineEditor + previewCursor int + horizonCursor int + autoVisualize bool + autoGraph bool + graph string + targets [3]bool + targetCursor int + width int + height int + done bool + quit bool +} + +type labelsLoadedMsg struct { + metric string + labels []string + err error +} + +type valuesLoadedMsg struct { + label string + values []string + err error +} + +type queryValidatedMsg struct { + err error +} + +type graphLoadedMsg struct { + graph string + err error +} + +type lineEditor struct { + value []rune + cursor int +} + +func RunMetricsExplorer( + stdin io.Reader, + stdout, stderr io.Writer, + client MetricsClient, + metrics []string, + options MetricsExplorerOptions, +) (MetricsResult, error) { + file, err := interactiveFile(stdin) + if err != nil { + return MetricsResult{}, err + } + if len(metrics) == 0 && strings.TrimSpace(options.Query) == "" { + return MetricsResult{}, errors.New("Prometheus returned no metrics") + } + + model, err := newMetricsModelWithOptions(client, metrics, options) + if err != nil { + return MetricsResult{}, err + } + result, err := tea.NewProgram( + model, + tea.WithInput(file), + tea.WithOutput(promptOutput(stdout, stderr)), + ).Run() + if err != nil { + return MetricsResult{}, fmt.Errorf("run metrics explorer: %w", err) + } + + finalModel, ok := result.(metricsModel) + if !ok { + return MetricsResult{}, errors.New("unexpected metrics explorer state") + } + if finalModel.quit { + return MetricsResult{}, errors.New("metrics exploration canceled") + } + if !finalModel.done { + return MetricsResult{}, errors.New("metrics exploration did not finish") + } + + return MetricsResult{ + Query: strings.TrimSpace(finalModel.editor.String()), + GenerateScaledObject: finalModel.targets[0], + GenerateMetricPredictor: finalModel.targets[1], + CreateResources: finalModel.targets[2], + }, nil +} + +func newMetricsModel(client MetricsClient, metricNames []string) metricsModel { + model, _ := newMetricsModelWithOptions(client, metricNames, MetricsExplorerOptions{}) + return model +} + +func newMetricsModelWithOptions( + client MetricsClient, + metricNames []string, + options MetricsExplorerOptions, +) (metricsModel, error) { + metrics := append([]string(nil), metricNames...) + sort.Strings(metrics) + horizonCursor, err := metricHorizonIndex(options.Horizon) + if err != nil { + return metricsModel{}, err + } + model := metricsModel{ + client: client, + metrics: metrics, + selected: make(map[string]string), + filter: options.Filter, + horizonCursor: horizonCursor, + autoVisualize: options.Visualize, + autoGraph: strings.TrimSpace(options.Horizon) != "", + width: 100, + height: 30, + } + model.filterMetricNames() + model.loading = model.currentMetric() != "" + model.resetEditor() + if query := strings.TrimSpace(options.Query); query != "" { + model.editor.Set(query) + model.stage = editStage + model.loading = true + } + return model, nil +} + +func (m metricsModel) Init() tea.Cmd { + if m.stage == editStage && m.loading { + return m.validateQuery(strings.TrimSpace(m.editor.String())) + } + return m.loadLabels() +} + +func (m metricsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + return m, nil + case labelsLoadedMsg: + if msg.metric != m.currentMetric() { + return m, nil + } + if m.stage == browseStage { + m.loading = false + } + if msg.err != nil { + m.err = msg.err.Error() + m.labels = nil + return m, nil + } + m.labels = msg.labels + m.labelCursor = clampCursor(m.labelCursor, len(m.labels)) + m.err = "" + return m, nil + case valuesLoadedMsg: + if m.stage != browseStage || m.pane != valuesPane || m.currentLabel() != msg.label { + return m, nil + } + m.loading = false + if msg.err != nil { + m.err = msg.err.Error() + m.labelValues = nil + return m, nil + } + m.labelValues = msg.values + m.valueCursor = clampCursor(m.valueCursor, len(m.filteredValues())) + m.err = "" + return m, nil + case queryValidatedMsg: + if m.stage != editStage || !m.loading { + return m, nil + } + m.loading = false + if msg.err != nil { + m.err = msg.err.Error() + return m, nil + } + m.err = "" + m.previewCursor = 0 + if m.autoGraph { + return m.startGraph() + } + if m.autoVisualize { + m.stage = horizonStage + return m, nil + } + m.stage = previewStage + return m, nil + case graphLoadedMsg: + if m.stage != graphLoadingStage || !m.loading { + return m, nil + } + m.loading = false + if msg.err != nil { + m.err = msg.err.Error() + m.stage = horizonStage + return m, nil + } + m.graph = msg.graph + m.err = "" + m.stage = graphStage + return m, nil + case tea.KeyMsg: + if msg.Type == tea.KeyCtrlC { + m.quit = true + return m, tea.Quit + } + switch m.stage { + case browseStage: + return m.updateBrowse(msg) + case editStage: + return m.updateEdit(msg) + case previewStage: + return m.updatePreview(msg) + case horizonStage: + return m.updateHorizon(msg) + case graphLoadingStage: + if msg.Type == tea.KeyEsc { + m.stage = horizonStage + m.loading = false + } + return m, nil + case graphStage: + return m.updateGraph(msg) + case targetsStage: + return m.updateTargets(msg) + } + } + return m, nil +} + +func (m metricsModel) updateBrowse(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if msg.Type == tea.KeyCtrlE { + m.stage = editStage + m.loading = false + m.err = "" + return m, nil + } + + if m.searchMode { + switch msg.Type { + case tea.KeyEsc, tea.KeyEnter: + m.searchMode = false + return m, nil + case tea.KeyBackspace: + if m.pane == valuesPane { + m.valueFilter = removeLastRune(m.valueFilter) + m.valueCursor = 0 + return m, nil + } + m.filter = removeLastRune(m.filter) + return m.applyMetricFilter() + default: + if msg.Type == tea.KeyRunes { + if m.pane == valuesPane { + m.valueFilter += string(msg.Runes) + m.valueCursor = 0 + return m, nil + } + m.filter += string(msg.Runes) + return m.applyMetricFilter() + } + } + return m, nil + } + + if m.pane == metricsPane { + switch msg.Type { + case tea.KeyRunes: + m.filter += string(msg.Runes) + return m.applyMetricFilter() + case tea.KeyBackspace: + if m.filter == "" { + return m, nil + } + m.filter = removeLastRune(m.filter) + return m.applyMetricFilter() + } + } + + switch msg.String() { + case "ctrl+c": + m.quit = true + return m, tea.Quit + case "/": + if m.pane == valuesPane { + m.searchMode = true + } + case "tab": + if m.pane == metricsPane && len(m.labels) > 0 { + m.pane = labelsPane + } else { + m.pane = metricsPane + } + case "left", "h", "esc": + if m.pane == valuesPane { + m.pane = labelsPane + m.valueFilter = "" + m.err = "" + } else if m.pane == labelsPane { + m.pane = metricsPane + } else if m.filter != "" { + m.filter = "" + return m.applyMetricFilter() + } + case "right", "l", "enter": + switch m.pane { + case metricsPane: + if len(m.labels) > 0 { + m.pane = labelsPane + } + case labelsPane: + if m.currentLabel() != "" { + m.pane = valuesPane + m.valueCursor = 0 + m.valueFilter = "" + m.labelValues = nil + m.loading = true + m.err = "" + return m, m.loadValues() + } + case valuesPane: + values := m.filteredValues() + if len(values) > 0 { + m.selected[m.currentLabel()] = values[m.valueCursor] + m.pane = labelsPane + m.valueFilter = "" + m.resetEditor() + } + } + case "up", "k": + switch m.pane { + case metricsPane: + if m.metricCursor > 0 { + m.metricCursor-- + return m.metricChanged() + } + case labelsPane: + if m.labelCursor > 0 { + m.labelCursor-- + } + case valuesPane: + if m.valueCursor > 0 { + m.valueCursor-- + } + } + case "down", "j": + switch m.pane { + case metricsPane: + if m.metricCursor < len(m.filtered)-1 { + m.metricCursor++ + return m.metricChanged() + } + case labelsPane: + if m.labelCursor < len(m.labels)-1 { + m.labelCursor++ + } + case valuesPane: + if m.valueCursor < len(m.filteredValues())-1 { + m.valueCursor++ + } + } + case "pgup": + if m.pane == metricsPane && m.metricCursor > 0 { + m.metricCursor = max(0, m.metricCursor-m.metricPageSize()) + return m.metricChanged() + } + case "pgdown": + if m.pane == metricsPane && m.metricCursor < len(m.filtered)-1 { + m.metricCursor = min(len(m.filtered)-1, m.metricCursor+m.metricPageSize()) + return m.metricChanged() + } + case "backspace", "delete", "x": + if m.pane == labelsPane { + delete(m.selected, m.currentLabel()) + m.resetEditor() + } + } + return m, nil +} + +func (m metricsModel) updateEdit(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if m.loading { + if msg.Type == tea.KeyEsc { + m.loading = false + m.stage = browseStage + } + return m, nil + } + + switch msg.Type { + case tea.KeyEsc: + m.stage = browseStage + m.err = "" + return m, nil + case tea.KeyEnter: + query := strings.TrimSpace(m.editor.String()) + if query == "" { + m.err = "PromQL query cannot be empty." + return m, nil + } + m.loading = true + m.err = "" + return m, m.validateQuery(query) + default: + m.editor.Update(msg) + m.err = "" + return m, nil + } +} + +func (m metricsModel) updatePreview(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "esc", "ctrl+e": + m.stage = editStage + m.err = "" + case "up", "down", "j", "k", "tab": + if m.previewCursor == 0 { + m.previewCursor = 1 + } else { + m.previewCursor = 0 + } + case "g": + m.previewCursor = 0 + m.stage = horizonStage + case "c": + m.previewCursor = 1 + m.stage = targetsStage + case "enter": + if m.previewCursor == 0 { + m.stage = horizonStage + return m, nil + } + m.stage = targetsStage + } + return m, nil +} + +func (m metricsModel) updateHorizon(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "esc": + m.stage = previewStage + m.err = "" + case "ctrl+e": + m.stage = editStage + m.err = "" + case "up", "k": + if m.horizonCursor > 0 { + m.horizonCursor-- + } + case "down", "j": + if m.horizonCursor < len(metricHorizons)-1 { + m.horizonCursor++ + } + case "enter": + return m.startGraph() + } + return m, nil +} + +func (m metricsModel) updateGraph(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "esc": + m.stage = horizonStage + case "ctrl+e": + m.stage = editStage + case "enter", "c": + m.stage = targetsStage + } + return m, nil +} + +func (m metricsModel) updateTargets(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + m.err = "" + switch msg.String() { + case "esc", "ctrl+e": + m.stage = editStage + case "up", "k": + if m.targetCursor > 0 { + m.targetCursor-- + } + case "down", "j": + if m.targetCursor < len(m.targets)-1 { + m.targetCursor++ + } + case " ": + m.targets[m.targetCursor] = !m.targets[m.targetCursor] + case "enter": + if m.targets[2] && !m.targets[0] && !m.targets[1] { + m.err = "Select at least one resource manifest before enabling creation." + return m, nil + } + m.done = true + return m, tea.Quit + } + return m, nil +} + +func (m metricsModel) startGraph() (tea.Model, tea.Cmd) { + m.stage = graphLoadingStage + m.loading = true + m.err = "" + query := strings.TrimSpace(m.editor.String()) + width := m.width + horizon := m.currentHorizon() + return m, func() tea.Msg { + end := time.Now() + start := end.Add(-horizon.duration) + series, err := m.client.RangeQuery(context.Background(), query, start, end, horizon.step) + if err != nil { + return graphLoadedMsg{err: err} + } + graph, err := renderMetricGraph(series, width, query) + return graphLoadedMsg{graph: graph, err: err} + } +} + +func (m metricsModel) currentHorizon() metricHorizon { + return metricHorizons[clampCursor(m.horizonCursor, len(metricHorizons))] +} + +func (m metricsModel) loadLabels() tea.Cmd { + metric := m.currentMetric() + if metric == "" { + return nil + } + return func() tea.Msg { + labels, err := m.client.LabelNames(context.Background(), metric) + return labelsLoadedMsg{metric: metric, labels: labels, err: err} + } +} + +func (m metricsModel) loadValues() tea.Cmd { + label := m.currentLabel() + otherLabels := make(map[string]string, len(m.selected)) + for key, value := range m.selected { + if key != label { + otherLabels[key] = value + } + } + selector := prometheus.BuildSelector(m.currentMetric(), otherLabels) + return func() tea.Msg { + values, err := m.client.LabelValues(context.Background(), label, selector) + return valuesLoadedMsg{label: label, values: values, err: err} + } +} + +func (m metricsModel) validateQuery(query string) tea.Cmd { + return func() tea.Msg { + return queryValidatedMsg{err: m.client.ValidateQuery(context.Background(), query)} + } +} + +func (m metricsModel) metricChanged() (tea.Model, tea.Cmd) { + m.labels = nil + m.labelValues = nil + m.labelCursor = 0 + m.valueCursor = 0 + m.selected = make(map[string]string) + m.loading = m.currentMetric() != "" + m.err = "" + m.resetEditor() + if !m.loading { + return m, nil + } + return m, m.loadLabels() +} + +func (m metricsModel) applyMetricFilter() (tea.Model, tea.Cmd) { + m.filterMetricNames() + m.metricCursor = 0 + return m.metricChanged() +} + +func (m *metricsModel) filterMetricNames() { + needle := strings.ToLower(m.filter) + filtered := make([]string, 0, len(m.metrics)) + for _, metric := range m.metrics { + if strings.Contains(strings.ToLower(metric), needle) { + filtered = append(filtered, metric) + } + } + m.filtered = filtered +} + +func (m metricsModel) metricPageSize() int { + return max(5, m.height-9) +} + +func (m *metricsModel) resetEditor() { + query := "" + if metric := m.currentMetric(); metric != "" { + query = "sum(" + prometheus.BuildSelector(metric, m.selected) + ")" + } + m.editor.Set(query) +} + +func (m metricsModel) currentMetric() string { + if m.metricCursor < 0 || m.metricCursor >= len(m.filtered) { + return "" + } + return m.filtered[m.metricCursor] +} + +func (m metricsModel) currentLabel() string { + if m.labelCursor < 0 || m.labelCursor >= len(m.labels) { + return "" + } + return m.labels[m.labelCursor] +} + +func (m metricsModel) filteredValues() []string { + if m.valueFilter == "" { + return m.labelValues + } + needle := strings.ToLower(m.valueFilter) + values := make([]string, 0, len(m.labelValues)) + for _, value := range m.labelValues { + if strings.Contains(strings.ToLower(value), needle) { + values = append(values, value) + } + } + return values +} + +func (m metricsModel) View() string { + if m.done { + return "\n" + } + switch m.stage { + case browseStage: + return m.browseView() + case editStage: + return m.editView() + case previewStage: + return m.previewView() + case horizonStage: + return m.horizonView() + case graphLoadingStage: + horizon := m.currentHorizon() + return m.loadingView(fmt.Sprintf("Loading samples from the %s at a %s step…", horizon.label, metricStepLabel(horizon.step))) + case graphStage: + return m.graphView() + case targetsStage: + return m.targetsView() + default: + return "" + } +} + +func (m metricsModel) browseView() string { + width := max(m.width, 60) + leftWidth := max(28, width/2-2) + rightWidth := max(28, width-leftWidth-3) + listHeight := max(5, m.height-9) + + leftTitle := "Metrics" + if m.pane == metricsPane { + leftTitle = metricsActiveStyle.Render(leftTitle) + } + filter := m.filter + if filter == "" { + filter = "type to filter" + } else { + filter = "filter: " + filter + "█" + } + + var left strings.Builder + left.WriteString(leftTitle) + left.WriteString(" ") + left.WriteString(metricsHintStyle.Render(filter)) + left.WriteString("\n\n") + left.WriteString(renderList(m.filtered, m.metricCursor, listHeight, leftWidth, m.pane == metricsPane)) + + var right strings.Builder + rightTitle := "Labels" + if m.pane == labelsPane { + rightTitle = metricsActiveStyle.Render(rightTitle) + } + if m.pane == valuesPane { + rightTitle = metricsActiveStyle.Render("Values: " + m.currentLabel()) + } + right.WriteString(rightTitle) + right.WriteString("\n\n") + if m.loading { + right.WriteString(metricsHintStyle.Render("Loading…")) + } else if m.pane == valuesPane { + values := m.filteredValues() + valueFilter := m.valueFilter + if m.searchMode { + valueFilter += "█" + } + if valueFilter != "" { + right.WriteString(metricsHintStyle.Render("/ " + valueFilter)) + right.WriteString("\n") + } + right.WriteString(renderList(values, m.valueCursor, listHeight-1, rightWidth, true)) + } else if len(m.labels) == 0 { + right.WriteString(metricsHintStyle.Render("No labels found.")) + } else { + rows := make([]string, len(m.labels)) + for index, label := range m.labels { + rows[index] = label + if value, ok := m.selected[label]; ok { + rows[index] = "✓ " + label + "=" + value + } + } + right.WriteString(renderList(rows, m.labelCursor, listHeight, rightWidth, m.pane == labelsPane)) + } + + leftPanel := lipgloss.NewStyle().Width(leftWidth).MaxWidth(leftWidth).Render(left.String()) + rightPanel := lipgloss.NewStyle(). + BorderLeft(true). + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("238")). + PaddingLeft(1). + Width(rightWidth). + MaxWidth(rightWidth). + Render(right.String()) + + var output strings.Builder + output.WriteString(metricsTitleStyle.Render("Kedify Metrics Explorer")) + output.WriteString("\n") + output.WriteString(metricsHintStyle.Render("Build a selector, then edit and validate the final PromQL expression.")) + output.WriteString("\n\n") + output.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, leftPanel, rightPanel)) + output.WriteString("\n\n") + output.WriteString(metricsQueryStyle.Render(m.editor.String())) + output.WriteString("\n") + if m.err != "" { + output.WriteString(metricsErrorStyle.Render(m.err)) + output.WriteString("\n") + } + output.WriteString(metricsHintStyle.Render("↑/↓/PgUp/PgDn move • type to filter • Backspace edit • Esc clear • →/Enter drill down • Ctrl+E edit query")) + output.WriteString("\n") + return output.String() +} + +func (m metricsModel) editView() string { + query := m.editor.View() + var output strings.Builder + output.WriteString(metricsTitleStyle.Render("Edit PromQL")) + output.WriteString("\n") + output.WriteString(metricsHintStyle.Render("The query is checked with Prometheus before you continue.")) + output.WriteString("\n\n") + output.WriteString(metricsQueryStyle.Render(query)) + output.WriteString("\n\n") + if m.loading { + output.WriteString(metricsHintStyle.Render("Validating query…")) + output.WriteString("\n") + } + if m.err != "" { + output.WriteString(metricsErrorStyle.Render(m.err)) + output.WriteString("\n") + } + output.WriteString(metricsHintStyle.Render("Enter validate • ←/→ move cursor • Esc return to metrics • Ctrl+C cancel")) + output.WriteString("\n") + return output.String() +} + +func (m metricsModel) previewView() string { + options := []string{"Visualize metric", "Continue to resource YAML"} + var output strings.Builder + output.WriteString(metricsTitleStyle.Render("PromQL is valid")) + output.WriteString("\n\n") + output.WriteString(metricsQueryStyle.Render(m.editor.String())) + output.WriteString("\n\n") + for index, option := range options { + prefix := " " + style := lipgloss.NewStyle() + if index == m.previewCursor { + prefix = "› " + style = metricsSelectedStyle + } + output.WriteString(style.Render(prefix + option)) + output.WriteString("\n") + } + if m.err != "" { + output.WriteString("\n") + output.WriteString(metricsErrorStyle.Render(m.err)) + output.WriteString("\n") + } + output.WriteString("\n") + output.WriteString(metricsHintStyle.Render("↑/↓ choose • Enter continue • Ctrl+E edit")) + output.WriteString("\n") + return output.String() +} + +func (m metricsModel) horizonView() string { + var output strings.Builder + output.WriteString(metricsTitleStyle.Render("Select a time horizon")) + output.WriteString("\n") + output.WriteString(metricsHintStyle.Render("The query step is adjusted to keep the terminal graph responsive.")) + output.WriteString("\n\n") + for index, horizon := range metricHorizons { + line := fmt.Sprintf("%s (%s step)", horizon.label, metricStepLabel(horizon.step)) + if index == m.horizonCursor { + output.WriteString(metricsSelectedStyle.Render("› " + line)) + } else { + output.WriteString(" " + line) + } + output.WriteString("\n") + } + if m.err != "" { + output.WriteString("\n") + output.WriteString(metricsErrorStyle.Render(m.err)) + output.WriteString("\n") + } + output.WriteString("\n") + output.WriteString(metricsHintStyle.Render("↑/↓ choose • Enter visualize • Esc return • Ctrl+E edit query")) + output.WriteString("\n") + return output.String() +} + +func (m metricsModel) loadingView(message string) string { + return metricsTitleStyle.Render("Kedify Metrics Explorer") + "\n\n" + + metricsHintStyle.Render(message) + "\n" + + metricsHintStyle.Render("Esc to return.") + "\n" +} + +func (m metricsModel) graphView() string { + var output strings.Builder + output.WriteString(metricsTitleStyle.Render("Metric preview — " + m.currentHorizon().label)) + output.WriteString("\n\n") + output.WriteString(m.graph) + output.WriteString("\n") + output.WriteString(metricsHintStyle.Render("Enter continue • Ctrl+E edit query • Esc return")) + output.WriteString("\n") + return output.String() +} + +func (m metricsModel) targetsView() string { + options := []string{ + "generate ScaledObject YAML", + "generate MetricPredictor YAML", + "create selected resources in the cluster", + } + var output strings.Builder + output.WriteString(metricsTitleStyle.Render("Generate Kubernetes resources")) + output.WriteString("\n") + output.WriteString(metricsHintStyle.Render("YAML is printed to stdout. Cluster creation is opt-in and disabled by default.")) + output.WriteString("\n\n") + for index, option := range options { + check := "[ ]" + if m.targets[index] { + check = "[x]" + } + line := check + " " + option + if index == m.targetCursor { + line = "› " + line + output.WriteString(metricsSelectedStyle.Render(line)) + } else { + output.WriteString(" " + line) + } + output.WriteString("\n") + } + if m.err != "" { + output.WriteString("\n") + output.WriteString(metricsErrorStyle.Render(m.err)) + output.WriteString("\n") + } + output.WriteString("\n") + output.WriteString(metricsHintStyle.Render("↑/↓ move • Space toggle • Enter finish • Ctrl+E edit query")) + output.WriteString("\n") + return output.String() +} + +func (e *lineEditor) Set(value string) { + e.value = []rune(value) + e.cursor = len(e.value) +} + +func (e *lineEditor) String() string { + return string(e.value) +} + +func (e *lineEditor) Update(msg tea.KeyMsg) { + switch msg.Type { + case tea.KeyLeft: + if e.cursor > 0 { + e.cursor-- + } + case tea.KeyRight: + if e.cursor < len(e.value) { + e.cursor++ + } + case tea.KeyHome, tea.KeyCtrlA: + e.cursor = 0 + case tea.KeyEnd, tea.KeyCtrlE: + e.cursor = len(e.value) + case tea.KeyBackspace: + if e.cursor > 0 { + e.value = append(e.value[:e.cursor-1], e.value[e.cursor:]...) + e.cursor-- + } + case tea.KeyDelete: + if e.cursor < len(e.value) { + e.value = append(e.value[:e.cursor], e.value[e.cursor+1:]...) + } + default: + if msg.Type == tea.KeyRunes { + e.value = insertRunes(e.value, e.cursor, msg.Runes) + e.cursor += len(msg.Runes) + } + } +} + +func (e *lineEditor) View() string { + before := string(e.value[:e.cursor]) + after := string(e.value[e.cursor:]) + cursor := " " + if after != "" { + r, size := utf8.DecodeRuneInString(after) + cursor = string(r) + after = after[size:] + } + return before + promptCursorStyle.Render(cursor) + after +} + +func renderList(items []string, cursor, height, width int, active bool) string { + if len(items) == 0 { + return metricsHintStyle.Render("No matches.") + } + height = max(height, 1) + start := max(0, cursor-height/2) + if start+height > len(items) { + start = max(0, len(items)-height) + } + end := min(len(items), start+height) + + var output strings.Builder + for index := start; index < end; index++ { + prefix := " " + style := lipgloss.NewStyle() + if index == cursor { + prefix = "› " + if active { + style = metricsSelectedStyle + } else { + style = metricsActiveStyle + } + } + line := truncateText(prefix+items[index], width-1) + output.WriteString(style.Render(line)) + if index < end-1 { + output.WriteString("\n") + } + } + return output.String() +} + +func renderMetricGraph(series []prometheus.Series, terminalWidth int, query string) (string, error) { + if len(series) == 0 { + return "", errors.New("the query returned no samples for the selected time horizon") + } + + const ( + maxSeries = 4 + bytesPerMiB = 1024 * 1024 + ) + queryUsesMemoryMetric := queryContainsMemoryMetric(query) + var output strings.Builder + limit := min(len(series), maxSeries) + for index := 0; index < limit; index++ { + memoryValues := queryUsesMemoryMetric || + strings.Contains(strings.ToLower(series[index].Labels["__name__"]), "memory") + values := make([]float64, 0, len(series[index].Points)) + for _, point := range series[index].Points { + if !math.IsNaN(point.Value) && !math.IsInf(point.Value, 0) { + value := point.Value + if memoryValues { + value /= bytesPerMiB + } + values = append(values, value) + } + } + if len(values) == 0 { + continue + } + if output.Len() > 0 { + output.WriteString("\n") + } + caption := seriesCaption(series[index].Labels) + if memoryValues { + caption += " (MiB)" + } + output.WriteString(caption) + output.WriteString("\n") + graphWidth := min(max(terminalWidth-14, 30), 120) + output.WriteString(asciigraph.Plot( + values, + asciigraph.Height(10), + asciigraph.Width(graphWidth), + asciigraph.SeriesColors(asciigraph.Cyan), + )) + output.WriteString("\n") + } + if output.Len() == 0 { + return "", errors.New("the query returned no numeric samples for the selected time horizon") + } + if len(series) > maxSeries { + output.WriteString(metricsHintStyle.Render(fmt.Sprintf("Showing %d of %d series.", maxSeries, len(series)))) + output.WriteString("\n") + } + return output.String(), nil +} + +func queryContainsMemoryMetric(query string) bool { + query = stripPromQLStringLiterals(query) + query = promQLLabelListPattern.ReplaceAllStringFunc(query, func(value string) string { + return strings.Repeat(" ", len(value)) + }) + for _, match := range promQLIdentifierPattern.FindAllStringIndex(query, -1) { + identifier := strings.ToLower(query[match[0]:match[1]]) + if !strings.Contains(identifier, "memory") { + continue + } + + remainder := strings.TrimLeft(query[match[1]:], " \t\r\n") + if strings.HasPrefix(remainder, "(") || + strings.HasPrefix(remainder, "=") || + strings.HasPrefix(remainder, "!=") || + strings.HasPrefix(remainder, "=~") || + strings.HasPrefix(remainder, "!~") { + continue + } + return true + } + return false +} + +func stripPromQLStringLiterals(query string) string { + runes := []rune(query) + var quote rune + escaped := false + for index, current := range runes { + if quote == 0 { + if current == '"' || current == '\'' || current == '`' { + quote = current + runes[index] = ' ' + } + continue + } + + runes[index] = ' ' + if escaped { + escaped = false + continue + } + if current == '\\' && quote != '`' { + escaped = true + continue + } + if current == quote { + quote = 0 + } + } + return string(runes) +} + +func seriesCaption(labels map[string]string) string { + name := labels["__name__"] + keys := make([]string, 0, len(labels)) + for key := range labels { + if key != "__name__" { + keys = append(keys, key) + } + } + sort.Strings(keys) + parts := make([]string, 0, len(keys)) + for _, key := range keys { + parts = append(parts, key+"="+labels[key]) + } + if len(parts) == 0 { + if name == "" { + return "" + } + return name + } + return name + "{" + strings.Join(parts, ",") + "}" +} + +func truncateText(value string, width int) string { + if width < 2 { + return value + } + runes := []rune(value) + if len(runes) <= width { + return value + } + return string(runes[:width-1]) + "…" +} + +func removeLastRune(value string) string { + runes := []rune(value) + if len(runes) == 0 { + return "" + } + return string(runes[:len(runes)-1]) +} + +func clampCursor(cursor, length int) int { + if length == 0 { + return 0 + } + return min(max(cursor, 0), length-1) +} + +func metricStepLabel(step time.Duration) string { + if step%time.Hour == 0 { + return fmt.Sprintf("%dh", step/time.Hour) + } + if step%time.Minute == 0 { + return fmt.Sprintf("%dm", step/time.Minute) + } + return step.String() +} + +func metricHorizonIndex(argument string) (int, error) { + argument = strings.TrimSpace(argument) + if argument == "" { + return 0, nil + } + for index, horizon := range metricHorizons { + if horizon.argument == argument { + return index, nil + } + } + return 0, fmt.Errorf("unsupported metric horizon %q", argument) +} diff --git a/internal/tui/metrics_test.go b/internal/tui/metrics_test.go new file mode 100644 index 0000000..2897e40 --- /dev/null +++ b/internal/tui/metrics_test.go @@ -0,0 +1,499 @@ +package tui + +import ( + "context" + "errors" + "fmt" + "reflect" + "strings" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/guptarohit/asciigraph" + + "github.com/kedify/cli/internal/prometheus" +) + +type fakeMetricsClient struct { + labels []string + values []string + validateErr error + rangeSeries []prometheus.Series + rangeErr error + lastSelector string + lastLabel string + lastQuery string + rangeStart time.Time + rangeEnd time.Time + rangeStep time.Duration +} + +func (f *fakeMetricsClient) LabelNames(_ context.Context, selector string) ([]string, error) { + f.lastSelector = selector + return f.labels, nil +} + +func (f *fakeMetricsClient) LabelValues(_ context.Context, label, selector string) ([]string, error) { + f.lastLabel = label + f.lastSelector = selector + return f.values, nil +} + +func (f *fakeMetricsClient) ValidateQuery(_ context.Context, query string) error { + f.lastQuery = query + return f.validateErr +} + +func (f *fakeMetricsClient) RangeQuery( + _ context.Context, + query string, + start, end time.Time, + step time.Duration, +) ([]prometheus.Series, error) { + f.lastQuery = query + f.rangeStart = start + f.rangeEnd = end + f.rangeStep = step + return f.rangeSeries, f.rangeErr +} + +func TestMetricsModelDrillsIntoLabelsAndBuildsQuery(t *testing.T) { + client := &fakeMetricsClient{ + labels: []string{"method", "zone"}, + values: []string{"GET", "POST"}, + } + model := newMetricsModel(client, []string{"up", "http_requests_total"}) + + initMsg := model.Init()() + loaded := initMsg.(labelsLoadedMsg) + if loaded.metric != "http_requests_total" { + t.Fatalf("initial metric = %q", loaded.metric) + } + updated, _ := model.Update(initMsg) + model = updated.(metricsModel) + + updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyRight}) + model = updated.(metricsModel) + if model.pane != labelsPane { + t.Fatalf("pane = %d, want labelsPane", model.pane) + } + + updated, cmd := model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + model = updated.(metricsModel) + if model.pane != valuesPane || cmd == nil { + t.Fatalf("value drill-down pane = %d, cmd = %v", model.pane, cmd) + } + valuesMsg := cmd() + updated, _ = model.Update(valuesMsg) + model = updated.(metricsModel) + + updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + model = updated.(metricsModel) + if model.selected["method"] != "GET" { + t.Fatalf("selected labels = %#v", model.selected) + } + if got := model.editor.String(); got != `sum(http_requests_total{method="GET"})` { + t.Fatalf("query = %q", got) + } + + updated, cmd = model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + model = updated.(metricsModel) + _ = cmd() + if client.lastSelector != "http_requests_total" { + t.Fatalf("selector for changing method = %q, want current label omitted", client.lastSelector) + } +} + +func TestMetricsModelValidatesThenChoosesTargets(t *testing.T) { + client := &fakeMetricsClient{} + model := newMetricsModel(client, []string{"up"}) + model.stage = editStage + model.loading = false + + updated, cmd := model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + model = updated.(metricsModel) + if cmd == nil || !model.loading { + t.Fatalf("validation cmd = %v, loading = %v", cmd, model.loading) + } + queryWhileValidating := model.editor.String() + updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("x")}) + model = updated.(metricsModel) + if model.editor.String() != queryWhileValidating { + t.Fatal("query changed while validation was in flight") + } + updated, _ = model.Update(cmd()) + model = updated.(metricsModel) + if model.stage != previewStage { + t.Fatalf("stage = %d, want previewStage", model.stage) + } + + model.previewCursor = 1 + updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + model = updated.(metricsModel) + if model.stage != targetsStage { + t.Fatalf("stage = %d, want targetsStage", model.stage) + } + + updated, _ = model.Update(tea.KeyMsg{Type: tea.KeySpace}) + model = updated.(metricsModel) + if !model.targets[0] { + t.Fatal("ScaledObject target was not selected") + } + updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyDown}) + model = updated.(metricsModel) + updated, _ = model.Update(tea.KeyMsg{Type: tea.KeySpace}) + model = updated.(metricsModel) + if !model.targets[1] { + t.Fatal("MetricPredictor target was not selected") + } + if model.targets[2] { + t.Fatal("resource creation must be disabled by default") + } + updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyDown}) + model = updated.(metricsModel) + updated, _ = model.Update(tea.KeyMsg{Type: tea.KeySpace}) + model = updated.(metricsModel) + if !model.targets[2] { + t.Fatal("cluster creation target was not selected") + } +} + +func TestMetricsModelRequiresManifestForClusterCreation(t *testing.T) { + model := newMetricsModel(&fakeMetricsClient{}, []string{"up"}) + model.stage = targetsStage + model.loading = false + model.targetCursor = 2 + + updated, _ := model.Update(tea.KeyMsg{Type: tea.KeySpace}) + model = updated.(metricsModel) + updated, cmd := model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + model = updated.(metricsModel) + if cmd != nil || model.done { + t.Fatal("model finished with creation selected but no resource manifest") + } + if !strings.Contains(model.err, "Select at least one") { + t.Fatalf("error = %q", model.err) + } +} + +func TestMetricsModelKeepsValidationErrorsInEditor(t *testing.T) { + client := &fakeMetricsClient{validateErr: errors.New("parse error")} + model := newMetricsModel(client, []string{"up"}) + model.stage = editStage + model.loading = false + + updated, cmd := model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + model = updated.(metricsModel) + updated, _ = model.Update(cmd()) + model = updated.(metricsModel) + if model.stage != editStage || model.err != "parse error" { + t.Fatalf("stage = %d, error = %q", model.stage, model.err) + } +} + +func TestMetricsModelFiltersByTypedSubstringAndUsesCtrlEForEditing(t *testing.T) { + model := newMetricsModel(&fakeMetricsClient{}, []string{ + "errors_total", + "http_requests_total", + "node_errors_total", + "up", + }) + model.loading = false + + updated, _ := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("E")}) + model = updated.(metricsModel) + if model.filter != "E" { + t.Fatalf("filter = %q, want E", model.filter) + } + want := []string{"errors_total", "http_requests_total", "node_errors_total"} + if !reflect.DeepEqual(model.filtered, want) { + t.Fatalf("filtered metrics = %#v, want %#v", model.filtered, want) + } + if model.stage != browseStage { + t.Fatalf("typing E changed stage to %d", model.stage) + } + + updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyCtrlE}) + model = updated.(metricsModel) + if model.stage != editStage { + t.Fatalf("Ctrl+E stage = %d, want editStage", model.stage) + } +} + +func TestMetricsModelSeedsAnEditableFilter(t *testing.T) { + model, err := newMetricsModelWithOptions( + &fakeMetricsClient{}, + []string{"cpu_usage", "memory", "memory_usage"}, + MetricsExplorerOptions{Filter: "memory_"}, + ) + if err != nil { + t.Fatalf("newMetricsModelWithOptions() error = %v", err) + } + if model.filter != "memory_" { + t.Fatalf("filter = %q, want memory_", model.filter) + } + if want := []string{"memory_usage"}; !reflect.DeepEqual(model.filtered, want) { + t.Fatalf("filtered metrics = %#v, want %#v", model.filtered, want) + } + + updated, _ := model.Update(tea.KeyMsg{Type: tea.KeyBackspace}) + model = updated.(metricsModel) + if model.filter != "memory" { + t.Fatalf("filter after Backspace = %q, want memory", model.filter) + } + if want := []string{"memory", "memory_usage"}; !reflect.DeepEqual(model.filtered, want) { + t.Fatalf("filtered metrics after Backspace = %#v, want %#v", model.filtered, want) + } +} + +func TestMetricsModelStartsAtRequestedQueryStage(t *testing.T) { + tests := []struct { + name string + options MetricsExplorerOptions + wantStage int + wantGraph bool + }{ + { + name: "query asks whether to visualize", + options: MetricsExplorerOptions{Query: "sum(foobar)"}, + wantStage: previewStage, + }, + { + name: "visualize asks for horizon", + options: MetricsExplorerOptions{Query: "sum(foobar)", Visualize: true}, + wantStage: horizonStage, + }, + { + name: "horizon loads graph", + options: MetricsExplorerOptions{ + Query: "sum(foobar)", + Visualize: true, + Horizon: "3d", + }, + wantStage: graphLoadingStage, + wantGraph: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + client := &fakeMetricsClient{rangeSeries: []prometheus.Series{{ + Points: []prometheus.Point{{Value: 1}}, + }}} + model, err := newMetricsModelWithOptions(client, nil, test.options) + if err != nil { + t.Fatalf("newMetricsModelWithOptions() error = %v", err) + } + if model.stage != editStage || !model.loading { + t.Fatalf("initial stage = %d, loading = %v", model.stage, model.loading) + } + + validateCmd := model.Init() + if validateCmd == nil { + t.Fatal("initial query validation command is nil") + } + updated, graphCmd := model.Update(validateCmd()) + model = updated.(metricsModel) + if model.stage != test.wantStage { + t.Fatalf("stage after validation = %d, want %d", model.stage, test.wantStage) + } + if client.lastQuery != "sum(foobar)" { + t.Fatalf("validated query = %q, want sum(foobar)", client.lastQuery) + } + if (graphCmd != nil) != test.wantGraph { + t.Fatalf("graph command present = %v, want %v", graphCmd != nil, test.wantGraph) + } + if !test.wantGraph { + return + } + + updated, _ = model.Update(graphCmd()) + model = updated.(metricsModel) + if model.stage != graphStage { + t.Fatalf("stage after graph load = %d, want graphStage", model.stage) + } + if got := client.rangeEnd.Sub(client.rangeStart); got != 3*24*time.Hour { + t.Fatalf("query duration = %s, want 72h", got) + } + if client.rangeStep != 15*time.Minute { + t.Fatalf("query step = %s, want 15m", client.rangeStep) + } + }) + } +} + +func TestMetricsModelRejectsUnsupportedHorizon(t *testing.T) { + _, err := newMetricsModelWithOptions( + &fakeMetricsClient{}, + nil, + MetricsExplorerOptions{Query: "up", Visualize: true, Horizon: "2d"}, + ) + if err == nil || !strings.Contains(err.Error(), "unsupported metric horizon") { + t.Fatalf("newMetricsModelWithOptions() error = %v", err) + } +} + +func TestMetricsModelPageUpAndPageDownMoveAWholePage(t *testing.T) { + metrics := make([]string, 40) + for index := range metrics { + metrics[index] = fmt.Sprintf("metric_%02d", index) + } + model := newMetricsModel(&fakeMetricsClient{}, metrics) + model.height = 20 + model.loading = false + + updated, _ := model.Update(tea.KeyMsg{Type: tea.KeyPgDown}) + model = updated.(metricsModel) + if model.metricCursor != 11 { + t.Fatalf("Page Down cursor = %d, want 11", model.metricCursor) + } + + updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyPgDown}) + model = updated.(metricsModel) + if model.metricCursor != 22 { + t.Fatalf("second Page Down cursor = %d, want 22", model.metricCursor) + } + + updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyPgUp}) + model = updated.(metricsModel) + if model.metricCursor != 11 { + t.Fatalf("Page Up cursor = %d, want 11", model.metricCursor) + } +} + +func TestMetricsModelQueriesSelectedTimeHorizon(t *testing.T) { + tests := []struct { + name string + index int + duration time.Duration + step time.Duration + }{ + {name: "last 6 hours", index: 0, duration: 6 * time.Hour, step: time.Minute}, + {name: "last day", index: 1, duration: 24 * time.Hour, step: 5 * time.Minute}, + {name: "last 3 days", index: 2, duration: 3 * 24 * time.Hour, step: 15 * time.Minute}, + {name: "last week", index: 3, duration: 7 * 24 * time.Hour, step: 30 * time.Minute}, + {name: "last month", index: 4, duration: 30 * 24 * time.Hour, step: 2 * time.Hour}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + client := &fakeMetricsClient{rangeSeries: []prometheus.Series{{ + Points: []prometheus.Point{{Value: 1}}, + }}} + model := newMetricsModel(client, []string{"up"}) + model.stage = horizonStage + model.horizonCursor = test.index + + updated, cmd := model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + model = updated.(metricsModel) + if cmd == nil || model.stage != graphLoadingStage { + t.Fatalf("graph command = %v, stage = %d", cmd, model.stage) + } + updated, _ = model.Update(cmd()) + model = updated.(metricsModel) + + if got := client.rangeEnd.Sub(client.rangeStart); got != test.duration { + t.Fatalf("query duration = %s, want %s", got, test.duration) + } + if client.rangeStep != test.step { + t.Fatalf("query step = %s, want %s", client.rangeStep, test.step) + } + if model.stage != graphStage { + t.Fatalf("stage = %d, want graphStage", model.stage) + } + if !strings.Contains(model.graphView(), test.name) { + t.Fatalf("graph title does not contain %q:\n%s", test.name, model.graphView()) + } + }) + } +} + +func TestMetricsModelSelectsTimeHorizonBeforeLoadingGraph(t *testing.T) { + model := newMetricsModel(&fakeMetricsClient{}, []string{"up"}) + model.stage = previewStage + + updated, cmd := model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + model = updated.(metricsModel) + if cmd != nil || model.stage != horizonStage { + t.Fatalf("graph choice command = %v, stage = %d", cmd, model.stage) + } + + for index, horizon := range metricHorizons { + if !strings.Contains(model.horizonView(), horizon.label) { + t.Fatalf("horizon %d (%q) is missing:\n%s", index, horizon.label, model.horizonView()) + } + } +} + +func TestRenderMetricGraph(t *testing.T) { + graph, err := renderMetricGraph([]prometheus.Series{{ + Labels: map[string]string{"__name__": "up", "job": "prometheus"}, + Points: []prometheus.Point{ + {Value: 1}, + {Value: 2}, + {Value: 1.5}, + }, + }}, 80, "up") + if err != nil { + t.Fatalf("renderMetricGraph() error = %v", err) + } + if !strings.Contains(graph, "up{job=prometheus}") { + t.Fatalf("graph caption missing:\n%s", graph) + } + if !strings.Contains(graph, asciigraph.Cyan.String()) { + t.Fatalf("graph does not use cyan for the series:\n%q", graph) + } +} + +func TestRenderMetricGraphConvertsMemoryBytesToMiB(t *testing.T) { + const mib = 1024 * 1024 + graph, err := renderMetricGraph([]prometheus.Series{{ + Labels: map[string]string{"job": "kubelet"}, + Points: []prometheus.Point{ + {Value: mib}, + {Value: 2 * mib}, + {Value: 1.5 * mib}, + }, + }}, 80, "sum(container_memory_usage_bytes)") + if err != nil { + t.Fatalf("renderMetricGraph() error = %v", err) + } + if !strings.Contains(graph, "(MiB)") { + t.Fatalf("memory unit is missing:\n%s", graph) + } + if !strings.Contains(graph, "1.00") { + t.Fatalf("converted MiB value is missing:\n%s", graph) + } + if strings.Contains(graph, "2097152") { + t.Fatalf("graph still contains unconverted byte values:\n%s", graph) + } +} + +func TestQueryContainsMemoryMetricIgnoresLabelNamesAndValues(t *testing.T) { + tests := []struct { + query string + want bool + }{ + {query: "sum(container_memory_usage_bytes)", want: true}, + {query: "node_Memory_MemAvailable_bytes", want: true}, + {query: `up{job="memory"}`, want: false}, + {query: `up{memory_type="working_set"}`, want: false}, + {query: `sum by (memory_pool) (up)`, want: false}, + } + for _, test := range tests { + if got := queryContainsMemoryMetric(test.query); got != test.want { + t.Fatalf("queryContainsMemoryMetric(%q) = %v, want %v", test.query, got, test.want) + } + } +} + +func TestTextEditorSupportsCursorInsertion(t *testing.T) { + var editor lineEditor + editor.Set("ac") + editor.Update(tea.KeyMsg{Type: tea.KeyLeft}) + editor.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("b")}) + if got := editor.String(); got != "abc" { + t.Fatalf("editor = %q, want abc", got) + } +} diff --git a/internal/tui/prompt.go b/internal/tui/prompt.go new file mode 100644 index 0000000..68ca512 --- /dev/null +++ b/internal/tui/prompt.go @@ -0,0 +1,306 @@ +package tui + +import ( + "errors" + "fmt" + "io" + "os" + "strings" + "unicode/utf8" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/huh" + "github.com/charmbracelet/lipgloss" +) + +var ( + promptTitleStyle = lipgloss.NewStyle().Bold(true).Foreground(tuiCyan) + promptHintStyle = lipgloss.NewStyle().Foreground(tuiMuted) + promptCursorStyle = lipgloss.NewStyle().Reverse(true) + promptSelectedStyle = lipgloss.NewStyle().Bold(true).Foreground(tuiCyan) + promptErrorStyle = lipgloss.NewStyle().Foreground(tuiError) +) + +type selectionModel struct { + title string + hint string + options []string + cursor int + choice int + done bool + quit bool +} + +type textPromptModel struct { + title string + hint string + value []rune + cursor int + validate func(string) error + err string + done bool + quit bool +} + +func Select(stdin io.Reader, stdout, stderr io.Writer, title, hint string, options []string) (int, error) { + file, err := interactiveFile(stdin) + if err != nil { + return 0, err + } + if len(options) == 0 { + return 0, errors.New("no options available") + } + + model := selectionModel{ + title: title, + hint: hint, + options: options, + choice: -1, + } + result, err := tea.NewProgram(model, tea.WithInput(file), tea.WithOutput(promptOutput(stdout, stderr))).Run() + if err != nil { + return 0, fmt.Errorf("run selection prompt: %w", err) + } + finalModel, ok := result.(selectionModel) + if !ok { + return 0, errors.New("unexpected selection prompt state") + } + if finalModel.quit { + return 0, errors.New("selection canceled") + } + if finalModel.choice < 0 { + return 0, errors.New("no option selected") + } + return finalModel.choice, nil +} + +func PromptText( + stdin io.Reader, + stdout, stderr io.Writer, + title, hint, initial string, + validate func(string) error, +) (string, error) { + file, err := interactiveFile(stdin) + if err != nil { + return "", err + } + + value := []rune(initial) + model := textPromptModel{ + title: title, + hint: hint, + value: value, + cursor: len(value), + validate: validate, + } + result, err := tea.NewProgram(model, tea.WithInput(file), tea.WithOutput(promptOutput(stdout, stderr))).Run() + if err != nil { + return "", fmt.Errorf("run text prompt: %w", err) + } + finalModel, ok := result.(textPromptModel) + if !ok { + return "", errors.New("unexpected text prompt state") + } + if finalModel.quit { + return "", errors.New("input canceled") + } + return strings.TrimSpace(string(finalModel.value)), nil +} + +func Confirm(stdin io.Reader, stdout, stderr io.Writer, title, hint string, defaultYes bool) (bool, error) { + file, err := interactiveFile(stdin) + if err != nil { + return false, err + } + + value := defaultYes + confirm := newCyanConfirm(title, hint, &value) + form := huh.NewForm(huh.NewGroup(confirm)). + WithTheme(cyanHuhTheme()). + WithKeyMap(cyanHuhKeyMap()). + WithInput(file). + WithOutput(promptOutput(stdout, stderr)). + WithShowHelp(true) + if err := form.Run(); err != nil { + if errors.Is(err, huh.ErrUserAborted) { + return false, errors.New("confirmation canceled") + } + return false, fmt.Errorf("run confirmation prompt: %w", err) + } + if form.State != huh.StateCompleted { + return false, errors.New("confirmation canceled") + } + return value, nil +} + +func RequireInteractive(stdin io.Reader) error { + _, err := interactiveFile(stdin) + return err +} + +func interactiveFile(stdin io.Reader) (*os.File, error) { + file, ok := stdin.(*os.File) + if !ok || !isInteractive(file) { + return nil, errors.New("metrics requires an interactive terminal") + } + return file, nil +} + +func (m selectionModel) Init() tea.Cmd { + return nil +} + +func (m selectionModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "esc": + m.quit = true + return m, tea.Quit + case "up", "k": + if m.cursor > 0 { + m.cursor-- + } + case "down", "j": + if m.cursor < len(m.options)-1 { + m.cursor++ + } + case "enter": + m.choice = m.cursor + m.done = true + return m, tea.Quit + } + } + return m, nil +} + +func (m selectionModel) View() string { + if m.done { + return "\n" + } + + var output strings.Builder + output.WriteString(promptTitleStyle.Render(m.title)) + output.WriteString("\n") + if m.hint != "" { + output.WriteString(promptHintStyle.Render(m.hint)) + output.WriteString("\n") + } + output.WriteString("\n") + for index, option := range m.options { + prefix := " " + style := lipgloss.NewStyle() + if index == m.cursor { + prefix = "› " + style = promptSelectedStyle + } + output.WriteString(style.Render(prefix + option)) + output.WriteString("\n") + } + output.WriteString("\n") + output.WriteString(promptHintStyle.Render("Use ↑/↓ or j/k, Enter to select, Esc to cancel.")) + output.WriteString("\n") + return output.String() +} + +func (m textPromptModel) Init() tea.Cmd { + return nil +} + +func (m textPromptModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.Type { + case tea.KeyCtrlC, tea.KeyEsc: + m.quit = true + return m, tea.Quit + case tea.KeyEnter: + value := strings.TrimSpace(string(m.value)) + if value == "" { + m.err = "A value is required." + return m, nil + } + if m.validate != nil { + if err := m.validate(value); err != nil { + m.err = err.Error() + return m, nil + } + } + m.done = true + return m, tea.Quit + case tea.KeyLeft: + if m.cursor > 0 { + m.cursor-- + } + case tea.KeyRight: + if m.cursor < len(m.value) { + m.cursor++ + } + case tea.KeyHome, tea.KeyCtrlA: + m.cursor = 0 + case tea.KeyEnd, tea.KeyCtrlE: + m.cursor = len(m.value) + case tea.KeyBackspace: + if m.cursor > 0 { + m.value = append(m.value[:m.cursor-1], m.value[m.cursor:]...) + m.cursor-- + } + case tea.KeyDelete: + if m.cursor < len(m.value) { + m.value = append(m.value[:m.cursor], m.value[m.cursor+1:]...) + } + default: + if msg.Type == tea.KeyRunes { + m.value = insertRunes(m.value, m.cursor, msg.Runes) + m.cursor += len(msg.Runes) + } + } + m.err = "" + } + return m, nil +} + +func (m textPromptModel) View() string { + if m.done { + return "\n" + } + + var output strings.Builder + output.WriteString(promptTitleStyle.Render(m.title)) + output.WriteString("\n") + if m.hint != "" { + output.WriteString(promptHintStyle.Render(m.hint)) + output.WriteString("\n") + } + output.WriteString("\n") + + before := string(m.value[:m.cursor]) + after := string(m.value[m.cursor:]) + cursor := " " + if after != "" { + r, size := utf8.DecodeRuneInString(after) + cursor = string(r) + after = after[size:] + } + output.WriteString(before) + output.WriteString(promptCursorStyle.Render(cursor)) + output.WriteString(after) + output.WriteString("\n") + output.WriteString(promptHintStyle.Render("Enter to continue, Esc to cancel.")) + if m.err != "" { + output.WriteString("\n") + output.WriteString(promptErrorStyle.Render(m.err)) + } + output.WriteString("\n") + return output.String() +} + +func insertRunes(value []rune, index int, runes []rune) []rune { + if len(runes) == 0 { + return value + } + value = append(value, make([]rune, len(runes))...) + copy(value[index+len(runes):], value[index:len(value)-len(runes)]) + copy(value[index:], runes) + return value +} diff --git a/internal/tui/resource_creation.go b/internal/tui/resource_creation.go new file mode 100644 index 0000000..69f0802 --- /dev/null +++ b/internal/tui/resource_creation.go @@ -0,0 +1,161 @@ +package tui + +import ( + "errors" + "fmt" + "io" + "strings" + + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/huh" + "github.com/charmbracelet/lipgloss" +) + +type resourceCreationModel struct { + viewport viewport.Model + confirmation *huh.Confirm + answer *bool + context string + namespace string + confirmed bool + done bool + quit bool + width int + height int +} + +func ConfirmResourceCreation( + stdin io.Reader, + stdout, stderr io.Writer, + manifest, currentContext, namespace string, +) (bool, error) { + file, err := interactiveFile(stdin) + if err != nil { + return false, err + } + + model := newResourceCreationModel(manifest, currentContext, namespace) + result, err := tea.NewProgram( + model, + tea.WithInput(file), + tea.WithOutput(promptOutput(stdout, stderr)), + ).Run() + if err != nil { + return false, fmt.Errorf("run resource creation confirmation: %w", err) + } + + finalModel, ok := result.(resourceCreationModel) + if !ok { + return false, errors.New("unexpected resource creation confirmation state") + } + if finalModel.quit { + return false, errors.New("resource creation confirmation canceled") + } + if !finalModel.done { + return false, errors.New("resource creation confirmation did not finish") + } + return finalModel.confirmed, nil +} + +func newResourceCreationModel(manifest, currentContext, namespace string) resourceCreationModel { + yamlViewport := viewport.New(96, 14) + yamlViewport.Style = lipgloss.NewStyle(). + Border(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("238")). + Padding(0, 1) + yamlViewport.SetContent(manifest) + + answer := false + confirmation := newCyanConfirm( + "Are you sure you want to create these resources in k8s cluster?", + "", + &answer, + ) + confirmation.WithWidth(98) + + return resourceCreationModel{ + viewport: yamlViewport, + confirmation: confirmation, + answer: &answer, + context: currentContext, + namespace: namespace, + width: 100, + height: 30, + } +} + +func (m resourceCreationModel) Init() tea.Cmd { + return nil +} + +func (m resourceCreationModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + m.resizeViewport() + return m, nil + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "esc": + m.quit = true + return m, tea.Quit + case "up", "down", "j", "k", "pgup", "pgdown", "home", "end": + break + default: + updated, cmd := m.confirmation.Update(msg) + m.confirmation = updated.(*huh.Confirm) + switch msg.String() { + case "y", "Y", "n", "N", "enter": + m.confirmed = *m.answer + m.done = true + return m, tea.Quit + } + return m, cmd + } + } + + var cmd tea.Cmd + m.viewport, cmd = m.viewport.Update(msg) + return m, cmd +} + +func (m resourceCreationModel) View() string { + if m.done { + return "\n" + } + + var output strings.Builder + output.WriteString(metricsTitleStyle.Render("Review Kubernetes resources")) + output.WriteString("\n\n") + output.WriteString("Kubernetes context: ") + output.WriteString(metricsQueryStyle.Render(m.context)) + output.WriteString("\n") + output.WriteString("Target namespace: ") + output.WriteString(metricsQueryStyle.Render(m.namespace)) + output.WriteString("\n\n") + output.WriteString(metricsHintStyle.Render("YAML to create:")) + output.WriteString("\n") + output.WriteString(m.viewport.View()) + if m.viewport.TotalLineCount() > m.viewport.VisibleLineCount() { + output.WriteString("\n") + output.WriteString(metricsHintStyle.Render(fmt.Sprintf( + "YAML scroll: %3.0f%%", + m.viewport.ScrollPercent()*100, + ))) + } + output.WriteString("\n\n") + output.WriteString(m.confirmation.View()) + output.WriteString("\n") + output.WriteString(metricsHintStyle.Render("↑/↓/PgUp/PgDn review YAML • ←/→ choose • y/n answer • Enter confirm • Esc cancel")) + output.WriteString("\n") + return output.String() +} + +func (m *resourceCreationModel) resizeViewport() { + contentWidth := max(30, m.width-2) + m.viewport.Width = contentWidth + m.viewport.Height = max(5, m.height-14) + m.confirmation.WithWidth(contentWidth) +} diff --git a/internal/tui/resource_creation_test.go b/internal/tui/resource_creation_test.go new file mode 100644 index 0000000..7a1fcf2 --- /dev/null +++ b/internal/tui/resource_creation_test.go @@ -0,0 +1,68 @@ +package tui + +import ( + "fmt" + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" +) + +func TestResourceCreationReviewShowsManifestContextAndNamespace(t *testing.T) { + manifest := "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: demo\n" + model := newResourceCreationModel(manifest, "production-eu", "payments") + view := model.View() + + for _, expected := range []string{ + "apiVersion: v1", + "kind: ConfigMap", + "production-eu", + "payments", + "Are you sure you want to create these resources in k8s cluster?", + } { + if !strings.Contains(view, expected) { + t.Fatalf("review is missing %q:\n%s", expected, view) + } + } + if *model.answer { + t.Fatal("resource creation must default to No") + } +} + +func TestResourceCreationReviewRequiresExplicitYes(t *testing.T) { + model := newResourceCreationModel("kind: ConfigMap\n", "dev", "default") + + updated, cmd := model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + model = updated.(resourceCreationModel) + if cmd == nil || !model.done { + t.Fatalf("default confirmation command = %v, done = %v", cmd, model.done) + } + if model.confirmed { + t.Fatal("default Enter confirmed resource creation") + } + + model = newResourceCreationModel("kind: ConfigMap\n", "dev", "default") + updated, cmd = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("y")}) + model = updated.(resourceCreationModel) + if cmd == nil || !model.done || !model.confirmed { + t.Fatalf("yes confirmation command = %v, done = %v, confirmed = %v", cmd, model.done, model.confirmed) + } +} + +func TestResourceCreationReviewScrollsLongYAML(t *testing.T) { + lines := make([]string, 30) + for index := range lines { + lines[index] = fmt.Sprintf("line-%02d", index) + } + model := newResourceCreationModel(strings.Join(lines, "\n"), "dev", "default") + model.viewport.Height = 5 + + updated, _ := model.Update(tea.KeyMsg{Type: tea.KeyDown}) + model = updated.(resourceCreationModel) + if model.viewport.YOffset != 1 { + t.Fatalf("viewport offset = %d, want 1", model.viewport.YOffset) + } + if !strings.Contains(model.View(), "YAML scroll:") { + t.Fatalf("scroll status is missing:\n%s", model.View()) + } +} diff --git a/internal/tui/spinner.go b/internal/tui/spinner.go new file mode 100644 index 0000000..5b5fd2c --- /dev/null +++ b/internal/tui/spinner.go @@ -0,0 +1,104 @@ +package tui + +import ( + "fmt" + "io" + "os" + + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +type operationDoneMsg[T any] struct { + result T + err error +} + +type operationSpinnerModel[T any] struct { + spinner spinner.Model + message string + operation func() (T, error) + result T + err error + done bool +} + +func RunWithSpinner[T any](output io.Writer, message string, operation func() (T, error)) (T, error) { + if output == nil || !isInteractiveWriter(output) { + if output != nil { + _, _ = fmt.Fprintln(output, message) + } + return operation() + } + + model := newOperationSpinnerModel(message, operation) + result, err := tea.NewProgram( + model, + tea.WithInput(nil), + tea.WithOutput(output), + ).Run() + if err != nil { + var zero T + return zero, fmt.Errorf("run progress spinner: %w", err) + } + + finalModel, ok := result.(operationSpinnerModel[T]) + if !ok { + var zero T + return zero, fmt.Errorf("unexpected progress spinner state") + } + return finalModel.result, finalModel.err +} + +func newOperationSpinnerModel[T any]( + message string, + operation func() (T, error), +) operationSpinnerModel[T] { + return operationSpinnerModel[T]{ + spinner: spinner.New( + spinner.WithSpinner(spinner.MiniDot), + spinner.WithStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("86"))), + ), + message: message, + operation: operation, + } +} + +func (m operationSpinnerModel[T]) Init() tea.Cmd { + return tea.Batch( + m.spinner.Tick, + func() tea.Msg { + result, err := m.operation() + return operationDoneMsg[T]{result: result, err: err} + }, + ) +} + +func (m operationSpinnerModel[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case spinner.TickMsg: + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + return m, cmd + case operationDoneMsg[T]: + m.result = msg.result + m.err = msg.err + m.done = true + return m, tea.Quit + default: + return m, nil + } +} + +func (m operationSpinnerModel[T]) View() string { + if m.done { + return "" + } + return m.spinner.View() + " " + m.message +} + +func isInteractiveWriter(output io.Writer) bool { + file, ok := output.(*os.File) + return ok && isInteractive(file) +} diff --git a/internal/tui/spinner_test.go b/internal/tui/spinner_test.go new file mode 100644 index 0000000..317b6c9 --- /dev/null +++ b/internal/tui/spinner_test.go @@ -0,0 +1,71 @@ +package tui + +import ( + "bytes" + "errors" + "strings" + "testing" +) + +func TestRunWithSpinnerFallsBackToPlainProgressForNonInteractiveOutput(t *testing.T) { + output := &bytes.Buffer{} + called := false + + result, err := RunWithSpinner(output, "Discovering Prometheus…", func() (string, error) { + called = true + return "done", nil + }) + if err != nil { + t.Fatalf("RunWithSpinner() error = %v", err) + } + if !called { + t.Fatal("operation was not called") + } + if result != "done" { + t.Fatalf("result = %q, want done", result) + } + if output.String() != "Discovering Prometheus…\n" { + t.Fatalf("output = %q", output.String()) + } +} + +func TestRunWithSpinnerReturnsOperationError(t *testing.T) { + wantErr := errors.New("discovery failed") + _, err := RunWithSpinner(&bytes.Buffer{}, "Discovering Prometheus…", func() (string, error) { + return "", wantErr + }) + if !errors.Is(err, wantErr) { + t.Fatalf("RunWithSpinner() error = %v, want %v", err, wantErr) + } +} + +func TestOperationSpinnerModelAnimatesAndClearsWhenDone(t *testing.T) { + model := newOperationSpinnerModel("Discovering Prometheus…", func() (string, error) { + return "done", nil + }) + initialView := model.View() + if !strings.Contains(initialView, "Discovering Prometheus…") { + t.Fatalf("initial view = %q", initialView) + } + + updated, tickCmd := model.Update(model.spinner.Tick()) + model = updated.(operationSpinnerModel[string]) + if tickCmd == nil { + t.Fatal("spinner did not schedule its next frame") + } + if model.View() == initialView { + t.Fatalf("spinner frame did not change: %q", model.View()) + } + + updated, quitCmd := model.Update(operationDoneMsg[string]{result: "done"}) + model = updated.(operationSpinnerModel[string]) + if quitCmd == nil { + t.Fatal("completed spinner did not quit") + } + if model.View() != "" { + t.Fatalf("completed spinner view = %q, want empty", model.View()) + } + if model.result != "done" { + t.Fatalf("result = %q, want done", model.result) + } +} diff --git a/internal/tui/theme.go b/internal/tui/theme.go new file mode 100644 index 0000000..e0bd186 --- /dev/null +++ b/internal/tui/theme.go @@ -0,0 +1,65 @@ +package tui + +import ( + "github.com/charmbracelet/huh" + "github.com/charmbracelet/lipgloss" +) + +var ( + tuiCyan = lipgloss.Color("86") + tuiMuted = lipgloss.Color("241") + tuiError = lipgloss.Color("196") +) + +func cyanHuhTheme() *huh.Theme { + theme := huh.ThemeBase() + + theme.Focused.Base = theme.Focused.Base.BorderForeground(tuiCyan) + theme.Focused.Card = theme.Focused.Base + theme.Focused.Title = theme.Focused.Title.Foreground(tuiCyan).Bold(true) + theme.Focused.Description = theme.Focused.Description.Foreground(tuiMuted) + theme.Focused.ErrorIndicator = theme.Focused.ErrorIndicator.Foreground(tuiError) + theme.Focused.ErrorMessage = theme.Focused.ErrorMessage.Foreground(tuiError) + theme.Focused.FocusedButton = theme.Focused.FocusedButton. + Foreground(lipgloss.Color("0")). + Background(tuiCyan). + Bold(true) + theme.Focused.BlurredButton = theme.Focused.BlurredButton. + Foreground(tuiCyan). + Background(lipgloss.NoColor{}) + + theme.Blurred = theme.Focused + theme.Blurred.Base = theme.Blurred.Base.BorderStyle(lipgloss.HiddenBorder()) + theme.Blurred.Card = theme.Blurred.Base + + theme.Group.Title = theme.Focused.Title + theme.Group.Description = theme.Focused.Description + theme.Help.ShortKey = theme.Help.ShortKey.Foreground(tuiCyan) + theme.Help.ShortDesc = theme.Help.ShortDesc.Foreground(tuiMuted) + theme.Help.FullKey = theme.Help.FullKey.Foreground(tuiCyan) + theme.Help.FullDesc = theme.Help.FullDesc.Foreground(tuiMuted) + + return theme +} + +func cyanHuhKeyMap() *huh.KeyMap { + keyMap := huh.NewDefaultKeyMap() + keyMap.Quit.SetKeys("ctrl+c", "esc") + keyMap.Quit.SetHelp("esc", "cancel") + return keyMap +} + +func newCyanConfirm(title, description string, value *bool) *huh.Confirm { + confirm := huh.NewConfirm(). + Title(title). + Description(description). + Affirmative("Yes"). + Negative("No"). + Value(value). + WithButtonAlignment(lipgloss.Left) + confirm.WithTheme(cyanHuhTheme()) + confirm.WithKeyMap(cyanHuhKeyMap()) + confirm.WithPosition(huh.FieldPosition{}) + confirm.Focus() + return confirm +} diff --git a/internal/tui/theme_test.go b/internal/tui/theme_test.go new file mode 100644 index 0000000..a141ed3 --- /dev/null +++ b/internal/tui/theme_test.go @@ -0,0 +1,41 @@ +package tui + +import ( + "testing" + + tea "github.com/charmbracelet/bubbletea" +) + +func TestCyanHuhThemeStylesFocusedConfirmation(t *testing.T) { + theme := cyanHuhTheme() + + if got := theme.Focused.Title.GetForeground(); got != tuiCyan { + t.Fatalf("focused title color = %v, want %v", got, tuiCyan) + } + if got := theme.Focused.FocusedButton.GetBackground(); got != tuiCyan { + t.Fatalf("focused button background = %v, want %v", got, tuiCyan) + } + if got := theme.Help.ShortKey.GetForeground(); got != tuiCyan { + t.Fatalf("help key color = %v, want %v", got, tuiCyan) + } +} + +func TestCyanConfirmUsesHuhKeyBindings(t *testing.T) { + value := false + confirm := newCyanConfirm("Continue?", "", &value) + + _, _ = confirm.Update(tea.KeyMsg{Type: tea.KeyRight}) + if !value { + t.Fatal("right arrow did not select Yes") + } + + _, _ = confirm.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("n")}) + if value { + t.Fatal("n did not select No") + } + + _, _ = confirm.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("y")}) + if !value { + t.Fatal("y did not select Yes") + } +} diff --git a/internal/tui/yaml_editor.go b/internal/tui/yaml_editor.go new file mode 100644 index 0000000..36b98de --- /dev/null +++ b/internal/tui/yaml_editor.go @@ -0,0 +1,162 @@ +package tui + +import ( + "errors" + "fmt" + "io" + "strings" + + "github.com/charmbracelet/bubbles/textarea" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "gopkg.in/yaml.v3" +) + +type yamlEditorModel struct { + editor textarea.Model + err string + width int + height int + done bool + quit bool +} + +func EditYAML( + stdin io.Reader, + stdout, stderr io.Writer, + value string, +) (string, error) { + file, err := interactiveFile(stdin) + if err != nil { + return "", err + } + + model := newYAMLEditorModel(value) + result, err := tea.NewProgram( + model, + tea.WithInput(file), + tea.WithOutput(promptOutput(stdout, stderr)), + ).Run() + if err != nil { + return "", fmt.Errorf("run YAML editor: %w", err) + } + + finalModel, ok := result.(yamlEditorModel) + if !ok { + return "", errors.New("unexpected YAML editor state") + } + if finalModel.quit { + return "", errors.New("YAML editing canceled") + } + if !finalModel.done { + return "", errors.New("YAML editing did not finish") + } + return finalModel.editor.Value(), nil +} + +func newYAMLEditorModel(value string) yamlEditorModel { + editor := textarea.New() + editor.SetValue(value) + editor.ShowLineNumbers = true + editor.CharLimit = 0 + editor.SetWidth(98) + editor.SetHeight(22) + editor.FocusedStyle.CursorLine = lipgloss.NewStyle().Background(lipgloss.Color("235")) + editor.FocusedStyle.CursorLineNumber = lipgloss.NewStyle(). + Foreground(lipgloss.Color("86")). + Background(lipgloss.Color("235")) + editor.FocusedStyle.LineNumber = lipgloss.NewStyle().Foreground(lipgloss.Color("241")) + editor.FocusedStyle.Text = lipgloss.NewStyle().Foreground(lipgloss.Color("252")) + _ = editor.Focus() + + return yamlEditorModel{ + editor: editor, + width: 100, + height: 30, + } +} + +func (m yamlEditorModel) Init() tea.Cmd { + return m.editor.Focus() +} + +func (m yamlEditorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + m.resizeEditor() + return m, nil + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "esc": + m.quit = true + return m, tea.Quit + case "ctrl+s": + if err := validateYAML(m.editor.Value()); err != nil { + m.err = err.Error() + return m, nil + } + m.done = true + m.err = "" + return m, tea.Quit + } + } + + var cmd tea.Cmd + m.editor, cmd = m.editor.Update(msg) + m.err = "" + return m, cmd +} + +func (m yamlEditorModel) View() string { + if m.done { + return "\n" + } + + var output strings.Builder + output.WriteString(metricsTitleStyle.Render("Edit generated YAML")) + output.WriteString("\n") + output.WriteString(metricsHintStyle.Render("Line numbers are for display only and are not included in the output.")) + output.WriteString("\n\n") + output.WriteString(m.editor.View()) + if m.err != "" { + output.WriteString("\n") + output.WriteString(metricsErrorStyle.Render(m.err)) + } + output.WriteString("\n\n") + output.WriteString(metricsHintStyle.Render("Ctrl+S save and continue • Esc cancel")) + output.WriteString("\n") + return output.String() +} + +func (m *yamlEditorModel) resizeEditor() { + m.editor.SetWidth(max(20, m.width-2)) + m.editor.SetHeight(max(5, m.height-7)) +} + +func validateYAML(value string) error { + if strings.TrimSpace(value) == "" { + return errors.New("YAML cannot be empty") + } + + decoder := yaml.NewDecoder(strings.NewReader(value)) + documents := 0 + for { + var document any + err := decoder.Decode(&document) + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return fmt.Errorf("invalid YAML: %w", err) + } + if document != nil { + documents++ + } + } + if documents == 0 { + return errors.New("YAML must contain at least one document") + } + return nil +} diff --git a/internal/tui/yaml_editor_test.go b/internal/tui/yaml_editor_test.go new file mode 100644 index 0000000..42d02f2 --- /dev/null +++ b/internal/tui/yaml_editor_test.go @@ -0,0 +1,63 @@ +package tui + +import ( + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" +) + +func TestYAMLEditorUsesTextareaWithLineNumbers(t *testing.T) { + value := "apiVersion: v1\nkind: ConfigMap\n" + model := newYAMLEditorModel(value) + + if !model.editor.ShowLineNumbers { + t.Fatal("YAML editor line numbers are disabled") + } + if !model.editor.Focused() { + t.Fatal("YAML textarea is not focused") + } + if model.editor.Value() != value { + t.Fatalf("editor value = %q, want %q", model.editor.Value(), value) + } + if !strings.Contains(model.View(), "Ctrl+S save and continue") { + t.Fatalf("editor help is missing:\n%s", model.View()) + } +} + +func TestYAMLEditorSavesValidEditedYAML(t *testing.T) { + model := newYAMLEditorModel("kind: ConfigMap") + updated, _ := model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + model = updated.(yamlEditorModel) + updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("metadata:")}) + model = updated.(yamlEditorModel) + + updated, cmd := model.Update(tea.KeyMsg{Type: tea.KeyCtrlS}) + model = updated.(yamlEditorModel) + if cmd == nil || !model.done { + t.Fatalf("save command = %v, done = %v", cmd, model.done) + } + if got := model.editor.Value(); got != "kind: ConfigMap\nmetadata:" { + t.Fatalf("edited YAML = %q", got) + } +} + +func TestYAMLEditorRejectsInvalidYAMLOnSave(t *testing.T) { + model := newYAMLEditorModel("kind: [") + updated, cmd := model.Update(tea.KeyMsg{Type: tea.KeyCtrlS}) + model = updated.(yamlEditorModel) + + if cmd != nil || model.done { + t.Fatalf("save command = %v, done = %v", cmd, model.done) + } + if !strings.Contains(model.err, "invalid YAML") { + t.Fatalf("error = %q", model.err) + } +} + +func TestValidateYAMLAcceptsMultipleDocuments(t *testing.T) { + value := "kind: ConfigMap\n---\nkind: Secret\n" + if err := validateYAML(value); err != nil { + t.Fatalf("validateYAML() error = %v", err) + } +} From 3792865f773b7df237b0740d8ac909ef935227e6 Mon Sep 17 00:00:00 2001 From: Jirka Kremser Date: Tue, 28 Jul 2026 17:42:38 +0200 Subject: [PATCH 2/2] linters Signed-off-by: Jirka Kremser --- internal/cli/metrics/metrics.go | 2 +- internal/metrics/resources.go | 4 ++-- internal/prometheus/client.go | 17 ++++++++++------- internal/tui/metrics.go | 2 +- 4 files changed, 14 insertions(+), 11 deletions(-) diff --git a/internal/cli/metrics/metrics.go b/internal/cli/metrics/metrics.go index 699c3a9..47ec988 100644 --- a/internal/cli/metrics/metrics.go +++ b/internal/cli/metrics/metrics.go @@ -93,7 +93,7 @@ func (c *MetricsCmd) Run(app *clictx.Context) error { return err } if len(metricNames) == 0 { - return errors.New("Prometheus returned no active metrics for selector {__name__=~\".+\"}") + return errors.New("prometheus returned no active metrics for selector {__name__=~\".+\"}") } } diff --git a/internal/metrics/resources.go b/internal/metrics/resources.go index d7f2122..394933c 100644 --- a/internal/metrics/resources.go +++ b/internal/metrics/resources.go @@ -111,7 +111,7 @@ func BuildResources(options ResourceOptions) ([]byte, error) { if options.MetricPredictorName != "" { if strings.TrimSpace(options.PrometheusURL) == "" { - return nil, fmt.Errorf("Prometheus URL is required for MetricPredictor %q", options.MetricPredictorName) + return nil, fmt.Errorf("prometheus URL is required for MetricPredictor %q", options.MetricPredictorName) } rangeFields := 0 for _, field := range []string{options.PrometheusStart, options.PrometheusEnd, options.PrometheusStep} { @@ -120,7 +120,7 @@ func BuildResources(options ResourceOptions) ([]byte, error) { } } if rangeFields != 0 && rangeFields != 3 { - return nil, fmt.Errorf("Prometheus start, end, and step must be specified together") + return nil, fmt.Errorf("prometheus start, end, and step must be specified together") } resources = append(resources, metricPredictor{ APIVersion: "keda.kedify.io/v1alpha1", diff --git a/internal/prometheus/client.go b/internal/prometheus/client.go index e9d66df..3e3a620 100644 --- a/internal/prometheus/client.go +++ b/internal/prometheus/client.go @@ -94,7 +94,7 @@ func newClientWithHTTP(rawURL string, httpClient *http.Client, scopeOrgID string func NormalizeURL(rawURL string) (string, error) { rawURL = strings.TrimSpace(rawURL) if rawURL == "" { - return "", errors.New("Prometheus server URL is required") + return "", errors.New("prometheus server URL is required") } if !strings.Contains(rawURL, "://") { rawURL = "http://" + rawURL @@ -105,13 +105,13 @@ func NormalizeURL(rawURL string) (string, error) { return "", fmt.Errorf("parse Prometheus URL: %w", err) } if parsed.Scheme != "http" && parsed.Scheme != "https" { - return "", fmt.Errorf("Prometheus URL scheme must be http or https, got %q", parsed.Scheme) + return "", fmt.Errorf("prometheus URL scheme must be http or https, got %q", parsed.Scheme) } if parsed.Host == "" { - return "", errors.New("Prometheus server URL must include a host") + return "", errors.New("prometheus server URL must include a host") } if parsed.RawQuery != "" || parsed.Fragment != "" { - return "", errors.New("Prometheus server URL must not include a query or fragment") + return "", errors.New("prometheus server URL must not include a query or fragment") } parsed.Path = strings.TrimRight(parsed.Path, "/") @@ -169,7 +169,7 @@ func (c *Client) ValidateQuery(ctx context.Context, query string) error { func (c *Client) RangeQuery(ctx context.Context, query string, start, end time.Time, step time.Duration) ([]Series, error) { if step <= 0 { - return nil, errors.New("Prometheus range query step must be greater than zero") + return nil, errors.New("prometheus range query step must be greater than zero") } params := url.Values{ @@ -257,14 +257,17 @@ func (c *Client) get(ctx context.Context, path string, params url.Values, target if err != nil { return err } - defer response.Body.Close() body, err := io.ReadAll(io.LimitReader(response.Body, 32<<20)) if err != nil { + _ = response.Body.Close() return fmt.Errorf("read Prometheus response: %w", err) } + if err := response.Body.Close(); err != nil { + return fmt.Errorf("close Prometheus response body: %w", err) + } if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { - return fmt.Errorf("Prometheus returned HTTP %d: %s", response.StatusCode, strings.TrimSpace(string(body))) + return fmt.Errorf("prometheus returned HTTP %d: %s", response.StatusCode, strings.TrimSpace(string(body))) } var envelope apiResponse diff --git a/internal/tui/metrics.go b/internal/tui/metrics.go index 701d7a1..76c2b50 100644 --- a/internal/tui/metrics.go +++ b/internal/tui/metrics.go @@ -155,7 +155,7 @@ func RunMetricsExplorer( return MetricsResult{}, err } if len(metrics) == 0 && strings.TrimSpace(options.Query) == "" { - return MetricsResult{}, errors.New("Prometheus returned no metrics") + return MetricsResult{}, errors.New("prometheus returned no metrics") } model, err := newMetricsModelWithOptions(client, metrics, options)