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
+104
View File
@@ -0,0 +1,104 @@
package controller
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"sort"
"strings"
"time"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"k8s.io/apimachinery/pkg/util/intstr"
"sigs.k8s.io/controller-runtime/pkg/client"
)
func intstrFromInt(i int) intstr.IntOrString { return intstr.FromInt(i) }
const (
requeueShort = 15 * time.Second
requeueLong = 2 * time.Minute
managedByLabel = "app.kubernetes.io/managed-by"
managedByValue = "kea-operator"
clusterLabel = "kea.unkin.net/cluster"
roleLabel = "kea.unkin.net/role"
finalizer = "kea.unkin.net/finalizer"
defaultOperatorImage = "git.unkin.net/unkin/kea-operator:latest"
defaultAPIImage = "git.unkin.net/unkin/kea-api:latest"
)
func headlessName(cluster string) string { return cluster + "-headless" }
func serviceName(cluster string) string { return cluster }
func configMapName(cluster string) string { return cluster + "-config" }
func stsName(cluster string) string { return cluster }
func peerDNS(cluster, ns string, ordinal int) string {
return fmt.Sprintf("http://%s-%d.%s.%s:%d/", cluster, ordinal, headlessName(cluster), ns, 8000)
}
func commonLabels(cluster string) map[string]string {
return map[string]string{
managedByLabel: managedByValue,
clusterLabel: cluster,
}
}
// setReady sets the single "Ready" condition, mirroring bind-operator.
func setReady(conds *[]metav1.Condition, gen int64, ok bool, reason, msg string) {
status := metav1.ConditionFalse
if ok {
status = metav1.ConditionTrue
}
meta.SetStatusCondition(conds, metav1.Condition{
Type: "Ready",
Status: status,
ObservedGeneration: gen,
Reason: reason,
Message: msg,
})
}
// configHash returns a stable hash of the named ConfigMap's data. Pods copy
// config out of a projected volume at startup, so a ConfigMap change alone
// never reaches a running pod; stamping this hash on the pod template is what
// rolls the StatefulSet. The input must contain no pod IPs or the roll loops.
func configHash(ctx context.Context, c client.Client, ns, name string) (string, error) {
var cm corev1.ConfigMap
if err := c.Get(ctx, types.NamespacedName{Namespace: ns, Name: name}, &cm); err != nil {
return "", err
}
keys := make([]string, 0, len(cm.Data))
for k := range cm.Data {
keys = append(keys, k)
}
sort.Strings(keys)
var buf bytes.Buffer
for _, k := range keys {
buf.WriteString(k)
buf.WriteByte(0)
buf.WriteString(cm.Data[k])
buf.WriteByte(0)
}
sum := sha256.Sum256(buf.Bytes())
return hex.EncodeToString(sum[:]), nil
}
func int32ptr(i int32) *int32 { return &i }
func ptr[T any](v T) *T { return &v }
// splitPool splits a "start - end" (any spacing) or single-address pool into
// its address fields.
func splitPool(p string) []string {
if !strings.Contains(p, "-") {
return []string{strings.TrimSpace(p)}
}
parts := strings.SplitN(p, "-", 2)
return []string{strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1])}
}