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.
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/LINUXexpert-org/stalwart-migrator/internal/checkpoint"
|
|
"github.com/LINUXexpert-org/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)}
|
|
}
|