From c50881b27443980f9c38a36793709ec944a3dff3 Mon Sep 17 00:00:00 2001 From: Seth Vargo Date: Fri, 13 Jul 2018 13:35:08 -0400 Subject: [PATCH] Add plugin CLI for interacting with the plugin catalog (#4911) * Add 'plugin list' command * Add 'plugin register' command * Add 'plugin deregister' command * Use a shared plugin helper * Add 'plugin read' command * Rename to plugin info * Add base plugin for help text * Fix arg ordering * Add docs * Rearrange to alphabetize * Fix arg ordering in example * Don't use "sudo" in command description --- api/sys_plugins.go | 6 +- command/command_test.go | 17 ++ command/commands.go | 25 +++ command/plugin.go | 46 +++++ command/plugin_deregister.go | 86 ++++++++++ command/plugin_deregister_test.go | 156 +++++++++++++++++ command/plugin_info.go | 97 +++++++++++ command/plugin_info_test.go | 161 ++++++++++++++++++ command/plugin_list.go | 89 ++++++++++ command/plugin_list_test.go | 99 +++++++++++ command/plugin_register.go | 135 +++++++++++++++ command/plugin_register_test.go | 151 ++++++++++++++++ command/plugin_test.go | 78 +++++++++ website/source/docs/commands/plugin.html.md | 63 +++++++ .../docs/commands/plugin/deregister.html.md | 27 +++ .../source/docs/commands/plugin/info.html.md | 43 +++++ .../source/docs/commands/plugin/list.html.md | 35 ++++ .../docs/commands/plugin/register.html.md | 53 ++++++ website/source/layouts/docs.erb | 17 ++ 19 files changed, 1382 insertions(+), 2 deletions(-) create mode 100644 command/plugin.go create mode 100644 command/plugin_deregister.go create mode 100644 command/plugin_deregister_test.go create mode 100644 command/plugin_info.go create mode 100644 command/plugin_info_test.go create mode 100644 command/plugin_list.go create mode 100644 command/plugin_list_test.go create mode 100644 command/plugin_register.go create mode 100644 command/plugin_register_test.go create mode 100644 command/plugin_test.go create mode 100644 website/source/docs/commands/plugin.html.md create mode 100644 website/source/docs/commands/plugin/deregister.html.md create mode 100644 website/source/docs/commands/plugin/info.html.md create mode 100644 website/source/docs/commands/plugin/list.html.md create mode 100644 website/source/docs/commands/plugin/register.html.md diff --git a/api/sys_plugins.go b/api/sys_plugins.go index 8183b10f5b77..c061b45bdb35 100644 --- a/api/sys_plugins.go +++ b/api/sys_plugins.go @@ -60,12 +60,14 @@ func (c *Sys) GetPlugin(i *GetPluginInput) (*GetPluginResponse, error) { } defer resp.Body.Close() - var result GetPluginResponse + var result struct { + Data GetPluginResponse + } err = resp.DecodeJSON(&result) if err != nil { return nil, err } - return &result, err + return &result.Data, err } // RegisterPluginInput is used as input to the RegisterPlugin function. diff --git a/command/command_test.go b/command/command_test.go index 77598a9a424c..ecfc7441d880 100644 --- a/command/command_test.go +++ b/command/command_test.go @@ -93,6 +93,23 @@ func testVaultServerUnseal(tb testing.TB) (*api.Client, []string, func()) { }) } +// testVaultServerUnseal creates a test vault cluster and returns a configured +// API client, list of unseal keys (as strings), and a closer function +// configured with the given plugin directory. +func testVaultServerPluginDir(tb testing.TB, pluginDir string) (*api.Client, []string, func()) { + tb.Helper() + + return testVaultServerCoreConfig(tb, &vault.CoreConfig{ + DisableMlock: true, + DisableCache: true, + Logger: defaultVaultLogger, + CredentialBackends: defaultVaultCredentialBackends, + AuditBackends: defaultVaultAuditBackends, + LogicalBackends: defaultVaultLogicalBackends, + PluginDirectory: pluginDir, + }) +} + // testVaultServerCoreConfig creates a new vault cluster with the given core // configuration. This is a lower-level test helper. func testVaultServerCoreConfig(tb testing.TB, coreConfig *vault.CoreConfig) (*api.Client, []string, func()) { diff --git a/command/commands.go b/command/commands.go index b67b31da379c..a0defe5449ce 100644 --- a/command/commands.go +++ b/command/commands.go @@ -361,6 +361,31 @@ func initCommands(ui, serverCmdUi cli.Ui, runOpts *RunOptions) { BaseCommand: getBaseCommand(), }, nil }, + "plugin": func() (cli.Command, error) { + return &PluginCommand{ + BaseCommand: getBaseCommand(), + }, nil + }, + "plugin deregister": func() (cli.Command, error) { + return &PluginDeregisterCommand{ + BaseCommand: getBaseCommand(), + }, nil + }, + "plugin info": func() (cli.Command, error) { + return &PluginInfoCommand{ + BaseCommand: getBaseCommand(), + }, nil + }, + "plugin list": func() (cli.Command, error) { + return &PluginListCommand{ + BaseCommand: getBaseCommand(), + }, nil + }, + "plugin register": func() (cli.Command, error) { + return &PluginRegisterCommand{ + BaseCommand: getBaseCommand(), + }, nil + }, "policy": func() (cli.Command, error) { return &PolicyCommand{ BaseCommand: getBaseCommand(), diff --git a/command/plugin.go b/command/plugin.go new file mode 100644 index 000000000000..4ed82850333b --- /dev/null +++ b/command/plugin.go @@ -0,0 +1,46 @@ +package command + +import ( + "strings" + + "github.com/mitchellh/cli" +) + +var _ cli.Command = (*PluginCommand)(nil) + +type PluginCommand struct { + *BaseCommand +} + +func (c *PluginCommand) Synopsis() string { + return "Interact with Vault plugins and catalog" +} + +func (c *PluginCommand) Help() string { + helpText := ` +Usage: vault plugin [options] [args] + + This command groups subcommands for interacting with Vault's plugins and the + plugin catalog. Here are a few examples of the plugin commands: + + List all available plugins in the catalog: + + $ vault plugin list + + Register a new plugin to the catalog: + + $ vault plugin register -sha256=d3f0a8b... my-custom-plugin + + Get information about a plugin in the catalog: + + $ vault plugin info my-custom-plugin + + Please see the individual subcommand help for detailed usage information. +` + + return strings.TrimSpace(helpText) +} + +func (c *PluginCommand) Run(args []string) int { + return cli.RunResultHelp +} diff --git a/command/plugin_deregister.go b/command/plugin_deregister.go new file mode 100644 index 000000000000..ad6fd66b0245 --- /dev/null +++ b/command/plugin_deregister.go @@ -0,0 +1,86 @@ +package command + +import ( + "fmt" + "strings" + + "github.com/hashicorp/vault/api" + "github.com/mitchellh/cli" + "github.com/posener/complete" +) + +var _ cli.Command = (*PluginDeregisterCommand)(nil) +var _ cli.CommandAutocomplete = (*PluginDeregisterCommand)(nil) + +type PluginDeregisterCommand struct { + *BaseCommand +} + +func (c *PluginDeregisterCommand) Synopsis() string { + return "Deregister an existing plugin in the catalog" +} + +func (c *PluginDeregisterCommand) Help() string { + helpText := ` +Usage: vault plugin deregister [options] NAME + + Deregister an existing plugin in the catalog. If the plugin does not exist, + no action is taken (the command is idempotent). + + Deregister the plugin named my-custom-plugin: + + $ vault plugin deregister my-custom-plugin + +` + c.Flags().Help() + + return strings.TrimSpace(helpText) +} + +func (c *PluginDeregisterCommand) Flags() *FlagSets { + return c.flagSet(FlagSetHTTP) +} + +func (c *PluginDeregisterCommand) AutocompleteArgs() complete.Predictor { + return c.PredictVaultPlugins() +} + +func (c *PluginDeregisterCommand) AutocompleteFlags() complete.Flags { + return c.Flags().Completions() +} + +func (c *PluginDeregisterCommand) Run(args []string) int { + f := c.Flags() + + if err := f.Parse(args); err != nil { + c.UI.Error(err.Error()) + return 1 + } + + args = f.Args() + switch { + case len(args) < 1: + c.UI.Error(fmt.Sprintf("Not enough arguments (expected 1, got %d)", len(args))) + return 1 + case len(args) > 1: + c.UI.Error(fmt.Sprintf("Too many arguments (expected 1, got %d)", len(args))) + return 1 + } + + client, err := c.Client() + if err != nil { + c.UI.Error(err.Error()) + return 2 + } + + pluginName := strings.TrimSpace(args[0]) + + if err := client.Sys().DeregisterPlugin(&api.DeregisterPluginInput{ + Name: pluginName, + }); err != nil { + c.UI.Error(fmt.Sprintf("Error deregistering plugin named %s: %s", pluginName, err)) + return 2 + } + + c.UI.Output(fmt.Sprintf("Success! Deregistered plugin (if it was registered): %s", pluginName)) + return 0 +} diff --git a/command/plugin_deregister_test.go b/command/plugin_deregister_test.go new file mode 100644 index 000000000000..4d326aafc905 --- /dev/null +++ b/command/plugin_deregister_test.go @@ -0,0 +1,156 @@ +package command + +import ( + "strings" + "testing" + + "github.com/hashicorp/vault/api" + "github.com/mitchellh/cli" +) + +func testPluginDeregisterCommand(tb testing.TB) (*cli.MockUi, *PluginDeregisterCommand) { + tb.Helper() + + ui := cli.NewMockUi() + return ui, &PluginDeregisterCommand{ + BaseCommand: &BaseCommand{ + UI: ui, + }, + } +} + +func TestPluginDeregisterCommand_Run(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + args []string + out string + code int + }{ + { + "not_enough_args", + nil, + "Not enough arguments", + 1, + }, + { + "too_many_args", + []string{"foo", "bar"}, + "Too many arguments", + 1, + }, + { + "not_a_plugin", + []string{"nope_definitely_never_a_plugin_nope"}, + "", + 0, + }, + } + + for _, tc := range cases { + tc := tc + + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + client, closer := testVaultServer(t) + defer closer() + + ui, cmd := testPluginDeregisterCommand(t) + cmd.client = client + + code := cmd.Run(tc.args) + if code != tc.code { + t.Errorf("expected %d to be %d", code, tc.code) + } + + combined := ui.OutputWriter.String() + ui.ErrorWriter.String() + if !strings.Contains(combined, tc.out) { + t.Errorf("expected %q to contain %q", combined, tc.out) + } + }) + } + + t.Run("integration", func(t *testing.T) { + t.Parallel() + + pluginDir, cleanup := testPluginDir(t) + defer cleanup(t) + + client, _, closer := testVaultServerPluginDir(t, pluginDir) + defer closer() + + pluginName := "my-plugin" + _, sha256Sum := testPluginCreateAndRegister(t, client, pluginDir, pluginName) + + ui, cmd := testPluginDeregisterCommand(t) + cmd.client = client + + if err := client.Sys().RegisterPlugin(&api.RegisterPluginInput{ + Name: pluginName, + Command: pluginName, + SHA256: sha256Sum, + }); err != nil { + t.Fatal(err) + } + + code := cmd.Run([]string{ + pluginName, + }) + if exp := 0; code != exp { + t.Errorf("expected %d to be %d", code, exp) + } + + expected := "Success! Deregistered plugin (if it was registered): " + combined := ui.OutputWriter.String() + ui.ErrorWriter.String() + if !strings.Contains(combined, expected) { + t.Errorf("expected %q to contain %q", combined, expected) + } + + resp, err := client.Sys().ListPlugins(&api.ListPluginsInput{}) + if err != nil { + t.Fatal(err) + } + + found := false + for _, p := range resp.Names { + if p == pluginName { + found = true + } + } + if found { + t.Errorf("expected %q to not be in %q", pluginName, resp.Names) + } + }) + + t.Run("communication_failure", func(t *testing.T) { + t.Parallel() + + client, closer := testVaultServerBad(t) + defer closer() + + ui, cmd := testPluginDeregisterCommand(t) + cmd.client = client + + code := cmd.Run([]string{ + "my-plugin", + }) + if exp := 2; code != exp { + t.Errorf("expected %d to be %d", code, exp) + } + + expected := "Error deregistering plugin named my-plugin: " + combined := ui.OutputWriter.String() + ui.ErrorWriter.String() + if !strings.Contains(combined, expected) { + t.Errorf("expected %q to contain %q", combined, expected) + } + }) + + t.Run("no_tabs", func(t *testing.T) { + t.Parallel() + + _, cmd := testPluginDeregisterCommand(t) + assertNoTabs(t, cmd) + }) +} diff --git a/command/plugin_info.go b/command/plugin_info.go new file mode 100644 index 000000000000..c4232e9f5eca --- /dev/null +++ b/command/plugin_info.go @@ -0,0 +1,97 @@ +package command + +import ( + "fmt" + "strings" + + "github.com/hashicorp/vault/api" + "github.com/mitchellh/cli" + "github.com/posener/complete" +) + +var _ cli.Command = (*PluginInfoCommand)(nil) +var _ cli.CommandAutocomplete = (*PluginInfoCommand)(nil) + +type PluginInfoCommand struct { + *BaseCommand +} + +func (c *PluginInfoCommand) Synopsis() string { + return "Read information about a plugin in the catalog" +} + +func (c *PluginInfoCommand) Help() string { + helpText := ` +Usage: vault plugin info [options] NAME + + Displays information about a plugin in the catalog with the given name. If + the plugin does not exist, an error is returned. + + Get info about a plugin: + + $ vault plugin info mysql-database-plugin + +` + c.Flags().Help() + + return strings.TrimSpace(helpText) +} + +func (c *PluginInfoCommand) Flags() *FlagSets { + return c.flagSet(FlagSetHTTP | FlagSetOutputField | FlagSetOutputFormat) +} + +func (c *PluginInfoCommand) AutocompleteArgs() complete.Predictor { + return c.PredictVaultPlugins() +} + +func (c *PluginInfoCommand) AutocompleteFlags() complete.Flags { + return c.Flags().Completions() +} + +func (c *PluginInfoCommand) Run(args []string) int { + f := c.Flags() + + if err := f.Parse(args); err != nil { + c.UI.Error(err.Error()) + return 1 + } + + args = f.Args() + switch { + case len(args) < 1: + c.UI.Error(fmt.Sprintf("Not enough arguments (expected 1, got %d)", len(args))) + return 1 + case len(args) > 1: + c.UI.Error(fmt.Sprintf("Too many arguments (expected 1, got %d)", len(args))) + return 1 + } + + client, err := c.Client() + if err != nil { + c.UI.Error(err.Error()) + return 2 + } + + pluginName := strings.TrimSpace(args[0]) + + resp, err := client.Sys().GetPlugin(&api.GetPluginInput{ + Name: pluginName, + }) + if err != nil { + c.UI.Error(fmt.Sprintf("Error reading plugin named %s: %s", pluginName, err)) + return 2 + } + + data := map[string]interface{}{ + "args": resp.Args, + "builtin": resp.Builtin, + "command": resp.Command, + "name": resp.Name, + "sha256": resp.SHA256, + } + + if c.flagField != "" { + return PrintRawField(c.UI, data, c.flagField) + } + return OutputData(c.UI, data) +} diff --git a/command/plugin_info_test.go b/command/plugin_info_test.go new file mode 100644 index 000000000000..bc6e8bc3badb --- /dev/null +++ b/command/plugin_info_test.go @@ -0,0 +1,161 @@ +package command + +import ( + "strings" + "testing" + + "github.com/mitchellh/cli" +) + +func testPluginInfoCommand(tb testing.TB) (*cli.MockUi, *PluginInfoCommand) { + tb.Helper() + + ui := cli.NewMockUi() + return ui, &PluginInfoCommand{ + BaseCommand: &BaseCommand{ + UI: ui, + }, + } +} + +func TestPluginInfoCommand_Run(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + args []string + out string + code int + }{ + { + "too_many_args", + []string{"foo", "bar"}, + "Too many arguments", + 1, + }, + { + "no_plugin_exist", + []string{"not-a-real-plugin-like-ever"}, + "Error reading plugin", + 2, + }, + } + + t.Run("validations", func(t *testing.T) { + t.Parallel() + + for _, tc := range cases { + tc := tc + + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + client, closer := testVaultServer(t) + defer closer() + + ui, cmd := testPluginInfoCommand(t) + cmd.client = client + + code := cmd.Run(tc.args) + if code != tc.code { + t.Errorf("expected %d to be %d", code, tc.code) + } + + combined := ui.OutputWriter.String() + ui.ErrorWriter.String() + if !strings.Contains(combined, tc.out) { + t.Errorf("expected %q to contain %q", combined, tc.out) + } + }) + } + }) + + t.Run("default", func(t *testing.T) { + t.Parallel() + + pluginDir, cleanup := testPluginDir(t) + defer cleanup(t) + + client, _, closer := testVaultServerPluginDir(t, pluginDir) + defer closer() + + pluginName := "my-plugin" + _, sha256Sum := testPluginCreateAndRegister(t, client, pluginDir, pluginName) + + ui, cmd := testPluginInfoCommand(t) + cmd.client = client + + code := cmd.Run([]string{ + pluginName, + }) + if exp := 0; code != exp { + t.Errorf("expected %d to be %d", code, exp) + } + + combined := ui.OutputWriter.String() + ui.ErrorWriter.String() + if !strings.Contains(combined, pluginName) { + t.Errorf("expected %q to contain %q", combined, pluginName) + } + if !strings.Contains(combined, sha256Sum) { + t.Errorf("expected %q to contain %q", combined, sha256Sum) + } + }) + + t.Run("field", func(t *testing.T) { + t.Parallel() + + pluginDir, cleanup := testPluginDir(t) + defer cleanup(t) + + client, _, closer := testVaultServerPluginDir(t, pluginDir) + defer closer() + + pluginName := "my-plugin" + testPluginCreateAndRegister(t, client, pluginDir, pluginName) + + ui, cmd := testPluginInfoCommand(t) + cmd.client = client + + code := cmd.Run([]string{ + "-field", "builtin", + pluginName, + }) + if exp := 0; code != exp { + t.Errorf("expected %d to be %d", code, exp) + } + + combined := ui.OutputWriter.String() + ui.ErrorWriter.String() + if exp := "false"; combined != exp { + t.Errorf("expected %q to be %q", combined, exp) + } + }) + + t.Run("communication_failure", func(t *testing.T) { + t.Parallel() + + client, closer := testVaultServerBad(t) + defer closer() + + ui, cmd := testPluginInfoCommand(t) + cmd.client = client + + code := cmd.Run([]string{ + "my-plugin", + }) + if exp := 2; code != exp { + t.Errorf("expected %d to be %d", code, exp) + } + + expected := "Error reading plugin named my-plugin: " + combined := ui.OutputWriter.String() + ui.ErrorWriter.String() + if !strings.Contains(combined, expected) { + t.Errorf("expected %q to contain %q", combined, expected) + } + }) + + t.Run("no_tabs", func(t *testing.T) { + t.Parallel() + + _, cmd := testPluginInfoCommand(t) + assertNoTabs(t, cmd) + }) +} diff --git a/command/plugin_list.go b/command/plugin_list.go new file mode 100644 index 000000000000..4e8375c66266 --- /dev/null +++ b/command/plugin_list.go @@ -0,0 +1,89 @@ +package command + +import ( + "fmt" + "sort" + "strings" + + "github.com/hashicorp/vault/api" + "github.com/mitchellh/cli" + "github.com/posener/complete" +) + +var _ cli.Command = (*PluginListCommand)(nil) +var _ cli.CommandAutocomplete = (*PluginListCommand)(nil) + +type PluginListCommand struct { + *BaseCommand +} + +func (c *PluginListCommand) Synopsis() string { + return "Lists available plugins" +} + +func (c *PluginListCommand) Help() string { + helpText := ` +Usage: vault plugin list [options] + + Lists available plugins registered in the catalog. This does not list whether + plugins are in use, but rather just their availability. + + List all available plugins in the catalog: + + $ vault plugin list + +` + c.Flags().Help() + + return strings.TrimSpace(helpText) +} + +func (c *PluginListCommand) Flags() *FlagSets { + return c.flagSet(FlagSetHTTP | FlagSetOutputFormat) +} + +func (c *PluginListCommand) AutocompleteArgs() complete.Predictor { + return complete.PredictNothing +} + +func (c *PluginListCommand) AutocompleteFlags() complete.Flags { + return c.Flags().Completions() +} + +func (c *PluginListCommand) Run(args []string) int { + f := c.Flags() + + if err := f.Parse(args); err != nil { + c.UI.Error(err.Error()) + return 1 + } + + args = f.Args() + if len(args) > 0 { + c.UI.Error(fmt.Sprintf("Too many arguments (expected 0, got %d)", len(args))) + return 1 + } + + client, err := c.Client() + if err != nil { + c.UI.Error(err.Error()) + return 2 + } + + resp, err := client.Sys().ListPlugins(&api.ListPluginsInput{}) + if err != nil { + c.UI.Error(fmt.Sprintf("Error listing available plugins: %s", err)) + return 2 + } + + pluginNames := resp.Names + sort.Strings(pluginNames) + + switch Format(c.UI) { + case "table": + list := append([]string{"Plugins"}, pluginNames...) + c.UI.Output(tableOutput(list, nil)) + return 0 + default: + return OutputData(c.UI, pluginNames) + } +} diff --git a/command/plugin_list_test.go b/command/plugin_list_test.go new file mode 100644 index 000000000000..86a274d71fd3 --- /dev/null +++ b/command/plugin_list_test.go @@ -0,0 +1,99 @@ +package command + +import ( + "strings" + "testing" + + "github.com/mitchellh/cli" +) + +func testPluginListCommand(tb testing.TB) (*cli.MockUi, *PluginListCommand) { + tb.Helper() + + ui := cli.NewMockUi() + return ui, &PluginListCommand{ + BaseCommand: &BaseCommand{ + UI: ui, + }, + } +} + +func TestPluginListCommand_Run(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + args []string + out string + code int + }{ + { + "too_many_args", + []string{"foo"}, + "Too many arguments", + 1, + }, + { + "lists", + nil, + "Plugins", + 0, + }, + } + + t.Run("validations", func(t *testing.T) { + t.Parallel() + + for _, tc := range cases { + tc := tc + + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + client, closer := testVaultServer(t) + defer closer() + + ui, cmd := testPluginListCommand(t) + cmd.client = client + + code := cmd.Run(tc.args) + if code != tc.code { + t.Errorf("expected %d to be %d", code, tc.code) + } + + combined := ui.OutputWriter.String() + ui.ErrorWriter.String() + if !strings.Contains(combined, tc.out) { + t.Errorf("expected %q to contain %q", combined, tc.out) + } + }) + } + }) + + t.Run("communication_failure", func(t *testing.T) { + t.Parallel() + + client, closer := testVaultServerBad(t) + defer closer() + + ui, cmd := testPluginListCommand(t) + cmd.client = client + + code := cmd.Run([]string{}) + if exp := 2; code != exp { + t.Errorf("expected %d to be %d", code, exp) + } + + expected := "Error listing available plugins: " + combined := ui.OutputWriter.String() + ui.ErrorWriter.String() + if !strings.Contains(combined, expected) { + t.Errorf("expected %q to contain %q", combined, expected) + } + }) + + t.Run("no_tabs", func(t *testing.T) { + t.Parallel() + + _, cmd := testPluginListCommand(t) + assertNoTabs(t, cmd) + }) +} diff --git a/command/plugin_register.go b/command/plugin_register.go new file mode 100644 index 000000000000..9bd5e76b33b1 --- /dev/null +++ b/command/plugin_register.go @@ -0,0 +1,135 @@ +package command + +import ( + "fmt" + "strings" + + "github.com/hashicorp/vault/api" + "github.com/mitchellh/cli" + "github.com/posener/complete" +) + +var _ cli.Command = (*PluginRegisterCommand)(nil) +var _ cli.CommandAutocomplete = (*PluginRegisterCommand)(nil) + +type PluginRegisterCommand struct { + *BaseCommand + + flagArgs []string + flagCommand string + flagSHA256 string +} + +func (c *PluginRegisterCommand) Synopsis() string { + return "Registers a new plugin in the catalog" +} + +func (c *PluginRegisterCommand) Help() string { + helpText := ` +Usage: vault plugin register [options] NAME + + Registers a new plugin in the catalog. The plugin binary must exist in Vault's + configured plugin directory. + + Register the plugin named my-custom-plugin: + + $ vault plugin register -sha256=d3f0a8b... my-custom-plugin + + Register a plugin with custom arguments: + + $ vault plugin register \ + -sha256=d3f0a8b... \ + -args=--with-glibc,--with-cgo \ + my-custom-plugin + +` + c.Flags().Help() + + return strings.TrimSpace(helpText) +} + +func (c *PluginRegisterCommand) Flags() *FlagSets { + set := c.flagSet(FlagSetHTTP) + + f := set.NewFlagSet("Command Options") + + f.StringSliceVar(&StringSliceVar{ + Name: "args", + Target: &c.flagArgs, + Completion: complete.PredictAnything, + Usage: "Arguments to pass to the plugin when starting. Separate " + + "multiple arguments with a comma.", + }) + + f.StringVar(&StringVar{ + Name: "command", + Target: &c.flagCommand, + Completion: complete.PredictAnything, + Usage: "Command to spawn the plugin. This defaults to the name of the " + + "plugin if unspecified.", + }) + + f.StringVar(&StringVar{ + Name: "sha256", + Target: &c.flagSHA256, + Completion: complete.PredictAnything, + Usage: "SHA256 of the plugin binary. This is required for all plugins.", + }) + + return set +} + +func (c *PluginRegisterCommand) AutocompleteArgs() complete.Predictor { + return c.PredictVaultPlugins() +} + +func (c *PluginRegisterCommand) AutocompleteFlags() complete.Flags { + return c.Flags().Completions() +} + +func (c *PluginRegisterCommand) Run(args []string) int { + f := c.Flags() + + if err := f.Parse(args); err != nil { + c.UI.Error(err.Error()) + return 1 + } + + args = f.Args() + switch { + case len(args) < 1: + c.UI.Error(fmt.Sprintf("Not enough arguments (expected 1, got %d)", len(args))) + return 1 + case len(args) > 1: + c.UI.Error(fmt.Sprintf("Too many arguments (expected 1, got %d)", len(args))) + return 1 + case c.flagSHA256 == "": + c.UI.Error("SHA256 is required for all plugins, please provide -sha256") + return 1 + } + + client, err := c.Client() + if err != nil { + c.UI.Error(err.Error()) + return 2 + } + + pluginName := strings.TrimSpace(args[0]) + + command := c.flagCommand + if command == "" { + command = pluginName + } + + if err := client.Sys().RegisterPlugin(&api.RegisterPluginInput{ + Name: pluginName, + Args: c.flagArgs, + Command: command, + SHA256: c.flagSHA256, + }); err != nil { + c.UI.Error(fmt.Sprintf("Error registering plugin %s: %s", pluginName, err)) + return 2 + } + + c.UI.Output(fmt.Sprintf("Success! Registered plugin: %s", pluginName)) + return 0 +} diff --git a/command/plugin_register_test.go b/command/plugin_register_test.go new file mode 100644 index 000000000000..aae490298f46 --- /dev/null +++ b/command/plugin_register_test.go @@ -0,0 +1,151 @@ +package command + +import ( + "strings" + "testing" + + "github.com/hashicorp/vault/api" + "github.com/mitchellh/cli" +) + +func testPluginRegisterCommand(tb testing.TB) (*cli.MockUi, *PluginRegisterCommand) { + tb.Helper() + + ui := cli.NewMockUi() + return ui, &PluginRegisterCommand{ + BaseCommand: &BaseCommand{ + UI: ui, + }, + } +} + +func TestPluginRegisterCommand_Run(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + args []string + out string + code int + }{ + { + "not_enough_args", + nil, + "Not enough arguments", + 1, + }, + { + "too_many_args", + []string{"foo", "bar"}, + "Too many arguments", + 1, + }, + { + "not_a_plugin", + []string{"nope_definitely_never_a_plugin_nope"}, + "", + 2, + }, + } + + for _, tc := range cases { + tc := tc + + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + client, closer := testVaultServer(t) + defer closer() + + ui, cmd := testPluginRegisterCommand(t) + cmd.client = client + + args := append([]string{"-sha256", "abcd1234"}, tc.args...) + code := cmd.Run(args) + if code != tc.code { + t.Errorf("expected %d to be %d", code, tc.code) + } + + combined := ui.OutputWriter.String() + ui.ErrorWriter.String() + if !strings.Contains(combined, tc.out) { + t.Errorf("expected %q to contain %q", combined, tc.out) + } + }) + } + + t.Run("integration", func(t *testing.T) { + t.Parallel() + + pluginDir, cleanup := testPluginDir(t) + defer cleanup(t) + + client, _, closer := testVaultServerPluginDir(t, pluginDir) + defer closer() + + pluginName := "my-plugin" + _, sha256Sum := testPluginCreate(t, pluginDir, pluginName) + + ui, cmd := testPluginRegisterCommand(t) + cmd.client = client + + code := cmd.Run([]string{ + "-sha256", sha256Sum, + pluginName, + }) + if exp := 0; code != exp { + t.Errorf("expected %d to be %d", code, exp) + } + + expected := "Success! Registered plugin: my-plugin" + combined := ui.OutputWriter.String() + ui.ErrorWriter.String() + if !strings.Contains(combined, expected) { + t.Errorf("expected %q to contain %q", combined, expected) + } + + resp, err := client.Sys().ListPlugins(&api.ListPluginsInput{}) + if err != nil { + t.Fatal(err) + } + + found := false + for _, p := range resp.Names { + if p == pluginName { + found = true + } + } + if !found { + t.Errorf("expected %q to be in %q", pluginName, resp.Names) + } + }) + + t.Run("communication_failure", func(t *testing.T) { + t.Parallel() + + client, closer := testVaultServerBad(t) + defer closer() + + ui, cmd := testPluginRegisterCommand(t) + cmd.client = client + + code := cmd.Run([]string{ + "-sha256", "abcd1234", + "my-plugin", + }) + if exp := 2; code != exp { + t.Errorf("expected %d to be %d", code, exp) + } + + expected := "Error registering plugin my-plugin:" + combined := ui.OutputWriter.String() + ui.ErrorWriter.String() + if !strings.Contains(combined, expected) { + t.Errorf("expected %q to contain %q", combined, expected) + } + }) + + t.Run("no_tabs", func(t *testing.T) { + t.Parallel() + + _, cmd := testPluginRegisterCommand(t) + assertNoTabs(t, cmd) + }) +} diff --git a/command/plugin_test.go b/command/plugin_test.go new file mode 100644 index 000000000000..7f64c14721d0 --- /dev/null +++ b/command/plugin_test.go @@ -0,0 +1,78 @@ +package command + +import ( + "crypto/sha256" + "fmt" + "io" + "io/ioutil" + "os" + "path/filepath" + "testing" + + "github.com/hashicorp/vault/api" +) + +// testPluginDir creates a temporary directory suitable for holding plugins. +// This helper also resolves symlinks to make tests happy on OS X. +func testPluginDir(tb testing.TB) (string, func(tb testing.TB)) { + tb.Helper() + + dir, err := ioutil.TempDir("", "") + if err != nil { + tb.Fatal(err) + } + + // OSX tempdir are /var, but actually symlinked to /private/var + dir, err = filepath.EvalSymlinks(dir) + if err != nil { + tb.Fatal(err) + } + + return dir, func(tb testing.TB) { + if err := os.RemoveAll(dir); err != nil { + tb.Fatal(err) + } + } +} + +// testPluginCreate creates a sample plugin in a tempdir and returns the shasum +// and filepath to the plugin. +func testPluginCreate(tb testing.TB, dir, name string) (string, string) { + tb.Helper() + + pth := dir + "/" + name + if err := ioutil.WriteFile(pth, nil, 0755); err != nil { + tb.Fatal(err) + } + + f, err := os.Open(pth) + if err != nil { + tb.Fatal(err) + } + defer f.Close() + + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + tb.Fatal(err) + } + sha256Sum := fmt.Sprintf("%x", h.Sum(nil)) + + return pth, sha256Sum +} + +// testPluginCreateAndRegister creates a plugin and registers it in the catalog. +func testPluginCreateAndRegister(tb testing.TB, client *api.Client, dir, name string) (string, string) { + tb.Helper() + + pth, sha256Sum := testPluginCreate(tb, dir, name) + + if err := client.Sys().RegisterPlugin(&api.RegisterPluginInput{ + Name: name, + Command: name, + SHA256: sha256Sum, + }); err != nil { + tb.Fatal(err) + } + + return pth, sha256Sum +} diff --git a/website/source/docs/commands/plugin.html.md b/website/source/docs/commands/plugin.html.md new file mode 100644 index 000000000000..c86c06fb7c75 --- /dev/null +++ b/website/source/docs/commands/plugin.html.md @@ -0,0 +1,63 @@ +--- +layout: "docs" +page_title: "plugin - Command" +sidebar_current: "docs-commands-plugin" +description: |- + The "plugin" command groups subcommands for interacting with + Vault's plugins and the plugin catalog. +--- + +# plugin + +The `plugin` command groups subcommands for interacting with Vault's plugins and +the plugin catalog + +## Examples + +List all available plugins in the catalog: + +```text +$ vault plugin list + +Plugins +------- +my-custom-plugin +# ... +``` + +Register a new plugin to the catalog: + +```text +$ vault plugin register \ + -sha256=d3f0a8be02f6c074cf38c9c99d4d04c9c6466249 \ + my-custom-plugin +Success! Registered plugin: my-custom-plugin +``` + +Get information about a plugin in the catalog: + +```text +$ vault plugin info my-custom-plugin +Key Value +--- ----- +command my-custom-plugin +name my-custom-plugin +sha256 d3f0a8be02f6c074cf38c9c99d4d04c9c6466249 +``` + +## Usage + +```text +Usage: vault plugin [options] [args] + + # ... + +Subcommands: + deregister Deregister an existing plugin in the catalog + list Lists available plugins + read Read information about a plugin in the catalog + register Registers a new plugin in the catalog +``` + +For more information, examples, and usage about a subcommand, click on the name +of the subcommand in the sidebar. diff --git a/website/source/docs/commands/plugin/deregister.html.md b/website/source/docs/commands/plugin/deregister.html.md new file mode 100644 index 000000000000..e49bf9a4b504 --- /dev/null +++ b/website/source/docs/commands/plugin/deregister.html.md @@ -0,0 +1,27 @@ +--- +layout: "docs" +page_title: "plugin deregister - Command" +sidebar_current: "docs-commands-plugin-deregister" +description: |- + The "plugin deregister" command deregisters a new plugin in Vault's plugin + catalog. +--- + +# plugin deregister + +The `plugin deregister` command deregisters an existing plugin from Vault's +plugin catalog. If the plugin does not exist, no error is returned. + +## Examples + +Deregister a plugin: + +```text +$ vault plugin deregister my-custom-plugin +Success! Deregistered plugin (if it was registered): my-custom-plugin +``` + +## Usage + +There are no flags beyond the [standard set of flags](/docs/commands/index.html) +included on all commands. diff --git a/website/source/docs/commands/plugin/info.html.md b/website/source/docs/commands/plugin/info.html.md new file mode 100644 index 000000000000..2e3284d095be --- /dev/null +++ b/website/source/docs/commands/plugin/info.html.md @@ -0,0 +1,43 @@ +--- +layout: "docs" +page_title: "plugin info - Command" +sidebar_current: "docs-commands-plugin-info" +description: |- + The "plugin info" command displays information about a plugin in the catalog. +--- + +# plugin info + +The `plugin info` displays information about a plugin in the catalog. + +## Examples + +Display information about a plugin + +```text +$ vault plugin info my-custom-plugin + +Key Value +--- ----- +args [] +builtin false +command my-custom-plugin +name my-custom-plugin +sha256 d3f0a8be02f6c074cf38c9c99d4d04c9c6466249 +``` + +## Usage + +The following flags are available in addition to the [standard set of +flags](/docs/commands/index.html) included on all commands. + +### Output Options + +- `-field` `(string: "")` - Print only the field with the given name. Specifying + this option will take precedence over other formatting directives. The result + will not have a trailing newline making it ideal for piping to other + processes. + +- `-format` `(string: "table")` - Print the output in the given format. Valid + formats are "table", "json", or "yaml". This can also be specified via the + `VAULT_FORMAT` environment variable. diff --git a/website/source/docs/commands/plugin/list.html.md b/website/source/docs/commands/plugin/list.html.md new file mode 100644 index 000000000000..ab35ae5614f9 --- /dev/null +++ b/website/source/docs/commands/plugin/list.html.md @@ -0,0 +1,35 @@ +--- +layout: "docs" +page_title: "plugin list - Command" +sidebar_current: "docs-commands-plugin-list" +description: |- + The "plugin list" command lists all available plugins in the plugin catalog. +--- + +# plugin list + +The `plugin list` command lists all available plugins in the plugin catalog. + +## Examples + +List all available plugins in the catalog. + +```text +$ vault plugin list + +Plugins +------- +my-custom-plugin +# ... +``` + +## Usage + +The following flags are available in addition to the [standard set of +flags](/docs/commands/index.html) included on all commands. + +### Output Options + +- `-format` `(string: "table")` - Print the output in the given format. Valid + formats are "table", "json", or "yaml". This can also be specified via the + `VAULT_FORMAT` environment variable. diff --git a/website/source/docs/commands/plugin/register.html.md b/website/source/docs/commands/plugin/register.html.md new file mode 100644 index 000000000000..cde96f4d6ece --- /dev/null +++ b/website/source/docs/commands/plugin/register.html.md @@ -0,0 +1,53 @@ +--- +layout: "docs" +page_title: "plugin register - Command" +sidebar_current: "docs-commands-plugin-register" +description: |- + The "plugin register" command registers a new plugin in Vault's plugin + catalog. +--- + +# plugin register + +The `plugin register` command registers a new plugin in Vault's plugin catalog. + +## Examples + +Register a plugin: + +```text +$ vault plugin register \ + -sha256=d3f0a8be02f6c074cf38c9c99d4d04c9c6466249 \ + my-custom-plugin +Success! Registered plugin: my-custom-plugin +``` + +Register a plugin with custom args: + +```text +$ vault plugin register \ + -sha256=d3f0a8be02f6c074cf38c9c99d4d04c9c6466249 \ + -args=--with-glibc,--with-curl-bindings \ + my-custom-plugin +``` + +## Usage + +The following flags are available in addition to the [standard set of +flags](/docs/commands/index.html) included on all commands. + +### Output Options + +- `-format` `(string: "table")` - Print the output in the given format. Valid + formats are "table", "json", or "yaml". This can also be specified via the + `VAULT_FORMAT` environment variable. + +### Command Options + +- `-sha256` `(string: )` - Checksum (SHA256) of the plugin binary. + +- `-args` `(string: "")` - List of arguments to pass to the binary plugin during + each invocation. Specify multiple arguments with commas. + +- `-command` `(string: "")` - Name of the command to run to invoke the binary. + By default, this is the name of the plugin. diff --git a/website/source/layouts/docs.erb b/website/source/layouts/docs.erb index ce08e994dac8..dcd210af0552 100644 --- a/website/source/layouts/docs.erb +++ b/website/source/layouts/docs.erb @@ -265,6 +265,23 @@ > path-help + > + plugin + + > policy