Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
42a3c738fa |
@@ -339,9 +339,7 @@ local index alone, and still exits zero.
|
|||||||
|
|
||||||
**`snapshot purge`**: Remove old snapshots based on criteria. Retention is
|
**`snapshot purge`**: Remove old snapshots based on criteria. Retention is
|
||||||
per-snapshot-name (`--keep-latest` keeps the latest of each name, not the
|
per-snapshot-name (`--keep-latest` keeps the latest of each name, not the
|
||||||
latest globally). If a snapshot cannot be removed from the local index, the
|
latest globally).
|
||||||
command stops there, leaves that snapshot's metadata on the destination store
|
|
||||||
and exits non-zero; `snapshot create --prune` fails the same way.
|
|
||||||
* `--keep-latest`: Keep only the most recent snapshot of each name
|
* `--keep-latest`: Keep only the most recent snapshot of each name
|
||||||
* `--older-than <duration>`: Remove snapshots older than duration (e.g. `30d`,
|
* `--older-than <duration>`: Remove snapshots older than duration (e.g. `30d`,
|
||||||
`4w`, `6mo`, `1y`; `m` is minutes, `mo` is months)
|
`4w`, `6mo`, `1y`; `m` is minutes, `mo` is months)
|
||||||
|
|||||||
@@ -1,90 +0,0 @@
|
|||||||
package cli //nolint:testpackage // shares runEntry and the hermetic config helpers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"sneak.berlin/go/vaultik/internal/database"
|
|
||||||
"sneak.berlin/go/vaultik/internal/snapshot"
|
|
||||||
"sneak.berlin/go/vaultik/internal/types"
|
|
||||||
)
|
|
||||||
|
|
||||||
// The `snapshot purge` command word and flags used below.
|
|
||||||
const (
|
|
||||||
cmdPurge = "purge"
|
|
||||||
flagKeepLatest = "--keep-latest"
|
|
||||||
flagForce = "--force"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestEntrySnapshotPurgeKeepsMetadataWhenLocalIndexDeleteFails makes
|
|
||||||
// removing a snapshot from the local index fail and checks that
|
|
||||||
// `snapshot purge` exits non-zero and leaves that snapshot's metadata on
|
|
||||||
// the destination store. It used to log the error, delete the metadata
|
|
||||||
// anyway and exit 0.
|
|
||||||
//
|
|
||||||
//nolint:paralleltest // replaces os.Args, os.Stdout, os.Stderr and the xdg globals
|
|
||||||
func TestEntrySnapshotPurgeKeepsMetadataWhenLocalIndexDeleteFails(t *testing.T) {
|
|
||||||
ctx := context.Background()
|
|
||||||
configPath := writeHermeticPruneConfig(t, false)
|
|
||||||
|
|
||||||
// writeHermeticPruneConfig puts the index and the store directory
|
|
||||||
// beside the config file.
|
|
||||||
dir := filepath.Dir(configPath)
|
|
||||||
|
|
||||||
// Two snapshots of the same name: --keep-latest purges the older.
|
|
||||||
olderStart := time.Date(2026, 5, 1, 9, 0, 0, 0, time.UTC)
|
|
||||||
newerStart := olderStart.Add(time.Hour)
|
|
||||||
olderID := "test-host_test_" + olderStart.Format(time.RFC3339)
|
|
||||||
newerID := "test-host_test_" + newerStart.Format(time.RFC3339)
|
|
||||||
|
|
||||||
db, err := database.New(ctx, filepath.Join(dir, "index.sqlite"))
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
repos := database.NewRepositories(db)
|
|
||||||
|
|
||||||
manifestPaths := make(map[string]string)
|
|
||||||
|
|
||||||
for id, startedAt := range map[string]time.Time{
|
|
||||||
olderID: olderStart,
|
|
||||||
newerID: newerStart,
|
|
||||||
} {
|
|
||||||
completedAt := startedAt.Add(time.Minute)
|
|
||||||
snap := &database.Snapshot{
|
|
||||||
ID: types.SnapshotID(id),
|
|
||||||
Hostname: "test-host",
|
|
||||||
VaultikVersion: "test",
|
|
||||||
StartedAt: startedAt,
|
|
||||||
CompletedAt: &completedAt,
|
|
||||||
}
|
|
||||||
|
|
||||||
err = repos.WithTx(ctx, func(ctx context.Context, tx *sql.Tx) error {
|
|
||||||
return repos.Snapshots.Create(ctx, tx, snap)
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
manifestPaths[id] = filepath.Join(dir, "store", "metadata",
|
|
||||||
snapshot.RemoteSnapshotKey(id), "manifest.json.zst")
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Dir(manifestPaths[id]), 0o750))
|
|
||||||
require.NoError(t, os.WriteFile(manifestPaths[id], []byte("stub"), 0o600))
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = db.Conn().ExecContext(ctx, `
|
|
||||||
CREATE TRIGGER refuse_snapshot_delete BEFORE DELETE ON snapshots
|
|
||||||
BEGIN SELECT RAISE(ABORT, 'simulated index error'); END`)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NoError(t, db.Close())
|
|
||||||
|
|
||||||
code, _, stderr := runEntry(t,
|
|
||||||
flagConfig, configPath, cmdSnapshot, cmdPurge, flagKeepLatest, flagForce)
|
|
||||||
|
|
||||||
assert.Equal(t, 1, code)
|
|
||||||
assert.Contains(t, stderr, "simulated index error")
|
|
||||||
assert.FileExists(t, manifestPaths[olderID],
|
|
||||||
"a snapshot still in the local index keeps its metadata")
|
|
||||||
}
|
|
||||||
@@ -578,3 +578,54 @@ func TestExcludePatterns_DoubleStarFileAtEveryDepth(t *testing.T) {
|
|||||||
require.Equal(t, 1, result.FilesScanned,
|
require.Equal(t, 1, result.FilesScanned,
|
||||||
"**/ file pattern should match at the top level and nested")
|
"**/ file pattern should match at the top level and nested")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExcludePatterns_AnchoredDoubleStarAtEveryDepth(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// As in gitignore, /**/ also matches directly under the source dir
|
||||||
|
fs := setupAnchoredTestFS(t)
|
||||||
|
|
||||||
|
scanner, repos, cleanup := createTestScanner(t, fs, []string{"/**/projectname"})
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
createSnapshotRecord(ctx, t, repos, "test-snapshot")
|
||||||
|
|
||||||
|
result, err := scanner.Scan(ctx, "/backup", "test-snapshot")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// /**/projectname should exclude BOTH:
|
||||||
|
// - /backup/projectname/file.txt
|
||||||
|
// - /backup/otherproject/projectname/file.txt
|
||||||
|
// Total files: 4, excluded: 2
|
||||||
|
require.Equal(t, 2, result.FilesScanned,
|
||||||
|
"/**/ pattern should match at the top level and nested")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExcludePatterns_DoubleStarInMiddleMatchesZeroDirectories(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// As in gitignore, a/**/b matches a/b as well as a/c/b. The last part
|
||||||
|
// is a glob: with a plain name the glob library already matched a/b.
|
||||||
|
fs := &afero.MemMapFs{}
|
||||||
|
files := []string{"/backup/a/x.log", "/backup/a/c/y.log", "/backup/a/c/kept.txt"}
|
||||||
|
|
||||||
|
for _, path := range files {
|
||||||
|
require.NoError(t, fs.MkdirAll(filepath.Dir(path), 0755))
|
||||||
|
require.NoError(t, afero.WriteFile(fs, path, []byte(path), 0644))
|
||||||
|
}
|
||||||
|
|
||||||
|
scanner, repos, cleanup := createTestScanner(t, fs, []string{"a/**/*.log"})
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
createSnapshotRecord(ctx, t, repos, "test-snapshot")
|
||||||
|
|
||||||
|
result, err := scanner.Scan(ctx, "/backup", "test-snapshot")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// a/**/*.log should exclude /backup/a/x.log and /backup/a/c/y.log
|
||||||
|
// Total files: 3, excluded: 2
|
||||||
|
require.Equal(t, 1, result.FilesScanned,
|
||||||
|
"a/**/*.log should match a/x.log as well as a/c/y.log")
|
||||||
|
}
|
||||||
|
|||||||
@@ -2035,33 +2035,60 @@ func compileExcludePatterns(patterns []string) []compiledPattern {
|
|||||||
pattern = strings.TrimSuffix(pattern, "/")
|
pattern = strings.TrimSuffix(pattern, "/")
|
||||||
|
|
||||||
// A leading "**/" demands a "/" before the name and so misses an
|
// A leading "**/" demands a "/" before the name and so misses an
|
||||||
// entry directly under the source path. An unanchored pattern
|
// entry directly under the source path. It means "at every depth",
|
||||||
// already matches at every depth, so drop the prefix: as in
|
// which is what an unanchored pattern already does, so drop the
|
||||||
// gitignore, "**/x" then also matches a top-level "x".
|
// prefix and match unanchored: as in gitignore, "**/x" and
|
||||||
if !anchored {
|
// "/**/x" then also match a top-level "x".
|
||||||
pattern = strings.TrimPrefix(pattern, "**/")
|
if rest, found := strings.CutPrefix(pattern, "**/"); found {
|
||||||
|
pattern = rest
|
||||||
|
anchored = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compile the glob pattern
|
// Likewise "/**/" inside a pattern demands two slashes, so
|
||||||
// For patterns without path separators, we need to match them as components
|
// "a/**/*.log" misses "a/x.log". As in gitignore it stands for
|
||||||
// e.g., ".git" should match ".git" anywhere in the path
|
// zero or more directories, so also compile the pattern with it
|
||||||
g, err := glob.Compile(pattern, '/')
|
// made a single "/". The glob library's {a,b} is no help: with a
|
||||||
if err != nil {
|
// "**" inside it in mid-pattern, it misses paths it should match.
|
||||||
log.Warn("Invalid exclude pattern, skipping", "pattern", p, "error", err)
|
for _, variant := range doubleStarVariants(pattern) {
|
||||||
|
// For patterns without path separators, we need to match them as components
|
||||||
|
// e.g., ".git" should match ".git" anywhere in the path
|
||||||
|
g, err := glob.Compile(variant, '/')
|
||||||
|
if err != nil {
|
||||||
|
log.Warn("Invalid exclude pattern, skipping", "pattern", p, "error", err)
|
||||||
|
|
||||||
continue
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
compiled = append(compiled, compiledPattern{
|
||||||
|
pattern: g,
|
||||||
|
anchored: anchored,
|
||||||
|
original: p,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
compiled = append(compiled, compiledPattern{
|
|
||||||
pattern: g,
|
|
||||||
anchored: anchored,
|
|
||||||
original: p,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return compiled
|
return compiled
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// doubleStarVariants returns pattern once for every way of keeping each
|
||||||
|
// "/**/" in it or making it a single "/".
|
||||||
|
func doubleStarVariants(pattern string) []string {
|
||||||
|
const doubleStar = "/**/"
|
||||||
|
|
||||||
|
before, after, found := strings.Cut(pattern, doubleStar)
|
||||||
|
if !found {
|
||||||
|
return []string{pattern}
|
||||||
|
}
|
||||||
|
|
||||||
|
var variants []string
|
||||||
|
|
||||||
|
for _, rest := range doubleStarVariants(after) {
|
||||||
|
variants = append(variants, before+doubleStar+rest, before+"/"+rest)
|
||||||
|
}
|
||||||
|
|
||||||
|
return variants
|
||||||
|
}
|
||||||
|
|
||||||
// shouldExclude checks if a path should be excluded based on exclude patterns
|
// shouldExclude checks if a path should be excluded based on exclude patterns
|
||||||
// filePath is the full path to the file
|
// filePath is the full path to the file
|
||||||
// rootPath is the root of the backup source directory
|
// rootPath is the root of the backup source directory
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
package vaultik_test
|
package vaultik_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -11,9 +9,7 @@ import (
|
|||||||
|
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"sneak.berlin/go/vaultik/internal/config"
|
|
||||||
"sneak.berlin/go/vaultik/internal/log"
|
"sneak.berlin/go/vaultik/internal/log"
|
||||||
"sneak.berlin/go/vaultik/internal/storage"
|
|
||||||
"sneak.berlin/go/vaultik/internal/ui"
|
"sneak.berlin/go/vaultik/internal/ui"
|
||||||
"sneak.berlin/go/vaultik/internal/vaultik"
|
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||||
)
|
)
|
||||||
@@ -81,174 +77,6 @@ func TestDeepVerifyAcceptsHealthyAndRejectsCorruptBlob(t *testing.T) {
|
|||||||
"deep verify should fail on a corrupted blob")
|
"deep verify should fail on a corrupted blob")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestDeepVerifyJSONCountsBlobs checks the verified and missing counts
|
|
||||||
// that deep verify reports under --json: on a healthy snapshot, with a
|
|
||||||
// damaged blob, and with missing blobs. Deep verify stops at the first
|
|
||||||
// damaged blob, so the blobs read before it are the ones verified; when
|
|
||||||
// blobs are missing it reads no blob contents, but counts every missing
|
|
||||||
// blob.
|
|
||||||
func TestDeepVerifyJSONCountsBlobs(t *testing.T) {
|
|
||||||
log.Initialize(log.Config{})
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewOsFs()
|
|
||||||
ctx := context.Background()
|
|
||||||
storeDir := filepath.Join(t.TempDir(), "remote")
|
|
||||||
|
|
||||||
cfg, storer, snapshotID, keys := backUpSeveralBlobs(ctx, t, storeDir)
|
|
||||||
|
|
||||||
healthy, err := deepVerifyJSON(ctx, t, cfg, storer, snapshotID)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Equal(t, "ok", healthy.Status)
|
|
||||||
require.Len(t, keys, healthy.BlobCount)
|
|
||||||
require.Equal(t, healthy.BlobCount, healthy.Verified)
|
|
||||||
require.Zero(t, healthy.Missing)
|
|
||||||
|
|
||||||
// Damage the last blob read, keeping its length, so every blob before
|
|
||||||
// it is read and passes first.
|
|
||||||
lastPath := filepath.Join(storeDir, keys[len(keys)-1])
|
|
||||||
blob, err := afero.ReadFile(fs, lastPath)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
blob[len(blob)/2] ^= 0xff
|
|
||||||
require.NoError(t, afero.WriteFile(fs, lastPath, blob, 0o600))
|
|
||||||
|
|
||||||
got, err := deepVerifyJSON(ctx, t, cfg, storer, snapshotID)
|
|
||||||
require.Error(t, err)
|
|
||||||
require.Equal(t, "failed", got.Status)
|
|
||||||
require.Equal(t, healthy.BlobCount-1, got.Verified)
|
|
||||||
require.Zero(t, got.Missing)
|
|
||||||
|
|
||||||
// Delete the first blob and the damaged last one: both must be
|
|
||||||
// counted as missing, not only the first one found.
|
|
||||||
var deletedSize int64
|
|
||||||
|
|
||||||
for _, key := range []string{keys[0], keys[len(keys)-1]} {
|
|
||||||
info, statErr := storer.Stat(ctx, key)
|
|
||||||
require.NoError(t, statErr)
|
|
||||||
|
|
||||||
deletedSize += info.Size
|
|
||||||
|
|
||||||
require.NoError(t, storer.Delete(ctx, key))
|
|
||||||
}
|
|
||||||
|
|
||||||
got, err = deepVerifyJSON(ctx, t, cfg, storer, snapshotID)
|
|
||||||
require.Error(t, err)
|
|
||||||
require.Equal(t, "failed", got.Status)
|
|
||||||
require.Equal(t, 2, got.Missing)
|
|
||||||
require.Equal(t, deletedSize, got.MissingSize)
|
|
||||||
require.Zero(t, got.Verified)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestDeepVerifyJSONCountsWrongSizeBlob checks that under --deep --json a
|
|
||||||
// blob of the wrong size is counted in mismatched and fails the run, and
|
|
||||||
// that the check goes on past it to count a missing blob after it.
|
|
||||||
func TestDeepVerifyJSONCountsWrongSizeBlob(t *testing.T) {
|
|
||||||
log.Initialize(log.Config{})
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewOsFs()
|
|
||||||
ctx := context.Background()
|
|
||||||
storeDir := filepath.Join(t.TempDir(), "remote")
|
|
||||||
|
|
||||||
cfg, storer, snapshotID, keys := backUpSeveralBlobs(ctx, t, storeDir)
|
|
||||||
|
|
||||||
// Make the first blob checked longer, and delete the last one.
|
|
||||||
firstPath := filepath.Join(storeDir, keys[0])
|
|
||||||
blob, err := afero.ReadFile(fs, firstPath)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
blob = append(blob, []byte("extra")...)
|
|
||||||
require.NoError(t, afero.WriteFile(fs, firstPath, blob, 0o600))
|
|
||||||
require.NoError(t, storer.Delete(ctx, keys[len(keys)-1]))
|
|
||||||
|
|
||||||
got, err := deepVerifyJSON(ctx, t, cfg, storer, snapshotID)
|
|
||||||
require.Error(t, err)
|
|
||||||
require.Equal(t, "failed", got.Status)
|
|
||||||
require.Equal(t, 1, got.Mismatched)
|
|
||||||
require.Equal(t, 1, got.Missing)
|
|
||||||
require.Zero(t, got.Verified)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestDeepVerifyStopsOnBlobThatCannotBeChecked checks that a blob that is
|
|
||||||
// present in storage but cannot be checked is not counted as missing:
|
|
||||||
// deep verify stops and fails with the storage error.
|
|
||||||
func TestDeepVerifyStopsOnBlobThatCannotBeChecked(t *testing.T) {
|
|
||||||
log.Initialize(log.Config{})
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
storeDir := filepath.Join(t.TempDir(), "remote")
|
|
||||||
|
|
||||||
cfg, storer, snapshotID, keys := backUpSeveralBlobs(ctx, t, storeDir)
|
|
||||||
failing := statFailingStorer{Storer: storer, failKeys: []string{keys[0]}}
|
|
||||||
|
|
||||||
got, err := deepVerifyJSON(ctx, t, cfg, failing, snapshotID)
|
|
||||||
require.Error(t, err)
|
|
||||||
require.Equal(t, "failed", got.Status)
|
|
||||||
require.Zero(t, got.Missing)
|
|
||||||
require.Contains(t, got.ErrorMessage, errStatDenied.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
// backUpSeveralBlobs backs up random data, which does not compress or
|
|
||||||
// deduplicate, so it fills at least three blobs in a store on disk at
|
|
||||||
// storeDir. It returns the blobs' storage keys sorted, which is hash
|
|
||||||
// order, the order deep verify reads them in.
|
|
||||||
func backUpSeveralBlobs(
|
|
||||||
ctx context.Context, t *testing.T, storeDir string,
|
|
||||||
) (*config.Config, *storage.FileStorer, string, []string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
fs := afero.NewOsFs()
|
|
||||||
tempDir := t.TempDir()
|
|
||||||
dataDir := filepath.Join(tempDir, "source")
|
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
|
||||||
|
|
||||||
chunkSize := int64(32 * 1024)
|
|
||||||
maxBlobSize := int64(128 * 1024)
|
|
||||||
|
|
||||||
data := make([]byte, maxBlobSize*4)
|
|
||||||
_, err := rand.Read(data)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NoError(t, fs.MkdirAll(dataDir, 0o755))
|
|
||||||
require.NoError(t, afero.WriteFile(fs,
|
|
||||||
filepath.Join(dataDir, "data.bin"), data, 0o644))
|
|
||||||
|
|
||||||
cfg, storer, snapshotID := runFileStorageBackup(
|
|
||||||
ctx, t, fs, dataDir, storeDir, dbPath, chunkSize, maxBlobSize)
|
|
||||||
|
|
||||||
keys := storedBlobKeys(t, storeDir)
|
|
||||||
require.GreaterOrEqual(t, len(keys), 3)
|
|
||||||
|
|
||||||
return cfg, storer, snapshotID, keys
|
|
||||||
}
|
|
||||||
|
|
||||||
// deepVerifyJSON runs deep verify with --json on snapshotID and returns
|
|
||||||
// the decoded result and the error RunDeepVerify returned.
|
|
||||||
func deepVerifyJSON(
|
|
||||||
ctx context.Context, t *testing.T,
|
|
||||||
cfg *config.Config, storer storage.Storer, snapshotID string,
|
|
||||||
) (vaultik.VerifyResult, error) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
var out bytes.Buffer
|
|
||||||
|
|
||||||
v := &vaultik.Vaultik{
|
|
||||||
Config: cfg,
|
|
||||||
Storage: storer,
|
|
||||||
Fs: afero.NewOsFs(),
|
|
||||||
Stdout: &out,
|
|
||||||
Stderr: io.Discard,
|
|
||||||
UI: ui.NewWithColor(io.Discard, false),
|
|
||||||
}
|
|
||||||
v.SetContext(ctx)
|
|
||||||
|
|
||||||
err := v.RunDeepVerify(snapshotID,
|
|
||||||
&vaultik.VerifyOptions{Deep: true, JSON: true})
|
|
||||||
|
|
||||||
return decodeVerifyResult(t, out.Bytes()), err
|
|
||||||
}
|
|
||||||
|
|
||||||
// corruptOneBlob flips a middle byte of the first blob file found under
|
// corruptOneBlob flips a middle byte of the first blob file found under
|
||||||
// blobsDir, leaving the file length unchanged.
|
// blobsDir, leaving the file length unchanged.
|
||||||
func corruptOneBlob(t *testing.T, fs afero.Fs, blobsDir string) {
|
func corruptOneBlob(t *testing.T, fs afero.Fs, blobsDir string) {
|
||||||
|
|||||||
@@ -627,8 +627,7 @@ func selectSnapshotsToPurge(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// confirmAndExecutePurge shows deletion candidates, confirms with the
|
// confirmAndExecutePurge shows deletion candidates, confirms with the
|
||||||
// user, and deletes snapshots. It stops with an error at the first
|
// user, and deletes snapshots.
|
||||||
// snapshot it cannot delete.
|
|
||||||
func (v *Vaultik) confirmAndExecutePurge(
|
func (v *Vaultik) confirmAndExecutePurge(
|
||||||
toDelete []SnapshotInfo, force, quiet bool,
|
toDelete []SnapshotInfo, force, quiet bool,
|
||||||
) error {
|
) error {
|
||||||
@@ -670,11 +669,10 @@ func (v *Vaultik) confirmAndExecutePurge(
|
|||||||
snapshotID := snap.ID.String()
|
snapshotID := snap.ID.String()
|
||||||
log.Info("Deleting snapshot", "id", snapshotID)
|
log.Info("Deleting snapshot", "id", snapshotID)
|
||||||
|
|
||||||
// Stop before the remote delete: a snapshot still in the local
|
|
||||||
// index must keep its metadata on the destination store.
|
|
||||||
err := v.deleteSnapshotFromLocalDB(snapshotID)
|
err := v.deleteSnapshotFromLocalDB(snapshotID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("deleting snapshot %s from local index: %w", snapshotID, err)
|
log.Error("Failed to delete from local database",
|
||||||
|
"snapshot_id", snapshotID, "error", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = v.deleteRemoteSnapshotByKey(snapshot.RemoteSnapshotKey(snapshotID))
|
_, err = v.deleteRemoteSnapshotByKey(snapshot.RemoteSnapshotKey(snapshotID))
|
||||||
|
|||||||
+17
-39
@@ -17,7 +17,6 @@ import (
|
|||||||
"sneak.berlin/go/vaultik/internal/database"
|
"sneak.berlin/go/vaultik/internal/database"
|
||||||
"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"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Sentinel errors for snapshot verification failures.
|
// Sentinel errors for snapshot verification failures.
|
||||||
@@ -33,9 +32,7 @@ var (
|
|||||||
errManifestExtraBlob = errors.New("manifest contains blob not in database")
|
errManifestExtraBlob = errors.New("manifest contains blob not in database")
|
||||||
errManifestMissingBlob = errors.New(
|
errManifestMissingBlob = errors.New(
|
||||||
"manifest omits blob present in database")
|
"manifest omits blob present in database")
|
||||||
errBlobSizeMismatch = errors.New("blob size mismatch")
|
errBlobSizeMismatch = errors.New("blob size mismatch")
|
||||||
errBlobsNotInStorage = errors.New(
|
|
||||||
"blobs missing from storage or stored with the wrong size")
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// verifyStatusFailed is the JSON status value for a failed verification.
|
// verifyStatusFailed is the JSON status value for a failed verification.
|
||||||
@@ -152,6 +149,7 @@ func (v *Vaultik) RunDeepVerify(snapshotID string, opts *VerifyOptions) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
result.Status = "ok"
|
result.Status = "ok"
|
||||||
|
result.Verified = len(dbBlobs)
|
||||||
|
|
||||||
if opts.JSON {
|
if opts.JSON {
|
||||||
return v.outputVerifyJSON(result)
|
return v.outputVerifyJSON(result)
|
||||||
@@ -308,7 +306,7 @@ func (v *Vaultik) runVerificationSteps(
|
|||||||
v.stdoutf("Checking blob existence in remote storage...\n")
|
v.stdoutf("Checking blob existence in remote storage...\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
err = v.verifyBlobExistenceFromDB(dbBlobs, result)
|
err = v.verifyBlobExistenceFromDB(dbBlobs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return v.deepVerifyFailure(result, opts, err.Error(), err)
|
return v.deepVerifyFailure(result, opts, err.Error(), err)
|
||||||
}
|
}
|
||||||
@@ -319,8 +317,7 @@ func (v *Vaultik) runVerificationSteps(
|
|||||||
len(dbBlobs), ubytes(totalSize))
|
len(dbBlobs), ubytes(totalSize))
|
||||||
}
|
}
|
||||||
|
|
||||||
err = v.performDeepVerificationFromDB(
|
err = v.performDeepVerificationFromDB(dbBlobs, tdb.db.Conn(), opts, identities)
|
||||||
dbBlobs, tdb.db.Conn(), opts, result, identities)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return v.deepVerifyFailure(result, opts, err.Error(), err)
|
return v.deepVerifyFailure(result, opts, err.Error(), err)
|
||||||
}
|
}
|
||||||
@@ -672,14 +669,8 @@ func (v *Vaultik) verifyManifestAgainstDatabase(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// verifyBlobExistenceFromDB checks that every blob from the database is
|
// verifyBlobExistenceFromDB checks that all blobs from database exist in S3
|
||||||
// in storage with the size the database records. It checks them all,
|
func (v *Vaultik) verifyBlobExistenceFromDB(blobs []snapshot.BlobInfo) error {
|
||||||
// counting the missing and wrong-size blobs in result, and fails if it
|
|
||||||
// found any. Any storage error other than not found stops the check and
|
|
||||||
// fails with that error.
|
|
||||||
func (v *Vaultik) verifyBlobExistenceFromDB(
|
|
||||||
blobs []snapshot.BlobInfo, result *VerifyResult,
|
|
||||||
) error {
|
|
||||||
log.Info("Verifying blob existence in S3", "blob_count", len(blobs))
|
log.Info("Verifying blob existence in S3", "blob_count", len(blobs))
|
||||||
|
|
||||||
for i, blob := range blobs {
|
for i, blob := range blobs {
|
||||||
@@ -692,23 +683,17 @@ func (v *Vaultik) verifyBlobExistenceFromDB(
|
|||||||
// Construct blob path
|
// Construct blob path
|
||||||
blobPath := fmt.Sprintf("blobs/%s/%s/%s", blob.Hash[:2], blob.Hash[2:4], blob.Hash)
|
blobPath := fmt.Sprintf("blobs/%s/%s/%s", blob.Hash[:2], blob.Hash[2:4], blob.Hash)
|
||||||
|
|
||||||
|
// Check blob exists
|
||||||
stat, err := v.Storage.Stat(v.ctx, blobPath)
|
stat, err := v.Storage.Stat(v.ctx, blobPath)
|
||||||
switch {
|
if err != nil {
|
||||||
case errors.Is(err, storage.ErrNotFound):
|
return fmt.Errorf("blob %s missing from storage: %w", blob.Hash, err)
|
||||||
log.Warn("Blob missing from storage", "hash", blob.Hash)
|
}
|
||||||
|
|
||||||
result.Missing++
|
// Verify size matches
|
||||||
result.MissingSize += blob.CompressedSize
|
if stat.Size != blob.CompressedSize {
|
||||||
case err != nil:
|
return fmt.Errorf(
|
||||||
return fmt.Errorf("checking blob %s in storage: %w",
|
"%w: blob %s: S3 has %d bytes, database has %d bytes",
|
||||||
blob.Hash, err)
|
errBlobSizeMismatch, blob.Hash, stat.Size, blob.CompressedSize)
|
||||||
case stat.Size != blob.CompressedSize:
|
|
||||||
log.Warn("Blob has the wrong size in storage",
|
|
||||||
"hash", blob.Hash,
|
|
||||||
"storage_size", stat.Size,
|
|
||||||
"database_size", blob.CompressedSize)
|
|
||||||
|
|
||||||
result.Mismatched++
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Progress update every 100 blobs
|
// Progress update every 100 blobs
|
||||||
@@ -722,22 +707,16 @@ func (v *Vaultik) verifyBlobExistenceFromDB(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if result.Missing > 0 || result.Mismatched > 0 {
|
|
||||||
return fmt.Errorf("%w: %d missing, %d with the wrong size",
|
|
||||||
errBlobsNotInStorage, result.Missing, result.Mismatched)
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Info("✓ All blobs exist in storage")
|
log.Info("✓ All blobs exist in storage")
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// performDeepVerificationFromDB downloads and verifies the content of
|
// performDeepVerificationFromDB downloads and verifies the content of
|
||||||
// each blob using the database as source, stopping at the first blob that
|
// each blob using the database as source.
|
||||||
// fails. Each blob that passes is counted in result.Verified.
|
|
||||||
func (v *Vaultik) performDeepVerificationFromDB(
|
func (v *Vaultik) performDeepVerificationFromDB(
|
||||||
blobs []snapshot.BlobInfo, db *sql.DB, opts *VerifyOptions,
|
blobs []snapshot.BlobInfo, db *sql.DB, opts *VerifyOptions,
|
||||||
result *VerifyResult, identities []age.Identity,
|
identities []age.Identity,
|
||||||
) error {
|
) error {
|
||||||
// Calculate total bytes for ETA
|
// Calculate total bytes for ETA
|
||||||
var totalBytesExpected int64
|
var totalBytesExpected int64
|
||||||
@@ -760,7 +739,6 @@ func (v *Vaultik) performDeepVerificationFromDB(
|
|||||||
return fmt.Errorf("blob %s verification failed: %w", blobInfo.Hash, err)
|
return fmt.Errorf("blob %s verification failed: %w", blobInfo.Hash, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
result.Verified++
|
|
||||||
bytesProcessed += blobInfo.CompressedSize
|
bytesProcessed += blobInfo.CompressedSize
|
||||||
elapsed := time.Since(startTime)
|
elapsed := time.Since(startTime)
|
||||||
remaining := len(blobs) - (i + 1)
|
remaining := len(blobs) - (i + 1)
|
||||||
|
|||||||
Reference in New Issue
Block a user