1b8be63d05
Kea 2.6.5 restricts control/HA unix socket paths to its compiled
runstatedir and rejects any other path by exact string match
("invalid path specified: '/run/kea', supported path is '/var/run/kea'"),
even though /var/run is a symlink to /run. The operator rendered sockets
under /run/kea, so kea-dhcp4 and kea-ctrl-agent crash-looped on startup.
- Point RunDir at /var/run/kea so all derived config/socket paths match.
- Pre-create /var/run/kea in the kea image.
- Assert rendered socket paths live under /var/run/kea.
Claude-Session: https://claude.ai/code/session_01JUoARVdmhxKQHyyyp1pxeT
252 lines
7.3 KiB
Go
252 lines
7.3 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 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)
|
|
}
|
|
}
|