3f14554fae
- 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
368 lines
11 KiB
Go
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
|
|
}
|