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:
@@ -0,0 +1,204 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user