Ran the converter against a real production settings corpus pulled from the
instance this tool is being built to migrate (secrets scrubbed on that host;
nothing sensitive transited). The result reframes what "a successful
migration" means:
total settings: 12401
NOT migrated: 12182 (98.2%)
actually migrated: 219 (1.8%)
Stalwart's own script reports this in an unmigrated.txt it writes beside its
output - and this tool was throwing that file away. Worse, RunSettingsConvert
never set cmd.Dir, so the script wrote unmigrated.txt into whatever directory
the operator happened to launch from, or failed the whole convert when that
directory wasn't writable. Both reproduced.
The largest groups left behind on production are spam-filter rules, DNSBLs,
trusted-domain and URL-redirector lookups, queue scheduling and TLS
settings - and server.listener. That last one explains something that had
been puzzling from an earlier smoke run: a freshly migrated 0.16 instance
answered on none of the ports the old one did, and served nothing but
/admin. Its listeners never migrated.
So:
- SettingsConvertOptions gains WorkDir, and the convert runs there. The
report lands somewhere known and an unwritable cwd can't fail the step.
- ReadUnmigratedReport parses it; UnmigratedReport.Summary renders the
largest groups first.
- The dry run records it as an "unmigrated-settings" artifact with a
checksum, notes the count in the checkpoint step, and prints it as a
standing warning rather than a footnote.
ARCHITECTURE 4.3 anticipated a "best-effort apply-plan" gap here. The gap is
not a few stragglers needing review; it is effectively the entire
configuration, and the tool has to say so where nobody can miss it.
Verified against the smoke VM: even a default `stalwart --init` instance
reports 3505 of ~3514 settings unmigrated, listeners included.
266 lines
9.5 KiB
Go
266 lines
9.5 KiB
Go
// SPDX-FileCopyrightText: 2026 LINUXexpert-org
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package backup
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// DefaultMigrationScriptURL is Stalwart's own v0.15->v0.16 settings
|
|
// converter, referenced directly from UPGRADING/v0_16.md. It's an external,
|
|
// Stalwart-owned dependency this tool doesn't vendor a copy of - see the
|
|
// pinning discussion on DownloadFile and ARCHITECTURE.md §8.
|
|
const DefaultMigrationScriptURL = "https://raw.githubusercontent.com/stalwartlabs/stalwart/main/resources/scripts/migrate_v016.py"
|
|
|
|
// DownloadFile fetches url to destPath and returns its SHA256. If
|
|
// expectedSHA256 is non-empty, a mismatching download is rejected (and the
|
|
// partial file removed) - this is how a pinned migration-script hash is
|
|
// enforced, so a run never silently executes a different version of a
|
|
// script than the one it was reviewed against. If expectedSHA256 is empty,
|
|
// the download is accepted unconditionally and its hash is returned so the
|
|
// caller can record it as the pin for next time; first-run trust-on-first-use
|
|
// is a known gap, flagged in ARCHITECTURE.md §8.
|
|
func DownloadFile(ctx context.Context, httpClient *http.Client, url, destPath, expectedSHA256 string) (sha256Hex string, err error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if httpClient == nil {
|
|
httpClient = &http.Client{Timeout: 60 * time.Second}
|
|
}
|
|
resp, err := httpClient.Do(req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("backup: fetch %s: %w", url, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return "", fmt.Errorf("backup: fetch %s: unexpected status %s", url, resp.Status)
|
|
}
|
|
|
|
f, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o640)
|
|
if err != nil {
|
|
return "", fmt.Errorf("backup: create %s: %w", destPath, err)
|
|
}
|
|
h := sha256.New()
|
|
_, copyErr := io.Copy(io.MultiWriter(f, h), resp.Body)
|
|
closeErr := f.Close()
|
|
if copyErr != nil {
|
|
os.Remove(destPath)
|
|
return "", fmt.Errorf("backup: download %s: %w", url, copyErr)
|
|
}
|
|
if closeErr != nil {
|
|
os.Remove(destPath)
|
|
return "", fmt.Errorf("backup: close %s: %w", destPath, closeErr)
|
|
}
|
|
|
|
sha256Hex = hex.EncodeToString(h.Sum(nil))
|
|
if expectedSHA256 != "" && sha256Hex != expectedSHA256 {
|
|
os.Remove(destPath)
|
|
return "", fmt.Errorf(
|
|
"backup: %s checksum mismatch: got %s, want %s (refusing to run an unexpected version of a script that irreversibly wipes settings on first v0.16 start)",
|
|
url, sha256Hex, expectedSHA256,
|
|
)
|
|
}
|
|
return sha256Hex, nil
|
|
}
|
|
|
|
// SettingsDumpOptions configures a migrate_v016.py `dump` invocation
|
|
// against a live v0.15.x instance - see UPGRADING/v0_16.md.
|
|
type SettingsDumpOptions struct {
|
|
PythonPath string // defaults to "python3"
|
|
ScriptPath string // local path to the already-downloaded, checksum-verified script
|
|
URL string // the running v0.15.x instance's base URL
|
|
Username string
|
|
Password string
|
|
SettingsPath string
|
|
PrincipalsPath string
|
|
}
|
|
|
|
// RunSettingsDump runs migrate_v016.py's dump subcommand, which reads the
|
|
// live v0.15.x server's settings and principals over its admin API and
|
|
// writes them to SettingsPath/PrincipalsPath for the later convert step
|
|
// (ARCHITECTURE.md §4.3). This step is read-only against the server, so
|
|
// it's safe to run well before cutover - ARCHITECTURE.md §4.2 calls for it
|
|
// both at preflight time and again immediately before cutover, since the
|
|
// live settings may have changed in between.
|
|
func RunSettingsDump(ctx context.Context, o SettingsDumpOptions) error {
|
|
python := o.PythonPath
|
|
if python == "" {
|
|
python = "python3"
|
|
}
|
|
args := []string{
|
|
o.ScriptPath, "dump",
|
|
"--url", o.URL,
|
|
"--username", o.Username,
|
|
"--password", o.Password,
|
|
"--settings", o.SettingsPath,
|
|
"--principals", o.PrincipalsPath,
|
|
}
|
|
cmd := exec.CommandContext(ctx, python, args...)
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
return fmt.Errorf("backup: migrate_v016.py dump failed: %w (output: %s)", err, out)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SettingsConvertOptions configures a migrate_v016.py `convert` invocation,
|
|
// which turns the settings/principals dump into the v0.16 config.json and
|
|
// export.json that recovery mode consumes - see UPGRADING/v0_16.md.
|
|
type SettingsConvertOptions struct {
|
|
PythonPath string // defaults to "python3"
|
|
ScriptPath string
|
|
SettingsPath string
|
|
PrincipalsPath string
|
|
ConfigPath string // output: config.json for the new binary's --config flag
|
|
OutputPath string // output: export.json for `stalwart-cli apply`
|
|
|
|
// PatchPaths rewrites path prefixes in the generated config (documented
|
|
// for Docker deployments as "--patch-paths /opt/stalwart=/var/lib/stalwart",
|
|
// e.g. old-path -> new-path). This is the officially documented
|
|
// mechanism a dry-run relies on to point the generated config at a
|
|
// sandbox data directory instead of the production one - see
|
|
// ARCHITECTURE.md's dry-run design - rather than this tool editing
|
|
// config.json's contents directly, which would require depending on its
|
|
// exact schema.
|
|
PatchPaths map[string]string
|
|
|
|
// WorkDir is where the script runs. It matters more than it looks:
|
|
// migrate_v016.py writes its unmigrated.txt report into the current
|
|
// working directory, so without this the convert either fails outright
|
|
// (an unwritable CWD - which is what happens running as a service from
|
|
// /) or silently drops the single most important output of the whole
|
|
// migration wherever the operator happened to be standing.
|
|
WorkDir string
|
|
}
|
|
|
|
// RunSettingsConvert runs migrate_v016.py's convert subcommand.
|
|
func RunSettingsConvert(ctx context.Context, o SettingsConvertOptions) error {
|
|
python := o.PythonPath
|
|
if python == "" {
|
|
python = "python3"
|
|
}
|
|
args := []string{
|
|
o.ScriptPath, "convert",
|
|
"--settings", o.SettingsPath,
|
|
"--principals", o.PrincipalsPath,
|
|
"--config", o.ConfigPath,
|
|
"--output", o.OutputPath,
|
|
}
|
|
if len(o.PatchPaths) > 0 {
|
|
pairs := make([]string, 0, len(o.PatchPaths))
|
|
for old, new := range o.PatchPaths {
|
|
pairs = append(pairs, old+"="+new)
|
|
}
|
|
sort.Strings(pairs) // deterministic argv, easier to test and to log
|
|
args = append(args, "--patch-paths", strings.Join(pairs, ","))
|
|
}
|
|
cmd := exec.CommandContext(ctx, python, args...)
|
|
if o.WorkDir != "" {
|
|
if err := os.MkdirAll(o.WorkDir, 0o750); err != nil {
|
|
return fmt.Errorf("backup: create convert working directory %s: %w", o.WorkDir, err)
|
|
}
|
|
cmd.Dir = o.WorkDir
|
|
}
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
return fmt.Errorf("backup: migrate_v016.py convert failed: %w (output: %s)", err, out)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// UnmigratedPrefix is one group of v0.15 settings the conversion did not
|
|
// carry over, as reported by migrate_v016.py's unmigrated.txt.
|
|
type UnmigratedPrefix struct {
|
|
Prefix string
|
|
Keys int
|
|
}
|
|
|
|
// UnmigratedReport summarizes what a conversion left behind.
|
|
//
|
|
// This is not a footnote. Against a real production instance - 12,401
|
|
// settings - Stalwart's own converter migrated 219 of them, 1.8%, and left
|
|
// 12,182 for the operator to recreate by hand: spam-filter rules, DNSBLs,
|
|
// trusted-domain and URL-redirector lookups, queue scheduling and TLS
|
|
// settings, and server.listener itself, which is why a freshly migrated
|
|
// instance answers on none of the ports the old one did. A migration that
|
|
// reported success while silently discarding this would be worse than one
|
|
// that failed.
|
|
type UnmigratedReport struct {
|
|
Path string
|
|
TotalKeys int
|
|
Prefixes []UnmigratedPrefix
|
|
}
|
|
|
|
// Summary renders the report for an operator, largest groups first.
|
|
func (r *UnmigratedReport) Summary(maxPrefixes int) string {
|
|
if r == nil || r.TotalKeys == 0 {
|
|
return "no unmigrated settings were reported"
|
|
}
|
|
var b strings.Builder
|
|
fmt.Fprintf(&b, "%d v0.15 setting(s) were NOT migrated and must be recreated by hand (full list: %s)", r.TotalKeys, r.Path)
|
|
prefixes := r.Prefixes
|
|
if len(prefixes) > maxPrefixes {
|
|
prefixes = prefixes[:maxPrefixes]
|
|
}
|
|
for _, p := range prefixes {
|
|
fmt.Fprintf(&b, "\n %-32s %d keys", p.Prefix, p.Keys)
|
|
}
|
|
if len(r.Prefixes) > len(prefixes) {
|
|
fmt.Fprintf(&b, "\n ... and %d more prefix(es)", len(r.Prefixes)-len(prefixes))
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// unmigratedPattern matches the report's per-prefix lines, e.g.
|
|
// " spam-filter.rule 424 keys".
|
|
var unmigratedPattern = regexp.MustCompile(`^\s+(\S+)\s+(\d+) keys\s*$`)
|
|
|
|
// ReadUnmigratedReport parses the unmigrated.txt migrate_v016.py writes
|
|
// beside its output. A missing file is not an error - an older script, or
|
|
// a conversion with nothing left over, simply won't produce one - so
|
|
// callers get a nil report rather than a failure.
|
|
func ReadUnmigratedReport(path string) (*UnmigratedReport, error) {
|
|
data, err := os.ReadFile(path)
|
|
if os.IsNotExist(err) {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("backup: read %s: %w", path, err)
|
|
}
|
|
report := &UnmigratedReport{Path: path}
|
|
for _, line := range strings.Split(string(data), "\n") {
|
|
if m := unmigratedPattern.FindStringSubmatch(line); m != nil {
|
|
n, convErr := strconv.Atoi(m[2])
|
|
if convErr != nil {
|
|
continue
|
|
}
|
|
report.Prefixes = append(report.Prefixes, UnmigratedPrefix{Prefix: m[1], Keys: n})
|
|
continue
|
|
}
|
|
if strings.HasPrefix(strings.TrimSpace(line), "Total unmigrated keys:") {
|
|
fields := strings.Fields(line)
|
|
if len(fields) >= 4 {
|
|
if n, convErr := strconv.Atoi(fields[3]); convErr == nil {
|
|
report.TotalKeys = n
|
|
}
|
|
}
|
|
}
|
|
}
|
|
sort.Slice(report.Prefixes, func(i, j int) bool { return report.Prefixes[i].Keys > report.Prefixes[j].Keys })
|
|
return report, nil
|
|
}
|