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