package backup import ( "compress/gzip" "context" "fmt" "io" "os" "path/filepath" "time" "sanity/internal/config" ) type Executor struct{} type Result struct { Timestamp time.Time SourceFile string ArtifactPath string SizeBytes int64 TotalBackupDirSizeBytes int64 Duration time.Duration } func NewExecutor() *Executor { return &Executor{} } func (e *Executor) Run(ctx context.Context, cfg config.Config) (Result, error) { start := time.Now().UTC() if err := ctx.Err(); err != nil { return Result{}, err } sourceInfo, err := os.Stat(cfg.Backup.SourceFile) if err != nil { return Result{}, fmt.Errorf("stat source file: %w", err) } if sourceInfo.IsDir() { return Result{}, fmt.Errorf("source_file must be a file, got directory") } if err := os.MkdirAll(cfg.Backup.OutputDir, 0o755); err != nil { return Result{}, fmt.Errorf("create output_dir: %w", err) } artifactName := fmt.Sprintf("%s-%s.gz", cfg.Backup.FilePrefix, start.Format("20060102-150405")) artifactPath := filepath.Join(cfg.Backup.OutputDir, artifactName) tempPath := artifactPath + ".tmp" result, err := compressFile(ctx, cfg.Backup.SourceFile, tempPath) if err != nil { _ = os.Remove(tempPath) return Result{}, err } if err := os.Rename(tempPath, artifactPath); err != nil { _ = os.Remove(tempPath) return Result{}, fmt.Errorf("finalize artifact: %w", err) } result.Timestamp = start result.SourceFile = cfg.Backup.SourceFile result.ArtifactPath = artifactPath dirSize, dirSizeErr := DirectorySize(cfg.Backup.OutputDir) if dirSizeErr != nil { result.TotalBackupDirSizeBytes = -1 } else { result.TotalBackupDirSizeBytes = dirSize } result.Duration = time.Since(start) return result, nil } func DirectorySize(root string) (int64, error) { var total int64 err := filepath.WalkDir(root, func(path string, entry os.DirEntry, walkErr error) error { if walkErr != nil { return walkErr } if entry.IsDir() { return nil } info, err := entry.Info() if err != nil { return err } total += info.Size() return nil }) if err != nil { return 0, err } return total, nil } func compressFile(ctx context.Context, sourcePath string, tempPath string) (Result, error) { source, err := os.Open(sourcePath) if err != nil { return Result{}, fmt.Errorf("open source_file: %w", err) } defer source.Close() target, err := os.OpenFile(tempPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644) if err != nil { return Result{}, fmt.Errorf("create temp artifact: %w", err) } gz, err := gzip.NewWriterLevel(target, gzip.BestCompression) if err != nil { target.Close() return Result{}, fmt.Errorf("create gzip writer: %w", err) } copyBuffer := make([]byte, 1024*1024) written, copyErr := copyWithContext(ctx, gz, source, copyBuffer) closeErr := gz.Close() syncErr := target.Sync() fileCloseErr := target.Close() if copyErr != nil { return Result{}, copyErr } if closeErr != nil { return Result{}, fmt.Errorf("close gzip writer: %w", closeErr) } if syncErr != nil { return Result{}, fmt.Errorf("sync temp artifact: %w", syncErr) } if fileCloseErr != nil { return Result{}, fmt.Errorf("close temp artifact: %w", fileCloseErr) } return Result{SizeBytes: written}, nil } func copyWithContext(ctx context.Context, dst io.Writer, src io.Reader, buffer []byte) (int64, error) { var total int64 for { if err := ctx.Err(); err != nil { return total, err } n, readErr := src.Read(buffer) if n > 0 { written, writeErr := dst.Write(buffer[:n]) total += int64(written) if writeErr != nil { return total, fmt.Errorf("write compressed data: %w", writeErr) } if written != n { return total, fmt.Errorf("short write while compressing") } } if readErr == io.EOF { return total, nil } if readErr != nil { return total, fmt.Errorf("read source data: %w", readErr) } } }