Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3cb1cf9e9f |
No files matched your search
@@ -309,22 +309,11 @@ func (v *Vaultik) restoreAllFiles(
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
plan, unplanned, err := newRestorePlan(
|
plan, err := newRestorePlan(v.ctx, repos, files, chunkToBlobMap, blobIDToHash)
|
||||||
v.ctx, repos, files, chunkToBlobMap, blobIDToHash)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("building restore plan: %w", err)
|
return nil, fmt.Errorf("building restore plan: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ranging over files rather than the map reports them in a stable order.
|
|
||||||
for _, file := range files {
|
|
||||||
if planErr, ok := unplanned[file.ID]; ok {
|
|
||||||
err = v.handleRestoreFileError(plan, opts, result, file, file.ID, planErr)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
filesByID, totalBytesExpected := indexRestoreFiles(files)
|
filesByID, totalBytesExpected := indexRestoreFiles(files)
|
||||||
|
|
||||||
v.UI.Beginf("Restoring %s files (%s) to %s.",
|
v.UI.Beginf("Restoring %s files (%s) to %s.",
|
||||||
|
|||||||
@@ -61,8 +61,8 @@ func craftedFile(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestRestoreShortChunkHashInNoBlob proves a file whose short chunk hash
|
// TestRestoreShortChunkHashInNoBlob proves a file whose short chunk hash
|
||||||
// has no blob_chunks row is left out of the restore plan with an error,
|
// has no blob_chunks row fails restore planning and the chunk write with
|
||||||
// and fails the chunk write with an error.
|
// an error.
|
||||||
func TestRestoreShortChunkHashInNoBlob(t *testing.T) {
|
func TestRestoreShortChunkHashInNoBlob(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -77,11 +77,9 @@ func TestRestoreShortChunkHashInNoBlob(t *testing.T) {
|
|||||||
chunkToBlobMap, err := v.buildChunkToBlobMap(ctx, repos)
|
chunkToBlobMap, err := v.buildChunkToBlobMap(ctx, repos)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
plan, unplanned, err := newRestorePlan(ctx, repos, []*database.File{file},
|
_, err = newRestorePlan(ctx, repos, []*database.File{file},
|
||||||
chunkToBlobMap, map[string]string{})
|
chunkToBlobMap, map[string]string{})
|
||||||
require.NoError(t, err)
|
require.ErrorIs(t, err, errPlanChunkMissing)
|
||||||
require.ErrorIs(t, unplanned[file.ID], errPlanChunkMissing)
|
|
||||||
require.False(t, plan.hasPending())
|
|
||||||
|
|
||||||
fileChunks, err := repos.FileChunks.GetByFileID(ctx, file.ID)
|
fileChunks, err := repos.FileChunks.GetByFileID(ctx, file.ID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|||||||
@@ -41,25 +41,20 @@ type restorePlan struct {
|
|||||||
|
|
||||||
// newRestorePlan builds the file→blob index for the given files. Files
|
// newRestorePlan builds the file→blob index for the given files. Files
|
||||||
// whose chunks reference no blobs (symlinks, directories) start in the
|
// whose chunks reference no blobs (symlinks, directories) start in the
|
||||||
// ready queue immediately. A file with a chunk that no blob holds cannot
|
// ready queue immediately.
|
||||||
// be restored: it is left out of the plan and returned, by ID, with the
|
|
||||||
// reason.
|
|
||||||
func newRestorePlan(
|
func newRestorePlan(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
repos *database.Repositories,
|
repos *database.Repositories,
|
||||||
files []*database.File,
|
files []*database.File,
|
||||||
chunkToBlobMap map[string]*database.BlobChunk,
|
chunkToBlobMap map[string]*database.BlobChunk,
|
||||||
blobIDToHash map[string]string,
|
blobIDToHash map[string]string,
|
||||||
) (*restorePlan, map[types.FileID]error, error) {
|
) (*restorePlan, error) {
|
||||||
p := &restorePlan{
|
p := &restorePlan{
|
||||||
fileBlobs: make(map[types.FileID]map[string]struct{}, len(files)),
|
fileBlobs: make(map[types.FileID]map[string]struct{}, len(files)),
|
||||||
blobFiles: make(map[string]map[types.FileID]struct{}),
|
blobFiles: make(map[string]map[types.FileID]struct{}),
|
||||||
ready: make([]types.FileID, 0, len(files)),
|
ready: make([]types.FileID, 0, len(files)),
|
||||||
cached: make(map[string]struct{}),
|
cached: make(map[string]struct{}),
|
||||||
}
|
}
|
||||||
unplanned := make(map[types.FileID]error)
|
|
||||||
|
|
||||||
nextFile:
|
|
||||||
for _, f := range files {
|
for _, f := range files {
|
||||||
if f.IsSymlink() || f.Mode&uint32(os.ModeDir) != 0 {
|
if f.IsSymlink() || f.Mode&uint32(os.ModeDir) != 0 {
|
||||||
// No chunks to fetch — restore can run immediately.
|
// No chunks to fetch — restore can run immediately.
|
||||||
@@ -71,7 +66,7 @@ nextFile:
|
|||||||
|
|
||||||
fileChunks, err := repos.FileChunks.GetByFileID(ctx, f.ID)
|
fileChunks, err := repos.FileChunks.GetByFileID(ctx, f.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, fmt.Errorf("planning %s: %w", f.Path, err)
|
return nil, fmt.Errorf("planning %s: %w", f.Path, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
blobs := make(map[string]struct{})
|
blobs := make(map[string]struct{})
|
||||||
@@ -79,18 +74,14 @@ nextFile:
|
|||||||
for _, fc := range fileChunks {
|
for _, fc := range fileChunks {
|
||||||
bc, ok := chunkToBlobMap[fc.ChunkHash.String()]
|
bc, ok := chunkToBlobMap[fc.ChunkHash.String()]
|
||||||
if !ok {
|
if !ok {
|
||||||
unplanned[f.ID] = fmt.Errorf("%w: %s",
|
return nil, fmt.Errorf("planning %s: %w: %s",
|
||||||
errPlanChunkMissing, shortHash(fc.ChunkHash.String()))
|
f.Path, errPlanChunkMissing, shortHash(fc.ChunkHash.String()))
|
||||||
|
|
||||||
continue nextFile
|
|
||||||
}
|
}
|
||||||
|
|
||||||
hash, ok := blobIDToHash[bc.BlobID.String()]
|
hash, ok := blobIDToHash[bc.BlobID.String()]
|
||||||
if !ok {
|
if !ok {
|
||||||
unplanned[f.ID] = fmt.Errorf("%w: %s",
|
return nil, fmt.Errorf("planning %s: %w: %s",
|
||||||
errPlanBlobIDMissing, bc.BlobID)
|
f.Path, errPlanBlobIDMissing, bc.BlobID)
|
||||||
|
|
||||||
continue nextFile
|
|
||||||
}
|
}
|
||||||
|
|
||||||
blobs[hash] = struct{}{}
|
blobs[hash] = struct{}{}
|
||||||
@@ -112,7 +103,7 @@ nextFile:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return p, unplanned, nil
|
return p, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// markBlobCached records that the named blob is now resident in the
|
// markBlobCached records that the named blob is now resident in the
|
||||||
|
|||||||
@@ -28,8 +28,7 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// missingBlobBackup is a snapshot of single-chunk files spread over two
|
// missingBlobBackup is a snapshot of single-chunk files spread over two
|
||||||
// blobs, with one of those blobs lost: deleted from the store, or missing
|
// blobs, with one of those blobs deleted from the store.
|
||||||
// from the snapshot metadata.
|
|
||||||
type missingBlobBackup struct {
|
type missingBlobBackup struct {
|
||||||
fs afero.Fs
|
fs afero.Fs
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
@@ -38,39 +37,20 @@ type missingBlobBackup struct {
|
|||||||
restoreDir string
|
restoreDir string
|
||||||
// files holds the original content by source path.
|
// files holds the original content by source path.
|
||||||
files map[string][]byte
|
files map[string][]byte
|
||||||
// lost holds the source paths whose chunk is in the lost blob.
|
// lost holds the source paths whose chunk is in the deleted blob.
|
||||||
lost map[string]bool
|
lost map[string]bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestRestoreSkipErrorsSkipsFilesOfMissingBlob restores with SkipErrors
|
// TestRestoreSkipErrorsSkipsFilesOfMissingBlob restores with SkipErrors
|
||||||
// after one blob of a two-blob snapshot was deleted from the store.
|
// 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) {
|
func TestRestoreSkipErrorsSkipsFilesOfMissingBlob(t *testing.T) {
|
||||||
log.Initialize(log.Config{})
|
log.Initialize(log.Config{})
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
assertRestoreSkipsLostFiles(ctx, t, backupThenDeleteOneBlob(ctx, t))
|
backup := backupThenDeleteOneBlob(ctx, t)
|
||||||
}
|
|
||||||
|
|
||||||
// TestRestoreSkipErrorsSkipsFilesWithChunkInNoBlob restores with
|
|
||||||
// SkipErrors a snapshot whose metadata lists chunks that no blob holds, as
|
|
||||||
// earlier backup defects left behind.
|
|
||||||
func TestRestoreSkipErrorsSkipsFilesWithChunkInNoBlob(t *testing.T) {
|
|
||||||
log.Initialize(log.Config{})
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
assertRestoreSkipsLostFiles(ctx, t, backupThenDropOneBlobFromMetadata(ctx, t))
|
|
||||||
}
|
|
||||||
|
|
||||||
// assertRestoreSkipsLostFiles restores backup with SkipErrors. Every file
|
|
||||||
// stored in the lost blob must be reported as failed and left absent,
|
|
||||||
// every other file must be restored intact, and Restore must still return
|
|
||||||
// an error.
|
|
||||||
func assertRestoreSkipsLostFiles(
|
|
||||||
ctx context.Context, t *testing.T, backup *missingBlobBackup,
|
|
||||||
) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
var out bytes.Buffer
|
var out bytes.Buffer
|
||||||
|
|
||||||
@@ -92,14 +72,14 @@ func assertRestoreSkipsLostFiles(
|
|||||||
|
|
||||||
if backup.lost[path] {
|
if backup.lost[path] {
|
||||||
assert.Containsf(t, out.String(), path,
|
assert.Containsf(t, out.String(), path,
|
||||||
"%s needs the lost blob and must be reported", path)
|
"%s needs the deleted blob and must be reported", path)
|
||||||
assert.NoFileExists(t, restored)
|
assert.NoFileExists(t, restored)
|
||||||
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
got, err := afero.ReadFile(backup.fs, restored)
|
got, err := afero.ReadFile(backup.fs, restored)
|
||||||
require.NoErrorf(t, err, "%s does not need the lost blob", path)
|
require.NoErrorf(t, err, "%s does not need the deleted blob", path)
|
||||||
assert.Equalf(t, content, got, "%s restored with wrong content", path)
|
assert.Equalf(t, content, got, "%s restored with wrong content", path)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -123,30 +103,6 @@ func TestRestoreMissingBlobAbortsWithoutSkipErrors(t *testing.T) {
|
|||||||
require.ErrorIs(t, err, storage.ErrNotFound)
|
require.ErrorIs(t, err, storage.ErrNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestRestoreChunkInNoBlobAbortsWithoutSkipErrors checks that a chunk held
|
|
||||||
// by no blob still ends the restore, before any file is written, when
|
|
||||||
// SkipErrors is not set.
|
|
||||||
func TestRestoreChunkInNoBlobAbortsWithoutSkipErrors(t *testing.T) {
|
|
||||||
log.Initialize(log.Config{})
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
backup := backupThenDropOneBlobFromMetadata(ctx, t)
|
|
||||||
|
|
||||||
v := newReaderVaultik(ctx, backup.cfg, backup.storer, nil, backup.fs)
|
|
||||||
|
|
||||||
err := v.Restore(&vaultik.RestoreOptions{
|
|
||||||
SnapshotID: backup.snapshotID,
|
|
||||||
TargetDir: backup.restoreDir,
|
|
||||||
})
|
|
||||||
|
|
||||||
require.ErrorContains(t, err, "chunk missing from blob map")
|
|
||||||
|
|
||||||
for path := range backup.files {
|
|
||||||
assert.NoFileExists(t, filepath.Join(backup.restoreDir, path))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// backupThenDeleteOneBlob backs up missingBlobFileCount single-chunk
|
// backupThenDeleteOneBlob backs up missingBlobFileCount single-chunk
|
||||||
// files, then deletes from the store the blob holding the first of them.
|
// files, then deletes from the store the blob holding the first of them.
|
||||||
func backupThenDeleteOneBlob(
|
func backupThenDeleteOneBlob(
|
||||||
@@ -154,54 +110,6 @@ func backupThenDeleteOneBlob(
|
|||||||
) *missingBlobBackup {
|
) *missingBlobBackup {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
backup, db, lostBlob := backupSingleChunkFiles(ctx, t)
|
|
||||||
require.NoError(t, db.Close())
|
|
||||||
|
|
||||||
require.NoError(t, backup.storer.Delete(ctx, fmt.Sprintf(
|
|
||||||
"blobs/%s/%s/%s", lostBlob[:2], lostBlob[2:4], lostBlob)))
|
|
||||||
|
|
||||||
return backup
|
|
||||||
}
|
|
||||||
|
|
||||||
// backupThenDropOneBlobFromMetadata backs up missingBlobFileCount
|
|
||||||
// single-chunk files, then removes the blob holding the first of them from
|
|
||||||
// the snapshot and exports the snapshot metadata again. The exported
|
|
||||||
// metadata then lists chunks that no blob holds.
|
|
||||||
func backupThenDropOneBlobFromMetadata(
|
|
||||||
ctx context.Context, t *testing.T,
|
|
||||||
) *missingBlobBackup {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
backup, db, lostBlob := backupSingleChunkFiles(ctx, t)
|
|
||||||
|
|
||||||
// The uploads row refers to the blob row, so it would keep the export
|
|
||||||
// from deleting it.
|
|
||||||
for _, query := range []string{
|
|
||||||
"DELETE FROM snapshot_blobs WHERE blob_hash = ?",
|
|
||||||
"DELETE FROM uploads WHERE blob_hash = ?",
|
|
||||||
} {
|
|
||||||
_, err := db.Conn().ExecContext(ctx, query, lostBlob)
|
|
||||||
require.NoError(t, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
sm := newFaultSnapshotManager(
|
|
||||||
backup.fs, backup.storer, backup.cfg, database.NewRepositories(db))
|
|
||||||
require.NoError(t,
|
|
||||||
sm.ExportSnapshotMetadata(ctx, db.Path(), backup.snapshotID))
|
|
||||||
require.NoError(t, db.Close())
|
|
||||||
|
|
||||||
return backup
|
|
||||||
}
|
|
||||||
|
|
||||||
// backupSingleChunkFiles backs up missingBlobFileCount single-chunk files.
|
|
||||||
// It returns the backup, with lost naming the files stored in the same
|
|
||||||
// blob as the first of them, the still open local index, and the hash of
|
|
||||||
// that blob.
|
|
||||||
func backupSingleChunkFiles(
|
|
||||||
ctx context.Context, t *testing.T,
|
|
||||||
) (*missingBlobBackup, *database.DB, string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
fs := afero.NewOsFs()
|
fs := afero.NewOsFs()
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
dataDir := filepath.Join(tempDir, "src")
|
dataDir := filepath.Join(tempDir, "src")
|
||||||
@@ -235,11 +143,16 @@ func backupSingleChunkFiles(
|
|||||||
blobOfFile[path] = blobHashOfSingleChunkFile(ctx, t, repos, path)
|
blobOfFile[path] = blobHashOfSingleChunkFile(ctx, t, repos, path)
|
||||||
}
|
}
|
||||||
|
|
||||||
lostBlob := blobOfFile[filepath.Join(dataDir, "file-00.bin")]
|
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)
|
lost := make(map[string]bool)
|
||||||
|
|
||||||
for path, hash := range blobOfFile {
|
for path, hash := range blobOfFile {
|
||||||
if hash == lostBlob {
|
if hash == deleted {
|
||||||
lost[path] = true
|
lost[path] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -255,7 +168,7 @@ func backupSingleChunkFiles(
|
|||||||
restoreDir: filepath.Join(tempDir, "restored"),
|
restoreDir: filepath.Join(tempDir, "restored"),
|
||||||
files: files,
|
files: files,
|
||||||
lost: lost,
|
lost: lost,
|
||||||
}, db, lostBlob
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// blobHashOfSingleChunkFile returns the hash of the blob holding the one
|
// blobHashOfSingleChunkFile returns the hash of the blob holding the one
|
||||||
|
|||||||
Reference in new issue
Block a user