123faf8bbf
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
189 lines
4.8 KiB
Go
189 lines
4.8 KiB
Go
package shared
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
)
|
|
|
|
// withConfigDir points XDG_CONFIG_HOME at a temp dir and writes the given
|
|
// config file into <tmp>/vault/<name>, returning the temp base.
|
|
func withConfigDir(t *testing.T, name, content string) string {
|
|
t.Helper()
|
|
base := t.TempDir()
|
|
t.Setenv("XDG_CONFIG_HOME", base)
|
|
dir := filepath.Join(base, appDir)
|
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if content != "" {
|
|
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
return base
|
|
}
|
|
|
|
const sampleConfig = `
|
|
defaults:
|
|
method: ldap
|
|
user: ben
|
|
contexts:
|
|
sydney:
|
|
address: https://vault.syd1.au.unkin.net
|
|
staging/sydney:
|
|
address: https://vault-staging.syd1.au.unkin.net
|
|
namespace: staging
|
|
user: svc-ben
|
|
legacy:
|
|
address: https://vault-legacy.example.net
|
|
method: userpass
|
|
path: userpass2
|
|
`
|
|
|
|
func TestLoadAndContextNames(t *testing.T) {
|
|
withConfigDir(t, "vctl.yaml", sampleConfig)
|
|
|
|
cfg, err := Load()
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
got := cfg.ContextNames()
|
|
want := []string{"legacy", "staging/sydney", "sydney"}
|
|
if len(got) != len(want) {
|
|
t.Fatalf("ContextNames = %v, want %v", got, want)
|
|
}
|
|
for i := range want {
|
|
if got[i] != want[i] {
|
|
t.Fatalf("ContextNames[%d] = %q, want %q (%v)", i, got[i], want[i], got)
|
|
}
|
|
}
|
|
if cfg.Path() == "" {
|
|
t.Error("Path() is empty after loading a config")
|
|
}
|
|
}
|
|
|
|
func TestConfigPathPrefersVctlYaml(t *testing.T) {
|
|
base := withConfigDir(t, "vctl.yaml", sampleConfig)
|
|
// also write a config.yaml; vctl.yaml should win
|
|
if err := os.WriteFile(filepath.Join(base, appDir, "config.yaml"), []byte(sampleConfig), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got, want := ConfigPath(), filepath.Join(base, appDir, "vctl.yaml"); got != want {
|
|
t.Errorf("ConfigPath = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestConfigPathFallsBackToConfigYaml(t *testing.T) {
|
|
base := withConfigDir(t, "config.yaml", sampleConfig)
|
|
if got, want := ConfigPath(), filepath.Join(base, appDir, "config.yaml"); got != want {
|
|
t.Errorf("ConfigPath = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestLoadMissingConfigIsNotError(t *testing.T) {
|
|
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
|
cfg, err := Load()
|
|
if err != nil {
|
|
t.Fatalf("Load with no file: %v", err)
|
|
}
|
|
if len(cfg.ContextNames()) != 0 {
|
|
t.Errorf("expected no contexts, got %v", cfg.ContextNames())
|
|
}
|
|
}
|
|
|
|
func TestResolveAppliesDefaults(t *testing.T) {
|
|
withConfigDir(t, "vctl.yaml", sampleConfig)
|
|
cfg, err := Load()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// sydney: inherits method+user from defaults, no namespace, path == method.
|
|
rc, err := cfg.Resolve("sydney")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if rc.Method != "ldap" || rc.User != "ben" || rc.Namespace != "" || rc.Path != "ldap" {
|
|
t.Errorf("sydney resolved = %+v", rc)
|
|
}
|
|
|
|
// staging/sydney: overrides user + namespace, inherits method.
|
|
rc, err = cfg.Resolve("staging/sydney")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if rc.Method != "ldap" || rc.User != "svc-ben" || rc.Namespace != "staging" {
|
|
t.Errorf("staging/sydney resolved = %+v", rc)
|
|
}
|
|
|
|
// legacy: explicit method + custom auth path.
|
|
rc, err = cfg.Resolve("legacy")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if rc.Method != "userpass" || rc.Path != "userpass2" {
|
|
t.Errorf("legacy resolved = %+v", rc)
|
|
}
|
|
}
|
|
|
|
func TestResolveUnknownContext(t *testing.T) {
|
|
withConfigDir(t, "vctl.yaml", sampleConfig)
|
|
cfg, _ := Load()
|
|
if _, err := cfg.Resolve("nope"); err == nil {
|
|
t.Error("expected error for unknown context")
|
|
}
|
|
}
|
|
|
|
func TestResolveMethodDefaultWhenUnset(t *testing.T) {
|
|
withConfigDir(t, "vctl.yaml", `
|
|
contexts:
|
|
bare:
|
|
address: https://vault.example.net
|
|
`)
|
|
t.Setenv("USER", "alice")
|
|
cfg, _ := Load()
|
|
rc, err := cfg.Resolve("bare")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if rc.Method != DefaultMethod {
|
|
t.Errorf("method = %q, want %q", rc.Method, DefaultMethod)
|
|
}
|
|
if rc.User != "alice" {
|
|
t.Errorf("user = %q, want alice ($USER fallback)", rc.User)
|
|
}
|
|
}
|
|
|
|
func TestResolveWithOverrides(t *testing.T) {
|
|
withConfigDir(t, "vctl.yaml", sampleConfig)
|
|
cfg, _ := Load()
|
|
|
|
// Overriding the method also moves the auth path (context did not pin one).
|
|
rc, err := cfg.ResolveWithOverrides("sydney", "okta", "otheruser")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if rc.Method != "okta" || rc.Path != "okta" || rc.User != "otheruser" {
|
|
t.Errorf("override resolved = %+v", rc)
|
|
}
|
|
|
|
// legacy pins path=userpass2, so a method override must NOT change the path.
|
|
rc, err = cfg.ResolveWithOverrides("legacy", "okta", "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if rc.Method != "okta" || rc.Path != "userpass2" {
|
|
t.Errorf("pinned-path override resolved = %+v", rc)
|
|
}
|
|
|
|
// Empty overrides leave resolved values untouched.
|
|
rc, err = cfg.ResolveWithOverrides("sydney", "", "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if rc.Method != "ldap" || rc.User != "ben" {
|
|
t.Errorf("no-op override resolved = %+v", rc)
|
|
}
|
|
}
|