Files
encapi/internal/server/handlers.go
T
unkinben 373d21a744 initial implementation: encapi ENC server + CLI
Postgres-backed External Node Classifier for Puppet, replacing Cobbler.
- encapi HTTP server (chi + pgx): read/write API + two ENC document shapes
  (reshaped for the exec terminus; cobbler-wire for enc_direct_facts.rb)
- encapi-cli: classify/node/role/status CRUD + import-cobbler seeder
- pkg/client Go SDK; unit tests across all packages (DB via testcontainers)
- Dockerfile (distroless), Makefile, nfpm RPM (encapi-cli + encapi-enc wrapper),
  Woodpecker CI, docs/cutover.md
2026-07-04 23:45:15 +10:00

224 lines
5.9 KiB
Go

package server
import (
"encoding/json"
"errors"
"net/http"
"github.com/go-chi/chi/v5"
"git.unkin.net/unkin/encapi/internal/database"
"git.unkin.net/unkin/encapi/internal/enc"
"git.unkin.net/unkin/encapi/pkg/models"
)
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})
}
func writeYAML(w http.ResponseWriter, b []byte) {
w.Header().Set("Content-Type", "application/x-yaml")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(b)
}
// mapErr translates store errors into HTTP status codes.
func mapErr(w http.ResponseWriter, err error) {
if errors.Is(err, database.ErrNotFound) {
writeError(w, http.StatusNotFound, "not found")
return
}
writeError(w, http.StatusInternalServerError, err.Error())
}
// ---------- ENC ----------
// resolveNode loads a node, its role, and any distro params.
func (s *Server) resolveNode(w http.ResponseWriter, r *http.Request) (models.Node, models.Role, map[string]any, bool) {
certname := chi.URLParam(r, "certname")
node, err := s.store.GetNode(r.Context(), certname)
if err != nil {
mapErr(w, err)
return models.Node{}, models.Role{}, nil, false
}
role, err := s.store.GetRole(r.Context(), node.Role)
if err != nil {
if errors.Is(err, database.ErrNotFound) {
// A node pinned to a role that no longer exists: still classify it,
// just without default params, rather than 500.
role = &models.Role{Name: node.Role}
} else {
mapErr(w, err)
return models.Node{}, models.Role{}, nil, false
}
}
distroParams, err := s.resolver.Resolve(r.Context(), certname)
if err != nil {
writeError(w, http.StatusBadGateway, "distro resolver: "+err.Error())
return models.Node{}, models.Role{}, nil, false
}
return *node, *role, distroParams, true
}
func (s *Server) handleENCFinal(w http.ResponseWriter, r *http.Request) {
node, role, distroParams, ok := s.resolveNode(w, r)
if !ok {
return
}
out, err := enc.Final(node, role, distroParams)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeYAML(w, out)
}
func (s *Server) handleENCCobbler(w http.ResponseWriter, r *http.Request) {
node, role, distroParams, ok := s.resolveNode(w, r)
if !ok {
return
}
out, err := enc.Cobbler(node, role, distroParams)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeYAML(w, out)
}
// ---------- roles ----------
func (s *Server) listRoles(w http.ResponseWriter, r *http.Request) {
roles, err := s.store.ListRoles(r.Context())
if err != nil {
mapErr(w, err)
return
}
writeJSON(w, http.StatusOK, roles)
}
func (s *Server) getRole(w http.ResponseWriter, r *http.Request) {
role, err := s.store.GetRole(r.Context(), chi.URLParam(r, "name"))
if err != nil {
mapErr(w, err)
return
}
writeJSON(w, http.StatusOK, role)
}
func (s *Server) putRole(w http.ResponseWriter, r *http.Request) {
var role models.Role
if err := json.NewDecoder(r.Body).Decode(&role); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return
}
role.Name = chi.URLParam(r, "name")
if err := s.store.UpsertRole(r.Context(), &role); err != nil {
mapErr(w, err)
return
}
writeJSON(w, http.StatusOK, role)
}
func (s *Server) deleteRole(w http.ResponseWriter, r *http.Request) {
if err := s.store.DeleteRole(r.Context(), chi.URLParam(r, "name")); err != nil {
mapErr(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// ---------- statuses ----------
func (s *Server) listStatuses(w http.ResponseWriter, r *http.Request) {
statuses, err := s.store.ListStatuses(r.Context())
if err != nil {
mapErr(w, err)
return
}
writeJSON(w, http.StatusOK, statuses)
}
func (s *Server) getStatus(w http.ResponseWriter, r *http.Request) {
status, err := s.store.GetStatus(r.Context(), chi.URLParam(r, "name"))
if err != nil {
mapErr(w, err)
return
}
writeJSON(w, http.StatusOK, status)
}
func (s *Server) putStatus(w http.ResponseWriter, r *http.Request) {
var status models.Status
if err := json.NewDecoder(r.Body).Decode(&status); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return
}
status.Name = chi.URLParam(r, "name")
if err := s.store.UpsertStatus(r.Context(), &status); err != nil {
mapErr(w, err)
return
}
writeJSON(w, http.StatusOK, status)
}
func (s *Server) deleteStatus(w http.ResponseWriter, r *http.Request) {
if err := s.store.DeleteStatus(r.Context(), chi.URLParam(r, "name")); err != nil {
mapErr(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// ---------- nodes ----------
func (s *Server) listNodes(w http.ResponseWriter, r *http.Request) {
nodes, err := s.store.ListNodes(r.Context())
if err != nil {
mapErr(w, err)
return
}
writeJSON(w, http.StatusOK, nodes)
}
func (s *Server) getNode(w http.ResponseWriter, r *http.Request) {
node, err := s.store.GetNode(r.Context(), chi.URLParam(r, "certname"))
if err != nil {
mapErr(w, err)
return
}
writeJSON(w, http.StatusOK, node)
}
func (s *Server) putNode(w http.ResponseWriter, r *http.Request) {
var node models.Node
if err := json.NewDecoder(r.Body).Decode(&node); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return
}
node.Certname = chi.URLParam(r, "certname")
if node.Role == "" || node.Environment == "" {
writeError(w, http.StatusBadRequest, "role and environment are required")
return
}
if err := s.store.UpsertNode(r.Context(), &node); err != nil {
mapErr(w, err)
return
}
writeJSON(w, http.StatusOK, node)
}
func (s *Server) deleteNode(w http.ResponseWriter, r *http.Request) {
if err := s.store.DeleteNode(r.Context(), chi.URLParam(r, "certname")); err != nil {
mapErr(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}