Files
kea-operator/internal/keaapi/server_test.go
T
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

179 lines
4.8 KiB
Go

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