Files
stalwart-migrator/cmd/stalwart-migrate/run.go
T
jcoffey-dev e96e72bf79 Tell what a container inherits from what it overrides
Checked against a real stalwartlabs/stalwart image, `docker inspect` on an
ordinary container reports User "stalwart", Entrypoint
["/usr/local/bin/stalwart"] and Cmd ["--config",
"/etc/stalwart/config.json"] — all three inherited, none of them given.

Two things followed from reading those as the operator's.

A container user was listed as configuration a recreate would drop, so
every container off the official image was refused as unrecreatable. That
refusal lived in cutover, downstream of the stop, the settings conversion
and the store migration: it arrived with mail down and data already
moved, which is the failure issue #1 was filed for. Each of the three is
now compared against `docker image inspect` of the image the container is
on. Inherited values are left to the new image, whose own defaults are
the ones that go with it. Overrides are carried: --user, --entrypoint,
and the rest of an entrypoint as leading argv. Cmd and Entrypoint were
not being read at all, so an overridden one was silently dropped — the
exact loss the unsupported list exists to prevent.

The recreatability question also moved into preflight, while the server
is still running. Cutover asks it again, since the two are separated by
the whole migration, but only one of them can refuse without cost.

The other half: the recreated container is now started with `--config`
pointing at the migrated config in the data volume. Left to the image's
default command it came up on /etc/stalwart/config.json — a different
volume, holding whatever the old version left there — so cutover would
have produced a running server with nothing to do with the migration that
preceded it. An overridden command and that --config are the same argv
and cannot be merged honestly, so a container with one is refused and
told why.

The config is also chowned to whatever owns the data directory, before
the recovery cycle opens it. The image runs as uid 2000 and this tool
writes as root; §4.8 is the standing reminder that byte-perfect and
unreadable is a way to report success.

Found while checking @kaya-eu's field report in #1 against a real image.
Their three manual migrations are where the config step comes from.
2026-08-29 17:45:23 -07:00

564 lines
26 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"
"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")
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)
}
}()
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)
}
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,
}
}