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])}
}
+253
View File
@@ -0,0 +1,253 @@
package controller
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
rbacv1 "k8s.io/api/rbac/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
v1alpha1 "git.unkin.net/unkin/kea-operator/api/v1alpha1"
)
// KeaAPIReconciler spawns the REST API service that CRUDs KeaSubnet and
// KeaClientClass CRs (a Terraform-friendly alternative to argocd-managed CRs).
type KeaAPIReconciler struct {
client.Client
Scheme *runtime.Scheme
}
// +kubebuilder:rbac:groups=kea.unkin.net,resources=keaapis,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=kea.unkin.net,resources=keaapis/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups="",resources=serviceaccounts;secrets,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=roles;rolebindings,verbs=get;list;watch;create;update;patch;delete
func (r *KeaAPIReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
var api v1alpha1.KeaAPI
if err := r.Get(ctx, req.NamespacedName, &api); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
}
for _, step := range []func(context.Context, *v1alpha1.KeaAPI) error{
r.reconcileServiceAccount,
r.reconcileRBAC,
r.reconcileTokenSecret,
r.reconcileDeployment,
r.reconcileService,
} {
if err := step(ctx, &api); err != nil {
api.Status.Phase = "Error"
setReady(&api.Status.Conditions, api.Generation, false, "ReconcileError", err.Error())
_ = r.Status().Update(ctx, &api)
return ctrl.Result{}, err
}
}
var dep appsv1.Deployment
_ = r.Get(ctx, types.NamespacedName{Namespace: api.Namespace, Name: api.Name}, &dep)
port := api.Spec.Service.Port
if port == 0 {
port = 8080
}
api.Status.ReadyReplicas = dep.Status.ReadyReplicas
api.Status.Endpoint = fmt.Sprintf("http://%s.%s.svc:%d", api.Name, api.Namespace, port)
api.Status.ObservedGeneration = api.Generation
if dep.Status.ReadyReplicas > 0 {
api.Status.Phase = "Ready"
setReady(&api.Status.Conditions, api.Generation, true, "Ready", "api ready")
} else {
api.Status.Phase = "Progressing"
setReady(&api.Status.Conditions, api.Generation, false, "Progressing", "waiting for api pods")
}
if err := r.Status().Update(ctx, &api); err != nil {
return ctrl.Result{}, err
}
if api.Status.Phase != "Ready" {
return ctrl.Result{RequeueAfter: requeueShort}, nil
}
return ctrl.Result{RequeueAfter: requeueLong}, nil
}
func (r *KeaAPIReconciler) reconcileServiceAccount(ctx context.Context, api *v1alpha1.KeaAPI) error {
sa := &corev1.ServiceAccount{ObjectMeta: metav1.ObjectMeta{Name: api.Name, Namespace: api.Namespace}}
_, err := ctrl.CreateOrUpdate(ctx, r.Client, sa, func() error {
sa.Labels = apiLabels(api.Name)
return ctrl.SetControllerReference(api, sa, r.Scheme)
})
return err
}
func (r *KeaAPIReconciler) reconcileRBAC(ctx context.Context, api *v1alpha1.KeaAPI) error {
role := &rbacv1.Role{ObjectMeta: metav1.ObjectMeta{Name: api.Name, Namespace: api.Namespace}}
if _, err := ctrl.CreateOrUpdate(ctx, r.Client, role, func() error {
role.Labels = apiLabels(api.Name)
role.Rules = []rbacv1.PolicyRule{
{
APIGroups: []string{"kea.unkin.net"},
Resources: []string{"keasubnets", "keaclientclasses"},
Verbs: []string{"get", "list", "watch", "create", "update", "patch", "delete"},
},
{
APIGroups: []string{"kea.unkin.net"},
Resources: []string{"keasubnets/status", "keaclientclasses/status"},
Verbs: []string{"get"},
},
}
return ctrl.SetControllerReference(api, role, r.Scheme)
}); err != nil {
return err
}
rb := &rbacv1.RoleBinding{ObjectMeta: metav1.ObjectMeta{Name: api.Name, Namespace: api.Namespace}}
_, err := ctrl.CreateOrUpdate(ctx, r.Client, rb, func() error {
rb.Labels = apiLabels(api.Name)
rb.RoleRef = rbacv1.RoleRef{APIGroup: "rbac.authorization.k8s.io", Kind: "Role", Name: api.Name}
rb.Subjects = []rbacv1.Subject{{Kind: "ServiceAccount", Name: api.Name, Namespace: api.Namespace}}
return ctrl.SetControllerReference(api, rb, r.Scheme)
})
return err
}
// reconcileTokenSecret creates the bearer-token Secret only when absent, so it
// may instead be pre-seeded (e.g. by a Vault static secret). It is intentionally
// not owned/overwritten once it exists.
func (r *KeaAPIReconciler) reconcileTokenSecret(ctx context.Context, api *v1alpha1.KeaAPI) error {
name := tokenSecretName(api)
var existing corev1.Secret
err := r.Get(ctx, types.NamespacedName{Namespace: api.Namespace, Name: name}, &existing)
if err == nil {
return nil
}
if !apierrors.IsNotFound(err) {
return err
}
tok := make([]byte, 32)
if _, err := rand.Read(tok); err != nil {
return err
}
sec := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: api.Namespace, Labels: apiLabels(api.Name)},
Type: corev1.SecretTypeOpaque,
StringData: map[string]string{"token": hex.EncodeToString(tok)},
}
return r.Create(ctx, sec)
}
func (r *KeaAPIReconciler) reconcileDeployment(ctx context.Context, api *v1alpha1.KeaAPI) error {
image := api.Spec.Image
if image == "" {
image = defaultAPIImage
}
replicas := int32(1)
if api.Spec.Replicas != nil {
replicas = *api.Spec.Replicas
}
port := api.Spec.Service.Port
if port == 0 {
port = 8080
}
dep := &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Name: api.Name, Namespace: api.Namespace}}
_, err := ctrl.CreateOrUpdate(ctx, r.Client, dep, func() error {
dep.Labels = apiLabels(api.Name)
dep.Spec.Replicas = int32ptr(replicas)
dep.Spec.Selector = &metav1.LabelSelector{MatchLabels: apiLabels(api.Name)}
dep.Spec.Template = corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{Labels: apiLabels(api.Name)},
Spec: corev1.PodSpec{
ServiceAccountName: api.Name,
NodeSelector: api.Spec.NodeSelector,
Tolerations: api.Spec.Tolerations,
Affinity: api.Spec.Affinity,
Containers: []corev1.Container{{
Name: "kea-api",
Image: image,
Command: []string{"kea-api"},
Ports: []corev1.ContainerPort{{Name: "http", ContainerPort: port}},
Env: []corev1.EnvVar{
{Name: "LISTEN_ADDR", Value: fmt.Sprintf(":%d", port)},
{Name: "TARGET_NAMESPACE", Value: api.Namespace},
{Name: "KEA_API_TOKEN", ValueFrom: &corev1.EnvVarSource{
SecretKeyRef: &corev1.SecretKeySelector{
LocalObjectReference: corev1.LocalObjectReference{Name: tokenSecretName(api)},
Key: "token",
},
}},
},
Resources: api.Spec.Resources,
ReadinessProbe: &corev1.Probe{
ProbeHandler: corev1.ProbeHandler{HTTPGet: &corev1.HTTPGetAction{
Path: "/healthz", Port: intstrFromInt(int(port)),
}},
InitialDelaySeconds: 3, PeriodSeconds: 10,
},
SecurityContext: &corev1.SecurityContext{
RunAsNonRoot: ptr(true),
AllowPrivilegeEscalation: ptr(false),
ReadOnlyRootFilesystem: ptr(true),
Capabilities: &corev1.Capabilities{Drop: []corev1.Capability{"ALL"}},
},
}},
},
}
return ctrl.SetControllerReference(api, dep, r.Scheme)
})
return err
}
func (r *KeaAPIReconciler) reconcileService(ctx context.Context, api *v1alpha1.KeaAPI) error {
port := api.Spec.Service.Port
if port == 0 {
port = 8080
}
svcType := api.Spec.Service.Type
if svcType == "" {
svcType = corev1.ServiceTypeClusterIP
}
svc := &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: api.Name, Namespace: api.Namespace}}
_, err := ctrl.CreateOrUpdate(ctx, r.Client, svc, func() error {
svc.Labels = apiLabels(api.Name)
svc.Annotations = api.Spec.Service.Annotations
svc.Spec.Type = svcType
svc.Spec.Selector = apiLabels(api.Name)
svc.Spec.Ports = []corev1.ServicePort{{Name: "http", Port: port, TargetPort: intstrFromInt(int(port))}}
return ctrl.SetControllerReference(api, svc, r.Scheme)
})
return err
}
func tokenSecretName(api *v1alpha1.KeaAPI) string {
if api.Spec.TokenSecretName != "" {
return api.Spec.TokenSecretName
}
return api.Name + "-token"
}
func apiLabels(name string) map[string]string {
return map[string]string{
managedByLabel: managedByValue,
"app.kubernetes.io/name": "kea-api",
"app.kubernetes.io/instance": name,
}
}
func (r *KeaAPIReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&v1alpha1.KeaAPI{}).
Owns(&appsv1.Deployment{}).
Owns(&corev1.Service{}).
Owns(&corev1.ServiceAccount{}).
Owns(&rbacv1.Role{}).
Owns(&rbacv1.RoleBinding{}).
Complete(r)
}
@@ -0,0 +1,57 @@
package controller
import (
"context"
"strings"
"k8s.io/apimachinery/pkg/runtime"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
v1alpha1 "git.unkin.net/unkin/kea-operator/api/v1alpha1"
)
// KeaClientClassReconciler validates KeaClientClass CRs and maintains status.
type KeaClientClassReconciler struct {
client.Client
Scheme *runtime.Scheme
}
// +kubebuilder:rbac:groups=kea.unkin.net,resources=keaclientclasses,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=kea.unkin.net,resources=keaclientclasses/status,verbs=get;update;patch
func (r *KeaClientClassReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
var cc v1alpha1.KeaClientClass
if err := r.Get(ctx, req.NamespacedName, &cc); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
}
if cc.Spec.Test == "" && len(cc.Spec.ArchHex) == 0 {
cc.Status.Phase = "Invalid"
setReady(&cc.Status.Conditions, cc.Generation, false, "NoMatch", "either test or archHex must be set")
_ = r.Status().Update(ctx, &cc)
return ctrl.Result{}, nil
}
for _, a := range cc.Spec.ArchHex {
if !strings.HasPrefix(a, "0x") {
cc.Status.Phase = "Invalid"
setReady(&cc.Status.Conditions, cc.Generation, false, "BadArch", "archHex values must be 0x-prefixed, e.g. 0x0007")
_ = r.Status().Update(ctx, &cc)
return ctrl.Result{}, nil
}
}
cc.Status.Phase = "Ready"
cc.Status.ObservedGeneration = cc.Generation
setReady(&cc.Status.Conditions, cc.Generation, true, "Validated", "client class accepted")
if err := r.Status().Update(ctx, &cc); err != nil {
return ctrl.Result{}, err
}
return ctrl.Result{}, nil
}
func (r *KeaClientClassReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&v1alpha1.KeaClientClass{}).
Complete(r)
}
@@ -0,0 +1,396 @@
package controller
import (
"context"
"fmt"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/handler"
"sigs.k8s.io/controller-runtime/pkg/log"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
v1alpha1 "git.unkin.net/unkin/kea-operator/api/v1alpha1"
"git.unkin.net/unkin/kea-operator/internal/kea"
)
// KeaClusterReconciler reconciles a KeaCluster.
type KeaClusterReconciler struct {
client.Client
Scheme *runtime.Scheme
Control *kea.ControlClient
}
// +kubebuilder:rbac:groups=kea.unkin.net,resources=keaclusters,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=kea.unkin.net,resources=keaclusters/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=kea.unkin.net,resources=keasubnets,verbs=get;list;watch
// +kubebuilder:rbac:groups=kea.unkin.net,resources=keaclientclasses,verbs=get;list;watch
// +kubebuilder:rbac:groups=apps,resources=statefulsets,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups="",resources=services;configmaps,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups="",resources=pods,verbs=get;list;watch
func (r *KeaClusterReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
l := log.FromContext(ctx)
var cluster v1alpha1.KeaCluster
if err := r.Get(ctx, req.NamespacedName, &cluster); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
}
if err := r.reconcileConfigMap(ctx, &cluster); err != nil {
return r.fail(ctx, &cluster, "ConfigError", err)
}
if err := r.reconcileServices(ctx, &cluster); err != nil {
return r.fail(ctx, &cluster, "ServiceError", err)
}
sts, err := r.reconcileStatefulSet(ctx, &cluster)
if err != nil {
return r.fail(ctx, &cluster, "WorkloadError", err)
}
r.reloadReadyPods(ctx, &cluster)
ready := sts.Status.ReadyReplicas
desired := int32(1)
if cluster.Spec.Replicas != nil {
desired = *cluster.Spec.Replicas
}
cluster.Status.ObservedGeneration = cluster.Generation
cluster.Status.Replicas = sts.Status.Replicas
cluster.Status.ReadyReplicas = ready
cluster.Status.ServiceIP = r.serviceIP(ctx, &cluster)
if ready > 0 {
cluster.Status.ActivePeer = cluster.Name + "-0"
}
if ready >= desired && desired > 0 {
cluster.Status.Phase = "Ready"
setReady(&cluster.Status.Conditions, cluster.Generation, true, "Ready", "all replicas ready")
} else {
cluster.Status.Phase = "Progressing"
setReady(&cluster.Status.Conditions, cluster.Generation, false, "Progressing",
fmt.Sprintf("%d/%d replicas ready", ready, desired))
}
if err := r.Status().Update(ctx, &cluster); err != nil {
l.Error(err, "status update")
}
if cluster.Status.Phase != "Ready" {
return ctrl.Result{RequeueAfter: requeueShort}, nil
}
return ctrl.Result{RequeueAfter: requeueLong}, nil
}
func (r *KeaClusterReconciler) fail(ctx context.Context, c *v1alpha1.KeaCluster, reason string, err error) (ctrl.Result, error) {
c.Status.Phase = "Error"
setReady(&c.Status.Conditions, c.Generation, false, reason, err.Error())
_ = r.Status().Update(ctx, c)
return ctrl.Result{}, err
}
// buildInput gathers matching subnets/classes and the stable HA peer list.
func (r *KeaClusterReconciler) buildInput(ctx context.Context, c *v1alpha1.KeaCluster) (kea.RenderInput, error) {
var subnetList v1alpha1.KeaSubnetList
if err := r.List(ctx, &subnetList, client.InNamespace(c.Namespace)); err != nil {
return kea.RenderInput{}, err
}
var subnets []v1alpha1.KeaSubnet
for _, s := range subnetList.Items {
if s.Spec.ClusterRef == "" || s.Spec.ClusterRef == c.Name {
subnets = append(subnets, s)
}
}
var classList v1alpha1.KeaClientClassList
if err := r.List(ctx, &classList, client.InNamespace(c.Namespace)); err != nil {
return kea.RenderInput{}, err
}
var classes []v1alpha1.KeaClientClass
for _, cc := range classList.Items {
if cc.Spec.ClusterRef == "" || cc.Spec.ClusterRef == c.Name {
classes = append(classes, cc)
}
}
return kea.RenderInput{
Cluster: *c,
Subnets: subnets,
ClientClasses: classes,
Peers: r.peers(c),
}, nil
}
// peers returns stable HA peer identities (DNS only, no pod IPs).
func (r *KeaClusterReconciler) peers(c *v1alpha1.KeaCluster) []kea.Peer {
replicas := int32(1)
if c.Spec.Replicas != nil {
replicas = *c.Spec.Replicas
}
mode := c.Spec.HA.Mode
if mode == "" {
mode = v1alpha1.HAHotStandby
}
peers := make([]kea.Peer, 0, replicas)
for i := int32(0); i < replicas; i++ {
role := "backup"
switch {
case i == 0:
role = "primary"
case i == 1 && mode == v1alpha1.HAHotStandby:
role = "standby"
case i == 1:
role = "secondary"
}
peers = append(peers, kea.Peer{
Name: fmt.Sprintf("server%d", i),
URL: peerDNS(c.Name, c.Namespace, int(i)),
Role: role,
})
}
return peers
}
func (r *KeaClusterReconciler) reconcileConfigMap(ctx context.Context, c *v1alpha1.KeaCluster) error {
in, err := r.buildInput(ctx, c)
if err != nil {
return err
}
dhcp4, err := kea.RenderDHCP4(in)
if err != nil {
return err
}
agent, err := kea.RenderCtrlAgent()
if err != nil {
return err
}
cm := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: configMapName(c.Name), Namespace: c.Namespace}}
_, err = ctrl.CreateOrUpdate(ctx, r.Client, cm, func() error {
cm.Labels = commonLabels(c.Name)
cm.Data = map[string]string{
"kea-dhcp4.conf": dhcp4,
"kea-ctrl-agent.conf": agent,
"entrypoint-dhcp4.sh": kea.EntrypointDHCP4(),
"entrypoint-ctrlagent.sh": kea.EntrypointCtrlAgent(),
}
return ctrl.SetControllerReference(c, cm, r.Scheme)
})
return err
}
func (r *KeaClusterReconciler) reconcileServices(ctx context.Context, c *v1alpha1.KeaCluster) error {
// Headless service for stable per-pod DNS (HA peer URLs, ctrl-agent).
headless := &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: headlessName(c.Name), Namespace: c.Namespace}}
if _, err := ctrl.CreateOrUpdate(ctx, r.Client, headless, func() error {
headless.Labels = commonLabels(c.Name)
headless.Spec.ClusterIP = corev1.ClusterIPNone
headless.Spec.PublishNotReadyAddresses = true
headless.Spec.Selector = commonLabels(c.Name)
headless.Spec.Ports = []corev1.ServicePort{
{Name: "ctrl", Port: kea.CtrlAgentPort, Protocol: corev1.ProtocolTCP},
}
return ctrl.SetControllerReference(c, headless, r.Scheme)
}); err != nil {
return err
}
// Anycast DHCP service (LoadBalancer via PureLB by default).
svc := &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: serviceName(c.Name), Namespace: c.Namespace}}
_, err := ctrl.CreateOrUpdate(ctx, r.Client, svc, func() error {
svc.Labels = commonLabels(c.Name)
svc.Annotations = mergeAnnotations(c.Spec.Service)
svc.Spec.Selector = commonLabels(c.Name)
svcType := c.Spec.Service.Type
if svcType == "" {
svcType = corev1.ServiceTypeLoadBalancer
}
svc.Spec.Type = svcType
if c.Spec.Service.LoadBalancerIP != "" {
svc.Spec.LoadBalancerIP = c.Spec.Service.LoadBalancerIP
}
if c.Spec.Service.LoadBalancerClass != nil {
svc.Spec.LoadBalancerClass = c.Spec.Service.LoadBalancerClass
}
if svcType == corev1.ServiceTypeLoadBalancer || svcType == corev1.ServiceTypeNodePort {
svc.Spec.ExternalTrafficPolicy = corev1.ServiceExternalTrafficPolicyLocal
}
svc.Spec.Ports = []corev1.ServicePort{
{Name: "dhcp", Port: kea.DHCP4Port, Protocol: corev1.ProtocolUDP},
}
return ctrl.SetControllerReference(c, svc, r.Scheme)
})
return err
}
func mergeAnnotations(s v1alpha1.ClusterServiceSpec) map[string]string {
out := map[string]string{}
for k, v := range s.Annotations {
out[k] = v
}
if s.IPAddressPool != "" {
out["purelb.io/service-group"] = s.IPAddressPool
}
if len(out) == 0 {
return nil
}
return out
}
func (r *KeaClusterReconciler) reconcileStatefulSet(ctx context.Context, c *v1alpha1.KeaCluster) (*appsv1.StatefulSet, error) {
hash, err := configHash(ctx, r.Client, c.Namespace, configMapName(c.Name))
if err != nil {
return nil, err
}
image := c.Spec.Image
if image == "" {
image = kea.DefaultImage
}
replicas := int32(1)
if c.Spec.Replicas != nil {
replicas = *c.Spec.Replicas
}
sts := &appsv1.StatefulSet{ObjectMeta: metav1.ObjectMeta{Name: stsName(c.Name), Namespace: c.Namespace}}
_, err = ctrl.CreateOrUpdate(ctx, r.Client, sts, func() error {
sts.Labels = commonLabels(c.Name)
sts.Spec.ServiceName = headlessName(c.Name)
sts.Spec.Replicas = int32ptr(replicas)
sts.Spec.Selector = &metav1.LabelSelector{MatchLabels: commonLabels(c.Name)}
sts.Spec.PodManagementPolicy = appsv1.ParallelPodManagement
sts.Spec.Template = r.podTemplate(c, image, hash)
return ctrl.SetControllerReference(c, sts, r.Scheme)
})
if err != nil {
return nil, err
}
return sts, nil
}
func (r *KeaClusterReconciler) podTemplate(c *v1alpha1.KeaCluster, image, hash string) corev1.PodTemplateSpec {
volProjected := corev1.Volume{
Name: "kea-etc",
VolumeSource: corev1.VolumeSource{
ConfigMap: &corev1.ConfigMapVolumeSource{
LocalObjectReference: corev1.LocalObjectReference{Name: configMapName(c.Name)},
DefaultMode: int32ptr(0o755),
},
},
}
volRun := corev1.Volume{Name: "run", VolumeSource: corev1.VolumeSource{EmptyDir: &corev1.EmptyDirVolumeSource{}}}
mounts := []corev1.VolumeMount{
{Name: "kea-etc", MountPath: kea.ConfigDir, ReadOnly: true},
{Name: "run", MountPath: kea.RunDir},
}
dhcp4 := corev1.Container{
Name: kea.ContainerDHCP4,
Image: image,
Command: []string{"/bin/sh", kea.ConfigDir + "/entrypoint-dhcp4.sh"},
Resources: c.Spec.Resources,
Ports: []corev1.ContainerPort{
{Name: "dhcp", ContainerPort: kea.DHCP4Port, Protocol: corev1.ProtocolUDP},
},
VolumeMounts: mounts,
}
agent := corev1.Container{
Name: kea.ContainerCtrlAgent,
Image: image,
Command: []string{"/bin/sh", kea.ConfigDir + "/entrypoint-ctrlagent.sh"},
Resources: c.Spec.Resources,
Ports: []corev1.ContainerPort{
{Name: "ctrl", ContainerPort: kea.CtrlAgentPort, Protocol: corev1.ProtocolTCP},
},
VolumeMounts: mounts,
ReadinessProbe: &corev1.Probe{
ProbeHandler: corev1.ProbeHandler{TCPSocket: &corev1.TCPSocketAction{Port: intstrFromInt(kea.CtrlAgentPort)}},
InitialDelaySeconds: 5, PeriodSeconds: 10,
},
}
labels := commonLabels(c.Name)
return corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{
Labels: labels,
Annotations: map[string]string{"kea.unkin.net/config-hash": hash},
},
Spec: corev1.PodSpec{
Containers: []corev1.Container{dhcp4, agent},
Volumes: []corev1.Volume{volProjected, volRun},
NodeSelector: c.Spec.NodeSelector,
Tolerations: c.Spec.Tolerations,
Affinity: c.Spec.Affinity,
},
}
}
// reloadReadyPods best-effort hot-reloads config on ready pods via the
// ctrl-agent REST channel, analogous to bind-operator's rndc reconfig.
func (r *KeaClusterReconciler) reloadReadyPods(ctx context.Context, c *v1alpha1.KeaCluster) {
if r.Control == nil {
return
}
var pods corev1.PodList
if err := r.List(ctx, &pods, client.InNamespace(c.Namespace), client.MatchingLabels(commonLabels(c.Name))); err != nil {
return
}
l := log.FromContext(ctx)
for i := range pods.Items {
p := &pods.Items[i]
if p.Status.PodIP == "" || !podReady(p) {
continue
}
url := fmt.Sprintf("http://%s:%d/", p.Status.PodIP, kea.CtrlAgentPort)
if err := r.Control.ConfigReload(ctx, url); err != nil {
l.V(1).Info("config-reload failed", "pod", p.Name, "err", err.Error())
}
}
}
func (r *KeaClusterReconciler) serviceIP(ctx context.Context, c *v1alpha1.KeaCluster) string {
var svc corev1.Service
if err := r.Get(ctx, types.NamespacedName{Namespace: c.Namespace, Name: serviceName(c.Name)}, &svc); err != nil {
return ""
}
if len(svc.Status.LoadBalancer.Ingress) > 0 {
return svc.Status.LoadBalancer.Ingress[0].IP
}
return svc.Spec.ClusterIP
}
func podReady(p *corev1.Pod) bool {
for _, cond := range p.Status.Conditions {
if cond.Type == corev1.PodReady {
return cond.Status == corev1.ConditionTrue
}
}
return false
}
func (r *KeaClusterReconciler) SetupWithManager(mgr ctrl.Manager) error {
mapToClusters := func(ctx context.Context, obj client.Object) []reconcile.Request {
var list v1alpha1.KeaClusterList
if err := r.List(ctx, &list, client.InNamespace(obj.GetNamespace())); err != nil {
return nil
}
var reqs []reconcile.Request
for _, c := range list.Items {
reqs = append(reqs, reconcile.Request{NamespacedName: types.NamespacedName{Namespace: c.Namespace, Name: c.Name}})
}
return reqs
}
return ctrl.NewControllerManagedBy(mgr).
For(&v1alpha1.KeaCluster{}).
Owns(&appsv1.StatefulSet{}).
Owns(&corev1.Service{}).
Owns(&corev1.ConfigMap{}).
Watches(&v1alpha1.KeaSubnet{}, handler.EnqueueRequestsFromMapFunc(mapToClusters)).
Watches(&v1alpha1.KeaClientClass{}, handler.EnqueueRequestsFromMapFunc(mapToClusters)).
Complete(r)
}
+167
View File
@@ -0,0 +1,167 @@
package controller
import (
"context"
"testing"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
v1alpha1 "git.unkin.net/unkin/kea-operator/api/v1alpha1"
)
func testScheme(t *testing.T) *runtime.Scheme {
t.Helper()
s := runtime.NewScheme()
if err := clientgoscheme.AddToScheme(s); err != nil {
t.Fatal(err)
}
if err := v1alpha1.AddToScheme(s); err != nil {
t.Fatal(err)
}
return s
}
func newClusterFixture() *v1alpha1.KeaCluster {
return &v1alpha1.KeaCluster{
ObjectMeta: metav1.ObjectMeta{Name: "pxe", Namespace: "dhcp-system"},
Spec: v1alpha1.KeaClusterSpec{
Replicas: int32ptr(2),
DomainName: "main.unkin.net",
HA: v1alpha1.HASpec{Mode: v1alpha1.HAHotStandby},
},
}
}
func TestKeaClusterReconcileCreatesWorkload(t *testing.T) {
scheme := testScheme(t)
cluster := newClusterFixture()
subnet := &v1alpha1.KeaSubnet{
ObjectMeta: metav1.ObjectMeta{Name: "s13", Namespace: "dhcp-system"},
Spec: v1alpha1.KeaSubnetSpec{Subnet: "198.18.13.0/24", Pools: []string{"198.18.13.200 - 198.18.13.220"}},
}
cl := fake.NewClientBuilder().
WithScheme(scheme).
WithStatusSubresource(&v1alpha1.KeaCluster{}).
WithObjects(cluster, subnet).
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)
}
// ConfigMap rendered with the subnet.
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)
}
if got := cm.Data["kea-dhcp4.conf"]; got == "" || !contains(got, "198.18.13.0/24") {
t.Errorf("configmap missing subnet render")
}
// StatefulSet with 2 containers and a config-hash annotation.
var sts appsv1.StatefulSet
if err := cl.Get(context.Background(), types.NamespacedName{Namespace: "dhcp-system", Name: "pxe"}, &sts); err != nil {
t.Fatalf("statefulset not created: %v", err)
}
if *sts.Spec.Replicas != 2 {
t.Errorf("expected 2 replicas, got %d", *sts.Spec.Replicas)
}
if len(sts.Spec.Template.Spec.Containers) != 2 {
t.Errorf("expected kea-dhcp4 + kea-ctrl-agent containers, got %d", len(sts.Spec.Template.Spec.Containers))
}
if sts.Spec.Template.Annotations["kea.unkin.net/config-hash"] == "" {
t.Errorf("missing config-hash annotation")
}
// Anycast + headless services.
for _, name := range []string{"pxe", "pxe-headless"} {
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)
}
}
}
// TestConfigHashChangesWithSubnets guards the roll trigger: adding a subnet
// must change the pod-template config hash (so the STS rolls).
func TestConfigHashChangesWithSubnets(t *testing.T) {
scheme := testScheme(t)
hashFor := func(objs ...client.Object) string {
base := []client.Object{newClusterFixture()}
cl := fake.NewClientBuilder().
WithScheme(scheme).
WithStatusSubresource(&v1alpha1.KeaCluster{}).
WithObjects(append(base, objs...)...).
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)
}
var sts appsv1.StatefulSet
if err := cl.Get(context.Background(), types.NamespacedName{Namespace: "dhcp-system", Name: "pxe"}, &sts); err != nil {
t.Fatal(err)
}
return sts.Spec.Template.Annotations["kea.unkin.net/config-hash"]
}
empty := hashFor()
withSubnet := hashFor(&v1alpha1.KeaSubnet{
ObjectMeta: metav1.ObjectMeta{Name: "s13", Namespace: "dhcp-system"},
Spec: v1alpha1.KeaSubnetSpec{Subnet: "198.18.13.0/24"},
})
if empty == withSubnet {
t.Errorf("config hash did not change when a subnet was added")
}
}
func TestClusterRefFiltersSubnets(t *testing.T) {
scheme := testScheme(t)
cluster := newClusterFixture()
mine := &v1alpha1.KeaSubnet{
ObjectMeta: metav1.ObjectMeta{Name: "mine", Namespace: "dhcp-system"},
Spec: v1alpha1.KeaSubnetSpec{Subnet: "198.18.13.0/24", ClusterRef: "pxe"},
}
other := &v1alpha1.KeaSubnet{
ObjectMeta: metav1.ObjectMeta{Name: "other", Namespace: "dhcp-system"},
Spec: v1alpha1.KeaSubnetSpec{Subnet: "10.9.9.0/24", ClusterRef: "someone-else"},
}
cl := fake.NewClientBuilder().WithScheme(scheme).
WithStatusSubresource(&v1alpha1.KeaCluster{}).
WithObjects(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)
}
var cm corev1.ConfigMap
_ = cl.Get(context.Background(), types.NamespacedName{Namespace: "dhcp-system", Name: "pxe-config"}, &cm)
conf := cm.Data["kea-dhcp4.conf"]
if !contains(conf, "198.18.13.0/24") {
t.Errorf("cluster-matched subnet missing from config")
}
if contains(conf, "10.9.9.0/24") {
t.Errorf("subnet bound to another cluster leaked into config")
}
}
func contains(hay, needle string) bool {
return len(hay) >= len(needle) && (indexOf(hay, needle) >= 0)
}
func indexOf(hay, needle string) int {
for i := 0; i+len(needle) <= len(hay); i++ {
if hay[i:i+len(needle)] == needle {
return i
}
}
return -1
}
@@ -0,0 +1,72 @@
package controller
import (
"context"
"fmt"
"net"
"k8s.io/apimachinery/pkg/runtime"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
v1alpha1 "git.unkin.net/unkin/kea-operator/api/v1alpha1"
)
// KeaSubnetReconciler validates KeaSubnet CRs and maintains their status. The
// actual kea config is rendered by the KeaCluster controller, which watches
// subnets and re-renders on change.
type KeaSubnetReconciler struct {
client.Client
Scheme *runtime.Scheme
}
// +kubebuilder:rbac:groups=kea.unkin.net,resources=keasubnets,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=kea.unkin.net,resources=keasubnets/status,verbs=get;update;patch
func (r *KeaSubnetReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
var subnet v1alpha1.KeaSubnet
if err := r.Get(ctx, req.NamespacedName, &subnet); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
}
if _, _, err := net.ParseCIDR(subnet.Spec.Subnet); err != nil {
subnet.Status.Phase = "Invalid"
setReady(&subnet.Status.Conditions, subnet.Generation, false, "InvalidCIDR", err.Error())
_ = r.Status().Update(ctx, &subnet)
return ctrl.Result{}, nil
}
for _, p := range subnet.Spec.Pools {
if err := validatePool(p); err != nil {
subnet.Status.Phase = "Invalid"
setReady(&subnet.Status.Conditions, subnet.Generation, false, "InvalidPool", err.Error())
_ = r.Status().Update(ctx, &subnet)
return ctrl.Result{}, nil
}
}
subnet.Status.Phase = "Ready"
subnet.Status.AssignedID = subnet.Spec.ID
subnet.Status.ObservedGeneration = subnet.Generation
setReady(&subnet.Status.Conditions, subnet.Generation, true, "Validated", "subnet accepted")
if err := r.Status().Update(ctx, &subnet); err != nil {
return ctrl.Result{}, err
}
return ctrl.Result{}, nil
}
func validatePool(p string) error {
// Accept "start - end" (with or without spaces) or a single address.
fields := splitPool(p)
for _, f := range fields {
if net.ParseIP(f) == nil {
return fmt.Errorf("invalid pool address %q in %q", f, p)
}
}
return nil
}
func (r *KeaSubnetReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&v1alpha1.KeaSubnet{}).
Complete(r)
}
+37
View File
@@ -0,0 +1,37 @@
package controller
import (
ctrl "sigs.k8s.io/controller-runtime"
"git.unkin.net/unkin/kea-operator/internal/kea"
)
// SetupAll wires every reconciler into the manager.
func SetupAll(mgr ctrl.Manager) error {
if err := (&KeaClusterReconciler{
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
Control: kea.NewControlClient(),
}).SetupWithManager(mgr); err != nil {
return err
}
if err := (&KeaSubnetReconciler{
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
}).SetupWithManager(mgr); err != nil {
return err
}
if err := (&KeaClientClassReconciler{
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
}).SetupWithManager(mgr); err != nil {
return err
}
if err := (&KeaAPIReconciler{
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
}).SetupWithManager(mgr); err != nil {
return err
}
return nil
}
+51
View File
@@ -0,0 +1,51 @@
package kea
import "fmt"
type ctrlAgentRoot struct {
ControlAgent controlAgent `json:"Control-agent"`
}
type controlAgent struct {
HTTPHost string `json:"http-host"`
HTTPPort int `json:"http-port"`
ControlSockets map[string]map[string]any `json:"control-sockets"`
Loggers []logger `json:"loggers"`
}
// RenderCtrlAgent renders the deterministic kea-ctrl-agent.conf JSON. The
// control agent exposes the HA/REST control channel on CtrlAgentPort and
// forwards to kea-dhcp4 over the shared unix socket.
func RenderCtrlAgent() (string, error) {
return marshal(ctrlAgentRoot{ControlAgent: controlAgent{
HTTPHost: "0.0.0.0",
HTTPPort: CtrlAgentPort,
ControlSockets: map[string]map[string]any{
"dhcp4": {"socket-type": "unix", "socket-name": CtrlSocketPath},
},
Loggers: loggers("kea-ctrl-agent"),
}})
}
// EntrypointDHCP4 is the kea-dhcp4 container entrypoint. It derives this pod's
// HA peer name from the StatefulSet ordinal, substitutes the placeholder in the
// projected config, and execs the server.
func EntrypointDHCP4() string {
return fmt.Sprintf(`#!/bin/sh
set -e
ORD="${HOSTNAME##*-}"
mkdir -p %[1]s
sed "s/%[2]s/server${ORD}/g" %[3]s/kea-dhcp4.conf > %[4]s
exec %[5]s -c %[4]s
`, RunDir, ThisServerPlaceholder, ConfigDir, DHCP4ConfPath, DHCP4Bin)
}
// EntrypointCtrlAgent is the kea-ctrl-agent container entrypoint.
func EntrypointCtrlAgent() string {
return fmt.Sprintf(`#!/bin/sh
set -e
mkdir -p %[1]s
cp %[2]s/kea-ctrl-agent.conf %[3]s
exec %[4]s -c %[3]s
`, RunDir, ConfigDir, CtrlAgentConfPath, CtrlAgentBin)
}
+68
View File
@@ -0,0 +1,68 @@
package kea
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"time"
)
// ControlClient talks to a kea-ctrl-agent REST endpoint.
type ControlClient struct {
HTTP *http.Client
}
// NewControlClient returns a ControlClient with a bounded timeout.
func NewControlClient() *ControlClient {
return &ControlClient{HTTP: &http.Client{Timeout: 5 * time.Second}}
}
type command struct {
Command string `json:"command"`
Service []string `json:"service,omitempty"`
Arguments any `json:"arguments,omitempty"`
}
type response struct {
Result int `json:"result"`
Text string `json:"text"`
}
// ConfigReload asks the dhcp4 server behind the agent at baseURL to re-read its
// config file from disk (the hot-reload path, analogous to rndc reconfig).
func (c *ControlClient) ConfigReload(ctx context.Context, baseURL string) error {
return c.send(ctx, baseURL, command{Command: "config-reload", Service: []string{"dhcp4"}})
}
func (c *ControlClient) send(ctx context.Context, baseURL string, cmd command) error {
body, err := json.Marshal(cmd)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.HTTP.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("kea control %q: http %d", cmd.Command, resp.StatusCode)
}
var results []response
if err := json.NewDecoder(resp.Body).Decode(&results); err != nil {
return fmt.Errorf("decode kea control response: %w", err)
}
for _, r := range results {
if r.Result != 0 {
return fmt.Errorf("kea control %q failed: %s", cmd.Command, r.Text)
}
}
return nil
}
+337
View File
@@ -0,0 +1,337 @@
package kea
import (
"encoding/json"
"fmt"
"sort"
"strings"
v1alpha1 "git.unkin.net/unkin/kea-operator/api/v1alpha1"
)
// ThisServerPlaceholder is substituted by each pod's entrypoint with its
// ordinal-derived HA peer name (e.g. "server0"). Keeping it a placeholder in
// the shared config means the ConfigMap is identical across pods and carries
// no pod IPs, so the config hash never triggers a restart loop.
const ThisServerPlaceholder = "@@THIS_SERVER_NAME@@"
// Peer is a stable HA peer identity (no pod IPs — DNS names only).
type Peer struct {
Name string
URL string
Role string
}
// RenderInput aggregates a KeaCluster with its matching subnets and client
// classes into everything needed to render the kea configs.
type RenderInput struct {
Cluster v1alpha1.KeaCluster
Subnets []v1alpha1.KeaSubnet
ClientClasses []v1alpha1.KeaClientClass
Peers []Peer
}
// ---- kea-dhcp4.conf model (field order = JSON key order, deterministic) ----
type dhcp4Root struct {
Dhcp4 dhcp4 `json:"Dhcp4"`
}
type dhcp4 struct {
InterfacesConfig map[string]any `json:"interfaces-config"`
ControlSocket map[string]any `json:"control-socket"`
LeaseDatabase map[string]any `json:"lease-database"`
ValidLifetime int `json:"valid-lifetime"`
MaxValidLifetime int `json:"max-valid-lifetime"`
Authoritative bool `json:"authoritative"`
DDNSSendUpdates bool `json:"ddns-send-updates"`
OptionDef []optionDef `json:"option-def,omitempty"`
OptionData []optionData `json:"option-data,omitempty"`
ClientClasses []clientClass `json:"client-classes,omitempty"`
HooksLibraries []hookLib `json:"hooks-libraries"`
Subnet4 []subnet4 `json:"subnet4"`
Loggers []logger `json:"loggers"`
}
type optionDef struct {
Name string `json:"name"`
Code int `json:"code"`
Type string `json:"type"`
Space string `json:"space"`
Array bool `json:"array,omitempty"`
RecordTypes string `json:"record-types,omitempty"`
Encapsulate string `json:"encapsulate,omitempty"`
}
type optionData struct {
Name string `json:"name,omitempty"`
Code int `json:"code,omitempty"`
Space string `json:"space,omitempty"`
Data string `json:"data"`
CSVFormat *bool `json:"csv-format,omitempty"`
}
type clientClass struct {
Name string `json:"name"`
Test string `json:"test,omitempty"`
BootFileName string `json:"boot-file-name,omitempty"`
NextServer string `json:"next-server,omitempty"`
ServerHostname string `json:"server-hostname,omitempty"`
OptionData []optionData `json:"option-data,omitempty"`
}
type pool struct {
Pool string `json:"pool"`
}
type subnet4 struct {
ID int `json:"id"`
Subnet string `json:"subnet"`
Pools []pool `json:"pools,omitempty"`
NextServer string `json:"next-server,omitempty"`
BootFileName string `json:"boot-file-name,omitempty"`
ValidLifetime int `json:"valid-lifetime,omitempty"`
ClientClass string `json:"client-class,omitempty"`
OptionData []optionData `json:"option-data,omitempty"`
}
type hookLib struct {
Library string `json:"library"`
Parameters map[string]any `json:"parameters,omitempty"`
}
type logger struct {
Name string `json:"name"`
Severity string `json:"severity"`
OutputOptions []map[string]any `json:"output_options"`
}
// RenderDHCP4 renders the deterministic kea-dhcp4.conf JSON.
func RenderDHCP4(in RenderInput) (string, error) {
in = sortInput(in)
spec := in.Cluster.Spec
valid := spec.DefaultLeaseTime
if valid == 0 {
valid = 1200
}
maxValid := spec.MaxLeaseTime
if maxValid == 0 {
maxValid = 86400
}
d := dhcp4{
InterfacesConfig: map[string]any{"interfaces": []string{"*"}},
ControlSocket: map[string]any{"socket-type": "unix", "socket-name": CtrlSocketPath},
LeaseDatabase: map[string]any{"type": "memfile", "persist": false},
ValidLifetime: valid,
MaxValidLifetime: maxValid,
Authoritative: true,
DDNSSendUpdates: false,
HooksLibraries: hooks(in),
Subnet4: renderSubnets(in),
Loggers: loggers("kea-dhcp4"),
}
for _, od := range spec.OptionDefs {
space := od.Space
if space == "" {
space = "dhcp4"
}
d.OptionDef = append(d.OptionDef, optionDef{
Name: od.Name, Code: od.Code, Type: od.Type, Space: space,
Array: od.Array, RecordTypes: od.RecordTypes, Encapsulate: od.Encapsulate,
})
}
// Global options shared by every subnet.
if spec.DomainName != "" {
d.OptionData = append(d.OptionData, optionData{Name: "domain-name", Data: spec.DomainName})
}
if len(spec.NTPServers) > 0 {
d.OptionData = append(d.OptionData, optionData{Name: "ntp-servers", Data: strings.Join(spec.NTPServers, ",")})
}
d.ClientClasses = renderClasses(in)
return marshal(dhcp4Root{Dhcp4: d})
}
func renderSubnets(in RenderInput) []subnet4 {
out := make([]subnet4, 0, len(in.Subnets))
for _, s := range in.Subnets {
sub := subnet4{
ID: s.Spec.ID,
Subnet: s.Spec.Subnet,
NextServer: s.Spec.NextServer,
BootFileName: s.Spec.BootFileName,
ValidLifetime: s.Spec.ValidLifetime,
}
for _, p := range s.Spec.Pools {
sub.Pools = append(sub.Pools, pool{Pool: normalizePool(p)})
}
if len(s.Spec.ClientClasses) == 1 {
sub.ClientClass = s.Spec.ClientClasses[0]
}
if len(s.Spec.Routers) > 0 {
sub.OptionData = append(sub.OptionData, optionData{Name: "routers", Data: strings.Join(s.Spec.Routers, ",")})
}
if len(s.Spec.DNSServers) > 0 {
sub.OptionData = append(sub.OptionData, optionData{Name: "domain-name-servers", Data: strings.Join(s.Spec.DNSServers, ",")})
}
if s.Spec.DomainName != "" {
sub.OptionData = append(sub.OptionData, optionData{Name: "domain-name", Data: s.Spec.DomainName})
}
sub.OptionData = append(sub.OptionData, convertOptionData(s.Spec.OptionData)...)
out = append(out, sub)
}
return out
}
func renderClasses(in RenderInput) []clientClass {
out := make([]clientClass, 0, len(in.ClientClasses))
for _, c := range in.ClientClasses {
cc := clientClass{
Name: c.Name,
Test: classTest(c.Spec),
BootFileName: c.Spec.BootFileName,
NextServer: c.Spec.NextServer,
ServerHostname: c.Spec.ServerHostname,
OptionData: convertOptionData(c.Spec.OptionData),
}
out = append(out, cc)
}
return out
}
// classTest returns the raw test if set, else builds one from ArchHex.
func classTest(spec v1alpha1.KeaClientClassSpec) string {
if spec.Test != "" {
return spec.Test
}
terms := make([]string, 0, len(spec.ArchHex))
for _, a := range spec.ArchHex {
terms = append(terms, fmt.Sprintf("option[%d].hex == %s", ClientArchOption, a))
}
return strings.Join(terms, " or ")
}
func hooks(in RenderInput) []hookLib {
libs := []hookLib{{Library: LeaseCmdsLibrary}}
peers := make([]map[string]any, 0, len(in.Peers))
for _, p := range in.Peers {
peers = append(peers, map[string]any{
"name": p.Name,
"url": p.URL,
"role": p.Role,
"auto-failover": true,
})
}
mode := string(in.Cluster.Spec.HA.Mode)
if mode == "" {
mode = string(v1alpha1.HAHotStandby)
}
ha := in.Cluster.Spec.HA
rel := map[string]any{
"this-server-name": ThisServerPlaceholder,
"mode": mode,
"heartbeat-delay": firstNonZero(ha.HeartbeatDelay, 10000),
"max-response-delay": firstNonZero(ha.MaxResponseDelay, 60000),
"max-ack-delay": firstNonZero(ha.MaxAckDelay, 5000),
"max-unacked-clients": firstNonZero(ha.MaxUnackedClients, 5),
"peers": peers,
}
libs = append(libs, hookLib{
Library: HALibrary,
Parameters: map[string]any{"high-availability": []any{rel}},
})
return libs
}
func loggers(name string) []logger {
return []logger{{
Name: name,
Severity: "INFO",
OutputOptions: []map[string]any{
{"output": "stdout"},
},
}}
}
// AssignSubnetIDs stamps a stable numeric id on every subnet that lacks one,
// choosing the smallest unused positive integer in sorted-CIDR order.
func AssignSubnetIDs(subnets []v1alpha1.KeaSubnet) {
sort.SliceStable(subnets, func(i, j int) bool { return subnets[i].Spec.Subnet < subnets[j].Spec.Subnet })
used := map[int]bool{}
for i := range subnets {
if subnets[i].Spec.ID > 0 {
used[subnets[i].Spec.ID] = true
}
}
next := 1
for i := range subnets {
if subnets[i].Spec.ID == 0 {
for used[next] {
next++
}
subnets[i].Spec.ID = next
used[next] = true
}
}
}
// sortInput sorts subnets and classes deterministically and assigns subnet ids.
func sortInput(in RenderInput) RenderInput {
subs := make([]v1alpha1.KeaSubnet, len(in.Subnets))
copy(subs, in.Subnets)
AssignSubnetIDs(subs)
sort.SliceStable(subs, func(i, j int) bool { return subs[i].Spec.ID < subs[j].Spec.ID })
in.Subnets = subs
classes := make([]v1alpha1.KeaClientClass, len(in.ClientClasses))
copy(classes, in.ClientClasses)
sort.SliceStable(classes, func(i, j int) bool { return classes[i].Name < classes[j].Name })
in.ClientClasses = classes
peers := make([]Peer, len(in.Peers))
copy(peers, in.Peers)
sort.SliceStable(peers, func(i, j int) bool { return peers[i].Name < peers[j].Name })
in.Peers = peers
return in
}
func convertOptionData(in []v1alpha1.OptionData) []optionData {
out := make([]optionData, 0, len(in))
for _, o := range in {
out = append(out, optionData{
Name: o.Name, Code: o.Code, Space: o.Space, Data: o.Data, CSVFormat: o.CSVFormat,
})
}
return out
}
// normalizePool ensures the "start - end" spacing Kea expects.
func normalizePool(p string) string {
if strings.Contains(p, "-") && !strings.Contains(p, " - ") {
parts := strings.SplitN(p, "-", 2)
return strings.TrimSpace(parts[0]) + " - " + strings.TrimSpace(parts[1])
}
return strings.TrimSpace(p)
}
func firstNonZero(v, def int) int {
if v != 0 {
return v
}
return def
}
func marshal(v any) (string, error) {
b, err := json.MarshalIndent(v, "", " ")
if err != nil {
return "", err
}
return string(b) + "\n", nil
}
+234
View File
@@ -0,0 +1,234 @@
package kea
import (
"encoding/json"
"strings"
"testing"
v1alpha1 "git.unkin.net/unkin/kea-operator/api/v1alpha1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// referenceInput mirrors the ISC dhcpd config that must be fully representable:
// subnets 198.18.13-17.0/24 (pool .200-.220, routers, dns .19.15, next-server
// .19.19, domain main.unkin.net), plus 198.18.25.0/24 with no pool; the two PXE
// arch classes; authoritative; ddns off.
func referenceInput() RenderInput {
cluster := v1alpha1.KeaCluster{
ObjectMeta: metav1.ObjectMeta{Name: "pxe"},
Spec: v1alpha1.KeaClusterSpec{
DomainName: "main.unkin.net",
DefaultLeaseTime: 1200,
MaxLeaseTime: 86400,
HA: v1alpha1.HASpec{Mode: v1alpha1.HAHotStandby},
},
}
mkSubnet := func(name, cidr string, withPool bool) v1alpha1.KeaSubnet {
s := v1alpha1.KeaSubnet{
ObjectMeta: metav1.ObjectMeta{Name: name},
Spec: v1alpha1.KeaSubnetSpec{
Subnet: cidr,
Routers: []string{strings.TrimSuffix(cidr, "0/24") + "1"},
DNSServers: []string{"198.18.19.15"},
DomainName: "main.unkin.net",
NextServer: "198.18.19.19",
},
}
if withPool {
base := strings.TrimSuffix(cidr, "0/24")
s.Spec.Pools = []string{base + "200 - " + base + "220"}
}
return s
}
subnets := []v1alpha1.KeaSubnet{
mkSubnet("s13", "198.18.13.0/24", true),
mkSubnet("s14", "198.18.14.0/24", true),
mkSubnet("s15", "198.18.15.0/24", true),
mkSubnet("s16", "198.18.16.0/24", true),
mkSubnet("s17", "198.18.17.0/24", true),
mkSubnet("s25", "198.18.25.0/24", false),
}
classes := []v1alpha1.KeaClientClass{
{ObjectMeta: metav1.ObjectMeta{Name: "Legacy"}, Spec: v1alpha1.KeaClientClassSpec{
ArchHex: []string{"0x0000"}, BootFileName: "/undionly.kpxe"}},
{ObjectMeta: metav1.ObjectMeta{Name: "UEFI-64"}, Spec: v1alpha1.KeaClientClassSpec{
ArchHex: []string{"0x0007", "0x0009"}, BootFileName: "/ipxe.efi"}},
}
peers := []Peer{
{Name: "server0", URL: "http://pxe-0.pxe-headless.dhcp-system:8000/", Role: "primary"},
{Name: "server1", URL: "http://pxe-1.pxe-headless.dhcp-system:8000/", Role: "standby"},
}
return RenderInput{Cluster: cluster, Subnets: subnets, ClientClasses: classes, Peers: peers}
}
func TestRenderDHCP4IsValidJSON(t *testing.T) {
out, err := RenderDHCP4(referenceInput())
if err != nil {
t.Fatalf("render: %v", err)
}
var root map[string]any
if err := json.Unmarshal([]byte(out), &root); err != nil {
t.Fatalf("output is not valid JSON: %v\n%s", err, out)
}
if _, ok := root["Dhcp4"]; !ok {
t.Fatalf("missing Dhcp4 top-level key")
}
}
func TestRenderDHCP4ReferenceSemantics(t *testing.T) {
out, err := RenderDHCP4(referenceInput())
if err != nil {
t.Fatalf("render: %v", err)
}
must := []string{
`"authoritative": true`,
`"ddns-send-updates": false`,
`"valid-lifetime": 1200`,
`"max-valid-lifetime": 86400`,
`"198.18.13.0/24"`,
`"198.18.25.0/24"`,
`"198.18.13.200 - 198.18.13.220"`,
`"next-server": "198.18.19.19"`,
`"data": "198.18.19.15"`, // domain-name-servers
`"data": "198.18.13.1"`, // routers
`"data": "main.unkin.net"`, // domain-name
`"boot-file-name": "/undionly.kpxe"`,
`"boot-file-name": "/ipxe.efi"`,
`option[93].hex == 0x0000`,
`option[93].hex == 0x0007 or option[93].hex == 0x0009`,
`libdhcp_ha.so`,
`libdhcp_lease_cmds.so`,
`"mode": "hot-standby"`,
ThisServerPlaceholder,
`memfile`,
}
for _, m := range must {
if !strings.Contains(out, m) {
t.Errorf("rendered config missing %q\n---\n%s", m, out)
}
}
}
// TestSubnetWithoutPoolIsDeclared verifies the pool-less subnet still appears
// (Kea must know the subnet to service relayed requests) but carries no pools.
func TestSubnetWithoutPoolIsDeclared(t *testing.T) {
out, err := RenderDHCP4(referenceInput())
if err != nil {
t.Fatalf("render: %v", err)
}
var root dhcp4Root
if err := json.Unmarshal([]byte(out), &root); err != nil {
t.Fatalf("unmarshal: %v", err)
}
var found bool
for _, s := range root.Dhcp4.Subnet4 {
if s.Subnet == "198.18.25.0/24" {
found = true
if len(s.Pools) != 0 {
t.Errorf("198.18.25.0/24 should have no pools, got %v", s.Pools)
}
}
}
if !found {
t.Fatalf("pool-less subnet 198.18.25.0/24 not declared")
}
}
// TestRenderDeterministicWithShuffledInput asserts byte-identical output
// regardless of input ordering — unsorted input would churn the ConfigMap and
// trigger a restart loop.
func TestRenderDeterministicWithShuffledInput(t *testing.T) {
a := referenceInput()
b := referenceInput()
// shuffle b
b.Subnets[0], b.Subnets[5] = b.Subnets[5], b.Subnets[0]
b.ClientClasses[0], b.ClientClasses[1] = b.ClientClasses[1], b.ClientClasses[0]
b.Peers[0], b.Peers[1] = b.Peers[1], b.Peers[0]
oa, err := RenderDHCP4(a)
if err != nil {
t.Fatal(err)
}
ob, err := RenderDHCP4(b)
if err != nil {
t.Fatal(err)
}
if oa != ob {
t.Errorf("render not deterministic under shuffled input\n--A--\n%s\n--B--\n%s", oa, ob)
}
}
// TestNoPodIPsInConfig guards the restart-loop invariant: the rendered config
// (which drives the config hash) must contain only stable DNS peer names.
func TestNoPodIPsInConfig(t *testing.T) {
out, err := RenderDHCP4(referenceInput())
if err != nil {
t.Fatal(err)
}
for _, ip := range []string{"10.", "172.", "192.168."} {
if strings.Contains(out, `"url": "http://`+ip) {
t.Errorf("pod IP leaked into HA peer url (contains %q)", ip)
}
}
if !strings.Contains(out, "pxe-headless") {
t.Errorf("expected stable headless DNS peer url")
}
}
func TestSubnetIDAssignmentStableAndUnique(t *testing.T) {
in := referenceInput()
out, err := RenderDHCP4(in)
if err != nil {
t.Fatal(err)
}
var root dhcp4Root
if err := json.Unmarshal([]byte(out), &root); err != nil {
t.Fatal(err)
}
seen := map[int]bool{}
for _, s := range root.Dhcp4.Subnet4 {
if s.ID <= 0 {
t.Errorf("subnet %s has invalid id %d", s.Subnet, s.ID)
}
if seen[s.ID] {
t.Errorf("duplicate subnet id %d", s.ID)
}
seen[s.ID] = true
}
if len(seen) != 6 {
t.Errorf("expected 6 unique subnet ids, got %d", len(seen))
}
}
func TestExplicitSubnetIDPreserved(t *testing.T) {
in := referenceInput()
in.Subnets[2].Spec.ID = 42
out, err := RenderDHCP4(in)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(out, `"id": 42`) {
t.Errorf("explicit subnet id 42 not preserved")
}
}
func TestRenderCtrlAgent(t *testing.T) {
out, err := RenderCtrlAgent()
if err != nil {
t.Fatal(err)
}
var root map[string]any
if err := json.Unmarshal([]byte(out), &root); err != nil {
t.Fatalf("ctrl-agent config not valid JSON: %v", err)
}
for _, m := range []string{`"http-port": 8000`, `kea4-ctrl-socket`, `"dhcp4"`} {
if !strings.Contains(out, m) {
t.Errorf("ctrl-agent config missing %q", m)
}
}
}
+47
View File
@@ -0,0 +1,47 @@
package kea
// Filesystem and binary paths inside the kea container image, plus the
// operator's label/annotation vocabulary.
const (
// ContainerDHCP4 is the kea-dhcp4 container name.
ContainerDHCP4 = "kea-dhcp4"
// ContainerCtrlAgent is the kea-ctrl-agent container name.
ContainerCtrlAgent = "kea-ctrl-agent"
// ConfigDir is where projected config is mounted read-only.
ConfigDir = "/etc/kea-operator"
// RunDir is a shared emptyDir for the config copy and control socket.
RunDir = "/run/kea"
// DHCP4ConfPath is the runtime kea-dhcp4 config.
DHCP4ConfPath = RunDir + "/kea-dhcp4.conf"
// CtrlAgentConfPath is the runtime kea-ctrl-agent config.
CtrlAgentConfPath = RunDir + "/kea-ctrl-agent.conf"
// CtrlSocketPath is the unix control socket between ctrl-agent and dhcp4.
CtrlSocketPath = RunDir + "/kea4-ctrl-socket"
// EntrypointPath is the generated container entrypoint.
EntrypointPath = ConfigDir + "/entrypoint.sh"
// DHCP4Bin is the kea-dhcp4 server binary.
DHCP4Bin = "/usr/sbin/kea-dhcp4"
// CtrlAgentBin is the kea-ctrl-agent binary.
CtrlAgentBin = "/usr/sbin/kea-ctrl-agent"
// HooksDir holds the Kea hook libraries.
HooksDir = "/usr/lib64/kea/hooks"
// HALibrary is the High Availability hook.
HALibrary = HooksDir + "/libdhcp_ha.so"
// LeaseCmdsLibrary is the lease commands hook (required by HA lease sync).
LeaseCmdsLibrary = HooksDir + "/libdhcp_lease_cmds.so"
// CtrlAgentPort is the REST control channel port.
CtrlAgentPort = 8000
// DHCP4Port is the DHCPv4 server port.
DHCP4Port = 67
// DefaultImage is the kea workload image built by this repo.
DefaultImage = "git.unkin.net/unkin/kea:latest"
// ClientArchOption is the DHCP option code carrying PXE client arch.
ClientArchOption = 93
)
+117
View File
@@ -0,0 +1,117 @@
package keaapi
import v1alpha1 "git.unkin.net/unkin/kea-operator/api/v1alpha1"
// OptionDataAPI is the wire form of a DHCP option value.
type OptionDataAPI struct {
Name string `json:"name,omitempty"`
Code int `json:"code,omitempty"`
Space string `json:"space,omitempty"`
Data string `json:"data"`
}
// SubnetAPI is the JSON contract for a subnet resource. Name is the stable id
// (the CR name) and is authoritative from the URL path.
type SubnetAPI struct {
Name string `json:"name"`
ClusterRef string `json:"cluster_ref,omitempty"`
Subnet string `json:"subnet"`
ID int `json:"id,omitempty"`
Pools []string `json:"pools,omitempty"`
Routers []string `json:"routers,omitempty"`
DNSServers []string `json:"dns_servers,omitempty"`
DomainName string `json:"domain_name,omitempty"`
NextServer string `json:"next_server,omitempty"`
BootFileName string `json:"boot_file_name,omitempty"`
ClientClasses []string `json:"client_classes,omitempty"`
ValidLifetime int `json:"valid_lifetime,omitempty"`
OptionData []OptionDataAPI `json:"option_data,omitempty"`
}
// ClientClassAPI is the JSON contract for a PXE client-class resource.
type ClientClassAPI struct {
Name string `json:"name"`
ClusterRef string `json:"cluster_ref,omitempty"`
Test string `json:"test,omitempty"`
ArchHex []string `json:"arch_hex,omitempty"`
BootFileName string `json:"boot_file_name,omitempty"`
NextServer string `json:"next_server,omitempty"`
ServerHostname string `json:"server_hostname,omitempty"`
OptionData []OptionDataAPI `json:"option_data,omitempty"`
}
func optionDataToAPI(in []v1alpha1.OptionData) []OptionDataAPI {
out := make([]OptionDataAPI, 0, len(in))
for _, o := range in {
out = append(out, OptionDataAPI{Name: o.Name, Code: o.Code, Space: o.Space, Data: o.Data})
}
return out
}
func optionDataFromAPI(in []OptionDataAPI) []v1alpha1.OptionData {
out := make([]v1alpha1.OptionData, 0, len(in))
for _, o := range in {
out = append(out, v1alpha1.OptionData{Name: o.Name, Code: o.Code, Space: o.Space, Data: o.Data})
}
return out
}
func subnetToAPI(s *v1alpha1.KeaSubnet) SubnetAPI {
return SubnetAPI{
Name: s.Name,
ClusterRef: s.Spec.ClusterRef,
Subnet: s.Spec.Subnet,
ID: s.Spec.ID,
Pools: s.Spec.Pools,
Routers: s.Spec.Routers,
DNSServers: s.Spec.DNSServers,
DomainName: s.Spec.DomainName,
NextServer: s.Spec.NextServer,
BootFileName: s.Spec.BootFileName,
ClientClasses: s.Spec.ClientClasses,
ValidLifetime: s.Spec.ValidLifetime,
OptionData: optionDataToAPI(s.Spec.OptionData),
}
}
func subnetSpecFromAPI(a SubnetAPI) v1alpha1.KeaSubnetSpec {
return v1alpha1.KeaSubnetSpec{
ClusterRef: a.ClusterRef,
Subnet: a.Subnet,
ID: a.ID,
Pools: a.Pools,
Routers: a.Routers,
DNSServers: a.DNSServers,
DomainName: a.DomainName,
NextServer: a.NextServer,
BootFileName: a.BootFileName,
ClientClasses: a.ClientClasses,
ValidLifetime: a.ValidLifetime,
OptionData: optionDataFromAPI(a.OptionData),
}
}
func classToAPI(c *v1alpha1.KeaClientClass) ClientClassAPI {
return ClientClassAPI{
Name: c.Name,
ClusterRef: c.Spec.ClusterRef,
Test: c.Spec.Test,
ArchHex: c.Spec.ArchHex,
BootFileName: c.Spec.BootFileName,
NextServer: c.Spec.NextServer,
ServerHostname: c.Spec.ServerHostname,
OptionData: optionDataToAPI(c.Spec.OptionData),
}
}
func classSpecFromAPI(a ClientClassAPI) v1alpha1.KeaClientClassSpec {
return v1alpha1.KeaClientClassSpec{
ClusterRef: a.ClusterRef,
Test: a.Test,
ArchHex: a.ArchHex,
BootFileName: a.BootFileName,
NextServer: a.NextServer,
ServerHostname: a.ServerHostname,
OptionData: optionDataFromAPI(a.OptionData),
}
}
+195
View File
@@ -0,0 +1,195 @@
package keaapi
import (
"context"
"crypto/subtle"
"encoding/json"
"errors"
"net/http"
"strings"
"time"
"github.com/go-logr/logr"
)
// Server is the REST API exposing CRUD over KeaSubnet / KeaClientClass CRs.
type Server struct {
Store Store
Token string
Log logr.Logger
}
// Handler builds the routed http.Handler. Reads and writes are both token
// guarded (the whole surface mutates cluster state indirectly). Go 1.22+
// pattern routing gives chi-style method+path matching with no dependency.
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
})
mux.Handle("GET /api/v1/subnets", s.auth(http.HandlerFunc(s.listSubnets)))
mux.Handle("GET /api/v1/subnets/{name}", s.auth(http.HandlerFunc(s.getSubnet)))
mux.Handle("PUT /api/v1/subnets/{name}", s.auth(http.HandlerFunc(s.putSubnet)))
mux.Handle("DELETE /api/v1/subnets/{name}", s.auth(http.HandlerFunc(s.deleteSubnet)))
mux.Handle("GET /api/v1/clientclasses", s.auth(http.HandlerFunc(s.listClasses)))
mux.Handle("GET /api/v1/clientclasses/{name}", s.auth(http.HandlerFunc(s.getClass)))
mux.Handle("PUT /api/v1/clientclasses/{name}", s.auth(http.HandlerFunc(s.putClass)))
mux.Handle("DELETE /api/v1/clientclasses/{name}", s.auth(http.HandlerFunc(s.deleteClass)))
return mux
}
// ListenAndServe runs the server until ctx is cancelled.
func (s *Server) ListenAndServe(ctx context.Context, addr string) error {
srv := &http.Server{Addr: addr, Handler: s.Handler(), ReadHeaderTimeout: 10 * time.Second}
go func() {
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = srv.Shutdown(shutdownCtx)
}()
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
return err
}
return nil
}
func (s *Server) auth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if s.Token == "" {
writeError(w, http.StatusServiceUnavailable, "auth disabled: KEA_API_TOKEN not set")
return
}
presented := bearer(r)
if presented == "" || subtle.ConstantTimeCompare([]byte(presented), []byte(s.Token)) != 1 {
writeError(w, http.StatusUnauthorized, "invalid or missing token")
return
}
next.ServeHTTP(w, r)
})
}
func bearer(r *http.Request) string {
if h := r.Header.Get("Authorization"); h != "" {
if after, ok := strings.CutPrefix(h, "Bearer "); ok {
return after
}
}
return r.Header.Get("token")
}
// ---- subnet handlers ----
func (s *Server) listSubnets(w http.ResponseWriter, r *http.Request) {
items, err := s.Store.ListSubnets(r.Context())
if err != nil {
s.fail(w, err)
return
}
writeJSON(w, http.StatusOK, items)
}
func (s *Server) getSubnet(w http.ResponseWriter, r *http.Request) {
item, err := s.Store.GetSubnet(r.Context(), r.PathValue("name"))
if err != nil {
s.fail(w, err)
return
}
writeJSON(w, http.StatusOK, item)
}
func (s *Server) putSubnet(w http.ResponseWriter, r *http.Request) {
var in SubnetAPI
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return
}
in.Name = r.PathValue("name")
if in.Subnet == "" {
writeError(w, http.StatusBadRequest, "subnet is required")
return
}
out, err := s.Store.UpsertSubnet(r.Context(), in)
if err != nil {
s.fail(w, err)
return
}
writeJSON(w, http.StatusOK, out)
}
func (s *Server) deleteSubnet(w http.ResponseWriter, r *http.Request) {
if err := s.Store.DeleteSubnet(r.Context(), r.PathValue("name")); err != nil {
s.fail(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// ---- client class handlers ----
func (s *Server) listClasses(w http.ResponseWriter, r *http.Request) {
items, err := s.Store.ListClasses(r.Context())
if err != nil {
s.fail(w, err)
return
}
writeJSON(w, http.StatusOK, items)
}
func (s *Server) getClass(w http.ResponseWriter, r *http.Request) {
item, err := s.Store.GetClass(r.Context(), r.PathValue("name"))
if err != nil {
s.fail(w, err)
return
}
writeJSON(w, http.StatusOK, item)
}
func (s *Server) putClass(w http.ResponseWriter, r *http.Request) {
var in ClientClassAPI
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return
}
in.Name = r.PathValue("name")
if in.Test == "" && len(in.ArchHex) == 0 {
writeError(w, http.StatusBadRequest, "either test or arch_hex is required")
return
}
out, err := s.Store.UpsertClass(r.Context(), in)
if err != nil {
s.fail(w, err)
return
}
writeJSON(w, http.StatusOK, out)
}
func (s *Server) deleteClass(w http.ResponseWriter, r *http.Request) {
if err := s.Store.DeleteClass(r.Context(), r.PathValue("name")); err != nil {
s.fail(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) fail(w http.ResponseWriter, err error) {
if errors.Is(err, ErrNotFound) {
writeError(w, http.StatusNotFound, "not found")
return
}
s.Log.Error(err, "request failed")
writeError(w, http.StatusInternalServerError, err.Error())
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}
+178
View File
@@ -0,0 +1,178 @@
package keaapi
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/go-logr/logr"
"k8s.io/apimachinery/pkg/runtime"
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
v1alpha1 "git.unkin.net/unkin/kea-operator/api/v1alpha1"
)
const testToken = "s3cr3t"
func newTestServer(t *testing.T) *httptest.Server {
t.Helper()
scheme := runtime.NewScheme()
if err := clientgoscheme.AddToScheme(scheme); err != nil {
t.Fatal(err)
}
if err := v1alpha1.AddToScheme(scheme); err != nil {
t.Fatal(err)
}
cl := fake.NewClientBuilder().WithScheme(scheme).Build()
srv := &Server{
Store: &K8sStore{Client: cl, Namespace: "dhcp-system"},
Token: testToken,
Log: logr.Discard(),
}
return httptest.NewServer(srv.Handler())
}
func do(t *testing.T, method, url, token string, body any) *http.Response {
t.Helper()
var buf bytes.Buffer
if body != nil {
if err := json.NewEncoder(&buf).Encode(body); err != nil {
t.Fatal(err)
}
}
req, err := http.NewRequestWithContext(context.Background(), method, url, &buf)
if err != nil {
t.Fatal(err)
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
return resp
}
func TestSubnetCRUDLifecycle(t *testing.T) {
ts := newTestServer(t)
defer ts.Close()
base := ts.URL + "/api/v1/subnets/net13"
// PUT create
resp := do(t, http.MethodPut, base, testToken, SubnetAPI{
Subnet: "198.18.13.0/24", Pools: []string{"198.18.13.200 - 198.18.13.220"},
Routers: []string{"198.18.13.1"}, NextServer: "198.18.19.19",
})
if resp.StatusCode != http.StatusOK {
t.Fatalf("PUT create: got %d", resp.StatusCode)
}
var created SubnetAPI
_ = json.NewDecoder(resp.Body).Decode(&created)
resp.Body.Close()
if created.Name != "net13" {
t.Errorf("name not stamped from URL, got %q", created.Name)
}
// GET
resp = do(t, http.MethodGet, base, testToken, nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("GET: got %d", resp.StatusCode)
}
resp.Body.Close()
// PUT update (idempotent upsert)
resp = do(t, http.MethodPut, base, testToken, SubnetAPI{Subnet: "198.18.13.0/24", DomainName: "main.unkin.net"})
if resp.StatusCode != http.StatusOK {
t.Fatalf("PUT update: got %d", resp.StatusCode)
}
var updated SubnetAPI
_ = json.NewDecoder(resp.Body).Decode(&updated)
resp.Body.Close()
if updated.DomainName != "main.unkin.net" {
t.Errorf("update not applied")
}
// LIST
resp = do(t, http.MethodGet, ts.URL+"/api/v1/subnets", testToken, nil)
var list []SubnetAPI
_ = json.NewDecoder(resp.Body).Decode(&list)
resp.Body.Close()
if len(list) != 1 {
t.Errorf("expected 1 subnet, got %d", len(list))
}
// DELETE
resp = do(t, http.MethodDelete, base, testToken, nil)
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("DELETE: got %d", resp.StatusCode)
}
resp.Body.Close()
// GET after delete -> 404 (drives provider drift handling)
resp = do(t, http.MethodGet, base, testToken, nil)
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("GET after delete: expected 404, got %d", resp.StatusCode)
}
resp.Body.Close()
}
func TestAuthRequired(t *testing.T) {
ts := newTestServer(t)
defer ts.Close()
// no token
resp := do(t, http.MethodGet, ts.URL+"/api/v1/subnets", "", nil)
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("expected 401 without token, got %d", resp.StatusCode)
}
resp.Body.Close()
// wrong token
resp = do(t, http.MethodGet, ts.URL+"/api/v1/subnets", "nope", nil)
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("expected 401 with bad token, got %d", resp.StatusCode)
}
resp.Body.Close()
}
func TestHealthzOpen(t *testing.T) {
ts := newTestServer(t)
defer ts.Close()
resp := do(t, http.MethodGet, ts.URL+"/healthz", "", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("healthz should be open, got %d", resp.StatusCode)
}
resp.Body.Close()
}
func TestPutSubnetValidation(t *testing.T) {
ts := newTestServer(t)
defer ts.Close()
resp := do(t, http.MethodPut, ts.URL+"/api/v1/subnets/bad", testToken, SubnetAPI{})
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("expected 400 for missing subnet, got %d", resp.StatusCode)
}
resp.Body.Close()
}
func TestClientClassCRUD(t *testing.T) {
ts := newTestServer(t)
defer ts.Close()
base := ts.URL + "/api/v1/clientclasses/UEFI-64"
resp := do(t, http.MethodPut, base, testToken, ClientClassAPI{
ArchHex: []string{"0x0007", "0x0009"}, BootFileName: "/ipxe.efi",
})
if resp.StatusCode != http.StatusOK {
t.Fatalf("PUT class: got %d", resp.StatusCode)
}
resp.Body.Close()
resp = do(t, http.MethodPut, ts.URL+"/api/v1/clientclasses/empty", testToken, ClientClassAPI{})
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("expected 400 for class with no match, got %d", resp.StatusCode)
}
resp.Body.Close()
}
+122
View File
@@ -0,0 +1,122 @@
package keaapi
import (
"context"
"errors"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
v1alpha1 "git.unkin.net/unkin/kea-operator/api/v1alpha1"
)
// ErrNotFound is the sentinel the HTTP layer maps to 404.
var ErrNotFound = errors.New("not found")
// Store is the persistence seam. The HTTP handlers depend only on this; the
// k8s implementation below CRUDs CRs, but it could be swapped for any backend.
type Store interface {
UpsertSubnet(ctx context.Context, a SubnetAPI) (SubnetAPI, error)
GetSubnet(ctx context.Context, name string) (SubnetAPI, error)
ListSubnets(ctx context.Context) ([]SubnetAPI, error)
DeleteSubnet(ctx context.Context, name string) error
UpsertClass(ctx context.Context, a ClientClassAPI) (ClientClassAPI, error)
GetClass(ctx context.Context, name string) (ClientClassAPI, error)
ListClasses(ctx context.Context) ([]ClientClassAPI, error)
DeleteClass(ctx context.Context, name string) error
}
// K8sStore backs the API with KeaSubnet / KeaClientClass CRs in a namespace.
type K8sStore struct {
Client client.Client
Namespace string
}
func (s *K8sStore) key(name string) types.NamespacedName {
return types.NamespacedName{Namespace: s.Namespace, Name: name}
}
func (s *K8sStore) UpsertSubnet(ctx context.Context, a SubnetAPI) (SubnetAPI, error) {
obj := &v1alpha1.KeaSubnet{ObjectMeta: metav1.ObjectMeta{Name: a.Name, Namespace: s.Namespace}}
if _, err := controllerutil.CreateOrUpdate(ctx, s.Client, obj, func() error {
obj.Spec = subnetSpecFromAPI(a)
return nil
}); err != nil {
return SubnetAPI{}, err
}
return subnetToAPI(obj), nil
}
func (s *K8sStore) GetSubnet(ctx context.Context, name string) (SubnetAPI, error) {
var obj v1alpha1.KeaSubnet
if err := s.Client.Get(ctx, s.key(name), &obj); err != nil {
return SubnetAPI{}, mapGet(err)
}
return subnetToAPI(&obj), nil
}
func (s *K8sStore) ListSubnets(ctx context.Context) ([]SubnetAPI, error) {
var list v1alpha1.KeaSubnetList
if err := s.Client.List(ctx, &list, client.InNamespace(s.Namespace)); err != nil {
return nil, err
}
out := make([]SubnetAPI, 0, len(list.Items))
for i := range list.Items {
out = append(out, subnetToAPI(&list.Items[i]))
}
return out, nil
}
func (s *K8sStore) DeleteSubnet(ctx context.Context, name string) error {
obj := &v1alpha1.KeaSubnet{ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: s.Namespace}}
return mapGet(s.Client.Delete(ctx, obj))
}
func (s *K8sStore) UpsertClass(ctx context.Context, a ClientClassAPI) (ClientClassAPI, error) {
obj := &v1alpha1.KeaClientClass{ObjectMeta: metav1.ObjectMeta{Name: a.Name, Namespace: s.Namespace}}
if _, err := controllerutil.CreateOrUpdate(ctx, s.Client, obj, func() error {
obj.Spec = classSpecFromAPI(a)
return nil
}); err != nil {
return ClientClassAPI{}, err
}
return classToAPI(obj), nil
}
func (s *K8sStore) GetClass(ctx context.Context, name string) (ClientClassAPI, error) {
var obj v1alpha1.KeaClientClass
if err := s.Client.Get(ctx, s.key(name), &obj); err != nil {
return ClientClassAPI{}, mapGet(err)
}
return classToAPI(&obj), nil
}
func (s *K8sStore) ListClasses(ctx context.Context) ([]ClientClassAPI, error) {
var list v1alpha1.KeaClientClassList
if err := s.Client.List(ctx, &list, client.InNamespace(s.Namespace)); err != nil {
return nil, err
}
out := make([]ClientClassAPI, 0, len(list.Items))
for i := range list.Items {
out = append(out, classToAPI(&list.Items[i]))
}
return out, nil
}
func (s *K8sStore) DeleteClass(ctx context.Context, name string) error {
obj := &v1alpha1.KeaClientClass{ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: s.Namespace}}
return mapGet(s.Client.Delete(ctx, obj))
}
func mapGet(err error) error {
if apierrors.IsNotFound(err) {
return ErrNotFound
}
return err
}
var _ Store = (*K8sStore)(nil)