fix: streamline CreateBackup and RestoreBackup methods; improve snapshot handling and add skip logic for database files during restore
This commit is contained in:
@@ -290,8 +290,8 @@ func (s *BackupService) CreateBackup() (string, error) {
|
||||
return "", err
|
||||
}
|
||||
defer func() {
|
||||
if err := outFile.Close(); err != nil {
|
||||
logger.Log().WithError(err).Warn("failed to close backup file")
|
||||
if closeErr := outFile.Close(); closeErr != nil {
|
||||
logger.Log().WithError(closeErr).Warn("failed to close backup file")
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -301,20 +301,12 @@ func (s *BackupService) CreateBackup() (string, error) {
|
||||
// 1. Database
|
||||
dbPath := filepath.Join(s.DataDir, s.DatabaseName)
|
||||
// Ensure DB exists before backing up
|
||||
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
|
||||
if _, statErr := os.Stat(dbPath); os.IsNotExist(statErr) {
|
||||
return "", fmt.Errorf("database file not found: %s", dbPath)
|
||||
}
|
||||
backupSourcePath := dbPath
|
||||
cleanupBackupSource := func() {}
|
||||
|
||||
if snapshotPath, cleanup, err := createSQLiteSnapshot(dbPath); err == nil {
|
||||
backupSourcePath = snapshotPath
|
||||
cleanupBackupSource = cleanup
|
||||
} else {
|
||||
logger.Log().WithError(err).Warn("failed to create sqlite snapshot before backup; falling back to direct file copy")
|
||||
if checkpointErr := checkpointSQLiteDatabase(dbPath); checkpointErr != nil {
|
||||
logger.Log().WithError(checkpointErr).Warn("failed to checkpoint sqlite wal before backup; proceeding with file snapshot")
|
||||
}
|
||||
backupSourcePath, cleanupBackupSource, err := createSQLiteSnapshot(dbPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create sqlite snapshot before backup: %w", err)
|
||||
}
|
||||
defer cleanupBackupSource()
|
||||
|
||||
@@ -322,22 +314,6 @@ func (s *BackupService) CreateBackup() (string, error) {
|
||||
return "", fmt.Errorf("backup db: %w", err)
|
||||
}
|
||||
|
||||
if backupSourcePath == dbPath {
|
||||
walPath := dbPath + "-wal"
|
||||
if _, err := os.Stat(walPath); err == nil {
|
||||
if err := s.addToZip(w, walPath, s.DatabaseName+"-wal"); err != nil {
|
||||
return "", fmt.Errorf("backup db wal: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
shmPath := dbPath + "-shm"
|
||||
if _, err := os.Stat(shmPath); err == nil {
|
||||
if err := s.addToZip(w, shmPath, s.DatabaseName+"-shm"); err != nil {
|
||||
return "", fmt.Errorf("backup db shm: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Caddy Data (Certificates, etc)
|
||||
// We walk the 'caddy' subdirectory
|
||||
caddyDir := filepath.Join(s.DataDir, "caddy")
|
||||
@@ -446,8 +422,14 @@ func (s *BackupService) RestoreBackup(filename string) error {
|
||||
s.restoreDBPath = restoreDBPath
|
||||
}
|
||||
|
||||
// 2. Unzip to DataDir (overwriting)
|
||||
return s.unzip(srcPath, s.DataDir)
|
||||
// 2. Unzip to DataDir while skipping database files.
|
||||
// Database data is applied through controlled live rehydrate to avoid corrupting the active SQLite file.
|
||||
skipEntries := map[string]struct{}{
|
||||
s.DatabaseName: {},
|
||||
s.DatabaseName + "-wal": {},
|
||||
s.DatabaseName + "-shm": {},
|
||||
}
|
||||
return s.unzipWithSkip(srcPath, s.DataDir, skipEntries)
|
||||
}
|
||||
|
||||
// RehydrateLiveDatabase reloads the currently-open SQLite database from the restored DB file
|
||||
@@ -705,7 +687,7 @@ func (s *BackupService) extractDatabaseFromBackup(zipPath string) (string, error
|
||||
return tmpPath, nil
|
||||
}
|
||||
|
||||
func (s *BackupService) unzip(src, dest string) error {
|
||||
func (s *BackupService) unzipWithSkip(src, dest string, skipEntries map[string]struct{}) error {
|
||||
r, err := zip.OpenReader(src)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -717,6 +699,12 @@ func (s *BackupService) unzip(src, dest string) error {
|
||||
}()
|
||||
|
||||
for _, f := range r.File {
|
||||
if skipEntries != nil {
|
||||
if _, skip := skipEntries[filepath.Clean(f.Name)]; skip {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Use SafeJoinPath to prevent directory traversal attacks
|
||||
fpath, err := SafeJoinPath(dest, f.Name)
|
||||
if err != nil {
|
||||
|
||||
@@ -11,8 +11,19 @@ import (
|
||||
"github.com/Wikid82/charon/backend/internal/config"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func createSQLiteTestDB(t *testing.T, dbPath string) {
|
||||
t.Helper()
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.Exec("CREATE TABLE IF NOT EXISTS healthcheck (id INTEGER PRIMARY KEY, value TEXT)").Error)
|
||||
require.NoError(t, db.Exec("INSERT INTO healthcheck (value) VALUES (?)", "ok").Error)
|
||||
}
|
||||
|
||||
func TestBackupService_CreateAndList(t *testing.T) {
|
||||
// Setup temp dirs
|
||||
tmpDir, err := os.MkdirTemp("", "cpm-backup-service-test")
|
||||
@@ -23,10 +34,9 @@ func TestBackupService_CreateAndList(t *testing.T) {
|
||||
err = os.MkdirAll(dataDir, 0o700)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create dummy DB
|
||||
// Create valid sqlite DB
|
||||
dbPath := filepath.Join(dataDir, "charon.db")
|
||||
err = os.WriteFile(dbPath, []byte("dummy db"), 0o600)
|
||||
require.NoError(t, err)
|
||||
createSQLiteTestDB(t, dbPath)
|
||||
|
||||
// Create dummy caddy dir
|
||||
caddyDir := filepath.Join(dataDir, "caddy")
|
||||
@@ -58,18 +68,13 @@ func TestBackupService_CreateAndList(t *testing.T) {
|
||||
assert.Equal(t, filepath.Join(service.BackupDir, filename), path)
|
||||
|
||||
// Test Restore
|
||||
// Modify DB to verify restore
|
||||
err = os.WriteFile(dbPath, []byte("modified db"), 0o600)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = service.RestoreBackup(filename)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify DB content restored
|
||||
// #nosec G304 -- Test reads from known database path in test directory
|
||||
content, err := os.ReadFile(dbPath)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "dummy db", string(content))
|
||||
// DB file is staged for live rehydrate (not directly overwritten during unzip)
|
||||
assert.NotEmpty(t, service.restoreDBPath)
|
||||
assert.FileExists(t, service.restoreDBPath)
|
||||
|
||||
// Test Delete
|
||||
err = service.DeleteBackup(filename)
|
||||
@@ -1173,7 +1178,7 @@ func TestBackupService_FullCycle(t *testing.T) {
|
||||
|
||||
// Create database and caddy config
|
||||
dbPath := filepath.Join(dataDir, "charon.db")
|
||||
_ = os.WriteFile(dbPath, []byte("original db"), 0o600) // #nosec G306 -- test fixture
|
||||
createSQLiteTestDB(t, dbPath)
|
||||
|
||||
caddyDir := filepath.Join(dataDir, "caddy")
|
||||
_ = os.MkdirAll(caddyDir, 0o750) // #nosec G301 -- test directory
|
||||
@@ -1188,20 +1193,15 @@ func TestBackupService_FullCycle(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
// Modify files
|
||||
_ = os.WriteFile(dbPath, []byte("modified db"), 0o600) // #nosec G306 -- test fixture
|
||||
_ = os.WriteFile(filepath.Join(caddyDir, "config.json"), []byte(`{"modified": true}`), 0o600) // #nosec G306 -- test fixture
|
||||
|
||||
// Verify modification
|
||||
content, _ := os.ReadFile(dbPath) // #nosec G304 -- test fixture path
|
||||
assert.Equal(t, "modified db", string(content))
|
||||
|
||||
// Restore backup
|
||||
err = service.RestoreBackup(filename)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify restoration
|
||||
content, _ = os.ReadFile(dbPath) // #nosec G304 -- test fixture path
|
||||
assert.Equal(t, "original db", string(content))
|
||||
// DB file is staged for live rehydrate (not directly overwritten during unzip)
|
||||
assert.NotEmpty(t, service.restoreDBPath)
|
||||
assert.FileExists(t, service.restoreDBPath)
|
||||
|
||||
caddyContent, _ := os.ReadFile(filepath.Join(caddyDir, "config.json")) // #nosec G304 -- test fixture path
|
||||
assert.Equal(t, `{"original": true}`, string(caddyContent))
|
||||
|
||||
Reference in New Issue
Block a user