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 records whether op returned while the Vaultik context was still live; a run where it had not by the time RunWithApp returned is interrupted, whatever op returned. It used to look for context.Canceled in op's error, which verify --json does not return. 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 cover an op still running when the 30s shutdown timeout ends. Model: opus-5-5
397 lines
14 KiB
Go
397 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.
|
|
//
|
|
// The run counts as interrupted unless op returned, without an
|
|
// interrupt having cancelled it, before RunWithApp returned. 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
|
|
finished bool // op returned before any interrupt cancelled it
|
|
failed bool // op finished with an error
|
|
)
|
|
|
|
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, so a live context means no
|
|
// interrupt cancelled op. Check the context, not
|
|
// err: an interrupted op need not return
|
|
// context.Canceled (`snapshot verify --json`
|
|
// returns a verification failure).
|
|
if v.Context().Err() == nil {
|
|
if err != nil {
|
|
report(err)
|
|
}
|
|
|
|
mu.Lock()
|
|
finished = true
|
|
failed = err != nil
|
|
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
|
|
}
|
|
|
|
// RunWithApp returns only after the app was asked to stop, either by
|
|
// an interrupt or by the goroutine's Shutdown call. When op finished
|
|
// without being cancelled, the goroutine set finished before that
|
|
// call. So if finished is unset here, an interrupt stopped the app,
|
|
// and op either returned after it was cancelled or is still running
|
|
// because the shutdown timed out.
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
|
|
switch {
|
|
case !finished:
|
|
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
|
|
}
|