state: fail when a Node field is not classified for peer map reuse

Every exported types.Node field is pinned to relation/election/payload
in nodeFieldImpact; a new field fails until classified, and each
relation/election field gets a mutation check against updateChanges.
This commit is contained in:
Kristoffer Dalby
2026-09-25 17:36:26 +00:00
parent 1613023be9
commit d813144f3b
+94
View File
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"net/netip"
"reflect"
"runtime"
"slices"
"sync"
@@ -1825,3 +1826,96 @@ func TestNodeStoreAdjacencyMatchesFullBuild(t *testing.T) {
})
}
}
// nodeFieldImpact classifies what NodeStore work a change to each
// exported types.Node field requires:
// - "relation": policy (hscontrol/policy/v2) or BuildPeerMap reads it,
// so a write must recompute peer adjacency.
// - "election": affects route election (online/health) but not who
// sees whom.
// - "payload": neither; a write can reuse the previous peer adjacency.
//
// A field missing here fails TestUpdateChangesCoversEveryNodeField:
// classify it, and if it is a relation input, teach HasPolicyChange or
// HasNetworkChanges about it before updateChanges can let NodeStore
// reuse peer adjacency across the write.
var nodeFieldImpact = map[string]string{
"ID": "relation", "IPv4": "relation", "IPv6": "relation",
"UserID": "relation", "User": "relation", "Tags": "relation",
"ApprovedRoutes": "relation", "Hostinfo": "relation",
"IsOnline": "election", "Unhealthy": "election",
"MachineKey": "payload", "NodeKey": "payload", "DiscoKey": "payload",
"Endpoints": "payload", "Hostname": "payload", "GivenName": "payload",
"RegisterMethod": "payload", "AuthKeyID": "payload", "AuthKey": "payload",
"Expiry": "payload", "LastSeen": "payload", "CreatedAt": "payload",
"UpdatedAt": "payload", "DeletedAt": "payload",
"ActiveSessions": "payload", "SessionEpoch": "payload",
}
// TestUpdateChangesCoversEveryNodeField guards against a new types.Node
// field going unclassified in nodeFieldImpact. An unclassified field
// means nobody has decided whether updateChanges needs to know about
// it, which is exactly how a relation input goes silently unrebuilt.
func TestUpdateChangesCoversEveryNodeField(t *testing.T) {
typ := reflect.TypeFor[types.Node]()
for f := range typ.Fields() {
if !f.IsExported() {
continue
}
if _, ok := nodeFieldImpact[f.Name]; !ok {
t.Errorf("types.Node.%s is not classified in nodeFieldImpact", f.Name)
}
}
}
// TestUpdateChangesReportsRelationFields pins what updateChanges reports
// for a mutation to each relation/election field in nodeFieldImpact,
// against a real pre/post pair rather than the classification map alone.
func TestUpdateChangesReportsRelationFields(t *testing.T) {
pfx := netip.MustParsePrefix("10.44.0.0/24")
ip := netip.MustParseAddr("100.64.9.9")
tests := []struct {
name string
mutate func(*types.Node)
wantRelation bool
wantElection bool
}{
{name: "ipv4", mutate: func(n *types.Node) { n.IPv4 = &ip }, wantRelation: true, wantElection: true},
{name: "tags", mutate: func(n *types.Node) { n.Tags = []string{"tag:x"}; n.UserID, n.User = nil, nil }, wantRelation: true, wantElection: true},
{name: "user", mutate: func(n *types.Node) { n.UserID = new(uint(99)) }, wantRelation: true, wantElection: true},
{name: "approved route", mutate: func(n *types.Node) {
n.Hostinfo = &tailcfg.Hostinfo{RoutableIPs: []netip.Prefix{pfx}}
n.ApprovedRoutes = []netip.Prefix{pfx}
}, wantRelation: true, wantElection: true},
{
// Announcing a route the policy has not approved does not
// change who can access it (SubnetRoutes/ExitRoutes, which
// gate HasPolicyChange, stay empty), but HasNetworkChanges
// tracks the raw announcement so a later approval sees a
// fresh Hostinfo rather than one NodeStore decided to reuse.
name: "announced but not approved route",
mutate: func(n *types.Node) {
n.Hostinfo = &tailcfg.Hostinfo{RoutableIPs: []netip.Prefix{pfx}}
},
wantRelation: true, wantElection: true,
},
{name: "online", mutate: func(n *types.Node) { n.IsOnline = new(true) }, wantElection: true},
{name: "unhealthy", mutate: func(n *types.Node) { n.Unhealthy = true }, wantElection: true},
{name: "endpoint", mutate: func(n *types.Node) { n.Endpoints = []netip.AddrPort{netip.MustParseAddrPort("192.0.2.1:1")} }},
{name: "lastseen", mutate: func(n *types.Node) { n.LastSeen = new(time.Now()) }},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
pre := createTestNode(1, 1, "u", "n")
post := *pre.Clone()
tt.mutate(&post)
relation, election := updateChanges(&pre, &post)
require.Equal(t, tt.wantRelation, relation, "relation")
require.Equal(t, tt.wantElection, election, "election")
})
}
}