fix: streamline CreateBackup and RestoreBackup methods; improve snapshot handling and add skip logic for database files during restore

This commit is contained in:
GitHub Actions
2026-02-13 08:43:11 +00:00
parent 75b65d9163
commit 4d191e364a
2 changed files with 41 additions and 53 deletions

View File

@@ -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 {

View File

@@ -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))