d3fb5dcd1a
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
254 lines
8.7 KiB
Go
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)
|
|
}
|