-
Notifications
You must be signed in to change notification settings - Fork 4.3k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add PATCH support to Vault CLI (#17650)
* 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 <[email protected]> * Add documentation on PATCH support Signed-off-by: Alexander Scheel <[email protected]> * Add changelog Signed-off-by: Alexander Scheel <[email protected]> Signed-off-by: Alexander Scheel <[email protected]>
- Loading branch information
Showing
6 changed files
with
437 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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. | ||
``` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
}) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.