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.14.5
github.com/flashcatcloud/go-flashduty v0.15.0
github.com/mattn/go-runewidth v0.0.28
github.com/spf13/cobra v1.10.2
github.com/spf13/pflag v1.0.10
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,8 +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.14.5 h1:MNiKJTogpO9MDU50rN5G3OUXpq7O3Tv+l4iNw2SJyGY=
github.com/flashcatcloud/go-flashduty v0.14.5/go.mod h1:YpHiTYXR5NXBI/rGRZfUy537XMkhdCkwA8NW1QoRHwk=
github.com/flashcatcloud/go-flashduty v0.15.0 h1:aI7fQcCgppbJfQewkBR1g1eGA+N+GzZMF9hdCNint1I=
github.com/flashcatcloud/go-flashduty v0.15.0/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
57 changes: 57 additions & 0 deletions internal/cli/datasource_tool_output.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package cli

import (
"bytes"
"encoding/json"
"strconv"

"github.com/flashcatcloud/go-flashduty"

"github.com/flashcatcloud/flashduty-cli/internal/output"
)

// TOON and the table renderer treat RawMessage as []byte. Expand only the new
// tool envelope at this presentation boundary; JSON output stays byte-exact.
func datasourceToolOutput(value any, format output.Format) (any, error) {
if _, ok := value.(*flashduty.DatasourceToolResult); !ok || format == output.FormatJSON {
return value, nil
}
raw, err := json.Marshal(value)
if err != nil {
return nil, err
}
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.UseNumber()
var decoded any
if err := decoder.Decode(&decoded); err != nil {
return nil, err
}
return toolDisplayNumbers(decoded), nil
}

// toon-go rounds json.Number through float64. Preserve integer width and use
// decimal strings for values its numeric representation cannot retain.
func toolDisplayNumbers(value any) any {
switch v := value.(type) {
case json.Number:
if n, err := strconv.ParseInt(v.String(), 10, 64); err == nil {
return n
}
if n, err := strconv.ParseUint(v.String(), 10, 64); err == nil {
return n
}
if n, err := strconv.ParseFloat(v.String(), 64); err == nil && strconv.FormatFloat(n, 'g', -1, 64) == v.String() {
return n
}
return v.String()
case map[string]any:
for key, item := range v {
v[key] = toolDisplayNumbers(item)
}
case []any:
for i, item := range v {
v[i] = toolDisplayNumbers(item)
}
}
return value
}
158 changes: 158 additions & 0 deletions internal/cli/datasource_tools_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
package cli

import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"

"github.com/flashcatcloud/go-flashduty"
"github.com/toon-format/toon-go"
)

func TestDatasourceToolInvokeStdinPreservesJSON(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":"tools-test","data":{"datasource_id":42,"tool":"mongodb_mongod.command","data":{"counter":9007199254740993,"nested":[null,false,0]},"summary":"evidence"}}`)
}))
t.Cleanup(server.Close)
newClientFn = func() (*flashduty.Client, error) {
return flashduty.NewClient("test", flashduty.WithBaseURL(server.URL))
}
stdinReader = strings.NewReader(`{"datasource_id":42,"tool":"mongodb_mongod.command","params":{"command":{"count":"events","query":{"counter":9007199254740993}},"database":"app"}}`)
out, err := execCommand("monit", "datasource-tools-invoke", "--data", "-", "--output-format", "json")
if err != nil {
t.Fatal(err)
}
if raw := <-requests; !strings.Contains(raw, `"counter":9007199254740993`) || strings.Contains(raw, "9007199254740992") {
t.Fatalf("request lost numeric precision: %s", raw)
}
var result struct {
DatasourceID uint64 `json:"datasource_id"`
Tool string `json:"tool"`
Data json.RawMessage `json:"data"`
}
if err := json.Unmarshal([]byte(out), &result); err != nil {
t.Fatal(err)
}
if result.DatasourceID != 42 || result.Tool != "mongodb_mongod.command" || !strings.Contains(string(result.Data), "9007199254740993") {
t.Fatalf("response lost identity or 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) {
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(status)
_, _ = io.WriteString(w, `{"request_id":"trace-tools","error":{"code":"ServiceUnavailable","reason":"edge_upgrade_required","message":"upgrade Edge to v0.71.0"}}`)
}))
t.Cleanup(server.Close)
newClientFn = func() (*flashduty.Client, error) {
return flashduty.NewClient("test", flashduty.WithBaseURL(server.URL))
}
_, err := execCommand("monit", "datasource-tools-invoke", "42", "--tool", "redis_node.overview", "--json")
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-tools") {
t.Fatalf("CLI error omitted reason/request ID: %v", err)
}
})
}
}

func TestDatasourceWritePreservesFalseFlagsAndOmission(t *testing.T) {
for _, command := range []string{"datasource-create", "datasource-update"} {
for _, explicit := range []bool{false, true} {
t.Run(fmt.Sprintf("%s/explicit=%v", command, explicit), func(t *testing.T) {
saveAndResetGlobals(t)
stub := newGFStub(t)
args := []string{"monit", command, "--data", `{"id":42,"name":"cache","type_ident":"redis_node","address":"redis:6379","edge_cluster_name":"edge","payload":{"redis_node":{"database":0}}}`}
if explicit {
args = append(args, "--enabled=false", "--alerting-enabled=false")
}
if _, err := execCommand(args...); err != nil {
t.Fatal(err)
}
for _, field := range []string{"enabled", "alerting_enabled"} {
value, present := stub.lastBody[field]
if present != explicit || (explicit && value != false) {
t.Fatalf("%s changed presence/value: %+v", field, stub.lastBody)
}
}
})
}
}
}

func TestDataBodyRejectsMultipleValues(t *testing.T) {
for _, raw := range []string{`{} {}`, `{} garbage`, `null`} {
if _, err := genAssembleBody(raw, func(map[string]any) error { return nil }); err == nil {
t.Fatalf("invalid body accepted: %s", raw)
}
}
}

func TestDatasourceWriteRejectsExplicitNull(t *testing.T) {
for _, command := range []string{"datasource-create", "datasource-update"} {
for _, field := range []string{"enabled", "alerting_enabled"} {
t.Run(command+"/"+field, func(t *testing.T) {
saveAndResetGlobals(t)
stub := newGFStub(t)
body := fmt.Sprintf(`{"id":42,"name":"cache","type_ident":"redis_node","address":"redis:6379","edge_cluster_name":"edge","payload":{"redis_node":{}},%q:null}`, field)
_, err := execCommand("monit", command, "--data", body)
if err == nil || !strings.Contains(err.Error(), field+" must not be null") || stub.requests != 0 {
t.Fatalf("null became omission: err=%v requests=%d", err, stub.requests)
}
})
}
}
}

func TestDatasourceToolTOONPreservesEvidence(t *testing.T) {
saveAndResetGlobals(t)
stub := newGFStub(t)
stub.data = json.RawMessage(`{"datasource_id":42,"tool":"redis_node.overview","data":{"counter":9007199254740993,"unsigned":18446744073709551615,"ratio":0.1234567890123456789,"rate":1.25,"nested":[null,false,{"ready":true}]}}`)
out, err := execCommand("monit", "datasource-tools-invoke", "42", "--tool", "redis_node.overview", "--output-format", "toon")
if err != nil {
t.Fatal(err)
}
var got map[string]any
if err := toon.Unmarshal([]byte(out), &got); err != nil {
t.Fatalf("invalid TOON: %v: %s", err, out)
}
data, ok := got["data"].(map[string]any)
if !ok {
t.Fatalf("tool data is not an object: %s", out)
}
for key, want := range map[string]string{"counter": "9007199254740993", "unsigned": "18446744073709551615", "ratio": "0.1234567890123456789"} {
if data[key] != want {
t.Errorf("%s lost precision: %v; output=%s", key, data[key], out)
}
}
nested, ok := data["nested"].([]any)
if !ok || len(nested) != 3 || nested[0] != nil || nested[1] != false || nested[2].(map[string]any)["ready"] != true {
t.Fatalf("nested evidence changed: %s", out)
}
if fmt.Sprint(data["rate"]) != "1.25" {
t.Fatalf("ordinary rate changed: %s", out)
}
}
28 changes: 27 additions & 1 deletion internal/cli/gen_support.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,9 +113,21 @@ func genAssembleBody(dataFlag string, setFlags func(body map[string]any) error)
}
body := map[string]any{}
if dataJSON != "" {
if err := json.Unmarshal([]byte(dataJSON), &body); err != nil {
// Preserve integers inside datasource tool params (e.g. MongoDB
// filters) until the typed SDK binds them. float64 would silently
// round values above 2^53 before RawMessage can preserve the payload.
decoder := json.NewDecoder(strings.NewReader(dataJSON))
decoder.UseNumber()
if err := decoder.Decode(&body); err != nil {
return nil, fmt.Errorf("invalid --data JSON: %w", err)
}
var extra any
if err := decoder.Decode(&extra); err != io.EOF {
return nil, fmt.Errorf("invalid --data JSON: expected one JSON object")
}
if body == nil {
return nil, fmt.Errorf("invalid --data JSON: expected an object")
}
}
if err := setFlags(body); err != nil {
return nil, err
Expand Down Expand Up @@ -331,6 +343,11 @@ func bindURLTagged(body map[string]any, rv reflect.Value) {
// (renderGenericTable), since generated commands carry no hand-written column
// set; anything that isn't a list or object falls back to indented JSON.
func printGenericResult(ctx *RunContext, data any) error {
var err error
data, err = datasourceToolOutput(data, currentOutputFormat())
if err != nil {
return err
}
if ctx.Structured() {
return printBoundedGenericResult(ctx, data)
}
Expand Down Expand Up @@ -558,3 +575,12 @@ func genAddLeaf(parent *cobra.Command, leaf *cobra.Command) {
}
parent.AddCommand(leaf)
}

// genRejectNullField retains the backend distinction between an omitted
// presence-sensitive field and an explicitly invalid null before SDK binding.
func genRejectNullField(body map[string]any, field string) error {
if value, present := body[field]; present && value == nil {
return fmt.Errorf("%s must not be null", field)
}
return nil
}
34 changes: 25 additions & 9 deletions internal/cli/monit_agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import (
)

func newMonitAgentCmd() *cobra.Command {
cmd := newGroupCmd("monit-agent", "On-box diagnostics via flashmonit agents (host/mysql/redis/…)")
cmd := newGroupCmd("monit-agent", "Host diagnostics via flashmonit agents; database diagnostics use monit datasource-tools-invoke")
cmd.AddCommand(newMonitAgentCatalogCmd())
cmd.AddCommand(newMonitAgentInvokeCmd())
return cmd
Expand All @@ -25,6 +25,9 @@ func newMonitAgentCatalogCmd() *cobra.Command {
if targetLocator == "" {
return fmt.Errorf("--target-locator is required")
}
if err := validateMonitAgentKind(targetKind); err != nil {
return err
}
return runCommand(cmd, args, func(ctx *RunContext) error {
input := &flashduty.ToolCatalogRequest{
TargetKind: targetKind,
Expand All @@ -39,8 +42,8 @@ func newMonitAgentCatalogCmd() *cobra.Command {
},
}

cmd.Flags().StringVar(&targetKind, "target-kind", "", "Target kind (host|mysql|redis|…); omit to let the agent infer")
cmd.Flags().StringVar(&targetLocator, "target-locator", "", "Target locator: internal IP, hostname, or data-source name (required)")
cmd.Flags().StringVar(&targetKind, "target-kind", "", "Target kind: host; omit to use host routing")
cmd.Flags().StringVar(&targetLocator, "target-locator", "", "Host locator: registered internal IP or hostname (required)")

return cmd
}
Expand All @@ -60,17 +63,20 @@ The tools to run are carried in the --data request body:
--data '{"tools":[{"tool":"<name>","params":{<obj>}}, ... up to 8]}'
params is optional and defaults to {}. --data also accepts - to read stdin,
which avoids shell-quoting hell for params JSON that contains commas or quotes
(e.g. SQL). --target-locator (required) and --target-kind override any matching
(e.g. HTTP headers). --target-locator (required) and --target-kind override any matching
keys in --data.

# heredoc form for quoted/comma SQL:
fduty monit-agent invoke --target-locator 'X' --data - <<'FDUTY'
{"tools":[{"tool":"mysql.query","params":{"sql":"SELECT a, b FROM t WHERE s='RUNNING'","max_rows":50}}]}
# heredoc form for host diagnostics:
fduty monit-agent invoke --target-locator 'web-01' --data - <<'FDUTY'
{"tools":[{"tool":"os.overview"}]}
FDUTY`, "Diagnostics", "ToolsInvoke"),
RunE: func(cmd *cobra.Command, args []string) error {
if targetLocator == "" {
return fmt.Errorf("--target-locator is required")
}
if err := validateMonitAgentKind(targetKind); err != nil {
return err
}

// Assemble the body the standard way: --data (inline JSON or -
// stdin) overlaid with the typed --target-* flags, mirroring
Expand Down Expand Up @@ -99,6 +105,9 @@ keys in --data.

return runCommand(cmd, args, func(ctx *RunContext) error {
kind, _ := body["target_kind"].(string)
if err := validateMonitAgentKind(kind); err != nil {
return err
}
input := &flashduty.ToolInvokeRequest{
TargetKind: kind,
TargetLocator: targetLocator,
Expand All @@ -113,8 +122,8 @@ keys in --data.
},
}

cmd.Flags().StringVar(&targetKind, "target-kind", "", "Target kind (host|mysql|redis|…); omit to let the agent infer")
cmd.Flags().StringVar(&targetLocator, "target-locator", "", "Target locator: internal IP, hostname, or data-source name (required)")
cmd.Flags().StringVar(&targetKind, "target-kind", "", "Target kind: host; omit to use host routing")
cmd.Flags().StringVar(&targetLocator, "target-locator", "", "Host locator: registered internal IP or hostname (required)")
cmd.Flags().StringVar(&dataJSON, "data", "", `Request body as JSON carrying the tools to run: {"tools":[{"tool":"<name>","params":{<obj>}}, ... max 8]}. Accepts inline JSON, or - to read stdin.`)

return cmd
Expand Down Expand Up @@ -154,3 +163,10 @@ func parseInvokeTools(raw any) ([]flashduty.ToolInvokeRequestToolsItem, error) {
}
return out, nil
}

func validateMonitAgentKind(kind string) error {
if kind != "" && kind != "host" {
return fmt.Errorf("monit-agent supports host targets only; use monit datasource-tools-invoke for datasource diagnostics")
}
return nil
}
Loading