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
This commit is contained in:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user