Files
Donavan Fritz 2daa2a21f3
Build flock Image / build (push) Successful in 3m23s
agent: add flock.fritzlab.net/addresses annotation (eth0 static IPs)
Like anycast, addresses IPs are advertised via BGP (/128+/32) and get
host routes via the AnycastReconciler. The sole difference: they are
assigned to pod eth0 instead of lo, so workloads that inspect their
primary interface (e.g. Plex remote-access detection) see the public IP
directly.

- annotations.go: annAddresses const, Addresses []net.IP in ParsedAnnotations
- state.go: Addresses []string persisted in allocations.json
- anycast.go: resolveAnycastTargets processes Anycast+Addresses together
- netns_linux.go: configurePodSide assigns Addresses to eth0
- netns_stub.go: mirror Addresses field for non-Linux builds
- handlers.go: thread Addresses through ADD path

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-28 17:50:49 -05:00

113 lines
3.4 KiB
Go

package agent
import (
"net"
"sort"
)
// anycastNexthop is one (host-side veth, pod-eth0-IP) pair the kernel route
// can use as a multipath nexthop.
type anycastNexthop struct {
hostIface string
via net.IP
}
// anycastTarget describes the kernel route shape for one advertised anycast
// IP. When more than one Ready pod on this node binds the same anycast IP,
// every Ready pod contributes a nexthop and the kernel does per-flow ECMP
// across them.
//
// nexthops is sorted by canonical(via) for deterministic comparison and
// stable kernel-route ordering across reconcile passes — the
// AnycastReconciler skips kernel writes when the new and old targets are
// equal, which only works if the slice order is stable.
type anycastTarget struct {
nexthops []anycastNexthop
}
// equal reports whether two targets describe the same kernel route.
// Both sides are expected to be sorted (the canonical constructor sorts).
func (t anycastTarget) equal(o anycastTarget) bool {
if len(t.nexthops) != len(o.nexthops) {
return false
}
for i := range t.nexthops {
if t.nexthops[i].hostIface != o.nexthops[i].hostIface {
return false
}
if !t.nexthops[i].via.Equal(o.nexthops[i].via) {
return false
}
}
return true
}
// resolveAnycastTargets walks the committed allocation set and returns the
// desired kernel-route shape for every anycast IP that has at least one
// Ready local pod binding it. Multiple Ready pods sharing the same anycast
// IP collapse into a single multi-nexthop target so the kernel can
// per-flow ECMP across them.
//
// Pure: no kernel calls, no informer access. Pods are surfaced via the
// isReady callback so the reconciler can plug in its informer; tests can
// pass any function that satisfies the signature.
//
// warn is invoked for human-facing skip reasons (e.g. anycast with no
// unicast of same family). nil-safe — pass nil to silently drop.
func resolveAnycastTargets(
allocations []Allocation,
isReady func(namespace, name string) bool,
warn func(string),
) map[string]anycastTarget {
if warn == nil {
warn = func(string) {}
}
out := map[string]anycastTarget{}
for _, a := range allocations {
if a.State != StateCommitted || (len(a.Anycast) == 0 && len(a.Addresses) == 0) {
continue
}
if !isReady(a.Namespace, a.PodName) {
continue
}
host := HostIfaceName(a.ContainerID)
via6 := net.ParseIP(a.IP6)
via4 := net.ParseIP(a.IP4)
// Anycast (lo-bound) and Addresses (eth0-bound) are advertised
// identically: /128 or /32 host route on the host, BGP via BIRD.
for _, ipStr := range append(a.Anycast, a.Addresses...) {
ip := net.ParseIP(ipStr)
if ip == nil {
continue
}
var via net.IP
if ip.To4() != nil {
via = via4
} else {
via = via6
}
if via == nil {
warn("anycast " + ipStr + " skipped: pod " +
a.Namespace + "/" + a.PodName +
" has no unicast of same family")
continue
}
key := canonical(ip)
t := out[key]
t.nexthops = append(t.nexthops, anycastNexthop{hostIface: host, via: via})
out[key] = t
}
}
// Sort each target's nexthops for stable comparison + stable kernel
// ordering. Sort key is canonical(via) — sufficient for stability
// because (host, via) pairs are 1:1 (one veth per pod, one v6+v4 per
// pod, so via uniquely identifies the nexthop).
for k, t := range out {
sort.Slice(t.nexthops, func(i, j int) bool {
return canonical(t.nexthops[i].via) < canonical(t.nexthops[j].via)
})
out[k] = t
}
return out
}