diff --git a/script.go b/script.go index 4db47f8..7915656 100644 --- a/script.go +++ b/script.go @@ -25,6 +25,66 @@ import ( "gopkg.in/yaml.v3" ) +func underline(s string) string { + return "\033[4m" + s + "\033[0m" +} + +func usageDetails(cmds map[string]script.Cmd, sortedNames []string) (out []string) { + out = strings.Split(`DESCRIPTION + The 'db' command allows inspecting and manipulating + StateDB tables. Here's an example to get you started: + + > db tables + Name Object count Zombie objects Indexes ... + example 2 0 id, x ... + + > db show example + Name X + one 1 + two 2 + + > db prefix -index=id example o + Name X + one 1 + + > db insert example three.yaml four.yaml + + > db delete example three.yaml + +COMMANDS +`, "\n") + + for _, name := range sortedNames { + cmd := cmds[name] + u := cmd.Usage() + cmdLine := underline(name) + " " + u.Args + // Do manual line wrapping to indent nicely when wrapped. + const wrap = 60 /* wrap mark in script */ - 8 /* indent */ + dots := strings.Repeat(".", len(name)) + if len(cmdLine) > wrap { + idx := strings.LastIndex(cmdLine[:wrap], " ") + out = append(out, cmdLine[:idx]) + rest := cmdLine[idx:] + for { + if len(rest) < wrap { + out = append(out, dots+rest) + break + } + idx := strings.LastIndex(rest[:wrap], " ") + out = append(out, dots+rest[:idx]) + rest = rest[idx:] + } + } else { + out = append(out, cmdLine) + } + out = append(out, "") + out = append(out, u.Detail...) + out = append(out, "") + } + + return out +} + func ScriptCommands(db *DB) hive.ScriptCmdOut { subCmds := map[string]script.Cmd{ "tables": TablesCmd(db), @@ -39,26 +99,25 @@ func ScriptCommands(db *DB) hive.ScriptCmdOut { "watch": WatchCmd(db), "initialized": InitializedCmd(db), } - subCmdsList := strings.Join(slices.Collect(maps.Keys(subCmds)), ", ") + subCmdsNames := slices.Sorted(maps.Keys(subCmds)) + subCmdsList := strings.Join(subCmdsNames, ", ") return hive.NewScriptCmd( "db", script.Command( script.CmdUsage{ - Summary: "Inspect and manipulate StateDB", + Summary: "Inspect and manipulate StateDB ('help db' for usage)", Args: "cmd args...", - Detail: []string{ - "Supported commands: " + subCmdsList, - }, + Detail: usageDetails(subCmds, subCmdsNames), }, func(s *script.State, args ...string) (script.WaitFunc, error) { if len(args) < 1 { - return nil, fmt.Errorf("expected command (%s)", subCmdsList) + return nil, fmt.Errorf("expected command (%s), see 'help db'", subCmdsList) } cmd, ok := subCmds[args[0]] if !ok { - return nil, fmt.Errorf("command not found, expected one of %s", subCmdsList) + return nil, fmt.Errorf("command %q not found, expected one of: %s, see 'help db'", args[0], subCmdsList) } - wf, err := cmd.Run(s, args[1:]...) + wf, err := cmd.Run(s, sortedArgs(args[1:])...) if errors.Is(err, errUsage) { s.Logf("usage: db %s %s\n", args[0], cmd.Usage().Args) } @@ -73,14 +132,24 @@ var errUsage = errors.New("bad arguments") func TablesCmd(db *DB) script.Cmd { return script.Command( script.CmdUsage{ - Summary: "Show StateDB tables", - Args: "table", + Summary: "List StateDB tables", + Detail: []string{ + "List each registered table.", + "The following details are shown:", + "- Name: The name of the table as given to 'NewTable'", + "- Object count: Objects in the table", + "- Zombie objects: Deleted, but not observed objects", + "- Indexes: The indexes specified for the table", + "- Initializers: Pending table initializers", + "- Go type: The Go type, the T in Table[T]", + "- Last WriteTxn: The current/last write against the table", + }, }, func(s *script.State, args ...string) (script.WaitFunc, error) { txn := db.ReadTxn() tbls := db.GetTables(txn) w := newTabWriter(s.LogWriter()) - fmt.Fprintf(w, "Name\tObject count\tDeleted objects\tIndexes\tInitializers\tGo type\tLast WriteTxn\n") + fmt.Fprintf(w, "Name\tObject count\tZombie objects\tIndexes\tInitializers\tGo type\tLast WriteTxn\n") for _, tbl := range tbls { idxs := strings.Join(tbl.Indexes(), ", ") fmt.Fprintf(w, "%s\t%d\t%d\t%s\t%v\t%T\t%s\n", @@ -103,7 +172,15 @@ func InitializedCmd(db *DB) script.Cmd { return script.Command( script.CmdUsage{ Summary: "Wait until all or specific tables have been initialized", - Args: "(-timeout=) table...", + Args: "[-timeout=] table...", + Detail: []string{ + "Waits until all or specific tables have been marked", + "initialized. The default timeout is 5 seconds.", + "", + "This command is useful in tests where you might need to wait", + "for e.g. a background reflector to have started watching before", + "inserting objects.", + }, }, func(s *script.State, args ...string) (script.WaitFunc, error) { txn := db.ReadTxn() @@ -160,8 +237,21 @@ func InitializedCmd(db *DB) script.Cmd { func ShowCmd(db *DB) script.Cmd { return script.Command( script.CmdUsage{ - Summary: "Show table", - Args: "(-o=) (-columns=col1,...) (-format={table,yaml,json}) table", + Summary: "Show the contents of a table", + Args: "[-o=] [-columns=col1,...] [-format={table,yaml,json}] table", + Detail: []string{ + "Show the contents of a table.", + "", + "The contents are written to stdout, but can be written to", + "a file instead with the -o flag.", + "", + "By default the table is shown in the table format.", + "For YAML use '-format=yaml' and for JSON use '-format=json'", + "", + "To only show specific columns use the '-columns' flag. The", + "columns are as specified by 'TableHeader()' method.", + "This flag is only supported with 'table' formatting.", + }, }, func(s *script.State, args ...string) (script.WaitFunc, error) { flags := newCmdFlagSet() @@ -209,7 +299,20 @@ func CompareCmd(db *DB) script.Cmd { return script.Command( script.CmdUsage{ Summary: "Compare table", - Args: "(-timeout=) (-grep=) table file", + Args: "[-timeout=] [-grep=] table file", + Detail: []string{ + "Compare the contents of a table against a file.", + "The comparison is retried until a timeout (1s default).", + "", + "The file should be formatted in the same style as", + "the output from 'db show -format=table'. Indentation", + "does not matter as long as header is aligned with the data.", + "", + "Not all columns need to be specified. Remove the columns", + "from the file you do not want compared.", + "", + "The rows can be filtered with the -grep flag.", + }, }, func(s *script.State, args ...string) (script.WaitFunc, error) { flags := newCmdFlagSet() @@ -321,6 +424,10 @@ func InsertCmd(db *DB) script.Cmd { script.CmdUsage{ Summary: "Insert object into a table", Args: "table path...", + Detail: []string{ + "Insert one or more objects into a table. The input files", + "are expected to be YAML.", + }, }, func(s *script.State, args ...string) (script.WaitFunc, error) { return insertOrDelete(true, db, s, args...) @@ -333,6 +440,11 @@ func DeleteCmd(db *DB) script.Cmd { script.CmdUsage{ Summary: "Delete an object from the table", Args: "table path...", + Detail: []string{ + "Delete one or more objects from the table. The input files", + "are expected to be YAML and need to specify enough of the", + "object to construct the primary key", + }, }, func(s *script.State, args ...string) (script.WaitFunc, error) { return insertOrDelete(false, db, s, args...) @@ -391,19 +503,44 @@ func insertOrDelete(insert bool, db *DB, s *script.State, args ...string) (scrip } func PrefixCmd(db *DB) script.Cmd { - return queryCmd(db, queryCmdPrefix, "Query table by prefix") + return queryCmd(db, + queryCmdPrefix, + "Query table by prefix", + []string{ + "Show all objects that start with the given " + underline("key") + ".", + }, + ) } func LowerBoundCmd(db *DB) script.Cmd { - return queryCmd(db, queryCmdLowerBound, "Query table by lower bound search") + return queryCmd(db, + queryCmdLowerBound, + "Query table by lower bound search", + []string{ + "Show all objects that have a matching key equal or higher", + "than the query " + underline("key") + ".", + }, + ) } func ListCmd(db *DB) script.Cmd { - return queryCmd(db, queryCmdList, "List objects in the table") + return queryCmd(db, + queryCmdList, + "List objects in the table", + []string{ + "Show all objects matching the query key.", + }, + ) } func GetCmd(db *DB) script.Cmd { - return queryCmd(db, queryCmdGet, "Get the first matching object") + return queryCmd(db, + queryCmdGet, + "Get the first matching object", + []string{ + "Show the first object that matches the query key.", + }, + ) } const ( @@ -413,11 +550,12 @@ const ( queryCmdGet ) -func queryCmd(db *DB, query int, summary string) script.Cmd { +func queryCmd(db *DB, query int, summary string, detail []string) script.Cmd { return script.Command( script.CmdUsage{ Summary: summary, - Args: "(-o=) (-columns=col1,...) (-format={table*,yaml,json}) (-index=) table key", + Args: "[-o=] [-columns=col1,...] [-format={table*,yaml,json}] [-index=] table key", + Detail: detail, }, func(s *script.State, args ...string) (script.WaitFunc, error) { return runQueryCmd(query, db, s, args) @@ -516,6 +654,10 @@ func WatchCmd(db *DB) script.Cmd { script.CmdUsage{ Summary: "Watch a table for changes", Args: "table", + Detail: []string{ + "Watch a table for changes. Streams each insert or delete", + "that happens to the table.", + }, }, func(s *script.State, args ...string) (script.WaitFunc, error) { if len(args) < 1 { @@ -829,3 +971,24 @@ func newTabWriter(out io.Writer) *tabwriter.Writer { ) return tabwriter.NewWriter(out, minWidth, width, padding, padChar, flags) } + +// sortArgs sorts the arguments to bring '-arg' first. Allows mixing +// the argument order. If e.g. key starts with '-', then it'll just +// need to be quoted: "db get foo '-mykey'" +func sortedArgs(args []string) []string { + return slices.SortedStableFunc( + slices.Values(args), + func(a, b string) int { + aIsArg := strings.HasPrefix(a, "-") + bIsArg := strings.HasPrefix(b, "-") + switch { + case aIsArg && !bIsArg: + return -1 + case bIsArg && !aIsArg: + return 1 + default: + return 0 + } + }, + ) +} diff --git a/script_test.go b/script_test.go index 6bde166..ed3635c 100644 --- a/script_test.go +++ b/script_test.go @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Authors of Cilium + package statedb import ( @@ -109,3 +112,21 @@ func TestHeaderLine(t *testing.T) { } } } + +func TestSortedFlags(t *testing.T) { + cases := []struct{ input, expected string }{ + {"foo bar", "foo bar"}, + {"bar foo", "bar foo"}, + {"foo bar -baz=1", "-baz=1 foo bar"}, + {"bar foo -baz=1", "-baz=1 bar foo"}, + {"foo -baz=1 bar", "-baz=1 foo bar"}, + {"-baz=1 foo bar", "-baz=1 foo bar"}, + {"-baz=1 foo -quux=2 bar", "-baz=1 -quux=2 foo bar"}, + {"-baz=1 bar foo -quux=2", "-baz=1 -quux=2 bar foo"}, + } + + for _, tc := range cases { + actual := strings.Join(sortedArgs(strings.Split(tc.input, " ")), " ") + assert.Equal(t, tc.expected, actual) + } +}