check / check (push) Waiting to run
Ctrl-C or SIGTERM during snapshot create, restore or verify exited 0 with no error line (1, also silent, under snapshot verify --json), so an unfinished --cron backup looked like a success. RunOperation now counts an op as interrupted when the Vaultik context was cancelled before it returned, rather than when its error wraps context.Canceled, which verify --json does not. Entry prints "interrupted before the command finished" on stderr for it and returns 130, under --cron and --json too. SIGTERM also gives 130, as the issue asks, not 143. The test does not reach the branch for an op still running when the 30s shutdown timeout ends. Model: opus-5-5
400 lines
14 KiB
Go
400 lines
14 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
|
|
// (or, under `snapshot verify --json`, put in its document). 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")
|
|
|
|
// errInterrupted marks an operation that SIGINT or SIGTERM stopped
|
|
// before it finished. Entry shows it and returns exitCodeInterrupted.
|
|
var errInterrupted = errors.New("interrupted before the command finished")
|
|
|
|
// 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 failure so the caller can show it to the user before
|
|
// it becomes errReported. An interrupted op is not reported, whatever
|
|
// it returned; RunOperation returns errInterrupted instead.
|
|
func RunOperation(
|
|
ctx context.Context, opts AppOptions,
|
|
op func(v *vaultik.Vaultik) error, report func(err error),
|
|
) error {
|
|
var (
|
|
mu sync.Mutex
|
|
failed bool
|
|
interrupted 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)
|
|
|
|
// Only stop, called from OnStop below, cancels the
|
|
// Vaultik context; before op has returned, that
|
|
// happens only on an interrupt. Check the context,
|
|
// not err: an interrupted op need not return
|
|
// context.Canceled (`snapshot verify --json`
|
|
// returns a verification failure).
|
|
switch {
|
|
case v.Context().Err() != nil:
|
|
mu.Lock()
|
|
interrupted = true
|
|
mu.Unlock()
|
|
case err != nil:
|
|
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")
|
|
|
|
// op has not returned, so the app is stopping on
|
|
// an interrupt.
|
|
mu.Lock()
|
|
interrupted = true
|
|
mu.Unlock()
|
|
}
|
|
|
|
return nil
|
|
},
|
|
})
|
|
}))
|
|
|
|
err := RunWithApp(ctx, opts)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// RunWithApp returns only after OnStop has waited for the goroutine
|
|
// to return, whether the shutdown came from op finishing or from an
|
|
// interrupt, so the goroutine's write to failed or interrupted is in
|
|
// place by the time we read it. If OnStop timed out, the goroutine
|
|
// may still be running, and OnStop has set interrupted itself.
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
|
|
switch {
|
|
case interrupted:
|
|
return errInterrupted
|
|
case failed:
|
|
return errReported
|
|
default:
|
|
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 on stderr. 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 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) {
|
|
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
|
|
}
|