mirror of https://github.com/tailscale/tailscale/
control/controlclient, types/netmap: start plumbing delta netmap updates
Currently only the top four most popular changes: endpoints, DERP home, online, and LastSeen. Updates #1909 Change-Id: I03152da176b2b95232b56acabfb55dcdfaa16b79 Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>pull/9366/head
parent
c0ade132e6
commit
3af051ea27
@ -0,0 +1,21 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package controlknobs
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAsDebugJSON(t *testing.T) {
|
||||
var nilPtr *Knobs
|
||||
if got := nilPtr.AsDebugJSON(); got != nil {
|
||||
t.Errorf("AsDebugJSON(nil) = %v; want nil", got)
|
||||
}
|
||||
k := new(Knobs)
|
||||
got := k.AsDebugJSON()
|
||||
if want := reflect.TypeOf(Knobs{}).NumField(); len(got) != want {
|
||||
t.Errorf("AsDebugJSON map has %d fields; want %v", len(got), want)
|
||||
}
|
||||
}
|
@ -0,0 +1,168 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package netmap
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"reflect"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/util/cmpx"
|
||||
)
|
||||
|
||||
// NodeMutation is the common interface for types that describe
|
||||
// the change of a node's state.
|
||||
type NodeMutation interface {
|
||||
NodeIDBeingMutated() tailcfg.NodeID
|
||||
}
|
||||
|
||||
type mutatingNodeID tailcfg.NodeID
|
||||
|
||||
func (m mutatingNodeID) NodeIDBeingMutated() tailcfg.NodeID { return tailcfg.NodeID(m) }
|
||||
|
||||
// NodeMutationDERPHome is a NodeMutation that says a node
|
||||
// has changed its DERP home region.
|
||||
type NodeMutationDERPHome struct {
|
||||
mutatingNodeID
|
||||
DERPRegion int
|
||||
}
|
||||
|
||||
// NodeMutation is a NodeMutation that says a node's endpoints have changed.
|
||||
type NodeMutationEndpoints struct {
|
||||
mutatingNodeID
|
||||
Endpoints []netip.AddrPort
|
||||
}
|
||||
|
||||
// NodeMutationOnline is a NodeMutation that says a node is now online or
|
||||
// offline.
|
||||
type NodeMutationOnline struct {
|
||||
mutatingNodeID
|
||||
Online bool
|
||||
}
|
||||
|
||||
// NodeMutationLastSeen is a NodeMutation that says a node's LastSeen
|
||||
// value should be set to the current time.
|
||||
type NodeMutationLastSeen struct {
|
||||
mutatingNodeID
|
||||
LastSeen time.Time
|
||||
}
|
||||
|
||||
var peerChangeFields = sync.OnceValue(func() []reflect.StructField {
|
||||
var fields []reflect.StructField
|
||||
rt := reflect.TypeOf((*tailcfg.PeerChange)(nil)).Elem()
|
||||
for i := 0; i < rt.NumField(); i++ {
|
||||
fields = append(fields, rt.Field(i))
|
||||
}
|
||||
return fields
|
||||
})
|
||||
|
||||
// NodeMutationsFromPatch returns the NodeMutations that
|
||||
// p describes. If p describes something not yet supported
|
||||
// by a specific NodeMutation type, it returns (nil, false).
|
||||
func NodeMutationsFromPatch(p *tailcfg.PeerChange) (_ []NodeMutation, ok bool) {
|
||||
if p == nil || p.NodeID == 0 {
|
||||
return nil, false
|
||||
}
|
||||
var ret []NodeMutation
|
||||
rv := reflect.ValueOf(p).Elem()
|
||||
for i, sf := range peerChangeFields() {
|
||||
if rv.Field(i).IsZero() {
|
||||
continue
|
||||
}
|
||||
switch sf.Name {
|
||||
default:
|
||||
// Unhandled field.
|
||||
return nil, false
|
||||
case "NodeID":
|
||||
continue
|
||||
case "DERPRegion":
|
||||
ret = append(ret, NodeMutationDERPHome{mutatingNodeID(p.NodeID), p.DERPRegion})
|
||||
case "Endpoints":
|
||||
eps := make([]netip.AddrPort, len(p.Endpoints))
|
||||
for i, epStr := range p.Endpoints {
|
||||
var err error
|
||||
eps[i], err = netip.ParseAddrPort(epStr)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
ret = append(ret, NodeMutationEndpoints{mutatingNodeID(p.NodeID), eps})
|
||||
case "Online":
|
||||
ret = append(ret, NodeMutationOnline{mutatingNodeID(p.NodeID), *p.Online})
|
||||
case "LastSeen":
|
||||
ret = append(ret, NodeMutationLastSeen{mutatingNodeID(p.NodeID), *p.LastSeen})
|
||||
}
|
||||
}
|
||||
return ret, true
|
||||
}
|
||||
|
||||
// MutationsFromMapResponse returns all the discrete node mutations described
|
||||
// by res. It returns ok=false if res contains any non-patch field as defined
|
||||
// by mapResponseContainsNonPatchFields.
|
||||
func MutationsFromMapResponse(res *tailcfg.MapResponse, now time.Time) (ret []NodeMutation, ok bool) {
|
||||
if now.IsZero() {
|
||||
now = time.Now()
|
||||
}
|
||||
if mapResponseContainsNonPatchFields(res) {
|
||||
return nil, false
|
||||
}
|
||||
// All that remains is PeersChangedPatch, OnlineChange, and LastSeenChange.
|
||||
|
||||
for _, p := range res.PeersChangedPatch {
|
||||
deltas, ok := NodeMutationsFromPatch(p)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
ret = append(ret, deltas...)
|
||||
}
|
||||
for nid, v := range res.OnlineChange {
|
||||
ret = append(ret, NodeMutationOnline{mutatingNodeID(nid), v})
|
||||
}
|
||||
for nid, v := range res.PeerSeenChange {
|
||||
if v {
|
||||
ret = append(ret, NodeMutationLastSeen{mutatingNodeID(nid), now})
|
||||
}
|
||||
}
|
||||
slices.SortStableFunc(ret, func(a, b NodeMutation) int {
|
||||
return cmpx.Compare(a.NodeIDBeingMutated(), b.NodeIDBeingMutated())
|
||||
})
|
||||
return ret, true
|
||||
}
|
||||
|
||||
// mapResponseContainsNonPatchFields reports whether res contains only "patch"
|
||||
// fields set (PeersChangedPatch primarily, but also including the legacy
|
||||
// PeerSeenChange and OnlineChange fields).
|
||||
//
|
||||
// It ignores any of the meta fields that are handled by PollNetMap before the
|
||||
// peer change handling gets involved.
|
||||
//
|
||||
// The purpose of this function is to ask whether this is a tricky enough
|
||||
// MapResponse to warrant a full netmap update. When this returns false, it
|
||||
// means the response can be handled incrementally, patching up the local state.
|
||||
func mapResponseContainsNonPatchFields(res *tailcfg.MapResponse) bool {
|
||||
return res.Node != nil ||
|
||||
res.DERPMap != nil ||
|
||||
res.DNSConfig != nil ||
|
||||
res.Domain != "" ||
|
||||
res.CollectServices != "" ||
|
||||
res.PacketFilter != nil ||
|
||||
res.UserProfiles != nil ||
|
||||
res.Health != nil ||
|
||||
res.SSHPolicy != nil ||
|
||||
res.TKAInfo != nil ||
|
||||
res.DomainDataPlaneAuditLogID != "" ||
|
||||
res.Debug != nil ||
|
||||
res.ControlDialPlan != nil ||
|
||||
res.ClientVersion != nil ||
|
||||
res.Peers != nil ||
|
||||
res.PeersRemoved != nil ||
|
||||
// PeersChanged is too coarse to be considered a patch. Also, we convert
|
||||
// PeersChanged to PeersChangedPatch in patchifyPeersChanged before this
|
||||
// function is called, so it should never be set anyway. But for
|
||||
// completedness, and for tests, check it too:
|
||||
res.PeersChanged != nil
|
||||
}
|
@ -0,0 +1,199 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package netmap
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/logger"
|
||||
"tailscale.com/types/ptr"
|
||||
)
|
||||
|
||||
// tests mapResponseContainsNonPatchFields
|
||||
func TestMapResponseContainsNonPatchFields(t *testing.T) {
|
||||
|
||||
// reflectNonzero returns a non-zero value of the given type.
|
||||
reflectNonzero := func(t reflect.Type) reflect.Value {
|
||||
|
||||
switch t.Kind() {
|
||||
case reflect.Bool:
|
||||
return reflect.ValueOf(true)
|
||||
case reflect.String:
|
||||
return reflect.ValueOf("foo").Convert(t)
|
||||
case reflect.Int64:
|
||||
return reflect.ValueOf(int64(1))
|
||||
case reflect.Slice:
|
||||
return reflect.MakeSlice(t, 1, 1)
|
||||
case reflect.Ptr:
|
||||
return reflect.New(t.Elem())
|
||||
case reflect.Map:
|
||||
return reflect.MakeMap(t)
|
||||
}
|
||||
panic(fmt.Sprintf("unhandled %v", t))
|
||||
}
|
||||
|
||||
rt := reflect.TypeOf(tailcfg.MapResponse{})
|
||||
for i := 0; i < rt.NumField(); i++ {
|
||||
f := rt.Field(i)
|
||||
|
||||
var want bool
|
||||
switch f.Name {
|
||||
case "MapSessionHandle", "Seq", "KeepAlive", "PingRequest", "PopBrowserURL", "ControlTime":
|
||||
// There are meta fields that apply to all MapResponse values.
|
||||
// They should be ignored.
|
||||
want = false
|
||||
case "PeersChangedPatch", "PeerSeenChange", "OnlineChange":
|
||||
// The actual three delta fields we care about handling.
|
||||
want = false
|
||||
default:
|
||||
// Everything else should be conseratively handled as a
|
||||
// non-delta field. We want it to return true so if
|
||||
// the field is not listed in the function being tested,
|
||||
// it'll return false and we'll fail this test.
|
||||
// This makes sure any new fields added to MapResponse
|
||||
// are accounted for here.
|
||||
want = true
|
||||
}
|
||||
|
||||
var v tailcfg.MapResponse
|
||||
rv := reflect.ValueOf(&v).Elem()
|
||||
rv.FieldByName(f.Name).Set(reflectNonzero(f.Type))
|
||||
|
||||
got := mapResponseContainsNonPatchFields(&v)
|
||||
if got != want {
|
||||
t.Errorf("field %q: got %v; want %v\nJSON: %v", f.Name, got, want, logger.AsJSON(v))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// tests MutationsFromMapResponse
|
||||
func TestMutationsFromMapResponse(t *testing.T) {
|
||||
someTime := time.Unix(123, 0)
|
||||
fromChanges := func(changes ...*tailcfg.PeerChange) *tailcfg.MapResponse {
|
||||
return &tailcfg.MapResponse{
|
||||
PeersChangedPatch: changes,
|
||||
}
|
||||
}
|
||||
muts := func(muts ...NodeMutation) []NodeMutation { return muts }
|
||||
tests := []struct {
|
||||
name string
|
||||
mr *tailcfg.MapResponse
|
||||
want []NodeMutation // nil means !ok, zero-length means none
|
||||
}{
|
||||
{
|
||||
name: "patch-ep",
|
||||
mr: fromChanges(&tailcfg.PeerChange{
|
||||
NodeID: 1,
|
||||
Endpoints: []string{"1.2.3.4:567"},
|
||||
}, &tailcfg.PeerChange{
|
||||
NodeID: 2,
|
||||
Endpoints: []string{"8.9.10.11:1234"},
|
||||
}),
|
||||
want: muts(
|
||||
NodeMutationEndpoints{1, []netip.AddrPort{netip.MustParseAddrPort("1.2.3.4:567")}},
|
||||
NodeMutationEndpoints{2, []netip.AddrPort{netip.MustParseAddrPort("8.9.10.11:1234")}},
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "patch-derp",
|
||||
mr: fromChanges(&tailcfg.PeerChange{
|
||||
NodeID: 1,
|
||||
DERPRegion: 2,
|
||||
}),
|
||||
want: muts(NodeMutationDERPHome{1, 2}),
|
||||
},
|
||||
{
|
||||
name: "patch-online",
|
||||
mr: fromChanges(&tailcfg.PeerChange{
|
||||
NodeID: 1,
|
||||
Online: ptr.To(true),
|
||||
}),
|
||||
want: muts(NodeMutationOnline{1, true}),
|
||||
},
|
||||
{
|
||||
name: "patch-online-false",
|
||||
mr: fromChanges(&tailcfg.PeerChange{
|
||||
NodeID: 1,
|
||||
Online: ptr.To(false),
|
||||
}),
|
||||
want: muts(NodeMutationOnline{1, false}),
|
||||
},
|
||||
{
|
||||
name: "patch-lastseen",
|
||||
mr: fromChanges(&tailcfg.PeerChange{
|
||||
NodeID: 1,
|
||||
LastSeen: ptr.To(time.Unix(12345, 0)),
|
||||
}),
|
||||
want: muts(NodeMutationLastSeen{1, time.Unix(12345, 0)}),
|
||||
},
|
||||
{
|
||||
name: "legacy-online-change", // the old pre-Patch style
|
||||
mr: &tailcfg.MapResponse{
|
||||
OnlineChange: map[tailcfg.NodeID]bool{
|
||||
1: true,
|
||||
2: false,
|
||||
},
|
||||
},
|
||||
want: muts(
|
||||
NodeMutationOnline{1, true},
|
||||
NodeMutationOnline{2, false},
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "legacy-lastseen-change", // the old pre-Patch style
|
||||
mr: &tailcfg.MapResponse{
|
||||
PeerSeenChange: map[tailcfg.NodeID]bool{
|
||||
1: true,
|
||||
},
|
||||
},
|
||||
want: muts(
|
||||
NodeMutationLastSeen{1, someTime},
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "no-changes",
|
||||
mr: fromChanges(),
|
||||
want: make([]NodeMutation, 0), // non-nil to mean want ok but no changes
|
||||
},
|
||||
{
|
||||
name: "not-okay-patch-node-change",
|
||||
mr: &tailcfg.MapResponse{
|
||||
Node: &tailcfg.Node{}, // non-nil
|
||||
PeersChangedPatch: []*tailcfg.PeerChange{{
|
||||
NodeID: 1,
|
||||
DERPRegion: 2,
|
||||
}},
|
||||
},
|
||||
want: nil,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, gotOK := MutationsFromMapResponse(tt.mr, someTime)
|
||||
wantOK := tt.want != nil
|
||||
if gotOK != wantOK {
|
||||
t.Errorf("got ok=%v; want %v", gotOK, wantOK)
|
||||
} else if got == nil && gotOK {
|
||||
got = make([]NodeMutation, 0) // for cmd.Diff
|
||||
}
|
||||
if diff := cmp.Diff(tt.want, got,
|
||||
cmp.Comparer(func(a, b netip.Addr) bool { return a == b }),
|
||||
cmp.Comparer(func(a, b netip.AddrPort) bool { return a == b }),
|
||||
cmp.AllowUnexported(
|
||||
NodeMutationEndpoints{},
|
||||
NodeMutationDERPHome{},
|
||||
NodeMutationOnline{},
|
||||
NodeMutationLastSeen{},
|
||||
)); diff != "" {
|
||||
t.Errorf("wrong result (-want +got):\n%s", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue