Revert "control/controlclient,health,tailcfg: refactor control health message…"

This reverts commit aa8bc23c496821dfa00771c9604fc4a71ead7d4c.
This commit is contained in:
Paul Scott 2025-05-22 15:06:16 +01:00 committed by GitHub
parent aa8bc23c49
commit 3ffa7c642f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 122 additions and 495 deletions

View File

@ -12,7 +12,6 @@ import (
"sync/atomic" "sync/atomic"
"time" "time"
"tailscale.com/health"
"tailscale.com/logtail/backoff" "tailscale.com/logtail/backoff"
"tailscale.com/net/sockstats" "tailscale.com/net/sockstats"
"tailscale.com/tailcfg" "tailscale.com/tailcfg"
@ -199,11 +198,7 @@ func NewNoStart(opts Options) (_ *Auto, err error) {
c.mapCtx, c.mapCancel = context.WithCancel(context.Background()) c.mapCtx, c.mapCancel = context.WithCancel(context.Background())
c.mapCtx = sockstats.WithSockStats(c.mapCtx, sockstats.LabelControlClientAuto, opts.Logf) c.mapCtx = sockstats.WithSockStats(c.mapCtx, sockstats.LabelControlClientAuto, opts.Logf)
c.unregisterHealthWatch = opts.HealthTracker.RegisterWatcher(func(c health.Change) { c.unregisterHealthWatch = opts.HealthTracker.RegisterWatcher(direct.ReportHealthChange)
if c.WarnableChanged {
direct.ReportWarnableChange(c.Warnable, c.UnhealthyState)
}
})
return c, nil return c, nil
} }

View File

@ -1623,9 +1623,9 @@ func postPingResult(start time.Time, logf logger.Logf, c *http.Client, pr *tailc
return nil return nil
} }
// ReportWarnableChange reports to the control plane a change to this node's // ReportHealthChange reports to the control plane a change to this node's
// health. w must be non-nil. us can be nil to indicate a healthy state for w. // health. w must be non-nil. us can be nil to indicate a healthy state for w.
func (c *Direct) ReportWarnableChange(w *health.Warnable, us *health.UnhealthyState) { func (c *Direct) ReportHealthChange(w *health.Warnable, us *health.UnhealthyState) {
if w == health.NetworkStatusWarnable || w == health.IPNStateWarnable || w == health.LoginStateWarnable { if w == health.NetworkStatusWarnable || w == health.IPNStateWarnable || w == health.LoginStateWarnable {
// We don't report these. These include things like the network is down // We don't report these. These include things like the network is down
// (in which case we can't report anyway) or the user wanted things // (in which case we can't report anyway) or the user wanted things

View File

@ -6,10 +6,7 @@ package controlclient
import ( import (
"cmp" "cmp"
"context" "context"
"crypto/sha256"
"encoding/hex"
"encoding/json" "encoding/json"
"io"
"maps" "maps"
"net" "net"
"reflect" "reflect"
@ -831,16 +828,6 @@ func (ms *mapSession) sortedPeers() []tailcfg.NodeView {
func (ms *mapSession) netmap() *netmap.NetworkMap { func (ms *mapSession) netmap() *netmap.NetworkMap {
peerViews := ms.sortedPeers() peerViews := ms.sortedPeers()
// Convert all ms.lastHealth to the new [netmap.NetworkMap.DisplayMessages].
var msgs map[tailcfg.DisplayMessageID]tailcfg.DisplayMessage
for _, h := range ms.lastHealth {
mak.Set(&msgs, tailcfg.DisplayMessageID("control-health-"+strhash(h)), tailcfg.DisplayMessage{
Title: "Coordination server reports an issue",
Severity: tailcfg.SeverityMedium,
Text: "The coordination server is reporting a health issue: " + h,
})
}
nm := &netmap.NetworkMap{ nm := &netmap.NetworkMap{
NodeKey: ms.publicNodeKey, NodeKey: ms.publicNodeKey,
PrivateKey: ms.privateNodeKey, PrivateKey: ms.privateNodeKey,
@ -855,7 +842,7 @@ func (ms *mapSession) netmap() *netmap.NetworkMap {
SSHPolicy: ms.lastSSHPolicy, SSHPolicy: ms.lastSSHPolicy,
CollectServices: ms.collectServices, CollectServices: ms.collectServices,
DERPMap: ms.lastDERPMap, DERPMap: ms.lastDERPMap,
DisplayMessages: msgs, ControlHealth: ms.lastHealth,
TKAEnabled: ms.lastTKAInfo != nil && !ms.lastTKAInfo.Disabled, TKAEnabled: ms.lastTKAInfo != nil && !ms.lastTKAInfo.Disabled,
} }
@ -881,12 +868,5 @@ func (ms *mapSession) netmap() *netmap.NetworkMap {
if DevKnob.ForceProxyDNS() { if DevKnob.ForceProxyDNS() {
nm.DNS.Proxied = true nm.DNS.Proxied = true
} }
return nm return nm
} }
func strhash(h string) string {
s := sha256.New()
io.WriteString(s, h)
return hex.EncodeToString(s.Sum(nil))
}

View File

@ -7,7 +7,6 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"maps"
"net/netip" "net/netip"
"reflect" "reflect"
"strings" "strings"
@ -1149,36 +1148,23 @@ func TestNetmapHealthIntegration(t *testing.T) {
ht.GotStreamedMapResponse() ht.GotStreamedMapResponse()
nm := ms.netmapForResponse(&tailcfg.MapResponse{ nm := ms.netmapForResponse(&tailcfg.MapResponse{
Health: []string{ Health: []string{"Test message"},
"Test message",
"Another message",
},
}) })
ht.SetControlHealth(nm.DisplayMessages) ht.SetControlHealth(nm.ControlHealth)
want := map[health.WarnableCode]health.UnhealthyState{ state := ht.CurrentState()
"control-health-c0719e9a8d5d838d861dc6f675c899d2b309a3a65bb9fe6b11e5afcbf9a2c0b1": { warning, ok := state.Warnings["control-health"]
WarnableCode: "control-health-c0719e9a8d5d838d861dc6f675c899d2b309a3a65bb9fe6b11e5afcbf9a2c0b1",
Title: "Coordination server reports an issue",
Severity: health.SeverityMedium,
Text: "The coordination server is reporting a health issue: Test message",
},
"control-health-1dc7017a73a3c55c0d6a8423e3813c7ab6562d9d3064c2ec6ac7822f61b1db9c": {
WarnableCode: "control-health-1dc7017a73a3c55c0d6a8423e3813c7ab6562d9d3064c2ec6ac7822f61b1db9c",
Title: "Coordination server reports an issue",
Severity: health.SeverityMedium,
Text: "The coordination server is reporting a health issue: Another message",
},
}
got := maps.Clone(ht.CurrentState().Warnings) if !ok {
for k := range got { t.Fatal("no warning found in current state with code 'control-health'")
if !strings.HasPrefix(string(k), "control-health") { }
delete(got, k) 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 {
if d := cmp.Diff(want, got); d != "" { t.Errorf("warning.Severity = %s, want %s", got, want)
t.Fatalf("CurrentStatus().Warnings[\"control-health*\"] different than expected (-want +got)\n%s", d) }
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

@ -88,7 +88,7 @@ type Tracker struct {
// sysErr maps subsystems to their current error (or nil if the subsystem is healthy) // sysErr maps subsystems to their current error (or nil if the subsystem is healthy)
// Deprecated: using Warnables should be preferred // Deprecated: using Warnables should be preferred
sysErr map[Subsystem]error sysErr map[Subsystem]error
watchers set.HandleSet[func(Change)] // opt func to run if error state changes watchers set.HandleSet[func(*Warnable, *UnhealthyState)] // opt func to run if error state changes
timer tstime.TimerController timer tstime.TimerController
latestVersion *tailcfg.ClientVersion // or nil latestVersion *tailcfg.ClientVersion // or nil
@ -111,8 +111,7 @@ type Tracker struct {
ipnWantRunning bool ipnWantRunning bool
ipnWantRunningLastTrue time.Time // when ipnWantRunning last changed false -> true ipnWantRunningLastTrue time.Time // when ipnWantRunning last changed false -> true
anyInterfaceUp opt.Bool // empty means unknown (assume true) anyInterfaceUp opt.Bool // empty means unknown (assume true)
lastNotifiedControlMessages map[tailcfg.DisplayMessageID]tailcfg.DisplayMessage // latest control messages processed, kept for change detection controlHealth []string
controlMessages map[tailcfg.DisplayMessageID]tailcfg.DisplayMessage // latest control messages received
lastLoginErr error lastLoginErr error
localLogConfigErr error localLogConfigErr error
tlsConnectionErrors map[string]error // map[ServerName]error tlsConnectionErrors map[string]error // map[ServerName]error
@ -208,15 +207,13 @@ func unregister(w *Warnable) {
// the program. // the program.
type WarnableCode string type WarnableCode string
// A Warnable is something that we might want to warn the user about, or not. A // A Warnable is something that we might want to warn the user about, or not. A Warnable is either
// Warnable is either in a healthy or unhealthy state. A Warnable is unhealthy if // in an healthy or unhealth state. A Warnable is unhealthy if the Tracker knows about a WarningState
// the Tracker knows about a WarningState affecting the Warnable. // affecting the Warnable.
// // In most cases, Warnables are components of the backend (for instance, "DNS" or "Magicsock").
// In most cases, Warnables are components of the backend (for instance, "DNS" // Warnables are similar to the Subsystem type previously used in this package, but they provide
// or "Magicsock"). Warnables are similar to the Subsystem type previously used // a unique identifying code for each Warnable, along with more metadata that makes it easier for
// in this package, but they provide a unique identifying code for each // a GUI to display the Warnable in a user-friendly way.
// Warnable, along with more metadata that makes it easier for a GUI to display
// the Warnable in a user-friendly way.
type Warnable struct { type Warnable struct {
// Code is a string that uniquely identifies this Warnable across the entire Tailscale backend, // Code is a string that uniquely identifies this Warnable across the entire Tailscale backend,
// and can be mapped to a user-displayable localized string. // and can be mapped to a user-displayable localized string.
@ -412,18 +409,12 @@ func (t *Tracker) setUnhealthyLocked(w *Warnable, args Args) {
prevWs := t.warnableVal[w] prevWs := t.warnableVal[w]
mak.Set(&t.warnableVal, w, ws) mak.Set(&t.warnableVal, w, ws)
if !ws.Equal(prevWs) { if !ws.Equal(prevWs) {
change := Change{
WarnableChanged: true,
Warnable: w,
UnhealthyState: w.unhealthyState(ws),
}
for _, cb := range t.watchers { for _, cb := range t.watchers {
// If the Warnable has been unhealthy for more than its TimeToVisible, the callback should be // If the Warnable has been unhealthy for more than its TimeToVisible, the callback should be
// 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) {
cb(change) cb(w, w.unhealthyState(ws))
continue continue
} }
@ -436,7 +427,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 {
cb(change) cb(w, w.unhealthyState(ws))
delete(t.pendingVisibleTimers, w) delete(t.pendingVisibleTimers, w)
} }
}) })
@ -469,23 +460,8 @@ func (t *Tracker) setHealthyLocked(w *Warnable) {
delete(t.pendingVisibleTimers, w) delete(t.pendingVisibleTimers, w)
} }
change := Change{
WarnableChanged: true,
Warnable: w,
}
for _, cb := range t.watchers { for _, cb := range t.watchers {
cb(change) cb(w, nil)
}
}
// notifyWatchersControlChangedLocked calls each watcher to signal that control
// health messages have changed (and should be fetched via CurrentState).
func (t *Tracker) notifyWatchersControlChangedLocked() {
change := Change{
ControlHealthChanged: true,
}
for _, cb := range t.watchers {
cb(change)
} }
} }
@ -512,57 +488,23 @@ func (t *Tracker) AppendWarnableDebugFlags(base []string) []string {
return ret return ret
} }
// Change is used to communicate a change to health. This could either be due to // RegisterWatcher adds a function that will be called whenever the health state of any Warnable changes.
// a Warnable changing from health to unhealthy (or vice-versa), or because the // If a Warnable becomes unhealthy or its unhealthy state is updated, the callback will be called with its
// health messages received from the control-plane have changed. // current Representation.
// // If a Warnable becomes healthy, the callback will be called with ws set to nil.
// Exactly one *Changed field will be true. // The provided callback function will be executed in its own goroutine. The returned function can be used
type Change struct { // to unregister the callback.
// ControlHealthChanged indicates it was health messages from the func (t *Tracker) RegisterWatcher(cb func(w *Warnable, r *UnhealthyState)) (unregister func()) {
// control-plane server that changed. return t.registerSyncWatcher(func(w *Warnable, r *UnhealthyState) {
ControlHealthChanged bool go cb(w, r)
// WarnableChanged indicates it was a client Warnable which changed state.
WarnableChanged bool
// Warnable is whose health changed, as indicated in UnhealthyState.
Warnable *Warnable
// UnhealthyState is set if the changed Warnable is now unhealthy, or nil
// if Warnable is now healthy.
UnhealthyState *UnhealthyState
}
// RegisterWatcher adds a function that will be called its own goroutine
// whenever the health state of any client [Warnable] or control-plane health
// messages changes. The returned function can be used to unregister the
// callback.
//
// If a client [Warnable] becomes unhealthy or its unhealthy state is updated,
// the callback will be called with WarnableChanged set to true and the Warnable
// and its UnhealthyState:
//
// go cb(Change{WarnableChanged: true, Warnable: w, UnhealthyState: us})
//
// If a Warnable becomes healthy, the callback will be called with
// WarnableChanged set to true, the Warnable set, and UnhealthyState set to nil:
//
// go cb(Change{WarnableChanged: true, Warnable: w, UnhealthyState: nil})
//
// If the health messages from the control-plane change, the callback will be
// called with ControlHealthChanged set to true. Recipients can fetch the set of
// control-plane health messages by calling [Tracker.CurrentState]:
//
// go cb(Change{ControlHealthChanged: true})
func (t *Tracker) RegisterWatcher(cb func(Change)) (unregister func()) {
return t.registerSyncWatcher(func(c Change) {
go cb(c)
}) })
} }
// registerSyncWatcher adds a function that will be called whenever the health // registerSyncWatcher adds a function that will be called whenever the health
// state changes. The provided callback function will be executed synchronously. // state of any Warnable changes. The provided callback function will be
// Call RegisterWatcher to register any callbacks that won't return from // executed synchronously. Call RegisterWatcher to register any callbacks that
// execution immediately. // won't return from execution immediately.
func (t *Tracker) registerSyncWatcher(cb func(c Change)) (unregister func()) { func (t *Tracker) registerSyncWatcher(cb func(w *Warnable, r *UnhealthyState)) (unregister func()) {
if t.nil() { if t.nil() {
return func() {} return func() {}
} }
@ -570,7 +512,7 @@ func (t *Tracker) registerSyncWatcher(cb func(c Change)) (unregister func()) {
t.mu.Lock() t.mu.Lock()
defer t.mu.Unlock() defer t.mu.Unlock()
if t.watchers == nil { if t.watchers == nil {
t.watchers = set.HandleSet[func(Change)]{} t.watchers = set.HandleSet[func(*Warnable, *UnhealthyState)]{}
} }
handle := t.watchers.Add(cb) handle := t.watchers.Add(cb)
if t.timer == nil { if t.timer == nil {
@ -717,15 +659,13 @@ func (t *Tracker) updateLegacyErrorWarnableLocked(key Subsystem, err error) {
} }
} }
func (t *Tracker) SetControlHealth(problems map[tailcfg.DisplayMessageID]tailcfg.DisplayMessage) { func (t *Tracker) SetControlHealth(problems []string) {
if t.nil() { if t.nil() {
return return
} }
t.mu.Lock() t.mu.Lock()
defer t.mu.Unlock() defer t.mu.Unlock()
t.controlHealth = problems
t.controlMessages = problems
t.selfCheckLocked() t.selfCheckLocked()
} }
@ -1021,11 +961,11 @@ func (t *Tracker) OverallError() error {
return t.multiErrLocked() return t.multiErrLocked()
} }
// Strings() returns a string array containing the Text of all Warnings and // Strings() returns a string array containing the Text of all Warnings
// ControlHealth messages currently known to the Tracker. These strings can be // currently known to the Tracker. These strings can be presented to the
// presented to the user, although ideally you would use the Code property on // user, although ideally you would use the Code property on each Warning
// each Warning to show a localized version of them instead. This function is // to show a localized version of them instead.
// here for legacy compatibility purposes and is deprecated. // This function is here for legacy compatibility purposes and is deprecated.
func (t *Tracker) Strings() []string { func (t *Tracker) Strings() []string {
if t.nil() { if t.nil() {
return nil return nil
@ -1051,19 +991,6 @@ func (t *Tracker) stringsLocked() []string {
result = append(result, w.Text(ws.Args)) result = append(result, w.Text(ws.Args))
} }
} }
warnLen := len(result)
for _, c := range t.controlMessages {
if c.Title != "" && c.Text != "" {
result = append(result, c.Title+": "+c.Text)
} else if c.Title != "" {
result = append(result, c.Title)
} else if c.Text != "" {
result = append(result, c.Text)
}
}
sort.Strings(result[warnLen:])
return result return result
} }
@ -1244,10 +1171,14 @@ func (t *Tracker) updateBuiltinWarnablesLocked() {
t.setHealthyLocked(derpRegionErrorWarnable) t.setHealthyLocked(derpRegionErrorWarnable)
} }
// Check if control health messages have changed if len(t.controlHealth) > 0 {
if !maps.EqualFunc(t.lastNotifiedControlMessages, t.controlMessages, tailcfg.DisplayMessage.Equal) { for _, s := range t.controlHealth {
t.lastNotifiedControlMessages = t.controlMessages t.setUnhealthyLocked(controlHealthWarnable, Args{
t.notifyWatchersControlChangedLocked() ArgError: s,
})
}
} else {
t.setHealthyLocked(controlHealthWarnable)
} }
if err := envknob.ApplyDiskConfigError(); err != nil { if err := envknob.ApplyDiskConfigError(); err != nil {

View File

@ -5,14 +5,12 @@ package health
import ( import (
"fmt" "fmt"
"maps"
"reflect" "reflect"
"slices" "slices"
"strconv" "strconv"
"testing" "testing"
"time" "time"
"github.com/google/go-cmp/cmp"
"tailscale.com/tailcfg" "tailscale.com/tailcfg"
"tailscale.com/tstest" "tailscale.com/tstest"
"tailscale.com/types/opt" "tailscale.com/types/opt"
@ -27,7 +25,6 @@ func TestAppendWarnableDebugFlags(t *testing.T) {
w := Register(&Warnable{ w := Register(&Warnable{
Code: WarnableCode(fmt.Sprintf("warnable-code-%d", i)), Code: WarnableCode(fmt.Sprintf("warnable-code-%d", i)),
MapDebugFlag: fmt.Sprint(i), MapDebugFlag: fmt.Sprint(i),
Text: StaticMessage(""),
}) })
defer unregister(w) defer unregister(w)
if i%2 == 0 { if i%2 == 0 {
@ -117,9 +114,7 @@ func TestWatcher(t *testing.T) {
becameUnhealthy := make(chan struct{}) becameUnhealthy := make(chan struct{})
becameHealthy := make(chan struct{}) becameHealthy := make(chan struct{})
watcherFunc := func(c Change) { watcherFunc := func(w *Warnable, us *UnhealthyState) {
w := c.Warnable
us := c.UnhealthyState
if w != testWarnable { if w != testWarnable {
t.Fatalf("watcherFunc was called, but with an unexpected Warnable: %v, want: %v", w, testWarnable) t.Fatalf("watcherFunc was called, but with an unexpected Warnable: %v, want: %v", w, testWarnable)
} }
@ -189,9 +184,7 @@ func TestSetUnhealthyWithTimeToVisible(t *testing.T) {
becameUnhealthy := make(chan struct{}) becameUnhealthy := make(chan struct{})
becameHealthy := make(chan struct{}) becameHealthy := make(chan struct{})
watchFunc := func(c Change) { watchFunc := func(w *Warnable, us *UnhealthyState) {
w := c.Warnable
us := c.UnhealthyState
if w != mw { if w != mw {
t.Fatalf("watcherFunc was called, but with an unexpected Warnable: %v, want: %v", w, w) t.Fatalf("watcherFunc was called, but with an unexpected Warnable: %v, want: %v", w, w)
} }
@ -464,94 +457,21 @@ func TestControlHealth(t *testing.T) {
ht.SetIPNState("NeedsLogin", true) ht.SetIPNState("NeedsLogin", true)
ht.GotStreamedMapResponse() ht.GotStreamedMapResponse()
baseWarns := ht.CurrentState().Warnings ht.SetControlHealth([]string{"Test message"})
baseStrs := ht.Strings()
ht.SetControlHealth(map[tailcfg.DisplayMessageID]tailcfg.DisplayMessage{
"control-health-test": {
Title: "Control health message",
Text: "Extra help",
},
"control-health-title": {
Title: "Control health title only",
},
})
t.Run("Warnings", func(t *testing.T) {
wantWarns := map[WarnableCode]UnhealthyState{
"control-health-test": {
WarnableCode: "control-health-test",
Severity: SeverityMedium,
Title: "Control health message",
Text: "Extra help",
},
"control-health-title": {
WarnableCode: "control-health-title",
Severity: SeverityMedium,
Title: "Control health title only",
},
}
state := ht.CurrentState() state := ht.CurrentState()
gotWarns := maps.Clone(state.Warnings) warning, ok := state.Warnings["control-health"]
for k := range gotWarns {
if _, inBase := baseWarns[k]; inBase {
delete(gotWarns, k)
}
}
if diff := cmp.Diff(wantWarns, gotWarns); diff != "" {
t.Fatalf(`CurrentState().Warnings["control-health-*"] wrong (-want +got):\n%s`, diff)
}
})
t.Run("Strings()", func(t *testing.T) { if !ok {
wantStrs := []string{ t.Fatal("no warning found in current state with code 'control-health'")
"Control health message: Extra help",
"Control health title only",
} }
var gotStrs []string if got, want := warning.Title, "Coordination server reports an issue"; got != want {
for _, s := range ht.Strings() { t.Errorf("warning.Title = %q, want %q", got, want)
if !slices.Contains(baseStrs, s) {
gotStrs = append(gotStrs, s)
} }
if got, want := warning.Severity, SeverityMedium; got != want {
t.Errorf("warning.Severity = %s, want %s", got, want)
} }
if diff := cmp.Diff(wantStrs, gotStrs); diff != "" { if got, want := warning.Text, "The coordination server is reporting an health issue: Test message"; got != want {
t.Fatalf(`Strings() wrong (-want +got):\n%s`, diff) t.Errorf("warning.Text = %q, want %q", got, want)
}
})
t.Run("tailscaled_health_messages", func(t *testing.T) {
var r usermetric.Registry
ht.SetMetricsRegistry(&r)
got := ht.metricHealthMessage.Get(metricHealthMessageLabel{
Type: MetricLabelWarning,
}).String()
want := strconv.Itoa(
2 + // from SetControlHealth
len(baseStrs),
)
if got != want {
t.Errorf("metricsHealthMessage.Get(warning) = %q, want %q", got, want)
}
})
}
func TestControlHealthNotifiesOnSet(t *testing.T) {
ht := Tracker{}
ht.SetIPNState("NeedsLogin", true)
ht.GotStreamedMapResponse()
gotNotified := false
ht.registerSyncWatcher(func(_ Change) {
gotNotified = true
})
ht.SetControlHealth(map[tailcfg.DisplayMessageID]tailcfg.DisplayMessage{
"test": {},
})
if !gotNotified {
t.Errorf("watcher did not get called, want it to be called")
} }
} }
@ -560,45 +480,12 @@ func TestControlHealthNotifiesOnChange(t *testing.T) {
ht.SetIPNState("NeedsLogin", true) ht.SetIPNState("NeedsLogin", true)
ht.GotStreamedMapResponse() ht.GotStreamedMapResponse()
ht.SetControlHealth(map[tailcfg.DisplayMessageID]tailcfg.DisplayMessage{
"test-1": {},
})
gotNotified := false gotNotified := false
ht.registerSyncWatcher(func(_ Change) { ht.registerSyncWatcher(func(_ *Warnable, _ *UnhealthyState) {
gotNotified = true gotNotified = true
}) })
ht.SetControlHealth(map[tailcfg.DisplayMessageID]tailcfg.DisplayMessage{ ht.SetControlHealth([]string{"Test message"})
"test-2": {},
})
if !gotNotified {
t.Errorf("watcher did not get called, want it to be called")
}
}
func TestControlHealthNotifiesOnDetailsChange(t *testing.T) {
ht := Tracker{}
ht.SetIPNState("NeedsLogin", true)
ht.GotStreamedMapResponse()
ht.SetControlHealth(map[tailcfg.DisplayMessageID]tailcfg.DisplayMessage{
"test-1": {
Title: "Title",
},
})
gotNotified := false
ht.registerSyncWatcher(func(_ Change) {
gotNotified = true
})
ht.SetControlHealth(map[tailcfg.DisplayMessageID]tailcfg.DisplayMessage{
"test-1": {
Title: "Updated title",
},
})
if !gotNotified { if !gotNotified {
t.Errorf("watcher did not get called, want it to be called") t.Errorf("watcher did not get called, want it to be called")
@ -611,20 +498,16 @@ func TestControlHealthNoNotifyOnUnchanged(t *testing.T) {
ht.GotStreamedMapResponse() ht.GotStreamedMapResponse()
// Set up an existing control health issue // Set up an existing control health issue
ht.SetControlHealth(map[tailcfg.DisplayMessageID]tailcfg.DisplayMessage{ ht.SetControlHealth([]string{"Test message"})
"test": {},
})
// Now register our watcher // Now register our watcher
gotNotified := false gotNotified := false
ht.registerSyncWatcher(func(_ Change) { ht.registerSyncWatcher(func(_ *Warnable, _ *UnhealthyState) {
gotNotified = true gotNotified = true
}) })
// Send the same control health message again - should not notify // Send the same control health message again - should not notify
ht.SetControlHealth(map[tailcfg.DisplayMessageID]tailcfg.DisplayMessage{ ht.SetControlHealth([]string{"Test message"})
"test": {},
})
if gotNotified { if gotNotified {
t.Errorf("watcher got called, want it to not be called") t.Errorf("watcher got called, want it to not be called")
@ -636,13 +519,11 @@ func TestControlHealthIgnoredOutsideMapPoll(t *testing.T) {
ht.SetIPNState("NeedsLogin", true) ht.SetIPNState("NeedsLogin", true)
gotNotified := false gotNotified := false
ht.registerSyncWatcher(func(_ Change) { ht.registerSyncWatcher(func(_ *Warnable, _ *UnhealthyState) {
gotNotified = true gotNotified = true
}) })
ht.SetControlHealth(map[tailcfg.DisplayMessageID]tailcfg.DisplayMessage{ ht.SetControlHealth([]string{"Test message"})
"control-health": {},
})
state := ht.CurrentState() state := ht.CurrentState()
_, ok := state.Warnings["control-health"] _, ok := state.Warnings["control-health"]

View File

@ -5,8 +5,6 @@ package health
import ( import (
"time" "time"
"tailscale.com/tailcfg"
) )
// State contains the health status of the backend, and is // State contains the health status of the backend, and is
@ -23,8 +21,7 @@ type State struct {
} }
// UnhealthyState contains information to be shown to the user to inform them // UnhealthyState contains information to be shown to the user to inform them
// that a [Warnable] is currently unhealthy or [tailcfg.DisplayMessage] is being // that a Warnable is currently unhealthy.
// sent from the control-plane.
type UnhealthyState struct { type UnhealthyState struct {
WarnableCode WarnableCode WarnableCode WarnableCode
Severity Severity Severity Severity
@ -101,34 +98,11 @@ func (t *Tracker) CurrentState() *State {
wm[w.Code] = *w.unhealthyState(ws) wm[w.Code] = *w.unhealthyState(ws)
} }
for id, msg := range t.lastNotifiedControlMessages {
code := WarnableCode(id)
wm[code] = UnhealthyState{
WarnableCode: code,
Severity: severityFromTailcfg(msg.Severity),
Title: msg.Title,
Text: msg.Text,
ImpactsConnectivity: msg.ImpactsConnectivity,
// TODO(tailscale/corp#27759): DependsOn?
}
}
return &State{ return &State{
Warnings: wm, Warnings: wm,
} }
} }
func severityFromTailcfg(s tailcfg.DisplayMessageSeverity) Severity {
switch s {
case tailcfg.SeverityHigh:
return SeverityHigh
case tailcfg.SeverityLow:
return SeverityLow
default:
return SeverityMedium
}
}
// isEffectivelyHealthyLocked reports whether w is effectively healthy. // isEffectivelyHealthyLocked reports whether w is effectively healthy.
// That means it's either actually healthy or it has a dependency that // That means it's either actually healthy or it has a dependency that
// that's unhealthy, so we should treat w as healthy to not spam users // that's unhealthy, so we should treat w as healthy to not spam users

View File

@ -238,6 +238,16 @@ var applyDiskConfigWarnable = Register(&Warnable{
}, },
}) })
// controlHealthWarnable is a Warnable that warns the user that the coordination server is reporting an health issue.
var controlHealthWarnable = Register(&Warnable{
Code: "control-health",
Title: "Coordination server reports an issue",
Severity: SeverityMedium,
Text: func(args Args) string {
return fmt.Sprintf("The coordination server is reporting an health issue: %v", args[ArgError])
},
})
// warmingUpWarnableDuration is the duration for which the warmingUpWarnable is reported by the backend after the user // warmingUpWarnableDuration is the duration for which the warmingUpWarnable is reported by the backend after the user
// has changed ipnWantRunning to true from false. // has changed ipnWantRunning to true from false.
const warmingUpWarnableDuration = 5 * time.Second const warmingUpWarnableDuration = 5 * time.Second

View File

@ -933,16 +933,12 @@ func (b *LocalBackend) linkChange(delta *netmon.ChangeDelta) {
} }
} }
func (b *LocalBackend) onHealthChange(change health.Change) { func (b *LocalBackend) onHealthChange(w *health.Warnable, us *health.UnhealthyState) {
if change.WarnableChanged {
w := change.Warnable
us := change.UnhealthyState
if us == nil { if us == nil {
b.logf("health(warnable=%s): ok", w.Code) b.logf("health(warnable=%s): ok", w.Code)
} else { } else {
b.logf("health(warnable=%s): error: %s", w.Code, us.Text) b.logf("health(warnable=%s): error: %s", w.Code, us.Text)
} }
}
// Whenever health changes, send the current health state to the frontend. // Whenever health changes, send the current health state to the frontend.
state := b.health.CurrentState() state := b.health.CurrentState()
@ -5830,7 +5826,7 @@ func (b *LocalBackend) setNetMapLocked(nm *netmap.NetworkMap) {
b.pauseOrResumeControlClientLocked() b.pauseOrResumeControlClientLocked()
if nm != nil { if nm != nil {
b.health.SetControlHealth(nm.DisplayMessages) b.health.SetControlHealth(nm.ControlHealth)
} else { } else {
b.health.SetControlHealth(nil) b.health.SetControlHealth(nil)
} }

View File

@ -2028,7 +2028,7 @@ type MapResponse struct {
// plane's perspective. A nil value means no change from the previous // plane's perspective. A nil value means no change from the previous
// MapResponse. A non-nil 0-length slice restores the health to good (no // MapResponse. A non-nil 0-length slice restores the health to good (no
// known problems). A non-zero length slice are the list of problems that // known problems). A non-zero length slice are the list of problems that
// the control plane sees. // the control place sees.
// //
// Note that this package's type, due its use of a slice and omitempty, is // Note that this package's type, due its use of a slice and omitempty, is
// unable to marshal a zero-length non-nil slice. The control server needs // unable to marshal a zero-length non-nil slice. The control server needs
@ -2078,56 +2078,6 @@ type MapResponse struct {
DefaultAutoUpdate opt.Bool `json:",omitempty"` DefaultAutoUpdate opt.Bool `json:",omitempty"`
} }
// DisplayMessage represents a health state of the node from the control plane's
// perspective. It is deliberately similar to health.Warnable as both get
// converted into health.UnhealthyState to be sent to the GUI.
type DisplayMessage struct {
// Title is a string that the GUI uses as title for this message. The title
// should be short and fit in a single line.
Title string
// Text is an extended string that the GUI will display to the user.
Text string
// Severity is the severity of the DisplayMessage, which the GUI can use to
// determine how to display it. Maps to health.Severity.
Severity DisplayMessageSeverity
// ImpactsConnectivity is whether the health problem will impact the user's
// ability to connect to the Internet or other nodes on the tailnet, which
// the GUI can use to determine how to display it.
ImpactsConnectivity bool `json:",omitempty"`
}
// DisplayMessageID is a string that uniquely identifies the kind of health
// issue (e.g. "session-expired").
type DisplayMessageID string
// Equal returns true iff all fields are equal.
func (m DisplayMessage) Equal(o DisplayMessage) bool {
return m.Title == o.Title &&
m.Text == o.Text &&
m.Severity == o.Severity &&
m.ImpactsConnectivity == o.ImpactsConnectivity
}
// DisplayMessageSeverity represents how serious a [DisplayMessage] is. Analogous
// to health.Severity.
type DisplayMessageSeverity string
const (
// SeverityHigh is the highest severity level, used for critical errors that need immediate attention.
// On platforms where the client GUI can deliver notifications, a SeverityHigh message will trigger
// a modal notification.
SeverityHigh DisplayMessageSeverity = "high"
// SeverityMedium is used for errors that are important but not critical. This won't trigger a modal
// notification, however it will be displayed in a more visible way than a SeverityLow message.
SeverityMedium DisplayMessageSeverity = "medium"
// SeverityLow is used for less important notices that don't need immediate attention. The user will
// have to go to a Settings window, or another "hidden" GUI location to see these messages.
SeverityLow DisplayMessageSeverity = "low"
)
// ClientVersion is information about the latest client version that's available // ClientVersion is information about the latest client version that's available
// for the client (and whether they're already running it). // for the client (and whether they're already running it).
// //

View File

@ -878,79 +878,3 @@ func TestCheckTag(t *testing.T) {
}) })
} }
} }
func TestDisplayMessageEqual(t *testing.T) {
base := DisplayMessage{
Title: "title",
Text: "text",
Severity: SeverityHigh,
ImpactsConnectivity: false,
}
type test struct {
name string
value DisplayMessage
wantEqual bool
}
for _, test := range []test{
{
name: "same",
value: DisplayMessage{
Title: "title",
Text: "text",
Severity: SeverityHigh,
ImpactsConnectivity: false,
},
wantEqual: true,
},
{
name: "different-title",
value: DisplayMessage{
Title: "different title",
Text: "text",
Severity: SeverityHigh,
ImpactsConnectivity: false,
},
wantEqual: false,
},
{
name: "different-text",
value: DisplayMessage{
Title: "title",
Text: "different text",
Severity: SeverityHigh,
ImpactsConnectivity: false,
},
wantEqual: false,
},
{
name: "different-severity",
value: DisplayMessage{
Title: "title",
Text: "text",
Severity: SeverityMedium,
ImpactsConnectivity: false,
},
wantEqual: false,
},
{
name: "different-impactsConnectivity",
value: DisplayMessage{
Title: "title",
Text: "text",
Severity: SeverityHigh,
ImpactsConnectivity: true,
},
wantEqual: false,
},
} {
t.Run(test.name, func(t *testing.T) {
got := base.Equal(test.value)
if got != test.wantEqual {
t.Errorf("Equal: got %t, want %t", got, test.wantEqual)
}
})
}
}

View File

@ -54,12 +54,12 @@ type NetworkMap struct {
// between updates and should not be modified. // between updates and should not be modified.
DERPMap *tailcfg.DERPMap DERPMap *tailcfg.DERPMap
// DisplayMessages are the list of health check problems for this // ControlHealth are the list of health check problems for this
// node from the perspective of the control plane. // node from the perspective of the control plane.
// If empty, there are no known problems from the control plane's // If empty, there are no known problems from the control plane's
// point of view, but the node might know about its own health // point of view, but the node might know about its own health
// check problems. // check problems.
DisplayMessages map[tailcfg.DisplayMessageID]tailcfg.DisplayMessage ControlHealth []string
// TKAEnabled indicates whether the tailnet key authority should be // TKAEnabled indicates whether the tailnet key authority should be
// enabled, from the perspective of the control plane. // enabled, from the perspective of the control plane.