vctx: colourise json/yaml output with --color
This commit is contained in:
+27
-3
@@ -8,7 +8,7 @@ invocation only, and execs `vault` with the remaining arguments.
|
||||
## Synopsis
|
||||
|
||||
```
|
||||
vctx --context <context> [--format <fmt>] <any vault args...>
|
||||
vctx --context <context> [--format <fmt>] [--color auto|always|never] <any vault args...>
|
||||
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:
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
+120
-5
@@ -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 <context> <vault args...>",
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+115
-4
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user