mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-24 06:19:08 +02:00
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.
232 lines
9.9 KiB
Go
232 lines
9.9 KiB
Go
package internal
|
|
|
|
import (
|
|
"context"
|
|
"sync"
|
|
"time"
|
|
|
|
log "github.com/sirupsen/logrus"
|
|
|
|
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
|
|
)
|
|
|
|
// mapStateManager is the single read/write point between the management stream
|
|
// (writes) and the convergence loop (reads/applies).
|
|
//
|
|
// The stream calls SetTarget with the latest full SyncResponse — the complete
|
|
// desired state. A single background goroutine (run) applies it to the engine in
|
|
// bounded passes via apply() until converged, releasing syncMsgMux between passes
|
|
// so other subsystems interleave. If a newer update arrives mid-flight, the loop
|
|
// coalesces: it keeps converging toward the latest target and the intermediate one
|
|
// is SKIPPED — never applied on its own (logged, no onConverged).
|
|
//
|
|
// Convergence is a single comparison: appliedGen == targetGen. targetGen
|
|
// increments on every SetTarget (an internal generation counter, so it also covers
|
|
// config-only updates that carry no network-map serial).
|
|
//
|
|
// onConverged fires once for each — and only each — map that is actually processed
|
|
// (i.e. converged as the target). Skipped/superseded maps and dropped-on-error maps
|
|
// do NOT fire it. So "sync finished in X" / RecordSyncDuration always corresponds
|
|
// to a real, completed alignment.
|
|
type mapStateManager struct {
|
|
// apply performs one bounded apply pass and reports whether more passes are needed.
|
|
// firstPass is true on the first pass of a given target, so the caller can run
|
|
// wholesale (firewall/routes/DNS/forward-rules) once per target and skip it on the
|
|
// re-runs that only drain the bounded peer batches. The manager owns this signal
|
|
// because it owns the convergence boundary; the engine need not track serials for it.
|
|
apply func(update *mgmProto.SyncResponse, firstPass bool) (bool, error)
|
|
// onConverged is called once per processed map, with the elapsed time since that
|
|
// map was received (for the sync-duration metric / "sync finished" log).
|
|
onConverged func(time.Duration)
|
|
// persist snapshots an update to disk for restore-on-restart. Called once per
|
|
// update received from management (in SetTarget), including ones later coalesced
|
|
// or skipped from apply, so the on-disk state mirrors what management last sent.
|
|
// The impl skips config-only updates (no network map). May be nil.
|
|
persist func(*mgmProto.SyncResponse)
|
|
|
|
mu sync.Mutex
|
|
target *mgmProto.SyncResponse
|
|
targetGen uint64
|
|
appliedGen uint64
|
|
targetSetAt time.Time
|
|
|
|
wake chan struct{}
|
|
}
|
|
|
|
func newMapStateManager(apply func(update *mgmProto.SyncResponse, firstPass bool) (bool, error), persist func(*mgmProto.SyncResponse), onConverged func(time.Duration)) *mapStateManager {
|
|
return &mapStateManager{
|
|
apply: apply,
|
|
persist: persist,
|
|
onConverged: onConverged,
|
|
wake: make(chan struct{}, 1),
|
|
}
|
|
}
|
|
|
|
// SetTarget records the latest update as the desired state and wakes the loop.
|
|
// It returns immediately; convergence happens in the background. Serial-based
|
|
// staleness of the network map is still enforced inside apply (updateNetworkMap).
|
|
func (m *mapStateManager) SetTarget(update *mgmProto.SyncResponse) error {
|
|
m.mu.Lock()
|
|
// A target that has not settled yet (targetGen > appliedGen) is being superseded
|
|
// before it converged: we coalesce to the latest map and never apply this one on
|
|
// its own. It is SKIPPED — logged here, and it will not fire onConverged.
|
|
if m.target != nil && m.targetGen > m.appliedGen {
|
|
log.Debugf("sync map (gen %d) superseded before convergence, skipping", m.targetGen)
|
|
}
|
|
m.target = m.mergeTarget(m.target, update)
|
|
// Bump an internal generation counter, NOT the map serial: config-only updates
|
|
// (relay token rotation, STUN/TURN) arrive without a network map and carry no
|
|
// serial, yet must still be applied. Every SetTarget is therefore a distinct
|
|
// target regardless of payload. Map-serial staleness is enforced separately
|
|
// inside apply (updateNetworkMap).
|
|
m.targetGen++
|
|
m.targetSetAt = time.Now()
|
|
m.mu.Unlock()
|
|
|
|
select {
|
|
case m.wake <- struct{}{}:
|
|
default:
|
|
}
|
|
|
|
// Persist every update received from management — once per update (not per apply
|
|
// pass), and including ones that get coalesced/skipped from apply, so the on-disk
|
|
// state always reflects the latest map management sent. Done after waking the loop
|
|
// so convergence can start in parallel with the disk write. The persist impl skips
|
|
// config-only updates (no network map).
|
|
if m.persist != nil {
|
|
m.persist(update)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// mergeTarget combines the currently pending target with a freshly received update
|
|
// and returns the new desired state. It is called under m.mu from SetTarget and is
|
|
// the single seam where the replace-vs-squash decision lives.
|
|
//
|
|
// Today management always sends a FULL map (the complete desired state), so the
|
|
// update simply replaces whatever was pending — prev is ignored. When management
|
|
// starts sending incremental/delta updates, squash `update` onto `prev` here; the
|
|
// rest of the manager (generation tracking, convergence, signaling) is unaffected
|
|
// because it already treats target as "the complete desired state, whatever it is".
|
|
func (m *mapStateManager) mergeTarget(prev, update *mgmProto.SyncResponse) *mgmProto.SyncResponse {
|
|
// Nothing pending to preserve (no prev, or prev already fully applied): plain replace.
|
|
if prev == nil || update == nil || m.targetGen == m.appliedGen {
|
|
return update
|
|
}
|
|
|
|
// prev still has unapplied state (targetGen > appliedGen). In the sync protocol a
|
|
// nil component means "no change", so if `update` omits a component that prev
|
|
// carried, carry prev's forward — otherwise coalescing an update that superseded a
|
|
// not-yet-applied one would silently drop the map or config it uniquely brought.
|
|
// A present component in `update` is newer and wins. Management may send map-only
|
|
// updates (nil config) and config-only updates (nil map); both are handled here.
|
|
// A nil component in `update` means "no change", so fill it in from prev — otherwise
|
|
// coalescing an update that superseded a not-yet-applied one would drop the map or
|
|
// config it uniquely carried. A present component in `update` is newer and wins.
|
|
// We mutate `update` in place: it is a fresh per-message allocation from the sync
|
|
// stream (see receiveUpdatesEvents — not reused), and persisting this squashed target
|
|
// is correct, since it is the current full (superset) desired state.
|
|
if !syncCarriesNetworkMap(update) && syncCarriesNetworkMap(prev) {
|
|
update.NetworkMap = prev.GetNetworkMap()
|
|
update.NetworkMapEnvelope = prev.GetNetworkMapEnvelope()
|
|
update.Version = prev.GetVersion()
|
|
update.PeerConfig = prev.GetPeerConfig()
|
|
update.Checks = prev.Checks // checks travel with the map
|
|
}
|
|
if update.GetNetbirdConfig() == nil && prev.GetNetbirdConfig() != nil {
|
|
update.NetbirdConfig = prev.GetNetbirdConfig()
|
|
}
|
|
if update.GetSessionExpiresAt() == nil && prev.GetSessionExpiresAt() != nil {
|
|
update.SessionExpiresAt = prev.GetSessionExpiresAt()
|
|
}
|
|
return update
|
|
}
|
|
|
|
// syncCarriesNetworkMap reports whether the update carries a network map in
|
|
// either wire shape: the legacy NetworkMap or the components envelope, which
|
|
// leaves NetworkMap nil.
|
|
func syncCarriesNetworkMap(update *mgmProto.SyncResponse) bool {
|
|
return update.GetNetworkMap() != nil || update.GetNetworkMapEnvelope() != nil
|
|
}
|
|
|
|
// run drives convergence until ctx is done. It is meant to run in its own goroutine.
|
|
func (m *mapStateManager) run(ctx context.Context) {
|
|
// passGen is the generation of the most recent apply() call (0 = none). A pass is
|
|
// the first for its target when its generation differs from the previous one —
|
|
// true on a fresh target and on a coalesced switch to a newer target mid-flight.
|
|
var passGen uint64
|
|
for {
|
|
if ctx.Err() != nil {
|
|
return
|
|
}
|
|
|
|
m.mu.Lock()
|
|
target, tg, ag := m.target, m.targetGen, m.appliedGen
|
|
m.mu.Unlock()
|
|
|
|
// Fully converged (or nothing yet): block until a new target arrives.
|
|
if target == nil || ag == tg {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-m.wake:
|
|
continue
|
|
}
|
|
}
|
|
|
|
firstPass := tg != passGen
|
|
passGen = tg
|
|
more, err := m.apply(target, firstPass)
|
|
if err != nil {
|
|
if ctx.Err() != nil {
|
|
return
|
|
}
|
|
// Log and DROP this target — do not retry it. A deterministic failure
|
|
// (e.g. a malformed peer in the map) would otherwise spin every pass
|
|
// making no progress. Management is the source of truth and re-delivers
|
|
// the full map on the next sync, so dropping is safe; peers already
|
|
// applied this convergence stay (idempotent diffs) and the remainder is
|
|
// reconciled by the next target. Mirrors the legacy handleSync path,
|
|
// where the apply error was logged by the gRPC client and the update
|
|
// dropped. No onConverged: this target did not converge.
|
|
log.Errorf("apply sync pass, dropping update: %v", err)
|
|
m.settle(tg, false)
|
|
continue
|
|
}
|
|
|
|
if more {
|
|
// keep converging the current target; syncMsgMux was released by apply
|
|
// between passes so other subsystems interleave.
|
|
continue
|
|
}
|
|
|
|
// This pass converged. Mark applied and signal this one map.
|
|
m.settle(tg, true)
|
|
// if a newer target arrived mid-pass, settle is a no-op (targetGen != tg) and
|
|
// ag<tg next iteration -> apply it; this generation was skipped (logged in
|
|
// SetTarget) and is not signaled.
|
|
}
|
|
}
|
|
|
|
// settle marks generation tg as processed so the loop goes idle instead of
|
|
// re-applying the same target. It is a no-op when a newer target arrived during the
|
|
// pass (targetGen != tg), leaving appliedGen behind so that target re-applies — the
|
|
// just-finished generation was already counted as skipped.
|
|
//
|
|
// When signal is true (the pass converged) it fires onConverged once for this map;
|
|
// when false (the target was dropped on error) it does not — the map did not converge.
|
|
func (m *mapStateManager) settle(tg uint64, signal bool) {
|
|
m.mu.Lock()
|
|
if m.targetGen != tg {
|
|
m.mu.Unlock()
|
|
return
|
|
}
|
|
m.appliedGen = tg
|
|
setAt := m.targetSetAt
|
|
m.mu.Unlock()
|
|
|
|
if signal && m.onConverged != nil {
|
|
m.onConverged(time.Since(setAt))
|
|
}
|
|
}
|