package backup import ( "archive/tar" "compress/gzip" "context" "fmt" "io" "log/slog" "os" "path/filepath" "time" "git.misaka.ren/M1saka/docker_backup/internal/config" ) // Result holds the outcome of backing up a single project. type Result struct { ProjectName string Error error BackupPath string FileSize int64 Downtime time.Duration Duration time.Duration } // Engine orchestrates backup of Docker Compose projects. type Engine struct { BackupDir string TempDir string DryRun bool Logger *slog.Logger } // NewEngine creates a new backup engine. func NewEngine(backupDir, tempDir string, dryRun bool, logger *slog.Logger) *Engine { if logger == nil { logger = slog.Default() } return &Engine{ BackupDir: backupDir, TempDir: tempDir, DryRun: dryRun, Logger: logger, } } // Run backs up all configured projects. Results are returned in order. func (e *Engine) Run(ctx context.Context, projects []config.ProjectConfig) []Result { results := make([]Result, 0, len(projects)) for _, proj := range projects { results = append(results, e.backupOne(ctx, proj)) } return results } func (e *Engine) backupOne(ctx context.Context, proj config.ProjectConfig) Result { start := time.Now() result := Result{ProjectName: proj.Name} if err := ctx.Err(); err != nil { result.Error = err result.Duration = time.Since(start) return result } e.Logger.Info("starting backup", "project", proj.Name, "path", proj.Path) composeFilePath := filepath.Join(proj.Path, proj.ComposeFile) if _, err := os.Stat(composeFilePath); err != nil && !e.DryRun { result.Error = fmt.Errorf("compose file not found: %s: %w", composeFilePath, err) result.Duration = time.Since(start) return result } backupPath := e.backupPath(proj.Name) if e.DryRun { result.BackupPath = backupPath result.Duration = time.Since(start) e.Logger.Info("dry-run: backup plan complete", "project", proj.Name, "file", backupPath) return result } executor := NewComposeExecutor(proj.Path, proj.ComposeFile) // 1. Stop the compose project. If the stop is interrupted midway // (e.g. the context is cancelled), containers may be left stopped, so // always attempt a restart before returning. stopStart := time.Now() if err := executor.Stop(ctx); err != nil { _ = restartCompose(executor) result.Error = fmt.Errorf("stop: %w", err) result.Duration = time.Since(start) return result } e.Logger.Info("compose stopped", "project", proj.Name) // 2. Create a staging directory. stagingDir, err := os.MkdirTemp(e.TempDir, "dc-backup-"+proj.Name+"-*") if err != nil { _ = restartCompose(executor) result.Error = fmt.Errorf("create staging dir: %w", err) result.Duration = time.Since(start) return result } // 3. Copy project directory to staging. copier := &DirectoryCopier{Exclude: proj.Exclude, Context: ctx} if err := copier.Copy(proj.Path, stagingDir); err != nil { _ = os.RemoveAll(stagingDir) _ = restartCompose(executor) result.Error = fmt.Errorf("copy: %w", err) result.Duration = time.Since(start) return result } e.Logger.Info("files copied to staging", "project", proj.Name, "staging", stagingDir) // 4. Immediately restart the compose project. if err := restartCompose(executor); err != nil { _ = os.RemoveAll(stagingDir) result.Error = fmt.Errorf("start: %w", err) result.Duration = time.Since(start) return result } result.Downtime = time.Since(stopStart) e.Logger.Info("compose restarted", "project", proj.Name, "downtime", result.Downtime) // 5. Compress staging directory to the backup destination. backupDir := filepath.Dir(backupPath) if err := os.MkdirAll(backupDir, 0755); err != nil { _ = os.RemoveAll(stagingDir) result.Error = fmt.Errorf("create backup dir: %w", err) result.Duration = time.Since(start) return result } if err := createTarGz(ctx, stagingDir, backupPath); err != nil { _ = os.RemoveAll(stagingDir) result.Error = fmt.Errorf("compress: %w", err) result.Duration = time.Since(start) return result } e.Logger.Info("backup compressed", "project", proj.Name, "file", backupPath) // 6. Clean up staging. _ = os.RemoveAll(stagingDir) // 7. Record file size. if fi, err := os.Stat(backupPath); err == nil { result.FileSize = fi.Size() } result.BackupPath = backupPath result.Duration = time.Since(start) e.Logger.Info("backup complete", "project", proj.Name, "duration", result.Duration, "size", result.FileSize) return result } func (e *Engine) backupPath(projectName string) string { timestamp := time.Now().Format("20060102-150405.000000000") backupFile := fmt.Sprintf("%s-%s.tar.gz", projectName, timestamp) return filepath.Join(e.BackupDir, projectName, backupFile) } func restartCompose(executor *ComposeExecutor) error { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) defer cancel() return executor.Start(ctx) } // createTarGz creates a tar.gz archive from a source directory. func createTarGz(ctx context.Context, srcDir, dstPath string) error { f, err := os.Create(dstPath) if err != nil { return err } gw := gzip.NewWriter(f) tw := tar.NewWriter(gw) walkErr := filepath.WalkDir(srcDir, func(path string, d os.DirEntry, err error) error { if err != nil { return err } select { case <-ctx.Done(): return ctx.Err() default: } rel, err := filepath.Rel(srcDir, path) if err != nil { return fmt.Errorf("rel: %w", err) } if rel == "." { return nil } info, err := d.Info() if err != nil { return err } linkTarget := "" if info.Mode()&os.ModeSymlink != 0 { linkTarget, err = os.Readlink(path) if err != nil { return err } } header, err := tar.FileInfoHeader(info, linkTarget) if err != nil { return err } header.Name = filepath.ToSlash(rel) if err := tw.WriteHeader(header); err != nil { return err } if d.IsDir() || !info.Mode().IsRegular() { return nil } return copyFileToTar(tw, path) }) closeErr := closeTarGz(tw, gw, f) if walkErr != nil { _ = os.Remove(dstPath) return walkErr } if closeErr != nil { _ = os.Remove(dstPath) return closeErr } return nil } func closeTarGz(tw *tar.Writer, gw *gzip.Writer, f *os.File) error { if err := tw.Close(); err != nil { _ = gw.Close() _ = f.Close() return fmt.Errorf("close tar: %w", err) } if err := gw.Close(); err != nil { _ = f.Close() return fmt.Errorf("close gzip: %w", err) } if err := f.Close(); err != nil { return fmt.Errorf("close file: %w", err) } return nil } // copyFileToTar opens a file and copies it into the tar writer. // The file is closed before returning — no deferred leak in a loop. func copyFileToTar(tw *tar.Writer, path string) error { srcFile, err := os.Open(path) if err != nil { return err } defer srcFile.Close() _, err = io.Copy(tw, srcFile) return err }