Files
node-lookup/internal/puppet/puppetdb.go
T
unkinben 8800d5ce35
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
Add pburl and pblastreport companion tools to the RPM
node-lookup output is useful for pivoting to Puppetboard, but there was no
quick way to turn a list of hosts into Puppetboard node-page URLs or to see
when each host last ran Puppet. These two tools close that gap and ship in the
same RPM so they are available wherever node-lookup is.

- Add pburl: reads hostnames from args or piped node-lookup output and prints
  "<host> <puppetboard-node-page-url>".
- Add pblastreport: prints "<host>\t<last-report-time>\t<url>" using
  report_timestamp from the PuppetDB v4 nodes endpoint. Supports --relative/-r
  (relative age) and --timezone/-z <IANA> (default: local timezone).
- Add internal/puppet package shared by both tools: config load, PuppetDB
  nodes query, Puppetboard URL construction, and no-TTY-safe stdin host reading.
- Add puppetboard_url config key (env NODE_LOOKUP_PUPPETBOARD_URL) to the
  shared config so config init/show scaffold it for the whole tool family.
- Build all three binaries individually (each is its own main package) and
  generate per-binary bash/zsh/fish completions in the Makefile, build-rpm.sh,
  and nfpm spec; cross-compile and attach all three per os/arch in the release
  pipeline.
- Document the tools, config key, and env var in AGENTS.md.
2026-07-16 22:31:21 +10:00

72 lines
1.9 KiB
Go

package puppet
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
)
// Node is the subset of a PuppetDB v4 "nodes" record the companion tools use.
type Node struct {
Certname string `json:"certname"`
ReportTimestamp string `json:"report_timestamp"`
LatestReportStatus string `json:"latest_report_status"`
}
// NodesEndpoint derives the PuppetDB v4 "nodes" query endpoint from the
// configured facts endpoint by swapping the final path segment
// (…/pdb/query/v4/facts → …/pdb/query/v4/nodes). It leaves scheme/host/query
// untouched, so a custom NODE_LOOKUP_URL still resolves correctly.
func NodesEndpoint(factsURL string) string {
u, err := url.Parse(factsURL)
if err != nil {
return factsURL
}
p := strings.TrimRight(u.Path, "/")
if i := strings.LastIndex(p, "/"); i >= 0 {
p = p[:i] + "/nodes"
} else {
p = "/nodes"
}
u.Path = p
return u.String()
}
// LookupNode fetches the single PuppetDB node record for certname. It returns
// (nil, nil) when PuppetDB knows of no such node.
func LookupNode(nodesURL, certname string) (*Node, error) {
q, _ := json.Marshal([]interface{}{"=", "certname", certname})
nodes, err := queryNodes(nodesURL, string(q))
if err != nil {
return nil, err
}
if len(nodes) == 0 {
return nil, nil
}
return &nodes[0], nil
}
func queryNodes(nodesURL, query string) ([]Node, error) {
params := url.Values{}
params.Set("query", query)
resp, err := http.Get(nodesURL + "?" + params.Encode())
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
}
var nodes []Node
if err := json.NewDecoder(resp.Body).Decode(&nodes); err != nil {
return nil, fmt.Errorf("decode error: %w", err)
}
return nodes, nil
}