Files
netbird/client/internal/mapsync_test.go
T
riccardom e7c7c75ec6 [client] Treat a components envelope sync as carrying a network map
Envelope syncs leave NetworkMap nil and ship the map in NetworkMapEnvelope,
so the nil-map checks read every one of them as config-only: they were never
persisted, and a config-only update coalescing over a pending envelope sync
dropped its map. The version, top-level peer config and checks travel with
the envelope, and a nil session deadline is "no change" like the other
optional fields, so the pending one is carried forward too.
2026-10-20 10:18:03 +02:00

352 lines
13 KiB
Go

package internal
import (
"context"
"errors"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/types/known/timestamppb"
sharedgrpc "github.com/netbirdio/netbird/shared/management/grpc"
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
)
// mergeTarget fills components missing from the incoming update with the pending
// (not-yet-applied) prev's, in place, so a coalesced/superseded update does not drop
// the map or config it uniquely carried.
func TestMapStateManager_MergeTargetPreservesPendingState(t *testing.T) {
m := newMapStateManager(nil, nil, nil)
// config-only update while a full map is still converging (targetGen > appliedGen):
// the pending map (+ checks) is filled into the update in place
m.targetGen, m.appliedGen = 5, 4
prev := &mgmProto.SyncResponse{NetworkMap: &mgmProto.NetworkMap{Serial: 5}}
update := &mgmProto.SyncResponse{NetbirdConfig: &mgmProto.NetbirdConfig{}}
merged := m.mergeTarget(prev, update)
require.Same(t, update, merged, "merges in place, returns the update")
require.EqualValues(t, 5, merged.GetNetworkMap().GetSerial(), "pending map preserved")
require.NotNil(t, merged.GetNetbirdConfig(), "new config kept")
// symmetric: map-only update while a config-only update is pending -> keep the config
m.targetGen, m.appliedGen = 5, 4
prev = &mgmProto.SyncResponse{NetbirdConfig: &mgmProto.NetbirdConfig{}}
update = &mgmProto.SyncResponse{NetworkMap: &mgmProto.NetworkMap{Serial: 7}}
merged = m.mergeTarget(prev, update)
require.EqualValues(t, 7, merged.GetNetworkMap().GetSerial(), "new map kept")
require.NotNil(t, merged.GetNetbirdConfig(), "pending config preserved")
// prev already applied (targetGen == appliedGen): plain replace, no fill-in
m.targetGen, m.appliedGen = 5, 5
prev = &mgmProto.SyncResponse{NetworkMap: &mgmProto.NetworkMap{Serial: 5}}
update = &mgmProto.SyncResponse{NetbirdConfig: &mgmProto.NetbirdConfig{}}
merged = m.mergeTarget(prev, update)
require.Same(t, update, merged)
require.Nil(t, merged.GetNetworkMap(), "no map grafted when prev already applied")
// nothing to carry (update has a map, prev has no config): plain replace
m.targetGen, m.appliedGen = 5, 4
prev = &mgmProto.SyncResponse{NetworkMap: &mgmProto.NetworkMap{Serial: 5}}
update = &mgmProto.SyncResponse{NetworkMap: &mgmProto.NetworkMap{Serial: 6}}
require.Same(t, update, m.mergeTarget(prev, update))
}
// converges over the bounded passes (apply returns more until the 3rd pass),
// fires onConverged exactly once, then blocks (no further apply) until a new target.
func TestMapStateManager_ConvergesThenStops(t *testing.T) {
var passes int32
var firstPasses int32
converged := make(chan struct{}, 1)
apply := func(_ *mgmProto.SyncResponse, firstPass bool) (bool, error) {
n := atomic.AddInt32(&passes, 1)
if firstPass {
atomic.AddInt32(&firstPasses, 1)
}
return n < 3, nil // more on pass 1 and 2, converge on pass 3
}
m := newMapStateManager(apply, nil, func(time.Duration) { converged <- struct{}{} })
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go m.run(ctx)
require.NoError(t, m.SetTarget(&mgmProto.SyncResponse{}))
select {
case <-converged:
case <-time.After(2 * time.Second):
t.Fatal("manager did not converge")
}
require.EqualValues(t, 3, atomic.LoadInt32(&passes))
require.EqualValues(t, 1, atomic.LoadInt32(&firstPasses), "firstPass true only on pass 1, false on re-runs of the same target")
// once converged the loop blocks: no further apply calls
time.Sleep(100 * time.Millisecond)
require.EqualValues(t, 3, atomic.LoadInt32(&passes), "apply must not run after convergence")
}
// persist runs once per received update (not per apply pass), regardless of how many
// bounded passes that target takes to converge.
func TestMapStateManager_PersistsOncePerUpdate(t *testing.T) {
var passes, persists int32
converged := make(chan struct{}, 1)
apply := func(_ *mgmProto.SyncResponse, _ bool) (bool, error) {
n := atomic.AddInt32(&passes, 1)
return n < 3, nil // 3 passes for one target
}
persist := func(*mgmProto.SyncResponse) { atomic.AddInt32(&persists, 1) }
m := newMapStateManager(apply, persist, func(time.Duration) { converged <- struct{}{} })
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go m.run(ctx)
require.NoError(t, m.SetTarget(&mgmProto.SyncResponse{}))
select {
case <-converged:
case <-time.After(2 * time.Second):
t.Fatal("did not converge")
}
require.EqualValues(t, 3, atomic.LoadInt32(&passes))
require.EqualValues(t, 1, atomic.LoadInt32(&persists), "persist once per update, not per pass")
}
// every update received from management is persisted — even one that is coalesced /
// skipped from apply before it ever converges.
func TestMapStateManager_PersistsEveryUpdateIncludingSkipped(t *testing.T) {
release := make(chan struct{})
var persists int32
apply := func(_ *mgmProto.SyncResponse, _ bool) (bool, error) {
<-release // hold the first apply so the second update coalesces/skips
return false, nil
}
persist := func(*mgmProto.SyncResponse) { atomic.AddInt32(&persists, 1) }
m := newMapStateManager(apply, persist, nil)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go m.run(ctx)
require.NoError(t, m.SetTarget(&mgmProto.SyncResponse{})) // map1 -> apply blocks
require.NoError(t, m.SetTarget(&mgmProto.SyncResponse{})) // map2 supersedes map1 (skipped from apply)
close(release)
// both updates persisted even though map1 is skipped from apply
require.Eventually(t, func() bool { return atomic.LoadInt32(&persists) == 2 }, 2*time.Second, 10*time.Millisecond)
}
// each map that is actually processed (converged before the next arrives) fires
// onConverged exactly once — mirroring the legacy per-message handleSync timing.
func TestMapStateManager_SignalsEachProcessedMap(t *testing.T) {
converged := make(chan struct{}, 8)
apply := func(_ *mgmProto.SyncResponse, _ bool) (bool, error) {
return false, nil // converge in one pass
}
m := newMapStateManager(apply, nil, func(time.Duration) { converged <- struct{}{} })
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go m.run(ctx)
const maps = 3
for i := 0; i < maps; i++ {
require.NoError(t, m.SetTarget(&mgmProto.SyncResponse{}))
select { // wait for this map to converge before sending the next (no coalescing)
case <-converged:
case <-time.After(2 * time.Second):
t.Fatalf("map %d not signaled", i)
}
}
// no extra signals once the stream goes quiet
select {
case <-converged:
t.Fatal("unexpected extra onConverged")
case <-time.After(100 * time.Millisecond):
}
}
// a map superseded before it converges is skipped: only the latest (processed) map
// fires onConverged, not the skipped one.
func TestMapStateManager_SkippedMapNotSignaled(t *testing.T) {
release := make(chan struct{})
var applies, converged atomic.Int32
apply := func(_ *mgmProto.SyncResponse, _ bool) (bool, error) {
applies.Add(1)
<-release // hold the first apply in-flight so we can queue a newer target
return false, nil
}
m := newMapStateManager(apply, nil, func(time.Duration) { converged.Add(1) })
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go m.run(ctx)
// map1 is picked up; its apply blocks on release
require.NoError(t, m.SetTarget(&mgmProto.SyncResponse{}))
require.Eventually(t, func() bool { return applies.Load() >= 1 }, 2*time.Second, 5*time.Millisecond)
// map2 supersedes map1 before it settled -> map1 is skipped
require.NoError(t, m.SetTarget(&mgmProto.SyncResponse{}))
close(release) // let both applies proceed
// only the processed (latest) map signals; the skipped one does not
require.Eventually(t, func() bool { return converged.Load() == 1 }, 2*time.Second, 10*time.Millisecond)
time.Sleep(150 * time.Millisecond)
require.EqualValues(t, 1, converged.Load(), "skipped map must not fire onConverged")
require.EqualValues(t, 2, applies.Load(), "both targets entered apply (map1 once, map2 once)")
}
// an apply error drops the target: no retry of the same target, no onConverged,
// the loop goes idle — and a fresh target is still applied afterwards.
func TestMapStateManager_DropsTargetOnError(t *testing.T) {
applied := make(chan struct{}, 8)
var failNext atomic.Bool
failNext.Store(true)
apply := func(_ *mgmProto.SyncResponse, _ bool) (bool, error) {
applied <- struct{}{}
if failNext.Load() {
return false, errors.New("boom")
}
return false, nil // converge in one pass
}
var converged atomic.Int32
m := newMapStateManager(apply, nil, func(time.Duration) { converged.Add(1) })
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go m.run(ctx)
// first target errors -> applied once, then dropped (no retry, no onConverged)
require.NoError(t, m.SetTarget(&mgmProto.SyncResponse{}))
select {
case <-applied:
case <-time.After(2 * time.Second):
t.Fatal("errored target not applied")
}
select {
case <-applied:
t.Fatal("errored target must not be retried")
case <-time.After(150 * time.Millisecond):
}
require.EqualValues(t, 0, converged.Load(), "onConverged must not fire on error")
// a new target is still processed normally and converges
failNext.Store(false)
require.NoError(t, m.SetTarget(&mgmProto.SyncResponse{}))
select {
case <-applied:
case <-time.After(2 * time.Second):
t.Fatal("new target after error not applied")
}
require.Eventually(t, func() bool { return converged.Load() == 1 }, 2*time.Second, 10*time.Millisecond)
}
// a new target after convergence triggers a fresh apply; an idle (converged)
// manager does not apply on its own.
func TestMapStateManager_ReappliesOnNewTarget(t *testing.T) {
applied := make(chan struct{}, 8)
apply := func(_ *mgmProto.SyncResponse, _ bool) (bool, error) {
applied <- struct{}{}
return false, nil // converge in one pass
}
m := newMapStateManager(apply, nil, nil)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go m.run(ctx)
require.NoError(t, m.SetTarget(&mgmProto.SyncResponse{}))
select {
case <-applied:
case <-time.After(2 * time.Second):
t.Fatal("first target not applied")
}
// converged → must stay idle (no spurious apply)
select {
case <-applied:
t.Fatal("unexpected apply while idle/converged")
case <-time.After(150 * time.Millisecond):
}
require.NoError(t, m.SetTarget(&mgmProto.SyncResponse{}))
select {
case <-applied:
case <-time.After(2 * time.Second):
t.Fatal("new target not applied")
}
}
// cancelling the context while a target still has passes left stops the loop
// between passes instead of draining the target to convergence.
func TestMapStateManager_StopsBetweenPassesOnCancel(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var passes atomic.Int32
apply := func(_ *mgmProto.SyncResponse, _ bool) (bool, error) {
if passes.Add(1) == 1 {
cancel()
}
return true, nil // never converges on its own
}
m := newMapStateManager(apply, nil, nil)
require.NoError(t, m.SetTarget(&mgmProto.SyncResponse{}))
done := make(chan struct{})
go func() {
defer close(done)
m.run(ctx)
}()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("run kept applying passes after the context was cancelled")
}
require.EqualValues(t, 1, passes.Load(), "no pass should run after the cancelling one")
}
// A config-only update superseding a pending envelope sync must carry the
// envelope forward together with the fields that travel with it, and a nil
// session deadline means "no change" just like a nil map or config.
func TestMapStateManager_MergeTargetPreservesPendingEnvelope(t *testing.T) {
m := newMapStateManager(nil, nil, nil)
m.targetGen, m.appliedGen = 5, 4
envelope := &mgmProto.NetworkMapEnvelope{}
peerConfig := &mgmProto.PeerConfig{Fqdn: "peer.netbird.cloud"}
checks := []*mgmProto.Checks{{Files: []string{"/etc/hosts"}}}
deadline := timestamppb.New(time.Unix(1_900_000_000, 0))
prev := &mgmProto.SyncResponse{
NetworkMapEnvelope: envelope,
Version: int32(sharedgrpc.ComponentNetworkMap),
PeerConfig: peerConfig,
Checks: checks,
SessionExpiresAt: deadline,
}
update := &mgmProto.SyncResponse{NetbirdConfig: &mgmProto.NetbirdConfig{}}
merged := m.mergeTarget(prev, update)
require.Same(t, update, merged, "merges in place, returns the update")
require.Same(t, envelope, merged.GetNetworkMapEnvelope(), "pending envelope preserved")
require.Equal(t, int32(sharedgrpc.ComponentNetworkMap), merged.GetVersion(), "wire version travels with the envelope")
require.Same(t, peerConfig, merged.GetPeerConfig(), "top-level peer config travels with the envelope")
require.Equal(t, checks, merged.GetChecks(), "checks travel with the map")
require.Same(t, deadline, merged.GetSessionExpiresAt(), "pending session deadline preserved")
require.NotNil(t, merged.GetNetbirdConfig(), "new config kept")
// a newer envelope wins over the pending one
m.targetGen, m.appliedGen = 6, 5
newer := &mgmProto.NetworkMapEnvelope{}
update = &mgmProto.SyncResponse{NetworkMapEnvelope: newer, Version: int32(sharedgrpc.ComponentNetworkMap)}
merged = m.mergeTarget(prev, update)
require.Same(t, newer, merged.GetNetworkMapEnvelope(), "new envelope kept")
require.Nil(t, merged.GetPeerConfig(), "the pending peer config is not grafted onto a newer map")
}