2 Commits
Author SHA1 Message Date
sneak 80b44d1c16 Count each file, byte and upload once in backup statistics (closes #225)
check / check (push) Waiting to run
The scanner added a changed file's bytes again for each new chunk and
counted a file as unchanged for each chunk already stored. It now
counts files and bytes once, in the scan phase, and counts its own
uploads, so a --cron run, which has no progress reporter, records
them. The blob count no longer adds earlier paths' blobs again.

The snapshots row now stores the size of all files in total_size and
the referenced blobs' sizes in blob_size, blob_uncompressed_size and
compression_ratio, as docs/DATAMODEL.md says. Those sizes come from
one query, and a failed query fails the snapshot. DATAMODEL.md now
says chunk_count and blob_count count what the run added.

Removed UpdateSnapshotStats and GetCountBySnapshot, which nothing
calls any more.

Model: opus-5-5
2026-10-07 00:00:51 +00:00
clawbot 85d4ef118d Keep command output to the README's stdout and stderr rules (closes #224)
check / check (push) Successful in 16m49s
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
2026-10-07 01:12:15 +02:00
15 changed files with 368 additions and 138 deletions
+20 -16
View File
@@ -200,8 +200,10 @@ local index or the destination store. `config`, `database delete`,
### stdout and stderr ### stdout and stderr
Log output — everything from `--verbose` and `--debug`, and every Log output — everything from `--verbose` and `--debug`, and every
warning and error the logger emits — goes to **stderr**. stdout carries warning and error the logger emits — goes to **stderr**, and so does the
the output you asked for: tables, and the documents produced by `--json`. startup banner. stdout carries the output you asked for: tables, the
documents produced by `--json`, `config get` values, and completion
scripts.
This means `vaultik snapshot list --verbose > out.txt` captures the This means `vaultik snapshot list --verbose > out.txt` captures the
listing and leaves the diagnostics on your terminal. To capture both, listing and leaves the diagnostics on your terminal. To capture both,
@@ -645,8 +647,8 @@ Work planned after 1.0. Loosely ordered by priority.
## output style ## output style
Every command's user-facing output is governed by `internal/ui`, in one Every command's user-facing output is governed by `internal/ui`, in one
of two ways. Color is enabled when stdout is a TTY and the `NO_COLOR` of two ways. Color is enabled when the stream written to is a TTY and
environment variable is unset (https://no-color.org/). the `NO_COLOR` environment variable is unset (https://no-color.org/).
* **Status, progress, warnings, and errors** go through the `internal/ui` * **Status, progress, warnings, and errors** go through the `internal/ui`
message methods below: marker-prefixed, colored on a TTY, and — except message methods below: marker-prefixed, colored on a TTY, and — except
@@ -656,24 +658,26 @@ environment variable is unset (https://no-color.org/).
`config init`, `config set`, and `database delete`. `config init`, `config set`, and `database delete`.
* **The data a command exists to produce** is written plain, with no * **The data a command exists to produce** is written plain, with no
marker and no color, because a marker would corrupt a table or a marker and no color, because a marker would corrupt a table or a
parsed document. This covers the `version`, `info`, and `remote info` parsed document. This covers the `version`, `info`, `remote info` and
reports, the `snapshot list` table, `config get` values, and every `snapshot verify` reports, the `snapshot list` table, `config get`
`--json` document. `--quiet` silences the human reports and tables values, and every `--json` document. `--quiet` silences the human
(`version`, `info`, `remote info`, `snapshot list`) but never the reports and tables (`version`, `info`, `remote info`, `snapshot
machine-consumed `config get` value or the `--json` documents, which a verify`, `snapshot list`) but never the machine-consumed `config get`
script depends on. The `database delete` confirmation prompt is also value or the `--json` documents, which a script depends on. The
written this way and always shown: it is an interactive exchange the `database delete` confirmation prompt is also written this way and
operator must see. always shown: it is an interactive exchange the operator must see.
`internal/ui` writes to stdout; it is the output the user asked for. `internal/ui` writes to stdout; it is the output the user asked for. The
Structured log records are a different thing and go through exceptions are the startup banner and the error a failed command ends
`internal/log`, which writes to stderr (see "stdout and stderr" above). with, which go to stderr. Structured log records are a different thing
and go through `internal/log`, which writes to stderr (see "stdout and
stderr" above).
Message classes: Message classes:
| Class | Marker | Alignment | Use for | | Class | Marker | Alignment | Use for |
|-------|--------|-----------|---------| |-------|--------|-----------|---------|
| Banner | none | column 0 | The startup line printed once per invocation | | Banner | none | column 0 | The startup line printed once per invocation, on stderr |
| Begin | `》` (white) | column 0 | An operation is about to start (present-continuous verb) | | Begin | `》` (white) | column 0 | An operation is about to start (present-continuous verb) |
| Complete | `》` (green) | column 0 | An operation just finished (past-tense verb) | | Complete | `》` (green) | column 0 | An operation just finished (past-tense verb) |
| Info | `》` (white) | column 0 | Neutral status update | | Info | `》` (white) | column 0 | Neutral status update |
+9
View File
@@ -35,6 +35,15 @@ the tag exists and is exercised; what is left is merging `next` to
the snapshot references, and `docs/DATAMODEL.md` now says the snapshot references, and `docs/DATAMODEL.md` now says
`chunk_count` and `blob_count` count what the run added. `chunk_count` and `blob_count` count what the run added.
- 2026-10-06: Made command output follow the README's stdout and stderr
rules ([issue #224](https://git.eeqj.de/sneak/vaultik/issues/224)). The
startup banner went to stdout, so a `completion` script or a
`config get` value started with it; the banner now goes to stderr. A
failing `remote info`, `prune` or `snapshot remove` under `--json`
printed nothing on either stream, and now reports its error on stderr.
`snapshot verify --quiet` printed its whole report; it now prints
none, and a failure still reaches stderr with the same exit status.
- 2026-10-06: Made a backup without `--cron` of a snapshot with two or - 2026-10-06: Made a backup without `--cron` of a snapshot with two or
more `paths` complete instead of panicking with `close of closed more `paths` complete instead of panicking with `close of closed
channel` ([issue #253](https://git.eeqj.de/sneak/vaultik/issues/253)). channel` ([issue #253](https://git.eeqj.de/sneak/vaultik/issues/253)).
+13 -18
View File
@@ -200,10 +200,10 @@ func RunApp(ctx context.Context, app *fx.App) error {
} }
// errReported marks a failure the operation has already shown the user // errReported marks a failure the operation has already shown the user
// (and deliberately withheld under --json). Entry turns it into a // (or, under `snapshot verify --json`, put in its document). Entry
// non-zero exit status without printing anything further, so the error // turns it into a non-zero exit status without printing anything
// line is not doubled. It flows up from RunOperation through cobra to // further, so the error line is not doubled. It flows up from
// Entry. // RunOperation through cobra to Entry.
var errReported = errors.New("operation failed") var errReported = errors.New("operation failed")
// RunOperation runs op against the Vaultik instance inside the fx app // RunOperation runs op against the Vaultik instance inside the fx app
@@ -220,10 +220,10 @@ var errReported = errors.New("operation failed")
// interrupt OnStop cancels op and waits for the goroutine to return, so // interrupt OnStop cancels op and waits for the goroutine to return, so
// op's cleanup (removing decrypted scratch files) runs before the // op's cleanup (removing decrypted scratch files) runs before the
// process exits; the wait is bounded by shutdownTimeout. report is // process exits; the wait is bounded by shutdownTimeout. report is
// called with a non-canceled failure so the caller can log it (and // called with a non-canceled failure so the caller can show it to the
// suppress it under --json) before it becomes errReported. A context // user before it becomes errReported. A context cancellation is the
// cancellation is the interrupt path, not a failure: it is neither // interrupt path, not a failure: it is neither reported nor counted as
// reported nor counted as one. // one.
func RunOperation( func RunOperation(
ctx context.Context, opts AppOptions, ctx context.Context, opts AppOptions,
op func(v *vaultik.Vaultik) error, report func(err error), op func(v *vaultik.Vaultik) error, report func(err error),
@@ -293,13 +293,12 @@ func RunOperation(
// runVaultikApp runs the standard single-operation command lifecycle // runVaultikApp runs the standard single-operation command lifecycle
// shared by the snapshot list/purge/remove and remote nuke subcommands: // shared by the snapshot list/purge/remove and remote nuke subcommands:
// resolve the config, then run op against the Vaultik instance through // resolve the config, then run op against the Vaultik instance through
// RunOperation, reporting a failure prefixed with failMsg (suppressed // RunOperation, reporting a failure prefixed with failMsg on stderr. mode
// while suppressErrors is true, e.g. under --json). mode says whether the // says whether the command takes the PID lock. jsonOutput marks a command
// command takes the PID lock. jsonOutput marks a command whose stdout is a // whose stdout is a JSON document: it quiets the UI but, unlike Quiet,
// JSON document: it quiets the UI but, unlike Quiet, leaves the stderr log // leaves the stderr log level alone.
// level alone.
func runVaultikApp( func runVaultikApp(
cmd *cobra.Command, mode lockMode, jsonOutput, suppressErrors bool, cmd *cobra.Command, mode lockMode, jsonOutput bool,
failMsg string, op func(v *vaultik.Vaultik) error, failMsg string, op func(v *vaultik.Vaultik) error,
) error { ) error {
configPath, err := ResolveConfigPath() configPath, err := ResolveConfigPath()
@@ -319,10 +318,6 @@ func runVaultikApp(
}, },
Mode: mode, Mode: mode,
}, op, func(err error) { }, op, func(err error) {
if suppressErrors {
return
}
log.Error(failMsg, "error", err) log.Error(failMsg, "error", err)
ReportErrorf("%s: %v", failMsg, err) ReportErrorf("%s: %v", failMsg, err)
}) })
+10 -9
View File
@@ -16,16 +16,18 @@ import (
const shortCommitLen = 12 const shortCommitLen = 12
// Entry is the main entry point for the CLI application. // Entry is the main entry point for the CLI application.
// It prints the startup banner to stdout (unless a banner-suppressing // It prints the startup banner to stderr (unless a banner-suppressing
// flag is present in os.Args — see bannerSuppressedInArgs), executes the // flag is present in os.Args — see bannerSuppressedInArgs), executes the
// root cobra command, and routes any returned error through the // root cobra command, and routes any returned error through the
// ui.Writer so the user sees a properly formatted "🛑 ERROR:" line. // ui.Writer so the user sees a properly formatted "🛑 ERROR:" line.
// The banner goes to stderr because stdout carries only the output the
// user asked for, such as a completion script or a `config get` value.
// //
// It returns the process exit code (0 on success, 1 on error) rather // It returns the process exit code (0 on success, 1 on error) rather
// than calling os.Exit, so that main's deferred profile writers run // than calling os.Exit, so that main's deferred profile writers run
// before the process ends. See run in cmd/vaultik/main.go. // before the process ends. See run in cmd/vaultik/main.go.
func Entry() int { func Entry() int {
emitStartupBanner(os.Args[1:], os.Stdout) emitStartupBanner(os.Args[1:], os.Stderr)
rootCmd := NewRootCommand() rootCmd := NewRootCommand()
rootCmd.SilenceErrors = true rootCmd.SilenceErrors = true
@@ -33,8 +35,9 @@ func Entry() int {
err := rootCmd.Execute() err := rootCmd.Execute()
if err != nil { if err != nil {
// An operation that ran inside the fx app has already reported // An operation that ran inside the fx app has already reported
// its own failure (and suppressed it under --json); errReported // its own failure (`snapshot verify --json` puts it in the
// says so. Printing it again here would double the error line. // document instead); errReported says so. Printing it again
// here would double the error line.
// Every other error — bad arguments, a config that would not // Every other error — bad arguments, a config that would not
// load — reaches Entry unreported, so it is shown here. // load — reaches Entry unreported, so it is shown here.
if !errors.Is(err, errReported) { if !errors.Is(err, errReported) {
@@ -49,9 +52,8 @@ func Entry() int {
// emitStartupBanner writes the startup banner to w unless args (the // emitStartupBanner writes the startup banner to w unless args (the
// argument vector with the program name already stripped) contains a // argument vector with the program name already stripped) contains a
// flag that suppresses it. Split out of Entry so that the decision — the // flag that suppresses it. Split out of Entry so that the decision is
// only thing standing between a --json invocation and a parseable // reachable from a test without running the whole CLI.
// stdout — is reachable from a test without running the whole CLI.
func emitStartupBanner(args []string, w io.Writer) { func emitStartupBanner(args []string, w io.Writer) {
if bannerSuppressedInArgs(args) { if bannerSuppressedInArgs(args) {
return return
@@ -86,8 +88,7 @@ func ReportErrorf(format string, args ...any) {
// --json is a subcommand flag rather than a persistent one, but so is // --json is a subcommand flag rather than a persistent one, but so is
// --cron (it exists only on `snapshot create`), so this adds no new // --cron (it exists only on `snapshot create`), so this adds no new
// class of imprecision. The only cost of a false positive is a missing // class of imprecision. The only cost of a false positive is a missing
// decorative banner; the cost of a false negative is a corrupt document // decorative banner.
// on stdout, so the scan errs deliberately in that direction.
func bannerSuppressedInArgs(args []string) bool { func bannerSuppressedInArgs(args []string) bool {
for _, a := range args { for _, a := range args {
if a == "--" { if a == "--" {
+16 -43
View File
@@ -36,23 +36,14 @@ const (
// strips it before scanning, so it has to be present. // strips it before scanning, so it has to be present.
programName = "vaultik" programName = "vaultik"
// someSnapshotID is any snapshot identifier: these tests never run // someSnapshotID only fills the positional argument; no test needs
// the command, so it only has to occupy the positional argument. // the snapshot to exist.
someSnapshotID = "host_2026-01-01T00:00:00Z" someSnapshotID = "host_2026-01-01T00:00:00Z"
) )
// placeholderJSONDocument stands in for whatever document a --json
// command writes to stdout. `snapshot list --json` with no snapshots
// prints exactly this; the other --json commands print an object rather
// than an array, but this test is not about their shape. It is about
// what is on stdout *before* them, which is the same for all of them
// because Entry prints the banner before cobra has parsed anything and
// therefore before it can know which command is running.
const placeholderJSONDocument = "[]\n"
// jsonArgumentVectors are the argument vectors of every --json // jsonArgumentVectors are the argument vectors of every --json
// invocation the CLI accepts, with the program name stripped exactly as // invocation the CLI accepts, with the program name stripped exactly as
// Entry strips it. Each one must leave stdout untouched by the banner. // Entry strips it. Each one must suppress the banner.
// //
//nolint:gochecknoglobals // read-only test fixture shared by two tests //nolint:gochecknoglobals // read-only test fixture shared by two tests
var jsonArgumentVectors = map[string][]string{ var jsonArgumentVectors = map[string][]string{
@@ -74,39 +65,23 @@ var jsonArgumentVectors = map[string][]string{
}, },
} }
// TestJSONInvocationStdoutIsExactlyOneDocument is the CLI-layer // TestJSONInvocationSuppressesBanner checks that every --json
// regression guard for issue #106: `vaultik snapshot list --json | jq` // invocation suppresses the startup banner, as the README says --json
// must work with no other flags. // does along with --quiet and --cron. The scan is over the raw argument
// // vector, so each position and spelling of --json is listed.
// internal/vaultik's TestListSnapshots_JSONStdoutIsOnlyTheDocument func TestJSONInvocationSuppressesBanner(t *testing.T) {
// guards the same contract one layer down, but it calls the library
// function directly and so cannot see Entry, which is where the
// contamination was: the startup banner is written to stdout before
// cobra parses anything, and the suppression scan did not know about
// --json. The two banner lines and the blank line landed ahead of the
// document and `jq` refused the result.
//
// The document is a constant here because this test is about the
// argument vectors, one per --json command; the one that runs a real
// command end to end is TestEntryJSONStdoutIsExactlyOneDocument below.
func TestJSONInvocationStdoutIsExactlyOneDocument(t *testing.T) {
t.Parallel() t.Parallel()
for name, argv := range jsonArgumentVectors { for name, argv := range jsonArgumentVectors {
t.Run(name, func(t *testing.T) { t.Run(name, func(t *testing.T) {
t.Parallel() t.Parallel()
var stdout bytes.Buffer var banner bytes.Buffer
emitStartupBanner(argv, &stdout) emitStartupBanner(argv, &banner)
require.Empty(t, stdout.String(), assert.Empty(t, banner.String(),
"nothing may reach stdout ahead of a --json document") "--json suppresses the banner")
_, err := stdout.WriteString(placeholderJSONDocument)
require.NoError(t, err)
requireExactlyOneJSONDocument(t, stdout.String())
}) })
} }
} }
@@ -127,11 +102,11 @@ func TestBannerStillPrintedWithoutSuppressingFlag(t *testing.T) {
t.Run(name, func(t *testing.T) { t.Run(name, func(t *testing.T) {
t.Parallel() t.Parallel()
var stdout bytes.Buffer var banner bytes.Buffer
emitStartupBanner(argv, &stdout) emitStartupBanner(argv, &banner)
assert.Contains(t, stdout.String(), "starting up at", assert.Contains(t, banner.String(), "starting up at",
"the banner belongs on invocations that did not opt out") "the banner belongs on invocations that did not opt out")
}) })
} }
@@ -247,9 +222,7 @@ func TestEntryJSONStdoutIsExactlyOneDocument(t *testing.T) {
// captureProcessStdout redirects the process's own stdout to a pipe for // captureProcessStdout redirects the process's own stdout to a pipe for
// the duration of fn and returns what was written to it. The redirection // the duration of fn and returns what was written to it. The redirection
// has to be at the file-descriptor level rather than through an injected // has to be at the file-descriptor level rather than through an injected
// writer, because the banner and the JSON encoder reach os.Stdout // writer, because the commands Entry runs reach os.Stdout directly.
// independently and the point of the test is that both land in the same
// place.
// //
// Not parallel-safe: os.Stdout is process-global. // Not parallel-safe: os.Stdout is process-global.
func captureProcessStdout(t *testing.T, fn func()) string { func captureProcessStdout(t *testing.T, fn func()) string {
+3 -3
View File
@@ -15,8 +15,8 @@ import (
// run, so a failing command must come back with a non-zero code rather // run, so a failing command must come back with a non-zero code rather
// than ending the process here. // than ending the process here.
// //
// Stdout is captured only to keep the banner and command output off the // Stdout and stderr are captured only to keep the banner and command
// test log; the assertion is on the returned code. // output off the test log; the assertion is on the returned code.
// //
//nolint:paralleltest // replaces os.Args and rootFlags //nolint:paralleltest // replaces os.Args and rootFlags
func TestEntryReturnsStatusCode(t *testing.T) { func TestEntryReturnsStatusCode(t *testing.T) {
@@ -50,7 +50,7 @@ func TestEntryReturnsStatusCode(t *testing.T) {
var code int var code int
_ = captureProcessStdout(t, func() { code = Entry() }) _, _ = captureProcessStdoutAndStderr(t, func() { code = Entry() })
assert.Equal(t, testCase.want, code) assert.Equal(t, testCase.want, code)
}) })
+152
View File
@@ -0,0 +1,152 @@
package cli //nolint:testpackage // shares hermeticConfig and the capture helpers
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"github.com/adrg/xdg"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/vaultik/internal/database"
)
// TestEntryCompletionStdoutIsTheScript runs `vaultik completion bash`,
// whose stdout the README tells the user to source. The script has to
// start on the first line.
//
//nolint:paralleltest // replaces os.Args, os.Stdout and os.Stderr
func TestEntryCompletionStdoutIsTheScript(t *testing.T) {
code, stdout, _ := runEntry(t, "completion", "bash")
require.Equal(t, 0, code)
firstLine, _, _ := strings.Cut(stdout, "\n")
assert.True(t, strings.HasPrefix(firstLine, "# bash completion"),
"the first line of stdout must be the script's, got %q", firstLine)
}
// TestEntryConfigGetStdoutIsTheValue runs `vaultik config get`, whose
// stdout a script reads as the value and nothing else.
//
//nolint:paralleltest // replaces os.Args, os.Stdout and os.Stderr
func TestEntryConfigGetStdoutIsTheValue(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.yml")
require.NoError(t, os.WriteFile(configPath,
[]byte("hostname: test-host\n"), configFileMode))
code, stdout, _ := runEntry(t,
flagConfig, configPath, "config", "get", "hostname")
require.Equal(t, 0, code)
assert.Equal(t, "test-host\n", stdout)
}
// TestEntryJSONFailureIsReportedOnStderr runs each --json command that
// writes no document when it fails, against a destination it cannot
// use. The error must reach stderr, and stdout must stay empty.
//
//nolint:paralleltest // replaces os.Args, os.Stdout, os.Stderr and the xdg globals
func TestEntryJSONFailureIsReportedOnStderr(t *testing.T) {
for _, testCase := range []struct {
name string
args []string
wantOnStderr string
}{
{
name: "remote info",
args: []string{cmdRemote, cmdInfo, flagJSON},
wantOnStderr: "Failed to get remote info",
},
{
name: "prune",
args: []string{cmdPrune, flagJSON},
wantOnStderr: "Prune failed",
},
{
name: "snapshot remove",
args: []string{cmdSnapshot, cmdRemove, someSnapshotID, flagJSON},
wantOnStderr: "Failed to remove snapshot",
},
} {
t.Run(testCase.name, func(t *testing.T) {
configPath := writeUnusableDestinationConfig(t)
code, stdout, stderr := runEntry(t,
append([]string{flagConfig, configPath}, testCase.args...)...)
assert.Equal(t, 1, code)
assert.Empty(t, stdout,
"a failed --json command has no document to write")
assert.Contains(t, stderr, testCase.wantOnStderr,
"the failure must be reported on stderr")
})
}
}
// writeUnusableDestinationConfig builds a config whose destination
// directory does not exist, which fails `remote info`, and whose local
// index is bound to another destination, which fails `prune` and
// `snapshot remove` (a missing destination alone only makes `snapshot
// remove` warn). Returns the config path.
func writeUnusableDestinationConfig(t *testing.T) string {
t.Helper()
dir := t.TempDir()
configPath := filepath.Join(dir, "config.yml")
indexPath := filepath.Join(dir, "index.sqlite")
contents := fmt.Sprintf(hermeticConfig,
filepath.Join(dir, "source"),
filepath.Join(dir, "missing-store"),
indexPath)
require.NoError(t,
os.WriteFile(configPath, []byte(contents), configFileMode))
// The PID lock lives under xdg.DataHome, which xdg resolves at
// package init; point it at the temp dir so the test neither
// touches nor collides with the real one.
t.Setenv("XDG_DATA_HOME", filepath.Join(dir, "data"))
xdg.Reload()
t.Cleanup(xdg.Reload)
ctx := context.Background()
db, err := database.New(ctx, indexPath)
require.NoError(t, err)
defer func() { require.NoError(t, db.Close()) }()
require.NoError(t, database.NewRepositories(db).LocalMeta.Set(ctx,
database.LocalMetaKeyStorageURL, "file://"+filepath.Join(dir, "other")))
return configPath
}
// runEntry runs Entry with args after the program name and returns its
// exit code and what it wrote to stdout and stderr.
//
// Not parallel-safe: it replaces os.Args, os.Stdout and os.Stderr.
func runEntry(t *testing.T, args ...string) (int, string, string) {
t.Helper()
previousArgs := os.Args
t.Cleanup(func() {
os.Args = previousArgs
rootFlags = RootFlags{}
})
os.Args = append([]string{programName}, args...)
var code int
stdout, stderr := captureProcessStdoutAndStderr(t,
func() { code = Entry() })
return code, stdout, stderr
}
-4
View File
@@ -48,10 +48,6 @@ work (e.g. after a crashed backup or to reclaim storage).`,
}, func(v *vaultik.Vaultik) error { }, func(v *vaultik.Vaultik) error {
return v.Prune(opts) return v.Prune(opts)
}, func(err error) { }, func(err error) {
if opts.JSON {
return
}
log.Error("Prune operation failed", "error", err) log.Error("Prune operation failed", "error", err)
ReportErrorf("Prune failed: %v", err) ReportErrorf("Prune failed: %v", err)
}) })
+1 -5
View File
@@ -45,7 +45,7 @@ This is destructive and irreversible. Requires --force.`,
return errNukeNeedsForce return errNukeNeedsForce
} }
return runVaultikApp(cmd, mutating, false, false, "Remote nuke failed", return runVaultikApp(cmd, mutating, false, "Remote nuke failed",
func(v *vaultik.Vaultik) error { func(v *vaultik.Vaultik) error {
return v.NukeRemote(true) return v.NukeRemote(true)
}) })
@@ -92,10 +92,6 @@ func newRemoteInfoCommand() *cobra.Command {
}, func(v *vaultik.Vaultik) error { }, func(v *vaultik.Vaultik) error {
return v.RemoteInfo(jsonOutput) return v.RemoteInfo(jsonOutput)
}, func(err error) { }, func(err error) {
if jsonOutput {
return
}
log.Error("Failed to get remote info", "error", err) log.Error("Failed to get remote info", "error", err)
ReportErrorf("Failed to get remote info: %v", err) ReportErrorf("Failed to get remote info: %v", err)
}) })
+3 -3
View File
@@ -126,7 +126,7 @@ func newSnapshotListCommand() *cobra.Command {
Long: "Lists all snapshots with their ID, timestamp, and compressed size", Long: "Lists all snapshots with their ID, timestamp, and compressed size",
Args: cobra.NoArgs, Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error { RunE: func(cmd *cobra.Command, _ []string) error {
return runVaultikApp(cmd, readOnly, false, false, return runVaultikApp(cmd, readOnly, false,
"Failed to list snapshots", "Failed to list snapshots",
func(v *vaultik.Vaultik) error { func(v *vaultik.Vaultik) error {
return v.ListSnapshots(jsonOutput) return v.ListSnapshots(jsonOutput)
@@ -162,7 +162,7 @@ restrict the operation to specific snapshot names.`,
return errPurgeCriteriaBoth return errPurgeCriteriaBoth
} }
return runVaultikApp(cmd, mutating, false, false, return runVaultikApp(cmd, mutating, false,
"Failed to purge snapshots", "Failed to purge snapshots",
func(v *vaultik.Vaultik) error { func(v *vaultik.Vaultik) error {
return v.PurgeSnapshotsWithOptions(opts) return v.PurgeSnapshotsWithOptions(opts)
@@ -265,7 +265,7 @@ To wipe the entire destination store and start over, use 'vaultik remote
nuke --force' — it is the single supported entry point for that.`, nuke --force' — it is the single supported entry point for that.`,
Args: requireSnapshotIDArg, Args: requireSnapshotIDArg,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
return runVaultikApp(cmd, mutating, opts.JSON, opts.JSON, return runVaultikApp(cmd, mutating, opts.JSON,
"Failed to remove snapshot", "Failed to remove snapshot",
func(v *vaultik.Vaultik) error { func(v *vaultik.Vaultik) error {
_, err := v.RemoveSnapshot(args[0], opts) _, err := v.RemoveSnapshot(args[0], opts)
-16
View File
@@ -158,19 +158,3 @@ type UploadStats struct {
MinDurationMs int64 MinDurationMs int64
MaxDurationMs int64 MaxDurationMs int64
} }
// GetCountBySnapshot returns the count of uploads for a specific snapshot
func (r *UploadRepository) GetCountBySnapshot(
ctx context.Context, snapshotID string,
) (int64, error) {
query := `SELECT COUNT(*) FROM uploads WHERE snapshot_id = ?`
var count int64
err := r.conn.QueryRowContext(ctx, query, snapshotID).Scan(&count)
if err != nil {
return 0, err
}
return count, nil
}
+3 -2
View File
@@ -1829,8 +1829,9 @@ func (s *Scanner) processFileStreaming(
return nil return nil
} }
// updateChunkStats counts a chunk that was not already stored. File and // updateChunkStats counts a chunk that was not already stored. The scan
// byte counts are not touched here: the scan phase counts each file once. // result's file counts, BytesScanned and BytesSkipped are not touched
// here: the scan phase counts each file once.
func (s *Scanner) updateChunkStats(chunkSize int64, result *ScanResult) { func (s *Scanner) updateChunkStats(chunkSize int64, result *ScanResult) {
result.ChunksCreated++ result.ChunksCreated++
+21 -7
View File
@@ -711,7 +711,7 @@ func (v *Vaultik) VerifySnapshotWithOptions(
result.BlobCount = manifest.BlobCount result.BlobCount = manifest.BlobCount
result.TotalSize = manifest.TotalCompressedSize result.TotalSize = manifest.TotalCompressedSize
if !opts.JSON { if !opts.JSON && !v.UI.Quiet() {
v.stdoutf("Snapshot information:\n") v.stdoutf("Snapshot information:\n")
v.stdoutf(" Blob count: %d\n", manifest.BlobCount) v.stdoutf(" Blob count: %d\n", manifest.BlobCount)
v.stdoutf(" Total size: %s\n", ubytes(manifest.TotalCompressedSize)) v.stdoutf(" Total size: %s\n", ubytes(manifest.TotalCompressedSize))
@@ -766,7 +766,7 @@ func (v *Vaultik) printVerifyHeader(snapshotID string, opts *VerifyOptions) {
snapshotTime = t snapshotTime = t
} }
if !opts.JSON { if !opts.JSON && !v.UI.Quiet() {
v.stdoutf("Verifying snapshot %s\n", snapshotID) v.stdoutf("Verifying snapshot %s\n", snapshotID)
if !snapshotTime.IsZero() { if !snapshotTime.IsZero() {
@@ -806,7 +806,7 @@ func (v *Vaultik) verifyManifestBlobs(
stat, err := v.Storage.Stat(v.ctx, blobPath) stat, err := v.Storage.Stat(v.ctx, blobPath)
switch { switch {
case err != nil: case err != nil:
if !opts.JSON { if !opts.JSON && !v.UI.Quiet() {
v.stdoutf(" Missing: %s (%s)\n", v.stdoutf(" Missing: %s (%s)\n",
blob.Hash, ubytes(blob.CompressedSize)) blob.Hash, ubytes(blob.CompressedSize))
} }
@@ -814,7 +814,7 @@ func (v *Vaultik) verifyManifestBlobs(
missing++ missing++
missingSize += blob.CompressedSize missingSize += blob.CompressedSize
case stat.Size != blob.CompressedSize: case stat.Size != blob.CompressedSize:
if !opts.JSON { if !opts.JSON && !v.UI.Quiet() {
v.stdoutf(" Wrong size: %s (store has %s, manifest lists %s)\n", v.stdoutf(" Wrong size: %s (store has %s, manifest lists %s)\n",
blob.Hash, ubytes(stat.Size), ubytes(blob.CompressedSize)) blob.Hash, ubytes(stat.Size), ubytes(blob.CompressedSize))
} }
@@ -846,6 +846,22 @@ func (v *Vaultik) formatVerifyResult(
return v.outputVerifyJSON(result) return v.outputVerifyJSON(result)
} }
// Under --quiet a failure is still returned, and the cli layer
// prints it on stderr.
if !v.UI.Quiet() {
v.printVerifySummary(result, failure)
}
if failure != "" {
return fmt.Errorf("%w: %s", errSnapshotVerifyFailed, failure)
}
return nil
}
// printVerifySummary prints the counts and the status line that end the
// human-readable shallow verify report. failure is empty when it passed.
func (v *Vaultik) printVerifySummary(result *VerifyResult, failure string) {
v.stdoutf("\nVerification complete:\n") v.stdoutf("\nVerification complete:\n")
v.stdoutf(" Present with listed size: %d blobs\n", result.Verified) v.stdoutf(" Present with listed size: %d blobs\n", result.Verified)
@@ -867,14 +883,12 @@ func (v *Vaultik) formatVerifyResult(
if failure != "" { if failure != "" {
v.stdoutf("FAILED - %s\n", failure) v.stdoutf("FAILED - %s\n", failure)
return fmt.Errorf("%w: %s", errSnapshotVerifyFailed, failure) return
} }
// Report only what was actually checked: presence and size, not contents. // Report only what was actually checked: presence and size, not contents.
v.stdoutf("OK - all %d blobs listed in the manifest are present with the "+ v.stdoutf("OK - all %d blobs listed in the manifest are present with the "+
"listed size; contents not checked (use --deep)\n", result.Verified) "listed size; contents not checked (use --deep)\n", result.Verified)
return nil
} }
// shallowVerifyFailure returns a human-readable description of everything // shallowVerifyFailure returns a human-readable description of everything
+15 -12
View File
@@ -103,7 +103,7 @@ func (v *Vaultik) RunDeepVerify(snapshotID string, opts *VerifyOptions) error {
log.Info("Starting snapshot verification", "snapshot_id", snapshotID, "mode", "deep") log.Info("Starting snapshot verification", "snapshot_id", snapshotID, "mode", "deep")
if !opts.JSON { if !opts.JSON && !v.UI.Quiet() {
v.stdoutf("Deep verification of snapshot: %s\n\n", snapshotID) v.stdoutf("Deep verification of snapshot: %s\n\n", snapshotID)
} }
@@ -143,10 +143,13 @@ func (v *Vaultik) RunDeepVerify(snapshotID string, opts *VerifyOptions) error {
log.Info("✓ Verification completed successfully", log.Info("✓ Verification completed successfully",
"snapshot_id", snapshotID, "mode", "deep", "blobs_verified", len(dbBlobs)) "snapshot_id", snapshotID, "mode", "deep", "blobs_verified", len(dbBlobs))
v.stdoutf("\n✓ Verification completed successfully\n")
v.stdoutf(" Snapshot: %s\n", snapshotID) if !v.UI.Quiet() {
v.stdoutf(" Blobs verified: %d\n", len(dbBlobs)) v.stdoutf("\n✓ Verification completed successfully\n")
v.stdoutf(" Total size: %s\n", ubytes(totalSize)) v.stdoutf(" Snapshot: %s\n", snapshotID)
v.stdoutf(" Blobs verified: %d\n", len(dbBlobs))
v.stdoutf(" Total size: %s\n", ubytes(totalSize))
}
return nil return nil
} }
@@ -170,7 +173,7 @@ func (v *Vaultik) loadVerificationData(
// remote manifests; see its doc comment. // remote manifests; see its doc comment.
log.Info("Downloading manifest", "remote_key", remoteKey) log.Info("Downloading manifest", "remote_key", remoteKey)
if !opts.JSON { if !opts.JSON && !v.UI.Quiet() {
v.stdoutf("Downloading manifest...\n") v.stdoutf("Downloading manifest...\n")
} }
@@ -185,7 +188,7 @@ func (v *Vaultik) loadVerificationData(
"manifest_blob_count", manifest.BlobCount, "manifest_blob_count", manifest.BlobCount,
"manifest_total_size", ubytes(manifest.TotalCompressedSize)) "manifest_total_size", ubytes(manifest.TotalCompressedSize))
if !opts.JSON { if !opts.JSON && !v.UI.Quiet() {
v.stdoutf("Manifest loaded: %d blobs (%s)\n", v.stdoutf("Manifest loaded: %d blobs (%s)\n",
manifest.BlobCount, ubytes(manifest.TotalCompressedSize)) manifest.BlobCount, ubytes(manifest.TotalCompressedSize))
v.stdoutf("Downloading and decrypting database...\n") v.stdoutf("Downloading and decrypting database...\n")
@@ -215,7 +218,7 @@ func (v *Vaultik) loadVerificationData(
"db_blob_count", len(dbBlobs), "db_blob_count", len(dbBlobs),
"db_total_size", ubytes(dbTotalSize)) "db_total_size", ubytes(dbTotalSize))
if !opts.JSON { if !opts.JSON && !v.UI.Quiet() {
v.stdoutf("Database loaded: %d blobs (%s)\n", v.stdoutf("Database loaded: %d blobs (%s)\n",
len(dbBlobs), ubytes(dbTotalSize)) len(dbBlobs), ubytes(dbTotalSize))
} }
@@ -273,7 +276,7 @@ func (v *Vaultik) runVerificationSteps(
totalSize int64, totalSize int64,
identities []age.Identity, identities []age.Identity,
) error { ) error {
if !opts.JSON { if !opts.JSON && !v.UI.Quiet() {
v.stdoutf("Verifying manifest against database...\n") v.stdoutf("Verifying manifest against database...\n")
} }
@@ -282,7 +285,7 @@ func (v *Vaultik) runVerificationSteps(
return v.deepVerifyFailure(result, opts, err.Error(), err) return v.deepVerifyFailure(result, opts, err.Error(), err)
} }
if !opts.JSON { if !opts.JSON && !v.UI.Quiet() {
v.stdoutf("Manifest verified.\n") v.stdoutf("Manifest verified.\n")
v.stdoutf("Checking blob existence in remote storage...\n") v.stdoutf("Checking blob existence in remote storage...\n")
} }
@@ -292,7 +295,7 @@ func (v *Vaultik) runVerificationSteps(
return v.deepVerifyFailure(result, opts, err.Error(), err) return v.deepVerifyFailure(result, opts, err.Error(), err)
} }
if !opts.JSON { if !opts.JSON && !v.UI.Quiet() {
v.stdoutf("All blobs exist.\n") v.stdoutf("All blobs exist.\n")
v.stdoutf("Downloading and verifying blob contents (%d blobs, %s)...\n", v.stdoutf("Downloading and verifying blob contents (%d blobs, %s)...\n",
len(dbBlobs), ubytes(totalSize)) len(dbBlobs), ubytes(totalSize))
@@ -748,7 +751,7 @@ func (v *Vaultik) performDeepVerificationFromDB(
"eta", eta.Round(time.Second), "eta", eta.Round(time.Second),
) )
if !opts.JSON { if !opts.JSON && !v.UI.Quiet() {
v.stdoutf(" Verified %d/%d blobs (%d remaining) - %s/%s - elapsed %s, eta %s\n", v.stdoutf(" Verified %d/%d blobs (%d remaining) - %s/%s - elapsed %s, eta %s\n",
i+1, len(blobs), remaining, i+1, len(blobs), remaining,
ubytes(bytesProcessed), ubytes(bytesProcessed),
+102
View File
@@ -0,0 +1,102 @@
package vaultik_test
import (
"bytes"
"context"
"io"
"os"
"path/filepath"
"testing"
"github.com/spf13/afero"
"github.com/stretchr/testify/require"
"sneak.berlin/go/vaultik/internal/log"
"sneak.berlin/go/vaultik/internal/snapshot"
"sneak.berlin/go/vaultik/internal/ui"
"sneak.berlin/go/vaultik/internal/vaultik"
)
// TestVerify_QuietSuppressesReport is the --quiet contract for
// `snapshot verify`: neither shallow nor deep verify writes its report,
// a failed verify still returns its error (which the cli layer prints
// on stderr), and the --json document still emits.
func TestVerify_QuietSuppressesReport(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
fs := afero.NewOsFs()
tempDir := t.TempDir()
dataDir := filepath.Join(tempDir, "source")
storeDir := filepath.Join(tempDir, "remote")
dbPath := filepath.Join(tempDir, "index.sqlite")
chunkSize := int64(32 * 1024)
maxBlobSize := int64(128 * 1024)
require.NoError(t, fs.MkdirAll(dataDir, 0o755))
require.NoError(t, afero.WriteFile(fs,
filepath.Join(dataDir, "data.bin"),
bytesPattern("quiet-", int(maxBlobSize*2)), 0o644))
ctx := context.Background()
cfg, storer, snapshotID := runFileStorageBackup(
ctx, t, fs, dataDir, storeDir, dbPath, chunkSize, maxBlobSize)
// The UI writes to the same buffer as Stdout, as both write to the
// process's stdout in production.
var stdout bytes.Buffer
newQuietVerifier := func() *vaultik.Vaultik {
v := &vaultik.Vaultik{
Config: cfg,
Storage: storer,
Fs: fs,
Stdout: &stdout,
Stderr: io.Discard,
UI: ui.NewWithColor(&stdout, false),
}
v.SetContext(ctx)
v.UI.SetQuiet(true)
return v
}
require.NoError(t, newQuietVerifier().VerifySnapshotWithOptions(
snapshotID, &vaultik.VerifyOptions{}))
require.Empty(t, stdout.String(),
"shallow verify must write no report under --quiet")
require.NoError(t, newQuietVerifier().VerifySnapshotWithOptions(
snapshotID, &vaultik.VerifyOptions{Deep: true}))
require.Empty(t, stdout.String(),
"deep verify must write no report under --quiet")
require.NoError(t, newQuietVerifier().VerifySnapshotWithOptions(
snapshotID, &vaultik.VerifyOptions{JSON: true}))
require.Equal(t, "ok", decodeVerifyResult(t, stdout.Bytes()).Status,
"the --json document must still emit under --quiet")
// A snapshot without its encrypted database fails shallow verify. A
// failed report also lists each missing blob and each blob of the
// wrong size, so remove one blob and grow another.
require.NoError(t, os.Remove(filepath.Join(storeDir, "metadata",
snapshot.RemoteSnapshotKey(snapshotID), "db.zst.age")))
blobFiles, err := filepath.Glob(
filepath.Join(storeDir, "blobs", "*", "*", "*"))
require.NoError(t, err)
require.GreaterOrEqual(t, len(blobFiles), 2,
"the snapshot must span two blobs, one to remove and one to grow")
require.NoError(t, os.Remove(blobFiles[0]))
growOneBlob(t, fs, filepath.Join(storeDir, "blobs"))
stdout.Reset()
require.Error(t, newQuietVerifier().VerifySnapshotWithOptions(
snapshotID, &vaultik.VerifyOptions{}),
"--quiet must not change the outcome of a failed verify")
require.Empty(t, stdout.String(),
"a failed verify must write no report under --quiet")
}