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.
72 lines
2.7 KiB
Go
72 lines
2.7 KiB
Go
// 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
|
|
}
|