Move all the FooForTest methods on LocalBackend to instead be methods on a new unexported forTest type which is then given out to callers in other packages via an exported ForTest method (panicking in non-test contexts) that returns that unexported type. This is unusual style (exported returning unexported) but declutters godoc and makes call sites both more explicit and easier to read without the "ForTest" suffix polluting the symbols. Now FooForTest() changes into ForTest().Foo(). This was motivated by a pending change moving a bunch of code out of LocalBackend into other packages that required adding more ForTest methods to LocalBackend to keep the tests (now in other packages) working. Instead, do this refactor now so the future change is prettier. Updates #12614 Updates #cleanup Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com> Change-Id: Ib25e6d76d48dc8622ac3a955e0b1220d582e63a8
279 lines
8.9 KiB
Go
279 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
|
|
// ForTest().GetFilter, 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) {
|
|
|
|
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).
|
|
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.ForTest().GetFilter()
|
|
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)
|
|
}
|
|
})
|
|
}
|