Scaffold kea-operator: CRDs, controllers, config rendering, REST API, CI
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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user