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,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])}
|
||||
}
|
||||
Reference in New Issue
Block a user