// SPDX-FileCopyrightText: 2026 Coffey Labs // SPDX-License-Identifier: GPL-3.0-or-later package backup import ( "fmt" "sort" "strings" ) // Disposition is what an operator actually has to do about a group of // settings migrate_v016.py didn't carry over. // // The raw count is misleading on its own, and alarmingly so. A real // production instance reported 12,182 unmigrated settings, which reads as // an impossible amount of manual work. Categorised, 8,547 of them // repopulate themselves, 3,337 ship with v0.16, ~198 had already been // carried by another mechanism, and roughly 290 genuinely needed a human. // The difference between those two framings is the difference between a // migration nobody attempts and an afternoon's review. type Disposition string const ( // DispositionRegenerates: runtime state, not configuration. Nothing to // do; it rebuilds itself in normal operation. DispositionRegenerates Disposition = "regenerates" // DispositionShipped: v0.16 provides its own copy, and restoring // v0.15's would duplicate it or replace newer definitions with older // ones. DispositionShipped Disposition = "shipped with v0.16" // DispositionCarried: the substance came across by another route even // though these particular keys are listed as unmigrated. DispositionCarried Disposition = "already carried" // DispositionReview: genuinely site-specific. This is the real work. DispositionReview Disposition = "needs review" ) // dispositionRule maps a setting prefix to what to do about it. // // Every entry here was checked against a v0.16.14 instance migrated from a // real v0.15.5 - by snapshotting the migrated store and counting what was // already in it - not inferred from documentation. Rules are matched // longest-prefix-first, because "server.blocked-ip" is runtime state while // "server.auto-ban" right next to it is configuration. type dispositionRule struct { prefix string disposition Disposition note string } var dispositionRules = []dispositionRule{ {"server.blocked-ip", DispositionRegenerates, "auto-ban repopulates these from live traffic; a migrated instance simply starts with an empty list"}, {"lookup.url-redirectors", DispositionShipped, "v0.16 ships this list itself (observed as MemoryLookupKey/SpamUrlRedirector entries in a migrated store)"}, {"lookup.trusted-domains", DispositionShipped, "v0.16 ships this list itself (MemoryLookupKey/SpamTrustedDomain)"}, {"lookup.surbl-hashbl", DispositionShipped, "v0.16 ships this list itself (MemoryLookupKey)"}, {"spam-filter.rule", DispositionShipped, "v0.16 ships its own rules - 66 SpamRule objects were present in a migrated store, the same stock set under v0.16's naming (stwt_x -> STWT_X). Restoring v0.15's would duplicate them and revert a year of updates"}, {"spam-filter.dnsbl", DispositionShipped, "v0.16 ships its own DNSBL servers (18 SpamDnsblServer objects present after migration)"}, {"spam-filter.list", DispositionShipped, "stock lists (file extensions and similar) that v0.16 provides itself"}, {"signature.", DispositionCarried, "DKIM: carried across as DkimSignature objects, private keys included - verified on a migrated instance"}, {"server.listener", DispositionCarried, "regenerated by this tool's own apply plan (see the supplement)"}, } // Classification is one group of unmigrated settings with its disposition. type Classification struct { Prefix string Keys int Disposition Disposition Note string } // ClassifiedReport is an UnmigratedReport with each group's disposition // resolved, and totals per disposition. type ClassifiedReport struct { TotalKeys int Groups []Classification Counts map[Disposition]int } // Classify resolves what to do about each group in the report. func (r *UnmigratedReport) Classify() *ClassifiedReport { out := &ClassifiedReport{Counts: map[Disposition]int{}} if r == nil { return out } out.TotalKeys = r.TotalKeys for _, p := range r.Prefixes { disposition, note := classifyPrefix(p.Prefix) out.Groups = append(out.Groups, Classification{ Prefix: p.Prefix, Keys: p.Keys, Disposition: disposition, Note: note, }) out.Counts[disposition] += p.Keys } sort.Slice(out.Groups, func(i, j int) bool { return out.Groups[i].Keys > out.Groups[j].Keys }) return out } func classifyPrefix(prefix string) (Disposition, string) { best := -1 var disposition Disposition = DispositionReview var note string for _, rule := range dispositionRules { if !strings.HasPrefix(prefix, rule.prefix) { continue } if len(rule.prefix) > best { best, disposition, note = len(rule.prefix), rule.disposition, rule.note } } return disposition, note } // NeedsReview returns just the groups a human has to deal with, largest // first - the actual worklist, as opposed to the raw count. func (c *ClassifiedReport) NeedsReview() []Classification { var out []Classification for _, g := range c.Groups { if g.Disposition == DispositionReview { out = append(out, g) } } return out } // Summary renders the categorised report, leading with what needs a human // rather than with the total. func (c *ClassifiedReport) Summary(worklistPath string) string { if c == nil || c.TotalKeys == 0 { return "no unmigrated settings were reported" } var b strings.Builder fmt.Fprintf(&b, "%d setting(s) were not carried over by migrate_v016.py. What they actually are:\n", c.TotalKeys) for _, d := range []Disposition{DispositionRegenerates, DispositionShipped, DispositionCarried, DispositionReview} { n := c.Counts[d] if n == 0 { continue } label := string(d) if d == DispositionReview { label = "NEED YOUR REVIEW" } fmt.Fprintf(&b, "\n %6d %-18s", n, label) groups := []string{} for _, g := range c.Groups { if g.Disposition == d && g.Keys > 0 { groups = append(groups, fmt.Sprintf("%s (%d)", g.Prefix, g.Keys)) } } shown := groups if len(shown) > 6 { shown = shown[:6] } fmt.Fprintf(&b, " %s", strings.Join(shown, ", ")) if len(groups) > len(shown) { fmt.Fprintf(&b, ", +%d more", len(groups)-len(shown)) } } review := c.Counts[DispositionReview] fmt.Fprintf(&b, "\n\n Only the %d needing review are work. The rest either rebuild themselves,\n", review) fmt.Fprintf(&b, " ship with v0.16 (restoring v0.15's copies would revert them), or came\n") fmt.Fprintf(&b, " across by another route. Full key list: %s", worklistPath) return b.String() }