M2: netlink, IPAM/handler wiring, BIRD sidecar, CNI installer
Build flock Image / build (push) Has been cancelled

Code (Linux build, with no-op stubs for macOS dev):
- pkg/agent/netns_linux.go: ensureVeth → host-side configure (addrgenmode
  none, fe80::1/64, proxy_arp, forwarding) → move peer to pod ns →
  configure pod side (addr, default route via fe80::1, v4 169.254.1.1
  on-link gateway) → host /128 + /32 routes. Idempotent.
- pkg/agent/hostiface.go: deterministic host iface name flock<8hex> from
  FNV-1a-32(containerID).
- pkg/agent/annotations.go: parse flock.fritzlab.net/{ipv6,ipv4,cidr6,
  cidr4,ip-algo,anycast} with design-doc defaults; ParseCNIArgs for the
  K8S_POD_* keys kubelet sets.
- pkg/agent/podinfo.go: shared informer scoped to spec.nodeName==NODE,
  WaitForPod helper for ADD-vs-informer-sync race.
- pkg/agent/handlers.go: PodHandler does
    cache lookup → annotations → IPAM → store(pending) → SetupFunc →
    store(committed) → Result. Idempotent on retry. Del symmetric.
- pkg/routing/bird/config.go: text/template render with stable ordering;
  golden tests for host001 + anycast injection + sort stability.
- pkg/agent/bird.go: writes /etc/flock/bird/bird.conf, debounces 500ms,
  execs `birdc -s /run/flock/bird.ctl configure`. Installs blackhole
  kernel routes for the node summary CIDRs so BIRD's protocol kernel
  imports them.
- pkg/agent/runtime_linux.go: at startup, waits up to 60s for the per-
  node NodeConfig, reconciles committed allocations into IPAM.used,
  garbage-collects pending entries, builds PodHandler, swaps RPC
  handlers in.
- cmd/flock-installer: init-container binary that copies /opt/cni/bin/
  flock and writes 01-flock.conflist (lex-first so kubelet picks it
  over Calico's 10-calico.conflist on flock-labeled nodes).

Deploy:
- Dockerfile: alpine + iproute2 + bird2; multi-binary image.
- deploy/daemonset.yaml: install-cni init container; bird sidecar
  sharing /etc/flock/bird + /run/flock with the agent; ConfigMap-seeded
  bootstrap bird.conf so the sidecar boots before the agent renders.
  Privileged on flock-agent + install-cni; bird sidecar uses
  NET_ADMIN/RAW only.
- RBAC: pods + networkpolicies get/list/watch (the latter is reserved
  for M8 — harmless to grant now).

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Donavan Fritz
2026-04-24 22:33:48 -05:00
parent 31fcae2a97
commit eb1f5e0d8d
20 changed files with 1688 additions and 61 deletions
+176
View File
@@ -0,0 +1,176 @@
package agent
import (
"fmt"
"net"
"strings"
"code.fritzlab.net/fritzlab/flock/pkg/embed"
)
const annotationPrefix = "flock.fritzlab.net/"
// ParsedAnnotations is the typed view of a Pod's flock annotations.
type ParsedAnnotations struct {
WantV6 bool
WantV4 bool
CIDR6 []*net.IPNet
CIDR4 []*net.IPNet
IPAlgo []embed.Field
Anycast []net.IP
}
// ParseAnnotations applies the design-doc defaults (ipv6=true, ipv4=false)
// and validates the post-merge combination.
func ParseAnnotations(in map[string]string) (*ParsedAnnotations, error) {
out := &ParsedAnnotations{WantV6: true, WantV4: false}
if v, ok := in[annotationPrefix+"ipv6"]; ok {
switch strings.ToLower(strings.TrimSpace(v)) {
case "true":
out.WantV6 = true
case "false":
out.WantV6 = false
default:
return nil, fmt.Errorf("annotation ipv6=%q: must be true or false", v)
}
}
if v, ok := in[annotationPrefix+"ipv4"]; ok {
switch strings.ToLower(strings.TrimSpace(v)) {
case "true":
out.WantV4 = true
case "false":
out.WantV4 = false
default:
return nil, fmt.Errorf("annotation ipv4=%q: must be true or false", v)
}
}
if !out.WantV6 && !out.WantV4 {
return nil, fmt.Errorf("ipv6=false requires ipv4=true (pod must have at least one address)")
}
if v, ok := in[annotationPrefix+"cidr6"]; ok {
nets, err := parseCIDRList(v)
if err != nil {
return nil, fmt.Errorf("annotation cidr6: %w", err)
}
out.CIDR6 = nets
}
if v, ok := in[annotationPrefix+"cidr4"]; ok {
nets, err := parseCIDRList(v)
if err != nil {
return nil, fmt.Errorf("annotation cidr4: %w", err)
}
out.CIDR4 = nets
}
if v, ok := in[annotationPrefix+"ip-algo"]; ok {
fields, err := parseIPAlgo(v)
if err != nil {
return nil, fmt.Errorf("annotation ip-algo: %w", err)
}
out.IPAlgo = fields
}
if v, ok := in[annotationPrefix+"anycast"]; ok {
ips, err := parseIPList(v)
if err != nil {
return nil, fmt.Errorf("annotation anycast: %w", err)
}
out.Anycast = ips
}
return out, nil
}
func parseCIDRList(s string) ([]*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)
}
out = append(out, n)
}
if len(out) == 0 {
return nil, fmt.Errorf("empty CIDR list")
}
return out, nil
}
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
}
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 "namespace":
out = append(out, embed.FieldNamespace)
case "pod":
out = append(out, embed.FieldPod)
case "image":
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 parses the K=V;K=V CNI_ARGS string for the kubelet keys we care
// about. Other keys are ignored.
type CNIArgs struct {
PodNamespace string
PodName string
PodUID string
InfraID string
}
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
}