Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 2 additions & 8 deletions go.sum
Original file line number Diff line number Diff line change
@@ -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=
Expand Down
45 changes: 45 additions & 0 deletions internal/cli/datasource_tools_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
24 changes: 0 additions & 24 deletions internal/cli/helpers.go

This file was deleted.

38 changes: 0 additions & 38 deletions internal/cli/helpers_test.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package cli

import (
"reflect"
"strings"
"testing"
)
Expand Down Expand Up @@ -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)
}
})
}
}
125 changes: 66 additions & 59 deletions internal/cli/monit_query.go
Original file line number Diff line number Diff line change
@@ -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 ('<type>.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 <datasource-id>",
Short: "Invoke a datasource query or diagnostic tool",
Long: curatedLong(`Invoke one query or diagnostic tool against a configured datasource.

Query tools are '<type>.query' where '<type>' 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, <ds-type>.start/<ds-type>.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 '<type>.query' or an Edge-defined diagnostic tool (e.g. 'mysql.overview'). (required)")
_ = cmd.MarkFlagRequired("tool")
cmd.Flags().StringVar(&paramsFlag, "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 (<ds-type>.start / <ds-type>.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
}
Loading