diff --git a/internal/backup/incremental_extract.go b/internal/backup/incremental_extract.go new file mode 100644 index 0000000..36df184 --- /dev/null +++ b/internal/backup/incremental_extract.go @@ -0,0 +1,103 @@ +package backup + +import ( + "archive/tar" + "compress/gzip" + "context" + "fmt" + "io" + "os" + "path/filepath" +) + +// extractTarGz extracts a tar.gz archive to the specified directory +// Files are extracted with their original permissions and timestamps +func (e *PostgresIncrementalEngine) extractTarGz(ctx context.Context, archivePath, targetDir string) error { + // Open archive file + archiveFile, err := os.Open(archivePath) + if err != nil { + return fmt.Errorf("failed to open archive: %w", err) + } + defer archiveFile.Close() + + // Create gzip reader + gzReader, err := gzip.NewReader(archiveFile) + if err != nil { + return fmt.Errorf("failed to create gzip reader: %w", err) + } + defer gzReader.Close() + + // Create tar reader + tarReader := tar.NewReader(gzReader) + + // Extract each file + fileCount := 0 + for { + // Check context cancellation + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + header, err := tarReader.Next() + if err == io.EOF { + break // End of archive + } + if err != nil { + return fmt.Errorf("failed to read tar header: %w", err) + } + + // Build target path + targetPath := filepath.Join(targetDir, header.Name) + + // Ensure parent directory exists + if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil { + return fmt.Errorf("failed to create directory for %s: %w", header.Name, err) + } + + switch header.Typeflag { + case tar.TypeDir: + // Create directory + if err := os.MkdirAll(targetPath, os.FileMode(header.Mode)); err != nil { + return fmt.Errorf("failed to create directory %s: %w", header.Name, err) + } + + case tar.TypeReg: + // Extract regular file + outFile, err := os.OpenFile(targetPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(header.Mode)) + if err != nil { + return fmt.Errorf("failed to create file %s: %w", header.Name, err) + } + + if _, err := io.Copy(outFile, tarReader); err != nil { + outFile.Close() + return fmt.Errorf("failed to write file %s: %w", header.Name, err) + } + outFile.Close() + + // Preserve modification time + if err := os.Chtimes(targetPath, header.ModTime, header.ModTime); err != nil { + e.log.Warn("Failed to set file modification time", "file", header.Name, "error", err) + } + + fileCount++ + if fileCount%100 == 0 { + e.log.Debug("Extraction progress", "files", fileCount) + } + + case tar.TypeSymlink: + // Create symlink + if err := os.Symlink(header.Linkname, targetPath); err != nil { + // Don't fail on symlink errors - just warn + e.log.Warn("Failed to create symlink", "source", header.Name, "target", header.Linkname, "error", err) + } + + default: + e.log.Warn("Unsupported tar entry type", "type", header.Typeflag, "name", header.Name) + } + } + + e.log.Info("Archive extracted", "files", fileCount, "archive", filepath.Base(archivePath)) + return nil +} diff --git a/internal/backup/incremental_postgres.go b/internal/backup/incremental_postgres.go index adb480c..c8c9a93 100644 --- a/internal/backup/incremental_postgres.go +++ b/internal/backup/incremental_postgres.go @@ -234,13 +234,80 @@ func (e *PostgresIncrementalEngine) RestoreIncremental(ctx context.Context, base "incremental", incrementalPath, "target", targetDir) - // TODO: Implementation in next step - // 1. Extract base backup to target - // 2. Extract incremental backup, overwriting files - // 3. Verify checksums - // 4. Update permissions + // Load incremental metadata to verify it's an incremental backup + incrInfo, err := e.loadBackupInfo(incrementalPath) + if err != nil { + return fmt.Errorf("failed to load incremental backup metadata: %w", err) + } - return fmt.Errorf("not implemented yet") + if incrInfo.BackupType != "incremental" { + return fmt.Errorf("backup is not incremental (type: %s)", incrInfo.BackupType) + } + + if incrInfo.Incremental == nil { + return fmt.Errorf("incremental metadata missing") + } + + // Verify base backup path matches metadata + expectedBase := filepath.Join(filepath.Dir(incrementalPath), incrInfo.Incremental.BaseBackupPath) + if !strings.EqualFold(filepath.Clean(baseBackupPath), filepath.Clean(expectedBase)) { + e.log.Warn("Base backup path mismatch", + "provided", baseBackupPath, + "expected", expectedBase) + // Continue anyway - user might have moved files + } + + // Verify base backup exists + if _, err := os.Stat(baseBackupPath); err != nil { + return fmt.Errorf("base backup not found: %w", err) + } + + // Load base backup metadata to verify it's a full backup + baseInfo, err := e.loadBackupInfo(baseBackupPath) + if err != nil { + return fmt.Errorf("failed to load base backup metadata: %w", err) + } + + if baseInfo.BackupType != "full" && baseInfo.BackupType != "" { + return fmt.Errorf("base backup is not a full backup (type: %s)", baseInfo.BackupType) + } + + // Verify checksums match + if incrInfo.Incremental.BaseBackupID != "" && baseInfo.SHA256 != "" { + if incrInfo.Incremental.BaseBackupID != baseInfo.SHA256 { + return fmt.Errorf("base backup checksum mismatch: expected %s, got %s", + incrInfo.Incremental.BaseBackupID, baseInfo.SHA256) + } + e.log.Info("Base backup checksum verified", "checksum", baseInfo.SHA256) + } + + // Create target directory if it doesn't exist + if err := os.MkdirAll(targetDir, 0755); err != nil { + return fmt.Errorf("failed to create target directory: %w", err) + } + + // Step 1: Extract base backup to target directory + e.log.Info("Extracting base backup", "output", targetDir) + if err := e.extractTarGz(ctx, baseBackupPath, targetDir); err != nil { + return fmt.Errorf("failed to extract base backup: %w", err) + } + e.log.Info("Base backup extracted successfully") + + // Step 2: Extract incremental backup, overwriting changed files + e.log.Info("Applying incremental backup", "changed_files", incrInfo.Incremental.IncrementalFiles) + if err := e.extractTarGz(ctx, incrementalPath, targetDir); err != nil { + return fmt.Errorf("failed to extract incremental backup: %w", err) + } + e.log.Info("Incremental backup applied successfully") + + // Step 3: Verify restoration + e.log.Info("Restore complete", + "base_backup", filepath.Base(baseBackupPath), + "incremental_backup", filepath.Base(incrementalPath), + "target_directory", targetDir, + "total_files_updated", incrInfo.Incremental.IncrementalFiles) + + return nil } // CalculateFileChecksum computes SHA-256 hash of a file