diff --git a/go.mod b/go.mod index 990455b..f34d9e8 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/flashduty-sdk v0.9.0 + github.com/flashcatcloud/flashduty-sdk v0.9.1-0.20260527160039-3203385df5ad github.com/mattn/go-runewidth v0.0.23 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.9 diff --git a/go.sum b/go.sum index 62f1eab..fb44e95 100644 --- a/go.sum +++ b/go.sum @@ -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/flashduty-sdk v0.9.0 h1:gEBt9ZJ8HbDc22U1V4cWPitxlPxfztqKIe2x6TyRqJw= -github.com/flashcatcloud/flashduty-sdk v0.9.0/go.mod h1:dG4eJfdZaj4jNBMwEexbfK/3PmcIMhNeJ88L/DcZzUY= +github.com/flashcatcloud/flashduty-sdk v0.9.1-0.20260527160039-3203385df5ad h1:TreTSjEIGnp2byx4kGj3BN7RT+6ev8w2PCxVhTWbpVY= +github.com/flashcatcloud/flashduty-sdk v0.9.1-0.20260527160039-3203385df5ad/go.mod h1:dG4eJfdZaj4jNBMwEexbfK/3PmcIMhNeJ88L/DcZzUY= 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.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= diff --git a/internal/cli/change.go b/internal/cli/change.go index 0b049b1..77964ba 100644 --- a/internal/cli/change.go +++ b/internal/cli/change.go @@ -21,7 +21,7 @@ func newChangeCmd() *cobra.Command { } func newChangeListCmd() *cobra.Command { - var channelID int64 + var channel string var since, until string var limit, page int @@ -39,13 +39,22 @@ func newChangeListCmd() *cobra.Command { return fmt.Errorf("invalid --until: %w", err) } - result, err := ctx.Client.ListChanges(cmdContext(ctx.Cmd), &flashduty.ListChangesInput{ - ChannelID: channelID, + input := &flashduty.ListChangesInput{ StartTime: startTime, EndTime: endTime, Limit: limit, Page: page, - }) + } + + if channel != "" { + channelIDs, err := parseIntSlice(channel) + if err != nil { + return fmt.Errorf("invalid --channel: %w", err) + } + input.ChannelIDs = channelIDs + } + + result, err := ctx.Client.ListChanges(cmdContext(ctx.Cmd), input) if err != nil { return err } @@ -63,7 +72,7 @@ func newChangeListCmd() *cobra.Command { }, } - cmd.Flags().Int64Var(&channelID, "channel", 0, "Filter by channel ID") + cmd.Flags().StringVar(&channel, "channel", "", "Comma-separated channel IDs") cmd.Flags().StringVar(&since, "since", "24h", "Start time") cmd.Flags().StringVar(&until, "until", "now", "End time") cmd.Flags().IntVar(&limit, "limit", 20, "Max results") diff --git a/internal/cli/change_test.go b/internal/cli/change_test.go new file mode 100644 index 0000000..a7f7a0d --- /dev/null +++ b/internal/cli/change_test.go @@ -0,0 +1,48 @@ +package cli + +import ( + "testing" +) + +// TestChangeListChannelFlag verifies that --channel is a string flag (comma-separated IDs), +// not a singular int64 flag. Mirrors the alert list --channel pattern. +func TestChangeListChannelFlag(t *testing.T) { + cmd := newChangeListCmd() + flags := cmd.Flags() + + f := flags.Lookup("channel") + if f == nil { + t.Fatal("flag --channel not registered") + } + + // Must be a string flag (Value.Type() == "string"), not int64. + if got := f.Value.Type(); got != "string" { + t.Errorf("--channel flag type = %q, want %q", got, "string") + } + + // Default must be empty string (not "0"). + if got := f.DefValue; got != "" { + t.Errorf("--channel default = %q, want %q", got, "") + } +} + +// TestChangeListChannelParsing verifies that a comma-separated --channel value +// is correctly parsed to []int64 via parseIntSlice — the same helper used by +// alert list. Full comma-split semantics are covered by TestParseIntSlice in +// helpers_test.go; this test only confirms the wiring is correct. +func TestChangeListChannelParsing(t *testing.T) { + // parseIntSlice is the shared helper; spot-check the three-value case. + got, err := parseIntSlice("100,200,300") + if err != nil { + t.Fatalf("parseIntSlice(\"100,200,300\"): unexpected error: %v", err) + } + want := []int64{100, 200, 300} + if len(got) != len(want) { + t.Fatalf("length mismatch: got %d, want %d", len(got), len(want)) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("index %d: got %d, want %d", i, got[i], want[i]) + } + } +} diff --git a/internal/cli/command_test.go b/internal/cli/command_test.go index 96e6c2f..7b5ac04 100644 --- a/internal/cli/command_test.go +++ b/internal/cli/command_test.go @@ -282,6 +282,28 @@ func (m *mockClient) DeleteTeam(context.Context, *flashduty.TeamDeleteInput) err return fmt.Errorf("mockClient: DeleteTeam not implemented") } +func (m *mockClient) CreateMCPServer(context.Context, *flashduty.CreateMCPServerInput) (*flashduty.CreateMCPServerOutput, error) { + return nil, fmt.Errorf("mockClient: CreateMCPServer not implemented") +} + +// CLI Phase 2: monit-query +func (m *mockClient) MonitQueryDiagnose(context.Context, *flashduty.MonitQueryDiagnoseInput) (*flashduty.MonitQueryDiagnoseOutput, error) { + return nil, fmt.Errorf("mockClient: MonitQueryDiagnose not implemented") +} + +func (m *mockClient) MonitQueryRows(context.Context, *flashduty.MonitQueryRowsInput) (*flashduty.MonitQueryRowsOutput, error) { + return nil, fmt.Errorf("mockClient: MonitQueryRows not implemented") +} + +// CLI Phase 2: monit-agent +func (m *mockClient) MonitAgentCatalog(context.Context, *flashduty.MonitAgentCatalogInput) (*flashduty.MonitAgentCatalogOutput, error) { + return nil, fmt.Errorf("mockClient: MonitAgentCatalog not implemented") +} + +func (m *mockClient) MonitAgentInvoke(context.Context, *flashduty.MonitAgentInvokeInput) (*flashduty.MonitAgentInvokeOutput, error) { + return nil, fmt.Errorf("mockClient: MonitAgentInvoke not implemented") +} + // saveAndResetGlobals saves the current state of all global vars that commands // mutate, resets them to safe defaults, and returns a restore function for // t.Cleanup. @@ -352,6 +374,16 @@ func resetFlagSet(flags *pflag.FlagSet) { case "bool", "int", "int64", "string": _ = flag.Value.Set(flag.DefValue) flag.Changed = false + case "stringSlice", "stringArray": + // Slice-valued flags accumulate across Parse() calls; clear them + // explicitly so a later test isn't observing the previous test's + // repeated --flag entries. pflag's SliceValue / Append interfaces + // don't expose a "reset to default" — Set("") would append an + // empty entry, so we use Replace([]) to truly empty the slice. + if sv, ok := flag.Value.(pflag.SliceValue); ok { + _ = sv.Replace([]string{}) + flag.Changed = false + } } }) } diff --git a/internal/cli/helpers.go b/internal/cli/helpers.go new file mode 100644 index 0000000..c517c08 --- /dev/null +++ b/internal/cli/helpers.go @@ -0,0 +1,61 @@ +package cli + +import ( + "encoding/json" + "fmt" + "strings" + + flashduty "github.com/flashcatcloud/flashduty-sdk" +) + +// 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 +} + +// parseToolSpecs converts a slice of "name=[,params=]" specs into +// MonitAgentInvokeTool entries. The `name` key is required; `params` is +// optional and defaults to `{}` so the server-side decoder accepts it. Splits +// each spec on ',' first then on the first '=', mirroring parseKVSlice — that +// means params JSON containing commas isn't supported; specs with complex +// params must keep their objects single-keyed. +func parseToolSpecs(specs []string) ([]flashduty.MonitAgentInvokeTool, error) { + out := make([]flashduty.MonitAgentInvokeTool, 0, len(specs)) + for _, s := range specs { + var name string + params := json.RawMessage("{}") + for _, kv := range strings.Split(s, ",") { + i := strings.IndexByte(kv, '=') + if i < 0 { + return nil, fmt.Errorf("missing '=' in %q", kv) + } + k, v := kv[:i], kv[i+1:] + switch k { + case "name": + name = v + case "params": + params = json.RawMessage(v) + default: + return nil, fmt.Errorf("unknown key %q in tool-spec", k) + } + } + if name == "" { + return nil, fmt.Errorf("missing name= in spec %q", s) + } + out = append(out, flashduty.MonitAgentInvokeTool{Tool: name, Params: params}) + } + return out, nil +} diff --git a/internal/cli/helpers_test.go b/internal/cli/helpers_test.go index 1602949..146a62b 100644 --- a/internal/cli/helpers_test.go +++ b/internal/cli/helpers_test.go @@ -1,6 +1,7 @@ package cli import ( + "reflect" "strings" "testing" ) @@ -125,3 +126,40 @@ 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/mcp.go b/internal/cli/mcp.go new file mode 100644 index 0000000..3db09af --- /dev/null +++ b/internal/cli/mcp.go @@ -0,0 +1,87 @@ +package cli + +import ( + "fmt" + "strings" + + flashduty "github.com/flashcatcloud/flashduty-sdk" + "github.com/spf13/cobra" +) + +func newMCPCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "mcp", + Short: "Manage MCP server registrations", + } + cmd.AddCommand(newMCPCreateCmd()) + return cmd +} + +func newMCPCreateCmd() *cobra.Command { + var ( + serverName string + description string + transport string + command string + argsFlag []string + envEntries []string + url string + headerEntries []string + connectTimeout int + callTimeout int + teamID int64 + ) + + cmd := &cobra.Command{ + Use: "create", + Short: "Register an MCP server", + RunE: func(cmd *cobra.Command, args []string) error { + return runCommand(cmd, args, func(ctx *RunContext) error { + if strings.TrimSpace(serverName) == "" { + return fmt.Errorf("--server-name is required") + } + envMap, err := parseKVSlice(envEntries) + if err != nil { + return fmt.Errorf("invalid --env: %w", err) + } + headerMap, err := parseKVSlice(headerEntries) + if err != nil { + return fmt.Errorf("invalid --headers: %w", err) + } + input := &flashduty.CreateMCPServerInput{ + ServerName: serverName, + Description: description, + Transport: transport, + Command: command, + Args: argsFlag, + Env: envMap, + URL: url, + Headers: headerMap, + ConnectTimeout: connectTimeout, + CallTimeout: callTimeout, + TeamID: teamID, + } + result, err := ctx.Client.CreateMCPServer(cmdContext(ctx.Cmd), input) + if err != nil { + return err + } + return ctx.WriteResultJSON(result, + fmt.Sprintf("MCP server registered: %s (status: %s)", result.ServerID, result.Status)) + }) + }, + } + + cmd.Flags().StringVar(&serverName, "server-name", "", "MCP server display name (required)") + cmd.Flags().StringVar(&description, "description", "", "Server description") + cmd.Flags().StringVar(&transport, "transport", "streamable-http", "Transport: stdio|sse|streamable-http") + cmd.Flags().StringVar(&command, "command", "", "Executable (stdio transport)") + cmd.Flags().StringSliceVar(&argsFlag, "args", nil, "Executable args (stdio transport, repeatable)") + cmd.Flags().StringSliceVar(&envEntries, "env", nil, "Env entries KEY=VALUE (repeatable)") + cmd.Flags().StringVar(&url, "url", "", "URL (sse / streamable-http)") + cmd.Flags().StringSliceVar(&headerEntries, "headers", nil, "Header entries KEY=VALUE (repeatable)") + cmd.Flags().IntVar(&connectTimeout, "connect-timeout", 10, "Connection timeout in seconds") + cmd.Flags().IntVar(&callTimeout, "call-timeout", 60, "Tool-call timeout in seconds") + cmd.Flags().Int64Var(&teamID, "team-id", 0, "Team scope (0 = account-scope)") + + return cmd +} diff --git a/internal/cli/mcp_test.go b/internal/cli/mcp_test.go new file mode 100644 index 0000000..0beebac --- /dev/null +++ b/internal/cli/mcp_test.go @@ -0,0 +1,35 @@ +package cli + +import ( + "strings" + "testing" +) + +func TestMCPCreateFlagSurface(t *testing.T) { + cmd := newMCPCreateCmd() + flags := cmd.Flags() + for _, name := range []string{ + "server-name", "description", "transport", + "command", "args", "env", "url", "headers", + "connect-timeout", "call-timeout", "team-id", + } { + if flags.Lookup(name) == nil { + t.Errorf("flag --%s not registered", name) + } + } +} + +func TestMCPCreateRejectsEmptyServerName(t *testing.T) { + saveAndResetGlobals(t) + // The empty-name guard fires inside runCommand before CreateMCPServer is + // ever called, so a no-op stub is sufficient. + newClientFn = func() (flashdutyClient, error) { return &mockClient{}, nil } + + _, err := execCommand("mcp", "create") + if err == nil { + t.Fatal("expected error for empty --server-name, got nil") + } + if !strings.Contains(err.Error(), "--server-name is required") { + t.Fatalf("expected error %q, got %q", "--server-name is required", err.Error()) + } +} diff --git a/internal/cli/monit_agent.go b/internal/cli/monit_agent.go new file mode 100644 index 0000000..cd1f258 --- /dev/null +++ b/internal/cli/monit_agent.go @@ -0,0 +1,96 @@ +package cli + +import ( + "fmt" + + flashduty "github.com/flashcatcloud/flashduty-sdk" + "github.com/spf13/cobra" +) + +func newMonitAgentCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "monit-agent", + Short: "On-box diagnostics via flashmonit agents (host/mysql/redis/…)", + } + cmd.AddCommand(newMonitAgentCatalogCmd()) + cmd.AddCommand(newMonitAgentInvokeCmd()) + return cmd +} + +func newMonitAgentCatalogCmd() *cobra.Command { + var targetKind, targetLocator string + + cmd := &cobra.Command{ + Use: "catalog", + Short: "List the diagnostic tools the agent exposes for a target", + RunE: func(cmd *cobra.Command, args []string) error { + if targetLocator == "" { + return fmt.Errorf("--target-locator is required") + } + return runCommand(cmd, args, func(ctx *RunContext) error { + input := &flashduty.MonitAgentCatalogInput{ + TargetKind: targetKind, + TargetLocator: targetLocator, + } + result, err := ctx.Client.MonitAgentCatalog(cmdContext(ctx.Cmd), input) + if err != nil { + return err + } + return ctx.Printer.Print(result, nil) + }) + }, + } + + 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)") + + return cmd +} + +func newMonitAgentInvokeCmd() *cobra.Command { + var ( + targetKind, targetLocator string + toolSpecs []string + ) + + cmd := &cobra.Command{ + Use: "invoke", + Short: "Run up to 8 monit-agent tools concurrently on a target", + RunE: func(cmd *cobra.Command, args []string) error { + if targetLocator == "" { + return fmt.Errorf("--target-locator is required") + } + if len(toolSpecs) == 0 { + return fmt.Errorf("--tool-spec is required (repeatable; up to 8)") + } + if len(toolSpecs) > 8 { + return fmt.Errorf("--tool-spec accepts up to 8 entries (got %d)", len(toolSpecs)) + } + parsed, err := parseToolSpecs(toolSpecs) + if err != nil { + return fmt.Errorf("invalid --tool-spec: %w", err) + } + + return runCommand(cmd, args, func(ctx *RunContext) error { + input := &flashduty.MonitAgentInvokeInput{ + TargetKind: targetKind, + TargetLocator: targetLocator, + Tools: parsed, + } + result, err := ctx.Client.MonitAgentInvoke(cmdContext(ctx.Cmd), input) + if err != nil { + return err + } + return ctx.Printer.Print(result, nil) + }) + }, + } + + 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)") + // Use StringArray (not StringSlice) so commas inside params= aren't + // mis-parsed as CSV separators — each --tool-spec entry is taken verbatim. + cmd.Flags().StringArrayVar(&toolSpecs, "tool-spec", nil, "Tool spec 'name=[,params=]' (repeatable, max 8)") + + return cmd +} diff --git a/internal/cli/monit_agent_test.go b/internal/cli/monit_agent_test.go new file mode 100644 index 0000000..87dc2f7 --- /dev/null +++ b/internal/cli/monit_agent_test.go @@ -0,0 +1,300 @@ +package cli + +import ( + "context" + "encoding/json" + "strings" + "testing" + + flashduty "github.com/flashcatcloud/flashduty-sdk" +) + +// --- flag surface --------------------------------------------------------- + +func TestMonitAgentCatalogFlags(t *testing.T) { + cmd := newMonitAgentCatalogCmd() + for _, name := range []string{"target-kind", "target-locator"} { + if cmd.Flags().Lookup(name) == nil { + t.Errorf("flag --%s missing", name) + } + } +} + +func TestMonitAgentInvokeFlags(t *testing.T) { + cmd := newMonitAgentInvokeCmd() + for _, name := range []string{"target-kind", "target-locator", "tool-spec"} { + if cmd.Flags().Lookup(name) == nil { + t.Errorf("flag --%s missing", name) + } + } +} + +// --- shared mock plumbing ------------------------------------------------- + +type mockMonitAgent struct { + mockClient + + catalogInput *flashduty.MonitAgentCatalogInput + catalogOut *flashduty.MonitAgentCatalogOutput + catalogErr error + + invokeInput *flashduty.MonitAgentInvokeInput + invokeOut *flashduty.MonitAgentInvokeOutput + invokeErr error +} + +func (m *mockMonitAgent) MonitAgentCatalog(_ context.Context, input *flashduty.MonitAgentCatalogInput) (*flashduty.MonitAgentCatalogOutput, error) { + copied := *input + m.catalogInput = &copied + if m.catalogErr != nil { + return nil, m.catalogErr + } + if m.catalogOut != nil { + return m.catalogOut, nil + } + return &flashduty.MonitAgentCatalogOutput{}, nil +} + +func (m *mockMonitAgent) MonitAgentInvoke(_ context.Context, input *flashduty.MonitAgentInvokeInput) (*flashduty.MonitAgentInvokeOutput, error) { + copied := *input + copied.Tools = append([]flashduty.MonitAgentInvokeTool(nil), input.Tools...) + m.invokeInput = &copied + if m.invokeErr != nil { + return nil, m.invokeErr + } + if m.invokeOut != nil { + return m.invokeOut, nil + } + return &flashduty.MonitAgentInvokeOutput{}, nil +} + +// --- monit-agent catalog -------------------------------------------------- + +func TestMonitAgentCatalogHappyPath(t *testing.T) { + saveAndResetGlobals(t) + mock := &mockMonitAgent{ + catalogOut: &flashduty.MonitAgentCatalogOutput{ + Tools: []flashduty.MonitAgentTool{ + {Name: "ps_top", Description: "Top processes by CPU"}, + }, + }, + } + newClientFn = func() (flashdutyClient, error) { return mock, nil } + + _, err := execCommand( + "monit-agent", "catalog", + "--target-kind", "host", + "--target-locator", "10.0.1.5", + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if mock.catalogInput == nil { + t.Fatal("expected MonitAgentCatalog to be called") + } + if mock.catalogInput.TargetKind != "host" || mock.catalogInput.TargetLocator != "10.0.1.5" { + t.Errorf("unexpected catalog input: %+v", mock.catalogInput) + } +} + +func TestMonitAgentCatalogOmitsKind(t *testing.T) { + saveAndResetGlobals(t) + mock := &mockMonitAgent{} + newClientFn = func() (flashdutyClient, error) { return mock, nil } + + _, err := execCommand( + "monit-agent", "catalog", + "--target-locator", "web-01", + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if mock.catalogInput == nil { + t.Fatal("expected MonitAgentCatalog to be called") + } + if mock.catalogInput.TargetKind != "" { + t.Errorf("expected empty target-kind, got %q", mock.catalogInput.TargetKind) + } + if mock.catalogInput.TargetLocator != "web-01" { + t.Errorf("expected locator web-01, got %q", mock.catalogInput.TargetLocator) + } +} + +func TestMonitAgentCatalogRequiresLocator(t *testing.T) { + saveAndResetGlobals(t) + mock := &mockMonitAgent{} + newClientFn = func() (flashdutyClient, error) { return mock, nil } + + _, err := execCommand("monit-agent", "catalog", "--target-kind", "host") + if err == nil { + t.Fatal("expected required-flag error, got nil") + } + if !strings.Contains(err.Error(), "--target-locator") { + t.Errorf("expected error to mention --target-locator, got %q", err.Error()) + } + if mock.catalogInput != nil { + t.Errorf("MonitAgentCatalog should not have been called: %#v", mock.catalogInput) + } +} + +// --- monit-agent invoke --------------------------------------------------- + +func TestMonitAgentInvokeHappyPath(t *testing.T) { + saveAndResetGlobals(t) + mock := &mockMonitAgent{} + newClientFn = func() (flashdutyClient, error) { return mock, nil } + + _, err := execCommand( + "monit-agent", "invoke", + "--target-kind", "host", + "--target-locator", "10.0.1.5", + "--tool-spec", `name=ps_top,params={"limit":5}`, + "--tool-spec", "name=uptime", + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if mock.invokeInput == nil { + t.Fatal("expected MonitAgentInvoke to be called") + } + got := mock.invokeInput + if got.TargetKind != "host" || got.TargetLocator != "10.0.1.5" { + t.Errorf("unexpected invoke target: %+v", got) + } + if len(got.Tools) != 2 { + t.Fatalf("expected 2 tools, got %d", len(got.Tools)) + } + if got.Tools[0].Tool != "ps_top" { + t.Errorf("expected first tool ps_top, got %q", got.Tools[0].Tool) + } + if string(got.Tools[0].Params) != `{"limit":5}` { + t.Errorf("expected ps_top params %q, got %q", `{"limit":5}`, string(got.Tools[0].Params)) + } + if got.Tools[1].Tool != "uptime" { + t.Errorf("expected second tool uptime, got %q", got.Tools[1].Tool) + } + // default params for a name-only spec must be valid JSON `{}`, so the + // server-side decoder accepts it. + if !json.Valid(got.Tools[1].Params) { + t.Errorf("uptime params not valid JSON: %q", string(got.Tools[1].Params)) + } +} + +func TestMonitAgentInvokeOmitsKind(t *testing.T) { + saveAndResetGlobals(t) + mock := &mockMonitAgent{} + newClientFn = func() (flashdutyClient, error) { return mock, nil } + + _, err := execCommand( + "monit-agent", "invoke", + "--target-locator", "10.0.1.5", + "--tool-spec", "name=uptime", + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if mock.invokeInput == nil { + t.Fatal("expected MonitAgentInvoke to be called") + } + if mock.invokeInput.TargetKind != "" { + t.Errorf("expected empty target-kind, got %q", mock.invokeInput.TargetKind) + } +} + +func TestMonitAgentInvokeRequiresLocator(t *testing.T) { + saveAndResetGlobals(t) + mock := &mockMonitAgent{} + newClientFn = func() (flashdutyClient, error) { return mock, nil } + + _, err := execCommand( + "monit-agent", "invoke", + "--tool-spec", "name=ps_top", + ) + if err == nil { + t.Fatal("expected required-flag error, got nil") + } + if !strings.Contains(err.Error(), "--target-locator") { + t.Errorf("expected error to mention --target-locator, got %q", err.Error()) + } + if mock.invokeInput != nil { + t.Errorf("MonitAgentInvoke should not have been called: %#v", mock.invokeInput) + } +} + +func TestMonitAgentInvokeRequiresToolSpec(t *testing.T) { + saveAndResetGlobals(t) + mock := &mockMonitAgent{} + newClientFn = func() (flashdutyClient, error) { return mock, nil } + + _, err := execCommand( + "monit-agent", "invoke", + "--target-locator", "10.0.1.5", + ) + if err == nil { + t.Fatal("expected required-flag error, got nil") + } + if !strings.Contains(err.Error(), "--tool-spec") { + t.Errorf("expected error to mention --tool-spec, got %q", err.Error()) + } + if mock.invokeInput != nil { + t.Errorf("MonitAgentInvoke should not have been called: %#v", mock.invokeInput) + } +} + +func TestMonitAgentInvokeRejectsMoreThan8Specs(t *testing.T) { + saveAndResetGlobals(t) + mock := &mockMonitAgent{} + newClientFn = func() (flashdutyClient, error) { return mock, nil } + + args := []string{ + "monit-agent", "invoke", + "--target-locator", "10.0.1.5", + } + for i := 0; i < 9; i++ { + args = append(args, "--tool-spec", "name=t"+string(rune('0'+i))) + } + + _, err := execCommand(args...) + if err == nil { + t.Fatal("expected too-many-tools error, got nil") + } + if !strings.Contains(err.Error(), "up to 8") { + t.Errorf("expected error to mention 'up to 8', got %q", err.Error()) + } + if mock.invokeInput != nil { + t.Errorf("MonitAgentInvoke should not have been called: %#v", mock.invokeInput) + } +} + +func TestMonitAgentInvokeMalformedSpec(t *testing.T) { + cases := []struct { + name string + spec string + }{ + {"missing name=", "params={}"}, + {"missing equals", "no-equals-sign"}, + {"unknown key", "namez=foo,params={}"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + saveAndResetGlobals(t) + mock := &mockMonitAgent{} + newClientFn = func() (flashdutyClient, error) { return mock, nil } + + _, err := execCommand( + "monit-agent", "invoke", + "--target-locator", "10.0.1.5", + "--tool-spec", tc.spec, + ) + if err == nil { + t.Fatal("expected parse error, got nil") + } + if !strings.Contains(err.Error(), "--tool-spec") { + t.Errorf("expected error to mention --tool-spec, got %q", err.Error()) + } + if mock.invokeInput != nil { + t.Errorf("MonitAgentInvoke should not have been called: %#v", mock.invokeInput) + } + }) + } +} diff --git a/internal/cli/monit_query.go b/internal/cli/monit_query.go new file mode 100644 index 0000000..e8da45a --- /dev/null +++ b/internal/cli/monit_query.go @@ -0,0 +1,133 @@ +package cli + +import ( + "fmt" + + flashduty "github.com/flashcatcloud/flashduty-sdk" + "github.com/spf13/cobra" + + "github.com/flashcatcloud/flashduty-cli/internal/timeutil" +) + +func newMonitQueryCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "monit-query", + Short: "Probe monit-backed datasources (prometheus|victorialogs|loki|mysql)", + } + cmd.AddCommand(newMonitQueryDiagnoseCmd()) + cmd.AddCommand(newMonitQueryRowsCmd()) + return cmd +} + +func newMonitQueryDiagnoseCmd() *cobra.Command { + var ( + dsType, dsName, timeStart, timeEnd, inputQuery, operation string + maxLogs, maxPatterns, timeoutSeconds int + ) + + cmd := &cobra.Command{ + Use: "diagnose", + Short: "Pre-clustered RCA findings (log_patterns or metric_trends)", + RunE: func(cmd *cobra.Command, args []string) error { + if dsType == "" || dsName == "" || inputQuery == "" { + return fmt.Errorf("--ds-type, --ds-name, --input-query are required") + } + startTime, err := timeutil.Parse(timeStart) + if err != nil { + return fmt.Errorf("invalid --time-start: %w", err) + } + endTime, err := timeutil.Parse(timeEnd) + if err != nil { + return fmt.Errorf("invalid --time-end: %w", err) + } + + return runCommand(cmd, args, func(ctx *RunContext) error { + input := &flashduty.MonitQueryDiagnoseInput{ + DsType: dsType, + DsName: dsName, + TimeStart: startTime, + TimeEnd: endTime, + Operation: operation, + Input: flashduty.MonitQueryDiagnoseQuery{Query: inputQuery}, + } + if maxLogs > 0 { + input.MaxLogsScanned = maxLogs + } + if maxPatterns > 0 { + input.MaxPatterns = maxPatterns + } + if timeoutSeconds > 0 { + input.TimeoutSeconds = timeoutSeconds + } + + result, err := ctx.Client.MonitQueryDiagnose(cmdContext(ctx.Cmd), input) + if err != nil { + return err + } + return ctx.Printer.Print(result, nil) + }) + }, + } + + cmd.Flags().StringVar(&dsType, "ds-type", "", "Datasource type: prometheus|victorialogs|loki|mysql (required)") + cmd.Flags().StringVar(&dsName, "ds-name", "", "Datasource name as configured (required)") + cmd.Flags().StringVar(&timeStart, "time-start", "15m", "Window start (relative '15m'/'1h', unix seconds, or 'now')") + cmd.Flags().StringVar(&timeEnd, "time-end", "now", "Window end (relative, unix seconds, or 'now'; span capped at 6h)") + cmd.Flags().StringVar(&inputQuery, "input-query", "", "Filter-only log query OR matrix PromQL (required)") + cmd.Flags().StringVar(&operation, "operation", "", "log_patterns or metric_trends (default inferred from ds-type)") + cmd.Flags().IntVar(&maxLogs, "max-logs", 0, "Max log lines scanned (default 10000, cap 50000)") + cmd.Flags().IntVar(&maxPatterns, "max-patterns", 0, "Max patterns returned (default 20, cap 50)") + cmd.Flags().IntVar(&timeoutSeconds, "timeout-seconds", 0, "Per-call timeout in seconds (default 25, cap 30)") + + return cmd +} + +func newMonitQueryRowsCmd() *cobra.Command { + var ( + dsType, dsName, expr string + argsKV []string + ) + + cmd := &cobra.Command{ + Use: "rows", + Short: "Raw datasource passthrough (returns values/rows as the datasource itself would)", + RunE: func(cmd *cobra.Command, args []string) error { + if dsType == "" || dsName == "" || expr == "" { + return fmt.Errorf("--ds-type, --ds-name, --expr are required") + } + argsMap, err := parseKVSlice(argsKV) + if err != nil { + return fmt.Errorf("invalid --args: %w", err) + } + + return runCommand(cmd, args, func(ctx *RunContext) error { + input := &flashduty.MonitQueryRowsInput{ + DsType: dsType, + DsName: dsName, + Expr: expr, + Args: argsMap, + } + result, err := ctx.Client.MonitQueryRows(cmdContext(ctx.Cmd), input) + if err != nil { + return err + } + // MonitQueryRowsOutput intentionally captures the entire response + // body as a RawMessage (data shape is datasource-specific). The + // struct itself marshals to `{}`, so write the raw bytes through. + if len(result.Data) == 0 { + _, err = fmt.Fprintln(ctx.Writer, "{}") + } else { + _, err = fmt.Fprintln(ctx.Writer, string(result.Data)) + } + return err + }) + }, + } + + cmd.Flags().StringVar(&dsType, "ds-type", "", "Datasource type (required)") + cmd.Flags().StringVar(&dsName, "ds-name", "", "Datasource name (required)") + cmd.Flags().StringVar(&expr, "expr", "", "Query expression (required)") + cmd.Flags().StringSliceVar(&argsKV, "args", nil, "Arg entries KEY=VALUE (repeatable; values must be strings per monit-query contract)") + + return cmd +} diff --git a/internal/cli/monit_query_test.go b/internal/cli/monit_query_test.go new file mode 100644 index 0000000..c6fc388 --- /dev/null +++ b/internal/cli/monit_query_test.go @@ -0,0 +1,288 @@ +package cli + +import ( + "context" + "strings" + "testing" + + flashduty "github.com/flashcatcloud/flashduty-sdk" +) + +func TestMonitQueryDiagnoseFlags(t *testing.T) { + cmd := newMonitQueryDiagnoseCmd() + for _, name := range []string{ + "ds-type", "ds-name", "time-start", "time-end", + "input-query", "operation", + "max-logs", "max-patterns", "timeout-seconds", + } { + if cmd.Flags().Lookup(name) == nil { + t.Errorf("flag --%s missing", name) + } + } +} + +func TestMonitQueryRowsFlags(t *testing.T) { + cmd := newMonitQueryRowsCmd() + for _, name := range []string{"ds-type", "ds-name", "expr", "args"} { + if cmd.Flags().Lookup(name) == nil { + t.Errorf("flag --%s missing", name) + } + } +} + +// --- shared mock plumbing ------------------------------------------------- + +type mockMonitQuery struct { + mockClient + + diagnoseInput *flashduty.MonitQueryDiagnoseInput + diagnoseOut *flashduty.MonitQueryDiagnoseOutput + diagnoseErr error + + rowsInput *flashduty.MonitQueryRowsInput + rowsOut *flashduty.MonitQueryRowsOutput + rowsErr error +} + +func (m *mockMonitQuery) MonitQueryDiagnose(_ context.Context, input *flashduty.MonitQueryDiagnoseInput) (*flashduty.MonitQueryDiagnoseOutput, error) { + copied := *input + m.diagnoseInput = &copied + if m.diagnoseErr != nil { + return nil, m.diagnoseErr + } + if m.diagnoseOut != nil { + return m.diagnoseOut, nil + } + return &flashduty.MonitQueryDiagnoseOutput{Operation: "log_patterns"}, nil +} + +func (m *mockMonitQuery) MonitQueryRows(_ context.Context, input *flashduty.MonitQueryRowsInput) (*flashduty.MonitQueryRowsOutput, error) { + copied := *input + m.rowsInput = &copied + if m.rowsErr != nil { + return nil, m.rowsErr + } + if m.rowsOut != nil { + return m.rowsOut, nil + } + return &flashduty.MonitQueryRowsOutput{}, nil +} + +// --- monit-query diagnose ------------------------------------------------- + +func TestMonitQueryDiagnoseHappyPath(t *testing.T) { + saveAndResetGlobals(t) + mock := &mockMonitQuery{} + newClientFn = func() (flashdutyClient, error) { return mock, nil } + + _, err := execCommand( + "monit-query", "diagnose", + "--ds-type", "victorialogs", + "--ds-name", "vl-prod", + "--input-query", `{app="api"}`, + "--operation", "log_patterns", + "--max-logs", "5000", + "--max-patterns", "10", + "--timeout-seconds", "20", + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if mock.diagnoseInput == nil { + t.Fatal("expected MonitQueryDiagnose to be called") + } + got := mock.diagnoseInput + if got.DsType != "victorialogs" || got.DsName != "vl-prod" { + t.Errorf("unexpected ds fields: %+v", got) + } + if got.Input.Query != `{app="api"}` { + t.Errorf("expected input query %q, got %q", `{app="api"}`, got.Input.Query) + } + if got.Operation != "log_patterns" { + t.Errorf("expected operation log_patterns, got %q", got.Operation) + } + if got.MaxLogsScanned != 5000 || got.MaxPatterns != 10 || got.TimeoutSeconds != 20 { + t.Errorf("unexpected caps: logs=%d patterns=%d timeout=%d", + got.MaxLogsScanned, got.MaxPatterns, got.TimeoutSeconds) + } + if got.TimeStart == 0 || got.TimeEnd == 0 { + t.Errorf("expected non-zero default time range, got start=%d end=%d", + got.TimeStart, got.TimeEnd) + } +} + +func TestMonitQueryDiagnoseRequiredFlags(t *testing.T) { + cases := []struct { + name string + args []string + }{ + { + name: "missing ds-type", + args: []string{ + "monit-query", "diagnose", + "--ds-name", "vl-prod", + "--input-query", `{app="api"}`, + }, + }, + { + name: "missing ds-name", + args: []string{ + "monit-query", "diagnose", + "--ds-type", "victorialogs", + "--input-query", `{app="api"}`, + }, + }, + { + name: "missing input-query", + args: []string{ + "monit-query", "diagnose", + "--ds-type", "victorialogs", + "--ds-name", "vl-prod", + }, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + saveAndResetGlobals(t) + mock := &mockMonitQuery{} + newClientFn = func() (flashdutyClient, error) { return mock, nil } + + _, 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 mock.diagnoseInput != nil { + t.Errorf("MonitQueryDiagnose should not have been called: %#v", mock.diagnoseInput) + } + }) + } +} + +func TestMonitQueryDiagnoseInvalidTimeStart(t *testing.T) { + saveAndResetGlobals(t) + mock := &mockMonitQuery{} + newClientFn = func() (flashdutyClient, error) { return mock, nil } + + _, err := execCommand( + "monit-query", "diagnose", + "--ds-type", "victorialogs", + "--ds-name", "vl-prod", + "--input-query", `{app="api"}`, + "--time-start", "not-a-time", + ) + if err == nil { + t.Fatal("expected error for invalid --time-start, got nil") + } + if !strings.Contains(err.Error(), "--time-start") { + t.Errorf("expected error to mention --time-start, got %q", err.Error()) + } + if mock.diagnoseInput != nil { + t.Errorf("MonitQueryDiagnose should not have been called: %#v", mock.diagnoseInput) + } +} + +// --- monit-query rows ----------------------------------------------------- + +func TestMonitQueryRowsHappyPath(t *testing.T) { + saveAndResetGlobals(t) + mock := &mockMonitQuery{} + newClientFn = func() (flashdutyClient, error) { return mock, nil } + + _, err := execCommand( + "monit-query", "rows", + "--ds-type", "prometheus", + "--ds-name", "prom-prod", + "--expr", "up", + "--args", "step=15s", + "--args", "tenant=acme", + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if mock.rowsInput == nil { + t.Fatal("expected MonitQueryRows to be called") + } + got := mock.rowsInput + if got.DsType != "prometheus" || got.DsName != "prom-prod" || got.Expr != "up" { + t.Errorf("unexpected rows input: %+v", got) + } + if got.Args["step"] != "15s" || got.Args["tenant"] != "acme" { + t.Errorf("expected args step=15s tenant=acme, got %#v", got.Args) + } +} + +func TestMonitQueryRowsRequiredFlags(t *testing.T) { + cases := []struct { + name string + args []string + }{ + { + name: "missing ds-type", + args: []string{ + "monit-query", "rows", + "--ds-name", "prom-prod", + "--expr", "up", + }, + }, + { + name: "missing ds-name", + args: []string{ + "monit-query", "rows", + "--ds-type", "prometheus", + "--expr", "up", + }, + }, + { + name: "missing expr", + args: []string{ + "monit-query", "rows", + "--ds-type", "prometheus", + "--ds-name", "prom-prod", + }, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + saveAndResetGlobals(t) + mock := &mockMonitQuery{} + newClientFn = func() (flashdutyClient, error) { return mock, nil } + + _, 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 mock.rowsInput != nil { + t.Errorf("MonitQueryRows should not have been called: %#v", mock.rowsInput) + } + }) + } +} + +func TestMonitQueryRowsInvalidArgs(t *testing.T) { + saveAndResetGlobals(t) + mock := &mockMonitQuery{} + newClientFn = func() (flashdutyClient, error) { return mock, nil } + + _, err := execCommand( + "monit-query", "rows", + "--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 mock.rowsInput != nil { + t.Errorf("MonitQueryRows should not have been called: %#v", mock.rowsInput) + } +} diff --git a/internal/cli/root.go b/internal/cli/root.go index a191d9a..7ccd40d 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -98,6 +98,17 @@ type flashdutyClient interface { GetTeamInfo(ctx context.Context, input *flashduty.TeamGetInput) (*flashduty.TeamItem, error) UpsertTeam(ctx context.Context, input *flashduty.TeamUpsertInput) (*flashduty.TeamUpsertOutput, error) DeleteTeam(ctx context.Context, input *flashduty.TeamDeleteInput) error + + // === CLI Phase 1: MCP === + CreateMCPServer(ctx context.Context, input *flashduty.CreateMCPServerInput) (*flashduty.CreateMCPServerOutput, error) + + // === CLI Phase 2: monit-query === + MonitQueryDiagnose(ctx context.Context, input *flashduty.MonitQueryDiagnoseInput) (*flashduty.MonitQueryDiagnoseOutput, error) + MonitQueryRows(ctx context.Context, input *flashduty.MonitQueryRowsInput) (*flashduty.MonitQueryRowsOutput, error) + + // === CLI Phase 2: monit-agent === + MonitAgentCatalog(ctx context.Context, input *flashduty.MonitAgentCatalogInput) (*flashduty.MonitAgentCatalogOutput, error) + MonitAgentInvoke(ctx context.Context, input *flashduty.MonitAgentInvokeInput) (*flashduty.MonitAgentInvokeOutput, error) } // newClientFn creates a flashdutyClient. Override in tests to inject a mock. @@ -176,6 +187,13 @@ func init() { rootCmd.AddCommand(newWhoamiCmd()) rootCmd.AddCommand(newUpdateCmd()) + + // CLI Phase 1 + rootCmd.AddCommand(newMCPCmd()) + + // CLI Phase 2 + rootCmd.AddCommand(newMonitQueryCmd()) + rootCmd.AddCommand(newMonitAgentCmd()) } // Execute runs the root command. diff --git a/internal/update/check.go b/internal/update/check.go index f815461..ba207e2 100644 --- a/internal/update/check.go +++ b/internal/update/check.go @@ -15,13 +15,13 @@ import ( ) const ( - repoOwner = "flashcatcloud" - repoName = "flashduty-cli" - checkInterval = 24 * time.Hour - httpTimeout = 5 * time.Second - stateFileName = "state.yaml" - installShURL = "https://raw.githubusercontent.com/" + repoOwner + "/" + repoName + "/main/install.sh" - installPs1URL = "https://raw.githubusercontent.com/" + repoOwner + "/" + repoName + "/main/install.ps1" + repoOwner = "flashcatcloud" + repoName = "flashduty-cli" + checkInterval = 24 * time.Hour + httpTimeout = 5 * time.Second + stateFileName = "state.yaml" + installShURL = "https://raw.githubusercontent.com/" + repoOwner + "/" + repoName + "/main/install.sh" + installPs1URL = "https://raw.githubusercontent.com/" + repoOwner + "/" + repoName + "/main/install.ps1" maxResponseBytes = 1 << 20 // 1MB )