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
+35
View File
@@ -166,9 +166,44 @@ func ParseAnnotations(in map[string]string, defaults FamilyDefaults) (*ParsedAnn
out.Addresses = ips
}
// Reject pods that ask for an addresses- or anycast-supplied IP whose
// family was disabled (via the pod's ipv6/ipv4 annotation or NodeConfig
// default). Both annotation types put the IP on a pod interface and rely
// on the family being enabled for return-path routing — addresses needs
// the in-pod default v6/v4 route to send replies; anycast on lo needs
// the same default route on eth0 for the same reason. Silently accepting
// the IP would leave a non-functional pod, so we fail closed at ADD.
for _, ip := range out.Addresses {
if err := requireFamilyEnabled(ip, out.WantV6, out.WantV4, annAddresses); err != nil {
return nil, err
}
}
for _, ip := range out.Anycast {
if err := requireFamilyEnabled(ip, out.WantV6, out.WantV4, annAnycast); err != nil {
return nil, err
}
}
return out, nil
}
// requireFamilyEnabled returns an error when ip's family was opted out via
// the resolved WantV6/WantV4 booleans (pod annotation > NodeConfig default >
// built-in dual-stack). The source string identifies which annotation
// supplied the conflicting IP so the operator's error message is specific.
func requireFamilyEnabled(ip net.IP, wantV6, wantV4 bool, source string) error {
if ip.To4() != nil {
if !wantV4 {
return fmt.Errorf("annotation %s: contains IPv4 %s but ipv4 is disabled (annotation or NodeConfig default)", source, ip)
}
return nil
}
if !wantV6 {
return fmt.Errorf("annotation %s: contains IPv6 %s but ipv6 is disabled (annotation or NodeConfig default)", source, ip)
}
return nil
}
// parseBoolAnnotation accepts only "true" or "false" (case-insensitive,
// surrounding whitespace tolerated). All other values — including "1", "0",
// "yes", "no" — are rejected so operator typos are caught loudly rather