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,174 @@
|
||||
// Package client is a Go SDK for the encapi HTTP API. It is used by encapi-cli
|
||||
// and can be vendored by other Go callers (e.g. the Terraform provider).
|
||||
package client
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"git.unkin.net/unkin/encapi/pkg/models"
|
||||
)
|
||||
|
||||
// Client talks to an encapi server. Token is required only for writes.
|
||||
type Client struct {
|
||||
BaseURL string
|
||||
Token string
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
// New returns a Client for baseURL. Token may be empty for read-only use.
|
||||
func New(baseURL, token string) *Client {
|
||||
return &Client{
|
||||
BaseURL: baseURL,
|
||||
Token: token,
|
||||
HTTPClient: &http.Client{Timeout: 30 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// APIError is returned for non-2xx responses.
|
||||
type APIError struct {
|
||||
Status int
|
||||
Msg string
|
||||
}
|
||||
|
||||
func (e *APIError) Error() string { return fmt.Sprintf("encapi: HTTP %d: %s", e.Status, e.Msg) }
|
||||
|
||||
// NotFound reports whether err is a 404 from the API.
|
||||
func NotFound(err error) bool {
|
||||
var ae *APIError
|
||||
if e, ok := err.(*APIError); ok {
|
||||
ae = e
|
||||
}
|
||||
return ae != nil && ae.Status == http.StatusNotFound
|
||||
}
|
||||
|
||||
func (c *Client) do(ctx context.Context, method, path string, body, out any) error {
|
||||
var reader io.Reader
|
||||
if body != nil {
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reader = bytes.NewReader(b)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, c.BaseURL+path, reader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if c.Token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.Token)
|
||||
}
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 300 {
|
||||
msg := decodeError(resp.Body)
|
||||
return &APIError{Status: resp.StatusCode, Msg: msg}
|
||||
}
|
||||
if out != nil && resp.StatusCode != http.StatusNoContent {
|
||||
return json.NewDecoder(resp.Body).Decode(out)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodeError(r io.Reader) string {
|
||||
var e struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if json.NewDecoder(r).Decode(&e) == nil && e.Error != "" {
|
||||
return e.Error
|
||||
}
|
||||
return "request failed"
|
||||
}
|
||||
|
||||
// ENC fetches the reshaped ENC document (YAML) Puppet's exec terminus consumes.
|
||||
func (c *Client) ENC(ctx context.Context, certname string) ([]byte, error) {
|
||||
return c.getRaw(ctx, "/api/v1/nodes/"+url.PathEscape(certname)+"/enc")
|
||||
}
|
||||
|
||||
// ENCCobbler fetches the cobbler-wire-compatible ENC document (YAML).
|
||||
func (c *Client) ENCCobbler(ctx context.Context, certname string) ([]byte, error) {
|
||||
return c.getRaw(ctx, "/cblr/svc/op/puppet/hostname/"+url.PathEscape(certname))
|
||||
}
|
||||
|
||||
func (c *Client) getRaw(ctx context.Context, path string) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+path, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
return nil, &APIError{Status: resp.StatusCode, Msg: decodeError(resp.Body)}
|
||||
}
|
||||
return io.ReadAll(resp.Body)
|
||||
}
|
||||
|
||||
// --- roles ---
|
||||
|
||||
func (c *Client) ListRoles(ctx context.Context) ([]models.Role, error) {
|
||||
var out []models.Role
|
||||
return out, c.do(ctx, http.MethodGet, "/api/v1/roles", nil, &out)
|
||||
}
|
||||
func (c *Client) GetRole(ctx context.Context, name string) (*models.Role, error) {
|
||||
var out models.Role
|
||||
return &out, c.do(ctx, http.MethodGet, "/api/v1/roles/"+url.PathEscape(name), nil, &out)
|
||||
}
|
||||
func (c *Client) PutRole(ctx context.Context, r *models.Role) (*models.Role, error) {
|
||||
var out models.Role
|
||||
return &out, c.do(ctx, http.MethodPut, "/api/v1/roles/"+url.PathEscape(r.Name), r, &out)
|
||||
}
|
||||
func (c *Client) DeleteRole(ctx context.Context, name string) error {
|
||||
return c.do(ctx, http.MethodDelete, "/api/v1/roles/"+url.PathEscape(name), nil, nil)
|
||||
}
|
||||
|
||||
// --- statuses ---
|
||||
|
||||
func (c *Client) ListStatuses(ctx context.Context) ([]models.Status, error) {
|
||||
var out []models.Status
|
||||
return out, c.do(ctx, http.MethodGet, "/api/v1/statuses", nil, &out)
|
||||
}
|
||||
func (c *Client) GetStatus(ctx context.Context, name string) (*models.Status, error) {
|
||||
var out models.Status
|
||||
return &out, c.do(ctx, http.MethodGet, "/api/v1/statuses/"+url.PathEscape(name), nil, &out)
|
||||
}
|
||||
func (c *Client) PutStatus(ctx context.Context, s *models.Status) (*models.Status, error) {
|
||||
var out models.Status
|
||||
return &out, c.do(ctx, http.MethodPut, "/api/v1/statuses/"+url.PathEscape(s.Name), s, &out)
|
||||
}
|
||||
func (c *Client) DeleteStatus(ctx context.Context, name string) error {
|
||||
return c.do(ctx, http.MethodDelete, "/api/v1/statuses/"+url.PathEscape(name), nil, nil)
|
||||
}
|
||||
|
||||
// --- nodes ---
|
||||
|
||||
func (c *Client) ListNodes(ctx context.Context) ([]models.Node, error) {
|
||||
var out []models.Node
|
||||
return out, c.do(ctx, http.MethodGet, "/api/v1/nodes", nil, &out)
|
||||
}
|
||||
func (c *Client) GetNode(ctx context.Context, certname string) (*models.Node, error) {
|
||||
var out models.Node
|
||||
return &out, c.do(ctx, http.MethodGet, "/api/v1/nodes/"+url.PathEscape(certname), nil, &out)
|
||||
}
|
||||
func (c *Client) PutNode(ctx context.Context, n *models.Node) (*models.Node, error) {
|
||||
var out models.Node
|
||||
return &out, c.do(ctx, http.MethodPut, "/api/v1/nodes/"+url.PathEscape(n.Certname), n, &out)
|
||||
}
|
||||
func (c *Client) DeleteNode(ctx context.Context, certname string) error {
|
||||
return c.do(ctx, http.MethodDelete, "/api/v1/nodes/"+url.PathEscape(certname), nil, nil)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"git.unkin.net/unkin/encapi/pkg/models"
|
||||
)
|
||||
|
||||
func TestPutNodeSendsTokenAndBody(t *testing.T) {
|
||||
var gotAuth, gotMethod, gotPath string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
gotMethod = r.Method
|
||||
gotPath = r.URL.Path
|
||||
_, _ = w.Write([]byte(`{"certname":"h1","role":"roles::base","environment":"testing"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := New(srv.URL, "tok")
|
||||
n, err := c.PutNode(context.Background(), &models.Node{Certname: "h1", Role: "roles::base", Environment: "testing"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gotAuth != "Bearer tok" {
|
||||
t.Errorf("auth = %q", gotAuth)
|
||||
}
|
||||
if gotMethod != http.MethodPut || gotPath != "/api/v1/nodes/h1" {
|
||||
t.Errorf("%s %s", gotMethod, gotPath)
|
||||
}
|
||||
if n.Role != "roles::base" {
|
||||
t.Errorf("node = %+v", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRoleEscapesColons(t *testing.T) {
|
||||
var gotPath string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.EscapedPath()
|
||||
_, _ = w.Write([]byte(`{"name":"roles::infra::x"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
if _, err := New(srv.URL, "").GetRole(context.Background(), "roles::infra::x"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gotPath != "/api/v1/roles/roles::infra::x" {
|
||||
t.Errorf("path = %q", gotPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorMapping(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = w.Write([]byte(`{"error":"not found"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
_, err := New(srv.URL, "").GetNode(context.Background(), "ghost")
|
||||
if err == nil || !NotFound(err) {
|
||||
t.Fatalf("err = %v, want NotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestENCReturnsRawYAML(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte("classes:\n- roles::base\n"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
b, err := New(srv.URL, "").ENC(context.Background(), "h1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(b) != "classes:\n- roles::base\n" {
|
||||
t.Errorf("enc = %q", b)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// Package models holds the wire types shared between the encapi server, the
|
||||
// encapi-cli client, and (via the generated client) the Terraform provider.
|
||||
package models
|
||||
|
||||
// Role is a Puppet class assignment target, e.g. "roles::infra::storage::vault".
|
||||
// DefaultParams are inheritable parameters merged into every node that carries
|
||||
// the role; a node's own params take precedence on key collisions.
|
||||
type Role struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
DefaultParams map[string]any `json:"default_params,omitempty"`
|
||||
}
|
||||
|
||||
// Status is a Puppet environment (Cobbler calls these "status": testing,
|
||||
// production, development, ...). The set of valid statuses is managed
|
||||
// explicitly so a node can only be pinned to one that exists.
|
||||
type Status struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
// Node is a host-to-role assignment. Certname is the Puppet certname (fqdn).
|
||||
// Params override the role's DefaultParams for this host only.
|
||||
type Node struct {
|
||||
Certname string `json:"certname"`
|
||||
Role string `json:"role"`
|
||||
Environment string `json:"environment"`
|
||||
Params map[string]any `json:"params,omitempty"`
|
||||
}
|
||||
Reference in New Issue
Block a user