feat(vault-tui): implement KV client and service for managing secrets
- Added internal/vault/client.go for creating a Vault client with configuration settings. - Introduced internal/vault/errors.go to classify Vault API errors for better UI handling. - Created internal/vault/kv.go to manage KV secrets, including listing, reading, writing, and deleting operations. - Implemented internal/vault/mounts.go to list and describe secret engine mounts. - Developed internal/vault/service.go to provide a unified entry point for Vault operations. - Added internal/vault/kv_test.go for comprehensive testing of KV operations. - Introduced internal/ui/toast.go for transient notifications in the UI. - Added renovate.json for dependency management and updates.
This commit is contained in:
@@ -0,0 +1,233 @@
|
||||
// Package cli wires urfave/cli/v3 commands around internal/config,
|
||||
// internal/vault, internal/token, and internal/auth so every operation
|
||||
// (login, list, read, write, status, logout, and the "ui" command that
|
||||
// launches the Bubbletea TUI) is available both from the terminal directly
|
||||
// and, unchanged, from inside the TUI's screens.
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/hashicorp/vault/api"
|
||||
"github.com/urfave/cli/v3"
|
||||
|
||||
"git.morlana.online/f.weber/vault-tui/internal/auth"
|
||||
"git.morlana.online/f.weber/vault-tui/internal/config"
|
||||
"git.morlana.online/f.weber/vault-tui/internal/token"
|
||||
vaultsvc "git.morlana.online/f.weber/vault-tui/internal/vault"
|
||||
)
|
||||
|
||||
// App is the resolved, shared state every command's Action reads from. It
|
||||
// is built once in the root Command's Before hook and threaded through
|
||||
// context.Context (see appKey), which is the idiomatic urfave/cli/v3
|
||||
// pattern since BeforeFunc returns the (possibly-derived) context that
|
||||
// subsequent Action funcs receive.
|
||||
type App struct {
|
||||
ConfigPath string
|
||||
File *config.File
|
||||
Settings *config.Settings
|
||||
Client *api.Client // unauthenticated until EnsureLoggedIn/SetToken
|
||||
Store token.Store
|
||||
|
||||
// Overrides is the flag layer bootstrap resolved Settings with. Kept
|
||||
// around so SwitchProfile can re-run config.Resolve for a different
|
||||
// profile with identical flag/env precedence.
|
||||
Overrides config.Overrides
|
||||
}
|
||||
|
||||
type appKeyType struct{}
|
||||
|
||||
var appKey = appKeyType{}
|
||||
|
||||
func appFrom(ctx context.Context) (*App, error) {
|
||||
a, ok := ctx.Value(appKey).(*App)
|
||||
if !ok || a == nil {
|
||||
return nil, fmt.Errorf("internal error: app not initialized")
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// overridesFromCommand reads the global flags declared in root.go into a
|
||||
// config.Overrides — the "flag" layer of config.Resolve's precedence chain.
|
||||
func overridesFromCommand(cmd *cli.Command) config.Overrides {
|
||||
ov := config.Overrides{
|
||||
Profile: cmd.String("profile"),
|
||||
Address: cmd.String("address"),
|
||||
Namespace: cmd.String("namespace"),
|
||||
Token: cmd.String("token"),
|
||||
CACert: cmd.String("ca-cert"),
|
||||
ClientCert: cmd.String("client-cert"),
|
||||
ClientKey: cmd.String("client-key"),
|
||||
NoEnv: cmd.Bool("no-env"),
|
||||
NoColor: cmd.Bool("no-color"),
|
||||
}
|
||||
if cmd.IsSet("tls-skip-verify") {
|
||||
v := cmd.Bool("tls-skip-verify")
|
||||
ov.SkipVerify = &v
|
||||
}
|
||||
if cmd.IsSet("read-only") {
|
||||
v := cmd.Bool("read-only")
|
||||
ov.ReadOnly = &v
|
||||
} else if cmd.IsSet("write") {
|
||||
v := !cmd.Bool("write")
|
||||
ov.ReadOnly = &v
|
||||
}
|
||||
return ov
|
||||
}
|
||||
|
||||
// bootstrap builds an *App from global flags: loads the config file,
|
||||
// resolves Settings, and builds an (unauthenticated) *api.Client. It does
|
||||
// NOT resolve or attach a token — see EnsureLoggedIn, which most commands
|
||||
// call explicitly so that `vault-tui config init` and similar can run
|
||||
// before any Vault connectivity exists.
|
||||
func bootstrap(cmd *cli.Command) (*App, error) {
|
||||
path, err := config.ResolvePath(cmd.String("config"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// First-run UX: if there is genuinely no config yet, an interactive
|
||||
// session gets an inline wizard instead of a wall of "no profile
|
||||
// configured" errors — but never for `config ...` itself (that
|
||||
// subtree has its own explicit `init`) and never in a non-interactive
|
||||
// context (CI, pipes), where the tool must stay driven entirely by
|
||||
// flags/env instead of blocking on stdin.
|
||||
leaf := cmd.Args().First()
|
||||
if !config.Exists(path) && IsInteractive() && leaf != "config" {
|
||||
if _, err := runWizard(path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
file, err := config.Load(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ov := overridesFromCommand(cmd)
|
||||
settings, err := config.Resolve(file, ov.Profile, ov, config.OSEnviron)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
client, err := vaultsvc.NewClient(settings)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
store, err := storeFor(settings)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &App{ConfigPath: path, File: file, Settings: settings, Client: client, Store: store, Overrides: ov}, nil
|
||||
}
|
||||
|
||||
// SwitchProfile re-resolves Settings for a different profile using the same
|
||||
// flag/env overrides bootstrap ran with, and rebuilds Client and Store to
|
||||
// match — what lets the TUI's profile picker switch profiles in place
|
||||
// instead of requiring a restart. The caller is responsible for driving a
|
||||
// fresh login afterwards; this does not touch authentication state beyond
|
||||
// replacing the (unauthenticated) Client.
|
||||
func (a *App) SwitchProfile(profile string) error {
|
||||
ov := a.Overrides
|
||||
ov.Profile = profile
|
||||
settings, err := config.Resolve(a.File, profile, ov, config.OSEnviron)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
client, err := vaultsvc.NewClient(settings)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
store, err := storeFor(settings)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
a.Settings, a.Client, a.Store = settings, client, store
|
||||
return nil
|
||||
}
|
||||
|
||||
func storeFor(s *config.Settings) (token.Store, error) {
|
||||
switch s.Token.Storage {
|
||||
case "none":
|
||||
return token.NewNoneStore(), nil
|
||||
case "profile":
|
||||
if s.Token.File != "" {
|
||||
return token.NewFileStore(expandHome(s.Token.File)), nil
|
||||
}
|
||||
dir, err := config.StateDir()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return token.NewProfileStore(dir, s.Profile), nil
|
||||
default: // "vault-cli" or unset
|
||||
return token.NewVaultCLIStore()
|
||||
}
|
||||
}
|
||||
|
||||
func expandHome(p string) string {
|
||||
if len(p) >= 2 && p[:2] == "~/" {
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
return home + p[1:]
|
||||
}
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// EnsureLoggedIn resolves a token via internal/token.Resolve (flag > env >
|
||||
// config > store), validates it with token.Lookup, and sets it on a.Client.
|
||||
// If no token can be resolved at all, it returns a plain error telling the
|
||||
// caller to run `vault-tui login` — headless commands never block on stdin
|
||||
// here; interactive login is `login`'s job, not every command's.
|
||||
func (a *App) EnsureLoggedIn(ctx context.Context, flagToken string) (*token.Info, error) {
|
||||
envTok, _ := config.OSEnviron(config.EnvToken)
|
||||
resolved, err := token.Resolve(ctx, token.Options{
|
||||
Flag: flagToken,
|
||||
Env: envTok,
|
||||
Profile: a.File.Profiles[a.Settings.Profile],
|
||||
Store: a.Store,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resolved.Token == "" {
|
||||
return nil, fmt.Errorf("no Vault token found (checked --token, VAULT_TOKEN, and %s) — run `vault-tui login`", a.Store.Kind())
|
||||
}
|
||||
a.Client.SetToken(resolved.Token)
|
||||
|
||||
info, err := token.Lookup(ctx, a.Client)
|
||||
if err != nil {
|
||||
a.Client.ClearToken()
|
||||
return nil, fmt.Errorf("resolved a token via %s but Vault rejected it: %w", resolved.Origin, err)
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// Service builds the KV-facing Service around an already-authenticated
|
||||
// client. Call after EnsureLoggedIn.
|
||||
func (a *App) Service() *vaultsvc.Service {
|
||||
return vaultsvc.New(a.Client, a.Settings.ReadOnly)
|
||||
}
|
||||
|
||||
// Method resolves an auth.Method by name, special-casing "oidc" so that
|
||||
// per-profile OIDC settings (port, callback host, timeouts — see
|
||||
// config.OIDCOpts) are honoured even though the globally registered "oidc"
|
||||
// entry (for picker/help listing) only carries library defaults.
|
||||
func (a *App) Method(name string) (auth.Method, error) {
|
||||
if name == "" {
|
||||
name = a.Settings.Auth.Method
|
||||
}
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("no auth method configured for profile %q (set auth.method or pass -method)", a.Settings.Profile)
|
||||
}
|
||||
if name == "oidc" {
|
||||
return a.oidcMethod(), nil
|
||||
}
|
||||
m, ok := auth.Default().Get(name)
|
||||
if !ok {
|
||||
if reason, unavail := auth.Default().Unavailable(name); unavail {
|
||||
return nil, fmt.Errorf("auth method %q is unavailable in this build: %s", name, reason)
|
||||
}
|
||||
return nil, fmt.Errorf("unknown auth method %q", name)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/urfave/cli/v3"
|
||||
|
||||
"git.morlana.online/f.weber/vault-tui/internal/config"
|
||||
)
|
||||
|
||||
func configCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "config",
|
||||
Usage: "manage the vault-tui config file",
|
||||
Commands: []*cli.Command{
|
||||
{
|
||||
Name: "init",
|
||||
Usage: "interactively create a new config file",
|
||||
Action: func(ctx context.Context, cmd *cli.Command) error {
|
||||
a, err := appFrom(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if config.Exists(a.ConfigPath) && !askOverwrite(a.ConfigPath) {
|
||||
return nil
|
||||
}
|
||||
if !IsInteractive() {
|
||||
return fmt.Errorf("config init requires an interactive terminal")
|
||||
}
|
||||
_, err = runWizard(a.ConfigPath)
|
||||
return err
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "path",
|
||||
Usage: "print the resolved config file path",
|
||||
Action: func(ctx context.Context, cmd *cli.Command) error {
|
||||
a, err := appFrom(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println(a.ConfigPath)
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "show",
|
||||
Usage: "print the fully resolved settings for the active profile",
|
||||
Flags: []cli.Flag{&cli.BoolFlag{Name: "json"}},
|
||||
Action: func(ctx context.Context, cmd *cli.Command) error {
|
||||
a, err := appFrom(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cmd.Bool("json") {
|
||||
return printJSON(os.Stdout, a.Settings)
|
||||
}
|
||||
s := a.Settings
|
||||
out := map[string]string{
|
||||
"profile": s.Profile,
|
||||
"address": s.Address,
|
||||
"namespace": s.Namespace,
|
||||
"read_only": fmt.Sprint(s.ReadOnly),
|
||||
"auth.method": s.Auth.Method,
|
||||
"auth.mount": s.Auth.Mount,
|
||||
"tls.ca_cert": s.CACert,
|
||||
"tls.skip_verify": fmt.Sprint(s.SkipVerify),
|
||||
}
|
||||
printKV(os.Stdout, out)
|
||||
return nil
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func askOverwrite(path string) bool {
|
||||
fmt.Fprintf(os.Stderr, "%s already exists. Overwrite? (y/N): ", path)
|
||||
var resp string
|
||||
fmt.Fscanln(os.Stdin, &resp)
|
||||
return resp == "y" || resp == "yes"
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/urfave/cli/v3"
|
||||
|
||||
"git.morlana.online/f.weber/vault-tui/internal/vault"
|
||||
)
|
||||
|
||||
func deleteCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "delete",
|
||||
Usage: "delete a secret (soft-delete for KV v2; irreversible for KV v1/cubbyhole)",
|
||||
ArgsUsage: "<path>",
|
||||
Flags: []cli.Flag{
|
||||
&cli.BoolFlag{Name: "destroy", Usage: "KV v2: permanently destroy instead of soft-delete (irreversible)"},
|
||||
&cli.BoolFlag{Name: "metadata", Usage: "KV v2: delete all versions and metadata (irreversible)"},
|
||||
&cli.IntSliceFlag{Name: "versions", Usage: "KV v2: specific versions to target (default: current)"},
|
||||
},
|
||||
Action: func(ctx context.Context, cmd *cli.Command) error {
|
||||
a, err := appFrom(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cmd.Args().Len() == 0 {
|
||||
return fmt.Errorf("usage: vault-tui delete <path>")
|
||||
}
|
||||
if _, err := a.EnsureLoggedIn(ctx, cmd.String("token")); err != nil {
|
||||
return err
|
||||
}
|
||||
svc := a.Service()
|
||||
if svc.ReadOnly {
|
||||
return fmt.Errorf("refusing to delete: vault-tui is in read-only mode (pass --write to override)")
|
||||
}
|
||||
mount, rel, err := splitMountPath(ctx, svc, cmd.Args().First())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
op := vault.OpSoftDelete
|
||||
if mount.Kind != vault.EngineKVv2 {
|
||||
op = vault.OpDeleteV1
|
||||
} else if cmd.Bool("metadata") {
|
||||
op = vault.OpDeleteMetadata
|
||||
} else if cmd.Bool("destroy") {
|
||||
op = vault.OpDestroy
|
||||
}
|
||||
|
||||
ack, err := svc.KV.Delete(ctx, mount, rel, op, cmd.IntSlice("versions"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("deleted %s (op=%v)\n", ack.Path, ack.Op)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.morlana.online/f.weber/vault-tui/internal/vault"
|
||||
)
|
||||
|
||||
// splitMountPath finds which configured mount a full logical path like
|
||||
// "secret/team/prod/db" belongs to, and returns the mount plus the
|
||||
// remaining path relative to it ("team/prod/db"). Mirrors how `vault kv`
|
||||
// itself resolves a bare path against sys/internal/ui/mounts.
|
||||
func splitMountPath(ctx context.Context, svc *vault.Service, full string) (vault.Mount, string, error) {
|
||||
full = strings.TrimPrefix(full, "/")
|
||||
mounts, err := svc.Mounts(ctx)
|
||||
if err != nil {
|
||||
return vault.Mount{}, "", fmt.Errorf("listing mounts: %w", err)
|
||||
}
|
||||
var best vault.Mount
|
||||
for _, m := range mounts {
|
||||
mp := strings.TrimSuffix(m.Path, "/")
|
||||
if full == mp || strings.HasPrefix(full, mp+"/") {
|
||||
if len(mp) > len(strings.TrimSuffix(best.Path, "/")) {
|
||||
best = m
|
||||
}
|
||||
}
|
||||
}
|
||||
if best.Path == "" {
|
||||
return vault.Mount{}, "", fmt.Errorf("no configured mount matches %q", full)
|
||||
}
|
||||
if !best.Supported() {
|
||||
return vault.Mount{}, "", fmt.Errorf("mount %q (type %s) is not supported by vault-tui", best.Path, best.Type)
|
||||
}
|
||||
rel := strings.TrimPrefix(full, strings.TrimSuffix(best.Path, "/"))
|
||||
rel = strings.TrimPrefix(rel, "/")
|
||||
return best, rel, nil
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
func listCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "list",
|
||||
Aliases: []string{"ls"},
|
||||
Usage: "list secrets under a path (e.g. `vault-tui list secret/team/`)",
|
||||
ArgsUsage: "<path>",
|
||||
Flags: []cli.Flag{&cli.BoolFlag{Name: "json"}},
|
||||
Action: func(ctx context.Context, cmd *cli.Command) error {
|
||||
a, err := appFrom(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cmd.Args().Len() == 0 {
|
||||
return fmt.Errorf("usage: vault-tui list <path>")
|
||||
}
|
||||
if _, err := a.EnsureLoggedIn(ctx, cmd.String("token")); err != nil {
|
||||
return err
|
||||
}
|
||||
svc := a.Service()
|
||||
mount, rel, err := splitMountPath(ctx, svc, cmd.Args().First())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
l, err := svc.KV.List(ctx, mount, rel)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cmd.Bool("json") {
|
||||
return printJSON(os.Stdout, l)
|
||||
}
|
||||
for _, d := range l.Dirs {
|
||||
fmt.Println(d)
|
||||
}
|
||||
for _, f := range l.Leaves {
|
||||
fmt.Println(f)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/urfave/cli/v3"
|
||||
|
||||
"git.morlana.online/f.weber/vault-tui/internal/auth"
|
||||
)
|
||||
|
||||
func loginCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "login",
|
||||
Usage: "authenticate to Vault and save the resulting token",
|
||||
ArgsUsage: "[key=value ...]",
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{Name: "method", Usage: "auth method name (default: profile's auth.method)"},
|
||||
&cli.StringFlag{Name: "mount", Usage: "auth mount path (default: profile's auth.mount, or the method's default)"},
|
||||
&cli.BoolFlag{Name: "no-store", Usage: "print the token instead of saving it"},
|
||||
},
|
||||
Action: func(ctx context.Context, cmd *cli.Command) error {
|
||||
a, err := appFrom(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m, err := a.Method(cmd.String("method"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mount := cmd.String("mount")
|
||||
if mount == "" {
|
||||
mount = a.Settings.Auth.Mount
|
||||
}
|
||||
if mount == "" {
|
||||
mount = m.DefaultMount()
|
||||
}
|
||||
|
||||
cliArgs, err := parseKVArgs(cmd.Args().Slice())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
creds := auth.Prefill(m, auth.PrefillSource{
|
||||
CLIArgs: cliArgs,
|
||||
ConfigArgs: a.Settings.Auth.Params,
|
||||
})
|
||||
|
||||
if missing := auth.Missing(m, creds); len(missing) > 0 {
|
||||
if !IsInteractive() {
|
||||
names := make([]string, 0, len(missing))
|
||||
for _, f := range missing {
|
||||
names = append(names, f.Name)
|
||||
}
|
||||
return fmt.Errorf("missing required credentials for method %q: %v (set via VAULT_TUI_AUTH_<NAME>, a method-specific env var, key=value args, or run interactively)", m.Name(), names)
|
||||
}
|
||||
if err := promptMissing(m, creds); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := auth.Validate(m, creds); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
events := make(chan auth.Event, 16)
|
||||
go drainEvents(events)
|
||||
req := auth.Request{Mount: mount, Namespace: a.Settings.Namespace, Creds: creds, Events: events}
|
||||
|
||||
sec, err := m.Login(ctx, a.Client, req)
|
||||
close(events)
|
||||
if err != nil {
|
||||
return fmt.Errorf("login failed: %w", err)
|
||||
}
|
||||
result, err := auth.NewResult(sec, a.Settings.Namespace)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if cmd.Bool("no-store") {
|
||||
fmt.Println(result.Token)
|
||||
return nil
|
||||
}
|
||||
if err := a.Store.Store(ctx, result.Token); err != nil {
|
||||
return fmt.Errorf("saving token to %s: %w", a.Store.Location(), err)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "Success! Token saved via %s (%s).\n", a.Store.Kind(), a.Store.Location())
|
||||
if result.TTL > 0 {
|
||||
fmt.Fprintf(os.Stderr, "token ttl: %s policies: %v\n", result.TTL, result.Policies)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// drainEvents prints login progress (the OIDC URL, Okta push prompts, ...)
|
||||
// to stderr as it arrives. Used by every headless command that can trigger
|
||||
// a multi-step login; the TUI instead pumps these into its own model (see
|
||||
// internal/ui/app — not yet wired here).
|
||||
func drainEvents(events <-chan auth.Event) {
|
||||
for e := range events {
|
||||
switch e.Kind {
|
||||
case auth.EventOpenURL:
|
||||
fmt.Fprintf(os.Stderr, "\nOpen this URL to continue:\n\n %s\n\n", e.URL)
|
||||
case auth.EventWarning:
|
||||
fmt.Fprintf(os.Stderr, "warning: %s\n", e.Message)
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "%s\n", e.Message)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/urfave/cli/v3"
|
||||
|
||||
"git.morlana.online/f.weber/vault-tui/internal/token"
|
||||
)
|
||||
|
||||
func logoutCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "logout",
|
||||
Usage: "forget the saved token (does not revoke it in Vault unless -revoke is given)",
|
||||
Flags: []cli.Flag{
|
||||
&cli.BoolFlag{Name: "revoke", Usage: "also revoke the token in Vault"},
|
||||
},
|
||||
Action: func(ctx context.Context, cmd *cli.Command) error {
|
||||
a, err := appFrom(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
revoke := cmd.Bool("revoke")
|
||||
if p := a.File.Profiles[a.Settings.Profile]; p != nil && p.Token.RevokeOnLogout != nil {
|
||||
revoke = revoke || *p.Token.RevokeOnLogout
|
||||
}
|
||||
if revoke {
|
||||
if _, err := a.EnsureLoggedIn(ctx, cmd.String("token")); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "warning: could not verify token before revoking: %v\n", err)
|
||||
}
|
||||
if err := token.RevokeSelf(ctx, a.Client, a.Store); err != nil {
|
||||
return fmt.Errorf("revoking token: %w", err)
|
||||
}
|
||||
fmt.Println("Token revoked and forgotten.")
|
||||
return nil
|
||||
}
|
||||
if err := a.Store.Erase(ctx); err != nil {
|
||||
return fmt.Errorf("erasing token from %s: %w", a.Store.Location(), err)
|
||||
}
|
||||
fmt.Println("Token forgotten (not revoked in Vault).")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"git.morlana.online/f.weber/vault-tui/internal/auth"
|
||||
"git.morlana.online/f.weber/vault-tui/internal/auth/oidc"
|
||||
)
|
||||
|
||||
// init registers a default-configured OIDC method so it appears in
|
||||
// auth.Default() for listing/help purposes. app.Method special-cases
|
||||
// "oidc" to build a fresh oidc.Method from the profile's actual settings
|
||||
// (see oidcMethod below) instead of using this registered instance, because
|
||||
// OIDC's port/callback/timeout are meaningfully per-profile.
|
||||
func init() {
|
||||
auth.Register(oidc.Method{})
|
||||
}
|
||||
|
||||
func (a *App) oidcMethod() auth.Method {
|
||||
o := a.Settings.Auth.OIDC
|
||||
cfg := oidc.Config{
|
||||
Mount: a.Settings.Auth.Mount,
|
||||
Role: a.Settings.Auth.Params["role"],
|
||||
ListenAddress: o.ListenAddress,
|
||||
CallbackMethod: o.CallbackMethod,
|
||||
CallbackHost: o.CallbackHost,
|
||||
CallbackPath: o.CallbackPath,
|
||||
BrowserCommand: a.Settings.BrowserCommand,
|
||||
}
|
||||
if o.Port != nil {
|
||||
cfg.Port = *o.Port
|
||||
}
|
||||
if o.CallbackPort != nil {
|
||||
cfg.CallbackPort = *o.CallbackPort
|
||||
}
|
||||
if o.SkipBrowser != nil {
|
||||
cfg.SkipBrowser = *o.SkipBrowser
|
||||
}
|
||||
if o.AbortOnBrowserError != nil {
|
||||
cfg.AbortOnBrowserError = *o.AbortOnBrowserError
|
||||
}
|
||||
if o.Timeout != nil {
|
||||
cfg.Timeout = *o.Timeout
|
||||
}
|
||||
if cfg.Mount == "" {
|
||||
cfg.Mount = "oidc"
|
||||
}
|
||||
return oidc.Method{Cfg: cfg}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// printJSON is the shared -format=json path for every headless command.
|
||||
func printJSON(w io.Writer, v interface{}) error {
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(v)
|
||||
}
|
||||
|
||||
// printKV renders a map as an aligned "key value" table, sorted by key.
|
||||
// Used by `status` and `read` in their default (non-JSON) output.
|
||||
func printKV(w io.Writer, m map[string]string) {
|
||||
keys := make([]string, 0, len(m))
|
||||
width := 0
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
if len(k) > width {
|
||||
width = len(k)
|
||||
}
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
fmt.Fprintf(w, "%-*s %s\n", width, k, m[k])
|
||||
}
|
||||
}
|
||||
|
||||
// parseKVArgs turns ["key=value", "other=1"] positional args into a map,
|
||||
// used by `write` and as auth method params on `login`.
|
||||
func parseKVArgs(args []string) (map[string]string, error) {
|
||||
out := map[string]string{}
|
||||
for _, a := range args {
|
||||
k, v, ok := strings.Cut(a, "=")
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("expected key=value, got %q", a)
|
||||
}
|
||||
out[k] = v
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/term"
|
||||
|
||||
"git.morlana.online/f.weber/vault-tui/internal/auth"
|
||||
)
|
||||
|
||||
// promptMissing fills in any of m's still-missing required fields by
|
||||
// prompting on the controlling TTY. It never blocks when stdin is not a
|
||||
// terminal (e.g. CI/pipes) — callers should check IsInteractive first and
|
||||
// treat a non-empty Missing() list as a hard error in that case instead of
|
||||
// calling this.
|
||||
func promptMissing(m auth.Method, creds auth.Credentials) error {
|
||||
missing := auth.Missing(m, creds)
|
||||
if len(missing) == 0 {
|
||||
return nil
|
||||
}
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
for _, f := range missing {
|
||||
if f.Kind == auth.FieldSecret {
|
||||
v, err := readPassword(f.Label)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
creds[f.Name] = v
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "%s: ", f.Label)
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading %s: %w", f.Label, err)
|
||||
}
|
||||
creds[f.Name] = strings.TrimSpace(line)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readPassword(label string) (string, error) {
|
||||
fmt.Fprintf(os.Stderr, "%s (will be hidden): ", label)
|
||||
b, err := term.ReadPassword(int(os.Stdin.Fd()))
|
||||
fmt.Fprintln(os.Stderr)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("reading %s: %w", label, err)
|
||||
}
|
||||
return strings.TrimSpace(string(b)), nil
|
||||
}
|
||||
|
||||
// IsInteractive reports whether stdin is a terminal we can prompt on.
|
||||
func IsInteractive() bool {
|
||||
return term.IsTerminal(int(os.Stdin.Fd()))
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
func readCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "read",
|
||||
Usage: "read a secret (e.g. `vault-tui read secret/team/prod/db`)",
|
||||
ArgsUsage: "<path>",
|
||||
Flags: []cli.Flag{
|
||||
&cli.BoolFlag{Name: "json"},
|
||||
&cli.IntFlag{Name: "version", Usage: "KV v2 version to read (default: latest)"},
|
||||
&cli.StringFlag{Name: "field", Usage: "print only this field's raw value"},
|
||||
},
|
||||
Action: func(ctx context.Context, cmd *cli.Command) error {
|
||||
a, err := appFrom(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cmd.Args().Len() == 0 {
|
||||
return fmt.Errorf("usage: vault-tui read <path>")
|
||||
}
|
||||
if _, err := a.EnsureLoggedIn(ctx, cmd.String("token")); err != nil {
|
||||
return err
|
||||
}
|
||||
svc := a.Service()
|
||||
mount, rel, err := splitMountPath(ctx, svc, cmd.Args().First())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sec, err := svc.KV.Read(ctx, mount, rel, int(cmd.Int("version")))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if sec == nil {
|
||||
return fmt.Errorf("no secret found at %s", cmd.Args().First())
|
||||
}
|
||||
if field := cmd.String("field"); field != "" {
|
||||
v, ok := sec.Data[field]
|
||||
if !ok {
|
||||
return fmt.Errorf("field %q not present", field)
|
||||
}
|
||||
fmt.Println(v)
|
||||
return nil
|
||||
}
|
||||
if cmd.Bool("json") {
|
||||
return printJSON(os.Stdout, sec)
|
||||
}
|
||||
out := map[string]string{}
|
||||
for k, v := range sec.Data {
|
||||
out[k] = fmt.Sprint(v)
|
||||
}
|
||||
printKV(os.Stdout, out)
|
||||
if sec.Version > 0 {
|
||||
fmt.Printf("\nversion: %d\n", sec.Version)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
// Root builds the full command tree. DefaultCommand launches the TUI when
|
||||
// vault-tui is invoked with no subcommand, matching the plan's "possible
|
||||
// without any subcommand" requirement.
|
||||
func Root(version string) *cli.Command {
|
||||
root := &cli.Command{
|
||||
Name: "vault-tui",
|
||||
Usage: "Terminal UI for HashiCorp Vault",
|
||||
Version: version,
|
||||
DefaultCommand: "ui",
|
||||
// Deliberately no Sources: cli.EnvVars(...) here: internal/config.Resolve
|
||||
// (and config.ResolvePath/SelectProfile for --config/--profile) already
|
||||
// implements the full flag>env>profile>defaults>builtin chain with
|
||||
// correct per-field Origin tracking. Letting urfave/cli additionally
|
||||
// populate these flags from the same env vars would make cmd.IsSet()
|
||||
// indistinguishable between "user passed --address" and "VAULT_ADDR is
|
||||
// set", corrupting both precedence and the status bar's Origin display.
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{Name: "config", Usage: "path to config.yaml"},
|
||||
&cli.StringFlag{Name: "profile", Aliases: []string{"p"}},
|
||||
&cli.StringFlag{Name: "address"},
|
||||
&cli.StringFlag{Name: "namespace"},
|
||||
&cli.StringFlag{Name: "token"},
|
||||
&cli.StringFlag{Name: "ca-cert"},
|
||||
&cli.StringFlag{Name: "client-cert"},
|
||||
&cli.StringFlag{Name: "client-key"},
|
||||
&cli.BoolFlag{Name: "tls-skip-verify"},
|
||||
&cli.BoolFlag{Name: "read-only", Usage: "refuse all write operations"},
|
||||
&cli.BoolFlag{Name: "write", Usage: "allow write operations (overrides a read-only default)"},
|
||||
&cli.BoolFlag{Name: "no-env", Usage: "ignore VAULT_* environment variables"},
|
||||
&cli.BoolFlag{Name: "no-color"},
|
||||
},
|
||||
Before: func(ctx context.Context, cmd *cli.Command) (context.Context, error) {
|
||||
a, err := bootstrap(cmd)
|
||||
if err != nil {
|
||||
return ctx, err
|
||||
}
|
||||
return context.WithValue(ctx, appKey, a), nil
|
||||
},
|
||||
Commands: []*cli.Command{
|
||||
uiCommand(),
|
||||
loginCommand(),
|
||||
logoutCommand(),
|
||||
statusCommand(),
|
||||
listCommand(),
|
||||
readCommand(),
|
||||
writeCommand(),
|
||||
deleteCommand(),
|
||||
configCommand(),
|
||||
},
|
||||
}
|
||||
return root
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
func statusCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "status",
|
||||
Usage: "show the resolved profile, address, and token status",
|
||||
Flags: []cli.Flag{
|
||||
&cli.BoolFlag{Name: "json"},
|
||||
},
|
||||
Action: func(ctx context.Context, cmd *cli.Command) error {
|
||||
a, err := appFrom(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s := a.Settings
|
||||
out := map[string]string{
|
||||
"profile": s.Profile,
|
||||
"address": fmt.Sprintf("%s (%s)", s.Address, s.OriginOf("address").Key),
|
||||
"namespace": valueOr(s.Namespace, "(none)"),
|
||||
"read_only": fmt.Sprint(s.ReadOnly),
|
||||
"auth_method": valueOr(s.Auth.Method, "(unset)"),
|
||||
"token_store": fmt.Sprintf("%s (%s)", a.Store.Kind(), a.Store.Location()),
|
||||
}
|
||||
|
||||
info, err := a.EnsureLoggedIn(ctx, cmd.String("token"))
|
||||
if err != nil {
|
||||
out["token"] = fmt.Sprintf("none/invalid: %v", err)
|
||||
if cmd.Bool("json") {
|
||||
return printJSON(os.Stdout, out)
|
||||
}
|
||||
printKV(os.Stdout, out)
|
||||
return nil
|
||||
}
|
||||
out["token_accessor"] = info.Accessor
|
||||
out["token_policies"] = fmt.Sprint(info.Policies)
|
||||
out["token_ttl"] = info.TTL.String()
|
||||
out["token_renewable"] = fmt.Sprint(info.Renewable)
|
||||
|
||||
if cmd.Bool("json") {
|
||||
return printJSON(os.Stdout, out)
|
||||
}
|
||||
printKV(os.Stdout, out)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func valueOr(v, fallback string) string {
|
||||
if v == "" {
|
||||
return fallback
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
// uiCommand launches the Bubbletea TUI. Implemented in internal/ui; wired
|
||||
// up once that package exists (see runUI, set from cmd/vault-tui/main.go's
|
||||
// init-time hook to avoid internal/cli importing the TUI toolkit itself and
|
||||
// thus keeping headless commands buildable/testable without it).
|
||||
var runUI func(ctx context.Context, a *App) error
|
||||
|
||||
func uiCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "ui",
|
||||
Usage: "launch the terminal UI (default when no subcommand is given)",
|
||||
Action: func(ctx context.Context, cmd *cli.Command) error {
|
||||
a, err := appFrom(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if runUI == nil {
|
||||
return fmt.Errorf("the TUI is not available in this build")
|
||||
}
|
||||
return runUI(ctx, a)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// SetUIRunner lets cmd/vault-tui wire the real internal/ui implementation
|
||||
// into the "ui" command without internal/cli importing a TUI toolkit.
|
||||
func SetUIRunner(f func(ctx context.Context, a *App) error) { runUI = f }
|
||||
@@ -0,0 +1,107 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"git.morlana.online/f.weber/vault-tui/internal/auth"
|
||||
"git.morlana.online/f.weber/vault-tui/internal/config"
|
||||
)
|
||||
|
||||
// runWizard interactively builds one profile and writes it to path. It is
|
||||
// deliberately generic — it prompts for everything (address, namespace,
|
||||
// TLS, auth method and that method's fields) rather than assuming any
|
||||
// particular Vault deployment, per this project's design goal of not
|
||||
// hard-coding any environment's specifics into the tool itself.
|
||||
func runWizard(path string) (*config.File, error) {
|
||||
r := bufio.NewReader(os.Stdin)
|
||||
fmt.Fprintln(os.Stderr, "No config found — let's set up a Vault profile (you can edit it later at "+path+").")
|
||||
|
||||
profileName := ask(r, "Profile name", "default")
|
||||
addrDefault := "https://127.0.0.1:8200"
|
||||
if v, ok := config.OSEnviron(config.EnvAddress); ok && v != "" {
|
||||
addrDefault = v
|
||||
}
|
||||
address := ask(r, "Vault address", addrDefault)
|
||||
namespace := ask(r, "Namespace (blank for none)", "")
|
||||
|
||||
prof := &config.Profile{Address: address, Namespace: namespace}
|
||||
|
||||
if askYesNo(r, "Configure custom TLS (CA cert / client cert)?", false) {
|
||||
prof.TLS.CACert = ask(r, "CA certificate path (blank to use system trust)", "")
|
||||
prof.TLS.ClientCert = ask(r, "Client certificate path (blank for none)", "")
|
||||
if prof.TLS.ClientCert != "" {
|
||||
prof.TLS.ClientKey = ask(r, "Client key path", "")
|
||||
}
|
||||
if askYesNo(r, "Skip TLS verification (insecure, testing only)?", false) {
|
||||
t := true
|
||||
prof.TLS.SkipVerify = &t
|
||||
}
|
||||
}
|
||||
|
||||
names := auth.Default().Names()
|
||||
fmt.Fprintln(os.Stderr, "Available auth methods: "+strings.Join(names, ", "))
|
||||
method := ask(r, "Auth method", "oidc")
|
||||
m, ok := auth.Default().Get(method)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unknown auth method %q", method)
|
||||
}
|
||||
prof.Auth.Method = method
|
||||
mount := ask(r, "Auth mount path", m.DefaultMount())
|
||||
prof.Auth.Mount = mount
|
||||
|
||||
params := map[string]string{}
|
||||
for _, f := range m.Fields() {
|
||||
if f.Kind == auth.FieldSecret {
|
||||
// Secrets are never written to the config file; the user supplies
|
||||
// them at login time (env var, flag, or interactive prompt).
|
||||
continue
|
||||
}
|
||||
v := ask(r, f.Label, f.Default)
|
||||
if v != "" {
|
||||
params[f.Name] = v
|
||||
}
|
||||
}
|
||||
if len(params) > 0 {
|
||||
prof.Auth.Params = params
|
||||
}
|
||||
|
||||
file := &config.File{
|
||||
Version: config.SchemaVersion,
|
||||
CurrentProfile: profileName,
|
||||
Profiles: map[string]*config.Profile{profileName: prof},
|
||||
}
|
||||
if err := config.Save(path, file); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, "Saved "+path)
|
||||
return file, nil
|
||||
}
|
||||
|
||||
func ask(r *bufio.Reader, label, def string) string {
|
||||
if def != "" {
|
||||
fmt.Fprintf(os.Stderr, "%s [%s]: ", label, def)
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "%s: ", label)
|
||||
}
|
||||
line, _ := r.ReadString('\n')
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
return def
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
func askYesNo(r *bufio.Reader, label string, def bool) bool {
|
||||
d := "y/N"
|
||||
if def {
|
||||
d = "Y/n"
|
||||
}
|
||||
line := strings.ToLower(ask(r, label+" ("+d+")", ""))
|
||||
if line == "" {
|
||||
return def
|
||||
}
|
||||
return line == "y" || line == "yes"
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
func writeCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "write",
|
||||
Usage: "create or update a secret (e.g. `vault-tui write secret/team/prod/db user=admin pass=hunter2`)",
|
||||
ArgsUsage: "<path> key=value [key=value ...]",
|
||||
Flags: []cli.Flag{
|
||||
&cli.IntFlag{Name: "cas", Usage: "KV v2 check-and-set version (default: current version, if require_cas is on)"},
|
||||
&cli.BoolFlag{Name: "force", Usage: "skip check-and-set even if require_cas is on"},
|
||||
},
|
||||
Action: func(ctx context.Context, cmd *cli.Command) error {
|
||||
a, err := appFrom(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
args := cmd.Args().Slice()
|
||||
if len(args) < 2 {
|
||||
return fmt.Errorf("usage: vault-tui write <path> key=value [key=value ...]")
|
||||
}
|
||||
if _, err := a.EnsureLoggedIn(ctx, cmd.String("token")); err != nil {
|
||||
return err
|
||||
}
|
||||
svc := a.Service()
|
||||
if svc.ReadOnly {
|
||||
return fmt.Errorf("refusing to write: vault-tui is in read-only mode (pass --write to override)")
|
||||
}
|
||||
mount, rel, err := splitMountPath(ctx, svc, args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := parseKVArgs(args[1:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
iface := make(map[string]interface{}, len(data))
|
||||
for k, v := range data {
|
||||
iface[k] = v
|
||||
}
|
||||
|
||||
useCAS := a.Settings.RequireCAS && !cmd.Bool("force")
|
||||
cas := int(cmd.Int("cas"))
|
||||
if useCAS && !cmd.IsSet("cas") {
|
||||
current, err := svc.KV.Read(ctx, mount, rel, 0)
|
||||
if err == nil && current != nil {
|
||||
cas = current.Version
|
||||
}
|
||||
}
|
||||
|
||||
ack, err := svc.KV.Write(ctx, mount, rel, iface, useCAS, cas)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ack.Version > 0 {
|
||||
fmt.Printf("wrote %s (version %d)\n", args[0], ack.Version)
|
||||
} else {
|
||||
fmt.Printf("wrote %s\n", args[0])
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user