watcher: send the inconsistency alert once per disagreement (closes #158)
check / check (push) Failing after 37s

detectInconsistencies alerted for every neighbouring pair of nameservers
whose records differed, on every DNS check, for as long as they differed.
It now also takes the previous hostname state and alerts for a pair only
when both nameservers were in that state with equal records. The state
loaded at startup is the previous state for the first check, so a
disagreement saved before a restart is not reported again. The choice of
pairs is in newlyDisagreeingPairs, tested on record data built in the
test. The README describes the new behaviour.

Model: opus-5-5
This commit is contained in:
2026-09-28 23:04:03 +00:00
parent 95b017eb3e
commit 65e0a4ec39
5 changed files with 178 additions and 17 deletions
+11
View File
@@ -0,0 +1,11 @@
package watcher
import "sneak.berlin/go/dnswatcher/internal/state"
// NewlyDisagreeingPairs exports newlyDisagreeingPairs for testing.
func NewlyDisagreeingPairs(
prev *state.HostnameState,
current map[string]map[string][]string,
) [][2]string {
return newlyDisagreeingPairs(prev, current)
}
+116
View File
@@ -0,0 +1,116 @@
package watcher_test
import (
"slices"
"testing"
"sneak.berlin/go/dnswatcher/internal/state"
"sneak.berlin/go/dnswatcher/internal/watcher"
)
// hostnameState builds the state a check with these records leaves behind.
func hostnameState(
records map[string]map[string][]string,
) *state.HostnameState {
hs := &state.HostnameState{
RecordsByNameserver: make(map[string]*state.NameserverRecordState),
}
for ns, recs := range records {
hs.RecordsByNameserver[ns] = &state.NameserverRecordState{
Records: recs,
Status: "ok",
}
}
return hs
}
func TestNewlyDisagreeingPairs(t *testing.T) {
t.Parallel()
const (
nsA = "a.ns.example.net."
nsB = "b.ns.example.net."
)
agree := map[string]map[string][]string{
nsA: {"A": {"192.0.2.1"}},
nsB: {"A": {"192.0.2.1"}},
}
disagree := map[string]map[string][]string{
nsA: {"A": {"192.0.2.1"}},
nsB: {"A": {"192.0.2.2"}},
}
alert := [][2]string{{nsA, nsB}}
// Each case starts from the state loaded at startup and runs the
// checks in order; want[i] is what check i alerts for.
tests := []struct {
name string
loaded map[string]map[string][]string
checks []map[string]map[string][]string
want [][][2]string
}{
{
name: "disagreement persisting across checks alerts once",
loaded: agree,
checks: []map[string]map[string][]string{
disagree, disagree, disagree,
},
want: [][][2]string{alert, nil, nil},
},
{
name: "disagreement starting on a later check alerts on it",
loaded: agree,
checks: []map[string]map[string][]string{
agree, agree, disagree,
},
want: [][][2]string{nil, nil, alert},
},
{
name: "disagreement in the loaded state does not alert",
loaded: disagree,
checks: []map[string]map[string][]string{
disagree, disagree,
},
want: [][][2]string{nil, nil},
},
{
name: "nameserver new on this check does not alert",
loaded: map[string]map[string][]string{
nsA: {"A": {"192.0.2.1"}},
},
checks: []map[string]map[string][]string{disagree},
want: [][][2]string{nil},
},
{
name: "disagreement after agreeing again alerts again",
loaded: agree,
checks: []map[string]map[string][]string{
disagree, agree, disagree,
},
want: [][][2]string{alert, nil, alert},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
prev := hostnameState(tt.loaded)
for i, current := range tt.checks {
got := watcher.NewlyDisagreeingPairs(prev, current)
if !slices.Equal(got, tt.want[i]) {
t.Errorf(
"check %d: alerted for %v, want %v",
i, got, tt.want[i],
)
}
prev = hostnameState(current)
}
})
}
}
+42 -15
View File
@@ -366,7 +366,7 @@ func (w *Watcher) detectHostnameChanges(
) {
w.detectRecordChanges(ctx, hostname, prev, current)
w.detectNSDisappearances(ctx, hostname, prev, current)
w.detectInconsistencies(ctx, hostname, current)
w.detectInconsistencies(ctx, hostname, prev, current)
}
func (w *Watcher) detectRecordChanges(
@@ -448,22 +448,11 @@ func (w *Watcher) detectNSDisappearances(
func (w *Watcher) detectInconsistencies(
ctx context.Context,
hostname string,
prev *state.HostnameState,
current map[string]map[string][]string,
) {
nameservers := make([]string, 0, len(current))
for ns := range current {
nameservers = append(nameservers, ns)
}
sort.Strings(nameservers)
for i := range len(nameservers) - 1 {
ns1 := nameservers[i]
ns2 := nameservers[i+1]
if recordsEqual(current[ns1], current[ns2]) {
continue
}
for _, pair := range newlyDisagreeingPairs(prev, current) {
ns1, ns2 := pair[0], pair[1]
msg := fmt.Sprintf(
"Hostname: %s\n%s: %v\n%s: %v",
@@ -481,6 +470,44 @@ func (w *Watcher) detectInconsistencies(
}
}
// newlyDisagreeingPairs returns the pairs of nameservers, neighbours in
// sorted order, whose records differ in current but were equal in prev.
// A pair is returned only when both nameservers are in prev, so a
// disagreement that was already in prev is not returned again.
func newlyDisagreeingPairs(
prev *state.HostnameState,
current map[string]map[string][]string,
) [][2]string {
nameservers := make([]string, 0, len(current))
for ns := range current {
nameservers = append(nameservers, ns)
}
sort.Strings(nameservers)
var pairs [][2]string
for i := range len(nameservers) - 1 {
ns1 := nameservers[i]
ns2 := nameservers[i+1]
if recordsEqual(current[ns1], current[ns2]) {
continue
}
prev1, ok1 := prev.RecordsByNameserver[ns1]
prev2, ok2 := prev.RecordsByNameserver[ns2]
if !ok1 || !ok2 || !recordsEqual(prev1.Records, prev2.Records) {
continue
}
pairs = append(pairs, [2]string{ns1, ns2})
}
return pairs
}
func (w *Watcher) checkAllPorts(ctx context.Context) {
// Phase 1: Build current IP:port → hostname associations
// from fresh DNS data.