f296056360
ci/woodpecker/tag/release Pipeline failed
## Why `node-lookup` output is handy 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 small tools close that gap and ship in the **same RPM** so they're available wherever `node-lookup` is. ## Changes - Add **`pburl`**: reads hostnames from args or piped `node-lookup` output (first field of each line, de-duped) 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 (`<base>/node/<certname>`), and no-TTY-safe stdin host reading. - Add **`puppetboard_url`** config key (env `NODE_LOOKUP_PUPPETBOARD_URL`, default `https://puppetboard.k8s.syd1.au.unkin.net`) to the shared config so `config init`/`config show` scaffold it for the whole tool family. `node-lookup`'s own query behaviour is unchanged. - Build all three binaries individually (each is its own `main` package — a single `go build ./...` can't emit multiple mains) and generate per-binary bash/zsh/fish completions in the Makefile, `build-rpm.sh`, and nfpm spec. - Cross-compile and attach all three tools per os/arch in the release pipeline; extend `.gitignore`; `go mod tidy` promotes cobra/yaml to direct deps. - Document the tools, config key, and env var in `AGENTS.md`. ## Testing - `go test -race ./...` passes (new tests cover config precedence, `nodes` endpoint derivation, host-page URLs, `LookupNode`, stdin host parsing, and the report-time formatting incl. timezone/relative/edge cases). - Built the RPM locally and confirmed it installs all 3 binaries + 9 completion files. - Smoke-tested both tools end-to-end against a mock PuppetDB (timezone conversion, relative time, and error handling all correct). No cross-repo changes needed: the release reuses the existing `default` ServiceAccount and the artifactapi `rpm-internal` upload. Reviewed-on: #15 Co-authored-by: Ben Vincent <ben@unkin.net> Co-committed-by: Ben Vincent <ben@unkin.net>
72 lines
1.9 KiB
Go
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
|
|
}
|