snapshot create compacted the metadata database by running a sqlite3 command-line binary, after every blob had already been uploaded. On a host without that binary, which includes anyone who installed with go install, the backup failed at the last step, and two tests failed the same way. VACUUM now runs through the Go sqlite driver the program already uses, and its error is returned to the caller. The runtime Docker image no longer installs the sqlite package, since nothing in the binary calls it. model: claude-opus-4-8 (implementation, review); claude-fable-5-1 (merge)
318 lines
7.6 KiB
Go
318 lines
7.6 KiB
Go
//nolint:testpackage // exercises unexported SnapshotManager internals
|
|
package snapshot
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"database/sql"
|
|
"io"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/spf13/afero"
|
|
"sneak.berlin/go/vaultik/internal/config"
|
|
"sneak.berlin/go/vaultik/internal/database"
|
|
"sneak.berlin/go/vaultik/internal/log"
|
|
)
|
|
|
|
const (
|
|
// Test age public key for encryption
|
|
testAgeRecipient = "age1ezrjmfpwsc95svdg0y54mums3zevgzu0x0ecq2f7tp8a05gl0sjq9q9wjg"
|
|
)
|
|
|
|
// copyFile is a test helper to copy files using afero
|
|
func copyFile(fs afero.Fs, src, dst string) error {
|
|
sourceFile, err := fs.Open(src)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
defer func() { _ = sourceFile.Close() }()
|
|
|
|
destFile, err := fs.Create(dst)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
defer func() { _ = destFile.Close() }()
|
|
|
|
_, err = io.Copy(destFile, sourceFile)
|
|
|
|
return err
|
|
}
|
|
|
|
// verifyCleanedDB opens the cleaned database and checks that the kept
|
|
// snapshot survived while the orphan file and chunk were removed.
|
|
func verifyCleanedDB(
|
|
ctx context.Context,
|
|
t *testing.T,
|
|
tempDBPath, snapshotID string,
|
|
file *database.File,
|
|
chunk *database.Chunk,
|
|
) {
|
|
t.Helper()
|
|
|
|
cleanedDB, err := database.New(ctx, tempDBPath)
|
|
if err != nil {
|
|
t.Fatalf("failed to open cleaned database: %v", err)
|
|
}
|
|
|
|
defer func() {
|
|
err := cleanedDB.Close()
|
|
if err != nil {
|
|
t.Errorf("failed to close database: %v", err)
|
|
}
|
|
}()
|
|
|
|
cleanedRepos := database.NewRepositories(cleanedDB)
|
|
|
|
// Verify snapshot exists
|
|
verifySnapshot, err := cleanedRepos.Snapshots.GetByID(ctx, snapshotID)
|
|
if err != nil {
|
|
t.Fatalf("failed to get snapshot: %v", err)
|
|
}
|
|
|
|
if verifySnapshot == nil {
|
|
t.Error("snapshot should exist")
|
|
}
|
|
|
|
// Verify orphan file is gone
|
|
f, err := cleanedRepos.Files.GetByPath(ctx, file.Path.String())
|
|
if err != nil {
|
|
t.Fatalf("failed to check file: %v", err)
|
|
}
|
|
|
|
if f != nil {
|
|
t.Error("orphan file should not exist")
|
|
}
|
|
|
|
// Verify orphan chunk is gone
|
|
c, err := cleanedRepos.Chunks.GetByHash(ctx, chunk.ChunkHash.String())
|
|
if err != nil {
|
|
t.Fatalf("failed to check chunk: %v", err)
|
|
}
|
|
|
|
if c != nil {
|
|
t.Error("orphan chunk should not exist")
|
|
}
|
|
}
|
|
|
|
// TestVacuumDatabaseRemovesDeletedData proves the export path uploads a
|
|
// compacted database: after rows carrying a recognizable marker are deleted
|
|
// and vacuumDatabase runs, no page holding that marker survives in the file
|
|
// on disk (the file compressFile later reads for upload).
|
|
func TestVacuumDatabaseRemovesDeletedData(t *testing.T) {
|
|
log.Initialize(log.Config{})
|
|
t.Parallel()
|
|
|
|
ctx := context.Background()
|
|
fs := afero.NewOsFs()
|
|
|
|
tempDir := t.TempDir()
|
|
dbPath := filepath.Join(tempDir, "snapshot.db")
|
|
|
|
db, err := database.New(ctx, dbPath)
|
|
if err != nil {
|
|
t.Fatalf("failed to create database: %v", err)
|
|
}
|
|
|
|
// A marker distinctive enough that its presence in the raw file can only
|
|
// come from the rows inserted below.
|
|
marker := []byte("VACUUM_PROBE_DEADBEEF_DELETED_ROW")
|
|
payload := bytes.Repeat(marker, 128) // ~4 KiB per row
|
|
|
|
_, err = db.Conn().ExecContext(ctx,
|
|
"CREATE TABLE vacuum_probe (id INTEGER PRIMARY KEY, payload BLOB)")
|
|
if err != nil {
|
|
t.Fatalf("failed to create probe table: %v", err)
|
|
}
|
|
|
|
for range 512 {
|
|
_, err = db.Conn().ExecContext(ctx,
|
|
"INSERT INTO vacuum_probe (payload) VALUES (?)", payload)
|
|
if err != nil {
|
|
t.Fatalf("failed to insert probe row: %v", err)
|
|
}
|
|
}
|
|
|
|
_, err = db.Conn().ExecContext(ctx, "DELETE FROM vacuum_probe")
|
|
if err != nil {
|
|
t.Fatalf("failed to delete probe rows: %v", err)
|
|
}
|
|
|
|
// Close so the deletes reach the main file, mirroring the state
|
|
// prepareExportDB hands to vacuumDatabase.
|
|
err = db.Close()
|
|
if err != nil {
|
|
t.Fatalf("failed to close database: %v", err)
|
|
}
|
|
|
|
beforeInfo, err := fs.Stat(dbPath)
|
|
if err != nil {
|
|
t.Fatalf("failed to stat database before vacuum: %v", err)
|
|
}
|
|
|
|
beforeBytes, err := afero.ReadFile(fs, dbPath)
|
|
if err != nil {
|
|
t.Fatalf("failed to read database before vacuum: %v", err)
|
|
}
|
|
|
|
if !bytes.Contains(beforeBytes, marker) {
|
|
t.Fatalf("expected deleted-row data to linger before vacuum")
|
|
}
|
|
|
|
sm := &SnapshotManager{fs: fs}
|
|
|
|
err = sm.vacuumDatabase(ctx, dbPath)
|
|
if err != nil {
|
|
t.Fatalf("vacuumDatabase failed: %v", err)
|
|
}
|
|
|
|
afterBytes, err := afero.ReadFile(fs, dbPath)
|
|
if err != nil {
|
|
t.Fatalf("failed to read database after vacuum: %v", err)
|
|
}
|
|
|
|
if bytes.Contains(afterBytes, marker) {
|
|
t.Fatalf("deleted-row data survived vacuum in the uploaded file")
|
|
}
|
|
|
|
afterInfo, err := fs.Stat(dbPath)
|
|
if err != nil {
|
|
t.Fatalf("failed to stat database after vacuum: %v", err)
|
|
}
|
|
|
|
if afterInfo.Size() >= beforeInfo.Size() {
|
|
t.Fatalf("expected vacuum to shrink the file: before=%d after=%d",
|
|
beforeInfo.Size(), afterInfo.Size())
|
|
}
|
|
}
|
|
|
|
func TestCleanSnapshotDBEmptySnapshot(t *testing.T) {
|
|
// Initialize logger
|
|
log.Initialize(log.Config{})
|
|
t.Parallel()
|
|
|
|
ctx := context.Background()
|
|
fs := afero.NewOsFs()
|
|
|
|
// Create a test database
|
|
tempDir := t.TempDir()
|
|
dbPath := filepath.Join(tempDir, "test.db")
|
|
|
|
db, err := database.New(ctx, dbPath)
|
|
if err != nil {
|
|
t.Fatalf("failed to create database: %v", err)
|
|
}
|
|
|
|
repos := database.NewRepositories(db)
|
|
|
|
// Create an empty snapshot
|
|
snapshot := &database.Snapshot{
|
|
ID: "empty-snapshot",
|
|
Hostname: "test-host",
|
|
}
|
|
|
|
err = repos.WithTx(ctx, func(ctx context.Context, tx *sql.Tx) error {
|
|
return repos.Snapshots.Create(ctx, tx, snapshot)
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("failed to create snapshot: %v", err)
|
|
}
|
|
|
|
// Create some files and chunks not associated with any snapshot
|
|
file := &database.File{Path: "/orphan/file.txt", Size: 1000}
|
|
chunk := &database.Chunk{ChunkHash: "orphan-chunk", Size: 500}
|
|
|
|
err = repos.WithTx(ctx, func(ctx context.Context, tx *sql.Tx) error {
|
|
err := repos.Files.Create(ctx, tx, file)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return repos.Chunks.Create(ctx, tx, chunk)
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("failed to create orphan data: %v", err)
|
|
}
|
|
|
|
// Close the database
|
|
err = db.Close()
|
|
if err != nil {
|
|
t.Fatalf("failed to close database: %v", err)
|
|
}
|
|
|
|
// Copy database
|
|
tempDBPath := filepath.Join(tempDir, "temp.db")
|
|
|
|
err = copyFile(fs, dbPath, tempDBPath)
|
|
if err != nil {
|
|
t.Fatalf("failed to copy database: %v", err)
|
|
}
|
|
|
|
// Create a mock config for testing
|
|
cfg := &config.Config{
|
|
CompressionLevel: 3,
|
|
AgeRecipients: []string{testAgeRecipient},
|
|
}
|
|
// Create SnapshotManager with filesystem
|
|
sm := &SnapshotManager{
|
|
config: cfg,
|
|
fs: fs,
|
|
}
|
|
|
|
_, err = sm.cleanSnapshotDB(ctx, tempDBPath, snapshot.ID.String())
|
|
if err != nil {
|
|
t.Fatalf("failed to clean snapshot database: %v", err)
|
|
}
|
|
|
|
// Verify the cleaned database
|
|
verifyCleanedDB(ctx, t, tempDBPath, snapshot.ID.String(), file, chunk)
|
|
}
|
|
|
|
func TestCleanSnapshotDBNonExistentSnapshot(t *testing.T) {
|
|
// Initialize logger
|
|
log.Initialize(log.Config{})
|
|
t.Parallel()
|
|
|
|
ctx := context.Background()
|
|
fs := afero.NewOsFs()
|
|
|
|
// Create a test database
|
|
tempDir := t.TempDir()
|
|
dbPath := filepath.Join(tempDir, "test.db")
|
|
|
|
db, err := database.New(ctx, dbPath)
|
|
if err != nil {
|
|
t.Fatalf("failed to create database: %v", err)
|
|
}
|
|
|
|
// Close immediately
|
|
err = db.Close()
|
|
if err != nil {
|
|
t.Fatalf("failed to close database: %v", err)
|
|
}
|
|
|
|
// Copy database
|
|
tempDBPath := filepath.Join(tempDir, "temp.db")
|
|
|
|
err = copyFile(fs, dbPath, tempDBPath)
|
|
if err != nil {
|
|
t.Fatalf("failed to copy database: %v", err)
|
|
}
|
|
|
|
// Create a mock config for testing
|
|
cfg := &config.Config{
|
|
CompressionLevel: 3,
|
|
AgeRecipients: []string{testAgeRecipient},
|
|
}
|
|
// Try to clean with non-existent snapshot
|
|
sm := &SnapshotManager{config: cfg, fs: fs}
|
|
_, err = sm.cleanSnapshotDB(ctx, tempDBPath, "non-existent-snapshot")
|
|
|
|
// Should not error - it will just delete everything
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
}
|