Compare commits

..
Author SHA1 Message Date
sneak defda0921b Limit and retry the module download in the Docker build (closes #351)
check / check (push) Waiting to run
Each `go mod download` in the Dockerfile now runs under a five-minute
time limit and is tried up to three times. Without a limit, a stalled
connection to the module proxy held the step, and every build on the
host sharing it, until the kernel dropped the connection. Five minutes
is well above the three minutes a working download can take on a busy
build host. When all three attempts fail, the step prints "module
download failed" and exits 1, so the build stops there instead of
failing as a test or lint failure. A test checks every download step
for the limit, the retries, the message and the final `exit 1`.

Model: opus-5-5
2026-10-13 15:23:40 +00:00
clawbot e48bafccef Restore the other files when a chunk is in no blob under --skip-errors (closes #326)
check / check (push) Waiting to run
Restore planning stopped at the first file needing a chunk that no blob
holds, so --skip-errors never saw that file and nothing was restored.
The planner now leaves such a file out of the plan and returns it with
the reason, and each one goes through handleRestoreFileError like any
other failed file: skipped and counted under --skip-errors, fatal
without it. The fatal error now suggests --skip-errors, as other restore
failures do.

The skip-errors tests now share one backup helper; the new case removes
a blob from the snapshot metadata and exports it again.

Model: opus-5-5
2026-10-13 16:53:05 +02:00
10 changed files with 231 additions and 343 deletions
+24 -3
View File
@@ -5,7 +5,16 @@
FROM golangci/golangci-lint:v2.14.0@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f AS lint FROM golangci/golangci-lint:v2.14.0@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f AS lint
WORKDIR /src WORKDIR /src
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download # Without the time limit a stalled connection to the module proxy holds
# this step, and every build on the host sharing it, until the kernel
# drops the connection, which can take an hour. The limit is well above
# the three minutes a working download can take on a busy build host.
RUN for attempt in 1 2 3; do \
timeout 300 go mod download && exit 0; \
echo "go mod download attempt $attempt failed or ran over 300s" >&2; \
done; \
echo "module download failed: giving up after 3 attempts" >&2; \
exit 1
COPY . . COPY . .
# `golangci-lint run` silently ignores an unknown top-level key in # `golangci-lint run` silently ignores an unknown top-level key in
# .golangci.yml, such as a misspelt `linters:`; `config verify` fails on it. # .golangci.yml, such as a misspelt `linters:`; `config verify` fails on it.
@@ -18,7 +27,13 @@ RUN golangci-lint run --config .golangci.yml ./...
FROM golang:1.26.1@sha256:cd78d88e00afadbedd272f977d375a6247455f3a4b1178f8ae8bbcb201743a8a AS test FROM golang:1.26.1@sha256:cd78d88e00afadbedd272f977d375a6247455f3a4b1178f8ae8bbcb201743a8a AS test
WORKDIR /src WORKDIR /src
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download # Limited and retried as in the lint phase above.
RUN for attempt in 1 2 3; do \
timeout 300 go mod download && exit 0; \
echo "go mod download attempt $attempt failed or ran over 300s" >&2; \
done; \
echo "module download failed: giving up after 3 attempts" >&2; \
exit 1
COPY . . COPY . .
RUN go test -timeout 90s -race -cover ./... || \ RUN go test -timeout 90s -race -cover ./... || \
{ echo "--- Rerunning with -v for details ---"; \ { echo "--- Rerunning with -v for details ---"; \
@@ -36,7 +51,13 @@ RUN apk add --no-cache git
RUN git config --system --add safe.directory /src RUN git config --system --add safe.directory /src
WORKDIR /src WORKDIR /src
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download # Limited and retried as in the lint phase above.
RUN for attempt in 1 2 3; do \
timeout 300 go mod download && exit 0; \
echo "go mod download attempt $attempt failed or ran over 300s" >&2; \
done; \
echo "module download failed: giving up after 3 attempts" >&2; \
exit 1
COPY . . COPY . .
# The VERSION build arg when one is given, otherwise # The VERSION build arg when one is given, otherwise
+45
View File
@@ -0,0 +1,45 @@
package main_test
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestDockerfileBoundsEveryModuleDownload fails unless every
// `go mod download` in the Dockerfile runs under a time limit, is tried
// a fixed number of times, and then fails the build with a message that
// names the module download (issue #351). Like the version tests, this
// parses the Dockerfile; that a stalled download really is cut off and
// retried was checked by hand.
func TestDockerfileBoundsEveryModuleDownload(t *testing.T) {
t.Parallel()
var downloads []string
for _, instruction := range instructions(t, productDockerfile) {
if strings.Contains(instruction, "go mod download") {
downloads = append(downloads, instruction)
}
}
require.NotEmpty(t, downloads, "%s must download modules",
productDockerfile)
for _, download := range downloads {
assert.Contains(t, download, "timeout 300 go mod download",
"%s: a module download must have a time limit: %s",
productDockerfile, download)
assert.Contains(t, download, "for attempt in 1 2 3",
"%s: a module download must be retried: %s",
productDockerfile, download)
assert.Contains(t, download, "module download failed",
"%s: a module download that never succeeds must say so: %s",
productDockerfile, download)
assert.True(t, strings.HasSuffix(download, "exit 1"),
"%s: a module download that never succeeds must fail the"+
" build: %s", productDockerfile, download)
}
}
@@ -161,14 +161,13 @@ func TestVerifyManifestBlobsRejectsShortHash(t *testing.T) {
Blobs: []snapshot.BlobInfo{{Hash: "abc", CompressedSize: 1}}, Blobs: []snapshot.BlobInfo{{Hash: "abc", CompressedSize: 1}},
} }
verified, missing, mismatched, missingSize, unchecked, err := verified, missing, mismatched, missingSize, err :=
v.verifyManifestBlobs(manifest, &VerifyOptions{JSON: true}) v.verifyManifestBlobs(manifest, &VerifyOptions{JSON: true})
require.ErrorIs(t, err, errInvalidBlobHash) require.ErrorIs(t, err, errInvalidBlobHash)
require.Zero(t, verified) require.Zero(t, verified)
require.Zero(t, missing) require.Zero(t, missing)
require.Zero(t, mismatched) require.Zero(t, mismatched)
require.Zero(t, missingSize) require.Zero(t, missingSize)
require.Empty(t, unchecked)
} }
// TestVerifyBlobChunksRejectsNegativeLength proves a blob_chunks row with a // TestVerifyBlobChunksRejectsNegativeLength proves a blob_chunks row with a
+12 -1
View File
@@ -309,11 +309,22 @@ func (v *Vaultik) restoreAllFiles(
return nil, err return nil, err
} }
plan, err := newRestorePlan(v.ctx, repos, files, chunkToBlobMap, blobIDToHash) plan, unplanned, err := newRestorePlan(
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 fails restore planning and the chunk write with // has no blob_chunks row is left out of the restore plan with an error,
// an error. // and fails the chunk write with an error.
func TestRestoreShortChunkHashInNoBlob(t *testing.T) { func TestRestoreShortChunkHashInNoBlob(t *testing.T) {
t.Parallel() t.Parallel()
@@ -77,9 +77,11 @@ 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)
_, err = newRestorePlan(ctx, repos, []*database.File{file}, plan, unplanned, err := newRestorePlan(ctx, repos, []*database.File{file},
chunkToBlobMap, map[string]string{}) chunkToBlobMap, map[string]string{})
require.ErrorIs(t, err, errPlanChunkMissing) require.NoError(t, err)
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)
+17 -8
View File
@@ -41,20 +41,25 @@ 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. // ready queue immediately. A file with a chunk that no blob holds cannot
// 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, error) { ) (*restorePlan, map[types.FileID]error, 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.
@@ -66,7 +71,7 @@ func newRestorePlan(
fileChunks, err := repos.FileChunks.GetByFileID(ctx, f.ID) fileChunks, err := repos.FileChunks.GetByFileID(ctx, f.ID)
if err != nil { if err != nil {
return nil, fmt.Errorf("planning %s: %w", f.Path, err) return nil, nil, fmt.Errorf("planning %s: %w", f.Path, err)
} }
blobs := make(map[string]struct{}) blobs := make(map[string]struct{})
@@ -74,14 +79,18 @@ func newRestorePlan(
for _, fc := range fileChunks { for _, fc := range fileChunks {
bc, ok := chunkToBlobMap[fc.ChunkHash.String()] bc, ok := chunkToBlobMap[fc.ChunkHash.String()]
if !ok { if !ok {
return nil, fmt.Errorf("planning %s: %w: %s", unplanned[f.ID] = fmt.Errorf("%w: %s",
f.Path, errPlanChunkMissing, shortHash(fc.ChunkHash.String())) errPlanChunkMissing, shortHash(fc.ChunkHash.String()))
continue nextFile
} }
hash, ok := blobIDToHash[bc.BlobID.String()] hash, ok := blobIDToHash[bc.BlobID.String()]
if !ok { if !ok {
return nil, fmt.Errorf("planning %s: %w: %s", unplanned[f.ID] = fmt.Errorf("%w: %s",
f.Path, errPlanBlobIDMissing, bc.BlobID) errPlanBlobIDMissing, bc.BlobID)
continue nextFile
} }
blobs[hash] = struct{}{} blobs[hash] = struct{}{}
@@ -103,7 +112,7 @@ func newRestorePlan(
} }
} }
return p, nil return p, unplanned, nil
} }
// markBlobCached records that the named blob is now resident in the // markBlobCached records that the named blob is now resident in the
+103 -16
View File
@@ -28,7 +28,8 @@ 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 deleted from the store. // blobs, with one of those blobs lost: deleted from the store, or missing
// from the snapshot metadata.
type missingBlobBackup struct { type missingBlobBackup struct {
fs afero.Fs fs afero.Fs
cfg *config.Config cfg *config.Config
@@ -37,20 +38,39 @@ 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 deleted blob. // lost holds the source paths whose chunk is in the lost 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. Every file stored in // after one blob of a two-blob snapshot was deleted from the store.
// 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()
backup := backupThenDeleteOneBlob(ctx, t) assertRestoreSkipsLostFiles(ctx, t, 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
@@ -72,14 +92,14 @@ func TestRestoreSkipErrorsSkipsFilesOfMissingBlob(t *testing.T) {
if backup.lost[path] { if backup.lost[path] {
assert.Containsf(t, out.String(), path, assert.Containsf(t, out.String(), path,
"%s needs the deleted blob and must be reported", path) "%s needs the lost 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 deleted blob", path) require.NoErrorf(t, err, "%s does not need the lost blob", path)
assert.Equalf(t, content, got, "%s restored with wrong content", path) assert.Equalf(t, content, got, "%s restored with wrong content", path)
} }
} }
@@ -103,6 +123,30 @@ 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(
@@ -110,6 +154,54 @@ 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")
@@ -143,16 +235,11 @@ func backupThenDeleteOneBlob(
blobOfFile[path] = blobHashOfSingleChunkFile(ctx, t, repos, path) blobOfFile[path] = blobHashOfSingleChunkFile(ctx, t, repos, path)
} }
require.NoError(t, db.Close()) lostBlob := blobOfFile[filepath.Join(dataDir, "file-00.bin")]
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 == deleted { if hash == lostBlob {
lost[path] = true lost[path] = true
} }
} }
@@ -168,7 +255,7 @@ func backupThenDeleteOneBlob(
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
-223
View File
@@ -4,20 +4,15 @@ import (
"bytes" "bytes"
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt"
"io" "io"
"os" "os"
"path"
"path/filepath" "path/filepath"
"slices"
"testing" "testing"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"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"
"sneak.berlin/go/vaultik/internal/ui" "sneak.berlin/go/vaultik/internal/ui"
"sneak.berlin/go/vaultik/internal/vaultik" "sneak.berlin/go/vaultik/internal/vaultik"
) )
@@ -156,224 +151,6 @@ func TestShallowVerifyDetectsMissingDatabase(t *testing.T) {
"shallow verify must fail when db.zst.age is absent") "shallow verify must fail when db.zst.age is absent")
} }
// errStatDenied stands in for any storage error other than not-found: a
// permission error, an S3 403, a dropped connection.
var errStatDenied = errors.New("injected stat failure")
// statFailingStorer fails Stat with errStatDenied for the keys in
// failKeys and passes every other call to the wrapped Storer.
type statFailingStorer struct {
storage.Storer
failKeys []string
}
func (s statFailingStorer) Stat(
ctx context.Context, key string,
) (*storage.ObjectInfo, error) {
if slices.Contains(s.failKeys, key) {
return nil, fmt.Errorf("stat %q: %w", key, errStatDenied)
}
return s.Storer.Stat(ctx, key)
}
// TestShallowVerifyReportsStatErrorsApartFromMissing backs up a real
// snapshot, deletes one blob, and makes storage fail Stat with an error
// other than not-found for a second blob and for the encrypted database.
// Only the deleted blob may be counted as missing. The second blob and
// the database must be reported as could not be checked, with the error
// storage returned, and verify must fail, in the text output and under
// --json.
func TestShallowVerifyReportsStatErrorsApartFromMissing(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
fs := afero.NewOsFs()
tempDir := t.TempDir()
dataDir := filepath.Join(tempDir, "source")
storeDir := filepath.Join(tempDir, "remote")
dbPath := filepath.Join(tempDir, "index.sqlite")
chunkSize := int64(32 * 1024)
maxBlobSize := int64(128 * 1024)
// Random content, so the snapshot spans several blobs.
require.NoError(t, fs.MkdirAll(dataDir, 0o755))
appendRandomBytes(t, fs, map[string][]byte{},
filepath.Join(dataDir, "data.bin"), maxBlobSize*4)
ctx := context.Background()
cfg, storer, snapshotID := runFileStorageBackup(
ctx, t, fs, dataDir, storeDir, dbPath, chunkSize, maxBlobSize)
blobKeys := storedBlobKeys(t, storeDir)
require.GreaterOrEqual(t, len(blobKeys), 2, "expected at least two blobs")
missingKey, uncheckedKey := blobKeys[0], blobKeys[1]
missingHash, uncheckedHash := path.Base(missingKey), path.Base(uncheckedKey)
require.NoError(t, os.Remove(filepath.Join(storeDir, missingKey)))
dbKey := "metadata/" + snapshot.RemoteSnapshotKey(snapshotID) + "/db.zst.age"
failing := statFailingStorer{
Storer: storer,
failKeys: []string{uncheckedKey, dbKey},
}
newVerifier := func(out io.Writer) *vaultik.Vaultik {
v := &vaultik.Vaultik{
Config: cfg,
Storage: failing,
Fs: fs,
Stdout: out,
Stderr: io.Discard,
UI: ui.NewWithColor(io.Discard, false),
}
v.SetContext(ctx)
return v
}
var out bytes.Buffer
err := newVerifier(&out).VerifySnapshotWithOptions(
snapshotID, &vaultik.VerifyOptions{JSON: true})
require.Error(t, err)
result := decodeVerifyResult(t, out.Bytes())
require.Equal(t, "failed", result.Status)
require.Equal(t, 1, result.Missing, "only the deleted blob is missing")
require.Equal(t, result.BlobCount-2, result.Verified)
require.Len(t, result.Unchecked, 1)
require.Equal(t, uncheckedHash, result.Unchecked[0].Hash)
require.Contains(t, result.Unchecked[0].Error, errStatDenied.Error())
require.False(t, result.DatabaseMissing,
"a database that could not be checked is not missing")
require.Contains(t, result.DatabaseCheckError, errStatDenied.Error())
out.Reset()
err = newVerifier(&out).VerifySnapshotWithOptions(
snapshotID, &vaultik.VerifyOptions{})
require.Error(t, err)
text := out.String()
require.Contains(t, text, "Missing: "+missingHash)
require.NotContains(t, text, "Missing: "+uncheckedHash)
require.Contains(t, text, "Could not check: "+uncheckedHash)
require.Contains(t, text, "Encrypted database: could not be checked")
require.Contains(t, text, errStatDenied.Error())
require.Contains(t, text, "FAILED")
}
// TestShallowVerifyQuietFailsOnStatErrorAlone backs up a healthy snapshot,
// then makes storage fail Stat, with an error other than not-found, for
// one blob alone and then for the encrypted database alone. Each run must
// fail verify, and under --quiet, which drops the report, the error
// storage returned must still reach stderr.
//
//nolint:paralleltest // captureProcessStderr replaces os.Stderr
func TestShallowVerifyQuietFailsOnStatErrorAlone(t *testing.T) {
log.Initialize(log.Config{})
fs := afero.NewOsFs()
tempDir := t.TempDir()
dataDir := filepath.Join(tempDir, "source")
storeDir := filepath.Join(tempDir, "remote")
dbPath := filepath.Join(tempDir, "index.sqlite")
chunkSize := int64(32 * 1024)
maxBlobSize := int64(128 * 1024)
require.NoError(t, fs.MkdirAll(dataDir, 0o755))
require.NoError(t, afero.WriteFile(fs,
filepath.Join(dataDir, "data.bin"),
bytesPattern("shallow-quiet-", int(maxBlobSize*2)), 0o644))
ctx := context.Background()
cfg, storer, snapshotID := runFileStorageBackup(
ctx, t, fs, dataDir, storeDir, dbPath, chunkSize, maxBlobSize)
newQuietVerifier := func(s storage.Storer) *vaultik.Vaultik {
v := &vaultik.Vaultik{
Config: cfg,
Storage: s,
Fs: fs,
Stdout: io.Discard,
Stderr: io.Discard,
UI: ui.NewWithColor(io.Discard, false),
}
v.SetContext(ctx)
v.UI.SetQuiet(true)
return v
}
require.NoError(t,
newQuietVerifier(storer).VerifySnapshotWithOptions(
snapshotID, &vaultik.VerifyOptions{}),
"shallow verify should pass on a healthy snapshot")
blobKeys := storedBlobKeys(t, storeDir)
require.NotEmpty(t, blobKeys)
dbKey := "metadata/" + snapshot.RemoteSnapshotKey(snapshotID) + "/db.zst.age"
for _, failKey := range []string{blobKeys[0], dbKey} {
v := newQuietVerifier(statFailingStorer{
Storer: storer,
failKeys: []string{failKey},
})
var err error
stderr := captureProcessStderr(t, func() {
err = v.VerifySnapshotWithOptions(
snapshotID, &vaultik.VerifyOptions{})
})
require.Error(t, err,
"verify must fail when %s could not be checked", failKey)
require.Contains(t, stderr, errStatDenied.Error(),
"under --quiet the error for %s must still reach stderr", failKey)
}
}
// storedBlobKeys returns the storage key of every blob file under
// storeDir, sorted.
func storedBlobKeys(t *testing.T, storeDir string) []string {
t.Helper()
var keys []string
err := filepath.Walk(filepath.Join(storeDir, "blobs"),
func(p string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
return nil
}
rel, err := filepath.Rel(storeDir, p)
if err != nil {
return err
}
keys = append(keys, filepath.ToSlash(rel))
return nil
})
require.NoError(t, err)
return keys
}
// growOneBlob appends bytes to the first blob file found under blobsDir, // growOneBlob appends bytes to the first blob file found under blobsDir,
// changing its on-disk size so it no longer matches the manifest. // changing its on-disk size so it no longer matches the manifest.
func growOneBlob(t *testing.T, fs afero.Fs, blobsDir string) { func growOneBlob(t *testing.T, fs afero.Fs, blobsDir string) {
+21 -70
View File
@@ -14,7 +14,6 @@ import (
"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"
"sneak.berlin/go/vaultik/internal/types" "sneak.berlin/go/vaultik/internal/types"
) )
@@ -778,10 +777,18 @@ func (v *Vaultik) VerifySnapshotWithOptions(
v.stdoutf("Checking blob presence and sizes...\n") v.stdoutf("Checking blob presence and sizes...\n")
} }
v.checkSnapshotDatabase(remoteKey, result) // A snapshot is only restorable if its encrypted database is present
// alongside the blobs. Shallow verify checks that the object exists; it
// does not decrypt it (that is deep verify's job).
dbPath := fmt.Sprintf("metadata/%s/db.zst.age", remoteKey)
result.Verified, result.Missing, result.Mismatched, result.MissingSize, _, dbErr := v.Storage.Stat(v.ctx, dbPath)
result.Unchecked, err = v.verifyManifestBlobs(manifest, opts) if dbErr != nil {
result.DatabaseMissing = true
}
result.Verified, result.Missing, result.Mismatched, result.MissingSize, err =
v.verifyManifestBlobs(manifest, opts)
if err != nil { if err != nil {
if opts.JSON { if opts.JSON {
result.Status = verifyStatusFailed result.Status = verifyStatusFailed
@@ -818,46 +825,25 @@ func (v *Vaultik) printVerifyHeader(snapshotID string, opts *VerifyOptions) {
} }
} }
// checkSnapshotDatabase records in result whether the snapshot's
// encrypted database is missing or could not be checked. A snapshot is
// only restorable if its database is present alongside the blobs.
// Shallow verify checks that the object exists; it does not decrypt it
// (that is deep verify's job).
func (v *Vaultik) checkSnapshotDatabase(remoteKey string, result *VerifyResult) {
dbPath := fmt.Sprintf("metadata/%s/db.zst.age", remoteKey)
_, err := v.Storage.Stat(v.ctx, dbPath)
switch {
case errors.Is(err, storage.ErrNotFound):
result.DatabaseMissing = true
case err != nil:
result.DatabaseCheckError = err.Error()
}
}
// verifyManifestBlobs checks that each blob in the manifest is present in // verifyManifestBlobs checks that each blob in the manifest is present in
// storage with the size the manifest records, returning the counts of // storage with the size the manifest records, returning the counts of
// blobs that were present with the right size, absent, and present but the // blobs that were present with the right size, absent, and present but the
// wrong size, the total bytes of the absent blobs, and the blobs it could // wrong size, plus the total bytes of the absent blobs. It does not read
// not check because storage returned an error other than not-found. It // blob contents; deep verification (RunDeepVerify) does that. The size
// does not read blob contents; deep verification (RunDeepVerify) does // comparison matches the deep path (see verifyBlobExistenceFromDB).
// that. The size comparison matches the deep path (see
// verifyBlobExistenceFromDB).
func (v *Vaultik) verifyManifestBlobs( func (v *Vaultik) verifyManifestBlobs(
manifest *snapshot.Manifest, opts *VerifyOptions, manifest *snapshot.Manifest, opts *VerifyOptions,
) (int, int, int, int64, []UncheckedBlob, error) { ) (int, int, int, int64, error) {
var ( var (
verified, missing, mismatched int verified, missing, mismatched int
missingSize int64 missingSize int64
unchecked []UncheckedBlob
) )
for _, blob := range manifest.Blobs { for _, blob := range manifest.Blobs {
// The manifest is unauthenticated, so its blob hashes are checked // The manifest is unauthenticated, so its blob hashes are checked
// before being spliced into a storage path. // before being spliced into a storage path.
if !isBlobHash(blob.Hash) { if !isBlobHash(blob.Hash) {
return 0, 0, 0, 0, nil, fmt.Errorf("%w: %s", return 0, 0, 0, 0, fmt.Errorf("%w: %s",
errInvalidBlobHash, shortHash(blob.Hash)) errInvalidBlobHash, shortHash(blob.Hash))
} }
@@ -866,7 +852,7 @@ func (v *Vaultik) verifyManifestBlobs(
stat, err := v.Storage.Stat(v.ctx, blobPath) stat, err := v.Storage.Stat(v.ctx, blobPath)
switch { switch {
case errors.Is(err, storage.ErrNotFound): case err != nil:
if !opts.JSON && !v.UI.Quiet() { if !opts.JSON && !v.UI.Quiet() {
v.stdoutf(" Missing: %s (%s)\n", v.stdoutf(" Missing: %s (%s)\n",
blob.Hash, ubytes(blob.CompressedSize)) blob.Hash, ubytes(blob.CompressedSize))
@@ -874,13 +860,6 @@ func (v *Vaultik) verifyManifestBlobs(
missing++ missing++
missingSize += blob.CompressedSize missingSize += blob.CompressedSize
case err != nil:
if !opts.JSON && !v.UI.Quiet() {
v.stdoutf(" Could not check: %s (%v)\n", blob.Hash, err)
}
unchecked = append(unchecked,
UncheckedBlob{Hash: blob.Hash, Error: err.Error()})
case stat.Size != blob.CompressedSize: case stat.Size != blob.CompressedSize:
if !opts.JSON && !v.UI.Quiet() { if !opts.JSON && !v.UI.Quiet() {
v.stdoutf(" Wrong size: %s (store has %s, manifest lists %s)\n", v.stdoutf(" Wrong size: %s (store has %s, manifest lists %s)\n",
@@ -893,7 +872,7 @@ func (v *Vaultik) verifyManifestBlobs(
} }
} }
return verified, missing, mismatched, missingSize, unchecked, nil return verified, missing, mismatched, missingSize, nil
} }
// formatVerifyResult outputs the final verification results as JSON or // formatVerifyResult outputs the final verification results as JSON or
@@ -914,19 +893,9 @@ func (v *Vaultik) formatVerifyResult(
return v.outputVerifyJSON(result) return v.outputVerifyJSON(result)
} }
// --quiet drops the report but not the errors storage returned, which // Under --quiet a failure is still returned, and the cli layer
// are logged on stderr. A failure is still returned either way, and // prints it on stderr.
// the cli layer prints it on stderr. if !v.UI.Quiet() {
if v.UI.Quiet() {
for _, blob := range result.Unchecked {
log.Warn("Could not check blob", "blob", blob.Hash, "error", blob.Error)
}
if result.DatabaseCheckError != "" {
log.Warn("Could not check the encrypted database",
"snapshot", result.SnapshotID, "error", result.DatabaseCheckError)
}
} else {
v.printVerifySummary(result, failure) v.printVerifySummary(result, failure)
} }
@@ -952,19 +921,10 @@ func (v *Vaultik) printVerifySummary(result *VerifyResult, failure string) {
v.stdoutf(" Wrong size: %d blobs\n", result.Mismatched) v.stdoutf(" Wrong size: %d blobs\n", result.Mismatched)
} }
if len(result.Unchecked) > 0 {
v.stdoutf(" Could not check: %d blobs\n", len(result.Unchecked))
}
if result.DatabaseMissing { if result.DatabaseMissing {
v.stdoutf(" Encrypted database: missing\n") v.stdoutf(" Encrypted database: missing\n")
} }
if result.DatabaseCheckError != "" {
v.stdoutf(" Encrypted database: could not be checked (%s)\n",
result.DatabaseCheckError)
}
v.stdoutf(" Status: ") v.stdoutf(" Status: ")
if failure != "" { if failure != "" {
@@ -992,19 +952,10 @@ func shallowVerifyFailure(result *VerifyResult) string {
fmt.Sprintf("%d blobs have the wrong size", result.Mismatched)) fmt.Sprintf("%d blobs have the wrong size", result.Mismatched))
} }
if len(result.Unchecked) > 0 {
parts = append(parts, fmt.Sprintf("%d blobs could not be checked",
len(result.Unchecked)))
}
if result.DatabaseMissing { if result.DatabaseMissing {
parts = append(parts, "the encrypted database is missing") parts = append(parts, "the encrypted database is missing")
} }
if result.DatabaseCheckError != "" {
parts = append(parts, "the encrypted database could not be checked")
}
return strings.Join(parts, "; ") return strings.Join(parts, "; ")
} }
-14
View File
@@ -57,27 +57,13 @@ type VerifyResult struct {
Missing int `json:"missing"` Missing int `json:"missing"`
MissingSize int64 `json:"missing_size,omitempty"` MissingSize int64 `json:"missing_size,omitempty"`
Mismatched int `json:"mismatched,omitempty"` Mismatched int `json:"mismatched,omitempty"`
// Unchecked lists the blobs shallow verify could not check, because
// storage answered with an error other than not-found, so whether
// they are present is unknown.
Unchecked []UncheckedBlob `json:"unchecked,omitempty"`
// DatabaseMissing is set by shallow verify when the snapshot's // DatabaseMissing is set by shallow verify when the snapshot's
// encrypted database (metadata/<key>/db.zst.age) is absent, which // encrypted database (metadata/<key>/db.zst.age) is absent, which
// makes the snapshot unrestorable regardless of the blobs. // makes the snapshot unrestorable regardless of the blobs.
DatabaseMissing bool `json:"database_missing,omitempty"` DatabaseMissing bool `json:"database_missing,omitempty"`
// DatabaseCheckError is set by shallow verify to the error storage
// returned when the encrypted database could not be checked.
DatabaseCheckError string `json:"database_check_error,omitempty"`
ErrorMessage string `json:"error,omitempty"` ErrorMessage string `json:"error,omitempty"`
} }
// UncheckedBlob is a blob shallow verify could not check, with the error
// storage returned for it.
type UncheckedBlob struct {
Hash string `json:"hash"`
Error string `json:"error"`
}
// deepVerifyFailure records a failure in the result and returns it appropriately // deepVerifyFailure records a failure in the result and returns it appropriately
func (v *Vaultik) deepVerifyFailure( func (v *Vaultik) deepVerifyFailure(
result *VerifyResult, opts *VerifyOptions, msg string, err error, result *VerifyResult, opts *VerifyOptions, msg string, err error,