Files
kea-operator/internal/controller/helpers.go
T
unkinben 66ae5f5f3c
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
Point HA peer URLs at per-pod ClusterIP Services
## Why
kea-dhcp4 crash-loops at HA hook load: kea 2.6's HA hook parses each peer url host as an IP literal and never resolves DNS, so the StatefulSet headless hostnames are rejected ("Failed to convert string to address ..."). Verified in-cluster that only an IP works (short name, FQDN both fail; `kea-dhcp4 -t` does not exercise this, which is why the v0.1.3 wait did not catch it). Pod IPs cannot be baked into the config because they change on restart and would roll-loop the StatefulSet via the config hash.

## How
- create one ClusterIP Service per HA peer, selecting the pod by its statefulset.kubernetes.io/pod-name label, with publishNotReadyAddresses so peers are routable during bootstrap
- render each HA peer url as its peer Service ClusterIP (a stable IP literal, safe in the config hash); reconcile Services before the ConfigMap and requeue until the ClusterIPs are allocated
2026-08-09 18:59:20 +10:00

119 lines
3.7 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"
// statefulSetPodNameLabel is stamped on every StatefulSet pod by Kubernetes;
// the per-pod ClusterIP Service selects a single pod through it.
statefulSetPodNameLabel = "statefulset.kubernetes.io/pod-name"
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 }
// podName is the StatefulSet pod name for an ordinal.
func podName(cluster string, ordinal int) string { return fmt.Sprintf("%s-%d", cluster, ordinal) }
// peerServiceName is the per-pod ClusterIP Service fronting one HA peer's
// ctrl-agent. Its stable ClusterIP is what the HA hook peer URL points at.
func peerServiceName(cluster string, ordinal int) string {
return fmt.Sprintf("%s-peer-%d", cluster, ordinal)
}
// peerURL builds an HA peer control-channel URL. Kea 2.6's HA hook parses the
// host as an IP literal (no DNS), so this must be a stable IP address.
func peerURL(ip string) string { return fmt.Sprintf("http://%s:%d/", ip, 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])}
}