Files
unkin-agent 3f14554fae
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
read facts from PuppetDB's query API
- rename internal/pdbmux to internal/puppetdb
- fetch facts with one GET /pdb/query/v4/facts AST query per node
- drop the per-node fallback route and its two response shapes
- rename --pdb to --puppetdb, CATALOG_DIFF_PDB to CATALOG_DIFF_PUPPETDB and
  pdb_url to puppetdb_url
- describe the fact source generically in the README and AGENTS.md
2026-09-12 17:49:57 +10:00

368 lines
11 KiB
Go

// Package puppetserver compiles catalogs against an OpenVoxServer/Puppet
// Server 8 compiler over client mTLS. It is a separate client from puppetdb:
// 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)
}
// puppetDBMarkers appear in a compile failure caused by the compiler's own
// PuppetDB query rather than by the manifest. The estate sees these
// intermittently and the same node compiles on the next attempt.
var puppetDBMarkers = []string{
"server_urls",
"/pdb/query/",
"/pdb/cmd/",
}
// manifestMarkers appear in a failure the manifest itself causes. Recompiling
// the same code cannot change the outcome.
var manifestMarkers = []string{
"evaluation error",
"syntax error",
"could not find class",
"could not find declared class",
"could not find data item",
"parse error",
"compilation-error",
}
// Transient reports whether the failure is worth retrying. A failed PuppetDB
// query is checked first because the compiler reports it as an evaluation
// error, which otherwise reads as a manifest problem. Anything the manifest
// caused is never retried; every other 5xx is.
func (e *CompileError) Transient() bool {
haystack := strings.ToLower(e.Message + " " + e.Kind)
for _, marker := range puppetDBMarkers {
if strings.Contains(haystack, marker) {
return true
}
}
for _, marker := range manifestMarkers {
if strings.Contains(haystack, marker) {
return false
}
}
return e.Status >= 500
}
// EnvironmentMismatchError means the server compiled a different, named
// environment from the one asked for. It is only raised when both names are
// non-empty: an unnamed environment is a decode failure, not an ENC decision,
// and is reported as a ResponseError instead.
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. "+
"options.prefer_requested_environment did not override the environment the node "+
"resolved to; the usual cause is an ENC-supplied environment (encapic -> encapi) "+
"for a node whose encapi status is not \"testing\"",
e.Certname, e.Requested, e.Returned)
}
// ResponseError is a 200 response this client could not turn into a catalog:
// it did not decode, or it decoded without an environment name. Both mean the
// response shape is not the one expected, so retrying cannot help.
type ResponseError struct {
Certname string
Size int
Err error
}
func (e *ResponseError) Error() string {
if e.Err != nil {
return fmt.Sprintf("catalog response for %s did not decode (%d bytes): %v",
e.Certname, e.Size, e.Err)
}
return fmt.Sprintf(
"catalog response for %s decoded without an environment name (%d bytes): "+
"the compiler returned a response shape this client does not understand. "+
"This is a client/server protocol mismatch; nothing about the node's "+
"classification or environment needs changing",
e.Certname, e.Size)
}
func (e *ResponseError) Unwrap() error { return e.Err }
// 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)
}
cat, err := decodeCatalog(raw)
if err != nil {
return nil, &ResponseError{Certname: req.Certname, Size: len(raw), Err: err}
}
if cat.Environment == "" {
return nil, &ResponseError{Certname: req.Certname, Size: len(raw)}
}
if cat.Environment != req.Environment {
return nil, &EnvironmentMismatchError{
Certname: req.Certname,
Requested: req.Environment,
Returned: cat.Environment,
}
}
return cat, nil
}
// catalogEnvelope is the wrapper OpenVoxServer / Puppet Server 8 returns from
// POST /puppet/v4/catalog: the catalog itself sits under a single "catalog"
// key.
type catalogEnvelope struct {
Catalog *catalog.Catalog `json:"catalog"`
}
// decodeCatalog accepts both the wrapped response and a bare catalog, so a
// compiler that does not wrap still works.
func decodeCatalog(raw []byte) (*catalog.Catalog, error) {
var env catalogEnvelope
if err := json.Unmarshal(raw, &env); err == nil && env.Catalog != nil {
return env.Catalog, nil
}
var bare catalog.Catalog
if err := json.Unmarshal(raw, &bare); err != nil {
return nil, err
}
return &bare, 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
}