Files
encapi/pkg/client/client.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

175 lines
5.2 KiB
Go

// 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)
}