Refuse a Stalwart 1.0 target, and default --target to 0.16.27 #23

Merged
jcoffey-dev merged 3 commits from fix/refuse-1.0-target into main 2026-10-28 06:35:33 +00:00
11 changed files with 84 additions and 13 deletions

No files matched your search

+12
View File
@@ -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
+1 -1
View File
@@ -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")
+1 -1
View File
@@ -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")
+1 -1
View File
@@ -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 "+
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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
+7
View File
@@ -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 {
+11 -8
View File
@@ -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)
}
}
+6
View File
@@ -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,
+38
View File
@@ -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")
}
}
+5
View File
@@ -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 {