// 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" "os" "os/signal" "path/filepath" "strings" "sync" "syscall" "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): // // 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 and stops the fx application within the given context. // It handles graceful shutdown on interrupt signals (SIGINT, SIGTERM) and // ensures the application stops cleanly. The function blocks until the // application completes or is interrupted. Returns an error if startup fails. func RunApp(ctx context.Context, app *fx.App) error { // Set up signal handling for graceful shutdown sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) // Create a context that will be cancelled on signal ctx, cancel := context.WithCancel(ctx) defer cancel() // Start the app err := app.Start(ctx) if err != nil { return cleanStartupError(err) } // Handle shutdown shutdownComplete := make(chan struct{}) go func() { defer close(shutdownComplete) <-sigChan log.Notice("Received interrupt signal, shutting down gracefully...") // Create a timeout context for shutdown. The parent ctx is being // cancelled, so detach from its cancellation but keep its values. shutdownCtx, shutdownCancel := context.WithTimeout( context.WithoutCancel(ctx), shutdownTimeout) defer shutdownCancel() err := app.Stop(shutdownCtx) if err != nil { log.Error("Error during shutdown", "error", err) } }() // Wait for the signal handler to complete shutdown or the app to // request shutdown. select { case <-shutdownComplete: // Shutdown completed via signal return nil case <-ctx.Done(): // Context cancelled (shouldn't happen in normal operation) err := app.Stop(context.WithoutCancel(ctx)) if err != nil { log.Error("Error stopping app", "error", err) } return ctx.Err() case <-app.Done(): // App finished running (e.g., backup completed) 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. 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) { lc.Append(fx.Hook{ OnStart: func(_ context.Context) error { go 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 }, OnStop: func(_ context.Context) error { v.Cancel() 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 }