Files
unkinben 123faf8bbf
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline failed
ci/woodpecker/pr/pre-commit Pipeline was successful
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
2026-07-26 23:28:05 +10:00

200 lines
6.2 KiB
Go

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)
}
}