diff --git a/README.md b/README.md index da3991e..b1d808f 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,9 @@ A small, opinionated Kubernetes CNI built around three ideas: -1. **IPv6-first.** Every pod gets a globally routable IPv6 address. IPv4 is - per-pod opt-in for legacy clients. +1. **Dual-stack, IPv6-friendly.** Every pod gets a globally routable IPv6 + address by default. IPv4 is also enabled by default; either family can + be turned off per-node or per-pod when you really mean to. 2. **No tunnels, no NAT.** Pod addresses are the real packets on the wire. Each node speaks BGP to its upstream router and advertises its own per-node prefix. The pod network is just the LAN, plus host routes. @@ -127,7 +128,7 @@ spec: - 192.0.2.0/24 # IPv4 pool, used only when a pod opts in. defaults: ipv6: true # Optional. Built-in baseline if omitted. - ipv4: false # Optional. Built-in baseline if omitted. + ipv4: true # Optional. Built-in baseline if omitted. bgp: asn: 65101 # This node's local ASN. peers: @@ -143,13 +144,13 @@ spec: on this node — i.e. when the pod has no explicit `flock.fritzlab.net/ipv6` or `flock.fritzlab.net/ipv4` annotation. Pod annotations always override. If you omit `spec.defaults` (or any individual field inside it) flock -falls back to its built-in baseline of **IPv6 on, IPv4 off**. +falls back to its built-in baseline of **dual-stack (IPv6 on, IPv4 on)**. -| Goal | `spec.defaults` | -|---------------------------|----------------------------------------| -| IPv6-only (the default) | omit, or `{ ipv6: true, ipv4: false }`| -| Dual-stack by default | `{ ipv6: true, ipv4: true }` | -| IPv4-only (legacy node) | `{ ipv6: false, ipv4: true }` | +| Goal | `spec.defaults` | +|-----------------------------------|----------------------------------------| +| Dual-stack (the default) | omit, or `{ ipv6: true, ipv4: true }` | +| IPv6-only node | `{ ipv6: true, ipv4: false }` | +| IPv4-only (legacy node) | `{ ipv6: false, ipv4: true }` | A NodeConfig that resolves to "neither family" is rejected at allocation time, so misconfiguring both to false will surface as an error on the @@ -183,7 +184,7 @@ behaviour. ### Example pods -Default IPv6-only — no annotations needed: +Default dual-stack — no annotations needed: ```yaml apiVersion: v1 @@ -192,15 +193,15 @@ metadata: name: minimal ``` -Dual-stack on a node whose default is IPv6-only: +IPv6 only — opt out of the default v4 allocation: ```yaml apiVersion: v1 kind: Pod metadata: - name: legacy-client + name: v6-only annotations: - flock.fritzlab.net/ipv4: "true" + flock.fritzlab.net/ipv4: "false" ``` Operator-friendly addressing — `fnv(namespace) | fnv(pod) | random` @@ -227,7 +228,6 @@ spec: template: metadata: annotations: - flock.fritzlab.net/ipv4: "true" flock.fritzlab.net/anycast: "2001:db8:a::53, 192.0.2.53" spec: containers: @@ -274,28 +274,32 @@ annotation and the pod gets a normal allocation. | | flock | Calico | Cilium | |--------------------------|-----------------------------|------------------------------|------------------------------| -| Default address family | IPv6 | IPv4 | dual | +| Default address family | dual (IPv6+IPv4) | IPv4 | dual | | BGP | yes (BIRD) | yes | optional | | Overlay (VXLAN/IPIP) | never | optional | yes (geneve) or native | | NAT in datapath | never | masquerade by default | masquerade by default | | Anycast pod addressing | first-class | manual | optional, via service mesh | | eBPF datapath | no | optional | yes | -| NetworkPolicy | not yet | yes (Felix) | yes (eBPF) | +| NetworkPolicy | yes (nftables) | yes (Felix) | yes (eBPF) | | Cluster size target | small (< 100 nodes) | thousands | thousands | | Operational surface area | low (1 DaemonSet, 1 CRD) | medium | high | | Production-ready | alpha | yes | yes | flock is not trying to compete with Calico or Cilium. The right answer for most clusters is one of those two — flock exists for clusters where -every node already speaks BGP, the operator wants to think in IPv6-first -terms, and per-pod anycast is something they actually want to use rather -than work around. +every node already speaks BGP, the operator wants real (no NAT) IPv6 +addressing on every pod, and per-pod anycast is something they actually +want to use rather than work around. ## Limitations and non-goals -- No NetworkPolicy enforcement yet (planned). -- No NAT, no masquerade, no SNAT-egress. If your pods need to reach a - legacy IPv4-only destination, give them an IPv4 address explicitly. +- NetworkPolicy supports `networking.k8s.io/v1` (ingress + egress, all + three peer types, numeric ports + port ranges). Named ports and + AdminNetworkPolicy are not yet implemented. +- No NAT, no masquerade, no SNAT-egress. Pods reach the wider internet + using their real cluster-routable addresses; if your IPv4 pool isn't + routable beyond your network, those pods can't reach v4-only hosts on + the public internet without help from your border router. - No multi-cluster, no peering across clusters. - Linux-only datapath. - IPAM is per-node — there's no global allocator and no IP mobility. diff --git a/deploy/crds/flock.fritzlab.net_nodeconfigs.yaml b/deploy/crds/flock.fritzlab.net_nodeconfigs.yaml index c25da15..03c2d10 100644 --- a/deploy/crds/flock.fritzlab.net_nodeconfigs.yaml +++ b/deploy/crds/flock.fritzlab.net_nodeconfigs.yaml @@ -45,7 +45,7 @@ spec: when its own annotations don't specify. Pod annotations flock.fritzlab.net/ipv6 and flock.fritzlab.net/ipv4 always override these defaults. Built-in fallback (when this block - or any field is omitted) is IPv6=true, IPv4=false. + or any field is omitted) is IPv6=true, IPv4=true (dual-stack). properties: ipv6: type: boolean @@ -56,7 +56,7 @@ spec: type: boolean description: | Default IPv4 inclusion for pods on this node. Omit to - inherit the built-in baseline (false). + inherit the built-in baseline (true). bgp: type: object required: [asn, peers] diff --git a/deploy/install.yaml b/deploy/install.yaml index 8368f7b..4eb0252 100644 --- a/deploy/install.yaml +++ b/deploy/install.yaml @@ -45,7 +45,7 @@ spec: when its own annotations don't specify. Pod annotations flock.fritzlab.net/ipv6 and flock.fritzlab.net/ipv4 always override these defaults. Built-in fallback (when this block - or any field is omitted) is IPv6=true, IPv4=false. + or any field is omitted) is IPv6=true, IPv4=true (dual-stack). properties: ipv6: type: boolean @@ -56,7 +56,7 @@ spec: type: boolean description: | Default IPv4 inclusion for pods on this node. Omit to - inherit the built-in baseline (false). + inherit the built-in baseline (true). bgp: type: object required: [asn, peers] diff --git a/pkg/agent/annotations.go b/pkg/agent/annotations.go index 277e965..0195806 100644 --- a/pkg/agent/annotations.go +++ b/pkg/agent/annotations.go @@ -26,7 +26,7 @@ const ( // 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=false), then +// 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. // @@ -43,13 +43,17 @@ type FamilyDefaults struct { WantV4 bool } -// BuiltinFamilyDefaults returns flock's hard-coded fallback: IPv6 only. -// This is the policy applied when no NodeConfig override is in effect. +// 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: false} + return FamilyDefaults{WantV6: true, WantV4: true} } // FamilyDefaultsFromNodeConfig resolves the effective per-node defaults, diff --git a/pkg/agent/annotations_test.go b/pkg/agent/annotations_test.go index 16a3c66..691d283 100644 --- a/pkg/agent/annotations_test.go +++ b/pkg/agent/annotations_test.go @@ -13,8 +13,8 @@ func boolPtr(b bool) *bool { return &b } func TestBuiltinFamilyDefaults(t *testing.T) { d := BuiltinFamilyDefaults() - if !d.WantV6 || d.WantV4 { - t.Fatalf("built-in defaults wrong: v6=%v v4=%v (want true/false)", d.WantV6, d.WantV4) + if !d.WantV6 || !d.WantV4 { + t.Fatalf("built-in defaults wrong: v6=%v v4=%v (want dual-stack true/true)", d.WantV6, d.WantV4) } } @@ -37,14 +37,16 @@ func TestFamilyDefaultsFromNodeConfig_PartialOverride(t *testing.T) { nc := &flockv1alpha1.NodeConfig{ Spec: flockv1alpha1.NodeConfigSpec{ Defaults: &flockv1alpha1.FamilyDefaults{ - IPv4: boolPtr(true), + IPv4: boolPtr(false), }, }, } d := FamilyDefaultsFromNodeConfig(nc) - // IPv6 was unset → keeps built-in true; IPv4 was set → flipped on. - if !d.WantV6 || !d.WantV4 { - t.Fatalf("partial override wrong: %+v (want v6=true, v4=true)", d) + // IPv6 unset → keeps built-in true; IPv4 explicitly set to false → + // node opts the family off. Validates that an explicit false beats + // the dual-stack baseline rather than being silently overridden. + if !d.WantV6 || d.WantV4 { + t.Fatalf("partial override wrong: %+v (want v6=true, v4=false)", d) } } @@ -64,12 +66,27 @@ func TestFamilyDefaultsFromNodeConfig_FullOverride(t *testing.T) { } func TestParseAnnotations_BuiltinDefaults(t *testing.T) { + // Built-in baseline is dual-stack — no annotation needed. a, err := ParseAnnotations(nil, BuiltinFamilyDefaults()) if err != nil { t.Fatal(err) } + if !a.WantV6 || !a.WantV4 { + t.Fatalf("expected dual-stack default, got v6=%v v4=%v", a.WantV6, a.WantV4) + } +} + +// TestParseAnnotations_OptOutV4 — pods that want IPv6 only must opt out +// explicitly via the ipv4 annotation now that the built-in is dual-stack. +func TestParseAnnotations_OptOutV4(t *testing.T) { + a, err := ParseAnnotations(map[string]string{ + annotationPrefix + "ipv4": "false", + }, BuiltinFamilyDefaults()) + if err != nil { + t.Fatal(err) + } if !a.WantV6 || a.WantV4 { - t.Fatalf("defaults wrong: v6=%v v4=%v", a.WantV6, a.WantV4) + t.Fatalf("ipv4=false override failed: v6=%v v4=%v", a.WantV6, a.WantV4) } } @@ -100,6 +117,8 @@ func TestParseAnnotations_AnnotationOverridesNodeDefault(t *testing.T) { } func TestParseAnnotations_DualStackViaAnnotation(t *testing.T) { + // Same as built-in default; explicit ipv4=true is a no-op now but must + // still parse cleanly. a, err := ParseAnnotations(map[string]string{ annotationPrefix + "ipv4": "true", }, BuiltinFamilyDefaults()) @@ -112,10 +131,12 @@ func TestParseAnnotations_DualStackViaAnnotation(t *testing.T) { } func TestParseAnnotations_NoFamily(t *testing.T) { + // Pod opts out of both families → must be rejected. if _, err := ParseAnnotations(map[string]string{ annotationPrefix + "ipv6": "false", + annotationPrefix + "ipv4": "false", }, BuiltinFamilyDefaults()); err == nil { - t.Fatalf("expected error: ipv6=false ipv4=false") + t.Fatalf("expected error when pod opts out of both families") } } diff --git a/pkg/agent/ipam.go b/pkg/agent/ipam.go index 9fca2dd..6b447e0 100644 --- a/pkg/agent/ipam.go +++ b/pkg/agent/ipam.go @@ -69,8 +69,9 @@ type AllocRequest struct { Namespace string Pod string // WantV6 / WantV4 are the post-merge address family selection (pod - // annotation > NodeConfig.Spec.Defaults > built-in baseline). At least - // one MUST be true; Allocate rejects the request otherwise. + // annotation > NodeConfig.Spec.Defaults > built-in baseline of + // dual-stack). At least one MUST be true; Allocate rejects the request + // otherwise. WantV6 bool WantV4 bool // AnnCIDR6 / AnnCIDR4 come from the cidr6 / cidr4 annotations. Empty diff --git a/pkg/api/v1alpha1/nodeconfig_types.go b/pkg/api/v1alpha1/nodeconfig_types.go index 78ae70b..d76001c 100644 --- a/pkg/api/v1alpha1/nodeconfig_types.go +++ b/pkg/api/v1alpha1/nodeconfig_types.go @@ -35,13 +35,13 @@ type NodeConfigSpec struct { // always override these defaults. // // When a field is unset (nil), the agent falls back to its built-in - // baseline of IPv6=true, IPv4=false. When the whole Defaults block is - // nil, both built-in defaults apply. + // baseline of IPv6=true, IPv4=true (dual-stack). When the whole Defaults + // block is nil, both built-in defaults apply. // // Typical uses: - // - dual-stack node: Defaults: { ipv6: true, ipv4: true } - // - IPv4-only node: Defaults: { ipv6: false, ipv4: true } - // - default (omit Defaults entirely): IPv6-only. + // - dual-stack node (built-in default): omit Defaults entirely. + // - IPv6-only node: Defaults: { ipv6: true, ipv4: false } + // - IPv4-only node: Defaults: { ipv6: false, ipv4: true } // // Validation: at least one of IPv6 or IPv4 must end up true after merging // (annotations + defaults + built-in baseline). The agent rejects pods @@ -58,7 +58,7 @@ type FamilyDefaults struct { IPv6 *bool `json:"ipv6,omitempty"` // IPv4 is the default value for the `flock.fritzlab.net/ipv4` annotation. - // nil → fall back to the built-in baseline (false). + // nil → fall back to the built-in baseline (true). IPv4 *bool `json:"ipv4,omitempty"` }