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
+71
View File
@@ -0,0 +1,71 @@
// SPDX-FileCopyrightText: 2026 LINUXexpert-org
// SPDX-License-Identifier: GPL-3.0-or-later
package main
import (
"fmt"
"os"
"path/filepath"
"sort"
"github.com/LINUXexpert-org/stalwart-migrator/internal/backup"
"github.com/LINUXexpert-org/stalwart-migrator/internal/checkpoint"
)
// preservePlan lifts the run's irreplaceable inputs out of the scratch
// directory into the run's state directory, which is never cleaned up, and
// records each as a checkpoint artifact.
//
// These four are not intermediate files. The settings and principals dumps
// can only be taken from a live pre-migration instance, and the apply plan
// and its supplement are what was actually replayed into the store - so
// after cutover there is no way to produce any of them again. Until this
// existed they lived only in --work-dir, which a successful run deletes,
// and `rehearse` kept more of its conclusions than `run` did: the
// read-only command preserved the plan and the destructive one threw it
// away.
//
// What made that 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. Re-applying the original run's
// export.json and supplement.json against a fresh recovery boot is what
// got their server back, twice, on two different machines - and they had
// them only because they had passed --keep-artifacts. Nobody should need
// to have guessed that in advance. Reported by @kaya-eu in #1.
//
// A file that isn't there is skipped rather than failing the step: a patch
// bump converts nothing, and a supplement that couldn't be generated is
// already a warning of its own.
func preservePlan(stateDir string, files map[string]string, rs *checkpoint.RunState) ([]string, error) {
if stateDir == "" {
return nil, fmt.Errorf("no state directory to preserve the run's plan in")
}
names := make([]string, 0, len(files))
for name := range files {
names = append(names, name)
}
sort.Strings(names)
var kept []string
for _, name := range names {
src := files[name]
if _, err := os.Stat(src); err != nil {
continue
}
dst := filepath.Join(stateDir, filepath.Base(src))
if err := copyFile(src, dst); err != nil {
return nil, fmt.Errorf("preserve %s as %s: %w", src, dst, err)
}
sum, size, err := backup.HashFile(dst)
if err != nil {
return nil, fmt.Errorf("hash %s: %w", dst, err)
}
rs.RecordArtifact(name, checkpoint.Artifact{Path: dst, SHA256: sum, SizeBytes: size})
kept = append(kept, filepath.Base(dst))
}
if len(kept) == 0 {
return nil, fmt.Errorf("none of the run's plan files exist to preserve - the conversion produced nothing")
}
return kept, nil
}