Fix module path and restore cmd/ omitted by gitignore

Rename module github.com/johnellis/stalwart-migrator ->
github.com/LINUXexpert-org/stalwart-migrator to match the repository
location, so the module resolves under `go get`.

The initial commit's .gitignore listed the compiled binary as a bare
`stalwart-migrate` pattern, which Git matches at any depth -- so it also
excluded the cmd/stalwart-migrate/ source directory, and the initial
commit shipped without the CLI entrypoint. Anchor the pattern to the
repo root as /stalwart-migrate and add the four missing files.

go build, go vet, and go test ./... all pass.
This commit is contained in:
2026-08-22 18:23:48 -07:00
parent 719a945d64
commit 5566eed1c8
16 changed files with 438 additions and 17 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
# Binaries # Binaries
/bin/ /bin/
/dist/ /dist/
stalwart-migrate /stalwart-migrate
*.exe *.exe
# Test / coverage # Test / coverage
+53
View File
@@ -0,0 +1,53 @@
// Command stalwart-migrate drives an in-place Stalwart Mail Server upgrade
// (0.15.5 -> latest) through preflight checks, a defense-in-depth backup,
// a checkpointed migration, and post-migration validation, with rollback
// available at every step. See ARCHITECTURE.md for the full design.
package main
import (
"fmt"
"os"
)
func main() {
if len(os.Args) < 2 {
usage()
os.Exit(1)
}
var err error
switch os.Args[1] {
case "preflight":
err = runPreflight(os.Args[2:])
case "run":
err = runRun(os.Args[2:])
case "status":
err = runStatus(os.Args[2:])
case "rollback":
err = fmt.Errorf("not implemented yet: see internal/rollback")
case "confirm":
err = fmt.Errorf("not implemented yet: see internal/checkpoint")
case "report":
err = fmt.Errorf("not implemented yet: see internal/validate")
default:
usage()
os.Exit(1)
}
if err != nil {
fmt.Fprintln(os.Stderr, "stalwart-migrate:", err)
os.Exit(1)
}
}
func usage() {
fmt.Fprintln(os.Stderr, `usage: stalwart-migrate <command> [flags]
commands:
preflight run read-only checks and print the migration plan
run --dry-run: simulate and validate against a sandbox (real cutover isn't implemented yet)
status show the state of an in-progress or completed run
rollback restore the pre-migration backup for a given run
confirm close the rollback window for a completed run
report print the validation report for a run`)
}
+59
View File
@@ -0,0 +1,59 @@
package main
import (
"context"
"flag"
"fmt"
"os"
"github.com/LINUXexpert-org/stalwart-migrator/internal/checkpoint"
"github.com/LINUXexpert-org/stalwart-migrator/internal/preflight"
)
func runPreflight(args []string) error {
fs := flag.NewFlagSet("preflight", flag.ExitOnError)
binaryPath := fs.String("binary", "/usr/local/bin/stalwart", "path to the installed stalwart binary")
configPath := fs.String("config", "/etc/stalwart/config.toml", "path to stalwart's config file")
dataDir := fs.String("data-dir", "/var/lib/stalwart", "stalwart data directory")
containerName := fs.String("container", "stalwart", "docker container name, if applicable")
adminURL := fs.String("admin-url", "", "base URL for a JMAP reachability check (optional but recommended)")
adminUser := fs.String("admin-user", "", "admin username for the reachability check")
adminPassword := fs.String("admin-password", os.Getenv("STALWART_MIGRATE_ADMIN_PASSWORD"),
"admin password for the reachability check (or set STALWART_MIGRATE_ADMIN_PASSWORD)")
targetVersion := fs.String("target", "latest", `target Stalwart version, or "latest"`)
minFree := fs.Float64("min-free-multiple", 2.0, "required free disk space as a multiple of the data directory size")
stateDir := fs.String("state-dir", checkpoint.DefaultBaseDir, "directory to store run checkpoints in")
if err := fs.Parse(args); err != nil {
return err
}
store := checkpoint.NewStore(*stateDir)
rs, err := store.Create("", *targetVersion)
if err != nil {
return fmt.Errorf("create run: %w", err)
}
checker := preflight.New(preflight.Options{
BinaryPath: *binaryPath,
ConfigPath: *configPath,
DataDir: *dataDir,
ContainerName: *containerName,
AdminURL: *adminURL,
AdminUser: *adminUser,
AdminPassword: *adminPassword,
TargetVersion: *targetVersion,
MinFreeMultiple: *minFree,
})
report, err := checker.Run(context.Background(), store, rs)
fmt.Print(report.String())
fmt.Printf("\nrun id: %s\n", rs.RunID)
if err != nil {
return fmt.Errorf("preflight run %s failed to complete: %w", rs.RunID, err)
}
if report.Blocking() {
return fmt.Errorf("preflight found blocking issues for run %s - see FAIL lines above", rs.RunID)
}
fmt.Println("preflight passed - safe to proceed with `stalwart-migrate run`")
return nil
}
+247
View File
@@ -0,0 +1,247 @@
package main
import (
"context"
"flag"
"fmt"
"net/http"
"os"
"path/filepath"
"github.com/LINUXexpert-org/stalwart-migrator/internal/backup"
"github.com/LINUXexpert-org/stalwart-migrator/internal/checkpoint"
"github.com/LINUXexpert-org/stalwart-migrator/internal/plan"
"github.com/LINUXexpert-org/stalwart-migrator/internal/preflight"
"github.com/LINUXexpert-org/stalwart-migrator/internal/recovery"
"github.com/LINUXexpert-org/stalwart-migrator/internal/validate"
)
// runRun implements `stalwart-migrate run`. Only --dry-run is available
// today: a real cutover needs internal/rollback (so a failed migration can
// actually be undone) and real systemd/Docker service control, neither of
// which exist yet - see ARCHITECTURE.md §8. Committing to a real migration
// without a working rollback would violate the one thing this whole tool
// exists to guarantee, so `run` without --dry-run refuses rather than doing
// it partway.
//
// --dry-run runs preflight and a real backup (see the caveat printed below
// about why backup still touches the live data directory), then - if the
// plan crosses the 0.15/0.16 boundary - clones the verified backup into a
// disposable sandbox, converts the settings snapshot to point at that
// sandbox (via migrate_v016.py's own documented --patch-paths mechanism,
// not by this tool guessing at config.json's schema), runs the real
// recovery-mode migration against the sandbox, and boots the result
// normally to confirm it comes up. Nothing at the real binary path or the
// real service is ever touched.
//
// Every byte a dry run writes - the fs-backup copy, the settings/principals
// dumps, the downloaded migrate_v016.py, the sandbox clone and its
// config/export files - lives under one per-run directory
// (work-dir/<run-id>) that a deferred cleanup at the bottom of this
// function removes on every exit path: success, a failed check partway
// through, or an early refusal. The only thing left behind afterward is the
// checkpoint's state.json under --state-dir, which is exactly the
// success/failure log a rerun's `status <run-id>` reads - not bulk data.
// --keep-artifacts opts out, for when a failure needs inspecting.
func runRun(args []string) (err error) {
fs := flag.NewFlagSet("run", flag.ExitOnError)
binaryPath := fs.String("binary", "/usr/local/bin/stalwart", "path to the currently-installed stalwart binary")
targetBinaryPath := fs.String("target-binary", "", "path to an already-downloaded target-version stalwart binary (required to simulate a major-boundary migration)")
configPath := fs.String("config", "/etc/stalwart/config.toml", "path to stalwart's current config file")
dataDir := fs.String("data-dir", "/var/lib/stalwart", "stalwart data directory")
containerName := fs.String("container", "stalwart", "docker container name, if applicable")
adminURL := fs.String("admin-url", "", "base URL for the live instance's admin/JMAP API (required)")
adminUser := fs.String("admin-user", "", "admin username")
adminPassword := fs.String("admin-password", os.Getenv("STALWART_MIGRATE_ADMIN_PASSWORD"),
"admin password (or set STALWART_MIGRATE_ADMIN_PASSWORD)")
targetVersion := fs.String("target", "latest", `target Stalwart version, or "latest"`)
stateDir := fs.String("state-dir", checkpoint.DefaultBaseDir, "directory to store run checkpoints in")
workDir := fs.String("work-dir", "/var/lib/stalwart-migrator/work", "scratch directory for backups, dumps, and the dry-run sandbox (cleaned up afterward - see --keep-artifacts)")
stalwartCLI := fs.String("stalwart-cli", "stalwart-cli", "path to the stalwart-cli binary")
pythonPath := fs.String("python", "python3", "path to python3")
migrationScriptSHA256 := fs.String("migration-script-sha256", "", "pinned sha256 of migrate_v016.py (recommended; see preflight/backup output for the hash to pin after a first unpinned run)")
recoveryPort := fs.Int("recovery-port", 8080, "port recovery mode's HTTP listener binds, per UPGRADING/v0_16.md's own examples")
minFree := fs.Float64("min-free-multiple", 2.0, "required free disk space as a multiple of the data directory size")
dryRun := fs.Bool("dry-run", false, "simulate and validate the migration against a disposable sandbox, without touching production")
keepArtifacts := fs.Bool("keep-artifacts", false, "don't delete work-dir/<run-id> afterward (the fs-backup copy, dumps, and sandbox) - useful for inspecting a failure")
if err := fs.Parse(args); err != nil {
return err
}
if !*dryRun {
return fmt.Errorf(
"real (non-dry-run) migrations aren't available yet: internal/rollback and real systemd/Docker service control " +
"aren't implemented, so this tool can't yet guarantee it can undo a failed cutover. Run with --dry-run to " +
"validate the migration mechanics against a disposable sandbox copy of your data - nothing in production is touched",
)
}
if *adminURL == "" {
return fmt.Errorf("--admin-url is required")
}
ctx := context.Background()
httpClient := &http.Client{}
if err := os.MkdirAll(*workDir, 0o750); err != nil {
return fmt.Errorf("create work dir %s: %w", *workDir, err)
}
store := checkpoint.NewStore(*stateDir)
rs, err := store.Create("", *targetVersion)
if err != nil {
return fmt.Errorf("create run: %w", err)
}
fmt.Printf("run id: %s\n\n", rs.RunID)
runWorkDir := filepath.Join(*workDir, rs.RunID)
logPath := filepath.Join(*stateDir, rs.RunID, "state.json")
defer func() {
if _, statErr := os.Stat(runWorkDir); os.IsNotExist(statErr) {
return // nothing was ever written (e.g. refused before backup ran)
}
if *keepArtifacts {
fmt.Printf("\nartifacts kept at %s (--keep-artifacts) - remove manually when done inspecting\n", runWorkDir)
return
}
outcome := "succeeded"
if err != nil {
outcome = "failed"
}
if rmErr := os.RemoveAll(runWorkDir); rmErr != nil {
fmt.Fprintf(os.Stderr, "\nwarning: dry run %s, but failed to clean up %s: %v (remove it manually)\n", outcome, runWorkDir, rmErr)
return
}
fmt.Printf("\ndry run %s - cleaned up %s; the run log is at %s\n", outcome, runWorkDir, logPath)
}()
fmt.Println("--- preflight ---")
checker := preflight.New(preflight.Options{
BinaryPath: *binaryPath, ConfigPath: *configPath, DataDir: *dataDir, ContainerName: *containerName,
AdminURL: *adminURL, AdminUser: *adminUser, AdminPassword: *adminPassword,
TargetVersion: *targetVersion, MinFreeMultiple: *minFree, HTTPClient: httpClient,
})
pfReport, err := checker.Run(ctx, store, rs)
fmt.Print(pfReport.String())
if err != nil {
return fmt.Errorf("preflight failed to complete: %w", err)
}
if pfReport.Blocking() {
return fmt.Errorf("preflight found blocking issues - see FAIL lines above")
}
p, err := plan.Decide(rs.SourceVersion, rs.TargetVersion)
if err != nil {
return fmt.Errorf("plan: %w", err)
}
fmt.Printf("\nplan: %s\n", p.Reason)
fmt.Println("\n--- backup ---")
fmt.Println("(dry-run does not stop the live Stalwart service itself - no service control is implemented yet. " +
"For a guaranteed-consistent snapshot, stop stalwart before running this; otherwise the filesystem copy may " +
"reflect a live, in-use store. This is unrelated to whether production gets touched - it never does.)")
backupDir := filepath.Join(runWorkDir, "backup")
scriptDest := filepath.Join(runWorkDir, "migrate_v016.py")
settingsPath := filepath.Join(runWorkDir, "settings.json")
principalsPath := filepath.Join(runWorkDir, "principals.json")
backupOpts := backup.Options{
BinaryPath: *binaryPath,
SkipBinaryPreservation: true, // dry-run: never touch the production binary
DataDir: *dataDir,
BackupDir: backupDir,
MigrationScriptSHA256: *migrationScriptSHA256,
ScriptDestPath: scriptDest,
AdminURL: *adminURL,
AdminUser: *adminUser,
AdminPassword: *adminPassword,
SettingsDumpPath: settingsPath,
PrincipalsDumpPath: principalsPath,
PythonPath: *pythonPath,
HTTPClient: httpClient,
}
bkReport, err := backup.Run(ctx, store, rs, backupOpts)
fmt.Print(bkReport.String())
if err != nil {
return fmt.Errorf("backup failed: %w", err)
}
if !p.CrossesMajorBoundary {
fmt.Println("\nthis is a same-boundary patch upgrade: there's no recovery-mode phase to simulate. " +
"preflight and backup above are as far as a dry-run goes for this path - a real run would be a binary swap and restart.")
return nil
}
if *targetBinaryPath == "" {
return fmt.Errorf("--target-binary is required to simulate a major-boundary migration (0.15 -> 0.16 crosses one here)")
}
fmt.Println("\n--- convert (settings -> sandbox config) ---")
sandboxDataDir := filepath.Join(runWorkDir, "sandbox-data")
sandboxConfigPath := filepath.Join(runWorkDir, "sandbox-config.json")
sandboxExportPath := filepath.Join(runWorkDir, "sandbox-export.json")
if _, err := store.RunStep(rs, checkpoint.PhaseStage, "clone-sandbox-data", func() (checkpoint.StepOutcome, error) {
manifest, err := backup.CopyDataDir(backupDir, sandboxDataDir)
if err != nil {
return checkpoint.StepOutcome{}, err
}
return checkpoint.StepOutcome{Detail: fmt.Sprintf("cloned the verified backup (%d files) into the sandbox at %s", len(manifest.Files), sandboxDataDir)}, nil
}); err != nil {
return fmt.Errorf("clone sandbox data: %w", err)
}
fmt.Printf("cloned verified backup into sandbox: %s\n", sandboxDataDir)
if _, err := store.RunStep(rs, checkpoint.PhaseStage, "convert-settings", func() (checkpoint.StepOutcome, error) {
if err := backup.RunSettingsConvert(ctx, backup.SettingsConvertOptions{
PythonPath: *pythonPath, ScriptPath: scriptDest,
SettingsPath: settingsPath, PrincipalsPath: principalsPath,
ConfigPath: sandboxConfigPath, OutputPath: sandboxExportPath,
PatchPaths: map[string]string{*dataDir: sandboxDataDir},
}); err != nil {
return checkpoint.StepOutcome{}, err
}
return checkpoint.StepOutcome{Detail: fmt.Sprintf("generated %s and %s, patched to point at the sandbox", sandboxConfigPath, sandboxExportPath)}, nil
}); err != nil {
return fmt.Errorf("convert settings: %w", err)
}
fmt.Println("generated sandbox config.json and export.json")
fmt.Println("\n--- recovery-mode migration (against the sandbox) ---")
listenURL := fmt.Sprintf("http://127.0.0.1:%d/", *recoveryPort)
recReport, err := recovery.Run(ctx, store, rs, recovery.Options{
BinaryPath: *targetBinaryPath, ConfigPath: sandboxConfigPath, ListenURL: listenURL,
AdminUser: "admin", ApplyFiles: []string{sandboxExportPath}, CLIBinaryPath: *stalwartCLI,
HTTPClient: httpClient,
})
fmt.Print(recReport.String())
if err != nil {
return fmt.Errorf("recovery-mode migration against the sandbox failed: %w", err)
}
fmt.Println("\n--- boot check (normal boot of the migrated sandbox) ---")
if rs.PreflightSnapshot != nil {
fmt.Println("(comparing against the pre-migration account/mailbox snapshot preflight captured - " +
"this is the actual no-data-loss check, not just a reachability probe)")
} else {
fmt.Println("(no pre-migration snapshot to compare against - preflight couldn't capture one, most likely " +
"because --admin-url wasn't set; only reachability is checked)")
}
valReport, err := validate.Run(ctx, store, rs, validate.BootCheckOptions{
BinaryPath: *targetBinaryPath, ConfigPath: sandboxConfigPath, ListenURL: listenURL, HTTPClient: httpClient,
ContentIntegrityBefore: rs.PreflightSnapshot,
AdminUser: *adminUser,
AdminPassword: *adminPassword,
})
fmt.Print(valReport.String())
if err != nil {
return fmt.Errorf("post-migration validation failed: %w", err)
}
verified := "the migration mechanics succeeded"
if rs.PreflightSnapshot != nil {
verified = "the migration mechanics succeeded AND every account/mailbox message count matched before vs. after"
}
fmt.Printf("\nDRY RUN COMPLETE for run %s: %s, against a disposable sandbox copy of your data. Nothing in production was touched.\n", rs.RunID, verified)
return nil
}
+62
View File
@@ -0,0 +1,62 @@
package main
import (
"flag"
"fmt"
"github.com/LINUXexpert-org/stalwart-migrator/internal/checkpoint"
)
func runStatus(args []string) error {
fs := flag.NewFlagSet("status", flag.ExitOnError)
stateDir := fs.String("state-dir", checkpoint.DefaultBaseDir, "directory runs are checkpointed in")
if err := fs.Parse(args); err != nil {
return err
}
store := checkpoint.NewStore(*stateDir)
if fs.NArg() == 0 {
ids, err := store.List()
if err != nil {
return fmt.Errorf("list runs: %w", err)
}
if len(ids) == 0 {
fmt.Println("no runs found in", *stateDir)
return nil
}
fmt.Println("runs (newest first):")
for _, id := range ids {
fmt.Println(" ", id)
}
return nil
}
runID := fs.Arg(0)
rs, err := store.Load(runID)
if err != nil {
return fmt.Errorf("load run %s: %w", runID, err)
}
fmt.Printf("run: %s\n", rs.RunID)
fmt.Printf("source: %s\n", rs.SourceVersion)
fmt.Printf("target: %s\n", rs.TargetVersion)
fmt.Printf("topology: deployment=%s store=%s\n", rs.Topology.DeploymentKind, rs.Topology.StoreBackend)
fmt.Printf("rollback window closed: %v\n", rs.RollbackWindowClosed)
fmt.Println("steps:")
for _, step := range rs.Steps {
tag := string(step.Status)
if step.Verdict != "" {
tag = step.Verdict
}
line := fmt.Sprintf(" [%-4s] %s/%s", tag, step.Phase, step.Name)
if step.Detail != "" {
line += " - " + step.Detail
}
if step.Error != "" {
line += " (error: " + step.Error + ")"
}
fmt.Println(line)
}
return nil
}
+1 -1
View File
@@ -1,3 +1,3 @@
module github.com/johnellis/stalwart-migrator module github.com/LINUXexpert-org/stalwart-migrator
go 1.26.5 go 1.26.5
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"github.com/johnellis/stalwart-migrator/internal/checkpoint" "github.com/LINUXexpert-org/stalwart-migrator/internal/checkpoint"
) )
// Options configures a full backup pass. Which fields matter depends on // Options configures a full backup pass. Which fields matter depends on
+1 -1
View File
@@ -9,7 +9,7 @@ import (
"path/filepath" "path/filepath"
"testing" "testing"
"github.com/johnellis/stalwart-migrator/internal/checkpoint" "github.com/LINUXexpert-org/stalwart-migrator/internal/checkpoint"
) )
func TestBackupRunEndToEndAndResume(t *testing.T) { func TestBackupRunEndToEndAndResume(t *testing.T) {
+2 -2
View File
@@ -8,8 +8,8 @@ import (
"strings" "strings"
"time" "time"
"github.com/johnellis/stalwart-migrator/internal/checkpoint" "github.com/LINUXexpert-org/stalwart-migrator/internal/checkpoint"
"github.com/johnellis/stalwart-migrator/internal/stalwartapi" "github.com/LINUXexpert-org/stalwart-migrator/internal/stalwartapi"
) )
// Options configures a Checker. Every field has a conservative default // Options configures a Checker. Every field has a conservative default
+1 -1
View File
@@ -11,7 +11,7 @@ import (
"strings" "strings"
"testing" "testing"
"github.com/johnellis/stalwart-migrator/internal/checkpoint" "github.com/LINUXexpert-org/stalwart-migrator/internal/checkpoint"
) )
// writeFakeBinary creates a shell script that behaves like `stalwart // writeFakeBinary creates a shell script that behaves like `stalwart
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"net/http" "net/http"
"time" "time"
"github.com/johnellis/stalwart-migrator/internal/checkpoint" "github.com/LINUXexpert-org/stalwart-migrator/internal/checkpoint"
) )
// Options configures one full recovery-mode migration cycle: starting the // Options configures one full recovery-mode migration cycle: starting the
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"testing" "testing"
"time" "time"
"github.com/johnellis/stalwart-migrator/internal/checkpoint" "github.com/LINUXexpert-org/stalwart-migrator/internal/checkpoint"
) )
func TestRecoveryRunEndToEndAndResume(t *testing.T) { func TestRecoveryRunEndToEndAndResume(t *testing.T) {
+3 -3
View File
@@ -6,9 +6,9 @@ import (
"net/http" "net/http"
"time" "time"
"github.com/johnellis/stalwart-migrator/internal/checkpoint" "github.com/LINUXexpert-org/stalwart-migrator/internal/checkpoint"
"github.com/johnellis/stalwart-migrator/internal/recovery" "github.com/LINUXexpert-org/stalwart-migrator/internal/recovery"
"github.com/johnellis/stalwart-migrator/internal/stalwartapi" "github.com/LINUXexpert-org/stalwart-migrator/internal/stalwartapi"
) )
// BootCheckOptions configures a normal (non-recovery-mode) boot of the // BootCheckOptions configures a normal (non-recovery-mode) boot of the
+1 -1
View File
@@ -9,7 +9,7 @@ import (
"testing" "testing"
"time" "time"
"github.com/johnellis/stalwart-migrator/internal/checkpoint" "github.com/LINUXexpert-org/stalwart-migrator/internal/checkpoint"
) )
func freePort(t *testing.T) int { func freePort(t *testing.T) int {
+2 -2
View File
@@ -6,8 +6,8 @@ import (
"sort" "sort"
"strings" "strings"
"github.com/johnellis/stalwart-migrator/internal/checkpoint" "github.com/LINUXexpert-org/stalwart-migrator/internal/checkpoint"
"github.com/johnellis/stalwart-migrator/internal/stalwartapi" "github.com/LINUXexpert-org/stalwart-migrator/internal/stalwartapi"
) )
// MailboxDelta is one mailbox whose message count didn't match between the // MailboxDelta is one mailbox whose message count didn't match between the
+2 -2
View File
@@ -8,8 +8,8 @@ import (
"strings" "strings"
"testing" "testing"
"github.com/johnellis/stalwart-migrator/internal/checkpoint" "github.com/LINUXexpert-org/stalwart-migrator/internal/checkpoint"
"github.com/johnellis/stalwart-migrator/internal/stalwartapi" "github.com/LINUXexpert-org/stalwart-migrator/internal/stalwartapi"
) )
// jmapEnvelope mirrors the wire shape stalwartapi.Client.call() parses. // jmapEnvelope mirrors the wire shape stalwartapi.Client.call() parses.