check / check (push) Canceled after 0s
In the Dockerfile test phase, internal/vaultik spent 23.6s of 31.9s in 36 tests run one at a time. 24 of them were serial only because they call log.Initialize; they now call it before t.Parallel(), so the logger is replaced before any parallel test runs. The 12 still serial change the umask, TMPDIR, os.Stderr or time.Local. TestLargeDatasets took 8.4s of the 9.8s internal/database run by committing each of its 1,500 inserts on its own; it now makes them in one transaction. TestDedupOnlySnapshotRestores gives its second backup its own snapshot name instead of sleeping 1.1s for a new snapshot ID. Model: opus-5-5
196 lines
5.6 KiB
Go
196 lines
5.6 KiB
Go
package vaultik_test
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/spf13/afero"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"sneak.berlin/go/vaultik/internal/config"
|
|
"sneak.berlin/go/vaultik/internal/database"
|
|
"sneak.berlin/go/vaultik/internal/log"
|
|
"sneak.berlin/go/vaultik/internal/storage"
|
|
"sneak.berlin/go/vaultik/internal/ui"
|
|
"sneak.berlin/go/vaultik/internal/vaultik"
|
|
)
|
|
|
|
// A file no larger than the chunker's minimum chunk size (a quarter of
|
|
// faultChunkSize) is stored as one chunk, so it lives in exactly one blob.
|
|
// More of them than fit in faultMaxBlobSize make the snapshot span two
|
|
// blobs.
|
|
const (
|
|
missingBlobFileBytes = int(faultChunkSize / 4)
|
|
missingBlobFileCount = 20
|
|
)
|
|
|
|
// missingBlobBackup is a snapshot of single-chunk files spread over two
|
|
// blobs, with one of those blobs deleted from the store.
|
|
type missingBlobBackup struct {
|
|
fs afero.Fs
|
|
cfg *config.Config
|
|
storer storage.Storer
|
|
snapshotID string
|
|
restoreDir string
|
|
// files holds the original content by source path.
|
|
files map[string][]byte
|
|
// lost holds the source paths whose chunk is in the deleted blob.
|
|
lost map[string]bool
|
|
}
|
|
|
|
// TestRestoreSkipErrorsSkipsFilesOfMissingBlob restores with SkipErrors
|
|
// after one blob of a two-blob snapshot was deleted. Every file stored in
|
|
// that blob must be reported as failed and left absent, every other file
|
|
// must be restored intact, and Restore must still return an error.
|
|
func TestRestoreSkipErrorsSkipsFilesOfMissingBlob(t *testing.T) {
|
|
log.Initialize(log.Config{})
|
|
t.Parallel()
|
|
|
|
ctx := context.Background()
|
|
backup := backupThenDeleteOneBlob(ctx, t)
|
|
|
|
var out bytes.Buffer
|
|
|
|
v := newReaderVaultik(ctx, backup.cfg, backup.storer, nil, backup.fs)
|
|
v.UI = ui.NewWithColor(&out, false)
|
|
|
|
err := v.Restore(&vaultik.RestoreOptions{
|
|
SnapshotID: backup.snapshotID,
|
|
TargetDir: backup.restoreDir,
|
|
SkipErrors: true,
|
|
})
|
|
|
|
require.Error(t, err, "restore must fail when files were skipped")
|
|
assert.Contains(t, err.Error(),
|
|
fmt.Sprintf("%d file(s) failed to restore", len(backup.lost)))
|
|
|
|
for path, content := range backup.files {
|
|
restored := filepath.Join(backup.restoreDir, path)
|
|
|
|
if backup.lost[path] {
|
|
assert.Containsf(t, out.String(), path,
|
|
"%s needs the deleted blob and must be reported", path)
|
|
assert.NoFileExists(t, restored)
|
|
|
|
continue
|
|
}
|
|
|
|
got, err := afero.ReadFile(backup.fs, restored)
|
|
require.NoErrorf(t, err, "%s does not need the deleted blob", path)
|
|
assert.Equalf(t, content, got, "%s restored with wrong content", path)
|
|
}
|
|
}
|
|
|
|
// TestRestoreMissingBlobAbortsWithoutSkipErrors checks that a deleted blob
|
|
// still ends the restore with an error when SkipErrors is not set.
|
|
func TestRestoreMissingBlobAbortsWithoutSkipErrors(t *testing.T) {
|
|
log.Initialize(log.Config{})
|
|
t.Parallel()
|
|
|
|
ctx := context.Background()
|
|
backup := backupThenDeleteOneBlob(ctx, t)
|
|
|
|
v := newReaderVaultik(ctx, backup.cfg, backup.storer, nil, backup.fs)
|
|
|
|
err := v.Restore(&vaultik.RestoreOptions{
|
|
SnapshotID: backup.snapshotID,
|
|
TargetDir: backup.restoreDir,
|
|
})
|
|
|
|
require.ErrorIs(t, err, storage.ErrNotFound)
|
|
}
|
|
|
|
// backupThenDeleteOneBlob backs up missingBlobFileCount single-chunk
|
|
// files, then deletes from the store the blob holding the first of them.
|
|
func backupThenDeleteOneBlob(
|
|
ctx context.Context, t *testing.T,
|
|
) *missingBlobBackup {
|
|
t.Helper()
|
|
|
|
fs := afero.NewOsFs()
|
|
tempDir := t.TempDir()
|
|
dataDir := filepath.Join(tempDir, "src")
|
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
|
cfg := faultTestConfig()
|
|
|
|
require.NoError(t, fs.MkdirAll(dataDir, 0o755))
|
|
|
|
files := make(map[string][]byte, missingBlobFileCount)
|
|
|
|
for i := range missingBlobFileCount {
|
|
path := filepath.Join(dataDir, fmt.Sprintf("file-%02d.bin", i))
|
|
files[path] = bytesPattern(
|
|
fmt.Sprintf("file-%02d-", i), missingBlobFileBytes)
|
|
require.NoError(t, afero.WriteFile(fs, path, files[path], 0o644))
|
|
}
|
|
|
|
storer, err := storage.NewFileStorer(filepath.Join(tempDir, "remote"))
|
|
require.NoError(t, err)
|
|
|
|
db, err := database.New(ctx, dbPath)
|
|
require.NoError(t, err)
|
|
|
|
repos := database.NewRepositories(db)
|
|
|
|
id := fullFaultBackup(
|
|
ctx, t, fs, storer, cfg, repos, dataDir, dbPath, "missingblob")
|
|
|
|
blobOfFile := make(map[string]string, len(files))
|
|
for path := range files {
|
|
blobOfFile[path] = blobHashOfSingleChunkFile(ctx, t, repos, path)
|
|
}
|
|
|
|
require.NoError(t, db.Close())
|
|
|
|
deleted := blobOfFile[filepath.Join(dataDir, "file-00.bin")]
|
|
require.NoError(t, storer.Delete(ctx, fmt.Sprintf(
|
|
"blobs/%s/%s/%s", deleted[:2], deleted[2:4], deleted)))
|
|
|
|
lost := make(map[string]bool)
|
|
|
|
for path, hash := range blobOfFile {
|
|
if hash == deleted {
|
|
lost[path] = true
|
|
}
|
|
}
|
|
|
|
require.Less(t, len(lost), len(files),
|
|
"the snapshot must span more than one blob")
|
|
|
|
return &missingBlobBackup{
|
|
fs: fs,
|
|
cfg: cfg,
|
|
storer: storer,
|
|
snapshotID: id,
|
|
restoreDir: filepath.Join(tempDir, "restored"),
|
|
files: files,
|
|
lost: lost,
|
|
}
|
|
}
|
|
|
|
// blobHashOfSingleChunkFile returns the hash of the blob holding the one
|
|
// chunk of the file at path, as recorded in the local index.
|
|
func blobHashOfSingleChunkFile(
|
|
ctx context.Context, t *testing.T,
|
|
repos *database.Repositories, path string,
|
|
) string {
|
|
t.Helper()
|
|
|
|
chunks, err := repos.FileChunks.GetByPath(ctx, path)
|
|
require.NoError(t, err)
|
|
require.Lenf(t, chunks, 1, "%s must be a single chunk", path)
|
|
|
|
blobChunk, err := repos.BlobChunks.GetByChunkHash(
|
|
ctx, chunks[0].ChunkHash.String())
|
|
require.NoError(t, err)
|
|
require.NotNilf(t, blobChunk, "chunk of %s is in no blob", path)
|
|
|
|
blob, err := repos.Blobs.GetByID(ctx, blobChunk.BlobID.String())
|
|
require.NoError(t, err)
|
|
|
|
return blob.Hash.String()
|
|
}
|