Files
stalwart-migrator/internal/backup/backup.go
T
jcoffey-dev 77e80cb9f2 Change the copyright holder to Coffey Labs
119 SPDX-FileCopyrightText headers and the README's licence line.

The distinction that matters here: LINUXexpert-org appears in this repository
in two completely different roles. As a copyright holder in the SPDX headers,
which is what changes, and as the GitHub organisation in the module path and 64
import statements, which does not -- the repository still lives at
github.com/LINUXexpert-org/stalwart-migrator, and rewriting that would not be a
licence change, it would break the build.

Both replacements are anchored to their copyright forms, so an import path
cannot match either. Import count is 64 before and after, and go.mod is
untouched.

LICENSE untouched: the FSF's copyright on the GPL text and the "<name of
author>" placeholders are not ours to edit.

go vet, go build and go test all clean.
2026-08-30 01:28:23 -07:00

273 lines
11 KiB
Go

// SPDX-FileCopyrightText: 2026 Coffey Labs
// SPDX-License-Identifier: GPL-3.0-or-later
package backup
import (
"context"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/LINUXexpert-org/stalwart-migrator/internal/checkpoint"
)
// Options configures a full backup pass. Which fields matter depends on
// rs.Topology.StoreBackend, as recorded by the preflight phase - Run
// branches on that rather than requiring the caller to pre-select a code
// path.
type Options struct {
// Binary preservation.
BinaryPath string
// SkipBinaryPreservation, when true, never touches BinaryPath - used by
// a dry run, which must not move the production binary aside just to
// simulate a migration it hasn't committed to.
SkipBinaryPreservation bool
// Embedded backend (RocksDB/SQLite).
DataDir string
BackupDir string // destination for the fs snapshot; required if the backend is embedded
// External SQL backend (PostgreSQL/MySQL).
SQL SQLOptions
// FoundationDB backend.
FDB FDBOptions
// Settings/principals dump (always runs - every topology needs it).
MigrationScriptURL string // defaults to DefaultMigrationScriptURL
MigrationScriptSHA256 string // pinned hash; empty accepts and reports whatever is fetched (see DownloadFile)
ScriptDestPath string
AdminURL string
AdminUser string
AdminPassword string
SettingsDumpPath string
PrincipalsDumpPath string
PythonPath string
HTTPClient *http.Client
// Per-account content export (Vandelay) - optional defense-in-depth
// layer. Empty Accounts skips it entirely; this is expected until
// account enumeration is wired up (see stalwartapi.Client.AccountSnapshot).
Vandelay VandelayOptions
Accounts []string
}
// Run executes the full backup pass described in ARCHITECTURE.md §4.2,
// checkpointing each step. Unlike preflight, most steps here are hard
// failures: a backup that didn't actually happen must stop the pipeline,
// not just get reported and continued past. The one genuinely optional
// layer is the per-account Vandelay export, which is skipped (Status:
// StatusSkipped, not a failure) when Options.Accounts is empty - but if the
// operator populated Accounts, a failure there is hard too, since silently
// downgrading an explicitly requested backup layer would be worse than
// stopping.
func Run(ctx context.Context, store *checkpoint.Store, rs *checkpoint.RunState, opts Options) (Report, error) {
var report Report
step := func(name string, fn func() (checkpoint.StepOutcome, error)) (checkpoint.StepOutcome, error) {
outcome, err := store.RunStep(rs, checkpoint.PhaseBackup, name, fn)
if err != nil {
report.Results = append(report.Results, CheckResult{Name: name, Status: StatusFail, Detail: err.Error()})
return outcome, err
}
status := Status(outcome.Verdict)
if status == "" {
status = StatusOK
}
report.Results = append(report.Results, CheckResult{Name: name, Status: status, Detail: outcome.Detail})
return outcome, nil
}
if opts.SkipBinaryPreservation {
report.Results = append(report.Results, CheckResult{
Name: "preserve-binary", Status: StatusSkipped,
Detail: "skipped (SkipBinaryPreservation) - the production binary is never touched, e.g. for a dry run",
})
} else if _, err := step("preserve-binary", func() (checkpoint.StepOutcome, error) {
preserved, err := PreserveBinary(opts.BinaryPath, rs.SourceVersion)
if err != nil {
return checkpoint.StepOutcome{}, err
}
sum, size, err := HashFile(preserved)
if err != nil {
return checkpoint.StepOutcome{}, fmt.Errorf("backup: hash preserved binary %s: %w", preserved, err)
}
rs.RecordArtifact("old-binary", checkpoint.Artifact{Path: preserved, SHA256: sum, SizeBytes: size})
return checkpoint.StepOutcome{Detail: fmt.Sprintf("preserved %s as %s", opts.BinaryPath, preserved), Extra: preserved}, nil
}); err != nil {
return report, err
}
backends := strings.ToLower(rs.Topology.StoreBackend)
switch {
case strings.Contains(backends, "rocksdb") || strings.Contains(backends, "sqlite"):
if _, err := step("fs-snapshot", func() (checkpoint.StepOutcome, error) {
manifest, err := CopyDataDir(opts.DataDir, opts.BackupDir)
if err != nil {
return checkpoint.StepOutcome{}, err
}
manifestPath := filepath.Join(opts.BackupDir, "..", filepath.Base(opts.BackupDir)+".manifest.json")
if err := WriteManifest(manifestPath, manifest); err != nil {
return checkpoint.StepOutcome{}, err
}
sum, err := manifest.Checksum()
if err != nil {
return checkpoint.StepOutcome{}, err
}
rs.RecordArtifact("fs-backup", checkpoint.Artifact{Path: opts.BackupDir, SHA256: sum, SizeBytes: manifest.TotalBytes})
return checkpoint.StepOutcome{
Detail: fmt.Sprintf("copied %d file(s), %d bytes, from %s to %s", len(manifest.Files), manifest.TotalBytes, opts.DataDir, opts.BackupDir),
Extra: manifestPath,
}, nil
}); err != nil {
return report, err
}
if _, err := step("fs-verify", func() (checkpoint.StepOutcome, error) {
manifestOutcome := rs.Outcome(checkpoint.PhaseBackup, "fs-snapshot")
manifest, err := ReadManifest(manifestOutcome.Extra)
if err != nil {
return checkpoint.StepOutcome{}, err
}
if err := VerifyDataDirBackup(opts.BackupDir, manifest); err != nil {
return checkpoint.StepOutcome{}, err
}
return checkpoint.StepOutcome{Detail: fmt.Sprintf("re-hashed %d file(s), all match the manifest recorded at copy time", len(manifest.Files))}, nil
}); err != nil {
return report, err
}
case strings.Contains(backends, "postgresql"):
if _, err := step("sql-dump", func() (checkpoint.StepOutcome, error) {
if err := RunPgDump(ctx, opts.SQL); err != nil {
return checkpoint.StepOutcome{}, err
}
sum, size, err := HashFile(opts.SQL.OutPath)
if err != nil {
return checkpoint.StepOutcome{}, err
}
rs.RecordArtifact("sql-dump", checkpoint.Artifact{Path: opts.SQL.OutPath, SHA256: sum, SizeBytes: size})
return checkpoint.StepOutcome{Detail: fmt.Sprintf("pg_dump of critical tables (%s) to %s, %d bytes", strings.Join(criticalTables, " "), opts.SQL.OutPath, size)}, nil
}); err != nil {
return report, err
}
case strings.Contains(backends, "mysql"):
if _, err := step("sql-dump", func() (checkpoint.StepOutcome, error) {
if err := RunMySQLDump(ctx, opts.SQL); err != nil {
return checkpoint.StepOutcome{}, err
}
sum, size, err := HashFile(opts.SQL.OutPath)
if err != nil {
return checkpoint.StepOutcome{}, err
}
rs.RecordArtifact("sql-dump", checkpoint.Artifact{Path: opts.SQL.OutPath, SHA256: sum, SizeBytes: size})
return checkpoint.StepOutcome{Detail: fmt.Sprintf("mysqldump of critical tables (%s) to %s, %d bytes", strings.Join(criticalTables, " "), opts.SQL.OutPath, size)}, nil
}); err != nil {
return report, err
}
case strings.Contains(backends, "foundationdb"):
if _, err := step("fdb-backup", func() (checkpoint.StepOutcome, error) {
if err := StartFDBBackup(ctx, opts.FDB); err != nil {
return checkpoint.StepOutcome{}, err
}
return checkpoint.StepOutcome{
Detail: fmt.Sprintf("fdbbackup start issued for destination %s (tag %s) - this only confirms the job was accepted, not that it finished; check `fdbbackup status` before relying on it", opts.FDB.Destination, opts.FDB.Tag),
}, nil
}); err != nil {
return report, err
}
default:
// Not a skip. An unrecognized backend used to be reported as
// "skipped" and the run continued with no filesystem or database
// backup at all - the one artifact this phase exists to produce,
// quietly absent. It is reachable in practice: preflight failed to
// detect the backend of a real production instance because its
// config declares it with flat dotted keys, and the run would have
// proceeded backup-less.
err := fmt.Errorf("backup: no recognized store backend for this run (topology.store_backend=%q) - "+
"refusing to continue without a filesystem or database backup. Run preflight first; if it also can't "+
"identify the backend, the config layout isn't one this tool recognizes and the backup must be taken by hand",
rs.Topology.StoreBackend)
report.Results = append(report.Results, CheckResult{Name: "backend-backup", Status: StatusFail, Detail: err.Error()})
return report, err
}
if _, err := step("settings-dump", func() (checkpoint.StepOutcome, error) {
scriptURL := opts.MigrationScriptURL
if scriptURL == "" {
scriptURL = DefaultMigrationScriptURL
}
sum, err := DownloadFile(ctx, opts.HTTPClient, scriptURL, opts.ScriptDestPath, opts.MigrationScriptSHA256)
if err != nil {
return checkpoint.StepOutcome{}, err
}
pinNote := ""
if opts.MigrationScriptSHA256 == "" {
pinNote = fmt.Sprintf(" (no pin was configured - record sha256 %s as MigrationScriptSHA256 to pin it for future runs)", sum)
}
if err := RunSettingsDump(ctx, SettingsDumpOptions{
PythonPath: opts.PythonPath,
ScriptPath: opts.ScriptDestPath,
URL: opts.AdminURL,
Username: opts.AdminUser,
Password: opts.AdminPassword,
SettingsPath: opts.SettingsDumpPath,
PrincipalsPath: opts.PrincipalsDumpPath,
}); err != nil {
return checkpoint.StepOutcome{}, err
}
settingsSum, settingsSize, err := HashFile(opts.SettingsDumpPath)
if err != nil {
return checkpoint.StepOutcome{}, err
}
principalsSum, principalsSize, err := HashFile(opts.PrincipalsDumpPath)
if err != nil {
return checkpoint.StepOutcome{}, err
}
rs.RecordArtifact("settings-dump", checkpoint.Artifact{Path: opts.SettingsDumpPath, SHA256: settingsSum, SizeBytes: settingsSize})
rs.RecordArtifact("principals-dump", checkpoint.Artifact{Path: opts.PrincipalsDumpPath, SHA256: principalsSum, SizeBytes: principalsSize})
return checkpoint.StepOutcome{
Detail: fmt.Sprintf("dumped settings (%d bytes) and principals (%d bytes) from %s%s", settingsSize, principalsSize, opts.AdminURL, pinNote),
Extra: sum,
}, nil
}); err != nil {
return report, err
}
if len(opts.Accounts) == 0 {
report.Results = append(report.Results, CheckResult{
Name: "vandelay-export", Status: StatusSkipped,
Detail: "no account list supplied - skipped; pass Options.Accounts (or --full-content-backup with an account source once account enumeration is wired up) to enable this belt-and-suspenders layer",
})
} else if _, err := step("vandelay-export", func() (checkpoint.StepOutcome, error) {
if err := os.MkdirAll(opts.Vandelay.OutDir, 0o750); err != nil {
return checkpoint.StepOutcome{}, fmt.Errorf("backup: create vandelay output dir %s: %w", opts.Vandelay.OutDir, err)
}
files, err := ExportAccounts(ctx, opts.Vandelay, opts.Accounts)
if err != nil {
return checkpoint.StepOutcome{}, err
}
var totalSize int64
for i, f := range files {
sum, size, err := HashFile(f)
if err != nil {
return checkpoint.StepOutcome{}, err
}
rs.RecordArtifact(fmt.Sprintf("vandelay-%s", opts.Accounts[i]), checkpoint.Artifact{Path: f, SHA256: sum, SizeBytes: size})
totalSize += size
}
return checkpoint.StepOutcome{Detail: fmt.Sprintf("exported %d account(s), %d bytes total, to %s", len(files), totalSize, opts.Vandelay.OutDir)}, nil
}); err != nil {
return report, err
}
return report, nil
}