Merge pull request #12 from LINUXexpert-org/keep-the-runs-plan
Keep the four files a run cannot produce again
This commit is contained in:
@@ -374,7 +374,13 @@ if the gap matters to you.
|
|||||||
- **The original service definition is preserved** as `<unit>.pre-<run-id>`
|
- **The original service definition is preserved** as `<unit>.pre-<run-id>`
|
||||||
before cutover rewrites it, so you aren't reconstructing a unit file from
|
before cutover rewrites it, so you aren't reconstructing a unit file from
|
||||||
memory.
|
memory.
|
||||||
- **The settings and principals dumps** taken during backup stay on disk.
|
- **The settings and principals dumps, the apply plan and its supplement**
|
||||||
|
are kept in `<state-dir>/<run-id>` — `/var/lib/stalwart-migrator/runs/<run-id>`
|
||||||
|
unless you moved it. These four are the only files in a run that cannot be
|
||||||
|
produced again afterwards: the dumps need a live pre-migration instance,
|
||||||
|
and the plan is what was actually replayed into your store. They are kept
|
||||||
|
whether or not the run succeeded and whether or not you passed
|
||||||
|
`--keep-artifacts`.
|
||||||
- **Every artifact path and checksum is in the checkpoint**, and
|
- **Every artifact path and checksum is in the checkpoint**, and
|
||||||
`stalwart-migrate status <run-id>` prints exactly which steps completed
|
`stalwart-migrate status <run-id>` prints exactly which steps completed
|
||||||
and which failed — which is the first thing you want when deciding what to
|
and which failed — which is the first thing you want when deciding what to
|
||||||
@@ -383,6 +389,31 @@ if the gap matters to you.
|
|||||||
None of this is a substitute for the snapshot. It's what makes the twenty
|
None of this is a substitute for the snapshot. It's what makes the twenty
|
||||||
minutes after restoring one less unpleasant.
|
minutes after restoring one less unpleasant.
|
||||||
|
|
||||||
|
### Do not boot recovery mode again afterwards
|
||||||
|
|
||||||
|
The migration works by starting the new version once in recovery mode,
|
||||||
|
replaying your settings into it, and stopping it. That is a one-time step
|
||||||
|
in a migration, and it is worth knowing that it is not a general-purpose
|
||||||
|
maintenance mode.
|
||||||
|
|
||||||
|
An operator who booted recovery mode again — the same way the migration
|
||||||
|
does, `STALWART_RECOVERY_MODE=1` against the same data directory — for
|
||||||
|
reasons unrelated to the migration, on a server that had migrated
|
||||||
|
successfully days earlier, found that `Domain` and `Account` queries came
|
||||||
|
back empty on the next normal start. This happened twice, on two different
|
||||||
|
servers. It was not a stale read: creating a domain that had certainly
|
||||||
|
existed a moment earlier succeeded, with no `primaryKeyViolation`, so the
|
||||||
|
records were genuinely gone. Disk usage did not change.
|
||||||
|
|
||||||
|
What recovered it both times was re-applying that run's `export.json` and
|
||||||
|
`supplement.json` against a fresh recovery boot, which is why those two
|
||||||
|
files are now kept for you. If you need to change something after a
|
||||||
|
migration, use the admin API or `stalwart-cli` against the running server.
|
||||||
|
|
||||||
|
This is Stalwart's behaviour rather than this tool's, and it is reported
|
||||||
|
here because this tool is where you learned the technique. Reported by
|
||||||
|
[@kaya-eu](https://github.com/LINUXexpert-org/stalwart-migrator/issues/1).
|
||||||
|
|
||||||
### Why it works this way
|
### Why it works this way
|
||||||
|
|
||||||
An earlier version of this tool implemented rollback itself: it restored the
|
An earlier version of this tool implemented rollback itself: it restored the
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 LINUXexpert-org
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/LINUXexpert-org/stalwart-migrator/internal/checkpoint"
|
||||||
|
)
|
||||||
|
|
||||||
|
func writeTemp(t *testing.T, dir, name, body string) string {
|
||||||
|
t.Helper()
|
||||||
|
p := filepath.Join(dir, name)
|
||||||
|
if err := os.WriteFile(p, []byte(body), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRunState(t *testing.T) *checkpoint.RunState {
|
||||||
|
t.Helper()
|
||||||
|
rs, err := checkpoint.NewStore(t.TempDir()).Create("0.15.5", "0.16.19")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return rs
|
||||||
|
}
|
||||||
|
|
||||||
|
// The dumps can only be taken from a live pre-migration instance and the
|
||||||
|
// apply plan is what was replayed into the store, so after cutover none of
|
||||||
|
// them can be produced again. They used to live only in the work dir,
|
||||||
|
// which a successful run deletes.
|
||||||
|
func TestPreservePlanKeepsTheRunsInputsOutOfTheScratchDirectory(t *testing.T) {
|
||||||
|
work, state := t.TempDir(), t.TempDir()
|
||||||
|
rs := newRunState(t)
|
||||||
|
|
||||||
|
kept, err := preservePlan(state, map[string]string{
|
||||||
|
"settings-dump": writeTemp(t, work, "settings.json", `{"settings":1}`),
|
||||||
|
"principals-dump": writeTemp(t, work, "principals.json", `{"principals":1}`),
|
||||||
|
"converted-export": writeTemp(t, work, "export.json", `[{"@type":"create"}]`),
|
||||||
|
"supplement": writeTemp(t, work, "supplement.json", `[]`),
|
||||||
|
}, rs)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("preservePlan: %v", err)
|
||||||
|
}
|
||||||
|
if len(kept) != 4 {
|
||||||
|
t.Errorf("kept %v, want all four", kept)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The work dir is what gets deleted; what matters is that the state
|
||||||
|
// dir now stands on its own.
|
||||||
|
if err := os.RemoveAll(work); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, name := range []string{"settings-dump", "principals-dump", "converted-export", "supplement"} {
|
||||||
|
art, ok := rs.Artifacts[name]
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("%s was not recorded as an artifact", name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(art.Path, state) {
|
||||||
|
t.Errorf("%s kept at %s, want it inside the state dir", name, art.Path)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(art.Path); err != nil {
|
||||||
|
t.Errorf("%s does not survive the work dir being cleaned: %v", name, err)
|
||||||
|
}
|
||||||
|
if art.SHA256 == "" || art.SizeBytes == 0 {
|
||||||
|
t.Errorf("%s recorded without a checksum or size: %+v", name, art)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A patch bump converts nothing and a supplement that could not be
|
||||||
|
// generated is already a warning of its own, so an absent file is skipped
|
||||||
|
// rather than failing the step.
|
||||||
|
func TestPreservePlanSkipsWhatIsNotThere(t *testing.T) {
|
||||||
|
work, state := t.TempDir(), t.TempDir()
|
||||||
|
rs := newRunState(t)
|
||||||
|
|
||||||
|
kept, err := preservePlan(state, map[string]string{
|
||||||
|
"converted-export": writeTemp(t, work, "export.json", `[]`),
|
||||||
|
"supplement": filepath.Join(work, "never-written.json"),
|
||||||
|
}, rs)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("preservePlan: %v", err)
|
||||||
|
}
|
||||||
|
if len(kept) != 1 || kept[0] != "export.json" {
|
||||||
|
t.Errorf("kept %v, want just export.json", kept)
|
||||||
|
}
|
||||||
|
if _, ok := rs.Artifacts["supplement"]; ok {
|
||||||
|
t.Error("a file that was never written should not be recorded as an artifact")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing at all to preserve means the conversion produced nothing, which
|
||||||
|
// is not a state to carry quietly into a migration.
|
||||||
|
func TestPreservePlanRefusesWhenThereIsNothingToKeep(t *testing.T) {
|
||||||
|
work, state := t.TempDir(), t.TempDir()
|
||||||
|
rs := newRunState(t)
|
||||||
|
|
||||||
|
if _, err := preservePlan(state, map[string]string{
|
||||||
|
"converted-export": filepath.Join(work, "absent.json"),
|
||||||
|
}, rs); err == nil {
|
||||||
|
t.Fatal("want an error when none of the plan files exist")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/LINUXexpert-org/stalwart-migrator/internal/applyplan"
|
"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")
|
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")
|
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")
|
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)")
|
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")
|
yes := fs.Bool("yes", false, "actually perform the migration")
|
||||||
containerUnproven := fs.Bool("container-path-unproven", false,
|
containerUnproven := fs.Bool("container-path-unproven", false,
|
||||||
@@ -158,7 +161,12 @@ func runRun(args []string) (err error) {
|
|||||||
}
|
}
|
||||||
if rmErr := os.RemoveAll(runWorkDir); rmErr != nil {
|
if rmErr := os.RemoveAll(runWorkDir); rmErr != nil {
|
||||||
fmt.Fprintf(os.Stderr, "warning: couldn't clean up %s: %v\n", runWorkDir, rmErr)
|
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 ---")
|
fmt.Println("\n--- preflight ---")
|
||||||
@@ -416,6 +424,22 @@ func runRun(args []string) (err error) {
|
|||||||
applyFiles = append(applyFiles, supplementPath)
|
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) ---")
|
fmt.Println("\n--- recovery-mode migration (the store is migrated IN PLACE) ---")
|
||||||
recOpts := recovery.Options{
|
recOpts := recovery.Options{
|
||||||
BinaryPath: staged, ConfigPath: convertedConfig,
|
BinaryPath: staged, ConfigPath: convertedConfig,
|
||||||
|
|||||||
Reference in New Issue
Block a user