diff --git a/README.md b/README.md index 71e2e4e..45bb481 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,18 @@ open: some store migrations need one more recovery-mode boot, which is a manual step, and the Docker path has never completed a migration against a real Stalwart image. Details and field reports: [Status](docs/status.md). +## Support + +| Upgrade | Status | +| --- | --- | +| 0.15.5 → 0.16.27 | Supported. `--target` defaults to 0.16.27, the last 0.16 release | +| 0.16 → 1.0 | Not yet. The route is designed in [docs/design-1.0.md](docs/design-1.0.md) but not built, so the tool refuses a 1.0 target | +| Anything else | Not supported | + +The 0.15.5 → 0.16 path gets bug and security fixes. If a Stalwart release +changes its upgrade path in a way the tool can't follow, the tool refuses that +target, and this table says so. + ## License Copyright (C) 2026 Coffey Labs LLC. GPL-3.0-or-later: free software, with no diff --git a/cmd/stalwart-migrate/preflight.go b/cmd/stalwart-migrate/preflight.go index 73b5970..e51e8ef 100644 --- a/cmd/stalwart-migrate/preflight.go +++ b/cmd/stalwart-migrate/preflight.go @@ -23,7 +23,7 @@ func runPreflight(args []string) error { adminUser := fs.String("admin-user", "", "admin username for the reachability check") adminPassword := fs.String("admin-password", os.Getenv("STALWART_MIGRATE_ADMIN_PASSWORD"), "admin password for the reachability check (or set STALWART_MIGRATE_ADMIN_PASSWORD)") - targetVersion := fs.String("target", "latest", `target Stalwart version, or "latest"`) + targetVersion := fs.String("target", preflight.DefaultTargetVersion, `target Stalwart version, or "latest" (1.0 and later are refused for now)`) targetBinary := fs.String("target-binary", "", "read the target version from this already-downloaded binary instead of the release API (for a host with no route to the internet)") minFree := fs.Float64("min-free-multiple", 2.0, "required free disk space as a multiple of the data directory size") stateDir := fs.String("state-dir", checkpoint.DefaultBaseDir, "directory to store run checkpoints in") diff --git a/cmd/stalwart-migrate/rehearse.go b/cmd/stalwart-migrate/rehearse.go index 1ff00a5..f474663 100644 --- a/cmd/stalwart-migrate/rehearse.go +++ b/cmd/stalwart-migrate/rehearse.go @@ -48,7 +48,7 @@ func runRehearse(args []string) (err error) { adminUser := fs.String("admin-user", "", "admin username") 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"`) + targetVersion := fs.String("target", preflight.DefaultTargetVersion, `target Stalwart version, or "latest" (1.0 and later are refused for now)`) 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 for the dumps and converted plan (cleaned up afterward - see --keep-artifacts)") pythonPath := fs.String("python", "python3", "path to python3") diff --git a/cmd/stalwart-migrate/run.go b/cmd/stalwart-migrate/run.go index eab5de7..7069f5c 100644 --- a/cmd/stalwart-migrate/run.go +++ b/cmd/stalwart-migrate/run.go @@ -61,7 +61,7 @@ func runRun(args []string) (err error) { 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"`) + targetVersion := fs.String("target", preflight.DefaultTargetVersion, `target Stalwart version, or "latest" (1.0 and later are refused for now)`) 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 "+ diff --git a/docs/docker.md b/docs/docker.md index b90824c..930611d 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -20,7 +20,7 @@ goes double here. ## Flags - `--target-image` names the image in full, e.g. - `stalwartlabs/stalwart:v0.16.14`. It is never derived from the running + `stalwartlabs/stalwart:v0.16.27`. It is never derived from the running container by swapping the tag — that is wrong for a digest-pinned image, a mirror or a fork, and being wrong means pulling the wrong software into a mail server. diff --git a/docs/rehearsal.md b/docs/rehearsal.md index b08e077..b217c2a 100644 --- a/docs/rehearsal.md +++ b/docs/rehearsal.md @@ -35,7 +35,7 @@ password with `--admin-password` or `STALWART_MIGRATE_ADMIN_PASSWORD`. ```sh stalwart-migrate rehearse --admin-url https://mail.example.com \ - --admin-user admin --target 0.16.14 + --admin-user admin --target 0.16.27 ``` It runs preflight, dumps your settings and principals, converts them with diff --git a/internal/plan/plan.go b/internal/plan/plan.go index cb17da9..f10e188 100644 --- a/internal/plan/plan.go +++ b/internal/plan/plan.go @@ -103,6 +103,13 @@ func Decide(sourceVersion, targetVersion string) (*Plan, error) { if src.Compare(tgt) >= 0 { return nil, fmt.Errorf("plan: source %s is already at or beyond target %s - nothing to migrate", src, tgt) } + // 1.0 can't run on a 0.16 store: it needs the data copied into a new + // one, which this tool doesn't do yet (docs/design-1.0.md). Treating + // 0.16 -> 1.0 as a patch bump would swap the binary over a store it + // can't read. + if tgt.Major >= 1 { + return nil, fmt.Errorf("plan: target %s is Stalwart 1.0 or later, which this tool can't migrate to yet - target 0.16.27, the last 0.16 release", tgt) + } crosses := src.Major == 0 && src.Minor < 16 && (tgt.Major > 0 || tgt.Minor >= 16) if crosses { diff --git a/internal/plan/plan_test.go b/internal/plan/plan_test.go index a9d3d82..9a8f400 100644 --- a/internal/plan/plan_test.go +++ b/internal/plan/plan_test.go @@ -52,15 +52,18 @@ func TestDecideRefusesRegression(t *testing.T) { } } -func TestDecideFutureMajorCrossesBoundaryToo(t *testing.T) { - // A hypothetical 0.15.x -> 1.0.0 jump should still be treated as - // crossing the boundary this tool knows how to automate. - p, err := Decide("0.15.9", "1.0.0") - if err != nil { - t.Fatalf("Decide: %v", err) +func TestDecideRefusesStalwart1(t *testing.T) { + // This used to accept 0.15.x -> 1.0.0 as one more crossing of the + // 0.15/0.16 boundary. Stalwart 1.0 shipped needing its data copied into + // a new store, which no plan here does, so both routes to it are + // refused - 0.16.x -> 1.0.0 would otherwise pass as a patch bump. + for _, src := range []string{"0.15.5", "0.16.22", "0.16.27"} { + if p, err := Decide(src, "1.0.0"); err == nil { + t.Errorf("Decide(%s, 1.0.0) = %+v, want a refusal", src, p) + } } - if !p.CrossesMajorBoundary { - t.Error("CrossesMajorBoundary = false, want true for 0.15.9 -> 1.0.0") + if _, err := Decide("0.15.5", "0.16.27"); err != nil { + t.Errorf("Decide(0.15.5, 0.16.27): %v", err) } } diff --git a/internal/preflight/checks.go b/internal/preflight/checks.go index c892b25..951612d 100644 --- a/internal/preflight/checks.go +++ b/internal/preflight/checks.go @@ -179,6 +179,12 @@ func (c *Checker) Run(ctx context.Context, store *checkpoint.Store, rs *checkpoi Detail: fmt.Sprintf("current version %s is already at or beyond target %s - nothing to migrate", curV, tgtV), }, "" } + if tgtV.Major >= 1 { + return CheckResult{ + Status: StatusFail, + Detail: fmt.Sprintf("target %s is Stalwart 1.0 or later, which needs its data copied into a new store - this tool can't do that yet (docs/design-1.0.md); pass --target %s, the last 0.16 release", tgtV, DefaultTargetVersion), + }, "" + } if curV.Major == 0 && curV.Minor < 16 && (tgtV.Major > 0 || tgtV.Minor >= 16) { return CheckResult{ Status: StatusOK, diff --git a/internal/preflight/checks_test.go b/internal/preflight/checks_test.go index 67e560c..371b03b 100644 --- a/internal/preflight/checks_test.go +++ b/internal/preflight/checks_test.go @@ -607,3 +607,41 @@ func TestRehearseWarnsRatherThanBlocksOnAnUnrecreatableContainer(t *testing.T) { t.Fatalf("advisory mode should not block, got:\n%s", report.String()) } } + +func TestLatestResolvingToStalwart1IsRefused(t *testing.T) { + // Since 2026-10-26 Stalwart's latest release is 1.0.0. A 0.16 server + // asked to go to "latest" would otherwise plan a patch bump: the new + // binary swapped in over a store it can't read. + fakeTool(t, "stalwart-cli", "stalwart-cli 1.0.12") + fakeTool(t, "python3", "Python 3.13.5") + binaryPath := writeFakeBinary(t, "0.16.22", filepath.Join(t.TempDir(), "invocations")) + configPath := filepath.Join(t.TempDir(), "config.json") + if err := os.WriteFile(configPath, []byte(`{}`), 0o644); err != nil { + t.Fatal(err) + } + withFakeGithub(t, func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(Release{TagName: "v1.0.0"}) + }) + + store := checkpoint.NewStore(t.TempDir()) + rs, err := store.Create("", "latest") + if err != nil { + t.Fatalf("store.Create: %v", err) + } + report, err := New(Options{ + BinaryPath: binaryPath, + ConfigPath: configPath, + DataDir: t.TempDir(), + TargetVersion: "latest", + }).Run(context.Background(), store, rs) + if err != nil { + t.Fatalf("Run: %v", err) + } + res := resultFor(t, report, "upgrade-direction") + if res.Status != StatusFail || !strings.Contains(res.Detail, DefaultTargetVersion) { + t.Errorf("upgrade-direction = %s %q, want a failure naming %s", res.Status, res.Detail, DefaultTargetVersion) + } + if !report.Blocking() { + t.Error("report should block a run that targets 1.0") + } +} diff --git a/internal/preflight/release.go b/internal/preflight/release.go index 59658f4..1844aca 100644 --- a/internal/preflight/release.go +++ b/internal/preflight/release.go @@ -14,6 +14,11 @@ import ( // githubAPIBase is a var, not a const, so tests can point it at an // httptest server instead of hitting the real GitHub API. +// DefaultTargetVersion is what --target defaults to: the last 0.16 release. +// It was "latest" until Stalwart's latest became 1.0, which this tool can't +// migrate to yet. +const DefaultTargetVersion = "0.16.27" + var githubAPIBase = "https://api.github.com/repos/stalwartlabs/stalwart/releases" type ReleaseAsset struct {