53007a9257
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
280 lines
8.0 KiB
Go
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
|
|
}
|