All checks were successful
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>
307 lines
8.7 KiB
Go
307 lines
8.7 KiB
Go
package vaultik_test
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"maps"
|
|
"math/rand"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
"github.com/spf13/afero"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"sneak.berlin/go/vaultik/internal/config"
|
|
"sneak.berlin/go/vaultik/internal/database"
|
|
"sneak.berlin/go/vaultik/internal/log"
|
|
"sneak.berlin/go/vaultik/internal/snapshot"
|
|
"sneak.berlin/go/vaultik/internal/storage"
|
|
"sneak.berlin/go/vaultik/internal/ui"
|
|
"sneak.berlin/go/vaultik/internal/vaultik"
|
|
)
|
|
|
|
// TestRestoreSweeperEvictsBlobs exercises the reference-counted blob
|
|
// disk cache eviction during restore.
|
|
//
|
|
// The scenario: 30 unique 1 MB random files plus 10 duplicates of those
|
|
// (40 files total, 30 MB of unique content) get backed up with a 10 MB
|
|
// blob_size_limit. After backup the snapshot's encrypted blobs are
|
|
// restored through Vaultik.Restore, and per-key Get counts on the
|
|
// storage layer are recorded. Each blob in the snapshot MUST be
|
|
// downloaded exactly once — re-downloads would mean the sweeper either
|
|
// evicted a blob that was still needed (LRU regression) or that the
|
|
// cache held nothing at all (broken cache).
|
|
//
|
|
// The duplicates ensure deduplicated files share blobs with their
|
|
// originals; the sweeper must keep each blob alive until BOTH the
|
|
// original AND every duplicate referencing its chunks have been
|
|
// restored.
|
|
// buildSweeperFixture writes the source layout for the sweeper test: 30
|
|
// unique 1 MB random files plus 10 duplicates of a random subset. The
|
|
// PRNG seed is fixed so failures are reproducible; the entropy is what
|
|
// matters here — the FastCDC chunker needs realistic-looking data to
|
|
// pick chunk boundaries naturally. Returns the expected content by
|
|
// path.
|
|
func buildSweeperFixture(
|
|
t *testing.T, fs afero.Fs, dataDir string, uniqueFiles, duplicateFiles int,
|
|
) map[string][]byte {
|
|
t.Helper()
|
|
|
|
const fileSize = 1 * 1024 * 1024
|
|
|
|
rng := rand.New(rand.NewSource(42)) //nolint:gosec // G404: fixture data only
|
|
|
|
type sourceFile struct {
|
|
path string
|
|
data []byte
|
|
}
|
|
|
|
uniques := make([]sourceFile, 0, uniqueFiles)
|
|
expected := make(map[string][]byte, uniqueFiles+duplicateFiles)
|
|
|
|
for i := range uniqueFiles {
|
|
data := make([]byte, fileSize)
|
|
_, err := rng.Read(data)
|
|
require.NoError(t, err)
|
|
|
|
path := filepath.Join(dataDir, fmt.Sprintf("unique-%02d.bin", i))
|
|
require.NoError(t, afero.WriteFile(fs, path, data, 0o644))
|
|
uniques = append(uniques, sourceFile{path: path, data: data})
|
|
expected[path] = data
|
|
}
|
|
|
|
// Copy a subset of the originals to fresh paths so the chunker
|
|
// dedups them against the originals' blobs.
|
|
for i, idx := range rng.Perm(uniqueFiles)[:duplicateFiles] {
|
|
src := uniques[idx]
|
|
dstPath := filepath.Join(dataDir, fmt.Sprintf("dup-%02d.bin", i))
|
|
require.NoError(t, afero.WriteFile(fs, dstPath, src.data, 0o644))
|
|
|
|
expected[dstPath] = src.data
|
|
}
|
|
|
|
return expected
|
|
}
|
|
|
|
// verifySingleDownloadPerBlob asserts each blob on disk was fetched
|
|
// exactly once. >1 means the sweeper evicted a still-needed blob; 0
|
|
// means the cache silently stopped being consulted.
|
|
func verifySingleDownloadPerBlob(
|
|
t *testing.T, counter *countingStorer, storeDir string,
|
|
) {
|
|
t.Helper()
|
|
|
|
blobDownloads := 0
|
|
|
|
for key, count := range counter.snapshot() {
|
|
if !strings.HasPrefix(key, "blobs/") {
|
|
continue
|
|
}
|
|
|
|
assert.Equalf(t, 1, count,
|
|
"blob %s should have been downloaded exactly once during "+
|
|
"restore, got %d", key, count)
|
|
|
|
blobDownloads++
|
|
}
|
|
|
|
blobCount := countBlobsOnDisk(t, storeDir)
|
|
assert.Equal(t, blobCount, blobDownloads,
|
|
"every blob on disk should have been fetched exactly once during restore")
|
|
t.Logf("restore downloaded %d blobs, each exactly once", blobDownloads)
|
|
}
|
|
|
|
// runSweeperBackup performs the backup half of the sweeper test and
|
|
// closes the index database so the restore half runs from remote bytes
|
|
// only. Returns the snapshot ID.
|
|
func runSweeperBackup(
|
|
ctx context.Context, t *testing.T, fs afero.Fs,
|
|
storer *storage.FileStorer, cfg *config.Config,
|
|
dataDir, storeDir, dbPath string,
|
|
chunkSize, maxBlobSize int64,
|
|
uniqueFiles, duplicateFiles int,
|
|
) string {
|
|
t.Helper()
|
|
|
|
db, err := database.New(ctx, dbPath)
|
|
require.NoError(t, err)
|
|
|
|
defer func() { _ = db.Close() }()
|
|
|
|
repos := database.NewRepositories(db)
|
|
|
|
sm := snapshot.NewSnapshotManager(snapshot.SnapshotManagerParams{
|
|
Repos: repos,
|
|
Storage: storer,
|
|
Config: cfg,
|
|
})
|
|
sm.SetFilesystem(fs)
|
|
|
|
scanner := snapshot.NewScanner(snapshot.ScannerConfig{
|
|
FS: fs,
|
|
Storage: storer,
|
|
ChunkSize: chunkSize,
|
|
MaxBlobSize: maxBlobSize,
|
|
CompressionLevel: cfg.CompressionLevel,
|
|
AgeRecipients: cfg.AgeRecipients,
|
|
Repositories: repos,
|
|
})
|
|
|
|
snapshotID, err := sm.CreateSnapshotWithName(
|
|
ctx, cfg.Hostname, "sweeper", "test-version", "test-git")
|
|
require.NoError(t, err)
|
|
|
|
scanResult, err := scanner.Scan(ctx, dataDir, snapshotID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, uniqueFiles+duplicateFiles, scanResult.FilesScanned)
|
|
require.Greater(t, scanResult.BlobsCreated, 1,
|
|
"30 MB of unique data at 10 MB blob size should yield multiple blobs")
|
|
|
|
require.NoError(t, sm.CompleteSnapshot(ctx, snapshotID))
|
|
require.NoError(t, sm.ExportSnapshotMetadata(ctx, dbPath, snapshotID))
|
|
|
|
// Count blobs actually present on disk; this is the ground-truth
|
|
// figure each blob's GET count must equal exactly once.
|
|
blobCount := countBlobsOnDisk(t, storeDir)
|
|
require.Greater(t, blobCount, 1, "expected more than one blob")
|
|
t.Logf("backup produced %d blobs from %d files (%d unique + %d duplicates)",
|
|
blobCount, uniqueFiles+duplicateFiles, uniqueFiles, duplicateFiles)
|
|
|
|
// Force restore to operate without the source-side index, exactly
|
|
// as a real restore on a fresh machine would.
|
|
require.NoError(t, db.Close())
|
|
|
|
return snapshotID
|
|
}
|
|
|
|
func TestRestoreSweeperEvictsBlobs(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")
|
|
|
|
require.NoError(t, fs.MkdirAll(dataDir, 0o755))
|
|
|
|
const (
|
|
uniqueFiles = 30
|
|
duplicateFiles = 10
|
|
)
|
|
|
|
expected := buildSweeperFixture(t, fs, dataDir, uniqueFiles, duplicateFiles)
|
|
|
|
chunkSize := int64(64 * 1024)
|
|
maxBlobSize := int64(10 * 1024 * 1024)
|
|
|
|
storer, err := storage.NewFileStorer(storeDir)
|
|
require.NoError(t, err)
|
|
|
|
cfg := &config.Config{
|
|
AgeRecipients: []string{testAgePublicKey},
|
|
AgeSecretKey: testAgeSecretKey,
|
|
CompressionLevel: 3,
|
|
Hostname: testHostname,
|
|
BlobSizeLimit: config.Size(maxBlobSize),
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
snapshotID := runSweeperBackup(ctx, t, fs, storer, cfg,
|
|
dataDir, storeDir, dbPath, chunkSize, maxBlobSize,
|
|
uniqueFiles, duplicateFiles)
|
|
|
|
counter := newCountingStorer(storer)
|
|
|
|
restoreVaultik := &vaultik.Vaultik{
|
|
Config: cfg,
|
|
Storage: counter,
|
|
Fs: fs,
|
|
Stdout: io.Discard,
|
|
Stderr: io.Discard,
|
|
UI: ui.NewWithColor(io.Discard, false),
|
|
}
|
|
restoreVaultik.SetContext(ctx)
|
|
|
|
require.NoError(t, restoreVaultik.Restore(&vaultik.RestoreOptions{
|
|
SnapshotID: snapshotID,
|
|
TargetDir: restoreDir,
|
|
}))
|
|
|
|
// Verify every restored file byte-matches its source.
|
|
for origPath, want := range expected {
|
|
restoredPath := filepath.Join(restoreDir, origPath)
|
|
got, err := afero.ReadFile(fs, restoredPath)
|
|
require.NoErrorf(t, err, "restored file missing: %s", restoredPath)
|
|
require.Equalf(t, want, got, "byte mismatch for %s", origPath)
|
|
}
|
|
|
|
verifySingleDownloadPerBlob(t, counter, storeDir)
|
|
}
|
|
|
|
// countingStorer wraps a Storer and records the number of Get calls per
|
|
// key. Used to verify that the restore-side blob cache + sweeper avoid
|
|
// re-downloading blobs that are evicted while still needed.
|
|
type countingStorer struct {
|
|
storage.Storer
|
|
|
|
mu sync.Mutex
|
|
counts map[string]int
|
|
}
|
|
|
|
func newCountingStorer(inner storage.Storer) *countingStorer {
|
|
return &countingStorer{Storer: inner, counts: make(map[string]int)}
|
|
}
|
|
|
|
func (c *countingStorer) Get(
|
|
ctx context.Context, key string,
|
|
) (io.ReadCloser, error) {
|
|
c.mu.Lock()
|
|
c.counts[key]++
|
|
c.mu.Unlock()
|
|
|
|
return c.Storer.Get(ctx, key)
|
|
}
|
|
|
|
func (c *countingStorer) snapshot() map[string]int {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
out := make(map[string]int, len(c.counts))
|
|
maps.Copy(out, c.counts)
|
|
|
|
return out
|
|
}
|
|
|
|
// countBlobsOnDisk walks the blobs/ tree of a FileStorer-backed store
|
|
// and returns the total number of blob files. Used to ground-truth the
|
|
// expected number of restore-time downloads.
|
|
func countBlobsOnDisk(t *testing.T, storeDir string) int {
|
|
t.Helper()
|
|
|
|
count := 0
|
|
root := filepath.Join(storeDir, "blobs")
|
|
err := filepath.Walk(root, func(_ string, info os.FileInfo, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if !info.IsDir() {
|
|
count++
|
|
}
|
|
|
|
return nil
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
return count
|
|
}
|