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
175 lines
5.2 KiB
Go
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)
|
|
}
|