Files
flock/pkg/agent/bird.go
T
opsandClaude Sonnet 4.6 c9950348ff
flock PR validation / validate (pull_request) Successful in 11s
flock-agent: GC orphaned allocations; retry birdc on socket-not-ready
Three defects enabled the 2026-08-16 Gitea blackhole (bug-wdgjpz3a00gd):

1. Orphaned allocation GC missing: ungraceful eviction (TaintManagerEviction)
   never calls CNI DEL, so the old node keeps advertising the pod's public
   /128 via BGP. Older allocation wins BGP path selection; live pod's node
   yields → blackhole.

   Fix: after the pod informer syncs at startup, sweep all committed
   allocations via orphanedCommitted(). Any allocation whose owner pod is
   absent from the node (or whose UID mismatches, indicating name reuse) is
   torn down, removed from the store, and released from IPAM. A 60 s
   periodic GC goroutine provides the same sweep while the agent runs.

2. renderBird outside-aggregate IP loop lacked pod liveness check: stale
   committed allocations caused BIRD to keep advertising the /128 even in
   steady state between GC ticks.

   Fix: before adding an outside-aggregate primary IP to the BIRD export,
   verify the pod is still in the node-scoped informer cache with a matching
   UID. Orphans are skipped silently; the GC cleans them on the next tick.

3. birdc startup race: the agent's first Render() fires before BIRD has
   bound /run/flock/bird.ctl, so the configure call silently fails with
   "Unable to connect" and the initial routes are never advertised. A
   container-only flock-agent restart (BIRD left running) avoids the race;
   a full pod restart re-hits it.

   Fix: reload() now retries up to 20 × 500 ms on socket-absent and
   "Unable to connect" conditions. Any other birdc failure (syntax error,
   etc.) is not retried.

Fixes bug-wdgjpz3a00gd

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-16 06:26:38 +00:00

291 lines
8.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package agent
import (
"errors"
"fmt"
"log/slog"
"net"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
flockv1alpha1 "code.fritzlab.net/fritzlab/flock/pkg/api/v1alpha1"
"code.fritzlab.net/fritzlab/flock/pkg/routing/bird"
)
// BirdManager renders bird.conf and triggers birdc reload. Writes are
// debounced so a burst of NodeConfig / anycast changes coalesces.
type BirdManager struct {
NodeName string
ConfigPath string // /etc/flock/bird/bird.conf
BirdcSocket string // /run/flock/bird6.ctl (BIRD2 single-socket default)
BirdctlPath string // "birdc" — overridable for tests
Logger *slog.Logger
mu sync.Mutex
last string // last rendered output (de-dup)
cooldown *time.Timer
cooldownEnd time.Time // window during which further reloads are coalesced
pending bool // a render landed during cooldown; reload at window end
}
// reloadCooldown is the minimum spacing between two birdc reloads. The
// first change fires immediately (no leading-edge delay); follow-up
// changes within this window are coalesced into a single tail reload.
const reloadCooldown = 500 * time.Millisecond
// Render writes the config from a NodeConfig + anycast set. Idempotent —
// if the rendered content matches what we last wrote, no birdc reload.
func (b *BirdManager) Render(nc *flockv1alpha1.NodeConfig, anycast6, anycast4 []string, routerID string) error {
if nc == nil {
return fmt.Errorf("no NodeConfig")
}
in := bird.NodeBGP{
NodeName: b.NodeName,
RouterID: routerID,
LocalASN: nc.Spec.BGP.ASN,
CIDR6: nc.Spec.CIDR6,
CIDR4: nc.Spec.CIDR4,
Anycast6: anycast6,
Anycast4: anycast4,
}
// Pick a local source address per family that's on the same subnet as
// the BGP peer. crt001 rejects IPv6 advertisements whose next-hop is
// link-local-only; an explicit `source address` makes BIRD use a
// global next-hop self, which Cisco accepts.
//
// Also derive the connected subnet (peer IP masked to /64 v6 / /24 v4)
// per family. Render uses it to install `import where net != <subnet>`
// on the BGP channel so the gateway can't readvertise our own connected
// /64 back to us — accepting it would override the kernel route and
// hairpin all inter-host traffic via the gateway.
for _, p := range nc.Spec.BGP.Peers {
fam := bird.FamilyOf(p.Address)
if fam == "" {
continue
}
in.Peers = append(in.Peers, bird.Peer{Family: fam, Address: p.Address, ASN: p.ASN})
if local := localAddrSameSubnet(p.Address); local != "" {
if fam == "v6" && in.LocalV6 == "" {
in.LocalV6 = local
}
if fam == "v4" && in.LocalV4 == "" {
in.LocalV4 = local
}
}
if subnet := peerSubnet(p.Address); subnet != "" {
if fam == "v6" && in.LocalSubnetV6 == "" {
in.LocalSubnetV6 = subnet
}
if fam == "v4" && in.LocalSubnetV4 == "" {
in.LocalSubnetV4 = subnet
}
}
}
cfg, err := bird.Render(in)
if err != nil {
return err
}
b.mu.Lock()
defer b.mu.Unlock()
if cfg == b.last {
return nil
}
if err := os.MkdirAll(filepath.Dir(b.ConfigPath), 0o755); err != nil {
return fmt.Errorf("mkdir bird config dir: %w", err)
}
tmp := b.ConfigPath + ".tmp"
if err := os.WriteFile(tmp, []byte(cfg), 0o644); err != nil {
return fmt.Errorf("write bird.conf: %w", err)
}
if err := os.Rename(tmp, b.ConfigPath); err != nil {
return fmt.Errorf("rename bird.conf: %w", err)
}
b.last = cfg
b.scheduleReload()
return nil
}
// scheduleReload uses leading-edge + cooldown semantics: the first call
// reloads immediately; subsequent calls within reloadCooldown coalesce
// into a single deferred reload at the cooldown's end. Caller holds b.mu.
func (b *BirdManager) scheduleReload() {
now := time.Now()
if now.After(b.cooldownEnd) {
// Outside any active cooldown — fire now (leading edge).
b.cooldownEnd = now.Add(reloadCooldown)
b.pending = false
go b.reload()
return
}
// Inside cooldown — coalesce. If no tail timer is set, schedule one
// at the cooldown end; if already set, just leave it.
if b.pending {
return
}
b.pending = true
delay := b.cooldownEnd.Sub(now)
b.cooldown = time.AfterFunc(delay, func() {
b.mu.Lock()
b.pending = false
b.cooldownEnd = time.Now().Add(reloadCooldown)
b.mu.Unlock()
b.reload()
})
}
// birdcMaxAttempts and birdcRetryDelay bound the startup-socket retry loop.
// BIRD may not have bound its control socket yet when flock-agent first
// tries to configure it; 20 × 500 ms = 10 s covers the typical BIRD
// startup window without blocking indefinitely.
const (
birdcMaxAttempts = 20
birdcRetryDelay = 500 * time.Millisecond
)
func (b *BirdManager) reload() {
birdctl := b.BirdctlPath
if birdctl == "" {
birdctl = "birdc"
}
socket := b.BirdcSocket
if socket == "" {
socket = "/run/flock/bird.ctl"
}
for attempt := 1; attempt <= birdcMaxAttempts; attempt++ {
if attempt > 1 {
time.Sleep(birdcRetryDelay)
}
// Socket absent → BIRD hasn't bound it yet; retry.
if _, err := os.Stat(socket); os.IsNotExist(err) {
b.Logger.Debug("birdc socket not ready, retrying",
"attempt", attempt, "socket", socket)
continue
}
cmd := exec.Command(birdctl, "-s", socket, "configure")
out, err := cmd.CombinedOutput()
if err == nil {
b.Logger.Info("birdc configure ok", "out", string(out))
return
}
if errors.Is(err, exec.ErrNotFound) {
b.Logger.Warn("birdc not found", "err", err)
return
}
outStr := string(out)
// "Unable to connect" means BIRD exists but isn't listening yet.
if strings.Contains(outStr, "Unable to connect") {
b.Logger.Debug("birdc not ready, retrying",
"attempt", attempt, "err", err)
continue
}
// Any other failure (syntax error, etc.) is not retriable.
b.Logger.Warn("birdc reload failed", "err", err, "out", outStr)
return
}
b.Logger.Error("birdc configure gave up after retries",
"socket", socket, "attempts", birdcMaxAttempts)
}
// SummaryRoutes installs blackhole kernel routes for each NodeConfig CIDR.
// BIRD's protocol kernel imports them so they get advertised. Idempotent.
func (b *BirdManager) SummaryRoutes(nc *flockv1alpha1.NodeConfig) error {
if nc == nil {
return nil
}
for _, c := range nc.Spec.CIDR6 {
if err := installBlackhole(c); err != nil {
b.Logger.Warn("blackhole route v6", "cidr", c, "err", err)
}
}
for _, c := range nc.Spec.CIDR4 {
if err := installBlackhole(c); err != nil {
b.Logger.Warn("blackhole route v4", "cidr", c, "err", err)
}
}
return nil
}
// peerSubnet returns the canonical CIDR of the assumed connected subnet
// containing `peer` — /64 for IPv6, /24 for IPv4. Returns "" if peer
// doesn't parse. Matches the assumption already baked into
// localAddrSameSubnet: fritzlab convention is /64 v6 and /24 v4.
func peerSubnet(peer string) string {
pip := net.ParseIP(peer)
if pip == nil {
return ""
}
var mask net.IPMask
if pip.To4() != nil {
mask = net.CIDRMask(24, 32)
} else {
mask = net.CIDRMask(64, 128)
}
n := &net.IPNet{IP: pip.Mask(mask), Mask: mask}
return n.String()
}
// localAddrSameSubnet finds an IP on a local interface that's in the same
// /64 (v6) or /24 (v4) as `peer`. Returns "" if none. Used to derive the
// `source address` for a BGP session.
func localAddrSameSubnet(peer string) string {
pip := net.ParseIP(peer)
if pip == nil {
return ""
}
addrs, err := net.InterfaceAddrs()
if err != nil {
return ""
}
v4 := pip.To4() != nil
for _, a := range addrs {
ipn, ok := a.(*net.IPNet)
if !ok {
continue
}
ip := ipn.IP
if ip.IsLoopback() || ip.IsLinkLocalUnicast() {
continue
}
if (ip.To4() != nil) != v4 {
continue
}
// Use the peer's mask (assume same subnet) for membership test.
var mask net.IPMask
if v4 {
mask = net.CIDRMask(24, 32)
} else {
mask = net.CIDRMask(64, 128)
}
peerSubnet := &net.IPNet{IP: pip, Mask: mask}
if peerSubnet.Contains(ip) {
if v4 {
return ip.To4().String()
}
return ip.To16().String()
}
}
return ""
}
func installBlackhole(cidr string) error {
// Use `ip` rather than netlink so this file stays portable for non-Linux
// builds (the agent on macOS just no-ops). The agent only runs in
// Kubernetes pods on Linux nodes, so the exec is fine.
_, _, err := net.ParseCIDR(cidr)
if err != nil {
return err
}
cmd := exec.Command("ip", "route", "replace", "blackhole", cidr)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("ip route replace blackhole %s: %w (%s)", cidr, err, string(out))
}
return nil
}