3 Commits
Author SHA1 Message Date
clawbot eed117fe25 Route direct-stdout command output through internal/ui (closes #149)
check / check (pull_request) Successful in 1m27s
check / check (push) Successful in 3m18s
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
clawbot dd7a610c23 Mark a snapshot complete only after its metadata export succeeds (closes #177)
check / check (pull_request) Successful in 1m42s
check / check (push) Successful in 3m35s
finalizeSnapshotMetadata marked the snapshot complete and then exported its metadata. A crash after completion but before/during the export left the local index showing the snapshot complete while the destination had no manifest or database, and PruneDatabase (which drops only NULL completed_at rows) kept it: a silently unrestorable snapshot.

Reorder so completion is recorded last. CompleteSnapshot is split into PopulateSnapshotBlobs (before the export) and MarkSnapshotComplete (after it). An interrupted export now leaves the snapshot incomplete, so the next run PruneDatabase drops it and re-backs-up the data; the reverse tiny window leaves a restorable snapshot the index reports honestly as remote-only. Update REPOSTRUCTURE.md guarantee 4 and the ARCHITECTURE.md flow. Add a fault-injection test driving the full create path.

Model: opus-4-8
2026-09-22 20:00:41 +02:00
clawbot 1548c0f933 Correct the security claims in docs and comments, and record the accepted risks (closes #171)
check / check (pull_request) Successful in 1m58s
check / check (push) Successful in 2m49s
Docs and comments only; no behaviour change. Corrects ten overclaims the security review found: snapshot names are hashed but the hash uses no secret, so a guessed hostname and name can be confirmed; a blob is named by hex(SHA256(SHA256(uncompressed contents))), stated once in docs/REPOSTRUCTURE.md and referenced elsewhere; double hashing does not hide known content (blob packing does); age uses ChaCha20-Poly1305, not XChaCha20; encryption is required, not optional; a snapshot is marked complete before its metadata is uploaded; the export comment now matches its only caller; deep verify detects corruption, not authorship; adding a recipient does not reach existing data; restore examples target a user-owned directory.

Adds an Accepted Risks subsection under Security Considerations with the seven documented risks, cross-referenced from the README.

Model: opus-4-8
2026-09-22 19:28:31 +02:00
21 changed files with 696 additions and 91 deletions
+13 -9
View File
@@ -284,8 +284,10 @@ Manages snapshot lifecycle and metadata export.
Key methods: Key methods:
- `CreateSnapshot(ctx, hostname, version, commit)` → Create snapshot record - `CreateSnapshot(ctx, hostname, version, commit)` → Create snapshot record
- `CompleteSnapshot(ctx, snapshotID)`Mark snapshot complete - `PopulateSnapshotBlobs(ctx, snapshotID)`Record every blob the snapshot references
- `ExportSnapshotMetadata(ctx, dbPath, snapshotID)` → Export to S3 - `ExportSnapshotMetadata(ctx, dbPath, snapshotID)` → Export to S3
- `MarkSnapshotComplete(ctx, snapshotID)` → Record completion, only after a successful export
- `CompleteSnapshot(ctx, snapshotID)` → Convenience: populate blobs, then mark complete (no export between)
### `internal/database` ### `internal/database`
SQLite database for local index. Single-writer mode for thread safety. SQLite database for local index. Single-writer mode for thread safety.
@@ -335,16 +337,18 @@ CreateSnapshot(opts)
├─► SnapshotManager.UpdateSnapshotStatsExtended() ├─► SnapshotManager.UpdateSnapshotStatsExtended()
├─► SnapshotManager.CompleteSnapshot() ├─► SnapshotManager.PopulateSnapshotBlobs() // record referenced blobs
─► SnapshotManager.ExportSnapshotMetadata() ─► SnapshotManager.ExportSnapshotMetadata()
│ │
│ ├─► Copy database to temp file
│ ├─► Clean to only current snapshot data (VACUUM)
│ ├─► Compress binary SQLite with zstd
│ ├─► Encrypt with age
│ ├─► Upload db.zst.age to storage
│ └─► Upload manifest.json.zst to storage
├─► Copy database to temp file └─► SnapshotManager.MarkSnapshotComplete() // only after the export succeeds
├─► Clean to only current snapshot data (VACUUM)
├─► Compress binary SQLite with zstd
├─► Encrypt with age
├─► Upload db.zst.age to storage
└─► Upload manifest.json.zst to storage
``` ```
## Deduplication Strategy ## Deduplication Strategy
+20 -11
View File
@@ -645,17 +645,26 @@ priority.
## output style ## output style
The operational narration of the long-running commands — the Begin, Every command's user-facing output is governed by `internal/ui`, in one
Complete, Progress, and status lines of `snapshot create`, `prune`, of two ways. Color is enabled when stdout is a TTY and the `NO_COLOR`
`snapshot restore`, and the like — goes through helpers in `internal/ui` environment variable is unset (https://no-color.org/).
and conforms to the uniform style below. Some commands instead write
plain text straight to stdout (`version`, `info`, `config`, the * **Status, progress, warnings, and errors** go through the `internal/ui`
`database delete` prompt, and the `snapshot list` table); that output is message methods below: marker-prefixed, colored on a TTY, and — except
unstyled and does not honor `--quiet`. Routing it through `internal/ui` warnings and errors — silenced by `--quiet`. This is the operational
is tracked in narration of the long-running commands (`snapshot create`, `prune`,
[issue #149](https://git.eeqj.de/sneak/vaultik/issues/149). Color is `snapshot restore`, and the like) and the confirmations of
enabled when stdout is a TTY and the `NO_COLOR` environment variable is `config init`, `config set`, and `database delete`.
unset (https://no-color.org/). * **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
parsed document. This covers the `version`, `info`, and `remote info`
reports, the `snapshot list` table, `config get` values, and every
`--json` document. `--quiet` silences the human reports and tables
(`version`, `info`, `remote info`, `snapshot list`) but never the
machine-consumed `config get` value or the `--json` documents, which a
script depends on. The `database delete` confirmation prompt is also
written this way and 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.
Structured log records are a different thing and go through Structured log records are a different thing and go through
+15
View File
@@ -25,6 +25,21 @@ release" is exactly the contradiction
# Completed Steps # Completed Steps
- 2026-09-22: Routed the last direct-to-stdout command output through
`internal/ui`
([issue #149](https://git.eeqj.de/sneak/vaultik/issues/149)). The
`version`, `info`, `remote info`, `config`, and `database delete`
commands wrote plain text straight to stdout, so they were unstyled and
ignored `--quiet`. Output now falls in two buckets, both governed by
`internal/ui`: status lines and confirmations go through its message
methods (styled, and `--quiet` silences them), while the data a command
exists to produce — the reports, the `snapshot list` table, `config get`
values, and the `--json` documents — is written plain. `--quiet`
silences the human reports and tables but never the `config get` value
or the `--json` documents, which a script depends on, and the
`database delete` confirmation prompt is always shown. The README
output-style section now states this rule.
- 2026-09-22: Validated blob hashes, offsets and lengths read back from - 2026-09-22: Validated blob hashes, offsets and lengths read back from
the destination before using them the destination before using them
([issue #155](https://git.eeqj.de/sneak/vaultik/issues/155)). A blob ([issue #155](https://git.eeqj.de/sneak/vaultik/issues/155)). A blob
+1 -1
View File
@@ -153,7 +153,7 @@ These are known, deliberate properties of the format and the tooling, recorded s
1. **Blobs are immutable** - Once written, a blob is never modified 1. **Blobs are immutable** - Once written, a blob is never modified
2. **Blobs are written before metadata** - A snapshot's metadata is only written after all its blobs are successfully uploaded 2. **Blobs are written before metadata** - A snapshot's metadata is only written after all its blobs are successfully uploaded
3. **Metadata is written atomically** - Both db.zst.age and manifest.json.zst are written as complete files 3. **Metadata is written atomically** - Both db.zst.age and manifest.json.zst are written as complete files
4. **A snapshot is marked complete in the local DB before its metadata is uploaded, not after** - `CompleteSnapshot` runs first, then `ExportSnapshotMetadata` (see the backup data flow in [ARCHITECTURE.md](../ARCHITECTURE.md)). A crash between the two leaves a completed-looking row in the local index with no matching metadata on the destination store. `vaultik prune` reconciles this away: it drops any local snapshot whose remote metadata is missing. 4. **A snapshot is marked complete in the local DB only after its metadata is uploaded** - `finalizeSnapshotMetadata` runs `ExportSnapshotMetadata` first and records completion (`MarkSnapshotComplete`) only once the export succeeds (see the backup data flow in [ARCHITECTURE.md](../ARCHITECTURE.md)). A crash during the export therefore leaves the snapshot incomplete, so the next backup's `PruneDatabase` drops it and re-backs-up its data, rather than leaving a completed-looking row in the local index with no matching metadata on the destination store. (A crash in the brief moment after the export succeeds but before completion is recorded leaves a fully-restorable snapshot on the destination that the local index drops as incomplete on the next run; `snapshot list` then reports it honestly as remote-only, which is the safe direction: the destination copy stays restorable.)
## Pruning Safety ## Pruning Safety
+25 -17
View File
@@ -4,7 +4,6 @@ import (
"bytes" "bytes"
"errors" "errors"
"fmt" "fmt"
"io"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
@@ -13,6 +12,7 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
"sneak.berlin/go/vaultik/internal/ui"
) )
// configFileMode is the permission set for freshly written config files; // configFileMode is the permission set for freshly written config files;
@@ -265,7 +265,7 @@ The config is written to the path from --config, $VAULTIK_CONFIG, or
the platform default config directory (e.g. ~/Library/Application Support/ the platform default config directory (e.g. ~/Library/Application Support/
on macOS, ~/.config/ on Linux, /etc/vaultik/ as root).`, on macOS, ~/.config/ on Linux, /etc/vaultik/ as root).`,
Args: cobra.NoArgs, Args: cobra.NoArgs,
RunE: func(_ *cobra.Command, _ []string) error { RunE: func(cmd *cobra.Command, _ []string) error {
path := configPathForInit() path := configPathForInit()
_, err := os.Stat(path) _, err := os.Stat(path)
@@ -285,8 +285,11 @@ on macOS, ~/.config/ on Linux, /etc/vaultik/ as root).`,
return fmt.Errorf("writing config file: %w", err) return fmt.Errorf("writing config file: %w", err)
} }
_, _ = fmt.Fprintf(os.Stdout, "Config written to %s\n", path) // A written-confirmation, not scriptable output: route it
_, _ = fmt.Fprintln(os.Stdout, // through the UI so it is styled and --quiet silences it.
out := commandUI(cmd)
out.Infof("Config written to %s.", path)
out.Infof(
"Edit it to set your age_recipients, snapshots, and storage_url.") "Edit it to set your age_recipients, snapshots, and storage_url.")
return nil return nil
@@ -328,7 +331,7 @@ func newConfigGetCommand() *cobra.Command {
Use: "get <key>", Use: "get <key>",
Short: "Print a config value by dotted path (e.g. storage_url, compression_level)", Short: "Print a config value by dotted path (e.g. storage_url, compression_level)",
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
RunE: func(_ *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
path, err := ResolveConfigPath() path, err := ResolveConfigPath()
if err != nil { if err != nil {
return err return err
@@ -344,8 +347,13 @@ func newConfigGetCommand() *cobra.Command {
return err return err
} }
// The value is scriptable output: it must stay machine-plain
// (no marker, no color) and is never silenced by --quiet, so it
// is written straight to stdout rather than through the UI.
w := cmd.OutOrStdout()
if node.Kind == yaml.ScalarNode { if node.Kind == yaml.ScalarNode {
_, _ = fmt.Fprintln(os.Stdout, node.Value) _, _ = fmt.Fprintln(w, node.Value)
return nil return nil
} }
@@ -355,7 +363,7 @@ func newConfigGetCommand() *cobra.Command {
return fmt.Errorf("marshaling value: %w", err) return fmt.Errorf("marshaling value: %w", err)
} }
_, _ = fmt.Fprint(os.Stdout, string(out)) _, _ = fmt.Fprint(w, string(out))
return nil return nil
}, },
@@ -377,23 +385,23 @@ Examples:
vaultik config set compression_level 9 vaultik config set compression_level 9
vaultik config set s3.bucket mybucket # legacy S3 fields still supported`, vaultik config set s3.bucket mybucket # legacy S3 fields still supported`,
Args: cobra.ExactArgs(configSetArgs), Args: cobra.ExactArgs(configSetArgs),
RunE: func(_ *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
path, err := ResolveConfigPath() path, err := ResolveConfigPath()
if err != nil { if err != nil {
return err return err
} }
return writeConfigSet(os.Stdout, path, args[0], args[1]) return writeConfigSet(commandUI(cmd), path, args[0], args[1])
}, },
} }
} }
// writeConfigSet applies key=value to the config at path, writes it back // writeConfigSet applies key=value to the config at path, writes it back
// owner-only, and confirms the write by printing just the key name to w. // owner-only, and confirms the write by naming just the key through the
// The value is never echoed: it may be a secret such as // UI writer (styled, and silenced by --quiet). The value is never
// s3.secret_access_key, and captured stdout or a pasted terminal would // echoed: it may be a secret such as s3.secret_access_key, and captured
// then leak it. // stdout or a pasted terminal would then leak it.
func writeConfigSet(w io.Writer, path, key, value string) error { func writeConfigSet(out *ui.Writer, path, key, value string) error {
root, err := loadYAMLFile(path) root, err := loadYAMLFile(path)
if err != nil { if err != nil {
return err return err
@@ -404,12 +412,12 @@ func writeConfigSet(w io.Writer, path, key, value string) error {
return err return err
} }
out, err := marshalConfigYAML(root) data, err := marshalConfigYAML(root)
if err != nil { if err != nil {
return fmt.Errorf("marshaling config: %w", err) return fmt.Errorf("marshaling config: %w", err)
} }
err = os.WriteFile(path, out, configFileMode) err = os.WriteFile(path, data, configFileMode)
if err != nil { if err != nil {
return fmt.Errorf("writing config file: %w", err) return fmt.Errorf("writing config file: %w", err)
} }
@@ -425,7 +433,7 @@ func writeConfigSet(w io.Writer, path, key, value string) error {
} }
} }
_, _ = fmt.Fprintln(w, key) out.Infof("Set %s.", key)
return nil return nil
} }
+11 -8
View File
@@ -9,6 +9,7 @@ import (
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
"sneak.berlin/go/vaultik/internal/config" "sneak.berlin/go/vaultik/internal/config"
"sneak.berlin/go/vaultik/internal/ui"
) )
// TestDefaultConfigTemplateParses ensures the init template is valid YAML // TestDefaultConfigTemplateParses ensures the init template is valid YAML
@@ -246,19 +247,20 @@ func TestWriteConfigSetHidesSecret(t *testing.T) {
t.Fatalf("seed config: %v", err) t.Fatalf("seed config: %v", err)
} }
var out bytes.Buffer var buf bytes.Buffer
err = writeConfigSet(&out, path, "s3.secret_access_key", secret) err = writeConfigSet(ui.NewWithColor(&buf, false), path,
"s3.secret_access_key", secret)
if err != nil { if err != nil {
t.Fatalf("writeConfigSet: %v", err) t.Fatalf("writeConfigSet: %v", err)
} }
if strings.Contains(out.String(), secret) { if strings.Contains(buf.String(), secret) {
t.Errorf("output echoed the secret value: %q", out.String()) t.Errorf("output echoed the secret value: %q", buf.String())
} }
if !strings.Contains(out.String(), "s3.secret_access_key") { if !strings.Contains(buf.String(), "s3.secret_access_key") {
t.Errorf("output did not confirm the key name: %q", out.String()) t.Errorf("output did not confirm the key name: %q", buf.String())
} }
} }
@@ -277,9 +279,10 @@ func TestWriteConfigSetTightensMode(t *testing.T) {
t.Fatalf("seed config: %v", err) t.Fatalf("seed config: %v", err)
} }
var out bytes.Buffer var buf bytes.Buffer
err = writeConfigSet(&out, path, "compression_level", "9") err = writeConfigSet(ui.NewWithColor(&buf, false), path,
"compression_level", "9")
if err != nil { if err != nil {
t.Fatalf("writeConfigSet: %v", err) t.Fatalf("writeConfigSet: %v", err)
} }
+12 -11
View File
@@ -48,7 +48,7 @@ storage destination on that run.
Use --force to skip the confirmation prompt.`, Use --force to skip the confirmation prompt.`,
Args: cobra.NoArgs, Args: cobra.NoArgs,
RunE: func(_ *cobra.Command, _ []string) error { RunE: func(cmd *cobra.Command, _ []string) error {
// Resolve config path // Resolve config path
configPath, err := ResolveConfigPath() configPath, err := ResolveConfigPath()
if err != nil { if err != nil {
@@ -62,26 +62,31 @@ Use --force to skip the confirmation prompt.`,
} }
dbPath := cfg.IndexPath dbPath := cfg.IndexPath
out := commandUI(cmd)
// Check if database exists // Check if database exists
_, err = os.Stat(dbPath) _, err = os.Stat(dbPath)
if os.IsNotExist(err) { if os.IsNotExist(err) {
_, _ = fmt.Fprintf(os.Stdout, "Database does not exist: %s\n", dbPath) out.Infof("Local state database does not exist: %s.", dbPath)
return nil return nil
} }
// Confirm unless --force // Confirm unless --force. The prompt and its immediate result
// are an interactive exchange the operator must see, so they go
// straight to stdout rather than through the UI and --quiet does
// not silence them.
if !force { if !force {
_, _ = fmt.Fprintf(os.Stdout, w := cmd.OutOrStdout()
_, _ = fmt.Fprintf(w,
"This will delete the local state database at:\n %s\n\n", dbPath) "This will delete the local state database at:\n %s\n\n", dbPath)
_, _ = fmt.Fprint(os.Stdout, "Are you sure? Type 'yes' to confirm: ") _, _ = fmt.Fprint(w, "Are you sure? Type 'yes' to confirm: ")
var confirm string var confirm string
_, err = fmt.Scanln(&confirm) _, err = fmt.Scanln(&confirm)
if err != nil || confirm != "yes" { if err != nil || confirm != "yes" {
_, _ = fmt.Fprintln(os.Stdout, "Aborted.") _, _ = fmt.Fprintln(w, "Aborted.")
//nolint:nilerr // a failed/aborted confirmation is a clean abort //nolint:nilerr // a failed/aborted confirmation is a clean abort
return nil return nil
@@ -100,11 +105,7 @@ Use --force to skip the confirmation prompt.`,
_ = os.Remove(walPath) // Ignore errors - files may not exist _ = os.Remove(walPath) // Ignore errors - files may not exist
_ = os.Remove(shmPath) _ = os.Remove(shmPath)
rootFlags := GetRootFlags() out.Infof("Local state database deleted: %s.", dbPath)
if !rootFlags.Quiet {
_, _ = fmt.Fprintf(os.Stdout, "Database deleted: %s\n", dbPath)
}
log.Info("Local state database deleted", "path", dbPath) log.Info("Local state database deleted", "path", dbPath)
return nil return nil
+206
View File
@@ -0,0 +1,206 @@
package cli //nolint:testpackage // sets the unexported rootFlags directly
import (
"bytes"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"github.com/spf13/cobra"
)
// setRootFlags overrides the global rootFlags for the duration of one
// test and restores it afterward. These tests must not run in parallel:
// the flags are process-global, so the whole struct is saved and put
// back rather than left mutated for the next test.
func setRootFlags(t *testing.T, f RootFlags) {
t.Helper()
old := rootFlags
rootFlags = f
t.Cleanup(func() { rootFlags = old })
}
// seedFile writes content to a fresh file and returns its path.
func seedFile(t *testing.T, dir, name, content string) string {
t.Helper()
path := filepath.Join(dir, name)
err := os.WriteFile(path, []byte(content), 0o600)
if err != nil {
t.Fatalf("seeding %s: %v", name, err)
}
return path
}
// mustExecute runs a command with its output captured and fails the test
// if it errors, returning what the command printed.
func mustExecute(t *testing.T, cmd *cobra.Command, args ...string) string {
t.Helper()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetArgs(args)
err := cmd.Execute()
if err != nil {
t.Fatalf("%s failed: %v", cmd.Name(), err)
}
return out.String()
}
// TestVersionQuietSuppressesReport checks that --quiet silences the whole
// version report: it is human-facing output, not a scriptable value.
//
//nolint:paralleltest // mutates the process-global rootFlags
func TestVersionQuietSuppressesReport(t *testing.T) {
setRootFlags(t, RootFlags{Quiet: true})
out := mustExecute(t, NewVersionCommand())
if out != "" {
t.Errorf("--quiet version printed %q, want nothing", out)
}
}
// TestConfigGetIgnoresQuiet checks that a config value is printed even
// under --quiet: it is scriptable output a caller depends on, so --quiet
// must not suppress it, and it stays machine-plain (no marker, no color).
//
//nolint:paralleltest // mutates the process-global rootFlags
func TestConfigGetIgnoresQuiet(t *testing.T) {
dir := t.TempDir()
path := seedFile(t, dir, "config.yml", "storage_url: file:///mnt/x\n")
setRootFlags(t, RootFlags{Quiet: true, ConfigPath: path})
out := mustExecute(t, newConfigGetCommand(), "storage_url")
if out != "file:///mnt/x\n" {
t.Errorf("config get --quiet = %q, want the plain value", out)
}
}
// TestConfigSetQuietSuppressesConfirmation checks that --quiet silences
// the confirmation line while still writing the value to the file.
//
//nolint:paralleltest // mutates the process-global rootFlags
func TestConfigSetQuietSuppressesConfirmation(t *testing.T) {
dir := t.TempDir()
path := seedFile(t, dir, "config.yml", "compression_level: 3\n")
setRootFlags(t, RootFlags{Quiet: true, ConfigPath: path})
out := mustExecute(t, newConfigSetCommand(), "compression_level", "9")
if out != "" {
t.Errorf("--quiet config set printed %q, want nothing", out)
}
data, err := os.ReadFile(path) //nolint:gosec // G304: test-controlled path
if err != nil {
t.Fatalf("reading config back: %v", err)
}
if !strings.Contains(string(data), "compression_level: 9") {
t.Errorf("config set did not write the value under --quiet:\n%s", data)
}
}
// TestConfigSetConfirmsWhenNotQuiet checks that the confirmation names
// the key (styled) when --quiet is not set.
//
//nolint:paralleltest // mutates the process-global rootFlags
func TestConfigSetConfirmsWhenNotQuiet(t *testing.T) {
dir := t.TempDir()
path := seedFile(t, dir, "config.yml", "compression_level: 3\n")
setRootFlags(t, RootFlags{ConfigPath: path})
out := mustExecute(t, newConfigSetCommand(), "compression_level", "9")
if !strings.Contains(out, "compression_level") {
t.Errorf("config set did not confirm the key: %q", out)
}
}
// TestConfigInitQuietSuppressesConfirmation checks that --quiet silences
// the "config written" confirmation while still writing the file.
//
//nolint:paralleltest // mutates the process-global rootFlags
func TestConfigInitQuietSuppressesConfirmation(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "new-config.yml")
setRootFlags(t, RootFlags{Quiet: true, ConfigPath: path})
out := mustExecute(t, newConfigInitCommand())
if out != "" {
t.Errorf("--quiet config init printed %q, want nothing", out)
}
_, err := os.Stat(path)
if err != nil {
t.Errorf("config init did not write the file under --quiet: %v", err)
}
}
// seedDatabaseDeleteConfig writes a valid config whose index_path is a
// seeded database file, and returns both paths.
func seedDatabaseDeleteConfig(t *testing.T, dir string) (string, string) {
t.Helper()
dbPath := seedFile(t, dir, "index.sqlite", "not-a-real-db")
cfg := fmt.Sprintf(hermeticConfig,
filepath.Join(dir, "source"), filepath.Join(dir, "store"), dbPath)
cfgPath := seedFile(t, dir, "config.yml", cfg)
return dbPath, cfgPath
}
// TestDatabaseDeleteQuietSuppressesMessage checks that --quiet silences
// the "database deleted" line while still removing the file.
//
//nolint:paralleltest // mutates the process-global rootFlags
func TestDatabaseDeleteQuietSuppressesMessage(t *testing.T) {
dir := t.TempDir()
dbPath, cfgPath := seedDatabaseDeleteConfig(t, dir)
setRootFlags(t, RootFlags{Quiet: true, ConfigPath: cfgPath})
out := mustExecute(t, newDatabaseDeleteCommand(), "--force")
if out != "" {
t.Errorf("--quiet database delete printed %q, want nothing", out)
}
_, err := os.Stat(dbPath)
if !os.IsNotExist(err) {
t.Errorf("database delete did not remove the file: stat err = %v", err)
}
}
// TestDatabaseDeleteReportsWhenNotQuiet checks that the deletion is
// reported when --quiet is not set.
//
//nolint:paralleltest // mutates the process-global rootFlags
func TestDatabaseDeleteReportsWhenNotQuiet(t *testing.T) {
dir := t.TempDir()
_, cfgPath := seedDatabaseDeleteConfig(t, dir)
setRootFlags(t, RootFlags{ConfigPath: cfgPath})
out := mustExecute(t, newDatabaseDeleteCommand(), "--force")
if !strings.Contains(out, "deleted") {
t.Errorf("database delete did not report the deletion: %q", out)
}
}
+15
View File
@@ -9,6 +9,7 @@ import (
"github.com/adrg/xdg" "github.com/adrg/xdg"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"sneak.berlin/go/vaultik/internal/ui"
) )
// errConfigNotFound is wrapped by all config-resolution failures. // errConfigNotFound is wrapped by all config-resolution failures.
@@ -81,6 +82,20 @@ func GetRootFlags() RootFlags {
return rootFlags return rootFlags
} }
// commandUI returns a UI writer for a command's stdout, in quiet mode
// when the global --quiet flag is set. This is how the pure-cli
// commands (version, config, database) reach internal/ui: color follows
// the writer (a TTY gets color, a captured test buffer does not), and
// --quiet silences the same message classes it silences everywhere else.
func commandUI(cmd *cobra.Command) *ui.Writer {
w := ui.New(cmd.OutOrStdout())
if GetRootFlags().Quiet {
w.SetQuiet(true)
}
return w
}
// ResolveConfigPath resolves the config file path from flags, environment, or default. // ResolveConfigPath resolves the config file path from flags, environment, or default.
// Search order: --config flag, VAULTIK_CONFIG env, XDG config dir, // Search order: --config flag, VAULTIK_CONFIG env, XDG config dir,
// /etc/vaultik/config.yml. // /etc/vaultik/config.yml.
+2 -1
View File
@@ -192,7 +192,8 @@ func newSnapshotVerifyCommand() *cobra.Command {
Long: "Checks that every blob the snapshot's manifest lists is present\n" + Long: "Checks that every blob the snapshot's manifest lists is present\n" +
"in storage with the size the manifest records, and that the\n" + "in storage with the size the manifest records, and that the\n" +
"snapshot's encrypted database is present. It does not read blob\n" + "snapshot's encrypted database is present. It does not read blob\n" +
"contents; use --deep to download and cryptographically verify them.\n\n" + "contents; use --deep to download, decrypt, and re-hash every blob\n" +
"to detect corruption -- integrity, not who wrote it.\n\n" +
"The snapshot may be named by its ID or, on a host with no local\n" + "The snapshot may be named by its ID or, on a host with no local\n" +
"index, by the remote key that 'snapshot list' prints for a\n" + "index, by the remote key that 'snapshot list' prints for a\n" +
"remote-only snapshot (an unambiguous leading part is enough).", "remote-only snapshot (an unambiguous leading part is enough).",
+14 -5
View File
@@ -2,11 +2,11 @@ package cli
import ( import (
"fmt" "fmt"
"io"
"runtime" "runtime"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"sneak.berlin/go/vaultik/internal/globals" "sneak.berlin/go/vaultik/internal/globals"
"sneak.berlin/go/vaultik/internal/ui"
) )
// NewVersionCommand creates the version command // NewVersionCommand creates the version command
@@ -17,16 +17,25 @@ func NewVersionCommand() *cobra.Command {
Long: `Print version, git commit, and build information for vaultik.`, Long: `Print version, git commit, and build information for vaultik.`,
Args: cobra.NoArgs, Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, _ []string) { Run: func(cmd *cobra.Command, _ []string) {
writeVersion(cmd.OutOrStdout()) writeVersion(commandUI(cmd))
}, },
} }
return cmd return cmd
} }
// writeVersion prints the version report. It takes a writer rather than // writeVersion prints the version report through the UI writer. The
// using os.Stdout directly so the output can be asserted on in tests. // report is the output this command exists to produce, so it is written
func writeVersion(w io.Writer) { // plain (markers would corrupt the aligned report) via the writer's
// underlying stdout; --quiet silences it like any other non-error
// output.
func writeVersion(out *ui.Writer) {
if out.Quiet() {
return
}
w := out.Out()
_, _ = fmt.Fprintf(w, "vaultik %s\n", globals.Version) _, _ = fmt.Fprintf(w, "vaultik %s\n", globals.Version)
_, _ = fmt.Fprintf(w, " commit: %s\n", globals.Commit) _, _ = fmt.Fprintf(w, " commit: %s\n", globals.Commit)
_, _ = fmt.Fprintf(w, " build date: %s\n", globals.CommitDate) _, _ = fmt.Fprintf(w, " build date: %s\n", globals.CommitDate)
+4 -4
View File
@@ -34,9 +34,9 @@ func runVersionCommand(t *testing.T) string {
// the report is the version the binary was actually built with. The // the report is the version the binary was actually built with. The
// test binary carries no -ldflags, so that is the "dev" default -- the // test binary carries no -ldflags, so that is the "dev" default -- the
// same string an untagged `make vaultik` build stamps a prefix of. // same string an untagged `make vaultik` build stamps a prefix of.
//
//nolint:paralleltest // executes a command that reads the global rootFlags
func TestVersionCommandReportsBuildVersion(t *testing.T) { func TestVersionCommandReportsBuildVersion(t *testing.T) {
t.Parallel()
out := runVersionCommand(t) out := runVersionCommand(t)
wantFirst := "vaultik " + globals.Version wantFirst := "vaultik " + globals.Version
@@ -55,9 +55,9 @@ func TestVersionCommandReportsBuildVersion(t *testing.T) {
// being exactly "dev", so once untagged builds started carrying their // being exactly "dev", so once untagged builds started carrying their
// commit sha it would have gone silent and an unreleased binary would // commit sha it would have gone silent and an unreleased binary would
// have looked like a release. // have looked like a release.
//
//nolint:paralleltest // executes a command that reads the global rootFlags
func TestVersionCommandFlagsDevelopmentBuild(t *testing.T) { func TestVersionCommandFlagsDevelopmentBuild(t *testing.T) {
t.Parallel()
if !globals.IsDevVersion(globals.Version) { if !globals.IsDevVersion(globals.Version) {
t.Skipf("test binary was stamped with release version %q", t.Skipf("test binary was stamped with release version %q",
globals.Version) globals.Version)
+4 -2
View File
@@ -3,8 +3,10 @@
// //
// Blobs in Vaultik are the final storage units uploaded to S3. Each blob is a // Blobs in Vaultik are the final storage units uploaded to S3. Each blob is a
// large (up to 10GB) file containing many compressed and encrypted chunks from // large (up to 10GB) file containing many compressed and encrypted chunks from
// multiple source files. Blobs are content-addressed, meaning their filename // multiple source files. Blobs are content-addressed: the filename in S3 is
// is derived from their SHA256 hash after compression and encryption. // hex(SHA256(SHA256(uncompressed blob contents))), computed from the chunk data
// before compression and encryption (not from the stored bytes). See
// blobgen.DoubleSHA256 and docs/REPOSTRUCTURE.md.
// //
// Schema is managed via numbered SQL migrations embedded in the schema/ // Schema is managed via numbered SQL migrations embedded in the schema/
// directory. Migration 000.sql bootstraps the schema_migrations tracking // directory. Migration 000.sql bootstraps the schema_migrations tracking
+43 -5
View File
@@ -201,11 +201,14 @@ func (sm *SnapshotManager) UpdateSnapshotStatsExtended(
}) })
} }
// CompleteSnapshot marks a snapshot as completed and ensures snapshot_blobs // PopulateSnapshotBlobs ensures snapshot_blobs holds an entry for every
// is populated with every blob holding any chunk referenced by the // blob that stores a chunk referenced by the snapshot's files, including
// snapshot's files (including deduplicated blobs uploaded by prior // blobs deduplicated from earlier snapshots. Without it, a fully
// snapshots). Without this, fully-deduplicated snapshots are unrestorable. // deduplicated snapshot would record no blobs and be unrestorable.
func (sm *SnapshotManager) CompleteSnapshot( //
// This must run before ExportSnapshotMetadata: the blob manifest and the
// trimmed metadata database are both built from snapshot_blobs.
func (sm *SnapshotManager) PopulateSnapshotBlobs(
ctx context.Context, snapshotID string, ctx context.Context, snapshotID string,
) error { ) error {
err := sm.repos.WithTx(ctx, func(ctx context.Context, tx *sql.Tx) error { err := sm.repos.WithTx(ctx, func(ctx context.Context, tx *sql.Tx) error {
@@ -219,6 +222,25 @@ func (sm *SnapshotManager) CompleteSnapshot(
"snapshot_id", snapshotID, "added", added) "snapshot_id", snapshotID, "added", added)
} }
return nil
})
if err != nil {
return fmt.Errorf("populating snapshot blobs: %w", err)
}
return nil
}
// MarkSnapshotComplete records the snapshot's completion timestamp. On the
// backup path this runs only after ExportSnapshotMetadata has succeeded, so
// the local index never marks a snapshot complete while the destination
// holds no manifest or database for it. A crash before this point leaves the
// snapshot incomplete, and the next run's PruneDatabase drops it. See
// https://git.eeqj.de/sneak/vaultik/issues/177.
func (sm *SnapshotManager) MarkSnapshotComplete(
ctx context.Context, snapshotID string,
) error {
err := sm.repos.WithTx(ctx, func(ctx context.Context, tx *sql.Tx) error {
return sm.repos.Snapshots.MarkComplete(ctx, tx, snapshotID) return sm.repos.Snapshots.MarkComplete(ctx, tx, snapshotID)
}) })
if err != nil { if err != nil {
@@ -230,6 +252,22 @@ func (sm *SnapshotManager) CompleteSnapshot(
return nil return nil
} }
// CompleteSnapshot populates snapshot_blobs and then marks the snapshot
// complete. The backup path (finalizeSnapshotMetadata) instead calls the two
// halves separately, with the metadata export between them, so completion is
// recorded only after a successful export. This convenience is for callers
// that do not interleave an export.
func (sm *SnapshotManager) CompleteSnapshot(
ctx context.Context, snapshotID string,
) error {
err := sm.PopulateSnapshotBlobs(ctx, snapshotID)
if err != nil {
return err
}
return sm.MarkSnapshotComplete(ctx, snapshotID)
}
// ExportSnapshotMetadata exports snapshot metadata to S3 // ExportSnapshotMetadata exports snapshot metadata to S3
// //
// This method executes the complete snapshot metadata export process: // This method executes the complete snapshot metadata export process:
+185 -3
View File
@@ -14,6 +14,7 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/vaultik/internal/config" "sneak.berlin/go/vaultik/internal/config"
"sneak.berlin/go/vaultik/internal/database" "sneak.berlin/go/vaultik/internal/database"
"sneak.berlin/go/vaultik/internal/globals"
"sneak.berlin/go/vaultik/internal/log" "sneak.berlin/go/vaultik/internal/log"
"sneak.berlin/go/vaultik/internal/snapshot" "sneak.berlin/go/vaultik/internal/snapshot"
"sneak.berlin/go/vaultik/internal/storage" "sneak.berlin/go/vaultik/internal/storage"
@@ -417,9 +418,10 @@ func TestBackupRetryAfterInterruptedUploadIsRestorable(t *testing.T) {
// database is uploaded but before the manifest. The destination is left // database is uploaded but before the manifest. The destination is left
// with blobs and a database but no manifest. verify and snapshot list // with blobs and a database but no manifest. verify and snapshot list
// must report the damage honestly rather than crashing or passing. // must report the damage honestly rather than crashing or passing.
// Automatic detection and repair of this partial state on the next run // Automatic detection and repair of this partial state on the next run is
// is tracked in https://git.eeqj.de/sneak/vaultik/issues/177 and is not // covered by TestBackupCompletesOnlyAfterMetadataExport
// asserted here. // (https://git.eeqj.de/sneak/vaultik/issues/177); this test exercises the
// lower-level export path in isolation.
// //
//nolint:paralleltest // installs the global logger via log.Initialize //nolint:paralleltest // installs the global logger via log.Initialize
func TestBackupSurvivesMetadataExportInterruption(t *testing.T) { func TestBackupSurvivesMetadataExportInterruption(t *testing.T) {
@@ -496,6 +498,186 @@ func TestBackupSurvivesMetadataExportInterruption(t *testing.T) {
"snapshot list must tolerate a partially-exported snapshot") "snapshot list must tolerate a partially-exported snapshot")
} }
// Scenario 2, repair: the process dies during the metadata export of a
// full backup run. Because completion is recorded only after the export
// succeeds (finalizeSnapshotMetadata), the interrupted snapshot is left
// incomplete rather than silently marked complete without metadata at the
// destination. Rerunning the backup must then prune the incomplete
// snapshot, produce a snapshot whose destination metadata and local index
// agree, and restore. See https://git.eeqj.de/sneak/vaultik/issues/177.
//
//nolint:paralleltest // installs the global logger via log.Initialize
func TestBackupCompletesOnlyAfterMetadataExport(t *testing.T) {
log.Initialize(log.Config{})
fs := afero.NewOsFs()
tempDir := t.TempDir()
dataDir := filepath.Join(tempDir, "src")
storeDir := filepath.Join(tempDir, "remote")
restoreDir := filepath.Join(tempDir, "restored")
dbPath := filepath.Join(tempDir, "index.sqlite")
ctx := context.Background()
testFiles := writeFaultSourceTree(t, fs, dataDir)
// A full-backup config: the fault-test defaults plus the fields the
// production create path reads (index location, chunk size, and the
// named snapshot to back up).
cfg := faultTestConfig()
cfg.IndexPath = dbPath
cfg.ChunkSize = config.Size(faultChunkSize)
cfg.Snapshots = map[string]config.SnapshotConfig{
"data": {Paths: []string{dataDir}},
}
inner, err := storage.NewFileStorer(storeDir)
require.NoError(t, err)
db, err := database.New(ctx, dbPath)
require.NoError(t, err)
repos := database.NewRepositories(db)
// failManifest is on for the first backup and off for the retry, so the
// manifest upload fails once — interrupting the export mid-way — then
// succeeds.
failManifest := true
store := faultstore.New(inner)
store.OnPut = func(key string) faultstore.PutAction {
if failManifest && strings.HasSuffix(key, "manifest.json.zst") {
return faultstore.PutFail
}
return faultstore.PutNormal
}
v := newBackupVaultik(ctx, cfg, store, repos, db, fs)
opts := &vaultik.SnapshotCreateOptions{Cron: true}
// First run: the export fails at the manifest upload, so the whole
// create fails and the snapshot is left incomplete.
require.Error(t, v.CreateSnapshot(opts),
"backup must fail when the metadata export is interrupted")
incompletes, err := repos.Snapshots.GetIncompleteSnapshots(ctx)
require.NoError(t, err)
require.Len(t, incompletes, 1,
"an interrupted export must leave exactly one incomplete snapshot")
afterFirst, err := repos.Snapshots.ListRecent(ctx, listRecentTestLimit)
require.NoError(t, err)
for _, s := range afterFirst {
require.Nil(t, s.CompletedAt,
"no snapshot may be marked complete before its metadata is exported")
}
// Second run on the same index and destination: the retry succeeds.
failManifest = false
require.NoError(t, v.CreateSnapshot(opts),
"a retry after an interrupted export must succeed")
assertRetryConsistentAndRestorable(
ctx, t, cfg, inner, repos, db, fs, restoreDir, testFiles)
}
// assertRetryConsistentAndRestorable checks the end state after the retry
// backup in TestBackupCompletesOnlyAfterMetadataExport: the interrupted
// snapshot is pruned, exactly one completed snapshot remains, its metadata
// is at the destination, and it restores to the original tree.
func assertRetryConsistentAndRestorable(
ctx context.Context, t *testing.T, cfg *config.Config,
inner storage.Storer, repos *database.Repositories, db *database.DB,
fs afero.Fs, restoreDir string, testFiles map[string][]byte,
) {
t.Helper()
incompletes, err := repos.Snapshots.GetIncompleteSnapshots(ctx)
require.NoError(t, err)
assert.Empty(t, incompletes,
"the next run's prune must drop the interrupted snapshot")
local, err := repos.Snapshots.ListRecent(ctx, listRecentTestLimit)
require.NoError(t, err)
require.Len(t, local, 1, "exactly one snapshot must remain after the retry")
final := local[0]
require.NotNil(t, final.CompletedAt, "the retry's snapshot must be complete")
// The destination and the local index agree: the completed snapshot has
// both its metadata objects at the destination.
key := snapshot.RemoteSnapshotKey(final.ID.String())
_, err = inner.Stat(ctx, "metadata/"+key+"/manifest.json.zst")
require.NoError(t, err, "the completed snapshot's manifest must be at the destination")
_, err = inner.Stat(ctx, "metadata/"+key+"/db.zst.age")
require.NoError(t, err, "the completed snapshot's database must be at the destination")
require.NoError(t, db.Close())
// The snapshot restores from the destination alone.
reader := newReaderVaultik(ctx, cfg, inner, nil, fs)
require.NoError(t, reader.Restore(&vaultik.RestoreOptions{
SnapshotID: final.ID.String(),
TargetDir: restoreDir,
Verify: true,
}), "the retry's snapshot must be restorable")
assertRestoredTree(t, fs, restoreDir, testFiles)
}
// listRecentTestLimit is a generous cap for the handful of snapshots these
// tests create when reading the local index directly.
const listRecentTestLimit = 100
// newBackupVaultik builds a Vaultik that runs the full create path
// (CreateSnapshot) writing through storer, wiring the same scanner factory
// and snapshot manager the production dependency graph provides.
func newBackupVaultik(
ctx context.Context, cfg *config.Config, storer storage.Storer,
repos *database.Repositories, db *database.DB, fs afero.Fs,
) *vaultik.Vaultik {
v := &vaultik.Vaultik{
Globals: &globals.Globals{Version: "v", Commit: "g"},
Config: cfg,
DB: db,
Repositories: repos,
Storage: storer,
SnapshotManager: newFaultSnapshotManager(fs, storer, cfg, repos),
ScannerFactory: faultScannerFactory(cfg, repos, storer),
Fs: fs,
Stdout: io.Discard,
Stderr: io.Discard,
UI: ui.NewWithColor(io.Discard, false),
}
v.SetContext(ctx)
return v
}
// faultScannerFactory mirrors the production provideScannerFactory, binding
// the scanner to the given store, repositories, and config so a full
// create-path backup writes through the fault-injecting store.
func faultScannerFactory(
cfg *config.Config, repos *database.Repositories, storer storage.Storer,
) snapshot.ScannerFactory {
return func(params snapshot.ScannerParams) *snapshot.Scanner {
return snapshot.NewScanner(snapshot.ScannerConfig{
FS: params.Fs,
Storage: storer,
ChunkSize: faultChunkSize,
MaxBlobSize: faultMaxBlobSize,
CompressionLevel: cfg.CompressionLevel,
AgeRecipients: cfg.AgeRecipients,
Repositories: repos,
EnableProgress: params.EnableProgress,
UI: params.UI,
Exclude: params.Exclude,
SkipErrors: params.SkipErrors,
})
}
}
// Scenario 5: the restore target runs out of space mid-file. Restore // Scenario 5: the restore target runs out of space mid-file. Restore
// must fail with an out-of-space error, and must not leave a truncated // must fail with an out-of-space error, and must not leave a truncated
// file at the target path presenting as a complete restore. Restore // file at the target path presenting as a complete restore. Restore
+23 -6
View File
@@ -13,6 +13,14 @@ import (
// ShowInfo displays system and configuration information // ShowInfo displays system and configuration information
func (v *Vaultik) ShowInfo() error { func (v *Vaultik) ShowInfo() error {
// The info report is the output this command exists to produce, so it
// is written plain (markers would corrupt the aligned report) through
// the UI writer's stdout; --quiet silences it like any other
// non-error output.
if v.UI.Quiet() {
return nil
}
// System Information // System Information
v.stdoutf("=== System Information ===\n") v.stdoutf("=== System Information ===\n")
v.stdoutf("OS/Architecture: %s/%s\n", runtime.GOOS, runtime.GOARCH) v.stdoutf("OS/Architecture: %s/%s\n", runtime.GOOS, runtime.GOARCH)
@@ -213,7 +221,12 @@ func (v *Vaultik) RemoteInfo(jsonOutput bool) error {
result.StorageType = storageInfo.Type result.StorageType = storageInfo.Type
result.StorageLocation = storageInfo.Location result.StorageLocation = storageInfo.Location
if !jsonOutput { // The human report is written only when it is neither the --json
// document (which needs stdout to itself) nor silenced by --quiet. The
// scan still runs in both cases so --json still gets a full result.
showText := !jsonOutput && !v.UI.Quiet()
if showText {
v.stdoutf("=== Remote Storage ===\n") v.stdoutf("=== Remote Storage ===\n")
v.stdoutf("Type: %s\n", storageInfo.Type) v.stdoutf("Type: %s\n", storageInfo.Type)
v.stdoutf("Location: %s\n", storageInfo.Location) v.stdoutf("Location: %s\n", storageInfo.Location)
@@ -226,7 +239,7 @@ func (v *Vaultik) RemoteInfo(jsonOutput bool) error {
return err return err
} }
if !jsonOutput { if showText {
v.stdoutf("Downloading %d manifest(s)...\n", len(snapshotIDs)) v.stdoutf("Downloading %d manifest(s)...\n", len(snapshotIDs))
} }
@@ -234,7 +247,7 @@ func (v *Vaultik) RemoteInfo(jsonOutput bool) error {
v.populateRemoteInfoResult(result, snapshotMetadata, snapshotIDs, referencedBlobs) v.populateRemoteInfoResult(result, snapshotMetadata, snapshotIDs, referencedBlobs)
err = v.scanRemoteBlobStorage(result, referencedBlobs, jsonOutput) err = v.scanRemoteBlobStorage(result, referencedBlobs, showText)
if err != nil { if err != nil {
return err return err
} }
@@ -252,7 +265,9 @@ func (v *Vaultik) RemoteInfo(jsonOutput bool) error {
return enc.Encode(result) return enc.Encode(result)
} }
if showText {
v.printRemoteInfoTable(result) v.printRemoteInfoTable(result)
}
return nil return nil
} }
@@ -362,11 +377,13 @@ func (v *Vaultik) populateRemoteInfoResult(
} }
} }
// scanRemoteBlobStorage lists all blobs on remote and computes orphan stats // scanRemoteBlobStorage lists all blobs on remote and computes orphan
// stats. showText is true only when the human report is being printed
// (not --json, not --quiet), gating the progress line.
func (v *Vaultik) scanRemoteBlobStorage( func (v *Vaultik) scanRemoteBlobStorage(
result *RemoteInfoResult, referencedBlobs map[string]int64, jsonOutput bool, result *RemoteInfoResult, referencedBlobs map[string]int64, showText bool,
) error { ) error {
if !jsonOutput { if showText {
v.stdoutf("Scanning blobs...\n") v.stdoutf("Scanning blobs...\n")
} }
+30
View File
@@ -0,0 +1,30 @@
package vaultik_test
import (
"bytes"
"testing"
"github.com/stretchr/testify/require"
"sneak.berlin/go/vaultik/internal/ui"
"sneak.berlin/go/vaultik/internal/vaultik"
)
// TestShowInfo_QuietSuppressesReport checks that --quiet silences the
// whole info report. The report is human-facing status, not a scriptable
// value, so under --quiet the command produces nothing (and touches none
// of its dependencies, which is why this minimal instance suffices).
func TestShowInfo_QuietSuppressesReport(t *testing.T) {
t.Parallel()
var out bytes.Buffer
v := &vaultik.Vaultik{
Stdout: &out,
UI: ui.NewWithColor(&out, false),
}
v.UI.SetQuiet(true)
require.NoError(t, v.ShowInfo())
require.Empty(t, out.String(),
"the info report must be suppressed under --quiet")
}
+20 -3
View File
@@ -69,6 +69,9 @@ func (v *Vaultik) CreateSnapshot(opts *SnapshotCreateOptions) error {
// Prune the database before starting: delete incomplete snapshots and orphaned data. // Prune the database before starting: delete incomplete snapshots and orphaned data.
// This ensures the database is consistent before we start a new snapshot. // This ensures the database is consistent before we start a new snapshot.
// Since we use locking, only one vaultik instance accesses the DB at a time. // Since we use locking, only one vaultik instance accesses the DB at a time.
// A snapshot whose metadata export was interrupted is left incomplete by
// finalizeSnapshotMetadata, so it is among the incomplete snapshots dropped
// here (https://git.eeqj.de/sneak/vaultik/issues/177).
_, err = v.PruneDatabase() _, err = v.PruneDatabase()
if err != nil { if err != nil {
return fmt.Errorf("prune database: %w", err) return fmt.Errorf("prune database: %w", err)
@@ -324,7 +327,12 @@ func (v *Vaultik) collectUploadStats(scanner *snapshot.Scanner, stats *snapshotS
} }
} }
// finalizeSnapshotMetadata updates stats, marks complete, and exports metadata // finalizeSnapshotMetadata updates stats, exports metadata, and only then
// marks the snapshot complete. Recording completion last is deliberate: an
// export interrupted by a crash leaves the snapshot incomplete rather than
// looking complete with no manifest or database at the destination. The next
// run's PruneDatabase drops the incomplete snapshot and re-backs-up its data.
// See https://git.eeqj.de/sneak/vaultik/issues/177.
func (v *Vaultik) finalizeSnapshotMetadata( func (v *Vaultik) finalizeSnapshotMetadata(
snapshotID string, stats *snapshotStats, snapshotID string, stats *snapshotStats,
) error { ) error {
@@ -346,9 +354,11 @@ func (v *Vaultik) finalizeSnapshotMetadata(
return fmt.Errorf("updating snapshot stats: %w", err) return fmt.Errorf("updating snapshot stats: %w", err)
} }
err = v.SnapshotManager.CompleteSnapshot(v.ctx, snapshotID) // snapshot_blobs must be populated before the export, which builds the
// manifest and the trimmed metadata database from it.
err = v.SnapshotManager.PopulateSnapshotBlobs(v.ctx, snapshotID)
if err != nil { if err != nil {
return fmt.Errorf("completing snapshot: %w", err) return fmt.Errorf("populating snapshot blobs: %w", err)
} }
err = v.SnapshotManager.ExportSnapshotMetadata( err = v.SnapshotManager.ExportSnapshotMetadata(
@@ -357,6 +367,13 @@ func (v *Vaultik) finalizeSnapshotMetadata(
return fmt.Errorf("exporting snapshot metadata: %w", err) return fmt.Errorf("exporting snapshot metadata: %w", err)
} }
// Record completion last, so an interrupted export never leaves a
// snapshot marked complete without its metadata at the destination.
err = v.SnapshotManager.MarkSnapshotComplete(v.ctx, snapshotID)
if err != nil {
return fmt.Errorf("marking snapshot complete: %w", err)
}
return nil return nil
} }
+6
View File
@@ -114,10 +114,16 @@ func (v *Vaultik) ListSnapshots(jsonOutput bool) error {
return encoder.Encode(snapshots) return encoder.Encode(snapshots)
} }
// The table is the output this command exists to produce, so it is
// written plain (markers would corrupt the columns) to the UI writer's
// stdout; --quiet silences it. Reconciliation notes below go through
// the UI methods, so their warnings still emit under --quiet.
if !v.UI.Quiet() {
err = v.printSnapshotTable(snapshots) err = v.printSnapshotTable(snapshots)
if err != nil { if err != nil {
return err return err
} }
}
if remoteErr == nil { if remoteErr == nil {
v.reportListDrift(snapshots, listing) v.reportListDrift(snapshots, listing)
@@ -0,0 +1,41 @@
package vaultik_test
import (
"testing"
"time"
"github.com/stretchr/testify/require"
"sneak.berlin/go/vaultik/internal/log"
)
// TestListSnapshots_QuietSuppressesTableNotJSON is the --quiet contract
// for `snapshot list`: the human table is silenced, but the --json
// document a script depends on still emits. A local snapshot with its
// remote counterpart present is used so there are no drift notes, whose
// warnings would emit even under --quiet.
func TestListSnapshots_QuietSuppressesTableNotJSON(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
env := newListEnv(t)
ts := time.Date(2026, 3, 1, 10, 0, 0, 0, time.UTC)
env.addLocal(t, listLocalID, ts)
env.addRemote(t, listLocalID, ts)
env.v.UI.SetQuiet(true)
// Table mode: nothing on stdout.
require.NoError(t, env.v.ListSnapshots(false))
require.Empty(t, env.stdout.String(),
"the table must be suppressed under --quiet")
// JSON mode: the document is still written despite the quiet UI.
env.stdout.Reset()
require.NoError(t, env.v.ListSnapshots(true))
rows := decodeListJSON(t, env.stdout.String())
require.Len(t, rows, 1)
require.Equal(t, listLocalID, rows[0].ID,
"the --json document must still emit under --quiet")
}
+1
View File
@@ -215,6 +215,7 @@ func NewForTesting(storage storage.Storer) *TestVaultik {
Stdout: stdout, Stdout: stdout,
Stderr: stderr, Stderr: stderr,
Stdin: stdin, Stdin: stdin,
UI: ui.NewWithColor(stdout, false),
}, },
Stdout: stdout, Stdout: stdout,
Stderr: stderr, Stderr: stderr,