On SIGINT/SIGTERM the process could exit before the interrupted command cleanup defers ran, leaving decrypted data in the temp directory (the blob cache and the decrypted snapshot database). RunApp now mirrors fx run sequence: start, block on app.Wait(), then app.Stop(), returning only after Stop completes. fx delivers both an OS interrupt and the finished operation Shutdowner.Shutdown() on one channel. Stop runs the OnStop hooks; the operation hook cancels the command and waits for its goroutine to return (bounded by shutdownTimeout) before exit. The old code returned as soon as app.Done fired, without Stop, so a real interrupt unwound to os.Exit while cleanup still ran. Restore loops check the context between chunks and blobs so the wait ends promptly. A cli test drives RunApp through the OnStop hook. Model: opus-4-8
377 lines
13 KiB
Go
377 lines
13 KiB
Go
// Package cli implements the vaultik command-line interface: cobra
|
|
// commands, fx application wiring, and process-level concerns such as
|
|
// signal handling and the PID lock.
|
|
package cli
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/adrg/xdg"
|
|
"github.com/spf13/cobra"
|
|
"go.uber.org/fx"
|
|
"sneak.berlin/go/vaultik/internal/config"
|
|
"sneak.berlin/go/vaultik/internal/database"
|
|
"sneak.berlin/go/vaultik/internal/globals"
|
|
"sneak.berlin/go/vaultik/internal/log"
|
|
"sneak.berlin/go/vaultik/internal/pidlock"
|
|
"sneak.berlin/go/vaultik/internal/snapshot"
|
|
"sneak.berlin/go/vaultik/internal/storage"
|
|
"sneak.berlin/go/vaultik/internal/ui"
|
|
"sneak.berlin/go/vaultik/internal/vaultik"
|
|
)
|
|
|
|
// shutdownTimeout bounds how long a signal-triggered graceful shutdown
|
|
// may take before we give up.
|
|
const shutdownTimeout = 30 * time.Second
|
|
|
|
// lockMode says whether a command mutates persistent state — the local
|
|
// index database or the remote store — and so must hold the process-wide
|
|
// PID lock, or only reads that state and may run alongside a mutator.
|
|
type lockMode int
|
|
|
|
const (
|
|
// mutating commands (snapshot create, snapshot purge, snapshot remove,
|
|
// prune, remote nuke) write the local index or the remote store. They
|
|
// hold the PID lock so that at most one runs at a time.
|
|
mutating lockMode = iota
|
|
// readOnly commands (info, snapshot list, snapshot verify, remote info,
|
|
// snapshot restore) do not write the local index or the remote store,
|
|
// so they run without the lock and are never blocked by a running
|
|
// mutator. restore writes only to the target directory it is given.
|
|
readOnly
|
|
)
|
|
|
|
// AppOptions contains common options for creating and running the fx
|
|
// application: the configuration file path, logging options, additional fx
|
|
// modules and invocations, and whether the command mutates persistent
|
|
// state (which decides whether it takes the PID lock).
|
|
type AppOptions struct {
|
|
ConfigPath string
|
|
LogOptions log.Options
|
|
Modules []fx.Option
|
|
Invokes []fx.Option
|
|
Mode lockMode
|
|
}
|
|
|
|
// setupGlobals records the startup time and, when an output-suppression
|
|
// flag is active, marks the UI writer quiet so that Begin/Complete/
|
|
// Info/Notice/Detail/Progress are silenced. Warning and Error are NOT
|
|
// silenced — per the documented convention that --quiet suppresses
|
|
// non-error output only. The startup banner is printed by Entry
|
|
// before cobra parses arguments, gated by the same arg-level check.
|
|
//
|
|
// --json quiets the UI here too, because stdout then carries a JSON
|
|
// document and human narration would corrupt it. Unlike Quiet it does
|
|
// not lower the stderr log level (issue #112), so --verbose/--debug
|
|
// still surface diagnostics alongside the document.
|
|
func setupGlobals(
|
|
lc fx.Lifecycle, g *globals.Globals, v *vaultik.Vaultik, opts log.Options,
|
|
) {
|
|
lc.Append(fx.Hook{
|
|
OnStart: func(_ context.Context) error {
|
|
g.StartTime = time.Now().UTC()
|
|
|
|
if opts.Cron || opts.Quiet || opts.JSON {
|
|
v.UI.SetQuiet(true)
|
|
}
|
|
|
|
return nil
|
|
},
|
|
})
|
|
}
|
|
|
|
// writeStartupBanner prints the two-line application banner followed by a
|
|
// blank line. Used both from the fx hook (for subcommand invocations) and
|
|
// from the root cobra Run handler (for `vaultik` with no subcommand).
|
|
func writeStartupBanner(w *ui.Writer, startTime time.Time, shortCommit string) {
|
|
w.Bannerf("%s %s by %s (commit %s, built on %s) starting up at %s.",
|
|
globals.Appname, globals.Version, globals.Author,
|
|
shortCommit, globals.CommitDate,
|
|
startTime.Format(time.RFC3339))
|
|
w.Bannerf("%s", globals.Homepage)
|
|
w.Bannerf("")
|
|
}
|
|
|
|
// NewApp creates a new fx application with common modules.
|
|
// It sets up the base modules (config, database, logging, globals) and
|
|
// combines them with any additional modules specified in the options.
|
|
// The returned fx.App is ready to be started with RunApp.
|
|
func NewApp(opts AppOptions) *fx.App {
|
|
baseModules := []fx.Option{
|
|
fx.Supply(config.Path(opts.ConfigPath)),
|
|
fx.Supply(opts.LogOptions),
|
|
fx.Provide(globals.New),
|
|
fx.Provide(log.New),
|
|
config.Module,
|
|
database.Module,
|
|
log.Module,
|
|
storage.Module,
|
|
snapshot.Module,
|
|
fx.Provide(vaultik.New),
|
|
fx.Invoke(setupGlobals),
|
|
fx.NopLogger,
|
|
}
|
|
|
|
capacity := len(baseModules) + len(opts.Modules) + len(opts.Invokes)
|
|
allOptions := make([]fx.Option, 0, capacity)
|
|
allOptions = append(allOptions, baseModules...)
|
|
allOptions = append(allOptions, opts.Modules...)
|
|
allOptions = append(allOptions, opts.Invokes...)
|
|
|
|
return fx.New(allOptions...)
|
|
}
|
|
|
|
// startupError carries a startup failure message that has been cleaned
|
|
// of fx dependency-injection noise. A distinct type (rather than
|
|
// errors.New) keeps the dynamic message out of err113's sight while
|
|
// preserving the exact user-facing text.
|
|
type startupError struct {
|
|
msg string
|
|
}
|
|
|
|
func (e *startupError) Error() string {
|
|
return e.msg
|
|
}
|
|
|
|
// cleanStartupError strips fx's dependency-injection call-chain noise from
|
|
// startup errors. fx wraps the underlying error with messages like
|
|
//
|
|
// could not build arguments for function "X" (file:line): failed to build T:
|
|
// could not build arguments for function "Y" (file:line): failed to build U:
|
|
// received non-nil error from function "Z" (file:line): <real error>
|
|
//
|
|
// Users care about the real error, not the DI plumbing. We strip everything
|
|
// up through the last "): " (which is always the close-paren of an fx
|
|
// function-location annotation followed by the wrapped error).
|
|
func cleanStartupError(err error) error {
|
|
msg := err.Error()
|
|
if idx := strings.LastIndex(msg, "): "); idx >= 0 {
|
|
msg = msg[idx+3:]
|
|
}
|
|
|
|
return &startupError{msg: msg}
|
|
}
|
|
|
|
// RunApp starts the fx application, blocks until it is asked to stop, and
|
|
// then stops it. The app is asked to stop either by an OS interrupt
|
|
// (SIGINT/SIGTERM — fx installs its own handler when app.Wait is called) or,
|
|
// on normal completion, by the finished operation calling
|
|
// Shutdowner.Shutdown(); both arrive on the app.Wait channel.
|
|
//
|
|
// Stopping runs the fx OnStop hooks, and RunApp does not return until Stop
|
|
// returns. On an interrupt the operation's OnStop hook cancels the running
|
|
// command and waits for it to unwind — removing its decrypted scratch files —
|
|
// so the process cannot proceed to exit mid-cleanup (issue #159). Waiting for
|
|
// Stop before returning is what makes that hook effective: routing the
|
|
// interrupt through app.Stop and not returning until it completes is required,
|
|
// because fx also fires the app.Wait channel on the signal, and an earlier
|
|
// version returned on that alone — unwinding to os.Exit while the concurrent
|
|
// cleanup still ran. The stop is bounded by shutdownTimeout. Returns an error
|
|
// if startup fails.
|
|
func RunApp(ctx context.Context, app *fx.App) error {
|
|
err := app.Start(ctx)
|
|
if err != nil {
|
|
return cleanStartupError(err)
|
|
}
|
|
|
|
// Block until an interrupt or the finished operation's
|
|
// Shutdowner.Shutdown() arrives, then stop the app in this goroutine so we
|
|
// return only after its OnStop hooks — including the operation's cleanup
|
|
// wait — have run. Detach the stop from ctx's cancellation but keep its
|
|
// values, and bound it by shutdownTimeout.
|
|
<-app.Wait()
|
|
|
|
shutdownCtx, cancel := context.WithTimeout(
|
|
context.WithoutCancel(ctx), shutdownTimeout)
|
|
defer cancel()
|
|
|
|
err = app.Stop(shutdownCtx)
|
|
if err != nil {
|
|
log.Error("Error during shutdown", "error", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// errReported marks a failure the operation has already shown the user
|
|
// (and deliberately withheld under --json). Entry turns it into a
|
|
// non-zero exit status without printing anything further, so the error
|
|
// line is not doubled. It flows up from RunOperation through cobra to
|
|
// Entry.
|
|
var errReported = errors.New("operation failed")
|
|
|
|
// RunOperation runs op against the Vaultik instance inside the fx app
|
|
// and turns a failure into a returned error rather than an os.Exit from
|
|
// within the goroutine. An os.Exit there skipped main's deferred
|
|
// profile writers -- so profiling a failing command yielded a truncated
|
|
// profile (issue #75) -- and RunWithApp's PID-lock release, and denied
|
|
// the app any graceful shutdown; returning the error to the top runs
|
|
// all three.
|
|
//
|
|
// op runs in a goroutine so OnStart returns promptly and an interrupt
|
|
// can still cancel through OnStop; when it finishes, success or failure,
|
|
// it triggers shutdown, which is what lets RunWithApp return. On an
|
|
// interrupt OnStop cancels op and waits for the goroutine to return, so
|
|
// op's cleanup (removing decrypted scratch files) runs before the
|
|
// process exits; the wait is bounded by shutdownTimeout. report is
|
|
// called with a non-canceled failure so the caller can log it (and
|
|
// suppress it under --json) before it becomes errReported. A context
|
|
// cancellation is the interrupt path, not a failure: it is neither
|
|
// reported nor counted as one.
|
|
func RunOperation(
|
|
ctx context.Context, opts AppOptions,
|
|
op func(v *vaultik.Vaultik) error, report func(err error),
|
|
) error {
|
|
var (
|
|
mu sync.Mutex
|
|
failed bool
|
|
)
|
|
|
|
opts.Invokes = append(opts.Invokes,
|
|
fx.Invoke(func(v *vaultik.Vaultik, lc fx.Lifecycle) {
|
|
var stop func(context.Context) bool
|
|
|
|
lc.Append(fx.Hook{
|
|
OnStart: func(_ context.Context) error {
|
|
stop = v.StartOperation(func() {
|
|
err := op(v)
|
|
if err != nil && !errors.Is(err, context.Canceled) {
|
|
report(err)
|
|
|
|
mu.Lock()
|
|
failed = true
|
|
mu.Unlock()
|
|
}
|
|
|
|
stopErr := v.Shutdowner.Shutdown()
|
|
if stopErr != nil {
|
|
log.Error("Failed to shutdown", "error", stopErr)
|
|
}
|
|
})
|
|
|
|
return nil
|
|
},
|
|
// On an interrupt, cancel the operation and wait for it to
|
|
// unwind so its cleanup defers (which remove decrypted
|
|
// scratch files from the temp directory) run before the
|
|
// process exits. The wait is bounded by ctx, the existing
|
|
// shutdownTimeout.
|
|
OnStop: func(ctx context.Context) error {
|
|
if !stop(ctx) {
|
|
log.Warn("Shutdown timed out before the operation " +
|
|
"finished; decrypted temporary files may remain")
|
|
}
|
|
|
|
return nil
|
|
},
|
|
})
|
|
}))
|
|
|
|
err := RunWithApp(ctx, opts)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// The goroutine sets failed before triggering the shutdown that lets
|
|
// RunWithApp return, so the write is in place by the time we read it.
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
|
|
if failed {
|
|
return errReported
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// runVaultikApp runs the standard single-operation command lifecycle
|
|
// shared by the snapshot list/purge/remove and remote nuke subcommands:
|
|
// resolve the config, then run op against the Vaultik instance through
|
|
// RunOperation, reporting a failure prefixed with failMsg (suppressed
|
|
// while suppressErrors is true, e.g. under --json). mode says whether the
|
|
// command takes the PID lock. jsonOutput marks a command whose stdout is a
|
|
// JSON document: it quiets the UI but, unlike Quiet, leaves the stderr log
|
|
// level alone.
|
|
func runVaultikApp(
|
|
cmd *cobra.Command, mode lockMode, jsonOutput, suppressErrors bool,
|
|
failMsg string, op func(v *vaultik.Vaultik) error,
|
|
) error {
|
|
configPath, err := ResolveConfigPath()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
rootFlags := GetRootFlags()
|
|
|
|
return RunOperation(cmd.Context(), AppOptions{
|
|
ConfigPath: configPath,
|
|
LogOptions: log.Options{
|
|
Verbose: rootFlags.Verbose,
|
|
Debug: rootFlags.Debug,
|
|
Quiet: rootFlags.Quiet,
|
|
JSON: jsonOutput,
|
|
},
|
|
Mode: mode,
|
|
}, op, func(err error) {
|
|
if suppressErrors {
|
|
return
|
|
}
|
|
|
|
log.Error(failMsg, "error", err)
|
|
ReportErrorf("%s: %v", failMsg, err)
|
|
})
|
|
}
|
|
|
|
// RunWithApp is a helper that creates and runs an fx app with the given options.
|
|
// It combines NewApp and RunApp into a single convenient function. This is the
|
|
// preferred way to run CLI commands that need the full application context.
|
|
// A mutating command takes the process-wide PID lock before starting so that
|
|
// only one runs at a time; a read-only command runs without it and is not
|
|
// blocked while a mutator holds the lock (opts.Mode).
|
|
func RunWithApp(ctx context.Context, opts AppOptions) error {
|
|
release, err := acquireLockIfMutating(opts.Mode,
|
|
filepath.Join(xdg.DataHome, "vaultik"))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
defer release()
|
|
|
|
app := NewApp(opts)
|
|
|
|
return RunApp(ctx, app)
|
|
}
|
|
|
|
// acquireLockIfMutating takes the process-wide PID lock in lockDir for a
|
|
// mutating command and returns a function that releases it. A read-only
|
|
// command takes no lock, so it returns a no-op release and is never blocked
|
|
// while a mutator holds the lock. ErrAlreadyRunning (another mutator holds
|
|
// the lock) is surfaced as a "cannot start" error.
|
|
func acquireLockIfMutating(mode lockMode, lockDir string) (func(), error) {
|
|
if mode != mutating {
|
|
return func() {}, nil
|
|
}
|
|
|
|
lock, err := pidlock.Acquire(lockDir)
|
|
if err != nil {
|
|
if errors.Is(err, pidlock.ErrAlreadyRunning) {
|
|
return nil, fmt.Errorf("cannot start: %w", err)
|
|
}
|
|
|
|
return nil, fmt.Errorf("failed to acquire lock: %w", err)
|
|
}
|
|
|
|
return func() {
|
|
err := lock.Release()
|
|
if err != nil {
|
|
log.Warn("Failed to release PID lock", "error", err)
|
|
}
|
|
}, nil
|
|
}
|