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.
265 lines
9.2 KiB
Go
265 lines
9.2 KiB
Go
// SPDX-FileCopyrightText: 2026 LINUXexpert-org
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package backup
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestDownloadFileAcceptsMatchingChecksum(t *testing.T) {
|
|
content := "print('fake migration script')\n"
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Write([]byte(content))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
sum := sha256.Sum256([]byte(content))
|
|
expected := hex.EncodeToString(sum[:])
|
|
|
|
dest := filepath.Join(t.TempDir(), "script.py")
|
|
got, err := DownloadFile(context.Background(), nil, srv.URL, dest, expected)
|
|
if err != nil {
|
|
t.Fatalf("DownloadFile: %v", err)
|
|
}
|
|
if got != expected {
|
|
t.Errorf("returned checksum = %s, want %s", got, expected)
|
|
}
|
|
data, _ := os.ReadFile(dest)
|
|
if string(data) != content {
|
|
t.Errorf("downloaded content = %q, want %q", data, content)
|
|
}
|
|
}
|
|
|
|
func TestDownloadFileRejectsMismatchedChecksum(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Write([]byte("unexpected content"))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
dest := filepath.Join(t.TempDir(), "script.py")
|
|
_, err := DownloadFile(context.Background(), nil, srv.URL, dest, "0000000000000000000000000000000000000000000000000000000000000000")
|
|
if err == nil {
|
|
t.Fatal("DownloadFile should reject a checksum mismatch")
|
|
}
|
|
if _, statErr := os.Stat(dest); !os.IsNotExist(statErr) {
|
|
t.Error("DownloadFile should remove the file it wrote after a checksum mismatch")
|
|
}
|
|
}
|
|
|
|
func TestDownloadFileWithoutPinReturnsComputedHash(t *testing.T) {
|
|
content := "arbitrary content"
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Write([]byte(content))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
dest := filepath.Join(t.TempDir(), "script.py")
|
|
got, err := DownloadFile(context.Background(), nil, srv.URL, dest, "")
|
|
if err != nil {
|
|
t.Fatalf("DownloadFile: %v", err)
|
|
}
|
|
sum := sha256.Sum256([]byte(content))
|
|
want := hex.EncodeToString(sum[:])
|
|
if got != want {
|
|
t.Errorf("returned checksum = %s, want %s", got, want)
|
|
}
|
|
}
|
|
|
|
func TestRunSettingsDumpInvokesScriptWithFlags(t *testing.T) {
|
|
dir := t.TempDir()
|
|
log := argsFile(t, dir)
|
|
pythonDir := withFakeExecutable(t, "python3", fakeScriptLoggingArgs(log, "exit 0"))
|
|
|
|
err := RunSettingsDump(context.Background(), SettingsDumpOptions{
|
|
PythonPath: filepath.Join(pythonDir, "python3"),
|
|
ScriptPath: "/opt/migrate_v016.py",
|
|
URL: "https://mail.example.com",
|
|
Username: "admin",
|
|
Password: "hunter2",
|
|
SettingsPath: filepath.Join(dir, "settings.json"),
|
|
PrincipalsPath: filepath.Join(dir, "principals.json"),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("RunSettingsDump: %v", err)
|
|
}
|
|
got := readArgsFile(t, log)
|
|
for _, want := range []string{"/opt/migrate_v016.py", "dump", "--url https://mail.example.com", "--username admin"} {
|
|
if !strings.Contains(got, want) {
|
|
t.Errorf("script invoked with %q, missing %q", got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRunSettingsDumpPropagatesFailure(t *testing.T) {
|
|
pythonDir := withFakeExecutable(t, "python3", "#!/bin/sh\necho 'auth failed' >&2\nexit 1\n")
|
|
err := RunSettingsDump(context.Background(), SettingsDumpOptions{
|
|
PythonPath: filepath.Join(pythonDir, "python3"),
|
|
ScriptPath: "/opt/migrate_v016.py",
|
|
})
|
|
if err == nil {
|
|
t.Fatal("RunSettingsDump should error when the script exits non-zero")
|
|
}
|
|
if !strings.Contains(err.Error(), "auth failed") {
|
|
t.Errorf("error = %v, want it to include the script's stderr", err)
|
|
}
|
|
}
|
|
|
|
func TestRunSettingsConvertInvokesScriptWithFlags(t *testing.T) {
|
|
dir := t.TempDir()
|
|
log := argsFile(t, dir)
|
|
pythonDir := withFakeExecutable(t, "python3", fakeScriptLoggingArgs(log, "exit 0"))
|
|
|
|
err := RunSettingsConvert(context.Background(), SettingsConvertOptions{
|
|
PythonPath: filepath.Join(pythonDir, "python3"),
|
|
ScriptPath: "/opt/migrate_v016.py",
|
|
SettingsPath: filepath.Join(dir, "settings.json"),
|
|
PrincipalsPath: filepath.Join(dir, "principals.json"),
|
|
ConfigPath: filepath.Join(dir, "config.json"),
|
|
OutputPath: filepath.Join(dir, "export.json"),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("RunSettingsConvert: %v", err)
|
|
}
|
|
got := readArgsFile(t, log)
|
|
for _, want := range []string{"convert", "--settings", "--config", "--output"} {
|
|
if !strings.Contains(got, want) {
|
|
t.Errorf("script invoked with %q, missing %q", got, want)
|
|
}
|
|
}
|
|
if strings.Contains(got, "--patch-paths") {
|
|
t.Errorf("script invoked with %q, should not include --patch-paths when none given", got)
|
|
}
|
|
}
|
|
|
|
func TestRunSettingsConvertWithPatchPaths(t *testing.T) {
|
|
dir := t.TempDir()
|
|
log := argsFile(t, dir)
|
|
pythonDir := withFakeExecutable(t, "python3", fakeScriptLoggingArgs(log, "exit 0"))
|
|
|
|
err := RunSettingsConvert(context.Background(), SettingsConvertOptions{
|
|
PythonPath: filepath.Join(pythonDir, "python3"),
|
|
ScriptPath: "/opt/migrate_v016.py",
|
|
SettingsPath: filepath.Join(dir, "settings.json"),
|
|
PrincipalsPath: filepath.Join(dir, "principals.json"),
|
|
ConfigPath: filepath.Join(dir, "config.json"),
|
|
OutputPath: filepath.Join(dir, "export.json"),
|
|
PatchPaths: map[string]string{"/var/lib/stalwart": "/tmp/sandbox/stalwart"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("RunSettingsConvert: %v", err)
|
|
}
|
|
got := readArgsFile(t, log)
|
|
if !strings.Contains(got, "--patch-paths /var/lib/stalwart=/tmp/sandbox/stalwart") {
|
|
t.Errorf("script invoked with %q, missing the expected --patch-paths flag", got)
|
|
}
|
|
}
|
|
|
|
func TestRunSettingsConvertPropagatesFailure(t *testing.T) {
|
|
pythonDir := withFakeExecutable(t, "python3", "#!/bin/sh\necho 'unsupported settings key' >&2\nexit 1\n")
|
|
err := RunSettingsConvert(context.Background(), SettingsConvertOptions{
|
|
PythonPath: filepath.Join(pythonDir, "python3"),
|
|
ScriptPath: "/opt/migrate_v016.py",
|
|
})
|
|
if err == nil {
|
|
t.Fatal("RunSettingsConvert should error when the script exits non-zero")
|
|
}
|
|
if !strings.Contains(err.Error(), "unsupported settings key") {
|
|
t.Errorf("error = %v, want it to include the script's stderr", err)
|
|
}
|
|
}
|
|
|
|
// The report this parses is the most consequential output of a real
|
|
// migration: against a production instance with 12,401 settings,
|
|
// migrate_v016.py migrated 219 of them and listed the other 12,182 here.
|
|
// Losing or ignoring this file means bringing up a server that answers on
|
|
// no ports, since server.listener is among the settings that don't carry.
|
|
const sampleUnmigrated = `# Unmigrated v0.15 settings
|
|
|
|
These v0.15 settings were not migrated by the script and must be
|
|
reviewed manually.
|
|
|
|
Total unmigrated keys: 12182 across 69 prefixes.
|
|
|
|
server.blocked-ip 8547 keys
|
|
lookup.url-redirectors 1076 keys
|
|
spam-filter.rule 424 keys
|
|
server.listener 26 keys
|
|
asn.expires 1 keys
|
|
`
|
|
|
|
func TestReadUnmigratedReportParsesTotalsAndPrefixes(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "unmigrated.txt")
|
|
if err := os.WriteFile(path, []byte(sampleUnmigrated), 0o640); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
report, err := ReadUnmigratedReport(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if report.TotalKeys != 12182 {
|
|
t.Errorf("TotalKeys = %d, want 12182", report.TotalKeys)
|
|
}
|
|
if len(report.Prefixes) != 5 {
|
|
t.Fatalf("parsed %d prefixes, want 5", len(report.Prefixes))
|
|
}
|
|
// Largest first, so the summary leads with what matters most.
|
|
if report.Prefixes[0].Prefix != "server.blocked-ip" || report.Prefixes[0].Keys != 8547 {
|
|
t.Errorf("first prefix = %+v, want server.blocked-ip 8547", report.Prefixes[0])
|
|
}
|
|
|
|
summary := report.Summary(3)
|
|
if !strings.Contains(summary, "12182") || !strings.Contains(summary, "must be recreated by hand") {
|
|
t.Errorf("summary should lead with the scale of the problem:\n%s", summary)
|
|
}
|
|
if !strings.Contains(summary, "and 2 more prefix(es)") {
|
|
t.Errorf("summary should say how much it elided:\n%s", summary)
|
|
}
|
|
}
|
|
|
|
// An older script, or a conversion with nothing left over, writes no file.
|
|
// That is not an error.
|
|
func TestReadUnmigratedReportTreatsMissingFileAsNoReport(t *testing.T) {
|
|
report, err := ReadUnmigratedReport(filepath.Join(t.TempDir(), "absent.txt"))
|
|
if err != nil {
|
|
t.Errorf("missing report should not be an error: %v", err)
|
|
}
|
|
if report != nil {
|
|
t.Errorf("report = %+v, want nil", report)
|
|
}
|
|
}
|
|
|
|
// migrate_v016.py writes unmigrated.txt into its working directory, so the
|
|
// convert has to run somewhere writable that the caller knows about.
|
|
func TestRunSettingsConvertRunsInTheGivenWorkDir(t *testing.T) {
|
|
dir := t.TempDir()
|
|
workDir := filepath.Join(dir, "work")
|
|
log := argsFile(t, dir)
|
|
withFakeExecutable(t, "python3", fakeScriptLoggingArgs(log, "pwd >> "+log+"\ntouch unmigrated.txt"))
|
|
|
|
err := RunSettingsConvert(context.Background(), SettingsConvertOptions{
|
|
ScriptPath: "/tmp/migrate_v016.py", SettingsPath: "/tmp/s.json", PrincipalsPath: "/tmp/p.json",
|
|
ConfigPath: filepath.Join(dir, "c.json"), OutputPath: filepath.Join(dir, "e.json"),
|
|
WorkDir: workDir,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(readArgsFile(t, log), workDir) {
|
|
t.Errorf("script did not run in WorkDir; log:\n%s", readArgsFile(t, log))
|
|
}
|
|
if _, err := os.Stat(filepath.Join(workDir, "unmigrated.txt")); err != nil {
|
|
t.Errorf("unmigrated.txt should land in WorkDir, not the caller's cwd: %v", err)
|
|
}
|
|
}
|