Files
vaultik/internal/vaultik/vaultik.go
T
clawbot eed117fe25
check / check (pull_request) Successful in 1m27s
check / check (push) Successful in 3m18s
Route direct-stdout command output through internal/ui (closes #149)
version, info, remote info, config, and database delete wrote plain text straight to stdout, so they were unstyled and ignored --quiet. Output is now governed by internal/ui in two buckets. Status lines and confirmations (config init, config set, the database delete prompt) go through the ui message methods and are silenced by --quiet. The data a command exists to produce is written plain -- the version/info/remote-info reports, the snapshot list table, config get values, and the --json documents -- and is never suppressed, since a script depends on it and a marker would corrupt a table or document. The database delete confirmation prompt is always shown. Pure-cli commands reach ui through a small commandUI helper.

Model: opus-4-8
2026-09-22 20:28:50 +02:00

225 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,
UI: ui.NewWithColor(stdout, false),
},
Stdout: stdout,
Stderr: stderr,
Stdin: stdin,
}
}