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
This commit is contained in:
@@ -0,0 +1,223 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
)
|
||||
|
||||
// requireToken enforces a static bearer token on mutating endpoints. The token
|
||||
// is accepted either as "Authorization: Bearer <token>" or a bare "token"
|
||||
// header. When no server token is configured, all writes are refused.
|
||||
func (s *Server) requireToken(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if s.writeToken == "" {
|
||||
writeError(w, http.StatusServiceUnavailable, "writes disabled: ENCAPI_WRITE_TOKEN not set")
|
||||
return
|
||||
}
|
||||
presented := bearer(r)
|
||||
if presented == "" || subtle.ConstantTimeCompare([]byte(presented), []byte(s.writeToken)) != 1 {
|
||||
writeError(w, http.StatusUnauthorized, "invalid or missing write 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")
|
||||
}
|
||||
|
||||
func structuredLogger(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
|
||||
defer func() {
|
||||
slog.Info("request",
|
||||
"method", r.Method,
|
||||
"path", r.URL.Path,
|
||||
"status", ww.Status(),
|
||||
"duration_ms", time.Since(start).Milliseconds(),
|
||||
"remote", r.RemoteAddr,
|
||||
"request_id", middleware.GetReqID(r.Context()),
|
||||
)
|
||||
}()
|
||||
next.ServeHTTP(ww, r)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// Package server exposes encapi over HTTP: open read endpoints (including the
|
||||
// two ENC document shapes Puppet consumes) and token-guarded write endpoints.
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
|
||||
"git.unkin.net/unkin/encapi/internal/distro"
|
||||
"git.unkin.net/unkin/encapi/pkg/models"
|
||||
)
|
||||
|
||||
// Store is the persistence surface the HTTP handlers depend on. *database.DB
|
||||
// satisfies it; tests supply a fake.
|
||||
type Store interface {
|
||||
UpsertRole(ctx context.Context, r *models.Role) error
|
||||
GetRole(ctx context.Context, name string) (*models.Role, error)
|
||||
ListRoles(ctx context.Context) ([]models.Role, error)
|
||||
DeleteRole(ctx context.Context, name string) error
|
||||
|
||||
UpsertStatus(ctx context.Context, s *models.Status) error
|
||||
GetStatus(ctx context.Context, name string) (*models.Status, error)
|
||||
ListStatuses(ctx context.Context) ([]models.Status, error)
|
||||
DeleteStatus(ctx context.Context, name string) error
|
||||
|
||||
UpsertNode(ctx context.Context, n *models.Node) error
|
||||
GetNode(ctx context.Context, certname string) (*models.Node, error)
|
||||
ListNodes(ctx context.Context) ([]models.Node, error)
|
||||
DeleteNode(ctx context.Context, certname string) error
|
||||
}
|
||||
|
||||
// Server holds handler dependencies.
|
||||
type Server struct {
|
||||
store Store
|
||||
resolver distro.Resolver
|
||||
writeToken string
|
||||
}
|
||||
|
||||
// New builds a Server. writeToken guards mutating endpoints; an empty token
|
||||
// fails all writes closed.
|
||||
func New(store Store, resolver distro.Resolver, writeToken string) *Server {
|
||||
if resolver == nil {
|
||||
resolver = distro.Noop{}
|
||||
}
|
||||
return &Server{store: store, resolver: resolver, writeToken: writeToken}
|
||||
}
|
||||
|
||||
// Router returns the fully-wired HTTP handler.
|
||||
func (s *Server) Router() http.Handler {
|
||||
r := chi.NewRouter()
|
||||
r.Use(middleware.RequestID)
|
||||
r.Use(middleware.Recoverer)
|
||||
r.Use(structuredLogger)
|
||||
|
||||
r.Get("/healthz", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ok\n"))
|
||||
})
|
||||
|
||||
// --- ENC documents Puppet consumes (open) ---
|
||||
r.Get("/api/v1/nodes/{certname}/enc", s.handleENCFinal)
|
||||
r.Get("/cblr/svc/op/puppet/hostname/{certname}", s.handleENCCobbler)
|
||||
|
||||
// --- JSON reads (open) ---
|
||||
r.Get("/api/v1/roles", s.listRoles)
|
||||
r.Get("/api/v1/roles/{name}", s.getRole)
|
||||
r.Get("/api/v1/statuses", s.listStatuses)
|
||||
r.Get("/api/v1/statuses/{name}", s.getStatus)
|
||||
r.Get("/api/v1/nodes", s.listNodes)
|
||||
r.Get("/api/v1/nodes/{certname}", s.getNode)
|
||||
|
||||
// --- writes (token-guarded) ---
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(s.requireToken)
|
||||
r.Put("/api/v1/roles/{name}", s.putRole)
|
||||
r.Delete("/api/v1/roles/{name}", s.deleteRole)
|
||||
r.Put("/api/v1/statuses/{name}", s.putStatus)
|
||||
r.Delete("/api/v1/statuses/{name}", s.deleteStatus)
|
||||
r.Put("/api/v1/nodes/{certname}", s.putNode)
|
||||
r.Delete("/api/v1/nodes/{certname}", s.deleteNode)
|
||||
})
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
// ListenAndServe runs the HTTP server until ctx is cancelled.
|
||||
func (s *Server) ListenAndServe(ctx context.Context, addr string) error {
|
||||
srv := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: s.Router(),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
_ = srv.Shutdown(shutdownCtx)
|
||||
}()
|
||||
slog.Info("encapi listening", "addr", addr)
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"git.unkin.net/unkin/encapi/internal/database"
|
||||
"git.unkin.net/unkin/encapi/pkg/models"
|
||||
)
|
||||
|
||||
// fakeStore is an in-memory Store for handler tests.
|
||||
type fakeStore struct {
|
||||
roles map[string]models.Role
|
||||
statuses map[string]models.Status
|
||||
nodes map[string]models.Node
|
||||
}
|
||||
|
||||
func newFake() *fakeStore {
|
||||
return &fakeStore{
|
||||
roles: map[string]models.Role{},
|
||||
statuses: map[string]models.Status{},
|
||||
nodes: map[string]models.Node{},
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeStore) UpsertRole(_ context.Context, r *models.Role) error {
|
||||
f.roles[r.Name] = *r
|
||||
return nil
|
||||
}
|
||||
func (f *fakeStore) GetRole(_ context.Context, name string) (*models.Role, error) {
|
||||
r, ok := f.roles[name]
|
||||
if !ok {
|
||||
return nil, database.ErrNotFound
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
func (f *fakeStore) ListRoles(context.Context) ([]models.Role, error) {
|
||||
out := []models.Role{}
|
||||
for _, r := range f.roles {
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func (f *fakeStore) DeleteRole(_ context.Context, name string) error {
|
||||
if _, ok := f.roles[name]; !ok {
|
||||
return database.ErrNotFound
|
||||
}
|
||||
delete(f.roles, name)
|
||||
return nil
|
||||
}
|
||||
func (f *fakeStore) UpsertStatus(_ context.Context, s *models.Status) error {
|
||||
f.statuses[s.Name] = *s
|
||||
return nil
|
||||
}
|
||||
func (f *fakeStore) GetStatus(_ context.Context, name string) (*models.Status, error) {
|
||||
s, ok := f.statuses[name]
|
||||
if !ok {
|
||||
return nil, database.ErrNotFound
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
func (f *fakeStore) ListStatuses(context.Context) ([]models.Status, error) {
|
||||
out := []models.Status{}
|
||||
for _, s := range f.statuses {
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func (f *fakeStore) DeleteStatus(_ context.Context, name string) error {
|
||||
if _, ok := f.statuses[name]; !ok {
|
||||
return database.ErrNotFound
|
||||
}
|
||||
delete(f.statuses, name)
|
||||
return nil
|
||||
}
|
||||
func (f *fakeStore) UpsertNode(_ context.Context, n *models.Node) error {
|
||||
f.nodes[n.Certname] = *n
|
||||
return nil
|
||||
}
|
||||
func (f *fakeStore) GetNode(_ context.Context, certname string) (*models.Node, error) {
|
||||
n, ok := f.nodes[certname]
|
||||
if !ok {
|
||||
return nil, database.ErrNotFound
|
||||
}
|
||||
return &n, nil
|
||||
}
|
||||
func (f *fakeStore) ListNodes(context.Context) ([]models.Node, error) {
|
||||
out := []models.Node{}
|
||||
for _, n := range f.nodes {
|
||||
out = append(out, n)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func (f *fakeStore) DeleteNode(_ context.Context, certname string) error {
|
||||
if _, ok := f.nodes[certname]; !ok {
|
||||
return database.ErrNotFound
|
||||
}
|
||||
delete(f.nodes, certname)
|
||||
return nil
|
||||
}
|
||||
|
||||
func testServer() (*httptest.Server, *fakeStore) {
|
||||
f := newFake()
|
||||
f.statuses["testing"] = models.Status{Name: "testing"}
|
||||
f.statuses["production"] = models.Status{Name: "production"}
|
||||
f.roles["roles::infra::storage::vault"] = models.Role{Name: "roles::infra::storage::vault"}
|
||||
f.nodes["h1.example"] = models.Node{Certname: "h1.example", Role: "roles::infra::storage::vault", Environment: "testing"}
|
||||
srv := New(f, nil, "s3cret")
|
||||
return httptest.NewServer(srv.Router()), f
|
||||
}
|
||||
|
||||
func TestReadsAreOpen(t *testing.T) {
|
||||
ts, _ := testServer()
|
||||
defer ts.Close()
|
||||
for _, path := range []string{"/healthz", "/api/v1/roles", "/api/v1/nodes", "/api/v1/statuses", "/api/v1/nodes/h1.example"} {
|
||||
resp, err := http.Get(ts.URL + path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("GET %s = %d, want 200", path, resp.StatusCode)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteRequiresToken(t *testing.T) {
|
||||
ts, _ := testServer()
|
||||
defer ts.Close()
|
||||
|
||||
req, _ := http.NewRequest(http.MethodPut, ts.URL+"/api/v1/roles/roles::x", strings.NewReader(`{}`))
|
||||
resp, _ := http.DefaultClient.Do(req)
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("no token = %d, want 401", resp.StatusCode)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
req, _ = http.NewRequest(http.MethodPut, ts.URL+"/api/v1/roles/roles::x", strings.NewReader(`{"description":"d"}`))
|
||||
req.Header.Set("Authorization", "Bearer s3cret")
|
||||
resp, _ = http.DefaultClient.Do(req)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("with token = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
func TestWriteBareTokenHeader(t *testing.T) {
|
||||
ts, _ := testServer()
|
||||
defer ts.Close()
|
||||
req, _ := http.NewRequest(http.MethodPut, ts.URL+"/api/v1/statuses/dev", strings.NewReader(`{"description":"d"}`))
|
||||
req.Header.Set("token", "s3cret")
|
||||
resp, _ := http.DefaultClient.Do(req)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("bare token header = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
func TestWritesDisabledWithoutServerToken(t *testing.T) {
|
||||
f := newFake()
|
||||
srv := New(f, nil, "") // no token configured
|
||||
ts := httptest.NewServer(srv.Router())
|
||||
defer ts.Close()
|
||||
req, _ := http.NewRequest(http.MethodPut, ts.URL+"/api/v1/roles/r", strings.NewReader(`{}`))
|
||||
req.Header.Set("Authorization", "Bearer anything")
|
||||
resp, _ := http.DefaultClient.Do(req)
|
||||
if resp.StatusCode != http.StatusServiceUnavailable {
|
||||
t.Fatalf("= %d, want 503", resp.StatusCode)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
func TestENCFinalEndpoint(t *testing.T) {
|
||||
ts, _ := testServer()
|
||||
defer ts.Close()
|
||||
resp, err := http.Get(ts.URL + "/api/v1/nodes/h1.example/enc")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var doc map[string]any
|
||||
if err := yaml.NewDecoder(resp.Body).Decode(&doc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := doc["environment"]; ok {
|
||||
t.Error("testing environment must be dropped in final ENC")
|
||||
}
|
||||
classes := doc["classes"].([]any)
|
||||
if classes[0] != "roles::infra::storage::vault" {
|
||||
t.Errorf("classes = %#v", classes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestENCCobblerEndpoint(t *testing.T) {
|
||||
ts, _ := testServer()
|
||||
defer ts.Close()
|
||||
resp, err := http.Get(ts.URL + "/cblr/svc/op/puppet/hostname/h1.example")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var doc map[string]any
|
||||
if err := yaml.NewDecoder(resp.Body).Decode(&doc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if doc["environment"] != "testing" {
|
||||
t.Errorf("cobbler form must keep environment, got %v", doc["environment"])
|
||||
}
|
||||
if _, ok := doc["classes"].(map[string]any); !ok {
|
||||
t.Errorf("cobbler classes must be a map, got %#v", doc["classes"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestENCUnknownNode404(t *testing.T) {
|
||||
ts, _ := testServer()
|
||||
defer ts.Close()
|
||||
resp, _ := http.Get(ts.URL + "/api/v1/nodes/ghost/enc")
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("= %d, want 404", resp.StatusCode)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
func TestPutNodeValidation(t *testing.T) {
|
||||
ts, _ := testServer()
|
||||
defer ts.Close()
|
||||
// missing role/environment
|
||||
req, _ := http.NewRequest(http.MethodPut, ts.URL+"/api/v1/nodes/h2", strings.NewReader(`{}`))
|
||||
req.Header.Set("Authorization", "Bearer s3cret")
|
||||
resp, _ := http.DefaultClient.Do(req)
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("= %d, want 400", resp.StatusCode)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
func TestPutAndGetNodeRoundTrip(t *testing.T) {
|
||||
ts, _ := testServer()
|
||||
defer ts.Close()
|
||||
body := `{"role":"roles::infra::storage::vault","environment":"production","params":{"k":"v"}}`
|
||||
req, _ := http.NewRequest(http.MethodPut, ts.URL+"/api/v1/nodes/h9", strings.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer s3cret")
|
||||
resp, _ := http.DefaultClient.Do(req)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("put = %d", resp.StatusCode)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
resp, err := http.Get(ts.URL + "/api/v1/nodes/h9")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var n models.Node
|
||||
_ = json.NewDecoder(resp.Body).Decode(&n)
|
||||
if n.Role != "roles::infra::storage::vault" || n.Environment != "production" || n.Params["k"] != "v" {
|
||||
t.Errorf("round trip node = %+v", n)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user