satoru/cmd/satoru/backup_job.go

688 lines
23 KiB
Go

package main
import (
"bufio"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
"go.uber.org/zap"
"satoru/internal/store"
)
const (
defaultStagingRoot = "./backups"
defaultResticRepo = "./repos/restic"
)
func (a *app) runBackupJob(ctx context.Context, job store.Job, site store.Site) (string, string) {
a.log.Debug("backup job begin", zap.Int64("job_id", job.ID), zap.Int64("site_id", site.ID), zap.String("site_uuid", site.SiteUUID), zap.Int("targets", len(site.Targets)))
_ = a.store.AddJobEvent(ctx, store.JobEvent{JobID: job.ID, Level: "info", Message: "Backup job started"})
preflightStatus, preflightSummary := a.runPreflightJob(ctx, job, site)
if preflightStatus == "failed" {
msg := "backup aborted by preflight: " + preflightSummary
_ = a.store.AddJobEvent(ctx, store.JobEvent{JobID: job.ID, Level: "error", Message: msg})
return "failed", msg
}
if preflightStatus == "warning" {
_ = a.store.AddJobEvent(ctx, store.JobEvent{JobID: job.ID, Level: "warn", Message: "preflight warning; continuing backup"})
}
stagingRoot := getenvDefault("SATORU_STAGING_ROOT", defaultStagingRoot)
resticRepo := getenvDefault("SATORU_RESTIC_REPO", defaultResticRepo)
if err := os.MkdirAll(stagingRoot, 0o700); err != nil {
return "failed", "failed to create staging root"
}
if err := os.MkdirAll(resticRepo, 0o700); err != nil {
return "failed", "failed to create restic repo directory"
}
successes := 0
failures := 0
stagedPaths := map[string]struct{}{}
for _, target := range site.Targets {
stageDir := targetStageDir(stagingRoot, site.SiteUUID, target)
if err := os.MkdirAll(stageDir, 0o700); err != nil {
failures++
_ = a.store.AddJobEvent(ctx, store.JobEvent{JobID: job.ID, Level: "error", Message: fmt.Sprintf("stage dir failed for %s: %v", target.Path, err)})
continue
}
var err error
switch target.Mode {
case "directory":
err = a.pullDirectoryTarget(ctx, job.ID, site, target, stageDir)
case "sqlite_dump":
err = pullSQLiteTarget(ctx, job.ID, site, target, stageDir)
case "mysql_dump":
err = pullMySQLTarget(ctx, site, target, stageDir)
case "podman_save":
err = pullPodmanSaveTarget(ctx, site, target, stageDir)
case "podman_export":
err = pullPodmanExportTarget(ctx, site, target, stageDir)
default:
err = fmt.Errorf("unknown target mode: %s", target.Mode)
}
if err != nil {
failures++
a.log.Debug("backup target failed", zap.Int64("job_id", job.ID), zap.Int64("site_id", site.ID), zap.String("target_path", target.Path), zap.String("target_mode", target.Mode), zap.Error(err))
_ = a.store.AddJobEvent(ctx, store.JobEvent{JobID: job.ID, Level: "warn", Message: fmt.Sprintf("target failed: %s (%s): %v", target.Path, target.Mode, err)})
continue
}
successes++
stagedPaths[stageDir] = struct{}{}
a.log.Debug("backup target success", zap.Int64("job_id", job.ID), zap.Int64("site_id", site.ID), zap.String("target_path", target.Path), zap.String("target_mode", target.Mode), zap.String("stage_dir", stageDir))
_ = a.store.AddJobEvent(ctx, store.JobEvent{JobID: job.ID, Level: "info", Message: fmt.Sprintf("target synced: %s (%s)", target.Path, target.Mode)})
}
if successes == 0 {
return "failed", fmt.Sprintf("backup failed: %d/%d targets failed", failures, len(site.Targets))
}
paths := make([]string, 0, len(stagedPaths))
for p := range stagedPaths {
paths = append(paths, p)
}
if err := runResticBackup(ctx, resticRepo, site, job.ID, paths); err != nil {
_ = a.store.AddJobEvent(ctx, store.JobEvent{JobID: job.ID, Level: "error", Message: "restic backup failed: " + err.Error()})
return "failed", "backup failed: restic backup error"
}
_ = a.store.AddJobEvent(ctx, store.JobEvent{JobID: job.ID, Level: "info", Message: "restic backup completed"})
if failures > 0 || preflightStatus == "warning" {
return "warning", fmt.Sprintf("backup warning: %d/%d targets synced", successes, len(site.Targets))
}
return "success", fmt.Sprintf("backup complete: %d/%d targets synced", successes, len(site.Targets))
}
func (a *app) pullDirectoryTarget(ctx context.Context, jobID int64, site store.Site, target store.SiteTarget, stageDir string) error {
sshCmd := fmt.Sprintf("ssh -p %d", site.Port)
remote, err := syncRemotePath(ctx, site, target.Path)
if err != nil {
return err
}
cmdCtx, cancel := context.WithTimeout(ctx, 10*time.Minute)
defer cancel()
args := []string{"-a", "--delete", "--info=progress2", "-e", sshCmd}
for _, filter := range site.Filters {
args = append(args, "--exclude", filter)
}
args = append(args, remote, stageDir+"/")
cmd := exec.CommandContext(cmdCtx, "rsync", args...)
stderr, err := cmd.StderrPipe()
if err != nil {
return err
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return err
}
if err := cmd.Start(); err != nil {
return err
}
go func() {
_, _ = io.Copy(io.Discard, stdout)
}()
scanner := bufio.NewScanner(stderr)
scanner.Split(scanCRLF)
lastEmit := time.Time{}
tail := make([]string, 0, 20)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
tail = append(tail, line)
if len(tail) > 20 {
tail = tail[len(tail)-20:]
}
progress := parseRsyncProgress(line)
if progress == "" {
continue
}
if time.Since(lastEmit) < 2*time.Second {
continue
}
lastEmit = time.Now()
_ = a.store.AddJobEvent(ctx, store.JobEvent{
JobID: jobID,
Level: "info",
Message: fmt.Sprintf("rsync %s: %s", target.Path, progress),
})
}
if err := scanner.Err(); err != nil {
return err
}
if err := cmd.Wait(); err != nil {
msg := strings.TrimSpace(strings.Join(tail, "\n"))
if msg == "" {
msg = err.Error()
}
return errors.New(msg)
}
return nil
}
func syncRemotePath(ctx context.Context, site store.Site, path string) (string, error) {
pathType, err := remotePathType(ctx, site, path)
if err != nil {
return "", err
}
switch pathType {
case "dir":
return fmt.Sprintf("%s@%s:%s/", site.SSHUser, site.Host, path), nil
case "file":
return fmt.Sprintf("%s@%s:%s", site.SSHUser, site.Host, path), nil
default:
return "", errors.New("target path is neither a directory nor a file")
}
}
func remotePathType(ctx context.Context, site store.Site, path string) (string, error) {
target := fmt.Sprintf("%s@%s", site.SSHUser, site.Host)
cmdCtx, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel()
quoted := shellQuote(path)
remoteCmd := fmt.Sprintf("if [ -d %s ]; then echo dir; elif [ -f %s ]; then echo file; else echo missing; fi", quoted, quoted)
cmd := exec.CommandContext(cmdCtx, "ssh", "-p", strconv.Itoa(site.Port), target, remoteCmd)
out, err := cmd.CombinedOutput()
if err != nil {
msg := strings.TrimSpace(string(out))
if msg == "" {
msg = err.Error()
}
return "", errors.New(msg)
}
v := strings.TrimSpace(string(out))
if v == "" || v == "missing" {
return "", errors.New("target path not found")
}
return v, nil
}
func pullSQLiteTarget(ctx context.Context, jobID int64, site store.Site, target store.SiteTarget, stageDir string) error {
tmpBase := fmt.Sprintf("/tmp/satoru-backup-%d-%s.sqlite3", jobID, shortHash(target.Path))
quotedTmp := shellQuote(tmpBase)
remoteCmd := strings.Join([]string{
sqliteBackupCommand(target.Path, quotedTmp),
fmt.Sprintf("gzip -f -- %s", quotedTmp),
}, " && ")
if err := sshCheck(ctx, site, remoteCmd); err != nil {
_ = sshCheck(ctx, site, fmt.Sprintf("rm -f -- %s %s", shellQuote(tmpBase), shellQuote(tmpBase+".gz")))
return err
}
sshCmd := fmt.Sprintf("ssh -p %d", site.Port)
remoteGz := fmt.Sprintf("%s@%s:%s", site.SSHUser, site.Host, tmpBase+".gz")
localFile := filepath.Join(stageDir, "sqlite-backup.sql.gz")
cmdCtx, cancel := context.WithTimeout(ctx, 10*time.Minute)
defer cancel()
cmd := exec.CommandContext(cmdCtx, "rsync", "-a", "-e", sshCmd, remoteGz, localFile)
out, err := cmd.CombinedOutput()
_ = sshCheck(ctx, site, fmt.Sprintf("rm -f -- %s %s", shellQuote(tmpBase), shellQuote(tmpBase+".gz")))
if err != nil {
return errors.New(strings.TrimSpace(string(out)))
}
return nil
}
func pullMySQLTarget(ctx context.Context, site store.Site, target store.SiteTarget, stageDir string) error {
if !target.MySQLHost.Valid || !target.MySQLUser.Valid || !target.MySQLDB.Valid || !target.MySQLPassword.Valid {
return errors.New("mysql target missing db host/db user/db name/db password")
}
tmpBase := fmt.Sprintf("/tmp/satoru-mysql-%s.sql", shortHash(target.Path+target.MySQLHost.String+target.MySQLDB.String))
remoteCmd := strings.Join([]string{
mysqlDumpCommand(target, false, tmpBase),
fmt.Sprintf("gzip -f -- %s", shellQuote(tmpBase)),
}, " && ")
if err := sshCheck(ctx, site, remoteCmd); err != nil {
_ = sshCheck(ctx, site, fmt.Sprintf("rm -f -- %s %s", shellQuote(tmpBase), shellQuote(tmpBase+".gz")))
return err
}
sshCmd := fmt.Sprintf("ssh -p %d", site.Port)
remoteGz := fmt.Sprintf("%s@%s:%s", site.SSHUser, site.Host, tmpBase+".gz")
localFile := filepath.Join(stageDir, "mysql-dump.sql.gz")
cmdCtx, cancel := context.WithTimeout(ctx, 10*time.Minute)
defer cancel()
cmd := exec.CommandContext(cmdCtx, "rsync", "-a", "-e", sshCmd, remoteGz, localFile)
out, err := cmd.CombinedOutput()
_ = sshCheck(ctx, site, fmt.Sprintf("rm -f -- %s %s", shellQuote(tmpBase), shellQuote(tmpBase+".gz")))
if err != nil {
return errors.New(strings.TrimSpace(string(out)))
}
return nil
}
func pullPodmanSaveTarget(ctx context.Context, site store.Site, target store.SiteTarget, stageDir string) error {
imageName := strings.TrimSpace(target.Path)
if imageName == "" {
return errors.New("podman target missing image name")
}
tmpTar := fmt.Sprintf("/tmp/satoru-podman-%s.tar", shortHash(targetIdentity(target)))
remoteCmd := fmt.Sprintf("podman save -o %s %s", shellQuote(tmpTar), shellQuote(imageName))
if err := sshCheck(ctx, site, remoteCmd); err != nil {
_ = sshCheck(ctx, site, fmt.Sprintf("rm -f -- %s", shellQuote(tmpTar)))
return err
}
sshCmd := fmt.Sprintf("ssh -p %d", site.Port)
remoteTar := fmt.Sprintf("%s@%s:%s", site.SSHUser, site.Host, tmpTar)
localFile := filepath.Join(stageDir, "podman-image.tar")
cmdCtx, cancel := context.WithTimeout(ctx, 20*time.Minute)
defer cancel()
cmd := exec.CommandContext(cmdCtx, "rsync", "-a", "-e", sshCmd, remoteTar, localFile)
out, err := cmd.CombinedOutput()
_ = sshCheck(ctx, site, fmt.Sprintf("rm -f -- %s", shellQuote(tmpTar)))
if err != nil {
return errors.New(strings.TrimSpace(string(out)))
}
return nil
}
func pullPodmanExportTarget(ctx context.Context, site store.Site, target store.SiteTarget, stageDir string) error {
containerName := strings.TrimSpace(target.Path)
if containerName == "" {
return errors.New("podman export target missing container name")
}
tmpTar := fmt.Sprintf("/tmp/satoru-podman-export-%s.tar", shortHash(targetIdentity(target)))
remoteCmd := fmt.Sprintf("podman export -o %s %s", shellQuote(tmpTar), shellQuote(containerName))
if err := sshCheck(ctx, site, remoteCmd); err != nil {
_ = sshCheck(ctx, site, fmt.Sprintf("rm -f -- %s", shellQuote(tmpTar)))
return err
}
sshCmd := fmt.Sprintf("ssh -p %d", site.Port)
remoteTar := fmt.Sprintf("%s@%s:%s", site.SSHUser, site.Host, tmpTar)
localFile := filepath.Join(stageDir, "podman-container.tar")
cmdCtx, cancel := context.WithTimeout(ctx, 20*time.Minute)
defer cancel()
cmd := exec.CommandContext(cmdCtx, "rsync", "-a", "-e", sshCmd, remoteTar, localFile)
out, err := cmd.CombinedOutput()
_ = sshCheck(ctx, site, fmt.Sprintf("rm -f -- %s", shellQuote(tmpTar)))
if err != nil {
return errors.New(strings.TrimSpace(string(out)))
}
return nil
}
func runResticBackup(ctx context.Context, repoPath string, site store.Site, jobID int64, stagedPaths []string) error {
if err := ensureResticRepo(ctx, repoPath); err != nil {
return err
}
args := []string{"-r", repoPath, "backup"}
args = append(args, stagedPaths...)
args = append(args, "--tag", "site_uuid:"+site.SiteUUID, "--tag", "site_id:"+strconv.FormatInt(site.ID, 10), "--tag", "job_id:"+strconv.FormatInt(jobID, 10))
cmdCtx, cancel := context.WithTimeout(ctx, 20*time.Minute)
defer cancel()
cmd := exec.CommandContext(cmdCtx, "restic", args...)
cmd.Env = resticEnv()
out, err := cmd.CombinedOutput()
if err != nil {
return errors.New(strings.TrimSpace(string(out)))
}
return nil
}
func (a *app) runRetentionJob(ctx context.Context, job store.Job, site store.Site) (string, string) {
repoPath := getenvDefault("SATORU_RESTIC_REPO", defaultResticRepo)
if err := ensureResticRepo(ctx, repoPath); err != nil {
return "failed", "retention failed: restic repo unavailable"
}
retentionArgs := strings.Fields(getenvDefault("SATORU_RESTIC_RETENTION_ARGS", "--keep-daily 7 --keep-weekly 4 --keep-monthly 6"))
args := []string{"-r", repoPath, "forget", "--prune", "--tag", "site_uuid:" + site.SiteUUID}
args = append(args, retentionArgs...)
a.log.Debug("retention command", zap.Int64("job_id", job.ID), zap.Int64("site_id", site.ID), zap.Strings("args", args))
cmdCtx, cancel := context.WithTimeout(ctx, 20*time.Minute)
defer cancel()
cmd := exec.CommandContext(cmdCtx, "restic", args...)
cmd.Env = resticEnv()
out, err := cmd.CombinedOutput()
if err != nil {
msg := strings.TrimSpace(string(out))
if msg == "" {
msg = err.Error()
}
_ = a.store.AddJobEvent(ctx, store.JobEvent{JobID: job.ID, Level: "error", Message: "retention failed: " + msg})
return "failed", "retention failed"
}
_ = a.store.AddJobEvent(ctx, store.JobEvent{JobID: job.ID, Level: "info", Message: "retention completed"})
return "success", "retention completed"
}
func (a *app) runResticSyncJob(ctx context.Context, job store.Job, site store.Site) (string, string) {
b2Repo := configValue("SATORU_RESTIC_B2_REPOSITORY")
if b2Repo == "" {
_ = a.store.AddJobEvent(ctx, store.JobEvent{JobID: job.ID, Level: "warn", Message: "restic sync skipped: SATORU_RESTIC_B2_REPOSITORY not set"})
return "warning", "restic sync skipped: B2 repository not configured"
}
repoPath := getenvDefault("SATORU_RESTIC_REPO", defaultResticRepo)
if err := ensureResticRepo(ctx, repoPath); err != nil {
_ = a.store.AddJobEvent(ctx, store.JobEvent{JobID: job.ID, Level: "error", Message: "restic sync failed: local repo unavailable"})
return "failed", "restic sync failed: local repo unavailable"
}
snapshotID, err := latestSnapshotIDForSite(ctx, repoPath, site.SiteUUID)
if err != nil {
_ = a.store.AddJobEvent(ctx, store.JobEvent{JobID: job.ID, Level: "error", Message: "restic sync failed: " + err.Error()})
return "failed", "restic sync failed: local snapshot lookup error"
}
if snapshotID == "" {
_ = a.store.AddJobEvent(ctx, store.JobEvent{JobID: job.ID, Level: "warn", Message: "restic sync skipped: no local snapshots for site"})
return "warning", "restic sync skipped: no local snapshots for site"
}
_ = a.store.AddJobEvent(ctx, store.JobEvent{JobID: job.ID, Level: "info", Message: "restic sync started"})
args, env, copyMode, err := buildResticCopyInvocation(ctx, repoPath, b2Repo, snapshotID)
if err != nil {
_ = a.store.AddJobEvent(ctx, store.JobEvent{JobID: job.ID, Level: "error", Message: "restic sync failed: " + err.Error()})
return "failed", "restic sync failed: command build error"
}
a.log.Debug("restic sync copy", zap.Int64("job_id", job.ID), zap.Int64("site_id", site.ID), zap.String("local_repo", repoPath), zap.String("b2_repo", b2Repo), zap.String("snapshot_id", snapshotID), zap.String("copy_mode", copyMode), zap.Strings("args", args))
cmdCtx, cancel := context.WithTimeout(ctx, 5*time.Minute)
defer cancel()
cmd := exec.CommandContext(cmdCtx, "restic", args...)
cmd.Env = env
out, err := cmd.CombinedOutput()
if err != nil {
msg := strings.TrimSpace(string(out))
if msg == "" {
msg = err.Error()
}
_ = a.store.AddJobEvent(ctx, store.JobEvent{JobID: job.ID, Level: "error", Message: "restic sync failed: " + msg})
return "failed", "restic sync failed"
}
_ = a.store.AddJobEvent(ctx, store.JobEvent{JobID: job.ID, Level: "info", Message: "restic sync completed"})
return "success", "restic sync completed"
}
func buildResticCopyInvocation(ctx context.Context, sourceRepo, destinationRepo, snapshotID string) ([]string, []string, string, error) {
modern, err := resticSupportsFromRepo(ctx)
if err != nil {
return nil, nil, "", err
}
if modern {
args := []string{"-r", destinationRepo, "copy", snapshotID, "--from-repo", sourceRepo}
return args, resticCopyEnvModern(), "from-repo", nil
}
args := []string{"-r", sourceRepo, "copy", snapshotID, "--repo2", destinationRepo}
return args, resticEnv(), "repo2", nil
}
func resticSupportsFromRepo(ctx context.Context) (bool, error) {
cmdCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
cmd := exec.CommandContext(cmdCtx, "restic", "copy", "--help")
out, err := cmd.CombinedOutput()
if err != nil {
msg := strings.TrimSpace(string(out))
if msg == "" {
msg = err.Error()
}
return false, errors.New(msg)
}
help := string(out)
return strings.Contains(help, "--from-repo"), nil
}
func resticCopyEnvModern() []string {
env := os.Environ()
password := configValue("RESTIC_PASSWORD")
passwordFile := configValue("RESTIC_PASSWORD_FILE")
password2 := configValue("RESTIC_PASSWORD2")
passwordFile2 := configValue("RESTIC_PASSWORD_FILE2")
b2ApplicationID := configValue("B2_APPLICATION_ID")
b2ApplicationKey := configValue("B2_APPLICATION_KEY")
b2AccountID := configValue("B2_ACCOUNT_ID")
b2AccountKey := configValue("B2_ACCOUNT_KEY")
if password == "" && passwordFile == "" {
password = configuredResticPassword()
}
if password2 == "" && passwordFile2 == "" {
if password != "" {
password2 = password
} else {
password2 = configuredResticPassword()
}
}
if password2 != "" {
env = append(env, "RESTIC_PASSWORD="+password2)
}
if passwordFile2 != "" {
env = append(env, "RESTIC_PASSWORD_FILE="+passwordFile2)
}
if password != "" {
env = append(env, "RESTIC_FROM_PASSWORD="+password)
}
if passwordFile != "" {
env = append(env, "RESTIC_FROM_PASSWORD_FILE="+passwordFile)
}
if b2AccountID == "" && b2ApplicationID != "" {
env = append(env, "B2_ACCOUNT_ID="+b2ApplicationID)
}
if b2AccountKey == "" && b2ApplicationKey != "" {
env = append(env, "B2_ACCOUNT_KEY="+b2ApplicationKey)
}
return env
}
func ensureResticRepo(ctx context.Context, repoPath string) error {
check := exec.CommandContext(ctx, "restic", "-r", repoPath, "cat", "config")
check.Env = resticEnv()
if err := check.Run(); err == nil {
return nil
}
initCmd := exec.CommandContext(ctx, "restic", "-r", repoPath, "init")
initCmd.Env = resticEnv()
out, err := initCmd.CombinedOutput()
if err != nil && !strings.Contains(string(out), "already initialized") {
return errors.New(strings.TrimSpace(string(out)))
}
return nil
}
func resticEnv() []string {
env := os.Environ()
password := configValue("RESTIC_PASSWORD")
passwordFile := configValue("RESTIC_PASSWORD_FILE")
password2 := configValue("RESTIC_PASSWORD2")
passwordFile2 := configValue("RESTIC_PASSWORD_FILE2")
b2ApplicationID := configValue("B2_APPLICATION_ID")
b2ApplicationKey := configValue("B2_APPLICATION_KEY")
b2AccountID := configValue("B2_ACCOUNT_ID")
b2AccountKey := configValue("B2_ACCOUNT_KEY")
if password == "" && passwordFile == "" {
password = configuredResticPassword()
}
if password2 == "" && passwordFile2 == "" {
// Use repo2-specific password when provided; otherwise fall back to primary/default password.
if password != "" {
password2 = password
} else {
password2 = configuredResticPassword()
}
}
if password != "" {
env = append(env, "RESTIC_PASSWORD="+password)
}
if passwordFile != "" {
env = append(env, "RESTIC_PASSWORD_FILE="+passwordFile)
}
if password2 != "" {
env = append(env, "RESTIC_PASSWORD2="+password2)
}
if passwordFile2 != "" {
env = append(env, "RESTIC_PASSWORD_FILE2="+passwordFile2)
}
// Prefer clearer B2 application naming and map to restic backend vars.
if b2AccountID == "" && b2ApplicationID != "" {
env = append(env, "B2_ACCOUNT_ID="+b2ApplicationID)
}
if b2AccountKey == "" && b2ApplicationKey != "" {
env = append(env, "B2_ACCOUNT_KEY="+b2ApplicationKey)
}
return env
}
type resticSnapshot struct {
ID string `json:"id"`
Time time.Time `json:"time"`
}
func latestSnapshotIDForSite(ctx context.Context, repoPath, siteUUID string) (string, error) {
args := []string{"-r", repoPath, "snapshots", "--json", "--tag", "site_uuid:" + siteUUID}
cmdCtx, cancel := context.WithTimeout(ctx, 2*time.Minute)
defer cancel()
cmd := exec.CommandContext(cmdCtx, "restic", args...)
cmd.Env = resticEnv()
out, err := cmd.CombinedOutput()
if err != nil {
msg := strings.TrimSpace(string(out))
if msg == "" {
msg = err.Error()
}
return "", errors.New(msg)
}
var snapshots []resticSnapshot
if err := json.Unmarshal(out, &snapshots); err != nil {
return "", err
}
if len(snapshots) == 0 {
return "", nil
}
latest := snapshots[0]
for _, snap := range snapshots[1:] {
if snap.Time.After(latest.Time) {
latest = snap
}
}
return latest.ID, nil
}
func targetStageDir(root, siteUUID string, target store.SiteTarget) string {
hash := hashPath(targetIdentity(target))
modeDir := "dir"
if target.Mode == "sqlite_dump" {
modeDir = "sqlite"
} else if target.Mode == "mysql_dump" {
modeDir = "mysql"
} else if target.Mode == "podman_save" {
modeDir = "podman"
} else if target.Mode == "podman_export" {
modeDir = "podman-export"
}
return filepath.Join(root, siteUUID, hash, modeDir)
}
func targetIdentity(target store.SiteTarget) string {
parts := []string{target.Mode, target.Path}
if target.MySQLHost.Valid {
parts = append(parts, target.MySQLHost.String)
}
if target.MySQLUser.Valid {
parts = append(parts, target.MySQLUser.String)
}
if target.MySQLDB.Valid {
parts = append(parts, target.MySQLDB.String)
}
return strings.Join(parts, "|")
}
func hashPath(path string) string {
sum := sha256.Sum256([]byte(path))
return hex.EncodeToString(sum[:])
}
func shortHash(path string) string {
v := hashPath(path)
if len(v) < 12 {
return v
}
return v[:12]
}
func parseRsyncProgress(line string) string {
if !strings.Contains(line, "/s") || !strings.Contains(line, "to-chk=") {
return ""
}
fields := strings.Fields(strings.ReplaceAll(line, ",", ""))
var percent, speed, eta string
for _, f := range fields {
if strings.HasSuffix(f, "%") {
percent = f
}
if strings.HasSuffix(f, "/s") {
speed = f
}
if strings.Count(f, ":") == 2 {
eta = f
}
}
if speed == "" {
return ""
}
parts := make([]string, 0, 3)
if percent != "" {
parts = append(parts, percent)
}
parts = append(parts, speed)
if eta != "" {
parts = append(parts, "eta "+eta)
}
return strings.Join(parts, " ")
}
func scanCRLF(data []byte, atEOF bool) (advance int, token []byte, err error) {
for i := 0; i < len(data); i++ {
if data[i] == '\n' || data[i] == '\r' {
return i + 1, data[:i], nil
}
}
if atEOF && len(data) > 0 {
return len(data), data, nil
}
return 0, nil, nil
}