NodeMutationAdd was a misleading name: a PeersChanged entry in a MapResponse can represent either a truly new peer or a full replacement for an existing peer that couldn't be expressed as a PeerChangedPatch. Calling it "Add" implied it was always a completely new node, which is wrong. (I'd changed my mind on the design of mapping add/delete events to NodeMutations halfway through #19607 and forgot to update the name, even though I'd updated half the docs) Rename it to NodeMutationUpsert to reflect the actual semantics: the node should be inserted or replaced in the peer map regardless of whether it already existed. Updates #19607 Updates #12542 Change-Id: Iebd3daddb3318cba02e115a1b184fcb3ee8f83d6 Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
280 lines
8.9 KiB
Go
280 lines
8.9 KiB
Go
// Copyright (c) Tailscale Inc & contributors
|
|
// SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
package largetailnet_test
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http/httptest"
|
|
"net/netip"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"tailscale.com/ipn/store/mem"
|
|
"tailscale.com/tailcfg"
|
|
"tailscale.com/tsnet"
|
|
"tailscale.com/tstest"
|
|
"tailscale.com/tstest/integration"
|
|
"tailscale.com/tstest/integration/testcontrol"
|
|
"tailscale.com/tstest/largetailnet"
|
|
"tailscale.com/types/ipproto"
|
|
"tailscale.com/types/logger"
|
|
"tailscale.com/util/clientmetric"
|
|
"tailscale.com/wgengine/filter"
|
|
)
|
|
|
|
// metricByName returns the [clientmetric.Metric] with the given name,
|
|
// failing the test if not found.
|
|
func metricByName(t testing.TB, name string) *clientmetric.Metric {
|
|
t.Helper()
|
|
for _, m := range clientmetric.Metrics() {
|
|
if m.Name() == name {
|
|
return m
|
|
}
|
|
}
|
|
t.Fatalf("metric %q not found", name)
|
|
return nil
|
|
}
|
|
|
|
// TestNetmapDeltaFastPath drives a sequence of MapResponses against an
|
|
// in-process tsnet + testcontrol harness via [largetailnet.Streamer]'s
|
|
// AltMapStream hook, exercising every delta-message kind the
|
|
// incremental netmap path handles. After each delta it asserts both:
|
|
//
|
|
// - the appropriate fast-path metric counters incremented (i.e. we
|
|
// stayed on the incremental path and did not fall through to a full
|
|
// netmap rebuild); and
|
|
//
|
|
// - the corresponding side effect is observable on the [LocalBackend]
|
|
// (a fresh peer resolvable via PeerByID, a UserProfile resolvable
|
|
// via UserProfile, a packet filter rule reflected in
|
|
// GetFilterForTest, a per-field patch reflected in PeerByID, etc.).
|
|
//
|
|
// This is the destination-side companion to
|
|
// [tstest/largetailnet/BenchmarkGiantTailnet], which only measures cost
|
|
// of the same fast path — this test verifies correctness.
|
|
func TestNetmapDeltaFastPath(t *testing.T) {
|
|
tstest.Shard(t)
|
|
|
|
logf := logger.Discard
|
|
if testing.Verbose() {
|
|
logf = t.Logf
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(t.Context(), 60*time.Second)
|
|
t.Cleanup(cancel)
|
|
|
|
derpMap := integration.RunDERPAndSTUN(t, logf, "127.0.0.1")
|
|
|
|
// Start with one initial peer (NodeID 2) so the initial netmap is
|
|
// realistic. The fast path will not fire for the initial response —
|
|
// it always goes through UpdateFullNetmap — but every subsequent
|
|
// SendDelta should.
|
|
streamer := largetailnet.New(1, derpMap)
|
|
ctrl := &testcontrol.Server{
|
|
DERPMap: derpMap,
|
|
DNSConfig: &tailcfg.DNSConfig{},
|
|
AltMapStream: streamer.AltMapStream(),
|
|
Logf: logf,
|
|
}
|
|
ctrl.HTTPTestServer = httptest.NewUnstartedServer(ctrl)
|
|
ctrl.HTTPTestServer.Start()
|
|
t.Cleanup(ctrl.HTTPTestServer.Close)
|
|
|
|
tmp := filepath.Join(t.TempDir(), "tsnet")
|
|
if err := os.MkdirAll(tmp, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
s := &tsnet.Server{
|
|
Dir: tmp,
|
|
ControlURL: ctrl.HTTPTestServer.URL,
|
|
Hostname: "delta-test",
|
|
Store: new(mem.Store),
|
|
Ephemeral: true,
|
|
Logf: logf,
|
|
}
|
|
t.Cleanup(func() { s.Close() })
|
|
if _, err := s.Up(ctx); err != nil {
|
|
t.Fatalf("tsnet.Server.Up: %v", err)
|
|
}
|
|
lb := tsnet.TestHooks.LocalBackend(s)
|
|
|
|
// Snapshot baseline metric values; we'll assert deltas against
|
|
// these. Globals make per-test isolation impossible, but deltas
|
|
// are robust against interleaving (assuming no other test runs in
|
|
// parallel here, hence tstest.Shard above).
|
|
mFast := metricByName(t, "controlclient_map_response_handled_incrementally")
|
|
mFull := metricByName(t, "controlclient_map_response_handled_full_rebuild")
|
|
mUpsert := metricByName(t, "localbackend_netmap_delta_peer_upserted")
|
|
mRem := metricByName(t, "localbackend_netmap_delta_peer_removed")
|
|
mPatch := metricByName(t, "localbackend_netmap_delta_peer_patched")
|
|
mFilter := metricByName(t, "localbackend_update_packet_filter")
|
|
mUsers := metricByName(t, "localbackend_update_user_profiles")
|
|
baseline := map[*clientmetric.Metric]int64{
|
|
mFast: mFast.Value(), mFull: mFull.Value(),
|
|
mUpsert: mUpsert.Value(), mRem: mRem.Value(), mPatch: mPatch.Value(),
|
|
mFilter: mFilter.Value(), mUsers: mUsers.Value(),
|
|
}
|
|
dumpMetrics := func(t *testing.T) {
|
|
t.Helper()
|
|
for _, m := range []*clientmetric.Metric{mFast, mFull, mUpsert, mRem, mPatch, mFilter, mUsers} {
|
|
t.Logf("metric %s = %d (baseline %d, delta %d)", m.Name(), m.Value(), baseline[m], m.Value()-baseline[m])
|
|
}
|
|
}
|
|
waitDelta := func(t *testing.T, m *clientmetric.Metric, want int64) {
|
|
t.Helper()
|
|
err := tstest.WaitFor(2*time.Second, func() error {
|
|
got := m.Value() - baseline[m]
|
|
if got >= want {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("%s delta = %d, want >= %d", m.Name(), got, want)
|
|
})
|
|
if err != nil {
|
|
dumpMetrics(t)
|
|
t.Fatalf("%s: %v", m.Name(), err)
|
|
}
|
|
if got := m.Value() - baseline[m]; got != want {
|
|
t.Errorf("%s delta = %d, want exactly %d", m.Name(), got, want)
|
|
}
|
|
baseline[m] = m.Value()
|
|
}
|
|
|
|
// Helper to send a MapResponse and wait for it to be processed by
|
|
// the client. We use the metric deltas as our synchronization
|
|
// point: SendDelta is synchronous from the streamer side, but the
|
|
// client processes the response on its own goroutine, so we wait
|
|
// for the fast-path counter to tick.
|
|
sendDelta := func(t *testing.T, mr *tailcfg.MapResponse) {
|
|
t.Helper()
|
|
if err := streamer.SendDelta(ctx, mr); err != nil {
|
|
t.Fatalf("SendDelta: %v", err)
|
|
}
|
|
}
|
|
|
|
// Self IPv4, used as the destination in packet filter checks below.
|
|
// largetailnet derives self addresses from SelfNodeID via node4/node6;
|
|
// for SelfNodeID=1 that's 100.100.0.1.
|
|
selfIP4 := netip.MustParseAddr("100.100.0.1")
|
|
|
|
// addedPeerID is set by the peer_added_with_filter_and_user_profile
|
|
// subtest and consumed later by peer_removed.
|
|
var addedPeerID tailcfg.NodeID
|
|
|
|
t.Run("peer_added_with_filter_and_user_profile", func(t *testing.T) {
|
|
// Add a fresh peer. Bundle a new PacketFilter rule allowing
|
|
// TCP from that peer's IP to a port we'll later probe, and a
|
|
// new UserProfile for the user that owns the new peer.
|
|
newPeer := streamer.AllocPeer()
|
|
newUser := tailcfg.UserID(42)
|
|
newPeer.User = newUser
|
|
newPeer.Addresses = []netip.Prefix{netip.MustParsePrefix("100.64.0.42/32")}
|
|
addedPeerID = newPeer.ID
|
|
sendDelta(t, &tailcfg.MapResponse{
|
|
PeersChanged: []*tailcfg.Node{newPeer},
|
|
PacketFilter: []tailcfg.FilterRule{{
|
|
SrcIPs: []string{"100.64.0.42/32"},
|
|
IPProto: []int{int(ipproto.TCP)},
|
|
DstPorts: []tailcfg.NetPortRange{{IP: "*", Ports: tailcfg.PortRange{First: 22, Last: 22}}},
|
|
}},
|
|
UserProfiles: []tailcfg.UserProfile{{
|
|
ID: newUser,
|
|
LoginName: "alice@example.com",
|
|
DisplayName: "Alice",
|
|
}},
|
|
})
|
|
|
|
waitDelta(t, mFast, 1)
|
|
waitDelta(t, mUpsert, 1)
|
|
waitDelta(t, mFilter, 1)
|
|
waitDelta(t, mUsers, 1)
|
|
waitDelta(t, mFull, 0)
|
|
|
|
// Side effects.
|
|
nv, ok := lb.PeerByID(newPeer.ID)
|
|
if !ok || nv.ID() != newPeer.ID {
|
|
t.Errorf("PeerByID(%d) ok=%v node=%v", newPeer.ID, ok, nv)
|
|
}
|
|
uv, ok := lb.UserProfile(newUser)
|
|
if !ok || uv.LoginName() != "alice@example.com" {
|
|
t.Errorf("UserProfile(%d) ok=%v login=%q", newUser, ok, uv.LoginName())
|
|
}
|
|
pf := lb.GetFilterForTest()
|
|
if got := pf.Check(netip.MustParseAddr("100.64.0.42"), selfIP4, 22, ipproto.TCP); got != filter.Accept {
|
|
t.Errorf("packet filter Check from new peer = %s; want Accept", got)
|
|
}
|
|
})
|
|
|
|
t.Run("peer_patch_derp_home", func(t *testing.T) {
|
|
// Patch the initial peer's DERPRegion via PeersChangedPatch.
|
|
// This rides as NodeMutationDERPHome.
|
|
sendDelta(t, &tailcfg.MapResponse{
|
|
PeersChangedPatch: []*tailcfg.PeerChange{{
|
|
NodeID: 2,
|
|
DERPRegion: 7,
|
|
}},
|
|
})
|
|
|
|
waitDelta(t, mFast, 1)
|
|
waitDelta(t, mPatch, 1)
|
|
waitDelta(t, mFull, 0)
|
|
|
|
nv, ok := lb.PeerByID(2)
|
|
if !ok {
|
|
t.Fatalf("PeerByID(2) not found")
|
|
}
|
|
if got := nv.HomeDERP(); got != 7 {
|
|
t.Errorf("HomeDERP = %d, want 7", got)
|
|
}
|
|
})
|
|
|
|
t.Run("peer_online_and_last_seen", func(t *testing.T) {
|
|
// Online + LastSeen on the same delta. PeerSeenChange's value
|
|
// is true to set LastSeen, false to clear it; the time it gets
|
|
// is now() at the time MutationsFromMapResponse runs on the
|
|
// client, not a wire value.
|
|
sendDelta(t, &tailcfg.MapResponse{
|
|
OnlineChange: map[tailcfg.NodeID]bool{2: true},
|
|
PeerSeenChange: map[tailcfg.NodeID]bool{2: true},
|
|
})
|
|
|
|
waitDelta(t, mFast, 1)
|
|
// Two mutations: one NodeMutationOnline + one NodeMutationLastSeen.
|
|
waitDelta(t, mPatch, 2)
|
|
waitDelta(t, mFull, 0)
|
|
|
|
nv, ok := lb.PeerByID(2)
|
|
if !ok {
|
|
t.Fatalf("PeerByID(2) not found")
|
|
}
|
|
if o := nv.Online(); !o.Valid() || !o.Get() {
|
|
t.Errorf("Online = %v, want true", o)
|
|
}
|
|
})
|
|
|
|
t.Run("peer_removed", func(t *testing.T) {
|
|
if addedPeerID == 0 {
|
|
t.Fatal("peer_added_with_filter_and_user_profile must run first")
|
|
}
|
|
// Sanity check: the peer should currently exist.
|
|
if _, ok := lb.PeerByID(addedPeerID); !ok {
|
|
t.Fatalf("PeerByID(%d) missing before removal", addedPeerID)
|
|
}
|
|
|
|
sendDelta(t, &tailcfg.MapResponse{
|
|
PeersRemoved: []tailcfg.NodeID{addedPeerID},
|
|
})
|
|
|
|
waitDelta(t, mFast, 1)
|
|
waitDelta(t, mRem, 1)
|
|
waitDelta(t, mFull, 0)
|
|
|
|
if _, ok := lb.PeerByID(addedPeerID); ok {
|
|
t.Errorf("PeerByID(%d) still present after PeersRemoved", addedPeerID)
|
|
}
|
|
})
|
|
}
|