Files
vault-tools/shared/config.go
T
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

205 lines
6.0 KiB
Go

// Package shared holds the plumbing common to the vault-tools CLIs (vctl and
// vctx): config-file parsing, the on-disk token cache, and a small Vault HTTP
// API client. Both tools read the SAME config file and token cache so a single
// ~/.config/vault/ configures every tool in the family.
package shared
import (
"fmt"
"os"
"path/filepath"
"sort"
"gopkg.in/yaml.v3"
)
const (
// DefaultMethod is the auth method used when neither the context nor the
// file-level defaults specify one.
DefaultMethod = "ldap"
// appDir is the per-user config/cache subdirectory both tools live under.
appDir = "vault"
)
// configFileNames are the accepted config file basenames, tried in order. The
// first one that exists wins.
var configFileNames = []string{"vctl.yaml", "config.yaml"}
// Context is a single vault instance the tools can target. Every field except
// Address is optional and, when empty, falls back to the file-level Defaults
// and finally the built-in defaults.
type Context struct {
// Address is the Vault API base URL, e.g. https://vault.syd1.au.unkin.net.
Address string `yaml:"address"`
// Method is the auth method (ldap, userpass, okta, radius, token, ...).
Method string `yaml:"method,omitempty"`
// User is the login username (LDAP/userpass/...); ignored for token auth.
User string `yaml:"user,omitempty"`
// Namespace is the Vault namespace (X-Vault-Namespace) for the context.
Namespace string `yaml:"namespace,omitempty"`
// Path overrides the auth mount path (defaults to Method), e.g. "ldap2".
Path string `yaml:"path,omitempty"`
}
// Defaults holds file-level fallbacks applied to every context that does not
// set its own value.
type Defaults struct {
Method string `yaml:"method,omitempty"`
User string `yaml:"user,omitempty"`
Namespace string `yaml:"namespace,omitempty"`
}
// Config is the parsed config file: file-level defaults plus a map of named
// contexts. Context names may contain slashes (e.g. "staging/sydney"), which
// map to nested cache paths.
type Config struct {
Defaults Defaults `yaml:"defaults"`
Contexts map[string]Context `yaml:"contexts"`
// path records the file this config was loaded from (empty if none found).
path string
}
// ResolvedContext is a Context with all defaults applied, ready to use. Method
// and User are always populated.
type ResolvedContext struct {
Name string
Address string
Method string
User string
Namespace string
Path string
}
// ConfigDir returns the XDG_CONFIG_HOME/vault directory.
func ConfigDir() string {
base := os.Getenv("XDG_CONFIG_HOME")
if base == "" {
home, _ := os.UserHomeDir()
base = filepath.Join(home, ".config")
}
return filepath.Join(base, appDir)
}
// ConfigPath returns the path of the first existing config file, or the path
// the file would take (the first candidate) when none exists yet.
func ConfigPath() string {
dir := ConfigDir()
for _, name := range configFileNames {
p := filepath.Join(dir, name)
if _, err := os.Stat(p); err == nil {
return p
}
}
return filepath.Join(dir, configFileNames[0])
}
// Load reads and parses the first config file found in ConfigDir. A missing
// config file is not an error: an empty Config is returned so callers can give
// a helpful "no contexts configured" message.
func Load() (*Config, error) {
cfg := &Config{Contexts: map[string]Context{}}
dir := ConfigDir()
for _, name := range configFileNames {
p := filepath.Join(dir, name)
data, err := os.ReadFile(p)
if err != nil {
if os.IsNotExist(err) {
continue
}
return cfg, fmt.Errorf("reading config %s: %w", p, err)
}
if err := yaml.Unmarshal(data, cfg); err != nil {
return cfg, fmt.Errorf("parsing config %s: %w", p, err)
}
if cfg.Contexts == nil {
cfg.Contexts = map[string]Context{}
}
cfg.path = p
return cfg, nil
}
return cfg, nil
}
// Path returns the file this config was loaded from, or "" if none was found.
func (c *Config) Path() string { return c.path }
// ContextNames returns the configured context names, sorted. Used to drive
// shell completion for the --context flag / context arguments.
func (c *Config) ContextNames() []string {
names := make([]string, 0, len(c.Contexts))
for n := range c.Contexts {
names = append(names, n)
}
sort.Strings(names)
return names
}
// Resolve looks up a context by name and applies the fallback chain for each
// field: context value < file defaults < built-in default. Method and User are
// guaranteed non-empty in the result (User defaults to $USER).
func (c *Config) Resolve(name string) (ResolvedContext, error) {
ctx, ok := c.Contexts[name]
if !ok {
return ResolvedContext{}, fmt.Errorf("no context %q in %s", name, displayPath(c.path))
}
if ctx.Address == "" {
return ResolvedContext{}, fmt.Errorf("context %q has no address", name)
}
method := firstNonEmpty(ctx.Method, c.Defaults.Method, DefaultMethod)
user := firstNonEmpty(ctx.User, c.Defaults.User, os.Getenv("USER"))
namespace := firstNonEmpty(ctx.Namespace, c.Defaults.Namespace)
path := firstNonEmpty(ctx.Path, method)
return ResolvedContext{
Name: name,
Address: ctx.Address,
Method: method,
User: user,
Namespace: namespace,
Path: path,
}, nil
}
// ResolveWithOverrides is Resolve plus explicit CLI-flag overrides for method
// and user; an empty override leaves the resolved value untouched. When the
// method is overridden and the context did not pin an explicit auth path, the
// auth path follows the new method.
func (c *Config) ResolveWithOverrides(name, method, user string) (ResolvedContext, error) {
rc, err := c.Resolve(name)
if err != nil {
return rc, err
}
if method != "" {
pinnedPath := c.Contexts[name].Path != ""
rc.Method = method
if !pinnedPath {
rc.Path = method
}
}
if user != "" {
rc.User = user
}
return rc, nil
}
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if v != "" {
return v
}
}
return ""
}
func displayPath(p string) string {
if p == "" {
return ConfigPath() + " (not found)"
}
return p
}