688 lines
23 KiB
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
|
|
}
|