Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
43483e21d4 |
@@ -22,15 +22,14 @@ the tag exists and is exercised; what is left is merging `next` to
|
|||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
- 2026-10-13: Recorded the size of the stored object for a blob a backup
|
- 2026-10-13: Made an S3 listing that the server cuts short an error
|
||||||
found already stored
|
([issue #297](https://git.eeqj.de/sneak/vaultik/issues/297)). A page
|
||||||
([issue #300](https://git.eeqj.de/sneak/vaultik/issues/300)). When the
|
marked truncated with no continuation token, as a server answering V2
|
||||||
upload was skipped, the blob row and the manifest kept the size of the
|
listings V1-style sends, ended the listing without an error, so
|
||||||
local copy, which differs from the stored object's when
|
`prune` saw only the snapshots on the first page and could delete
|
||||||
`compression_level` or the recipients changed since it was written.
|
blobs the rest still use. Such a page is now a listing error, and
|
||||||
`snapshot verify` then failed on a good snapshot, shallow and deep,
|
`prune`, `snapshot list` and `remote nuke` handle it as a destination
|
||||||
and the listed sizes were wrong. Marking the blob uploaded now records
|
that cannot be listed.
|
||||||
the size of the object in storage.
|
|
||||||
|
|
||||||
- 2026-10-13: Made `snapshot remove` fail for a snapshot that does not
|
- 2026-10-13: Made `snapshot remove` fail for a snapshot that does not
|
||||||
exist ([issue #293](https://git.eeqj.de/sneak/vaultik/issues/293)).
|
exist ([issue #293](https://git.eeqj.de/sneak/vaultik/issues/293)).
|
||||||
|
|||||||
+2
-2
@@ -90,7 +90,7 @@ Stores information about packed, compressed, and encrypted blob files.
|
|||||||
- `created_ts` (INTEGER NOT NULL) - Creation timestamp
|
- `created_ts` (INTEGER NOT NULL) - Creation timestamp
|
||||||
- `finished_ts` (INTEGER) - Finalization timestamp (NULL if in progress)
|
- `finished_ts` (INTEGER) - Finalization timestamp (NULL if in progress)
|
||||||
- `uncompressed_size` (INTEGER NOT NULL DEFAULT 0) - Total size of chunks before compression
|
- `uncompressed_size` (INTEGER NOT NULL DEFAULT 0) - Total size of chunks before compression
|
||||||
- `compressed_size` (INTEGER NOT NULL DEFAULT 0) - Size after compression and encryption. Once uploaded, the size of the object in storage: when the blob was already stored, under another compression level or other recipients, this differs from the size of the local copy
|
- `compressed_size` (INTEGER NOT NULL DEFAULT 0) - Size after compression and encryption
|
||||||
- `uploaded_ts` (INTEGER) - Upload completion timestamp (NULL if not uploaded)
|
- `uploaded_ts` (INTEGER) - Upload completion timestamp (NULL if not uploaded)
|
||||||
|
|
||||||
**Purpose:** Tracks blob lifecycle from creation through upload. The UUID primary key allows immediate association of chunks with blobs.
|
**Purpose:** Tracks blob lifecycle from creation through upload. The UUID primary key allows immediate association of chunks with blobs.
|
||||||
@@ -183,7 +183,7 @@ Tracks blob upload metrics.
|
|||||||
- `UPDATE blobs SET blob_hash = ?, finished_ts = ?` - Finalize blob after packing
|
- `UPDATE blobs SET blob_hash = ?, finished_ts = ?` - Finalize blob after packing
|
||||||
|
|
||||||
4. **Upload**
|
4. **Upload**
|
||||||
- `UPDATE blobs SET uploaded_ts = ?, compressed_size = ?` - Mark blob as uploaded, with the size of the object in storage (the upload is skipped when the blob is already stored)
|
- `UPDATE blobs SET uploaded_ts = ?` - Mark blob as uploaded
|
||||||
- `INSERT INTO uploads` - Record upload metrics with snapshot_id
|
- `INSERT INTO uploads` - Record upload metrics with snapshot_id
|
||||||
- `INSERT INTO snapshot_blobs` - Associate blob with snapshot
|
- `INSERT INTO snapshot_blobs` - Associate blob with snapshot
|
||||||
|
|
||||||
|
|||||||
@@ -159,16 +159,13 @@ func (r *BlobRepository) UpdateFinished(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateUploaded marks a blob as uploaded and records the size of the
|
// UpdateUploaded marks a blob as uploaded
|
||||||
// stored object as its compressed size. That size can differ from the
|
|
||||||
// local copy's when an identical blob was already stored with another
|
|
||||||
// compression level or other recipients.
|
|
||||||
func (r *BlobRepository) UpdateUploaded(
|
func (r *BlobRepository) UpdateUploaded(
|
||||||
ctx context.Context, tx *sql.Tx, id string, storedSize int64,
|
ctx context.Context, tx *sql.Tx, id string,
|
||||||
) error {
|
) error {
|
||||||
query := `
|
query := `
|
||||||
UPDATE blobs
|
UPDATE blobs
|
||||||
SET uploaded_ts = ?, compressed_size = ?
|
SET uploaded_ts = ?
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -176,9 +173,9 @@ func (r *BlobRepository) UpdateUploaded(
|
|||||||
|
|
||||||
var err error
|
var err error
|
||||||
if tx != nil {
|
if tx != nil {
|
||||||
_, err = tx.ExecContext(ctx, query, now, storedSize, id)
|
_, err = tx.ExecContext(ctx, query, now, id)
|
||||||
} else {
|
} else {
|
||||||
_, err = r.db.ExecWithLog(ctx, query, now, storedSize, id)
|
_, err = r.db.ExecWithLog(ctx, query, now, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -122,8 +122,8 @@ func TestBlobRepositoryUpdates(t *testing.T) {
|
|||||||
t.Errorf("expected compressed size 500, got %d", updated.CompressedSize)
|
t.Errorf("expected compressed size 500, got %d", updated.CompressedSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test UpdateUploaded, with a stored object larger than the local copy
|
// Test UpdateUploaded
|
||||||
err = repo.UpdateUploaded(ctx, nil, blob.ID.String(), 600)
|
err = repo.UpdateUploaded(ctx, nil, blob.ID.String())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to update blob as uploaded: %v", err)
|
t.Fatalf("failed to update blob as uploaded: %v", err)
|
||||||
}
|
}
|
||||||
@@ -141,10 +141,6 @@ func TestBlobRepositoryUpdates(t *testing.T) {
|
|||||||
if uploaded.UploadedTS.Before(now.Add(-1 * time.Second)) {
|
if uploaded.UploadedTS.Before(now.Add(-1 * time.Second)) {
|
||||||
t.Error("uploaded timestamp should be around test time")
|
t.Error("uploaded timestamp should be around test time")
|
||||||
}
|
}
|
||||||
|
|
||||||
if uploaded.CompressedSize != 600 {
|
|
||||||
t.Errorf("expected the stored size 600, got %d", uploaded.CompressedSize)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBlobRepositoryDuplicate(t *testing.T) {
|
func TestBlobRepositoryDuplicate(t *testing.T) {
|
||||||
|
|||||||
+29
-2
@@ -18,6 +18,13 @@ import (
|
|||||||
"github.com/aws/smithy-go/logging"
|
"github.com/aws/smithy-go/logging"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ErrNoContinuationToken is returned by ListObjects and ListObjectsStream
|
||||||
|
// when the server marks a page of a listing truncated but sends no
|
||||||
|
// continuation token to fetch the rest with, as a server answering V2
|
||||||
|
// listings V1-style does.
|
||||||
|
var ErrNoContinuationToken = errors.New(
|
||||||
|
"server marked the listing truncated but sent no continuation token")
|
||||||
|
|
||||||
// Client wraps the AWS S3 client for vaultik operations.
|
// Client wraps the AWS S3 client for vaultik operations.
|
||||||
// It provides a simplified interface for S3 operations with automatic
|
// It provides a simplified interface for S3 operations with automatic
|
||||||
// prefix handling and connection management. All operations are performed
|
// prefix handling and connection management. All operations are performed
|
||||||
@@ -214,7 +221,7 @@ func (c *Client) ListObjects(ctx context.Context, prefix string) ([]string, erro
|
|||||||
})
|
})
|
||||||
|
|
||||||
for paginator.HasMorePages() {
|
for paginator.HasMorePages() {
|
||||||
page, err := paginator.NextPage(ctx)
|
page, err := nextListPage(ctx, paginator)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -235,6 +242,26 @@ func (c *Client) ListObjects(ctx context.Context, prefix string) ([]string, erro
|
|||||||
return keys, nil
|
return keys, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// nextListPage fetches the next page of a listing. The SDK paginator takes
|
||||||
|
// a page marked truncated without a continuation token for the last page
|
||||||
|
// and stops without an error, so such a page is ErrNoContinuationToken
|
||||||
|
// here instead.
|
||||||
|
func nextListPage(
|
||||||
|
ctx context.Context, paginator *s3.ListObjectsV2Paginator,
|
||||||
|
) (*s3.ListObjectsV2Output, error) {
|
||||||
|
page, err := paginator.NextPage(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
truncated := aws.ToBool(page.IsTruncated)
|
||||||
|
if truncated && aws.ToString(page.NextContinuationToken) == "" {
|
||||||
|
return nil, ErrNoContinuationToken
|
||||||
|
}
|
||||||
|
|
||||||
|
return page, nil
|
||||||
|
}
|
||||||
|
|
||||||
// HeadObject checks if an object exists in S3.
|
// HeadObject checks if an object exists in S3.
|
||||||
// Returns true if the object exists, false otherwise.
|
// Returns true if the object exists, false otherwise.
|
||||||
// The key is automatically prefixed with the configured prefix.
|
// The key is automatically prefixed with the configured prefix.
|
||||||
@@ -300,7 +327,7 @@ func (c *Client) ListObjectsStream(
|
|||||||
})
|
})
|
||||||
|
|
||||||
for paginator.HasMorePages() {
|
for paginator.HasMorePages() {
|
||||||
page, err := paginator.NextPage(ctx)
|
page, err := nextListPage(ctx, paginator)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ch <- ObjectInfo{Err: err}
|
ch <- ObjectInfo{Err: err}
|
||||||
|
|
||||||
|
|||||||
@@ -1509,8 +1509,7 @@ func (s *Scanner) finalizeProcessPhase(ctx context.Context, result *ScanResult)
|
|||||||
// snapshot references has uploaded_ts set, so deduplication and
|
// snapshot references has uploaded_ts set, so deduplication and
|
||||||
// interrupted-run repair treat these blobs as trustworthy.
|
// interrupted-run repair treat these blobs as trustworthy.
|
||||||
err = s.repos.WithTx(ctx, func(ctx context.Context, tx *sql.Tx) error {
|
err = s.repos.WithTx(ctx, func(ctx context.Context, tx *sql.Tx) error {
|
||||||
err := s.repos.Blobs.UpdateUploaded(
|
err := s.repos.Blobs.UpdateUploaded(ctx, tx, b.ID)
|
||||||
ctx, tx, b.ID, b.Compressed)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("marking blob uploaded: %w", err)
|
return fmt.Errorf("marking blob uploaded: %w", err)
|
||||||
}
|
}
|
||||||
@@ -1546,7 +1545,7 @@ func (s *Scanner) handleBlobReady(
|
|||||||
blobPath := fmt.Sprintf("blobs/%s/%s/%s",
|
blobPath := fmt.Sprintf("blobs/%s/%s/%s",
|
||||||
finishedBlob.Hash[:2], finishedBlob.Hash[2:4], finishedBlob.Hash)
|
finishedBlob.Hash[:2], finishedBlob.Hash[2:4], finishedBlob.Hash)
|
||||||
|
|
||||||
storedSize, blobExists, err := s.uploadBlobIfNeeded(
|
blobExists, err := s.uploadBlobIfNeeded(
|
||||||
ctx, blobPath, blobWithReader, startTime, result)
|
ctx, blobPath, blobWithReader, startTime, result)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.cleanupBlobTempFile(blobWithReader)
|
s.cleanupBlobTempFile(blobWithReader)
|
||||||
@@ -1554,8 +1553,7 @@ func (s *Scanner) handleBlobReady(
|
|||||||
return fmt.Errorf("uploading blob %s: %w", finishedBlob.Hash, err)
|
return fmt.Errorf("uploading blob %s: %w", finishedBlob.Hash, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = s.recordBlobMetadata(
|
err = s.recordBlobMetadata(ctx, finishedBlob, blobExists, startTime)
|
||||||
ctx, finishedBlob, storedSize, blobExists, startTime)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.cleanupBlobTempFile(blobWithReader)
|
s.cleanupBlobTempFile(blobWithReader)
|
||||||
|
|
||||||
@@ -1577,22 +1575,20 @@ func (s *Scanner) handleBlobReady(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// uploadBlobIfNeeded uploads the blob to storage if it doesn't already
|
// uploadBlobIfNeeded uploads the blob to storage if it doesn't already
|
||||||
// exist. It returns the size of the object in storage, which for an
|
// exist, returns whether it existed
|
||||||
// existing object can differ from the local copy's, and whether it
|
|
||||||
// already existed.
|
|
||||||
func (s *Scanner) uploadBlobIfNeeded(
|
func (s *Scanner) uploadBlobIfNeeded(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
blobPath string,
|
blobPath string,
|
||||||
blobWithReader *blob.WithReader,
|
blobWithReader *blob.WithReader,
|
||||||
startTime time.Time,
|
startTime time.Time,
|
||||||
result *ScanResult,
|
result *ScanResult,
|
||||||
) (int64, bool, error) {
|
) (bool, error) {
|
||||||
finishedBlob := blobWithReader.FinishedBlob
|
finishedBlob := blobWithReader.FinishedBlob
|
||||||
|
|
||||||
// Check if blob already exists (deduplication after restart)
|
// Check if blob already exists (deduplication after restart)
|
||||||
destination := s.storage.Info().Location
|
destination := s.storage.Info().Location
|
||||||
|
|
||||||
stored, err := s.storage.Stat(ctx, blobPath)
|
_, err := s.storage.Stat(ctx, blobPath)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
log.Info("Blob already exists in storage, skipping upload",
|
log.Info("Blob already exists in storage, skipping upload",
|
||||||
"hash", finishedBlob.Hash,
|
"hash", finishedBlob.Hash,
|
||||||
@@ -1601,7 +1597,7 @@ func (s *Scanner) uploadBlobIfNeeded(
|
|||||||
s.ui.Hex(finishedBlob.Hash), s.ui.Size(finishedBlob.Compressed),
|
s.ui.Hex(finishedBlob.Hash), s.ui.Size(finishedBlob.Compressed),
|
||||||
s.ui.Path(destination))
|
s.ui.Path(destination))
|
||||||
|
|
||||||
return stored.Size, true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
s.ui.Beginf("Uploading blob %s (%s) to %s.",
|
s.ui.Beginf("Uploading blob %s (%s) to %s.",
|
||||||
@@ -1615,7 +1611,7 @@ func (s *Scanner) uploadBlobIfNeeded(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("Failed to upload blob", "hash", finishedBlob.Hash, "error", err)
|
log.Error("Failed to upload blob", "hash", finishedBlob.Hash, "error", err)
|
||||||
|
|
||||||
return 0, false, fmt.Errorf("uploading blob to storage: %w", err)
|
return false, fmt.Errorf("uploading blob to storage: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
uploadDuration := time.Since(startTime)
|
uploadDuration := time.Since(startTime)
|
||||||
@@ -1646,7 +1642,7 @@ func (s *Scanner) uploadBlobIfNeeded(
|
|||||||
stats.BytesUploaded.Add(finishedBlob.Compressed)
|
stats.BytesUploaded.Add(finishedBlob.Compressed)
|
||||||
}
|
}
|
||||||
|
|
||||||
return finishedBlob.Compressed, false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// makeUploadProgressCallback creates a progress callback for blob uploads.
|
// makeUploadProgressCallback creates a progress callback for blob uploads.
|
||||||
@@ -1716,7 +1712,6 @@ func (s *Scanner) makeUploadProgressCallback(
|
|||||||
func (s *Scanner) recordBlobMetadata(
|
func (s *Scanner) recordBlobMetadata(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
finishedBlob *blob.FinishedBlob,
|
finishedBlob *blob.FinishedBlob,
|
||||||
storedSize int64,
|
|
||||||
blobExists bool,
|
blobExists bool,
|
||||||
startTime time.Time,
|
startTime time.Time,
|
||||||
) error {
|
) error {
|
||||||
@@ -1728,8 +1723,7 @@ func (s *Scanner) recordBlobMetadata(
|
|||||||
uploadDuration := time.Since(startTime)
|
uploadDuration := time.Since(startTime)
|
||||||
|
|
||||||
return s.repos.WithTx(ctx, func(txCtx context.Context, tx *sql.Tx) error {
|
return s.repos.WithTx(ctx, func(txCtx context.Context, tx *sql.Tx) error {
|
||||||
err := s.repos.Blobs.UpdateUploaded(
|
err := s.repos.Blobs.UpdateUploaded(txCtx, tx, finishedBlob.ID)
|
||||||
txCtx, tx, finishedBlob.ID, storedSize)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("updating blob upload timestamp: %w", err)
|
return fmt.Errorf("updating blob upload timestamp: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"slices"
|
"slices"
|
||||||
@@ -22,13 +23,16 @@ import (
|
|||||||
// s3TestBucket is the bucket created for each in-process S3 server.
|
// s3TestBucket is the bucket created for each in-process S3 server.
|
||||||
const s3TestBucket = "test-bucket"
|
const s3TestBucket = "test-bucket"
|
||||||
|
|
||||||
// Credentials for the tests that build a storer from a config.Config. The
|
// Credentials for the tests' S3 clients. The test servers accept any.
|
||||||
// in-process S3 server accepts any.
|
|
||||||
const (
|
const (
|
||||||
s3TestAccessKeyID = "key"
|
s3TestAccessKeyID = "key"
|
||||||
s3TestSecretAccessKey = "secret"
|
s3TestSecretAccessKey = "secret"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// s3TestRegion is the region of the S3 clients built here with
|
||||||
|
// s3.NewClient. The test servers ignore it.
|
||||||
|
const s3TestRegion = "us-east-1"
|
||||||
|
|
||||||
// newS3Storer builds an s3:// backend backed by a fresh in-process
|
// newS3Storer builds an s3:// backend backed by a fresh in-process
|
||||||
// S3 server. It reuses the same in-memory S3 harness (gofakes3 + s3mem
|
// S3 server. It reuses the same in-memory S3 harness (gofakes3 + s3mem
|
||||||
// over httptest) that internal/s3 and the not-found regression test use,
|
// over httptest) that internal/s3 and the not-found regression test use,
|
||||||
@@ -55,7 +59,7 @@ func newS3Storer(t *testing.T) storage.Storer {
|
|||||||
Bucket: s3TestBucket,
|
Bucket: s3TestBucket,
|
||||||
AccessKeyID: "test",
|
AccessKeyID: "test",
|
||||||
SecretAccessKey: "test",
|
SecretAccessKey: "test",
|
||||||
Region: "us-east-1",
|
Region: s3TestRegion,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("new client: %v", err)
|
t.Fatalf("new client: %v", err)
|
||||||
@@ -93,6 +97,68 @@ func TestS3StorerMissingKeyMapsToErrNotFound(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestS3ListingCutShortIsAnError checks that List and ListStream fail on a
|
||||||
|
// listing page marked truncated that carries no continuation token, as a
|
||||||
|
// server answering V2 listings V1-style sends it. Taken as the last page,
|
||||||
|
// it would hide every later key, and prune would treat the blobs of the
|
||||||
|
// snapshots listed there as unreferenced.
|
||||||
|
func TestS3ListingCutShortIsAnError(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const (
|
||||||
|
prefix = "metadata/"
|
||||||
|
truncatedPage = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
||||||
|
<Name>test-bucket</Name>
|
||||||
|
<Prefix>metadata/</Prefix>
|
||||||
|
<KeyCount>1</KeyCount>
|
||||||
|
<MaxKeys>1</MaxKeys>
|
||||||
|
<IsTruncated>true</IsTruncated>
|
||||||
|
<Contents>
|
||||||
|
<Key>metadata/snap/manifest.json.zst</Key>
|
||||||
|
<Size>8</Size>
|
||||||
|
</Contents>
|
||||||
|
</ListBucketResult>`
|
||||||
|
)
|
||||||
|
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(
|
||||||
|
func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
_, _ = io.WriteString(w, truncatedPage)
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
|
||||||
|
client, err := s3.NewClient(context.Background(), s3.Config{
|
||||||
|
Endpoint: srv.URL,
|
||||||
|
Bucket: s3TestBucket,
|
||||||
|
AccessKeyID: s3TestAccessKeyID,
|
||||||
|
SecretAccessKey: s3TestSecretAccessKey,
|
||||||
|
Region: s3TestRegion,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("new client: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
storer := storage.NewS3Storer(client)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
keys, err := storer.List(ctx, prefix)
|
||||||
|
if !errors.Is(err, s3.ErrNoContinuationToken) {
|
||||||
|
t.Errorf("List = %q, %v; want ErrNoContinuationToken", keys, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var streamErr error
|
||||||
|
|
||||||
|
for obj := range storer.ListStream(ctx, prefix) {
|
||||||
|
if obj.Err != nil {
|
||||||
|
streamErr = obj.Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !errors.Is(streamErr, s3.ErrNoContinuationToken) {
|
||||||
|
t.Errorf("ListStream error = %v, want ErrNoContinuationToken", streamErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestS3URLPrefixKeyLayout pins the bucket keys an s3:// URL reads and
|
// TestS3URLPrefixKeyLayout pins the bucket keys an s3:// URL reads and
|
||||||
// writes: the README's remote storage layout, with the prefix joined to
|
// writes: the README's remote storage layout, with the prefix joined to
|
||||||
// each key by one "/". s3://b/p and s3://b/p/ must be the same
|
// each key by one "/". s3://b/p and s3://b/p/ must be the same
|
||||||
|
|||||||
@@ -1,83 +0,0 @@
|
|||||||
package vaultik_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"io"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"filippo.io/age"
|
|
||||||
"github.com/spf13/afero"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"sneak.berlin/go/vaultik/internal/log"
|
|
||||||
"sneak.berlin/go/vaultik/internal/ui"
|
|
||||||
"sneak.berlin/go/vaultik/internal/vaultik"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestVerifyPassesWhenBackupReusesStoredBlob backs up a file, then backs it
|
|
||||||
// up again from an empty local index, as after `database delete`, with an
|
|
||||||
// extra age recipient. The second backup builds the same blob, finds it
|
|
||||||
// already stored and skips the upload, but its own copy is larger, because
|
|
||||||
// age writes a header entry for each recipient. The second snapshot must
|
|
||||||
// record the size of the stored object, so shallow and deep verify pass.
|
|
||||||
func TestVerifyPassesWhenBackupReusesStoredBlob(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")
|
|
||||||
secondDBPath := filepath.Join(tempDir, "second.sqlite")
|
|
||||||
|
|
||||||
chunkSize := int64(64 * 1024)
|
|
||||||
maxBlobSize := int64(512 * 1024)
|
|
||||||
|
|
||||||
require.NoError(t, fs.MkdirAll(dataDir, 0o755))
|
|
||||||
require.NoError(t, afero.WriteFile(fs,
|
|
||||||
filepath.Join(dataDir, "data.bin"),
|
|
||||||
bytesPattern("reuse-", int(chunkSize*3)), 0o644))
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
runFileStorageBackup(ctx, t, fs, dataDir, storeDir,
|
|
||||||
filepath.Join(tempDir, "first.sqlite"), chunkSize, maxBlobSize)
|
|
||||||
|
|
||||||
env := setupDedupBackupEnv(
|
|
||||||
ctx, t, fs, storeDir, secondDBPath, chunkSize, maxBlobSize)
|
|
||||||
|
|
||||||
defer func() { _ = env.db.Close() }()
|
|
||||||
|
|
||||||
extra, err := age.GenerateX25519Identity()
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
env.cfg.AgeRecipients = append(env.cfg.AgeRecipients,
|
|
||||||
extra.Recipient().String())
|
|
||||||
|
|
||||||
snapshotID, result := runDedupSnapshot(ctx, t, env.sm, env.makeScanner(),
|
|
||||||
env.cfg.Hostname, "second", dataDir, secondDBPath)
|
|
||||||
require.Positive(t, result.BlobsCreated)
|
|
||||||
require.Zero(t, result.BlobsUploaded,
|
|
||||||
"the second backup should find every blob already stored")
|
|
||||||
|
|
||||||
require.NoError(t, env.db.Close())
|
|
||||||
|
|
||||||
verifier := &vaultik.Vaultik{
|
|
||||||
Config: env.cfg,
|
|
||||||
Storage: env.storer,
|
|
||||||
Fs: fs,
|
|
||||||
Stdout: io.Discard,
|
|
||||||
Stderr: io.Discard,
|
|
||||||
UI: ui.NewWithColor(io.Discard, false),
|
|
||||||
}
|
|
||||||
verifier.SetContext(ctx)
|
|
||||||
|
|
||||||
require.NoError(t,
|
|
||||||
verifier.VerifySnapshotWithOptions(snapshotID, &vaultik.VerifyOptions{}),
|
|
||||||
"shallow verify should pass")
|
|
||||||
require.NoError(t,
|
|
||||||
verifier.VerifySnapshotWithOptions(snapshotID,
|
|
||||||
&vaultik.VerifyOptions{Deep: true}),
|
|
||||||
"deep verify should pass")
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user