Compare commits
1
Commits
next
..
ae06beb2c4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae06beb2c4 |
+10
-14
@@ -284,10 +284,8 @@ Manages snapshot lifecycle and metadata export.
|
||||
|
||||
Key methods:
|
||||
- `CreateSnapshot(ctx, hostname, version, commit)` → Create snapshot record
|
||||
- `PopulateSnapshotBlobs(ctx, snapshotID)` → Record every blob the snapshot references
|
||||
- `CompleteSnapshot(ctx, snapshotID)` → Mark snapshot complete
|
||||
- `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`
|
||||
SQLite database for local index. Single-writer mode for thread safety.
|
||||
@@ -337,18 +335,16 @@ CreateSnapshot(opts)
|
||||
│
|
||||
├─► SnapshotManager.UpdateSnapshotStatsExtended()
|
||||
│
|
||||
├─► SnapshotManager.PopulateSnapshotBlobs() // record referenced blobs
|
||||
├─► SnapshotManager.CompleteSnapshot()
|
||||
│
|
||||
├─► 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
|
||||
│
|
||||
└─► SnapshotManager.MarkSnapshotComplete() // only after the export succeeds
|
||||
└─► 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
|
||||
```
|
||||
|
||||
## Deduplication Strategy
|
||||
|
||||
@@ -645,26 +645,17 @@ priority.
|
||||
|
||||
## output style
|
||||
|
||||
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`
|
||||
environment variable is unset (https://no-color.org/).
|
||||
|
||||
* **Status, progress, warnings, and errors** go through the `internal/ui`
|
||||
message methods below: marker-prefixed, colored on a TTY, and — except
|
||||
warnings and errors — silenced by `--quiet`. This is the operational
|
||||
narration of the long-running commands (`snapshot create`, `prune`,
|
||||
`snapshot restore`, and the like) and the confirmations of
|
||||
`config init`, `config set`, and `database delete`.
|
||||
* **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.
|
||||
The operational narration of the long-running commands — the Begin,
|
||||
Complete, Progress, and status lines of `snapshot create`, `prune`,
|
||||
`snapshot restore`, and the like — goes through helpers in `internal/ui`
|
||||
and conforms to the uniform style below. Some commands instead write
|
||||
plain text straight to stdout (`version`, `info`, `config`, the
|
||||
`database delete` prompt, and the `snapshot list` table); that output is
|
||||
unstyled and does not honor `--quiet`. Routing it through `internal/ui`
|
||||
is tracked in
|
||||
[issue #149](https://git.eeqj.de/sneak/vaultik/issues/149). Color is
|
||||
enabled when stdout is a TTY and the `NO_COLOR` environment variable is
|
||||
unset (https://no-color.org/).
|
||||
|
||||
`internal/ui` writes to stdout; it is the output the user asked for.
|
||||
Structured log records are a different thing and go through
|
||||
|
||||
@@ -25,21 +25,6 @@ release" is exactly the contradiction
|
||||
|
||||
# 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
|
||||
the destination before using them
|
||||
([issue #155](https://git.eeqj.de/sneak/vaultik/issues/155)). A blob
|
||||
|
||||
@@ -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
|
||||
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
|
||||
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.)
|
||||
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.
|
||||
|
||||
## Pruning Safety
|
||||
|
||||
|
||||
+17
-25
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -12,7 +13,6 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"gopkg.in/yaml.v3"
|
||||
"sneak.berlin/go/vaultik/internal/ui"
|
||||
)
|
||||
|
||||
// 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/
|
||||
on macOS, ~/.config/ on Linux, /etc/vaultik/ as root).`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
RunE: func(_ *cobra.Command, _ []string) error {
|
||||
path := configPathForInit()
|
||||
|
||||
_, err := os.Stat(path)
|
||||
@@ -285,11 +285,8 @@ on macOS, ~/.config/ on Linux, /etc/vaultik/ as root).`,
|
||||
return fmt.Errorf("writing config file: %w", err)
|
||||
}
|
||||
|
||||
// A written-confirmation, not scriptable output: route it
|
||||
// through the UI so it is styled and --quiet silences it.
|
||||
out := commandUI(cmd)
|
||||
out.Infof("Config written to %s.", path)
|
||||
out.Infof(
|
||||
_, _ = fmt.Fprintf(os.Stdout, "Config written to %s\n", path)
|
||||
_, _ = fmt.Fprintln(os.Stdout,
|
||||
"Edit it to set your age_recipients, snapshots, and storage_url.")
|
||||
|
||||
return nil
|
||||
@@ -331,7 +328,7 @@ func newConfigGetCommand() *cobra.Command {
|
||||
Use: "get <key>",
|
||||
Short: "Print a config value by dotted path (e.g. storage_url, compression_level)",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
RunE: func(_ *cobra.Command, args []string) error {
|
||||
path, err := ResolveConfigPath()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -347,13 +344,8 @@ func newConfigGetCommand() *cobra.Command {
|
||||
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 {
|
||||
_, _ = fmt.Fprintln(w, node.Value)
|
||||
_, _ = fmt.Fprintln(os.Stdout, node.Value)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -363,7 +355,7 @@ func newConfigGetCommand() *cobra.Command {
|
||||
return fmt.Errorf("marshaling value: %w", err)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprint(w, string(out))
|
||||
_, _ = fmt.Fprint(os.Stdout, string(out))
|
||||
|
||||
return nil
|
||||
},
|
||||
@@ -385,23 +377,23 @@ Examples:
|
||||
vaultik config set compression_level 9
|
||||
vaultik config set s3.bucket mybucket # legacy S3 fields still supported`,
|
||||
Args: cobra.ExactArgs(configSetArgs),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
RunE: func(_ *cobra.Command, args []string) error {
|
||||
path, err := ResolveConfigPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return writeConfigSet(commandUI(cmd), path, args[0], args[1])
|
||||
return writeConfigSet(os.Stdout, path, args[0], args[1])
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// writeConfigSet applies key=value to the config at path, writes it back
|
||||
// owner-only, and confirms the write by naming just the key through the
|
||||
// UI writer (styled, and silenced by --quiet). The value is never
|
||||
// echoed: it may be a secret such as s3.secret_access_key, and captured
|
||||
// stdout or a pasted terminal would then leak it.
|
||||
func writeConfigSet(out *ui.Writer, path, key, value string) error {
|
||||
// owner-only, and confirms the write by printing just the key name to w.
|
||||
// The value is never echoed: it may be a secret such as
|
||||
// s3.secret_access_key, and captured stdout or a pasted terminal would
|
||||
// then leak it.
|
||||
func writeConfigSet(w io.Writer, path, key, value string) error {
|
||||
root, err := loadYAMLFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -412,12 +404,12 @@ func writeConfigSet(out *ui.Writer, path, key, value string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
data, err := marshalConfigYAML(root)
|
||||
out, err := marshalConfigYAML(root)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshaling config: %w", err)
|
||||
}
|
||||
|
||||
err = os.WriteFile(path, data, configFileMode)
|
||||
err = os.WriteFile(path, out, configFileMode)
|
||||
if err != nil {
|
||||
return fmt.Errorf("writing config file: %w", err)
|
||||
}
|
||||
@@ -433,7 +425,7 @@ func writeConfigSet(out *ui.Writer, path, key, value string) error {
|
||||
}
|
||||
}
|
||||
|
||||
out.Infof("Set %s.", key)
|
||||
_, _ = fmt.Fprintln(w, key)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
"sneak.berlin/go/vaultik/internal/config"
|
||||
"sneak.berlin/go/vaultik/internal/ui"
|
||||
)
|
||||
|
||||
// TestDefaultConfigTemplateParses ensures the init template is valid YAML
|
||||
@@ -247,20 +246,19 @@ func TestWriteConfigSetHidesSecret(t *testing.T) {
|
||||
t.Fatalf("seed config: %v", err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
var out bytes.Buffer
|
||||
|
||||
err = writeConfigSet(ui.NewWithColor(&buf, false), path,
|
||||
"s3.secret_access_key", secret)
|
||||
err = writeConfigSet(&out, path, "s3.secret_access_key", secret)
|
||||
if err != nil {
|
||||
t.Fatalf("writeConfigSet: %v", err)
|
||||
}
|
||||
|
||||
if strings.Contains(buf.String(), secret) {
|
||||
t.Errorf("output echoed the secret value: %q", buf.String())
|
||||
if strings.Contains(out.String(), secret) {
|
||||
t.Errorf("output echoed the secret value: %q", out.String())
|
||||
}
|
||||
|
||||
if !strings.Contains(buf.String(), "s3.secret_access_key") {
|
||||
t.Errorf("output did not confirm the key name: %q", buf.String())
|
||||
if !strings.Contains(out.String(), "s3.secret_access_key") {
|
||||
t.Errorf("output did not confirm the key name: %q", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -279,10 +277,9 @@ func TestWriteConfigSetTightensMode(t *testing.T) {
|
||||
t.Fatalf("seed config: %v", err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
var out bytes.Buffer
|
||||
|
||||
err = writeConfigSet(ui.NewWithColor(&buf, false), path,
|
||||
"compression_level", "9")
|
||||
err = writeConfigSet(&out, path, "compression_level", "9")
|
||||
if err != nil {
|
||||
t.Fatalf("writeConfigSet: %v", err)
|
||||
}
|
||||
|
||||
+11
-12
@@ -48,7 +48,7 @@ storage destination on that run.
|
||||
|
||||
Use --force to skip the confirmation prompt.`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
RunE: func(_ *cobra.Command, _ []string) error {
|
||||
// Resolve config path
|
||||
configPath, err := ResolveConfigPath()
|
||||
if err != nil {
|
||||
@@ -62,31 +62,26 @@ Use --force to skip the confirmation prompt.`,
|
||||
}
|
||||
|
||||
dbPath := cfg.IndexPath
|
||||
out := commandUI(cmd)
|
||||
|
||||
// Check if database exists
|
||||
_, err = os.Stat(dbPath)
|
||||
if os.IsNotExist(err) {
|
||||
out.Infof("Local state database does not exist: %s.", dbPath)
|
||||
_, _ = fmt.Fprintf(os.Stdout, "Database does not exist: %s\n", dbPath)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Confirm unless --force
|
||||
if !force {
|
||||
w := cmd.OutOrStdout()
|
||||
_, _ = fmt.Fprintf(w,
|
||||
_, _ = fmt.Fprintf(os.Stdout,
|
||||
"This will delete the local state database at:\n %s\n\n", dbPath)
|
||||
_, _ = fmt.Fprint(w, "Are you sure? Type 'yes' to confirm: ")
|
||||
_, _ = fmt.Fprint(os.Stdout, "Are you sure? Type 'yes' to confirm: ")
|
||||
|
||||
var confirm string
|
||||
|
||||
_, err = fmt.Scanln(&confirm)
|
||||
if err != nil || confirm != "yes" {
|
||||
_, _ = fmt.Fprintln(w, "Aborted.")
|
||||
_, _ = fmt.Fprintln(os.Stdout, "Aborted.")
|
||||
|
||||
//nolint:nilerr // a failed/aborted confirmation is a clean abort
|
||||
return nil
|
||||
@@ -105,7 +100,11 @@ Use --force to skip the confirmation prompt.`,
|
||||
_ = os.Remove(walPath) // Ignore errors - files may not exist
|
||||
_ = os.Remove(shmPath)
|
||||
|
||||
out.Infof("Local state database deleted: %s.", dbPath)
|
||||
rootFlags := GetRootFlags()
|
||||
if !rootFlags.Quiet {
|
||||
_, _ = fmt.Fprintf(os.Stdout, "Database deleted: %s\n", dbPath)
|
||||
}
|
||||
|
||||
log.Info("Local state database deleted", "path", dbPath)
|
||||
|
||||
return nil
|
||||
|
||||
@@ -1,206 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
|
||||
"github.com/adrg/xdg"
|
||||
"github.com/spf13/cobra"
|
||||
"sneak.berlin/go/vaultik/internal/ui"
|
||||
)
|
||||
|
||||
// errConfigNotFound is wrapped by all config-resolution failures.
|
||||
@@ -82,20 +81,6 @@ func GetRootFlags() 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.
|
||||
// Search order: --config flag, VAULTIK_CONFIG env, XDG config dir,
|
||||
// /etc/vaultik/config.yml.
|
||||
|
||||
+5
-14
@@ -2,11 +2,11 @@ package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"runtime"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"sneak.berlin/go/vaultik/internal/globals"
|
||||
"sneak.berlin/go/vaultik/internal/ui"
|
||||
)
|
||||
|
||||
// NewVersionCommand creates the version command
|
||||
@@ -17,25 +17,16 @@ func NewVersionCommand() *cobra.Command {
|
||||
Long: `Print version, git commit, and build information for vaultik.`,
|
||||
Args: cobra.NoArgs,
|
||||
Run: func(cmd *cobra.Command, _ []string) {
|
||||
writeVersion(commandUI(cmd))
|
||||
writeVersion(cmd.OutOrStdout())
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// writeVersion prints the version report through the UI writer. The
|
||||
// report is the output this command exists to produce, so it is written
|
||||
// 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()
|
||||
|
||||
// writeVersion prints the version report. It takes a writer rather than
|
||||
// using os.Stdout directly so the output can be asserted on in tests.
|
||||
func writeVersion(w io.Writer) {
|
||||
_, _ = fmt.Fprintf(w, "vaultik %s\n", globals.Version)
|
||||
_, _ = fmt.Fprintf(w, " commit: %s\n", globals.Commit)
|
||||
_, _ = fmt.Fprintf(w, " build date: %s\n", globals.CommitDate)
|
||||
|
||||
@@ -34,9 +34,9 @@ func runVersionCommand(t *testing.T) string {
|
||||
// the report is the version the binary was actually built with. The
|
||||
// test binary carries no -ldflags, so that is the "dev" default -- the
|
||||
// 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) {
|
||||
t.Parallel()
|
||||
|
||||
out := runVersionCommand(t)
|
||||
|
||||
wantFirst := "vaultik " + globals.Version
|
||||
@@ -55,9 +55,9 @@ func TestVersionCommandReportsBuildVersion(t *testing.T) {
|
||||
// being exactly "dev", so once untagged builds started carrying their
|
||||
// commit sha it would have gone silent and an unreleased binary would
|
||||
// have looked like a release.
|
||||
//
|
||||
//nolint:paralleltest // executes a command that reads the global rootFlags
|
||||
func TestVersionCommandFlagsDevelopmentBuild(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if !globals.IsDevVersion(globals.Version) {
|
||||
t.Skipf("test binary was stamped with release version %q",
|
||||
globals.Version)
|
||||
|
||||
@@ -201,14 +201,11 @@ func (sm *SnapshotManager) UpdateSnapshotStatsExtended(
|
||||
})
|
||||
}
|
||||
|
||||
// PopulateSnapshotBlobs ensures snapshot_blobs holds an entry for every
|
||||
// blob that stores a chunk referenced by the snapshot's files, including
|
||||
// blobs deduplicated from earlier snapshots. Without it, a fully
|
||||
// deduplicated snapshot would record no blobs and be unrestorable.
|
||||
//
|
||||
// This must run before ExportSnapshotMetadata: the blob manifest and the
|
||||
// trimmed metadata database are both built from snapshot_blobs.
|
||||
func (sm *SnapshotManager) PopulateSnapshotBlobs(
|
||||
// CompleteSnapshot marks a snapshot as completed and ensures snapshot_blobs
|
||||
// is populated with every blob holding any chunk referenced by the
|
||||
// snapshot's files (including deduplicated blobs uploaded by prior
|
||||
// snapshots). Without this, fully-deduplicated snapshots are unrestorable.
|
||||
func (sm *SnapshotManager) CompleteSnapshot(
|
||||
ctx context.Context, snapshotID string,
|
||||
) error {
|
||||
err := sm.repos.WithTx(ctx, func(ctx context.Context, tx *sql.Tx) error {
|
||||
@@ -222,25 +219,6 @@ func (sm *SnapshotManager) PopulateSnapshotBlobs(
|
||||
"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)
|
||||
})
|
||||
if err != nil {
|
||||
@@ -252,22 +230,6 @@ func (sm *SnapshotManager) MarkSnapshotComplete(
|
||||
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
|
||||
//
|
||||
// This method executes the complete snapshot metadata export process:
|
||||
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/vaultik/internal/config"
|
||||
"sneak.berlin/go/vaultik/internal/database"
|
||||
"sneak.berlin/go/vaultik/internal/globals"
|
||||
"sneak.berlin/go/vaultik/internal/log"
|
||||
"sneak.berlin/go/vaultik/internal/snapshot"
|
||||
"sneak.berlin/go/vaultik/internal/storage"
|
||||
@@ -418,10 +417,9 @@ func TestBackupRetryAfterInterruptedUploadIsRestorable(t *testing.T) {
|
||||
// database is uploaded but before the manifest. The destination is left
|
||||
// with blobs and a database but no manifest. verify and snapshot list
|
||||
// must report the damage honestly rather than crashing or passing.
|
||||
// Automatic detection and repair of this partial state on the next run is
|
||||
// covered by TestBackupCompletesOnlyAfterMetadataExport
|
||||
// (https://git.eeqj.de/sneak/vaultik/issues/177); this test exercises the
|
||||
// lower-level export path in isolation.
|
||||
// Automatic detection and repair of this partial state on the next run
|
||||
// is tracked in https://git.eeqj.de/sneak/vaultik/issues/177 and is not
|
||||
// asserted here.
|
||||
//
|
||||
//nolint:paralleltest // installs the global logger via log.Initialize
|
||||
func TestBackupSurvivesMetadataExportInterruption(t *testing.T) {
|
||||
@@ -498,186 +496,6 @@ func TestBackupSurvivesMetadataExportInterruption(t *testing.T) {
|
||||
"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
|
||||
// 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
|
||||
|
||||
@@ -13,14 +13,6 @@ import (
|
||||
|
||||
// ShowInfo displays system and configuration information
|
||||
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
|
||||
v.stdoutf("=== System Information ===\n")
|
||||
v.stdoutf("OS/Architecture: %s/%s\n", runtime.GOOS, runtime.GOARCH)
|
||||
@@ -221,12 +213,7 @@ func (v *Vaultik) RemoteInfo(jsonOutput bool) error {
|
||||
result.StorageType = storageInfo.Type
|
||||
result.StorageLocation = storageInfo.Location
|
||||
|
||||
// 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 {
|
||||
if !jsonOutput {
|
||||
v.stdoutf("=== Remote Storage ===\n")
|
||||
v.stdoutf("Type: %s\n", storageInfo.Type)
|
||||
v.stdoutf("Location: %s\n", storageInfo.Location)
|
||||
@@ -239,7 +226,7 @@ func (v *Vaultik) RemoteInfo(jsonOutput bool) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if showText {
|
||||
if !jsonOutput {
|
||||
v.stdoutf("Downloading %d manifest(s)...\n", len(snapshotIDs))
|
||||
}
|
||||
|
||||
@@ -247,7 +234,7 @@ func (v *Vaultik) RemoteInfo(jsonOutput bool) error {
|
||||
|
||||
v.populateRemoteInfoResult(result, snapshotMetadata, snapshotIDs, referencedBlobs)
|
||||
|
||||
err = v.scanRemoteBlobStorage(result, referencedBlobs, showText)
|
||||
err = v.scanRemoteBlobStorage(result, referencedBlobs, jsonOutput)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -265,9 +252,7 @@ func (v *Vaultik) RemoteInfo(jsonOutput bool) error {
|
||||
return enc.Encode(result)
|
||||
}
|
||||
|
||||
if showText {
|
||||
v.printRemoteInfoTable(result)
|
||||
}
|
||||
v.printRemoteInfoTable(result)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -377,13 +362,11 @@ func (v *Vaultik) populateRemoteInfoResult(
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
// scanRemoteBlobStorage lists all blobs on remote and computes orphan stats
|
||||
func (v *Vaultik) scanRemoteBlobStorage(
|
||||
result *RemoteInfoResult, referencedBlobs map[string]int64, showText bool,
|
||||
result *RemoteInfoResult, referencedBlobs map[string]int64, jsonOutput bool,
|
||||
) error {
|
||||
if showText {
|
||||
if !jsonOutput {
|
||||
v.stdoutf("Scanning blobs...\n")
|
||||
}
|
||||
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
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")
|
||||
}
|
||||
@@ -69,9 +69,6 @@ func (v *Vaultik) CreateSnapshot(opts *SnapshotCreateOptions) error {
|
||||
// Prune the database before starting: delete incomplete snapshots and orphaned data.
|
||||
// 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.
|
||||
// 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()
|
||||
if err != nil {
|
||||
return fmt.Errorf("prune database: %w", err)
|
||||
@@ -327,12 +324,7 @@ func (v *Vaultik) collectUploadStats(scanner *snapshot.Scanner, stats *snapshotS
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
// finalizeSnapshotMetadata updates stats, marks complete, and exports metadata
|
||||
func (v *Vaultik) finalizeSnapshotMetadata(
|
||||
snapshotID string, stats *snapshotStats,
|
||||
) error {
|
||||
@@ -354,11 +346,9 @@ func (v *Vaultik) finalizeSnapshotMetadata(
|
||||
return fmt.Errorf("updating snapshot stats: %w", err)
|
||||
}
|
||||
|
||||
// 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)
|
||||
err = v.SnapshotManager.CompleteSnapshot(v.ctx, snapshotID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("populating snapshot blobs: %w", err)
|
||||
return fmt.Errorf("completing snapshot: %w", err)
|
||||
}
|
||||
|
||||
err = v.SnapshotManager.ExportSnapshotMetadata(
|
||||
@@ -367,13 +357,6 @@ func (v *Vaultik) finalizeSnapshotMetadata(
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -114,15 +114,9 @@ func (v *Vaultik) ListSnapshots(jsonOutput bool) error {
|
||||
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)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = v.printSnapshotTable(snapshots)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if remoteErr == nil {
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
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")
|
||||
}
|
||||
@@ -215,7 +215,6 @@ func NewForTesting(storage storage.Storer) *TestVaultik {
|
||||
Stdout: stdout,
|
||||
Stderr: stderr,
|
||||
Stdin: stdin,
|
||||
UI: ui.NewWithColor(stdout, false),
|
||||
},
|
||||
Stdout: stdout,
|
||||
Stderr: stderr,
|
||||
|
||||
Reference in New Issue
Block a user