Files
jcoffey-dev 77e80cb9f2 Change the copyright holder to Coffey Labs
119 SPDX-FileCopyrightText headers and the README's licence line.

The distinction that matters here: LINUXexpert-org appears in this repository
in two completely different roles. As a copyright holder in the SPDX headers,
which is what changes, and as the GitHub organisation in the module path and 64
import statements, which does not -- the repository still lives at
github.com/LINUXexpert-org/stalwart-migrator, and rewriting that would not be a
licence change, it would break the build.

Both replacements are anchored to their copyright forms, so an import path
cannot match either. Import count is 64 before and after, and go.mod is
untouched.

LICENSE untouched: the FSF's copyright on the GPL text and the "<name of
author>" placeholders are not ours to edit.

go vet, go build and go test all clean.
2026-08-30 01:28:23 -07:00

113 lines
4.0 KiB
Go

// SPDX-FileCopyrightText: 2026 Coffey Labs
// SPDX-License-Identifier: GPL-3.0-or-later
package preflight
import (
"bytes"
"context"
"fmt"
"os/exec"
"strings"
)
// minCLIVersion is the oldest stalwart-cli the migration can use.
// UPGRADING/v0_16.md is explicit: "install the new CLI (make sure to
// install v1.0.2 or later)". Older builds have no `apply` command at all.
var minCLIVersion = semver{1, 0, 2}
// CheckExternalTools verifies the programs the migration shells out to
// exist and are new enough, before anything is touched.
//
// This check exists because its absence took a production mail server down.
// A live migration stopped the service, converted the settings, and only
// then discovered that the host's stalwart-cli was 0.13.4 - present, but
// from the era when the CLI shipped with the server, and with no `apply`
// command. The migration needs v1.0.2+ from the separately-versioned
// stalwartlabs/cli repository, which is a different download most operators
// have never made. Recovery cost a restore from a day-old snapshot.
//
// Every one of those facts was knowable in under a second, from a stopped
// state, before any risk was taken. That is what preflight is for.
func CheckExternalTools(ctx context.Context, cliPath, pythonPath string, crossesMajorBoundary bool) []CheckResult {
if cliPath == "" {
cliPath = "stalwart-cli"
}
if pythonPath == "" {
pythonPath = "python3"
}
if !crossesMajorBoundary {
// A patch bump replays no settings, so neither tool is invoked.
return []CheckResult{{
Name: "external-tools",
Status: StatusOK,
Detail: "not needed for a same-boundary patch upgrade: no settings conversion or apply happens",
}}
}
var results []CheckResult
version, err := toolVersion(ctx, cliPath, "--version")
switch {
case err != nil:
results = append(results, CheckResult{
Name: "stalwart-cli",
Status: StatusFail,
Detail: fmt.Sprintf("%s is required to replay settings into the migrated store and could not be run: %v. "+
"It is a separate download from the server - see https://stalw.art/docs/management/cli/overview - and "+
"must be v%s or later. A stalwart-cli that shipped alongside a 0.15 server has no `apply` command and "+
"will not do", cliPath, err, minCLIVersion),
})
default:
parsed, parseErr := parseSemver(version)
switch {
case parseErr != nil:
results = append(results, CheckResult{
Name: "stalwart-cli",
Status: StatusWarn,
Detail: fmt.Sprintf("%s reported a version this tool couldn't parse (%q); it must be v%s or later", cliPath, version, minCLIVersion),
})
case parsed.Compare(minCLIVersion) < 0:
results = append(results, CheckResult{
Name: "stalwart-cli",
Status: StatusFail,
Detail: fmt.Sprintf("%s is v%s, but the migration needs v%s or later - older builds have no `apply` command. "+
"Install it from https://stalw.art/docs/management/cli/overview (it is a separate download from the server)",
cliPath, parsed, minCLIVersion),
})
default:
results = append(results, CheckResult{
Name: "stalwart-cli",
Status: StatusOK,
Detail: fmt.Sprintf("%s is v%s (needs v%s or later)", cliPath, parsed, minCLIVersion),
})
}
}
if _, err := toolVersion(ctx, pythonPath, "--version"); err != nil {
results = append(results, CheckResult{
Name: "python3",
Status: StatusFail,
Detail: fmt.Sprintf("%s is required to run Stalwart's migrate_v016.py and could not be run: %v", pythonPath, err),
})
} else {
results = append(results, CheckResult{
Name: "python3", Status: StatusOK,
Detail: fmt.Sprintf("%s is available for migrate_v016.py", pythonPath),
})
}
return results
}
// toolVersion runs a program's version flag and returns its output.
func toolVersion(ctx context.Context, path string, arg string) (string, error) {
cmd := exec.CommandContext(ctx, path, arg)
var out bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &out
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("%w (output: %s)", err, strings.TrimSpace(out.String()))
}
return strings.TrimSpace(out.String()), nil
}