diff --git a/docs/agents/ARCHITECTURE-CNI.md b/docs/agents/ARCHITECTURE-CNI.md index 4e868438..29bcb4be 100644 --- a/docs/agents/ARCHITECTURE-CNI.md +++ b/docs/agents/ARCHITECTURE-CNI.md @@ -59,7 +59,8 @@ CNI config and acts on them. Each attachment endpoint is assigned a /128 USID (Unique Local SID, RFC 8986 Section 3.2). There is no companion-operator-injected `srv6_sid` NAD/config -field: `galactic-bgp` (`internal/cnibgp/bgp.go`'s `registerEBPFDatapath`) +field: `galactic-bgp` (`internal/cnibgp/bgp.go`'s `registerEBPFDatapath`, +which calls `internal/plumbing/ebpf/attachreg`'s `RegisterDatapath`) derives the uSID `Block` from the node's `BGPRouter.spec.srv6Locator` via `uformat.Block`, and registers `locator_table`/`function_table`/`vrf_table` entries keyed on that `Block` plus this attachment's `Argument` (its @@ -71,6 +72,30 @@ history). If the router lacks either `srv6Locator` or `nodeID`, eBPF registration is skipped entirely for that attachment (`registerEBPFDatapath` returns `registered=false`, not an error). +CNI ADD is the only writer of an attachment's rows, and of this node's own +`locator_table`, `function_table`, `node_src_addr_table` and +`public_uplink_table` rows, so a map recreated empty by a layout change or a +lost pin would otherwise stay empty until every workload re-attaches. +`galactic-cni run` rebuilds every missing row +(`internal/gc/datapath_repair.go`'s `RepairAttachmentDatapath`, through the +same `attachreg` helpers ADD uses), always off the daemon's main loop: once +right after the datapath loads, retried while a pass fails or finds +attachments whose `BGPRouter` or `BGPVRFInstance` has not appeared yet (the +latter for up to ten minutes), and again on every eBPF GC tick. It finds +attachments by their host interface name and VRF master, takes the routing +table and egress kind from the kernel, the guest prefixes from the pod-subnet +routes out that interface and the gateways from its own addresses, and the +Block and Argument from the `BGPRouter` and `BGPVRFInstance`. It only writes a +row that is missing, and never one under the ingress sidecar's Block. The +sidecar return path's tables are excluded by their reserved table ID range, +since the sidecar's own `vrf_table` rows that also mark them may be among the +rows lost. A VPC's shared `vrf_table` and `vpc_attribution_table` rows are +rebuilt from its lowest-ifindex attachment, while ADD leaves whichever +attachment registered last, so those two may differ from before the loss; +delivery reads the per-interface `ifindex_egress_kind_table` row, which is +exact. Once no egress shard resolves, a pass skips the remaining VRFs' shard +routes rather than wait out each one's neighbor solicitation. + `galactic-router`'s own reconciler independently derives the *same* SID value from the same inputs (`srv6.ComputeSID`, `internal/plumbing/srv6/usid.go`, called from `internal/reconcile/reconcile.go`) for the BGP control-plane diff --git a/docs/cni/environment-variables.md b/docs/cni/environment-variables.md index fd708fb5..518e883d 100644 --- a/docs/cni/environment-variables.md +++ b/docs/cni/environment-variables.md @@ -217,7 +217,7 @@ Each entry names a shard's Block, Node-ID and Function. Whatever **Argument** it carries is a placeholder: it identifies no tenant and never could, one configured value being shared by every VRF on every node. Each attachment's CNI ADD rewrites that Argument to its own VRFID before installing the route -(`internal/cnibgp`'s `shardSIDsForTenant`), which is what lets a shard tell +(`internal/plumbing/ebpf/attachreg`'s `ShardSIDsForTenant`), which is what lets a shard tell two tenants on one node apart — see [#538](https://github.com/datum-cloud/galactic/issues/538). An entry that is not a well-formed uFMT 48+16 address fails the ADD there rather than being diff --git a/docs/cni/gc-cmd-sequence.md b/docs/cni/gc-cmd-sequence.md index 78aa1497..6ef21e7e 100644 --- a/docs/cni/gc-cmd-sequence.md +++ b/docs/cni/gc-cmd-sequence.md @@ -275,6 +275,12 @@ sequenceDiagram ### Notes +- **Missing rows are rebuilt alongside.** The same tick starts + `RepairAttachmentDatapath` (`internal/gc/datapath_repair.go`) off the main + loop, which writes back every row CNI ADD wrote that a recreated map has + lost, from kernel state and the CRDs. It also runs when the datapath loads. + The two need no ordering: this sweep reaps only keys no live + `BGPVRFInstance` names, and the repair writes keys for live ones only. - **This is a reconcile against a generation cutoff, not a two-list diff.** `Generation()` is captured *before* `BGPVRFInstance`s are listed. A CNI ADD that registers a brand-new entry (`registerEBPFDatapath`, see diff --git a/internal/cnibgp/bgp.go b/internal/cnibgp/bgp.go index 0e9a5784..a4f0b74b 100644 --- a/internal/cnibgp/bgp.go +++ b/internal/cnibgp/bgp.go @@ -9,13 +9,14 @@ // does not own; host-interface gateway configuration lives in internal/hostgw, // called directly by the master plugins. // -// Two narrow exceptions touch kernel state here. registerEBPFDatapath resolves -// the host-side interface's ifindex with a read-only netlink.LinkByName to key -// its ifindex_vrf_table row, because the (Block, Argument) values that row -// pairs with are known only at that call site, and the interface name is -// deterministic. installEgressRoutes writes a real kernel route, because -// the optional routing plugin in this chain may be absent from a conflist and -// the route must exist wherever a NAT66 shard is configured. That route +// The eBPF registration itself lives in internal/plumbing/ebpf/attachreg, +// which the galactic-cni daemon also uses to rebuild rows a map recreation has +// lost. Two narrow exceptions there touch kernel state on this plugin's +// behalf. It resolves the host-side interface's ifindex with a read-only +// netlink.LinkByName to key its ifindex_vrf_table row, because the interface +// name is deterministic. It writes the VRF's shard egress routes, because the +// optional routing plugin in this chain may be absent from a conflist and the +// route must exist wherever a NAT66 shard is configured. That route // encapsulates toward the shard's SID with this attachment's VRFID written // into the Argument, which is what a shard reads back to tell one tenant on // this node from another. @@ -26,35 +27,24 @@ import ( "errors" "fmt" "log/slog" - "net" "net/netip" - "os" - "path/filepath" "sort" "strconv" "strings" "time" - "github.com/cilium/ebpf" "github.com/containernetworking/cni/pkg/skel" - "github.com/vishvananda/netlink" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "go.datum.net/galactic/internal/cniipam" - "go.datum.net/galactic/internal/config" "go.datum.net/galactic/internal/crdnames" "go.datum.net/galactic/internal/gc" - "go.datum.net/galactic/internal/plumbing/ebpf/attach" - "go.datum.net/galactic/internal/plumbing/ebpf/egressroutemap" - "go.datum.net/galactic/internal/plumbing/ebpf/ifindexvrfmap" + "go.datum.net/galactic/internal/plumbing/ebpf/attachreg" "go.datum.net/galactic/internal/plumbing/ebpf/uformat" - "go.datum.net/galactic/internal/plumbing/ebpf/usidmap" - "go.datum.net/galactic/internal/plumbing/intf" "go.datum.net/galactic/internal/plumbing/srv6" - "go.datum.net/galactic/internal/plumbing/vrf" bgpv1alpha1 "go.datum.net/network/api/v1alpha1" ) @@ -65,8 +55,8 @@ const maxRetries = 2 // ifaceTypeVeth and ifaceTypeTap are the values publishConfig.ifaceType // accepts. Inferred from prevResult, not from a config field. const ( - ifaceTypeVeth = "veth" - ifaceTypeTap = "tap" + ifaceTypeVeth = attachreg.InterfaceTypeVeth + ifaceTypeTap = attachreg.InterfaceTypeTap ) // publishConfig carries the subset of this plugin's own config that @@ -318,10 +308,10 @@ func localEgressPrefixes(ipamResult *cniipam.IPAMResult) []string { return prefixes } if ipamResult.IPv6Gateway != nil { - prefixes = append(prefixes, ipamResult.IPv6Gateway.String()+"/128") + prefixes = append(prefixes, attachreg.HostPrefix(ipamResult.IPv6Gateway)) } if ipamResult.IPv4Gateway != nil { - prefixes = append(prefixes, ipamResult.IPv4Gateway.String()+"/32") + prefixes = append(prefixes, attachreg.HostPrefix(ipamResult.IPv4Gateway)) } return prefixes } @@ -527,487 +517,45 @@ func publishBGPState( return result, err } -// registerEBPFDatapath registers this attachment against the eBPF uSID -// datapath's pinned maps. registered is false with a nil error only when this -// router has no SRv6 locator or node ID configured, meaning SRv6 is -// deliberately not set up for it. Any other failure returns an error. -// -// prefixes are this attachment's guest CIDRs and gateway host addresses. -// Each is registered as a local pass-through egress_route_table entry in this -// VPC's VRF, so local destinations outrank the VRF's ::/0 NAT66 default. -// An empty slice is valid and registers nothing. -func registerEBPFDatapath( - bgp bgpConfig, vpc, vpcAttachment, ifaceType string, argument uint16, pinDir string, prefixes []string, -) (registered bool, err error) { - if bgp.srv6Locator == "" || bgp.nodeID == 0 { - return false, nil - } - - if bgp.nodeID < uformat.NodeIDMin || bgp.nodeID > uformat.NodeIDMax { - return false, fmt.Errorf("eBPF registration: nodeID %d out of range [%#x,%#x]", - bgp.nodeID, uint16(uformat.NodeIDMin), uint16(uformat.NodeIDMax)) - } - - egressKind, err := egressKindForInterfaceType(ifaceType) - if err != nil { - return false, fmt.Errorf("determine eBPF egress kind: %w", err) - } - - prefix, err := netip.ParsePrefix(bgp.srv6Locator) - if err != nil { - return false, fmt.Errorf("parse SRv6 locator %q for eBPF registration: %w", bgp.srv6Locator, err) - } - block, err := uformat.Block(prefix.Addr()) - if err != nil { - return false, fmt.Errorf("derive eBPF uSID Block from locator %q: %w", bgp.srv6Locator, err) - } - - vrfTableID, err := vrf.TableID(vpc) - if err != nil { - return false, fmt.Errorf("look up VRF table id for eBPF registration: %w", err) - } - - // Installs or refreshes this VRF's NAT66 default egress route. The - // optional routing plugin in this chain may be absent from a given - // conflist, and this route must exist wherever a shard is configured, so - // it is written here. - if err := installEgressRoutes(vrfTableID, argument); err != nil { - return false, fmt.Errorf("install NAT66 default egress route: %w", err) - } - - if err := registerLocalEgressRoutes(pinDir, vrfTableID, prefixes); err != nil { - return false, fmt.Errorf("register local pass-through egress route: %w", err) - } - - // The host-side interface's ifindex keys this attachment's - // ifindex_vrf_table row. See the package doc comment for why this - // read-only netlink call is an accepted exception. - hostIfindex, err := hostInterfaceIndex(vpc, vpcAttachment) - if err != nil { - return false, fmt.Errorf("resolve host interface ifindex for eBPF registration: %w", err) - } - - registry, closer, err := usidmap.OpenPinnedRegistry(pinDir) - if err != nil { - return false, fmt.Errorf("open pinned eBPF uSID maps: %w", err) - } - defer func() { _ = closer.Close() }() - - if err := registry.Locator.Register(block, uint16(bgp.nodeID)); err != nil { - return false, fmt.Errorf("register eBPF locator_table entry: %w", err) - } - if err := registry.Function.Register(block, uformat.FunctionEndDT46); err != nil { - return false, fmt.Errorf("register eBPF function_table entry: %w", err) - } - - if err := registry.VRF.Register(block, argument, vrfTableID, egressKind); err != nil { - return false, fmt.Errorf("register eBPF vrf_table entry: %w", err) - } - - // Attribute this (block, argument) to its VPC/VPCAttachment for byte - // accounting (datum-cloud/enhancements#878), keyed identically to the - // vrf_table row just registered. vpc and vpcAttachment are already known - // here as this attachment's own CNI-config identifiers, so this needs no - // central uSID-to-VPC lookup for the receiving side. Non-fatal: a - // registration failure here must not fail the CNI ADD, since vrf_table's - // own registration above already succeeded and forwarding is unaffected; - // only attribution for this attachment is degraded until the next ADD/DEL - // or GC repair. - vpcNum, vpcAttachmentNum, err := decodeVPCIdentifiers(vpc, vpcAttachment) - if err != nil { - slog.Warn("ADD: could not decode VPC/VPCAttachment for byte-accounting attribution; "+ - "vrf_table counters for this entry will report unattributed until the next ADD/DEL", - "vpc", vpc, "vpcAttachment", vpcAttachment, "err", err) - } else if err := registry.VPCAttribution.Register(block, argument, vpcNum, vpcAttachmentNum); err != nil { - slog.Warn("ADD: could not register eBPF vpc_attribution_table entry; "+ - "vrf_table counters for this entry will report unattributed until the next ADD/DEL", - "vpc", vpc, "vpcAttachment", vpcAttachment, "err", err) - } - - ifindexTable, ifindexCloser, err := ifindexvrfmap.OpenPinned(pinDir) - if err != nil { - return false, fmt.Errorf("open pinned eBPF ifindex_vrf_table: %w", err) - } - defer func() { _ = ifindexCloser.Close() }() - if err := ifindexTable.Register(hostIfindex, block, argument); err != nil { - return false, fmt.Errorf("register eBPF ifindex_vrf_table entry: %w", err) - } - if err := registerEgressKind(pinDir, hostIfindex, egressKind); err != nil { - return false, err - } - - // Attach usid_egress to this attachment's host-side interface. This is - // what translates a reply's source address on the way back out. - hostName := intf.GenerateInterfaceNameHost(vpc, vpcAttachment) - if err := attachUsidEgress(pinDir, hostName); err != nil { - return false, fmt.Errorf("attach eBPF usid_egress to host interface %q: %w", hostName, err) - } - - // Register this node's own SRv6 SID base. A per-node constant rather than - // a per-attachment one, but idempotent and cheap enough to redo on every - // ADD instead of adding a once-per-node lifecycle hook. - // - // Non-fatal, unlike the registrations above. A node whose BGPRouter names - // no SRv6 locator or node ID has no SID to register and no SRv6 endpoint - // of its own -- the same condition that leaves this attachment's own - // advertised SID unset above. Failing every pod attach on such a node is - // worse than the alternative, where only traffic needing encapsulation - // fails. usid_egress fails open for exactly this gap, and a later ADD - // succeeds once the locator is configured. - if err := registerNodeSourceAddress(pinDir, bgp.srv6Locator, bgp.nodeID); err != nil { - slog.Warn("ADD: could not register this node's own SRv6 SID; "+ - "egress routing will fail open until this succeeds", "err", err) - } - - // Register this node's fabric-uplink next hop. Same per-node, - // redo-on-every-ADD, non-fatal shape as registerNodeSourceAddress above, - // for the same reason: resolving it needs a converged underlay neighbor. - // usid_egress falls through to egress_route_table while the entry is - // absent. - if err := registerPublicUplink(pinDir); err != nil { - slog.Warn("ADD: could not register this node's own public uplink; "+ - "a DSR backend's VIP-sourced reply traffic will fail open to egress_route_table until this succeeds", - "err", err) - } - - return true, nil -} - -// decodeVPCIdentifiers decodes vpc and vpcAttachment, the base62 identifiers -// CNI configuration carries (see docs/cni/conflist-reference.md), into the -// fixed-width numeric form vpc_attribution_table stores: 48-bit VPC, 16-bit -// VPCAttachment. Both are validated as base62 elsewhere in this chain (see -// isValidBase62 in config.go); a decode failure here means that validation -// was bypassed or the format changed underneath it, not an ordinary runtime -// condition. -func decodeVPCIdentifiers(vpc, vpcAttachment string) (vpcNum uint64, vpcAttachmentNum uint32, err error) { - vpcHex, err := intf.Base62ToHex(vpc) - if err != nil { - return 0, 0, fmt.Errorf("decode vpc %q: %w", vpc, err) - } - vpcNum, err = strconv.ParseUint(vpcHex, 16, 48) - if err != nil { - return 0, 0, fmt.Errorf("parse decoded vpc %q (hex %q) as uint48: %w", vpc, vpcHex, err) - } - - vpcAttachmentHex, err := intf.Base62ToHex(vpcAttachment) - if err != nil { - return 0, 0, fmt.Errorf("decode vpcAttachment %q: %w", vpcAttachment, err) - } - vpcAttachmentNum64, err := strconv.ParseUint(vpcAttachmentHex, 16, 16) - if err != nil { - return 0, 0, fmt.Errorf("parse decoded vpcAttachment %q (hex %q) as uint16: %w", - vpcAttachment, vpcAttachmentHex, err) - } - return vpcNum, uint32(vpcAttachmentNum64), nil -} - -// registerNodeSourceAddress derives this node's own End.DT46 SID base from -// locator and nodeID and writes it into node_src_addr_table, the value -// usid_egress completes with the packet's own Argument and stamps into every -// outer header it pushes. While the entry is missing, every egress_route_table -// hit fails open instead of encapsulating, so no installed egress route can -// carry traffic. pinDir is the bpffs directory holding the pinned map. Failure -// here does not fail the CNI ADD; see the call site. -// -// The value is this node's SID rather than its uplink interface address, which -// is what it held until #550: the shard that translates a tenant's egress -// traffic sends the reply back to whatever the outer source was, and only a -// SID is both routable across the fabric and decapsulated on arrival. See -// node_src_addr_table's own comment in internal/plumbing/ebpf/prog/usid.c. -func registerNodeSourceAddress(pinDir, locator string, nodeID int32) error { - sid, err := srv6.NodeSIDBase(locator, nodeID) - if err != nil { - return fmt.Errorf("derive node SID base: %w", err) - } - addr := net.IP(sid.AsSlice()) - nodeSrc, closer, err := egressroutemap.OpenPinnedNodeSourceAddress(pinDir) - if err != nil { - return fmt.Errorf("open pinned node_src_addr_table: %w", err) - } - defer func() { _ = closer.Close() }() - return nodeSrc.Set(addr) -} - -// registerPublicUplink resolves this node's fabric-uplink next hop and writes -// it into public_uplink_table, the value usid_egress redirects a DSR backend's -// VIP-sourced reply toward once apply_vip_xlat has rewritten that reply's -// source address, bypassing egress_route_table's NAT66 default. Without it, -// such a reply is re-translated through a NAT66 shard instead of reaching the -// real client. pinDir is the bpffs directory holding the pinned map. Failure -// here does not fail the CNI ADD; see the call site. -func registerPublicUplink(pinDir string) error { - linkIndex, dmac, smac, err := srv6.ResolvePublicUplink() - if err != nil { - return fmt.Errorf("resolve public uplink: %w", err) - } - uplink, closer, err := egressroutemap.OpenPinnedPublicUplink(pinDir) - if err != nil { - return fmt.Errorf("open pinned public_uplink_table: %w", err) - } - defer func() { _ = closer.Close() }() - return uplink.Set(linkIndex, dmac, smac) -} - -// attachUsidEgress loads usid_egress from its pin and attaches it to -// ifaceName's TC ingress hook. attach.Load pins the program there so a -// short-lived process like this one can reach it without reloading. -// -// The forward path works without this, because nothing on it depends on a -// reply leaving with its source address translated. Only a full round trip -// needs it, and a missing attachment stalls the handshake, with the client -// discarding replies that arrive from an address it never contacted. -// -// Idempotent, so it is safe to call on every attachment ADD. -func attachUsidEgress(pinDir, ifaceName string) error { - program, err := ebpf.LoadPinnedProgram(filepath.Join(pinDir, attach.UsidEgressPinName), nil) - if err != nil { - return fmt.Errorf("load pinned usid_egress program: %w", err) - } - defer func() { _ = program.Close() }() - - return attach.AttachEgress(program, ifaceName) -} - -// registerLocalEgressRoutes registers each of prefixes as a local pass-through -// egress_route_table entry in Linux VRF table vrfTableID, so guest prefixes -// and gateway host addresses outrank the VRF's NAT66 default. pinDir is the -// bpffs directory holding the pinned map. Idempotent, so a repeat ADD, or a sibling -// re-registering an unrelated prefix in the same VRF, is safe. -// -// It opens the map through egressroutemap rather than the srv6 wrappers, which -// resolve their pin directory from a package var instead of a parameter: -// registerEBPFDatapath is designed to run against an arbitrary pinDir. -// -// prefixes are the CIDR strings localEgressPrefixes already produced, so -// a parse failure means that function emitted something unparseable. It is a -// hard error here rather than a silent skip. -func registerLocalEgressRoutes(pinDir string, vrfTableID uint32, prefixes []string) error { - if len(prefixes) == 0 { - return nil - } - table, closer, err := egressroutemap.OpenPinnedEgressRouteTable(pinDir) - if err != nil { - return fmt.Errorf("open pinned egress_route_table: %w", err) - } - defer func() { _ = closer.Close() }() - - for _, p := range prefixes { - _, prefix, err := net.ParseCIDR(p) - if err != nil { - return fmt.Errorf("parse prefix %q: %w", p, err) - } - if err := table.RegisterPassThrough(vrfTableID, prefix); err != nil { - return fmt.Errorf("register local pass-through route for %s: %w", p, err) - } - } - return nil -} - -// installEgressRoutes installs or refreshes vrfTableID's egress routes toward -// the configured shards: the ::/0 default that reaches the IPv6 internet, and, -// where this fabric has NAT64, a more-specific route for the NAT64 prefix. -// Idempotent, so it is safe on every attachment ADD sharing this VRF. -// -// argument is this attachment's VRFID, written into every shard SID these -// routes encapsulate toward. It is what makes a shard able to tell one tenant -// on this node from another: the shard reads it back out of the outer -// destination and composes it with the encapsulation source into its session -// table key. Without it every VRF on this node encapsulates toward a byte- -// identical destination, and two tenants whose inner tuples also match -- an -// ordinary occurrence with overlapping RFC 4193 ULAs -- share one connection -// row and one masquerade port, so the second tenant's replies are delivered to -// the first. See struct conn_key in internal/plumbing/ebpf/natprog/nat.c. -// -// Both routes point at the same shard SID, argument included. A shard decides -// which translation a packet gets from its inner destination, so the second -// route exists to make the NAT64 prefix reachable at all rather than to steer -// it somewhere else -- which matters because the two are independent: a fabric -// may offer NAT64 without NAT66, and then no default route exists for this -// traffic to fall into. -// -// No shard configured is not an error: the shard list parses to an empty slice -// and srv6.EgressDefaultRouteAdd no-ops. A shard SID that is invalid, or that -// has no reachable route yet, fails this attachment's ADD rather than leaving -// the VRF with no egress at all. -func installEgressRoutes(vrfTableID uint32, argument uint16) error { - // cniConfig is nil until InitCNIConfig runs, which several unit tests - // calling registerEBPFDatapath directly never do. Treated as "no shard - // configured" rather than a panic. +// egressConfig returns the egress shard configuration CNI ADD installs a +// VRF's shard routes from. cniConfig is nil until InitCNIConfig runs, which +// unit tests calling registerEBPFDatapath directly never do; that reads as +// no shard configured. +func egressConfig() attachreg.EgressConfig { if cniConfig == nil { - return nil - } - shardSIDs, err := config.ParseEgressShardSIDs(cniConfig.EgressShardSIDs) - if err != nil { - return fmt.Errorf("parse %s: %w", config.EnvCNIEgressShardSIDs, err) - } - if len(shardSIDs) == 0 { - return nil - } - tenantSIDs, err := shardSIDsForTenant(shardSIDs, argument) - if err != nil { - return fmt.Errorf("apply tenant argument to %s: %w", config.EnvCNIEgressShardSIDs, err) - } - if err := srv6.EgressDefaultRouteAdd(vrfTableID, tenantSIDs); err != nil { - return err - } - return installNAT64EgressRoute(vrfTableID, tenantSIDs) -} - -// shardSIDsForTenant returns sids with each SID's 12-bit Argument replaced by -// argument, leaving Block, Node-ID and Function as the operator configured -// them. Whatever Argument an operator baked into a configured SID is therefore -// overwritten rather than honoured; it identifies no tenant and never could, -// one configured value being shared by every VRF on every node. -// -// A SID that is not a well-formed uFMT 48+16 address fails here rather than -// being passed through unchanged. uformat.Decode's padding check is what -// catches it -- an address with anything in bits 81-128 is not a uSID, and -// writing an Argument into it would produce a plausible-looking destination -// that addresses nothing. An IPv4 entry is unmapped first so it fails as "not -// an IPv6 address" rather than as stray padding, which is what it actually is. -// -// The shard must have a route covering its whole Block and Node-ID for these -// destinations to be reachable, not just a host route for the one SID the -// operator configured. EgressShardReconciler advertises that /64; see -// shardAdvertisementPrefixes. -func shardSIDsForTenant(sids []net.IP, argument uint16) ([]net.IP, error) { - out := make([]net.IP, 0, len(sids)) - for _, sid := range sids { - addr, ok := netip.AddrFromSlice(sid.To16()) - if !ok { - return nil, fmt.Errorf("egress shard SID %s is not a 16-byte address", sid) - } - fields, err := uformat.Decode(addr.Unmap()) - if err != nil { - return nil, fmt.Errorf("decode egress shard SID %s: %w", sid, err) - } - fields.Argument = argument - tenant, err := uformat.Encode(fields) - if err != nil { - return nil, fmt.Errorf("encode egress shard SID %s with argument %#x: %w", sid, argument, err) - } - out = append(out, net.IP(tenant.AsSlice())) - } - return out, nil -} - -// egressPrefixRouteAddFn is a variable so tests can observe route installs -// without a pinned egress_route_table. -var egressPrefixRouteAddFn = srv6.EgressPrefixRouteAdd - -// installNAT64EgressRoute installs vrfTableID's route for each of the fabric's -// NAT64 prefixes, all toward the same shards. An unset list means this fabric -// has no NAT64 and is not an error; a set but invalid one is a misconfiguration -// and fails the ADD, since silently skipping it would leave the VRF with no -// IPv4 reachability and nothing to say why. -func installNAT64EgressRoute(vrfTableID uint32, shardSIDs []net.IP) error { - prefixes, err := config.ParseNAT64Prefixes(cniConfig.NAT64Prefix) - if err != nil { - return fmt.Errorf("parse %s: %w", config.EnvCNINAT64Prefix, err) + return attachreg.EgressConfig{} } - for _, prefix := range prefixes { - if err := egressPrefixRouteAddFn(vrfTableID, prefix, shardSIDs); err != nil { - return err - } - } - return nil + return attachreg.EgressConfig{ShardSIDs: cniConfig.EgressShardSIDs, NAT64Prefix: cniConfig.NAT64Prefix} } -// hostInterfaceIndex resolves this attachment's host-side veth or tap -// interface's kernel ifindex by name, using the same deterministic name the -// master plugin used to create it, so no value needs threading through -// prevResult to find it again. -func hostInterfaceIndex(vpc, vpcAttachment string) (uint32, error) { - hostName := intf.GenerateInterfaceNameHost(vpc, vpcAttachment) - link, err := netlink.LinkByName(hostName) - if err != nil { - return 0, fmt.Errorf("look up host interface %q: %w", hostName, err) - } - return uint32(link.Attrs().Index), nil -} - -// registerEgressKind records which redirect helper usid_ingress uses to deliver -// into this attachment's host-side interface. It is keyed by that interface -// rather than by VPC because one VPC can hold tap and veth attachments on the -// same node. +// registerEBPFDatapath registers this attachment against the eBPF uSID +// datapath's pinned maps under pinDir through attachreg.RegisterDatapath, with +// this ADD's egress configuration. registered is false with a nil error only +// when this router has no SRv6 locator or node ID configured. // -// A map that is not pinned yet is logged, not returned. The install-cni init -// container replaces this binary before the run container reloads the datapath -// that pins the map, so an ADD can land in between. Failing it would block -// Instances from starting during every upgrade, while the datapath's fallback -// for a missing entry, a plain redirect, still delivers to both kinds. -func registerEgressKind(pinDir string, hostIfindex, egressKind uint32) error { - table, closer, err := ifindexvrfmap.OpenPinnedEgressKind(pinDir) - if errors.Is(err, os.ErrNotExist) { - slog.Warn("ADD: eBPF ifindex_egress_kind_table is not pinned yet; "+ - "delivery to this attachment uses the plain-redirect fallback until its next ADD", - "hostIfindex", hostIfindex, "err", err) - return nil - } - if err != nil { - return fmt.Errorf("open pinned eBPF ifindex_egress_kind_table: %w", err) - } - defer func() { _ = closer.Close() }() - if err := table.Register(hostIfindex, egressKind); err != nil { - return fmt.Errorf("register eBPF ifindex_egress_kind_table entry: %w", err) - } - return nil +// prefixes are this attachment's guest CIDRs and gateway host addresses, as +// localEgressPrefixes produces them. +func registerEBPFDatapath( + bgp bgpConfig, vpc, vpcAttachment, ifaceType string, argument uint16, pinDir string, prefixes []string, +) (registered bool, err error) { + return attachreg.RegisterDatapath(pinDir, attachreg.Registration{ + Node: attachreg.Node{Locator: bgp.srv6Locator, NodeID: bgp.nodeID}, + VPC: vpc, + VPCAttachment: vpcAttachment, + InterfaceType: ifaceType, + Argument: argument, + LocalPrefixes: prefixes, + Egress: egressConfig(), + }) } // registerTenantGateway records this attachment's IPAM gateways in -// tenant_gw_table, the addresses usid_egress sends an ICMP Packet Too Big or -// Fragmentation Needed from when the tenant sends a packet too big for the -// fabric. An attachment with no IPAM result, such as a tap workload managing -// its own addressing, has its entry removed instead, so a reused ifindex never -// keeps another attachment's gateway. -// -// Every failure is logged, not returned. Without an entry the datapath drops -// an oversized packet with no error and counts it, which is how it behaved -// before this map existed, and not worth failing the attach over. That covers -// the window where this binary is newer than the datapath that pins the map, -// as for registerEgressKind. +// tenant_gw_table through attachreg.RegisterTenantGateway. A nil ipamResult +// removes the entry instead. Every failure is logged, not returned. func registerTenantGateway(pinDir, vpc, vpcAttachment string, ipamResult *cniipam.IPAMResult) { - hostIfindex, err := hostInterfaceIndex(vpc, vpcAttachment) - if err != nil { - slog.Warn("ADD: could not resolve host interface for eBPF tenant_gw_table; "+ - "packets too big for the fabric get no ICMP error until the next ADD", "err", err) - return - } - table, closer, err := ifindexvrfmap.OpenPinnedGateway(pinDir) - if err != nil { - slog.Warn("ADD: could not open eBPF tenant_gw_table; "+ - "packets too big for the fabric get no ICMP error until the next ADD", - "hostIfindex", hostIfindex, "err", err) - return - } - defer func() { _ = closer.Close() }() - - if ipamResult == nil { - if err := table.Unregister(hostIfindex); err != nil { - slog.Warn("ADD: could not clear eBPF tenant_gw_table entry", "hostIfindex", hostIfindex, "err", err) - } - return - } - gw6, _ := netip.AddrFromSlice(ipamResult.IPv6Gateway) - gw4, _ := netip.AddrFromSlice(ipamResult.IPv4Gateway) - if err := table.Register(hostIfindex, gw6, gw4.Unmap()); err != nil { - slog.Warn("ADD: could not register eBPF tenant_gw_table entry; "+ - "packets too big for the fabric get no ICMP error until the next ADD", - "hostIfindex", hostIfindex, "err", err) - } -} - -// egressKindForInterfaceType maps a "veth" or "tap" interface type to the -// egress kind the datapath uses to choose between bpf_redirect_peer, which -// crosses into the container's netns, and plain bpf_redirect, which does not. -func egressKindForInterfaceType(ifaceType string) (uint32, error) { - switch ifaceType { - case ifaceTypeVeth: - return usidmap.EgressKindVeth, nil - case ifaceTypeTap: - return usidmap.EgressKindTap, nil - default: - return 0, fmt.Errorf("unknown interface type %q", ifaceType) + var gw *attachreg.Gateways + if ipamResult != nil { + gw = &attachreg.Gateways{IPv6: ipamResult.IPv6Gateway, IPv4: ipamResult.IPv4Gateway} } + attachreg.RegisterTenantGateway(pinDir, vpc, vpcAttachment, gw) } diff --git a/internal/cnibgp/bgp_ebpf_test.go b/internal/cnibgp/bgp_ebpf_test.go index f994952c..dd6e87e4 100644 --- a/internal/cnibgp/bgp_ebpf_test.go +++ b/internal/cnibgp/bgp_ebpf_test.go @@ -18,6 +18,7 @@ import ( "go.datum.net/galactic/internal/cni/veth" "go.datum.net/galactic/internal/cniipam" "go.datum.net/galactic/internal/plumbing/ebpf/attach" + "go.datum.net/galactic/internal/plumbing/ebpf/attachreg" "go.datum.net/galactic/internal/plumbing/ebpf/egressroutemap" "go.datum.net/galactic/internal/plumbing/ebpf/ifindexvrfmap" "go.datum.net/galactic/internal/plumbing/ebpf/uformat" @@ -411,9 +412,9 @@ func TestRegisterEBPFDatapath_MixedInterfaceTypesKeepOwnEgressKind(t *testing.T) t.Fatalf("OpenPinnedEgressKind: %v", err) } defer func() { _ = closer.Close() }() - tapIfindex, err := hostInterfaceIndex(vpc, tapAttachment) + tapIfindex, err := attachreg.HostInterfaceIndex(vpc, tapAttachment) if err != nil { - t.Fatalf("hostInterfaceIndex(tap): %v", err) + t.Fatalf("attachreg.HostInterfaceIndex(tap): %v", err) } if err := kinds.Unregister(tapIfindex); err != nil { t.Fatalf("Unregister(tap): %v", err) @@ -428,17 +429,6 @@ func TestRegisterEBPFDatapath_MixedInterfaceTypesKeepOwnEgressKind(t *testing.T) } } -// TestRegisterEgressKind_UnpinnedMapIsNotFatal covers an ADD that runs after -// install-cni replaced this binary but before the datapath reloaded and pinned -// the map. Failing it would block Instances from starting during an upgrade. -func TestRegisterEgressKind_UnpinnedMapIsNotFatal(t *testing.T) { - requireRoot(t) - pinDir := fmt.Sprintf("/sys/fs/bpf/galactic-bgp-test-unpinned-%d", os.Getpid()) - if err := registerEgressKind(pinDir, 42, usidmap.EgressKindTap); err != nil { - t.Errorf("registerEgressKind with no pinned map = %v, want nil", err) - } -} - // TestRegisterTenantGateway_WritesAndClearsEntry checks an attachment's IPAM // gateways reach tenant_gw_table under its host interface's ifindex, and that // an ADD with no IPAM result clears the entry rather than leaving a previous diff --git a/internal/cnibgp/bgp_test.go b/internal/cnibgp/bgp_test.go index 673bdb05..cc14ccbb 100644 --- a/internal/cnibgp/bgp_test.go +++ b/internal/cnibgp/bgp_test.go @@ -10,7 +10,6 @@ import ( "fmt" "maps" "net" - "net/netip" "os" "reflect" "strings" @@ -32,8 +31,8 @@ import ( "go.datum.net/galactic/internal/config" "go.datum.net/galactic/internal/crdnames" "go.datum.net/galactic/internal/plumbing/ebpf/attach" + "go.datum.net/galactic/internal/plumbing/ebpf/attachreg" "go.datum.net/galactic/internal/plumbing/ebpf/uformat" - "go.datum.net/galactic/internal/plumbing/ebpf/usidmap" "go.datum.net/galactic/internal/plumbing/srv6" "go.datum.net/galactic/internal/plumbing/vrf" bgpv1alpha1 "go.datum.net/network/api/v1alpha1" @@ -230,40 +229,6 @@ func TestCheckArgumentCollision(t *testing.T) { }) } -// ---- egressKindForInterfaceType -------------------------------------------- - -func TestEgressKindForInterfaceType(t *testing.T) { - tests := []struct { - name string - iface string - want uint32 - wantErr bool - }{ - {name: "veth maps to EgressKindVeth", iface: ifaceTypeVeth, want: usidmap.EgressKindVeth}, - {name: "tap maps to EgressKindTap", iface: ifaceTypeTap, want: usidmap.EgressKindTap}, - {name: "empty type errors", iface: "", wantErr: true}, - {name: "unknown type errors", iface: "bogus", wantErr: true}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := egressKindForInterfaceType(tt.iface) - if tt.wantErr { - if err == nil { - t.Fatalf("egressKindForInterfaceType(%q) error = nil, want error", tt.iface) - } - return - } - if err != nil { - t.Fatalf("egressKindForInterfaceType(%q) unexpected error: %v", tt.iface, err) - } - if got != tt.want { - t.Errorf("egressKindForInterfaceType(%q) = %d, want %d", tt.iface, got, tt.want) - } - }) - } -} - // ---- buildVRFInstanceSpec --------------------------------------------------- func TestBuildVRFInstanceSpec(t *testing.T) { @@ -1150,186 +1115,21 @@ func TestRetryK8sOpsExhaustsDeadline(t *testing.T) { } } -// TestInstallNAT66EgressRoute_NilCNIConfigIsANoop is the regression test -// for a real panic found live: several tests in this package (and, before -// this guard, installEgressRoutes itself) call registerEBPFDatapath -// directly without ever calling InitCNIConfig first, leaving the -// package-level cniConfig nil -- exactly the state ops_del_test.go's own -// helper already works around for a different call path. Production never -// hits this (cmd/galactic-bgp's main.go always calls InitCNIConfig before -// any cmdAdd can run), but a nil cniConfig here must be treated as "no -// shard configured yet," not a nil pointer dereference. -func TestInstallNAT66EgressRoute_NilCNIConfigIsANoop(t *testing.T) { +// TestEgressConfig_NilCNIConfigIsNoShard covers unit tests that call +// registerEBPFDatapath without InitCNIConfig, leaving cniConfig nil: that must +// read as no shard configured rather than dereference nil. +func TestEgressConfig_NilCNIConfigIsNoShard(t *testing.T) { original := cniConfig cniConfig = nil - defer func() { cniConfig = original }() - - if err := installEgressRoutes(1, 0x005); err != nil { - t.Errorf("installEgressRoutes(1, 0x005) = %v, want nil with cniConfig == nil", err) - } -} - -// TestShardSIDsForTenant_WritesTheArgument is the regression test for #538: -// every tenant VRF on a node encapsulated toward a byte-identical shard SID, so -// a shard read one constant Argument for all of them and two same-node tenants -// with overlapping ULAs shared a connection row. -func TestShardSIDsForTenant_WritesTheArgument(t *testing.T) { - sids, err := config.ParseEgressShardSIDs("2001:db8:ff01:9:e001::,2001:db8:ff02:9:e001::") - if err != nil { - t.Fatalf("config.ParseEgressShardSIDs() error = %v, want nil", err) - } - - got, err := shardSIDsForTenant(sids, 0x2a5) - if err != nil { - t.Fatalf("shardSIDsForTenant() error = %v, want nil", err) - } - - want := []string{"2001:db8:ff01:9:e2a5::", "2001:db8:ff02:9:e2a5::"} - if len(got) != len(want) { - t.Fatalf("shardSIDsForTenant() = %v, want %v", got, want) - } - for i, w := range want { - if got[i].String() != w { - t.Errorf("shardSIDsForTenant()[%d] = %v, want %s", i, got[i], w) - } - } -} - -// TestShardSIDsForTenant_DistinctPerTenant states the property #538 is about -// directly: two VRFIDs on one node must not produce the same destination. -func TestShardSIDsForTenant_DistinctPerTenant(t *testing.T) { - sids, err := config.ParseEgressShardSIDs("2001:db8:ff01:9:e001::") - if err != nil { - t.Fatalf("config.ParseEgressShardSIDs() error = %v, want nil", err) - } - - a, err := shardSIDsForTenant(sids, 0x001) - if err != nil { - t.Fatalf("shardSIDsForTenant(0x001) error = %v, want nil", err) - } - b, err := shardSIDsForTenant(sids, 0x002) - if err != nil { - t.Fatalf("shardSIDsForTenant(0x002) error = %v, want nil", err) - } - if a[0].Equal(b[0]) { - t.Errorf("VRFIDs 0x001 and 0x002 both encapsulate toward %v; they must differ", a[0]) - } -} - -// TestShardSIDsForTenant_OverwritesAConfiguredArgument covers the operator- -// supplied Argument every deployment bakes into its shard SID today. It -// identifies no tenant -- one configured value is shared by every VRF on every -// node -- so it is replaced, not honoured or treated as a conflict. -func TestShardSIDsForTenant_OverwritesAConfiguredArgument(t *testing.T) { - sids, err := config.ParseEgressShardSIDs("2001:db8:ff01:9:efff::") - if err != nil { - t.Fatalf("config.ParseEgressShardSIDs() error = %v, want nil", err) - } - - got, err := shardSIDsForTenant(sids, 0x007) - if err != nil { - t.Fatalf("shardSIDsForTenant() error = %v, want nil", err) - } - if want := "2001:db8:ff01:9:e007::"; got[0].String() != want { - t.Errorf("shardSIDsForTenant() = %v, want %s", got[0], want) - } -} - -// TestShardSIDsForTenant_PreservesBlockNodeIDAndFunction guards the fields the -// Argument rewrite must leave alone: get any of them wrong and the packet is -// addressed to a different shard, or to no shard at all. -func TestShardSIDsForTenant_PreservesBlockNodeIDAndFunction(t *testing.T) { - sids, err := config.ParseEgressShardSIDs("2001:db8:ff01:9:e001::") - if err != nil { - t.Fatalf("config.ParseEgressShardSIDs() error = %v, want nil", err) - } - - got, err := shardSIDsForTenant(sids, 0x123) - if err != nil { - t.Fatalf("shardSIDsForTenant() error = %v, want nil", err) - } - addr, ok := netip.AddrFromSlice(got[0].To16()) - if !ok { - t.Fatalf("shardSIDsForTenant() returned %v, which is not a 16-byte address", got[0]) - } - fields, err := uformat.Decode(addr) - if err != nil { - t.Fatalf("uformat.Decode(%v) error = %v, want nil", addr, err) - } - if fields.Block != 0x2001_0db8_ff01 { - t.Errorf("Block = %#x, want %#x", fields.Block, 0x2001_0db8_ff01) - } - if fields.NodeID != 9 { - t.Errorf("NodeID = %d, want 9", fields.NodeID) - } - if fields.Function != uformat.FunctionEndDT46 { - t.Errorf("Function = %#x, want %#x", fields.Function, uformat.FunctionEndDT46) - } - if fields.Argument != 0x123 { - t.Errorf("Argument = %#x, want %#x", fields.Argument, 0x123) - } -} + t.Cleanup(func() { cniConfig = original }) -// TestShardSIDsForTenant_RejectsAMalformedSID covers the entries config.ParseEgressShardSIDs -// accepts as valid IP addresses but that are not uSIDs. Writing an Argument -// into one produces a plausible-looking destination that addresses nothing, so -// it fails the ADD instead of being passed through unchanged. -func TestShardSIDsForTenant_RejectsAMalformedSID(t *testing.T) { - for _, raw := range []string{ - "2001:db8:ff01:9:e001::1", // non-zero padding: not a uFMT 48+16 address - "192.0.2.1", // IPv4: not an SRv6 SID at all - } { - sids, err := config.ParseEgressShardSIDs(raw) - if err != nil { - t.Fatalf("config.ParseEgressShardSIDs(%q) error = %v, want nil", raw, err) - } - if _, err := shardSIDsForTenant(sids, 0x005); err == nil { - t.Errorf("shardSIDsForTenant(%q) error = nil, want an error", raw) - } + if got := egressConfig(); got != (attachreg.EgressConfig{}) { + t.Errorf("egressConfig() = %+v with cniConfig == nil, want the zero value", got) } -} - -func TestInstallNAT64EgressRoute_OneRoutePerPrefixToSameShards(t *testing.T) { - originalConfig, originalAdd := cniConfig, egressPrefixRouteAddFn - t.Cleanup(func() { cniConfig, egressPrefixRouteAddFn = originalConfig, originalAdd }) - type call struct { - table uint32 - prefix string - sids []net.IP - } - var calls []call - egressPrefixRouteAddFn = func(table uint32, prefix *net.IPNet, sids []net.IP) error { - calls = append(calls, call{table, prefix.String(), sids}) - return nil - } - sids := []net.IP{net.ParseIP("2001:db8:ff01:9:e2a5::")} - - cniConfig = &config.CNIConfig{NAT64Prefix: "2001:db8:64::/96, 64:ff9b::/96"} - if err := installNAT64EgressRoute(7, sids); err != nil { - t.Fatalf("installNAT64EgressRoute() = %v, want nil", err) - } - want := []string{"2001:db8:64::/96", "64:ff9b::/96"} - if len(calls) != len(want) { - t.Fatalf("installed %d routes, want %d: %+v", len(calls), len(want), calls) - } - for i, w := range want { - if calls[i].table != 7 || calls[i].prefix != w || !reflect.DeepEqual(calls[i].sids, sids) { - t.Errorf("route %d = %+v, want table 7, prefix %s, SIDs %v", i, calls[i], w, sids) - } - } - - calls = nil - cniConfig = &config.CNIConfig{} - if err := installNAT64EgressRoute(7, sids); err != nil || len(calls) != 0 { - t.Errorf("unset list: err = %v, routes = %+v, want nil and none", err, calls) - } - - cniConfig = &config.CNIConfig{NAT64Prefix: "64:ff9b::/96,64:ff9b::/64"} - if err := installNAT64EgressRoute(7, sids); err == nil { - t.Error("installNAT64EgressRoute() = nil with a non-/96 entry, want an error") - } - if len(calls) != 0 { - t.Errorf("invalid list installed %+v, want nothing", calls) + cniConfig = &config.CNIConfig{EgressShardSIDs: "2001:db8:ff01:9:e001::", NAT64Prefix: "64:ff9b::/96"} + want := attachreg.EgressConfig{ShardSIDs: "2001:db8:ff01:9:e001::", NAT64Prefix: "64:ff9b::/96"} + if got := egressConfig(); got != want { + t.Errorf("egressConfig() = %+v, want %+v", got, want) } } diff --git a/internal/cnibgp/ops_check.go b/internal/cnibgp/ops_check.go index a17c207c..a533de77 100644 --- a/internal/cnibgp/ops_check.go +++ b/internal/cnibgp/ops_check.go @@ -25,6 +25,7 @@ import ( "go.datum.net/galactic/internal/config" "go.datum.net/galactic/internal/crdnames" "go.datum.net/galactic/internal/nadpatch" + "go.datum.net/galactic/internal/plumbing/ebpf/attachreg" "go.datum.net/galactic/internal/plumbing/ebpf/ifindexvrfmap" "go.datum.net/galactic/internal/plumbing/ebpf/uformat" "go.datum.net/galactic/internal/plumbing/ebpf/usidmap" @@ -209,11 +210,11 @@ func checkEBPFEntry(pluginConf *PluginConf, argument uint16, bgp bgpConfig, ifac // an error here: once the datapath has reloaded, a still-missing entry means // this attachment has lost its fast delivery path. func checkEgressKind(pinDir, vpc, vpcAttachment, ifaceType string) error { - want, err := egressKindForInterfaceType(ifaceType) + want, err := attachreg.EgressKindForInterfaceType(ifaceType) if err != nil { return fmt.Errorf("determine eBPF egress kind: %w", err) } - hostIfindex, err := hostInterfaceIndex(vpc, vpcAttachment) + hostIfindex, err := attachreg.HostInterfaceIndex(vpc, vpcAttachment) if err != nil { return fmt.Errorf("resolve host interface ifindex for eBPF check: %w", err) } diff --git a/internal/config/cni.go b/internal/config/cni.go index acd81a9a..dcf21279 100644 --- a/internal/config/cni.go +++ b/internal/config/cni.go @@ -61,7 +61,7 @@ const ( // it carries is a placeholder. Every attachment's ADD rewrites that // Argument to its own VRFID before installing the route, which is what // lets a shard tell two tenants on one node apart -- see - // internal/cnibgp's shardSIDsForTenant. An entry that is not a well-formed + // attachreg.ShardSIDsForTenant. An entry that is not a well-formed // uFMT 48+16 address fails the ADD there rather than being passed through. // // The installer resolves it once at startup, from its real pod environment diff --git a/internal/controller/egressshard_controller.go b/internal/controller/egressshard_controller.go index 2e7449e7..6c5dcc49 100644 --- a/internal/controller/egressshard_controller.go +++ b/internal/controller/egressshard_controller.go @@ -473,7 +473,7 @@ func shardAdvertisementName(shardName string) string { // makes the control plane agree with that. // // Whatever Argument the operator baked into the configured SID is therefore not -// advertised and carries no meaning; installEgressRoutes overwrites it per +// advertised and carries no meaning; attachreg overwrites it per // tenant. Reserving the Block and Node-ID for this shard alone is what the /64 // requires, which was already true -- locator_matches' doc comment spells out // what reusing a co-located BGPRouter's Node-ID silently breaks, and program diff --git a/internal/gc/datapath_repair.go b/internal/gc/datapath_repair.go new file mode 100644 index 00000000..696592cd --- /dev/null +++ b/internal/gc/datapath_repair.go @@ -0,0 +1,536 @@ +// Copyright 2026 Datum Cloud, Inc. +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package gc + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net" + "os" + "regexp" + "sort" + "strings" + + "github.com/vishvananda/netlink" + "golang.org/x/sys/unix" + "sigs.k8s.io/controller-runtime/pkg/client" + + "go.datum.net/galactic/internal/plumbing/ebpf/attachreg" + "go.datum.net/galactic/internal/plumbing/ebpf/egressroutemap" + "go.datum.net/galactic/internal/plumbing/ebpf/uformat" + "go.datum.net/galactic/internal/plumbing/srv6" + bgpv1alpha1 "go.datum.net/network/api/v1alpha1" +) + +// hostLinkNameRegex matches the host-side interface name the master plugins +// create for an attachment, "G%09s%03sH": the zero-padded base62 VPC and +// VPCAttachment. +var hostLinkNameRegex = regexp.MustCompile(`^G([A-Za-z0-9]{9})([A-Za-z0-9]{3})H$`) + +// The kernel and resolution calls RepairAttachmentDatapath makes, indirected so +// tests can substitute fabricated state and in-memory maps. +var ( + openDatapathMapsFn = attachreg.OpenPinnedMaps + listRoutesFn = func(tableID uint32, family int) ([]netlink.Route, error) { + return netlink.RouteListFiltered(family, &netlink.Route{Table: int(tableID)}, netlink.RT_FILTER_TABLE) + } + listAddrsFn = netlink.AddrList + linkByIndexFn = netlink.LinkByIndex + resolvePublicUplinkFn attachreg.UplinkResolver = srv6.ResolvePublicUplink +) + +// DatapathRepairConfig is RepairAttachmentDatapath's input beyond the +// Kubernetes client. +type DatapathRepairConfig struct { + Namespace string + NodeName string + // PinDir is the bpffs directory holding the uSID datapath's pinned maps. + PinDir string + // Egress is the conflist's egress configuration, the same CNI ADD uses + // for a VRF's shard routes. + Egress attachreg.EgressConfig + // ForeignTableID reports a VRF routing table another writer on this node + // owns. An attachment enslaved to one is left alone. Nil reports none. + ForeignTableID func(tableID uint32) bool +} + +// DatapathRepairResult reports one RepairAttachmentDatapath pass. +type DatapathRepairResult struct { + // Rebuilt counts the rows written, per map. + Rebuilt attachreg.Counts + // Attachments is how many attachments the pass matched to a + // BGPVRFInstance and examined. + Attachments int + // Skipped is how many host-named interfaces the pass left alone: not + // enslaved to a VRF, in a table another writer owns, of an unknown link + // type, or whose BGPVRFInstance has an out-of-range VRFID. + Skipped int + // Pending is how many attachment interfaces the pass could not repair yet + // because this node's BGPRouter or the VPC's BGPVRFInstance is not found. + // Either may still be on its way, so a caller wanting a prompt repair + // retries while Pending is non-zero. + Pending int + // UplinkErr reports a missing public_uplink_table row that could not be + // rebuilt because the uplink's neighbor has not resolved. It is not a + // failure of the pass. + UplinkErr error +} + +// discoveredAttachment is one attachment found in the kernel, with the name and +// VRF master its rows were derived from, so the pass can tell whether the +// interface was replaced while they were written. +type discoveredAttachment struct { + attachreg.Attachment + name string + masterIndex int +} + +// RepairAttachmentDatapath rebuilds the uSID datapath rows CNI ADD writes for +// every attachment on nodeName, and this node's own, that are missing from the +// maps pinned under cfg.PinDir. CNI ADD is the only other writer of those +// rows, so a map recreated empty stays empty until each attachment re-attaches +// without this. +// +// Attachments are found in the kernel: every interface named like a host-side +// attachment interface and enslaved to a VRF. The VRF's table supplies the +// routing table ID, the link type the egress kind, the routes in that table +// out the interface and the interface's own non-link-local addresses the local +// prefixes and gateways. The Block and Node-ID come from nodeName's BGPRouter, +// and the Argument from the VPC's BGPVRFInstance on that router. +// +// Only a missing row is written. A row that exists is left alone, whoever +// wrote it, and nothing is ever written under the ingress sidecar's Block. +// cfg.ForeignTableID is what keeps the pass off the sidecar return path's +// tables: in the case this repairs, vrf_table may itself be empty, so the +// sidecar's own vrf_table rows, which also mark its tables, may be gone too. +// +// Attachments are repaired in ascending host ifindex order, so the shared +// per-VRF rows FillVRF writes come from the lowest-numbered attachment. +// +// A pin directory that does not exist, or a router with no SRv6 identity, is +// nothing to repair and returns a zero result. A node with attachment +// interfaces but no BGPRouter yet reports them in Pending. Every attachment is +// attempted until ctx ends; the error joins every failure. +func RepairAttachmentDatapath( + ctx context.Context, k8s client.Client, cfg DatapathRepairConfig, +) (DatapathRepairResult, error) { + var result DatapathRepairResult + + if _, err := os.Stat(cfg.PinDir); err != nil { + return result, nil + } + + node, routerName, found, err := nodeSRv6Identity(ctx, k8s, cfg.Namespace, cfg.NodeName) + if err != nil { + return result, err + } + if !found { + result.Pending, err = countHostAttachments() + return result, err + } + if !node.Configured() { + return result, nil + } + block, err := node.Block() + if err != nil { + return result, fmt.Errorf("derive this node's uSID Block: %w", err) + } + if block == uformat.BlockIngressSidecar { + return result, fmt.Errorf("BGPRouter %s locator %s maps to the ingress sidecar's reserved Block", + routerName, node.Locator) + } + + vrfIDs, err := vrfIDsForRouter(ctx, k8s, cfg.Namespace, routerName) + if err != nil { + return result, err + } + + maps, closer, err := openDatapathMapsFn(cfg.PinDir) + if err != nil { + return result, fmt.Errorf("open pinned uSID datapath maps: %w", err) + } + defer func() { _ = closer.Close() }() + + sidecarTables, err := egressroutemap.SidecarOwnedTableIDs(maps.Registry.VRF) + if err != nil { + return result, err + } + foreign := func(tableID uint32) bool { + if _, ok := sidecarTables[tableID]; ok { + return true + } + return cfg.ForeignTableID != nil && cfg.ForeignTableID(tableID) + } + + attachments, counts, err := discoverAttachments(cfg.NodeName, block, vrfIDs, foreign) + result.Skipped, result.Pending = counts.skipped, counts.pending + if err != nil { + return result, err + } + result.Attachments = len(attachments) + if len(attachments) == 0 { + return result, nil + } + + var errs []error + nodeRows, uplinkErr, err := maps.FillNode(node, resolvePublicUplinkFn) + result.Rebuilt.Add(nodeRows) + result.UplinkErr = uplinkErr + if err != nil { + errs = append(errs, fmt.Errorf("this node's rows: %w", err)) + } + + for i, a := range attachments { + if err := ctx.Err(); err != nil { + errs = append(errs, fmt.Errorf("stopped before %d of %d attachments: %w", len(attachments)-i, + len(attachments), err)) + break + } + rows, err := repairAttachment(maps, a, attachments, cfg.Egress) + result.Rebuilt.Add(rows) + if err != nil { + errs = append(errs, fmt.Errorf("attachment %s: %w", a.name, err)) + } + } + return result, errors.Join(errs...) +} + +// repairAttachment fills a's missing VRF and interface rows. If a's interface +// was deleted or re-enslaved while they were written, as a concurrent CNI DEL +// does, the rows are removed again rather than left for an interface that is +// gone, except those another of all still needs. +func repairAttachment( + maps *attachreg.Maps, a discoveredAttachment, all []discoveredAttachment, egress attachreg.EgressConfig, +) (attachreg.Counts, error) { + var errs []error + vrfRows, err := maps.FillVRF(a.Attachment, egress) + if err != nil { + errs = append(errs, err) + } + + ifaceRows, err := maps.FillInterface(a.Attachment) + if err != nil { + errs = append(errs, err) + } + if (ifaceRows.Total() > 0 || vrfRows.Total() > 0) && !stillAttached(a) { + slog.Debug("Attachment interface went away during datapath repair; removing the rows just written", + "hostInterface", a.name, "ifindex", a.HostIfindex) + if err := maps.RemoveInterfaceRows(a.HostIfindex, ifaceRows); err != nil { + errs = append(errs, fmt.Errorf("remove rows for an interface that went away: %w", err)) + } + keepPrefixes, keepVRF := neededByOthers(a, all) + if err := maps.RemoveVRFRows(a.Attachment, vrfRows, keepPrefixes, keepVRF); err != nil { + errs = append(errs, fmt.Errorf("remove VRF rows for an interface that went away: %w", err)) + } + return attachreg.Counts{ShardEgressRoutes: vrfRows.ShardEgressRoutes}, errors.Join(errs...) + } + rows := vrfRows.Counts + rows.Add(ifaceRows) + return rows, errors.Join(errs...) +} + +// neededByOthers returns the local prefixes another attachment in all needs in +// a's VRF table, and whether another attachment shares a's (Block, Argument). +func neededByOthers(a discoveredAttachment, all []discoveredAttachment) (map[string]struct{}, bool) { + prefixes := map[string]struct{}{} + sharesKey := false + for _, o := range all { + if o.HostIfindex == a.HostIfindex { + continue + } + if o.Block == a.Block && o.Argument == a.Argument { + sharesKey = true + } + if o.VRFTableID != a.VRFTableID { + continue + } + for _, p := range o.LocalPrefixes { + if _, n, err := net.ParseCIDR(p); err == nil { + prefixes[n.String()] = struct{}{} + } + } + } + return prefixes, sharesKey +} + +// stillAttached reports whether a's interface still exists under the same +// ifindex, name, and VRF master it was discovered with. +func stillAttached(a discoveredAttachment) bool { + link, err := linkByIndexFn(int(a.HostIfindex)) + if err != nil { + return false + } + return link.Attrs().Name == a.name && link.Attrs().MasterIndex == a.masterIndex +} + +// nodeSRv6Identity returns the SRv6 identity of nodeName's BGPRouter and the +// router's name. found is false when no router targets the node. A router +// with no SRv6 locator or node ID is found with an unconfigured Node, for +// which CNI ADD registers nothing either. More than one router targeting the +// node is an error, as it is for CNI ADD. +func nodeSRv6Identity( + ctx context.Context, k8s client.Client, namespace, nodeName string, +) (node attachreg.Node, routerName string, found bool, err error) { + routers, err := routersForNode(ctx, k8s, namespace, nodeName) + if err != nil { + return attachreg.Node{}, "", false, err + } + switch len(routers) { + case 0: + return attachreg.Node{}, "", false, nil + case 1: + default: + return attachreg.Node{}, "", false, fmt.Errorf("ambiguous BGP config: %d BGPRouters target node %s in namespace %s", + len(routers), nodeName, namespace) + } + for name, r := range routers { + node = attachreg.Node{Locator: r.Spec.SRv6Locator, NodeID: r.Spec.NodeID} + routerName = name + } + return node, routerName, true, nil +} + +// countHostAttachments returns how many interfaces are named like an +// attachment's host side and enslaved to a VRF. +func countHostAttachments() (int, error) { + links, err := listAllLinksFn() + if err != nil { + return 0, fmt.Errorf("list kernel interfaces: %w", err) + } + vrfs := map[int]bool{} + for _, l := range links { + if _, ok := l.(*netlink.Vrf); ok { + vrfs[l.Attrs().Index] = true + } + } + n := 0 + for _, l := range links { + if hostLinkNameRegex.MatchString(l.Attrs().Name) && vrfs[l.Attrs().MasterIndex] { + n++ + } + } + return n, nil +} + +// vrfIDsForRouter returns the Spec.VRFID of every BGPVRFInstance in namespace +// whose RouterRef names routerName, keyed by instance name. +func vrfIDsForRouter( + ctx context.Context, k8s client.Client, namespace, routerName string, +) (map[string]int32, error) { + list := &bgpv1alpha1.BGPVRFInstanceList{} + if err := k8s.List(ctx, list, client.InNamespace(namespace)); err != nil { + return nil, fmt.Errorf("list BGPVRFInstances: %w", err) + } + ids := make(map[string]int32, len(list.Items)) + for _, inst := range list.Items { + if inst.Spec.RouterRef == nil || inst.Spec.RouterRef.Name != routerName { + continue + } + ids[inst.Name] = inst.Spec.VRFID + } + return ids, nil +} + +// unpad reverses the zero padding an interface name applies to a base62 +// identifier, which CRD names do not carry. +func unpad(s string) string { + if t := strings.TrimLeft(s, "0"); t != "" { + return t + } + return "0" +} + +// discoveryCounts is how many host-named interfaces discoverAttachments did +// not resolve into an attachment, by reason. +type discoveryCounts struct { + skipped int + pending int +} + +// discoverAttachments lists every interface named like an attachment's host +// side and enslaved to a VRF, and resolves each into the Attachment its rows +// are built from, in ascending host ifindex order. An interface not enslaved +// to a VRF, whose VRF table foreign claims, whose link type is neither veth +// nor tap, or whose BGPVRFInstance's VRFID is out of range is counted as +// skipped; one whose VPC has no BGPVRFInstance on this node yet as pending. +func discoverAttachments( + nodeName string, block uint64, vrfIDs map[string]int32, foreign func(uint32) bool, +) (attachments []discoveredAttachment, counts discoveryCounts, err error) { + links, err := listAllLinksFn() + if err != nil { + return nil, counts, fmt.Errorf("list kernel interfaces: %w", err) + } + byIndex := make(map[int]netlink.Link, len(links)) + for _, l := range links { + byIndex[l.Attrs().Index] = l + } + + routes := newRouteCache() + var errs []error + for _, link := range links { + attrs := link.Attrs() + m := hostLinkNameRegex.FindStringSubmatch(attrs.Name) + if m == nil { + continue + } + master, ok := byIndex[attrs.MasterIndex].(*netlink.Vrf) + if !ok { + counts.skipped++ + continue + } + if foreign(master.Table) { + counts.skipped++ + continue + } + ifaceType, ok := attachreg.InterfaceTypeForLink(link) + if !ok { + slog.Debug("Datapath repair: skipping attachment interface of unknown type", + "hostInterface", attrs.Name, "type", link.Type()) + counts.skipped++ + continue + } + vpc, vpcAttachment := unpad(m[1]), unpad(m[2]) + argument, found, inRange := argumentForVPC(vrfIDs, vpc, nodeName) + if !found { + slog.Debug("Datapath repair: no BGPVRFInstance yet for attachment interface", + "hostInterface", attrs.Name, "vpc", vpc) + counts.pending++ + continue + } + if !inRange { + counts.skipped++ + continue + } + + prefixes, gateways, err := localAddressing(link, master.Table, routes) + if err != nil { + errs = append(errs, fmt.Errorf("read addressing of %s: %w", attrs.Name, err)) + continue + } + attachments = append(attachments, discoveredAttachment{ + Attachment: attachreg.Attachment{ + VPC: vpc, + VPCAttachment: vpcAttachment, + HostIfindex: uint32(attrs.Index), + Block: block, + Argument: argument, + VRFTableID: master.Table, + InterfaceType: ifaceType, + LocalPrefixes: prefixes, + Gateways: gateways, + }, + name: attrs.Name, + masterIndex: attrs.MasterIndex, + }) + } + sort.Slice(attachments, func(i, j int) bool { return attachments[i].HostIfindex < attachments[j].HostIfindex }) + return attachments, counts, errors.Join(errs...) +} + +// argumentForVPC returns the Argument of vpc's BGPVRFInstance on nodeName, +// accepting both the current and legacy CRD name shapes. found is false when +// no instance exists, and inRange false when its VRFID is outside the +// Argument range. +func argumentForVPC(vrfIDs map[string]int32, vpc, nodeName string) (argument uint16, found, inRange bool) { + for _, key := range vpcKeys(vpc) { + id, ok := vrfIDs[key+"-"+nodeName] + if !ok { + continue + } + if id < int32(uformat.ArgumentMin) || id > int32(uformat.ArgumentMax) { + slog.Warn("Datapath repair: skipping BGPVRFInstance with out-of-range VRFID", + "vrfInstance", key+"-"+nodeName, "vrfID", id) + return 0, true, false + } + return uint16(id), true, true + } + return 0, false, false +} + +// routeCache lists each VRF table's routes once per pass, since every +// attachment in a VRF reads the same table. +type routeCache map[uint32][]netlink.Route + +func newRouteCache() routeCache { return routeCache{} } + +// get returns tableID's IPv6 and IPv4 routes. +func (c routeCache) get(tableID uint32) ([]netlink.Route, error) { + if routes, ok := c[tableID]; ok { + return routes, nil + } + var routes []netlink.Route + for _, family := range []int{netlink.FAMILY_V6, netlink.FAMILY_V4} { + rs, err := listRoutesFn(tableID, family) + if err != nil { + return nil, fmt.Errorf("list routes in VRF table %d: %w", tableID, err) + } + routes = append(routes, rs...) + } + c[tableID] = routes + return routes, nil +} + +// localAddressing recovers what CNI ADD registered as link's local prefixes and +// gateways from the kernel. The guest prefixes are the pod-subnet routes the +// master plugin installs in the VRF's table out link: unicast, not +// kernel-generated, with no gateway and not link-scoped. The gateways are +// link's own addresses that are not link-local, each also a host prefix. A link +// with no such address, a tap with no IPAM, has nil gateways. Where a family +// carries more than one address, the first the kernel lists is the gateway. +func localAddressing(link netlink.Link, tableID uint32, routes routeCache) ([]string, *attachreg.Gateways, error) { + tableRoutes, err := routes.get(tableID) + if err != nil { + return nil, nil, err + } + var prefixes []string + for i := range tableRoutes { + if isPodSubnetRoute(&tableRoutes[i], link.Attrs().Index) { + prefixes = append(prefixes, tableRoutes[i].Dst.String()) + } + } + + var gw attachreg.Gateways + for _, family := range []int{netlink.FAMILY_V6, netlink.FAMILY_V4} { + addrs, err := listAddrsFn(link, family) + if err != nil { + return nil, nil, fmt.Errorf("list addresses: %w", err) + } + for _, a := range addrs { + if a.IPNet == nil || a.IP.IsLinkLocalUnicast() { + continue + } + prefixes = append(prefixes, attachreg.HostPrefix(a.IP)) + setGateway(&gw, a.IP) + } + } + if gw.IPv6 == nil && gw.IPv4 == nil { + return prefixes, nil, nil + } + return prefixes, &gw, nil +} + +// setGateway records ip as gw's gateway for its family unless one is already +// set. +func setGateway(gw *attachreg.Gateways, ip net.IP) { + if v4 := ip.To4(); v4 != nil { + if gw.IPv4 == nil { + gw.IPv4 = v4 + } + return + } + if gw.IPv6 == nil { + gw.IPv6 = ip + } +} + +// isPodSubnetRoute reports whether r is a pod-subnet route out ifindex, as +// opposed to a kernel-generated route or a termination route with a gateway or +// link scope. +func isPodSubnetRoute(r *netlink.Route, ifindex int) bool { + return r.Dst != nil && r.LinkIndex == ifindex && r.Gw == nil && len(r.MultiPath) == 0 && + r.Type == unix.RTN_UNICAST && r.Protocol != unix.RTPROT_KERNEL && r.Scope != netlink.SCOPE_LINK +} diff --git a/internal/gc/datapath_repair_root_test.go b/internal/gc/datapath_repair_root_test.go new file mode 100644 index 00000000..e7e67d8d --- /dev/null +++ b/internal/gc/datapath_repair_root_test.go @@ -0,0 +1,445 @@ +// Copyright 2026 Datum Cloud, Inc. +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package gc + +import ( + "context" + "encoding/binary" + "encoding/hex" + "errors" + "fmt" + "maps" + "net" + "os" + "path/filepath" + "slices" + "testing" + + "github.com/cilium/ebpf" + "github.com/containernetworking/plugins/pkg/ns" + "github.com/vishvananda/netlink" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + "go.datum.net/galactic/internal/cni/tap" + "go.datum.net/galactic/internal/cni/veth" + "go.datum.net/galactic/internal/cniipam" + "go.datum.net/galactic/internal/config" + "go.datum.net/galactic/internal/crdnames" + "go.datum.net/galactic/internal/hostgw" + "go.datum.net/galactic/internal/plumbing/ebpf/attach" + "go.datum.net/galactic/internal/plumbing/ebpf/attachreg" + "go.datum.net/galactic/internal/plumbing/ebpf/prog" + "go.datum.net/galactic/internal/plumbing/ebpf/uformat" + "go.datum.net/galactic/internal/plumbing/ebpf/usidmap" + "go.datum.net/galactic/internal/plumbing/vrf" + bgpv1alpha1 "go.datum.net/network/api/v1alpha1" +) + +// rebuiltMaps are the pinned maps RepairAttachmentDatapath rebuilds. +var rebuiltMaps = []string{ + prog.UsidMapVrfTable, + prog.UsidMapLocatorTable, + prog.UsidMapFunctionTable, + prog.UsidMapVpcAttributionTable, + prog.UsidMapIfindexVrfTable, + prog.UsidMapIfindexEgressKindTable, + prog.UsidMapTenantGwTable, + prog.UsidMapEgressRouteTable, + prog.UsidMapNodeSrcAddrTable, + prog.UsidMapPublicUplinkTable, +} + +// rootAttachment is one attachment the root test creates and registers +// through the CNI ADD-side helpers. +type rootAttachment struct { + vpc, vpcAttachment string + ifaceType string + vrfID int32 + ipam *cniipam.IPAMResult +} + +// localEgressPrefixes mirrors cnibgp's own derivation of an ADD's local +// prefixes from its IPAM result, which this package cannot import. +func (a rootAttachment) localEgressPrefixes() []string { + if a.ipam == nil { + return nil + } + var prefixes []string + if a.ipam.IPv6Subnet != nil { + prefixes = append(prefixes, a.ipam.IPv6Subnet.String()) + } + if a.ipam.IPv4Address != nil { + prefixes = append(prefixes, a.ipam.IPv4Address.String()+"/32") + } + if a.ipam.IPv6Gateway != nil { + prefixes = append(prefixes, attachreg.HostPrefix(a.ipam.IPv6Gateway)) + } + if a.ipam.IPv4Gateway != nil { + prefixes = append(prefixes, attachreg.HostPrefix(a.ipam.IPv4Gateway)) + } + return prefixes +} + +func (a rootAttachment) gateways() *attachreg.Gateways { + if a.ipam == nil { + return nil + } + return &attachreg.Gateways{IPv6: a.ipam.IPv6Gateway, IPv4: a.ipam.IPv4Gateway} +} + +// snapshotMaps returns every rebuilt map's rows as hex key to hex value, +// with the Generation stamp and datapath counters cleared, which a rebuilt +// row carries afresh and which say nothing about what the row routes. +func snapshotMaps(t *testing.T, pinDir string) map[string]map[string]string { + t.Helper() + out := map[string]map[string]string{} + for _, name := range rebuiltMaps { + m, err := ebpf.LoadPinnedMap(filepath.Join(pinDir, name), nil) + if err != nil { + t.Fatalf("open pinned %s: %v", name, err) + } + rows := map[string]string{} + var key, value []byte + it := m.Iterate() + for it.Next(&key, &value) { + rows[hex.EncodeToString(key)] = hex.EncodeToString(normalizeRow(name, value)) + } + if err := it.Err(); err != nil { + t.Fatalf("iterate %s: %v", name, err) + } + _ = m.Close() + out[name] = rows + } + return out +} + +// normalizeRow clears the bytes of value that are a write stamp or a packet +// counter rather than routing state. +func normalizeRow(mapName string, value []byte) []byte { + v := append([]byte(nil), value...) + switch mapName { + case prog.UsidMapVrfTable: + // vrf_table_id and egress_kind are the first 8 bytes; the rest are + // counters and the generation. + clear(v[8:]) + case prog.UsidMapLocatorTable: + clear(v) + case prog.UsidMapVpcAttributionTable: + // vpc, vpc_attachment and padding, then the generation. + clear(v[16:]) + } + return v +} + +// emptyMaps leaves every rebuilt map pinned but empty, the state a recreated +// map is left in. +func emptyMaps(t *testing.T, pinDir string) { + t.Helper() + for _, name := range rebuiltMaps { + m, err := ebpf.LoadPinnedMap(filepath.Join(pinDir, name), nil) + if err != nil { + t.Fatalf("open pinned %s: %v", name, err) + } + if m.Type() == ebpf.Array { + zero := make([]byte, m.ValueSize()) + if err := m.Put(uint32(0), zero); err != nil { + t.Fatalf("clear %s: %v", name, err) + } + _ = m.Close() + continue + } + var keys [][]byte + var key, value []byte + it := m.Iterate() + for it.Next(&key, &value) { + keys = append(keys, append([]byte(nil), key...)) + } + for _, k := range keys { + if err := m.Delete(k); err != nil && !errors.Is(err, ebpf.ErrKeyNotExist) { + t.Fatalf("delete from %s: %v", name, err) + } + } + _ = m.Close() + } +} + +// setUpUplink gives the current namespace an SRv6 uplink with a resolved +// neighbor and a route covering the egress shard's whole locator, so shard +// routes and public_uplink_table both resolve. +func setUpUplink(name, shardLocator string) error { + dummy := &netlink.Dummy{LinkAttrs: netlink.LinkAttrs{Name: name}} + if err := netlink.LinkAdd(dummy); err != nil { + return fmt.Errorf("add uplink: %w", err) + } + if err := netlink.LinkSetUp(dummy); err != nil { + return fmt.Errorf("set uplink up: %w", err) + } + gw := net.ParseIP("fe80::1") + if err := netlink.NeighAdd(&netlink.Neigh{ + LinkIndex: dummy.Attrs().Index, Family: netlink.FAMILY_V6, State: netlink.NUD_PERMANENT, + IP: gw, HardwareAddr: net.HardwareAddr{0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa}, + }); err != nil { + return fmt.Errorf("add uplink neighbor: %w", err) + } + _, dst, err := net.ParseCIDR(shardLocator) + if err != nil { + return err + } + if err := netlink.RouteAdd(&netlink.Route{LinkIndex: dummy.Attrs().Index, Dst: dst, Gw: gw}); err != nil { + return fmt.Errorf("add shard route: %w", err) + } + return nil +} + +// createAttachment creates a's VRF, host interface, and host gateway +// addressing the way the master plugin does. +func createAttachment(a rootAttachment) error { + if err := vrf.Add(a.vpc); err != nil { + return fmt.Errorf("vrf.Add(%s): %w", a.vpc, err) + } + switch a.ifaceType { + case attachreg.InterfaceTypeTap: + if _, err := tap.Add(a.vpc, a.vpcAttachment, "owner", 1500); err != nil { + return fmt.Errorf("tap.Add: %w", err) + } + default: + if _, err := veth.Add(a.vpc, a.vpcAttachment, "owner", 1500); err != nil { + return fmt.Errorf("veth.Add: %w", err) + } + } + if err := hostgw.ConfigureHostGateway(a.vpc, a.vpcAttachment, a.ipam, nil); err != nil { + return fmt.Errorf("ConfigureHostGateway: %w", err) + } + return nil +} + +// registerAttachment registers a through the CNI ADD-side helpers, as +// galactic-bgp does. +func registerAttachment(a rootAttachment, node attachreg.Node, pinDir string, egress attachreg.EgressConfig) error { + registered, err := attachreg.RegisterDatapath(pinDir, attachreg.Registration{ + Node: node, + VPC: a.vpc, + VPCAttachment: a.vpcAttachment, + InterfaceType: a.ifaceType, + Argument: uint16(a.vrfID), + LocalPrefixes: a.localEgressPrefixes(), + Egress: egress, + }) + if err != nil || !registered { + return fmt.Errorf("RegisterDatapath(%s/%s) = %v, %w; want registered", a.vpc, a.vpcAttachment, registered, err) + } + attachreg.RegisterTenantGateway(pinDir, a.vpc, a.vpcAttachment, a.gateways()) + return nil +} + +// TestRepairAttachmentDatapath_RebuildsWhatADDWrote registers attachments +// through the real CNI ADD-side helpers, empties every map they wrote, and +// requires one repair pass to put back the rows ADD wrote. +// +// The VPC holding a veth and a tap attachment shares one vrf_table and one +// vpc_attribution_table row, which ADD leaves holding whichever attachment +// registered last, and the repair writes from the lower host ifindex. In +// ReverseOrder the last ADD is the lower ifindex, so every row matches +// exactly. In NaturalOrder it is not, and those two rows must instead hold +// the lower-ifindex attachment's values while every other row matches. +func TestRepairAttachmentDatapath_RebuildsWhatADDWrote(t *testing.T) { + requireRoot(t) + t.Run("ReverseOrder", func(t *testing.T) { rebuildsWhatADDWrote(t, true) }) + t.Run("NaturalOrder", func(t *testing.T) { rebuildsWhatADDWrote(t, false) }) +} + +// sharedVRFKey is the vrf_table and vpc_attribution_table key, in the raw byte +// form snapshotMaps reports, of the (Block, Argument) the fixture's veth and +// tap attachments share. +func sharedVRFKey(t *testing.T, argument uint16) string { + t.Helper() + key, err := uformat.NewVRFKey(testBlock(t), argument) + if err != nil { + t.Fatalf("NewVRFKey: %v", err) + } + return hex.EncodeToString(binary.NativeEndian.AppendUint64(nil, uint64(key))) +} + +func rebuildsWhatADDWrote(t *testing.T, reverse bool) { + const ( + nodeName = "repair-node" + uplinkName = "rpup0" + shardSID = "2001:db8:ff01:9:e001::" + shardLocator = "2001:db8:ff01:9::/64" + ) + node := attachreg.Node{Locator: testLocator, NodeID: 5} + egress := attachreg.EgressConfig{ShardSIDs: shardSID, NAT64Prefix: "64:ff9b::/96"} + attachments := []rootAttachment{ + {vpc: "jU", vpcAttachment: "abc", ifaceType: attachreg.InterfaceTypeVeth, vrfID: 0x21, ipam: &cniipam.IPAMResult{ + IPv6Subnet: mustCIDR(t, "fd20:30:ff01::/96"), + IPv6Gateway: net.ParseIP("fd20:30::1"), + IPv4Address: net.ParseIP("10.1.0.5"), + IPv4Gateway: net.ParseIP("172.21.1.1"), + }}, + {vpc: "jU", vpcAttachment: "def", ifaceType: attachreg.InterfaceTypeTap, vrfID: 0x21, ipam: &cniipam.IPAMResult{ + IPv6Subnet: mustCIDR(t, "fd20:30:ff02::/96"), + IPv6Gateway: net.ParseIP("fd20:30::1"), + IPv4Address: net.ParseIP("10.1.0.6"), + IPv4Gateway: net.ParseIP("172.21.1.129"), + }}, + {vpc: "kV", vpcAttachment: "ghi", ifaceType: attachreg.InterfaceTypeTap, vrfID: 0x22}, + } + + objects := make([]client.Object, 0, 3) + objects = append(objects, &bgpv1alpha1.BGPRouter{ + ObjectMeta: metav1.ObjectMeta{Name: repairRouter, Namespace: repairNamespace}, + Spec: bgpv1alpha1.BGPRouterSpec{ + TargetRef: bgpv1alpha1.TargetRef{Kind: targetKindNode, Name: nodeName}, LocalASN: 65000, + SRv6Locator: node.Locator, NodeID: node.NodeID, + }, + }) + for _, vpc := range []struct { + name string + id int32 + }{{"jU", 0x21}, {"kV", 0x22}} { + objects = append(objects, &bgpv1alpha1.BGPVRFInstance{ + ObjectMeta: metav1.ObjectMeta{Name: crdnames.BGPVRFInstanceName(vpc.name, nodeName), Namespace: repairNamespace}, + Spec: bgpv1alpha1.BGPVRFInstanceSpec{ + RouterTarget: bgpv1alpha1.RouterTarget{RouterRef: &bgpv1alpha1.RouterRef{Name: repairRouter}}, + VRFID: vpc.id, + ImportRouteTargets: []bgpv1alpha1.RouteTarget{{Value: testRouteTarget}}, + ExportRouteTargets: []bgpv1alpha1.RouteTarget{{Value: testRouteTarget}}, + }, + }) + } + k8s := fake.NewClientBuilder().WithScheme(gcTestScheme(t)).WithObjects(objects...).Build() + + pinDir := fmt.Sprintf("/sys/fs/bpf/galactic-repair-test-%d", os.Getpid()) + t.Cleanup(func() { _ = os.RemoveAll(pinDir) }) + objs, err := attach.Load(pinDir) + if err != nil { + t.Fatalf("attach.Load: %v", err) + } + t.Cleanup(func() { _ = objs.Close() }) + + t.Setenv(config.EnvCNIEBPFInterfaces, uplinkName) + attach.InvalidateUplinkIndexes() + t.Cleanup(attach.InvalidateUplinkIndexes) + + nsObj, err := ns.TempNetNS() + if err != nil { + t.Fatalf("create test netns: %v", err) + } + t.Cleanup(func() { _ = nsObj.Close() }) + + // Everything that reads or writes links runs inside the namespace; the + // pinned maps are global, so the snapshots run outside it, on the test + // goroutine. + err = nsObj.Do(func(ns.NetNS) error { + if err := setUpUplink(uplinkName, shardLocator); err != nil { + return err + } + for _, a := range attachments { + if err := createAttachment(a); err != nil { + return err + } + } + order := slices.Clone(attachments) + if reverse { + slices.Reverse(order) + } + for _, a := range order { + if err := registerAttachment(a, node, pinDir, egress); err != nil { + return err + } + } + return nil + }) + if err != nil { + t.Fatalf("register attachments: %v", err) + } + + want := snapshotMaps(t, pinDir) + for _, name := range rebuiltMaps { + if len(want[name]) == 0 { + t.Fatalf("ADD wrote no %s rows; the fixture does not exercise it", name) + } + } + emptyMaps(t, pinDir) + + repair := func() (DatapathRepairResult, error) { + var result DatapathRepairResult + err := nsObj.Do(func(ns.NetNS) error { + var err error + result, err = RepairAttachmentDatapath(context.Background(), k8s, DatapathRepairConfig{ + Namespace: repairNamespace, NodeName: nodeName, PinDir: pinDir, Egress: egress, + }) + return err + }) + return result, err + } + + result, err := repair() + if err != nil { + t.Fatalf("RepairAttachmentDatapath: %v", err) + } + if result.UplinkErr != nil { + t.Errorf("public uplink not rebuilt: %v", result.UplinkErr) + } + if result.Attachments != len(attachments) { + t.Errorf("repair examined %d attachments, want %d", result.Attachments, len(attachments)) + } + + got := snapshotMaps(t, pinDir) + if !reverse { + checkSharedRowsFromLowestIfindex(t, pinDir, attachments[0]) + shared := sharedVRFKey(t, uint16(attachments[0].vrfID)) + for _, name := range []string{prog.UsidMapVrfTable, prog.UsidMapVpcAttributionTable} { + if _, ok := got[name][shared]; !ok { + t.Errorf("%s after repair has no row for the shared key", name) + } + delete(got[name], shared) + delete(want[name], shared) + } + } + for _, name := range rebuiltMaps { + if !maps.Equal(got[name], want[name]) { + t.Errorf("%s after repair:\n got %v\nwant %v", name, got[name], want[name]) + } + } + + again, err := repair() + if err != nil || again.Rebuilt.Total() != 0 { + t.Errorf("second pass rebuilt %+v err %v, want nothing", again.Rebuilt, err) + } +} + +// checkSharedRowsFromLowestIfindex fails t unless the shared vrf_table and +// vpc_attribution_table rows hold lowest's values, lowest being the +// attachment created first and so given the lower host ifindex. +func checkSharedRowsFromLowestIfindex(t *testing.T, pinDir string, lowest rootAttachment) { + t.Helper() + reg, closer, err := usidmap.OpenPinnedRegistry(pinDir) + if err != nil { + t.Fatalf("OpenPinnedRegistry: %v", err) + } + defer func() { _ = closer.Close() }() + block, argument := testBlock(t), uint16(lowest.vrfID) + + wantKind, err := attachreg.EgressKindForInterfaceType(lowest.ifaceType) + if err != nil { + t.Fatalf("EgressKindForInterfaceType: %v", err) + } + if e, ok, err := reg.VRF.Get(block, argument); err != nil || !ok || e.EgressKind != wantKind { + t.Errorf("shared vrf_table row = %+v ok %v err %v, want egress kind %d from the lowest ifindex", + e, ok, err, wantKind) + } + wantVPC, wantAtt, err := attachreg.DecodeVPCIdentifiers(lowest.vpc, lowest.vpcAttachment) + if err != nil { + t.Fatalf("DecodeVPCIdentifiers: %v", err) + } + if e, ok, err := reg.VPCAttribution.Get(block, argument); err != nil || !ok || + e.VPC != wantVPC || e.VPCAttachment != wantAtt { + t.Errorf("shared vpc_attribution_table row = %+v ok %v err %v, want attachment %s", + e, ok, err, lowest.vpcAttachment) + } +} diff --git a/internal/gc/datapath_repair_test.go b/internal/gc/datapath_repair_test.go new file mode 100644 index 00000000..db93018e --- /dev/null +++ b/internal/gc/datapath_repair_test.go @@ -0,0 +1,946 @@ +// Copyright 2026 Datum Cloud, Inc. +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package gc + +import ( + "context" + "errors" + "io" + "net" + "net/netip" + "reflect" + "testing" + + "github.com/cilium/ebpf" + "github.com/vishvananda/netlink" + "golang.org/x/sys/unix" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + "go.datum.net/galactic/internal/crdnames" + "go.datum.net/galactic/internal/plumbing/ebpf/attachreg" + "go.datum.net/galactic/internal/plumbing/ebpf/egressroutemap" + "go.datum.net/galactic/internal/plumbing/ebpf/ifindexvrfmap" + "go.datum.net/galactic/internal/plumbing/ebpf/uformat" + "go.datum.net/galactic/internal/plumbing/ebpf/usidmap" + bgpv1alpha1 "go.datum.net/network/api/v1alpha1" +) + +// memTable is an in-memory usidmap.Table keyed by any comparable key, copying +// values in and out by reflection the way *ebpf.Map copies them. +type memTable struct { + entries map[any]any + order []any +} + +func newMemTable() *memTable { return &memTable{entries: map[any]any{}} } + +func (m *memTable) Put(key, value any) error { + if _, ok := m.entries[key]; !ok { + m.order = append(m.order, key) + } + m.entries[key] = value + return nil +} + +func (m *memTable) Lookup(key, valueOut any) error { + v, ok := m.entries[key] + if !ok { + return ebpf.ErrKeyNotExist + } + reflect.ValueOf(valueOut).Elem().Set(reflect.ValueOf(v)) + return nil +} + +func (m *memTable) Delete(key any) error { + if _, ok := m.entries[key]; !ok { + return ebpf.ErrKeyNotExist + } + delete(m.entries, key) + for i, k := range m.order { + if k == key { + m.order = append(m.order[:i], m.order[i+1:]...) + break + } + } + return nil +} + +func (m *memTable) Iterate() usidmap.Iterator { return &memIterator{table: m, idx: -1} } + +type memIterator struct { + table *memTable + idx int +} + +func (it *memIterator) Next(keyOut, valueOut any) bool { + it.idx++ + if it.idx >= len(it.table.order) { + return false + } + k := it.table.order[it.idx] + reflect.ValueOf(keyOut).Elem().Set(reflect.ValueOf(k)) + reflect.ValueOf(valueOut).Elem().Set(reflect.ValueOf(it.table.entries[k])) + return true +} + +func (it *memIterator) Err() error { return nil } + +// memMaps is an attachreg.Maps over memTables, with each table kept so a test +// can count its rows. +type memMaps struct { + maps *attachreg.Maps + tables map[string]*memTable +} + +func newMemMaps() *memMaps { + t := map[string]*memTable{} + for _, name := range []string{"vrf", "locator", "function", "attribution", "ifindex", "kind", "gateway", + "route", "nodesrc", "uplink"} { + t[name] = newMemTable() + } + m := &memMaps{tables: t} + m.maps = m.open() + return m +} + +// open returns a new attachreg.Maps over m's tables, as each repair pass +// opens the pinned maps afresh. +func (m *memMaps) open() *attachreg.Maps { + t := m.tables + return &attachreg.Maps{ + Registry: &usidmap.Registry{ + VRF: usidmap.NewVRFTable(t["vrf"]), + Locator: usidmap.NewLocatorTable(t["locator"]), + Function: usidmap.NewFunctionTable(t["function"]), + VPCAttribution: usidmap.NewVPCAttributionTable(t["attribution"]), + }, + IfindexVRF: ifindexvrfmap.NewIfindexVRFTable(t["ifindex"]), + EgressKind: ifindexvrfmap.NewEgressKindTable(t["kind"]), + Gateway: ifindexvrfmap.NewGatewayTable(t["gateway"]), + EgressRoute: egressroutemap.NewEgressRouteTable(t["route"]), + NodeSource: egressroutemap.NewNodeSourceAddress(t["nodesrc"]), + PublicUplink: egressroutemap.NewPublicUplink(t["uplink"]), + } +} + +// rows returns the number of rows in every table, by name. +func (m *memMaps) rows() map[string]int { + out := map[string]int{} + for name, t := range m.tables { + out[name] = len(t.entries) + } + return out +} + +const ( + repairNamespace = "galactic-system" + repairNode = "worker-a" + repairRouter = "router-a" + repairNodeID = int32(5) +) + +// repairFixture is the kernel and API state one RepairAttachmentDatapath test +// runs against. +type repairFixture struct { + t *testing.T + mem *memMaps + links []netlink.Link + routes map[uint32][]netlink.Route + addrs map[int][]netlink.Addr + objects []client.Object + // gone lists ifindexes linkByIndexFn reports missing, standing in for a + // CNI DEL that lands mid-pass. + gone map[int]bool + uplinkErr error + foreign func(uint32) bool + egress attachreg.EgressConfig + // ctx is the pass's context; nil means context.Background. + ctx context.Context + cancel context.CancelFunc +} + +func newRepairFixture(t *testing.T) *repairFixture { + t.Helper() + f := &repairFixture{ + t: t, + mem: newMemMaps(), + routes: map[uint32][]netlink.Route{}, + addrs: map[int][]netlink.Addr{}, + gone: map[int]bool{}, + } + f.objects = append(f.objects, &bgpv1alpha1.BGPRouter{ + ObjectMeta: metav1.ObjectMeta{Name: repairRouter, Namespace: repairNamespace}, + Spec: bgpv1alpha1.BGPRouterSpec{ + TargetRef: bgpv1alpha1.TargetRef{Kind: targetKindNode, Name: repairNode}, + LocalASN: 65000, + SRv6Locator: testLocator, + NodeID: repairNodeID, + }, + }) + return f +} + +func (f *repairFixture) addVRFInstance(name string, vrfID int32) { + f.objects = append(f.objects, &bgpv1alpha1.BGPVRFInstance{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: repairNamespace}, + Spec: bgpv1alpha1.BGPVRFInstanceSpec{ + RouterTarget: bgpv1alpha1.RouterTarget{RouterRef: &bgpv1alpha1.RouterRef{Name: repairRouter}}, + VRFID: vrfID, + ImportRouteTargets: []bgpv1alpha1.RouteTarget{{Value: testRouteTarget}}, + ExportRouteTargets: []bgpv1alpha1.RouteTarget{{Value: testRouteTarget}}, + }, + }) +} + +func (f *repairFixture) addVRF(name string, index int, table uint32) *netlink.Vrf { + v := &netlink.Vrf{LinkAttrs: netlink.LinkAttrs{Name: name, Index: index}, Table: table} + f.links = append(f.links, v) + return v +} + +// addHostLink adds an attachment's host-side link enslaved to master (nil for +// none), of type veth or tap. +func (f *repairFixture) addHostLink(ifaceType, name string, index int, master *netlink.Vrf) { + attrs := netlink.LinkAttrs{Name: name, Index: index} + if master != nil { + attrs.MasterIndex = master.Index + } + switch ifaceType { + case attachreg.InterfaceTypeTap: + f.links = append(f.links, &netlink.Tuntap{LinkAttrs: attrs}) + default: + f.links = append(f.links, &netlink.Veth{LinkAttrs: attrs}) + } +} + +// addPodRoute adds the pod-subnet route the master plugin installs for dst in +// table out ifindex. +func (f *repairFixture) addPodRoute(table uint32, ifindex int, dst string) { + _, n, err := net.ParseCIDR(dst) + if err != nil { + f.t.Fatalf("ParseCIDR(%q): %v", dst, err) + } + f.routes[table] = append(f.routes[table], netlink.Route{ + Dst: n, LinkIndex: ifindex, Table: int(table), Type: unix.RTN_UNICAST, Protocol: unix.RTPROT_BOOT, + }) +} + +func (f *repairFixture) addAddr(ifindex int, cidr string) { + addr, err := netlink.ParseAddr(cidr) + if err != nil { + f.t.Fatalf("ParseAddr(%q): %v", cidr, err) + } + f.addrs[ifindex] = append(f.addrs[ifindex], *addr) +} + +// run installs the fixture's state behind every indirection and runs one +// pass. +func (f *repairFixture) run() (DatapathRepairResult, error) { + f.t.Helper() + origOpen, origAll, origRoutes, origAddrs, origByIndex, origUplink := + openDatapathMapsFn, listAllLinksFn, listRoutesFn, listAddrsFn, linkByIndexFn, resolvePublicUplinkFn + f.t.Cleanup(func() { + openDatapathMapsFn, listAllLinksFn, listRoutesFn, listAddrsFn, linkByIndexFn, resolvePublicUplinkFn = + origOpen, origAll, origRoutes, origAddrs, origByIndex, origUplink + }) + + openDatapathMapsFn = func(string) (*attachreg.Maps, io.Closer, error) { + return f.mem.open(), io.NopCloser(nil), nil + } + listAllLinksFn = func() ([]netlink.Link, error) { return f.links, nil } + listRoutesFn = func(table uint32, family int) ([]netlink.Route, error) { + var out []netlink.Route + for _, r := range f.routes[table] { + if (r.Dst.IP.To4() != nil) == (family == netlink.FAMILY_V4) { + out = append(out, r) + } + } + return out, nil + } + listAddrsFn = func(link netlink.Link, family int) ([]netlink.Addr, error) { + var out []netlink.Addr + for _, a := range f.addrs[link.Attrs().Index] { + if (a.IP.To4() != nil) == (family == netlink.FAMILY_V4) { + out = append(out, a) + } + } + return out, nil + } + linkByIndexFn = func(index int) (netlink.Link, error) { + if f.gone[index] { + return nil, errors.New("link not found") + } + for _, l := range f.links { + if l.Attrs().Index == index { + return l, nil + } + } + return nil, errors.New("link not found") + } + resolvePublicUplinkFn = func() (int, net.HardwareAddr, net.HardwareAddr, error) { + if f.uplinkErr != nil { + return 0, nil, nil, f.uplinkErr + } + return 2, net.HardwareAddr{0xaa, 0, 0, 0, 0, 1}, net.HardwareAddr{0xbb, 0, 0, 0, 0, 2}, nil + } + + k8s := fake.NewClientBuilder().WithScheme(gcTestScheme(f.t)).WithObjects(f.objects...).Build() + ctx := f.ctx + if ctx == nil { + ctx = context.Background() + } + return RepairAttachmentDatapath(ctx, k8s, DatapathRepairConfig{ + Namespace: repairNamespace, + NodeName: repairNode, + PinDir: f.t.TempDir(), + Egress: f.egress, + ForeignTableID: f.foreign, + }) +} + +func testBlock(t *testing.T) uint64 { + t.Helper() + block, err := blockFromLocator(t) + if err != nil { + t.Fatalf("derive block: %v", err) + } + return block +} + +func mustCIDR(t *testing.T, s string) *net.IPNet { + t.Helper() + _, n, err := net.ParseCIDR(s) + if err != nil { + t.Fatalf("ParseCIDR(%q): %v", s, err) + } + return n +} + +// wantPassThrough fails t unless table holds a pass-through entry for every +// one of prefixes. +func wantPassThrough(t *testing.T, mem *memMaps, table uint32, prefixes ...string) { + t.Helper() + for _, p := range prefixes { + sid, ok, err := mem.maps.EgressRoute.Lookup(table, mustCIDR(t, p)) + if err != nil || !ok { + t.Errorf("egress_route_table[%d, %s]: ok %v err %v, want a pass-through entry", table, p, ok, err) + continue + } + if !sid.Equal(net.IPv6zero) { + t.Errorf("egress_route_table[%d, %s] sid = %s, want the pass-through sentinel", table, p, sid) + } + } +} + +func TestRepairAttachmentDatapath_VethAndTap(t *testing.T) { + f := newRepairFixture(t) + block := testBlock(t) + + // A veth attachment on VPC "jU", dual-stack with gateways. + vrfA := f.addVRF("G0000000jUV", 10, 7) + f.addVRFInstance(crdnames.BGPVRFInstanceName("jU", repairNode), 0x21) + f.addHostLink(attachreg.InterfaceTypeVeth, "G0000000jUabcH", 11, vrfA) + f.addPodRoute(7, 11, "fd20:30:ff01::/96") + f.addPodRoute(7, 11, "10.1.0.5/32") + f.addAddr(11, "fe80::1/64") + f.addAddr(11, "fd20:30::1/128") + f.addAddr(11, "172.21.1.1/32") + + // A tap attachment on VPC "kV", IPv6-only. + vrfB := f.addVRF("G0000000kVV", 20, 8) + f.addVRFInstance(crdnames.BGPVRFInstanceName("kV", repairNode), 0x22) + f.addHostLink(attachreg.InterfaceTypeTap, "G0000000kVdefH", 21, vrfB) + f.addPodRoute(8, 21, "fd20:40:ff01::/96") + f.addAddr(21, "fd20:40::1/128") + + result, err := f.run() + if err != nil { + t.Fatalf("RepairAttachmentDatapath: %v", err) + } + if result.Attachments != 2 || result.Skipped != 0 { + t.Errorf("attachments %d skipped %d, want 2 and 0", result.Attachments, result.Skipped) + } + want := attachreg.Counts{ + Locator: 1, Function: 1, NodeSource: 1, PublicUplink: 1, + VRF: 2, VPCAttribution: 2, IfindexVRF: 2, EgressKind: 2, TenantGateway: 2, + LocalEgressRoutes: 6, + } + if result.Rebuilt != want { + t.Errorf("rebuilt = %+v, want %+v", result.Rebuilt, want) + } + + m := f.mem.maps + for _, w := range []wantAttachmentRows{ + {arg: 0x21, table: 7, kind: usidmap.EgressKindVeth, ifindex: 11, vpc: "jU", att: "abc"}, + {arg: 0x22, table: 8, kind: usidmap.EgressKindTap, ifindex: 21, vpc: "kV", att: "def"}, + } { + checkAttachmentRows(t, m, block, w) + } + + gw6, gw4, ok, err := m.Gateway.Get(11) + if err != nil || !ok || gw6 != netip.MustParseAddr("fd20:30::1") || gw4 != netip.MustParseAddr("172.21.1.1") { + t.Errorf("tenant_gw_table[11] = %v %v ok %v err %v, want fd20:30::1 and 172.21.1.1", gw6, gw4, ok, err) + } + gw6, gw4, ok, err = m.Gateway.Get(21) + if err != nil || !ok || gw6 != netip.MustParseAddr("fd20:40::1") || gw4.IsValid() { + t.Errorf("tenant_gw_table[21] = %v %v ok %v err %v, want fd20:40::1 and no IPv4", gw6, gw4, ok, err) + } + + wantPassThrough(t, f.mem, 7, "fd20:30:ff01::/96", "10.1.0.5/32", "fd20:30::1/128", "172.21.1.1/32") + wantPassThrough(t, f.mem, 8, "fd20:40:ff01::/96", "fd20:40::1/128") + if _, ok, _ := m.EgressRoute.Lookup(7, mustCIDR(t, "fe80::/64")); ok { + t.Error("a link-local address was registered as a local pass-through route") + } + + checkNodeRows(t, m, block) +} + +// wantAttachmentRows is what one attachment's VRF and interface rows must +// hold after a repair. +type wantAttachmentRows struct { + arg uint16 + table uint32 + kind uint32 + ifindex uint32 + vpc, att string +} + +// checkAttachmentRows fails t unless m holds w's vrf_table, +// vpc_attribution_table, ifindex_vrf_table and ifindex_egress_kind_table rows. +func checkAttachmentRows(t *testing.T, m *attachreg.Maps, block uint64, w wantAttachmentRows) { + t.Helper() + entry, ok, err := m.Registry.VRF.Get(block, w.arg) + if err != nil || !ok || entry.VRFTableID != w.table || entry.EgressKind != w.kind { + t.Errorf("vrf_table[%#x] = %+v ok %v err %v, want table %d kind %d", w.arg, entry, ok, err, w.table, w.kind) + } + wantVPC, wantAtt, _ := attachreg.DecodeVPCIdentifiers(w.vpc, w.att) + attr, ok, err := m.Registry.VPCAttribution.Get(block, w.arg) + if err != nil || !ok || attr.VPC != wantVPC || attr.VPCAttachment != wantAtt { + t.Errorf("vpc_attribution_table[%#x] = %+v ok %v err %v, want VPC %d attachment %d", + w.arg, attr, ok, err, wantVPC, wantAtt) + } + ifEntry, ok, err := m.IfindexVRF.Get(w.ifindex) + if err != nil || !ok || ifEntry.Block != block || ifEntry.Argument != w.arg { + t.Errorf("ifindex_vrf_table[%d] = %+v ok %v err %v, want argument %#x", w.ifindex, ifEntry, ok, err, w.arg) + } + kind, ok, err := m.EgressKind.Get(w.ifindex) + if err != nil || !ok || kind != w.kind { + t.Errorf("ifindex_egress_kind_table[%d] = %d ok %v err %v, want %d", w.ifindex, kind, ok, err, w.kind) + } +} + +// checkNodeRows fails t unless m holds this node's locator_table, +// function_table, node_src_addr_table and public_uplink_table rows. +func checkNodeRows(t *testing.T, m *attachreg.Maps, block uint64) { + t.Helper() + if _, ok, err := m.Registry.Locator.Get(block, uint16(repairNodeID)); err != nil || !ok { + t.Errorf("locator_table entry: ok %v err %v, want present", ok, err) + } + if _, ok, err := m.Registry.Function.Get(block, uformat.FunctionEndDT46); err != nil || !ok { + t.Errorf("function_table entry: ok %v err %v, want present", ok, err) + } + wantSrc, err := attachreg.NodeSourceAddress(testLocator, repairNodeID) + if err != nil { + t.Fatalf("NodeSourceAddress: %v", err) + } + if got, ok, err := m.NodeSource.Get(); err != nil || !ok || !got.Equal(wantSrc) { + t.Errorf("node_src_addr_table = %v ok %v err %v, want %v", got, ok, err, wantSrc) + } + if idx, _, _, ok, err := m.PublicUplink.Get(); err != nil || !ok || idx != 2 { + t.Errorf("public_uplink_table link %d ok %v err %v, want link 2", idx, ok, err) + } +} + +func TestRepairAttachmentDatapath_LegacyVRF(t *testing.T) { + f := newRepairFixture(t) + vrf := f.addVRF("G0000000jUabcV", 10, 9) + f.addVRFInstance("jU-"+repairNode, 0x30) + f.addHostLink(attachreg.InterfaceTypeVeth, "G0000000jUabcH", 11, vrf) + + if _, err := f.run(); err != nil { + t.Fatalf("RepairAttachmentDatapath: %v", err) + } + entry, ok, err := f.mem.maps.Registry.VRF.Get(testBlock(t), 0x30) + if err != nil || !ok || entry.VRFTableID != 9 { + t.Errorf("vrf_table entry = %+v ok %v err %v, want the legacy VRF's table 9", entry, ok, err) + } +} + +func TestRepairAttachmentDatapath_TapWithoutIPAM(t *testing.T) { + f := newRepairFixture(t) + vrf := f.addVRF("G0000000jUV", 10, 7) + f.addVRFInstance(crdnames.BGPVRFInstanceName("jU", repairNode), 0x21) + f.addHostLink(attachreg.InterfaceTypeTap, "G0000000jUabcH", 11, vrf) + f.addAddr(11, "fe80::1/64") + + result, err := f.run() + if err != nil { + t.Fatalf("RepairAttachmentDatapath: %v", err) + } + if result.Rebuilt.TenantGateway != 0 || result.Rebuilt.LocalEgressRoutes != 0 { + t.Errorf("rebuilt = %+v, want no tenant_gw_table or local route rows for a tap with no IPAM", result.Rebuilt) + } + if result.Rebuilt.VRF != 1 || result.Rebuilt.IfindexVRF != 1 || result.Rebuilt.EgressKind != 1 { + t.Errorf("rebuilt = %+v, want the VRF and interface rows", result.Rebuilt) + } + if kind, _, _ := f.mem.maps.EgressKind.Get(11); kind != usidmap.EgressKindTap { + t.Errorf("egress kind = %d, want EgressKindTap", kind) + } +} + +// TestRepairAttachmentDatapath_SkipsUnusable covers every interface the pass +// must leave alone, either for good or, with wantPending, until its +// BGPVRFInstance appears. None of them leaves a row in any map. +func TestRepairAttachmentDatapath_SkipsUnusable(t *testing.T) { + tests := []struct { + name string + setup func(f *repairFixture) + wantPending bool + }{ + { + name: "MissingBGPVRFInstance", + wantPending: true, + setup: func(f *repairFixture) { + vrf := f.addVRF("G0000000jUV", 10, 7) + f.addHostLink(attachreg.InterfaceTypeVeth, "G0000000jUabcH", 11, vrf) + }, + }, + { + name: "InstanceOnAnotherNode", + wantPending: true, + setup: func(f *repairFixture) { + vrf := f.addVRF("G0000000jUV", 10, 7) + f.addVRFInstance(crdnames.BGPVRFInstanceName("jU", "worker-b"), 0x21) + f.addHostLink(attachreg.InterfaceTypeVeth, "G0000000jUabcH", 11, vrf) + }, + }, + { + name: "OutOfRangeVRFID", + setup: func(f *repairFixture) { + vrf := f.addVRF("G0000000jUV", 10, 7) + f.addVRFInstance(crdnames.BGPVRFInstanceName("jU", repairNode), int32(uformat.ArgumentMax)+1) + f.addHostLink(attachreg.InterfaceTypeVeth, "G0000000jUabcH", 11, vrf) + }, + }, + { + name: "LinkWithoutMaster", + setup: func(f *repairFixture) { + f.addVRFInstance(crdnames.BGPVRFInstanceName("jU", repairNode), 0x21) + f.addHostLink(attachreg.InterfaceTypeVeth, "G0000000jUabcH", 11, nil) + }, + }, + { + name: "SidecarReturnTableRange", + setup: func(f *repairFixture) { + vrf := f.addVRF("G0000000jUV", 10, 0xF021) + f.addVRFInstance(crdnames.BGPVRFInstanceName("jU", repairNode), 0x21) + f.addHostLink(attachreg.InterfaceTypeVeth, "G0000000jUabcH", 11, vrf) + f.foreign = func(id uint32) bool { return id >= 0xF000 && id <= 0xFFFF } + }, + }, + { + name: "UnknownLinkType", + setup: func(f *repairFixture) { + vrf := f.addVRF("G0000000jUV", 10, 7) + f.addVRFInstance(crdnames.BGPVRFInstanceName("jU", repairNode), 0x21) + f.links = append(f.links, &netlink.Dummy{ + LinkAttrs: netlink.LinkAttrs{Name: "G0000000jUabcH", Index: 11, MasterIndex: vrf.Index}, + }) + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f := newRepairFixture(t) + tt.setup(f) + result, err := f.run() + if err != nil { + t.Fatalf("RepairAttachmentDatapath: %v", err) + } + wantSkipped, wantPending := 1, 0 + if tt.wantPending { + wantSkipped, wantPending = 0, 1 + } + if result.Attachments != 0 || result.Skipped != wantSkipped || result.Pending != wantPending { + t.Errorf("attachments %d skipped %d pending %d, want 0, %d and %d", + result.Attachments, result.Skipped, result.Pending, wantSkipped, wantPending) + } + for name, n := range f.mem.rows() { + if n != 0 { + t.Errorf("%s holds %d rows, want none", name, n) + } + } + }) + } +} + +func TestRepairAttachmentDatapath_IgnoresOtherInterfaces(t *testing.T) { + f := newRepairFixture(t) + vrf := f.addVRF("G0000000jUV", 10, 7) + f.addVRFInstance(crdnames.BGPVRFInstanceName("jU", repairNode), 0x21) + f.addHostLink(attachreg.InterfaceTypeVeth, "G0000000jUabcG", 11, vrf) // guest-side name + f.addHostLink(attachreg.InterfaceTypeVeth, "eth0", 12, vrf) + + result, err := f.run() + if err != nil { + t.Fatalf("RepairAttachmentDatapath: %v", err) + } + if result.Attachments != 0 || result.Skipped != 0 || result.Rebuilt.Total() != 0 { + t.Errorf("result = %+v, want nothing examined, skipped, or rebuilt", result) + } +} + +// TestRepairAttachmentDatapath_SidecarOwnedTable covers a VRF table the +// ingress sidecar's own vrf_table rows claim. +func TestRepairAttachmentDatapath_SidecarOwnedTable(t *testing.T) { + f := newRepairFixture(t) + vrf := f.addVRF("G0000000jUV", 10, 0x10005) + f.addVRFInstance(crdnames.BGPVRFInstanceName("jU", repairNode), 0x21) + f.addHostLink(attachreg.InterfaceTypeVeth, "G0000000jUabcH", 11, vrf) + sidecarVRF := f.mem.maps.Registry.VRF + if err := sidecarVRF.Register(uformat.BlockIngressSidecar, 0x5, 0x10005, usidmap.EgressKindVeth); err != nil { + t.Fatalf("seed sidecar row: %v", err) + } + + result, err := f.run() + if err != nil { + t.Fatalf("RepairAttachmentDatapath: %v", err) + } + if result.Skipped != 1 || result.Rebuilt.Total() != 0 { + t.Errorf("result = %+v, want the attachment skipped and nothing rebuilt", result) + } +} + +func TestRepairAttachmentDatapath_TwoAttachmentsOneVRF(t *testing.T) { + f := newRepairFixture(t) + vrf := f.addVRF("G0000000jUV", 10, 7) + f.addVRFInstance(crdnames.BGPVRFInstanceName("jU", repairNode), 0x21) + f.addHostLink(attachreg.InterfaceTypeVeth, "G0000000jUabcH", 11, vrf) + f.addPodRoute(7, 11, "fd20:30:ff01::/96") + f.addAddr(11, "fd20:30::1/128") + f.addHostLink(attachreg.InterfaceTypeVeth, "G0000000jUdefH", 12, vrf) + f.addPodRoute(7, 12, "fd20:30:ff02::/96") + f.addAddr(12, "fd20:30::1/128") + + result, err := f.run() + if err != nil { + t.Fatalf("RepairAttachmentDatapath: %v", err) + } + if result.Rebuilt.VRF != 1 || result.Rebuilt.VPCAttribution != 1 { + t.Errorf("rebuilt = %+v, want one shared vrf_table and vpc_attribution_table row", result.Rebuilt) + } + if result.Rebuilt.IfindexVRF != 2 || result.Rebuilt.EgressKind != 2 || result.Rebuilt.TenantGateway != 2 { + t.Errorf("rebuilt = %+v, want interface rows for both attachments", result.Rebuilt) + } + // The shared gateway address is one route, registered once. + if result.Rebuilt.LocalEgressRoutes != 3 { + t.Errorf("rebuilt %d local routes, want 3", result.Rebuilt.LocalEgressRoutes) + } + wantPassThrough(t, f.mem, 7, "fd20:30:ff01::/96", "fd20:30:ff02::/96", "fd20:30::1/128") +} + +// TestRepairAttachmentDatapath_OnlyPodSubnetRoutes covers the other routes a +// VRF table can hold out an attachment's interface, none of which ADD +// registers. +func TestRepairAttachmentDatapath_OnlyPodSubnetRoutes(t *testing.T) { + f := newRepairFixture(t) + vrf := f.addVRF("G0000000jUV", 10, 7) + f.addVRFInstance(crdnames.BGPVRFInstanceName("jU", repairNode), 0x21) + f.addHostLink(attachreg.InterfaceTypeVeth, "G0000000jUabcH", 11, vrf) + f.addPodRoute(7, 11, "fd20:30:ff01::/96") + f.routes[7] = append(f.routes[7], + netlink.Route{Dst: mustCIDR(t, "fe80::/64"), LinkIndex: 11, Type: unix.RTN_UNICAST, Protocol: unix.RTPROT_KERNEL}, + netlink.Route{Dst: mustCIDR(t, "fd99::/64"), LinkIndex: 11, Type: unix.RTN_UNICAST, Protocol: unix.RTPROT_BOOT, + Gw: net.ParseIP("fd20:30:ff01::5")}, + netlink.Route{Dst: mustCIDR(t, "10.9.0.0/24"), LinkIndex: 11, Type: unix.RTN_UNICAST, Protocol: unix.RTPROT_BOOT, + Scope: netlink.SCOPE_LINK}, + netlink.Route{Dst: mustCIDR(t, "fd20:30:ff09::/96"), LinkIndex: 99, Type: unix.RTN_UNICAST, + Protocol: unix.RTPROT_BOOT}, + ) + + result, err := f.run() + if err != nil { + t.Fatalf("RepairAttachmentDatapath: %v", err) + } + if result.Rebuilt.LocalEgressRoutes != 1 { + t.Errorf("rebuilt %d local routes, want only the pod subnet", result.Rebuilt.LocalEgressRoutes) + } + wantPassThrough(t, f.mem, 7, "fd20:30:ff01::/96") +} + +// TestRepairAttachmentDatapath_ExistingRowsUntouched seeds a row of every +// per-attachment kind with a value the pass would not write, and requires it +// to survive. +func TestRepairAttachmentDatapath_ExistingRowsUntouched(t *testing.T) { + f := newRepairFixture(t) + block := testBlock(t) + vrf := f.addVRF("G0000000jUV", 10, 7) + f.addVRFInstance(crdnames.BGPVRFInstanceName("jU", repairNode), 0x21) + f.addHostLink(attachreg.InterfaceTypeTap, "G0000000jUabcH", 11, vrf) + f.addPodRoute(7, 11, "fd20:30:ff01::/96") + f.addAddr(11, "fd20:30::1/128") + + m := f.mem.maps + // The sidecar return path's row for the same key, pointing at its own + // table. + if err := m.Registry.VRF.Register(block, 0x21, 0xF021, usidmap.EgressKindVeth); err != nil { + t.Fatalf("seed vrf_table: %v", err) + } + if err := m.Registry.VPCAttribution.Register(block, 0x21, 1, 2); err != nil { + t.Fatalf("seed vpc_attribution_table: %v", err) + } + if err := m.IfindexVRF.Register(11, block, 0x22); err != nil { + t.Fatalf("seed ifindex_vrf_table: %v", err) + } + if err := m.EgressKind.Register(11, usidmap.EgressKindVeth); err != nil { + t.Fatalf("seed ifindex_egress_kind_table: %v", err) + } + if err := m.Gateway.Register(11, netip.MustParseAddr("fd99::1"), netip.Addr{}); err != nil { + t.Fatalf("seed tenant_gw_table: %v", err) + } + if err := m.NodeSource.Set(net.ParseIP("2001:db8:ffff::1")); err != nil { + t.Fatalf("seed node_src_addr_table: %v", err) + } + + result, err := f.run() + if err != nil { + t.Fatalf("RepairAttachmentDatapath: %v", err) + } + want := attachreg.Counts{Locator: 1, Function: 1, PublicUplink: 1, LocalEgressRoutes: 2} + if result.Rebuilt != want { + t.Errorf("rebuilt = %+v, want %+v", result.Rebuilt, want) + } + + if e, _, _ := m.Registry.VRF.Get(block, 0x21); e.VRFTableID != 0xF021 || e.EgressKind != usidmap.EgressKindVeth { + t.Errorf("vrf_table row = %+v, want the seeded table 0xF021", e) + } + if e, _, _ := m.Registry.VPCAttribution.Get(block, 0x21); e.VPC != 1 || e.VPCAttachment != 2 { + t.Errorf("vpc_attribution_table row = %+v, want the seeded values", e) + } + if e, _, _ := m.IfindexVRF.Get(11); e.Argument != 0x22 { + t.Errorf("ifindex_vrf_table row = %+v, want the seeded argument", e) + } + if kind, _, _ := m.EgressKind.Get(11); kind != usidmap.EgressKindVeth { + t.Errorf("egress kind = %d, want the seeded veth", kind) + } + if gw6, _, _, _ := m.Gateway.Get(11); gw6 != netip.MustParseAddr("fd99::1") { + t.Errorf("tenant_gw_table IPv6 = %v, want the seeded fd99::1", gw6) + } + if src, _, _ := m.NodeSource.Get(); !src.Equal(net.ParseIP("2001:db8:ffff::1")) { + t.Errorf("node_src_addr_table = %v, want the seeded value", src) + } +} + +func TestRepairAttachmentDatapath_SecondPassIsANoop(t *testing.T) { + f := newRepairFixture(t) + vrf := f.addVRF("G0000000jUV", 10, 7) + f.addVRFInstance(crdnames.BGPVRFInstanceName("jU", repairNode), 0x21) + f.addHostLink(attachreg.InterfaceTypeVeth, "G0000000jUabcH", 11, vrf) + f.addPodRoute(7, 11, "fd20:30:ff01::/96") + f.addAddr(11, "fd20:30::1/128") + + first, err := f.run() + if err != nil || first.Rebuilt.Total() == 0 { + t.Fatalf("first pass: rebuilt %+v err %v, want rows rebuilt", first.Rebuilt, err) + } + before := f.mem.rows() + second, err := f.run() + if err != nil { + t.Fatalf("second pass: %v", err) + } + if second.Rebuilt.Total() != 0 { + t.Errorf("second pass rebuilt %+v, want nothing", second.Rebuilt) + } + if after := f.mem.rows(); !reflect.DeepEqual(before, after) { + t.Errorf("rows after second pass = %v, want unchanged %v", after, before) + } +} + +// TestRepairAttachmentDatapath_InterfaceGoneMidPass stands in for a CNI DEL +// that removes the interface while the pass writes its rows. +func TestRepairAttachmentDatapath_InterfaceGoneMidPass(t *testing.T) { + f := newRepairFixture(t) + vrf := f.addVRF("G0000000jUV", 10, 7) + f.addVRFInstance(crdnames.BGPVRFInstanceName("jU", repairNode), 0x21) + f.addHostLink(attachreg.InterfaceTypeVeth, "G0000000jUabcH", 11, vrf) + f.addPodRoute(7, 11, "fd20:30:ff01::/96") + f.addAddr(11, "fd20:30::1/128") + f.gone[11] = true + + result, err := f.run() + if err != nil { + t.Fatalf("RepairAttachmentDatapath: %v", err) + } + if result.Rebuilt.IfindexVRF != 0 || result.Rebuilt.VRF != 0 || result.Rebuilt.LocalEgressRoutes != 0 { + t.Errorf("rebuilt = %+v, want none of the vanished attachment's rows counted", result.Rebuilt) + } + for _, name := range []string{"ifindex", "kind", "gateway", "vrf", "attribution", "route"} { + if n := len(f.mem.tables[name].entries); n != 0 { + t.Errorf("%s holds %d rows, want the rows for the vanished interface removed", name, n) + } + } +} + +// TestRepairAttachmentDatapath_InterfaceGoneMidPassKeepsSharedRows covers a +// vanished attachment whose VRF another live attachment shares: rows the +// sibling also needs stay, and only the vanished attachment's own go. +func TestRepairAttachmentDatapath_InterfaceGoneMidPassKeepsSharedRows(t *testing.T) { + f := newRepairFixture(t) + block := testBlock(t) + vrf := f.addVRF("G0000000jUV", 10, 7) + f.addVRFInstance(crdnames.BGPVRFInstanceName("jU", repairNode), 0x21) + f.addHostLink(attachreg.InterfaceTypeVeth, "G0000000jUabcH", 11, vrf) + f.addPodRoute(7, 11, "fd20:30:ff01::/96") + f.addAddr(11, "fd20:30::1/128") + f.addHostLink(attachreg.InterfaceTypeVeth, "G0000000jUdefH", 12, vrf) + f.addPodRoute(7, 12, "fd20:30:ff02::/96") + f.addAddr(12, "fd20:30::1/128") + f.gone[11] = true + + if _, err := f.run(); err != nil { + t.Fatalf("RepairAttachmentDatapath: %v", err) + } + m := f.mem.maps + if _, ok, _ := m.Registry.VRF.Get(block, 0x21); !ok { + t.Error("vrf_table row removed, want it kept for the attachment still sharing it") + } + wantPassThrough(t, f.mem, 7, "fd20:30:ff02::/96", "fd20:30::1/128") + present, err := m.EgressRoute.Prefixes() + if err != nil { + t.Fatalf("Prefixes: %v", err) + } + if _, ok := present[7]["fd20:30:ff01::/96"]; ok { + t.Error("the vanished attachment's own subnet route is still present") + } + if _, ok, _ := m.IfindexVRF.Get(11); ok { + t.Error("ifindex_vrf_table row for the vanished interface is still present") + } + if _, ok, _ := m.IfindexVRF.Get(12); !ok { + t.Error("ifindex_vrf_table row for the live interface is missing") + } +} + +// TestRepairAttachmentDatapath_LowestIfindexOwnsSharedRows covers a VPC with +// a tap and a veth attachment: the shared vrf_table and vpc_attribution_table +// rows come from the lower host ifindex whatever order the kernel lists them +// in, while each interface keeps its own egress kind. +func TestRepairAttachmentDatapath_LowestIfindexOwnsSharedRows(t *testing.T) { + f := newRepairFixture(t) + block := testBlock(t) + vrf := f.addVRF("G0000000jUV", 10, 7) + f.addVRFInstance(crdnames.BGPVRFInstanceName("jU", repairNode), 0x21) + f.addHostLink(attachreg.InterfaceTypeVeth, "G0000000jUdefH", 12, vrf) + f.addHostLink(attachreg.InterfaceTypeTap, "G0000000jUabcH", 11, vrf) + + if _, err := f.run(); err != nil { + t.Fatalf("RepairAttachmentDatapath: %v", err) + } + m := f.mem.maps + if e, _, _ := m.Registry.VRF.Get(block, 0x21); e.EgressKind != usidmap.EgressKindTap { + t.Errorf("vrf_table egress kind = %d, want the tap's, from ifindex 11", e.EgressKind) + } + wantVPC, wantAtt, _ := attachreg.DecodeVPCIdentifiers("jU", "abc") + if e, _, _ := m.Registry.VPCAttribution.Get(block, 0x21); e.VPC != wantVPC || e.VPCAttachment != wantAtt { + t.Errorf("vpc_attribution_table = %+v, want attachment abc", e) + } + for ifindex, want := range map[uint32]uint32{11: usidmap.EgressKindTap, 12: usidmap.EgressKindVeth} { + if kind, _, _ := m.EgressKind.Get(ifindex); kind != want { + t.Errorf("ifindex_egress_kind_table[%d] = %d, want %d", ifindex, kind, want) + } + } +} + +func TestRepairAttachmentDatapath_CanceledContextStopsBetweenAttachments(t *testing.T) { + f := newRepairFixture(t) + vrf := f.addVRF("G0000000jUV", 10, 7) + f.addVRFInstance(crdnames.BGPVRFInstanceName("jU", repairNode), 0x21) + f.addHostLink(attachreg.InterfaceTypeVeth, "G0000000jUabcH", 11, vrf) + f.ctx, f.cancel = context.WithCancel(context.Background()) + f.cancel() + + result, err := f.run() + if !errors.Is(err, context.Canceled) { + t.Errorf("RepairAttachmentDatapath = %v, want it to report the cancellation", err) + } + if result.Rebuilt.VRF != 0 || result.Rebuilt.IfindexVRF != 0 { + t.Errorf("rebuilt = %+v, want no attachment repaired after cancellation", result.Rebuilt) + } +} + +func TestRepairAttachmentDatapath_UnresolvedUplinkIsNotAnError(t *testing.T) { + f := newRepairFixture(t) + vrf := f.addVRF("G0000000jUV", 10, 7) + f.addVRFInstance(crdnames.BGPVRFInstanceName("jU", repairNode), 0x21) + f.addHostLink(attachreg.InterfaceTypeVeth, "G0000000jUabcH", 11, vrf) + f.uplinkErr = errors.New("no resolved neighbor") + + result, err := f.run() + if err != nil { + t.Fatalf("RepairAttachmentDatapath: %v, want no error for an unresolved uplink", err) + } + if result.UplinkErr == nil || result.Rebuilt.PublicUplink != 0 { + t.Errorf("UplinkErr %v, rebuilt uplink %d; want the error reported and no row", result.UplinkErr, + result.Rebuilt.PublicUplink) + } + if result.Rebuilt.VRF != 1 { + t.Errorf("rebuilt = %+v, want the other rows still rebuilt", result.Rebuilt) + } +} + +func TestRepairAttachmentDatapath_NodeIdentity(t *testing.T) { + newAttached := func(t *testing.T) *repairFixture { + f := newRepairFixture(t) + vrf := f.addVRF("G0000000jUV", 10, 7) + f.addVRFInstance(crdnames.BGPVRFInstanceName("jU", repairNode), 0x21) + f.addHostLink(attachreg.InterfaceTypeVeth, "G0000000jUabcH", 11, vrf) + return f + } + + t.Run("NoRouterYet", func(t *testing.T) { + f := newAttached(t) + f.objects = f.objects[1:] + result, err := f.run() + if err != nil || result.Rebuilt.Total() != 0 || result.Pending != 1 { + t.Errorf("result %+v err %v, want the attachment pending, nothing rebuilt and no error", result, err) + } + }) + t.Run("NoRouterNoAttachments", func(t *testing.T) { + f := newRepairFixture(t) + f.objects = nil + f.addVRF("G0000000jUV", 10, 7) + result, err := f.run() + if err != nil || result.Pending != 0 || result.Rebuilt.Total() != 0 { + t.Errorf("result %+v err %v, want a clean, empty pass", result, err) + } + }) + t.Run("NoLocator", func(t *testing.T) { + f := newAttached(t) + f.objects[0].(*bgpv1alpha1.BGPRouter).Spec.SRv6Locator = "" + result, err := f.run() + if err != nil || result.Rebuilt.Total() != 0 { + t.Errorf("result %+v err %v, want nothing and no error", result, err) + } + }) + t.Run("AmbiguousRouters", func(t *testing.T) { + f := newAttached(t) + second := f.objects[0].(*bgpv1alpha1.BGPRouter).DeepCopy() + second.Name = "router-b" + f.objects = append(f.objects, second) + if _, err := f.run(); err == nil { + t.Error("RepairAttachmentDatapath = nil error with two routers for the node, want an error") + } + }) + t.Run("MissingPinDir", func(t *testing.T) { + k8s := fake.NewClientBuilder().WithScheme(gcTestScheme(t)).Build() + result, err := RepairAttachmentDatapath(context.Background(), k8s, DatapathRepairConfig{ + Namespace: repairNamespace, NodeName: repairNode, PinDir: "/nonexistent/galactic-pin-dir", + }) + if err != nil || result.Rebuilt.Total() != 0 { + t.Errorf("result %+v err %v, want nothing and no error", result, err) + } + }) +} diff --git a/internal/gc/gc.go b/internal/gc/gc.go index 4eca087a..fc0be240 100644 --- a/internal/gc/gc.go +++ b/internal/gc/gc.go @@ -519,7 +519,7 @@ func SweepEBPFVRFTable(ctx context.Context, k8s client.Client, namespace, nodeNa } if len(routers) == 0 { // A node with any live eBPF-registered attachment necessarily has a - // BGPRouter targeting it, since registerEBPFDatapath requires one. + // BGPRouter targeting it, since attachreg.RegisterDatapath requires one. // Finding none is indistinguishable from a transient listing hiccup or // a router just renamed, so it must not read as "genuinely nothing is // live": that would fold every entry into the stale case and wipe the @@ -602,11 +602,11 @@ func SweepEBPFVRFTable(ctx context.Context, k8s client.Client, namespace, nodeNa } // Repair entries a live BGPVRFInstance expects but vrf_table lacks. A - // reload that changes vrf_table's layout recreates it empty, and nothing - // repopulates a pre-existing attachment: registerEBPFDatapath runs once, - // at CNI ADD, and is never re-invoked for an attachment that already - // succeeded. Reconcile above only deletes, so this is vrf_table's only - // self-healing path. + // reload that changes vrf_table's layout recreates it empty, and CNI ADD + // never re-registers an attachment that already succeeded. + // RepairAttachmentDatapath, started on the same tick, rebuilds them from + // each attachment's interface; this covers a VRF it skipped, such as one + // whose attachment interface is mid-replacement. for key, instName := range liveCRDNames { if _, ok := existingKeys[key]; ok { continue // already present -- Reconcile above is what handles this one @@ -648,10 +648,9 @@ func SweepEBPFVRFTable(ctx context.Context, k8s client.Client, namespace, nodeNa // vpc_attribution_table shares vrf_table's exact (Block, Argument) key // and lifecycle (see its own doc comment in usid.c), so it reconciles - // against the same live set and cutoff captured above. It has no repair - // step of its own: a missing row only reports as unattributed - // (internal/plumbing/ebpf/metrics) until the next CNI ADD/DEL repopulates - // it. + // against the same live set and cutoff captured above. A missing row is + // rebuilt by RepairAttachmentDatapath, not here; until then it reports as + // unattributed (internal/plumbing/ebpf/metrics). attrRemoved, attrErr := reg.VPCAttribution.Reconcile(live, cutoff) for _, e := range attrRemoved { slog.Info("GC: removed stale eBPF vpc_attribution_table entry", "block", e.Block, "argument", e.Argument) diff --git a/internal/installer/datapathrepair.go b/internal/installer/datapathrepair.go new file mode 100644 index 00000000..bfcef9df --- /dev/null +++ b/internal/installer/datapathrepair.go @@ -0,0 +1,132 @@ +// Copyright 2026 Datum Cloud, Inc. +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package installer + +import ( + "context" + "log/slog" + "time" + + "go.datum.net/galactic/internal/gc" + "go.datum.net/galactic/internal/plumbing/ebpf/attach" +) + +// datapathRepairRetryInterval is the first wait between startup repair passes +// that need repeating. Each later wait doubles, up to +// datapathRepairMaxRetryInterval. datapathRepairPendingLimit bounds how long +// the startup passes keep repeating for attachments whose BGPRouter or +// BGPVRFInstance is not found yet; after it the GC tick takes over. Vars so +// tests can shrink them. +var ( + datapathRepairRetryInterval = 15 * time.Second + datapathRepairMaxRetryInterval = 5 * time.Minute + datapathRepairPendingLimit = 10 * time.Minute +) + +// repairDatapathFn is gc.RepairAttachmentDatapath, indirected so tests can +// observe passes without a datapath or an API server. +var repairDatapathFn = gc.RepairAttachmentDatapath + +// datapathRepairEnabled reports whether st has a loaded datapath and a +// Kubernetes client, without which there is nothing to repair or nothing to +// repair it from. +func datapathRepairEnabled(st ebpfDatapathState) bool { + return st.objs != nil && st.k8sClient != nil +} + +// isSidecarReturnTable reports whether tableID is one of the routing tables +// the sidecar return path allocates. The datapath repair must never treat one +// as an attachment's VRF, and this range check is what stops it: the +// sidecar's own vrf_table rows, which also mark such tables, may be among the +// rows a recreated map has lost. +func isSidecarReturnTable(tableID uint32) bool { + return tableID >= sidecarReturnTableBase && tableID <= sidecarReturnTableMax +} + +// runDatapathRepair runs one gc.RepairAttachmentDatapath pass and logs one +// summary line with the rows it rebuilt per map: at Info when it rebuilt +// anything, at Warn when it failed or left the public uplink unresolved, and +// at Debug otherwise. It returns the pass's result and error. +func runDatapathRepair(ctx context.Context, st ebpfDatapathState) (gc.DatapathRepairResult, error) { + result, err := repairDatapathFn(ctx, st.k8sClient, gc.DatapathRepairConfig{ + Namespace: st.namespace, + NodeName: st.nodeName, + PinDir: attach.PinDir, + Egress: st.egress, + ForeignTableID: isSidecarReturnTable, + }) + + attrs := append([]any{"attachments", result.Attachments, "skipped", result.Skipped, + "pending", result.Pending, "rebuilt", result.Rebuilt.Total()}, result.Rebuilt.LogAttrs()...) + switch { + case err != nil: + slog.Warn("eBPF attachment datapath repair failed; rows CNI ADD wrote may still be missing", + append(attrs, "err", err)...) + case result.UplinkErr != nil: + slog.Warn("eBPF attachment datapath repair could not rebuild this node's public uplink", + append(attrs, "uplinkErr", result.UplinkErr)...) + case result.Rebuilt.Total() > 0: + slog.Info("eBPF attachment datapath repair rebuilt missing rows", attrs...) + default: + slog.Debug("eBPF attachment datapath repair found nothing missing", attrs...) + } + return result, err +} + +// startDatapathRepair runs datapath repair passes off Run's goroutine, +// waiting datapathRepairRetryInterval before the first repeat and doubling +// each wait after that. A pass that fails is repeated until one succeeds. A +// pass that leaves attachments pending is repeated until none are or +// datapathRepairPendingLimit has passed since the first. ctx ending stops it. +// +// sem is the size-1 semaphore the GC tick's pass shares, held only for the +// length of a pass, so the two never run at once. +func startDatapathRepair(ctx context.Context, sem chan struct{}, st ebpfDatapathState) { + if !datapathRepairEnabled(st) { + return + } + wait, maxWait, pendingLimit := datapathRepairRetryInterval, datapathRepairMaxRetryInterval, datapathRepairPendingLimit + go func() { + started := time.Now() + for { + select { + case sem <- struct{}{}: + case <-ctx.Done(): + return + } + result, err := runDatapathRepair(ctx, st) + <-sem + pending := result.Pending > 0 && time.Since(started) < pendingLimit + if err == nil && !pending { + return + } + + select { + case <-time.After(wait): + case <-ctx.Done(): + return + } + wait = min(wait*2, maxWait) + } + }() +} + +// repairDatapathOnTick starts one datapath repair pass off Run's goroutine, +// unless a pass already holds sem, in which case that pass covers this tick. +// A pass can wait on neighbor resolution, so running it on Run's goroutine +// would stall every other ticker and shutdown. +func repairDatapathOnTick(ctx context.Context, sem chan struct{}, st ebpfDatapathState) { + if !datapathRepairEnabled(st) { + return + } + select { + case sem <- struct{}{}: + go func() { + defer func() { <-sem }() + _, _ = runDatapathRepair(ctx, st) + }() + default: + } +} diff --git a/internal/installer/datapathrepair_test.go b/internal/installer/datapathrepair_test.go new file mode 100644 index 00000000..62f0f42b --- /dev/null +++ b/internal/installer/datapathrepair_test.go @@ -0,0 +1,227 @@ +// Copyright 2026 Datum Cloud, Inc. +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package installer + +import ( + "context" + "errors" + "sync/atomic" + "testing" + "time" + + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + "go.datum.net/galactic/internal/gc" + "go.datum.net/galactic/internal/plumbing/ebpf/attachreg" + "go.datum.net/galactic/internal/plumbing/ebpf/prog" +) + +// withRepairFn replaces repairDatapathFn for one test with fn and shrinks the +// retry intervals so a retrying test finishes quickly. +func withRepairFn( + t *testing.T, fn func(context.Context, client.Client, gc.DatapathRepairConfig) (gc.DatapathRepairResult, error), +) { + t.Helper() + origFn, origRetry, origMax := repairDatapathFn, datapathRepairRetryInterval, datapathRepairMaxRetryInterval + t.Cleanup(func() { + repairDatapathFn, datapathRepairRetryInterval, datapathRepairMaxRetryInterval = origFn, origRetry, origMax + }) + repairDatapathFn = fn + datapathRepairRetryInterval = time.Millisecond + datapathRepairMaxRetryInterval = 4 * time.Millisecond +} + +// repairState is an ebpfDatapathState with a loaded datapath and a client. +func repairState() ebpfDatapathState { + return ebpfDatapathState{ + objs: &prog.UsidObjects{}, + k8sClient: fake.NewClientBuilder().Build(), + namespace: testNamespace, + nodeName: "worker-a", + egress: attachreg.EgressConfig{ShardSIDs: "2001:db8:ff01:9:e001::"}, + } +} + +func TestStartDatapathRepair_RetriesUntilAPassSucceeds(t *testing.T) { + var calls atomic.Int32 + var gotCfg atomic.Value + done := make(chan struct{}) + withRepairFn(t, func( + _ context.Context, _ client.Client, cfg gc.DatapathRepairConfig, + ) (gc.DatapathRepairResult, error) { + gotCfg.Store(cfg) + if calls.Add(1) < 3 { + return gc.DatapathRepairResult{}, errors.New("transient") + } + close(done) + return gc.DatapathRepairResult{}, nil + }) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + sem := make(chan struct{}, 1) + startDatapathRepair(ctx, sem, repairState()) + + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatalf("repair made %d passes, want it to retry until the third succeeds", calls.Load()) + } + time.Sleep(20 * time.Millisecond) + if n := calls.Load(); n != 3 { + t.Errorf("repair made %d passes, want exactly 3 (no pass after the first success)", n) + } + + cfg := gotCfg.Load().(gc.DatapathRepairConfig) + if cfg.NodeName != "worker-a" || cfg.Namespace != testNamespace || cfg.Egress.ShardSIDs == "" { + t.Errorf("repair config = %+v, want the state's node, namespace and egress config", cfg) + } + if cfg.ForeignTableID == nil || !cfg.ForeignTableID(sidecarReturnTableBase+1) { + t.Error("repair config does not treat the sidecar return tables as foreign") + } + select { + case sem <- struct{}{}: + default: + t.Error("semaphore still held after the startup repair finished") + } +} + +func TestStartDatapathRepair_DisabledWithoutDatapathOrClient(t *testing.T) { + var calls atomic.Int32 + withRepairFn(t, func(context.Context, client.Client, gc.DatapathRepairConfig) (gc.DatapathRepairResult, error) { + calls.Add(1) + return gc.DatapathRepairResult{}, nil + }) + + noDatapath := repairState() + noDatapath.objs = nil + noClient := repairState() + noClient.k8sClient = nil + + sem := make(chan struct{}, 1) + for _, st := range []ebpfDatapathState{noDatapath, noClient} { + startDatapathRepair(context.Background(), sem, st) + repairDatapathOnTick(context.Background(), sem, st) + } + time.Sleep(20 * time.Millisecond) + if n := calls.Load(); n != 0 { + t.Errorf("repair made %d passes, want none without a datapath and a client", n) + } +} + +// TestRepairDatapathOnTick_RunsOffTheCallersGoroutine covers a pass that +// blocks, as one waiting on unresolvable shards does: the tick must return at +// once, hold the semaphore for the pass, and skip while it is held. +func TestRepairDatapathOnTick_RunsOffTheCallersGoroutine(t *testing.T) { + var calls atomic.Int32 + release := make(chan struct{}) + withRepairFn(t, func(context.Context, client.Client, gc.DatapathRepairConfig) (gc.DatapathRepairResult, error) { + calls.Add(1) + <-release + return gc.DatapathRepairResult{}, nil + }) + + sem := make(chan struct{}, 1) + returned := make(chan struct{}) + go func() { + repairDatapathOnTick(context.Background(), sem, repairState()) + close(returned) + }() + select { + case <-returned: + case <-time.After(time.Second): + t.Fatal("tick blocked on a pass that has not finished") + } + + repairDatapathOnTick(context.Background(), sem, repairState()) + close(release) + waitFor(t, func() bool { return len(sem) == 0 }) + if n := calls.Load(); n != 1 { + t.Errorf("ticks ran %d passes, want 1: the second tick must skip while the first holds the semaphore", n) + } +} + +// TestStartDatapathRepair_RetriesWhileAttachmentsArePending covers attachments +// whose BGPRouter or BGPVRFInstance appears only after startup: the startup +// passes keep going until a pass finds nothing pending. +func TestStartDatapathRepair_RetriesWhileAttachmentsArePending(t *testing.T) { + var calls atomic.Int32 + withRepairFn(t, func(context.Context, client.Client, gc.DatapathRepairConfig) (gc.DatapathRepairResult, error) { + if calls.Add(1) < 4 { + return gc.DatapathRepairResult{Pending: 2}, nil + } + return gc.DatapathRepairResult{Attachments: 2}, nil + }) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + startDatapathRepair(ctx, make(chan struct{}, 1), repairState()) + + waitFor(t, func() bool { return calls.Load() >= 4 }) + time.Sleep(20 * time.Millisecond) + if n := calls.Load(); n != 4 { + t.Errorf("repair made %d passes, want 4: retried while pending, then stopped", n) + } +} + +// TestStartDatapathRepair_StopsRetryingPendingAfterTheLimit covers +// attachments that stay pending: the startup passes give up after +// datapathRepairPendingLimit and leave them to the GC tick. +func TestStartDatapathRepair_StopsRetryingPendingAfterTheLimit(t *testing.T) { + var calls atomic.Int32 + withRepairFn(t, func(context.Context, client.Client, gc.DatapathRepairConfig) (gc.DatapathRepairResult, error) { + calls.Add(1) + return gc.DatapathRepairResult{Pending: 1}, nil + }) + origLimit := datapathRepairPendingLimit + t.Cleanup(func() { datapathRepairPendingLimit = origLimit }) + datapathRepairPendingLimit = 30 * time.Millisecond + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + startDatapathRepair(ctx, make(chan struct{}, 1), repairState()) + + waitFor(t, func() bool { return calls.Load() >= 2 }) + time.Sleep(100 * time.Millisecond) + settled := calls.Load() + time.Sleep(100 * time.Millisecond) + if n := calls.Load(); n != settled { + t.Errorf("repair still retrying (%d then %d passes) after the pending limit", settled, n) + } +} + +// waitFor polls cond until it holds or a second passes. +func waitFor(t *testing.T, cond func() bool) { + t.Helper() + deadline := time.Now().Add(time.Second) + for !cond() { + if time.Now().After(deadline) { + t.Fatal("condition not met within a second") + } + time.Sleep(time.Millisecond) + } +} + +func TestIsSidecarReturnTable(t *testing.T) { + tests := []struct { + name string + tableID uint32 + want bool + }{ + {name: "BelowRange", tableID: sidecarReturnTableBase - 1, want: false}, + {name: "Base", tableID: sidecarReturnTableBase, want: true}, + {name: "Max", tableID: sidecarReturnTableMax, want: true}, + {name: "AboveRange", tableID: sidecarReturnTableMax + 1, want: false}, + {name: "TenantTable", tableID: 7, want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isSidecarReturnTable(tt.tableID); got != tt.want { + t.Errorf("isSidecarReturnTable(%#x) = %v, want %v", tt.tableID, got, tt.want) + } + }) + } +} diff --git a/internal/installer/installer.go b/internal/installer/installer.go index ce6f7c17..2881cf51 100644 --- a/internal/installer/installer.go +++ b/internal/installer/installer.go @@ -34,6 +34,7 @@ import ( "go.datum.net/galactic/internal/hostconf" "go.datum.net/galactic/internal/hostgw" "go.datum.net/galactic/internal/plumbing/ebpf/attach" + "go.datum.net/galactic/internal/plumbing/ebpf/attachreg" "go.datum.net/galactic/internal/plumbing/ebpf/egressroutemap" "go.datum.net/galactic/internal/plumbing/ebpf/metrics" "go.datum.net/galactic/internal/plumbing/ebpf/prog" @@ -504,6 +505,11 @@ type ebpfDatapathState struct { // order, from the same host conflist CNI ADD reads it from. The egress // route sweep uses it to keep each VRF on the first reachable shard. egressShardSIDs []net.IP + + // egress is the same host conflist's raw egress configuration, which the + // datapath repair rebuilds a VRF's shard routes from exactly as CNI ADD + // installs them. + egress attachreg.EgressConfig } // startEBPFDatapath loads and attaches the eBPF datapath and returns the state @@ -548,6 +554,7 @@ func startEBPFDatapath(ctx context.Context, m *metrics.Metrics) (ebpfDatapathSta return state, datapath, nil } state.egressShardSIDs = loadEgressShardSIDs(hostConf.EgressShardSIDs) + state.egress = attachreg.EgressConfig{ShardSIDs: hostConf.EgressShardSIDs, NAT64Prefix: hostConf.NAT64Prefix} if k8sClient, err := newK8sClientFn(); err != nil { slog.Warn("eBPF vrf_table GC sweep disabled: failed to create k8s client", "err", err) } else { @@ -962,9 +969,13 @@ func radvActorFailed(actors *radvActorSet, failure radvActorFailure) { // reports SERVING once the process is up, since credential refresh and log // rotation have no meaningful unhealthy state; a separate // ebpfHealthServiceName service reports the polled result of attach.Health. -// 7. Sweeps stale vrf_table and vpc_attribution_table entries (reconciled -// together against the same live set) and reconciles nptv6_table -// entries on a ticker. All three run here rather than in +// 7. Rebuilds the attachment and per-node rows only CNI ADD otherwise +// writes, once the datapath loads and again on the GC ticker, so a map +// recreated empty does not stay empty until every workload re-attaches. +// Each pass runs off this goroutine. The same ticker also sweeps stale +// vrf_table and vpc_attribution_table entries (reconciled together +// against the same live set) and reconciles nptv6_table entries. All of +// this runs here rather than in // galactic-router's GC controller because the pinned maps exist only // inside this container. // 8. Runs one radv.RunActor per recorded tap attachment, reconciled on a short @@ -994,6 +1005,13 @@ func Run(ctx context.Context, grpcHealthPort, metricsPort int) error { // the BGPRouter carrying the locator may not exist yet. reconcileLocatorLocalRoute(ctx, ebpfState) + // Rebuild the attachment rows only CNI ADD otherwise writes, which a map + // recreated by this load has lost. Off this goroutine and retried while a + // pass fails or leaves attachments pending; the GC tick below repeats it. + // Size-1 semaphore, so the startup and tick passes never overlap. + datapathRepairSem := make(chan struct{}, 1) + startDatapathRepair(ctx, datapathRepairSem, ebpfState) + // Serve Prometheus metrics at the conventional /metrics scrape path. metricsMux := http.NewServeMux() metricsMux.Handle("/metrics", m.Handler()) @@ -1119,6 +1137,10 @@ func Run(ctx context.Context, grpcHealthPort, metricsPort int) error { if ebpfState.k8sClient == nil { continue } + // Off this goroutine. Ordering against the sweep below does + // not matter: the sweep reaps only keys no live CRD names, + // and the repair writes keys for live CRDs only. + repairDatapathOnTick(ctx, datapathRepairSem, ebpfState) result := gc.SweepEBPFVRFTable(ctx, ebpfState.k8sClient, ebpfState.namespace, ebpfState.nodeName, attach.PinDir) logEBPFVRFSweepResult(result) diff --git a/internal/plumbing/ebpf/attachreg/doc.go b/internal/plumbing/ebpf/attachreg/doc.go new file mode 100644 index 00000000..9f9c4d7c --- /dev/null +++ b/internal/plumbing/ebpf/attachreg/doc.go @@ -0,0 +1,17 @@ +// Copyright 2026 Datum Cloud, Inc. +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +// Package attachreg writes one VPC attachment's rows, and this node's own, into +// the uSID datapath's pinned maps. +// +// It has two callers that must write identical values. galactic-bgp registers +// an attachment at CNI ADD through RegisterDatapath and RegisterTenantGateway. +// The galactic-cni daemon rebuilds rows a map recreation has lost through the +// Maps Fill methods, from kernel state and CRDs rather than a CNI result. Both +// derive every value through the helpers here, so the two cannot drift apart. +// +// The Fill methods only write a row that is missing. A row that exists, from +// an ADD, from the ingress sidecar, or from the sidecar return path, is left as +// it is. +package attachreg diff --git a/internal/plumbing/ebpf/attachreg/fill.go b/internal/plumbing/ebpf/attachreg/fill.go new file mode 100644 index 00000000..636c41e2 --- /dev/null +++ b/internal/plumbing/ebpf/attachreg/fill.go @@ -0,0 +1,518 @@ +// Copyright 2026 Datum Cloud, Inc. +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package attachreg + +import ( + "errors" + "fmt" + "io" + "log/slog" + "net" + "os" + + "go.datum.net/galactic/internal/plumbing/ebpf/egressroutemap" + "go.datum.net/galactic/internal/plumbing/ebpf/ifindexvrfmap" + "go.datum.net/galactic/internal/plumbing/ebpf/uformat" + "go.datum.net/galactic/internal/plumbing/ebpf/usidmap" + "go.datum.net/galactic/internal/plumbing/srv6" +) + +// Maps is every pinned map RegisterDatapath and RegisterTenantGateway write, +// opened once so a repair pass can read and fill many rows. A Maps serves one +// pass: the egress_route_table prefixes its first FillVRF reads are reused by +// every later one. +// +// EgressKind and Gateway are nil when the running datapath predates those +// maps; their rows are then skipped, as ADD skips them. +type Maps struct { + Registry *usidmap.Registry + IfindexVRF *ifindexvrfmap.IfindexVRFTable + EgressKind *ifindexvrfmap.EgressKindTable + Gateway *ifindexvrfmap.GatewayTable + EgressRoute *egressroutemap.EgressRouteTable + NodeSource *egressroutemap.NodeSourceAddress + PublicUplink *egressroutemap.PublicUplink + + // routePrefixes is egress_route_table's exact prefixes by VRF table, + // read on first use and updated with every route this Maps writes. + routePrefixes map[uint32]map[string]struct{} + + // shardsUnresolvable is set once a shard route fails because no + // configured shard resolves; see FillVRF. + shardsUnresolvable bool +} + +// egressRoutesPresent returns the exact prefixes egress_route_table holds for +// tableID, reading the whole map on first use. +func (m *Maps) egressRoutesPresent(tableID uint32) (map[string]struct{}, error) { + if m.routePrefixes == nil { + prefixes, err := m.EgressRoute.Prefixes() + if err != nil { + return nil, err + } + m.routePrefixes = prefixes + } + if m.routePrefixes[tableID] == nil { + m.routePrefixes[tableID] = map[string]struct{}{} + } + return m.routePrefixes[tableID], nil +} + +// closers closes every map handle OpenPinnedMaps opened. +type closers []io.Closer + +func (c closers) Close() error { + var errs []error + for _, closer := range c { + if err := closer.Close(); err != nil { + errs = append(errs, err) + } + } + return errors.Join(errs...) +} + +// OpenPinnedMaps opens every map in Maps from its pin under pinDir. The +// returned closer releases this process's handles and must be closed when the +// caller is done; the maps stay pinned. +func OpenPinnedMaps(pinDir string) (*Maps, io.Closer, error) { + var ( + m Maps + cs closers + ) + fail := func(err error) (*Maps, io.Closer, error) { + _ = cs.Close() + return nil, nil, err + } + + registry, registryCloser, err := usidmap.OpenPinnedRegistry(pinDir) + if err != nil { + return fail(fmt.Errorf("open pinned uSID maps: %w", err)) + } + m.Registry, cs = registry, append(cs, registryCloser) + + ifindexTable, ifindexMap, err := ifindexvrfmap.OpenPinned(pinDir) + if err != nil { + return fail(fmt.Errorf("open pinned ifindex_vrf_table: %w", err)) + } + m.IfindexVRF, cs = ifindexTable, append(cs, ifindexMap) + + kinds, kindsMap, err := ifindexvrfmap.OpenPinnedEgressKind(pinDir) + switch { + case errors.Is(err, os.ErrNotExist): + case err != nil: + return fail(fmt.Errorf("open pinned ifindex_egress_kind_table: %w", err)) + default: + m.EgressKind, cs = kinds, append(cs, kindsMap) + } + + gateways, gatewaysMap, err := ifindexvrfmap.OpenPinnedGateway(pinDir) + switch { + case errors.Is(err, os.ErrNotExist): + case err != nil: + return fail(fmt.Errorf("open pinned tenant_gw_table: %w", err)) + default: + m.Gateway, cs = gateways, append(cs, gatewaysMap) + } + + routes, routesCloser, err := egressroutemap.OpenPinnedEgressRouteTable(pinDir) + if err != nil { + return fail(fmt.Errorf("open pinned egress_route_table: %w", err)) + } + m.EgressRoute, cs = routes, append(cs, routesCloser) + + nodeSrc, nodeSrcCloser, err := egressroutemap.OpenPinnedNodeSourceAddress(pinDir) + if err != nil { + return fail(fmt.Errorf("open pinned node_src_addr_table: %w", err)) + } + m.NodeSource, cs = nodeSrc, append(cs, nodeSrcCloser) + + uplink, uplinkCloser, err := egressroutemap.OpenPinnedPublicUplink(pinDir) + if err != nil { + return fail(fmt.Errorf("open pinned public_uplink_table: %w", err)) + } + m.PublicUplink, cs = uplink, append(cs, uplinkCloser) + + return &m, cs, nil +} + +// Counts is how many rows a fill wrote into each map. +type Counts struct { + Locator int + Function int + NodeSource int + PublicUplink int + VRF int + VPCAttribution int + IfindexVRF int + EgressKind int + TenantGateway int + LocalEgressRoutes int + ShardEgressRoutes int +} + +// Add adds o's counts to c. +func (c *Counts) Add(o Counts) { + c.Locator += o.Locator + c.Function += o.Function + c.NodeSource += o.NodeSource + c.PublicUplink += o.PublicUplink + c.VRF += o.VRF + c.VPCAttribution += o.VPCAttribution + c.IfindexVRF += o.IfindexVRF + c.EgressKind += o.EgressKind + c.TenantGateway += o.TenantGateway + c.LocalEgressRoutes += o.LocalEgressRoutes + c.ShardEgressRoutes += o.ShardEgressRoutes +} + +// Total is the number of rows written across every map. +func (c Counts) Total() int { + return c.Locator + c.Function + c.NodeSource + c.PublicUplink + c.VRF + c.VPCAttribution + + c.IfindexVRF + c.EgressKind + c.TenantGateway + c.LocalEgressRoutes + c.ShardEgressRoutes +} + +// LogAttrs returns c as slog key-value pairs, one per map. +func (c Counts) LogAttrs() []any { + return []any{ + "locatorTable", c.Locator, + "functionTable", c.Function, + "nodeSrcAddrTable", c.NodeSource, + "publicUplinkTable", c.PublicUplink, + "vrfTable", c.VRF, + "vpcAttributionTable", c.VPCAttribution, + "ifindexVRFTable", c.IfindexVRF, + "ifindexEgressKindTable", c.EgressKind, + "tenantGWTable", c.TenantGateway, + "egressRouteTableLocal", c.LocalEgressRoutes, + "egressRouteTableShard", c.ShardEgressRoutes, + } +} + +// UplinkResolver resolves this node's fabric-uplink next hop: the link index +// and the destination and source MACs public_uplink_table holds. +type UplinkResolver func() (linkIndex int, dmac, smac net.HardwareAddr, err error) + +// FillNode writes this node's own rows that are missing: locator_table and +// function_table for node's Block, node_src_addr_table, and +// public_uplink_table. A node with no SRv6 identity writes nothing. +// +// uplinkErr reports a public uplink that could not be resolved, which needs a +// converged underlay neighbor and is retried by the next fill rather than +// treated as a failure. err reports every other failure; each row is attempted +// regardless. +func (m *Maps) FillNode(node Node, resolveUplink UplinkResolver) (written Counts, uplinkErr, err error) { + if !node.Configured() { + return Counts{}, nil, nil + } + block, err := node.Block() + if err != nil { + return Counts{}, nil, err + } + + var errs []error + nodeID := uint16(node.NodeID) + if _, ok, err := m.Registry.Locator.Get(block, nodeID); err != nil { + errs = append(errs, err) + } else if !ok { + if err := m.Registry.Locator.Register(block, nodeID); err != nil { + errs = append(errs, fmt.Errorf("register locator_table entry: %w", err)) + } else { + written.Locator++ + } + } + + if _, ok, err := m.Registry.Function.Get(block, uformat.FunctionEndDT46); err != nil { + errs = append(errs, err) + } else if !ok { + if err := m.Registry.Function.Register(block, uformat.FunctionEndDT46); err != nil { + errs = append(errs, fmt.Errorf("register function_table entry: %w", err)) + } else { + written.Function++ + } + } + + if _, ok, err := m.NodeSource.Get(); err != nil { + errs = append(errs, err) + } else if !ok { + if err := m.fillNodeSource(node); err != nil { + errs = append(errs, err) + } else { + written.NodeSource++ + } + } + + if _, _, _, ok, err := m.PublicUplink.Get(); err != nil { + errs = append(errs, err) + } else if !ok { + if linkIndex, dmac, smac, err := resolveUplink(); err != nil { + uplinkErr = fmt.Errorf("resolve public uplink: %w", err) + } else if err := m.PublicUplink.Set(linkIndex, dmac, smac); err != nil { + errs = append(errs, err) + } else { + written.PublicUplink++ + } + } + + return written, uplinkErr, errors.Join(errs...) +} + +// fillNodeSource writes NodeSourceAddress for node into node_src_addr_table. +func (m *Maps) fillNodeSource(node Node) error { + addr, err := NodeSourceAddress(node.Locator, node.NodeID) + if err != nil { + return err + } + return m.NodeSource.Set(addr) +} + +// Attachment is one attachment's identity and the kernel state its rows are +// derived from. +type Attachment struct { + // VPC and VPCAttachment are the base62 identifiers the host interface is + // named after. + VPC string + VPCAttachment string + HostIfindex uint32 + Block uint64 + // Argument is the VPC's VRFID on this node, its 12-bit uSID Argument. + Argument uint16 + VRFTableID uint32 + // InterfaceType is InterfaceTypeVeth or InterfaceTypeTap. + InterfaceType string + // LocalPrefixes are the guest CIDRs and gateway host addresses, in the + // form Registration.LocalPrefixes takes. + LocalPrefixes []string + // Gateways is nil for an attachment with no IPAM gateway, which has no + // tenant_gw_table row. + Gateways *Gateways +} + +// VRFRows records what one FillVRF call wrote, for RemoveVRFRows. +type VRFRows struct { + Counts + // LocalPrefixes are the local pass-through routes written. + LocalPrefixes []*net.IPNet +} + +// FillVRF writes the missing rows a's VRF needs: vrf_table and +// vpc_attribution_table for (Block, Argument), the VRF's shard routes from +// egress, and a's local pass-through routes in egress_route_table. Every +// row is attempted; the errors are joined. +// +// vrf_table and vpc_attribution_table hold one row per VRF, which CNI ADD +// overwrites on every attachment, so after several ADDs they hold whichever +// ran last. FillVRF cannot know that order and writes them from the first +// attachment it is called for; a caller wanting a stable choice calls it in a +// stable order. Delivery does not depend on either: usid_ingress reads the +// per-interface ifindex_egress_kind_table row, which is exact. +// +// Once a shard route fails because no configured shard resolves, every later +// FillVRF on this Maps skips shard routes, since each attempt waits out a +// neighbor solicitation per shard and none would succeed. The first failure +// is the one reported. +func (m *Maps) FillVRF(a Attachment, egress EgressConfig) (VRFRows, error) { + var ( + written VRFRows + errs []error + ) + if a.Block == uformat.BlockIngressSidecar { + return written, fmt.Errorf("attachment %s/%s: Block %#x belongs to the ingress sidecar", + a.VPC, a.VPCAttachment, a.Block) + } + egressKind, err := EgressKindForInterfaceType(a.InterfaceType) + if err != nil { + return written, fmt.Errorf("determine eBPF egress kind: %w", err) + } + + // Exact prefixes, not Lookup: the map's longest-prefix match would report + // every prefix present once the VRF's ::/0 route is. + present, err := m.egressRoutesPresent(a.VRFTableID) + if err != nil { + return written, err + } + // A prefix reported missing is about to be written, so it is recorded + // here and no later attachment sharing this VRF writes it again in this + // pass. A write that fails is reported, and retried by the next pass. + routeExists := func(prefix *net.IPNet) (bool, error) { + _, ok := present[prefix.String()] + present[prefix.String()] = struct{}{} + return ok, nil + } + tenantSIDs, err := tenantShardSIDs(egress, a.Argument) + switch { + case err != nil: + errs = append(errs, err) + case len(tenantSIDs) > 0 && !m.shardsUnresolvable: + n, err := installEgressRoutesIn(m.EgressRoute, a.VRFTableID, tenantSIDs, egress.NAT64Prefix, routeExists) + written.ShardEgressRoutes += n + if errors.Is(err, srv6.ErrNoShardResolvable) { + m.shardsUnresolvable = true + } + if err != nil { + errs = append(errs, fmt.Errorf("install shard egress routes: %w", err)) + } + } + + local, err := registerLocalEgressRoutesIn(m.EgressRoute, a.VRFTableID, a.LocalPrefixes, routeExists) + written.LocalPrefixes = local + written.LocalEgressRoutes += len(local) + if err != nil { + errs = append(errs, fmt.Errorf("register local pass-through egress routes: %w", err)) + } + + if _, ok, err := m.Registry.VRF.Get(a.Block, a.Argument); err != nil { + errs = append(errs, err) + } else if !ok { + if err := m.Registry.VRF.Register(a.Block, a.Argument, a.VRFTableID, egressKind); err != nil { + errs = append(errs, fmt.Errorf("register vrf_table entry: %w", err)) + } else { + written.VRF++ + } + } + + if _, ok, err := m.Registry.VPCAttribution.Get(a.Block, a.Argument); err != nil { + errs = append(errs, err) + } else if !ok { + ok, err := m.fillAttribution(a) + switch { + case err != nil: + errs = append(errs, err) + case ok: + written.VPCAttribution++ + } + } + + return written, errors.Join(errs...) +} + +// RemoveVRFRows removes rows FillVRF wrote for a, as written records them, for +// an attachment whose interface went away while they were written. Local +// routes in keepPrefixes, which another attachment in the same VRF table +// still needs, are kept. The vrf_table and vpc_attribution_table rows are +// removed only when keepVRF is false, meaning no other attachment shares +// (Block, Argument). Shard routes are per VRF and left alone. +func (m *Maps) RemoveVRFRows(a Attachment, written VRFRows, keepPrefixes map[string]struct{}, keepVRF bool) error { + var errs []error + for _, prefix := range written.LocalPrefixes { + if _, keep := keepPrefixes[prefix.String()]; keep { + continue + } + if err := m.EgressRoute.Unregister(a.VRFTableID, prefix); err != nil { + errs = append(errs, err) + continue + } + if present := m.routePrefixes[a.VRFTableID]; present != nil { + delete(present, prefix.String()) + } + } + if !keepVRF { + if written.VRF > 0 { + if err := m.Registry.VRF.Unregister(a.Block, a.Argument); err != nil { + errs = append(errs, err) + } + } + if written.VPCAttribution > 0 { + if err := m.Registry.VPCAttribution.Unregister(a.Block, a.Argument); err != nil { + errs = append(errs, err) + } + } + } + return errors.Join(errs...) +} + +// fillAttribution writes a's vpc_attribution_table row. Identifiers that do +// not decode are logged and skipped, as ADD skips them, since retrying cannot +// change them. +func (m *Maps) fillAttribution(a Attachment) (bool, error) { + vpcNum, vpcAttachmentNum, err := DecodeVPCIdentifiers(a.VPC, a.VPCAttachment) + if err != nil { + slog.Warn("Could not decode VPC/VPCAttachment for byte-accounting attribution; "+ + "vrf_table counters for this entry will report unattributed", + "vpc", a.VPC, "vpcAttachment", a.VPCAttachment, "err", err) + return false, nil + } + if err := m.Registry.VPCAttribution.Register(a.Block, a.Argument, vpcNum, vpcAttachmentNum); err != nil { + return false, fmt.Errorf("register vpc_attribution_table entry: %w", err) + } + return true, nil +} + +// FillInterface writes the missing rows keyed by a's host ifindex: +// ifindex_vrf_table, ifindex_egress_kind_table, and tenant_gw_table when a +// has gateways. The returned Counts records which rows it wrote, for +// RemoveInterfaceRows. +func (m *Maps) FillInterface(a Attachment) (Counts, error) { + var ( + written Counts + errs []error + ) + if a.Block == uformat.BlockIngressSidecar { + return written, fmt.Errorf("attachment %s/%s: Block %#x belongs to the ingress sidecar", + a.VPC, a.VPCAttachment, a.Block) + } + egressKind, err := EgressKindForInterfaceType(a.InterfaceType) + if err != nil { + return written, fmt.Errorf("determine eBPF egress kind: %w", err) + } + + if _, ok, err := m.IfindexVRF.Get(a.HostIfindex); err != nil { + errs = append(errs, err) + } else if !ok { + if err := m.IfindexVRF.Register(a.HostIfindex, a.Block, a.Argument); err != nil { + errs = append(errs, fmt.Errorf("register ifindex_vrf_table entry: %w", err)) + } else { + written.IfindexVRF++ + } + } + + if m.EgressKind != nil { + if _, ok, err := m.EgressKind.Get(a.HostIfindex); err != nil { + errs = append(errs, err) + } else if !ok { + if err := m.EgressKind.Register(a.HostIfindex, egressKind); err != nil { + errs = append(errs, fmt.Errorf("register ifindex_egress_kind_table entry: %w", err)) + } else { + written.EgressKind++ + } + } + } + + if m.Gateway != nil && a.Gateways != nil { + if _, _, ok, err := m.Gateway.Get(a.HostIfindex); err != nil { + errs = append(errs, err) + } else if !ok { + if err := registerGateways(m.Gateway, a.HostIfindex, *a.Gateways); err != nil { + errs = append(errs, fmt.Errorf("register tenant_gw_table entry: %w", err)) + } else { + written.TenantGateway++ + } + } + } + + return written, errors.Join(errs...) +} + +// RemoveInterfaceRows removes the rows keyed by ifindex that written, as +// FillInterface returned it, records were written. A caller uses it when the +// interface turns out to have been deleted while those rows were written. +func (m *Maps) RemoveInterfaceRows(ifindex uint32, written Counts) error { + var errs []error + if written.IfindexVRF > 0 { + if err := m.IfindexVRF.Unregister(ifindex); err != nil { + errs = append(errs, err) + } + } + if written.EgressKind > 0 && m.EgressKind != nil { + if err := m.EgressKind.Unregister(ifindex); err != nil { + errs = append(errs, err) + } + } + if written.TenantGateway > 0 && m.Gateway != nil { + if err := m.Gateway.Unregister(ifindex); err != nil { + errs = append(errs, err) + } + } + return errors.Join(errs...) +} diff --git a/internal/plumbing/ebpf/attachreg/fill_test.go b/internal/plumbing/ebpf/attachreg/fill_test.go new file mode 100644 index 00000000..b502e79f --- /dev/null +++ b/internal/plumbing/ebpf/attachreg/fill_test.go @@ -0,0 +1,241 @@ +// Copyright 2026 Datum Cloud, Inc. +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package attachreg + +import ( + "errors" + "fmt" + "net" + "reflect" + "testing" + "time" + + "github.com/cilium/ebpf" + + "go.datum.net/galactic/internal/plumbing/ebpf/egressroutemap" + "go.datum.net/galactic/internal/plumbing/ebpf/ifindexvrfmap" + "go.datum.net/galactic/internal/plumbing/ebpf/uformat" + "go.datum.net/galactic/internal/plumbing/ebpf/usidmap" + "go.datum.net/galactic/internal/plumbing/srv6" +) + +// memTable is an in-memory usidmap.Table keyed by any comparable key. +type memTable map[any]any + +func (m memTable) Put(key, value any) error { m[key] = value; return nil } + +func (m memTable) Lookup(key, valueOut any) error { + v, ok := m[key] + if !ok { + return ebpf.ErrKeyNotExist + } + reflect.ValueOf(valueOut).Elem().Set(reflect.ValueOf(v)) + return nil +} + +func (m memTable) Delete(key any) error { + if _, ok := m[key]; !ok { + return ebpf.ErrKeyNotExist + } + delete(m, key) + return nil +} + +func (m memTable) Iterate() usidmap.Iterator { + it := &memIterator{} + for k, v := range m { + it.keys = append(it.keys, k) + it.values = append(it.values, v) + } + return it +} + +// memIterator walks a snapshot of a memTable's entries. +type memIterator struct { + keys, values []any + idx int +} + +func (it *memIterator) Next(keyOut, valueOut any) bool { + if it.idx >= len(it.keys) { + return false + } + reflect.ValueOf(keyOut).Elem().Set(reflect.ValueOf(it.keys[it.idx])) + reflect.ValueOf(valueOut).Elem().Set(reflect.ValueOf(it.values[it.idx])) + it.idx++ + return true +} + +func (it *memIterator) Err() error { return nil } + +// newMemMaps returns Maps over empty memTables, without the optional +// ifindex_egress_kind_table and tenant_gw_table when withOptional is false. +func newMemMaps(withOptional bool) *Maps { + m := &Maps{ + Registry: &usidmap.Registry{ + VRF: usidmap.NewVRFTable(memTable{}), + Locator: usidmap.NewLocatorTable(memTable{}), + Function: usidmap.NewFunctionTable(memTable{}), + VPCAttribution: usidmap.NewVPCAttributionTable(memTable{}), + }, + IfindexVRF: ifindexvrfmap.NewIfindexVRFTable(memTable{}), + EgressRoute: egressroutemap.NewEgressRouteTable(memTable{}), + NodeSource: egressroutemap.NewNodeSourceAddress(memTable{}), + PublicUplink: egressroutemap.NewPublicUplink(memTable{}), + } + if withOptional { + m.EgressKind = ifindexvrfmap.NewEgressKindTable(memTable{}) + m.Gateway = ifindexvrfmap.NewGatewayTable(memTable{}) + } + return m +} + +const testBlock = uint64(0x2001_0db8_0001) + +func testAttachment() Attachment { + return Attachment{ + VPC: "jU", VPCAttachment: "abc", HostIfindex: 11, Block: testBlock, Argument: 0x21, VRFTableID: 7, + InterfaceType: InterfaceTypeVeth, + LocalPrefixes: []string{"fd20:30:ff01::/96", "fd20:30::1/128"}, + Gateways: &Gateways{IPv6: net.ParseIP("fd20:30::1")}, + } +} + +// TestFillVRF_ShardRoutesFillOnlyMissing checks the VRF's shard routes are +// built from the egress config with the attachment's Argument written into +// the shard SID, and that a route already present is not rewritten. +func TestFillVRF_ShardRoutesFillOnlyMissing(t *testing.T) { + calls := recordRouteAdds(t, nil) + m := newMemMaps(true) + egress := EgressConfig{ShardSIDs: "2001:db8:ff01:9:e001::", NAT64Prefix: testNAT64Prefix} + + // Stand in for a ::/0 route ADD already installed. + if err := m.EgressRoute.RegisterPassThrough(7, egressroutemap.DefaultPrefix); err != nil { + t.Fatalf("seed ::/0: %v", err) + } + + written, err := m.FillVRF(testAttachment(), egress) + if err != nil { + t.Fatalf("FillVRF: %v", err) + } + if written.ShardEgressRoutes != 1 || len(*calls) != 1 { + t.Fatalf("shard routes written %d, calls %+v; want only the missing NAT64 route", written.ShardEgressRoutes, + *calls) + } + c := (*calls)[0] + wantSID := net.ParseIP("2001:db8:ff01:9:e021::") + if c.table != 7 || c.prefix != testNAT64Prefix || len(c.sids) != 1 || !c.sids[0].Equal(wantSID) { + t.Errorf("shard route = %+v, want table 7, 64:ff9b::/96 toward %s", c, wantSID) + } + if written.VRF != 1 || written.VPCAttribution != 1 || written.LocalEgressRoutes != 2 { + t.Errorf("written = %+v, want the vrf_table, vpc_attribution_table and both local rows", written) + } +} + +func TestFillVRF_RejectsSidecarBlock(t *testing.T) { + m := newMemMaps(true) + a := testAttachment() + a.Block = uformat.BlockIngressSidecar + if _, err := m.FillVRF(a, EgressConfig{}); err == nil { + t.Error("FillVRF under the ingress sidecar's Block = nil error, want a refusal") + } + if _, err := m.FillInterface(a); err == nil { + t.Error("FillInterface under the ingress sidecar's Block = nil error, want a refusal") + } +} + +// TestFillInterface_WithoutOptionalMaps covers a datapath that predates +// ifindex_egress_kind_table and tenant_gw_table. +func TestFillInterface_WithoutOptionalMaps(t *testing.T) { + m := newMemMaps(false) + written, err := m.FillInterface(testAttachment()) + if err != nil { + t.Fatalf("FillInterface: %v", err) + } + if want := (Counts{IfindexVRF: 1}); written != want { + t.Errorf("written = %+v, want %+v", written, want) + } + if err := m.RemoveInterfaceRows(11, Counts{IfindexVRF: 1, EgressKind: 1, TenantGateway: 1}); err != nil { + t.Errorf("RemoveInterfaceRows: %v", err) + } + if _, ok, _ := m.IfindexVRF.Get(11); ok { + t.Error("ifindex_vrf_table row still present after RemoveInterfaceRows") + } +} + +func TestRemoveInterfaceRows_OnlyRemovesWhatWasWritten(t *testing.T) { + m := newMemMaps(true) + a := testAttachment() + if err := m.EgressKind.Register(a.HostIfindex, usidmap.EgressKindTap); err != nil { + t.Fatalf("seed egress kind: %v", err) + } + written, err := m.FillInterface(a) + if err != nil { + t.Fatalf("FillInterface: %v", err) + } + if err := m.RemoveInterfaceRows(a.HostIfindex, written); err != nil { + t.Fatalf("RemoveInterfaceRows: %v", err) + } + if kind, ok, _ := m.EgressKind.Get(a.HostIfindex); !ok || kind != usidmap.EgressKindTap { + t.Errorf("egress kind = %d ok %v, want the pre-existing tap row kept", kind, ok) + } + if _, _, ok, _ := m.Gateway.Get(a.HostIfindex); ok { + t.Error("tenant_gw_table row still present, want the written row removed") + } +} + +// TestFillVRF_UnresolvableShardsTriedOncePerPass stands in for a fabric +// outage: every shard attempt waits out its neighbor solicitation and fails. +// Only the first VRF's attempt may pay that wait; later VRFs on the same Maps +// skip their shard routes, so a pass over many VRFs stays bounded, and their +// local rows are still written. +func TestFillVRF_UnresolvableShardsTriedOncePerPass(t *testing.T) { + const resolveWait = 50 * time.Millisecond + original := egressPrefixRouteAddFn + t.Cleanup(func() { egressPrefixRouteAddFn = original }) + var attempts int + egressPrefixRouteAddFn = func(*egressroutemap.EgressRouteTable, uint32, *net.IPNet, []net.IP) error { + attempts++ + time.Sleep(resolveWait) + return fmt.Errorf("%w yet for ::/0", srv6.ErrNoShardResolvable) + } + + m := newMemMaps(true) + egress := EgressConfig{ShardSIDs: "2001:db8:ff01:9:e001::,2001:db8:ff02:9:e001::", NAT64Prefix: testNAT64Prefix} + start := time.Now() + for i := range 20 { + a := testAttachment() + a.Argument = uint16(0x21 + i) + a.VRFTableID = uint32(7 + i) + written, err := m.FillVRF(a, egress) + if i == 0 && !errors.Is(err, srv6.ErrNoShardResolvable) { + t.Errorf("first FillVRF error = %v, want the unresolvable shard reported", err) + } + if i > 0 && err != nil { + t.Errorf("FillVRF %d error = %v, want shard routes skipped without a further error", i, err) + } + if written.VRF != 1 || written.LocalEgressRoutes != 2 { + t.Errorf("FillVRF %d wrote %+v, want its vrf_table and local rows regardless", i, written.Counts) + } + } + if attempts != 1 { + t.Errorf("shard route attempts = %d, want 1 for the whole pass", attempts) + } + if elapsed := time.Since(start); elapsed > 10*resolveWait { + t.Errorf("pass over 20 VRFs took %v, want it bounded by about one resolution wait", elapsed) + } +} + +func TestFillNode_NotConfiguredWritesNothing(t *testing.T) { + m := newMemMaps(true) + resolve := func() (int, net.HardwareAddr, net.HardwareAddr, error) { + t.Fatal("resolver called for a node with no SRv6 identity") + return 0, nil, nil, nil + } + written, uplinkErr, err := m.FillNode(Node{}, resolve) + if written.Total() != 0 || uplinkErr != nil || err != nil { + t.Errorf("FillNode(zero) = %+v, %v, %v; want nothing", written, uplinkErr, err) + } +} diff --git a/internal/plumbing/ebpf/attachreg/register.go b/internal/plumbing/ebpf/attachreg/register.go new file mode 100644 index 00000000..3df0d17a --- /dev/null +++ b/internal/plumbing/ebpf/attachreg/register.go @@ -0,0 +1,596 @@ +// Copyright 2025 Datum Cloud, Inc. +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package attachreg + +import ( + "errors" + "fmt" + "log/slog" + "net" + "net/netip" + "os" + "path/filepath" + "strconv" + + "github.com/cilium/ebpf" + "github.com/vishvananda/netlink" + + "go.datum.net/galactic/internal/config" + "go.datum.net/galactic/internal/plumbing/ebpf/attach" + "go.datum.net/galactic/internal/plumbing/ebpf/egressroutemap" + "go.datum.net/galactic/internal/plumbing/ebpf/ifindexvrfmap" + "go.datum.net/galactic/internal/plumbing/ebpf/uformat" + "go.datum.net/galactic/internal/plumbing/ebpf/usidmap" + "go.datum.net/galactic/internal/plumbing/intf" + "go.datum.net/galactic/internal/plumbing/srv6" + "go.datum.net/galactic/internal/plumbing/vrf" +) + +// InterfaceTypeVeth and InterfaceTypeTap are the attachment interface types +// EgressKindForInterfaceType accepts. +const ( + InterfaceTypeVeth = "veth" + InterfaceTypeTap = "tap" +) + +// Node is this node's SRv6 identity, from its BGPRouter. A zero Locator or +// NodeID means SRv6 is deliberately not configured for it. +type Node struct { + Locator string + NodeID int32 +} + +// Configured reports whether n carries an SRv6 identity at all. +func (n Node) Configured() bool { + return n.Locator != "" && n.NodeID != 0 +} + +// Block validates n's Node-ID range and derives the uSID Block from its +// locator. +func (n Node) Block() (uint64, error) { + if n.NodeID < uformat.NodeIDMin || n.NodeID > uformat.NodeIDMax { + return 0, fmt.Errorf("eBPF registration: nodeID %d out of range [%#x,%#x]", + n.NodeID, uint16(uformat.NodeIDMin), uint16(uformat.NodeIDMax)) + } + prefix, err := netip.ParsePrefix(n.Locator) + if err != nil { + return 0, fmt.Errorf("parse SRv6 locator %q for eBPF registration: %w", n.Locator, err) + } + block, err := uformat.Block(prefix.Addr()) + if err != nil { + return 0, fmt.Errorf("derive eBPF uSID Block from locator %q: %w", n.Locator, err) + } + return block, nil +} + +// EgressConfig is the fabric-wide egress configuration a tenant VRF's shard +// routes are built from, as the raw comma-separated values the host conflist +// carries. The zero value configures no shard. +type EgressConfig struct { + ShardSIDs string + NAT64Prefix string +} + +// Registration is one attachment's CNI ADD-time input to RegisterDatapath. +type Registration struct { + Node Node + VPC string + VPCAttachment string + // InterfaceType is InterfaceTypeVeth or InterfaceTypeTap. + InterfaceType string + // Argument is the attachment's VRFID, its 12-bit uSID Argument. + Argument uint16 + // LocalPrefixes are the attachment's guest CIDRs and gateway host + // addresses. Each is registered as a local pass-through + // egress_route_table entry in the VPC's VRF, so local destinations outrank + // the VRF's ::/0 NAT66 default. Empty is valid and registers nothing. + LocalPrefixes []string + Egress EgressConfig +} + +// RegisterDatapath registers one attachment against the eBPF uSID datapath's +// pinned maps under pinDir. registered is false with a nil error only when +// r.Node has no SRv6 locator or node ID configured, meaning SRv6 is +// deliberately not set up for it. Any other failure returns an error. +func RegisterDatapath(pinDir string, r Registration) (registered bool, err error) { + if !r.Node.Configured() { + return false, nil + } + + block, err := r.Node.Block() + if err != nil { + return false, err + } + + egressKind, err := EgressKindForInterfaceType(r.InterfaceType) + if err != nil { + return false, fmt.Errorf("determine eBPF egress kind: %w", err) + } + + vrfTableID, err := vrf.TableID(r.VPC) + if err != nil { + return false, fmt.Errorf("look up VRF table id for eBPF registration: %w", err) + } + + // Installs or refreshes this VRF's NAT66 default egress route. The + // optional routing plugin in this chain may be absent from a given + // conflist, and this route must exist wherever a shard is configured, so + // it is written here. + if err := installEgressRoutes(pinDir, vrfTableID, r.Argument, r.Egress); err != nil { + return false, fmt.Errorf("install NAT66 default egress route: %w", err) + } + + if err := registerLocalEgressRoutes(pinDir, vrfTableID, r.LocalPrefixes); err != nil { + return false, fmt.Errorf("register local pass-through egress route: %w", err) + } + + // The host-side interface's ifindex keys this attachment's + // ifindex_vrf_table row, and only the host side's deterministic name is + // known here. + hostIfindex, err := HostInterfaceIndex(r.VPC, r.VPCAttachment) + if err != nil { + return false, fmt.Errorf("resolve host interface ifindex for eBPF registration: %w", err) + } + + registry, closer, err := usidmap.OpenPinnedRegistry(pinDir) + if err != nil { + return false, fmt.Errorf("open pinned eBPF uSID maps: %w", err) + } + defer func() { _ = closer.Close() }() + + if err := registry.Locator.Register(block, uint16(r.Node.NodeID)); err != nil { + return false, fmt.Errorf("register eBPF locator_table entry: %w", err) + } + if err := registry.Function.Register(block, uformat.FunctionEndDT46); err != nil { + return false, fmt.Errorf("register eBPF function_table entry: %w", err) + } + + if err := registry.VRF.Register(block, r.Argument, vrfTableID, egressKind); err != nil { + return false, fmt.Errorf("register eBPF vrf_table entry: %w", err) + } + + // Attribute this (block, argument) to its VPC/VPCAttachment for byte + // accounting, keyed identically to the vrf_table row just registered. + // Non-fatal: forwarding is unaffected, and only attribution for this + // attachment is degraded until the next ADD or repair. + vpcNum, vpcAttachmentNum, err := DecodeVPCIdentifiers(r.VPC, r.VPCAttachment) + if err != nil { + slog.Warn("ADD: could not decode VPC/VPCAttachment for byte-accounting attribution; "+ + "vrf_table counters for this entry will report unattributed until the next ADD/DEL", + "vpc", r.VPC, "vpcAttachment", r.VPCAttachment, "err", err) + } else if err := registry.VPCAttribution.Register(block, r.Argument, vpcNum, vpcAttachmentNum); err != nil { + slog.Warn("ADD: could not register eBPF vpc_attribution_table entry; "+ + "vrf_table counters for this entry will report unattributed until the next ADD/DEL", + "vpc", r.VPC, "vpcAttachment", r.VPCAttachment, "err", err) + } + + ifindexTable, ifindexCloser, err := ifindexvrfmap.OpenPinned(pinDir) + if err != nil { + return false, fmt.Errorf("open pinned eBPF ifindex_vrf_table: %w", err) + } + defer func() { _ = ifindexCloser.Close() }() + if err := ifindexTable.Register(hostIfindex, block, r.Argument); err != nil { + return false, fmt.Errorf("register eBPF ifindex_vrf_table entry: %w", err) + } + if err := registerEgressKind(pinDir, hostIfindex, egressKind); err != nil { + return false, err + } + + // Attach usid_egress to this attachment's host-side interface. This is + // what translates a reply's source address on the way back out. + hostName := intf.GenerateInterfaceNameHost(r.VPC, r.VPCAttachment) + if err := attachUsidEgress(pinDir, hostName); err != nil { + return false, fmt.Errorf("attach eBPF usid_egress to host interface %q: %w", hostName, err) + } + + // Register this node's own SRv6 SID base. A per-node constant rather than + // a per-attachment one, but idempotent and cheap enough to redo on every + // ADD instead of adding a once-per-node lifecycle hook. + // + // Non-fatal, unlike the registrations above: usid_egress fails open while + // the entry is missing, and failing every pod attach is worse than only + // traffic needing encapsulation failing. + if err := registerNodeSourceAddress(pinDir, r.Node.Locator, r.Node.NodeID); err != nil { + slog.Warn("ADD: could not register this node's own SRv6 SID; "+ + "egress routing will fail open until this succeeds", "err", err) + } + + // Register this node's fabric-uplink next hop. Same per-node, + // redo-on-every-ADD, non-fatal shape as registerNodeSourceAddress above, + // for the same reason: resolving it needs a converged underlay neighbor. + // usid_egress falls through to egress_route_table while the entry is + // absent. + if err := registerPublicUplink(pinDir); err != nil { + slog.Warn("ADD: could not register this node's own public uplink; "+ + "a DSR backend's VIP-sourced reply traffic will fail open to egress_route_table until this succeeds", + "err", err) + } + + return true, nil +} + +// DecodeVPCIdentifiers decodes vpc and vpcAttachment, the base62 identifiers +// CNI configuration carries, into the fixed-width numeric form +// vpc_attribution_table stores: 48-bit VPC, 16-bit VPCAttachment. Leading +// zero digits, as an interface name pads them with, do not change the result. +func DecodeVPCIdentifiers(vpc, vpcAttachment string) (vpcNum uint64, vpcAttachmentNum uint32, err error) { + vpcHex, err := intf.Base62ToHex(vpc) + if err != nil { + return 0, 0, fmt.Errorf("decode vpc %q: %w", vpc, err) + } + vpcNum, err = strconv.ParseUint(vpcHex, 16, 48) + if err != nil { + return 0, 0, fmt.Errorf("parse decoded vpc %q (hex %q) as uint48: %w", vpc, vpcHex, err) + } + + vpcAttachmentHex, err := intf.Base62ToHex(vpcAttachment) + if err != nil { + return 0, 0, fmt.Errorf("decode vpcAttachment %q: %w", vpcAttachment, err) + } + vpcAttachmentNum64, err := strconv.ParseUint(vpcAttachmentHex, 16, 16) + if err != nil { + return 0, 0, fmt.Errorf("parse decoded vpcAttachment %q (hex %q) as uint16: %w", + vpcAttachment, vpcAttachmentHex, err) + } + return vpcNum, uint32(vpcAttachmentNum64), nil +} + +// NodeSourceAddress returns the value node_src_addr_table holds for a node +// with this locator and nodeID: its own End.DT46 SID base, which usid_egress +// completes with the packet's Argument and stamps into every outer header it +// pushes. Only a SID is both routable across the fabric and decapsulated on +// arrival, which a shard's reply needs. +func NodeSourceAddress(locator string, nodeID int32) (net.IP, error) { + sid, err := srv6.NodeSIDBase(locator, nodeID) + if err != nil { + return nil, fmt.Errorf("derive node SID base: %w", err) + } + return net.IP(sid.AsSlice()), nil +} + +// registerNodeSourceAddress writes NodeSourceAddress(locator, nodeID) into +// the node_src_addr_table pinned under pinDir. While the entry is missing, +// every egress_route_table hit fails open instead of encapsulating. +func registerNodeSourceAddress(pinDir, locator string, nodeID int32) error { + addr, err := NodeSourceAddress(locator, nodeID) + if err != nil { + return err + } + nodeSrc, closer, err := egressroutemap.OpenPinnedNodeSourceAddress(pinDir) + if err != nil { + return fmt.Errorf("open pinned node_src_addr_table: %w", err) + } + defer func() { _ = closer.Close() }() + return nodeSrc.Set(addr) +} + +// registerPublicUplink resolves this node's fabric-uplink next hop and writes +// it into the public_uplink_table pinned under pinDir: where usid_egress +// redirects a DSR backend's VIP-sourced reply once apply_vip_xlat has +// rewritten its source, bypassing egress_route_table's NAT66 default. +func registerPublicUplink(pinDir string) error { + linkIndex, dmac, smac, err := srv6.ResolvePublicUplink() + if err != nil { + return fmt.Errorf("resolve public uplink: %w", err) + } + uplink, closer, err := egressroutemap.OpenPinnedPublicUplink(pinDir) + if err != nil { + return fmt.Errorf("open pinned public_uplink_table: %w", err) + } + defer func() { _ = closer.Close() }() + return uplink.Set(linkIndex, dmac, smac) +} + +// attachUsidEgress loads usid_egress from its pin and attaches it to +// ifaceName's TC ingress hook. Without it a reply leaves with its source +// address untranslated and the client discards it. Idempotent. +func attachUsidEgress(pinDir, ifaceName string) error { + program, err := ebpf.LoadPinnedProgram(filepath.Join(pinDir, attach.UsidEgressPinName), nil) + if err != nil { + return fmt.Errorf("load pinned usid_egress program: %w", err) + } + defer func() { _ = program.Close() }() + + return attach.AttachEgress(program, ifaceName) +} + +// HostPrefix returns ip as a host prefix, "/32" for IPv4 and "/128" for IPv6, +// in the CIDR string form LocalPrefixes carries. +func HostPrefix(ip net.IP) string { + if ip.To4() != nil { + return ip.String() + "/32" + } + return ip.String() + "/128" +} + +// registerLocalEgressRoutes registers each of prefixes as a local +// pass-through egress_route_table entry in Linux VRF table vrfTableID, in the +// map pinned under pinDir. Idempotent. +func registerLocalEgressRoutes(pinDir string, vrfTableID uint32, prefixes []string) error { + if len(prefixes) == 0 { + return nil + } + table, closer, err := egressroutemap.OpenPinnedEgressRouteTable(pinDir) + if err != nil { + return fmt.Errorf("open pinned egress_route_table: %w", err) + } + defer func() { _ = closer.Close() }() + + _, err = registerLocalEgressRoutesIn(table, vrfTableID, prefixes, nil) + return err +} + +// routeExistsFn reports whether egress_route_table already holds an entry for +// a prefix, letting a caller skip it. A nil routeExistsFn skips nothing. +type routeExistsFn func(prefix *net.IPNet) (bool, error) + +// registerLocalEgressRoutesIn registers each of prefixes not skipped by exists +// as a local pass-through entry in Linux VRF table vrfTableID, and returns the +// prefixes it wrote. A prefix that does not parse is a hard error rather than a +// silent skip: every caller builds them with a CIDR String or HostPrefix. +func registerLocalEgressRoutesIn( + table *egressroutemap.EgressRouteTable, vrfTableID uint32, prefixes []string, exists routeExistsFn, +) ([]*net.IPNet, error) { + var written []*net.IPNet + for _, p := range prefixes { + _, prefix, err := net.ParseCIDR(p) + if err != nil { + return written, fmt.Errorf("parse prefix %q: %w", p, err) + } + if exists != nil { + found, err := exists(prefix) + if err != nil { + return written, err + } + if found { + continue + } + } + if err := table.RegisterPassThrough(vrfTableID, prefix); err != nil { + return written, fmt.Errorf("register local pass-through route for %s: %w", p, err) + } + written = append(written, prefix) + } + return written, nil +} + +// installEgressRoutes installs or refreshes vrfTableID's egress routes toward +// the configured shards, in the egress_route_table pinned under pinDir: the +// ::/0 default that reaches the IPv6 internet and, where this fabric has +// NAT64, a more-specific route for each NAT64 prefix. Idempotent. +// +// argument is this attachment's VRFID, written into every shard SID these +// routes encapsulate toward. A shard reads it back out of the outer +// destination to tell one tenant on this node from another; without it two +// tenants with overlapping inner tuples would share one connection row. Both +// routes point at the same shard SID, since a shard picks the translation from +// the inner destination. +// +// No shard configured is not an error and installs nothing. A shard SID that +// is invalid, or that has no reachable route yet, is an error. +func installEgressRoutes(pinDir string, vrfTableID uint32, argument uint16, cfg EgressConfig) error { + tenantSIDs, err := tenantShardSIDs(cfg, argument) + if err != nil || len(tenantSIDs) == 0 { + return err + } + table, closer, err := egressroutemap.OpenPinnedEgressRouteTable(pinDir) + if err != nil { + return fmt.Errorf("open pinned egress_route_table: %w", err) + } + defer func() { _ = closer.Close() }() + + _, err = installEgressRoutesIn(table, vrfTableID, tenantSIDs, cfg.NAT64Prefix, nil) + return err +} + +// tenantShardSIDs parses cfg's shard list and writes argument into every SID. +// An empty list returns none and no error. +func tenantShardSIDs(cfg EgressConfig, argument uint16) ([]net.IP, error) { + shardSIDs, err := config.ParseEgressShardSIDs(cfg.ShardSIDs) + if err != nil { + return nil, fmt.Errorf("parse %s: %w", config.EnvCNIEgressShardSIDs, err) + } + if len(shardSIDs) == 0 { + return nil, nil + } + tenantSIDs, err := ShardSIDsForTenant(shardSIDs, argument) + if err != nil { + return nil, fmt.Errorf("apply tenant argument to %s: %w", config.EnvCNIEgressShardSIDs, err) + } + return tenantSIDs, nil +} + +// egressPrefixRouteAddFn is a variable so tests can observe route installs +// without resolving a real shard. +var egressPrefixRouteAddFn = srv6.EgressPrefixRouteAddTo + +// installEgressRoutesIn installs vrfTableID's ::/0 route and then one route per +// NAT64 prefix in nat64Raw, each toward tenantSIDs, skipping any prefix exists +// reports present. It returns how many routes it wrote. An unset NAT64 list +// means this fabric has no NAT64; a set but invalid one is an error, returned +// after the ::/0 route is installed. +func installEgressRoutesIn( + table *egressroutemap.EgressRouteTable, vrfTableID uint32, tenantSIDs []net.IP, nat64Raw string, + exists routeExistsFn, +) (int, error) { + written := 0 + add := func(prefix *net.IPNet) error { + if exists != nil { + found, err := exists(prefix) + if err != nil || found { + return err + } + } + if err := egressPrefixRouteAddFn(table, vrfTableID, prefix, tenantSIDs); err != nil { + return err + } + written++ + return nil + } + + if err := add(egressroutemap.DefaultPrefix); err != nil { + return written, err + } + prefixes, err := config.ParseNAT64Prefixes(nat64Raw) + if err != nil { + return written, fmt.Errorf("parse %s: %w", config.EnvCNINAT64Prefix, err) + } + for _, prefix := range prefixes { + if err := add(prefix); err != nil { + return written, err + } + } + return written, nil +} + +// ShardSIDsForTenant returns sids with each SID's 12-bit Argument replaced by +// argument, leaving Block, Node-ID and Function as configured. Whatever +// Argument an operator configured is overwritten: one configured value is +// shared by every VRF on every node, so it identifies no tenant. +// +// A SID that is not a well-formed uFMT 48+16 address is an error rather than +// passed through, since writing an Argument into it would produce a +// plausible-looking destination that addresses nothing. An IPv4 entry is +// unmapped first so it fails as "not an IPv6 address". +func ShardSIDsForTenant(sids []net.IP, argument uint16) ([]net.IP, error) { + out := make([]net.IP, 0, len(sids)) + for _, sid := range sids { + addr, ok := netip.AddrFromSlice(sid.To16()) + if !ok { + return nil, fmt.Errorf("egress shard SID %s is not a 16-byte address", sid) + } + fields, err := uformat.Decode(addr.Unmap()) + if err != nil { + return nil, fmt.Errorf("decode egress shard SID %s: %w", sid, err) + } + fields.Argument = argument + tenant, err := uformat.Encode(fields) + if err != nil { + return nil, fmt.Errorf("encode egress shard SID %s with argument %#x: %w", sid, argument, err) + } + out = append(out, net.IP(tenant.AsSlice())) + } + return out, nil +} + +// HostInterfaceIndex resolves an attachment's host-side veth or tap +// interface's kernel ifindex by the deterministic name the master plugin +// created it under. +func HostInterfaceIndex(vpc, vpcAttachment string) (uint32, error) { + hostName := intf.GenerateInterfaceNameHost(vpc, vpcAttachment) + link, err := netlink.LinkByName(hostName) + if err != nil { + return 0, fmt.Errorf("look up host interface %q: %w", hostName, err) + } + return uint32(link.Attrs().Index), nil +} + +// registerEgressKind records, in the ifindex_egress_kind_table pinned under +// pinDir, which redirect helper usid_ingress uses to deliver into the host-side +// interface hostIfindex. +// +// A map that is not pinned yet is logged, not returned: the CNI binary is +// replaced before the datapath that pins the map reloads, and the datapath's +// fallback for a missing entry, a plain redirect, still delivers to both +// kinds. +func registerEgressKind(pinDir string, hostIfindex, egressKind uint32) error { + table, closer, err := ifindexvrfmap.OpenPinnedEgressKind(pinDir) + if errors.Is(err, os.ErrNotExist) { + slog.Warn("ADD: eBPF ifindex_egress_kind_table is not pinned yet; "+ + "delivery to this attachment uses the plain-redirect fallback until its next ADD", + "hostIfindex", hostIfindex, "err", err) + return nil + } + if err != nil { + return fmt.Errorf("open pinned eBPF ifindex_egress_kind_table: %w", err) + } + defer func() { _ = closer.Close() }() + if err := table.Register(hostIfindex, egressKind); err != nil { + return fmt.Errorf("register eBPF ifindex_egress_kind_table entry: %w", err) + } + return nil +} + +// Gateways are an attachment's IPAM gateway addresses. Either may be nil, +// meaning no gateway in that family. +type Gateways struct { + IPv6 net.IP + IPv4 net.IP +} + +// registerGateways writes gw into tenant_gw_table for hostIfindex, the +// addresses usid_egress sends an ICMP Packet Too Big or Fragmentation Needed +// from. +func registerGateways(table *ifindexvrfmap.GatewayTable, hostIfindex uint32, gw Gateways) error { + gw6, _ := netip.AddrFromSlice(gw.IPv6) + gw4, _ := netip.AddrFromSlice(gw.IPv4) + return table.Register(hostIfindex, gw6, gw4.Unmap()) +} + +// RegisterTenantGateway records an attachment's IPAM gateways in the +// tenant_gw_table pinned under pinDir. A nil gw, an attachment with no IPAM +// result such as a tap workload managing its own addressing, removes the +// entry instead, so a reused ifindex never keeps another attachment's +// gateway. +// +// Every failure is logged, not returned. Without an entry the datapath drops +// an oversized packet with no error and counts it, which is not worth failing +// the attach over. +func RegisterTenantGateway(pinDir, vpc, vpcAttachment string, gw *Gateways) { + hostIfindex, err := HostInterfaceIndex(vpc, vpcAttachment) + if err != nil { + slog.Warn("ADD: could not resolve host interface for eBPF tenant_gw_table; "+ + "packets too big for the fabric get no ICMP error until the next ADD", "err", err) + return + } + table, closer, err := ifindexvrfmap.OpenPinnedGateway(pinDir) + if err != nil { + slog.Warn("ADD: could not open eBPF tenant_gw_table; "+ + "packets too big for the fabric get no ICMP error until the next ADD", + "hostIfindex", hostIfindex, "err", err) + return + } + defer func() { _ = closer.Close() }() + + if gw == nil { + if err := table.Unregister(hostIfindex); err != nil { + slog.Warn("ADD: could not clear eBPF tenant_gw_table entry", "hostIfindex", hostIfindex, "err", err) + } + return + } + if err := registerGateways(table, hostIfindex, *gw); err != nil { + slog.Warn("ADD: could not register eBPF tenant_gw_table entry; "+ + "packets too big for the fabric get no ICMP error until the next ADD", + "hostIfindex", hostIfindex, "err", err) + } +} + +// EgressKindForInterfaceType maps InterfaceTypeVeth or InterfaceTypeTap to the +// egress kind the datapath uses to choose between bpf_redirect_peer, which +// crosses into the container's netns, and plain bpf_redirect, which does not. +func EgressKindForInterfaceType(ifaceType string) (uint32, error) { + switch ifaceType { + case InterfaceTypeVeth: + return usidmap.EgressKindVeth, nil + case InterfaceTypeTap: + return usidmap.EgressKindTap, nil + default: + return 0, fmt.Errorf("unknown interface type %q", ifaceType) + } +} + +// InterfaceTypeForLink returns the attachment interface type of a host-side +// link read back from the kernel: InterfaceTypeTap for a tuntap device and +// InterfaceTypeVeth for a veth. ok is false for any other link type. +func InterfaceTypeForLink(link netlink.Link) (ifaceType string, ok bool) { + switch link.Type() { + case "tuntap": + return InterfaceTypeTap, true + case "veth": + return InterfaceTypeVeth, true + default: + return "", false + } +} diff --git a/internal/plumbing/ebpf/attachreg/register_test.go b/internal/plumbing/ebpf/attachreg/register_test.go new file mode 100644 index 00000000..f1abaf3b --- /dev/null +++ b/internal/plumbing/ebpf/attachreg/register_test.go @@ -0,0 +1,333 @@ +// Copyright 2025 Datum Cloud, Inc. +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package attachreg + +import ( + "errors" + "fmt" + "net" + "net/netip" + "os" + "reflect" + "testing" + + "github.com/vishvananda/netlink" + + "go.datum.net/galactic/internal/config" + "go.datum.net/galactic/internal/plumbing/ebpf/egressroutemap" + "go.datum.net/galactic/internal/plumbing/ebpf/uformat" + "go.datum.net/galactic/internal/plumbing/ebpf/usidmap" +) + +// testNAT64Prefix is the NAT64 prefix the egress route tests configure. +const ( + testNAT64Prefix = "64:ff9b::/96" + defaultPrefix = "::/0" +) + +func TestEgressKindForInterfaceType(t *testing.T) { + tests := []struct { + name string + iface string + want uint32 + wantErr bool + }{ + {name: "VethMapsToEgressKindVeth", iface: InterfaceTypeVeth, want: usidmap.EgressKindVeth}, + {name: "TapMapsToEgressKindTap", iface: InterfaceTypeTap, want: usidmap.EgressKindTap}, + {name: "EmptyTypeErrors", iface: "", wantErr: true}, + {name: "UnknownTypeErrors", iface: "bogus", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := EgressKindForInterfaceType(tt.iface) + if (err != nil) != tt.wantErr { + t.Fatalf("EgressKindForInterfaceType(%q) error = %v, wantErr %v", tt.iface, err, tt.wantErr) + } + if !tt.wantErr && got != tt.want { + t.Errorf("EgressKindForInterfaceType(%q) = %d, want %d", tt.iface, got, tt.want) + } + }) + } +} + +func TestInterfaceTypeForLink(t *testing.T) { + tests := []struct { + name string + link netlink.Link + want string + wantOK bool + }{ + {name: "Tuntap", link: &netlink.Tuntap{}, want: InterfaceTypeTap, wantOK: true}, + {name: "Veth", link: &netlink.Veth{}, want: InterfaceTypeVeth, wantOK: true}, + {name: "Dummy", link: &netlink.Dummy{}, wantOK: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := InterfaceTypeForLink(tt.link) + if ok != tt.wantOK || got != tt.want { + t.Errorf("InterfaceTypeForLink(%s) = %q, %v; want %q, %v", tt.link.Type(), got, ok, tt.want, tt.wantOK) + } + }) + } +} + +func TestHostPrefix(t *testing.T) { + tests := []struct { + name string + ip net.IP + want string + }{ + {name: "IPv6", ip: net.ParseIP("fd20:30::1"), want: "fd20:30::1/128"}, + {name: "IPv4SixteenByte", ip: net.ParseIP("172.21.1.1"), want: "172.21.1.1/32"}, + {name: "IPv4FourByte", ip: net.ParseIP("172.21.1.1").To4(), want: "172.21.1.1/32"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := HostPrefix(tt.ip); got != tt.want { + t.Errorf("HostPrefix(%v) = %q, want %q", tt.ip, got, tt.want) + } + }) + } +} + +func TestDecodeVPCIdentifiers_IgnoresZeroPadding(t *testing.T) { + vpc, att, err := DecodeVPCIdentifiers("jU", "abc") + if err != nil { + t.Fatalf("DecodeVPCIdentifiers(jU, abc) error = %v", err) + } + paddedVPC, paddedAtt, err := DecodeVPCIdentifiers("0000000jU", "abc") + if err != nil { + t.Fatalf("DecodeVPCIdentifiers(0000000jU, abc) error = %v", err) + } + if vpc != paddedVPC || att != paddedAtt { + t.Errorf("padded decode = (%d, %d), unpadded = (%d, %d); want equal", paddedVPC, paddedAtt, vpc, att) + } +} + +// TestInstallEgressRoutes_NoShardIsANoop covers the zero EgressConfig, which +// must install nothing and never open a pinned map. +func TestInstallEgressRoutes_NoShardIsANoop(t *testing.T) { + if err := installEgressRoutes("/nonexistent/galactic-pin-dir", 1, 0x005, EgressConfig{}); err != nil { + t.Errorf("installEgressRoutes with no shard = %v, want nil", err) + } +} + +// TestShardSIDsForTenant_WritesTheArgument guards against every tenant VRF on +// a node encapsulating toward a byte-identical shard SID, which would let two +// same-node tenants with overlapping ULAs share a connection row. +func TestShardSIDsForTenant_WritesTheArgument(t *testing.T) { + sids, err := config.ParseEgressShardSIDs("2001:db8:ff01:9:e001::,2001:db8:ff02:9:e001::") + if err != nil { + t.Fatalf("config.ParseEgressShardSIDs() error = %v, want nil", err) + } + + got, err := ShardSIDsForTenant(sids, 0x2a5) + if err != nil { + t.Fatalf("ShardSIDsForTenant() error = %v, want nil", err) + } + + want := []string{"2001:db8:ff01:9:e2a5::", "2001:db8:ff02:9:e2a5::"} + if len(got) != len(want) { + t.Fatalf("ShardSIDsForTenant() = %v, want %v", got, want) + } + for i, w := range want { + if got[i].String() != w { + t.Errorf("ShardSIDsForTenant()[%d] = %v, want %s", i, got[i], w) + } + } +} + +// TestShardSIDsForTenant_DistinctPerTenant: two VRFIDs on one node must not +// produce the same destination. +func TestShardSIDsForTenant_DistinctPerTenant(t *testing.T) { + sids, err := config.ParseEgressShardSIDs("2001:db8:ff01:9:e001::") + if err != nil { + t.Fatalf("config.ParseEgressShardSIDs() error = %v, want nil", err) + } + + a, err := ShardSIDsForTenant(sids, 0x001) + if err != nil { + t.Fatalf("ShardSIDsForTenant(0x001) error = %v, want nil", err) + } + b, err := ShardSIDsForTenant(sids, 0x002) + if err != nil { + t.Fatalf("ShardSIDsForTenant(0x002) error = %v, want nil", err) + } + if a[0].Equal(b[0]) { + t.Errorf("VRFIDs 0x001 and 0x002 both encapsulate toward %v; they must differ", a[0]) + } +} + +// TestShardSIDsForTenant_OverwritesAConfiguredArgument: an operator-supplied +// Argument identifies no tenant, so it is replaced rather than honoured. +func TestShardSIDsForTenant_OverwritesAConfiguredArgument(t *testing.T) { + sids, err := config.ParseEgressShardSIDs("2001:db8:ff01:9:efff::") + if err != nil { + t.Fatalf("config.ParseEgressShardSIDs() error = %v, want nil", err) + } + + got, err := ShardSIDsForTenant(sids, 0x007) + if err != nil { + t.Fatalf("ShardSIDsForTenant() error = %v, want nil", err) + } + if want := "2001:db8:ff01:9:e007::"; got[0].String() != want { + t.Errorf("ShardSIDsForTenant() = %v, want %s", got[0], want) + } +} + +// TestShardSIDsForTenant_PreservesBlockNodeIDAndFunction guards the fields the +// Argument rewrite must leave alone. +func TestShardSIDsForTenant_PreservesBlockNodeIDAndFunction(t *testing.T) { + sids, err := config.ParseEgressShardSIDs("2001:db8:ff01:9:e001::") + if err != nil { + t.Fatalf("config.ParseEgressShardSIDs() error = %v, want nil", err) + } + + got, err := ShardSIDsForTenant(sids, 0x123) + if err != nil { + t.Fatalf("ShardSIDsForTenant() error = %v, want nil", err) + } + addr, ok := netip.AddrFromSlice(got[0].To16()) + if !ok { + t.Fatalf("ShardSIDsForTenant() returned %v, which is not a 16-byte address", got[0]) + } + fields, err := uformat.Decode(addr) + if err != nil { + t.Fatalf("uformat.Decode(%v) error = %v, want nil", addr, err) + } + if fields.Block != 0x2001_0db8_ff01 { + t.Errorf("Block = %#x, want %#x", fields.Block, 0x2001_0db8_ff01) + } + if fields.NodeID != 9 { + t.Errorf("NodeID = %d, want 9", fields.NodeID) + } + if fields.Function != uformat.FunctionEndDT46 { + t.Errorf("Function = %#x, want %#x", fields.Function, uformat.FunctionEndDT46) + } + if fields.Argument != 0x123 { + t.Errorf("Argument = %#x, want %#x", fields.Argument, 0x123) + } +} + +// TestShardSIDsForTenant_RejectsAMalformedSID covers entries that parse as IP +// addresses but are not uSIDs. +func TestShardSIDsForTenant_RejectsAMalformedSID(t *testing.T) { + for _, raw := range []string{ + "2001:db8:ff01:9:e001::1", // non-zero padding: not a uFMT 48+16 address + "192.0.2.1", // IPv4: not an SRv6 SID at all + } { + sids, err := config.ParseEgressShardSIDs(raw) + if err != nil { + t.Fatalf("config.ParseEgressShardSIDs(%q) error = %v, want nil", raw, err) + } + if _, err := ShardSIDsForTenant(sids, 0x005); err == nil { + t.Errorf("ShardSIDsForTenant(%q) error = nil, want an error", raw) + } + } +} + +// routeCall is one egressPrefixRouteAddFn invocation a test recorded. +type routeCall struct { + table uint32 + prefix string + sids []net.IP +} + +// recordRouteAdds replaces egressPrefixRouteAddFn for one test with a recorder +// that returns failWith for every call, and returns the recorded calls. +func recordRouteAdds(t *testing.T, failWith error) *[]routeCall { + t.Helper() + original := egressPrefixRouteAddFn + t.Cleanup(func() { egressPrefixRouteAddFn = original }) + calls := &[]routeCall{} + egressPrefixRouteAddFn = func( + _ *egressroutemap.EgressRouteTable, table uint32, prefix *net.IPNet, sids []net.IP, + ) error { + *calls = append(*calls, routeCall{table, prefix.String(), sids}) + return failWith + } + return calls +} + +func TestInstallEgressRoutesIn_DefaultThenOneRoutePerNAT64Prefix(t *testing.T) { + calls := recordRouteAdds(t, nil) + sids := []net.IP{net.ParseIP("2001:db8:ff01:9:e2a5::")} + + n, err := installEgressRoutesIn(nil, 7, sids, "2001:db8:64::/96, 64:ff9b::/96", nil) + if err != nil { + t.Fatalf("installEgressRoutesIn() = %v, want nil", err) + } + want := []string{defaultPrefix, "2001:db8:64::/96", testNAT64Prefix} + if n != len(want) || len(*calls) != len(want) { + t.Fatalf("installed %d routes (%+v), want %d", n, *calls, len(want)) + } + for i, w := range want { + c := (*calls)[i] + if c.table != 7 || c.prefix != w || !reflect.DeepEqual(c.sids, sids) { + t.Errorf("route %d = %+v, want table 7, prefix %s, SIDs %v", i, c, w, sids) + } + } + + *calls = nil + if n, err := installEgressRoutesIn(nil, 7, sids, "", nil); err != nil || n != 1 || len(*calls) != 1 { + t.Errorf("unset NAT64 list: n = %d, err = %v, routes = %+v; want only ::/0", n, err, *calls) + } + + *calls = nil + if _, err := installEgressRoutesIn(nil, 7, sids, "64:ff9b::/96,64:ff9b::/64", nil); err == nil { + t.Error("installEgressRoutesIn() = nil with a non-/96 NAT64 entry, want an error") + } + if len(*calls) != 1 || (*calls)[0].prefix != defaultPrefix { + t.Errorf("invalid NAT64 list installed %+v, want only the ::/0 route before the error", *calls) + } +} + +func TestInstallEgressRoutesIn_SkipsExistingPrefixes(t *testing.T) { + calls := recordRouteAdds(t, nil) + sids := []net.IP{net.ParseIP("2001:db8:ff01:9:e2a5::")} + + exists := func(prefix *net.IPNet) (bool, error) { return prefix.String() == defaultPrefix, nil } + n, err := installEgressRoutesIn(nil, 7, sids, testNAT64Prefix, exists) + if err != nil { + t.Fatalf("installEgressRoutesIn() = %v, want nil", err) + } + if n != 1 || len(*calls) != 1 || (*calls)[0].prefix != testNAT64Prefix { + t.Errorf("n = %d, routes = %+v; want only the missing NAT64 route", n, *calls) + } +} + +func TestInstallEgressRoutesIn_StopsAtFirstFailure(t *testing.T) { + wantErr := errors.New("no shard resolvable") + calls := recordRouteAdds(t, wantErr) + + n, err := installEgressRoutesIn(nil, 7, []net.IP{net.ParseIP("2001:db8:ff01:9:e2a5::")}, testNAT64Prefix, nil) + if !errors.Is(err, wantErr) { + t.Errorf("installEgressRoutesIn() = %v, want %v", err, wantErr) + } + if n != 0 || len(*calls) != 1 { + t.Errorf("n = %d, routes = %+v; want one failed ::/0 attempt", n, *calls) + } +} + +// requireRoot skips the test unless running as root: pinned eBPF maps need +// CAP_BPF and a bpffs mount. +func requireRoot(t *testing.T) { + t.Helper() + if os.Geteuid() != 0 { + t.Skip("requires root (CAP_NET_ADMIN/CAP_BPF); re-run via sudo") + } +} + +// TestRegisterEgressKind_UnpinnedMapIsNotFatal covers an ADD that runs after +// install-cni replaced this binary but before the datapath reloaded and pinned +// the map. Failing it would block Instances from starting during an upgrade. +func TestRegisterEgressKind_UnpinnedMapIsNotFatal(t *testing.T) { + requireRoot(t) + pinDir := fmt.Sprintf("/sys/fs/bpf/galactic-attachreg-test-unpinned-%d", os.Getpid()) + if err := registerEgressKind(pinDir, 42, usidmap.EgressKindTap); err != nil { + t.Errorf("registerEgressKind with no pinned map = %v, want nil", err) + } +} diff --git a/internal/plumbing/ebpf/egressroutemap/egressroute.go b/internal/plumbing/ebpf/egressroutemap/egressroute.go index 13c4874b..adac30af 100644 --- a/internal/plumbing/ebpf/egressroutemap/egressroute.go +++ b/internal/plumbing/ebpf/egressroutemap/egressroute.go @@ -321,11 +321,11 @@ func (t *EgressRouteTable) RegisterPassThrough(tableID uint32, prefix *net.IPNet return nil } -// Lookup reads egress_route_table's entry for (tableID, prefix) and reports -// whether it exists. This is an exact-match lookup on the key Register and -// Unregister use, not the longest-prefix match usid_egress performs against a -// packet destination, so it serves tests and observability rather than -// answering what a given destination would resolve to. +// Lookup reads the entry egress_route_table resolves (tableID, prefix) to and +// reports whether there is one. Against the kernel map this is a +// longest-prefix match bounded by prefix's own length, so a shorter entry +// covering prefix, such as ::/0, answers for it. Prefixes reports which +// prefixes have an entry of their own. func (t *EgressRouteTable) Lookup(tableID uint32, prefix *net.IPNet) (sid net.IP, ok bool, err error) { key, err := buildKey(tableID, prefix) if err != nil { @@ -344,6 +344,34 @@ func (t *EgressRouteTable) Lookup(tableID uint32, prefix *net.IPNet) (sid net.IP return sid, true, nil } +// Prefixes returns every prefix egress_route_table holds an entry of its own +// for, in CIDR string form, keyed by Linux VRF table ID. It walks the whole +// map once. Unlike Lookup it never answers for one prefix with a shorter one. +func (t *EgressRouteTable) Prefixes() (map[uint32]map[string]struct{}, error) { + byTable := map[uint32]map[string]struct{}{} + var ( + key prog.UsidEgressRouteKey + value prog.UsidEgressRouteValue + ) + iter := t.table.Iterate() + for iter.Next(&key, &value) { + ones := int(key.Prefixlen) - egressRouteKeyFixedBits + prefix := &net.IPNet{IP: net.IP(append([]byte(nil), key.Addr[:]...)), Mask: net.CIDRMask(ones, 128)} + if key.Family == egressRouteFamilyINET4 { + prefix = &net.IPNet{IP: net.IP(append([]byte(nil), key.Addr[:4]...)), Mask: net.CIDRMask(ones, 32)} + } + prefix.IP = prefix.IP.Mask(prefix.Mask) + if byTable[key.TableId] == nil { + byTable[key.TableId] = map[string]struct{}{} + } + byTable[key.TableId][prefix.String()] = struct{}{} + } + if err := iter.Err(); err != nil { + return nil, fmt.Errorf("egressroutemap: egress_route_table: list prefixes: %w", err) + } + return byTable, nil +} + // Unregister removes egress_route_table's entry for (tableID, prefix). An entry // that is already absent is not an error, matching the other map writers in // this codebase. @@ -368,6 +396,12 @@ type NodeSourceAddress struct { table usidmap.Table } +// NewNodeSourceAddress wraps table as a NodeSourceAddress. Production callers +// use OpenPinnedNodeSourceAddress; tests pass a fake. +func NewNodeSourceAddress(table usidmap.Table) *NodeSourceAddress { + return &NodeSourceAddress{table: table} +} + // nodeSourceAddressKey is node_src_addr_table's only valid key -- // BPF_MAP_TYPE_ARRAY, one entry, matching usid.c's own `src_key = 0`. const nodeSourceAddressKey = uint32(0) @@ -417,6 +451,12 @@ type PublicUplink struct { table usidmap.Table } +// NewPublicUplink wraps table as a PublicUplink. Production callers use +// OpenPinnedPublicUplink; tests pass a fake. +func NewPublicUplink(table usidmap.Table) *PublicUplink { + return &PublicUplink{table: table} +} + // publicUplinkKey is public_uplink_table's only valid key. The map is a // one-entry array, matching node_src_addr_table. const publicUplinkKey = uint32(0) @@ -625,7 +665,7 @@ type nextHop struct { // // Shards are compared on everything but the Argument: Block, Node-ID and // Function name a shard, and the Argument is the tenant's VRFID that ADD wrote -// into its copy of the shard's SID. See internal/cnibgp's shardSIDsForTenant. +// into its copy of the shard's SID. See attachreg.ShardSIDsForTenant. // // A shard's resolution is cached for the sweep by its locator, Block plus // Node-ID, rather than per SID. Every VRF's copy of a shard differs only below diff --git a/internal/plumbing/ebpf/egressroutemap/egressroute_test.go b/internal/plumbing/ebpf/egressroutemap/egressroute_test.go index 8181aea6..a0c79174 100644 --- a/internal/plumbing/ebpf/egressroutemap/egressroute_test.go +++ b/internal/plumbing/ebpf/egressroutemap/egressroute_test.go @@ -7,6 +7,7 @@ package egressroutemap import ( "net" "net/netip" + "reflect" "testing" "go.datum.net/galactic/internal/plumbing/ebpf/prog" @@ -380,3 +381,31 @@ func TestPublicUplink_SetRejectsWrongLengthMAC(t *testing.T) { t.Error("Set with a 3-byte smac = nil error, want an error") } } + +// TestEgressRouteTable_PrefixesListsExactEntriesByTable covers the +// existence check a repair relies on: a prefix covered by ::/0 is not +// reported unless it has an entry of its own, and each table's entries are +// kept apart. +func TestEgressRouteTable_PrefixesListsExactEntriesByTable(t *testing.T) { + tbl := NewEgressRouteTable(newFakeTable()) + for _, p := range []string{"::/0", "fd20:30:ff01::/96", "10.1.0.5/32"} { + if err := tbl.RegisterPassThrough(7, mustCIDR(t, p)); err != nil { + t.Fatalf("RegisterPassThrough(7, %s): %v", p, err) + } + } + if err := tbl.RegisterPassThrough(8, mustCIDR(t, "fd20:40::/96")); err != nil { + t.Fatalf("RegisterPassThrough(8): %v", err) + } + + all, err := tbl.Prefixes() + if err != nil { + t.Fatalf("Prefixes(): %v", err) + } + want := map[uint32]map[string]struct{}{ + 7: {"::/0": {}, "fd20:30:ff01::/96": {}, "10.1.0.5/32": {}}, + 8: {"fd20:40::/96": {}}, + } + if !reflect.DeepEqual(all, want) { + t.Errorf("Prefixes() = %v, want %v", all, want) + } +} diff --git a/internal/plumbing/ebpf/egressroutemap/pin.go b/internal/plumbing/ebpf/egressroutemap/pin.go index 1dc5e214..ae4cb09d 100644 --- a/internal/plumbing/ebpf/egressroutemap/pin.go +++ b/internal/plumbing/ebpf/egressroutemap/pin.go @@ -40,7 +40,7 @@ func OpenPinnedNodeSourceAddress(pinDir string) (*NodeSourceAddress, io.Closer, return nil, nil, fmt.Errorf("egressroutemap: open pinned map %q under %q: %w", prog.UsidMapNodeSrcAddrTable, pinDir, err) } - return &NodeSourceAddress{table: usidmap.KernelTable{Map: m}}, m, nil + return NewNodeSourceAddress(usidmap.KernelTable{Map: m}), m, nil } // OpenPinnedPublicUplink opens public_uplink_table from its pinned path under @@ -52,5 +52,5 @@ func OpenPinnedPublicUplink(pinDir string) (*PublicUplink, io.Closer, error) { return nil, nil, fmt.Errorf("egressroutemap: open pinned map %q under %q: %w", prog.UsidMapPublicUplinkTable, pinDir, err) } - return &PublicUplink{table: usidmap.KernelTable{Map: m}}, m, nil + return NewPublicUplink(usidmap.KernelTable{Map: m}), m, nil } diff --git a/internal/plumbing/ebpf/usidmap/vpcattr.go b/internal/plumbing/ebpf/usidmap/vpcattr.go index 93f6dc1b..e7101943 100644 --- a/internal/plumbing/ebpf/usidmap/vpcattr.go +++ b/internal/plumbing/ebpf/usidmap/vpcattr.go @@ -15,7 +15,7 @@ import ( ) // VPCAttributionEntry is one decoded vpc_attribution_table row: the -// VPC/VPCAttachment identity registerEBPFDatapath already knows for a +// VPC/VPCAttachment identity attachreg.RegisterDatapath already knows for a // (Block, Argument) at CNI ADD time, kept alongside vrf_table's own counters // so byte accounting (datum-cloud/enhancements#878) can attribute them to a // tenant without a central uSID-to-VPC lookup for the receiving side, which diff --git a/internal/plumbing/srv6/egress.go b/internal/plumbing/srv6/egress.go index 5ede4e17..2ce6edab 100644 --- a/internal/plumbing/srv6/egress.go +++ b/internal/plumbing/srv6/egress.go @@ -15,6 +15,11 @@ import ( "go.datum.net/galactic/internal/plumbing/ebpf/egressroutemap" ) +// ErrNoShardResolvable is wrapped by EgressPrefixRouteAdd and +// EgressPrefixRouteAddTo when not one configured shard SID has a resolvable +// route and neighbor. +var ErrNoShardResolvable = errors.New("no egress shard SID is resolvable") + // pinDir is the bpffs directory the egress route helpers open // egress_route_table from. A package var so tests can redirect it and avoid // needing a real bpffs mount or root. @@ -108,10 +113,25 @@ func EgressPrefixRouteAdd(tableID uint32, prefix *net.IPNet, shardSIDs []net.IP) return fmt.Errorf("srv6: EgressPrefixRouteAdd: %w", err) } defer closer.Close() //nolint:errcheck // best-effort close of our own fd, immediately after use + return EgressPrefixRouteAddTo(table, tableID, prefix, shardSIDs) +} + +// EgressPrefixRouteAddTo is EgressPrefixRouteAdd against an egress_route_table +// the caller has already opened. Shard selection and error behavior are +// identical; an empty shardSIDs installs nothing and returns nil. +func EgressPrefixRouteAddTo( + table *egressroutemap.EgressRouteTable, tableID uint32, prefix *net.IPNet, shardSIDs []net.IP, +) error { + if len(shardSIDs) == 0 { + return nil + } + if prefix == nil { + return errors.New("srv6: EgressPrefixRouteAddTo: prefix is nil") + } var unresolved []error for _, sid := range shardSIDs { - // Checked before touching bpffs, as in RouteEgressAdd. + // Checked before writing the entry, as in RouteEgressAdd. if sid == nil || sid.IsUnspecified() { return fmt.Errorf("refusing to install egress route for %s: shard SID %s is not a usable SRv6 SID", prefix, sid) @@ -122,8 +142,8 @@ func EgressPrefixRouteAdd(tableID uint32, prefix *net.IPNet, shardSIDs []net.IP) } return nil } - return fmt.Errorf("no egress shard SID is resolvable yet for %s, out of %d configured: %w", - prefix, len(shardSIDs), errors.Join(unresolved...)) + return fmt.Errorf("%w yet for %s, out of %d configured: %w", + ErrNoShardResolvable, prefix, len(shardSIDs), errors.Join(unresolved...)) } // EgressDefaultRouteDel removes egress_route_table's default (::/0) entry for