a6202a36bd
Build flock Image / build (push) Has been cancelled
BuiltinFamilyDefaults() now returns {WantV6: true, WantV4: true}. Pods
that want a single family explicitly opt out via the
flock.fritzlab.net/ipv4 (or ipv6) annotation, or the operator narrows
the default at the node level via NodeConfig.Spec.Defaults.
Annotation precedence is unchanged: pod annotation > NodeConfig defaults
> built-in baseline. Tests updated to reflect the new baseline; the
"opt out of v4" path now has explicit coverage.
Docs updated:
- NodeConfig.Spec.Defaults Go doc + CRD descriptions reflect the new
baseline and its overrides
- README opening framing softened from "IPv6-first" to "dual-stack,
IPv6-friendly"; example pods + spec.defaults table flipped to
treat dual-stack as the default and v6/v4-only as overrides
- README NetworkPolicy line in the comparison table flipped to
"yes (nftables)" since v1 enforcement shipped
- Limitations note about IPv4-only destinations rewritten — every
pod has v4 by default now, so the question is whether your IPv4
pool is routable beyond your network
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
310 lines
9.4 KiB
Go
310 lines
9.4 KiB
Go
package agent
|
|
|
|
import (
|
|
"fmt"
|
|
"net"
|
|
"strings"
|
|
|
|
flockv1alpha1 "code.fritzlab.net/fritzlab/flock/pkg/api/v1alpha1"
|
|
"code.fritzlab.net/fritzlab/flock/pkg/embed"
|
|
)
|
|
|
|
// annotationPrefix is the namespace under which all flock pod annotations
|
|
// live. Anything not starting with this prefix is ignored by the parser.
|
|
const annotationPrefix = "flock.fritzlab.net/"
|
|
|
|
// Recognised annotation keys (without the prefix).
|
|
const (
|
|
annIPv6 = "ipv6"
|
|
annIPv4 = "ipv4"
|
|
annCIDR6 = "cidr6"
|
|
annCIDR4 = "cidr4"
|
|
annIPAlgo = "ip-algo"
|
|
annAnycast = "anycast"
|
|
)
|
|
|
|
// FamilyDefaults is the per-call baseline for whether a pod receives an IPv6
|
|
// and/or IPv4 address. It is the merge of:
|
|
//
|
|
// 1. flock's built-in baseline (IPv6=true, IPv4=true — dual-stack), then
|
|
// 2. any NodeConfig.Spec.Defaults override the operator has applied to
|
|
// the local node.
|
|
//
|
|
// Pod-level `flock.fritzlab.net/ipv{6,4}` annotations override this baseline.
|
|
//
|
|
// Use FamilyDefaultsFromNodeConfig to compute a value from a NodeConfig,
|
|
// or BuiltinFamilyDefaults() if no NodeConfig is in scope.
|
|
type FamilyDefaults struct {
|
|
// WantV6 is the default-on value for IPv6 inclusion when the pod has no
|
|
// explicit ipv6 annotation.
|
|
WantV6 bool
|
|
// WantV4 is the default-on value for IPv4 inclusion when the pod has no
|
|
// explicit ipv4 annotation.
|
|
WantV4 bool
|
|
}
|
|
|
|
// BuiltinFamilyDefaults returns flock's hard-coded fallback: dual-stack
|
|
// (IPv6 + IPv4). This is the policy applied when no NodeConfig override is
|
|
// in effect. Pods that want a single family explicitly opt out via the
|
|
// `flock.fritzlab.net/ipv6` or `flock.fritzlab.net/ipv4` annotation, or
|
|
// the operator narrows the fallback at the node level via
|
|
// NodeConfig.Spec.Defaults.
|
|
//
|
|
// We define it as a function rather than a var so callers can't mutate the
|
|
// shared baseline at runtime.
|
|
func BuiltinFamilyDefaults() FamilyDefaults {
|
|
return FamilyDefaults{WantV6: true, WantV4: true}
|
|
}
|
|
|
|
// FamilyDefaultsFromNodeConfig resolves the effective per-node defaults,
|
|
// falling back to BuiltinFamilyDefaults for any field the NodeConfig leaves
|
|
// unset. A nil NodeConfig (or nil Spec.Defaults) returns the built-in
|
|
// baseline unchanged.
|
|
func FamilyDefaultsFromNodeConfig(nc *flockv1alpha1.NodeConfig) FamilyDefaults {
|
|
out := BuiltinFamilyDefaults()
|
|
if nc == nil || nc.Spec.Defaults == nil {
|
|
return out
|
|
}
|
|
if nc.Spec.Defaults.IPv6 != nil {
|
|
out.WantV6 = *nc.Spec.Defaults.IPv6
|
|
}
|
|
if nc.Spec.Defaults.IPv4 != nil {
|
|
out.WantV4 = *nc.Spec.Defaults.IPv4
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ParsedAnnotations is the typed view of a pod's flock annotations after the
|
|
// node-level defaults have been merged in. All slices are non-nil only when
|
|
// the corresponding annotation was present and parsed cleanly.
|
|
type ParsedAnnotations struct {
|
|
// WantV6 is true when the pod should receive an IPv6 address.
|
|
WantV6 bool
|
|
// WantV4 is true when the pod should receive an IPv4 address.
|
|
WantV4 bool
|
|
// CIDR6 narrows IPv6 allocation to specific operator-approved sub-ranges
|
|
// of the node's CIDR6 set. nil/empty means "use any node CIDR6".
|
|
CIDR6 []*net.IPNet
|
|
// CIDR4 narrows IPv4 allocation. nil/empty means "use any node CIDR4".
|
|
CIDR4 []*net.IPNet
|
|
// IPAlgo is the ordered list of identity fields used to build the IID.
|
|
// nil/empty means "random IID".
|
|
IPAlgo []embed.Field
|
|
// Anycast is the set of anycast IPs to bind on the pod's loopback.
|
|
// nil/empty means "no anycast".
|
|
Anycast []net.IP
|
|
}
|
|
|
|
// ParseAnnotations applies the supplied per-node defaults and validates the
|
|
// post-merge combination. It is pure — it does not consult NodeConfig or any
|
|
// global state — so it is safe to call from tests and fuzz targets.
|
|
//
|
|
// Annotation precedence: pod annotation > FamilyDefaults > built-in baseline.
|
|
// Callers compute FamilyDefaults via FamilyDefaultsFromNodeConfig and pass it
|
|
// in.
|
|
//
|
|
// Errors:
|
|
// - any unknown ipv6/ipv4 value (must be "true" or "false", case-insensitive)
|
|
// - any malformed cidr6/cidr4/anycast/ip-algo value
|
|
// - the post-merge combination resolves to neither IPv6 nor IPv4 (a pod
|
|
// must have at least one address)
|
|
func ParseAnnotations(in map[string]string, defaults FamilyDefaults) (*ParsedAnnotations, error) {
|
|
out := &ParsedAnnotations{WantV6: defaults.WantV6, WantV4: defaults.WantV4}
|
|
|
|
if v, ok := in[annotationPrefix+annIPv6]; ok {
|
|
b, err := parseBoolAnnotation(annIPv6, v)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out.WantV6 = b
|
|
}
|
|
if v, ok := in[annotationPrefix+annIPv4]; ok {
|
|
b, err := parseBoolAnnotation(annIPv4, v)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out.WantV4 = b
|
|
}
|
|
if !out.WantV6 && !out.WantV4 {
|
|
return nil, fmt.Errorf("annotations + defaults resolve to no address family (need at least one of ipv6/ipv4)")
|
|
}
|
|
|
|
if v, ok := in[annotationPrefix+annCIDR6]; ok {
|
|
nets, err := parseCIDRList(v, familyV6)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("annotation %s: %w", annCIDR6, err)
|
|
}
|
|
out.CIDR6 = nets
|
|
}
|
|
if v, ok := in[annotationPrefix+annCIDR4]; ok {
|
|
nets, err := parseCIDRList(v, familyV4)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("annotation %s: %w", annCIDR4, err)
|
|
}
|
|
out.CIDR4 = nets
|
|
}
|
|
|
|
if v, ok := in[annotationPrefix+annIPAlgo]; ok {
|
|
fields, err := parseIPAlgo(v)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("annotation %s: %w", annIPAlgo, err)
|
|
}
|
|
out.IPAlgo = fields
|
|
}
|
|
|
|
if v, ok := in[annotationPrefix+annAnycast]; ok {
|
|
ips, err := parseIPList(v)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("annotation %s: %w", annAnycast, err)
|
|
}
|
|
out.Anycast = ips
|
|
}
|
|
|
|
return out, 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
|
|
// than silently producing the "false" default.
|
|
func parseBoolAnnotation(key, v string) (bool, error) {
|
|
switch strings.ToLower(strings.TrimSpace(v)) {
|
|
case "true":
|
|
return true, nil
|
|
case "false":
|
|
return false, nil
|
|
default:
|
|
return false, fmt.Errorf("annotation %s=%q: must be \"true\" or \"false\"", key, v)
|
|
}
|
|
}
|
|
|
|
// addressFamily distinguishes IPv6 vs IPv4 in places where the parser must
|
|
// validate the family of supplied CIDRs.
|
|
type addressFamily int
|
|
|
|
const (
|
|
familyAny addressFamily = iota
|
|
familyV6
|
|
familyV4
|
|
)
|
|
|
|
// parseCIDRList parses a comma-separated CIDR list. Whitespace around items
|
|
// is trimmed; empty items are silently dropped. The list must contain at
|
|
// least one entry post-trim.
|
|
//
|
|
// If `want` is familyV6 or familyV4 each entry's family is checked and a
|
|
// mismatch is reported, so an `flock.fritzlab.net/cidr6` annotation cannot
|
|
// silently slip a v4 prefix into the v6 allocator.
|
|
func parseCIDRList(s string, want addressFamily) ([]*net.IPNet, error) {
|
|
var out []*net.IPNet
|
|
for _, part := range strings.Split(s, ",") {
|
|
part = strings.TrimSpace(part)
|
|
if part == "" {
|
|
continue
|
|
}
|
|
_, n, err := net.ParseCIDR(part)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid CIDR %q: %w", part, err)
|
|
}
|
|
isV4 := n.IP.To4() != nil
|
|
switch want {
|
|
case familyV6:
|
|
if isV4 {
|
|
return nil, fmt.Errorf("CIDR %q is IPv4, expected IPv6", part)
|
|
}
|
|
case familyV4:
|
|
if !isV4 {
|
|
return nil, fmt.Errorf("CIDR %q is IPv6, expected IPv4", part)
|
|
}
|
|
}
|
|
out = append(out, n)
|
|
}
|
|
if len(out) == 0 {
|
|
return nil, fmt.Errorf("empty CIDR list")
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// parseIPList parses a comma-separated literal-IP list. Same trim/empty
|
|
// semantics as parseCIDRList. Mixed v4 and v6 entries are allowed (anycast
|
|
// pods can advertise both families together).
|
|
func parseIPList(s string) ([]net.IP, error) {
|
|
var out []net.IP
|
|
for _, part := range strings.Split(s, ",") {
|
|
part = strings.TrimSpace(part)
|
|
if part == "" {
|
|
continue
|
|
}
|
|
ip := net.ParseIP(part)
|
|
if ip == nil {
|
|
return nil, fmt.Errorf("invalid IP %q", part)
|
|
}
|
|
out = append(out, ip)
|
|
}
|
|
if len(out) == 0 {
|
|
return nil, fmt.Errorf("empty IP list")
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// parseIPAlgo parses the ip-algo annotation. Each comma-separated token must
|
|
// match one of: namespace, pod, image. Empty tokens are dropped; unknown
|
|
// tokens are reported.
|
|
func parseIPAlgo(s string) ([]embed.Field, error) {
|
|
var out []embed.Field
|
|
for _, part := range strings.Split(s, ",") {
|
|
part = strings.TrimSpace(part)
|
|
switch part {
|
|
case "":
|
|
continue
|
|
case string(embed.FieldNamespace):
|
|
out = append(out, embed.FieldNamespace)
|
|
case string(embed.FieldPod):
|
|
out = append(out, embed.FieldPod)
|
|
case string(embed.FieldImage):
|
|
out = append(out, embed.FieldImage)
|
|
default:
|
|
return nil, fmt.Errorf("unknown ip-algo field %q (allowed: namespace, pod, image)", part)
|
|
}
|
|
}
|
|
if len(out) == 0 {
|
|
return nil, fmt.Errorf("empty ip-algo")
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// CNIArgs is the typed view of the K=V;K=V CNI_ARGS string passed by kubelet.
|
|
// We only keep the fields the agent uses; unknown keys are ignored.
|
|
type CNIArgs struct {
|
|
PodNamespace string
|
|
PodName string
|
|
PodUID string
|
|
InfraID string
|
|
}
|
|
|
|
// ParseCNIArgs is permissive by design — kubelet versions and runtime
|
|
// shims pass varying sets of keys. Malformed entries are skipped silently
|
|
// rather than failing the whole ADD; required-key validation is the
|
|
// caller's responsibility.
|
|
func ParseCNIArgs(s string) CNIArgs {
|
|
var a CNIArgs
|
|
for _, kv := range strings.Split(s, ";") {
|
|
eq := strings.IndexByte(kv, '=')
|
|
if eq < 0 {
|
|
continue
|
|
}
|
|
k, v := kv[:eq], kv[eq+1:]
|
|
switch k {
|
|
case "K8S_POD_NAMESPACE":
|
|
a.PodNamespace = v
|
|
case "K8S_POD_NAME":
|
|
a.PodName = v
|
|
case "K8S_POD_UID":
|
|
a.PodUID = v
|
|
case "K8S_POD_INFRA_CONTAINER_ID":
|
|
a.InfraID = v
|
|
}
|
|
}
|
|
return a
|
|
}
|