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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/agents/ARCHITECTURE-CNI.md
Original file line number Diff line number Diff line change
Expand Up @@ -465,7 +465,7 @@ auto-detect directly (see Configuration above).
- **Base62 interface names.** Kernel interface names use the format `G{9-char-vpc-base62}{3-char-att-base62}{suffix}` (suffix: `V` = VRF, `H` = host veth/tap, `G` = guest veth pre-move), fitting in the 15-character kernel limit. The hex form is used for BGP route targets; base62 for kernel interfaces.
- **VRF/route-target model via BGPVRFInstance.** `galactic-bgp` creates a `BGPVRFInstance` (RouteDistinguisher + import/export Route Targets, all set to the derived RT) before the `BGPAdvertisement`; `galactic-router`'s GoBGP runtime applies VRFs before originating EVPN paths.
- **Every VRF table ends in an unreachable default, per family.** `vrf.Add` installs `unreachable default` for IPv4 and IPv6 at metric `4278198272` (`vrf.UnreachableDefaultMetric`, the value the kernel's VRF documentation recommends), on every call, not only when it creates the VRF. Without it, a lookup that misses in the VRF's table falls through the rules after the l3mdev rule to the main table, and the host routes a tenant packet the eBPF datapath didn't claim out its own default interface with the tenant's source address. The high metric means any real default in the table, such as the ingress sidecar's, still wins. Route sweepers must leave it alone: `hostgw` only removes unicast routes on the attachment's own device, and the installer's sidecar-return prune only touches tables from `0xF000` up. Each has a test pinning that.
- **TCP MSS is clamped in the datapath, not by the tenant or the fabric MTU.** A tenant packet crosses the fabric inside a 40-byte outer IPv6 header with no SRH, so a tenant interface at the uplink's own MTU advertises an MSS the path cannot carry, and nothing on the path fragments or reports an oversized segment. `clamp_tcp_mss` in `usid.c` lowers the MSS of every SYN and SYN-ACK at three points: `usid_egress` just before it encapsulates, `usid_egress`'s DSR reply redirect (the client's segments reach the backend encapsulated by the gateway), and `usid_ingress` just after it decapsulates (the only place a SYN-ACK returning through an egress shard can be clamped, since the shard does not). The limits are per tenant family, 1400 for IPv6 and 1420 for IPv4 at a 1500-byte fabric, and the IPv6 value also covers NAT64. `galactic-cni run` sizes them from the smallest uplink MTU (`internal/plumbing/ebpf/mssclamp`) into `mss_clamp_table`, overridable with `GALACTIC_CNI_TCP_MSS_CLAMP` (see [docs/cni/environment-variables.md](../cni/environment-variables.md#galactic_cni_tcp_mss_clamp)). The map and its counters (`mss_clamp_stats`, sized with spare slots) are new maps rather than new fields or `drop_reasons` slots, because changing any existing pinned map's layout makes `attach.Load` recreate every pinned map empty. `clamp_tcp_mss` is a global BPF function, verified once on its own, and walks at most 16 options: past about 20 steps the verifier's cost jumps from thousands of instructions to over its 1M limit. `usid_verifier_cost_test.go` fails if either program's verifier cost passes 16,000 instructions.
- **TCP MSS is clamped in the datapath, not by the tenant or the fabric MTU.** A tenant packet crosses the fabric inside a 40-byte outer IPv6 header with no SRH, so a tenant interface at the uplink's own MTU advertises an MSS the path cannot carry, and nothing on the path fragments or reports an oversized segment. `clamp_tcp_mss` in `usid.c` lowers the MSS of every SYN and SYN-ACK at three points: `usid_egress` just before it encapsulates, `usid_egress`'s DSR reply redirect (the client's segments reach the backend encapsulated by the gateway), and `usid_ingress` just after it decapsulates (the only place a SYN-ACK returning through an egress shard can be clamped, since the shard does not). The limits are per tenant family, 1400 for IPv6 and 1420 for IPv4 at a 1500-byte fabric, and the IPv6 value also covers NAT64. `galactic-cni run` sizes them from the smallest uplink MTU (`internal/plumbing/ebpf/mssclamp`) into `mss_clamp_table`, overridable with `GALACTIC_CNI_TCP_MSS_CLAMP` (see [docs/cni/environment-variables.md](../cni/environment-variables.md#galactic_cni_tcp_mss_clamp)). The map and its counters (`mss_clamp_stats`, sized with spare slots) are new maps rather than new fields or `drop_reasons` slots, because changing an existing pinned map's layout makes `attach.Load` recreate that map empty, and the rows only CNI ADD writes do not come back until each attachment is re-added. `clamp_tcp_mss` is a global BPF function, verified once on its own, and walks at most 16 options: past about 20 steps the verifier's cost jumps from thousands of instructions to over its 1M limit. `usid_verifier_cost_test.go` fails if either program's verifier cost passes 16,000 instructions.

- **A packet too big for the fabric gets an ICMP error from the datapath, not a silent drop at the uplink.** `usid_egress` calls `send_too_big` after the MSS clamp and before it encapsulates. A packet longer than `encap_mtu_table`'s limit (the uplink MTU less 40, so 1460 at a 1500-byte fabric) is rewritten in place into an ICMPv6 Packet Too Big, or an ICMPv4 Fragmentation Needed for an IPv4 tenant with DF set, and redirected back out the interface it arrived on. The error carries the limit, comes from the attachment's own IPAM gateway (`tenant_gw_table`, keyed by host-side ifindex and written by `galactic-bgp` at ADD, removed by `galactic-veth`/`galactic-tap` at DEL), and quotes the packet's first 68 bytes with NPTv6 undone, so the tenant's path MTU discovery matches it to the socket that sent it. A GSO packet is judged by its segment length. No error is sent for an IPv4 packet without DF, for an ICMP error, past a per-CPU token bucket of 1,000 a second with a burst of 100 (`pmtu_icmp_bucket`), or for an attachment with no gateway in that family; those are dropped and counted as `fib_frag_needed`. Each outcome is counted in `pmtu_stats`, exported as `galactic_usid_pmtu_packets_total{result}`, and the limit as `galactic_usid_pmtu_limit_bytes`. The same `mssclamp.Reconciler` writes `encap_mtu_table` and `mss_clamp_table` from one `GALACTIC_CNI_TCP_MSS_CLAMP` setting, so the two cannot disagree. A pod's or guest's TCP and UDP packets usually arrive `CHECKSUM_PARTIAL`, which no BPF helper can clear, and a device without checksum offload (a tap whose guest did not negotiate it) would finish that checksum into the middle of the error. So the error is laid out with room pushed at the front until the kernel's checksum spot (the original transport header plus 16 for TCP, 6 otherwise) lands on a trailing slot word, with a pad word chosen so the sum the kernel writes there equals what is already there. `usid_pmtu_tap_test.go` checks this against the kernel through a `virtio_net_hdr` tap, with offload on and off.
- **CRD-driven config, no sidecar gRPC.** `galactic-bgp` writes `BGPVRFInstance`/`BGPAdvertisement` CRDs directly via a bare controller-runtime client; `galactic-router`'s reconciler picks them up. No in-node gRPC calls between any of the CNI-chain binaries and `galactic-router`.
Expand Down
11 changes: 5 additions & 6 deletions internal/gc/gc.go
Original file line number Diff line number Diff line change
Expand Up @@ -601,8 +601,8 @@ func SweepEBPFVRFTable(ctx context.Context, k8s client.Client, namespace, nodeNa
}
}

// Repair entries a live BGPVRFInstance expects but vrf_table lacks. An
// incompatible-schema reload wipes every pinned map, and nothing
// 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
Expand Down Expand Up @@ -649,10 +649,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: unlike vrf_table, an incompatible-schema wipe of this
// brand-new map cannot happen on a fresh rollout, and a missing row
// simply reports as unattributed (internal/plumbing/ebpf/metrics) until
// the next CNI ADD/DEL repopulates it.
// step of its own: a missing row only reports as unattributed
// (internal/plumbing/ebpf/metrics) until the next CNI ADD/DEL repopulates
// it.
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)
Expand Down
59 changes: 23 additions & 36 deletions internal/plumbing/ebpf/attach/attach.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
"golang.org/x/sys/unix"

"go.datum.net/galactic/internal/config"
"go.datum.net/galactic/internal/plumbing/ebpf/mappin"
"go.datum.net/galactic/internal/plumbing/ebpf/preflight"
"go.datum.net/galactic/internal/plumbing/ebpf/prog"
)
Expand Down Expand Up @@ -135,18 +136,31 @@ func Load(pinDir string) (objs *prog.UsidObjects, err error) {
loadErr := spec.LoadAndAssign(&loaded, opts)
if loadErr != nil && errors.Is(loadErr, ebpf.ErrMapIncompatible) {
// A pin left by a previous version no longer matches the compiled map
// spec, after a changed value size or entry count, and cannot be
// reused. Every map here is control-plane-owned and reconstructable
// from CRD state, with GC sweeping anything stale, so recreating it is
// safe. Leaving this fatal would crashloop every node on the first
// schema change until an operator deleted the pins by hand.
slog.Warn("attach: pinned eBPF map incompatible with the newly compiled map spec, recreating "+
"(control-plane state will repopulate on the next CNI ADD/GC sweep)", "pinDir", pinDir, "err", loadErr)
if unpinErr := unpinIncompatibleMaps(spec, pinDir); unpinErr != nil {
// spec, after a changed key size, value size or entry count, and
// cannot be reused. Leaving this fatal would crashloop every node on
// the first schema change until an operator deleted the pins by hand.
//
// Only the maps whose layout changed are recreated. Several maps here,
// locator_table, function_table, vrf_table, ifindex_vrf_table and
// ifindex_egress_kind_table among them, are written by CNI ADD alone.
// Recreating them empties them on a compute node until every workload
// re-attaches, which blackholes every attachment on the node. An
// unchanged map keeps its rows.
unpinned, unpinErr := mappin.UnpinIncompatible(spec, pinDir, nil)
if unpinErr != nil {
err = fmt.Errorf("attach: recreate incompatible pinned maps: %w", unpinErr)
return nil, err
}
loadErr = spec.LoadAndAssign(&loaded, opts)
if len(unpinned) == 0 {
err = fmt.Errorf("attach: load reported an incompatible map, but every pin under %s matches: %w",
pinDir, loadErr)
return nil, err
}
slog.Warn("attach: pinned eBPF maps incompatible with the newly compiled map spec, recreating them",
"pinDir", pinDir, "maps", unpinned, "err", loadErr)
if loadErr = spec.LoadAndAssign(&loaded, opts); loadErr != nil {
loadErr = fmt.Errorf("after recreating %v: %w", unpinned, loadErr)
}
}
if loadErr != nil {
var ve *ebpf.VerifierError
Expand Down Expand Up @@ -185,33 +199,6 @@ func Load(pinDir string) (objs *prog.UsidObjects, err error) {
// from every map name in the same directory so the two can never collide.
const UsidEgressPinName = "usid_egress_prog"

// unpinIncompatibleMaps removes the on-disk pin for every map spec names, if one
// exists under pinDir, so the retry that follows an incompatible-map load creates
// each map fresh instead of failing against the stale pin again.
//
// A map with no existing pin is not an error: that one would have loaded fine.
// Clearing all of them unconditionally is simpler and equally safe, since every
// one is reconstructable from control-plane state.
func unpinIncompatibleMaps(spec *ebpf.CollectionSpec, pinDir string) error {
var errs []error
for name := range spec.Maps {
path := filepath.Join(pinDir, name)
m, err := ebpf.LoadPinnedMap(path, nil)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
continue
}
errs = append(errs, fmt.Errorf("load pinned map %q for recreation: %w", name, err))
continue
}
if err := m.Unpin(); err != nil {
errs = append(errs, fmt.Errorf("unpin stale map %q: %w", name, err))
}
_ = m.Close()
}
return errors.Join(errs...)
}

// Attach attaches program to the ingress hook of each named interface, through a
// clsact qdisc and a direct-action BPF filter, creating the qdisc if it does not
// exist.
Expand Down
4 changes: 2 additions & 2 deletions internal/plumbing/ebpf/attach/attach_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -300,8 +300,8 @@ func TestResolveFilterPriority_EnvOverride(t *testing.T) {

// TestLoad_AddedMapKeepsExistingPinnedState covers rolling out a datapath build
// that adds a map. The previous build's pins lack it, and that alone must not
// count as incompatible, which would recreate every map empty and drop live
// routing state until each attachment is re-added.
// count as incompatible, which would recreate maps empty and drop live routing
// state until each attachment is re-added.
func TestLoad_AddedMapKeepsExistingPinnedState(t *testing.T) {
requireRoot(t)

Expand Down
78 changes: 78 additions & 0 deletions internal/plumbing/ebpf/attach/schema_change_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// Copyright 2026 Datum Cloud, Inc.
//
// SPDX-License-Identifier: AGPL-3.0-or-later

package attach

import (
"fmt"
"os"
"path/filepath"
"testing"

"github.com/cilium/ebpf"

"go.datum.net/galactic/internal/plumbing/ebpf/prog"
)

// A new version that changes one map's layout recreates that map alone. Every
// other map keeps its rows across the reload. This is the case where
// vip_xlat_table's key grew: recreating every map with it emptied the tables
// only CNI ADD writes, and every attachment on the node went dark until its
// workload re-attached.
func TestLoad_SchemaChangeRecreatesOnlyTheChangedMap(t *testing.T) {
requireRoot(t)
pinDir := filepath.Join("/sys/fs/bpf", fmt.Sprintf("galactic-schema-test-%d", os.Getpid()))
t.Cleanup(func() { _ = os.RemoveAll(pinDir) })

objs, err := Load(pinDir)
if err != nil {
t.Fatalf("first Load: %v", err)
}
vrfKey := uint64(0x123)
if err := objs.VrfTable.Put(vrfKey, prog.UsidVrfValue{VrfTableId: 42}); err != nil {
t.Fatalf("seed vrf_table: %v", err)
}
if err := objs.LocatorTable.Put(uint64(0x456), prog.UsidLocatorValue{Generation: 1}); err != nil {
t.Fatalf("seed locator_table: %v", err)
}
newKeySize := objs.VipXlatTable.KeySize()
_ = objs.Close()

// Stand in for the previous version's vip_xlat_table: same map, older,
// smaller key.
vipPin := filepath.Join(pinDir, prog.UsidMapVipXlatTable)
if err := os.Remove(vipPin); err != nil {
t.Fatalf("remove vip_xlat_table pin: %v", err)
}
old, err := ebpf.NewMap(&ebpf.MapSpec{
Name: "vip_xlat_table", Type: ebpf.Hash, KeySize: newKeySize / 2, ValueSize: 8, MaxEntries: 16,
})
if err != nil {
t.Fatalf("create old-layout vip_xlat_table: %v", err)
}
if err := old.Pin(vipPin); err != nil {
t.Fatalf("pin old-layout vip_xlat_table: %v", err)
}
_ = old.Close()

objs, err = Load(pinDir)
if err != nil {
t.Fatalf("Load over an old-layout vip_xlat_table: %v", err)
}
defer objs.Close() //nolint:errcheck // test-owned objects

if got := objs.VipXlatTable.KeySize(); got != newKeySize {
t.Errorf("vip_xlat_table key size = %d after reload, want the new layout's %d", got, newKeySize)
}
var vrf prog.UsidVrfValue
if err := objs.VrfTable.Lookup(vrfKey, &vrf); err != nil {
t.Errorf("vrf_table lost its row across the reload: %v", err)
} else if vrf.VrfTableId != 42 {
t.Errorf("vrf_table row = table %d, want 42", vrf.VrfTableId)
}
var loc prog.UsidLocatorValue
if err := objs.LocatorTable.Lookup(uint64(0x456), &loc); err != nil {
t.Errorf("locator_table lost its row across the reload: %v", err)
}
}
42 changes: 13 additions & 29 deletions internal/plumbing/ebpf/edgeattach/attach.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
"go.datum.net/galactic/internal/plumbing/bond"
"go.datum.net/galactic/internal/plumbing/ebpf/edgepreflight"
"go.datum.net/galactic/internal/plumbing/ebpf/edgeprog"
"go.datum.net/galactic/internal/plumbing/ebpf/mappin"
"go.datum.net/galactic/internal/plumbing/ebpf/xdpattach"
)

Expand Down Expand Up @@ -103,12 +104,20 @@ func Load(pinDir string, dispatchMaps map[string]*ebpf.Map) (*edgeprog.EdgedsrOb
// startup. A stale pin from an incompatible layout is safe to
// recreate rather than fatal. The dispatcher's maps are not ours and
// are never touched here.
slog.Warn("edgeattach: pinned eBPF map incompatible with the newly compiled map spec, recreating "+
"(control-plane state will repopulate on the next NetworkRule reconcile)", "pinDir", pinDir, "err", loadErr)
if unpinErr := unpinIncompatibleMaps(spec, pinDir); unpinErr != nil {
unpinned, unpinErr := mappin.UnpinIncompatible(spec, pinDir, func(name string) bool { return dispatchMapNames[name] })
if unpinErr != nil {
return nil, fmt.Errorf("edgeattach: recreate incompatible pinned maps: %w", unpinErr)
}
loadErr = spec.LoadAndAssign(&loaded, opts)
if len(unpinned) == 0 {
return nil, fmt.Errorf("edgeattach: load reported an incompatible map, but every pin under %s matches: %w",
pinDir, loadErr)
}
slog.Warn("edgeattach: pinned eBPF maps incompatible with the newly compiled map spec, recreating them "+
"(control-plane state will repopulate on the next NetworkRule reconcile)",
"pinDir", pinDir, "maps", unpinned, "err", loadErr)
if loadErr = spec.LoadAndAssign(&loaded, opts); loadErr != nil {
loadErr = fmt.Errorf("after recreating %v: %w", unpinned, loadErr)
}
}
if loadErr != nil {
var ve *ebpf.VerifierError
Expand All @@ -120,31 +129,6 @@ func Load(pinDir string, dispatchMaps map[string]*ebpf.Map) (*edgeprog.EdgedsrOb
return &loaded, nil
}

// unpinIncompatibleMaps mirrors internal/plumbing/ebpf/attach's identical
// helper -- see that function's doc comment.
func unpinIncompatibleMaps(spec *ebpf.CollectionSpec, pinDir string) error {
var errs []error
for name := range spec.Maps {
if dispatchMapNames[name] {
continue
}
path := filepath.Join(pinDir, name)
m, err := ebpf.LoadPinnedMap(path, nil)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
continue
}
errs = append(errs, fmt.Errorf("load pinned map %q for recreation: %w", name, err))
continue
}
if err := m.Unpin(); err != nil {
errs = append(errs, fmt.Errorf("unpin stale map %q: %w", name, err))
}
_ = m.Close()
}
return errors.Join(errs...)
}

// ResolveTargets resolves ifaceName to the interface names Attach should
// actually attach the XDP program to.
//
Expand Down
Loading
Loading