agent: addresses annotation replaces IPAM allocation
Build flock Image / build (push) Successful in 5m27s

When flock.fritzlab.net/addresses provides a v6 or v4, the IP becomes
the pod's primary IP for that family — bound to eth0, default route off
it, on-link host route via setHostRoute, and a per-pod /128 or /32 in
BGP. IPAM no longer allocates a private IP alongside it. The pod ends up
with exactly the operator-supplied addresses on eth0 (plus any extras
beyond the first-of-family, which keep the pre-existing layered
behavior).

This is the fix the original addresses-annotation work missed: bug #1
allocated a private IP next to the public one (so VPN-routed clients
could land on the private path on Plex). Promoting addresses-supplied
IPs into the IPAM-style routing slot keeps the public IP as the only
primary IP visible from outside.

Three pieces:
- annotations.go: reject pods whose addresses/anycast IP family is
  disabled (ipv6/ipv4 annotation or NodeConfig default). Both annotation
  types rely on the family being enabled for return-path routing.
- handlers.go: peel first v6 + first v4 from Addresses into res.IP6/IP4;
  suppress IPAM for those families; skip IPAM call entirely if both
  families are addresses-supplied.
- anycast_linux.go: extend renderBird to advertise any IPAM IP that's
  outside the node's BGP aggregate as a per-pod /32 or /128. This is
  what makes 142.202.202.166 reachable when host004's pod CIDR is
  172.25.214.0/24 — the addresses-promoted IP isn't covered by the
  aggregate.

Tests: 7 new annotation tests covering the conflict cases (ipv4=false +
addresses-v4, NodeConfig default + addresses-v4, etc.) plus 5 unit tests
for the splitAddressesPrimary helper.

README updated with the addresses-replaces-IPAM behavior, the
addresses-vs-anycast comparison, the conflict rule, and a Plex-style
example.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Donavan Fritz
2026-04-29 09:46:48 -05:00
parent 40e13037b5
commit a17d33e182
6 changed files with 376 additions and 11 deletions
+57 -7
View File
@@ -140,21 +140,44 @@ func (h *PodHandler) Add(ctx context.Context, req flockcni.Request) (*current.Re
}
ipAlgo := ResolveIPAlgo(pod.Annotations, nodeAnn, h.Logger)
// addresses-annotation IPs replace IPAM allocation for any family they
// cover. Plex needs its public IPv4 to be the pod's primary v4 (default
// route source, on-link host route, /32 in BGP) — not just an extra IP
// layered on top of a private IPAM allocation. Peel one v6 + one v4 out
// of Addresses to use as the pod's primary IPs; anything beyond that
// stays in addrExtras and gets the existing layered behavior.
addrV6, addrV4, addrExtras := splitAddressesPrimary(parsed.Addresses)
allocReq := AllocRequest{
ContainerID: req.ContainerID,
Namespace: args.PodNamespace,
Pod: args.PodName,
App: deriveAppName(pod),
WantV6: parsed.WantV6,
WantV4: parsed.WantV4,
WantV6: parsed.WantV6 && addrV6 == nil,
WantV4: parsed.WantV4 && addrV4 == nil,
AnnCIDR6: parsed.CIDR6,
AnnCIDR4: parsed.CIDR4,
IPAlgo: ipAlgo,
Image: podImageRef(pod),
}
res, err := h.IPAM.Allocate(allocReq)
if err != nil {
return nil, fmt.Errorf("ipam: %w", err)
var res AllocResult
if allocReq.WantV6 || allocReq.WantV4 {
var err error
res, err = h.IPAM.Allocate(allocReq)
if err != nil {
return nil, fmt.Errorf("ipam: %w", err)
}
}
// Promote the peeled addresses IPs into the primary slots. They get the
// IPAM-style routing path: bound to eth0 in configurePodSide, default
// route via fe80::1 / v4ProxyGW, on-link host route via setHostRoute.
// BGP advertisement of the /32/128 is handled by the AnycastReconciler
// via renderBird's outside-aggregate detection.
if addrV6 != nil {
res.IP6 = addrV6
}
if addrV4 != nil {
res.IP4 = addrV4
}
// Persist pending entry before any netlink work so a crash mid-ADD
@@ -167,7 +190,7 @@ func (h *PodHandler) Add(ctx context.Context, req flockcni.Request) (*current.Re
IP6: ipString(res.IP6),
IP4: ipString(res.IP4),
Anycast: anycastStrings(parsed.Anycast),
Addresses: anycastStrings(parsed.Addresses),
Addresses: anycastStrings(addrExtras),
State: StatePending,
AllocatedAt: time.Now().UTC(),
}
@@ -184,7 +207,7 @@ func (h *PodHandler) Add(ctx context.Context, req flockcni.Request) (*current.Re
IP6: res.IP6,
IP4: res.IP4,
Anycast: parsed.Anycast,
Addresses: parsed.Addresses,
Addresses: addrExtras,
}
if err := h.SetupFunc(setup); err != nil {
// Roll forward: leave pending entry in place so startup GC can clean
@@ -270,6 +293,33 @@ func ipString(ip net.IP) string {
return canonical(ip)
}
// splitAddressesPrimary peels off the first IPv6 and first IPv4 from the
// addresses list to use as the pod's primary IPs in place of an IPAM
// allocation. The remaining entries (anything beyond the first of each
// family) stay in extras for the existing layered eth0 binding via the
// AnycastReconciler's via-route path.
//
// Order of the input is preserved in extras. Either of v6/v4 may be nil
// when the addresses list contains no IP of that family — the caller falls
// back to IPAM allocation in that case.
func splitAddressesPrimary(ips []net.IP) (v6, v4 net.IP, extras []net.IP) {
for _, ip := range ips {
if ip.To4() != nil {
if v4 == nil {
v4 = ip.To4()
continue
}
} else {
if v6 == nil {
v6 = ip.To16()
continue
}
}
extras = append(extras, ip)
}
return
}
func anycastStrings(ips []net.IP) []string {
if len(ips) == 0 {
return nil