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
179 lines
4.8 KiB
Go
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()
|
|
}
|