Update golangci-lint to v2.12.2 with canonical config (#62)
check / check (push) Successful in 5s
check / check (push) Successful in 5s
Updates golangci-lint to v2.12.2 everywhere it is pinned and installs the canonical `.golangci.yml`, then remediates every finding the new linter/config surfaces so `make check` is green. ## Version bump - `Dockerfile` lint stage: `golangci/golangci-lint:v2.11.3-alpine` -> `v2.12.2-alpine` (digest-pinned, date comment updated) - `Makefile` `deps` target: `go install` moved from the old v1 module path at `@latest` to the pinned `github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2` - `.golangci.yml` replaced with the canonical config (v2 schema; settings under `linters.settings` so the thresholds actually apply; `default: all` with the standard six disables) - `script/bootstrap` installs golangci-lint via the system package manager and carries no version pin, so it is unchanged - CI (`.gitea/workflows/check.yml`) only runs `script/cibuild`, so it needed no change ## Lint remediation The canonical config surfaced ~3,300 findings across 56k lines. All are fixed, behavior-preserving; incorporates and supersedes the per-package mechanical passes already merged to `main` (refs #61). Highlights: - `err113`: dynamic errors replaced with package sentinels + `%w` wrapping; comparisons via `errors.Is` - `goprintffuncname`: printf-style helpers renamed with an `f` suffix (`ui.Writer` message methods, `cli.ReportErrorf`, `database.Fatalf`) and all call sites updated - `revive` stutter renames: `blob.Handler`, `blob.WithReader`, `blob.ChunkPosition`, `storage.URL`, `storage.Info`; missing doc comments added - `contextcheck`/`noctx`: `context.Context` threaded through `blob.Packer` and the scanner call sites; context-aware `exec`/`sql` variants - `funlen`/`cyclop`/`gocognit`/`dupl`: oversized and duplicated functions split into focused helpers (production and test code) - tests: `t.Parallel()` added where safe (global logger init kept in the serial phase for `-race`), `t.TempDir()`/`t.Helper()` adopted, several suites converted to external test packages - `gosec`: bounded integer conversions, `ReadHeaderTimeout` on the test HTTP server; remaining warnings suppressed per-site with justifications - remaining `nolint` directives are rare, targeted, and each carries a reason (e.g. `nilnil` not-found contract in the repository layer, fx module globals, on-disk snake_case struct tags) - removed the deprecated `log.LogOptions` alias (callers migrated to `log.Options`) `make check` (tests with `-race`, lint, fmt-check) passes. Co-authored-by: sneak <sneak@sneak.berlin> Reviewed-on: #62 Co-authored-by: clawbot <clawbot@noreply.example.org> Co-committed-by: clawbot <clawbot@noreply.example.org>
This commit was merged in pull request #62.
This commit is contained in:
@@ -24,6 +24,16 @@ import (
|
||||
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||
)
|
||||
|
||||
// Shared fixture values used across the vaultik integration tests.
|
||||
const (
|
||||
testLabel = "test"
|
||||
testHostname = "test-host"
|
||||
testAgePublicKey = "age1ezrjmfpwsc95svdg0y54mums3zevgzu0x0ecq2f7tp8a05g" +
|
||||
"l0sjq9q9wjg"
|
||||
testAgeSecretKey = "AGE-SECRET-KEY-19CR5YSFW59HM4TLD6GXVEDMZFTVVF7PPHKU" +
|
||||
"T68TXSFPK7APHXA2QS2NJA5"
|
||||
)
|
||||
|
||||
// MockStorer implements storage.Storer for testing
|
||||
type MockStorer struct {
|
||||
mu sync.Mutex
|
||||
@@ -38,7 +48,7 @@ func NewMockStorer() *MockStorer {
|
||||
}
|
||||
}
|
||||
|
||||
func (m *MockStorer) Put(ctx context.Context, key string, reader io.Reader) error {
|
||||
func (m *MockStorer) Put(_ context.Context, key string, reader io.Reader) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
@@ -54,11 +64,14 @@ func (m *MockStorer) Put(ctx context.Context, key string, reader io.Reader) erro
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockStorer) PutWithProgress(ctx context.Context, key string, reader io.Reader, size int64, progress storage.ProgressCallback) error {
|
||||
func (m *MockStorer) PutWithProgress(
|
||||
ctx context.Context, key string, reader io.Reader,
|
||||
_ int64, _ storage.ProgressCallback,
|
||||
) error {
|
||||
return m.Put(ctx, key, reader)
|
||||
}
|
||||
|
||||
func (m *MockStorer) Get(ctx context.Context, key string) (io.ReadCloser, error) {
|
||||
func (m *MockStorer) Get(_ context.Context, key string) (io.ReadCloser, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
@@ -72,7 +85,7 @@ func (m *MockStorer) Get(ctx context.Context, key string) (io.ReadCloser, error)
|
||||
return io.NopCloser(bytes.NewReader(data)), nil
|
||||
}
|
||||
|
||||
func (m *MockStorer) Stat(ctx context.Context, key string) (*storage.ObjectInfo, error) {
|
||||
func (m *MockStorer) Stat(_ context.Context, key string) (*storage.ObjectInfo, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
@@ -89,7 +102,7 @@ func (m *MockStorer) Stat(ctx context.Context, key string) (*storage.ObjectInfo,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *MockStorer) Delete(ctx context.Context, key string) error {
|
||||
func (m *MockStorer) Delete(_ context.Context, key string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
@@ -99,7 +112,7 @@ func (m *MockStorer) Delete(ctx context.Context, key string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockStorer) List(ctx context.Context, prefix string) ([]string, error) {
|
||||
func (m *MockStorer) List(_ context.Context, prefix string) ([]string, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
@@ -108,7 +121,8 @@ func (m *MockStorer) List(ctx context.Context, prefix string) ([]string, error)
|
||||
var keys []string
|
||||
|
||||
for key := range m.data {
|
||||
if len(prefix) == 0 || (len(key) >= len(prefix) && key[:len(prefix)] == prefix) {
|
||||
if len(prefix) == 0 ||
|
||||
(len(key) >= len(prefix) && key[:len(prefix)] == prefix) {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
}
|
||||
@@ -116,7 +130,9 @@ func (m *MockStorer) List(ctx context.Context, prefix string) ([]string, error)
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
func (m *MockStorer) ListStream(ctx context.Context, prefix string) <-chan storage.ObjectInfo {
|
||||
func (m *MockStorer) ListStream(
|
||||
_ context.Context, prefix string,
|
||||
) <-chan storage.ObjectInfo {
|
||||
ch := make(chan storage.ObjectInfo)
|
||||
go func() {
|
||||
defer close(ch)
|
||||
@@ -125,7 +141,8 @@ func (m *MockStorer) ListStream(ctx context.Context, prefix string) <-chan stora
|
||||
defer m.mu.Unlock()
|
||||
|
||||
for key, data := range m.data {
|
||||
if len(prefix) == 0 || (len(key) >= len(prefix) && key[:len(prefix)] == prefix) {
|
||||
if len(prefix) == 0 ||
|
||||
(len(key) >= len(prefix) && key[:len(prefix)] == prefix) {
|
||||
ch <- storage.ObjectInfo{
|
||||
Key: key,
|
||||
Size: int64(len(data)),
|
||||
@@ -137,8 +154,8 @@ func (m *MockStorer) ListStream(ctx context.Context, prefix string) <-chan stora
|
||||
return ch
|
||||
}
|
||||
|
||||
func (m *MockStorer) Info() storage.StorageInfo {
|
||||
return storage.StorageInfo{
|
||||
func (m *MockStorer) Info() storage.Info {
|
||||
return storage.Info{
|
||||
Type: "mock",
|
||||
Location: "memory",
|
||||
}
|
||||
@@ -163,123 +180,55 @@ func (m *MockStorer) GetStorageSize() int {
|
||||
return len(m.data)
|
||||
}
|
||||
|
||||
// TestEndToEndBackup tests the full backup workflow with mocked dependencies
|
||||
func TestEndToEndBackup(t *testing.T) {
|
||||
// Initialize logger
|
||||
log.Initialize(log.Config{})
|
||||
// writeTestFileTree creates each file (and its parent directory) in fs.
|
||||
func writeTestFileTree(t *testing.T, fs afero.Fs, files map[string]string) {
|
||||
t.Helper()
|
||||
|
||||
// Create in-memory filesystem
|
||||
fs := afero.NewMemMapFs()
|
||||
for path, content := range files {
|
||||
dir := filepath.Dir(path)
|
||||
|
||||
// Create test directory structure and files
|
||||
testFiles := map[string]string{
|
||||
"/home/user/documents/file1.txt": "This is file 1 content",
|
||||
"/home/user/documents/file2.txt": "This is file 2 content with more data",
|
||||
"/home/user/pictures/photo1.jpg": "Binary photo data here...",
|
||||
"/home/user/code/main.go": "package main\n\nfunc main() {\n\tprintln(\"Hello, World!\")\n}",
|
||||
}
|
||||
|
||||
// Create all directories first
|
||||
dirs := []string{
|
||||
"/home/user/documents",
|
||||
"/home/user/pictures",
|
||||
"/home/user/code",
|
||||
}
|
||||
for _, dir := range dirs {
|
||||
err := fs.MkdirAll(dir, 0755)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create directory %s: %v", dir, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create test files
|
||||
for path, content := range testFiles {
|
||||
err := afero.WriteFile(fs, path, []byte(content), 0644)
|
||||
err = afero.WriteFile(fs, path, []byte(content), 0644)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test file %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create mock storage
|
||||
mockStorage := NewMockStorer()
|
||||
// createTestSnapshotRecord inserts a snapshot row so scans have a
|
||||
// snapshot to attach to.
|
||||
func createTestSnapshotRecord(
|
||||
t *testing.T, repos *database.Repositories, snapshotID string,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
// Create test configuration
|
||||
cfg := &config.Config{
|
||||
Snapshots: map[string]config.SnapshotConfig{
|
||||
"test": {
|
||||
Paths: []string{"/home/user"},
|
||||
},
|
||||
},
|
||||
Exclude: []string{"*.tmp", "*.log"},
|
||||
ChunkSize: config.Size(16 * 1024), // 16KB chunks
|
||||
BlobSizeLimit: config.Size(100 * 1024), // 100KB blobs
|
||||
CompressionLevel: 3,
|
||||
AgeRecipients: []string{"age1ezrjmfpwsc95svdg0y54mums3zevgzu0x0ecq2f7tp8a05gl0sjq9q9wjg"}, // Test public key
|
||||
AgeSecretKey: "AGE-SECRET-KEY-19CR5YSFW59HM4TLD6GXVEDMZFTVVF7PPHKUT68TXSFPK7APHXA2QS2NJA5", // Test private key
|
||||
S3: config.S3Config{
|
||||
Endpoint: "http://localhost:9000", // MinIO endpoint for testing
|
||||
Region: "us-east-1",
|
||||
Bucket: "test-bucket",
|
||||
AccessKeyID: "test-access",
|
||||
SecretAccessKey: "test-secret",
|
||||
},
|
||||
IndexPath: ":memory:", // In-memory SQLite database
|
||||
}
|
||||
|
||||
// For a true end-to-end test, we'll create a simpler test that focuses on
|
||||
// the core backup logic using the scanner directly with our mock storage
|
||||
ctx := context.Background()
|
||||
|
||||
// Create in-memory database
|
||||
db, err := database.New(ctx, ":memory:")
|
||||
|
||||
require.NoError(t, err)
|
||||
defer func() {
|
||||
err := db.Close()
|
||||
if err != nil {
|
||||
t.Errorf("failed to close database: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
repos := database.NewRepositories(db)
|
||||
|
||||
// Create scanner with mock storage
|
||||
scanner := snapshot.NewScanner(snapshot.ScannerConfig{
|
||||
FS: fs,
|
||||
ChunkSize: cfg.ChunkSize.Int64(),
|
||||
Repositories: repos,
|
||||
Storage: mockStorage,
|
||||
MaxBlobSize: cfg.BlobSizeLimit.Int64(),
|
||||
CompressionLevel: cfg.CompressionLevel,
|
||||
AgeRecipients: cfg.AgeRecipients,
|
||||
EnableProgress: false,
|
||||
})
|
||||
|
||||
// Create a snapshot record
|
||||
snapshotID := "test-snapshot-001"
|
||||
err = repos.WithTx(ctx, func(ctx context.Context, tx *sql.Tx) error {
|
||||
snapshot := &database.Snapshot{
|
||||
err := repos.WithTx(ctx, func(ctx context.Context, tx *sql.Tx) error {
|
||||
snap := &database.Snapshot{
|
||||
ID: types.SnapshotID(snapshotID),
|
||||
Hostname: "test-host",
|
||||
Hostname: testHostname,
|
||||
VaultikVersion: "test-version",
|
||||
StartedAt: time.Now(),
|
||||
}
|
||||
|
||||
return repos.Snapshots.Create(ctx, tx, snapshot)
|
||||
return repos.Snapshots.Create(ctx, tx, snap)
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Run the backup scan
|
||||
result, err := scanner.Scan(ctx, "/home/user", snapshotID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify scan results
|
||||
// The scanner counts both files and directories, so we have:
|
||||
// 4 files + 4 directories (/home, /home/user, /home/user/documents, /home/user/pictures, /home/user/code)
|
||||
assert.GreaterOrEqual(t, result.FilesScanned, 4, "Should scan at least 4 files")
|
||||
assert.Positive(t, result.BytesScanned, "Should scan some bytes")
|
||||
assert.Positive(t, result.ChunksCreated, "Should create chunks")
|
||||
assert.Positive(t, result.BlobsCreated, "Should create blobs")
|
||||
// TestEndToEndBackup tests the full backup workflow with mocked dependencies
|
||||
// verifyEndToEndBackupState checks storage upload calls, database file
|
||||
// rows, and chunk mappings after the end-to-end backup scan.
|
||||
func verifyEndToEndBackupState(
|
||||
ctx context.Context, t *testing.T,
|
||||
repos *database.Repositories, mockStorage *MockStorer,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
// Verify storage operations
|
||||
calls := mockStorage.GetCalls()
|
||||
@@ -313,9 +262,108 @@ func TestEndToEndBackup(t *testing.T) {
|
||||
assert.Equal(t, 4, regularFiles, "Should have 4 regular files in database")
|
||||
|
||||
// Verify chunks were created by checking a specific file
|
||||
fileChunks, err := repos.FileChunks.GetByPath(ctx, "/home/user/documents/file1.txt")
|
||||
fileChunks, err := repos.FileChunks.GetByPath(ctx,
|
||||
"/home/user/documents/file1.txt")
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, fileChunks, "Should have chunks for file1.txt")
|
||||
}
|
||||
|
||||
// newEndToEndTestConfig builds the standard config used by the
|
||||
// end-to-end backup test.
|
||||
func newEndToEndTestConfig() *config.Config {
|
||||
return &config.Config{
|
||||
Snapshots: map[string]config.SnapshotConfig{
|
||||
testLabel: {
|
||||
Paths: []string{"/home/user"},
|
||||
},
|
||||
},
|
||||
Exclude: []string{"*.tmp", "*.log"},
|
||||
ChunkSize: config.Size(16 * 1024), // 16KB chunks
|
||||
BlobSizeLimit: config.Size(100 * 1024), // 100KB blobs
|
||||
CompressionLevel: 3,
|
||||
AgeRecipients: []string{testAgePublicKey},
|
||||
AgeSecretKey: testAgeSecretKey,
|
||||
S3: config.S3Config{
|
||||
Endpoint: "http://localhost:9000", // MinIO endpoint for testing
|
||||
Region: "us-east-1",
|
||||
Bucket: "test-bucket",
|
||||
AccessKeyID: "test-access",
|
||||
SecretAccessKey: "test-secret",
|
||||
},
|
||||
IndexPath: ":memory:", // In-memory SQLite database
|
||||
}
|
||||
}
|
||||
|
||||
func TestEndToEndBackup(t *testing.T) {
|
||||
// Initialize logger
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
// Create in-memory filesystem
|
||||
fs := afero.NewMemMapFs()
|
||||
|
||||
// Create test directory structure and files
|
||||
testFiles := map[string]string{
|
||||
"/home/user/documents/file1.txt": "This is file 1 content",
|
||||
"/home/user/documents/file2.txt": "This is file 2 content with more data",
|
||||
"/home/user/pictures/photo1.jpg": "Binary photo data here...",
|
||||
"/home/user/code/main.go": "package main\n\nfunc main() {\n" +
|
||||
"\tprintln(\"Hello, World!\")\n}",
|
||||
}
|
||||
|
||||
writeTestFileTree(t, fs, testFiles)
|
||||
|
||||
// Create mock storage
|
||||
mockStorage := NewMockStorer()
|
||||
|
||||
cfg := newEndToEndTestConfig()
|
||||
|
||||
// For a true end-to-end test, we'll create a simpler test that focuses on
|
||||
// the core backup logic using the scanner directly with our mock storage
|
||||
ctx := context.Background()
|
||||
|
||||
// Create in-memory database
|
||||
db, err := database.New(ctx, ":memory:")
|
||||
|
||||
require.NoError(t, err)
|
||||
defer func() {
|
||||
err := db.Close()
|
||||
if err != nil {
|
||||
t.Errorf("failed to close database: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
repos := database.NewRepositories(db)
|
||||
|
||||
// Create scanner with mock storage
|
||||
scanner := snapshot.NewScanner(snapshot.ScannerConfig{
|
||||
FS: fs,
|
||||
ChunkSize: cfg.ChunkSize.Int64(),
|
||||
Repositories: repos,
|
||||
Storage: mockStorage,
|
||||
MaxBlobSize: cfg.BlobSizeLimit.Int64(),
|
||||
CompressionLevel: cfg.CompressionLevel,
|
||||
AgeRecipients: cfg.AgeRecipients,
|
||||
EnableProgress: false,
|
||||
})
|
||||
|
||||
// Create a snapshot record
|
||||
snapshotID := "test-snapshot-001"
|
||||
createTestSnapshotRecord(t, repos, snapshotID)
|
||||
|
||||
// Run the backup scan
|
||||
result, err := scanner.Scan(ctx, "/home/user", snapshotID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify scan results. The scanner counts both files and
|
||||
// directories: 4 files + directories (/home, /home/user,
|
||||
// /home/user/documents, /home/user/pictures, /home/user/code).
|
||||
assert.GreaterOrEqual(t, result.FilesScanned, 4, "Should scan at least 4 files")
|
||||
assert.Positive(t, result.BytesScanned, "Should scan some bytes")
|
||||
assert.Positive(t, result.ChunksCreated, "Should create chunks")
|
||||
assert.Positive(t, result.BlobsCreated, "Should create blobs")
|
||||
|
||||
verifyEndToEndBackupState(ctx, t, repos, mockStorage)
|
||||
|
||||
// Verify blobs were uploaded to storage
|
||||
assert.Positive(t, mockStorage.GetStorageSize(), "Should have blobs in storage")
|
||||
@@ -343,6 +391,7 @@ func TestEndToEndBackup(t *testing.T) {
|
||||
func TestBackupAndVerify(t *testing.T) {
|
||||
// Initialize logger
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
// Create in-memory filesystem
|
||||
fs := afero.NewMemMapFs()
|
||||
@@ -379,22 +428,12 @@ func TestBackupAndVerify(t *testing.T) {
|
||||
Storage: mockStorage,
|
||||
MaxBlobSize: int64(1024 * 1024), // 1MB blobs
|
||||
CompressionLevel: 3,
|
||||
AgeRecipients: []string{"age1ezrjmfpwsc95svdg0y54mums3zevgzu0x0ecq2f7tp8a05gl0sjq9q9wjg"}, // Test public key
|
||||
AgeRecipients: []string{testAgePublicKey},
|
||||
})
|
||||
|
||||
// Create a snapshot
|
||||
snapshotID := "test-snapshot-001"
|
||||
err = repos.WithTx(ctx, func(ctx context.Context, tx *sql.Tx) error {
|
||||
snapshot := &database.Snapshot{
|
||||
ID: types.SnapshotID(snapshotID),
|
||||
Hostname: "test-host",
|
||||
VaultikVersion: "test-version",
|
||||
StartedAt: time.Now(),
|
||||
}
|
||||
|
||||
return repos.Snapshots.Create(ctx, tx, snapshot)
|
||||
})
|
||||
require.NoError(t, err)
|
||||
createTestSnapshotRecord(t, repos, snapshotID)
|
||||
|
||||
// Run the backup
|
||||
result, err := scanner.Scan(ctx, "/data", snapshotID)
|
||||
@@ -402,12 +441,14 @@ func TestBackupAndVerify(t *testing.T) {
|
||||
|
||||
// Verify backup created blobs
|
||||
assert.Positive(t, result.BlobsCreated, "Should create at least one blob")
|
||||
assert.Equal(t, mockStorage.GetStorageSize(), result.BlobsCreated, "Storage should have the blobs")
|
||||
assert.Equal(t, mockStorage.GetStorageSize(), result.BlobsCreated,
|
||||
"Storage should have the blobs")
|
||||
|
||||
// Verify we can retrieve the blob from storage
|
||||
objects, err := mockStorage.List(ctx, "blobs/")
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, objects, result.BlobsCreated, "Should have correct number of blobs in storage")
|
||||
assert.Len(t, objects, result.BlobsCreated,
|
||||
"Should have correct number of blobs in storage")
|
||||
|
||||
// Get the first blob and verify it exists
|
||||
if len(objects) > 0 {
|
||||
@@ -438,66 +479,15 @@ func TestBackupAndVerify(t *testing.T) {
|
||||
// TestBackupAndRestore tests the full backup and restore workflow
|
||||
// This test verifies that the restore code correctly handles the binary SQLite
|
||||
// database format that is exported by the snapshot manager.
|
||||
func TestBackupAndRestore(t *testing.T) {
|
||||
// Initialize logger
|
||||
log.Initialize(log.Config{})
|
||||
|
||||
// Create real temp directory for the database (SQLite needs real filesystem)
|
||||
realTempDir, err := os.MkdirTemp("", "vaultik-test-")
|
||||
require.NoError(t, err)
|
||||
|
||||
defer func() { _ = os.RemoveAll(realTempDir) }()
|
||||
|
||||
// Use real OS filesystem for this test
|
||||
fs := afero.NewOsFs()
|
||||
|
||||
// Create test directory structure and files
|
||||
dataDir := filepath.Join(realTempDir, "data")
|
||||
testFiles := map[string]string{
|
||||
filepath.Join(dataDir, "file1.txt"): "This is file 1 content",
|
||||
filepath.Join(dataDir, "file2.txt"): "This is file 2 content with more data",
|
||||
filepath.Join(dataDir, "subdir", "file3.txt"): "This is file 3 in a subdirectory",
|
||||
}
|
||||
|
||||
// Create directories and files
|
||||
for path, content := range testFiles {
|
||||
dir := filepath.Dir(path)
|
||||
|
||||
err := fs.MkdirAll(dir, 0755)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create directory %s: %v", dir, err)
|
||||
}
|
||||
|
||||
err = afero.WriteFile(fs, path, []byte(content), 0644)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test file %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create mock storage
|
||||
mockStorage := NewMockStorer()
|
||||
|
||||
// Test keypair
|
||||
agePublicKey := "age1ezrjmfpwsc95svdg0y54mums3zevgzu0x0ecq2f7tp8a05gl0sjq9q9wjg"
|
||||
ageSecretKey := "AGE-SECRET-KEY-19CR5YSFW59HM4TLD6GXVEDMZFTVVF7PPHKUT68TXSFPK7APHXA2QS2NJA5"
|
||||
|
||||
// Create database file
|
||||
dbPath := filepath.Join(realTempDir, "test.db")
|
||||
db, err := database.New(ctx, dbPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
defer func() { _ = db.Close() }()
|
||||
|
||||
repos := database.NewRepositories(db)
|
||||
|
||||
// Create config for snapshot manager
|
||||
cfg := &config.Config{
|
||||
AgeSecretKey: ageSecretKey,
|
||||
AgeRecipients: []string{agePublicKey},
|
||||
CompressionLevel: 3,
|
||||
}
|
||||
// runBackupPhase performs the backup half of the round-trip test:
|
||||
// create the snapshot, scan the data directory, complete the snapshot,
|
||||
// and export its metadata, verifying the metadata upload.
|
||||
func runBackupPhase(
|
||||
ctx context.Context, t *testing.T, fs afero.Fs,
|
||||
repos *database.Repositories, mockStorage *MockStorer,
|
||||
cfg *config.Config, dataDir, dbPath, agePublicKey string,
|
||||
) string {
|
||||
t.Helper()
|
||||
|
||||
// Create snapshot manager
|
||||
sm := snapshot.NewSnapshotManager(snapshot.SnapshotManagerParams{
|
||||
@@ -519,14 +509,16 @@ func TestBackupAndRestore(t *testing.T) {
|
||||
})
|
||||
|
||||
// Create a snapshot
|
||||
snapshotID, err := sm.CreateSnapshot(ctx, "test-host", "test-version", "test-git")
|
||||
snapshotID, err := sm.CreateSnapshotWithName(
|
||||
ctx, testHostname, "", "test-version", "test-git")
|
||||
require.NoError(t, err)
|
||||
t.Logf("Created snapshot: %s", snapshotID)
|
||||
|
||||
// Run the backup (scan)
|
||||
result, err := scanner.Scan(ctx, dataDir, snapshotID)
|
||||
require.NoError(t, err)
|
||||
t.Logf("Scan complete: %d files, %d blobs", result.FilesScanned, result.BlobsCreated)
|
||||
t.Logf("Scan complete: %d files, %d blobs",
|
||||
result.FilesScanned, result.BlobsCreated)
|
||||
|
||||
// Complete the snapshot
|
||||
err = sm.CompleteSnapshot(ctx, snapshotID)
|
||||
@@ -541,7 +533,63 @@ func TestBackupAndRestore(t *testing.T) {
|
||||
keys, err := mockStorage.List(ctx, "metadata/")
|
||||
require.NoError(t, err)
|
||||
t.Logf("Metadata keys: %v", keys)
|
||||
assert.GreaterOrEqual(t, len(keys), 2, "Should have at least db.zst.age and manifest.json.zst")
|
||||
assert.GreaterOrEqual(t, len(keys), 2,
|
||||
"Should have at least db.zst.age and manifest.json.zst")
|
||||
|
||||
return snapshotID
|
||||
}
|
||||
|
||||
func TestBackupAndRestore(t *testing.T) {
|
||||
// Initialize logger
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
// Create real temp directory for the database (SQLite needs real filesystem)
|
||||
realTempDir := t.TempDir()
|
||||
|
||||
// Use real OS filesystem for this test
|
||||
fs := afero.NewOsFs()
|
||||
|
||||
// Create test directory structure and files
|
||||
dataDir := filepath.Join(realTempDir, "data")
|
||||
testFiles := map[string]string{
|
||||
filepath.Join(dataDir, "file1.txt"): "This is file 1 content",
|
||||
filepath.Join(dataDir, "file2.txt"): "This is file 2 content " +
|
||||
"with more data",
|
||||
filepath.Join(dataDir, "subdir", "file3.txt"): "This is file 3 " +
|
||||
"in a subdirectory",
|
||||
}
|
||||
|
||||
// Create directories and files
|
||||
writeTestFileTree(t, fs, testFiles)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create mock storage
|
||||
mockStorage := NewMockStorer()
|
||||
|
||||
// Test keypair
|
||||
agePublicKey := testAgePublicKey
|
||||
ageSecretKey := testAgeSecretKey
|
||||
|
||||
// Create database file
|
||||
dbPath := filepath.Join(realTempDir, "test.db")
|
||||
db, err := database.New(ctx, dbPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
defer func() { _ = db.Close() }()
|
||||
|
||||
repos := database.NewRepositories(db)
|
||||
|
||||
// Create config for snapshot manager
|
||||
cfg := &config.Config{
|
||||
AgeSecretKey: ageSecretKey,
|
||||
AgeRecipients: []string{agePublicKey},
|
||||
CompressionLevel: 3,
|
||||
}
|
||||
|
||||
snapshotID := runBackupPhase(
|
||||
ctx, t, fs, repos, mockStorage, cfg, dataDir, dbPath, agePublicKey)
|
||||
|
||||
// Close the source database
|
||||
err = db.Close()
|
||||
@@ -570,49 +618,32 @@ func TestBackupAndRestore(t *testing.T) {
|
||||
for origPath, expectedContent := range testFiles {
|
||||
restoredPath := filepath.Join(restoreDir, origPath)
|
||||
restoredContent, err := afero.ReadFile(fs, restoredPath)
|
||||
require.NoError(t, err, "Should be able to read restored file: %s", restoredPath)
|
||||
assert.Equal(t, expectedContent, string(restoredContent), "Restored content should match original for: %s", origPath)
|
||||
require.NoError(t, err,
|
||||
"Should be able to read restored file: %s", restoredPath)
|
||||
assert.Equal(t, expectedContent, string(restoredContent),
|
||||
"Restored content should match original for: %s", origPath)
|
||||
}
|
||||
|
||||
t.Log("Backup and restore test completed successfully")
|
||||
}
|
||||
|
||||
// TestEndToEndFileStorage exercises the full backup → restore loop against the
|
||||
// real `file://` storage backend (FileStorer) on a real OS filesystem. This is
|
||||
// the closest local approximation of a production backup: encrypted blobs get
|
||||
// written to disk, the metadata SQLite database is exported through the same
|
||||
// blobgen pipeline as a real backup, and restoration reads them back through
|
||||
// the public Vaultik.Restore entrypoint. It is the canonical end-to-end smoke
|
||||
// test for 1.0.
|
||||
func TestEndToEndFileStorage(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
|
||||
// Real OS filesystem (SQLite + FileStorer both need it).
|
||||
fs := afero.NewOsFs()
|
||||
tempDir, err := os.MkdirTemp("", "vaultik-e2e-")
|
||||
require.NoError(t, err)
|
||||
|
||||
defer func() { _ = os.RemoveAll(tempDir) }()
|
||||
|
||||
dataDir := filepath.Join(tempDir, "source")
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
restoreDir := filepath.Join(tempDir, "restored")
|
||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||
|
||||
// Write a representative mix of file sizes:
|
||||
// - empty file
|
||||
// - tiny text file
|
||||
// - file just under chunk boundary
|
||||
// - file forcing multiple chunks
|
||||
// - nested subdirectories
|
||||
chunkSize := int64(64 * 1024)
|
||||
maxBlobSize := int64(512 * 1024)
|
||||
// setupE2ESourceTree writes a representative mix of file sizes (empty
|
||||
// file, tiny text file, file under the chunk boundary, multi-chunk
|
||||
// file, nested subdirectories), a permission-restricted file, an empty
|
||||
// directory, and a symlink. It returns the content map keyed by path
|
||||
// plus the restricted-file, empty-dir, and symlink paths.
|
||||
func setupE2ESourceTree(
|
||||
t *testing.T, fs afero.Fs, dataDir string, chunkSize int64,
|
||||
) (map[string][]byte, string, string, string) {
|
||||
t.Helper()
|
||||
|
||||
testFiles := map[string][]byte{
|
||||
filepath.Join(dataDir, "empty.txt"): {},
|
||||
filepath.Join(dataDir, "small.txt"): []byte("hello vaultik"),
|
||||
filepath.Join(dataDir, "subdir", "medium.bin"): bytesPattern("medium-", int(chunkSize/2)),
|
||||
filepath.Join(dataDir, "subdir", "large.bin"): bytesPattern("large-", int(chunkSize*4)),
|
||||
filepath.Join(dataDir, "empty.txt"): {},
|
||||
filepath.Join(dataDir, "small.txt"): []byte("hello vaultik"),
|
||||
filepath.Join(dataDir, "subdir", "medium.bin"): bytesPattern(
|
||||
"medium-", int(chunkSize/2)),
|
||||
filepath.Join(dataDir, "subdir", "large.bin"): bytesPattern(
|
||||
"large-", int(chunkSize*4)),
|
||||
filepath.Join(dataDir, "deep", "nest", "leaf.txt"): []byte("leaf"),
|
||||
}
|
||||
|
||||
@@ -624,6 +655,7 @@ func TestEndToEndFileStorage(t *testing.T) {
|
||||
// Create a file with non-default permissions.
|
||||
restrictedPath := filepath.Join(dataDir, "restricted.txt")
|
||||
require.NoError(t, afero.WriteFile(fs, restrictedPath, []byte("secret"), 0o600))
|
||||
|
||||
testFiles[restrictedPath] = []byte("secret")
|
||||
|
||||
// Create an empty directory (should survive round-trip).
|
||||
@@ -634,22 +666,38 @@ func TestEndToEndFileStorage(t *testing.T) {
|
||||
symlinkPath := filepath.Join(dataDir, "link-to-small")
|
||||
require.NoError(t, os.Symlink("small.txt", symlinkPath))
|
||||
|
||||
return testFiles, restrictedPath, emptyDir, symlinkPath
|
||||
}
|
||||
|
||||
// TestEndToEndFileStorage exercises the full backup → restore loop against the
|
||||
// real `file://` storage backend (FileStorer) on a real OS filesystem. This is
|
||||
// the closest local approximation of a production backup: encrypted blobs get
|
||||
// written to disk, the metadata SQLite database is exported through the same
|
||||
// blobgen pipeline as a real backup, and restoration reads them back through
|
||||
// the public Vaultik.Restore entrypoint. It is the canonical end-to-end smoke
|
||||
// test for 1.0.
|
||||
// runFileStorageBackup performs the backup half of the file-storage
|
||||
// end-to-end test against a real on-disk FileStorer, verifies the
|
||||
// on-disk layout, and closes the index database so the restore half
|
||||
// runs from remote bytes only.
|
||||
func runFileStorageBackup(
|
||||
ctx context.Context, t *testing.T, fs afero.Fs,
|
||||
dataDir, storeDir, dbPath string,
|
||||
chunkSize, maxBlobSize int64,
|
||||
) (*config.Config, *storage.FileStorer, string) {
|
||||
t.Helper()
|
||||
|
||||
// FileStorer is the real-world local-disk backend.
|
||||
storer, err := storage.NewFileStorer(storeDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
agePublicKey := "age1ezrjmfpwsc95svdg0y54mums3zevgzu0x0ecq2f7tp8a05gl0sjq9q9wjg"
|
||||
ageSecretKey := "AGE-SECRET-KEY-19CR5YSFW59HM4TLD6GXVEDMZFTVVF7PPHKUT68TXSFPK7APHXA2QS2NJA5"
|
||||
|
||||
cfg := &config.Config{
|
||||
AgeRecipients: []string{agePublicKey},
|
||||
AgeSecretKey: ageSecretKey,
|
||||
AgeRecipients: []string{testAgePublicKey},
|
||||
AgeSecretKey: testAgeSecretKey,
|
||||
CompressionLevel: 3,
|
||||
Hostname: "test-host",
|
||||
Hostname: testHostname,
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
db, err := database.New(ctx, dbPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -674,7 +722,8 @@ func TestEndToEndFileStorage(t *testing.T) {
|
||||
Repositories: repos,
|
||||
})
|
||||
|
||||
snapshotID, err := sm.CreateSnapshotWithName(ctx, cfg.Hostname, "e2e", "test-version", "test-git")
|
||||
snapshotID, err := sm.CreateSnapshotWithName(
|
||||
ctx, cfg.Hostname, "e2e", "test-version", "test-git")
|
||||
require.NoError(t, err)
|
||||
|
||||
scanResult, err := scanner.Scan(ctx, dataDir, snapshotID)
|
||||
@@ -692,7 +741,8 @@ func TestEndToEndFileStorage(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.True(t, blobInfo.IsDir())
|
||||
|
||||
metaInfo, err := os.Stat(filepath.Join(storeDir, "metadata", snapshot.RemoteSnapshotKey(snapshotID)))
|
||||
metaInfo, err := os.Stat(filepath.Join(
|
||||
storeDir, "metadata", snapshot.RemoteSnapshotKey(snapshotID)))
|
||||
require.NoError(t, err)
|
||||
require.True(t, metaInfo.IsDir())
|
||||
|
||||
@@ -700,6 +750,39 @@ func TestEndToEndFileStorage(t *testing.T) {
|
||||
// the remote bytes plus the secret key, with no help from the local index.
|
||||
require.NoError(t, db.Close())
|
||||
|
||||
return cfg, storer, snapshotID
|
||||
}
|
||||
|
||||
func TestEndToEndFileStorage(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
// Real OS filesystem (SQLite + FileStorer both need it).
|
||||
fs := afero.NewOsFs()
|
||||
tempDir := t.TempDir()
|
||||
|
||||
dataDir := filepath.Join(tempDir, "source")
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
restoreDir := filepath.Join(tempDir, "restored")
|
||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||
|
||||
// Write a representative mix of file sizes:
|
||||
// - empty file
|
||||
// - tiny text file
|
||||
// - file just under chunk boundary
|
||||
// - file forcing multiple chunks
|
||||
// - nested subdirectories
|
||||
chunkSize := int64(64 * 1024)
|
||||
maxBlobSize := int64(512 * 1024)
|
||||
|
||||
testFiles, restrictedPath, emptyDir, symlinkPath :=
|
||||
setupE2ESourceTree(t, fs, dataDir, chunkSize)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
cfg, storer, snapshotID := runFileStorageBackup(
|
||||
ctx, t, fs, dataDir, storeDir, dbPath, chunkSize, maxBlobSize)
|
||||
|
||||
restoreVaultik := &vaultik.Vaultik{
|
||||
Config: cfg,
|
||||
Storage: storer,
|
||||
@@ -716,6 +799,19 @@ func TestEndToEndFileStorage(t *testing.T) {
|
||||
Verify: true,
|
||||
}))
|
||||
|
||||
verifyE2ERestoredTree(t, fs, restoreDir, testFiles,
|
||||
restrictedPath, emptyDir, symlinkPath)
|
||||
}
|
||||
|
||||
// verifyE2ERestoredTree byte-compares every restored file and checks the
|
||||
// restricted-permission file, empty directory, and symlink special cases.
|
||||
func verifyE2ERestoredTree(
|
||||
t *testing.T, fs afero.Fs, restoreDir string,
|
||||
testFiles map[string][]byte,
|
||||
restrictedPath, emptyDir, symlinkPath string,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
// Byte-equality compare every original against its restored copy.
|
||||
for origPath, expected := range testFiles {
|
||||
restoredPath := filepath.Join(restoreDir, origPath)
|
||||
@@ -751,22 +847,22 @@ func TestEndToEndFileStorage(t *testing.T) {
|
||||
// regression where snapshot_blobs was populated only for blobs uploaded
|
||||
// during the snapshot, leaving fully-deduplicated snapshots unrestorable
|
||||
// with "chunk X not found in any blob" errors.
|
||||
func TestDedupOnlySnapshotRestores(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
// dedupBackupEnv bundles the moving parts of the dedup round-trip test.
|
||||
type dedupBackupEnv struct {
|
||||
cfg *config.Config
|
||||
storer *storage.FileStorer
|
||||
db *database.DB
|
||||
repos *database.Repositories
|
||||
sm *snapshot.SnapshotManager
|
||||
makeScanner func() *snapshot.Scanner
|
||||
}
|
||||
|
||||
fs := afero.NewOsFs()
|
||||
tempDir, err := os.MkdirTemp("", "vaultik-dedup-")
|
||||
require.NoError(t, err)
|
||||
|
||||
defer func() { _ = os.RemoveAll(tempDir) }()
|
||||
|
||||
dataDir := filepath.Join(tempDir, "source")
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
restoreDir := filepath.Join(tempDir, "restored")
|
||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||
|
||||
chunkSize := int64(64 * 1024)
|
||||
maxBlobSize := int64(512 * 1024)
|
||||
// writeDedupSourceFiles writes the two deterministic source files used
|
||||
// by the dedup test and returns their expected contents by path.
|
||||
func writeDedupSourceFiles(
|
||||
t *testing.T, fs afero.Fs, dataDir string, chunkSize int64,
|
||||
) map[string][]byte {
|
||||
t.Helper()
|
||||
|
||||
testFiles := map[string][]byte{
|
||||
filepath.Join(dataDir, "a.bin"): bytesPattern("a-", int(chunkSize*3)),
|
||||
@@ -777,25 +873,30 @@ func TestDedupOnlySnapshotRestores(t *testing.T) {
|
||||
require.NoError(t, afero.WriteFile(fs, path, content, 0o644))
|
||||
}
|
||||
|
||||
return testFiles
|
||||
}
|
||||
|
||||
// setupDedupBackupEnv creates the storer, config, database, snapshot
|
||||
// manager, and scanner factory for the dedup round-trip test.
|
||||
func setupDedupBackupEnv(
|
||||
ctx context.Context, t *testing.T, fs afero.Fs,
|
||||
storeDir, dbPath string, chunkSize, maxBlobSize int64,
|
||||
) *dedupBackupEnv {
|
||||
t.Helper()
|
||||
|
||||
storer, err := storage.NewFileStorer(storeDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
agePublicKey := "age1ezrjmfpwsc95svdg0y54mums3zevgzu0x0ecq2f7tp8a05gl0sjq9q9wjg"
|
||||
ageSecretKey := "AGE-SECRET-KEY-19CR5YSFW59HM4TLD6GXVEDMZFTVVF7PPHKUT68TXSFPK7APHXA2QS2NJA5"
|
||||
|
||||
cfg := &config.Config{
|
||||
AgeRecipients: []string{agePublicKey},
|
||||
AgeSecretKey: ageSecretKey,
|
||||
AgeRecipients: []string{testAgePublicKey},
|
||||
AgeSecretKey: testAgeSecretKey,
|
||||
CompressionLevel: 3,
|
||||
Hostname: "test-host",
|
||||
Hostname: testHostname,
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
db, err := database.New(ctx, dbPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
defer func() { _ = db.Close() }()
|
||||
|
||||
repos := database.NewRepositories(db)
|
||||
|
||||
makeScanner := func() *snapshot.Scanner {
|
||||
@@ -814,31 +915,85 @@ func TestDedupOnlySnapshotRestores(t *testing.T) {
|
||||
})
|
||||
sm.SetFilesystem(fs)
|
||||
|
||||
return &dedupBackupEnv{
|
||||
cfg: cfg,
|
||||
storer: storer,
|
||||
db: db,
|
||||
repos: repos,
|
||||
sm: sm,
|
||||
makeScanner: makeScanner,
|
||||
}
|
||||
}
|
||||
|
||||
// runDedupSnapshot creates a "dedup" snapshot, scans dataDir into it,
|
||||
// completes it, and exports its metadata, returning the snapshot ID and
|
||||
// scan result.
|
||||
func runDedupSnapshot(
|
||||
ctx context.Context, t *testing.T,
|
||||
sm *snapshot.SnapshotManager, scanner *snapshot.Scanner,
|
||||
hostname, dataDir, dbPath string,
|
||||
) (string, *snapshot.ScanResult) {
|
||||
t.Helper()
|
||||
|
||||
id, err := sm.CreateSnapshotWithName(ctx, hostname, "dedup", "v", "g")
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := scanner.Scan(ctx, dataDir, id)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, sm.CompleteSnapshot(ctx, id))
|
||||
require.NoError(t, sm.ExportSnapshotMetadata(ctx, dbPath, id))
|
||||
|
||||
return id, result
|
||||
}
|
||||
|
||||
func TestDedupOnlySnapshotRestores(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")
|
||||
restoreDir := filepath.Join(tempDir, "restored")
|
||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||
|
||||
chunkSize := int64(64 * 1024)
|
||||
maxBlobSize := int64(512 * 1024)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
testFiles := writeDedupSourceFiles(t, fs, dataDir, chunkSize)
|
||||
|
||||
env := setupDedupBackupEnv(
|
||||
ctx, t, fs, storeDir, dbPath, chunkSize, maxBlobSize)
|
||||
defer func() { _ = env.db.Close() }()
|
||||
|
||||
cfg, storer, repos, sm := env.cfg, env.storer, env.repos, env.sm
|
||||
makeScanner := env.makeScanner
|
||||
db := env.db
|
||||
|
||||
// First snapshot — uploads all blobs.
|
||||
id1, err := sm.CreateSnapshotWithName(ctx, cfg.Hostname, "dedup", "v", "g")
|
||||
require.NoError(t, err)
|
||||
r1, err := makeScanner().Scan(ctx, dataDir, id1)
|
||||
require.NoError(t, err)
|
||||
require.Positive(t, r1.BlobsCreated, "first snapshot should upload at least one blob")
|
||||
require.NoError(t, sm.CompleteSnapshot(ctx, id1))
|
||||
require.NoError(t, sm.ExportSnapshotMetadata(ctx, dbPath, id1))
|
||||
_, r1 := runDedupSnapshot(ctx, t, sm, makeScanner(),
|
||||
cfg.Hostname, dataDir, dbPath)
|
||||
require.Positive(t, r1.BlobsCreated,
|
||||
"first snapshot should upload at least one blob")
|
||||
|
||||
// Second snapshot — same data, every chunk dedups. Sleep past the
|
||||
// second-precision timestamp so the snapshot IDs differ.
|
||||
time.Sleep(1100 * time.Millisecond)
|
||||
|
||||
id2, err := sm.CreateSnapshotWithName(ctx, cfg.Hostname, "dedup", "v", "g")
|
||||
require.NoError(t, err)
|
||||
r2, err := makeScanner().Scan(ctx, dataDir, id2)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 0, r2.BlobsCreated, "second snapshot should upload zero new blobs (fully dedup'd)")
|
||||
require.NoError(t, sm.CompleteSnapshot(ctx, id2))
|
||||
require.NoError(t, sm.ExportSnapshotMetadata(ctx, dbPath, id2))
|
||||
id2, r2 := runDedupSnapshot(ctx, t, sm, makeScanner(),
|
||||
cfg.Hostname, dataDir, dbPath)
|
||||
require.Equal(t, 0, r2.BlobsCreated,
|
||||
"second snapshot should upload zero new blobs (fully dedup'd)")
|
||||
|
||||
// snapshot_blobs for id2 must be populated despite no uploads.
|
||||
blobHashes, err := repos.Snapshots.GetBlobHashes(ctx, id2)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, blobHashes, "snapshot_blobs for fully-dedup'd snapshot must reference blobs uploaded by prior snapshot")
|
||||
require.NotEmpty(t, blobHashes, "snapshot_blobs for fully-dedup'd snapshot "+
|
||||
"must reference blobs uploaded by prior snapshot")
|
||||
|
||||
require.NoError(t, db.Close())
|
||||
|
||||
@@ -871,7 +1026,7 @@ func TestDedupOnlySnapshotRestores(t *testing.T) {
|
||||
func bytesPattern(tag string, n int) []byte {
|
||||
out := make([]byte, n)
|
||||
for i := range out {
|
||||
out[i] = byte(tag[i%len(tag)] ^ byte(i&0xff))
|
||||
out[i] = tag[i%len(tag)] ^ byte(i&0xff)
|
||||
}
|
||||
|
||||
return out
|
||||
|
||||
Reference in New Issue
Block a user