ipn/ipnlocal: update netmap cache after peer deltas are applied (#20111)

Add an UpdatePeers method to the cache. This allows us to support netmap peer deltas,
by allowing just the peers to be updated in an existing cache. As a safety check, reject
an update if there was no base netmap data to apply a change to.

Then, when processing peer mutations in the backend, capture any changes that should
be applied to the cache and update it, if one is enabled.

Updates #12542

Change-Id: I2f8790a8fdc5e85fce6700ba4821a8cb10dddffa
Signed-off-by: M. J. Fromberger <fromberger@tailscale.com>
This commit is contained in:
M. J. Fromberger
2026-06-12 09:41:00 -07:00
committed by GitHub
parent b23089a5ef
commit 9cb071666c
4 changed files with 135 additions and 0 deletions
+38
View File
@@ -242,6 +242,44 @@ func (c *Cache) Store(ctx context.Context, nm *netmap.NetworkMap) error {
return c.removeUnwantedKeys(ctx)
}
// UpdatePeers updates the cache to add or replace ("upsert") any peers
// specified in update, and discard any peers specified in remove. This does
// not affect any previous cached values for the self node or user profiles.
//
// It is expected that update and remove will be disjoint, but in the event
// they are not, remove takes precedence. If the cache does not already contain
// at least a self node, UpdatePeers reports an error without changing anything.
func (c *Cache) UpdatePeers(ctx context.Context, update []tailcfg.NodeView, remove []tailcfg.StableNodeID) error {
if !buildfeatures.HasCacheNetMap {
return nil
}
if !c.wantKeys.Contains(selfKey) {
return errors.New("no netmap is cached to apply an update")
}
// Attempt all updates and removals before reporting any errors.
var errs []error
for _, p := range update {
// We may already have an entry for this peer, or it may be new, but
// rather than do a lookup we can just replace the existing entry if it
// exists.
key := peerKeyPrefix + cacheKey(p.StableID())
if err := c.writeJSON(ctx, key, netmapNode{Node: &p}); err != nil {
errs = append(errs, err)
continue
}
c.wantKeys.Add(key)
}
for _, q := range remove {
key := peerKeyPrefix + cacheKey(q)
if err := c.store.Remove(ctx, string(key)); err != nil {
errs = append(errs, err)
}
c.wantKeys.Delete(key)
}
return errors.Join(errs...)
}
// updateSelfOnly updates the "static" parts of the netmap in the cache. It is
// shared between [Cache.UpdateSelfOnly] and [Cache.Store].
func (c *Cache) updateSelfOnly(ctx context.Context, nm *netmap.NetworkMap) error {
@@ -307,6 +307,55 @@ func TestUpdateSelfOnly(t *testing.T) {
}
}
func TestUpdatePeers(t *testing.T) {
modNode1 := (&tailcfg.Node{
ID: 99001,
StableID: "n99001FAKE",
Name: "test1altered.example.com.",
}).View() // a modified version of testNode1
newNode3 := (&tailcfg.Node{
ID: 99003,
StableID: "n99003FAKE",
Name: "test3.example.com.",
}).View() // a new node hitherto unseen
update := []tailcfg.NodeView{newNode3, modNode1}
remove := []tailcfg.StableNodeID{testNode2.StableID()}
// Modify a shallow copy of the map so we can perform an update and verify
// that it round-trips through a Load after calling UpdatePeers.
updated := *testMap
updated.Peers = []tailcfg.NodeView{modNode1, newNode3} // N.B. sorted
s := make(testStore)
c := netmapcache.NewCache(s)
// Prior to storing anything, the cache should reject an update because it
// has no base netmap to apply changes to.
if err := c.UpdatePeers(t.Context(), update, remove); err == nil {
t.Error("UpdatePeers on empty cache unexpectedly succeeded")
} else {
t.Logf("UpdatePeers on empty cache: %v (OK)", err)
}
// Initialize the cache with the test map so we get a baseline.
if err := c.Store(t.Context(), testMap); err != nil {
t.Fatalf("Store initial netmap: %v", err)
}
if err := c.UpdatePeers(t.Context(), update, remove); err != nil {
t.Fatalf("UpdatePeers failed; %v", err)
}
got, err := c.Load(t.Context())
if err != nil {
t.Fatalf("Load netmap failed: %v", err)
}
if diff := diffNetMaps(got, &updated); diff != "" {
t.Fatalf("Updated map differs (-got, +want):\n%s", diff)
}
}
// skippedMapFields are the names of fields that should not be considered by
// network map caching, and thus skipped when comparing test results.
var skippedMapFields = []string{