Compare commits
5
Commits
8585bc87bb
...
3116c1a646
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3116c1a646 | ||
|
|
5d8c515192 | ||
|
|
42eb50b151 | ||
|
|
25ee9211d3 | ||
|
|
3361e9ebd9 |
+24
-3
@@ -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
|
||||||
|
|||||||
@@ -294,7 +294,8 @@ needed.
|
|||||||
of the local index and the backup destination store — with timestamps
|
of the local index and the backup destination store — with timestamps
|
||||||
and three sizes per snapshot (compressed remote size; total
|
and three sizes per snapshot (compressed remote size; total
|
||||||
uncompressed chunk size; size of the chunks that snapshot stored that
|
uncompressed chunk size; size of the chunks that snapshot stored that
|
||||||
were not stored before, recorded when its backup ran).
|
were not stored before). Both chunk sizes are recorded when the
|
||||||
|
snapshot's backup runs.
|
||||||
|
|
||||||
Listing the destination store does **not** require the age secret key,
|
Listing the destination store does **not** require the age secret key,
|
||||||
so it works in vaultik's intended configuration, where the backed-up
|
so it works in vaultik's intended configuration, where the backed-up
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -122,6 +122,7 @@ Tracks backup snapshots.
|
|||||||
- `new_chunk_size` (INTEGER) - Total size of those chunks before compression; `snapshot list` shows it as NEW CHUNK SIZE
|
- `new_chunk_size` (INTEGER) - Total size of those chunks before compression; `snapshot list` shows it as NEW CHUNK SIZE
|
||||||
- `blob_count` (INTEGER) - Number of blobs this snapshot created
|
- `blob_count` (INTEGER) - Number of blobs this snapshot created
|
||||||
- `total_size` (INTEGER) - Total size of all files
|
- `total_size` (INTEGER) - Total size of all files
|
||||||
|
- `uncompressed_chunk_size` (INTEGER) - Total size before compression of the distinct chunks the snapshot's files reference, recorded when its backup ran; `snapshot list` shows it as UNCOMPRESSED SIZE
|
||||||
- `blob_size` (INTEGER) - Total compressed size of all referenced blobs
|
- `blob_size` (INTEGER) - Total compressed size of all referenced blobs
|
||||||
- `blob_uncompressed_size` (INTEGER) - Total uncompressed size of all referenced blobs
|
- `blob_uncompressed_size` (INTEGER) - Total uncompressed size of all referenced blobs
|
||||||
- `compression_ratio` (REAL) - Compression ratio achieved
|
- `compression_ratio` (REAL) - Compression ratio achieved
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ CREATE TABLE IF NOT EXISTS snapshots (
|
|||||||
new_chunk_size INTEGER NOT NULL DEFAULT 0,
|
new_chunk_size INTEGER NOT NULL DEFAULT 0,
|
||||||
blob_count INTEGER NOT NULL DEFAULT 0,
|
blob_count INTEGER NOT NULL DEFAULT 0,
|
||||||
total_size INTEGER NOT NULL DEFAULT 0,
|
total_size INTEGER NOT NULL DEFAULT 0,
|
||||||
|
uncompressed_chunk_size INTEGER NOT NULL DEFAULT 0,
|
||||||
blob_size INTEGER NOT NULL DEFAULT 0,
|
blob_size INTEGER NOT NULL DEFAULT 0,
|
||||||
blob_uncompressed_size INTEGER NOT NULL DEFAULT 0,
|
blob_uncompressed_size INTEGER NOT NULL DEFAULT 0,
|
||||||
compression_ratio REAL NOT NULL DEFAULT 1.0,
|
compression_ratio REAL NOT NULL DEFAULT 1.0,
|
||||||
|
|||||||
@@ -120,7 +120,11 @@ func (r *SnapshotRepository) UpdateCounts(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateExtendedStats updates extended statistics for a snapshot
|
// UpdateExtendedStats updates extended statistics for a snapshot. It also
|
||||||
|
// records the total plaintext size of the distinct chunks the snapshot's
|
||||||
|
// files reference. Only the snapshot's own backup can take that total: a
|
||||||
|
// file's row is shared by every snapshot that lists it, and a later
|
||||||
|
// backup of a changed file rewrites the row with the new chunks.
|
||||||
func (r *SnapshotRepository) UpdateExtendedStats(
|
func (r *SnapshotRepository) UpdateExtendedStats(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
tx *sql.Tx,
|
tx *sql.Tx,
|
||||||
@@ -145,7 +149,17 @@ func (r *SnapshotRepository) UpdateExtendedStats(
|
|||||||
compression_level = ?,
|
compression_level = ?,
|
||||||
upload_bytes = ?,
|
upload_bytes = ?,
|
||||||
upload_duration_ms = ?,
|
upload_duration_ms = ?,
|
||||||
new_chunk_size = ?
|
new_chunk_size = ?,
|
||||||
|
uncompressed_chunk_size = (
|
||||||
|
SELECT COALESCE(SUM(c.size), 0)
|
||||||
|
FROM (
|
||||||
|
SELECT DISTINCT fc.chunk_hash
|
||||||
|
FROM snapshot_files sf
|
||||||
|
JOIN file_chunks fc ON sf.file_id = fc.file_id
|
||||||
|
WHERE sf.snapshot_id = snapshots.id
|
||||||
|
) sc
|
||||||
|
JOIN chunks c ON sc.chunk_hash = c.chunk_hash
|
||||||
|
)
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -555,30 +569,22 @@ func (r *SnapshotRepository) GetSnapshotBlobSizes(
|
|||||||
return compressed, uncompressed, nil
|
return compressed, uncompressed, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSnapshotUncompressedChunkSize returns the sum of plaintext sizes of all unique
|
// GetSnapshotUncompressedChunkSize returns the total plaintext size of the
|
||||||
// chunks referenced by a snapshot (via snapshot_files → file_chunks → chunks).
|
// distinct chunks the snapshot's files reference, as the snapshot's backup
|
||||||
|
// recorded it (see UpdateExtendedStats).
|
||||||
func (r *SnapshotRepository) GetSnapshotUncompressedChunkSize(
|
func (r *SnapshotRepository) GetSnapshotUncompressedChunkSize(
|
||||||
ctx context.Context, snapshotID string,
|
ctx context.Context, snapshotID string,
|
||||||
) (int64, error) {
|
) (int64, error) {
|
||||||
query := `
|
query := `SELECT uncompressed_chunk_size FROM snapshots WHERE id = ?`
|
||||||
SELECT COALESCE(SUM(c.size), 0)
|
|
||||||
FROM (
|
|
||||||
SELECT DISTINCT fc.chunk_hash
|
|
||||||
FROM snapshot_files sf
|
|
||||||
JOIN file_chunks fc ON sf.file_id = fc.file_id
|
|
||||||
WHERE sf.snapshot_id = ?
|
|
||||||
) sc
|
|
||||||
JOIN chunks c ON sc.chunk_hash = c.chunk_hash
|
|
||||||
`
|
|
||||||
|
|
||||||
var totalSize int64
|
var uncompressedChunkSize int64
|
||||||
|
|
||||||
err := r.db.conn.QueryRowContext(ctx, query, snapshotID).Scan(&totalSize)
|
err := r.db.conn.QueryRowContext(ctx, query, snapshotID).Scan(&uncompressedChunkSize)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("querying uncompressed chunk size: %w", err)
|
return 0, fmt.Errorf("querying uncompressed chunk size: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return totalSize, nil
|
return uncompressedChunkSize, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSnapshotNewChunkSize returns the total plaintext size of the chunks the
|
// GetSnapshotNewChunkSize returns the total plaintext size of the chunks the
|
||||||
|
|||||||
+63
-13
@@ -120,9 +120,10 @@ func (f *FileStorer) Delete(_ context.Context, key string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// List returns all keys with the given prefix. It fails when the
|
// List returns all keys with the given prefix, reading through symlinked
|
||||||
// destination directory is missing; a missing prefix under it is an
|
// directories. It fails when the destination directory is missing, or
|
||||||
// empty listing.
|
// when the prefix or a directory above it is a symlink whose target is
|
||||||
|
// missing; a prefix that is truly missing is an empty listing.
|
||||||
func (f *FileStorer) List(ctx context.Context, prefix string) ([]string, error) {
|
func (f *FileStorer) List(ctx context.Context, prefix string) ([]string, error) {
|
||||||
var keys []string
|
var keys []string
|
||||||
|
|
||||||
@@ -133,8 +134,7 @@ func (f *FileStorer) List(ctx context.Context, prefix string) ([]string, error)
|
|||||||
|
|
||||||
basePath := f.fullPath(prefix)
|
basePath := f.fullPath(prefix)
|
||||||
|
|
||||||
// Check if the prefix exists
|
exists, err := f.prefixExists(prefix)
|
||||||
exists, err := afero.Exists(f.fs, basePath)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("checking path: %w", err)
|
return nil, fmt.Errorf("checking path: %w", err)
|
||||||
}
|
}
|
||||||
@@ -143,7 +143,7 @@ func (f *FileStorer) List(ctx context.Context, prefix string) ([]string, error)
|
|||||||
return keys, nil // Empty list for non-existent prefix
|
return keys, nil // Empty list for non-existent prefix
|
||||||
}
|
}
|
||||||
|
|
||||||
err = afero.Walk(f.fs, basePath, func(path string, info os.FileInfo, err error) error {
|
err = f.walk(basePath, func(path string, info os.FileInfo, err error) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -176,7 +176,9 @@ func (f *FileStorer) List(ctx context.Context, prefix string) ([]string, error)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ListStream returns a channel of ObjectInfo for large result sets. Like
|
// ListStream returns a channel of ObjectInfo for large result sets. Like
|
||||||
// List, it sends an error when the destination directory is missing.
|
// List, it reads through symlinked directories and sends an error when
|
||||||
|
// the destination directory is missing, or when the prefix or a directory
|
||||||
|
// above it is a symlink whose target is missing.
|
||||||
func (f *FileStorer) ListStream(ctx context.Context, prefix string) <-chan ObjectInfo {
|
func (f *FileStorer) ListStream(ctx context.Context, prefix string) <-chan ObjectInfo {
|
||||||
ch := make(chan ObjectInfo)
|
ch := make(chan ObjectInfo)
|
||||||
|
|
||||||
@@ -192,8 +194,7 @@ func (f *FileStorer) ListStream(ctx context.Context, prefix string) <-chan Objec
|
|||||||
|
|
||||||
basePath := f.fullPath(prefix)
|
basePath := f.fullPath(prefix)
|
||||||
|
|
||||||
// Check if the prefix exists
|
exists, err := f.prefixExists(prefix)
|
||||||
exists, err := afero.Exists(f.fs, basePath)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ch <- ObjectInfo{Err: fmt.Errorf("checking path: %w", err)}
|
ch <- ObjectInfo{Err: fmt.Errorf("checking path: %w", err)}
|
||||||
|
|
||||||
@@ -204,7 +205,7 @@ func (f *FileStorer) ListStream(ctx context.Context, prefix string) <-chan Objec
|
|||||||
return // Empty channel for non-existent prefix
|
return // Empty channel for non-existent prefix
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = afero.Walk(f.fs, basePath, func(path string, info os.FileInfo, err error) error {
|
_ = f.walk(basePath, func(path string, info os.FileInfo, err error) error {
|
||||||
// Check context cancellation
|
// Check context cancellation
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
@@ -243,11 +244,12 @@ func (f *FileStorer) ListStream(ctx context.Context, prefix string) <-chan Objec
|
|||||||
}
|
}
|
||||||
|
|
||||||
// DeletePartialUploads removes every file under prefix whose name ends in
|
// DeletePartialUploads removes every file under prefix whose name ends in
|
||||||
// tempSuffix. A missing prefix has none to remove.
|
// tempSuffix. Like List, it reads through symlinked directories and fails
|
||||||
|
// on one whose target is missing. A missing prefix has none to remove.
|
||||||
func (f *FileStorer) DeletePartialUploads(ctx context.Context, prefix string) error {
|
func (f *FileStorer) DeletePartialUploads(ctx context.Context, prefix string) error {
|
||||||
basePath := f.fullPath(prefix)
|
basePath := f.fullPath(prefix)
|
||||||
|
|
||||||
exists, err := afero.Exists(f.fs, basePath)
|
exists, err := f.prefixExists(prefix)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("checking path: %w", err)
|
return fmt.Errorf("checking path: %w", err)
|
||||||
}
|
}
|
||||||
@@ -256,7 +258,7 @@ func (f *FileStorer) DeletePartialUploads(ctx context.Context, prefix string) er
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
err = afero.Walk(f.fs, basePath, func(path string, info os.FileInfo, err error) error {
|
err = f.walk(basePath, func(path string, info os.FileInfo, err error) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -352,6 +354,54 @@ func (f *FileStorer) writeAtomic(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// walk is afero.Walk, except that it reads through a symlinked directory
|
||||||
|
// the way Get and Stat do, where afero.Walk reports the link as one file.
|
||||||
|
// afero.Walk uses Lstat, which does not follow a link, whenever the
|
||||||
|
// filesystem has one; the struct below carries only the afero.Fs methods,
|
||||||
|
// so the walk uses Stat instead. A link back up the tree is followed until
|
||||||
|
// the operating system refuses the path for too many levels of links, and
|
||||||
|
// the walk then fails.
|
||||||
|
func (f *FileStorer) walk(root string, fn filepath.WalkFunc) error {
|
||||||
|
return afero.Walk(struct{ afero.Fs }{f.fs}, root, fn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// prefixExists reports whether prefix exists under the destination
|
||||||
|
// directory. It checks each directory on the way down to prefix in turn,
|
||||||
|
// and the first one Stat cannot find decides: if it is not there at all,
|
||||||
|
// prefix is missing; if it is a symlink whose target is missing, such as
|
||||||
|
// a link to a volume that is not mounted, prefixExists fails, since
|
||||||
|
// listing it as empty would let prune delete every blob.
|
||||||
|
func (f *FileStorer) prefixExists(prefix string) (bool, error) {
|
||||||
|
lstater, canLstat := f.fs.(afero.Lstater)
|
||||||
|
path := f.basePath
|
||||||
|
|
||||||
|
for name := range strings.SplitSeq(prefix, "/") {
|
||||||
|
path = filepath.Join(path, name)
|
||||||
|
|
||||||
|
_, err := f.fs.Stat(path)
|
||||||
|
if err == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if !os.IsNotExist(err) {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lstat does not follow a symlink at path, so it finds the link
|
||||||
|
// that Stat could not follow.
|
||||||
|
if canLstat {
|
||||||
|
_, _, lstatErr := lstater.LstatIfPossible(path)
|
||||||
|
if lstatErr == nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
// fullPath returns the full filesystem path for a key.
|
// fullPath returns the full filesystem path for a key.
|
||||||
func (f *FileStorer) fullPath(key string) string {
|
func (f *FileStorer) fullPath(key string) string {
|
||||||
return filepath.Join(f.basePath, key)
|
return filepath.Join(f.basePath, key)
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"sneak.berlin/go/vaultik/internal/storage"
|
"sneak.berlin/go/vaultik/internal/storage"
|
||||||
@@ -60,3 +63,232 @@ func TestFileStorerListMissingDestination(t *testing.T) {
|
|||||||
t.Errorf("ListStream error = %v, want a not-exist error", streamErr)
|
t.Errorf("ListStream error = %v, want a not-exist error", streamErr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The two directories at the top of a destination, either of which the
|
||||||
|
// tests below replace with a symlink.
|
||||||
|
const (
|
||||||
|
metadataDir = "metadata"
|
||||||
|
blobsDir = "blobs"
|
||||||
|
)
|
||||||
|
|
||||||
|
// moveBehindSymlink moves base/dir to a directory elsewhere and puts a
|
||||||
|
// symlink to it in its place.
|
||||||
|
func moveBehindSymlink(t *testing.T, base, dir string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
linkPath := filepath.Join(base, dir)
|
||||||
|
movedPath := filepath.Join(t.TempDir(), dir)
|
||||||
|
|
||||||
|
err := os.Rename(linkPath, movedPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("moving %s away: %v", dir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = os.Symlink(movedPath, linkPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("linking %s: %v", dir, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFileStorerListsThroughSymlinkedDirectory checks that List and
|
||||||
|
// ListStream read through a symlinked metadata/ or blobs/ directory, as Get
|
||||||
|
// and Stat do, instead of listing the link as one key. Listing the whole
|
||||||
|
// destination meets the link part-way through the walk; listing its prefix
|
||||||
|
// starts the walk at the link. A prefix missing behind the link lists as
|
||||||
|
// empty.
|
||||||
|
func TestFileStorerListsThroughSymlinkedDirectory(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, dir := range []string{metadataDir, blobsDir} {
|
||||||
|
t.Run(dir, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
base := t.TempDir()
|
||||||
|
key := dir + "/aa/bb/object"
|
||||||
|
|
||||||
|
s, err := storage.NewFileStorer(base)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewFileStorer: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = s.Put(context.Background(), key, strings.NewReader("data"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Put: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
moveBehindSymlink(t, base, dir)
|
||||||
|
|
||||||
|
want := []string{key}
|
||||||
|
|
||||||
|
for _, prefix := range []string{"", dir + "/"} {
|
||||||
|
got := listSorted(t, s, prefix)
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Errorf("List(%q) = %v, want %v", prefix, got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
got = listStreamKeys(t, s, prefix)
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Errorf("ListStream(%q) = %v, want %v", prefix, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
absent := dir + "/absent/"
|
||||||
|
|
||||||
|
if got := listSorted(t, s, absent); len(got) != 0 {
|
||||||
|
t.Errorf("List(%q) = %v, want empty", absent, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := listStreamKeys(t, s, absent); len(got) != 0 {
|
||||||
|
t.Errorf("ListStream(%q) = %v, want empty", absent, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFileStorerListFailsOnSymlinkWithMissingTarget checks that List and
|
||||||
|
// ListStream fail on a symlinked metadata/ or blobs/ directory whose target
|
||||||
|
// is missing, such as a link to a volume that is not mounted, as they do on
|
||||||
|
// a missing destination directory, instead of listing it as empty. That
|
||||||
|
// holds for a prefix below the link too.
|
||||||
|
func TestFileStorerListFailsOnSymlinkWithMissingTarget(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
for _, dir := range []string{metadataDir, blobsDir} {
|
||||||
|
t.Run(dir, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
base := t.TempDir()
|
||||||
|
|
||||||
|
s, err := storage.NewFileStorer(base)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewFileStorer: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
unmounted := filepath.Join(t.TempDir(), "unmounted")
|
||||||
|
|
||||||
|
err = os.Symlink(unmounted, filepath.Join(base, dir))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Symlink: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, prefix := range []string{"", dir + "/", dir + "/aa/"} {
|
||||||
|
keys, err := s.List(ctx, prefix)
|
||||||
|
if !errors.Is(err, fs.ErrNotExist) {
|
||||||
|
t.Errorf("List(%q) = %v, %v; want a not-exist error",
|
||||||
|
prefix, keys, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var streamErr error
|
||||||
|
|
||||||
|
for object := range s.ListStream(ctx, prefix) {
|
||||||
|
if object.Err != nil {
|
||||||
|
streamErr = object.Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !errors.Is(streamErr, fs.ErrNotExist) {
|
||||||
|
t.Errorf("ListStream(%q) error = %v, want a not-exist error",
|
||||||
|
prefix, streamErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFileStorerListFailsOnSymlinkLoop checks that a symlink pointing back
|
||||||
|
// at the destination makes List and ListStream fail instead of walking it
|
||||||
|
// forever.
|
||||||
|
func TestFileStorerListFailsOnSymlinkLoop(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
base := t.TempDir()
|
||||||
|
|
||||||
|
s, err := storage.NewFileStorer(base)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewFileStorer: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = s.Put(ctx, testBlobKey, strings.NewReader("data"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Put: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = os.Symlink(base, filepath.Join(base, "blobs", "loop"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Symlink: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
keys, err := s.List(ctx, "blobs/")
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("List = %d keys and no error, want an error", len(keys))
|
||||||
|
}
|
||||||
|
|
||||||
|
var streamErr error
|
||||||
|
|
||||||
|
for object := range s.ListStream(ctx, "blobs/") {
|
||||||
|
if object.Err != nil {
|
||||||
|
streamErr = object.Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if streamErr == nil {
|
||||||
|
t.Error("ListStream sent no error, want one")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFileStorerDeletePartialUploadsThroughSymlinkedDirectory checks that
|
||||||
|
// DeletePartialUploads, which remote nuke uses, removes a leftover temp
|
||||||
|
// file behind a symlinked metadata/ or blobs/ directory and keeps the
|
||||||
|
// object at the real key.
|
||||||
|
func TestFileStorerDeletePartialUploadsThroughSymlinkedDirectory(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
for _, dir := range []string{metadataDir, blobsDir} {
|
||||||
|
t.Run(dir, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
base := t.TempDir()
|
||||||
|
key := dir + "/aa/bb/object"
|
||||||
|
|
||||||
|
s, err := storage.NewFileStorer(base)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewFileStorer: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = s.Put(ctx, key, strings.NewReader("data"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Put: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
leftover := filepath.Join(base, key+"-123456.partial")
|
||||||
|
|
||||||
|
err = os.WriteFile(leftover, []byte("half"), 0o600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("writing leftover temp file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
moveBehindSymlink(t, base, dir)
|
||||||
|
|
||||||
|
err = s.DeletePartialUploads(ctx, dir+"/")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DeletePartialUploads: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// leftover runs through the link.
|
||||||
|
_, err = os.Stat(leftover)
|
||||||
|
if !os.IsNotExist(err) {
|
||||||
|
t.Errorf("leftover temp file was not removed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = s.Stat(ctx, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Stat of the real key: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+40
-6
@@ -30,6 +30,12 @@ var (
|
|||||||
"storage URL must not carry credentials; " +
|
"storage URL must not carry credentials; " +
|
||||||
"set s3.access_key_id and s3.secret_access_key in the config instead")
|
"set s3.access_key_id and s3.secret_access_key in the config instead")
|
||||||
ErrURLUnknownParam = errors.New("unknown query parameter in storage URL")
|
ErrURLUnknownParam = errors.New("unknown query parameter in storage URL")
|
||||||
|
ErrURLBadQuery = errors.New(
|
||||||
|
"storage URL query cannot be decoded; " +
|
||||||
|
"check it for a ; or a % not followed by two hex digits")
|
||||||
|
ErrRcloneRemoteParams = errors.New(
|
||||||
|
"rclone URL must not carry parameters in the remote name; " +
|
||||||
|
"set them on the remote with rclone config instead")
|
||||||
)
|
)
|
||||||
|
|
||||||
// URL represents a parsed storage URL.
|
// URL represents a parsed storage URL.
|
||||||
@@ -90,8 +96,9 @@ func parseFileURL(rawPath string) (*URL, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// parseS3URL parses an s3://bucket/prefix URL. It rejects credentials in
|
// parseS3URL parses an s3://bucket/prefix URL. It rejects credentials in
|
||||||
// the userinfo and any query parameter other than endpoint, region and
|
// the userinfo or in the endpoint parameter's userinfo, a query that
|
||||||
// ssl, so a credential-bearing URL is never stored or echoed.
|
// cannot be decoded, and any query parameter other than endpoint, region
|
||||||
|
// and ssl, so a credential-bearing URL is never stored or echoed.
|
||||||
func parseS3URL(rawURL string) (*URL, error) {
|
func parseS3URL(rawURL string) (*URL, error) {
|
||||||
u, err := url.Parse(rawURL)
|
u, err := url.Parse(rawURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -107,13 +114,26 @@ func parseS3URL(rawURL string) (*URL, error) {
|
|||||||
return nil, ErrMissingBucket
|
return nil, ErrMissingBucket
|
||||||
}
|
}
|
||||||
|
|
||||||
query := u.Query()
|
// u.Query() would silently drop a part it cannot decode, endpoint
|
||||||
|
// included. The decode error is not wrapped: it can quote the value.
|
||||||
|
query, err := url.ParseQuery(u.RawQuery)
|
||||||
|
if err != nil {
|
||||||
|
return nil, ErrURLBadQuery
|
||||||
|
}
|
||||||
|
|
||||||
err = rejectUnknownParams(query, "endpoint", "region", "ssl")
|
err = rejectUnknownParams(query, "endpoint", "region", "ssl")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A host name never contains "@", so one in an endpoint, with or
|
||||||
|
// without a scheme, can only introduce userinfo.
|
||||||
|
for _, endpoint := range query["endpoint"] {
|
||||||
|
if strings.Contains(endpoint, "@") {
|
||||||
|
return nil, ErrURLCredentials
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return &URL{
|
return &URL{
|
||||||
Scheme: schemeS3,
|
Scheme: schemeS3,
|
||||||
Bucket: bucket,
|
Bucket: bucket,
|
||||||
@@ -125,9 +145,23 @@ func parseS3URL(rawURL string) (*URL, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// parseRcloneURL parses an rclone://remote/path URL. rclone:// takes no
|
// parseRcloneURL parses an rclone://remote/path URL. rclone:// takes no
|
||||||
// query parameters, so credentials in the userinfo and any parameter at
|
// query parameters, so credentials in the userinfo, parameters in the
|
||||||
// all are rejected rather than silently ignored.
|
// remote name and any query parameter at all are rejected rather than
|
||||||
|
// silently ignored.
|
||||||
func parseRcloneURL(rawURL string) (*URL, error) {
|
func parseRcloneURL(rawURL string) (*URL, error) {
|
||||||
|
// The remote name runs up to the first "/", "?" or "#". It never
|
||||||
|
// contains a comma; one starts rclone's remote,param=value form,
|
||||||
|
// whose values can be credentials. This is checked before url.Parse,
|
||||||
|
// whose error for such a name can print part of it.
|
||||||
|
remote := strings.TrimPrefix(rawURL, "rclone://")
|
||||||
|
if end := strings.IndexAny(remote, "/?#"); end >= 0 {
|
||||||
|
remote = remote[:end]
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(remote, ",") {
|
||||||
|
return nil, ErrRcloneRemoteParams
|
||||||
|
}
|
||||||
|
|
||||||
u, err := url.Parse(rawURL)
|
u, err := url.Parse(rawURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, wrapParseError(err)
|
return nil, wrapParseError(err)
|
||||||
@@ -137,7 +171,7 @@ func parseRcloneURL(rawURL string) (*URL, error) {
|
|||||||
return nil, ErrURLCredentials
|
return nil, ErrURLCredentials
|
||||||
}
|
}
|
||||||
|
|
||||||
remote := u.Host
|
remote = u.Host
|
||||||
if remote == "" {
|
if remote == "" {
|
||||||
return nil, ErrMissingRemote
|
return nil, ErrMissingRemote
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -150,6 +150,19 @@ func TestParseStorageURLRejectsCredentials(t *testing.T) {
|
|||||||
wantErr: storage.ErrURLCredentials,
|
wantErr: storage.ErrURLCredentials,
|
||||||
secrets: []string{key, secret, "mybucket"},
|
secrets: []string{key, secret, "mybucket"},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "s3 userinfo in endpoint",
|
||||||
|
raw: "s3://mybucket?endpoint=https://" +
|
||||||
|
key + ":" + secret + "@minio.example.com",
|
||||||
|
wantErr: storage.ErrURLCredentials,
|
||||||
|
secrets: []string{key, secret},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "s3 userinfo in endpoint without scheme",
|
||||||
|
raw: "s3://mybucket?endpoint=" + key + ":" + secret + "@minio.example.com",
|
||||||
|
wantErr: storage.ErrURLCredentials,
|
||||||
|
secrets: []string{key, secret},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "s3 unknown query param",
|
name: "s3 unknown query param",
|
||||||
raw: "s3://mybucket?access_key=" + key + "&secret=" + secret,
|
raw: "s3://mybucket?access_key=" + key + "&secret=" + secret,
|
||||||
@@ -203,6 +216,104 @@ func TestParseStorageURLRejectsCredentials(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestParseStorageURLRejectsRcloneRemoteParams checks that an rclone
|
||||||
|
// remote name carrying parameters (rclone's "remote,param=value" form,
|
||||||
|
// which can hold a credential) is rejected without echoing the value,
|
||||||
|
// including the URLs that url.Parse itself refuses.
|
||||||
|
func TestParseStorageURLRejectsRcloneRemoteParams(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
raw string
|
||||||
|
secrets []string // must not appear in the error message
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "configured remote",
|
||||||
|
raw: "rclone://gdrive,client_secret=topsecret/backups",
|
||||||
|
secrets: []string{"topsecret"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "remote defined inline",
|
||||||
|
raw: "rclone://:s3,access_key_id=KEY,secret_access_key=SECRET/bucket",
|
||||||
|
secrets: []string{"KEY", "SECRET"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "colon in a parameter value",
|
||||||
|
raw: "rclone://gdrive,client_secret=ab:cd/backups",
|
||||||
|
secrets: []string{"cd"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
_, err := storage.ParseStorageURL(tc.raw)
|
||||||
|
if !errors.Is(err, storage.ErrRcloneRemoteParams) {
|
||||||
|
t.Fatalf("ParseStorageURL(%q) error = %v, want %v",
|
||||||
|
tc.raw, err, storage.ErrRcloneRemoteParams)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, secret := range tc.secrets {
|
||||||
|
if strings.Contains(err.Error(), secret) {
|
||||||
|
t.Errorf("error message leaked %q: %v", secret, err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseStorageURLRejectsUndecodableQuery checks that an s3 URL whose
|
||||||
|
// query cannot be decoded is rejected, not read with that part of the
|
||||||
|
// query left out, and that the error does not echo it.
|
||||||
|
func TestParseStorageURLRejectsUndecodableQuery(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const (
|
||||||
|
key = "AKIAKEY"
|
||||||
|
secret = "topsecret"
|
||||||
|
)
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
raw string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "semicolon",
|
||||||
|
raw: "s3://mybucket/prefix?endpoint=https://" + key + ":" + secret +
|
||||||
|
"@minio.example.com:9000;region=us-east-1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bad percent escape",
|
||||||
|
raw: "s3://mybucket/prefix?endpoint=https://" + key + ":" + secret +
|
||||||
|
"%zz@minio.example.com:9000",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
_, err := storage.ParseStorageURL(tc.raw)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("ParseStorageURL(%q) returned no error", tc.raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !errors.Is(err, storage.ErrURLBadQuery) {
|
||||||
|
t.Errorf("ParseStorageURL(%q) error = %v, want %v",
|
||||||
|
tc.raw, err, storage.ErrURLBadQuery)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, s := range []string{key, secret, "minio.example.com"} {
|
||||||
|
if strings.Contains(err.Error(), s) {
|
||||||
|
t.Errorf("error message leaked %q: %v", s, err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestParseStorageURLParseFailureHidesURL checks that when url.Parse
|
// TestParseStorageURLParseFailureHidesURL checks that when url.Parse
|
||||||
// itself fails, the wrapped error carries only the inner cause, not the
|
// itself fails, the wrapped error carries only the inner cause, not the
|
||||||
// *url.Error whose text embeds the raw (possibly credential-bearing) URL.
|
// *url.Error whose text embeds the raw (possibly credential-bearing) URL.
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package vaultik_test
|
package vaultik_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -9,7 +11,9 @@ 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"
|
||||||
)
|
)
|
||||||
@@ -77,6 +81,174 @@ 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) {
|
||||||
|
|||||||
@@ -483,13 +483,14 @@ func TestListSnapshots_UnreadableManifestDoesNotHideOthers(t *testing.T) {
|
|||||||
//
|
//
|
||||||
//nolint:tagliatelle // snake_case is the established output format
|
//nolint:tagliatelle // snake_case is the established output format
|
||||||
type listJSONRow struct {
|
type listJSONRow struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
RemoteKey string `json:"remote_key"`
|
RemoteKey string `json:"remote_key"`
|
||||||
Timestamp string `json:"timestamp"`
|
Timestamp string `json:"timestamp"`
|
||||||
CompressedSize int64 `json:"compressed_size"`
|
CompressedSize int64 `json:"compressed_size"`
|
||||||
NewChunkSize int64 `json:"new_chunk_size"`
|
UncompressedSize int64 `json:"uncompressed_size"`
|
||||||
LocallyTracked bool `json:"locally_tracked"`
|
NewChunkSize int64 `json:"new_chunk_size"`
|
||||||
RemotePresent *bool `json:"remote_present"`
|
LocallyTracked bool `json:"locally_tracked"`
|
||||||
|
RemotePresent *bool `json:"remote_present"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// decodeListJSON parses the command's stdout, which must contain
|
// decodeListJSON parses the command's stdout, which must contain
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
package vaultik_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"sneak.berlin/go/vaultik/internal/log"
|
||||||
|
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestPruneDeletesNothingThroughSymlinkedDirectory checks that prune keeps
|
||||||
|
// every blob and the local snapshot record when the metadata/ or blobs/
|
||||||
|
// directory of a file:// destination is a symlink to a directory elsewhere
|
||||||
|
// (https://git.eeqj.de/sneak/vaultik/issues/296). A backup writes through
|
||||||
|
// such a link, so prune has to list through it too.
|
||||||
|
func TestPruneDeletesNothingThroughSymlinkedDirectory(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, dir := range []string{"metadata", "blobs"} {
|
||||||
|
t.Run(dir, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
storeDir := filepath.Join(t.TempDir(), "store")
|
||||||
|
v, repos, _ := backUpToFileDestination(ctx, t, storeDir)
|
||||||
|
blobFiles := blobFilesIn(t, storeDir)
|
||||||
|
moveBehindSymlink(t, storeDir, dir)
|
||||||
|
|
||||||
|
require.NoError(t, v.Prune(&vaultik.PruneOptions{Force: true}))
|
||||||
|
|
||||||
|
// When blobs/ is the link, these paths run through it.
|
||||||
|
for _, blobFile := range blobFiles {
|
||||||
|
assert.FileExists(t, blobFile, "prune must delete no blob")
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshots, err := repos.Snapshots.ListRecent(ctx, listRecentTestLimit)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, snapshots, 1, "prune must delete no local snapshot record")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPruneFailsOnSymlinkedDirectoryWithMissingTarget checks that prune
|
||||||
|
// fails, deleting no blob and keeping the local snapshot record, when the
|
||||||
|
// metadata/ or blobs/ directory of a file:// destination is a symlink
|
||||||
|
// whose target is missing, as a link to a volume that is not mounted is.
|
||||||
|
func TestPruneFailsOnSymlinkedDirectoryWithMissingTarget(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, dir := range []string{"metadata", "blobs"} {
|
||||||
|
t.Run(dir, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
storeDir := filepath.Join(t.TempDir(), "store")
|
||||||
|
v, repos, _ := backUpToFileDestination(ctx, t, storeDir)
|
||||||
|
blobFiles := blobFilesIn(t, storeDir)
|
||||||
|
targetPath := moveBehindSymlink(t, storeDir, dir)
|
||||||
|
|
||||||
|
// Unmount the volume the link points to, and mount it again
|
||||||
|
// once prune has run.
|
||||||
|
unmountedPath := targetPath + "-unmounted"
|
||||||
|
require.NoError(t, os.Rename(targetPath, unmountedPath))
|
||||||
|
|
||||||
|
err := v.Prune(&vaultik.PruneOptions{Force: true})
|
||||||
|
require.ErrorIs(t, err, fs.ErrNotExist)
|
||||||
|
|
||||||
|
require.NoError(t, os.Rename(unmountedPath, targetPath))
|
||||||
|
|
||||||
|
// When blobs/ is the link, these paths run through it.
|
||||||
|
for _, blobFile := range blobFiles {
|
||||||
|
assert.FileExists(t, blobFile, "prune must delete no blob")
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshots, err := repos.Snapshots.ListRecent(ctx, listRecentTestLimit)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, snapshots, 1, "prune must delete no local snapshot record")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRemoveSnapshotWarnsOnSymlinkedMetadataWithMissingTarget checks that
|
||||||
|
// snapshot remove warns that the metadata could not be removed from the
|
||||||
|
// destination when metadata/ is a symlink whose target is missing, as it
|
||||||
|
// does when the destination directory is missing, instead of finding no
|
||||||
|
// metadata to remove.
|
||||||
|
func TestRemoveSnapshotWarnsOnSymlinkedMetadataWithMissingTarget(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
storeDir := filepath.Join(t.TempDir(), "store")
|
||||||
|
v, repos, out := backUpToFileDestination(ctx, t, storeDir)
|
||||||
|
targetPath := moveBehindSymlink(t, storeDir, "metadata")
|
||||||
|
require.NoError(t, os.Rename(targetPath, targetPath+"-unmounted"))
|
||||||
|
|
||||||
|
result, err := v.RemoveSnapshot(localSnapshotID(ctx, t, repos, "first"),
|
||||||
|
&vaultik.RemoveOptions{Force: true})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.False(t, result.RemoteRemoved)
|
||||||
|
assert.Contains(t, out.String(),
|
||||||
|
"Could not remove snapshot metadata from remote")
|
||||||
|
assert.NotContains(t, out.String(),
|
||||||
|
"Removed snapshot metadata from remote storage")
|
||||||
|
}
|
||||||
|
|
||||||
|
// blobFilesIn returns the paths of the blob files under storeDir/blobs, and
|
||||||
|
// fails the test when there are none.
|
||||||
|
func blobFilesIn(t *testing.T, storeDir string) []string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var blobFiles []string
|
||||||
|
|
||||||
|
err := filepath.WalkDir(filepath.Join(storeDir, "blobs"),
|
||||||
|
func(path string, entry fs.DirEntry, err error) error {
|
||||||
|
if err == nil && !entry.IsDir() {
|
||||||
|
blobFiles = append(blobFiles, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotEmpty(t, blobFiles)
|
||||||
|
|
||||||
|
return blobFiles
|
||||||
|
}
|
||||||
|
|
||||||
|
// moveBehindSymlink moves storeDir/dir to a directory elsewhere, puts a
|
||||||
|
// symlink to it in its place, and returns where it went.
|
||||||
|
func moveBehindSymlink(t *testing.T, storeDir, dir string) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
linkPath := filepath.Join(storeDir, dir)
|
||||||
|
targetPath := filepath.Join(t.TempDir(), dir)
|
||||||
|
require.NoError(t, os.Rename(linkPath, targetPath))
|
||||||
|
require.NoError(t, os.Symlink(targetPath, linkPath))
|
||||||
|
|
||||||
|
return targetPath
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package vaultik_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/spf13/afero"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"sneak.berlin/go/vaultik/internal/database"
|
||||||
|
"sneak.berlin/go/vaultik/internal/log"
|
||||||
|
"sneak.berlin/go/vaultik/internal/storage"
|
||||||
|
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestUncompressedSizeOfChangedFileStaysFixed covers
|
||||||
|
// https://git.eeqj.de/sneak/vaultik/issues/360. A backup that changes a
|
||||||
|
// file leaves the earlier snapshot's UNCOMPRESSED SIZE as it was when that
|
||||||
|
// snapshot's backup completed. The file's row in the local index is shared
|
||||||
|
// by both snapshots and rewritten by the second backup.
|
||||||
|
func TestUncompressedSizeOfChangedFileStaysFixed(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
fs := afero.NewOsFs()
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
dataDir := filepath.Join(tempDir, "src")
|
||||||
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
changedPath := filepath.Join(dataDir, "changed.bin")
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
files := writeFaultSourceTree(t, fs, dataDir)
|
||||||
|
cfg := changedFileConfig(dataDir, dbPath)
|
||||||
|
|
||||||
|
appendRandomBytes(t, fs, files, changedPath, appendedFileSize)
|
||||||
|
|
||||||
|
store, err := storage.NewFileStorer(filepath.Join(tempDir, "remote"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
db, err := database.New(ctx, dbPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { _ = db.Close() })
|
||||||
|
|
||||||
|
repos := database.NewRepositories(db)
|
||||||
|
v := newBackupVaultik(ctx, cfg, store, repos, db, fs)
|
||||||
|
|
||||||
|
require.NoError(t, backUp(v, "first"))
|
||||||
|
|
||||||
|
// With nothing to compare against, sizeOfNewChunks totals every
|
||||||
|
// distinct chunk of the files.
|
||||||
|
firstID := localSnapshotID(ctx, t, repos, "first")
|
||||||
|
firstSize := sizeOfNewChunks(t, files, nil)
|
||||||
|
require.Equal(t, firstSize, listedUncompressedSizes(t, v)[firstID],
|
||||||
|
"the first snapshot must count the chunks of every file it holds")
|
||||||
|
|
||||||
|
appendRandomBytes(t, fs, files, changedPath, faultChunkSize)
|
||||||
|
require.NoError(t, backUp(v, "second"))
|
||||||
|
|
||||||
|
secondID := localSnapshotID(ctx, t, repos, "second")
|
||||||
|
sizes := listedUncompressedSizes(t, v)
|
||||||
|
assert.Equal(t, firstSize, sizes[firstID],
|
||||||
|
"a later backup must not change the first snapshot's figure")
|
||||||
|
assert.Equal(t, sizeOfNewChunks(t, files, nil), sizes[secondID],
|
||||||
|
"the second snapshot must count the changed file's chunks")
|
||||||
|
}
|
||||||
|
|
||||||
|
// listedUncompressedSizes runs `snapshot list --json` and returns each
|
||||||
|
// listed snapshot's uncompressed_size by snapshot ID.
|
||||||
|
func listedUncompressedSizes(t *testing.T, v *vaultik.Vaultik) map[string]int64 {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var out bytes.Buffer
|
||||||
|
|
||||||
|
v.Stdout = &out
|
||||||
|
require.NoError(t, v.ListSnapshots(true))
|
||||||
|
|
||||||
|
sizes := make(map[string]int64)
|
||||||
|
for _, row := range decodeListJSON(t, out.String()) {
|
||||||
|
sizes[row.ID] = row.UncompressedSize
|
||||||
|
}
|
||||||
|
|
||||||
|
return sizes
|
||||||
|
}
|
||||||
+39
-17
@@ -17,6 +17,7 @@ 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.
|
||||||
@@ -32,7 +33,9 @@ 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.
|
||||||
@@ -149,7 +152,6 @@ 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)
|
||||||
@@ -306,7 +308,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)
|
err = v.verifyBlobExistenceFromDB(dbBlobs, result)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return v.deepVerifyFailure(result, opts, err.Error(), err)
|
return v.deepVerifyFailure(result, opts, err.Error(), err)
|
||||||
}
|
}
|
||||||
@@ -317,7 +319,8 @@ func (v *Vaultik) runVerificationSteps(
|
|||||||
len(dbBlobs), ubytes(totalSize))
|
len(dbBlobs), ubytes(totalSize))
|
||||||
}
|
}
|
||||||
|
|
||||||
err = v.performDeepVerificationFromDB(dbBlobs, tdb.db.Conn(), opts, identities)
|
err = v.performDeepVerificationFromDB(
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
@@ -669,8 +672,14 @@ func (v *Vaultik) verifyManifestAgainstDatabase(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// verifyBlobExistenceFromDB checks that all blobs from database exist in S3
|
// verifyBlobExistenceFromDB checks that every blob from the database is
|
||||||
func (v *Vaultik) verifyBlobExistenceFromDB(blobs []snapshot.BlobInfo) error {
|
// in storage with the size the database records. It checks them all,
|
||||||
|
// 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 {
|
||||||
@@ -683,17 +692,23 @@ func (v *Vaultik) verifyBlobExistenceFromDB(blobs []snapshot.BlobInfo) error {
|
|||||||
// 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)
|
||||||
if err != nil {
|
switch {
|
||||||
return fmt.Errorf("blob %s missing from storage: %w", blob.Hash, err)
|
case errors.Is(err, storage.ErrNotFound):
|
||||||
}
|
log.Warn("Blob missing from storage", "hash", blob.Hash)
|
||||||
|
|
||||||
// Verify size matches
|
result.Missing++
|
||||||
if stat.Size != blob.CompressedSize {
|
result.MissingSize += blob.CompressedSize
|
||||||
return fmt.Errorf(
|
case err != nil:
|
||||||
"%w: blob %s: S3 has %d bytes, database has %d bytes",
|
return fmt.Errorf("checking blob %s in storage: %w",
|
||||||
errBlobSizeMismatch, blob.Hash, stat.Size, blob.CompressedSize)
|
blob.Hash, err)
|
||||||
|
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
|
||||||
@@ -707,16 +722,22 @@ func (v *Vaultik) verifyBlobExistenceFromDB(blobs []snapshot.BlobInfo) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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.
|
// each blob using the database as source, stopping at the first blob that
|
||||||
|
// 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,
|
||||||
identities []age.Identity,
|
result *VerifyResult, identities []age.Identity,
|
||||||
) error {
|
) error {
|
||||||
// Calculate total bytes for ETA
|
// Calculate total bytes for ETA
|
||||||
var totalBytesExpected int64
|
var totalBytesExpected int64
|
||||||
@@ -739,6 +760,7 @@ 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