Skip to content

Commit

Permalink
Add PATCH support to Vault CLI (#17650)
Browse files Browse the repository at this point in the history
* Add patch support to CLI

This is based off the existing write command, using the
JSONMergePatch(...) API client method rather than Write(...), allowing
us to update specific fields.

Signed-off-by: Alexander Scheel <alex.scheel@hashicorp.com>

* Add documentation on PATCH support

Signed-off-by: Alexander Scheel <alex.scheel@hashicorp.com>

* Add changelog

Signed-off-by: Alexander Scheel <alex.scheel@hashicorp.com>

Signed-off-by: Alexander Scheel <alex.scheel@hashicorp.com>
  • Loading branch information
cipherboy committed Oct 26, 2022
1 parent 172e0ef commit b0bf1c0
Show file tree
Hide file tree
Showing 6 changed files with 437 additions and 0 deletions.
3 changes: 3 additions & 0 deletions changelog/17650.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
```release-note:improvement
cli: Add support for creating requests to existing non-KVv2 PATCH-capable endpoints.
```
5 changes: 5 additions & 0 deletions command/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -488,6 +488,11 @@ func initCommands(ui, serverCmdUi cli.Ui, runOpts *RunOptions) {
BaseCommand: getBaseCommand(),
}, nil
},
"patch": func() (cli.Command, error) {
return &PatchCommand{
BaseCommand: getBaseCommand(),
}, nil
},
"path-help": func() (cli.Command, error) {
return &PathHelpCommand{
BaseCommand: getBaseCommand(),
Expand Down
135 changes: 135 additions & 0 deletions command/patch.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
package command

import (
"context"
"fmt"
"io"
"os"
"strings"

"github.com/mitchellh/cli"
"github.com/posener/complete"
)

var (
_ cli.Command = (*PatchCommand)(nil)
_ cli.CommandAutocomplete = (*PatchCommand)(nil)
)

// PatchCommand is a Command that puts data into the Vault.
type PatchCommand struct {
*BaseCommand

flagForce bool

testStdin io.Reader // for tests
}

func (c *PatchCommand) Synopsis() string {
return "Patch data, configuration, and secrets"
}

func (c *PatchCommand) Help() string {
helpText := `
Usage: vault patch [options] PATH [DATA K=V...]
Patches data in Vault at the given path. The data can be credentials, secrets,
configuration, or arbitrary data. The specific behavior of this command is
determined at the thing mounted at the path.
Data is specified as "key=value" pairs. If the value begins with an "@", then
it is loaded from a file. If the value is "-", Vault will read the value from
stdin.
Unlike write, patch will only modify specified fields.
Persist data in the generic secrets engine without modifying any other fields:
$ vault patch pki/roles/example allow_localhost=false
The data can also be consumed from a file on disk by prefixing with the "@"
symbol. For example:
$ vault patch pki/roles/example @role.json
Or it can be read from stdin using the "-" symbol:
$ echo "example.com" | vault patch pki/roles/example allowed_domains=-
For a full list of examples and paths, please see the documentation that
corresponds to the secret engines in use.
` + c.Flags().Help()

return strings.TrimSpace(helpText)
}

func (c *PatchCommand) Flags() *FlagSets {
set := c.flagSet(FlagSetHTTP | FlagSetOutputField | FlagSetOutputFormat)
f := set.NewFlagSet("Command Options")

f.BoolVar(&BoolVar{
Name: "force",
Aliases: []string{"f"},
Target: &c.flagForce,
Default: false,
EnvVar: "",
Completion: complete.PredictNothing,
Usage: "Allow the operation to continue with no key=value pairs. This " +
"allows writing to keys that do not need or expect data.",
})

return set
}

func (c *PatchCommand) AutocompleteArgs() complete.Predictor {
// Return an anything predictor here. Without a way to access help
// information, we don't know what paths we could patch.
return complete.PredictAnything
}

func (c *PatchCommand) AutocompleteFlags() complete.Flags {
return c.Flags().Completions()
}

func (c *PatchCommand) 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.flagForce:
c.UI.Error("Must supply data or use -force")
return 1
}

// Pull our fake stdin if needed
stdin := (io.Reader)(os.Stdin)
if c.testStdin != nil {
stdin = c.testStdin
}

path := sanitizePath(args[0])

data, err := parseArgsData(stdin, args[1:])
if err != nil {
c.UI.Error(fmt.Sprintf("Failed to parse K=V data: %s", err))
return 1
}

client, err := c.Client()
if err != nil {
c.UI.Error(err.Error())
return 2
}

secret, err := client.Logical().JSONMergePatch(context.Background(), path, data)
return handleWriteSecretOutput(c.BaseCommand, path, secret, err)
}
202 changes: 202 additions & 0 deletions command/patch_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
package command

import (
"io"
"strings"
"testing"

"github.com/hashicorp/vault/api"
"github.com/mitchellh/cli"
)

func testPatchCommand(tb testing.TB) (*cli.MockUi, *PatchCommand) {
tb.Helper()

ui := cli.NewMockUi()
return ui, &PatchCommand{
BaseCommand: &BaseCommand{
UI: ui,
},
}
}

func TestPatchCommand_Run(t *testing.T) {
t.Parallel()

cases := []struct {
name string
args []string
out string
code int
}{
{
"not_enough_args",
[]string{},
"Not enough arguments",
1,
},
{
"empty_kvs",
[]string{"secret/write/foo"},
"Must supply data or use -force",
1,
},
{
"force_kvs",
[]string{"-force", "pki/roles/example"},
"Success!",
0,
},
{
"force_f_kvs",
[]string{"-f", "pki/roles/example"},
"Success!",
0,
},
{
"kvs_no_value",
[]string{"pki/roles/example", "foo"},
"Failed to parse K=V data",
1,
},
{
"single_value",
[]string{"pki/roles/example", "allow_localhost=true"},
"Success!",
0,
},
{
"multi_value",
[]string{"pki/roles/example", "allow_localhost=true", "allowed_domains=true"},
"Success!",
0,
},
}

for _, tc := range cases {
tc := tc

t.Run(tc.name, func(t *testing.T) {
t.Parallel()

client, closer := testVaultServer(t)
defer closer()

if err := client.Sys().Mount("pki", &api.MountInput{
Type: "pki",
}); err != nil {
t.Fatalf("pki mount error: %#v", err)
}

if _, err := client.Logical().Write("pki/roles/example", nil); err != nil {
t.Fatalf("failed to prime role: %v", err)
}

if _, err := client.Logical().Write("pki/root/generate/internal", map[string]interface{}{
"key_type": "ec",
"common_name": "Root X1",
}); err != nil {
t.Fatalf("failed to prime CA: %v", err)
}

ui, cmd := testPatchCommand(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("stdin_full", func(t *testing.T) {
t.Parallel()

client, closer := testVaultServer(t)
defer closer()

if err := client.Sys().Mount("pki", &api.MountInput{
Type: "pki",
}); err != nil {
t.Fatalf("pki mount error: %#v", err)
}

if _, err := client.Logical().Write("pki/roles/example", nil); err != nil {
t.Fatalf("failed to prime role: %v", err)
}

if _, err := client.Logical().Write("pki/root/generate/internal", map[string]interface{}{
"key_type": "ec",
"common_name": "Root X1",
}); err != nil {
t.Fatalf("failed to prime CA: %v", err)
}

stdinR, stdinW := io.Pipe()
go func() {
stdinW.Write([]byte(`{"allow_localhost":"false","allow_wildcard_certificates":"false"}`))
stdinW.Close()
}()

ui, cmd := testPatchCommand(t)
cmd.client = client
cmd.testStdin = stdinR

code := cmd.Run([]string{
"pki/roles/example", "-",
})
if code != 0 {
combined := ui.OutputWriter.String() + ui.ErrorWriter.String()
t.Fatalf("expected retcode=%d to be 0\nOutput:\n%v", code, combined)
}

secret, err := client.Logical().Read("pki/roles/example")
if err != nil {
t.Fatal(err)
}
if secret == nil || secret.Data == nil {
t.Fatal("expected secret to have data")
}
if exp, act := false, secret.Data["allow_localhost"].(bool); exp != act {
t.Errorf("expected allowed_localhost=%v to be %v", act, exp)
}
if exp, act := false, secret.Data["allow_wildcard_certificates"].(bool); exp != act {
t.Errorf("expected allow_wildcard_certificates=%v to be %v", act, exp)
}
})

t.Run("communication_failure", func(t *testing.T) {
t.Parallel()

client, closer := testVaultServerBad(t)
defer closer()

ui, cmd := testPatchCommand(t)
cmd.client = client

code := cmd.Run([]string{
"foo/bar", "a=b",
})
if exp := 2; code != exp {
t.Errorf("expected %d to be %d", code, exp)
}

expected := "Error writing data to foo/bar: "
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 := testPatchCommand(t)
assertNoTabs(t, cmd)
})
}
5 changes: 5 additions & 0 deletions command/write.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"os"
"strings"

"github.com/hashicorp/vault/api"
"github.com/mitchellh/cli"
"github.com/posener/complete"
)
Expand Down Expand Up @@ -138,6 +139,10 @@ func (c *WriteCommand) Run(args []string) int {
}

secret, err := client.Logical().Write(path, data)
return handleWriteSecretOutput(c.BaseCommand, path, secret, err)
}

func handleWriteSecretOutput(c *BaseCommand, path string, secret *api.Secret, err error) int {
if err != nil {
c.UI.Error(fmt.Sprintf("Error writing data to %s: %s", path, err))
if secret != nil {
Expand Down

0 comments on commit b0bf1c0

Please sign in to comment.