The settings and principals dumps, the apply plan and its supplement lived only in --work-dir, which a successful run deletes. All four are irreplaceable once the store has been migrated: the dumps can only be taken from a live pre-migration instance, and the plan is what was actually replayed. `rehearse` already kept the plan and the supplement, so the read-only command preserved more of its conclusions than the destructive one did. They are now copied into the run's state directory before the store is touched, recorded as artifacts with checksums, and kept whether or not the run succeeded and whether or not --keep-artifacts was passed. README claimed the dumps stayed on disk; now they do. What made this concrete: an operator who booted recovery mode again after a completed migration, for an unrelated reason, and found Domain and Account queries coming back empty on the next start — twice, on two different servers, and verified as genuinely gone rather than a stale read. Re-applying that run's export.json and supplement.json against a fresh recovery boot is what got the server back both times, and they had those files only because they had thought to pass --keep-artifacts. Nobody should have to guess that in advance. The README now says not to boot recovery mode after a migration. That is Stalwart's behaviour rather than this tool's, but this tool is where an operator learns the technique, and it said nothing about it being a one-time step. Reported by @kaya-eu in #1.
588 lines
27 KiB
Go
588 lines
27 KiB
Go
// SPDX-FileCopyrightText: 2026 LINUXexpert-org
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"flag"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/LINUXexpert-org/stalwart-migrator/internal/applyplan"
|
|
"github.com/LINUXexpert-org/stalwart-migrator/internal/backup"
|
|
"github.com/LINUXexpert-org/stalwart-migrator/internal/checkpoint"
|
|
"github.com/LINUXexpert-org/stalwart-migrator/internal/cutover"
|
|
"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/service"
|
|
"github.com/LINUXexpert-org/stalwart-migrator/internal/stage"
|
|
"github.com/LINUXexpert-org/stalwart-migrator/internal/validate"
|
|
)
|
|
|
|
// runRun implements `stalwart-migrate run`: the real migration.
|
|
//
|
|
// The phase order is ARCHITECTURE.md §4's, and it was arrived at by
|
|
// performing this migration by hand against a clone of a production
|
|
// instance before it was ever written down as code:
|
|
//
|
|
// preflight -> stage -> dump -> preserve binary -> STOP -> convert ->
|
|
// generate supplement -> recovery-mode migration -> cutover -> START
|
|
//
|
|
// The dump happens before the service stops, because it reads settings
|
|
// over the admin API and a stopped server has no admin API. Everything
|
|
// between the stop and the end of cutover is downtime, and on a real 3.6 GB
|
|
// store that stretch was seconds of work - the window is dominated by
|
|
// verification and by however long an operator takes to answer, not by
|
|
// data volume.
|
|
//
|
|
// Two gates, deliberately separate. --yes says "do it"; it is about intent.
|
|
// --recovery-point-confirmed says "I have a snapshot or backup I have
|
|
// verified I can restore"; it is a claim about the world, which this tool
|
|
// cannot check and must not assume. Nothing here can undo a migration -
|
|
// recovery is the operator's (§4.8) - so a run that proceeded without that
|
|
// claim would be proceeding on a hope.
|
|
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")
|
|
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")
|
|
newConfigPath := fs.String("new-config", "", "where the converted v0.16 config is installed (default: config.json beside --config)")
|
|
unitName := fs.String("unit", "stalwart", "systemd unit name")
|
|
serviceUnitPath := fs.String("service-unit", "/etc/systemd/system/stalwart.service", "systemd unit file to repoint at the new binary")
|
|
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 - must be a directory account, not a config fallback-admin (see README)")
|
|
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"`)
|
|
targetBinary := fs.String("target-binary", "", "use an already-downloaded target binary instead of fetching one")
|
|
targetImage := fs.String("target-image", "", "for a container deployment: the target image, named in full "+
|
|
"(e.g. stalwartlabs/stalwart:v0.16.14). Never guessed from the running container - a derived tag is wrong for a "+
|
|
"digest-pinned image, a mirror or a fork")
|
|
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")
|
|
pythonPath := fs.String("python", "python3", "path to python3")
|
|
stalwartCLI := fs.String("stalwart-cli", "stalwart-cli", "path to stalwart-cli (v1.0.2 or later; a separate download from the server)")
|
|
scriptSHA := fs.String("migration-script-sha256", "", "pinned sha256 of migrate_v016.py")
|
|
scriptPath := fs.String("migration-script", "", "use a local copy of migrate_v016.py instead of fetching it (for a host with no route to the internet)")
|
|
binarySHA := fs.String("target-binary-sha256", "", "pinned sha256 of the target release archive")
|
|
minFree := fs.Float64("min-free-multiple", 2.0, "required free disk space as a multiple of the data directory size")
|
|
recalcQuotas := fs.Bool("recalculate-quotas", true, "schedule the post-migration quota rebuild")
|
|
keepArtifacts := fs.Bool("keep-artifacts", false, "don't delete work-dir/<run-id> afterward. The run's own inputs - the "+
|
|
"settings and principals dumps, the apply plan and its supplement - are kept in state-dir/<run-id> either way; this "+
|
|
"additionally keeps the scratch files around them")
|
|
resume := fs.String("resume", "", "resume an interrupted run by id instead of starting a new one (see `status` for ids)")
|
|
yes := fs.Bool("yes", false, "actually perform the migration")
|
|
containerUnproven := fs.Bool("container-path-unproven", false,
|
|
"acknowledge that the container migration path has never been run against a real Stalwart image. Its logic is "+
|
|
"tested and its refusals are real, but a fake docker proves only that the right commands are assembled - not that "+
|
|
"the image reads the config it is handed. Required for a container deployment")
|
|
recoveryConfirmed := fs.Bool("recovery-point-confirmed", false,
|
|
"confirm you have a snapshot or backup you have verified you can restore - this tool cannot undo a migration")
|
|
if err := fs.Parse(args); err != nil {
|
|
return err
|
|
}
|
|
if *adminURL == "" {
|
|
return fmt.Errorf("--admin-url is required")
|
|
}
|
|
if *newConfigPath == "" {
|
|
*newConfigPath = filepath.Join(filepath.Dir(*configPath), "config.json")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
httpClient := &http.Client{}
|
|
|
|
fmt.Println("This migrates a live mail server in place. It will:")
|
|
fmt.Printf(" 1. check %s, then fetch and verify the %s binary\n", *binaryPath, *targetVersion)
|
|
fmt.Printf(" 2. dump settings from %s while it is still running\n", *adminURL)
|
|
fmt.Printf(" 3. STOP the service - mail is down from here\n")
|
|
fmt.Printf(" 4. convert the settings and migrate the store at %s IN PLACE\n", *dataDir)
|
|
fmt.Printf(" 5. install the new binary at %s and repoint %s\n", *binaryPath, *serviceUnitPath)
|
|
fmt.Printf(" 6. start the service and check it answers\n")
|
|
fmt.Println("\nThis tool cannot undo any of it. Recovery is your snapshot or backup.")
|
|
|
|
if !*recoveryConfirmed {
|
|
return fmt.Errorf("\nrefusing to start: pass --recovery-point-confirmed once you have a snapshot or backup you have " +
|
|
"actually verified you can restore. This tool does not take one and cannot check yours")
|
|
}
|
|
if !*yes {
|
|
fmt.Println("\nnothing has been touched. Re-run with --yes to perform this migration.")
|
|
return nil
|
|
}
|
|
|
|
store := checkpoint.NewStore(*stateDir)
|
|
var rs *checkpoint.RunState
|
|
if *resume != "" {
|
|
// Resuming is not a convenience. A run that fails partway leaves
|
|
// the service stopped and the store part-migrated, and starting
|
|
// over is often impossible: preflight would re-run against a
|
|
// binary that has already been moved aside, and the settings dump
|
|
// needs a live pre-migration instance that no longer exists.
|
|
// Completed steps are skipped from the checkpoint, so this picks
|
|
// up where it stopped.
|
|
if rs, err = store.Load(*resume); err != nil {
|
|
return fmt.Errorf("resume run %s: %w", *resume, err)
|
|
}
|
|
fmt.Printf("\nresuming run: %s (completed steps will be skipped)\n", rs.RunID)
|
|
} else {
|
|
if rs, err = store.Create("", *targetVersion); err != nil {
|
|
return fmt.Errorf("create run: %w", err)
|
|
}
|
|
fmt.Printf("\nrun id: %s\n", rs.RunID)
|
|
}
|
|
runWorkDir := filepath.Join(*workDir, rs.RunID)
|
|
runStateDir := filepath.Join(*stateDir, rs.RunID)
|
|
if err := os.MkdirAll(runWorkDir, 0o750); err != nil {
|
|
return fmt.Errorf("create work dir: %w", err)
|
|
}
|
|
defer func() {
|
|
if *keepArtifacts {
|
|
fmt.Printf("\nartifacts kept at %s (--keep-artifacts)\n", runWorkDir)
|
|
return
|
|
}
|
|
if err != nil {
|
|
// Never clean up after a failure. These files - the settings
|
|
// dump, the converted config and export plan - are the run's
|
|
// inputs, and after the service has been stopped they cannot
|
|
// be regenerated: the dump needs a live pre-migration instance.
|
|
// Deleting them once turned a missing-dependency error into a
|
|
// restore-from-snapshot, because there was no way forward and
|
|
// no way back.
|
|
fmt.Fprintf(os.Stderr, "\nthe run failed; its artifacts are kept at %s\n", runWorkDir)
|
|
fmt.Fprintf(os.Stderr, "resume it once the cause is fixed:\n stalwart-migrate run --resume %s [same flags]\n", rs.RunID)
|
|
return
|
|
}
|
|
if rmErr := os.RemoveAll(runWorkDir); rmErr != nil {
|
|
fmt.Fprintf(os.Stderr, "warning: couldn't clean up %s: %v\n", runWorkDir, rmErr)
|
|
return
|
|
}
|
|
// What is deleted here is scratch. The run's inputs were copied to
|
|
// the state directory before the store was touched, because they
|
|
// cannot be produced again once it has been.
|
|
fmt.Printf("\ncleaned up %s; the run's dumps and apply plan are kept in %s\n", runWorkDir, runStateDir)
|
|
}()
|
|
|
|
fmt.Println("\n--- preflight ---")
|
|
pfReport, err := preflight.New(preflight.Options{
|
|
BinaryPath: *binaryPath, ConfigPath: *configPath, DataDir: *dataDir, ContainerName: *containerName,
|
|
AdminURL: *adminURL, AdminUser: *adminUser, AdminPassword: *adminPassword,
|
|
TargetVersion: *targetVersion, TargetBinaryPath: *targetBinary, MinFreeMultiple: *minFree, HTTPClient: httpClient,
|
|
CLIPath: *stalwartCLI, PythonPath: *pythonPath,
|
|
}).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)
|
|
|
|
// Preflight recorded how this deployment is run, and from here the two
|
|
// differ in three places: what gets staged, what the recovery cycle
|
|
// launches, and what cutover replaces. Everything between them is the
|
|
// same migration.
|
|
isContainer := service.Kind(rs.Topology.DeploymentKind) == service.Docker
|
|
var containerFacts preflight.ContainerFacts
|
|
if isContainer {
|
|
if !*containerUnproven {
|
|
return fmt.Errorf(
|
|
"refusing to start: this is a container deployment, and that path has never been run against a real Stalwart " +
|
|
"image. Its logic is tested and its refusals are real, but a fake docker proves only that the right commands " +
|
|
"are assembled, not that the image reads the config it is handed. Pass --container-path-unproven if you " +
|
|
"accept that, ideally against a clone of production first")
|
|
}
|
|
if *targetImage == "" {
|
|
return fmt.Errorf("refusing to start: a container deployment needs --target-image; it is never guessed from the running container")
|
|
}
|
|
if containerFacts, err = preflight.InspectContainer(ctx, *containerName); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
fmt.Println("\n--- stage ---")
|
|
staged := *targetBinary
|
|
stagedImage := ""
|
|
switch {
|
|
case isContainer:
|
|
img, err := stage.RunImage(ctx, store, rs, stage.ImageOptions{
|
|
Image: *targetImage, TargetVersion: rs.TargetVersion,
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("stage: %w", err)
|
|
}
|
|
stagedImage = img.Ref
|
|
fmt.Println(rs.Outcome(checkpoint.PhaseStage, "stage-image").Detail)
|
|
case staged == "":
|
|
staged = filepath.Join(runWorkDir, "stalwart-"+rs.TargetVersion)
|
|
if staged, err = stage.Run(ctx, store, rs, stage.Options{
|
|
TargetVersion: *targetVersion, DestPath: staged, SHA256: *binarySHA, HTTPClient: httpClient,
|
|
}); err != nil {
|
|
return fmt.Errorf("stage: %w", err)
|
|
}
|
|
fmt.Println(rs.Outcome(checkpoint.PhaseStage, "stage-binary").Detail)
|
|
default:
|
|
fmt.Println("using the already-staged binary at", staged)
|
|
}
|
|
|
|
script := filepath.Join(runWorkDir, "migrate_v016.py")
|
|
settingsPath := filepath.Join(runWorkDir, "settings.json")
|
|
principalsPath := filepath.Join(runWorkDir, "principals.json")
|
|
// The converted config has to be readable by whatever boots next. For a
|
|
// binary that is any path on this host; for a container it has to be
|
|
// somewhere the container already mounts, because cutover recreates it
|
|
// with the mounts it had and cannot invent a new one. So it goes inside
|
|
// the data volume, written on the host side and named on the container
|
|
// side.
|
|
convertedConfig := filepath.Join(runWorkDir, "config.json")
|
|
containerConfigPath := ""
|
|
containerConfigDir, containerConfigOwner := "", ""
|
|
if isContainer {
|
|
mount, ok := containerFacts.MountFor(*dataDir)
|
|
if !ok {
|
|
return fmt.Errorf(
|
|
"refusing to start: --data-dir %s is not covered by any of %s's mounts (%s). For a container it must name the "+
|
|
"path *inside* the container, since that is where its data actually lives",
|
|
*dataDir, *containerName, preflight.DescribeMounts(containerFacts.Mounts))
|
|
}
|
|
hostDir := filepath.Join(mount.Source, "stalwart-migrate")
|
|
if err := os.MkdirAll(hostDir, 0o750); err != nil {
|
|
return fmt.Errorf("create %s (the host side of %s): %w", hostDir, mount.Destination, err)
|
|
}
|
|
convertedConfig = filepath.Join(hostDir, "config.json")
|
|
containerConfigPath = path.Join(mount.Destination, "stalwart-migrate", "config.json")
|
|
containerConfigOwner = mount.Source
|
|
containerConfigDir = hostDir
|
|
}
|
|
convertedExport := filepath.Join(runWorkDir, "export.json")
|
|
unmigratedPath := filepath.Join(runWorkDir, "unmigrated.txt")
|
|
supplementPath := filepath.Join(runWorkDir, "supplement.json")
|
|
|
|
if p.CrossesMajorBoundary {
|
|
fmt.Println("\n--- dump (service still up) ---")
|
|
if _, err := store.RunStep(rs, checkpoint.PhaseBackup, "settings-dump", func() (checkpoint.StepOutcome, error) {
|
|
if _, err := backup.ProvideFile(ctx, httpClient, *scriptPath, backup.DefaultMigrationScriptURL, script, *scriptSHA); err != nil {
|
|
return checkpoint.StepOutcome{}, err
|
|
}
|
|
if err := backup.RunSettingsDump(ctx, backup.SettingsDumpOptions{
|
|
PythonPath: *pythonPath, ScriptPath: script, URL: *adminURL,
|
|
Username: *adminUser, Password: *adminPassword,
|
|
SettingsPath: settingsPath, PrincipalsPath: principalsPath,
|
|
}); err != nil {
|
|
return checkpoint.StepOutcome{}, err
|
|
}
|
|
return checkpoint.StepOutcome{Detail: "dumped settings and principals"}, nil
|
|
}); err != nil {
|
|
return fmt.Errorf("settings dump: %w", err)
|
|
}
|
|
fmt.Println("dumped settings and principals")
|
|
}
|
|
|
|
fmt.Println("\n--- preserve the old binary ---")
|
|
if _, err := store.RunStep(rs, checkpoint.PhaseBackup, "preserve-binary", func() (checkpoint.StepOutcome, error) {
|
|
preserved, err := backup.PreserveBinary(*binaryPath, rs.SourceVersion)
|
|
if err != nil {
|
|
return checkpoint.StepOutcome{}, err
|
|
}
|
|
sum, size, err := backup.HashFile(preserved)
|
|
if err != nil {
|
|
return checkpoint.StepOutcome{}, err
|
|
}
|
|
rs.RecordArtifact("old-binary", checkpoint.Artifact{Path: preserved, SHA256: sum, SizeBytes: size})
|
|
return checkpoint.StepOutcome{Detail: "preserved " + preserved}, nil
|
|
}); err != nil {
|
|
return fmt.Errorf("preserve binary: %w", err)
|
|
}
|
|
fmt.Println(rs.Outcome(checkpoint.PhaseBackup, "preserve-binary").Detail)
|
|
|
|
controller, err := service.New(service.Options{
|
|
Kind: service.Kind(rs.Topology.DeploymentKind), UnitName: *unitName, ContainerName: *containerName,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
fmt.Println("\n--- stopping the service: MAIL IS DOWN FROM HERE ---")
|
|
windowStart := time.Now()
|
|
if _, err := store.RunStep(rs, checkpoint.PhaseCutover, "stop-service", func() (checkpoint.StepOutcome, error) {
|
|
if err := controller.Stop(ctx); err != nil {
|
|
return checkpoint.StepOutcome{}, err
|
|
}
|
|
if err := service.WaitFor(ctx, controller, false, 2*time.Minute); err != nil {
|
|
return checkpoint.StepOutcome{}, err
|
|
}
|
|
return checkpoint.StepOutcome{Detail: controller.Target() + " is stopped"}, nil
|
|
}); err != nil {
|
|
return fmt.Errorf("stop service: %w", err)
|
|
}
|
|
fmt.Println(controller.Target(), "stopped")
|
|
|
|
// Mail is down from here, and every return below is a return with it
|
|
// still down. Registered after the stop rather than before, so it only
|
|
// ever restarts something this tool actually stopped.
|
|
//
|
|
// It does not pretend to have recovered the migration: a run that
|
|
// aborted midway is still part-migrated and still needs --resume or the
|
|
// operator's recovery point. What it prevents is the narrower and worse
|
|
// outcome of the tool exiting on an error it could see coming while the
|
|
// server it stopped stays stopped.
|
|
defer func() {
|
|
if err == nil {
|
|
return
|
|
}
|
|
// The run's context may already be cancelled - that can be why we
|
|
// are here - and a cancelled context cannot start anything.
|
|
restartCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 3*time.Minute)
|
|
defer cancel()
|
|
active, activeErr := controller.Active(restartCtx)
|
|
if activeErr == nil && active {
|
|
return
|
|
}
|
|
fmt.Println("\n--- this run failed with", controller.Target(), "stopped: starting it again ---")
|
|
if startErr := controller.Start(restartCtx); startErr != nil {
|
|
fmt.Printf("could not start %s: %v\n", controller.Target(), startErr)
|
|
fmt.Println("MAIL IS STILL DOWN - start it by hand before anything else.")
|
|
return
|
|
}
|
|
if waitErr := service.WaitFor(restartCtx, controller, true, 2*time.Minute); waitErr != nil {
|
|
fmt.Printf("%s was asked to start but did not come up: %v\n", controller.Target(), waitErr)
|
|
fmt.Println("MAIL IS STILL DOWN - check it by hand before anything else.")
|
|
return
|
|
}
|
|
fmt.Println(controller.Target(), "is running again. The migration itself did not complete - see the error below.")
|
|
}()
|
|
|
|
if p.CrossesMajorBoundary {
|
|
fmt.Println("\n--- convert ---")
|
|
if _, err := store.RunStep(rs, checkpoint.PhaseStage, "convert-settings", func() (checkpoint.StepOutcome, error) {
|
|
if err := backup.RunSettingsConvert(ctx, backup.SettingsConvertOptions{
|
|
PythonPath: *pythonPath, ScriptPath: script,
|
|
SettingsPath: settingsPath, PrincipalsPath: principalsPath,
|
|
ConfigPath: convertedConfig, OutputPath: convertedExport, WorkDir: runWorkDir,
|
|
}); err != nil {
|
|
return checkpoint.StepOutcome{}, err
|
|
}
|
|
// Repair migrate_v016.py's domain/tenant mismatch before the
|
|
// plan is ever applied. Left alone it surfaces as
|
|
// "invalidForeignKey | Object id: Domain#..." partway through
|
|
// apply - with the old service already stopped and the store
|
|
// already at schema v6, i.e. at the one point in the run where
|
|
// there is no way forward and no way back. See
|
|
// applyplan/tenants.go.
|
|
tenantFix, err := applyplan.ReconcileDomainTenantsFile(convertedExport)
|
|
if err != nil {
|
|
return checkpoint.StepOutcome{}, err
|
|
}
|
|
detail := "converted settings into a v0.16 apply plan"
|
|
if len(tenantFix.Adoptions) > 0 {
|
|
detail += " - " + tenantFix.String()
|
|
}
|
|
// The container reads this config, and it does not run as
|
|
// root. Done here rather than at cutover because the recovery
|
|
// cycle is the first thing to open it, and a config it cannot
|
|
// read surfaces there as a boot that never comes up.
|
|
if containerConfigOwner != "" {
|
|
owner, err := matchOwnership(containerConfigOwner, containerConfigDir, convertedConfig)
|
|
if err != nil {
|
|
return checkpoint.StepOutcome{}, err
|
|
}
|
|
detail += fmt.Sprintf("; %s is owned %s", convertedConfig, owner)
|
|
}
|
|
return checkpoint.StepOutcome{Detail: detail}, nil
|
|
}); err != nil {
|
|
return fmt.Errorf("convert settings: %w", err)
|
|
}
|
|
fmt.Println(rs.Outcome(checkpoint.PhaseStage, "convert-settings").Detail)
|
|
unmigrated, readErr := backup.ReadUnmigratedReport(unmigratedPath)
|
|
if readErr == nil && unmigrated != nil && unmigrated.TotalKeys > 0 {
|
|
keptWorklist := filepath.Join(runStateDir, "unmigrated.txt")
|
|
if copyErr := copyFile(unmigratedPath, keptWorklist); copyErr == nil {
|
|
if sum, size, hashErr := backup.HashFile(keptWorklist); hashErr == nil {
|
|
rs.RecordArtifact("unmigrated-settings", checkpoint.Artifact{Path: keptWorklist, SHA256: sum, SizeBytes: size})
|
|
}
|
|
}
|
|
fmt.Println(unmigrated.Classify().Summary(keptWorklist))
|
|
}
|
|
|
|
fmt.Println("\n--- supplemental plan ---")
|
|
applyFiles := []string{convertedExport}
|
|
if err := buildSupplement(settingsPath, principalsPath, unmigratedPath, supplementPath); err != nil {
|
|
fmt.Fprintf(os.Stderr, "warning: couldn't generate the supplemental plan: %v\n", err)
|
|
} else {
|
|
applyFiles = append(applyFiles, supplementPath)
|
|
}
|
|
|
|
if _, err := store.RunStep(rs, checkpoint.PhaseBackup, "preserve-plan", func() (checkpoint.StepOutcome, error) {
|
|
kept, err := preservePlan(runStateDir, map[string]string{
|
|
"settings-dump": settingsPath,
|
|
"principals-dump": principalsPath,
|
|
"converted-export": convertedExport,
|
|
"supplement": supplementPath,
|
|
}, rs)
|
|
if err != nil {
|
|
return checkpoint.StepOutcome{}, err
|
|
}
|
|
return checkpoint.StepOutcome{Detail: fmt.Sprintf("kept %s in %s", strings.Join(kept, ", "), runStateDir)}, nil
|
|
}); err != nil {
|
|
return fmt.Errorf("preserve the run's plan: %w", err)
|
|
}
|
|
fmt.Println(rs.Outcome(checkpoint.PhaseBackup, "preserve-plan").Detail)
|
|
|
|
fmt.Println("\n--- recovery-mode migration (the store is migrated IN PLACE) ---")
|
|
recOpts := recovery.Options{
|
|
BinaryPath: staged, ConfigPath: convertedConfig,
|
|
ListenURL: "http://127.0.0.1:8080/", AdminUser: "admin",
|
|
ApplyFiles: applyFiles, CLIBinaryPath: *stalwartCLI,
|
|
StartupTimeout: 20 * time.Minute, HTTPClient: httpClient,
|
|
}
|
|
if isContainer {
|
|
// The live container is stopped, so the recovery one takes its
|
|
// mounts and publishes recovery mode's listener where the
|
|
// health check on this side can reach it.
|
|
mounts := make([]recovery.ContainerMount, 0, len(containerFacts.Mounts))
|
|
for _, m := range containerFacts.Mounts {
|
|
src := m.Name
|
|
if src == "" {
|
|
src = m.Source
|
|
}
|
|
mounts = append(mounts, recovery.ContainerMount{Source: src, Destination: m.Destination, ReadOnly: !m.RW})
|
|
}
|
|
recOpts.ConfigPath = containerConfigPath
|
|
recOpts.Launcher = recovery.ContainerLauncher{
|
|
Image: stagedImage, Mounts: mounts,
|
|
Name: *containerName + "-migrate-recovery",
|
|
Publish: []string{"127.0.0.1:8080:8080"},
|
|
}
|
|
}
|
|
recReport, err := recovery.Run(ctx, store, rs, recOpts)
|
|
fmt.Print(recReport.String())
|
|
if err != nil {
|
|
return fmt.Errorf("recovery-mode migration failed - the store may be part-migrated and the service is still "+
|
|
"stopped; restore your recovery point rather than restarting the old version against it: %w", err)
|
|
}
|
|
}
|
|
|
|
fmt.Println("\n--- cutover ---")
|
|
configSource := convertedConfig
|
|
if !p.CrossesMajorBoundary {
|
|
configSource = "" // a patch bump keeps its existing config
|
|
}
|
|
cutReport, err := cutover.Run(ctx, store, rs, cutover.Options{
|
|
StagedBinaryPath: staged, BinaryPath: *binaryPath,
|
|
ServiceUnitPath: *serviceUnitPath, ConfigPath: *newConfigPath,
|
|
ConfigSource: configSource, ConfigOwnerReference: *configPath,
|
|
Deployment: service.Options{Kind: service.Kind(rs.Topology.DeploymentKind), UnitName: *unitName, ContainerName: *containerName},
|
|
Container: containerCutover(isContainer, *containerName, stagedImage, runStateDir, containerConfigOf(p, containerConfigPath)),
|
|
RecoveryPointConfirmed: *recoveryConfirmed,
|
|
AdminURL: *adminURL, AdminUser: *adminUser, AdminPassword: *adminPassword,
|
|
HTTPClient: httpClient, RecalculateQuotas: *recalcQuotas && p.CrossesMajorBoundary,
|
|
StartTimeout: 3 * time.Minute, HealthTimeout: 5 * time.Minute, QuotaTimeout: 30 * time.Minute,
|
|
})
|
|
fmt.Print(cutReport.String())
|
|
if err != nil {
|
|
return fmt.Errorf("cutover failed: %w", err)
|
|
}
|
|
|
|
fmt.Println("\n--- validate ---")
|
|
valReport, valErr := validate.RunLive(ctx, store, rs, validate.LiveOptions{
|
|
AdminURL: *adminURL, AdminUser: *adminUser, AdminPassword: *adminPassword,
|
|
HTTPClient: httpClient, Before: rs.PreflightSnapshot,
|
|
})
|
|
fmt.Print(valReport.String())
|
|
if valErr != nil {
|
|
return fmt.Errorf("the migrated service is up, but validation could not complete - check it by hand before "+
|
|
"treating this migration as done: %w", valErr)
|
|
}
|
|
if valReport.Blocking() {
|
|
// The service is live and serving mail; that is deliberately not
|
|
// undone here. The operator has the finding and their recovery
|
|
// point, and only they can weigh one against the other.
|
|
return fmt.Errorf("the migrated service is up, but accounts or domains from before the migration are missing " +
|
|
"from it - see the FAIL line above. Your recovery point is the way back; this tool will not undo a migration")
|
|
}
|
|
|
|
fmt.Printf("\nMIGRATION COMPLETE for run %s. Mail was down for %s.\n",
|
|
rs.RunID, time.Since(windowStart).Round(time.Second))
|
|
fmt.Printf("Now: confirm you can log in as %s, send and receive a test message, and work through\n", *adminUser)
|
|
fmt.Printf("the settings that did not carry over: %s\n", filepath.Join(runStateDir, "unmigrated.txt"))
|
|
return nil
|
|
}
|
|
|
|
// buildSupplement generates the apply plan for what migrate_v016.py leaves
|
|
// behind - listeners, without which the migrated server binds nothing, and
|
|
// administrator roles, without which nobody can administer it.
|
|
func buildSupplement(settingsPath, principalsPath, unmigratedPath, outPath string) error {
|
|
settings, err := backup.ReadSettingsDump(settingsPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
unmigrated, err := backup.ReadUnmigratedKeys(unmigratedPath, settings)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
p, coverage, err := applyplan.Build(settings, unmigrated, applyplan.DefaultGenerators())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
principals, err := backup.ReadPrincipalsDump(principalsPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
roleOps, _, roleWarnings, err := applyplan.AccountRoleOperations(principals)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
p.Operations = append(p.Operations, roleOps...)
|
|
if len(p.Operations) == 0 {
|
|
return fmt.Errorf("nothing to generate")
|
|
}
|
|
if err := p.WriteNDJSON(outPath); err != nil {
|
|
return err
|
|
}
|
|
fmt.Println(coverage.Summary(4))
|
|
fmt.Printf(" + %d account-role operation(s)\n", len(roleOps))
|
|
for _, w := range append(coverage.Warnings, roleWarnings...) {
|
|
fmt.Printf(" warning: %s\n", w)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// containerCutover is the cutover options for a container deployment, or
|
|
// nil for a binary one. Nil is what keeps cutover refusing a container it
|
|
// was given no image to recreate from.
|
|
// containerConfigOf is the container-side config path cutover should start
|
|
// the new container with, which is none at all for a patch bump: nothing
|
|
// was converted, so the container keeps the command its image gives it and
|
|
// the configuration it was already running under.
|
|
func containerConfigOf(p *plan.Plan, configPath string) string {
|
|
if !p.CrossesMajorBoundary {
|
|
return ""
|
|
}
|
|
return configPath
|
|
}
|
|
|
|
func containerCutover(isContainer bool, name, image, preserveDir, configPath string) *cutover.ContainerOptions {
|
|
if !isContainer {
|
|
return nil
|
|
}
|
|
// configPath is the container-side path to the migrated config, and is
|
|
// empty for a patch bump that converted nothing - there the container
|
|
// keeps whatever command its image gives it, which is what it was
|
|
// already running under.
|
|
return &cutover.ContainerOptions{
|
|
ContainerName: name, StagedImage: image, PreserveDir: preserveDir, ConfigPath: configPath,
|
|
}
|
|
}
|