373d21a744
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
224 lines
5.9 KiB
Go
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)
|
|
}
|