check / check (push) Failing after 27m30s
The startup banner moves from stdout to stderr, so a `completion` script, a `config get` value and the hidden `__complete` command print only their own output. `--quiet`, `--cron` and `--json` still suppress it. A failing `remote info`, `prune` or `snapshot remove` under `--json` now reports its error on stderr. Their reporters returned early under `--json`, so the failure reached neither stream. `snapshot verify --quiet` writes no report. A failure is still returned and printed on stderr, with the same exit status. Judgement call: the banner's stream, posted on the issue for the owner. Model: opus-5-5
372 lines
13 KiB
Go
372 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
|
|
// (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")
|
|
|
|
// 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 show it to the
|
|
// user 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 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
|
|
}
|