Files
unkinben d3fb5dcd1a
ci/woodpecker/pr/build Pipeline failed
ci/woodpecker/pr/pre-commit Pipeline failed
ci/woodpecker/pr/test Pipeline failed
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
2026-08-02 18:42:46 +10:00

254 lines
8.7 KiB
Go

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)
}