The phases have all existed for a while; nothing chained them. The order
here is the one arrived at by performing this migration by hand against a
clone of production before writing it down:
preflight -> stage -> dump -> preserve binary -> STOP ->
convert -> supplement -> recovery-mode migration -> cutover -> START
The dump runs before the stop because it reads settings over the admin API,
and a stopped server has no admin API. Everything from the stop to the end
of cutover is downtime.
internal/stage fills the last missing phase (4.3): resolve the release,
take the x86_64 linux-gnu server build and refuse to substitute another,
verify a pinned checksum if one was given, extract the binary - refusing
any archive entry that isn't a regular file, since a tarball is untrusted
input - and confirm the result reports the version its tag claimed.
Everything upstream of that last check is an assumption about someone
else's release process.
Two gates, separate on purpose. --yes is about intent. --recovery-point-
confirmed is a claim about the world: this tool cannot undo a migration
(4.8) and cannot check whether a snapshot exists, so a run that proceeded
without the operator asserting one would be proceeding on a hope.
Verified end to end against a real Stalwart 0.15.5 with email-style account
names, a named admin account, and seeded mail:
MIGRATION COMPLETE. Mail was down for 6s.
Every cutover step green, including recalculate-quotas ("rebuilt disk
quotas for 2 account(s)") - the first time the x:Task wire format inferred
from Stalwart's schema reference has actually been exercised. It works,
now that endpoint discovery and role restoration make it reachable. After
the migration the named admin still administers, alice logs in with
unchanged credentials to the same four messages, and new SMTP delivery is
accepted.
Both refusal gates were tested, as was the failure path: an apply that
fails leaves the run stopped with the store part-migrated, and the error
says to restore the recovery point rather than restart the old version
against it.
84 lines
2.7 KiB
Go
84 lines
2.7 KiB
Go
// SPDX-FileCopyrightText: 2026 LINUXexpert-org
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package preflight
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// githubAPIBase is a var, not a const, so tests can point it at an
|
|
// httptest server instead of hitting the real GitHub API.
|
|
var githubAPIBase = "https://api.github.com/repos/stalwartlabs/stalwart/releases"
|
|
|
|
type ReleaseAsset struct {
|
|
Name string `json:"name"`
|
|
DownloadURL string `json:"browser_download_url"`
|
|
SizeBytes int64 `json:"size"`
|
|
}
|
|
|
|
type Release struct {
|
|
TagName string `json:"tag_name"`
|
|
Assets []ReleaseAsset `json:"assets"`
|
|
}
|
|
|
|
// ResolveRelease looks up a Stalwart release from the public GitHub API.
|
|
// version is either an exact tag like "0.16.14" (a "v" prefix is added if
|
|
// missing) or "latest".
|
|
func ResolveRelease(ctx context.Context, httpClient *http.Client, version string) (*Release, error) {
|
|
url := githubAPIBase + "/latest"
|
|
if version != "" && version != "latest" {
|
|
tag := version
|
|
if !strings.HasPrefix(tag, "v") {
|
|
tag = "v" + tag
|
|
}
|
|
url = githubAPIBase + "/tags/" + tag
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Accept", "application/vnd.github+json")
|
|
if httpClient == nil {
|
|
httpClient = &http.Client{Timeout: 30 * time.Second}
|
|
}
|
|
resp, err := httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("preflight: fetch release %s: %w", url, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("preflight: fetch release %s: unexpected status %s", url, resp.Status)
|
|
}
|
|
var rel Release
|
|
if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
|
|
return nil, fmt.Errorf("preflight: parse release response from %s: %w", url, err)
|
|
}
|
|
return &rel, nil
|
|
}
|
|
|
|
// ChecksumAsset returns the release asset that looks like a published
|
|
// checksum manifest, if any. Its presence isn't guaranteed by Stalwart's
|
|
// release process, so callers must treat a nil result as "no independently
|
|
// published checksum to verify the download against", not an error.
|
|
func ChecksumAsset(rel *Release) *ReleaseAsset {
|
|
for i := range rel.Assets {
|
|
name := strings.ToLower(rel.Assets[i].Name)
|
|
if strings.Contains(name, "sha256") || strings.Contains(name, "checksum") {
|
|
return &rel.Assets[i]
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ReleaseAPIBase returns the release API endpoint, and SetReleaseAPIBase
|
|
// overrides it. Both exist so other packages' tests can point release
|
|
// lookups at a local server instead of the real GitHub API.
|
|
func ReleaseAPIBase() string { return githubAPIBase }
|
|
func SetReleaseAPIBase(base string) { githubAPIBase = base }
|