control/controlclient,health: add tests for control health tracking

Updates tailscale/corp#27759

Signed-off-by: James Sanderson <jsanderson@tailscale.com>
This commit is contained in:
James Sanderson 2025-04-29 11:37:12 +01:00 committed by James 'zofrex' Sanderson
parent ac1215c7e0
commit 1f1c323eeb
3 changed files with 130 additions and 3 deletions

View File

@ -17,6 +17,7 @@ import (
"github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp"
"go4.org/mem" "go4.org/mem"
"tailscale.com/control/controlknobs" "tailscale.com/control/controlknobs"
"tailscale.com/health"
"tailscale.com/tailcfg" "tailscale.com/tailcfg"
"tailscale.com/tstest" "tailscale.com/tstest"
"tailscale.com/tstime" "tailscale.com/tstime"
@ -1136,3 +1137,34 @@ func BenchmarkMapSessionDelta(b *testing.B) {
}) })
} }
} }
// TestNetmapHealthIntegration checks that we get the expected health warnings
// from processing a map response and passing the NetworkMap to a health tracker
func TestNetmapHealthIntegration(t *testing.T) {
ms := newTestMapSession(t, nil)
ht := health.Tracker{}
ht.SetIPNState("NeedsLogin", true)
ht.GotStreamedMapResponse()
nm := ms.netmapForResponse(&tailcfg.MapResponse{
Health: []string{"Test message"},
})
ht.SetControlHealth(nm.ControlHealth)
state := ht.CurrentState()
warning, ok := state.Warnings["control-health"]
if !ok {
t.Fatal("no warning found in current state with code 'control-health'")
}
if got, want := warning.Title, "Coordination server reports an issue"; got != want {
t.Errorf("warning.Title = %q, want %q", got, want)
}
if got, want := warning.Severity, health.SeverityMedium; got != want {
t.Errorf("warning.Severity = %s, want %s", got, want)
}
if got, want := warning.Text, "The coordination server is reporting an health issue: Test message"; got != want {
t.Errorf("warning.Text = %q, want %q", got, want)
}
}

View File

@ -402,7 +402,7 @@ func (t *Tracker) setUnhealthyLocked(w *Warnable, args Args) {
// executed immediately. Otherwise, the callback should be enqueued to run once the Warnable // executed immediately. Otherwise, the callback should be enqueued to run once the Warnable
// becomes visible. // becomes visible.
if w.IsVisible(ws, t.now) { if w.IsVisible(ws, t.now) {
go cb(w, w.unhealthyState(ws)) cb(w, w.unhealthyState(ws))
continue continue
} }
@ -415,7 +415,7 @@ func (t *Tracker) setUnhealthyLocked(w *Warnable, args Args) {
// Check if the Warnable is still unhealthy, as it could have become healthy between the time // Check if the Warnable is still unhealthy, as it could have become healthy between the time
// the timer was set for and the time it was executed. // the timer was set for and the time it was executed.
if t.warnableVal[w] != nil { if t.warnableVal[w] != nil {
go cb(w, w.unhealthyState(ws)) cb(w, w.unhealthyState(ws))
delete(t.pendingVisibleTimers, w) delete(t.pendingVisibleTimers, w)
} }
}) })
@ -449,7 +449,7 @@ func (t *Tracker) setHealthyLocked(w *Warnable) {
} }
for _, cb := range t.watchers { for _, cb := range t.watchers {
go cb(w, nil) cb(w, nil)
} }
} }
@ -483,6 +483,16 @@ func (t *Tracker) AppendWarnableDebugFlags(base []string) []string {
// The provided callback function will be executed in its own goroutine. The returned function can be used // The provided callback function will be executed in its own goroutine. The returned function can be used
// to unregister the callback. // to unregister the callback.
func (t *Tracker) RegisterWatcher(cb func(w *Warnable, r *UnhealthyState)) (unregister func()) { func (t *Tracker) RegisterWatcher(cb func(w *Warnable, r *UnhealthyState)) (unregister func()) {
return t.registerSyncWatcher(func(w *Warnable, r *UnhealthyState) {
go cb(w, r)
})
}
// registerSyncWatcher adds a function that will be called whenever the health
// state of any Warnable changes. The provided callback function will be
// executed synchronously. Call RegisterWatcher to register any callbacks that
// won't return from execution immediately.
func (t *Tracker) registerSyncWatcher(cb func(w *Warnable, r *UnhealthyState)) (unregister func()) {
if t.nil() { if t.nil() {
return func() {} return func() {}
} }

View File

@ -451,3 +451,88 @@ func TestNoDERPHomeWarnableManual(t *testing.T) {
t.Fatalf("got unexpected noDERPHomeWarnable warnable: %v", ws) t.Fatalf("got unexpected noDERPHomeWarnable warnable: %v", ws)
} }
} }
func TestControlHealth(t *testing.T) {
ht := Tracker{}
ht.SetIPNState("NeedsLogin", true)
ht.GotStreamedMapResponse()
ht.SetControlHealth([]string{"Test message"})
state := ht.CurrentState()
warning, ok := state.Warnings["control-health"]
if !ok {
t.Fatal("no warning found in current state with code 'control-health'")
}
if got, want := warning.Title, "Coordination server reports an issue"; got != want {
t.Errorf("warning.Title = %q, want %q", got, want)
}
if got, want := warning.Severity, SeverityMedium; got != want {
t.Errorf("warning.Severity = %s, want %s", got, want)
}
if got, want := warning.Text, "The coordination server is reporting an health issue: Test message"; got != want {
t.Errorf("warning.Text = %q, want %q", got, want)
}
}
func TestControlHealthNotifiesOnChange(t *testing.T) {
ht := Tracker{}
ht.SetIPNState("NeedsLogin", true)
ht.GotStreamedMapResponse()
gotNotified := false
ht.registerSyncWatcher(func(_ *Warnable, _ *UnhealthyState) {
gotNotified = true
})
ht.SetControlHealth([]string{"Test message"})
if !gotNotified {
t.Errorf("watcher did not get called, want it to be called")
}
}
func TestControlHealthNoNotifyOnUnchanged(t *testing.T) {
ht := Tracker{}
ht.SetIPNState("NeedsLogin", true)
ht.GotStreamedMapResponse()
// Set up an existing control health issue
ht.SetControlHealth([]string{"Test message"})
// Now register our watcher
gotNotified := false
ht.registerSyncWatcher(func(_ *Warnable, _ *UnhealthyState) {
gotNotified = true
})
// Send the same control health message again - should not notify
ht.SetControlHealth([]string{"Test message"})
if gotNotified {
t.Errorf("watcher got called, want it to not be called")
}
}
func TestControlHealthIgnoredOutsideMapPoll(t *testing.T) {
ht := Tracker{}
ht.SetIPNState("NeedsLogin", true)
gotNotified := false
ht.registerSyncWatcher(func(_ *Warnable, _ *UnhealthyState) {
gotNotified = true
})
ht.SetControlHealth([]string{"Test message"})
state := ht.CurrentState()
_, ok := state.Warnings["control-health"]
if ok {
t.Error("got a warning with code 'control-health', want none")
}
if gotNotified {
t.Error("watcher got called, want it to not be called")
}
}