Point HA peer URLs at per-pod ClusterIP Services
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful

## 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
This commit is contained in:
2026-08-09 18:59:20 +10:00
parent 9795d13610
commit 66ae5f5f3c
3 changed files with 149 additions and 17 deletions
+79 -6
View File
@@ -40,6 +40,79 @@ func newClusterFixture() *v1alpha1.KeaCluster {
}
}
// peerServiceFixtures stands in for the per-pod ClusterIP Services with the
// ClusterIPs the apiserver would allocate (the fake client does not allocate),
// so peers() can read them when rendering the HA config.
func peerServiceFixtures() []client.Object {
return []client.Object{
&corev1.Service{
ObjectMeta: metav1.ObjectMeta{Name: "pxe-peer-0", Namespace: "dhcp-system"},
Spec: corev1.ServiceSpec{Type: corev1.ServiceTypeClusterIP, ClusterIP: "10.96.10.10"},
},
&corev1.Service{
ObjectMeta: metav1.ObjectMeta{Name: "pxe-peer-1", Namespace: "dhcp-system"},
Spec: corev1.ServiceSpec{Type: corev1.ServiceTypeClusterIP, ClusterIP: "10.96.10.11"},
},
}
}
func withPeers(objs ...client.Object) []client.Object {
return append(objs, peerServiceFixtures()...)
}
// TestPeerURLsUseClusterIPs is the regression guard for the HA bootstrap fix:
// kea 2.6's HA hook rejects hostnames, so the rendered peer URLs must be the
// per-pod ClusterIP literals, never the headless DNS names.
func TestPeerURLsUseClusterIPs(t *testing.T) {
scheme := testScheme(t)
cl := fake.NewClientBuilder().
WithScheme(scheme).
WithStatusSubresource(&v1alpha1.KeaCluster{}).
WithObjects(withPeers(newClusterFixture())...).
Build()
r := &KeaClusterReconciler{Client: cl, Scheme: scheme}
if _, err := r.Reconcile(context.Background(), ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "dhcp-system", Name: "pxe"}}); err != nil {
t.Fatalf("reconcile: %v", err)
}
var cm corev1.ConfigMap
if err := cl.Get(context.Background(), types.NamespacedName{Namespace: "dhcp-system", Name: "pxe-config"}, &cm); err != nil {
t.Fatalf("configmap not created: %v", err)
}
conf := cm.Data["kea-dhcp4.conf"]
for _, want := range []string{"http://10.96.10.10:8000/", "http://10.96.10.11:8000/"} {
if !contains(conf, want) {
t.Errorf("HA peer URL %q missing from rendered config", want)
}
}
if contains(conf, "kea-headless") {
t.Errorf("HA peer URLs must not use headless DNS names (kea's HA hook rejects hostnames)")
}
// The per-pod ClusterIP Services must exist.
for _, name := range []string{"pxe-peer-0", "pxe-peer-1"} {
var svc corev1.Service
if err := cl.Get(context.Background(), types.NamespacedName{Namespace: "dhcp-system", Name: name}, &svc); err != nil {
t.Errorf("per-pod service %s not present: %v", name, err)
}
}
}
// TestPeersRequeueWithoutClusterIP proves the render blocks (returns an error to
// requeue) until the peer ClusterIPs are allocated, rather than emitting a
// hostname the HA hook would reject.
func TestPeersRequeueWithoutClusterIP(t *testing.T) {
scheme := testScheme(t)
cl := fake.NewClientBuilder().
WithScheme(scheme).
WithStatusSubresource(&v1alpha1.KeaCluster{}).
WithObjects(newClusterFixture()).
Build()
r := &KeaClusterReconciler{Client: cl, Scheme: scheme}
if _, err := r.Reconcile(context.Background(), ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "dhcp-system", Name: "pxe"}}); err == nil {
t.Fatal("expected reconcile to error while peer ClusterIPs are unallocated")
}
}
func TestKeaClusterReconcileCreatesWorkload(t *testing.T) {
scheme := testScheme(t)
cluster := newClusterFixture()
@@ -50,7 +123,7 @@ func TestKeaClusterReconcileCreatesWorkload(t *testing.T) {
cl := fake.NewClientBuilder().
WithScheme(scheme).
WithStatusSubresource(&v1alpha1.KeaCluster{}).
WithObjects(cluster, subnet).
WithObjects(withPeers(cluster, subnet)...).
Build()
r := &KeaClusterReconciler{Client: cl, Scheme: scheme}
@@ -82,8 +155,8 @@ func TestKeaClusterReconcileCreatesWorkload(t *testing.T) {
t.Errorf("missing config-hash annotation")
}
// Anycast + headless services.
for _, name := range []string{"pxe", "pxe-headless"} {
// Anycast + headless + per-pod peer services.
for _, name := range []string{"pxe", "pxe-headless", "pxe-peer-0", "pxe-peer-1"} {
var svc corev1.Service
if err := cl.Get(context.Background(), types.NamespacedName{Namespace: "dhcp-system", Name: name}, &svc); err != nil {
t.Errorf("service %s not created: %v", name, err)
@@ -102,7 +175,7 @@ func TestReconcileWiresInitContainer(t *testing.T) {
cl := fake.NewClientBuilder().
WithScheme(scheme).
WithStatusSubresource(&v1alpha1.KeaCluster{}).
WithObjects(cluster).
WithObjects(withPeers(cluster)...).
Build()
r := &KeaClusterReconciler{Client: cl, Scheme: scheme}
if _, err := r.Reconcile(context.Background(), ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "dhcp-system", Name: "pxe"}}); err != nil {
@@ -186,7 +259,7 @@ func TestConfigHashChangesWithSubnets(t *testing.T) {
scheme := testScheme(t)
hashFor := func(objs ...client.Object) string {
base := []client.Object{newClusterFixture()}
base := withPeers(newClusterFixture())
cl := fake.NewClientBuilder().
WithScheme(scheme).
WithStatusSubresource(&v1alpha1.KeaCluster{}).
@@ -226,7 +299,7 @@ func TestClusterRefFiltersSubnets(t *testing.T) {
}
cl := fake.NewClientBuilder().WithScheme(scheme).
WithStatusSubresource(&v1alpha1.KeaCluster{}).
WithObjects(cluster, mine, other).Build()
WithObjects(withPeers(cluster, mine, other)...).Build()
r := &KeaClusterReconciler{Client: cl, Scheme: scheme}
if _, err := r.Reconcile(context.Background(), ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "dhcp-system", Name: "pxe"}}); err != nil {
t.Fatal(err)