Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/agents/ARCHITECTURE-ROUTER.md
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,7 @@ co-located `galactic-gateway` container's own health port on the same
- **Peer FSM transitions are logged via a dedicated GoBGP event watcher, not GoBGP's own logger — and also drive real-time BGPPeer Kubernetes Events.** `GALACTIC_ROUTER`'s `LogLevel` config only gates GoBGP's *internal* `slog` logger, whose handler is wired to `io.Discard` regardless of level (`internal/runtime/gobgp/server.go`'s `buildServerOptions`) — so none of GoBGP's own peer state log lines ever reach this process's output. Instead, `internal/runtime/gobgp/peer_monitor.go` runs a shared `BgpServer.WatchEvent(..., WatchPeer())` goroutine (the same one-goroutine-per-runtime pattern `monitor.go`'s `watchEVPNRIB` uses for EVPN best-path) that diffs each peer's FSM state against `lastPeerState` and logs a `slog.Info` line on every real transition, including `disconnectReason`/`disconnectMessage` when a peer drops out of Established. The same transition is also handed to an optional `model.PeerStateObserver` (set via `gobgp.NewRuntimeFactory`'s `observer` parameter): in production this is `internal/controller/peer_events.go`'s `PeerStateEventEmitter`, a `manager.Runnable` that resolves the peer's address to its `BGPPeer` object and raises a `SessionEstablished`/`SessionDown` Kubernetes Event on it — but only for transitions into/out of Established, not every intermediate FSM hop (see the emitter's own doc comments for why). This is independent of, and does not replace, the periodic (`peerStatusRequeue`, 30s) `BGPPeer.Status` update in `internal/controller/bgprouter_controller.go`'s `updatePeerStatuses`: that keeps the CRD's status current on a poll and can miss a transition that reverts between two polls; the `slog` line and the Event are both real-time and don't have that gap. See `docs/plans/router-bgp-peer-session-events.md` for the full design.
- **Routes for route-target-less EVPN paths defer to the underlay.** A path with no route target (the gateway's anycast VIPs, and each egress shard's SID `/64` and masquerade address) becomes a plain kernel route toward its BGP next hop, with no encapsulation. These routes live in their own table, `srv6.PlainRouteTable` (`0xFFFE0000`), marked `proto 250`, and are reached through two policy rules ahead of the main table's own: `32000: lookup main suppress_prefixlength 0`, then `32001: lookup 0xFFFE0000`. An underlay route for the same destination, or for anything more specific than the default route, therefore always wins. Keeping them out of the main table is required, not cosmetic: zebra imports every main-table route it did not install as a `kernel` route with distance 0, so a plain route there stopped FRR installing its own BGP route for the same prefix (#670). The kernel cannot resolve an IPv6 next hop recursively, so each route is written with the link and next hop the gateway resolves to at the time. `internal/runtime/gobgp/plainroutes.go` re-resolves every route whenever the main table changes and every 30s, and retries one that cannot resolve yet. It removes owned routes no path asks for once a two-minute startup grace has passed, and deletes the unmarked main-table route an earlier version left for each prefix. `ip route show table all proto 250` lists every route it owns. XDP datapaths that call `bpf_fib_lookup` with `BPF_FIB_LOOKUP_DIRECT` (`natprog`, `edgeprog`) read the main table only and do not see these routes.
- **A tenant VRF route that fails to install is retried, not dropped.** `processEVPNPath` writes a VRF-scoped path's route into `egress_route_table` through `internal/runtime/gobgp/vrfroutes.go`. `Register` resolves the SID's uplink and neighbor MAC at write time, which fails while an uplink is still coming up after a restart, and BGP never redelivers an unchanged best path, so before #676 such a route stayed missing until the path changed upstream. `vrfRoutes` keeps each failed route in a pending set and retries it whenever the main table changes (1s debounce) and every 30s, until it installs or its path is withdrawn; removing a VRF drops its pending routes. One mutex serializes every write to the map from this runtime, taken per route during a retry so an unresolvable neighbor (up to 2s) does not hold up the RIB watcher for the whole pass. `EgressRouteTable.Refresh` cannot cover this: it walks existing entries only. The pending count, and the plain-route reconciler's failing count, are exported as `galactic_router_route_install_failing{kind="vrf"|"plain"}`, and `GalacticRouterRoutesNotInstalled` fires when either stays above 0 for 10 minutes.
- **`ServiceVIPBinding`s are generated, one per node per backend.** `NetworkRuleBindingReconciler` (`internal/controller/networkrule_binding_controller.go`) runs on every node and writes a binding for each IPv6 VIP of each accepted `NetworkRule` and each backend the rule's `backendSelector` places on this node, through the same `selectRuleBackends` the gateway uses, so the gateway never sends a backend's flows to a node with no binding planned for it. Each node writes and deletes only its own bindings (labelled `galactic.datum.net/binding-node` and `app.kubernetes.io/managed-by: galactic-router`), so there is one writer per object and no leader election. A binding is owned by its rule, records the rule's `vpcRef` (which names the VRF its rows go in) and takes `egressKind` from the attachment's interface mode. A backend serves only the oldest rule (by creation time, then namespace/name) selecting it on the same backend port and protocol, since its node translates the backend's replies back to only one VIP; both sides compute that owner with `ruleBackendOwners` over every rule in the namespace, so a newer rule gets no binding for the backend and no flows for it from the gateway, and every rule in a namespace is re-queued when one of them changes. Rules compete only within a VPC, so the per-VPC `VPCAttachment` fan-out still reaches every rule an attachment change can affect. The node summarizes its bindings on the rule as `<node>/BackendsBound` (`BackendsClaimed` names a backend an older rule serves); a rule whose spec cannot be built into bindings (a second IPv6 VIP, or a selector that does not parse) keeps the node's existing bindings, the same as a rule that loses `Accepted`, so an invalid edit does not drop the backend nodes' `vip_xlat_table` rows. Only the nodes that hold bindings for such a rule report `InvalidRule`, since the gateways already report the error and a condition from every node would be a status write per node per edit. Several backends of one VIP can share a node: the gateway writes each backend's slot into the uSID and `usid.c` keys the ingress row on it (#799).
- **`ServiceVIPBinding`s are generated, one per node per backend.** `NetworkRuleBindingReconciler` (`internal/controller/networkrule_binding_controller.go`) runs on every node and writes a binding for each IPv6 VIP of each accepted `NetworkRule` and each backend the rule's `backendSelector` places on this node, through the same `selectRuleBackends` the gateway uses, so the gateway never sends a backend's flows to a node with no binding planned for it. Each node writes and deletes only its own bindings (labelled `galactic.datum.net/binding-node` and `app.kubernetes.io/managed-by: galactic-router`), so there is one writer per object and no leader election. A binding is owned by its rule, records the rule's `vpcRef` (which names the VRF its rows go in) and takes `egressKind` from the attachment's interface mode. `ServiceVIPBindingReconciler` programs a binding only when a `BGPAdvertisement` on this node's `BGPRouter` with that VRF's ID has a prefix containing `backendAddress`, and otherwise reports `BindFailed`; it watches advertisements on its own router so a binding written before its advertisement converges, and teardown skips the check. A backend serves only the oldest rule (by creation time, then namespace/name) selecting it on the same backend port and protocol, since its node translates the backend's replies back to only one VIP; both sides compute that owner with `ruleBackendOwners` over every rule in the namespace, so a newer rule gets no binding for the backend and no flows for it from the gateway, and every rule in a namespace is re-queued when one of them changes. Rules compete only within a VPC, so the per-VPC `VPCAttachment` fan-out still reaches every rule an attachment change can affect. The node summarizes its bindings on the rule as `<node>/BackendsBound` (`BackendsClaimed` names a backend an older rule serves); a rule whose spec cannot be built into bindings (a second IPv6 VIP, or a selector that does not parse) keeps the node's existing bindings, the same as a rule that loses `Accepted`, so an invalid edit does not drop the backend nodes' `vip_xlat_table` rows. Only the nodes that hold bindings for such a rule report `InvalidRule`, since the gateways already report the error and a condition from every node would be a status write per node per edit. Several backends of one VIP can share a node: the gateway writes each backend's slot into the uSID and `usid.c` keys the ingress row on it (#799).
- **Surviving routers clean up after a departed node.** Only a node's own `NetworkRuleBindingReconciler` clears its `<node>/BackendsBound` conditions and deletes its generated bindings, and only its own `ServiceVIPBindingReconciler` removes the teardown finalizer from bindings targeting it. So once a node's `Node` object is deleted, `NodeDepartureReconciler` (`internal/controller/node_departure_controller.go`) on every other router removes that node's `BackendsBound` condition from every `NetworkRule`, deletes its generated bindings and removes the finalizer from every binding whose `targetRef.name` names it, hand-written ones included; the kernel rows went with the node. Without it the conditions stayed forever and a deleted rule's bindings stayed `Terminating`. Every Node create and delete maps to one sweep key, so the sweep runs on the initial Node list (startup), on every Node delete and every `--gc-interval`, and a router down during the delete still converges. A node missing from the cached Node list counts as departed only once an uncached `Get` returns NotFound. Every router sweeps, with no leader election: status writes and finalizer patches retry on conflict (the patch carries the resourceVersion) and NotFound counts as done.
- **Deleting a `ServiceVIPBinding` does not need its VRF on the node.** A binding's two `vip_xlat_table` rows are keyed on its VRF's uSID Argument. `ServiceVIPBindingReconciler` reads it from the `BGPVRFInstance` its `vpcRef` names on this node, and that is gone once the VPC leaves the node. Before #750, deletion re-derived it, failed, held the finalizer forever and left both rows in the map. Teardown now finds the rows by value with `vipxlatmap.UnregisterBinding`: since #764 each key carries the VIP or backend address, and since #799 the ingress key also carries the backend's slot, so the binding's ingress row (its VIP, port and slot, rewriting to its backend) and egress row (its backend and port, rewriting to its VIP) are found exactly, under any Argument. Each row is removed only while it still holds the binding's values, so a row another binding has since written stays. When the VRF context still resolves, `UnregisterBindingAt` applies the same checks at that location too. Neither pass deletes a row without first reading it, and a process-wide lock keeps a bind from landing between the read and the delete. A value check cannot tell two bindings with identical values apart, so while such a twin is live on the node, deleting one removes nothing. Rows exist only for IPv6 addresses, so an IPv4 or invalid address skips both passes instead of blocking deletion. Only a failed kernel delete keeps the finalizer. Rows left behind some other way, by a finalizer cleared by hand, a crash or a manual write, are removed by the periodic sweep below; to remove one sooner, use `galactic-router vip xlat list` and `vip xlat remove --protocol --vip --port --backend --backend-port` (`--slot` defaults to the backend's own), run in that node's `galactic-router` pod (`kubectl exec <pod> -- /galactic-router vip xlat list`).
- **`vip_xlat_table` is swept on the GC ticker.** `controller.VIPXlatSweeper` (`internal/controller/vipxlat_sweep.go`) runs on every GC tick after the first, never in the initial pass at startup, so the `ServiceVIPBinding` reconciler has re-registered every live binding first. It takes the table's generation, lists every `ServiceVIPBinding` targeting this node (including ones being deleted, whose finalizer owns their rows), resolves each one's two rows the way the reconciler does, and calls `VipXlatTable.Reconcile`, which removes every row not in that set unless it was registered after the snapshot. The live set is by key, so a row is kept whatever value it holds. A binding whose VRF context does not resolve right now has no known Argument, so every row shaped like one of its rows (direction, slot, protocol, address and port, under any Block and Argument) is kept instead. A binding whose spec does not parse never wrote a row and protects nothing, and any error listing bindings or building the index skips the whole pass. This is what removes the slot-0 ingress rows a router built before #799 wrote: the current key carries the backend's slot, so nothing else ever matches them. `usid.c` has no slot-0 fallback, so `galactic-gateway` and `galactic-router` have to be upgraded together across #799; a node whose router has not yet rewritten its rows with slots drops the flows a new gateway sends it.
Expand Down
10 changes: 10 additions & 0 deletions docs/gateway/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -616,6 +616,16 @@ slot-0 ingress rows a `galactic-router` built before #799 wrote, so upgrade
bindings' rows with slots when it starts, and its periodic `vip_xlat_table`
sweep removes the old slot-0 rows.

A binding is programmed only once its VPC's VRF on the node advertises a
prefix containing `backendAddress`, through a `BGPAdvertisement` on that
node's `BGPRouter` with the VRF's ID. Until then it reports `Bound=False`
with reason `BindFailed`, naming the VPC, VRF, node and address, and claims
no row. This catches a hand-written binding for an address the node does not
serve, an attachment whose interface is not up yet, and a stale attachment
address. A new or changed advertisement on the node requeues its bindings,
so one written first binds as soon as the address is advertised. Deleting a
binding never needs the advertisement.

Each node summarizes its bindings on the rule as
`<backend-node>/BackendsBound`: `True` with reason `Bound` once every one
reports `Bound`, `False` with reason `BindingsNotBound` naming each that
Expand Down
Loading
Loading