diff --git a/go.mod b/go.mod index 603bbe5..b0061c3 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/flashcatcloud/flashduty-cli go 1.25.1 require ( - github.com/flashcatcloud/go-flashduty v0.15.1-0.20260908113851-5bf8f2902391 + github.com/flashcatcloud/go-flashduty v0.15.2 github.com/mattn/go-runewidth v0.0.28 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 diff --git a/go.sum b/go.sum index 732a29b..77c87a7 100644 --- a/go.sum +++ b/go.sum @@ -1,14 +1,8 @@ github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY= github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/flashcatcloud/go-flashduty v0.15.1-0.20260908003559-2ac06de1601e h1:CW8D+jijv7S/oJqj/hVMjMxzCzlT93FJPBx902bg5Nk= -github.com/flashcatcloud/go-flashduty v0.15.1-0.20260908003559-2ac06de1601e/go.mod h1:YpHiTYXR5NXBI/rGRZfUy537XMkhdCkwA8NW1QoRHwk= -github.com/flashcatcloud/go-flashduty v0.15.1-0.20260908025802-4fa9a76d8b57 h1:3g7059LyEJeIsLwT3qSng8BdV44GIOV9DQa+dLbalmo= -github.com/flashcatcloud/go-flashduty v0.15.1-0.20260908025802-4fa9a76d8b57/go.mod h1:YpHiTYXR5NXBI/rGRZfUy537XMkhdCkwA8NW1QoRHwk= -github.com/flashcatcloud/go-flashduty v0.15.1-0.20260908030757-f478f34797be h1:F3+A0vVRICnEeBshac70P+VBtuo64P5hBmxcfbFxiXk= -github.com/flashcatcloud/go-flashduty v0.15.1-0.20260908030757-f478f34797be/go.mod h1:YpHiTYXR5NXBI/rGRZfUy537XMkhdCkwA8NW1QoRHwk= -github.com/flashcatcloud/go-flashduty v0.15.1-0.20260908113851-5bf8f2902391 h1:u4IM9wE2/isYCAUMszeBsp4b9Na7Qh5mHlvPLpDgvkw= -github.com/flashcatcloud/go-flashduty v0.15.1-0.20260908113851-5bf8f2902391/go.mod h1:YpHiTYXR5NXBI/rGRZfUy537XMkhdCkwA8NW1QoRHwk= +github.com/flashcatcloud/go-flashduty v0.15.2 h1:dDUcoaMB48cLoRVJU8AcfXWaSEEHtI0Z6XCX81KHNpQ= +github.com/flashcatcloud/go-flashduty v0.15.2/go.mod h1:YpHiTYXR5NXBI/rGRZfUy537XMkhdCkwA8NW1QoRHwk= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/mattn/go-runewidth v0.0.28 h1:rPyg2ybwEKPebvpzVWe1gKBkH8EQFkxO4Y0hjBeLaBU= diff --git a/internal/cli/datasource_tools_test.go b/internal/cli/datasource_tools_test.go index b8bafb8..003072b 100644 --- a/internal/cli/datasource_tools_test.go +++ b/internal/cli/datasource_tools_test.go @@ -52,6 +52,51 @@ func TestDatasourceToolInvokeStdinPreservesJSON(t *testing.T) { } } +func TestDatasourceToolInvokeQueryParamsPreserveJSON(t *testing.T) { + saveAndResetGlobals(t) + requests := make(chan string, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/monit/datasource/tools/invoke" { + t.Errorf("unexpected endpoint: %s %s", r.Method, r.URL.Path) + } + raw, _ := io.ReadAll(r.Body) + requests <- string(raw) + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"request_id":"query-test","data":{"datasource_id":42,"tool":"prometheus.query","data":{"format":"explore_result.v1","result":{"kind":"samples","samples":[]}}}}`) + })) + t.Cleanup(server.Close) + newClientFn = func() (*flashduty.Client, error) { + return flashduty.NewClient("test", flashduty.WithBaseURL(server.URL)) + } + out, err := execCommand("monit", "datasource-tools-invoke", "42", "--tool", "prometheus.query", + "--data", `{"params":{"expr":"sum by (job) (rate(http_requests_total[5m]))","execution":{"kind":"instant","to_ms":9007199254740993}}}`, + "--output-format", "json") + if err != nil { + t.Fatal(err) + } + raw := <-requests + var sent struct { + DatasourceID uint64 `json:"datasource_id"` + Tool string `json:"tool"` + Params json.RawMessage `json:"params"` + } + if err := json.Unmarshal([]byte(raw), &sent); err != nil { + t.Fatal(err) + } + if sent.DatasourceID != 42 || sent.Tool != "prometheus.query" { + t.Fatalf("request lost identity: %s", raw) + } + if !strings.Contains(string(sent.Params), `"to_ms":9007199254740993`) || strings.Contains(string(sent.Params), "9007199254740992") { + t.Fatalf("params lost numeric precision: %s", sent.Params) + } + if !strings.Contains(string(sent.Params), `"kind":"instant"`) || !strings.Contains(string(sent.Params), "rate(http_requests_total[5m])") { + t.Fatalf("params mangled: %s", sent.Params) + } + if !strings.Contains(out, "explore_result.v1") { + t.Fatalf("response lost query evidence: %s", out) + } +} + func TestDatasourceToolErrorsAreNotReplayed(t *testing.T) { for _, status := range []int{400, 429, 503, 504} { t.Run(fmt.Sprint(status), func(t *testing.T) { diff --git a/internal/cli/helpers.go b/internal/cli/helpers.go deleted file mode 100644 index d5a3f9d..0000000 --- a/internal/cli/helpers.go +++ /dev/null @@ -1,24 +0,0 @@ -package cli - -import ( - "fmt" - "strings" -) - -// parseKVSlice converts a slice of "KEY=VALUE" entries into a map. -// Returns nil (not an error) for an empty input so callers can pass nil -// maps through to the SDK without triggering omitempty issues. -func parseKVSlice(entries []string) (map[string]string, error) { - if len(entries) == 0 { - return nil, nil - } - out := make(map[string]string, len(entries)) - for _, e := range entries { - i := strings.IndexByte(e, '=') - if i < 0 { - return nil, fmt.Errorf("missing '=': %q", e) - } - out[e[:i]] = e[i+1:] - } - return out, nil -} diff --git a/internal/cli/helpers_test.go b/internal/cli/helpers_test.go index 146a62b..1602949 100644 --- a/internal/cli/helpers_test.go +++ b/internal/cli/helpers_test.go @@ -1,7 +1,6 @@ package cli import ( - "reflect" "strings" "testing" ) @@ -126,40 +125,3 @@ func TestOrDash(t *testing.T) { func TestMemberPersonInfosDisplay(t *testing.T) { t.Skip("requires injection seam for fake client (Phase 3)") } - -func TestParseKVSlice(t *testing.T) { - cases := []struct { - name string - input []string - want map[string]string - wantErr bool - }{ - {"nil input", nil, nil, false}, - {"empty input", []string{}, nil, false}, - {"single pair", []string{"K=V"}, map[string]string{"K": "V"}, false}, - {"multiple pairs", []string{"A=1", "B=2"}, map[string]string{"A": "1", "B": "2"}, false}, - // Value contains additional '=' signs — only the first splits key from value. - {"value contains equals", []string{"K=a=b=c"}, map[string]string{"K": "a=b=c"}, false}, - {"empty value", []string{"K="}, map[string]string{"K": ""}, false}, - // Empty-key is the current behaviour when the entry starts with '='; documented here. - {"empty key", []string{"=V"}, map[string]string{"": "V"}, false}, - {"missing equals", []string{"NOEQ"}, nil, true}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - got, err := parseKVSlice(tc.input) - if tc.wantErr { - if err == nil { - t.Fatal("expected error, got nil") - } - return - } - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if !reflect.DeepEqual(got, tc.want) { - t.Errorf("got %v, want %v", got, tc.want) - } - }) - } -} diff --git a/internal/cli/monit_query.go b/internal/cli/monit_query.go index 7280e26..4b240ba 100644 --- a/internal/cli/monit_query.go +++ b/internal/cli/monit_query.go @@ -1,95 +1,102 @@ package cli import ( + "encoding/json" "fmt" + "io" "strconv" + "strings" "github.com/flashcatcloud/go-flashduty" "github.com/spf13/cobra" - - "github.com/flashcatcloud/flashduty-cli/internal/timeutil" ) +// newMonitQueryCmd builds the curated leaf for the unified datasource tool +// entry POST /monit/datasource/tools/invoke: query tools ('.query') and +// Edge-defined diagnostic tools share this one invocation surface. The +// generated `monit datasource-tools-invoke` is the spec-mirror equivalent. +// The CLI only assembles the request and renders the result — tool-name and +// per-datasource params validation are the server's job. func newMonitQueryCmd() *cobra.Command { - cmd := newGroupCmd("monit-query", "Query configured datasources; structured diagnostics use monit datasource-tools-invoke") - cmd.AddCommand(newMonitQueryDataCmd()) - return cmd -} - -func newMonitQueryDataCmd() *cobra.Command { var ( - dsType, dsName, expr string - delaySeconds int64 - argsKV []string + tool string + paramsFlag string + accountID int64 ) cmd := &cobra.Command{ - Use: "data", - Short: "Structured datasource query (returns a stable query_result.v1: frames/records/samples)", - Long: curatedLong("Structured datasource query returning the stable query_result.v1 result — frames, records, or samples — instead of the legacy flattened rows.", "Diagnostics", "QueryData"), + Use: "monit-query ", + Short: "Invoke a datasource query or diagnostic tool", + Long: curatedLong(`Invoke one query or diagnostic tool against a configured datasource. + +Query tools are '.query' where '' is one of 'prometheus', 'mysql', 'postgres', 'oracle', 'clickhouse', 'elasticsearch', 'loki', 'victorialogs', 'sls', 'tencent_cls'; their params are the per-datasource query schemas (expr + execution, plus log/sls/tencent_cls extensions). Diagnostic tools are defined by the executing Edge (e.g. 'mysql.overview'). The tool prefix must match the datasource type; the server validates the tool name and params.`, "DataSources", "ToolsInvoke"), + Example: ` flashduty monit-query 12345 --tool prometheus.query --params '{"expr":"up","execution":{"kind":"instant","to_ms":1757462400000}}' + flashduty monit-query 12345 --tool redis_node.overview`, + Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - if dsType == "" || dsName == "" || expr == "" { - return fmt.Errorf("--ds-type, --ds-name, --expr are required") + datasourceID, err := strconv.ParseInt(args[0], 10, 64) + if err != nil || datasourceID < 1 { + return fmt.Errorf("invalid datasource-id %q: must be a positive integer", args[0]) } - argsMap, err := parseKVSlice(argsKV) + params, err := resolveToolParams(paramsFlag) if err != nil { - return fmt.Errorf("invalid --args: %w", err) - } - if err := normalizeRawTimeArgs(dsType, argsMap); err != nil { return err } - return runCommand(cmd, args, func(ctx *RunContext) error { - input := &flashduty.QueryDataRequest{ - DsType: dsType, - DsName: dsName, - Expr: expr, - DelaySeconds: delaySeconds, - Args: argsMap, + req := &flashduty.DatasourceToolInvokeRequest{ + DatasourceID: uint64(datasourceID), + Tool: tool, + Params: params, + } + if cmd.Flags().Changed("account-id") { + req.AccountID = uint64(accountID) } - result, _, err := ctx.Client.Diagnostics.QueryData(cmdContext(ctx.Cmd), input) + out, _, err := ctx.Client.DataSources.ToolsInvoke(cmdContext(ctx.Cmd), req) if err != nil { return err } - return ctx.Printer.Print(result, nil) + return printGenericResult(ctx, out) }) }, } - cmd.Flags().StringVar(&dsType, "ds-type", "", "Datasource type (required)") - cmd.Flags().StringVar(&dsName, "ds-name", "", "Datasource name as configured (required)") - registerEnumFlag(cmd, "ds-type", "prometheus", "victorialogs", "loki", "mysql", "sls", "elasticsearch", "postgres", "oracle", "clickhouse") - cmd.Flags().StringVar(&expr, "expr", "", "Query expression (required)") - cmd.Flags().Int64Var(&delaySeconds, "delay-seconds", 0, "Look-back offset in seconds for point-in-time queries (default 0)") - cmd.Flags().StringSliceVar(&argsKV, "args", nil, "Arg entries KEY=VALUE (repeatable; values must be strings per monit-query contract). "+ - "For loki/victorialogs raw mode, .start/.end accept a relative duration ('15m'), 'now', a date/RFC3339 timestamp, "+ - "or a unix epoch in seconds or milliseconds — normalized to the form the datasource requires before sending") - + cmd.Flags().StringVar(&tool, "tool", "", "Single tool name prefixed by the datasource type: a query tool '.query' or an Edge-defined diagnostic tool (e.g. 'mysql.overview'). (required)") + _ = cmd.MarkFlagRequired("tool") + cmd.Flags().StringVar(¶msFlag, "params", "", "Tool-specific JSON parameters as inline JSON, or - to read stdin. Omitted means the params field is not sent (the server treats it as {}); explicit null is invalid. Numbers are sent byte-exact, so epoch-millisecond values above 2^53 keep their digits.") + cmd.Flags().Int64Var(&accountID, "account-id", 0, "Optional consistency check; must equal the authenticated account.") return cmd } -// normalizeRawTimeArgs rewrites the raw-mode time-window args of a -// monit-query data call (.start / .end) into the unix- -// seconds form the server requires, accepting any format timeutil.Parse -// understands (RFC3339, date/datetime, relative duration, unix seconds or -// milliseconds). Loki and VictoriaLogs are the only ds-types whose raw mode -// consumes these keys; other ds-types ignore args entirely, so nothing is -// touched for them. -func normalizeRawTimeArgs(dsType string, args map[string]string) error { - if dsType != "loki" && dsType != "victorialogs" { - return nil - } - for _, suffix := range []string{"start", "end"} { - key := dsType + "." + suffix - v, ok := args[key] - if !ok || v == "" { - continue - } - ts, err := timeutil.Parse(v) +// resolveToolParams validates the --params value as a single JSON object and +// returns it byte-exact for the request's raw params field. An empty flag +// returns nil so the field is omitted (the server treats omitted as {}; +// explicit null is invalid). Validation decodes with UseNumber only to reject +// malformed input — the wire payload is the original text, so large integers +// (epoch-millisecond execution windows) cannot be rounded through float64. +func resolveToolParams(flag string) (json.RawMessage, error) { + raw := flag + if flag == "-" { + b, err := readStdin("--params") if err != nil { - return fmt.Errorf("invalid --args %s=%s: %w", key, v, err) + return nil, fmt.Errorf("failed to read --params from stdin: %w", err) } - args[key] = strconv.FormatInt(ts, 10) + raw = string(b) + } + if raw == "" { + return nil, nil + } + decoder := json.NewDecoder(strings.NewReader(raw)) + decoder.UseNumber() + var obj map[string]any + if err := decoder.Decode(&obj); err != nil { + return nil, fmt.Errorf("invalid --params JSON: %w", err) + } + var extra any + if err := decoder.Decode(&extra); err != io.EOF { + return nil, fmt.Errorf("invalid --params JSON: expected one JSON object") + } + if obj == nil { + return nil, fmt.Errorf("invalid --params JSON: expected an object") } - return nil + return json.RawMessage(raw), nil } diff --git a/internal/cli/monit_query_test.go b/internal/cli/monit_query_test.go index 0c43826..d76c8e5 100644 --- a/internal/cli/monit_query_test.go +++ b/internal/cli/monit_query_test.go @@ -2,275 +2,202 @@ package cli import ( "encoding/json" + "errors" "fmt" - "strconv" + "io" + "net/http" + "net/http/httptest" "strings" + "sync/atomic" "testing" - "time" + + "github.com/flashcatcloud/go-flashduty" ) -func TestMonitQueryDataFlags(t *testing.T) { - cmd := newMonitQueryDataCmd() - for _, name := range []string{"ds-type", "ds-name", "expr", "args", "delay-seconds"} { - if cmd.Flags().Lookup(name) == nil { - t.Errorf("flag --%s missing", name) +// invokeRawStub captures the raw request body sent to the unified tool +// entry and replies with a canned tool envelope. Byte-level assertions need +// the raw body: decoding to map[string]any first would hide float64 rounding. +func invokeRawStub(t *testing.T) chan string { + t.Helper() + requests := make(chan string, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/monit/datasource/tools/invoke" { + t.Errorf("unexpected endpoint: %s %s", r.Method, r.URL.Path) } - } + raw, _ := io.ReadAll(r.Body) + requests <- string(raw) + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"request_id":"monit-query-test","data":{"datasource_id":42,"tool":"prometheus.query","data":{"format":"explore_result.v1","result":{"kind":"samples","samples":[{"labels":{"job":"api"},"value":1.25}]}}}}`) + })) + t.Cleanup(server.Close) + newClientFn = func() (*flashduty.Client, error) { + return flashduty.NewClient("test", flashduty.WithBaseURL(server.URL)) + } + return requests } -func TestRetiredMonitCommandsRejectBeforeRequest(t *testing.T) { - for _, args := range [][]string{ - {"monit-query", "diagnose"}, {"monit", "query-diagnose"}, - {"monit", "rule-counter-status"}, - {"monit", "store-ruleset-create"}, {"monit", "store-ruleset-update"}, - {"monit", "store-ruleset-list"}, {"monit", "store-ruleset-info"}, {"monit", "store-ruleset-delete"}, - } { - t.Run(strings.Join(args, " "), func(t *testing.T) { - saveAndResetGlobals(t) - stub := newGFStub(t) - _, err := execCommand(args...) - if err == nil || !strings.Contains(err.Error(), "unknown command") { - t.Fatalf("retired command error=%v", err) - } - if stub.requests != 0 { - t.Fatalf("retired command sent %d requests", stub.requests) - } - }) +func TestMonitQueryToolInvokePreservesParams(t *testing.T) { + saveAndResetGlobals(t) + requests := invokeRawStub(t) + out, err := execCommand("monit-query", "42", "--tool", "prometheus.query", + "--params", `{"expr":"sum by (job) (rate(http_requests_total[5m]))","execution":{"kind":"instant","to_ms":9007199254740993}}`, + "--output-format", "json") + if err != nil { + t.Fatal(err) + } + raw := <-requests + var sent struct { + DatasourceID uint64 `json:"datasource_id"` + Tool string `json:"tool"` + Params json.RawMessage `json:"params"` + } + if err := json.Unmarshal([]byte(raw), &sent); err != nil { + t.Fatal(err) + } + if sent.DatasourceID != 42 || sent.Tool != "prometheus.query" { + t.Fatalf("request lost identity: %s", raw) + } + if !strings.Contains(string(sent.Params), `"to_ms":9007199254740993`) || strings.Contains(string(sent.Params), "9007199254740992") { + t.Fatalf("params lost numeric precision: %s", sent.Params) + } + if !strings.Contains(out, "explore_result.v1") { + t.Fatalf("response lost query evidence: %s", out) } } -// --- monit-query data ----------------------------------------------------- - -func TestMonitQueryDataHappyPath(t *testing.T) { +func TestMonitQueryToolInvokeParamsFromStdin(t *testing.T) { saveAndResetGlobals(t) - stub := newGFStub(t) - // data returns the stable query_result.v1 envelope: data.{format,result}. - stub.data = map[string]any{ - "format": "query_result.v1", - "result": map[string]any{ - "kind": "samples", - "samples": []any{ - map[string]any{"labels": map[string]any{"job": "api"}, "value": 1.25}, - }, - }, - } - - out, err := execCommand( - "monit-query", "data", - "--ds-type", "prometheus", - "--ds-name", "prom-prod", - "--expr", "up", - "--delay-seconds", "30", - "--args", "step=15s", - "--output-format", "json", - ) + requests := invokeRawStub(t) + stdinReader = strings.NewReader(`{"expr":"up","execution":{"kind":"range","from_ms":9007199254740993,"to_ms":9007199254740995,"max_data_points":100}}`) + _, err := execCommand("monit-query", "42", "--tool", "prometheus.query", "--params", "-", "--json") if err != nil { - t.Fatalf("unexpected error: %v", err) + t.Fatal(err) + } + raw := <-requests + if !strings.Contains(raw, `"from_ms":9007199254740993`) || strings.Contains(raw, "9007199254740992") { + t.Fatalf("stdin params lost numeric precision: %s", raw) } - if stub.lastPath != "/monit/query/data" { - t.Fatalf("expected /monit/query/data, got %q", stub.lastPath) +} + +func TestMonitQueryToolInvokeOmitsParams(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + if _, err := execCommand("monit-query", "42", "--tool", "redis_node.overview"); err != nil { + t.Fatal(err) } - body := stub.lastBody - if body["ds_type"] != "prometheus" || body["ds_name"] != "prom-prod" || body["expr"] != "up" { - t.Errorf("unexpected data input: %#v", body) + if stub.lastPath != "/monit/datasource/tools/invoke" { + t.Fatalf("unexpected path: %s", stub.lastPath) } - if fmt.Sprint(body["delay_seconds"]) != "30" { - t.Errorf("expected delay_seconds 30, got %v", body["delay_seconds"]) + if _, present := stub.lastBody["params"]; present { + t.Fatalf("params should be omitted, got %v", stub.lastBody["params"]) } - args, _ := body["args"].(map[string]any) - if args["step"] != "15s" { - t.Errorf("expected args step=15s, got %#v", args) + if stub.lastBody["tool"] != "redis_node.overview" { + t.Fatalf("unexpected tool: %v", stub.lastBody["tool"]) } - var rendered map[string]any - if err := json.Unmarshal([]byte(out), &rendered); err != nil { - t.Fatalf("decode CLI JSON: %v\n%s", err, out) +} + +func TestMonitQueryToolInvokeAccountID(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + if _, err := execCommand("monit-query", "42", "--tool", "redis_node.overview", "--account-id", "7"); err != nil { + t.Fatal(err) } - if rendered["format"] != "query_result.v1" { - t.Errorf("expected format query_result.v1, got %v", rendered["format"]) + if fmt.Sprint(stub.lastBody["account_id"]) != "7" { + t.Fatalf("account_id not stamped: %v", stub.lastBody) } } -func TestMonitQueryDataRequiredFlags(t *testing.T) { - cases := []struct { +func TestMonitQueryToolInvokeRejectsBadInputBeforeRequest(t *testing.T) { + for _, tc := range []struct { name string args []string + want string }{ - { - name: "missing ds-type", - args: []string{ - "monit-query", "data", - "--ds-name", "prom-prod", - "--expr", "up", - }, - }, - { - name: "missing ds-name", - args: []string{ - "monit-query", "data", - "--ds-type", "prometheus", - "--expr", "up", - }, - }, - { - name: "missing expr", - args: []string{ - "monit-query", "data", - "--ds-type", "prometheus", - "--ds-name", "prom-prod", - }, - }, - } - for _, tc := range cases { + {"missing tool", []string{"monit-query", "42"}, "required"}, + {"bad datasource id", []string{"monit-query", "abc", "--tool", "prometheus.query"}, "datasource-id"}, + {"zero datasource id", []string{"monit-query", "0", "--tool", "prometheus.query"}, "datasource-id"}, + {"malformed params", []string{"monit-query", "42", "--tool", "prometheus.query", "--params", "{oops"}, "--params"}, + {"null params", []string{"monit-query", "42", "--tool", "prometheus.query", "--params", "null"}, "--params"}, + {"array params", []string{"monit-query", "42", "--tool", "prometheus.query", "--params", "[1,2]"}, "--params"}, + {"trailing params value", []string{"monit-query", "42", "--tool", "prometheus.query", "--params", "{} {}"}, "--params"}, + } { t.Run(tc.name, func(t *testing.T) { saveAndResetGlobals(t) stub := newGFStub(t) - _, err := execCommand(tc.args...) - if err == nil { - t.Fatal("expected required-flag error, got nil") - } - if !strings.Contains(err.Error(), "required") { - t.Errorf("expected error to mention 'required', got %q", err.Error()) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("error=%v, want substring %q", err, tc.want) } if stub.requests != 0 { - t.Errorf("data should not have been called: %d request(s)", stub.requests) + t.Fatalf("request sent despite invalid input: %d", stub.requests) } }) } } -// --- normalizeRawTimeArgs -------------------------------------------------- - -func TestNormalizeRawTimeArgsAcceptedFormats(t *testing.T) { - cases := []struct { - name string - input string - }{ - {"rfc3339 utc", "2026-08-11T09:40:00Z"}, - {"rfc3339 offset", "2026-08-11T09:40:00+08:00"}, - {"unix seconds", "1786497600"}, - {"unix milliseconds", "1786497600000"}, +func TestMonitQueryToolErrorsPassThrough(t *testing.T) { + saveAndResetGlobals(t) + var calls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + calls.Add(1) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _, _ = io.WriteString(w, `{"request_id":"trace-query","error":{"code":"BadRequest","reason":"edge_upgrade_required","message":"query tools require Explore-capable Edge"}}`) + })) + t.Cleanup(server.Close) + newClientFn = func() (*flashduty.Client, error) { + return flashduty.NewClient("test", flashduty.WithBaseURL(server.URL)) + } + _, err := execCommand("monit-query", "42", "--tool", "prometheus.query", + "--params", `{"expr":"up","execution":{"kind":"instant","to_ms":1757462400000}}`) + var apiErr *flashduty.ErrorResponse + if !errors.As(err, &apiErr) || apiErr.Reason != "edge_upgrade_required" || calls.Load() != 1 { + t.Fatalf("error lost or replayed: %v, calls=%d", err, calls.Load()) + } + if !strings.Contains(err.Error(), "edge_upgrade_required") || !strings.Contains(err.Error(), "trace-query") { + t.Fatalf("CLI error omitted reason/request ID: %v", err) } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - args := map[string]string{"victorialogs.start": tc.input, "victorialogs.end": tc.input} - if err := normalizeRawTimeArgs("victorialogs", args); err != nil { - t.Fatalf("normalizeRawTimeArgs(%q): unexpected error: %v", tc.input, err) +} + +// The retired `monit-query data` subcommand (and the long-gone `diagnose`) +// must fail before any request: monit-query is now a leaf tool-invoke command. +func TestRetiredMonitQuerySubcommandsRejectBeforeRequest(t *testing.T) { + for _, args := range [][]string{ + {"monit-query", "data", "--ds-type", "prometheus", "--ds-name", "prom-prod", "--expr", "up"}, + {"monit-query", "data"}, + {"monit-query", "diagnose"}, + } { + t.Run(strings.Join(args, " "), func(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + _, err := execCommand(args...) + if err == nil { + t.Fatal("retired command form succeeded") } - for _, key := range []string{"victorialogs.start", "victorialogs.end"} { - if _, err := strconv.ParseInt(args[key], 10, 64); err != nil { - t.Errorf("%s: expected normalized unix-seconds string, got %q", key, args[key]) - } + if stub.requests != 0 { + t.Fatalf("retired command form sent %d requests", stub.requests) } }) } } -func TestNormalizeRawTimeArgsLokiPrefix(t *testing.T) { - args := map[string]string{"loki.start": "2026-08-11T09:40:00Z", "loki.end": "2026-08-11T10:05:00Z"} - if err := normalizeRawTimeArgs("loki", args); err != nil { - t.Fatalf("unexpected error: %v", err) - } - wantStart := strconv.FormatInt(time.Date(2026, 8, 11, 9, 40, 0, 0, time.UTC).Unix(), 10) - wantEnd := strconv.FormatInt(time.Date(2026, 8, 11, 10, 5, 0, 0, time.UTC).Unix(), 10) - if args["loki.start"] != wantStart || args["loki.end"] != wantEnd { - t.Errorf("unexpected normalized loki args: %#v, want start=%s end=%s", args, wantStart, wantEnd) - } -} - -func TestNormalizeRawTimeArgsIgnoresOtherDsTypes(t *testing.T) { - args := map[string]string{"prometheus.start": "not-a-time"} - if err := normalizeRawTimeArgs("prometheus", args); err != nil { - t.Fatalf("unexpected error: %v", err) - } - if args["prometheus.start"] != "not-a-time" { - t.Errorf("expected prometheus args untouched, got %#v", args) - } -} - -func TestNormalizeRawTimeArgsIgnoresUnrelatedKeys(t *testing.T) { - args := map[string]string{"victorialogs.type": "raw", "victorialogs.timespan.value": "15"} - if err := normalizeRawTimeArgs("victorialogs", args); err != nil { - t.Fatalf("unexpected error: %v", err) - } - if args["victorialogs.type"] != "raw" || args["victorialogs.timespan.value"] != "15" { - t.Errorf("expected unrelated args untouched, got %#v", args) - } -} - -func TestNormalizeRawTimeArgsInvalidValue(t *testing.T) { - args := map[string]string{"victorialogs.start": "not-a-time"} - err := normalizeRawTimeArgs("victorialogs", args) - if err == nil { - t.Fatal("expected error for invalid victorialogs.start, got nil") - } - if !strings.Contains(err.Error(), "victorialogs.start") { - t.Errorf("expected error to mention victorialogs.start, got %q", err.Error()) - } -} - -// TestMonitQueryDataRawModeNormalizesRFC3339 is the regression test for the -// raw-vs-stats time format inconsistency: a raw-mode VictoriaLogs query given -// RFC3339 --args timestamps must reach the server as the unix-seconds form -// the raw query path requires. -func TestMonitQueryDataRawModeNormalizesRFC3339(t *testing.T) { - saveAndResetGlobals(t) - stub := newGFStub(t) - stub.data = map[string]any{"format": "query_result.v1", "result": map[string]any{"kind": "records", "records": []any{}}} - - _, err := execCommand( - "monit-query", "data", - "--ds-type", "victorialogs", - "--ds-name", "vl-prod", - "--expr", `{app="api"} |= "error"`, - "--args", "victorialogs.type=raw", - "--args", "victorialogs.start=2026-08-11T09:40:00Z", - "--args", "victorialogs.end=2026-08-11T10:05:00Z", - ) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - body := stub.lastBody - argsSent, _ := body["args"].(map[string]any) - start, ok := argsSent["victorialogs.start"].(string) - if !ok { - t.Fatalf("expected victorialogs.start in request args, got %#v", argsSent) - } - if _, err := strconv.ParseInt(start, 10, 64); err != nil { - t.Errorf("expected victorialogs.start to be unix-seconds, got %q", start) - } - end, ok := argsSent["victorialogs.end"].(string) - if !ok { - t.Fatalf("expected victorialogs.end in request args, got %#v", argsSent) - } - if _, err := strconv.ParseInt(end, 10, 64); err != nil { - t.Errorf("expected victorialogs.end to be unix-seconds, got %q", end) - } - wantStart := time.Date(2026, 8, 11, 9, 40, 0, 0, time.UTC).Unix() - wantEnd := time.Date(2026, 8, 11, 10, 5, 0, 0, time.UTC).Unix() - if start != strconv.FormatInt(wantStart, 10) || end != strconv.FormatInt(wantEnd, 10) { - t.Errorf("expected start=%d end=%d, got start=%s end=%s", wantStart, wantEnd, start, end) - } -} - -func TestMonitQueryDataInvalidArgs(t *testing.T) { - saveAndResetGlobals(t) - stub := newGFStub(t) - - _, err := execCommand( - "monit-query", "data", - "--ds-type", "prometheus", - "--ds-name", "prom-prod", - "--expr", "up", - "--args", "no-equals-sign", - ) - if err == nil { - t.Fatal("expected error for malformed --args, got nil") - } - if !strings.Contains(err.Error(), "--args") { - t.Errorf("expected error to mention --args, got %q", err.Error()) - } - if stub.requests != 0 { - t.Errorf("data should not have been called: %d request(s)", stub.requests) +func TestRetiredMonitCommandsRejectBeforeRequest(t *testing.T) { + for _, args := range [][]string{ + {"monit", "query-diagnose"}, + {"monit", "rule-counter-status"}, + {"monit", "store-ruleset-create"}, {"monit", "store-ruleset-update"}, + {"monit", "store-ruleset-list"}, {"monit", "store-ruleset-info"}, {"monit", "store-ruleset-delete"}, + } { + t.Run(strings.Join(args, " "), func(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + _, err := execCommand(args...) + if err == nil || !strings.Contains(err.Error(), "unknown command") { + t.Fatalf("retired command error=%v", err) + } + if stub.requests != 0 { + t.Fatalf("retired command sent %d requests", stub.requests) + } + }) } } diff --git a/internal/cli/zz_generated_alert_rules.go b/internal/cli/zz_generated_alert_rules.go index a5f494b..ddf2dc9 100644 --- a/internal/cli/zz_generated_alert_rules.go +++ b/internal/cli/zz_generated_alert_rules.go @@ -149,39 +149,6 @@ API: POST /monit/rule/counter/channel (monit-rule-read-counter-channel) return cmd } -func genAlertRulesReadCounterNodeCmd() *cobra.Command { - var dataJSON string - cmd := &cobra.Command{ - Use: "rule-counter-node", - Short: "Get rule counts by folder node", - Long: `Get rule counts by folder node. - -Return an object mapping top-level folder name to the total number of rules under that folder and all its descendants. - -API: POST /monit/rule/counter/node (monit-rule-read-counter-node) -`, - Example: ` flashduty monit rule-counter-node --data '{}'`, - RunE: func(cmd *cobra.Command, args []string) error { - return runCommand(cmd, args, func(ctx *RunContext) error { - body, err := genAssembleBody(dataJSON, func(body map[string]any) error { - return nil - }) - if err != nil { - return err - } - _ = body - out, _, err := ctx.Client.AlertRules.ReadCounterNode(cmdContext(ctx.Cmd)) - if err != nil { - return err - } - return printGenericResult(ctx, out) - }) - }, - } - cmd.Flags().StringVar(&dataJSON, "data", "", "Full request body as JSON; positional arguments and typed flags override its fields. Accepts inline JSON, or - to read stdin.") - return cmd -} - func genAlertRulesReadCounterTotalCmd() *cobra.Command { var dataJSON string cmd := &cobra.Command{ @@ -221,46 +188,6 @@ Response fields ('data' is a TOP-LEVEL array of these row objects — pipe 'jq ' return cmd } -func genAlertRulesReadDstypesCmd() *cobra.Command { - var dataJSON string - cmd := &cobra.Command{ - Use: "rule-dstypes", - Short: "List available datasource types", - Long: `List available datasource types. - -Return the list of datasource types ('DSType' records) that the current account can use when authoring alert rules — combines global types and account-scoped types. - -API: POST /monit/rule/dstypes (monit-rule-read-dstypes) - -Response fields ('data' is a TOP-LEVEL array of these row objects — pipe 'jq '.[]'', NOT '.items[]'): - - account_id (integer) (required) — Owning account ID. '0' for global types. - - id (integer) (required) — ID of the datasource type record. - - ident (string) (required) — Identifier used as the 'ds_type' of rules, e.g. 'prometheus'. - - name (string) (required) — Display name, e.g. 'Prometheus'. - - weight (integer) (required) — Display order weight; higher appears first. -`, - Example: ` flashduty monit rule-dstypes --data '{}'`, - RunE: func(cmd *cobra.Command, args []string) error { - return runCommand(cmd, args, func(ctx *RunContext) error { - body, err := genAssembleBody(dataJSON, func(body map[string]any) error { - return nil - }) - if err != nil { - return err - } - _ = body - out, _, err := ctx.Client.AlertRules.ReadDstypes(cmdContext(ctx.Cmd)) - if err != nil { - return err - } - return printGenericResult(ctx, out) - }) - }, - } - cmd.Flags().StringVar(&dataJSON, "data", "", "Full request body as JSON; positional arguments and typed flags override its fields. Accepts inline JSON, or - to read stdin.") - return cmd -} - func genAlertRulesReadExportCmd() *cobra.Command { var dataJSON string var fIDs []int @@ -369,96 +296,104 @@ Response fields ('data' is a TOP-LEVEL array of these row objects — pipe 'jq ' return cmd } -func genAlertRulesReadInfoCmd() *cobra.Command { +func genAlertRulesReadInfoV2Cmd() *cobra.Command { var dataJSON string var fID int64 cmd := &cobra.Command{ - Use: "rule-info", - Short: "Get alert rule detail", - Long: `Get alert rule detail. + Use: "rule-v2-info", + Short: "Get alert rule detail (V2)", + Long: `Get alert rule detail (V2). -Return the full configuration of an alert rule by its ID, including rule queries, thresholds, and notification settings. +Return the full V2 configuration of an alert rule by ID, including lifecycle v2 recovery and ending modes. -API: POST /monit/rule/info (monit-rule-read-info) +API: POST /monit/rule/v2/info (monit-rule-read-info-v2) Request fields: --id int (required) — Alert rule ID. Obtainable per folder via 'POST /monit/rule/list/basic'. Response fields ('data' envelope is unwrapped — these fields are at the top level): - - account_id (integer) (required) — Account ID. Filled by the server from the authenticated identity; do not provide. - - annotations (object) — Annotation key-value pairs delivered with alert events; keys must not start with '$' (reserved for query fields). - - channel_ids (array) — Channel IDs to send alerts to. - - created_at (string) (required) — Creation time as a Unix timestamp in seconds. Generated by the server; do not provide. CLI '--json' renders this as an RFC3339 string in the process's local timezone (NOT UTC, and NOT the wire integer); an unset value renders as null. - - creator_id (integer) (required) — Creator user ID. Filled by the server from the current user; do not provide. - - creator_name (string) (required) — Creator name. Filled by the server; do not provide. - - cron_pattern (string) (required) — Schedule expression: a 6-field cron (with seconds) or an '@every 30s' interval descriptor. Must not start with 'CRON_TZ=' or 'TZ='; use the 'timezone' field instead. - - debug_log_enabled (boolean) (required) — Whether to enable debug logging; the edge emits detailed evaluation logs, useful for troubleshooting rules that do not trigger as expected. - - delay_seconds (integer) (required) — Seconds to shift the evaluation query window backward, compensating for data ingestion latency. - - description (string) — Rule description, in Markdown. - - description_type (string) — Format for the description. Defaults to 'text' when omitted or empty. 'text' = plain text; 'markdown' = Markdown, rendered as Markdown in alert details. [text, markdown] - - ds_ids (array) — Datasource IDs, merged with 'ds_list' to decide which datasources the rule monitors; IDs survive datasource renames. At least one of 'ds_list' and 'ds_ids' must be provided. - - ds_list (array) — Data source name patterns (supports wildcards). At least one of 'ds_list' / 'ds_ids' must be non-empty; the two are merged to decide which datasources the rule monitors. - - ds_type (string) (required) — Datasource type identifier; allowed values are listed by 'POST /monit/rule/dstypes' (e.g. 'prometheus', 'elasticsearch'). - - enabled (boolean) (required) — Whether the rule is enabled. Updating to 'false' makes the server clean up the rule's active alerts. - - enabled_times (array) — Time windows when the rule is active. Defaults to all days from 00:00 to 23:59 when omitted or empty. + - account_id (integer) — Account ID, filled by the server from the authentication context; any client-supplied value is ignored. + - annotations (object) — Extra annotation key-value pairs delivered with alert events; keys must not start with '$' (reserved for query fields). + - channel_ids (array) — Collaboration space IDs alerts are sent to. May be empty; alerts then route through the global integration. + - created_at (integer) — Creation time as a Unix timestamp in seconds, generated by the server; any client-supplied value is ignored. + - creator_id (integer) — Creator member ID, filled by the server from the current user; any client-supplied value is ignored. + - creator_name (string) — Creator name, filled by the server; any client-supplied value is ignored. + - cron_pattern (string) (required) — Schedule expression: a 6-field cron (with seconds) or an '@every 30s' interval. Must not start with 'CRON_TZ=' or 'TZ='; set the timezone in the 'timezone' field instead. + - debug_log_enabled (boolean) — Enable debug logging; the edge then emits detailed evaluation logs for this rule, useful when the rule does not trigger as expected. + - delay_seconds (integer) — Seconds the evaluation query window is shifted back, compensating for data ingestion latency. + - description (string) — Rule description, Markdown format. + - description_type (string) — Format of the description content. Empty or omitted defaults to 'text'. 'text' = plain text; 'markdown' = Markdown, rendered as such in alert details. [text, markdown] + - ds_ids (array) — Datasource ID list, merged with 'ds_list' to decide the monitored datasources; IDs survive datasource renames. At least one of 'ds_list' / 'ds_ids' must be provided. + - ds_list (array) — Datasource name match patterns (wildcards supported). At least one of 'ds_list' / 'ds_ids' must be non-empty; both are merged to decide which datasources the rule monitors. + - ds_type (string) (required) — Datasource type identifier (e.g. 'prometheus', 'elasticsearch'). + - enabled (boolean) (required) — Whether the rule is enabled. Required — the server enforces an explicit value (including 'false') while decoding. Setting it to 'false' on update clears the rule's active alerts. + - enabled_times (array) — Time windows during which the rule is in effect. When omitted or empty, the rule is active 00:00–23:59 every day. - days (array) — Days of week (0=Sunday). - etime (string) — End time, e.g. '18:00'. - stime (string) — Start time, e.g. '09:00'. - - folder_id (integer) (required) — ID of the folder the rule belongs to. Obtainable via 'POST /monit/folder/list'. - - id (integer) (required) — Rule ID. Required for update; omit for create (assigned by the server). + - folder_id (integer) (required) — ID of the folder the rule belongs to; list folders via 'POST /monit/folder/list'. Cannot be changed through the update API — use '/monit/rule/move' instead. + - id (integer) — Rule ID. Required on update; omit on create (assigned by the server). + - investigation_targets (array) — Drill-down entries linked from the alert event detail page; at most 20 items, duplicates rejected. On update the field is presence-based: omit it to keep the current value, pass '[]' to clear. + - dashboard (object) — Configuration for the 'dashboard' kind; required when 'kind' is 'dashboard'. + - dashboard_id (string) (required) — Target dashboard ID; must be a canonical UUIDv7. + - target_id (string) — Panel ID inside the dashboard; must be a canonical UUIDv7. Optional. + - variable_bindings (object) — Dashboard variable bindings, keyed by dashboard variable name. + - kind (string) (required) — Entry type; currently only 'dashboard' is supported. [dashboard] - labels (object) — Custom labels. - - name (string) (required) — Rule name. Must be unique within the same folder. - - repeat_interval (integer) — Notification repeat interval in seconds. - - repeat_total (integer) — Max number of repeat notifications. - - rule_configs (object) (required) — Check configuration: query list plus trigger/recovery conditions. Structure see 'RuleConfigs'. - - check_anydata (object) — Any-data check configuration. Fires when the query returns any data rows. - - alerting_check_times (integer) — Number of consecutive evaluations that must satisfy the condition before alerting; minimum 1. - - enabled (boolean) — Whether any-data checking is enabled: any returned data row triggers an alert. - - push_recovery_event (boolean) — Whether to push a recovery event notification when the alert resolves. - - recovery (object) — Recovery condition for any-data check. If omitted or 'mode' is empty, treated as 'nodata'. - - args (object) — Datasource-specific options for the recovery query, same convention as 'queries[].args'; required for Elasticsearch datasources when 'mode' is 'ql'. - - condition (string) — Recovery expression. Required when 'mode' is 'ql'. - - mode (string) — 'nodata' = recover when the query returns no data; 'ql' = recover when the 'condition' expression evaluates to true. When 'mode' is 'ql', only a single query ('name=A') is permitted. [nodata, ql] - - recovery_check_times (integer) — Number of consecutive evaluations that must satisfy the recovery condition before resolving; minimum 1. - - severity (string) — Severity of any-data alert events; case-sensitive. [Critical, Warning, Info] - - check_nodata (object) — No-data check configuration. - - alert_on_empty_result (boolean) — Whether to trigger an alert when every query returns an empty result. - - alert_on_empty_result_severity (string) — Severity of empty-result alerts, case-sensitive; only effective when 'alert_on_empty_result' is enabled. [Critical, Warning, Info] - - alerting_check_times (integer) — Number of consecutive evaluations that must satisfy the condition before alerting; minimum 1. - - enabled (boolean) — Whether no-data checking is enabled: a previously-seen series that stops returning data triggers an alert. - - push_recovery_event (boolean) — Whether to push a recovery event notification when the alert resolves. - - recovery_check_times (integer) — Number of consecutive evaluations that must satisfy the recovery condition before resolving; minimum 1. - - resolve_timeout (integer) — Auto-resolve after N seconds. - - severity (string) — Severity of no-data alert events; case-sensitive. [Critical, Warning, Info] - - check_threshold (object) — Threshold check configuration. - - alerting_check_times (integer) — Number of consecutive evaluations that must satisfy the condition before alerting; minimum 1. - - critical (string) — Critical threshold expression referencing query results via '$' or '$.', e.g. '$A > 90'; at least one severity must be configured. - - enabled (boolean) — Whether threshold checking is enabled. - - info (string) — Info threshold expression, same syntax as 'critical'. - - push_recovery_event (boolean) — Whether to push a recovery event notification when the alert resolves. - - recovery (object) — Recovery evaluation configuration for threshold checks. - - args (object) — Datasource-specific extra parameters for the recovery query, using the same '.' key convention as query 'args'. Omitted when empty. - - condition (string) — Recovery condition expression; required when 'mode' is 'threshold' or 'ql', and must be empty for 'invert'. - - mode (string) — Recovery mode: 'invert' = resolve when the alert expression no longer holds ('condition' stays empty); 'threshold' = resolve when the 'condition' threshold expression holds; 'ql' = resolve when the 'condition' query expression evaluates true. [invert, threshold, ql] - - value_fields (array) — Numeric result fields the recovery 'condition' references as '$A.'; same semantics as the query's 'value_fields'. Omitted when empty. - - recovery_check_times (integer) — Number of consecutive evaluations that must satisfy the recovery condition before resolving; minimum 1. - - warning (string) — Warning threshold expression, same syntax as 'critical'. - - queries (array) (required) — Query list with at least one entry; each needs a unique 'name' ('R' and '__all__' are reserved) and a non-empty, non-duplicate 'expr'. - - args (object) — Datasource-specific query options keyed by the '.