diff --git a/docs/vctx.md b/docs/vctx.md index 2d35415..85345a1 100644 --- a/docs/vctx.md +++ b/docs/vctx.md @@ -8,7 +8,7 @@ invocation only, and execs `vault` with the remaining arguments. ## Synopsis ``` -vctx --context [--format ] +vctx --context [--format ] [--color auto|always|never] vctx version ``` @@ -30,7 +30,8 @@ vctx --context sydney kv put kv/foo/bar secret=baz [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. + TTY behaviour pass straight through (json/yaml output on a terminal is + instead run as a child and colourised; see [Colour](#colour)). The ambient `VAULT_ADDR` / `VAULT_TOKEN` / `VAULT_NAMESPACE` in your shell are ignored for the call — `vctx` always targets the chosen context. @@ -50,9 +51,32 @@ 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. +## Colour + +When the effective format is `json` or `yaml` (from the table above, +`--format`, a `-format` vault arg, or `VAULT_FORMAT`), `vctx` colourises vault's +output like `jq`/`yq`. To do so it runs `vault` as a child instead of exec'ing +it: stdin and stderr pass straight through, stdout is buffered and re-emitted +coloured, SIGINT/SIGTERM are forwarded, and vault's exit code is returned +(`128+n` if killed by signal `n`). + +`--color` controls this: + +| Value | Behaviour | +|----------|-----------| +| `auto` | colour only when stdout is a terminal, `NO_COLOR` is unset/empty and `TERM` is not `dumb` (default) | +| `always` | always colour | +| `never` | never colour | + +Without colour (piped output, `NO_COLOR`, `table`/`raw`/`pretty` formats) +`vctx` execs `vault` exactly as before, so scripts see unchanged output. +Streaming or interactive commands (`monitor`, `debug`, `events`, `ssh`, +`agent`, `proxy`, `server`) are always exec'd and never coloured. `JQ_COLORS` +overrides the JSON palette as in `jq`. + ## Flags -`--context` and `--format` are the only flags `vctx` consumes; they must come +`--context`, `--format` and `--color` 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: diff --git a/go.mod b/go.mod index 4e91f6d..daa002e 100644 --- a/go.mod +++ b/go.mod @@ -1,15 +1,16 @@ module git.unkin.net/unkin/vault-tools -go 1.25 +go 1.25.0 require ( + git.unkin.net/unkin/golib v0.2.0 github.com/spf13/cobra v1.10.2 - golang.org/x/term v0.30.0 + golang.org/x/term v0.45.0 gopkg.in/yaml.v3 v3.0.1 ) require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/spf13/pflag v1.0.9 // indirect - golang.org/x/sys v0.31.0 // indirect + golang.org/x/sys v0.47.0 // indirect ) diff --git a/go.sum b/go.sum index 09ec722..5d32f83 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +git.unkin.net/unkin/golib v0.2.0 h1:PrgjEJNhavODOKXdf2WCF2z0eVT+NsB4EoVlX7TOvwE= +git.unkin.net/unkin/golib v0.2.0/go.mod h1:3lVQcPSwJm8ExD64U5edmVmdo9gPICkKJ4pbrT4bulo= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= @@ -7,10 +9,10 @@ github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiT github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= -golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= -golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/vctx/main.go b/vctx/main.go index 93cef0c..853cb26 100644 --- a/vctx/main.go +++ b/vctx/main.go @@ -2,19 +2,25 @@ // // It resolves a context (via the same config + token cache as vcm), sets // VAULT_ADDR / VAULT_TOKEN / VAULT_NAMESPACE for that single invocation, and -// execs `vault` with the remaining arguments: +// execs `vault` with the remaining arguments (or runs it as a child to +// colourise json/yaml output on a terminal): // // vctx --context sydney kv put kv/foo/bar secret=baz // vctx --context staging/sydney token lookup package main import ( + "bytes" + "errors" "fmt" + "io" "os" "os/exec" + "os/signal" "strings" "syscall" + "git.unkin.net/unkin/golib/colour" "git.unkin.net/unkin/vault-tools/shared" "github.com/spf13/cobra" @@ -26,6 +32,13 @@ var version = "dev" // variable so tests can substitute a fake in place of syscall.Exec. var execVault = syscall.Exec +// osExit is swapped in tests to observe the child's exit code. +var osExit = os.Exit + +// streaming commands are never captured: buffering their stdout would hide +// output until exit (or break the TTY, for ssh). +var streaming = map[string]bool{"monitor": true, "debug": true, "events": true, "ssh": true, "agent": true, "proxy": true, "server": true} + func main() { if err := newRootCmd().Execute(); err != nil { os.Exit(1) @@ -33,7 +46,7 @@ func main() { } func newRootCmd() *cobra.Command { - var context, format string + var context, format, color string root := &cobra.Command{ Use: "vctx --context ", @@ -50,7 +63,7 @@ func newRootCmd() *cobra.Command { Args: cobra.ArbitraryArgs, SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - return runVctx(context, format, args) + return runVctx(context, format, color, args) }, } @@ -69,6 +82,9 @@ func newRootCmd() *cobra.Command { 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.Flags().StringVar(&color, "color", "auto", "Colourise json/yaml output: auto, always or never") + _ = root.RegisterFlagCompletionFunc("color", cobra.FixedCompletions( + []string{"auto", "always", "never"}, cobra.ShellCompDirectiveNoFileComp)) root.AddCommand(&cobra.Command{ Use: "version", @@ -82,11 +98,16 @@ func newRootCmd() *cobra.Command { } // runVctx validates inputs, builds the vault invocation for the context, and -// execs it (replacing this process). -func runVctx(context, format string, args []string) error { +// execs it (replacing this process), or runs it as a child and colourises its +// stdout when colour is enabled and the output format is json or yaml. +func runVctx(context, format, color string, args []string) error { if context == "" { return fmt.Errorf("--context is required") } + mode, ok := map[string]colour.Mode{"auto": colour.Auto, "always": colour.Always, "never": colour.Never}[color] + if !ok { + return fmt.Errorf("--color must be auto, always or never, got %q", color) + } if len(args) == 0 { return fmt.Errorf("no vault arguments given (e.g. vctx --context %s kv list kv/)", context) } @@ -95,6 +116,14 @@ func runVctx(context, format string, args []string) error { if err != nil { return err } + if f := colourFormat(args, env); f != "" && colour.Enabled(mode, os.Stdout) { + code, err := runChild(bin, argv, env, f, os.Stdout) + if err != nil { + return err + } + osExit(code) + return nil + } // Replace this process with vault so its exit status, signals and TTY // behaviour pass straight through. if err := execVault(bin, argv, env); err != nil { @@ -204,3 +233,89 @@ func autoFormat(args, baseEnv []string) string { } return "" } + +// colourFormat returns the effective vault output format ("json" or "yaml") +// when it is one we colourise, else "". A -format arg beats VAULT_FORMAT, as in +// vault itself. +func colourFormat(args, env []string) string { + f, cmd := "", "" + for _, e := range env { + if v, ok := strings.CutPrefix(e, "VAULT_FORMAT="); ok { + f = v + } + } + for i := 0; i < len(args); i++ { + a := args[i] + if a == "--" { + if cmd == "" && i+1 < len(args) { + cmd = args[i+1] + } + break + } + if !strings.HasPrefix(a, "-") { + if cmd == "" { + cmd = a + } + continue + } + name, val, hasVal := strings.Cut(strings.TrimLeft(a, "-"), "=") + if name != "format" { + continue + } + if !hasVal && i+1 < len(args) { + i++ + val = args[i] + } + f = val + } + f = strings.ToLower(f) + if streaming[cmd] || (f != "json" && f != "yaml") { + return "" + } + return f +} + +// runChild runs vault with stdin/stderr passed through, forwards SIGINT and +// SIGTERM, then writes its captured stdout colourised to w. It returns vault's +// exit code, or 128+n if it died from signal n. +func runChild(bin string, argv, env []string, format string, w io.Writer) (int, error) { + var out bytes.Buffer + cmd := exec.Command(bin, argv[1:]...) + cmd.Env, cmd.Stdin, cmd.Stdout, cmd.Stderr = env, os.Stdin, &out, os.Stderr + + sigs := make(chan os.Signal, 1) + signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) + defer signal.Stop(sigs) + if err := cmd.Start(); err != nil { + return 0, fmt.Errorf("run vault: %w", err) + } + done := make(chan struct{}) + defer close(done) + go func() { + for { + select { + case s := <-sigs: + _ = cmd.Process.Signal(s) + case <-done: + return + } + } + }() + + err := cmd.Wait() + var ee *exec.ExitError + if err != nil && !errors.As(err, &ee) { + return 0, fmt.Errorf("run vault: %w", err) + } + paint := colour.JSON + if format == "yaml" { + paint = colour.YAML + } + if err := paint(w, out.Bytes()); err != nil { + return 0, err + } + if ws, ok := cmd.ProcessState.Sys().(syscall.WaitStatus); ok && ws.Signaled() { + return 128 + int(ws.Signal()), nil + } + return cmd.ProcessState.ExitCode(), nil +} diff --git a/vctx/main_test.go b/vctx/main_test.go index ab8b056..e56deef 100644 --- a/vctx/main_test.go +++ b/vctx/main_test.go @@ -1,6 +1,7 @@ package main import ( + "bytes" "errors" "os" "path/filepath" @@ -152,10 +153,10 @@ func TestBuildInvocationUnknownContext(t *testing.T) { } func TestRunVctxValidation(t *testing.T) { - if err := runVctx("", "", []string{"status"}); err == nil || !strings.Contains(err.Error(), "--context is required") { + if err := runVctx("", "", "auto", []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", "", "auto", nil); err == nil || !strings.Contains(err.Error(), "no vault arguments") { t.Errorf("no args: got %v", err) } } @@ -174,7 +175,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", "", "auto", []string{"kv", "list", "kv/"}); err != nil { t.Fatalf("runVctx: %v", err) } if gotBin != vault { @@ -196,7 +197,7 @@ 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", "", "auto", []string{"status"}) if err == nil || !strings.Contains(err.Error(), "exec vault") { t.Errorf("expected wrapped exec error, got %v", err) } @@ -258,3 +259,113 @@ func TestBuildInvocationFormat(t *testing.T) { } } } + +func TestColourFormat(t *testing.T) { + cases := []struct { + args []string + env []string + want string + }{ + {[]string{"kv", "get", "kv/foo"}, []string{"VAULT_FORMAT=yaml"}, "yaml"}, + {[]string{"read", "x"}, []string{"VAULT_FORMAT=JSON"}, "json"}, + {[]string{"kv", "list", "kv/"}, []string{"VAULT_FORMAT=table"}, ""}, + {[]string{"read", "x"}, nil, ""}, + {[]string{"read", "x"}, []string{"VAULT_FORMAT=pretty"}, ""}, + {[]string{"read", "-format=raw", "x"}, []string{"VAULT_FORMAT=json"}, ""}, + {[]string{"read", "-format=json", "x"}, nil, "json"}, + {[]string{"read", "--format", "yaml", "x"}, []string{"VAULT_FORMAT=table"}, "yaml"}, + {[]string{"read", "--", "-format=json"}, nil, ""}, + {[]string{"events", "subscribe", "kv*"}, []string{"VAULT_FORMAT=json"}, ""}, + {[]string{"monitor"}, []string{"VAULT_FORMAT=json"}, ""}, + {[]string{"ssh", "-format=json", "host"}, nil, ""}, + } + for _, c := range cases { + if got := colourFormat(c.args, c.env); got != c.want { + t.Errorf("colourFormat(%v, %v) = %q, want %q", c.args, c.env, got, c.want) + } + } +} + +// fakeVault writes a shell script standing in for vault and returns its path. +func fakeVault(t *testing.T, body string) string { + t.Helper() + p := filepath.Join(t.TempDir(), "vault") + if err := os.WriteFile(p, []byte("#!/bin/sh\n"+body+"\n"), 0o755); err != nil { + t.Fatal(err) + } + return p +} + +func TestRunChild(t *testing.T) { + cases := []struct { + name, body, format string + code int + want string + }{ + {"json exit 0", `echo '{"a":1}'`, "json", 0, "\x1b[1;34m\"a\""}, + {"yaml exit 3", "echo 'a: b'; exit 3", "yaml", 3, "\x1b[36ma"}, + {"invalid passes through", "echo 'not json {'; exit 2", "json", 2, "not json {\n"}, + {"signal", "kill -TERM $$", "json", 128 + 15, ""}, + } + for _, c := range cases { + bin := fakeVault(t, c.body) + var out bytes.Buffer + code, err := runChild(bin, []string{bin}, nil, c.format, &out) + if err != nil { + t.Fatalf("%s: %v", c.name, err) + } + if code != c.code { + t.Errorf("%s: code = %d, want %d", c.name, code, c.code) + } + if !strings.Contains(out.String(), c.want) { + t.Errorf("%s: output %q missing %q", c.name, out.String(), c.want) + } + } +} + +func TestRunChildPassesArgsAndEnv(t *testing.T) { + bin := fakeVault(t, `echo "{\"args\":\"$*\",\"tok\":\"$VAULT_TOKEN\"}"`) + var out bytes.Buffer + if _, err := runChild(bin, []string{bin, "kv", "get", "x"}, []string{"VAULT_TOKEN=s.T"}, "json", &out); err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), "kv get x") || !strings.Contains(out.String(), "s.T") { + t.Errorf("output %q", out.String()) + } +} + +func TestRunVctxColourDecision(t *testing.T) { + setupVctx(t, vctxConfig, map[string]shared.Token{ + "sydney": {Address: "https://vault.syd1.au.unkin.net", Token: "s.X"}, + }) + t.Setenv("VAULT_FORMAT", "") + origExec, origExit := execVault, osExit + t.Cleanup(func() { execVault, osExit = origExec, origExit }) + + cases := []struct { + color string + args []string + exec bool + }{ + {"never", []string{"kv", "get", "kv/foo"}, true}, + {"auto", []string{"kv", "get", "kv/foo"}, true}, // stdout is not a TTY under go test + {"always", []string{"kv", "list", "kv/"}, true}, + {"always", []string{"status"}, true}, + {"always", []string{"kv", "get", "kv/foo"}, false}, + {"always", []string{"read", "-format=json", "x"}, false}, + } + for _, c := range cases { + execed, exited := false, -1 + execVault = func(string, []string, []string) error { execed = true; return nil } + osExit = func(code int) { exited = code } + if err := runVctx("sydney", "", c.color, c.args); err != nil { + t.Fatalf("%s %v: %v", c.color, c.args, err) + } + if execed != c.exec || (exited == 0) == c.exec { + t.Errorf("%s %v: exec=%v exit=%d, want exec=%v", c.color, c.args, execed, exited, c.exec) + } + } + if err := runVctx("sydney", "", "rainbow", []string{"status"}); err == nil { + t.Error("expected error for invalid --color") + } +}