Add Postgres storage layer and CRUD handlers

Add the store package (pgx-backed repository) with CRUD for the core resources
the compiler needs: fabrics, zones, address groups, portgroups, devices,
bindings, rules, policies. Every mutation bumps a global config generation.
Wire real JSON CRUD handlers with token auth, source/dest grammar validation on
rule create, and the agent status-report endpoint. Migration 0001 verified
against Postgres 17.
This commit is contained in:
benvin
2026-07-19 18:28:12 +10:00
parent aa5626e998
commit c32fe8bd76
4 changed files with 634 additions and 36 deletions
+233 -36
View File
@@ -1,57 +1,254 @@
package server
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"git.unkin.net/unkin/tomswallapi/internal/model"
"git.unkin.net/unkin/tomswallapi/internal/store"
)
// mountResources wires the Terraform-facing CRUD endpoints for every resource in
// the model. Handlers are stubbed pending the storage layer (see task: domain
// model + Postgres storage).
// mountResources wires the Terraform-facing CRUD endpoints. Resources with a
// dedicated repository method are wired here; the long-tail per-device sections
// (providers, tc, etc.) are added as their storage lands.
func (s *Server) mountResources(r chi.Router) {
for _, name := range resourceCollections {
r.Route("/"+name, func(r chi.Router) {
r.Get("/", s.notImplemented)
r.Post("/", s.notImplemented)
r.Get("/{id}", s.notImplemented)
r.Put("/{id}", s.notImplemented)
r.Delete("/{id}", s.notImplemented)
})
}
r.Get("/generation", s.handleGeneration)
r.Route("/fabrics", func(r chi.Router) {
r.Get("/", s.listFabrics)
r.Put("/{name}", s.putFabric)
})
r.Route("/zones", func(r chi.Router) {
r.Get("/", s.listZones)
r.Put("/{name}", s.putZone)
})
r.Route("/address-groups", func(r chi.Router) {
r.Get("/", s.listAddressGroups)
r.Put("/{name}", s.putAddressGroup)
})
r.Route("/devices", func(r chi.Router) {
r.Get("/", s.listDevices)
r.Put("/{name}", s.putDevice)
r.Get("/{name}/bindings", s.listBindings)
r.Put("/{name}/bindings/{zone}", s.putBinding)
})
r.Route("/rules", func(r chi.Router) {
r.Get("/", s.listRules)
r.Post("/", s.createRule)
r.Delete("/{id}", s.deleteRule)
})
}
// resourceCollections are the REST collection paths exposed to Terraform, one per
// model resource.
var resourceCollections = []string{
"fabrics",
"zones",
"address-groups",
"portgroups",
"policies",
"rules",
"devices",
"bindings",
"snat",
"netmap",
"nat",
"blrules",
"conntrack",
"hosts",
"providers",
"routes",
"routing-rules",
"tunnels",
func (s *Server) handleGeneration(w http.ResponseWriter, r *http.Request) {
g, err := s.store.Generation(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]int64{"generation": g})
}
// ---- Fabrics ---------------------------------------------------------------
func (s *Server) listFabrics(w http.ResponseWriter, r *http.Request) {
list, err := s.store.ListFabrics(r.Context())
respondList(w, list, err)
}
func (s *Server) putFabric(w http.ResponseWriter, r *http.Request) {
var f model.Fabric
if !decode(w, r, &f) {
return
}
f.Name = chi.URLParam(r, "name")
if err := s.store.UpsertFabric(r.Context(), f); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, f)
}
// ---- Zones -----------------------------------------------------------------
func (s *Server) listZones(w http.ResponseWriter, r *http.Request) {
list, err := s.store.ListZones(r.Context())
respondList(w, list, err)
}
func (s *Server) putZone(w http.ResponseWriter, r *http.Request) {
var z model.Zone
if !decode(w, r, &z) {
return
}
z.Name = chi.URLParam(r, "name")
if err := s.store.UpsertZone(r.Context(), z); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, z)
}
// ---- Address groups --------------------------------------------------------
func (s *Server) listAddressGroups(w http.ResponseWriter, r *http.Request) {
list, err := s.store.ListAddressGroups(r.Context())
respondList(w, list, err)
}
func (s *Server) putAddressGroup(w http.ResponseWriter, r *http.Request) {
var g model.AddressGroup
if !decode(w, r, &g) {
return
}
g.Name = chi.URLParam(r, "name")
if g.Type != model.GroupStatic && g.Type != model.GroupDNS && g.Type != model.GroupASN {
writeError(w, http.StatusBadRequest, "type must be one of: static, dns, asn")
return
}
if err := s.store.UpsertAddressGroup(r.Context(), g); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, g)
}
// ---- Devices & bindings ----------------------------------------------------
func (s *Server) listDevices(w http.ResponseWriter, r *http.Request) {
list, err := s.store.ListDevices(r.Context())
respondList(w, list, err)
}
func (s *Server) putDevice(w http.ResponseWriter, r *http.Request) {
var d model.Device
if !decode(w, r, &d) {
return
}
d.Name = chi.URLParam(r, "name")
if d.Class != model.ClassRouter && d.Class != model.ClassFirewall {
writeError(w, http.StatusBadRequest, "class must be one of: router, firewall")
return
}
if err := s.store.UpsertDevice(r.Context(), d); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, d)
}
func (s *Server) listBindings(w http.ResponseWriter, r *http.Request) {
list, err := s.store.ListBindings(r.Context(), chi.URLParam(r, "name"))
respondList(w, list, err)
}
func (s *Server) putBinding(w http.ResponseWriter, r *http.Request) {
var b model.Binding
if !decode(w, r, &b) {
return
}
b.Device = chi.URLParam(r, "name")
b.Zone = chi.URLParam(r, "zone")
if err := s.store.UpsertBinding(r.Context(), b); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, b)
}
// ---- Rules -----------------------------------------------------------------
func (s *Server) listRules(w http.ResponseWriter, r *http.Request) {
list, err := s.store.ListRules(r.Context())
respondList(w, list, err)
}
func (s *Server) createRule(w http.ResponseWriter, r *http.Request) {
var rule model.Rule
if !decode(w, r, &rule) {
return
}
id, err := s.store.CreateRule(r.Context(), rule)
if err != nil {
// Grammar/validation failures are client errors.
writeError(w, http.StatusBadRequest, err.Error())
return
}
rule.ID = id
writeJSON(w, http.StatusCreated, rule)
}
func (s *Server) deleteRule(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "id must be an integer")
return
}
if err := s.store.DeleteRule(r.Context(), id); err != nil {
if errors.Is(err, store.ErrNotFound) {
writeError(w, http.StatusNotFound, "rule not found")
return
}
writeError(w, http.StatusInternalServerError, err.Error())
return
}
w.WriteHeader(http.StatusNoContent)
}
// ---- Agent endpoints -------------------------------------------------------
func (s *Server) handleDeviceConfig(w http.ResponseWriter, r *http.Request) {
s.notImplemented(w, r)
// Rendering is implemented by the compiler (see task: compiler + agent endpoint).
notImplemented(w)
}
func (s *Server) handleDeviceStatus(w http.ResponseWriter, r *http.Request) {
s.notImplemented(w, r)
var body struct {
Generation int64 `json:"generation"`
}
if !decode(w, r, &body) {
return
}
if err := s.store.RecordDeviceStatus(r.Context(), chi.URLParam(r, "name"), body.Generation); err != nil {
if errors.Is(err, store.ErrNotFound) {
writeError(w, http.StatusNotFound, "device not found")
return
}
writeError(w, http.StatusInternalServerError, err.Error())
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) notImplemented(w http.ResponseWriter, _ *http.Request) {
// ---- helpers ---------------------------------------------------------------
// decode reads a JSON request body into v, writing a 400 on failure. It returns
// false when the caller should stop.
func decode(w http.ResponseWriter, r *http.Request, v any) bool {
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(v); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return false
}
return true
}
// respondList writes a list result or a 500, normalizing a nil slice to [].
func respondList[T any](w http.ResponseWriter, list []T, err error) {
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if list == nil {
list = []T{}
}
writeJSON(w, http.StatusOK, list)
}
func notImplemented(w http.ResponseWriter) {
writeError(w, http.StatusNotImplemented, "not implemented yet")
}