Files
kea-operator/internal/kea/config_test.go
T
unkinben f20b26fd71
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
Wait for HA peer DNS before starting kea-dhcp4
## Why
kea-dhcp4 crash-loops on a cold container start: the HA hook resolves the StatefulSet peer URL hostnames once at config load, but the peer DNS records are not resolvable in the first moment of a fresh container, and kea exits hard instead of retrying (HA_CONFIGURATION_FAILED / "Failed to convert string to address"). Once DNS is warm the exact config validates, so the failure is purely a startup race.

## How
- gate the dhcp4 entrypoint on `kea-dhcp4 -t` and retry until the config validates before exec'ing the server
2026-08-08 22:38:20 +10:00

275 lines
8.2 KiB
Go

package kea
import (
"encoding/json"
"strings"
"testing"
v1alpha1 "git.unkin.net/unkin/kea-operator/api/v1alpha1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// referenceInput mirrors the ISC dhcpd config that must be fully representable:
// subnets 198.18.13-17.0/24 (pool .200-.220, routers, dns .19.15, next-server
// .19.19, domain main.unkin.net), plus 198.18.25.0/24 with no pool; the two PXE
// arch classes; authoritative; ddns off.
func referenceInput() RenderInput {
cluster := v1alpha1.KeaCluster{
ObjectMeta: metav1.ObjectMeta{Name: "pxe"},
Spec: v1alpha1.KeaClusterSpec{
DomainName: "main.unkin.net",
DefaultLeaseTime: 1200,
MaxLeaseTime: 86400,
HA: v1alpha1.HASpec{Mode: v1alpha1.HAHotStandby},
},
}
mkSubnet := func(name, cidr string, withPool bool) v1alpha1.KeaSubnet {
s := v1alpha1.KeaSubnet{
ObjectMeta: metav1.ObjectMeta{Name: name},
Spec: v1alpha1.KeaSubnetSpec{
Subnet: cidr,
Routers: []string{strings.TrimSuffix(cidr, "0/24") + "1"},
DNSServers: []string{"198.18.19.15"},
DomainName: "main.unkin.net",
NextServer: "198.18.19.19",
},
}
if withPool {
base := strings.TrimSuffix(cidr, "0/24")
s.Spec.Pools = []string{base + "200 - " + base + "220"}
}
return s
}
subnets := []v1alpha1.KeaSubnet{
mkSubnet("s13", "198.18.13.0/24", true),
mkSubnet("s14", "198.18.14.0/24", true),
mkSubnet("s15", "198.18.15.0/24", true),
mkSubnet("s16", "198.18.16.0/24", true),
mkSubnet("s17", "198.18.17.0/24", true),
mkSubnet("s25", "198.18.25.0/24", false),
}
classes := []v1alpha1.KeaClientClass{
{ObjectMeta: metav1.ObjectMeta{Name: "Legacy"}, Spec: v1alpha1.KeaClientClassSpec{
ArchHex: []string{"0x0000"}, BootFileName: "/undionly.kpxe"}},
{ObjectMeta: metav1.ObjectMeta{Name: "UEFI-64"}, Spec: v1alpha1.KeaClientClassSpec{
ArchHex: []string{"0x0007", "0x0009"}, BootFileName: "/ipxe.efi"}},
}
peers := []Peer{
{Name: "server0", URL: "http://pxe-0.pxe-headless.dhcp-system:8000/", Role: "primary"},
{Name: "server1", URL: "http://pxe-1.pxe-headless.dhcp-system:8000/", Role: "standby"},
}
return RenderInput{Cluster: cluster, Subnets: subnets, ClientClasses: classes, Peers: peers}
}
func TestRenderDHCP4IsValidJSON(t *testing.T) {
out, err := RenderDHCP4(referenceInput())
if err != nil {
t.Fatalf("render: %v", err)
}
var root map[string]any
if err := json.Unmarshal([]byte(out), &root); err != nil {
t.Fatalf("output is not valid JSON: %v\n%s", err, out)
}
if _, ok := root["Dhcp4"]; !ok {
t.Fatalf("missing Dhcp4 top-level key")
}
}
func TestRenderDHCP4ReferenceSemantics(t *testing.T) {
out, err := RenderDHCP4(referenceInput())
if err != nil {
t.Fatalf("render: %v", err)
}
must := []string{
`"authoritative": true`,
`"ddns-send-updates": false`,
`"valid-lifetime": 1200`,
`"max-valid-lifetime": 86400`,
`"198.18.13.0/24"`,
`"198.18.25.0/24"`,
`"198.18.13.200 - 198.18.13.220"`,
`"next-server": "198.18.19.19"`,
`"data": "198.18.19.15"`, // domain-name-servers
`"data": "198.18.13.1"`, // routers
`"data": "main.unkin.net"`, // domain-name
`"boot-file-name": "/undionly.kpxe"`,
`"boot-file-name": "/ipxe.efi"`,
`option[93].hex == 0x0000`,
`option[93].hex == 0x0007 or option[93].hex == 0x0009`,
`libdhcp_ha.so`,
`libdhcp_lease_cmds.so`,
`"mode": "hot-standby"`,
ThisServerPlaceholder,
`memfile`,
}
for _, m := range must {
if !strings.Contains(out, m) {
t.Errorf("rendered config missing %q\n---\n%s", m, out)
}
}
}
// TestSubnetWithoutPoolIsDeclared verifies the pool-less subnet still appears
// (Kea must know the subnet to service relayed requests) but carries no pools.
func TestSubnetWithoutPoolIsDeclared(t *testing.T) {
out, err := RenderDHCP4(referenceInput())
if err != nil {
t.Fatalf("render: %v", err)
}
var root dhcp4Root
if err := json.Unmarshal([]byte(out), &root); err != nil {
t.Fatalf("unmarshal: %v", err)
}
var found bool
for _, s := range root.Dhcp4.Subnet4 {
if s.Subnet == "198.18.25.0/24" {
found = true
if len(s.Pools) != 0 {
t.Errorf("198.18.25.0/24 should have no pools, got %v", s.Pools)
}
}
}
if !found {
t.Fatalf("pool-less subnet 198.18.25.0/24 not declared")
}
}
// TestRenderDeterministicWithShuffledInput asserts byte-identical output
// regardless of input ordering — unsorted input would churn the ConfigMap and
// trigger a restart loop.
func TestRenderDeterministicWithShuffledInput(t *testing.T) {
a := referenceInput()
b := referenceInput()
// shuffle b
b.Subnets[0], b.Subnets[5] = b.Subnets[5], b.Subnets[0]
b.ClientClasses[0], b.ClientClasses[1] = b.ClientClasses[1], b.ClientClasses[0]
b.Peers[0], b.Peers[1] = b.Peers[1], b.Peers[0]
oa, err := RenderDHCP4(a)
if err != nil {
t.Fatal(err)
}
ob, err := RenderDHCP4(b)
if err != nil {
t.Fatal(err)
}
if oa != ob {
t.Errorf("render not deterministic under shuffled input\n--A--\n%s\n--B--\n%s", oa, ob)
}
}
// TestNoPodIPsInConfig guards the restart-loop invariant: the rendered config
// (which drives the config hash) must contain only stable DNS peer names.
func TestNoPodIPsInConfig(t *testing.T) {
out, err := RenderDHCP4(referenceInput())
if err != nil {
t.Fatal(err)
}
for _, ip := range []string{"10.", "172.", "192.168."} {
if strings.Contains(out, `"url": "http://`+ip) {
t.Errorf("pod IP leaked into HA peer url (contains %q)", ip)
}
}
if !strings.Contains(out, "pxe-headless") {
t.Errorf("expected stable headless DNS peer url")
}
}
func TestSubnetIDAssignmentStableAndUnique(t *testing.T) {
in := referenceInput()
out, err := RenderDHCP4(in)
if err != nil {
t.Fatal(err)
}
var root dhcp4Root
if err := json.Unmarshal([]byte(out), &root); err != nil {
t.Fatal(err)
}
seen := map[int]bool{}
for _, s := range root.Dhcp4.Subnet4 {
if s.ID <= 0 {
t.Errorf("subnet %s has invalid id %d", s.Subnet, s.ID)
}
if seen[s.ID] {
t.Errorf("duplicate subnet id %d", s.ID)
}
seen[s.ID] = true
}
if len(seen) != 6 {
t.Errorf("expected 6 unique subnet ids, got %d", len(seen))
}
}
func TestExplicitSubnetIDPreserved(t *testing.T) {
in := referenceInput()
in.Subnets[2].Spec.ID = 42
out, err := RenderDHCP4(in)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(out, `"id": 42`) {
t.Errorf("explicit subnet id 42 not preserved")
}
}
func TestRenderCtrlAgent(t *testing.T) {
out, err := RenderCtrlAgent()
if err != nil {
t.Fatal(err)
}
var root map[string]any
if err := json.Unmarshal([]byte(out), &root); err != nil {
t.Fatalf("ctrl-agent config not valid JSON: %v", err)
}
for _, m := range []string{`"http-port": 8000`, `kea4-ctrl-socket`, `"dhcp4"`} {
if !strings.Contains(out, m) {
t.Errorf("ctrl-agent config missing %q", m)
}
}
// Kea 2.6+ only accepts unix socket paths under /var/run/kea (exact string).
if !strings.Contains(out, `"socket-name": "/var/run/kea/`) {
t.Errorf("ctrl-agent socket-name must be under /var/run/kea, got: %s", out)
}
}
func TestEntrypointsHardenSocketDir(t *testing.T) {
// Kea 2.6+ rejects a socket dir "more relaxed than 750"; the emptyDir mount
// defaults to 0777, so the entrypoints must chmod it before exec'ing kea.
want := "chmod 0750 " + RunDir
if ep := EntrypointDHCP4(); !strings.Contains(ep, want) {
t.Errorf("dhcp4 entrypoint must %q, got:\n%s", want, ep)
}
if ep := EntrypointCtrlAgent(); !strings.Contains(ep, want) {
t.Errorf("ctrl-agent entrypoint must %q, got:\n%s", want, ep)
}
}
func TestEntrypointWaitsForConfigToValidate(t *testing.T) {
// The dhcp4 entrypoint must gate startup on `kea-dhcp4 -t` so a cold-start
// HA peer DNS resolution failure retries instead of crash-looping.
ep := EntrypointDHCP4()
for _, want := range []string{"until " + DHCP4Bin + " -t " + DHCP4ConfPath, "exec " + DHCP4Bin + " -c " + DHCP4ConfPath} {
if !strings.Contains(ep, want) {
t.Errorf("dhcp4 entrypoint must contain %q, got:\n%s", want, ep)
}
}
}
func TestControlSocketPathAllowedByKea(t *testing.T) {
if !strings.HasPrefix(CtrlSocketPath, "/var/run/kea/") {
t.Errorf("CtrlSocketPath %q must live under /var/run/kea (kea 2.6+ restriction)", CtrlSocketPath)
}
out, err := RenderDHCP4(referenceInput())
if err != nil {
t.Fatal(err)
}
if !strings.Contains(out, `"socket-name": "/var/run/kea/`) {
t.Errorf("dhcp4 control-socket must be under /var/run/kea, got: %s", out)
}
}