mirror of
https://github.com/slackhq/nebula.git
synced 2026-11-15 23:56:52 +01:00
Add a local control socket and nebula ctl (#1975)
Add support for accessing the previously ssh-only debug commands via `nebula ctl <command>.` - unix socket (named pipe on windows) for communication - sockets scoped to the user + an optional group - in cmd/nebula and cmd/nebula-service, but not nebula.Main Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
9a5bdb508e
commit
0f767ce529
54 files changed
+7581
-925
No files matched your search
+263
-46
@@ -1,36 +1,73 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CommandFlags is called before help or command execution to parse command line flags.
|
||||
// It should return a flag.FlagSet instance and a pointer to the struct that will contain parsed flags
|
||||
//
|
||||
// Every call must build a fresh FlagSet and a fresh struct. Nothing may be shared between
|
||||
// calls: a command runs on more than one surface and on more than one goroutine.
|
||||
type CommandFlags func() (*flag.FlagSet, any)
|
||||
|
||||
// CommandCallback is called when your command should execute.
|
||||
// fs will be a pointer to the struct provided by Command.Flags callback, if there was one. -h and -help are reserved
|
||||
// and handled automatically for you.
|
||||
// a will be any unconsumed arguments, if no Command.Flags was available this will be all the flags passed in.
|
||||
// w is the writer to use when sending messages back to the client.
|
||||
// An error returned by the callback is handed back to whoever dispatched the command, which reports a failure: exit
|
||||
// status 1 over ssh. The error is not shown to the user. For a failure the user caused or can act on, write the
|
||||
// message and return it as a *Failure, which Fail does in one step; the surface then reports the failure without
|
||||
// logging what the user was already told. Any other error is logged by the surface as well.
|
||||
type CommandCallback func(fs any, a []string, w StringWriter) error
|
||||
// RunFunc computes a command's Result.
|
||||
//
|
||||
// fs is the pointer returned by Command.Flags, if there was one; -h, -help, -json and
|
||||
// -pretty are reserved and handled before Run is called. a is the operands: whatever
|
||||
// was not consumed as a flag. The Result is rendered by whichever surface asked: Value
|
||||
// for a command that reports, Did or Failed for one that acts, Nothing for one with
|
||||
// nothing to say. An error is for a failure of the command itself; a bad argument is
|
||||
// Failed, so the caller hears why in its own format.
|
||||
type RunFunc func(fs any, a []string) (Result, error)
|
||||
|
||||
// TextFunc renders a Result's Value for a person. See Typed for declaring one against
|
||||
// the type the command actually returns.
|
||||
type TextFunc func(v any, w io.Writer) error
|
||||
|
||||
type Command struct {
|
||||
Name Name
|
||||
ShortDescription string
|
||||
Help string
|
||||
|
||||
Flags CommandFlags
|
||||
Callback CommandCallback
|
||||
Flags CommandFlags
|
||||
|
||||
// Run produces the command's Result, leaving presentation to Render.
|
||||
Run RunFunc
|
||||
|
||||
// Text renders a Result's Value for a person. Both surfaces call it, so a command
|
||||
// reads the same over ssh as it does through the control socket. It never sees an
|
||||
// outcome: Render prints those itself, the same way for every command. Leaving it
|
||||
// nil declares that no human rendering of the Value exists yet, which JSONOnly says
|
||||
// up front; Render then refuses text rather than inventing an interim form for
|
||||
// people to script against.
|
||||
Text TextFunc
|
||||
|
||||
// JSONOnly declares that the command's Value has no human rendering yet, so a surface
|
||||
// whose default is text asks for JSON instead, and the schema says so to a client. It
|
||||
// is a statement that no such form exists, not a fallback. A refusal is still
|
||||
// rendered as a sentence, since that always has a human form.
|
||||
JSONOnly bool
|
||||
|
||||
// Then, when set, is the second half of a command that acts: Run says what is about
|
||||
// to happen and Then does it and says how it went, with its Result rendered after
|
||||
// Run's and deciding the outcome. A Run that refused is the outcome, and Then does
|
||||
// not run. It exists for a command whose action can cut off
|
||||
// its own reply: reload restarts the ssh server when the sshd keys changed, which
|
||||
// ends the session that asked, so Run's line has to be on the wire before the reload
|
||||
// begins. A surface that answers in one piece, the control socket, runs Then before
|
||||
// it answers and carries both.
|
||||
Then func() Result
|
||||
}
|
||||
|
||||
// Failure is the error a command returns for a failure it has already explained to the user:
|
||||
// the message it wrote is the whole story. A surface reports it as a failure and does not
|
||||
// log it.
|
||||
// Failure is the error a surface gets for a refusal the person has already been shown:
|
||||
// a Run that returned Failed, a command that is not there, a line that would not parse.
|
||||
// The message it rendered is the whole story, so the surface reports the failure, exit
|
||||
// status 1 over ssh, and does not log it.
|
||||
type Failure struct {
|
||||
Msg string
|
||||
}
|
||||
@@ -39,45 +76,225 @@ func (f *Failure) Error() string {
|
||||
return f.Msg
|
||||
}
|
||||
|
||||
// Fail writes msg to w and returns it as a *Failure, so a command reports a failure to the
|
||||
// user and to its surface in one line. If the write itself fails, that error is returned
|
||||
// instead: the user was not told anything.
|
||||
func Fail(w StringWriter, msg string) error {
|
||||
if err := w.WriteLine(msg); err != nil {
|
||||
return err
|
||||
}
|
||||
return &Failure{Msg: msg}
|
||||
}
|
||||
|
||||
func execCommand(c *Command, args []string, w StringWriter) error {
|
||||
var (
|
||||
fl *flag.FlagSet
|
||||
fs any
|
||||
)
|
||||
|
||||
// commandFlags returns the FlagSet a command is driven through, on any surface: the
|
||||
// command's own flags, if it declares any, with the two presentation flags added. own
|
||||
// reports whether the command declared any of its own, which is what tells a caller
|
||||
// whether to say a parameter is unknown or that the command takes none at all.
|
||||
//
|
||||
// The presentation flags are defined here rather than by each command, because how a
|
||||
// result is displayed is not a property of the command that produced it. This is also
|
||||
// the one definition of them: the ssh line parses into it, nebula ctl parses its line
|
||||
// against the schema derived from it, and help renders from that schema. They are
|
||||
// command line flags only: a socket request chooses its rendering with the envelope's
|
||||
// format field, and Invoke refuses them as parameters.
|
||||
func commandFlags(c *Command) (fl *flag.FlagSet, fs any, own bool) {
|
||||
if c.Flags != nil {
|
||||
fl, fs = c.Flags()
|
||||
if fl != nil {
|
||||
// SetOutput() here in case fl.Parse dumps usage.
|
||||
fl.SetOutput(w.GetWriter())
|
||||
err := fl.Parse(args)
|
||||
if err != nil {
|
||||
// fl.Parse has dumped error information to the user via the w writer.
|
||||
return err
|
||||
}
|
||||
own = fl != nil
|
||||
if fl == nil {
|
||||
fl = flag.NewFlagSet(string(c.Name), flag.ContinueOnError)
|
||||
}
|
||||
|
||||
addPresentationFlags(fl)
|
||||
|
||||
// What Parse prints after a bad flag, and for -h: the command's usage as help renders
|
||||
// it, so a line that went wrong is answered with the same text as `help <command>`.
|
||||
fl.Usage = func() { _ = WriteCommandHelp(schemaOf(c, fl), fl.Output()) }
|
||||
|
||||
return fl, fs, own
|
||||
}
|
||||
|
||||
// addPresentationFlags defines the two presentation flags on fl.
|
||||
func addPresentationFlags(fl *flag.FlagSet) {
|
||||
fl.Bool(FlagJSON, false, "outputs as json")
|
||||
fl.Bool(FlagPretty, false, "pretty prints json, assumes -json")
|
||||
}
|
||||
|
||||
// Flagged fills in c's Flags and Run as a pair: define declares the flags on a fresh F,
|
||||
// and run is handed that F. The struct the flags fill is the struct Run reads by
|
||||
// construction, so no Run has to assert the type it was given or decide what to do
|
||||
// when the assertion fails.
|
||||
func Flagged[F any](c *Command, define func(*flag.FlagSet, *F), run func(*F, []string) (Result, error)) *Command {
|
||||
c.Flags = func() (*flag.FlagSet, any) {
|
||||
fl := flag.NewFlagSet("", flag.ContinueOnError)
|
||||
s := new(F)
|
||||
define(fl, s)
|
||||
return fl, s
|
||||
}
|
||||
c.Run = func(fs any, a []string) (Result, error) {
|
||||
s, ok := fs.(*F)
|
||||
if !ok {
|
||||
return Result{}, fmt.Errorf("%s was handed %T for its flags, not %T", c.Name, fs, s)
|
||||
}
|
||||
return run(s, a)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// isPresentationFlag reports whether name is one of the two flags commandFlags adds.
|
||||
func isPresentationFlag(name string) bool {
|
||||
return name == FlagJSON || name == FlagPretty
|
||||
}
|
||||
|
||||
// execLine runs an ssh command line against c, reporting problems to w. The line is
|
||||
// parsed into the command's own FlagSet and Run reads it directly, so a flag.Value whose
|
||||
// String does not re-parse, flag.Func above all, arrives as the line said. Invoke fills
|
||||
// the same FlagSet through Set.
|
||||
func (r *Registry) execLine(c *Command, args []string, w Output) error {
|
||||
fl, fs, _ := commandFlags(c)
|
||||
|
||||
// Parse reports a bad flag to this writer, with the command's usage after it. -h,
|
||||
// -help and --help take the same path with nothing wrong: the usage is the whole
|
||||
// answer, and Parse says so with ErrHelp. A -h that is a flag's value or follows --
|
||||
// is read as the operand it is, since ParseArgs is the one reading of the line.
|
||||
fl.SetOutput(w.Err)
|
||||
operands, err := ParseArgs(fl, args)
|
||||
if err != nil {
|
||||
if errors.Is(err, flag.ErrHelp) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
format := presentationFormat(fl)
|
||||
|
||||
// An error from Run is something the command could not do rather than a refusal it
|
||||
// chose, but the person at the prompt still hears it; the surface logs it too.
|
||||
res, err := c.Run(fs, operands)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintln(w.Err, err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
// Run's reply goes out first, and then the action, if the command has one and Run
|
||||
// did not refuse, whose Result is the outcome. In JSON only the outcome travels:
|
||||
// the promise is for a person to read, and a program expects one document.
|
||||
if c.Then == nil || !res.OK || r.formatFor(c, res, format) == FormatText {
|
||||
if err := r.renderLine(c, res, format, w); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if c.Then != nil && res.OK {
|
||||
res = c.Then()
|
||||
if err := r.renderLine(c, res, format, w); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if !res.OK {
|
||||
return &Failure{Msg: res.Reason}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// formatFor is the format a Result is rendered in. The line wins when it asked for one;
|
||||
// otherwise the surface that owns the registry decides, from the command and its
|
||||
// Result, so each surface can keep its own default.
|
||||
func (r *Registry) formatFor(c *Command, res Result, format Format) Format {
|
||||
if format == FormatUnset {
|
||||
return r.defaultFormatFor(c, res)
|
||||
}
|
||||
return format
|
||||
}
|
||||
|
||||
// renderLine writes one Result of a command line to w; see formatFor. An empty Result
|
||||
// writes nothing.
|
||||
func (r *Registry) renderLine(c *Command, res Result, format Format, w Output) error {
|
||||
if res.Empty() {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := Render(c, res, r.formatFor(c, res, format), w.Out); err != nil {
|
||||
if errors.Is(err, ErrNoTextRendering) {
|
||||
_, _ = fmt.Fprintln(w.Err, err.Error())
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ParseArgs parses args with fl and returns the operands.
|
||||
//
|
||||
// It differs from fl.Parse in two ways, and both surfaces parse through here so a line
|
||||
// means the same typed at an ssh prompt or to nebula ctl.
|
||||
//
|
||||
// Flags and operands may come in any order, which is the one way it differs from
|
||||
// fl.Parse. Parse stops at the first operand, so `print-tunnel 10.0.0.1 -json` would
|
||||
// reach the command with "-json" as a second operand and the flag silently dropped. Here
|
||||
// an operand ends nothing: parsing resumes after it, and only "--" ends the flags, with
|
||||
// everything after it an operand. What counts as a flag is Parse's rule: every argument
|
||||
// that starts with "-", a negative number included, so one that is meant as an operand
|
||||
// goes after "--".
|
||||
func ParseArgs(fl *flag.FlagSet, args []string) ([]string, error) {
|
||||
operands := make([]string, 0, len(args))
|
||||
|
||||
for len(args) > 0 {
|
||||
// Walk the flags the way Parse will, to the first argument it would not read as a
|
||||
// flag: an operand or the terminator. Everything before it is Parse's.
|
||||
end := len(args)
|
||||
for i := 0; i < len(args); {
|
||||
a := args[i]
|
||||
if a == "--" {
|
||||
if err := fl.Parse(args[:i]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append(operands, args[i+1:]...), nil
|
||||
}
|
||||
|
||||
if len(a) < 2 || a[0] != '-' {
|
||||
end = i
|
||||
break
|
||||
}
|
||||
|
||||
f, hasValue := LookupArg(fl, a)
|
||||
if f == nil {
|
||||
// An undefined flag: leave it for Parse to report.
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
if hasValue || IsBoolFlag(f) {
|
||||
i++
|
||||
} else {
|
||||
// The flag's value is the next argument, whatever it looks like.
|
||||
i += 2
|
||||
}
|
||||
args = fl.Args()
|
||||
}
|
||||
|
||||
if err := fl.Parse(args[:end]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if end == len(args) {
|
||||
break
|
||||
}
|
||||
|
||||
operands = append(operands, args[end])
|
||||
args = args[end+1:]
|
||||
}
|
||||
|
||||
return c.Callback(fs, args, w)
|
||||
return operands, nil
|
||||
}
|
||||
|
||||
func checkHelpArgs(args []string) bool {
|
||||
for _, a := range args {
|
||||
if a == "-h" || a == "-help" {
|
||||
return true
|
||||
}
|
||||
// LookupArg reads arg the way Parse does: one or two leading dashes, the flag's name, and
|
||||
// an optional "=value". f is the flag of fl by that name, nil when fl has none, and
|
||||
// hasValue reports that the value came with the argument rather than in the one after it.
|
||||
// It is the one reading of an argument's shape: ParseArgs walks a line with it, and
|
||||
// nebula ctl lifts its own flags out of a line with it, so the two cannot disagree about
|
||||
// which word is a flag's value.
|
||||
func LookupArg(fl *flag.FlagSet, arg string) (f *flag.Flag, hasValue bool) {
|
||||
name := strings.TrimPrefix(strings.TrimPrefix(arg, "-"), "-")
|
||||
if eq := strings.IndexByte(name, '='); eq >= 0 {
|
||||
name, hasValue = name[:eq], true
|
||||
}
|
||||
return fl.Lookup(name), hasValue
|
||||
}
|
||||
|
||||
return false
|
||||
// IsBoolFlag reports whether f is satisfied by its presence alone and never reads the
|
||||
// argument after it as its value. The flag package marks these with an unexported
|
||||
// interface, which is the only way to tell.
|
||||
func IsBoolFlag(f *flag.Flag) bool {
|
||||
bf, ok := f.Value.(interface{ IsBoolFlag() bool })
|
||||
return ok && bf.IsBoolFlag()
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"flag"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestParseArgsAllowsFlagsAfterOperands pins the one way ParseArgs departs from fl.Parse,
|
||||
// that a flag may follow an operand, and that it departs in no other: a negative number
|
||||
// is a flag, as Parse reads it, and goes after "--" when it is meant as an operand.
|
||||
func TestParseArgsAllowsFlagsAfterOperands(t *testing.T) {
|
||||
cases := map[string]struct {
|
||||
args []string
|
||||
wantOps []string
|
||||
wantN string
|
||||
wantV bool
|
||||
wantJSON bool
|
||||
wantErr string
|
||||
}{
|
||||
"flag after operand": {args: []string{"x", "-json"}, wantOps: []string{"x"}, wantJSON: true},
|
||||
"flags around": {args: []string{"-v", "x", "-n", "1", "y"}, wantOps: []string{"x", "y"}, wantV: true, wantN: "1"},
|
||||
"value after operand": {args: []string{"x", "-n", "-1"}, wantOps: []string{"x"}, wantN: "-1"},
|
||||
"equals value": {args: []string{"-n=-1", "x"}, wantOps: []string{"x"}, wantN: "-1"},
|
||||
"terminator after": {args: []string{"x", "--", "-json", "-1"}, wantOps: []string{"x", "-json", "-1"}},
|
||||
"terminator": {args: []string{"--", "-json"}, wantOps: []string{"-json"}},
|
||||
"defined numeric": {args: []string{"-2", "x"}, wantOps: []string{"x"}, wantV: true},
|
||||
"negative number": {args: []string{"-1"}, wantErr: "flag provided but not defined: -1"},
|
||||
"bad flag after": {args: []string{"x", "-nope"}, wantErr: "flag provided but not defined: -nope"},
|
||||
"undefined flag": {args: []string{"-nope", "x"}, wantErr: "flag provided but not defined: -nope"},
|
||||
"missing value": {args: []string{"-n"}, wantErr: "flag needs an argument: -n"},
|
||||
}
|
||||
|
||||
for name, tc := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
fl := flag.NewFlagSet("t", flag.ContinueOnError)
|
||||
fl.SetOutput(&bytes.Buffer{})
|
||||
n := fl.String("n", "", "")
|
||||
v := fl.Bool("v", false, "")
|
||||
two := fl.Bool("2", false, "a flag that happens to be a number")
|
||||
asJSON := fl.Bool(FlagJSON, false, "")
|
||||
|
||||
ops, err := ParseArgs(fl, tc.args)
|
||||
if tc.wantErr != "" {
|
||||
require.ErrorContains(t, err, tc.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.wantOps, ops)
|
||||
assert.Equal(t, tc.wantN, *n)
|
||||
assert.Equal(t, tc.wantV, *v || *two)
|
||||
assert.Equal(t, tc.wantJSON, *asJSON)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDispatchFlagAfterOperand: the same through a registered command with no Flags of
|
||||
// its own, which only has the presentation flags to find.
|
||||
func TestDispatchFlagAfterOperand(t *testing.T) {
|
||||
out := &bytes.Buffer{}
|
||||
require.NoError(t, testRegistry().DispatchArgs([]string{"served", "x", "-json"}, MergedOutput(out)))
|
||||
assert.Equal(t, "\"read x\"\n", out.String())
|
||||
}
|
||||
|
||||
// TestLookupArg pins the reading of an argument's shape that ParseArgs and nebula ctl
|
||||
// share.
|
||||
func TestLookupArg(t *testing.T) {
|
||||
fl := flag.NewFlagSet("t", flag.ContinueOnError)
|
||||
fl.String("n", "", "")
|
||||
fl.Bool("v", false, "")
|
||||
|
||||
for _, tc := range []struct {
|
||||
arg string
|
||||
name string
|
||||
hasValue bool
|
||||
}{
|
||||
{"-n", "n", false},
|
||||
{"--n", "n", false},
|
||||
{"-n=1", "n", true},
|
||||
{"--n=", "n", true},
|
||||
{"-v", "v", false},
|
||||
{"-nope", "", false},
|
||||
{"-1", "", false},
|
||||
} {
|
||||
f, hasValue := LookupArg(fl, tc.arg)
|
||||
if tc.name == "" {
|
||||
assert.Nil(t, f, tc.arg)
|
||||
} else {
|
||||
require.NotNil(t, f, tc.arg)
|
||||
assert.Equal(t, tc.name, f.Name, tc.arg)
|
||||
}
|
||||
assert.Equal(t, tc.hasValue, hasValue, tc.arg)
|
||||
}
|
||||
|
||||
assert.True(t, IsBoolFlag(fl.Lookup("v")))
|
||||
assert.False(t, IsBoolFlag(fl.Lookup("n")))
|
||||
}
|
||||
+316
-69
@@ -2,7 +2,9 @@ package commands
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"maps"
|
||||
"slices"
|
||||
"strings"
|
||||
@@ -11,14 +13,38 @@ import (
|
||||
"github.com/anmitsu/go-shlex"
|
||||
)
|
||||
|
||||
// Registry holds a set of commands. New is the only way to make one.
|
||||
// Registry holds a set of commands.
|
||||
//
|
||||
// It is safe for concurrent use. Commands may be registered while a surface is serving
|
||||
// from the registry: the ssh server clones it per session, and a clone may overlap a
|
||||
// Register.
|
||||
// It is safe for concurrent use. Commands may be registered while surfaces are serving
|
||||
// from the registry: the ssh server clones it per session and the control socket filters
|
||||
// it per request, and either may overlap a Register.
|
||||
type Registry struct {
|
||||
mu sync.RWMutex
|
||||
commands map[Name]*Command
|
||||
|
||||
// full is set on a filtered registry and points at the registry it was filtered
|
||||
// from, so a command that's filtered can be reported as such.
|
||||
full *Registry
|
||||
|
||||
// reason explains, in the terms an operator cares about, why this registry withholds
|
||||
// a command they can name. It is supplied by whoever built the filter, because why a
|
||||
// surface declines a command is that surface's business, not the registry's.
|
||||
reason string
|
||||
|
||||
// DefaultFormat decides how a result is rendered when the command line named no
|
||||
// format. It sees the command and its Result, and applies to values and refusals
|
||||
// alike: the registry does not special-case either. The renderer is shared between
|
||||
// surfaces; what each asks for by default is not, so the surface that owns this
|
||||
// registry sets it. Nil means text.
|
||||
DefaultFormat func(c *Command, r Result) Format
|
||||
}
|
||||
|
||||
// defaultFormatFor resolves the surface's default for c's Result.
|
||||
func (r *Registry) defaultFormatFor(c *Command, res Result) Format {
|
||||
if r.DefaultFormat == nil {
|
||||
return FormatText
|
||||
}
|
||||
return r.DefaultFormat(c, res)
|
||||
}
|
||||
|
||||
// New returns an empty Registry with only `help` available.
|
||||
@@ -32,15 +58,111 @@ func (r *Registry) registerHelp() {
|
||||
r.Register(&Command{
|
||||
Name: Help,
|
||||
ShortDescription: "prints available commands or help <command> for specific usage info",
|
||||
Callback: func(a any, args []string, w StringWriter) error {
|
||||
return r.help(args, w)
|
||||
Run: func(a any, args []string) (Result, error) {
|
||||
if len(args) == 0 {
|
||||
return Value(r.Schema()), nil
|
||||
}
|
||||
|
||||
if c := r.Get(Name(args[0])); c != nil {
|
||||
return Value(r.schemaFor(c)), nil
|
||||
}
|
||||
|
||||
if err := r.unavailable(Name(args[0])); err != nil {
|
||||
return Failed("%s", err), nil
|
||||
}
|
||||
|
||||
return Failed("Command not available %s", args[0]), nil
|
||||
},
|
||||
Text: renderHelp,
|
||||
})
|
||||
}
|
||||
|
||||
// Register adds a command that can be run by a user. A command registered under a name
|
||||
// already held replaces it; see Clone for what that means for Help.
|
||||
// renderHelp is help's human rendering: the command list, or one command's usage.
|
||||
func renderHelp(v any, w io.Writer) error {
|
||||
switch h := v.(type) {
|
||||
case []CommandSchema:
|
||||
return writeCommandList(h, w)
|
||||
case CommandSchema:
|
||||
return WriteCommandHelp(h, w)
|
||||
default:
|
||||
return fmt.Errorf("expected a command schema, got %T", v)
|
||||
}
|
||||
}
|
||||
|
||||
func writeCommandList(cmds []CommandSchema, w io.Writer) error {
|
||||
if _, err := io.WriteString(w, "Available commands:\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// In the order the schema came, which is the display order; see Registry.All.
|
||||
lines := make([]string, 0, len(cmds))
|
||||
for _, c := range cmds {
|
||||
lines = append(lines, fmt.Sprintf("%s - %s", c.Name, c.Short))
|
||||
}
|
||||
|
||||
_, err := io.WriteString(w, strings.Join(lines, "\n")+"\n\n")
|
||||
return err
|
||||
}
|
||||
|
||||
// WriteCommandHelp renders one command's usage. It is the single rendering of a
|
||||
// CommandSchema for a person: the daemon uses it for `help <command>` on both surfaces,
|
||||
// and a client that already holds the schema uses it for `-h`, so the two cannot drift.
|
||||
func WriteCommandHelp(c CommandSchema, w io.Writer) error {
|
||||
if _, err := fmt.Fprintf(w, "%s - %s\n", c.Name, c.Short); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if c.Help != "" {
|
||||
if _, err := fmt.Fprintf(w, " %s\n", c.Help); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Same layout as flag.PrintDefaults, including its habit of leaving out a default
|
||||
// that is the zero value.
|
||||
for _, f := range c.Flags {
|
||||
kind := ""
|
||||
if !f.Bool {
|
||||
kind = " string"
|
||||
}
|
||||
def := ""
|
||||
if f.Default != "" && f.Default != "false" {
|
||||
def = fmt.Sprintf(" (default %s)", f.Default)
|
||||
}
|
||||
if _, err := fmt.Fprintf(w, " -%s%s\n \t%s%s\n", f.Name, kind, f.Usage, def); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Register adds a command that can be run by a user.
|
||||
//
|
||||
// It panics if the command declares a reserved flag. That is a programming error, and
|
||||
// surfacing it here, at startup, is better than at the moment an operator first types
|
||||
// the command.
|
||||
func (r *Registry) Register(c *Command) {
|
||||
if c.Run == nil {
|
||||
panic(fmt.Sprintf("commands: %s has no Run", c.Name))
|
||||
}
|
||||
|
||||
if c.Flags != nil {
|
||||
if fl, _ := c.Flags(); fl != nil {
|
||||
for _, name := range reservedFlags {
|
||||
if fl.Lookup(name) != nil {
|
||||
panic(fmt.Sprintf("commands: %s declares reserved flag -%s", c.Name, name))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
r.insert(c)
|
||||
}
|
||||
|
||||
// insert adds c without the reserved flag check, for commands that have already passed
|
||||
// it on another registry.
|
||||
func (r *Registry) insert(c *Command) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.commands[c.Name] = c
|
||||
@@ -68,7 +190,8 @@ func (r *Registry) Match(prefix string) []string {
|
||||
return cmds
|
||||
}
|
||||
|
||||
// All returns every registered command sorted by name, which is the order help lists them.
|
||||
// All returns every registered command sorted by name, which is the order help lists
|
||||
// them and the schema describes them.
|
||||
func (r *Registry) All() []*Command {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
@@ -78,104 +201,228 @@ func (r *Registry) All() []*Command {
|
||||
return cmds
|
||||
}
|
||||
|
||||
// Clone returns a copy holding the same commands, with help bound to the copy: a
|
||||
// command registered on the clone alone is listed and described there, and one the
|
||||
// clone withholds is not. That rebinding replaces whatever was registered under Help on
|
||||
// the source, built-in or not.
|
||||
// Clone returns a copy holding the same commands.
|
||||
//
|
||||
// help is re-registered on the copy rather than carried over, because the source's help
|
||||
// is bound to the source and would list its commands, not the copy's, once a caller
|
||||
// registers something on the copy.
|
||||
func (r *Registry) Clone() *Registry {
|
||||
r.mu.RLock()
|
||||
c := &Registry{commands: maps.Clone(r.commands)}
|
||||
out := &Registry{
|
||||
commands: maps.Clone(r.commands),
|
||||
full: r.full,
|
||||
reason: r.reason,
|
||||
DefaultFormat: r.DefaultFormat,
|
||||
}
|
||||
r.mu.RUnlock()
|
||||
c.registerHelp()
|
||||
return c
|
||||
|
||||
out.registerHelp()
|
||||
return out
|
||||
}
|
||||
|
||||
// Filter returns a filtered copy of the registry.
|
||||
//
|
||||
// Keep should return true for commands that should be included in the copy.
|
||||
//
|
||||
// reason says, to an operator who names a command the copy left out, why this surface
|
||||
// does not serve it; "" leaves the registry to say only that it does not.
|
||||
func (r *Registry) Filter(keep func(*Command) bool, reason string) *Registry {
|
||||
out := &Registry{commands: map[Name]*Command{}, full: r, reason: reason, DefaultFormat: r.DefaultFormat}
|
||||
|
||||
for _, cmd := range r.All() {
|
||||
if keep(cmd) {
|
||||
out.insert(cmd)
|
||||
}
|
||||
}
|
||||
|
||||
// Bind help to the filtered set so it only advertises what this surface serves.
|
||||
out.registerHelp()
|
||||
return out
|
||||
}
|
||||
|
||||
// Dispatch parses a command line and runs it, reporting problems to w. The error is for
|
||||
// the caller to log, not to show; see CommandCallback.
|
||||
func (r *Registry) Dispatch(line string, w StringWriter) error {
|
||||
// A line shlex cannot split, an unbalanced quote.
|
||||
// the caller to log, not to show; see RunFunc and Failure.
|
||||
func (r *Registry) Dispatch(line string, w Output) error {
|
||||
// A line shlex cannot split, an unbalanced quote. Reported on Err so the output
|
||||
// stream stays parseable.
|
||||
args, err := shlex.Split(line, true)
|
||||
if err != nil {
|
||||
return Fail(w, "could not parse the command line: "+err.Error())
|
||||
msg := "could not parse the command line: " + err.Error()
|
||||
if _, err := fmt.Fprintln(w.Err, msg); err != nil {
|
||||
return err
|
||||
}
|
||||
return &Failure{Msg: msg}
|
||||
}
|
||||
|
||||
return r.DispatchArgs(args, w)
|
||||
}
|
||||
|
||||
// DispatchArgs runs an already tokenized command line. The error is as for Dispatch.
|
||||
func (r *Registry) DispatchArgs(args []string, w StringWriter) error {
|
||||
//
|
||||
// An empty line and an unknown command both end up running `help`, the first with the
|
||||
// arguments help itself would take; `<command> -h` is answered by the command's own
|
||||
// FlagSet, with the same rendering, in execLine.
|
||||
func (r *Registry) DispatchArgs(args []string, w Output) error {
|
||||
if len(args) == 0 {
|
||||
r.dumpCommands(w)
|
||||
return nil
|
||||
args = []string{"help"}
|
||||
}
|
||||
|
||||
c := r.Get(Name(args[0]))
|
||||
if c == nil {
|
||||
// Only the first word was looked up, so only it is named.
|
||||
msg := fmt.Sprintf("did not understand: %s", args[0])
|
||||
if err := w.WriteLine(msg); err != nil {
|
||||
if err := r.unavailable(Name(args[0])); err != nil {
|
||||
// Saying why, rather than "did not understand", keeps the rule legible and
|
||||
// points at a surface that will actually run the command.
|
||||
_, _ = fmt.Fprintln(w.Err, err)
|
||||
return err
|
||||
}
|
||||
|
||||
r.dumpCommands(w)
|
||||
// Only the first word was looked up, so only it is named.
|
||||
msg := fmt.Sprintf("did not understand: %s", args[0])
|
||||
if _, err := fmt.Fprintln(w.Out, msg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := r.DispatchArgs([]string{"help"}, w); err != nil {
|
||||
return err
|
||||
}
|
||||
return &Failure{Msg: msg}
|
||||
}
|
||||
|
||||
if checkHelpArgs(args) {
|
||||
// Through the registered help command, so -h and `help <name>` are one path.
|
||||
return execCommand(r.Get(Help), []string{string(c.Name)}, w)
|
||||
}
|
||||
|
||||
return execCommand(c, args[1:], w)
|
||||
return r.execLine(c, args[1:], w)
|
||||
}
|
||||
|
||||
// dumpCommands writes the list of available commands to w.
|
||||
func (r *Registry) dumpCommands(w StringWriter) {
|
||||
err := w.WriteLine("Available commands:")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// In the order All gives, which is the display order.
|
||||
cmds := make([]string, 0)
|
||||
for _, l := range r.All() {
|
||||
cmds = append(cmds, fmt.Sprintf("%s - %s", l.Name, l.ShortDescription))
|
||||
}
|
||||
|
||||
_ = w.Write(strings.Join(cmds, "\n") + "\n\n")
|
||||
}
|
||||
|
||||
// help writes the command list, or usage for the single command named in a.
|
||||
func (r *Registry) help(a []string, w StringWriter) error {
|
||||
// Just typed help
|
||||
if len(a) == 0 {
|
||||
r.dumpCommands(w)
|
||||
// unavailable explains why name cannot be run here when this registry was filtered from
|
||||
// one that holds it: the command exists, and this surface withholds it, for the reason
|
||||
// whoever built the filter gave. It is nil when name is simply unknown, so the two cases
|
||||
// read differently to an operator.
|
||||
func (r *Registry) unavailable(name Name) error {
|
||||
if r.full == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// We are printing a specific commands help text
|
||||
cmd := r.Get(Name(a[0]))
|
||||
if cmd == nil {
|
||||
return Fail(w, "Command not available "+a[0])
|
||||
c := r.full.Get(name)
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := w.WriteLine(fmt.Sprintf("%s - %s", cmd.Name, cmd.ShortDescription)); err != nil {
|
||||
return err
|
||||
reason := r.reason
|
||||
if reason == "" {
|
||||
reason = "this surface does not serve it"
|
||||
}
|
||||
|
||||
if cmd.Help != "" {
|
||||
if err := w.WriteLine(fmt.Sprintf(" %s", cmd.Help)); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("%s is not available on this surface: %s", c.Name, reason)
|
||||
}
|
||||
|
||||
// Resolve returns the command registered under name, or says why there is none: the
|
||||
// filter's reason for one this surface withholds, or unknown for a name that does not
|
||||
// exist.
|
||||
func (r *Registry) Resolve(name Name) (*Command, error) {
|
||||
if c := r.Get(name); c != nil {
|
||||
return c, nil
|
||||
}
|
||||
|
||||
if err := r.unavailable(name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("unknown command %q", name)
|
||||
}
|
||||
|
||||
// setParams sets each named parameter on fl, in the order of names, and stops at the
|
||||
// first that is unknown or does not parse. own says whether the command declared flags
|
||||
// of its own, which decides between "takes no parameters" and "has no parameter".
|
||||
//
|
||||
// The presentation flags are on fl, since commandFlags put them there, but they are not
|
||||
// parameters: a request has one place to choose its rendering, the envelope's format
|
||||
// field, and a client that sends them here is told where that is rather than having a
|
||||
// second, weaker way to say the same thing.
|
||||
func setParams(fl *flag.FlagSet, name string, names []string, params map[string]string, own bool) error {
|
||||
for _, k := range names {
|
||||
if isPresentationFlag(k) {
|
||||
return fmt.Errorf("%s is a command line flag, not a parameter; choose the rendering with the request's format field", k)
|
||||
}
|
||||
}
|
||||
|
||||
if cmd.Flags != nil {
|
||||
fs, _ := cmd.Flags()
|
||||
if fs != nil {
|
||||
fs.SetOutput(w.GetWriter())
|
||||
fs.PrintDefaults()
|
||||
f := fl.Lookup(k)
|
||||
if f == nil {
|
||||
if !own {
|
||||
return fmt.Errorf("%s takes no parameters", name)
|
||||
}
|
||||
return fmt.Errorf("%s has no parameter %q", name, k)
|
||||
}
|
||||
|
||||
v := params[k]
|
||||
if err := fl.Set(k, v); err != nil {
|
||||
if IsBoolFlag(f) {
|
||||
// The flag package's own words for this are "parse error".
|
||||
return fmt.Errorf("%s: expected a boolean, got %q", k, v)
|
||||
}
|
||||
return fmt.Errorf("%s: %w", k, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Schema describes every command in the registry, so a client can offer them without
|
||||
// having been built against this daemon.
|
||||
func (r *Registry) Schema() []CommandSchema {
|
||||
all := r.All()
|
||||
out := make([]CommandSchema, 0, len(all))
|
||||
for _, c := range all {
|
||||
out = append(out, r.schemaFor(c))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// schemaFor describes one command. It is the one derivation, for the whole schema and
|
||||
// for `help <command>` alike.
|
||||
func (r *Registry) schemaFor(c *Command) CommandSchema {
|
||||
fl, _, _ := commandFlags(c)
|
||||
return schemaOf(c, fl)
|
||||
}
|
||||
|
||||
// schemaOf describes c as driven through fl, the FlagSet commandFlags built for it. The
|
||||
// presentation flags are included because a command line client parses its line
|
||||
// against this and help renders from it; they are listed after the command's own,
|
||||
// which is where readers expect them. Invoke refuses them as parameters.
|
||||
func schemaOf(c *Command, fl *flag.FlagSet) CommandSchema {
|
||||
cs := CommandSchema{Name: c.Name, Short: c.ShortDescription, Help: c.Help}
|
||||
|
||||
var own, shared []FlagSchema
|
||||
fl.VisitAll(func(f *flag.Flag) {
|
||||
fs := FlagSchema{
|
||||
Name: f.Name,
|
||||
Bool: IsBoolFlag(f),
|
||||
Default: f.DefValue,
|
||||
Usage: f.Usage,
|
||||
}
|
||||
if isPresentationFlag(f.Name) {
|
||||
shared = append(shared, fs)
|
||||
return
|
||||
}
|
||||
own = append(own, fs)
|
||||
})
|
||||
cs.Flags = append(own, shared...)
|
||||
|
||||
return cs
|
||||
}
|
||||
|
||||
// CommandSchema describes one command well enough for a client to offer it.
|
||||
type CommandSchema struct {
|
||||
Name Name `json:"name"`
|
||||
Short string `json:"short,omitempty"`
|
||||
Help string `json:"help,omitempty"`
|
||||
Flags []FlagSchema `json:"flags,omitempty"`
|
||||
}
|
||||
|
||||
// FlagSchema describes one of a command's named parameters.
|
||||
type FlagSchema struct {
|
||||
Name string `json:"name"`
|
||||
|
||||
// Bool reports a parameter that takes no value, which is what tells a command line
|
||||
// client whether `-pretty` swallows the word after it.
|
||||
Bool bool `json:"bool,omitempty"`
|
||||
|
||||
Default string `json:"default,omitempty"`
|
||||
Usage string `json:"usage,omitempty"`
|
||||
}
|
||||
+352
-108
@@ -4,173 +4,417 @@ import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"flag"
|
||||
"io"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// notWithheld is a stand in for a surface's own policy, which the registry knows nothing
|
||||
// of; see Command.Identity for the one the info API applies.
|
||||
func notWithheld(c *Command) bool { return c.Name != "withheld" }
|
||||
|
||||
const testReason = "this surface does not run commands that write"
|
||||
|
||||
// textLine is a Text renderer for test commands whose result is one string.
|
||||
func textLine(v any, w io.Writer) error {
|
||||
_, err := io.WriteString(w, v.(string)+"\n")
|
||||
return err
|
||||
}
|
||||
|
||||
func testRegistry() *Registry {
|
||||
r := New()
|
||||
r.Register(&Command{
|
||||
Name: "served",
|
||||
ShortDescription: "a command this surface serves",
|
||||
Callback: func(fs any, a []string, w StringWriter) error {
|
||||
return w.WriteLine("read " + strings.Join(a, ","))
|
||||
Run: func(fs any, a []string) (Result, error) {
|
||||
return Value("read " + strings.Join(a, ",")), nil
|
||||
},
|
||||
Text: textLine,
|
||||
})
|
||||
r.Register(&Command{
|
||||
Name: "withheld",
|
||||
ShortDescription: "a command this surface withholds",
|
||||
Callback: func(fs any, a []string, w StringWriter) error {
|
||||
return w.WriteLine("wrote")
|
||||
Run: func(fs any, a []string) (Result, error) {
|
||||
return Value("wrote"), nil
|
||||
},
|
||||
Text: textLine,
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
// TestFlagUsageGoesToTheWriter pins that a parse failure's usage lands on the command's
|
||||
// writer.
|
||||
func TestFlagUsageGoesToTheWriter(t *testing.T) {
|
||||
// TestFilterKeepsWhatKeepSays: the copy holds the commands keep accepted, and help.
|
||||
func TestFilterKeepsWhatKeepSays(t *testing.T) {
|
||||
ro := testRegistry().Filter(notWithheld, testReason)
|
||||
|
||||
names := make([]string, 0)
|
||||
for _, c := range ro.All() {
|
||||
names = append(names, string(c.Name))
|
||||
}
|
||||
|
||||
assert.ElementsMatch(t, []string{"help", "served"}, names)
|
||||
}
|
||||
|
||||
// TestRefusesWithheldByName covers a client that knows a command exists and asks for it
|
||||
// anyway. The filter lives on the serving side, so the answer must not depend on what
|
||||
// the client was told was available.
|
||||
func TestRefusesWithheldByName(t *testing.T) {
|
||||
out, errOut := &bytes.Buffer{}, &bytes.Buffer{}
|
||||
w := Output{Out: out, Err: errOut}
|
||||
|
||||
err := testRegistry().Filter(notWithheld, testReason).DispatchArgs([]string{"withheld"}, w)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "withheld is not available on this surface")
|
||||
assert.Empty(t, out.String(), "a withheld command must produce no output")
|
||||
assert.Contains(t, errOut.String(), "withheld is not available on this surface")
|
||||
assert.Contains(t, errOut.String(), testReason,
|
||||
"the registry should render the explanation the filter supplied, not one of its own")
|
||||
}
|
||||
|
||||
// TestWithheldIsNotReportedAsUnknown keeps the two cases distinct: an operator who
|
||||
// mistypes and one who asked for a real command should not get the same answer.
|
||||
func TestWithheldIsNotReportedAsUnknown(t *testing.T) {
|
||||
out, errOut := &bytes.Buffer{}, &bytes.Buffer{}
|
||||
err := testRegistry().Filter(notWithheld, testReason).DispatchArgs([]string{"no-such-command"}, Output{Out: out, Err: errOut})
|
||||
|
||||
var f *Failure
|
||||
require.ErrorAs(t, err, &f, "an unknown command is a failure to the surface")
|
||||
assert.Contains(t, out.String(), "did not understand: no-such-command")
|
||||
}
|
||||
|
||||
// paramRegistry holds one command with a bool flag and a duration flag, and reports what
|
||||
// the command saw when it ran.
|
||||
func paramRegistry(t *testing.T) (*Registry, *[]paramsSeen) {
|
||||
t.Helper()
|
||||
|
||||
var seen []paramsSeen
|
||||
r := New()
|
||||
r.Register(&Command{
|
||||
Name: "emit",
|
||||
ShortDescription: "emits",
|
||||
Name: "set",
|
||||
ShortDescription: "sets things",
|
||||
Flags: func() (*flag.FlagSet, any) {
|
||||
fl := flag.NewFlagSet("", flag.ContinueOnError)
|
||||
fl.Bool("json", false, "emit json")
|
||||
return fl, nil
|
||||
s := paramsSeen{}
|
||||
fl.BoolVar(&s.Verbose, "verbose", false, "say more")
|
||||
fl.DurationVar(&s.Wait, "wait", 0, "how long")
|
||||
return fl, &s
|
||||
},
|
||||
Run: func(fs any, a []string) (Result, error) {
|
||||
seen = append(seen, *fs.(*paramsSeen))
|
||||
return Value("ok"), nil
|
||||
},
|
||||
Callback: func(fs any, a []string, w StringWriter) error { return nil },
|
||||
})
|
||||
|
||||
merged := &bytes.Buffer{}
|
||||
require.Error(t, r.DispatchArgs([]string{"emit", "-nope"}, NewStringWriter(merged)))
|
||||
assert.Contains(t, merged.String(), "flag provided but not defined")
|
||||
return r, &seen
|
||||
}
|
||||
|
||||
// TestDispatchReportsUnparseableLine: a line shlex rejects, an unbalanced quote, is a
|
||||
// failure the user is told about.
|
||||
type paramsSeen struct {
|
||||
Verbose bool
|
||||
Wait time.Duration
|
||||
}
|
||||
|
||||
// TestInvokeReportsParametersInNameOrder: parameters arrive as an unordered object, so
|
||||
// with two bad ones the error must not depend on map iteration. Name order decides.
|
||||
func TestInvokeReportsParametersInNameOrder(t *testing.T) {
|
||||
r, _ := paramRegistry(t)
|
||||
|
||||
cases := map[string]struct {
|
||||
params map[string]string
|
||||
want string
|
||||
}{
|
||||
"two unknown": {map[string]string{"zed": "1", "alpha": "1"}, `set has no parameter "alpha"`},
|
||||
"unknown before bad bool": {map[string]string{"aaa": "1", "verbose": "maybe"}, `set has no parameter "aaa"`},
|
||||
"bad bool before unknown": {map[string]string{"verbose": "maybe", "zzz": "1"}, `verbose: expected a boolean, got "maybe"`},
|
||||
"bad value before bad bool": {map[string]string{"wait": "soon", "verbose": "maybe"}, `verbose: expected a boolean`},
|
||||
}
|
||||
|
||||
for name, tc := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
// Enough runs that a map-order dependence would show.
|
||||
for i := 0; i < 50; i++ {
|
||||
_, err := Invoke(Request{Command: r.Get("set"), Params: tc.params})
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestInvokeWithABadParameterSetsNothing: a request with any bad parameter must not run
|
||||
// the command, and must leave nothing behind for the next request to see. The good
|
||||
// parameter sorts before the bad one, so a one-pass Invoke would have applied it.
|
||||
func TestInvokeWithABadParameterSetsNothing(t *testing.T) {
|
||||
r, seen := paramRegistry(t)
|
||||
|
||||
_, err := Invoke(Request{Command: r.Get("set"), Params: map[string]string{"verbose": "true", "wait": "soon"}})
|
||||
require.ErrorContains(t, err, "wait:")
|
||||
assert.Empty(t, *seen, "the command must not run with a partial parameter set")
|
||||
|
||||
v, err := Invoke(Request{Command: r.Get("set")})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "ok", v.Value)
|
||||
require.Len(t, *seen, 1)
|
||||
assert.Equal(t, paramsSeen{}, (*seen)[0], "nothing from the failed request carried over")
|
||||
|
||||
_, err = Invoke(Request{Command: r.Get("set"), Params: map[string]string{"verbose": "true", "wait": "2s"}})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, *seen, 2)
|
||||
assert.Equal(t, paramsSeen{Verbose: true, Wait: 2 * time.Second}, (*seen)[1])
|
||||
}
|
||||
|
||||
func TestHelpListsOnlyServedCommands(t *testing.T) {
|
||||
out := &bytes.Buffer{}
|
||||
require.NoError(t, testRegistry().Filter(notWithheld, testReason).DispatchArgs(nil, MergedOutput(out)))
|
||||
|
||||
assert.Contains(t, out.String(), "served")
|
||||
assert.NotContains(t, out.String(), "withheld")
|
||||
}
|
||||
|
||||
// TestDispatchReportsUnparseableLine pins that a line shlex rejects says so on Err rather
|
||||
// than coming back silent, so an operator with an unbalanced quote learns the quote is the
|
||||
// problem instead of assuming the command is broken.
|
||||
func TestDispatchReportsUnparseableLine(t *testing.T) {
|
||||
out := &bytes.Buffer{}
|
||||
err := testRegistry().Dispatch(`served "unterminated`, NewStringWriter(out))
|
||||
|
||||
var f *Failure
|
||||
require.ErrorAs(t, err, &f)
|
||||
assert.Equal(t, f.Msg+"\n", out.String())
|
||||
assert.Contains(t, out.String(), "could not parse the command line: ")
|
||||
out, errOut := &bytes.Buffer{}, &bytes.Buffer{}
|
||||
testRegistry().Dispatch(`served "unterminated`, Output{Out: out, Err: errOut})
|
||||
assert.Empty(t, out.String())
|
||||
assert.Contains(t, errOut.String(), "could not parse the command line")
|
||||
}
|
||||
|
||||
// TestFailTellsTheUserAndTheSurface: Fail writes the message and returns it as a Failure;
|
||||
// when the write fails, the user heard nothing, so the write error is what comes back.
|
||||
func TestFailTellsTheUserAndTheSurface(t *testing.T) {
|
||||
out := &bytes.Buffer{}
|
||||
err := Fail(NewStringWriter(out), "no such tunnel")
|
||||
var f *Failure
|
||||
require.ErrorAs(t, err, &f)
|
||||
assert.Equal(t, "no such tunnel", f.Msg)
|
||||
assert.Equal(t, "no such tunnel\n", out.String())
|
||||
|
||||
err = Fail(NewStringWriter(failingWriter{}), "no such tunnel")
|
||||
require.EqualError(t, err, "gone")
|
||||
assert.NotErrorAs(t, err, &f)
|
||||
// TestRegisterRejectsReservedFlags: a command that declares a reserved flag is refused
|
||||
// at Register rather than when an operator first runs it; see reservedFlags.
|
||||
func TestRegisterRejectsReservedFlags(t *testing.T) {
|
||||
for _, name := range []string{"json", "pretty", "h", "help", "config", "socket"} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
r := New()
|
||||
assert.PanicsWithValue(t, "commands: clash declares reserved flag -"+name, func() {
|
||||
r.Register(&Command{
|
||||
Name: "clash",
|
||||
Flags: func() (*flag.FlagSet, any) {
|
||||
fl := flag.NewFlagSet("clash", flag.ContinueOnError)
|
||||
fl.Bool(name, false, "")
|
||||
return fl, nil
|
||||
},
|
||||
Run: func(fs any, a []string) (Result, error) { return Nothing(), nil },
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type failingWriter struct{}
|
||||
// TestRegistryIsSafeForConcurrentUse runs the operations the surfaces perform, filtering
|
||||
// and cloning per request or session, against a stream of registrations. It exists to
|
||||
// be run under the race detector: the map underneath is not goroutine safe.
|
||||
func TestRegistryIsSafeForConcurrentUse(t *testing.T) {
|
||||
r := testRegistry()
|
||||
|
||||
func (failingWriter) Write([]byte) (int, error) { return 0, errors.New("gone") }
|
||||
|
||||
// TestAllSortsByName: registration order does not matter.
|
||||
func TestAllSortsByName(t *testing.T) {
|
||||
r := New()
|
||||
for _, n := range []Name{"zz", Version, "aa", ChangeRemote} {
|
||||
r.Register(&Command{Name: n})
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 8; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
for j := 0; j < 50; j++ {
|
||||
r.Register(&Command{
|
||||
Name: Name("late-" + strings.Repeat("a", i) + "-" + strings.Repeat("b", j)),
|
||||
ShortDescription: "registered after the surfaces started",
|
||||
Run: func(fs any, a []string) (Result, error) { return Nothing(), nil },
|
||||
})
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
var got []Name
|
||||
for _, c := range r.All() {
|
||||
got = append(got, c.Name)
|
||||
for i := 0; i < 8; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for j := 0; j < 50; j++ {
|
||||
f := r.Filter(notWithheld, testReason)
|
||||
_ = f.Get("served")
|
||||
_ = f.Schema()
|
||||
_ = r.Clone().Match("late-")
|
||||
}
|
||||
}()
|
||||
}
|
||||
assert.Equal(t, []Name{"aa", ChangeRemote, Help, Version, "zz"}, got)
|
||||
|
||||
wg.Wait()
|
||||
|
||||
f := r.Filter(notWithheld, testReason)
|
||||
assert.Len(t, f.Match("late-"), 8*50, "every late registration is visible once the writers finish")
|
||||
}
|
||||
|
||||
// TestHelpFlagAgreesWithHelpCommand pins that -h answers through the registered help
|
||||
// command, and that help on a clone describes the clone: a command registered only there
|
||||
// is known to both -h and `help <name>`, and one withheld from it is known to neither.
|
||||
func TestHelpFlagAgreesWithHelpCommand(t *testing.T) {
|
||||
// TestCloneHelpSeesCloneCommands: the ssh session registers logout on its clone, and
|
||||
// the source does not learn of it. Help on the clone answers for the clone, and -h on a
|
||||
// command prints what `help <name>` does, on a clone as on the source: both render
|
||||
// through WriteCommandHelp, and Clone rebinds help to the copy.
|
||||
func TestCloneHelpSeesCloneCommands(t *testing.T) {
|
||||
r := testRegistry()
|
||||
c := r.Clone()
|
||||
c.Register(&Command{
|
||||
Name: Logout,
|
||||
ShortDescription: "ends the session",
|
||||
Callback: func(fs any, a []string, w StringWriter) error { return nil },
|
||||
Run: func(fs any, a []string) (Result, error) { return Nothing(), nil },
|
||||
})
|
||||
|
||||
flag := &bytes.Buffer{}
|
||||
c.Dispatch("logout -h", NewStringWriter(flag))
|
||||
cmd := &bytes.Buffer{}
|
||||
c.Dispatch("help logout", NewStringWriter(cmd))
|
||||
assert.Equal(t, cmd.String(), flag.String())
|
||||
assert.Equal(t, "logout - ends the session\n", flag.String())
|
||||
|
||||
// The source never learns of it.
|
||||
src := &bytes.Buffer{}
|
||||
r.Dispatch("help logout", NewStringWriter(src))
|
||||
assert.Equal(t, "Command not available logout\n", src.String())
|
||||
|
||||
// And a command both hold is described either way.
|
||||
flag.Reset()
|
||||
c.Dispatch("served -h", NewStringWriter(flag))
|
||||
assert.Equal(t, "served - a command this surface serves\n", flag.String())
|
||||
}
|
||||
|
||||
// TestCloneHelpListsTheClone: the command list help prints is the clone's, so a clone
|
||||
// that withholds a command does not name it, and the source still does.
|
||||
func TestCloneHelpListsTheClone(t *testing.T) {
|
||||
r := testRegistry()
|
||||
c := New()
|
||||
c.Register(r.Get("served"))
|
||||
c = c.Clone()
|
||||
assert.Nil(t, r.Get(Logout), "the source is not affected")
|
||||
|
||||
out := &bytes.Buffer{}
|
||||
c.Dispatch("help", NewStringWriter(out))
|
||||
assert.Equal(t, "Available commands:\n"+
|
||||
"help - prints available commands or help <command> for specific usage info\n"+
|
||||
"served - a command this surface serves\n\n", out.String())
|
||||
require.NoError(t, c.DispatchArgs([]string{"help"}, MergedOutput(out)))
|
||||
assert.Contains(t, out.String(), "logout")
|
||||
|
||||
out.Reset()
|
||||
c.Dispatch("help withheld", NewStringWriter(out))
|
||||
assert.Equal(t, "Command not available withheld\n", out.String())
|
||||
|
||||
out.Reset()
|
||||
r.Dispatch("help withheld", NewStringWriter(out))
|
||||
assert.Equal(t, "withheld - a command this surface withholds\n", out.String())
|
||||
for _, name := range []string{"logout", "served"} {
|
||||
flag := &bytes.Buffer{}
|
||||
c.Dispatch(name+" -h", MergedOutput(flag))
|
||||
cmd := &bytes.Buffer{}
|
||||
c.Dispatch("help "+name, MergedOutput(cmd))
|
||||
assert.Equal(t, cmd.String(), flag.String(), name)
|
||||
assert.Contains(t, flag.String(), name+" - ", name)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDispatchReturnsTheCommandsError: what the callback returns reaches the caller, so
|
||||
// a surface can log it.
|
||||
func TestDispatchReturnsTheCommandsError(t *testing.T) {
|
||||
// TestThenRunsAfterTheReply pins the order Then exists for: Run's line is on the writer
|
||||
// before the action begins, Then's Result follows it, and Then's outcome is the
|
||||
// command's.
|
||||
func TestThenRunsAfterTheReply(t *testing.T) {
|
||||
out := &bytes.Buffer{}
|
||||
seen := "never ran"
|
||||
outcome := Did("acted")
|
||||
|
||||
r := New()
|
||||
r.Register(&Command{
|
||||
Name: "fails",
|
||||
Callback: func(fs any, a []string, w StringWriter) error {
|
||||
return errors.New("boom")
|
||||
Name: "act",
|
||||
ShortDescription: "replies, then acts",
|
||||
Run: func(fs any, a []string) (Result, error) {
|
||||
return Did("about to act"), nil
|
||||
},
|
||||
Then: func() Result {
|
||||
seen = out.String()
|
||||
return outcome
|
||||
},
|
||||
})
|
||||
|
||||
out := &bytes.Buffer{}
|
||||
require.EqualError(t, r.Dispatch("fails", NewStringWriter(out)), "boom")
|
||||
assert.Empty(t, out.String(), "a command's own error is not shown; the command speaks for itself")
|
||||
require.NoError(t, r.DispatchArgs([]string{"act"}, MergedOutput(out)))
|
||||
assert.Equal(t, "about to act\n", seen, "the line was written before the action")
|
||||
assert.Equal(t, "about to act\nacted\n", out.String())
|
||||
|
||||
out.Reset()
|
||||
outcome = Failed("could not act")
|
||||
var f *Failure
|
||||
require.ErrorAs(t, r.DispatchArgs([]string{"act"}, MergedOutput(out)), &f)
|
||||
assert.Equal(t, "about to act\ncould not act\n", out.String())
|
||||
|
||||
// In JSON only the outcome travels: one document for the program that asked.
|
||||
out.Reset()
|
||||
require.ErrorAs(t, r.DispatchArgs([]string{"act", "-json"}, MergedOutput(out)), &f)
|
||||
assert.JSONEq(t, `{"ok":false,"reason":"could not act"}`, out.String())
|
||||
|
||||
// A Run that refused is the outcome; the action does not happen.
|
||||
ran := false
|
||||
r.Register(&Command{
|
||||
Name: "refuse",
|
||||
ShortDescription: "refuses, so never acts",
|
||||
Run: func(fs any, a []string) (Result, error) {
|
||||
return Failed("not like that"), nil
|
||||
},
|
||||
Then: func() Result {
|
||||
ran = true
|
||||
return Did("acted anyway")
|
||||
},
|
||||
})
|
||||
out.Reset()
|
||||
require.ErrorAs(t, r.DispatchArgs([]string{"refuse"}, MergedOutput(out)), &f)
|
||||
assert.Equal(t, "not like that\n", out.String())
|
||||
assert.False(t, ran)
|
||||
out.Reset()
|
||||
require.ErrorAs(t, r.DispatchArgs([]string{"refuse", "-json"}, MergedOutput(out)), &f)
|
||||
assert.JSONEq(t, `{"ok":false,"reason":"not like that"}`, out.String())
|
||||
assert.False(t, ran)
|
||||
}
|
||||
|
||||
// TestUnknownCommandNamesTheWord: only the first word is looked up, so only it is
|
||||
// reported, followed by the command list, and the line is a failure to the surface.
|
||||
func TestUnknownCommandNamesTheWord(t *testing.T) {
|
||||
// TestWriteCommandHelpIsTheOneRendering pins the layout both `help <command>` on the
|
||||
// daemon and `-h` in the client print, so the two cannot drift apart again.
|
||||
func TestWriteCommandHelpIsTheOneRendering(t *testing.T) {
|
||||
cs := CommandSchema{
|
||||
Name: "change-remote",
|
||||
Short: "changes a remote",
|
||||
Help: "Give it an address.",
|
||||
Flags: []FlagSchema{
|
||||
{Name: "address", Usage: "the new remote", Default: "1.2.3.4:4242"},
|
||||
{Name: "by-index", Bool: true, Usage: "match by index"},
|
||||
{Name: "json", Bool: true, Usage: "outputs as json"},
|
||||
},
|
||||
}
|
||||
|
||||
out := &bytes.Buffer{}
|
||||
err := testRegistry().Dispatch(`nope "with args"`, NewStringWriter(out))
|
||||
var f *Failure
|
||||
require.ErrorAs(t, err, &f)
|
||||
assert.Equal(t, "did not understand: nope", f.Msg)
|
||||
assert.True(t, strings.HasPrefix(out.String(), "did not understand: nope\nAvailable commands:\n"), out.String())
|
||||
require.NoError(t, WriteCommandHelp(cs, out))
|
||||
|
||||
assert.Equal(t, "change-remote - changes a remote\n"+
|
||||
" Give it an address.\n"+
|
||||
" -address string\n \tthe new remote (default 1.2.3.4:4242)\n"+
|
||||
" -by-index\n \tmatch by index\n"+
|
||||
" -json\n \toutputs as json\n", out.String())
|
||||
}
|
||||
|
||||
// addrFlags is the flag struct of a test command that takes -address.
|
||||
type addrFlags struct{ Address string }
|
||||
|
||||
// TestHelpFlagIsReadWhereParseReadsIt: -h asks for help only where the line's parser
|
||||
// reads it as a flag. As a flag's value or after -- it reaches the command as the
|
||||
// operand it is, and help is rendered by the same function `help <command>` uses.
|
||||
func TestHelpFlagIsReadWhereParseReadsIt(t *testing.T) {
|
||||
var seen []string
|
||||
r := New()
|
||||
r.Register(Flagged(&Command{Name: "x", ShortDescription: "takes an address"},
|
||||
func(fl *flag.FlagSet, s *addrFlags) { fl.StringVar(&s.Address, "address", "", "an address") },
|
||||
func(s *addrFlags, a []string) (Result, error) {
|
||||
seen = append(seen, s.Address+"|"+strings.Join(a, ","))
|
||||
return Nothing(), nil
|
||||
}))
|
||||
|
||||
for _, line := range []string{"x -h", "x -help", "x --help", "x 10.0.0.1 -h", "x -address=1 -h"} {
|
||||
out := &bytes.Buffer{}
|
||||
r.Dispatch(line, MergedOutput(out))
|
||||
assert.Contains(t, out.String(), "x - takes an address\n", line)
|
||||
assert.Contains(t, out.String(), "-address string", line)
|
||||
}
|
||||
assert.Empty(t, seen, "asking for help must not run the command")
|
||||
|
||||
for _, line := range []string{"x -address -h", "x -- -h"} {
|
||||
out := &bytes.Buffer{}
|
||||
r.Dispatch(line, MergedOutput(out))
|
||||
assert.Empty(t, out.String(), line)
|
||||
}
|
||||
assert.Equal(t, []string{"-h|", "|-h"}, seen)
|
||||
}
|
||||
|
||||
// TestBadFlagIsAnsweredWithTheCommandsHelp: a flag the command does not have is reported
|
||||
// on the error stream followed by the command's usage, in the rendering help uses.
|
||||
func TestBadFlagIsAnsweredWithTheCommandsHelp(t *testing.T) {
|
||||
out, errOut := &bytes.Buffer{}, &bytes.Buffer{}
|
||||
r := New()
|
||||
r.Register(&Command{Name: "x", ShortDescription: "has no flags", Run: func(fs any, a []string) (Result, error) { return Nothing(), nil }})
|
||||
|
||||
require.Error(t, r.DispatchArgs([]string{"x", "-nope"}, Output{Out: out, Err: errOut}))
|
||||
assert.Empty(t, out.String())
|
||||
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())
|
||||
}
|
||||
|
||||
// TestRunErrorIsShown: an error Run returns reaches the person at the prompt on the
|
||||
// error stream, as well as the surface that logs it.
|
||||
func TestRunErrorIsShown(t *testing.T) {
|
||||
r := New()
|
||||
r.Register(&Command{
|
||||
Name: "broken",
|
||||
ShortDescription: "cannot do what it is asked",
|
||||
Run: func(fs any, a []string) (Result, error) {
|
||||
return Result{}, errors.New("no certificate is loaded")
|
||||
},
|
||||
})
|
||||
|
||||
out, errOut := &bytes.Buffer{}, &bytes.Buffer{}
|
||||
require.EqualError(t, r.DispatchArgs([]string{"broken"}, Output{Out: out, Err: errOut}), "no certificate is loaded")
|
||||
assert.Empty(t, out.String())
|
||||
assert.Equal(t, "no certificate is loaded\n", errOut.String())
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
// ErrNoTextRendering is returned by Render when a command's Value has no human rendering
|
||||
// and text was asked for anyway. A command declares this by leaving Text nil and saying
|
||||
// so with JSONOnly; it is not a fallback situation but a statement that no such form
|
||||
// exists yet, so nobody builds a script around an interim one. Each surface's default
|
||||
// policy avoids ever asking.
|
||||
var ErrNoTextRendering = errors.New("no human readable output yet; pass -json or -pretty")
|
||||
|
||||
// Format is how a result should be presented.
|
||||
type Format int
|
||||
|
||||
const (
|
||||
// FormatUnset is a request that expressed no preference. A surface resolves it with
|
||||
// its own default before rendering; Render itself treats it as FormatText.
|
||||
FormatUnset Format = iota
|
||||
|
||||
// FormatText renders for a person. It is what an operator gets by default on every
|
||||
// surface with a human at the other end.
|
||||
FormatText
|
||||
|
||||
// FormatJSON renders the result as compact JSON.
|
||||
FormatJSON
|
||||
|
||||
// FormatPrettyJSON renders the result as indented JSON.
|
||||
FormatPrettyJSON
|
||||
)
|
||||
|
||||
// Flag names for the two presentation flags. They are offered on every command rather
|
||||
// than declared by each one, because how a result is displayed is not a property of the
|
||||
// command that produced it.
|
||||
const (
|
||||
FlagJSON = "json"
|
||||
FlagPretty = "pretty"
|
||||
)
|
||||
|
||||
// Flag names nebula ctl takes for itself, anywhere on its line, to say which daemon to
|
||||
// talk to; see reservedFlags.
|
||||
const (
|
||||
FlagConfig = "config"
|
||||
FlagSocket = "socket"
|
||||
)
|
||||
|
||||
// reservedFlags are handled before a command's Run is called, so a command may not
|
||||
// declare them itself. -h and -help are intercepted as a request for usage; -json and
|
||||
// -pretty are added to every command's FlagSet by commandFlags, on every surface, so a
|
||||
// command that defined either would make that definition panic with "flag redefined"
|
||||
// on whichever goroutine ran the command; and -config and -socket are nebula ctl's own,
|
||||
// taken off the line before the client knows which command it names, so a command
|
||||
// that declared either would never receive it there.
|
||||
var reservedFlags = []string{"h", "help", FlagJSON, FlagPretty, FlagConfig, FlagSocket}
|
||||
|
||||
// Render writes r in the requested format. Every surface calls it, so a hostmap looks
|
||||
// the same over ssh as it does through the control socket, and a change to either
|
||||
// rendering happens once.
|
||||
func Render(c *Command, r Result, f Format, w io.Writer) error {
|
||||
switch f {
|
||||
case FormatJSON:
|
||||
return renderJSON(r.Payload(), w, false)
|
||||
|
||||
case FormatPrettyJSON:
|
||||
return renderJSON(r.Payload(), w, true)
|
||||
|
||||
default:
|
||||
// An outcome reads the same for every command: its reason, as a sentence. Only a
|
||||
// Value needs the command's own rendering, so Text never sees anything else.
|
||||
if r.Value == nil {
|
||||
_, err := io.WriteString(w, r.Reason+"\n")
|
||||
return err
|
||||
}
|
||||
|
||||
if c != nil && c.Text != nil {
|
||||
return c.Text(r.Value, w)
|
||||
}
|
||||
|
||||
name := "this command"
|
||||
if c != nil {
|
||||
name = string(c.Name)
|
||||
}
|
||||
return fmt.Errorf("%s has %w", name, ErrNoTextRendering)
|
||||
}
|
||||
}
|
||||
|
||||
func renderJSON(v any, w io.Writer, indent bool) error {
|
||||
enc := json.NewEncoder(w)
|
||||
if indent {
|
||||
enc.SetIndent("", " ")
|
||||
}
|
||||
|
||||
return enc.Encode(v)
|
||||
}
|
||||
|
||||
// Result is what every Run returns: whether the command worked, a sentence saying so,
|
||||
// and, for a command that reports rather than acts, the value it produced.
|
||||
//
|
||||
// Value and Reason are alternatives. A command that reports returns Value(v), and the
|
||||
// surfaces render v, as JSON or through the command's Text. A command that acts, or any
|
||||
// command refusing its arguments, returns Did or Failed, and the surfaces render the
|
||||
// Outcome: its Reason is the sentence these commands have always printed. Nothing is for
|
||||
// a command with nothing to say.
|
||||
//
|
||||
// The envelope itself never reaches a caller. Payload is what JSON carries, and Render
|
||||
// hands Text only the Value, so a command's renderer sees the one type it returns on
|
||||
// success and is never asked to recognise an Outcome.
|
||||
type Result struct {
|
||||
Outcome
|
||||
Value any
|
||||
}
|
||||
|
||||
// Outcome is the part of a Result that says whether the command worked and, in words a
|
||||
// caller can show someone, why. It is what a program receives from a command that acts
|
||||
// rather than reports: closing a tunnel, changing a remote, reloading config.
|
||||
//
|
||||
// One shape serves all of them. Designing a payload per outcome would be work without a
|
||||
// reader. Reason is set either way, so a success says what it did rather than leaving
|
||||
// the caller to infer it from a bare true.
|
||||
type Outcome struct {
|
||||
OK bool `json:"ok"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// Value is the Result of a command that reports: v is what it has to show, and it must
|
||||
// marshal to JSON.
|
||||
func Value(v any) Result {
|
||||
return Result{Outcome: Outcome{OK: true}, Value: v}
|
||||
}
|
||||
|
||||
// Failed is a convenience for the common "it did not work, here is why" case.
|
||||
func Failed(format string, args ...any) Result {
|
||||
return Result{Outcome: Outcome{Reason: fmt.Sprintf(format, args...)}}
|
||||
}
|
||||
|
||||
// Did is a convenience for the common "it worked, here is what happened" case.
|
||||
func Did(format string, args ...any) Result {
|
||||
return Result{Outcome: Outcome{OK: true, Reason: fmt.Sprintf(format, args...)}}
|
||||
}
|
||||
|
||||
// Nothing is the Result of a command with nothing to say: it did its work, and the
|
||||
// surface sends an empty, successful reply without rendering anything.
|
||||
func Nothing() Result {
|
||||
return Result{Outcome: Outcome{OK: true}}
|
||||
}
|
||||
|
||||
// Empty reports a Result with nothing to render: no Value and no Reason. The zero Result
|
||||
// counts, so a Run that returns Result{} says nothing rather than failing silently.
|
||||
func (r Result) Empty() bool {
|
||||
return r.Value == nil && r.Reason == ""
|
||||
}
|
||||
|
||||
// Payload is what a program receives: the Value when there is one, otherwise the
|
||||
// Outcome. No surface sends the envelope itself, so print-tunnel -json emits the tunnel
|
||||
// and close-tunnel -json emits {ok, reason}.
|
||||
func (r Result) Payload() any {
|
||||
if r.Value != nil {
|
||||
return r.Value
|
||||
}
|
||||
return r.Outcome
|
||||
}
|
||||
|
||||
// Typed adapts a renderer for one result type to the TextFunc a Command declares. The
|
||||
// value Render passes is the command's own, see Result, so all that is left is to name
|
||||
// its type once here instead of asserting it at the top of every renderer. A renderer
|
||||
// paired with a command that returns something else fails here, saying what arrived.
|
||||
func Typed[T any](render func(T, io.Writer) error) TextFunc {
|
||||
return func(v any, w io.Writer) error {
|
||||
t, ok := v.(T)
|
||||
if !ok {
|
||||
return fmt.Errorf("expected %T, got %T", t, v)
|
||||
}
|
||||
return render(t, w)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestRenderPrintsAnOutcomeWithoutText: a command that acts declares no Text. Its
|
||||
// outcome renders as the reason, the same for every command, and as {ok, reason} in
|
||||
// JSON, which is the shape these commands have always emitted.
|
||||
func TestRenderPrintsAnOutcomeWithoutText(t *testing.T) {
|
||||
c := &Command{Name: "act"}
|
||||
|
||||
out := &bytes.Buffer{}
|
||||
require.NoError(t, Render(c, Did("Closed"), FormatText, out))
|
||||
assert.Equal(t, "Closed\n", out.String())
|
||||
|
||||
out.Reset()
|
||||
require.NoError(t, Render(c, Failed("No vpn address was provided"), FormatText, out))
|
||||
assert.Equal(t, "No vpn address was provided\n", out.String())
|
||||
|
||||
out.Reset()
|
||||
require.NoError(t, Render(c, Failed("nope"), FormatJSON, out))
|
||||
assert.JSONEq(t, `{"ok":false,"reason":"nope"}`, out.String())
|
||||
}
|
||||
|
||||
// TestRenderHandsTextOnlyTheValue pins the guarantee Result exists for: a command's
|
||||
// renderer sees the type it returns on success and never the envelope or an outcome, so
|
||||
// a report command that refuses its arguments needs nothing in its Text to cope.
|
||||
func TestRenderHandsTextOnlyTheValue(t *testing.T) {
|
||||
var got any
|
||||
c := &Command{
|
||||
Name: "report",
|
||||
Text: func(v any, w io.Writer) error {
|
||||
got = v
|
||||
_, err := io.WriteString(w, "rendered\n")
|
||||
return err
|
||||
},
|
||||
}
|
||||
|
||||
out := &bytes.Buffer{}
|
||||
require.NoError(t, Render(c, Value("data"), FormatText, out))
|
||||
assert.Equal(t, "data", got)
|
||||
assert.Equal(t, "rendered\n", out.String())
|
||||
|
||||
got, out = nil, &bytes.Buffer{}
|
||||
require.NoError(t, Render(c, Failed("refused"), FormatText, out))
|
||||
assert.Nil(t, got, "Text must not be called for an outcome")
|
||||
assert.Equal(t, "refused\n", out.String())
|
||||
|
||||
out.Reset()
|
||||
require.NoError(t, Render(c, Value("data"), FormatJSON, out))
|
||||
assert.JSONEq(t, `"data"`, out.String(), "JSON carries the value, never the envelope")
|
||||
}
|
||||
|
||||
// TestRenderRefusesTextForAValueWithoutText: leaving Text nil is a statement that no
|
||||
// human form of the value exists yet, and asking for one is an error that says so.
|
||||
func TestRenderRefusesTextForAValueWithoutText(t *testing.T) {
|
||||
out := &bytes.Buffer{}
|
||||
err := Render(&Command{Name: "print-tunnel"}, Value(map[string]int{"index": 7}), FormatText, out)
|
||||
require.ErrorIs(t, err, ErrNoTextRendering)
|
||||
require.ErrorContains(t, err, "print-tunnel has no human readable output yet")
|
||||
assert.Empty(t, out.String())
|
||||
}
|
||||
|
||||
// TestTypedNamesTheTypeOnce: Typed replaces the assertion at the top of every renderer.
|
||||
// The right type reaches the renderer; the wrong one is reported, naming both.
|
||||
func TestTypedNamesTheTypeOnce(t *testing.T) {
|
||||
type info struct{ Name string }
|
||||
render := Typed(func(i info, w io.Writer) error {
|
||||
_, err := io.WriteString(w, i.Name+"\n")
|
||||
return err
|
||||
})
|
||||
|
||||
out := &bytes.Buffer{}
|
||||
require.NoError(t, render(info{Name: "tun0"}, out))
|
||||
assert.Equal(t, "tun0\n", out.String())
|
||||
|
||||
err := render("not an info", out)
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, "expected commands.info, got string")
|
||||
}
|
||||
|
||||
// TestResultShapes pins what each constructor means to a surface: Empty is the one that
|
||||
// renders nothing, Payload is what a program receives, and OK is set wherever a reason
|
||||
// or value says the command worked.
|
||||
func TestResultShapes(t *testing.T) {
|
||||
assert.True(t, Nothing().Empty())
|
||||
assert.True(t, Result{}.Empty(), "the zero Result says nothing rather than failing silently")
|
||||
assert.False(t, Did("done").Empty())
|
||||
assert.False(t, Failed("no").Empty())
|
||||
assert.False(t, Value("x").Empty())
|
||||
|
||||
assert.True(t, Value("x").OK)
|
||||
assert.True(t, Did("done").OK)
|
||||
assert.False(t, Failed("no").OK)
|
||||
|
||||
assert.Equal(t, "x", Value("x").Payload())
|
||||
assert.Equal(t, Outcome{OK: true, Reason: "done"}, Did("done").Payload())
|
||||
assert.Equal(t, Outcome{Reason: "no"}, Failed("no").Payload())
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"maps"
|
||||
"slices"
|
||||
)
|
||||
|
||||
// Request is one command to run, as every surface hands it to Invoke: the command, its
|
||||
// options by name in the form their flags parse, its operands, and the rendering the
|
||||
// line asked for, if any.
|
||||
//
|
||||
// nebula ctl builds one with ParseLine and it travels as a JSON object to the control
|
||||
// socket. An ssh line never becomes one: it is parsed straight into the command's own
|
||||
// FlagSet; see execLine.
|
||||
type Request struct {
|
||||
// Command is the command to run, resolved by the surface at its edge. See
|
||||
// Registry.Resolve for the one that tells an unknown name from a withheld one.
|
||||
Command *Command
|
||||
|
||||
// Params carries the options the line set, by flag name, each in the form its flag
|
||||
// parses from. An option left alone is absent, so the command's default applies.
|
||||
Params map[string]string
|
||||
|
||||
// Args carries the operands: whatever the line did not consume as a flag.
|
||||
Args []string
|
||||
|
||||
// Format is the rendering the line asked for with -json or -pretty, or FormatUnset
|
||||
// when it expressed no preference. It is ParseLine's reading of the line for the
|
||||
// client that sends the Request; Invoke does not read it, since the surface that
|
||||
// runs a Request renders with the format it agreed with its caller.
|
||||
Format Format
|
||||
}
|
||||
|
||||
// ParseLine reads a FlagSet that ParseArgs has filled, and the operands it returned,
|
||||
// into a Request to send over the wire. Command is left for the caller to set.
|
||||
//
|
||||
// Every flag the line set other than the presentation flags travels by name, as its
|
||||
// flag's String, which is what the daemon's Set parses. That round trip is exact for
|
||||
// the bool and string flags a client builds from the schema; it is the client's to make,
|
||||
// and the ssh path does not make it, since it runs the FlagSet it parsed.
|
||||
func ParseLine(fl *flag.FlagSet, operands []string) Request {
|
||||
req := Request{Args: operands, Format: presentationFormat(fl)}
|
||||
|
||||
fl.Visit(func(f *flag.Flag) {
|
||||
if isPresentationFlag(f.Name) {
|
||||
return
|
||||
}
|
||||
if req.Params == nil {
|
||||
req.Params = map[string]string{}
|
||||
}
|
||||
req.Params[f.Name] = f.Value.String()
|
||||
})
|
||||
|
||||
return req
|
||||
}
|
||||
|
||||
// presentationFormat is the one reading of -json and -pretty from a parsed line, so a
|
||||
// line means the same on every surface: only a flag set to true asks for JSON, since one
|
||||
// set to false expresses no preference, and -pretty implies -json. FormatUnset means the
|
||||
// line expressed no preference and the surface's own default applies.
|
||||
func presentationFormat(fl *flag.FlagSet) Format {
|
||||
var asJSON, pretty bool
|
||||
fl.Visit(func(f *flag.Flag) {
|
||||
switch f.Name {
|
||||
case FlagJSON:
|
||||
asJSON = flagIsTrue(f)
|
||||
case FlagPretty:
|
||||
pretty = flagIsTrue(f)
|
||||
}
|
||||
})
|
||||
|
||||
switch {
|
||||
case pretty:
|
||||
return FormatPrettyJSON
|
||||
case asJSON:
|
||||
return FormatJSON
|
||||
default:
|
||||
return FormatUnset
|
||||
}
|
||||
}
|
||||
|
||||
// flagIsTrue reports whether a bool flag was set to true. The flag package's bool value
|
||||
// is a flag.Getter whose Get returns the bool, so this never falls back to parsing text.
|
||||
func flagIsTrue(f *flag.Flag) bool {
|
||||
g, ok := f.Value.(flag.Getter)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
b, _ := g.Get().(bool)
|
||||
return b
|
||||
}
|
||||
|
||||
// Invoke runs one Request and returns the command's Result, unrendered.
|
||||
//
|
||||
// The parameters are applied by flag name, in name order: a request from the wire is a
|
||||
// JSON object with no order of its own, and which of two bad parameters is reported must
|
||||
// not change from one request to the next. An unknown parameter is an error rather than
|
||||
// something quietly ignored, because a caller that misspells one deserves to hear about
|
||||
// it instead of getting a default. A bad parameter means Run is never called, and the
|
||||
// FlagSet is fresh for this call, so nothing set before it is seen by anyone.
|
||||
//
|
||||
// Nothing here writes anywhere and nothing here renders. What a surface does with the
|
||||
// Result, marshal its Payload or run the command's own text rendering through Render,
|
||||
// is the surface's decision, and keeping that out of here is what lets a caller tell a
|
||||
// refusal from a success after the text has been produced. The presentation flags are
|
||||
// not parameters: a Request says how it wants rendering in Format, and sending them as
|
||||
// parameters is refused rather than read as a preference.
|
||||
func Invoke(req Request) (Result, error) {
|
||||
c := req.Command
|
||||
names := slices.Sorted(maps.Keys(req.Params))
|
||||
|
||||
fl, fs, own := commandFlags(c)
|
||||
if err := setParams(fl, string(c.Name), names, req.Params, own); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
|
||||
return c.Run(fs, req.Args)
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func lineFlags() *flag.FlagSet {
|
||||
fl := flag.NewFlagSet("set", flag.ContinueOnError)
|
||||
fl.Bool("verbose", false, "")
|
||||
fl.Duration("wait", 0, "")
|
||||
fl.String("label", "none", "")
|
||||
fl.Bool(FlagJSON, false, "")
|
||||
fl.Bool(FlagPretty, false, "")
|
||||
return fl
|
||||
}
|
||||
|
||||
// TestParseLineReadsThePresentationFlagsOnce: presentationFormat is the one reading of
|
||||
// -json and -pretty, for every surface, and ParseLine carries its answer. A flag set to
|
||||
// false expresses no preference, and neither flag ever travels as a parameter.
|
||||
func TestParseLineReadsThePresentationFlagsOnce(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
args []string
|
||||
want Format
|
||||
}{
|
||||
{nil, FormatUnset},
|
||||
{[]string{"-json=false"}, FormatUnset},
|
||||
{[]string{"-pretty=false"}, FormatUnset},
|
||||
{[]string{"-json"}, FormatJSON},
|
||||
{[]string{"-pretty"}, FormatPrettyJSON},
|
||||
{[]string{"-pretty", "-json=false"}, FormatPrettyJSON},
|
||||
} {
|
||||
fl := lineFlags()
|
||||
operands, err := ParseArgs(fl, tc.args)
|
||||
require.NoError(t, err)
|
||||
|
||||
req := ParseLine(fl, operands)
|
||||
assert.Equal(t, tc.want, req.Format, "%v", tc.args)
|
||||
assert.Nil(t, req.Params, "%v: presentation flags are not parameters", tc.args)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseLineCarriesWhatWasSetInParseableForm: only the flags the line set travel,
|
||||
// each as the string its flag parses from, so the daemon's Invoke can apply them to a
|
||||
// fresh FlagSet and get the same values back. Operands come through untouched, those
|
||||
// after "--" included.
|
||||
func TestParseLineCarriesWhatWasSetInParseableForm(t *testing.T) {
|
||||
fl := lineFlags()
|
||||
operands, err := ParseArgs(fl, []string{"-verbose", "-wait", "2s", "x", "--", "-1"})
|
||||
require.NoError(t, err)
|
||||
|
||||
req := ParseLine(fl, operands)
|
||||
assert.Equal(t, map[string]string{"verbose": "true", "wait": "2s"}, req.Params, "label was left alone and must be absent")
|
||||
assert.Equal(t, []string{"x", "-1"}, req.Args)
|
||||
assert.Equal(t, FormatUnset, req.Format)
|
||||
|
||||
// The round trip a client makes: what ParseLine produced is what Invoke applies.
|
||||
r, seen := paramRegistry(t)
|
||||
req.Command = r.Get("set")
|
||||
_, err = Invoke(req)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, *seen, 1)
|
||||
assert.Equal(t, paramsSeen{Verbose: true, Wait: 2 * time.Second}, (*seen)[0])
|
||||
}
|
||||
|
||||
// TestResolveTellsUnknownFromWithheld: the two cases read differently to an operator,
|
||||
// and Resolve is the edge that decides which one a name from the wire is.
|
||||
func TestResolveTellsUnknownFromWithheld(t *testing.T) {
|
||||
r := testRegistry().Filter(notWithheld, testReason)
|
||||
|
||||
c, err := r.Resolve("served")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, Name("served"), c.Name)
|
||||
|
||||
_, err = r.Resolve("withheld")
|
||||
require.ErrorContains(t, err, "withheld is not available on this surface")
|
||||
|
||||
_, err = r.Resolve("no-such-command")
|
||||
require.ErrorContains(t, err, `unknown command "no-such-command"`)
|
||||
}
|
||||
|
||||
// TestLineRunsTheFlagSetItParsed: the ssh path hands Run the FlagSet the line was parsed
|
||||
// into, with no trip through a Request. A flag.Func has no String worth re-parsing, so a
|
||||
// round trip would hand Run an empty value and no error; the line's value must arrive.
|
||||
func TestLineRunsTheFlagSetItParsed(t *testing.T) {
|
||||
var seen []string
|
||||
r := New()
|
||||
r.Register(&Command{
|
||||
Name: "mode",
|
||||
Flags: func() (*flag.FlagSet, any) {
|
||||
fl := flag.NewFlagSet("", flag.ContinueOnError)
|
||||
s := new(string)
|
||||
fl.Func("mode", "the mode", func(v string) error {
|
||||
*s = "set:" + v
|
||||
return nil
|
||||
})
|
||||
return fl, s
|
||||
},
|
||||
Run: func(fs any, a []string) (Result, error) {
|
||||
seen = append(seen, *fs.(*string))
|
||||
return Value("ok"), nil
|
||||
},
|
||||
})
|
||||
|
||||
var out, errw strings.Builder
|
||||
r.Dispatch("mode -json -mode fast", Output{Out: &out, Err: &errw})
|
||||
require.Equal(t, []string{"set:fast"}, seen)
|
||||
assert.Empty(t, errw.String())
|
||||
assert.Equal(t, "\"ok\"\n", out.String(), "-json was read from the same FlagSet")
|
||||
}
|
||||
+16
-32
@@ -2,38 +2,22 @@ package commands
|
||||
|
||||
import "io"
|
||||
|
||||
// StringWriter is how a command talks back to whoever invoked it: one stream, which the
|
||||
// ssh surface points at its channel.
|
||||
type StringWriter interface {
|
||||
WriteLine(string) error
|
||||
Write(string) error
|
||||
WriteBytes([]byte) error
|
||||
GetWriter() io.Writer
|
||||
// Output is where a command line surface's text goes.
|
||||
//
|
||||
// Out and Err are distinct so a command's primary output can stay machine readable on
|
||||
// a surface that separates the two. The ssh surface points both at the same channel and
|
||||
// sees a single merged stream. The local
|
||||
// control socket does not use one at all: it invokes by named parameter and carries the
|
||||
// returned value, so there is no stream for a command to write to.
|
||||
type Output struct {
|
||||
// Out receives what a command produces.
|
||||
Out io.Writer
|
||||
|
||||
// Err receives usage text, refusals and errors.
|
||||
Err io.Writer
|
||||
}
|
||||
|
||||
type stringWriter struct {
|
||||
w io.Writer
|
||||
}
|
||||
|
||||
// NewStringWriter returns a StringWriter that writes to w.
|
||||
func NewStringWriter(w io.Writer) StringWriter {
|
||||
return &stringWriter{w: w}
|
||||
}
|
||||
|
||||
func (w *stringWriter) WriteLine(s string) error {
|
||||
return w.Write(s + "\n")
|
||||
}
|
||||
|
||||
func (w *stringWriter) Write(s string) error {
|
||||
_, err := w.w.Write([]byte(s))
|
||||
return err
|
||||
}
|
||||
|
||||
func (w *stringWriter) WriteBytes(b []byte) error {
|
||||
_, err := w.w.Write(b)
|
||||
return err
|
||||
}
|
||||
|
||||
func (w *stringWriter) GetWriter() io.Writer {
|
||||
return w.w
|
||||
// MergedOutput sends output and errors to the same writer.
|
||||
func MergedOutput(w io.Writer) Output {
|
||||
return Output{Out: w, Err: w}
|
||||
}
|
||||
Reference in new issue
Block a user