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
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>
218 lines
6.8 KiB
Go
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)
|
|
}
|
|
}
|
|
})
|
|
}
|