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
205 lines
6.0 KiB
Go
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
|
|
}
|