Fix two preflight defects a live production rehearsal found

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.
This commit is contained in:
2026-08-23 21:58:53 -07:00
parent 1684c88877
commit a69f0bbff1
7 changed files with 199 additions and 13 deletions
+53 -2
View File
@@ -17,9 +17,60 @@ import (
// 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 false, fmt.Errorf("preflight: read config %s: %w", configPath, err)
return nil, fmt.Errorf("preflight: read config %s: %w", configPath, err)
}
return strings.Contains(strings.ToLower(string(data)), "cluster"), nil
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
}