Ran the rehearsal read-only against a live production instance. It completed, and two preflight checks were wrong in ways a test instance could not have shown. 1. Store-backend detection missed the config entirely. A config generated by `stalwart --init` declares `type = "rocksdb"` inside a `[store.rocksdb]` section, which is what every test fixture here used. The production config has no section headers at all and declares `store.rocksdb.type = "rocksdb"` flat. Detection only matched a bare `type` key, so it reported "no known store backend type found in config" and left topology.store_backend empty. That mattered more than a warning suggests: backup.Run treated an unrecognized backend as a *skip*, so a real run would have continued with no filesystem or database backup at all - the single artifact that phase exists to produce, quietly absent. Flat dotted keys are now detected, and an unrecognized backend is a hard failure rather than a skip. 2. The cluster warning didn't say where it matched. It fires on any occurrence of "cluster" anywhere in the config, which is the correct bias - a missed cluster corrupts a shared store - but on the production config the only match was inside the value of an unrelated setting, leaving a whole config to search to establish that. It now names the location and distinguishes a match in the setting name from one in its value. Both verified against the real config: store-backend now reports "rocksdb (store.rocksdb)", and the cluster warning names the setting, making a false positive dismissible at a glance. No production data is in this commit: the fixtures use example.com and the flat-key shape only. Coverage numbers from that run matched the earlier scrubbed-corpus measurement exactly.
77 lines
2.6 KiB
Go
77 lines
2.6 KiB
Go
// SPDX-FileCopyrightText: 2026 LINUXexpert-org
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package preflight
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
)
|
|
|
|
// LooksClustered does a conservative, heuristic scan for cluster-related
|
|
// configuration. It exists to force a manual confirmation gate (see
|
|
// ARCHITECTURE.md §4.1's cluster gate: one live node on the old version
|
|
// during migration corrupts a shared store), not to enumerate peers
|
|
// precisely. A false positive just costs an extra confirmation prompt; a
|
|
// false negative is the dangerous direction, so this errs toward matching
|
|
// broadly rather than requiring an exact schema match.
|
|
func LooksClustered(configPath string) (bool, error) {
|
|
locations, err := ClusterMentions(configPath)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return len(locations) > 0, nil
|
|
}
|
|
|
|
// ClusterMentions returns the setting keys whose name or value mentions
|
|
// clustering, so a warning can say *where* it matched.
|
|
//
|
|
// The matching stays deliberately broad - a missed cluster is the dangerous
|
|
// direction - but a bare "config mentions clustering" leaves an operator
|
|
// with a whole config to search. Against a real instance the single match
|
|
// was inside the value of an unrelated key, which is obvious in one glance
|
|
// when the warning names it and needs investigation when it doesn't.
|
|
func ClusterMentions(configPath string) ([]string, error) {
|
|
data, err := os.ReadFile(configPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("preflight: read config %s: %w", configPath, err)
|
|
}
|
|
var locations []string
|
|
section := ""
|
|
for _, raw := range strings.Split(string(data), "\n") {
|
|
line := strings.TrimSpace(raw)
|
|
if line == "" || strings.HasPrefix(line, "#") {
|
|
continue
|
|
}
|
|
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
|
|
section = strings.Trim(line, "[]")
|
|
// A [cluster] header is itself the declaration; don't let
|
|
// recording the section swallow the match.
|
|
if strings.Contains(strings.ToLower(section), "cluster") {
|
|
locations = append(locations, "["+section+"]")
|
|
}
|
|
continue
|
|
}
|
|
if !strings.Contains(strings.ToLower(line), "cluster") {
|
|
continue
|
|
}
|
|
key := line
|
|
if eq := strings.Index(line, "="); eq >= 0 {
|
|
key = strings.TrimSpace(line[:eq])
|
|
}
|
|
where := key
|
|
if section != "" {
|
|
where = section + "." + key
|
|
}
|
|
// Say whether it was the setting itself or only its value, since
|
|
// that is the difference between "this is clustered" and "this
|
|
// mentions the word".
|
|
if !strings.Contains(strings.ToLower(key), "cluster") {
|
|
where += " (in its value, not the setting name)"
|
|
}
|
|
locations = append(locations, where)
|
|
}
|
|
return locations, nil
|
|
}
|