Add archive package: tar.zst service directory snapshots
- Snapshot: VACUUM INTO for consistent db copy, excludes live db files and backups/, injects db snapshot, custom exclude patterns, streaming output via io.Writer - Restore: extract tar.zst with path traversal protection - zstd via github.com/klauspost/compress/zstd - 5 tests: full roundtrip with db integrity verification, without db, exclude patterns, dest dir creation - Update PROGRESS.md: all 9 packages complete, 87 total tests Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
252
archive/archive_test.go
Normal file
252
archive/archive_test.go
Normal file
@@ -0,0 +1,252 @@
|
||||
package archive
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.wntrmute.dev/kyle/mcdsl/db"
|
||||
)
|
||||
|
||||
// setupServiceDir creates a realistic /srv/<service>/ directory.
|
||||
func setupServiceDir(t *testing.T, database *sql.DB) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
|
||||
// Config file.
|
||||
writeFile(t, filepath.Join(dir, "service.toml"), "listen_addr = \":8443\"\n")
|
||||
|
||||
// Certs directory.
|
||||
certsDir := filepath.Join(dir, "certs")
|
||||
if err := os.Mkdir(certsDir, 0700); err != nil {
|
||||
t.Fatalf("mkdir certs: %v", err)
|
||||
}
|
||||
writeFile(t, filepath.Join(certsDir, "cert.pem"), "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----\n")
|
||||
writeFile(t, filepath.Join(certsDir, "key.pem"), "-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----\n")
|
||||
|
||||
// Live database file (should be excluded).
|
||||
writeFile(t, filepath.Join(dir, "service.db"), "live-db-data")
|
||||
writeFile(t, filepath.Join(dir, "service.db-wal"), "wal-data")
|
||||
writeFile(t, filepath.Join(dir, "service.db-shm"), "shm-data")
|
||||
|
||||
// Backups directory (should be excluded).
|
||||
backupsDir := filepath.Join(dir, "backups")
|
||||
if err := os.Mkdir(backupsDir, 0700); err != nil {
|
||||
t.Fatalf("mkdir backups: %v", err)
|
||||
}
|
||||
writeFile(t, filepath.Join(backupsDir, "old-backup.db"), "backup-data")
|
||||
|
||||
return dir
|
||||
}
|
||||
|
||||
func writeFile(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(path, []byte(content), 0600); err != nil {
|
||||
t.Fatalf("write %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
func openTestDB(t *testing.T) (*sql.DB, string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "real.db")
|
||||
database, err := db.Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("db.Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
|
||||
// Create some data to verify snapshot integrity.
|
||||
if _, err := database.Exec("CREATE TABLE test (id INTEGER PRIMARY KEY, val TEXT)"); err != nil {
|
||||
t.Fatalf("create table: %v", err)
|
||||
}
|
||||
if _, err := database.Exec("INSERT INTO test (val) VALUES ('snapshot-data')"); err != nil {
|
||||
t.Fatalf("insert: %v", err)
|
||||
}
|
||||
|
||||
return database, path
|
||||
}
|
||||
|
||||
func TestSnapshotAndRestore(t *testing.T) {
|
||||
database, dbPath := openTestDB(t)
|
||||
serviceDir := setupServiceDir(t, database)
|
||||
|
||||
// Snapshot.
|
||||
var buf bytes.Buffer
|
||||
err := Snapshot(SnapshotOptions{
|
||||
ServiceDir: serviceDir,
|
||||
DBPath: dbPath,
|
||||
DB: database,
|
||||
}, &buf)
|
||||
if err != nil {
|
||||
t.Fatalf("Snapshot: %v", err)
|
||||
}
|
||||
|
||||
if buf.Len() == 0 {
|
||||
t.Fatal("archive is empty")
|
||||
}
|
||||
|
||||
// Restore to a new directory.
|
||||
restoreDir := t.TempDir()
|
||||
if err := Restore(&buf, restoreDir); err != nil {
|
||||
t.Fatalf("Restore: %v", err)
|
||||
}
|
||||
|
||||
// Verify config file was restored.
|
||||
content, err := os.ReadFile(filepath.Join(restoreDir, "service.toml")) //nolint:gosec // test code
|
||||
if err != nil {
|
||||
t.Fatalf("read config: %v", err)
|
||||
}
|
||||
if string(content) != "listen_addr = \":8443\"\n" {
|
||||
t.Fatalf("config content = %q", string(content))
|
||||
}
|
||||
|
||||
// Verify certs were restored.
|
||||
if _, err := os.Stat(filepath.Join(restoreDir, "certs", "cert.pem")); err != nil {
|
||||
t.Fatalf("cert.pem missing: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(restoreDir, "certs", "key.pem")); err != nil {
|
||||
t.Fatalf("key.pem missing: %v", err)
|
||||
}
|
||||
|
||||
// Verify live DB files were excluded.
|
||||
if _, err := os.Stat(filepath.Join(restoreDir, "service.db-wal")); !os.IsNotExist(err) {
|
||||
t.Fatal("service.db-wal should not be in archive")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(restoreDir, "service.db-shm")); !os.IsNotExist(err) {
|
||||
t.Fatal("service.db-shm should not be in archive")
|
||||
}
|
||||
|
||||
// Verify backups were excluded.
|
||||
if _, err := os.Stat(filepath.Join(restoreDir, "backups")); !os.IsNotExist(err) {
|
||||
t.Fatal("backups/ should not be in archive")
|
||||
}
|
||||
|
||||
// Verify the VACUUM INTO snapshot was injected as the DB.
|
||||
dbFile := filepath.Join(restoreDir, filepath.Base(dbPath))
|
||||
restoredDB, err := sql.Open("sqlite", dbFile)
|
||||
if err != nil {
|
||||
t.Fatalf("open restored db: %v", err)
|
||||
}
|
||||
defer func() { _ = restoredDB.Close() }()
|
||||
|
||||
var val string
|
||||
if err := restoredDB.QueryRow("SELECT val FROM test").Scan(&val); err != nil {
|
||||
t.Fatalf("query restored db: %v", err)
|
||||
}
|
||||
if val != "snapshot-data" {
|
||||
t.Fatalf("val = %q, want %q", val, "snapshot-data")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnapshotWithoutDB(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeFile(t, filepath.Join(dir, "config.toml"), "test = true\n")
|
||||
|
||||
var buf bytes.Buffer
|
||||
err := Snapshot(SnapshotOptions{
|
||||
ServiceDir: dir,
|
||||
}, &buf)
|
||||
if err != nil {
|
||||
t.Fatalf("Snapshot: %v", err)
|
||||
}
|
||||
|
||||
// Restore and verify.
|
||||
restoreDir := t.TempDir()
|
||||
if err := Restore(&buf, restoreDir); err != nil {
|
||||
t.Fatalf("Restore: %v", err)
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(filepath.Join(restoreDir, "config.toml")) //nolint:gosec // test code
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
if string(content) != "test = true\n" {
|
||||
t.Fatalf("content = %q", string(content))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnapshotExcludesLiveDB(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeFile(t, filepath.Join(dir, "service.db"), "live")
|
||||
writeFile(t, filepath.Join(dir, "service.db-wal"), "wal")
|
||||
writeFile(t, filepath.Join(dir, "other.txt"), "keep")
|
||||
|
||||
var buf bytes.Buffer
|
||||
err := Snapshot(SnapshotOptions{
|
||||
ServiceDir: dir,
|
||||
}, &buf)
|
||||
if err != nil {
|
||||
t.Fatalf("Snapshot: %v", err)
|
||||
}
|
||||
|
||||
restoreDir := t.TempDir()
|
||||
if err := Restore(&buf, restoreDir); err != nil {
|
||||
t.Fatalf("Restore: %v", err)
|
||||
}
|
||||
|
||||
// other.txt should be present.
|
||||
if _, err := os.Stat(filepath.Join(restoreDir, "other.txt")); err != nil {
|
||||
t.Fatalf("other.txt missing: %v", err)
|
||||
}
|
||||
|
||||
// DB files should not.
|
||||
if _, err := os.Stat(filepath.Join(restoreDir, "service.db")); !os.IsNotExist(err) {
|
||||
t.Fatal("service.db should be excluded")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(restoreDir, "service.db-wal")); !os.IsNotExist(err) {
|
||||
t.Fatal("service.db-wal should be excluded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnapshotCustomExcludes(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeFile(t, filepath.Join(dir, "keep.txt"), "keep")
|
||||
writeFile(t, filepath.Join(dir, "skip.log"), "skip")
|
||||
|
||||
var buf bytes.Buffer
|
||||
err := Snapshot(SnapshotOptions{
|
||||
ServiceDir: dir,
|
||||
ExcludePatterns: []string{"*.log"},
|
||||
}, &buf)
|
||||
if err != nil {
|
||||
t.Fatalf("Snapshot: %v", err)
|
||||
}
|
||||
|
||||
restoreDir := t.TempDir()
|
||||
if err := Restore(&buf, restoreDir); err != nil {
|
||||
t.Fatalf("Restore: %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(filepath.Join(restoreDir, "keep.txt")); err != nil {
|
||||
t.Fatal("keep.txt should be present")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(restoreDir, "skip.log")); !os.IsNotExist(err) {
|
||||
t.Fatal("skip.log should be excluded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreCreatesDestDir(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeFile(t, filepath.Join(dir, "file.txt"), "data")
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := Snapshot(SnapshotOptions{ServiceDir: dir}, &buf); err != nil {
|
||||
t.Fatalf("Snapshot: %v", err)
|
||||
}
|
||||
|
||||
destDir := filepath.Join(t.TempDir(), "new", "nested", "dir")
|
||||
if err := Restore(&buf, destDir); err != nil {
|
||||
t.Fatalf("Restore: %v", err)
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(filepath.Join(destDir, "file.txt")) //nolint:gosec // test code
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
if string(content) != "data" {
|
||||
t.Fatalf("content = %q", string(content))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user