flock M1 scaffold: CNI plugin + agent + NodeConfig CRD
Build flock Image / build (push) Has been cancelled
Build flock Image / build (push) Has been cancelled
- cmd/flock + cmd/flock-agent: build cleanly; CNI ADD/DEL/CHECK return ErrInternal stubs until M2; agent boots, opens unix socket, logs JSON. - pkg/agent/state.go: durable allocations.json (atomic write + fsync + parent fsync); pending/committed lifecycle. Tests cover round-trip, replace-by-cid, version mismatch, no-leak-on-tmp. - pkg/embed/suffix.go: ip-algo IID embedding. Tests cover the /48-/96 nibble distribution table from the design doc, determinism, prefix preservation, N-nibble isolation, digest-vs-fallback divergence. - pkg/api/v1alpha1: minimal NodeConfig types (no controller-runtime yet). - deploy/: NodeConfig CRD, empty ServiceAccount/ClusterRole, DaemonSet pinned to flock.fritzlab.net/agent="" label so it only runs on opted-in nodes. - .gitea/workflows/main.yaml + Dockerfile: build + push to code.fritzlab.net/fritzlab/flock; runs go test in CI. Design doc: dfritzlab/k8s-manager/dfritz-cni.md. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
// Package embed implements ip-algo: deterministic embedding of pod identity
|
||||
// (namespace, pod name, image digest) into the host portion of an IPv6
|
||||
// address. The mapping is operator-friendly cosmetics — NOT a security
|
||||
// boundary. See dfritz-cni.md "IPv6 IID Embedding" for the full spec.
|
||||
package embed
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"net"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Field is one of the supported identity fields.
|
||||
type Field string
|
||||
|
||||
const (
|
||||
FieldNamespace Field = "namespace"
|
||||
FieldPod Field = "pod"
|
||||
FieldImage Field = "image"
|
||||
)
|
||||
|
||||
// Values carries the inputs for one embedding call. Image holds the SHA-256
|
||||
// manifest digest as 64 hex chars when known; otherwise pass the containerID
|
||||
// in ImageFallback and we'll FNV-1a-64 it.
|
||||
type Values struct {
|
||||
Namespace string
|
||||
Pod string
|
||||
Image string // 64-char hex sha256 manifest digest, or empty
|
||||
ImageFallback string // typically containerID, used when Image=="".
|
||||
}
|
||||
|
||||
// MaxFieldNibbles is the largest single-field width supported by this
|
||||
// implementation. 16 nibbles = 64 bits = the output width of FNV-1a-64.
|
||||
// Wider fields would require a wider hash; the design doc tolerates this
|
||||
// because real deployments use /64 nodes (15 field nibbles total).
|
||||
const MaxFieldNibbles = 16
|
||||
|
||||
// Embed returns the IPv6 address inside `network` whose host portion encodes
|
||||
// `fields` (in the given order) followed by the random nibble nNibble.
|
||||
//
|
||||
// `network` must be an IPv6 prefix whose length is a multiple of 4 (so the
|
||||
// host portion is a whole number of nibbles).
|
||||
//
|
||||
// `fields` must be non-empty. For a fully-random IID, the caller should pick
|
||||
// random bytes directly rather than calling Embed.
|
||||
//
|
||||
// nNibble is the random "instance" nibble; only the low 4 bits are used.
|
||||
// Callers regenerate it on collision (see allocations.json).
|
||||
func Embed(network *net.IPNet, fields []Field, vals Values, nNibble byte) (net.IP, error) {
|
||||
ones, bits := network.Mask.Size()
|
||||
if bits != 128 {
|
||||
return nil, fmt.Errorf("network is not IPv6: %s", network)
|
||||
}
|
||||
if ones%4 != 0 {
|
||||
return nil, fmt.Errorf("prefix length %d is not a multiple of 4", ones)
|
||||
}
|
||||
hostNibbles := (128 - ones) / 4
|
||||
if hostNibbles < 2 {
|
||||
return nil, fmt.Errorf("prefix /%d leaves %d host nibble(s); need at least 2 (one field + N)", ones, hostNibbles)
|
||||
}
|
||||
if len(fields) == 0 {
|
||||
return nil, fmt.Errorf("no fields specified; caller should generate random IID directly")
|
||||
}
|
||||
|
||||
fieldNibbles := hostNibbles - 1
|
||||
dist, err := distribute(fieldNibbles, len(fields))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
addr := make(net.IP, net.IPv6len)
|
||||
copy(addr, network.IP.To16())
|
||||
|
||||
// Stream nibbles left-to-right starting at the first host nibble.
|
||||
startNibble := ones / 4
|
||||
pos := 0
|
||||
for i, f := range fields {
|
||||
n := dist[i]
|
||||
v, err := fieldValue(f, vals, n*4)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Write `n` nibbles, most-significant first.
|
||||
for j := n - 1; j >= 0; j-- {
|
||||
nb := byte((v >> uint(j*4)) & 0xF)
|
||||
writeNibble(addr, startNibble+pos, nb)
|
||||
pos++
|
||||
}
|
||||
}
|
||||
writeNibble(addr, startNibble+pos, nNibble&0x0F)
|
||||
|
||||
return addr, nil
|
||||
}
|
||||
|
||||
// distribute splits `total` nibbles across `k` fields as evenly as possible,
|
||||
// giving any remainder to earlier fields one extra nibble at a time.
|
||||
func distribute(total, k int) ([]int, error) {
|
||||
if k <= 0 {
|
||||
return nil, fmt.Errorf("k must be > 0")
|
||||
}
|
||||
if total < k {
|
||||
return nil, fmt.Errorf("not enough host nibbles (%d) for %d fields", total, k)
|
||||
}
|
||||
out := make([]int, k)
|
||||
base := total / k
|
||||
rem := total % k
|
||||
for i := range out {
|
||||
out[i] = base
|
||||
if i < rem {
|
||||
out[i]++
|
||||
}
|
||||
if out[i] > MaxFieldNibbles {
|
||||
return nil, fmt.Errorf("field %d would need %d nibbles; max supported is %d", i, out[i], MaxFieldNibbles)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// fieldValue returns the top `bits` bits of the hash-or-digest for `f`,
|
||||
// right-aligned in the returned uint64.
|
||||
func fieldValue(f Field, v Values, bits int) (uint64, error) {
|
||||
if bits <= 0 || bits > 64 {
|
||||
return 0, fmt.Errorf("bad field bits %d (1..64)", bits)
|
||||
}
|
||||
switch f {
|
||||
case FieldNamespace:
|
||||
return topBitsFNV(v.Namespace, bits), nil
|
||||
case FieldPod:
|
||||
return topBitsFNV(v.Pod, bits), nil
|
||||
case FieldImage:
|
||||
if v.Image != "" {
|
||||
return topBitsHex(v.Image, bits)
|
||||
}
|
||||
return topBitsFNV(v.ImageFallback, bits), nil
|
||||
default:
|
||||
return 0, fmt.Errorf("unknown field %q", f)
|
||||
}
|
||||
}
|
||||
|
||||
func topBitsFNV(s string, bits int) uint64 {
|
||||
h := fnv.New64a()
|
||||
_, _ = h.Write([]byte(s))
|
||||
return h.Sum64() >> uint(64-bits)
|
||||
}
|
||||
|
||||
// topBitsHex parses a leading sha256-digest-style hex string and returns
|
||||
// its top `bits` bits, right-aligned. Accepts an optional "sha256:" prefix.
|
||||
func topBitsHex(s string, bits int) (uint64, error) {
|
||||
s = strings.TrimPrefix(s, "sha256:")
|
||||
if len(s) < 16 { // need at least 8 bytes / 64 bits to right-shift
|
||||
return 0, fmt.Errorf("image digest too short: %d hex chars", len(s))
|
||||
}
|
||||
b, err := hex.DecodeString(s[:16])
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("image digest not hex: %w", err)
|
||||
}
|
||||
var v uint64
|
||||
for _, x := range b {
|
||||
v = (v << 8) | uint64(x)
|
||||
}
|
||||
return v >> uint(64-bits), nil
|
||||
}
|
||||
|
||||
// writeNibble sets the (nibIdx)-th nibble of addr (0 = highest nibble of byte 0).
|
||||
func writeNibble(addr net.IP, nibIdx int, nb byte) {
|
||||
bytePos := nibIdx / 2
|
||||
if nibIdx%2 == 0 {
|
||||
addr[bytePos] = (addr[bytePos] & 0x0F) | (nb << 4)
|
||||
} else {
|
||||
addr[bytePos] = (addr[bytePos] & 0xF0) | (nb & 0x0F)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package embed
|
||||
|
||||
import (
|
||||
"net"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func mustCIDR(t *testing.T, s string) *net.IPNet {
|
||||
t.Helper()
|
||||
_, n, err := net.ParseCIDR(s)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseCIDR(%q): %v", s, err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func TestDistribute(t *testing.T) {
|
||||
cases := []struct {
|
||||
total, k int
|
||||
want []int
|
||||
}{
|
||||
// from the doc table
|
||||
{19, 1, []int{19}}, // /48 1 field — would exceed MaxFieldNibbles, see error test below
|
||||
{19, 2, []int{10, 9}},
|
||||
{19, 3, []int{7, 6, 6}},
|
||||
{17, 1, []int{17}},
|
||||
{17, 2, []int{9, 8}},
|
||||
{17, 3, []int{6, 6, 5}},
|
||||
{15, 1, []int{15}},
|
||||
{15, 2, []int{8, 7}},
|
||||
{15, 3, []int{5, 5, 5}},
|
||||
{11, 1, []int{11}},
|
||||
{11, 2, []int{6, 5}},
|
||||
{11, 3, []int{4, 4, 3}},
|
||||
{7, 1, []int{7}},
|
||||
{7, 2, []int{4, 3}},
|
||||
{7, 3, []int{3, 2, 2}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, err := distribute(c.total, c.k)
|
||||
if c.total > MaxFieldNibbles && c.k == 1 {
|
||||
if err == nil {
|
||||
t.Errorf("distribute(%d,%d): expected MaxFieldNibbles error", c.total, c.k)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("distribute(%d,%d): %v", c.total, c.k, err)
|
||||
continue
|
||||
}
|
||||
if !equal(got, c.want) {
|
||||
t.Errorf("distribute(%d,%d) = %v, want %v", c.total, c.k, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func equal(a, b []int) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func TestEmbed_Slash64Deterministic(t *testing.T) {
|
||||
// /64 with 3 fields: 5+5+5+1 nibbles = 64-bit IID.
|
||||
net64 := mustCIDR(t, "2602:817:3000:f001::/64")
|
||||
addr, err := Embed(net64,
|
||||
[]Field{FieldNamespace, FieldPod, FieldImage},
|
||||
Values{Namespace: "mail", Pod: "stalwart-0", ImageFallback: "container-abc"},
|
||||
0xe,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Embed: %v", err)
|
||||
}
|
||||
// Property: same inputs → same output (twice).
|
||||
addr2, err := Embed(net64,
|
||||
[]Field{FieldNamespace, FieldPod, FieldImage},
|
||||
Values{Namespace: "mail", Pod: "stalwart-0", ImageFallback: "container-abc"},
|
||||
0xe,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !addr.Equal(addr2) {
|
||||
t.Fatalf("non-deterministic: %s vs %s", addr, addr2)
|
||||
}
|
||||
// Property: prefix preserved.
|
||||
if !net64.Contains(addr) {
|
||||
t.Fatalf("addr %s outside network %s", addr, net64)
|
||||
}
|
||||
// Property: last nibble is exactly N.
|
||||
if got := addr[len(addr)-1] & 0x0F; got != 0xe {
|
||||
t.Fatalf("last nibble = %x, want e", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbed_DifferentInputsDifferentOutputs(t *testing.T) {
|
||||
net64 := mustCIDR(t, "2602:817:3000:f001::/64")
|
||||
a, _ := Embed(net64, []Field{FieldNamespace, FieldPod}, Values{Namespace: "ns1", Pod: "p1"}, 0)
|
||||
b, _ := Embed(net64, []Field{FieldNamespace, FieldPod}, Values{Namespace: "ns2", Pod: "p1"}, 0)
|
||||
if a.Equal(b) {
|
||||
t.Fatalf("different namespace produced identical IID: %s", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbed_NRandomizesLowNibble(t *testing.T) {
|
||||
net64 := mustCIDR(t, "2602:817:3000:f001::/64")
|
||||
a, _ := Embed(net64, []Field{FieldNamespace}, Values{Namespace: "x"}, 0x1)
|
||||
b, _ := Embed(net64, []Field{FieldNamespace}, Values{Namespace: "x"}, 0x2)
|
||||
if a.Equal(b) {
|
||||
t.Fatalf("changing N did not change address")
|
||||
}
|
||||
// And the only difference should be the last nibble.
|
||||
if a[15]>>4 != b[15]>>4 {
|
||||
t.Fatalf("upper nibble of last byte changed unexpectedly: %x vs %x", a[15], b[15])
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbed_RejectsBadInputs(t *testing.T) {
|
||||
net64 := mustCIDR(t, "2602:817:3000:f001::/64")
|
||||
if _, err := Embed(net64, nil, Values{}, 0); err == nil {
|
||||
t.Fatalf("expected error for empty fields")
|
||||
}
|
||||
odd := &net.IPNet{IP: net.ParseIP("2602:817:3000::"), Mask: net.CIDRMask(63, 128)}
|
||||
if _, err := Embed(odd, []Field{FieldNamespace}, Values{Namespace: "x"}, 0); err == nil {
|
||||
t.Fatalf("expected error for /63 (not nibble-aligned)")
|
||||
}
|
||||
v4 := &net.IPNet{IP: net.ParseIP("10.0.0.0").To4(), Mask: net.CIDRMask(8, 32)}
|
||||
if _, err := Embed(v4, []Field{FieldNamespace}, Values{Namespace: "x"}, 0); err == nil {
|
||||
t.Fatalf("expected error for IPv4 network")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbed_ImageDigestVsFallback(t *testing.T) {
|
||||
net64 := mustCIDR(t, "2602:817:3000:f001::/64")
|
||||
digest := "sha256:abcdef0123456789aabbccddeeff00112233445566778899aabbccddeeff0011"
|
||||
a, err := Embed(net64, []Field{FieldImage}, Values{Image: digest}, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("Embed digest: %v", err)
|
||||
}
|
||||
b, err := Embed(net64, []Field{FieldImage}, Values{ImageFallback: "ctr-xyz"}, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("Embed fallback: %v", err)
|
||||
}
|
||||
if a.Equal(b) {
|
||||
t.Fatalf("digest and fallback produced same IID")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user