defaults: built-in baseline is dual-stack (IPv6 + IPv4), not IPv6-only
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>
This commit is contained in:
Donavan Fritz
2026-04-25 10:07:48 -05:00
parent a7dc7bf1f4
commit a6202a36bd
7 changed files with 76 additions and 46 deletions
+24 -20
View File
@@ -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,12 +144,12 @@ 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 }` |
|-----------------------------------|----------------------------------------|
| 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
@@ -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.
@@ -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]
+2 -2
View File
@@ -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]
+8 -4
View File
@@ -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,
+29 -8
View File
@@ -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")
}
}
+3 -2
View File
@@ -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
+5 -5
View File
@@ -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 }
// - 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 }
// - default (omit Defaults entirely): IPv6-only.
//
// 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"`
}