registry_test.go 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533
  1. package commands
  2. import (
  3. "bytes"
  4. "errors"
  5. "flag"
  6. "io"
  7. "strings"
  8. "sync"
  9. "testing"
  10. "time"
  11. "github.com/stretchr/testify/assert"
  12. "github.com/stretchr/testify/require"
  13. )
  14. // notWithheld is a stand in for a surface's own policy; these tests use ForProfiling
  15. // because it is the only such flag today, not because the registry knows about it.
  16. func notWithheld(c *Command) bool { return !c.ForProfiling }
  17. const testReason = "this surface does not run profiling commands"
  18. // textLine is a Text renderer for test commands whose result is one string.
  19. func textLine(v any, w io.Writer) error {
  20. _, err := io.WriteString(w, v.(string)+"\n")
  21. return err
  22. }
  23. func testRegistry() *Registry {
  24. r := New()
  25. r.Register(&Command{
  26. Name: "served",
  27. ShortDescription: "a command this surface serves",
  28. Run: func(fs any, a []string) (Result, error) {
  29. return Value("read " + strings.Join(a, ",")), nil
  30. },
  31. Text: textLine,
  32. })
  33. r.Register(&Command{
  34. Name: "withheld",
  35. ShortDescription: "a command this surface withholds",
  36. ForProfiling: true,
  37. Run: func(fs any, a []string) (Result, error) {
  38. return Value("wrote"), nil
  39. },
  40. Text: textLine,
  41. })
  42. return r
  43. }
  44. // TestLocalSocketServesEverythingElse: the surface serves commands by default and
  45. // withholds only those that write files on the host.
  46. func TestLocalSocketServesEverythingElse(t *testing.T) {
  47. ro := testRegistry().Filter(notWithheld, testReason)
  48. names := make([]string, 0)
  49. for _, c := range ro.All() {
  50. names = append(names, string(c.Name))
  51. }
  52. assert.ElementsMatch(t, []string{"help", "served"}, names)
  53. }
  54. func TestUnannotatedCommandIsServed(t *testing.T) {
  55. r := New()
  56. r.Register(&Command{
  57. Name: "plain",
  58. ShortDescription: "a command with no annotations",
  59. Run: func(fs any, a []string) (Result, error) { return Nothing(), nil },
  60. })
  61. cmd := r.Filter(notWithheld, testReason).Get("plain")
  62. assert.NotNil(t, cmd, "commands reach the local socket unless they say otherwise")
  63. }
  64. func TestProfilingCommandIsWithheld(t *testing.T) {
  65. r := New()
  66. r.Register(&Command{
  67. Name: "save-a-profile",
  68. ShortDescription: "drives the profiler",
  69. ForProfiling: true,
  70. Run: func(fs any, a []string) (Result, error) { return Nothing(), nil },
  71. })
  72. cmd := r.Filter(notWithheld, testReason).Get("save-a-profile")
  73. assert.Nil(t, cmd, "a profiling command must not reach the local socket")
  74. }
  75. // TestRefusesWithheldByName covers a client that knows a command exists and asks for it
  76. // anyway. The filter lives on the serving side, so the answer must not depend on what
  77. // the client was told was available.
  78. func TestRefusesWithheldByName(t *testing.T) {
  79. out, errOut := &bytes.Buffer{}, &bytes.Buffer{}
  80. w := Output{Out: out, Err: errOut}
  81. err := testRegistry().Filter(notWithheld, testReason).DispatchArgs([]string{"withheld"}, w)
  82. require.Error(t, err)
  83. assert.Contains(t, err.Error(), "withheld is not available on this surface")
  84. assert.Empty(t, out.String(), "a withheld command must produce no output")
  85. assert.Contains(t, errOut.String(), "withheld is not available on this surface")
  86. assert.Contains(t, errOut.String(), testReason,
  87. "the registry should render the explanation the filter supplied, not one of its own")
  88. }
  89. // TestWithheldIsNotReportedAsUnknown keeps the two cases distinct: an operator who
  90. // mistypes and one who asked for a real command should not get the same answer.
  91. func TestWithheldIsNotReportedAsUnknown(t *testing.T) {
  92. out, errOut := &bytes.Buffer{}, &bytes.Buffer{}
  93. err := testRegistry().Filter(notWithheld, testReason).DispatchArgs([]string{"no-such-command"}, Output{Out: out, Err: errOut})
  94. var f *Failure
  95. require.ErrorAs(t, err, &f, "an unknown command is a failure to the surface")
  96. assert.Contains(t, out.String(), "did not understand: no-such-command")
  97. }
  98. // TestResolveTypesTheWithheldRefusal: a surface with status codes tells a withheld
  99. // command from an unknown one by the error's type, never by asking the unfiltered
  100. // registry, which is the one thing a filtered surface must not consult.
  101. func TestResolveTypesTheWithheldRefusal(t *testing.T) {
  102. r := testRegistry().Filter(notWithheld, testReason)
  103. _, err := r.Resolve("withheld")
  104. var withheld *WithheldError
  105. require.ErrorAs(t, err, &withheld)
  106. assert.Contains(t, err.Error(), testReason)
  107. _, err = r.Resolve("no-such-command")
  108. require.Error(t, err)
  109. assert.NotErrorAs(t, err, &withheld, "an unknown name is not a withheld one")
  110. }
  111. // TestSplitWriterKeepsOutputParseable is why the writer grew Err(): usage text on the
  112. // same stream as -json output would corrupt it.
  113. func TestSplitWriterKeepsOutputParseable(t *testing.T) {
  114. r := New()
  115. r.Register(&Command{
  116. Name: "emit",
  117. ShortDescription: "emits json",
  118. Flags: func() (*flag.FlagSet, any) {
  119. fl := flag.NewFlagSet("", flag.ContinueOnError)
  120. fl.Bool("verbose", false, "say more")
  121. return fl, nil
  122. },
  123. Run: func(fs any, a []string) (Result, error) {
  124. return Value(map[string]bool{"ok": true}), nil
  125. },
  126. })
  127. out, errOut := &bytes.Buffer{}, &bytes.Buffer{}
  128. require.Error(t, r.DispatchArgs([]string{"emit", "-nope"}, Output{Out: out, Err: errOut}))
  129. assert.Empty(t, out.String(), "flag errors must not land on the output stream")
  130. assert.Contains(t, errOut.String(), "flag provided but not defined")
  131. }
  132. // TestMergedWriterPreservesSshBehavior: the ssh surface points both streams at one
  133. // channel, and must keep seeing a single merged stream.
  134. func TestMergedWriterPreservesSshBehavior(t *testing.T) {
  135. r := New()
  136. r.Register(&Command{
  137. Name: "emit",
  138. ShortDescription: "emits",
  139. Flags: func() (*flag.FlagSet, any) {
  140. fl := flag.NewFlagSet("", flag.ContinueOnError)
  141. fl.Bool("verbose", false, "say more")
  142. return fl, nil
  143. },
  144. Run: func(fs any, a []string) (Result, error) { return Nothing(), nil },
  145. })
  146. merged := &bytes.Buffer{}
  147. require.Error(t, r.DispatchArgs([]string{"emit", "-nope"}, MergedOutput(merged)))
  148. assert.Contains(t, merged.String(), "flag provided but not defined")
  149. }
  150. // paramRegistry holds one command with a bool flag and a duration flag, and reports what
  151. // the command saw when it ran.
  152. func paramRegistry(t *testing.T) (*Registry, *[]paramsSeen) {
  153. t.Helper()
  154. var seen []paramsSeen
  155. r := New()
  156. r.Register(&Command{
  157. Name: "set",
  158. ShortDescription: "sets things",
  159. Flags: func() (*flag.FlagSet, any) {
  160. fl := flag.NewFlagSet("", flag.ContinueOnError)
  161. s := paramsSeen{}
  162. fl.BoolVar(&s.Verbose, "verbose", false, "say more")
  163. fl.DurationVar(&s.Wait, "wait", 0, "how long")
  164. return fl, &s
  165. },
  166. Run: func(fs any, a []string) (Result, error) {
  167. seen = append(seen, *fs.(*paramsSeen))
  168. return Value("ok"), nil
  169. },
  170. })
  171. return r, &seen
  172. }
  173. type paramsSeen struct {
  174. Verbose bool
  175. Wait time.Duration
  176. }
  177. // TestInvokeReportsParametersInNameOrder: parameters arrive as an unordered object, so
  178. // with two bad ones the error must not depend on map iteration. Name order decides.
  179. func TestInvokeReportsParametersInNameOrder(t *testing.T) {
  180. r, _ := paramRegistry(t)
  181. cases := map[string]struct {
  182. params map[string]string
  183. want string
  184. }{
  185. "two unknown": {map[string]string{"zed": "1", "alpha": "1"}, `set has no parameter "alpha"`},
  186. "unknown before bad bool": {map[string]string{"aaa": "1", "verbose": "maybe"}, `set has no parameter "aaa"`},
  187. "bad bool before unknown": {map[string]string{"verbose": "maybe", "zzz": "1"}, `verbose: expected a boolean, got "maybe"`},
  188. "bad value before bad bool": {map[string]string{"wait": "soon", "verbose": "maybe"}, `verbose: expected a boolean`},
  189. }
  190. for name, tc := range cases {
  191. t.Run(name, func(t *testing.T) {
  192. // Enough runs that a map-order dependence would show.
  193. for i := 0; i < 50; i++ {
  194. _, err := Invoke(Request{Command: r.Get("set"), Params: tc.params})
  195. require.Error(t, err)
  196. assert.ErrorContains(t, err, tc.want)
  197. }
  198. })
  199. }
  200. }
  201. // TestInvokeWithABadParameterSetsNothing: a request with any bad parameter must not run
  202. // the command, and must leave nothing behind for the next request to see. The good
  203. // parameter sorts before the bad one, so a one-pass Invoke would have applied it.
  204. func TestInvokeWithABadParameterSetsNothing(t *testing.T) {
  205. r, seen := paramRegistry(t)
  206. _, err := Invoke(Request{Command: r.Get("set"), Params: map[string]string{"verbose": "true", "wait": "soon"}})
  207. require.ErrorContains(t, err, "wait:")
  208. assert.Empty(t, *seen, "the command must not run with a partial parameter set")
  209. v, err := Invoke(Request{Command: r.Get("set")})
  210. require.NoError(t, err)
  211. assert.Equal(t, "ok", v.Value)
  212. require.Len(t, *seen, 1)
  213. assert.Equal(t, paramsSeen{}, (*seen)[0], "nothing from the failed request carried over")
  214. _, err = Invoke(Request{Command: r.Get("set"), Params: map[string]string{"verbose": "true", "wait": "2s"}})
  215. require.NoError(t, err)
  216. require.Len(t, *seen, 2)
  217. assert.Equal(t, paramsSeen{Verbose: true, Wait: 2 * time.Second}, (*seen)[1])
  218. }
  219. func TestHelpListsOnlyServedCommands(t *testing.T) {
  220. out := &bytes.Buffer{}
  221. require.NoError(t, testRegistry().Filter(notWithheld, testReason).DispatchArgs(nil, MergedOutput(out)))
  222. assert.Contains(t, out.String(), "served")
  223. assert.NotContains(t, out.String(), "withheld")
  224. }
  225. func TestDispatchHelpFlag(t *testing.T) {
  226. out := &bytes.Buffer{}
  227. testRegistry().Dispatch("served -h", MergedOutput(out))
  228. assert.Contains(t, out.String(), "served - a command this surface serves")
  229. }
  230. // TestDispatchReportsUnparseableLine pins that a line shlex rejects says so on Err rather
  231. // than coming back silent, so an operator with an unbalanced quote learns the quote is the
  232. // problem instead of assuming the command is broken.
  233. func TestDispatchReportsUnparseableLine(t *testing.T) {
  234. out, errOut := &bytes.Buffer{}, &bytes.Buffer{}
  235. testRegistry().Dispatch(`served "unterminated`, Output{Out: out, Err: errOut})
  236. assert.Empty(t, out.String())
  237. assert.Contains(t, errOut.String(), "could not parse the command line")
  238. }
  239. // TestRegisterRejectsReservedFlags pins the contract that -json and -pretty belong to the
  240. // registry, not the command. commandFlags defines them on every command's FlagSet, so a
  241. // command that declared one itself would panic with "flag redefined" the first time an
  242. // operator ran it, on a goroutine with no recover. Better to hear about it at Register.
  243. // -config and -socket are nebula ctl's, taken off its line wherever they appear, so a
  244. // command that declared one would never see it from that surface.
  245. func TestRegisterRejectsReservedFlags(t *testing.T) {
  246. for _, name := range []string{"json", "pretty", "h", "help", "config", "socket"} {
  247. t.Run(name, func(t *testing.T) {
  248. r := New()
  249. assert.PanicsWithValue(t, "commands: clash declares reserved flag -"+name, func() {
  250. r.Register(&Command{
  251. Name: "clash",
  252. Flags: func() (*flag.FlagSet, any) {
  253. fl := flag.NewFlagSet("clash", flag.ContinueOnError)
  254. fl.Bool(name, false, "")
  255. return fl, nil
  256. },
  257. Run: func(fs any, a []string) (Result, error) { return Nothing(), nil },
  258. })
  259. })
  260. })
  261. }
  262. }
  263. func TestCloneDoesNotAffectOriginal(t *testing.T) {
  264. r := testRegistry()
  265. c := r.Clone()
  266. c.Register(&Command{Name: "logout", ShortDescription: "ends the session"})
  267. assert.Nil(t, r.Get("logout"))
  268. }
  269. // TestRegistryIsSafeForConcurrentUse runs the operations the surfaces perform, filtering
  270. // and cloning per request or session, against a stream of registrations. It exists to
  271. // be run under the race detector: the map underneath is not goroutine safe.
  272. func TestRegistryIsSafeForConcurrentUse(t *testing.T) {
  273. r := testRegistry()
  274. var wg sync.WaitGroup
  275. for i := 0; i < 8; i++ {
  276. wg.Add(1)
  277. go func(i int) {
  278. defer wg.Done()
  279. for j := 0; j < 50; j++ {
  280. r.Register(&Command{
  281. Name: Name("late-" + strings.Repeat("a", i) + "-" + strings.Repeat("b", j)),
  282. ShortDescription: "registered after the surfaces started",
  283. Run: func(fs any, a []string) (Result, error) { return Nothing(), nil },
  284. })
  285. }
  286. }(i)
  287. }
  288. for i := 0; i < 8; i++ {
  289. wg.Add(1)
  290. go func() {
  291. defer wg.Done()
  292. for j := 0; j < 50; j++ {
  293. f := r.Filter(notWithheld, testReason)
  294. _ = f.Get("served")
  295. _ = f.Schema()
  296. _ = r.Clone().Match("late-")
  297. }
  298. }()
  299. }
  300. wg.Wait()
  301. f := r.Filter(notWithheld, testReason)
  302. assert.Len(t, f.Match("late-"), 8*50, "every late registration is visible once the writers finish")
  303. }
  304. // TestHelpFlagAgreesWithHelpCommand pins that -h on a command and `help <name>` print the
  305. // same text, on a clone as on the source: both render through WriteCommandHelp, and Clone
  306. // rebinds help to the copy, so a command registered on the clone is found either way.
  307. func TestHelpFlagAgreesWithHelpCommand(t *testing.T) {
  308. c := testRegistry().Clone()
  309. c.Register(&Command{
  310. Name: Logout,
  311. ShortDescription: "ends the session",
  312. Run: func(fs any, a []string) (Result, error) { return Nothing(), nil },
  313. })
  314. for _, name := range []string{"logout", "served"} {
  315. flag := &bytes.Buffer{}
  316. c.Dispatch(name+" -h", MergedOutput(flag))
  317. cmd := &bytes.Buffer{}
  318. c.Dispatch("help "+name, MergedOutput(cmd))
  319. assert.Equal(t, cmd.String(), flag.String(), name)
  320. assert.Contains(t, flag.String(), name+" - ", name)
  321. }
  322. }
  323. // TestCloneHelpSeesCloneCommands: help on a clone answers for the clone. The ssh session
  324. // registers logout on its clone, and `help logout` has to find it.
  325. func TestCloneHelpSeesCloneCommands(t *testing.T) {
  326. c := testRegistry().Clone()
  327. c.Register(&Command{Name: "logout", ShortDescription: "ends the session"})
  328. out := &bytes.Buffer{}
  329. require.NoError(t, c.DispatchArgs([]string{"help", "logout"}, MergedOutput(out)))
  330. assert.Contains(t, out.String(), "ends the session")
  331. out.Reset()
  332. require.NoError(t, c.DispatchArgs([]string{"help"}, MergedOutput(out)))
  333. assert.Contains(t, out.String(), "logout")
  334. }
  335. // TestThenRunsAfterTheReply pins the order Then exists for: Run's line is on the writer
  336. // before the action begins, Then's Result follows it, and Then's outcome is the
  337. // command's.
  338. func TestThenRunsAfterTheReply(t *testing.T) {
  339. out := &bytes.Buffer{}
  340. seen := "never ran"
  341. outcome := Did("acted")
  342. r := New()
  343. r.Register(&Command{
  344. Name: "act",
  345. ShortDescription: "replies, then acts",
  346. Run: func(fs any, a []string) (Result, error) {
  347. return Did("about to act"), nil
  348. },
  349. Then: func() Result {
  350. seen = out.String()
  351. return outcome
  352. },
  353. })
  354. require.NoError(t, r.DispatchArgs([]string{"act"}, MergedOutput(out)))
  355. assert.Equal(t, "about to act\n", seen, "the line was written before the action")
  356. assert.Equal(t, "about to act\nacted\n", out.String())
  357. out.Reset()
  358. outcome = Failed("could not act")
  359. var f *Failure
  360. require.ErrorAs(t, r.DispatchArgs([]string{"act"}, MergedOutput(out)), &f)
  361. assert.Equal(t, "about to act\ncould not act\n", out.String())
  362. // In JSON only the outcome travels: one document for the program that asked.
  363. out.Reset()
  364. require.ErrorAs(t, r.DispatchArgs([]string{"act", "-json"}, MergedOutput(out)), &f)
  365. assert.JSONEq(t, `{"ok":false,"reason":"could not act"}`, out.String())
  366. }
  367. // TestWriteCommandHelpIsTheOneRendering pins the layout both `help <command>` on the
  368. // daemon and `-h` in the client print, so the two cannot drift apart again.
  369. func TestWriteCommandHelpIsTheOneRendering(t *testing.T) {
  370. cs := CommandSchema{
  371. Name: "change-remote",
  372. Short: "changes a remote",
  373. Help: "Give it an address.",
  374. Flags: []FlagSchema{
  375. {Name: "address", Usage: "the new remote", Default: "1.2.3.4:4242"},
  376. {Name: "by-index", Bool: true, Usage: "match by index"},
  377. {Name: "json", Bool: true, Usage: "outputs as json"},
  378. },
  379. }
  380. out := &bytes.Buffer{}
  381. require.NoError(t, WriteCommandHelp(cs, out))
  382. assert.Equal(t, "change-remote - changes a remote\n"+
  383. " Give it an address.\n"+
  384. " -address string\n \tthe new remote (default 1.2.3.4:4242)\n"+
  385. " -by-index\n \tmatch by index\n"+
  386. " -json\n \toutputs as json\n", out.String())
  387. }
  388. // addrFlags is the flag struct of a test command that takes -address.
  389. type addrFlags struct{ Address string }
  390. // TestHelpFlagIsReadWhereParseReadsIt: -h asks for help only where the line's parser
  391. // reads it as a flag. As a flag's value or after -- it reaches the command as the
  392. // operand it is, and help is rendered by the same function `help <command>` uses.
  393. func TestHelpFlagIsReadWhereParseReadsIt(t *testing.T) {
  394. var seen []string
  395. r := New()
  396. r.Register(Flagged(&Command{Name: "x", ShortDescription: "takes an address"},
  397. func(fl *flag.FlagSet, s *addrFlags) { fl.StringVar(&s.Address, "address", "", "an address") },
  398. func(s *addrFlags, a []string) (Result, error) {
  399. seen = append(seen, s.Address+"|"+strings.Join(a, ","))
  400. return Nothing(), nil
  401. }))
  402. for _, line := range []string{"x -h", "x -help", "x --help", "x 10.0.0.1 -h", "x -address=1 -h"} {
  403. out := &bytes.Buffer{}
  404. r.Dispatch(line, MergedOutput(out))
  405. assert.Contains(t, out.String(), "x - takes an address\n", line)
  406. assert.Contains(t, out.String(), "-address string", line)
  407. }
  408. assert.Empty(t, seen, "asking for help must not run the command")
  409. for _, line := range []string{"x -address -h", "x -- -h"} {
  410. out := &bytes.Buffer{}
  411. r.Dispatch(line, MergedOutput(out))
  412. assert.Empty(t, out.String(), line)
  413. }
  414. assert.Equal(t, []string{"-h|", "|-h"}, seen)
  415. }
  416. // TestBadFlagIsAnsweredWithTheCommandsHelp: a flag the command does not have is reported
  417. // on the error stream followed by the command's usage, in the rendering help uses.
  418. func TestBadFlagIsAnsweredWithTheCommandsHelp(t *testing.T) {
  419. out, errOut := &bytes.Buffer{}, &bytes.Buffer{}
  420. r := New()
  421. r.Register(&Command{Name: "x", ShortDescription: "has no flags", Run: func(fs any, a []string) (Result, error) { return Nothing(), nil }})
  422. require.Error(t, r.DispatchArgs([]string{"x", "-nope"}, Output{Out: out, Err: errOut}))
  423. assert.Empty(t, out.String())
  424. assert.Equal(t, "flag provided but not defined: -nope\nx - has no flags\n -json\n \toutputs as json\n -pretty\n \tpretty prints json, assumes -json\n", errOut.String())
  425. }
  426. // TestParameterMistakesAreParamErrors: a surface with status codes needs to tell a
  427. // parameter the caller got wrong from a command that failed, and Invoke says which by
  428. // the error's type rather than its words.
  429. func TestParameterMistakesAreParamErrors(t *testing.T) {
  430. r := New()
  431. r.Register(&Command{
  432. Name: "flagged",
  433. Flags: func() (*flag.FlagSet, any) {
  434. fl := flag.NewFlagSet("", flag.ContinueOnError)
  435. s := struct{ N int }{}
  436. fl.IntVar(&s.N, "n", 0, "")
  437. return fl, &s
  438. },
  439. Run: func(fs any, a []string) (Result, error) { return Result{}, errors.New("ran and failed") },
  440. })
  441. r.Register(&Command{
  442. Name: "bare",
  443. Run: func(fs any, a []string) (Result, error) { return Nothing(), nil },
  444. })
  445. var pe *ParamError
  446. for _, tc := range []struct {
  447. name string
  448. params map[string]string
  449. }{
  450. {"flagged", map[string]string{"nope": "1"}},
  451. {"flagged", map[string]string{"n": "x"}},
  452. {"flagged", map[string]string{FlagJSON: "true"}},
  453. {"bare", map[string]string{"n": "1"}},
  454. } {
  455. _, err := Invoke(Request{Command: r.Get(Name(tc.name)), Params: tc.params})
  456. require.Error(t, err, "%s %v", tc.name, tc.params)
  457. require.ErrorAs(t, err, &pe, "%s %v: %v", tc.name, tc.params, err)
  458. }
  459. _, err := Invoke(Request{Command: r.Get("flagged"), Params: map[string]string{"n": "1"}})
  460. require.EqualError(t, err, "ran and failed")
  461. assert.NotErrorAs(t, err, &pe, "a Run error is the command's, not the caller's")
  462. }