Skip to content

Commit a09fa2c

Browse files
committed
feat(cli): add monit-agent catalog|invoke subcommands
Two-step on-box diagnostics surface: `catalog` discovers tools per target via /monit/tools/catalog, `invoke` runs up to 8 of them concurrently via /monit/tools/invoke. --tool-spec uses StringArray so params=<json> bodies with commas survive intact. Side-fix: extend test-helper resetFlagSet to also clear stringSlice and stringArray flags between execCommand calls; without it, a later test sees leftover repeated --flag entries from earlier ones.
1 parent c04b741 commit a09fa2c

5 files changed

Lines changed: 457 additions & 0 deletions

File tree

internal/cli/command_test.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,15 @@ func (m *mockClient) MonitQueryRows(context.Context, *flashduty.MonitQueryRowsIn
295295
return nil, fmt.Errorf("mockClient: MonitQueryRows not implemented")
296296
}
297297

298+
// CLI Phase 2: monit-agent
299+
func (m *mockClient) MonitAgentCatalog(context.Context, *flashduty.MonitAgentCatalogInput) (*flashduty.MonitAgentCatalogOutput, error) {
300+
return nil, fmt.Errorf("mockClient: MonitAgentCatalog not implemented")
301+
}
302+
303+
func (m *mockClient) MonitAgentInvoke(context.Context, *flashduty.MonitAgentInvokeInput) (*flashduty.MonitAgentInvokeOutput, error) {
304+
return nil, fmt.Errorf("mockClient: MonitAgentInvoke not implemented")
305+
}
306+
298307
// saveAndResetGlobals saves the current state of all global vars that commands
299308
// mutate, resets them to safe defaults, and returns a restore function for
300309
// t.Cleanup.
@@ -365,6 +374,16 @@ func resetFlagSet(flags *pflag.FlagSet) {
365374
case "bool", "int", "int64", "string":
366375
_ = flag.Value.Set(flag.DefValue)
367376
flag.Changed = false
377+
case "stringSlice", "stringArray":
378+
// Slice-valued flags accumulate across Parse() calls; clear them
379+
// explicitly so a later test isn't observing the previous test's
380+
// repeated --flag entries. pflag's SliceValue / Append interfaces
381+
// don't expose a "reset to default" — Set("") would append an
382+
// empty entry, so we use Replace([]) to truly empty the slice.
383+
if sv, ok := flag.Value.(pflag.SliceValue); ok {
384+
_ = sv.Replace([]string{})
385+
flag.Changed = false
386+
}
368387
}
369388
})
370389
}

internal/cli/helpers.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
package cli
22

33
import (
4+
"encoding/json"
45
"fmt"
56
"strings"
7+
8+
flashduty "github.com/flashcatcloud/flashduty-sdk"
69
)
710

811
// parseKVSlice converts a slice of "KEY=VALUE" entries into a map.
@@ -22,3 +25,37 @@ func parseKVSlice(entries []string) (map[string]string, error) {
2225
}
2326
return out, nil
2427
}
28+
29+
// parseToolSpecs converts a slice of "name=<tool>[,params=<json>]" specs into
30+
// MonitAgentInvokeTool entries. The `name` key is required; `params` is
31+
// optional and defaults to `{}` so the server-side decoder accepts it. Splits
32+
// each spec on ',' first then on the first '=', mirroring parseKVSlice — that
33+
// means params JSON containing commas isn't supported; specs with complex
34+
// params must keep their objects single-keyed.
35+
func parseToolSpecs(specs []string) ([]flashduty.MonitAgentInvokeTool, error) {
36+
out := make([]flashduty.MonitAgentInvokeTool, 0, len(specs))
37+
for _, s := range specs {
38+
var name string
39+
params := json.RawMessage("{}")
40+
for _, kv := range strings.Split(s, ",") {
41+
i := strings.IndexByte(kv, '=')
42+
if i < 0 {
43+
return nil, fmt.Errorf("missing '=' in %q", kv)
44+
}
45+
k, v := kv[:i], kv[i+1:]
46+
switch k {
47+
case "name":
48+
name = v
49+
case "params":
50+
params = json.RawMessage(v)
51+
default:
52+
return nil, fmt.Errorf("unknown key %q in tool-spec", k)
53+
}
54+
}
55+
if name == "" {
56+
return nil, fmt.Errorf("missing name= in spec %q", s)
57+
}
58+
out = append(out, flashduty.MonitAgentInvokeTool{Tool: name, Params: params})
59+
}
60+
return out, nil
61+
}

internal/cli/monit_agent.go

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
package cli
2+
3+
import (
4+
"fmt"
5+
6+
flashduty "github.com/flashcatcloud/flashduty-sdk"
7+
"github.com/spf13/cobra"
8+
)
9+
10+
func newMonitAgentCmd() *cobra.Command {
11+
cmd := &cobra.Command{
12+
Use: "monit-agent",
13+
Short: "On-box diagnostics via flashmonit agents (host/mysql/redis/…)",
14+
}
15+
cmd.AddCommand(newMonitAgentCatalogCmd())
16+
cmd.AddCommand(newMonitAgentInvokeCmd())
17+
return cmd
18+
}
19+
20+
func newMonitAgentCatalogCmd() *cobra.Command {
21+
var targetKind, targetLocator string
22+
23+
cmd := &cobra.Command{
24+
Use: "catalog",
25+
Short: "List the diagnostic tools the agent exposes for a target",
26+
RunE: func(cmd *cobra.Command, args []string) error {
27+
if targetLocator == "" {
28+
return fmt.Errorf("--target-locator is required")
29+
}
30+
return runCommand(cmd, args, func(ctx *RunContext) error {
31+
input := &flashduty.MonitAgentCatalogInput{
32+
TargetKind: targetKind,
33+
TargetLocator: targetLocator,
34+
}
35+
result, err := ctx.Client.MonitAgentCatalog(cmdContext(ctx.Cmd), input)
36+
if err != nil {
37+
return err
38+
}
39+
return ctx.Printer.Print(result, nil)
40+
})
41+
},
42+
}
43+
44+
cmd.Flags().StringVar(&targetKind, "target-kind", "", "Target kind (host|mysql|redis|…); omit to let the agent infer")
45+
cmd.Flags().StringVar(&targetLocator, "target-locator", "", "Target locator: internal IP, hostname, or data-source name (required)")
46+
47+
return cmd
48+
}
49+
50+
func newMonitAgentInvokeCmd() *cobra.Command {
51+
var (
52+
targetKind, targetLocator string
53+
toolSpecs []string
54+
)
55+
56+
cmd := &cobra.Command{
57+
Use: "invoke",
58+
Short: "Run up to 8 monit-agent tools concurrently on a target",
59+
RunE: func(cmd *cobra.Command, args []string) error {
60+
if targetLocator == "" {
61+
return fmt.Errorf("--target-locator is required")
62+
}
63+
if len(toolSpecs) == 0 {
64+
return fmt.Errorf("--tool-spec is required (repeatable; up to 8)")
65+
}
66+
if len(toolSpecs) > 8 {
67+
return fmt.Errorf("--tool-spec accepts up to 8 entries (got %d)", len(toolSpecs))
68+
}
69+
parsed, err := parseToolSpecs(toolSpecs)
70+
if err != nil {
71+
return fmt.Errorf("invalid --tool-spec: %w", err)
72+
}
73+
74+
return runCommand(cmd, args, func(ctx *RunContext) error {
75+
input := &flashduty.MonitAgentInvokeInput{
76+
TargetKind: targetKind,
77+
TargetLocator: targetLocator,
78+
Tools: parsed,
79+
}
80+
result, err := ctx.Client.MonitAgentInvoke(cmdContext(ctx.Cmd), input)
81+
if err != nil {
82+
return err
83+
}
84+
return ctx.Printer.Print(result, nil)
85+
})
86+
},
87+
}
88+
89+
cmd.Flags().StringVar(&targetKind, "target-kind", "", "Target kind (host|mysql|redis|…); omit to let the agent infer")
90+
cmd.Flags().StringVar(&targetLocator, "target-locator", "", "Target locator: internal IP, hostname, or data-source name (required)")
91+
// Use StringArray (not StringSlice) so commas inside params=<json> aren't
92+
// mis-parsed as CSV separators — each --tool-spec entry is taken verbatim.
93+
cmd.Flags().StringArrayVar(&toolSpecs, "tool-spec", nil, "Tool spec 'name=<tool>[,params=<json>]' (repeatable, max 8)")
94+
95+
return cmd
96+
}

0 commit comments

Comments
 (0)