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,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),
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user