d3fb5dcd1a
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
105 lines
3.0 KiB
Go
105 lines
3.0 KiB
Go
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])}
|
|
}
|