The repositories moved off LINUXexpert-org. Here that is not a documentation change: the old organisation was the module path, so it is declared in go.mod and repeated in every internal import. Leaving it would have been worse than a stale link. GitHub redirects the repository, but a go.mod whose module line disagrees with the path it was fetched from is an error rather than a redirect, so `go get` on the new address would have failed against the old declaration. go.mod, 34 files of imports, and the repository links in README and ARCHITECTURE. go mod tidy leaves go.sum untouched -- no dependency moved, only our own path.
497 lines
21 KiB
Go
497 lines
21 KiB
Go
// SPDX-FileCopyrightText: 2026 Coffey Labs
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package preflight
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/Coffey-Labs/stalwart-migrator/internal/checkpoint"
|
|
"github.com/Coffey-Labs/stalwart-migrator/internal/stalwartapi"
|
|
)
|
|
|
|
// Options configures a Checker. Every field has a conservative default
|
|
// applied by New except the ones that must name a real path on this host.
|
|
type Options struct {
|
|
BinaryPath string // installed stalwart binary, e.g. /usr/local/bin/stalwart
|
|
ConfigPath string // its config file (TOML pre-0.16, JSON 0.16+)
|
|
DataDir string // data directory to size/space-check
|
|
ContainerName string // docker container name, if applicable
|
|
AdminURL string // base URL for the JMAP reachability check; empty skips it
|
|
AdminUser string
|
|
AdminPassword string
|
|
TargetVersion string // e.g. "0.16.14" or "latest"
|
|
// TargetBinaryPath, when set, is read for the target version instead of
|
|
// asking the release API - the only way a host with no route out can
|
|
// pass this check.
|
|
TargetBinaryPath string
|
|
MinFreeMultiple float64
|
|
// CLIPath and PythonPath are the external programs the migration
|
|
// shells out to. Checked before anything is touched - see
|
|
// CheckExternalTools.
|
|
CLIPath string
|
|
PythonPath string
|
|
// ToolCheckAdvisory downgrades the external-tool checks from blocking
|
|
// to advisory. `rehearse` sets it: that phase never invokes
|
|
// stalwart-cli, and refusing to run the read-only reconnaissance that
|
|
// tells an operator what they need - because they don't yet have it -
|
|
// is backwards. `run` leaves it false, because there the tools are
|
|
// about to be used and a missing one means stopping a mail server to
|
|
// find out.
|
|
ToolCheckAdvisory bool
|
|
// DeploymentCheckAdvisory downgrades the deployment-kind check from
|
|
// blocking to advisory, for the same reason as ToolCheckAdvisory.
|
|
// `rehearse` sets it: it never stops the service or cuts over, so a
|
|
// deployment this tool cannot cut over is still worth rehearsing
|
|
// against - the reconnaissance is exactly what tells an operator what
|
|
// the manual path involves. `run` leaves it false, because there the
|
|
// alternative is finding out after mail is already down.
|
|
DeploymentCheckAdvisory bool
|
|
HTTPClient *http.Client
|
|
}
|
|
|
|
// Checker runs the preflight checks described in ARCHITECTURE.md §4.1.
|
|
type Checker struct {
|
|
opts Options
|
|
}
|
|
|
|
func New(opts Options) *Checker {
|
|
if opts.MinFreeMultiple <= 0 {
|
|
opts.MinFreeMultiple = 2.0
|
|
}
|
|
return &Checker{opts: opts}
|
|
}
|
|
|
|
// Run executes every preflight check, checkpointing each one so a killed
|
|
// and re-invoked run skips checks that already completed - see
|
|
// checkpoint.Store.RunStep. It never aborts early on a single Fail: the
|
|
// point of preflight is to surface every blocking issue in one pass rather
|
|
// than fail-stop-fix-retry one at a time. Callers decide what to do with a
|
|
// Report whose Blocking() is true. It only returns a non-nil error for a
|
|
// genuine execution fault (e.g. the checkpoint store itself can't be
|
|
// written to) - a check finding a real problem is reported via
|
|
// Status: StatusFail in the Report, not a Go error.
|
|
func (c *Checker) Run(ctx context.Context, store *checkpoint.Store, rs *checkpoint.RunState) (Report, error) {
|
|
var report Report
|
|
|
|
// runCheck wraps fn as a checkpointed step and appends its result to
|
|
// report, whether fn actually ran or was skipped because a prior
|
|
// attempt already completed it - either way report ends up with the
|
|
// same entries, and the returned checkpoint.StepOutcome.Extra carries
|
|
// whatever machine-readable value a later check in this same Run needs.
|
|
runCheck := func(name string, fn func() (CheckResult, string)) (checkpoint.StepOutcome, error) {
|
|
outcome, err := store.RunStep(rs, checkpoint.PhasePreflight, name, func() (checkpoint.StepOutcome, error) {
|
|
res, extra := fn()
|
|
return checkpoint.StepOutcome{Verdict: string(res.Status), Detail: res.Detail, Extra: extra}, nil
|
|
})
|
|
if err != nil {
|
|
return checkpoint.StepOutcome{}, err
|
|
}
|
|
report.Results = append(report.Results, CheckResult{Name: name, Status: Status(outcome.Verdict), Detail: outcome.Detail})
|
|
return outcome, nil
|
|
}
|
|
|
|
// Detected once, here, because the very first check needs it: what is
|
|
// running is a property of a container's image, not of any binary on
|
|
// this host, and a container-only host has no binary to ask. The
|
|
// deployment-kind check below reports it; this only decides who to put
|
|
// the question to.
|
|
kind := DetectDeploymentKind(ctx, c.opts.ContainerName)
|
|
|
|
versionOutcome, err := runCheck("version", func() (CheckResult, string) {
|
|
var (
|
|
cur string
|
|
err error
|
|
source string
|
|
)
|
|
if kind == DeploymentDocker {
|
|
cur, err = DetectContainerVersion(ctx, c.opts.ContainerName)
|
|
source = ", read from the image behind container " + containerNameOr(c.opts.ContainerName)
|
|
} else {
|
|
cur, err = DetectVersion(ctx, c.opts.BinaryPath)
|
|
}
|
|
if err != nil {
|
|
return CheckResult{Status: StatusFail, Detail: err.Error()}, ""
|
|
}
|
|
curV, _ := parseSemver(cur)
|
|
if curV.Compare(minSupportedSource) < 0 {
|
|
return CheckResult{
|
|
Status: StatusFail,
|
|
Detail: fmt.Sprintf("current version %s is older than the minimum supported %s - upgrade to 0.15.x first", cur, minSupportedSource),
|
|
}, cur
|
|
}
|
|
return CheckResult{Status: StatusOK, Detail: fmt.Sprintf("current version %s%s", cur, source)}, cur
|
|
})
|
|
if err != nil {
|
|
return report, err
|
|
}
|
|
|
|
targetOutcome, err := runCheck("target-release", func() (CheckResult, string) {
|
|
// A binary already on disk answers the question the release API was
|
|
// being asked - which version are we upgrading to - without needing
|
|
// a route to the internet. A host that has none cannot reach the
|
|
// API at all, and failing here would stop it migrating even though
|
|
// everything it needs is present.
|
|
if c.opts.TargetBinaryPath != "" {
|
|
got, err := DetectVersion(ctx, c.opts.TargetBinaryPath)
|
|
if err != nil {
|
|
return CheckResult{Status: StatusFail, Detail: fmt.Sprintf("couldn't read the version of %s: %v", c.opts.TargetBinaryPath, err)}, ""
|
|
}
|
|
want := strings.TrimPrefix(c.opts.TargetVersion, "v")
|
|
if want != "" && want != "latest" && want != got {
|
|
return CheckResult{Status: StatusFail, Detail: fmt.Sprintf(
|
|
"%s reports version %s, but this run targets %s - migrating to a version nobody planned for",
|
|
c.opts.TargetBinaryPath, got, want)}, ""
|
|
}
|
|
return CheckResult{Status: StatusOK, Detail: fmt.Sprintf("target is %s, taken from %s (no release lookup needed)", got, c.opts.TargetBinaryPath)}, got
|
|
}
|
|
rel, err := ResolveRelease(ctx, c.opts.HTTPClient, c.opts.TargetVersion)
|
|
if err != nil {
|
|
return CheckResult{Status: StatusFail, Detail: err.Error()}, ""
|
|
}
|
|
tag := strings.TrimPrefix(rel.TagName, "v")
|
|
detail := fmt.Sprintf("resolved target to %s (%d release assets)", rel.TagName, len(rel.Assets))
|
|
if asset := ChecksumAsset(rel); asset != nil {
|
|
detail += fmt.Sprintf(", checksum manifest available: %s", asset.Name)
|
|
} else {
|
|
detail += "; no published checksum manifest found - integrity relies on the one-time HTTPS download only"
|
|
}
|
|
return CheckResult{Status: StatusOK, Detail: detail}, tag
|
|
})
|
|
if err != nil {
|
|
return report, err
|
|
}
|
|
|
|
boundaryOutcome, err := runCheck("upgrade-direction", func() (CheckResult, string) {
|
|
curV, errCur := parseSemver(versionOutcome.Extra)
|
|
tgtV, errTgt := parseSemver(targetOutcome.Extra)
|
|
if errCur != nil || errTgt != nil {
|
|
return CheckResult{Status: StatusWarn, Detail: "could not compare current and target versions (one or both unresolved above)"}, ""
|
|
}
|
|
if curV.Compare(tgtV) >= 0 {
|
|
return CheckResult{
|
|
Status: StatusFail,
|
|
Detail: fmt.Sprintf("current version %s is already at or beyond target %s - nothing to migrate", curV, tgtV),
|
|
}, ""
|
|
}
|
|
if curV.Major == 0 && curV.Minor < 16 && (tgtV.Major > 0 || tgtV.Minor >= 16) {
|
|
return CheckResult{
|
|
Status: StatusOK,
|
|
Detail: fmt.Sprintf("%s -> %s crosses the 0.15/0.16 major boundary: full recovery-mode migration plan required (ARCHITECTURE.md §4.4)", curV, tgtV),
|
|
}, "crosses"
|
|
}
|
|
return CheckResult{
|
|
Status: StatusOK,
|
|
Detail: fmt.Sprintf("%s -> %s is a same-boundary patch upgrade: fast-path plan applies (ARCHITECTURE.md §4.6)", curV, tgtV),
|
|
}, "patch"
|
|
})
|
|
if err != nil {
|
|
return report, err
|
|
}
|
|
crossesBoundary := boundaryOutcome.Extra != "patch"
|
|
|
|
// Before anything else that matters: are the tools this migration
|
|
// depends on actually here? Discovering a missing stalwart-cli after
|
|
// the service has been stopped is what this exists to prevent.
|
|
for _, res := range CheckExternalTools(ctx, c.opts.CLIPath, c.opts.PythonPath, crossesBoundary) {
|
|
result := res
|
|
if c.opts.ToolCheckAdvisory && result.Status == StatusFail {
|
|
result.Status = StatusWarn
|
|
result.Detail = "(advisory for a rehearsal; this would block `run`) " + result.Detail
|
|
}
|
|
if _, err := runCheck(result.Name, func() (CheckResult, string) { return result, "" }); err != nil {
|
|
return report, err
|
|
}
|
|
}
|
|
|
|
deploymentOutcome, err := runCheck("deployment-kind", func() (CheckResult, string) {
|
|
// Detected once above, since the version check already needed it.
|
|
// Asking twice could report two different answers for one run.
|
|
// Docker has to fail here rather than later. Cutover refuses this
|
|
// deployment - recreating a container from a new image is not
|
|
// swapping a binary and rewriting a unit, and this tool does not
|
|
// automate it - but cutover runs after the service has been
|
|
// stopped. Refusing there means refusing with mail already down,
|
|
// which is how a migration attempt turned into an outage.
|
|
status := StatusOK
|
|
detail := fmt.Sprintf("detected deployment kind: %s", kind)
|
|
switch kind {
|
|
case DeploymentUnknown:
|
|
status = StatusWarn
|
|
case DeploymentDocker:
|
|
// No longer a refusal on its own: a container can be migrated
|
|
// now. What still refuses is specific and checked below -
|
|
// compose, and data that is not on a volume - because those are
|
|
// properties of this container rather than of containers.
|
|
detail += " - the container checks below decide whether this one can be migrated"
|
|
}
|
|
return CheckResult{Status: status, Detail: detail}, string(kind)
|
|
})
|
|
if err != nil {
|
|
return report, err
|
|
}
|
|
|
|
// The container checks below only mean anything for a container, and
|
|
// asking docker about one that isn't there would fail for the wrong
|
|
// reason.
|
|
if DeploymentKind(deploymentOutcome.Extra) == DeploymentDocker {
|
|
if err := c.runContainerChecks(ctx, runCheck); err != nil {
|
|
return report, err
|
|
}
|
|
}
|
|
|
|
storeOutcome, err := runCheck("store-backend", func() (CheckResult, string) {
|
|
matches, err := DetectStoreBackends(c.opts.ConfigPath)
|
|
if err != nil {
|
|
return CheckResult{Status: StatusFail, Detail: err.Error()}, ""
|
|
}
|
|
if len(matches) == 0 {
|
|
return CheckResult{Status: StatusWarn, Detail: "no known store backend type found in config - confirm manually before proceeding"}, ""
|
|
}
|
|
names := make([]string, len(matches))
|
|
backends := make([]string, len(matches))
|
|
for i, m := range matches {
|
|
names[i] = fmt.Sprintf("%s (%s)", m.Backend, m.Path)
|
|
backends[i] = m.Backend
|
|
}
|
|
return CheckResult{Status: StatusOK, Detail: "found: " + strings.Join(names, ", ")}, strings.Join(backends, ",")
|
|
})
|
|
if err != nil {
|
|
return report, err
|
|
}
|
|
|
|
if _, err := runCheck("cluster-config", func() (CheckResult, string) {
|
|
mentions, err := ClusterMentions(c.opts.ConfigPath)
|
|
if err != nil {
|
|
return CheckResult{Status: StatusFail, Detail: err.Error()}, ""
|
|
}
|
|
if len(mentions) > 0 {
|
|
shown := mentions
|
|
if len(shown) > 5 {
|
|
shown = shown[:5]
|
|
}
|
|
detail := fmt.Sprintf("config mentions clustering at %s", strings.Join(shown, ", "))
|
|
if len(mentions) > len(shown) {
|
|
detail += fmt.Sprintf(" (and %d more)", len(mentions)-len(shown))
|
|
}
|
|
detail += " - confirm every peer node is stopped before this run proceeds; the tool does not verify this for you"
|
|
return CheckResult{Status: StatusWarn, Detail: detail}, ""
|
|
}
|
|
return CheckResult{Status: StatusOK, Detail: "no cluster configuration detected"}, ""
|
|
}); err != nil {
|
|
return report, err
|
|
}
|
|
|
|
if _, err := runCheck("disk-space", func() (CheckResult, string) {
|
|
size, err := DirSize(c.opts.DataDir)
|
|
if err != nil {
|
|
return CheckResult{Status: StatusFail, Detail: err.Error()}, ""
|
|
}
|
|
free, err := FreeBytes(c.opts.DataDir)
|
|
if err != nil {
|
|
return CheckResult{Status: StatusFail, Detail: err.Error()}, ""
|
|
}
|
|
required := uint64(float64(size) * c.opts.MinFreeMultiple)
|
|
detail := fmt.Sprintf("data dir %s is %s, %s free, need >= %s (%.1fx, for the fs-snapshot backup)",
|
|
c.opts.DataDir, humanBytes(uint64(size)), humanBytes(free), humanBytes(required), c.opts.MinFreeMultiple)
|
|
if free < required {
|
|
return CheckResult{Status: StatusFail, Detail: detail}, ""
|
|
}
|
|
return CheckResult{Status: StatusOK, Detail: detail}, ""
|
|
}); err != nil {
|
|
return report, err
|
|
}
|
|
|
|
if c.opts.AdminURL != "" {
|
|
if _, err := runCheck("admin-reachable", func() (CheckResult, string) {
|
|
client := &stalwartapi.Client{
|
|
BaseURL: c.opts.AdminURL,
|
|
Username: c.opts.AdminUser,
|
|
Password: c.opts.AdminPassword,
|
|
HTTPClient: c.opts.HTTPClient,
|
|
}
|
|
if err := client.Ping(ctx); err != nil {
|
|
return CheckResult{Status: StatusFail, Detail: err.Error()}, ""
|
|
}
|
|
return CheckResult{Status: StatusOK, Detail: fmt.Sprintf("JMAP session reachable at %s with the given credentials", c.opts.AdminURL)}, ""
|
|
}); err != nil {
|
|
return report, err
|
|
}
|
|
|
|
if _, err := runCheck("account-snapshot", func() (CheckResult, string) {
|
|
client := &stalwartapi.Client{
|
|
BaseURL: c.opts.AdminURL,
|
|
Username: c.opts.AdminUser,
|
|
Password: c.opts.AdminPassword,
|
|
HTTPClient: c.opts.HTTPClient,
|
|
}
|
|
snap, err := client.AccountSnapshot(ctx)
|
|
if err != nil {
|
|
return CheckResult{
|
|
Status: StatusWarn,
|
|
Detail: fmt.Sprintf("could not capture the account/domain snapshot: %v - the post-migration directory-integrity check won't have anything to compare against", err),
|
|
}, ""
|
|
}
|
|
mailboxCounts := make(map[string][]checkpoint.MailboxCount, len(snap.MailboxCounts))
|
|
for account, counts := range snap.MailboxCounts {
|
|
converted := make([]checkpoint.MailboxCount, len(counts))
|
|
for i, mc := range counts {
|
|
converted[i] = checkpoint.MailboxCount{Mailbox: mc.Mailbox, Messages: mc.Messages}
|
|
}
|
|
mailboxCounts[account] = converted
|
|
}
|
|
rs.PreflightSnapshot = &checkpoint.PreflightSnapshot{
|
|
TakenAt: time.Now().UTC(),
|
|
AccountCount: snap.AccountCount,
|
|
Domains: snap.Domains,
|
|
MailboxCounts: mailboxCounts,
|
|
UsedQuota: snap.UsedQuota,
|
|
}
|
|
detail := fmt.Sprintf("captured snapshot: %d account(s) across %d domain(s), used-quota for %d account(s), mailbox counts for %d account(s)",
|
|
snap.AccountCount, len(snap.Domains), len(snap.UsedQuota), len(mailboxCounts))
|
|
if len(mailboxCounts) == 0 && len(snap.UsedQuota) > 0 {
|
|
// Expected against a 0.15.x source: it exposes no
|
|
// per-mailbox counts, so used-quota is what the
|
|
// post-migration comparison will have to work from.
|
|
detail += " (this source exposes no per-mailbox counts, so the post-migration check compares accounts, domains and used-quota)"
|
|
}
|
|
status := StatusOK
|
|
if len(snap.MailboxErrors) > 0 {
|
|
status = StatusWarn
|
|
accounts := make([]string, 0, len(snap.MailboxErrors))
|
|
for account := range snap.MailboxErrors {
|
|
accounts = append(accounts, account)
|
|
}
|
|
sort.Strings(accounts)
|
|
for i, account := range accounts {
|
|
if i >= 3 {
|
|
detail += fmt.Sprintf(" (and %d more)", len(accounts)-3)
|
|
break
|
|
}
|
|
detail += fmt.Sprintf("; mailbox count failed for %s: %s", account, snap.MailboxErrors[account])
|
|
}
|
|
}
|
|
return CheckResult{Status: status, Detail: detail}, ""
|
|
}); err != nil {
|
|
return report, err
|
|
}
|
|
|
|
if _, err := runCheck("admin-account-kind", func() (CheckResult, string) {
|
|
return c.adminAccountKind(rs), ""
|
|
}); err != nil {
|
|
return report, err
|
|
}
|
|
} else {
|
|
report.Results = append(report.Results, CheckResult{
|
|
Name: "admin-reachable",
|
|
Status: StatusWarn,
|
|
Detail: "no --admin-url configured - skipped; the account/mailbox snapshot validate needs later can't be captured without it",
|
|
})
|
|
}
|
|
|
|
// Multi-tenancy gate. migrate_v016.py carries the Tenant and the
|
|
// Domains but leaves every Account's tenantId null, so the apply fails
|
|
// with invalidForeignKey - and it fails during recovery-mode migration,
|
|
// which is after the service has been stopped. That is exactly the
|
|
// shape of failure preflight exists to move earlier.
|
|
if c.opts.AdminURL != "" && crossesBoundary {
|
|
if _, err := runCheck("multi-tenancy", func() (CheckResult, string) {
|
|
client := &stalwartapi.Client{
|
|
BaseURL: c.opts.AdminURL, Username: c.opts.AdminUser,
|
|
Password: c.opts.AdminPassword, HTTPClient: c.opts.HTTPClient,
|
|
}
|
|
layout, err := client.FetchTenantLayout(ctx)
|
|
if err != nil {
|
|
return CheckResult{
|
|
Status: StatusWarn,
|
|
Detail: fmt.Sprintf("couldn't map this instance's tenants: %v - if it is multi-tenant, "+
|
|
"a domain/tenant mismatch would only surface during the conversion", err),
|
|
}, ""
|
|
}
|
|
if len(layout.Tenants) == 0 {
|
|
return CheckResult{Status: StatusOK, Detail: "single-tenant: no tenant principals, so no account can mismatch its domain"}, ""
|
|
}
|
|
|
|
plan := layout.Analyze()
|
|
if len(plan.Problems) > 0 {
|
|
details := make([]string, 0, len(plan.Problems))
|
|
for _, p := range plan.Problems {
|
|
details = append(details, fmt.Sprintf("%s: %s", p.Domain, p.Detail))
|
|
}
|
|
return CheckResult{
|
|
Status: StatusFail,
|
|
Detail: fmt.Sprintf("this instance has %d tenant(s) (%s) in an arrangement v0.16 cannot represent - %s. "+
|
|
"Resolve this in v0.15 first: give each tenant its own domains, or move the accounts into one tenant",
|
|
len(layout.Tenants), strings.Join(layout.Tenants, ", "), strings.Join(details, "; ")),
|
|
}, ""
|
|
}
|
|
if len(plan.Adoptions) > 0 {
|
|
return CheckResult{
|
|
Status: StatusWarn,
|
|
Detail: fmt.Sprintf("this instance has %d tenant(s) (%s); %d domain(s) (%s) have no tenant of their own but are "+
|
|
"used only by accounts of a single tenant. v0.16 requires them to match, so the conversion will assign each "+
|
|
"domain to that tenant - the accounts migrate intact, but those domains become tenant-owned",
|
|
len(layout.Tenants), strings.Join(layout.Tenants, ", "),
|
|
len(plan.Adoptions), strings.Join(plan.Adoptions, ", ")),
|
|
}, ""
|
|
}
|
|
return CheckResult{
|
|
Status: StatusOK,
|
|
Detail: fmt.Sprintf("%d tenant(s) (%s), and every account already sits on a domain of its own tenant",
|
|
len(layout.Tenants), strings.Join(layout.Tenants, ", ")),
|
|
}, ""
|
|
}); err != nil {
|
|
return report, err
|
|
}
|
|
}
|
|
|
|
rs.Topology = checkpoint.Topology{
|
|
DeploymentKind: deploymentOutcome.Extra,
|
|
StoreBackend: storeOutcome.Extra,
|
|
}
|
|
if versionOutcome.Extra != "" {
|
|
rs.SourceVersion = versionOutcome.Extra
|
|
}
|
|
if targetOutcome.Extra != "" {
|
|
rs.TargetVersion = targetOutcome.Extra
|
|
}
|
|
if err := store.Save(rs); err != nil {
|
|
return report, fmt.Errorf("preflight: persist topology: %w", err)
|
|
}
|
|
|
|
return report, nil
|
|
}
|
|
|
|
// adminAccountKind reports whether the account preflight authenticated as
|
|
// still exists after the migration.
|
|
func (c *Checker) adminAccountKind(rs *checkpoint.RunState) CheckResult {
|
|
if rs.PreflightSnapshot == nil {
|
|
return CheckResult{Status: StatusWarn, Detail: "no snapshot was captured, so the admin account could not be looked up in the directory"}
|
|
}
|
|
// v0.16 keeps its configuration in the store, so the
|
|
// [authentication.fallback-admin] block a v0.15 config can
|
|
// define simply ceases to exist. An operator who authenticates
|
|
// as that admin gets through every check here - it works fine
|
|
// today - and then finds it rejected the moment the migration
|
|
// completes, taking the quota rebuild and the post-migration
|
|
// content comparison with it. Cheaper to say so now.
|
|
for account := range rs.PreflightSnapshot.UsedQuota {
|
|
if strings.EqualFold(account, c.opts.AdminUser) {
|
|
return CheckResult{Status: StatusOK, Detail: fmt.Sprintf("%s is an account in the directory, so it survives the migration", c.opts.AdminUser)}
|
|
}
|
|
if local, _, ok := strings.Cut(account, "@"); ok && strings.EqualFold(local, c.opts.AdminUser) {
|
|
return CheckResult{Status: StatusOK, Detail: fmt.Sprintf("%s matches directory account %s, which survives the migration", c.opts.AdminUser, account)}
|
|
}
|
|
}
|
|
return CheckResult{Status: StatusFail, Detail: fmt.Sprintf(
|
|
"%s authenticates now but is not an account in this directory - it is a config fallback-admin, and v0.16 keeps its "+
|
|
"config in the store, so the block defining it does not survive. The migration itself would succeed, then the quota "+
|
|
"rebuild and the post-migration content check would both be refused with 401. Re-run as an account that exists in "+
|
|
"the directory (see the README's \"You need a named admin account\")", c.opts.AdminUser)}
|
|
}
|