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
224 lines
6.7 KiB
Go
224 lines
6.7 KiB
Go
// Package vaultik implements the core backup, restore, verify, prune,
|
|
// and snapshot-management operations behind the vaultik CLI.
|
|
package vaultik
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
|
|
"github.com/spf13/afero"
|
|
"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/snapshot"
|
|
"sneak.berlin/go/vaultik/internal/storage"
|
|
"sneak.berlin/go/vaultik/internal/ui"
|
|
)
|
|
|
|
// Vaultik contains all dependencies needed for vaultik operations
|
|
type Vaultik struct {
|
|
Globals *globals.Globals
|
|
Config *config.Config
|
|
DB *database.DB
|
|
Repositories *database.Repositories
|
|
Storage storage.Storer
|
|
ScannerFactory snapshot.ScannerFactory
|
|
SnapshotManager *snapshot.SnapshotManager
|
|
Shutdowner fx.Shutdowner
|
|
Fs afero.Fs
|
|
|
|
// Context management
|
|
ctx context.Context //nolint:containedctx // ctx bound at construction by design
|
|
cancel context.CancelFunc
|
|
|
|
// IO. Stdout carries the output the user asked for and nothing else,
|
|
// so that `--json | jq` works. Stderr completes the standard triple
|
|
// for anything a command needs to write there directly; diagnostics
|
|
// are not that — they go through internal/log, which writes to the
|
|
// process's stderr. No production code writes to Stderr today, so
|
|
// searching for its writers turns up nothing; it is kept as the
|
|
// injection point a direct stderr write would otherwise have to
|
|
// invent, and removing it would make the triple asymmetric for no
|
|
// gain.
|
|
Stdout io.Writer
|
|
Stderr io.Writer
|
|
Stdin io.Reader
|
|
|
|
// UI is the writer for user-facing status, progress, warnings, errors.
|
|
// See package internal/ui for formatting conventions. It always wraps
|
|
// Stdout and is never swapped out; under --cron (and --quiet) the cli
|
|
// layer instead calls UI.SetQuiet(true), which drops Begin, Complete,
|
|
// Info, Notice, Detail, Progress, and Banner messages. Warning and
|
|
// Error are still emitted in that mode, so callers must not assume
|
|
// that --cron makes this writer silent.
|
|
UI *ui.Writer
|
|
|
|
// restoreCacheObserver, if non-nil, is invoked once with the
|
|
// restore-side blob disk cache immediately after the cache is
|
|
// created and again immediately before it is closed. Only
|
|
// internal-package tests set this; the type is unexported so
|
|
// callers outside this package can't reach it.
|
|
restoreCacheObserver func(*blobDiskCache)
|
|
}
|
|
|
|
// Params contains all parameters for New that can be provided by fx
|
|
type Params struct {
|
|
fx.In
|
|
|
|
Globals *globals.Globals
|
|
Config *config.Config
|
|
DB *database.DB
|
|
Repositories *database.Repositories
|
|
Storage storage.Storer
|
|
ScannerFactory snapshot.ScannerFactory
|
|
SnapshotManager *snapshot.SnapshotManager
|
|
Shutdowner fx.Shutdowner
|
|
Fs afero.Fs `optional:"true"`
|
|
}
|
|
|
|
// New creates a new Vaultik instance with proper context management
|
|
// It automatically includes crypto capabilities if age_secret_key is configured
|
|
func New(params Params) *Vaultik {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
|
|
// Use provided filesystem or default to OS filesystem
|
|
fs := params.Fs
|
|
if fs == nil {
|
|
fs = afero.NewOsFs()
|
|
}
|
|
|
|
// Set filesystem on SnapshotManager
|
|
params.SnapshotManager.SetFilesystem(fs)
|
|
|
|
return &Vaultik{
|
|
Globals: params.Globals,
|
|
Config: params.Config,
|
|
DB: params.DB,
|
|
Repositories: params.Repositories,
|
|
Storage: params.Storage,
|
|
ScannerFactory: params.ScannerFactory,
|
|
SnapshotManager: params.SnapshotManager,
|
|
Shutdowner: params.Shutdowner,
|
|
Fs: fs,
|
|
ctx: ctx,
|
|
cancel: cancel,
|
|
Stdout: os.Stdout,
|
|
Stderr: os.Stderr,
|
|
Stdin: os.Stdin,
|
|
UI: ui.New(os.Stdout),
|
|
}
|
|
}
|
|
|
|
// Context returns the Vaultik's context
|
|
func (v *Vaultik) Context() context.Context {
|
|
return v.ctx
|
|
}
|
|
|
|
// SetContext sets the Vaultik's context (primarily for testing)
|
|
func (v *Vaultik) SetContext(ctx context.Context) {
|
|
v.ctx = ctx
|
|
}
|
|
|
|
// Cancel cancels the Vaultik's context
|
|
func (v *Vaultik) Cancel() {
|
|
v.cancel()
|
|
}
|
|
|
|
// StartOperation runs fn in its own goroutine and returns a stop
|
|
// function. fn is the command being run (a restore, verify, prune, and
|
|
// so on); it observes cancellation through the Vaultik context and
|
|
// removes its decrypted scratch files (the blob cache and the temporary
|
|
// snapshot database) from the temp directory as it unwinds.
|
|
//
|
|
// Calling stop cancels the Vaultik context and then blocks until fn has
|
|
// returned — so that unwinding, and the cleanup it does, completes
|
|
// before the caller proceeds — or until the passed context is done,
|
|
// whichever comes first. It reports whether fn returned before that
|
|
// deadline. A signal-driven shutdown must call stop before the process
|
|
// exits; otherwise the process can exit mid-operation and leave
|
|
// decrypted data behind.
|
|
func (v *Vaultik) StartOperation(fn func()) func(context.Context) bool {
|
|
done := make(chan struct{})
|
|
|
|
go func() {
|
|
defer close(done)
|
|
|
|
fn()
|
|
}()
|
|
|
|
return func(ctx context.Context) bool {
|
|
v.Cancel()
|
|
|
|
select {
|
|
case <-done:
|
|
return true
|
|
case <-ctx.Done():
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
|
|
// CanDecrypt returns true if this Vaultik instance has decryption capabilities
|
|
func (v *Vaultik) CanDecrypt() bool {
|
|
return v.Config.AgeSecretKey != ""
|
|
}
|
|
|
|
// GetFilesystem returns the filesystem instance used by Vaultik
|
|
//
|
|
//nolint:ireturn // afero.Fs is the filesystem abstraction by design
|
|
func (v *Vaultik) GetFilesystem() afero.Fs {
|
|
return v.Fs
|
|
}
|
|
|
|
// stdoutf writes formatted output to stdout.
|
|
func (v *Vaultik) stdoutf(format string, args ...any) {
|
|
_, _ = fmt.Fprintf(v.Stdout, format, args...)
|
|
}
|
|
|
|
// printlnStdout writes a line to stdout.
|
|
func (v *Vaultik) printlnStdout(args ...any) {
|
|
_, _ = fmt.Fprintln(v.Stdout, args...)
|
|
}
|
|
|
|
// scanStdin reads a line of input from stdin.
|
|
func (v *Vaultik) scanStdin(a ...any) (int, error) {
|
|
return fmt.Fscanln(v.Stdin, a...)
|
|
}
|
|
|
|
// TestVaultik wraps a Vaultik with captured stdout/stderr for testing
|
|
type TestVaultik struct {
|
|
*Vaultik
|
|
|
|
Stdout *bytes.Buffer
|
|
Stderr *bytes.Buffer
|
|
Stdin *bytes.Buffer
|
|
}
|
|
|
|
// NewForTesting creates a minimal Vaultik instance for testing purposes.
|
|
// Only the Storage field is populated; other fields are nil.
|
|
// Returns a TestVaultik that captures stdout/stderr in buffers.
|
|
func NewForTesting(storage storage.Storer) *TestVaultik {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
stdout := &bytes.Buffer{}
|
|
stderr := &bytes.Buffer{}
|
|
stdin := &bytes.Buffer{}
|
|
|
|
return &TestVaultik{
|
|
Vaultik: &Vaultik{
|
|
Storage: storage,
|
|
ctx: ctx,
|
|
cancel: cancel,
|
|
Stdout: stdout,
|
|
Stderr: stderr,
|
|
Stdin: stdin,
|
|
},
|
|
Stdout: stdout,
|
|
Stderr: stderr,
|
|
Stdin: stdin,
|
|
}
|
|
}
|