Add vctl and vctx Vault token CLIs
Introduces the vault-tools monorepo: two Go CLIs that share a config file (~/.config/vault) and token cache (~/.cache/vault) for working with multiple Vault instances (contexts). - add shared/ library: config parsing (vctl.yaml/config.yaml, per-context overrides, slash contexts), token cache (0600/0700, atomic writes, path- traversal guards), and a small hand-rolled Vault HTTP client (login/renew) - add vctl: login/renew (single or --all), list, --method/--user overrides, no-echo password/token prompts, dynamic context completion - add vctx: resolve a context, set VAULT_ADDR/VAULT_TOKEN/VAULT_NAMESPACE and exec the vault CLI, passing remaining args through untouched - add unit tests across shared/, vctl and vctx command layers (config resolution, cache paths, vault client, --all iteration + error aggregation, vctx arg pass-through and env construction via fakeable exec/prompt seams) - add Makefile (build/test/completions/rpm, patch|minor|major version bumps), nfpm RPM packaging bundling bash/zsh/fish completions for both binaries - add Woodpecker pipelines: build/test/pre-commit on PRs, and a tag release that cross-compiles, builds+uploads the RPM to artifactapi, and cuts a Gitea release (serviceAccountName default, k8s resources on every step) - add README, per-command docs (docs/vctl.md, docs/vctx.md), AGENTS.md and an example config Claude-Session: https://claude.ai/code/session_015ur3i7D2azsMAWTSVABApv
This commit is contained in:
+143
@@ -0,0 +1,143 @@
|
||||
// Command vctx is a thin, context-aware wrapper around the real `vault` CLI.
|
||||
//
|
||||
// It resolves a context (via the same config + token cache as vctl), sets
|
||||
// VAULT_ADDR / VAULT_TOKEN / VAULT_NAMESPACE for that single invocation, and
|
||||
// execs `vault` with the remaining arguments:
|
||||
//
|
||||
// vctx --context sydney kv put kv/foo/bar secret=baz
|
||||
// vctx --context staging/sydney token lookup
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"syscall"
|
||||
|
||||
"git.unkin.net/unkin/vault-tools/shared"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var version = "dev"
|
||||
|
||||
// execVault replaces the current process with the vault binary. It is a package
|
||||
// variable so tests can substitute a fake in place of syscall.Exec.
|
||||
var execVault = syscall.Exec
|
||||
|
||||
func main() {
|
||||
if err := newRootCmd().Execute(); err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func newRootCmd() *cobra.Command {
|
||||
var context 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/vault (shared with vctl), loads its\n" +
|
||||
"cached token from ~/.cache/vault/<context>, sets VAULT_ADDR, VAULT_TOKEN and\n" +
|
||||
"VAULT_NAMESPACE for this invocation only, and execs `vault` with the\n" +
|
||||
"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
|
||||
// meant for vault (e.g. `kv get -field=foo`) are passed through untouched.
|
||||
Args: cobra.ArbitraryArgs,
|
||||
SilenceUsage: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runVctx(context, args)
|
||||
},
|
||||
}
|
||||
|
||||
// Do not treat vault flags interspersed with args as vctx flags: only the
|
||||
// leading --context is ours; everything from the first positional on is the
|
||||
// vault command line.
|
||||
root.Flags().SetInterspersed(false)
|
||||
root.Flags().StringVar(&context, "context", "", "Vault context to target (required)")
|
||||
_ = root.RegisterFlagCompletionFunc("context", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
cfg, err := shared.Load()
|
||||
if err != nil {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
return cfg.ContextNames(), cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
|
||||
root.AddCommand(&cobra.Command{
|
||||
Use: "version",
|
||||
Short: "Print the version",
|
||||
Args: cobra.NoArgs,
|
||||
Run: func(cmd *cobra.Command, args []string) { fmt.Println(version) },
|
||||
SilenceUsage: true,
|
||||
})
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
// runVctx validates inputs, builds the vault invocation for the context, and
|
||||
// execs it (replacing this process).
|
||||
func runVctx(context string, args []string) error {
|
||||
if context == "" {
|
||||
return fmt.Errorf("--context is required")
|
||||
}
|
||||
if len(args) == 0 {
|
||||
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())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Replace this process with vault so its exit status, signals and TTY
|
||||
// behaviour pass straight through.
|
||||
if err := execVault(bin, argv, env); err != nil {
|
||||
return fmt.Errorf("exec vault: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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) {
|
||||
cfg, err := shared.Load()
|
||||
if err != nil {
|
||||
return "", nil, nil, err
|
||||
}
|
||||
rc, err := cfg.Resolve(context)
|
||||
if err != nil {
|
||||
return "", nil, nil, err
|
||||
}
|
||||
tok, err := shared.LoadToken(context)
|
||||
if err != nil {
|
||||
return "", nil, nil, err
|
||||
}
|
||||
|
||||
vaultBin, err := exec.LookPath("vault")
|
||||
if err != nil {
|
||||
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,
|
||||
)
|
||||
// Namespace comes from the context, falling back to what the token was
|
||||
// issued under; only set when non-empty.
|
||||
ns := rc.Namespace
|
||||
if ns == "" {
|
||||
ns = tok.Namespace
|
||||
}
|
||||
if ns != "" {
|
||||
env = append(env, "VAULT_NAMESPACE="+ns)
|
||||
}
|
||||
|
||||
argv = append([]string{vaultBin}, args...)
|
||||
return vaultBin, argv, env, nil
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.unkin.net/unkin/vault-tools/shared"
|
||||
)
|
||||
|
||||
// setupVctx points XDG dirs at temp locations, writes a config file and the
|
||||
// given cached tokens, and puts a fake `vault` binary on PATH. It returns the
|
||||
// fake vault's absolute path.
|
||||
func setupVctx(t *testing.T, cfg string, tokens map[string]shared.Token) string {
|
||||
t.Helper()
|
||||
cfgHome := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", cfgHome)
|
||||
t.Setenv("XDG_CACHE_HOME", t.TempDir())
|
||||
|
||||
cfgDir := filepath.Join(cfgHome, "vault")
|
||||
if err := os.MkdirAll(cfgDir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(cfgDir, "vctl.yaml"), []byte(cfg), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, tok := range tokens {
|
||||
tk := tok
|
||||
tk.Context = name
|
||||
if err := shared.SaveToken(&tk); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
binDir := t.TempDir()
|
||||
vault := filepath.Join(binDir, "vault")
|
||||
if err := os.WriteFile(vault, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
return vault
|
||||
}
|
||||
|
||||
const vctxConfig = `
|
||||
contexts:
|
||||
sydney:
|
||||
address: https://vault.syd1.au.unkin.net
|
||||
staging/sydney:
|
||||
address: https://vault-staging.syd1.au.unkin.net
|
||||
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
|
||||
for _, e := range env {
|
||||
if strings.HasPrefix(e, key+"=") {
|
||||
val, ok = e[len(key)+1:], true
|
||||
}
|
||||
}
|
||||
return val, ok
|
||||
}
|
||||
|
||||
func TestBuildInvocationArgsAndEnvOverride(t *testing.T) {
|
||||
vault := setupVctx(t, vctxConfig, map[string]shared.Token{
|
||||
"sydney": {Address: "https://vault.syd1.au.unkin.net", Token: "s.SYDTOK"},
|
||||
})
|
||||
|
||||
// Ambient VAULT_ADDR/VAULT_TOKEN must be overridden by the context's values.
|
||||
baseEnv := []string{"HOME=/home/x", "VAULT_ADDR=ambient", "VAULT_TOKEN=ambient"}
|
||||
args := []string{"kv", "get", "-field=foo", "secret/x"}
|
||||
|
||||
bin, argv, env, err := buildInvocation("sydney", args, baseEnv)
|
||||
if err != nil {
|
||||
t.Fatalf("buildInvocation: %v", err)
|
||||
}
|
||||
if bin != vault {
|
||||
t.Errorf("bin = %q, want %q", bin, vault)
|
||||
}
|
||||
// argv is vault + the untouched args (vault flags like -field pass through).
|
||||
wantArgv := append([]string{vault}, args...)
|
||||
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" {
|
||||
t.Errorf("VAULT_ADDR = %q, want context override to win", v)
|
||||
}
|
||||
if v, _ := lastVal(env, "VAULT_TOKEN"); v != "s.SYDTOK" {
|
||||
t.Errorf("VAULT_TOKEN = %q, want s.SYDTOK", v)
|
||||
}
|
||||
if _, ok := lastVal(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" {
|
||||
t.Errorf("HOME = %q, base env not preserved", v)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
t.Fatalf("buildInvocation: %v", err)
|
||||
}
|
||||
if v, _ := lastVal(env, "VAULT_NAMESPACE"); v != "staging" {
|
||||
t.Errorf("VAULT_NAMESPACE = %q, want staging", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildInvocationNamespaceFallsBackToToken(t *testing.T) {
|
||||
// Context has no namespace, but the cached token records one — vctx should
|
||||
// fall back to the token's namespace.
|
||||
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)
|
||||
if err != nil {
|
||||
t.Fatalf("buildInvocation: %v", err)
|
||||
}
|
||||
if v, _ := lastVal(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)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing cached token")
|
||||
}
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
t.Errorf("error should wrap os.ErrNotExist, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildInvocationUnknownContext(t *testing.T) {
|
||||
setupVctx(t, vctxConfig, 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") {
|
||||
t.Errorf("empty context: got %v", err)
|
||||
}
|
||||
if err := runVctx("sydney", nil); err == nil || !strings.Contains(err.Error(), "no vault arguments") {
|
||||
t.Errorf("no args: got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunVctxExecsWithBuiltInvocation(t *testing.T) {
|
||||
vault := setupVctx(t, vctxConfig, map[string]shared.Token{
|
||||
"sydney": {Address: "https://vault.syd1.au.unkin.net", Token: "s.SYDTOK"},
|
||||
})
|
||||
|
||||
var gotBin string
|
||||
var gotArgv, gotEnv []string
|
||||
orig := execVault
|
||||
execVault = func(bin string, argv, env []string) error {
|
||||
gotBin, gotArgv, gotEnv = bin, argv, env
|
||||
return nil
|
||||
}
|
||||
t.Cleanup(func() { execVault = orig })
|
||||
|
||||
if err := runVctx("sydney", []string{"kv", "list", "kv/"}); err != nil {
|
||||
t.Fatalf("runVctx: %v", err)
|
||||
}
|
||||
if gotBin != vault {
|
||||
t.Errorf("exec bin = %q, want %q", gotBin, vault)
|
||||
}
|
||||
if !reflect.DeepEqual(gotArgv, []string{vault, "kv", "list", "kv/"}) {
|
||||
t.Errorf("exec argv = %v", gotArgv)
|
||||
}
|
||||
if v, _ := lastVal(gotEnv, "VAULT_TOKEN"); v != "s.SYDTOK" {
|
||||
t.Errorf("exec env VAULT_TOKEN = %q", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunVctxSurfacesExecError(t *testing.T) {
|
||||
setupVctx(t, vctxConfig, map[string]shared.Token{
|
||||
"sydney": {Address: "https://vault.syd1.au.unkin.net", Token: "s.X"},
|
||||
})
|
||||
orig := execVault
|
||||
execVault = func(bin string, argv, env []string) error { return errors.New("boom") }
|
||||
t.Cleanup(func() { execVault = orig })
|
||||
|
||||
err := runVctx("sydney", []string{"status"})
|
||||
if err == nil || !strings.Contains(err.Error(), "exec vault") {
|
||||
t.Errorf("expected wrapped exec error, got %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user