Merge pull request 'vctx: set VAULT_FORMAT from the vault command' (#3) from benvin/vctx-format into main
Reviewed-on: #3
This commit was merged in pull request #3.
This commit is contained in:
+22
-5
@@ -8,7 +8,7 @@ invocation only, and execs `vault` with the remaining arguments.
|
||||
## Synopsis
|
||||
|
||||
```
|
||||
vctx --context <context> <any vault args...>
|
||||
vctx --context <context> [--format <fmt>] <any vault args...>
|
||||
vctx version
|
||||
```
|
||||
|
||||
@@ -26,17 +26,34 @@ vctx --context sydney kv put kv/foo/bar secret=baz
|
||||
- `VAULT_TOKEN` = the cached token
|
||||
- `VAULT_NAMESPACE` = the context's namespace (only if non-empty; falls back
|
||||
to the namespace recorded in the cached token)
|
||||
- `VAULT_FORMAT` = `--format` if given, else picked from the command (see
|
||||
[Output format](#output-format))
|
||||
4. `exec`s the `vault` binary (found on `PATH`) with everything after the
|
||||
context. Because it replaces the process, `vault`'s exit status, signals and
|
||||
TTY behaviour pass straight through.
|
||||
|
||||
The ambient `VAULT_ADDR` / `VAULT_TOKEN` in your shell are ignored for the call
|
||||
The ambient `VAULT_ADDR` / `VAULT_TOKEN` / `VAULT_NAMESPACE` in your shell are ignored for the call
|
||||
— `vctx` always targets the chosen context.
|
||||
|
||||
## The `--context` flag
|
||||
## Output format
|
||||
|
||||
`--context` is the only flag `vctx` consumes; it must come **before** the vault
|
||||
command. Everything from the first non-flag argument onward is handed to `vault`
|
||||
Without `--format`, `vctx` sets `VAULT_FORMAT` from the vault command:
|
||||
|
||||
| Command | `VAULT_FORMAT` |
|
||||
|--------------------------------------------|----------------|
|
||||
| `kv list ...` | `table` |
|
||||
| `kv get ...` | `yaml` |
|
||||
| any path containing `/creds/` (read/write) | `yaml` |
|
||||
| anything else | unset (vault default) |
|
||||
|
||||
It is left alone when `VAULT_FORMAT` is already set in the environment or the
|
||||
vault args include `-format` or `-field`. `--format` (`table`, `json`, `yaml`,
|
||||
`pretty`; `raw` for `vault read` only) always sets it, overriding the environment.
|
||||
|
||||
## Flags
|
||||
|
||||
`--context` and `--format` are the only flags `vctx` consumes; they must come
|
||||
**before** the vault command. Everything from the first non-flag argument onward is handed to `vault`
|
||||
untouched, so vault's own flags work normally:
|
||||
|
||||
```bash
|
||||
|
||||
+79
-16
@@ -12,6 +12,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"git.unkin.net/unkin/vault-tools/shared"
|
||||
@@ -32,15 +33,16 @@ func main() {
|
||||
}
|
||||
|
||||
func newRootCmd() *cobra.Command {
|
||||
var context string
|
||||
var context, format string
|
||||
|
||||
root := &cobra.Command{
|
||||
Use: "vctx --context <context> <vault args...>",
|
||||
Short: "Run the vault CLI against a named context.",
|
||||
Long: "vctx resolves a context from ~/.config/vcm (shared with vcm), loads its\n" +
|
||||
"cached token from ~/.cache/vcm/<context>, sets VAULT_ADDR, VAULT_TOKEN and\n" +
|
||||
"VAULT_NAMESPACE for this invocation only, and execs `vault` with the\n" +
|
||||
"remaining arguments.\n\n" +
|
||||
"VAULT_NAMESPACE (and VAULT_FORMAT: yaml for kv get and */creds/*, table\n" +
|
||||
"for kv list, unless already chosen) for this invocation only, and execs\n" +
|
||||
"`vault` with the remaining arguments.\n\n" +
|
||||
"Example: vctx --context sydney kv put kv/foo/bar secret=baz",
|
||||
// Everything from the first non-flag argument on is the vault command
|
||||
// line; SetInterspersed(false) below stops flag parsing there so flags
|
||||
@@ -48,7 +50,7 @@ func newRootCmd() *cobra.Command {
|
||||
Args: cobra.ArbitraryArgs,
|
||||
SilenceUsage: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runVctx(context, args)
|
||||
return runVctx(context, format, args)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -64,6 +66,9 @@ func newRootCmd() *cobra.Command {
|
||||
}
|
||||
return cfg.ContextNames(), cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
root.Flags().StringVar(&format, "format", "", "VAULT_FORMAT for this call (default: picked from the vault command)")
|
||||
_ = root.RegisterFlagCompletionFunc("format", cobra.FixedCompletions(
|
||||
[]string{"table", "json", "yaml", "pretty"}, cobra.ShellCompDirectiveNoFileComp))
|
||||
|
||||
root.AddCommand(&cobra.Command{
|
||||
Use: "version",
|
||||
@@ -78,7 +83,7 @@ func newRootCmd() *cobra.Command {
|
||||
|
||||
// runVctx validates inputs, builds the vault invocation for the context, and
|
||||
// execs it (replacing this process).
|
||||
func runVctx(context string, args []string) error {
|
||||
func runVctx(context, format string, args []string) error {
|
||||
if context == "" {
|
||||
return fmt.Errorf("--context is required")
|
||||
}
|
||||
@@ -86,7 +91,7 @@ func runVctx(context string, args []string) error {
|
||||
return fmt.Errorf("no vault arguments given (e.g. vctx --context %s kv list kv/)", context)
|
||||
}
|
||||
|
||||
bin, argv, env, err := buildInvocation(context, args, os.Environ())
|
||||
bin, argv, env, err := buildInvocation(context, format, args, os.Environ())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -101,9 +106,11 @@ func runVctx(context string, args []string) error {
|
||||
// buildInvocation resolves the context and its cached token, locates the vault
|
||||
// binary, and returns the binary path, argv (vault + args) and the environment
|
||||
// to exec with. baseEnv is the starting environment (normally os.Environ());
|
||||
// the Vault settings are appended so they override any ambient values (exec
|
||||
// semantics: the last assignment of a variable wins).
|
||||
func buildInvocation(context string, args, baseEnv []string) (bin string, argv []string, env []string, err error) {
|
||||
// ambient copies of the keys vctx sets are dropped first, since Go programs
|
||||
// (vault included) honour the first occurrence of a duplicated variable.
|
||||
// VAULT_NAMESPACE is always dropped so a context without one is not redirected.
|
||||
// format, when non-empty, is set as VAULT_FORMAT; otherwise autoFormat picks one.
|
||||
func buildInvocation(context, format string, args, baseEnv []string) (bin string, argv []string, env []string, err error) {
|
||||
cfg, err := shared.Load()
|
||||
if err != nil {
|
||||
return "", nil, nil, err
|
||||
@@ -122,12 +129,10 @@ func buildInvocation(context string, args, baseEnv []string) (bin string, argv [
|
||||
return "", nil, nil, fmt.Errorf("vault CLI not found in PATH: %w", err)
|
||||
}
|
||||
|
||||
env = make([]string, len(baseEnv), len(baseEnv)+3)
|
||||
copy(env, baseEnv)
|
||||
env = append(env,
|
||||
"VAULT_ADDR="+rc.Address,
|
||||
"VAULT_TOKEN="+tok.Token,
|
||||
)
|
||||
set := map[string]string{
|
||||
"VAULT_ADDR": rc.Address,
|
||||
"VAULT_TOKEN": tok.Token,
|
||||
}
|
||||
// Namespace comes from the context, falling back to what the token was
|
||||
// issued under; only set when non-empty.
|
||||
ns := rc.Namespace
|
||||
@@ -135,9 +140,67 @@ func buildInvocation(context string, args, baseEnv []string) (bin string, argv [
|
||||
ns = tok.Namespace
|
||||
}
|
||||
if ns != "" {
|
||||
env = append(env, "VAULT_NAMESPACE="+ns)
|
||||
set["VAULT_NAMESPACE"] = ns
|
||||
}
|
||||
if format == "" {
|
||||
format = autoFormat(args, baseEnv)
|
||||
}
|
||||
if format != "" {
|
||||
set["VAULT_FORMAT"] = format
|
||||
}
|
||||
|
||||
for _, e := range baseEnv {
|
||||
k, _, _ := strings.Cut(e, "=")
|
||||
if _, ok := set[k]; ok || k == "VAULT_NAMESPACE" {
|
||||
continue
|
||||
}
|
||||
env = append(env, e)
|
||||
}
|
||||
for _, k := range []string{"VAULT_ADDR", "VAULT_TOKEN", "VAULT_NAMESPACE", "VAULT_FORMAT"} {
|
||||
if v, ok := set[k]; ok {
|
||||
env = append(env, k+"="+v)
|
||||
}
|
||||
}
|
||||
|
||||
argv = append([]string{vaultBin}, args...)
|
||||
return vaultBin, argv, env, nil
|
||||
}
|
||||
|
||||
// autoFormat picks VAULT_FORMAT from the vault command line: yaml for
|
||||
// `kv get` and dynamic creds (`*/creds/*`), table for `kv list`, else "".
|
||||
// It returns "" when the caller already chose (ambient VAULT_FORMAT, or a
|
||||
// -format/-field flag, since vault formats -field values in non-table modes).
|
||||
func autoFormat(args, baseEnv []string) string {
|
||||
for _, e := range baseEnv {
|
||||
if strings.HasPrefix(e, "VAULT_FORMAT=") && e != "VAULT_FORMAT=" {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
var pos []string
|
||||
for i, a := range args {
|
||||
if a == "--" {
|
||||
pos = append(pos, args[i+1:]...)
|
||||
break
|
||||
}
|
||||
name, _, _ := strings.Cut(strings.TrimLeft(a, "-"), "=")
|
||||
switch {
|
||||
case !strings.HasPrefix(a, "-"):
|
||||
pos = append(pos, a)
|
||||
case name == "format" || name == "field":
|
||||
return ""
|
||||
}
|
||||
}
|
||||
isKV := func(sub string) bool { return len(pos) >= 2 && pos[0] == "kv" && pos[1] == sub }
|
||||
switch {
|
||||
case isKV("list"):
|
||||
return "table"
|
||||
case isKV("get"):
|
||||
return "yaml"
|
||||
}
|
||||
for _, p := range pos {
|
||||
if strings.Contains(p, "/creds/") {
|
||||
return "yaml"
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
+84
-23
@@ -53,16 +53,20 @@ contexts:
|
||||
namespace: staging
|
||||
`
|
||||
|
||||
// lastVal returns the value of the last occurrence of key in an env slice,
|
||||
// mirroring exec's "last assignment wins" semantics.
|
||||
func lastVal(env []string, key string) (string, bool) {
|
||||
val, ok := "", false
|
||||
// envVal returns key's value from an env slice, failing the test if the key
|
||||
// appears more than once (Go's os/exec children honour the first occurrence).
|
||||
func envVal(t *testing.T, env []string, key string) (string, bool) {
|
||||
t.Helper()
|
||||
val, n := "", 0
|
||||
for _, e := range env {
|
||||
if strings.HasPrefix(e, key+"=") {
|
||||
val, ok = e[len(key)+1:], true
|
||||
val, n = e[len(key)+1:], n+1
|
||||
}
|
||||
}
|
||||
return val, ok
|
||||
if n > 1 {
|
||||
t.Errorf("%s appears %d times in env %v", key, n, env)
|
||||
}
|
||||
return val, n > 0
|
||||
}
|
||||
|
||||
func TestBuildInvocationArgsAndEnvOverride(t *testing.T) {
|
||||
@@ -71,10 +75,10 @@ func TestBuildInvocationArgsAndEnvOverride(t *testing.T) {
|
||||
})
|
||||
|
||||
// Ambient VAULT_ADDR/VAULT_TOKEN must be overridden by the context's values.
|
||||
baseEnv := []string{"HOME=/home/x", "VAULT_ADDR=ambient", "VAULT_TOKEN=ambient"}
|
||||
baseEnv := []string{"HOME=/home/x", "VAULT_ADDR=ambient", "VAULT_TOKEN=ambient", "VAULT_NAMESPACE=ambient"}
|
||||
args := []string{"kv", "get", "-field=foo", "secret/x"}
|
||||
|
||||
bin, argv, env, err := buildInvocation("sydney", args, baseEnv)
|
||||
bin, argv, env, err := buildInvocation("sydney", "", args, baseEnv)
|
||||
if err != nil {
|
||||
t.Fatalf("buildInvocation: %v", err)
|
||||
}
|
||||
@@ -86,17 +90,17 @@ func TestBuildInvocationArgsAndEnvOverride(t *testing.T) {
|
||||
if !reflect.DeepEqual(argv, wantArgv) {
|
||||
t.Errorf("argv = %v, want %v", argv, wantArgv)
|
||||
}
|
||||
if v, _ := lastVal(env, "VAULT_ADDR"); v != "https://vault.syd1.au.unkin.net" {
|
||||
if v, _ := envVal(t, env, "VAULT_ADDR"); v != "https://vault.syd1.au.unkin.net" {
|
||||
t.Errorf("VAULT_ADDR = %q, want context override to win", v)
|
||||
}
|
||||
if v, _ := lastVal(env, "VAULT_TOKEN"); v != "s.SYDTOK" {
|
||||
if v, _ := envVal(t, env, "VAULT_TOKEN"); v != "s.SYDTOK" {
|
||||
t.Errorf("VAULT_TOKEN = %q, want s.SYDTOK", v)
|
||||
}
|
||||
if _, ok := lastVal(env, "VAULT_NAMESPACE"); ok {
|
||||
if _, ok := envVal(t, env, "VAULT_NAMESPACE"); ok {
|
||||
t.Error("VAULT_NAMESPACE should be unset for a context with no namespace")
|
||||
}
|
||||
// The ambient HOME must be preserved.
|
||||
if v, _ := lastVal(env, "HOME"); v != "/home/x" {
|
||||
if v, _ := envVal(t, env, "HOME"); v != "/home/x" {
|
||||
t.Errorf("HOME = %q, base env not preserved", v)
|
||||
}
|
||||
}
|
||||
@@ -105,11 +109,11 @@ func TestBuildInvocationNamespaceFromContext(t *testing.T) {
|
||||
setupVctx(t, vctxConfig, map[string]shared.Token{
|
||||
"staging/sydney": {Address: "https://vault-staging.syd1.au.unkin.net", Namespace: "staging", Token: "s.X"},
|
||||
})
|
||||
_, _, env, err := buildInvocation("staging/sydney", []string{"token", "lookup"}, nil)
|
||||
_, _, env, err := buildInvocation("staging/sydney", "", []string{"token", "lookup"}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("buildInvocation: %v", err)
|
||||
}
|
||||
if v, _ := lastVal(env, "VAULT_NAMESPACE"); v != "staging" {
|
||||
if v, _ := envVal(t, env, "VAULT_NAMESPACE"); v != "staging" {
|
||||
t.Errorf("VAULT_NAMESPACE = %q, want staging", v)
|
||||
}
|
||||
}
|
||||
@@ -120,18 +124,18 @@ func TestBuildInvocationNamespaceFallsBackToToken(t *testing.T) {
|
||||
setupVctx(t, vctxConfig, map[string]shared.Token{
|
||||
"sydney": {Address: "https://vault.syd1.au.unkin.net", Namespace: "from-token", Token: "s.X"},
|
||||
})
|
||||
_, _, env, err := buildInvocation("sydney", []string{"status"}, nil)
|
||||
_, _, env, err := buildInvocation("sydney", "", []string{"status"}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("buildInvocation: %v", err)
|
||||
}
|
||||
if v, _ := lastVal(env, "VAULT_NAMESPACE"); v != "from-token" {
|
||||
if v, _ := envVal(t, env, "VAULT_NAMESPACE"); v != "from-token" {
|
||||
t.Errorf("VAULT_NAMESPACE = %q, want from-token fallback", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildInvocationMissingToken(t *testing.T) {
|
||||
setupVctx(t, vctxConfig, nil) // no cached tokens
|
||||
_, _, _, err := buildInvocation("sydney", []string{"status"}, nil)
|
||||
_, _, _, err := buildInvocation("sydney", "", []string{"status"}, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing cached token")
|
||||
}
|
||||
@@ -142,16 +146,16 @@ func TestBuildInvocationMissingToken(t *testing.T) {
|
||||
|
||||
func TestBuildInvocationUnknownContext(t *testing.T) {
|
||||
setupVctx(t, vctxConfig, nil)
|
||||
if _, _, _, err := buildInvocation("nope", []string{"status"}, nil); err == nil {
|
||||
if _, _, _, err := buildInvocation("nope", "", []string{"status"}, nil); err == nil {
|
||||
t.Error("expected error for unknown context")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunVctxValidation(t *testing.T) {
|
||||
if err := runVctx("", []string{"status"}); err == nil || !strings.Contains(err.Error(), "--context is required") {
|
||||
if err := runVctx("", "", []string{"status"}); err == nil || !strings.Contains(err.Error(), "--context is required") {
|
||||
t.Errorf("empty context: got %v", err)
|
||||
}
|
||||
if err := runVctx("sydney", nil); err == nil || !strings.Contains(err.Error(), "no vault arguments") {
|
||||
if err := runVctx("sydney", "", nil); err == nil || !strings.Contains(err.Error(), "no vault arguments") {
|
||||
t.Errorf("no args: got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -170,7 +174,7 @@ func TestRunVctxExecsWithBuiltInvocation(t *testing.T) {
|
||||
}
|
||||
t.Cleanup(func() { execVault = orig })
|
||||
|
||||
if err := runVctx("sydney", []string{"kv", "list", "kv/"}); err != nil {
|
||||
if err := runVctx("sydney", "", []string{"kv", "list", "kv/"}); err != nil {
|
||||
t.Fatalf("runVctx: %v", err)
|
||||
}
|
||||
if gotBin != vault {
|
||||
@@ -179,7 +183,7 @@ func TestRunVctxExecsWithBuiltInvocation(t *testing.T) {
|
||||
if !reflect.DeepEqual(gotArgv, []string{vault, "kv", "list", "kv/"}) {
|
||||
t.Errorf("exec argv = %v", gotArgv)
|
||||
}
|
||||
if v, _ := lastVal(gotEnv, "VAULT_TOKEN"); v != "s.SYDTOK" {
|
||||
if v, _ := envVal(t, gotEnv, "VAULT_TOKEN"); v != "s.SYDTOK" {
|
||||
t.Errorf("exec env VAULT_TOKEN = %q", v)
|
||||
}
|
||||
}
|
||||
@@ -192,8 +196,65 @@ func TestRunVctxSurfacesExecError(t *testing.T) {
|
||||
execVault = func(bin string, argv, env []string) error { return errors.New("boom") }
|
||||
t.Cleanup(func() { execVault = orig })
|
||||
|
||||
err := runVctx("sydney", []string{"status"})
|
||||
err := runVctx("sydney", "", []string{"status"})
|
||||
if err == nil || !strings.Contains(err.Error(), "exec vault") {
|
||||
t.Errorf("expected wrapped exec error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoFormat(t *testing.T) {
|
||||
cases := []struct {
|
||||
args []string
|
||||
env []string
|
||||
want string
|
||||
}{
|
||||
{[]string{"kv", "get", "-mount=secret", "foo"}, nil, "yaml"},
|
||||
{[]string{"read", "aws/creds/role"}, nil, "yaml"},
|
||||
{[]string{"write", "db/creds/x", "ttl=1h"}, nil, "yaml"},
|
||||
{[]string{"kv", "list"}, nil, "table"},
|
||||
{[]string{"kv", "list", "aws/creds/"}, nil, "table"},
|
||||
{[]string{"status"}, nil, ""},
|
||||
{[]string{"kv", "put", "kv/foo", "a=b"}, nil, ""},
|
||||
{[]string{"kv", "get", "-format=json", "kv/foo"}, nil, ""},
|
||||
{[]string{"read", "--format", "json", "aws/creds/role"}, nil, ""},
|
||||
{[]string{"kv", "get", "-field=password", "kv/foo"}, nil, ""},
|
||||
{[]string{"kv", "get", "--", "-format=json"}, nil, "yaml"},
|
||||
{[]string{"kv", "get", "kv/foo"}, []string{"VAULT_FORMAT=json"}, ""},
|
||||
{[]string{"kv", "get", "kv/foo"}, []string{"VAULT_FORMAT="}, "yaml"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := autoFormat(c.args, c.env); got != c.want {
|
||||
t.Errorf("autoFormat(%v, %v) = %q, want %q", c.args, c.env, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildInvocationFormat(t *testing.T) {
|
||||
setupVctx(t, vctxConfig, map[string]shared.Token{
|
||||
"sydney": {Address: "https://vault.syd1.au.unkin.net", Token: "s.X"},
|
||||
})
|
||||
ambient := []string{"VAULT_FORMAT=table"}
|
||||
cases := []struct {
|
||||
flag string
|
||||
args []string
|
||||
base []string
|
||||
want string
|
||||
set bool
|
||||
}{
|
||||
{"", []string{"kv", "get", "kv/foo"}, nil, "yaml", true},
|
||||
{"", []string{"status"}, nil, "", false},
|
||||
{"json", []string{"kv", "get", "kv/foo"}, nil, "json", true},
|
||||
{"json", []string{"status"}, nil, "json", true},
|
||||
{"", []string{"kv", "get", "kv/foo"}, ambient, "table", true},
|
||||
{"json", []string{"kv", "get", "kv/foo"}, ambient, "json", true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
_, _, env, err := buildInvocation("sydney", c.flag, c.args, c.base)
|
||||
if err != nil {
|
||||
t.Fatalf("buildInvocation: %v", err)
|
||||
}
|
||||
if v, ok := envVal(t, env, "VAULT_FORMAT"); v != c.want || ok != c.set {
|
||||
t.Errorf("flag %q args %v base %v: VAULT_FORMAT = %q (set %v), want %q (set %v)", c.flag, c.args, c.base, v, ok, c.want, c.set)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user