Keep the four files a run cannot produce again

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.
This commit is contained in:
2026-08-29 17:50:56 -07:00
parent f3b8994f60
commit f324d66c5c
4 changed files with 238 additions and 2 deletions
+25 -1
View File
@@ -11,6 +11,7 @@ import (
"os"
"path"
"path/filepath"
"strings"
"time"
"github.com/LINUXexpert-org/stalwart-migrator/internal/applyplan"
@@ -74,7 +75,9 @@ func runRun(args []string) (err error) {
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")
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,
@@ -158,7 +161,12 @@ func runRun(args []string) (err error) {
}
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 ---")
@@ -416,6 +424,22 @@ func runRun(args []string) (err error) {
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,