Files
puppet-catalog-tools/internal/puppetserver/puppetserver.go
T
unkin-agent 53007a9257
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was canceled
ci/woodpecker/pr/test Pipeline was canceled
add repo scaffold and the catalog-diff CLI
Compile a host's catalog in a base and a test environment over the
puppetserver API, then diff the two.

- add catalog-diff CLI with cobra flags, config precedence and exit codes
- add puppetserver mTLS client for POST /puppet/v4/catalog
- add pdbmux fact client
- add catalog normalisation, diff engine and text/JSON renderers
- add Makefile, nfpm RPM packaging and woodpecker pipelines
2026-09-12 15:54:25 +10:00

280 lines
8.0 KiB
Go

// Package puppetserver compiles catalogs against an OpenVoxServer/Puppet
// Server 8 compiler over client mTLS. It is a separate client from pdbmux:
// different endpoint, different auth model.
package puppetserver
import (
"bytes"
"context"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
"git.unkin.net/unkin/puppet-catalog-tools/internal/catalog"
)
// Client talks to a puppetserver compiler.
type Client struct {
BaseURL string
HTTP *http.Client
}
// New returns a client for baseURL using tlsCfg for transport and client auth.
func New(baseURL string, tlsCfg *tls.Config, timeout time.Duration) *Client {
return &Client{
BaseURL: strings.TrimRight(baseURL, "/"),
HTTP: &http.Client{
Timeout: timeout,
Transport: &http.Transport{TLSClientConfig: tlsCfg},
},
}
}
// TLSConfig builds a client mTLS config from PEM files. certFile/keyFile are
// the client certificate issued by the estate's Puppet CA; caFile is the CA
// bundle used to verify the server.
func TLSConfig(certFile, keyFile, caFile string) (*tls.Config, error) {
cfg := &tls.Config{MinVersion: tls.VersionTLS12}
if (certFile == "") != (keyFile == "") {
return nil, errors.New("--cert and --key must be given together")
}
if certFile != "" {
pair, err := tls.LoadX509KeyPair(certFile, keyFile)
if err != nil {
return nil, fmt.Errorf("loading client certificate: %w", err)
}
cfg.Certificates = []tls.Certificate{pair}
}
if caFile != "" {
pem, err := os.ReadFile(caFile)
if err != nil {
return nil, fmt.Errorf("reading CA bundle: %w", err)
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(pem) {
return nil, fmt.Errorf("no certificates found in %s", caFile)
}
cfg.RootCAs = pool
}
return cfg, nil
}
// CompileError is a catalog that the server refused to compile.
type CompileError struct {
Environment string
Status int
Kind string
Message string
}
func (e *CompileError) Error() string {
msg := e.Message
if msg == "" {
msg = "no message returned"
}
if e.Kind != "" {
return fmt.Sprintf("catalog compilation failed in environment %q (HTTP %d, %s): %s",
e.Environment, e.Status, e.Kind, msg)
}
return fmt.Sprintf("catalog compilation failed in environment %q (HTTP %d): %s",
e.Environment, e.Status, msg)
}
// EnvironmentMismatchError means the server compiled a different environment
// from the one asked for. Classification runs through an exec ENC (encapic ->
// encapi) whose environment key outranks the requested one, so a mismatch
// means the diff would have compared an environment against itself.
type EnvironmentMismatchError struct {
Certname string
Requested string
Returned string
}
func (e *EnvironmentMismatchError) Error() string {
return fmt.Sprintf(
"environment mismatch for %s: requested %q but the server compiled %q. "+
"The ENC (encapic -> encapi) supplies an authoritative environment for every node "+
"whose encapi status is not \"testing\", and options.prefer_requested_environment did "+
"not beat it. Set the node's encapi status to \"testing\" or fix the ENC before diffing",
e.Certname, e.Requested, e.Returned)
}
// CompileRequest describes one catalog compilation.
type CompileRequest struct {
Certname string
Environment string
Facts map[string]any
TrustedFacts map[string]any
}
type catalogRequest struct {
Certname string `json:"certname"`
Environment string `json:"environment"`
Persistence persistence `json:"persistence"`
Facts factValues `json:"facts"`
TrustedFacts factValues `json:"trusted_facts"`
TransactionUUID string `json:"transaction_uuid"`
Options map[string]any `json:"options"`
}
type persistence struct {
Facts bool `json:"facts"`
Catalog bool `json:"catalog"`
}
type factValues struct {
Values map[string]any `json:"values"`
}
// Compile compiles one catalog. It always verifies that the catalog the server
// returned is for the environment that was requested.
func (c *Client) Compile(ctx context.Context, req CompileRequest) (*catalog.Catalog, error) {
facts := req.Facts
if facts == nil {
facts = map[string]any{}
}
trusted := req.TrustedFacts
if trusted == nil {
trusted = TrustedFacts(req.Certname)
}
uuid, err := transactionUUID()
if err != nil {
return nil, err
}
body, err := json.Marshal(catalogRequest{
Certname: req.Certname,
Environment: req.Environment,
Persistence: persistence{Facts: false, Catalog: false},
Facts: factValues{Values: facts},
TrustedFacts: factValues{Values: trusted},
TransactionUUID: uuid,
Options: map[string]any{
"prefer_requested_environment": true,
"capture_logs": false,
"log_level": "info",
},
})
if err != nil {
return nil, fmt.Errorf("encoding catalog request: %w", err)
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+"/puppet/v4/catalog", bytes.NewReader(body))
if err != nil {
return nil, err
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Accept", "application/json")
resp, err := c.HTTP.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("catalog request to %s failed: %w", c.BaseURL, err)
}
defer func() { _ = resp.Body.Close() }()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("reading catalog response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, compileError(req.Environment, resp.StatusCode, raw)
}
var cat catalog.Catalog
if err := json.Unmarshal(raw, &cat); err != nil {
return nil, fmt.Errorf("parsing catalog for %s: %w", req.Certname, err)
}
if cat.Environment != req.Environment {
return nil, &EnvironmentMismatchError{
Certname: req.Certname,
Requested: req.Environment,
Returned: cat.Environment,
}
}
return &cat, nil
}
// Health checks the unauthenticated /status/v1/simple endpoint.
func (c *Client) Health(ctx context.Context) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+"/status/v1/simple", nil)
if err != nil {
return err
}
resp, err := c.HTTP.Do(req)
if err != nil {
return fmt.Errorf("puppetserver %s unreachable: %w", c.BaseURL, err)
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("reading puppetserver status: %w", err)
}
status := strings.TrimSpace(string(body))
if resp.StatusCode != http.StatusOK || status != "running" {
return fmt.Errorf("puppetserver %s is not running (HTTP %d: %s)", c.BaseURL, resp.StatusCode, status)
}
return nil
}
// compileError decodes a Puppet error body. Puppet Server reports the failure
// kind as "kind" or "issue_kind" depending on the error path.
func compileError(environment string, status int, raw []byte) error {
var body struct {
Kind string `json:"kind"`
IssueKind string `json:"issue_kind"`
Message string `json:"message"`
}
e := &CompileError{Environment: environment, Status: status}
if err := json.Unmarshal(raw, &body); err != nil {
e.Message = strings.TrimSpace(string(raw))
} else {
e.Kind = body.Kind
if e.Kind == "" {
e.Kind = body.IssueKind
}
e.Message = body.Message
if e.Message == "" {
e.Message = strings.TrimSpace(string(raw))
}
}
if len(e.Message) > 4000 {
e.Message = e.Message[:4000] + "..."
}
return e
}
// TrustedFacts synthesises the trusted facts hash for a certname, matching the
// shape Puppet builds from a client certificate.
func TrustedFacts(certname string) map[string]any {
hostname, domain, _ := strings.Cut(certname, ".")
return map[string]any{
"authenticated": "remote",
"certname": certname,
"extensions": map[string]any{},
"hostname": hostname,
"domain": domain,
}
}
func transactionUUID() (string, error) {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
return "", fmt.Errorf("generating transaction uuid: %w", err)
}
b[6] = (b[6] & 0x0f) | 0x40
b[8] = (b[8] & 0x3f) | 0x80
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]), nil
}