Scaffold kea-operator: CRDs, controllers, config rendering, REST API, CI
ci/woodpecker/pr/build Pipeline failed
ci/woodpecker/pr/pre-commit Pipeline failed
ci/woodpecker/pr/test Pipeline failed

Replace the ISC dhcpd PXE-boot VM with a Kea DHCP Kubernetes operator, modelled
on bind-operator. The operator renders kea-dhcp4 config from CRs and runs an HA
pair of kea-dhcp4 + kea-ctrl-agent servers behind an anycast Service.

- add KeaCluster/KeaSubnet/KeaClientClass/KeaAPI CRDs (group kea.unkin.net)
- render deterministic kea-dhcp4.conf + kea-ctrl-agent.conf into a ConfigMap and
  roll the StatefulSet via a config-hash annotation; best-effort hot-reload via
  the kea-ctrl-agent REST channel
- run HA hot-standby (memfile leases) with stable per-peer DNS identity from a
  StatefulSet; expose an anycast LoadBalancer Service for PureLB
- represent the full legacy dhcpd config: 198.18.13-17.0/24 pools, pool-less
  198.18.25.0/24, and the Legacy/UEFI-64 PXE arch classes (option 93)
- add the KeaAPI-spawned REST service: Terraform-friendly CRUD over subnet and
  client-class CRs (stable IDs, PUT upsert, 404 drift, bearer-token auth)
- add Makefile (patch/minor/major tag targets), distroless operator/api images,
  an AlmaLinux+EPEL kea workload image, and woodpecker CI with k8s resources +
  serviceAccountName on every step
- unit tests for config rendering, controller reconcile/config-hash, and the API

Claude-Session: https://claude.ai/code/session_01JUoARVdmhxKQHyyyp1pxeT
This commit is contained in:
unkinben
2026-08-02 17:19:53 +10:00
parent 9d471b0bff
commit d3fb5dcd1a
50 changed files with 10379 additions and 1 deletions
+51
View File
@@ -0,0 +1,51 @@
package kea
import "fmt"
type ctrlAgentRoot struct {
ControlAgent controlAgent `json:"Control-agent"`
}
type controlAgent struct {
HTTPHost string `json:"http-host"`
HTTPPort int `json:"http-port"`
ControlSockets map[string]map[string]any `json:"control-sockets"`
Loggers []logger `json:"loggers"`
}
// RenderCtrlAgent renders the deterministic kea-ctrl-agent.conf JSON. The
// control agent exposes the HA/REST control channel on CtrlAgentPort and
// forwards to kea-dhcp4 over the shared unix socket.
func RenderCtrlAgent() (string, error) {
return marshal(ctrlAgentRoot{ControlAgent: controlAgent{
HTTPHost: "0.0.0.0",
HTTPPort: CtrlAgentPort,
ControlSockets: map[string]map[string]any{
"dhcp4": {"socket-type": "unix", "socket-name": CtrlSocketPath},
},
Loggers: loggers("kea-ctrl-agent"),
}})
}
// EntrypointDHCP4 is the kea-dhcp4 container entrypoint. It derives this pod's
// HA peer name from the StatefulSet ordinal, substitutes the placeholder in the
// projected config, and execs the server.
func EntrypointDHCP4() string {
return fmt.Sprintf(`#!/bin/sh
set -e
ORD="${HOSTNAME##*-}"
mkdir -p %[1]s
sed "s/%[2]s/server${ORD}/g" %[3]s/kea-dhcp4.conf > %[4]s
exec %[5]s -c %[4]s
`, RunDir, ThisServerPlaceholder, ConfigDir, DHCP4ConfPath, DHCP4Bin)
}
// EntrypointCtrlAgent is the kea-ctrl-agent container entrypoint.
func EntrypointCtrlAgent() string {
return fmt.Sprintf(`#!/bin/sh
set -e
mkdir -p %[1]s
cp %[2]s/kea-ctrl-agent.conf %[3]s
exec %[4]s -c %[3]s
`, RunDir, ConfigDir, CtrlAgentConfPath, CtrlAgentBin)
}
+68
View File
@@ -0,0 +1,68 @@
package kea
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"time"
)
// ControlClient talks to a kea-ctrl-agent REST endpoint.
type ControlClient struct {
HTTP *http.Client
}
// NewControlClient returns a ControlClient with a bounded timeout.
func NewControlClient() *ControlClient {
return &ControlClient{HTTP: &http.Client{Timeout: 5 * time.Second}}
}
type command struct {
Command string `json:"command"`
Service []string `json:"service,omitempty"`
Arguments any `json:"arguments,omitempty"`
}
type response struct {
Result int `json:"result"`
Text string `json:"text"`
}
// ConfigReload asks the dhcp4 server behind the agent at baseURL to re-read its
// config file from disk (the hot-reload path, analogous to rndc reconfig).
func (c *ControlClient) ConfigReload(ctx context.Context, baseURL string) error {
return c.send(ctx, baseURL, command{Command: "config-reload", Service: []string{"dhcp4"}})
}
func (c *ControlClient) send(ctx context.Context, baseURL string, cmd command) error {
body, err := json.Marshal(cmd)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.HTTP.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("kea control %q: http %d", cmd.Command, resp.StatusCode)
}
var results []response
if err := json.NewDecoder(resp.Body).Decode(&results); err != nil {
return fmt.Errorf("decode kea control response: %w", err)
}
for _, r := range results {
if r.Result != 0 {
return fmt.Errorf("kea control %q failed: %s", cmd.Command, r.Text)
}
}
return nil
}
+337
View File
@@ -0,0 +1,337 @@
package kea
import (
"encoding/json"
"fmt"
"sort"
"strings"
v1alpha1 "git.unkin.net/unkin/kea-operator/api/v1alpha1"
)
// ThisServerPlaceholder is substituted by each pod's entrypoint with its
// ordinal-derived HA peer name (e.g. "server0"). Keeping it a placeholder in
// the shared config means the ConfigMap is identical across pods and carries
// no pod IPs, so the config hash never triggers a restart loop.
const ThisServerPlaceholder = "@@THIS_SERVER_NAME@@"
// Peer is a stable HA peer identity (no pod IPs — DNS names only).
type Peer struct {
Name string
URL string
Role string
}
// RenderInput aggregates a KeaCluster with its matching subnets and client
// classes into everything needed to render the kea configs.
type RenderInput struct {
Cluster v1alpha1.KeaCluster
Subnets []v1alpha1.KeaSubnet
ClientClasses []v1alpha1.KeaClientClass
Peers []Peer
}
// ---- kea-dhcp4.conf model (field order = JSON key order, deterministic) ----
type dhcp4Root struct {
Dhcp4 dhcp4 `json:"Dhcp4"`
}
type dhcp4 struct {
InterfacesConfig map[string]any `json:"interfaces-config"`
ControlSocket map[string]any `json:"control-socket"`
LeaseDatabase map[string]any `json:"lease-database"`
ValidLifetime int `json:"valid-lifetime"`
MaxValidLifetime int `json:"max-valid-lifetime"`
Authoritative bool `json:"authoritative"`
DDNSSendUpdates bool `json:"ddns-send-updates"`
OptionDef []optionDef `json:"option-def,omitempty"`
OptionData []optionData `json:"option-data,omitempty"`
ClientClasses []clientClass `json:"client-classes,omitempty"`
HooksLibraries []hookLib `json:"hooks-libraries"`
Subnet4 []subnet4 `json:"subnet4"`
Loggers []logger `json:"loggers"`
}
type optionDef struct {
Name string `json:"name"`
Code int `json:"code"`
Type string `json:"type"`
Space string `json:"space"`
Array bool `json:"array,omitempty"`
RecordTypes string `json:"record-types,omitempty"`
Encapsulate string `json:"encapsulate,omitempty"`
}
type optionData struct {
Name string `json:"name,omitempty"`
Code int `json:"code,omitempty"`
Space string `json:"space,omitempty"`
Data string `json:"data"`
CSVFormat *bool `json:"csv-format,omitempty"`
}
type clientClass struct {
Name string `json:"name"`
Test string `json:"test,omitempty"`
BootFileName string `json:"boot-file-name,omitempty"`
NextServer string `json:"next-server,omitempty"`
ServerHostname string `json:"server-hostname,omitempty"`
OptionData []optionData `json:"option-data,omitempty"`
}
type pool struct {
Pool string `json:"pool"`
}
type subnet4 struct {
ID int `json:"id"`
Subnet string `json:"subnet"`
Pools []pool `json:"pools,omitempty"`
NextServer string `json:"next-server,omitempty"`
BootFileName string `json:"boot-file-name,omitempty"`
ValidLifetime int `json:"valid-lifetime,omitempty"`
ClientClass string `json:"client-class,omitempty"`
OptionData []optionData `json:"option-data,omitempty"`
}
type hookLib struct {
Library string `json:"library"`
Parameters map[string]any `json:"parameters,omitempty"`
}
type logger struct {
Name string `json:"name"`
Severity string `json:"severity"`
OutputOptions []map[string]any `json:"output_options"`
}
// RenderDHCP4 renders the deterministic kea-dhcp4.conf JSON.
func RenderDHCP4(in RenderInput) (string, error) {
in = sortInput(in)
spec := in.Cluster.Spec
valid := spec.DefaultLeaseTime
if valid == 0 {
valid = 1200
}
maxValid := spec.MaxLeaseTime
if maxValid == 0 {
maxValid = 86400
}
d := dhcp4{
InterfacesConfig: map[string]any{"interfaces": []string{"*"}},
ControlSocket: map[string]any{"socket-type": "unix", "socket-name": CtrlSocketPath},
LeaseDatabase: map[string]any{"type": "memfile", "persist": false},
ValidLifetime: valid,
MaxValidLifetime: maxValid,
Authoritative: true,
DDNSSendUpdates: false,
HooksLibraries: hooks(in),
Subnet4: renderSubnets(in),
Loggers: loggers("kea-dhcp4"),
}
for _, od := range spec.OptionDefs {
space := od.Space
if space == "" {
space = "dhcp4"
}
d.OptionDef = append(d.OptionDef, optionDef{
Name: od.Name, Code: od.Code, Type: od.Type, Space: space,
Array: od.Array, RecordTypes: od.RecordTypes, Encapsulate: od.Encapsulate,
})
}
// Global options shared by every subnet.
if spec.DomainName != "" {
d.OptionData = append(d.OptionData, optionData{Name: "domain-name", Data: spec.DomainName})
}
if len(spec.NTPServers) > 0 {
d.OptionData = append(d.OptionData, optionData{Name: "ntp-servers", Data: strings.Join(spec.NTPServers, ",")})
}
d.ClientClasses = renderClasses(in)
return marshal(dhcp4Root{Dhcp4: d})
}
func renderSubnets(in RenderInput) []subnet4 {
out := make([]subnet4, 0, len(in.Subnets))
for _, s := range in.Subnets {
sub := subnet4{
ID: s.Spec.ID,
Subnet: s.Spec.Subnet,
NextServer: s.Spec.NextServer,
BootFileName: s.Spec.BootFileName,
ValidLifetime: s.Spec.ValidLifetime,
}
for _, p := range s.Spec.Pools {
sub.Pools = append(sub.Pools, pool{Pool: normalizePool(p)})
}
if len(s.Spec.ClientClasses) == 1 {
sub.ClientClass = s.Spec.ClientClasses[0]
}
if len(s.Spec.Routers) > 0 {
sub.OptionData = append(sub.OptionData, optionData{Name: "routers", Data: strings.Join(s.Spec.Routers, ",")})
}
if len(s.Spec.DNSServers) > 0 {
sub.OptionData = append(sub.OptionData, optionData{Name: "domain-name-servers", Data: strings.Join(s.Spec.DNSServers, ",")})
}
if s.Spec.DomainName != "" {
sub.OptionData = append(sub.OptionData, optionData{Name: "domain-name", Data: s.Spec.DomainName})
}
sub.OptionData = append(sub.OptionData, convertOptionData(s.Spec.OptionData)...)
out = append(out, sub)
}
return out
}
func renderClasses(in RenderInput) []clientClass {
out := make([]clientClass, 0, len(in.ClientClasses))
for _, c := range in.ClientClasses {
cc := clientClass{
Name: c.Name,
Test: classTest(c.Spec),
BootFileName: c.Spec.BootFileName,
NextServer: c.Spec.NextServer,
ServerHostname: c.Spec.ServerHostname,
OptionData: convertOptionData(c.Spec.OptionData),
}
out = append(out, cc)
}
return out
}
// classTest returns the raw test if set, else builds one from ArchHex.
func classTest(spec v1alpha1.KeaClientClassSpec) string {
if spec.Test != "" {
return spec.Test
}
terms := make([]string, 0, len(spec.ArchHex))
for _, a := range spec.ArchHex {
terms = append(terms, fmt.Sprintf("option[%d].hex == %s", ClientArchOption, a))
}
return strings.Join(terms, " or ")
}
func hooks(in RenderInput) []hookLib {
libs := []hookLib{{Library: LeaseCmdsLibrary}}
peers := make([]map[string]any, 0, len(in.Peers))
for _, p := range in.Peers {
peers = append(peers, map[string]any{
"name": p.Name,
"url": p.URL,
"role": p.Role,
"auto-failover": true,
})
}
mode := string(in.Cluster.Spec.HA.Mode)
if mode == "" {
mode = string(v1alpha1.HAHotStandby)
}
ha := in.Cluster.Spec.HA
rel := map[string]any{
"this-server-name": ThisServerPlaceholder,
"mode": mode,
"heartbeat-delay": firstNonZero(ha.HeartbeatDelay, 10000),
"max-response-delay": firstNonZero(ha.MaxResponseDelay, 60000),
"max-ack-delay": firstNonZero(ha.MaxAckDelay, 5000),
"max-unacked-clients": firstNonZero(ha.MaxUnackedClients, 5),
"peers": peers,
}
libs = append(libs, hookLib{
Library: HALibrary,
Parameters: map[string]any{"high-availability": []any{rel}},
})
return libs
}
func loggers(name string) []logger {
return []logger{{
Name: name,
Severity: "INFO",
OutputOptions: []map[string]any{
{"output": "stdout"},
},
}}
}
// AssignSubnetIDs stamps a stable numeric id on every subnet that lacks one,
// choosing the smallest unused positive integer in sorted-CIDR order.
func AssignSubnetIDs(subnets []v1alpha1.KeaSubnet) {
sort.SliceStable(subnets, func(i, j int) bool { return subnets[i].Spec.Subnet < subnets[j].Spec.Subnet })
used := map[int]bool{}
for i := range subnets {
if subnets[i].Spec.ID > 0 {
used[subnets[i].Spec.ID] = true
}
}
next := 1
for i := range subnets {
if subnets[i].Spec.ID == 0 {
for used[next] {
next++
}
subnets[i].Spec.ID = next
used[next] = true
}
}
}
// sortInput sorts subnets and classes deterministically and assigns subnet ids.
func sortInput(in RenderInput) RenderInput {
subs := make([]v1alpha1.KeaSubnet, len(in.Subnets))
copy(subs, in.Subnets)
AssignSubnetIDs(subs)
sort.SliceStable(subs, func(i, j int) bool { return subs[i].Spec.ID < subs[j].Spec.ID })
in.Subnets = subs
classes := make([]v1alpha1.KeaClientClass, len(in.ClientClasses))
copy(classes, in.ClientClasses)
sort.SliceStable(classes, func(i, j int) bool { return classes[i].Name < classes[j].Name })
in.ClientClasses = classes
peers := make([]Peer, len(in.Peers))
copy(peers, in.Peers)
sort.SliceStable(peers, func(i, j int) bool { return peers[i].Name < peers[j].Name })
in.Peers = peers
return in
}
func convertOptionData(in []v1alpha1.OptionData) []optionData {
out := make([]optionData, 0, len(in))
for _, o := range in {
out = append(out, optionData{
Name: o.Name, Code: o.Code, Space: o.Space, Data: o.Data, CSVFormat: o.CSVFormat,
})
}
return out
}
// normalizePool ensures the "start - end" spacing Kea expects.
func normalizePool(p string) string {
if strings.Contains(p, "-") && !strings.Contains(p, " - ") {
parts := strings.SplitN(p, "-", 2)
return strings.TrimSpace(parts[0]) + " - " + strings.TrimSpace(parts[1])
}
return strings.TrimSpace(p)
}
func firstNonZero(v, def int) int {
if v != 0 {
return v
}
return def
}
func marshal(v any) (string, error) {
b, err := json.MarshalIndent(v, "", " ")
if err != nil {
return "", err
}
return string(b) + "\n", nil
}
+234
View File
@@ -0,0 +1,234 @@
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)
}
}
}
+47
View File
@@ -0,0 +1,47 @@
package kea
// Filesystem and binary paths inside the kea container image, plus the
// operator's label/annotation vocabulary.
const (
// ContainerDHCP4 is the kea-dhcp4 container name.
ContainerDHCP4 = "kea-dhcp4"
// ContainerCtrlAgent is the kea-ctrl-agent container name.
ContainerCtrlAgent = "kea-ctrl-agent"
// ConfigDir is where projected config is mounted read-only.
ConfigDir = "/etc/kea-operator"
// RunDir is a shared emptyDir for the config copy and control socket.
RunDir = "/run/kea"
// DHCP4ConfPath is the runtime kea-dhcp4 config.
DHCP4ConfPath = RunDir + "/kea-dhcp4.conf"
// CtrlAgentConfPath is the runtime kea-ctrl-agent config.
CtrlAgentConfPath = RunDir + "/kea-ctrl-agent.conf"
// CtrlSocketPath is the unix control socket between ctrl-agent and dhcp4.
CtrlSocketPath = RunDir + "/kea4-ctrl-socket"
// EntrypointPath is the generated container entrypoint.
EntrypointPath = ConfigDir + "/entrypoint.sh"
// DHCP4Bin is the kea-dhcp4 server binary.
DHCP4Bin = "/usr/sbin/kea-dhcp4"
// CtrlAgentBin is the kea-ctrl-agent binary.
CtrlAgentBin = "/usr/sbin/kea-ctrl-agent"
// HooksDir holds the Kea hook libraries.
HooksDir = "/usr/lib64/kea/hooks"
// HALibrary is the High Availability hook.
HALibrary = HooksDir + "/libdhcp_ha.so"
// LeaseCmdsLibrary is the lease commands hook (required by HA lease sync).
LeaseCmdsLibrary = HooksDir + "/libdhcp_lease_cmds.so"
// CtrlAgentPort is the REST control channel port.
CtrlAgentPort = 8000
// DHCP4Port is the DHCPv4 server port.
DHCP4Port = 67
// DefaultImage is the kea workload image built by this repo.
DefaultImage = "git.unkin.net/unkin/kea:latest"
// ClientArchOption is the DHCP option code carrying PXE client arch.
ClientArchOption = 93
)