Files
DDI/agent/role/integration/dhcp_test.go
T
beug.k@beugsiud.deandClaude Opus 5.5 d3e5807a09
ci / Protobuf (push) Successful in 18s
ci / Web (lint, test, audit, build) (push) Successful in 1m16s
ci / Shell and licence headers (push) Successful in 20s
ci / Release build (dry run) (push) Successful in 2m9s
ci / Go (lint, test, vuln) (push) Successful in 4m19s
ci / End-to-end (server, agents, BIND, Kea) (push) Successful in 1m49s
ci / Development environment (compose) (push) Successful in 12s
ci / Integration (real BIND and Kea) (push) Successful in 5m12s
test: retry image pulls, executable e2e scripts, fix truncation test
Docker Hub timed out on the shared runner; pulls now retry and fall back
to a local image. The e2e scripts were committed without the executable
bit. The truncation test checked letters that the marker itself contains.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:37:53 +02:00

218 lines
6.8 KiB
Go

// SPDX-License-Identifier: Apache-2.0
//go:build integration
package integration
import (
"fmt"
"net/netip"
"strings"
"testing"
"git.beugsiud.de/beug.k/DDI/agent/core"
)
const (
keaSocketDir = "/run/kea"
keaSocket = keaSocketDir + "/kea4-ctrl-socket"
keaConfFile = "/etc/kea/kea-dhcp4.conf"
dhcpConfig = testDir + "/agent-dhcp.yaml"
)
// A small page size makes Leases page through three leases.
const dhcpAgentConfig = `dhcp:
config_file: ` + keaConfFile + `
control_socket: ` + keaSocket + `
lease_page_size: 2
command_timeout: 30s
`
// Kea 2.6.3+/3.x only accept control sockets in KEA_CONTROL_SOCKET_DIR,
// which must not be accessible by others.
const keaScript = `set -e
mkdir -p ` + keaSocketDir + ` /var/lib/kea
chmod 750 ` + keaSocketDir + `
exec kea-dhcp4 -c ` + keaConfFile + `
`
// keaConf renders a generator-like configuration (see the server's keagen
// golden files) with the lease_cmds hook from hooksDir.
func keaConf(socket, hooksDir string, subnets ...string) string {
return fmt.Sprintf(`{
"Dhcp4": {
"interfaces-config": {
"interfaces": ["*"],
"dhcp-socket-type": "udp"
},
"control-socket": {
"socket-type": "unix",
"socket-name": %q
},
"lease-database": {
"type": "memfile",
"persist": true,
"name": "/var/lib/kea/kea-leases4.csv",
"lfc-interval": 3600
},
"hooks-libraries": [
{
"library": %q
}
],
"valid-lifetime": 3600,
"subnet4": [%s]
}
}
`, socket, hooksDir+"/libdhcp_lease_cmds.so", strings.Join(subnets, ","))
}
const (
subnet1 = `
{
"id": 1,
"subnet": "192.0.2.0/24",
"pools": [{"pool": "192.0.2.100 - 192.0.2.199"}],
"option-data": [{"name": "routers", "data": "192.0.2.1"}]
}`
subnet2 = `
{
"id": 2,
"subnet": "198.51.100.0/24",
"valid-lifetime": 1800,
"pools": [{"pool": "198.51.100.100 - 198.51.100.199"}]
}`
// A pool outside its subnet: Kea rejects the configuration.
brokenSubnet = `
{
"id": 3,
"subnet": "203.0.113.0/24",
"pools": [{"pool": "10.0.0.1 - 10.0.0.9"}]
}`
)
// keaHooksDir finds the directory of libdhcp_lease_cmds.so in the image
// (Kea 3.x loads hooks only from its built-in hooks directory).
func keaHooksDir(t *testing.T, image string) string {
t.Helper()
pullImage(t, image)
out := mustDocker(t, "run", "--rm", "--entrypoint", "sh", image, "-c",
"dirname \"$(find / -name libdhcp_lease_cmds.so 2>/dev/null | head -n1)\"")
dir := strings.TrimSpace(out)
if dir == "" || dir == "." {
t.Fatalf("libdhcp_lease_cmds.so not found in %s", image)
}
return dir
}
func TestDHCPRole(t *testing.T) {
driver := driverBinary(t)
for _, image := range images("DDI_KEA_IMAGES",
"docker.cloudsmith.io/isc/docker/kea-dhcp4:2.6.5 docker.cloudsmith.io/isc/docker/kea-dhcp4:3.2.1") {
t.Run(image, func(t *testing.T) { testDHCPRole(t, image, driver) })
}
}
func testDHCPRole(t *testing.T, image string, driver []byte) {
hooks := keaHooksDir(t, image)
tr := newTree(t)
tr.file(keaConfFile, []byte(keaConf(keaSocket, hooks)), 0o640)
tr.file(driverPath, driver, 0o755)
tr.file(dhcpConfig, []byte(dhcpAgentConfig), 0o644)
conf1 := keaConf(keaSocket, hooks, subnet1)
conf2 := keaConf(keaSocket, hooks, subnet1, subnet2)
rev1 := tr.revision("1", 1, map[string]string{"kea-dhcp4.conf": conf1})
rev2 := tr.revision("2", 2, map[string]string{"kea-dhcp4.conf": conf2})
revBroken := tr.revision("broken", 3, map[string]string{"kea-dhcp4.conf": keaConf(keaSocket, hooks, subnet1, brokenSubnet)})
revSocket := tr.revision("socket", 3, map[string]string{"kea-dhcp4.conf": keaConf(keaSocketDir+"/other-socket", hooks, subnet1)})
c := startContainer(t, image, tr, keaScript, "KEA_CONTROL_SOCKET_DIR="+keaSocketDir)
if r := c.driver("dhcp", dhcpConfig, "wait"); r.Error != "" {
t.Fatalf("kea-dhcp4 did not start: %+v %+v", r, r.Health)
}
t.Run("version", func(t *testing.T) {
r := c.driver("dhcp", dhcpConfig, "version")
if r.Error != "" || !strings.HasPrefix(r.Version, "Kea ") {
t.Fatalf("version = %q, error %q", r.Version, r.Error)
}
})
r := c.driver("dhcp", dhcpConfig, "apply", rev1)
if r.Outcome != "succeeded" {
t.Fatalf("revision 1: %+v %+v", r, r.Health)
}
r = c.driver("dhcp", dhcpConfig, "apply", rev2, rev1)
if r.Outcome != "succeeded" {
t.Fatalf("revision 2: %+v %+v", r, r.Health)
}
if got := c.cat(keaConfFile); got != conf2 {
t.Fatalf("installed configuration differs from revision 2:\n%s", got)
}
t.Run("invalid configuration is rejected", func(t *testing.T) {
r := c.driver("dhcp", dhcpConfig, "apply", revBroken, rev2)
if r.Outcome != "rejected" || !strings.Contains(r.Error, "configuration check failed") {
t.Fatalf("got %+v", r)
}
})
t.Run("other control socket is rejected", func(t *testing.T) {
r := c.driver("dhcp", dhcpConfig, "apply", revSocket, rev2)
if r.Outcome != "rejected" || !strings.Contains(r.Error, "dhcp.control_socket") {
t.Fatalf("got %+v", r)
}
})
t.Run("failed reload restores the previous file", func(t *testing.T) {
// Activate without Validate, as if kea-dhcp4 -t had missed it.
r := c.driver("dhcp", dhcpConfig, "activate", revBroken, rev2)
if r.Error == "" || !strings.Contains(r.Error, "config-reload") {
t.Fatalf("got %+v", r)
}
if got := c.cat(keaConfFile); got != conf2 {
t.Errorf("configuration not restored:\n%s", got)
}
r = c.driver("dhcp", dhcpConfig, "health", rev2)
if r.Health == nil || r.Health.State != core.HealthOK {
t.Fatalf("health after restore: %+v", r.Health)
}
})
t.Run("leases", func(t *testing.T) {
macs := map[string]string{
"192.0.2.150": "1A:1B:1C:1D:1E:01",
"192.0.2.151": "1A:1B:1C:1D:1E:02",
"198.51.100.120": "1A:1B:1C:1D:1E:03",
}
for ip, mac := range macs {
subnet := 1
if strings.HasPrefix(ip, "198.") {
subnet = 2
}
args := fmt.Sprintf(`{"ip-address": %q, "hw-address": %q, "hostname": "host-%s", "subnet-id": %d}`,
ip, mac, strings.ReplaceAll(ip, ".", "-"), subnet)
r := c.driver("dhcp", dhcpConfig, "kea", "lease4-add", args)
if r.Error != "" || r.Response == nil || r.Response.Result != 0 {
t.Fatalf("lease4-add %s: %+v %+v", ip, r, r.Response)
}
}
r := c.driver("dhcp", dhcpConfig, "leases")
if r.Error != "" || len(r.Leases) != len(macs) {
t.Fatalf("leases: %+v", r)
}
for _, l := range r.Leases {
want, ok := macs[l.IP.String()]
if !ok || l.MAC != strings.ToLower(want) || l.State != core.LeaseActive || l.Expires.Before(l.Updated) {
t.Errorf("unexpected lease %+v", l)
}
if wantSubnet := uint32(1); netip.MustParsePrefix("198.51.100.0/24").Contains(l.IP) {
if l.SubnetID != 2 {
t.Errorf("lease %s: subnet %d", l.IP, l.SubnetID)
}
} else if l.SubnetID != wantSubnet {
t.Errorf("lease %s: subnet %d", l.IP, l.SubnetID)
}
}
})
}