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 }