// Copyright (c) 2026, Pim van Pelt package vpp import ( "context" "errors" "log/slog" "git.ipng.ch/ipng/vpp-maglev/internal/checker" ) // EventSource is the subset of checker.Checker that Reconciler needs. // Decoupling via an interface keeps the dependency direction // vpp → checker (checker never imports vpp). type EventSource interface { Subscribe() (<-chan checker.Event, func()) } // Reconciler bridges checker state transitions to VPP dataplane changes. // It subscribes to the checker's event channel and, for every transition, // runs SyncLBStateVIP for the frontend the backend belongs to. This is // the ONLY place in the codebase where backend state transitions cause // VPP calls — every LB change flows through Client.SyncLBStateVIP. // // The reconciler carries no state of its own. Idempotency is guaranteed // by SyncLBStateVIP itself (diff-based, driven by the pure asFromBackend // mapping in lbsync.go). type Reconciler struct { client *Client events EventSource stateSrc StateSource } // NewReconciler creates a Reconciler. client is the VPP client, events is // the checker (or anything that implements Subscribe), and stateSrc provides // the live config for SyncLBStateVIP calls. All three are normally the // checker/vpp client pair constructed at daemon startup. func NewReconciler(client *Client, events EventSource, stateSrc StateSource) *Reconciler { return &Reconciler{client: client, events: events, stateSrc: stateSrc} } // Run subscribes to the checker and loops until ctx is cancelled. Each // received event fires a single-VIP sync for the frontend the transitioned // backend belongs to. func (r *Reconciler) Run(ctx context.Context) { ch, unsub := r.events.Subscribe() defer unsub() slog.Info("vpp-reconciler-start") defer slog.Info("vpp-reconciler-stop") for { select { case <-ctx.Done(): return case ev, ok := <-ch: if !ok { return } r.handle(ev) } } } // handle reconciles one event. Operates only on backend-transition events // that carry a frontend name (the checker emits one event per frontend that // references the backend, so a backend shared across multiple frontends // produces multiple events and all relevant VIPs are reconciled). // Frontend-transition events are observational only — the dataplane work // they would imply has already been done by the backend-transition event // that triggered them. func (r *Reconciler) handle(ev checker.Event) { if ev.FrontendTransition != nil { return // frontend-only event; no dataplane work } if ev.FrontendName == "" { return } cfg := r.stateSrc.Config() if cfg == nil { return } slog.Debug("vpp-reconciler-event", "frontend", ev.FrontendName, "backend", ev.BackendName, "from", ev.Transition.From.String(), "to", ev.Transition.To.String()) if err := r.client.SyncLBStateVIP(cfg, ev.FrontendName, ""); err != nil { if errors.Is(err, ErrFrontendNotFound) { // Frontend was removed between the event being emitted and // us handling it; a periodic SyncLBStateAll will clean it up. return } slog.Warn("vpp-reconciler-error", "frontend", ev.FrontendName, "backend", ev.BackendName, "from", ev.Transition.From.String(), "to", ev.Transition.To.String(), "err", err) } }