check / check (push) Waiting to run
A source host whose credentials allow only PUT, which the documents say is enough to back up, failed every backup after its first: the marker check looks the marker up with an S3 HEAD, which those credentials are refused, and any error other than not found was fatal. Such an error now means the check could not be made: it is logged at info level, so --cron stays silent, and the command goes on. Not found still stops it. The documents now say so, and that such a host does not notice an emptied destination, so vaultik database delete must be run on it after the destination is emptied. The fault-injecting test store gains a Stat hook for the test. Model: opus-5-5
177 lines
5.5 KiB
Go
177 lines
5.5 KiB
Go
package cli //nolint:testpackage // shares hermeticConfig and the capture helpers
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/adrg/xdg"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"sneak.berlin/go/vaultik/internal/database"
|
|
"sneak.berlin/go/vaultik/internal/types"
|
|
)
|
|
|
|
// pruneJSONDocument is the shape `prune --json` writes: the
|
|
// PruneBlobsResult document, and nothing else.
|
|
//
|
|
//nolint:tagliatelle // snake_case is the established JSON output format
|
|
type pruneJSONDocument struct {
|
|
BlobsFound int `json:"blobs_found"`
|
|
BlobsDeleted int `json:"blobs_deleted"`
|
|
BytesFreed int64 `json:"bytes_freed"`
|
|
}
|
|
|
|
// stalePruneSnapshotID is seeded into the local index with no manifest
|
|
// on the destination store, which is exactly what makes it stale.
|
|
const stalePruneSnapshotID = "test-host_test_2026-04-01T09:00:00Z"
|
|
|
|
// TestEntryPruneJSONStdoutIsExactlyOneDocument is the end-to-end
|
|
// regression guard for issue #108: `vaultik prune --json | jq .` must
|
|
// work with no other flags.
|
|
//
|
|
// It runs Entry over the process's real stdout descriptor, through
|
|
// cobra and the fx graph, against a hermetic file:// destination store
|
|
// — the same construction TestEntryJSONStdoutIsExactlyOneDocument uses
|
|
// for `snapshot list`, with the pipe to jq replaced by a decoder.
|
|
//
|
|
// Both branches of the local-snapshot reconciliation are exercised
|
|
// because the three stdout writes that broke this covered all of them:
|
|
// one line per stale record and a summary when there were any, and a
|
|
// "No stale local snapshots found." line when there were none. No input
|
|
// avoided the contamination, so no single branch demonstrates the fix.
|
|
//
|
|
// Not parallel: it replaces os.Args, os.Stdout and the xdg globals.
|
|
//
|
|
//nolint:paralleltest // replaces os.Args, os.Stdout and the xdg globals
|
|
func TestEntryPruneJSONStdoutIsExactlyOneDocument(t *testing.T) {
|
|
for _, testCase := range []struct {
|
|
name string
|
|
seedStale bool
|
|
description string
|
|
}{
|
|
{
|
|
name: "no stale local records",
|
|
seedStale: false,
|
|
description: "the empty-index branch used to print a 'No stale' line",
|
|
},
|
|
{
|
|
name: "stale local records present",
|
|
seedStale: true,
|
|
description: "the removal branch used to print a line per record " +
|
|
"plus a summary",
|
|
},
|
|
} {
|
|
t.Run(testCase.name, func(t *testing.T) {
|
|
configPath := writeHermeticPruneConfig(t, testCase.seedStale)
|
|
|
|
previousArgs := os.Args
|
|
|
|
t.Cleanup(func() {
|
|
os.Args = previousArgs
|
|
rootFlags = RootFlags{}
|
|
})
|
|
|
|
os.Args = []string{
|
|
programName, flagConfig, configPath, cmdPrune, flagJSON,
|
|
}
|
|
|
|
stdout := captureProcessStdout(t, func() { _ = Entry() })
|
|
|
|
requireExactlyOneJSONDocument(t, stdout)
|
|
|
|
var document pruneJSONDocument
|
|
|
|
require.NoError(t, json.Unmarshal([]byte(stdout), &document),
|
|
testCase.description)
|
|
|
|
// A destination store with no blobs has none to prune. The
|
|
// assertion that matters is the one above; this one keeps the
|
|
// test honest about which document it decoded.
|
|
assert.Equal(t, 0, document.BlobsFound)
|
|
})
|
|
}
|
|
}
|
|
|
|
// writeHermeticPruneConfig builds a config over a temp directory with an
|
|
// empty destination directory and, if seedStale is set, creates the
|
|
// index database up front with one snapshot record that has no
|
|
// counterpart on the destination store, and gives the destination the
|
|
// marker a backup writes. Returns the config path.
|
|
func writeHermeticPruneConfig(t *testing.T, seedStale bool) string {
|
|
t.Helper()
|
|
|
|
dir := t.TempDir()
|
|
configPath := filepath.Join(dir, "config.yml")
|
|
indexPath := filepath.Join(dir, "index.sqlite")
|
|
storeDir := filepath.Join(dir, "store")
|
|
|
|
contents := fmt.Sprintf(hermeticConfig,
|
|
filepath.Join(dir, "source"),
|
|
storeDir,
|
|
indexPath)
|
|
|
|
require.NoError(t,
|
|
os.WriteFile(configPath, []byte(contents), configFileMode))
|
|
|
|
// prune fails on a destination directory that does not exist, so the
|
|
// empty store is created here rather than left to a first backup.
|
|
require.NoError(t, os.Mkdir(storeDir, 0o750))
|
|
|
|
// The PID lock lives under xdg.DataHome, which xdg resolves at
|
|
// package init; point it at the temp dir so the test neither
|
|
// touches nor collides with the real one.
|
|
t.Setenv("XDG_DATA_HOME", filepath.Join(dir, "data"))
|
|
xdg.Reload()
|
|
t.Cleanup(xdg.Reload)
|
|
|
|
if seedStale {
|
|
seedStaleSnapshotRecord(t, indexPath)
|
|
|
|
// While the local index holds a snapshot, prune fails on a
|
|
// destination that answers that it has no marker.
|
|
require.NoError(t, os.WriteFile(
|
|
filepath.Join(storeDir, "vaultik-destination.txt"), nil, 0o600))
|
|
}
|
|
|
|
return configPath
|
|
}
|
|
|
|
// seedStaleSnapshotRecord creates the index database at path and
|
|
// inserts one completed snapshot into it. Nothing is written to the
|
|
// destination store, so `prune` finds the record stale and removes it —
|
|
// the branch that printed a line per record.
|
|
func seedStaleSnapshotRecord(t *testing.T, path string) {
|
|
t.Helper()
|
|
|
|
ctx := context.Background()
|
|
|
|
db, err := database.New(ctx, path)
|
|
require.NoError(t, err)
|
|
|
|
defer func() { require.NoError(t, db.Close()) }()
|
|
|
|
startedAt := time.Date(2026, 4, 1, 9, 0, 0, 0, time.UTC)
|
|
completedAt := startedAt.Add(time.Minute)
|
|
|
|
snap := &database.Snapshot{
|
|
ID: types.SnapshotID(stalePruneSnapshotID),
|
|
Hostname: "test-host",
|
|
VaultikVersion: "test",
|
|
StartedAt: startedAt,
|
|
CompletedAt: &completedAt,
|
|
}
|
|
|
|
repos := database.NewRepositories(db)
|
|
|
|
err = repos.WithTx(ctx, func(ctx context.Context, tx *sql.Tx) error {
|
|
return repos.Snapshots.Create(ctx, tx, snap)
|
|
})
|
|
require.NoError(t, err)
|
|
}
|