Add pburl and pblastreport companion tools to the RPM (#15)
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>
This commit was merged in pull request #15.
This commit is contained in:
2026-07-16 22:37:26 +10:00
committed by BenVincent
parent 5982d257d5
commit f296056360
18 changed files with 881 additions and 46 deletions
+9
View File
@@ -0,0 +1,9 @@
package puppet
import "strings"
// HostPageURL returns the Puppetboard node-detail page URL for a certname,
// e.g. https://puppetboard.example.net/node/host1.example.net.
func HostPageURL(base, certname string) string {
return strings.TrimRight(base, "/") + "/node/" + certname
}
+90
View File
@@ -0,0 +1,90 @@
// Package puppet holds the small pieces of PuppetDB/Puppetboard plumbing shared
// by the node-lookup companion tools (pburl, pblastreport): config loading,
// PuppetDB "nodes" queries, Puppetboard URL construction, and reading hostnames
// from piped node-lookup output.
//
// It intentionally reads the SAME config file, env vars, and defaults as the
// node-lookup CLI so a single `~/.config/node-lookup/config.yaml` configures
// every tool in the family.
package puppet
import (
"fmt"
"os"
"path/filepath"
"gopkg.in/yaml.v3"
)
const (
// DefaultPuppetDBURL is the PuppetDB v4 facts endpoint (shared with node-lookup).
DefaultPuppetDBURL = "http://puppetdbapi.service.consul:8080/pdb/query/v4/facts"
// DefaultRoleFact is the role fact node-lookup queries with -R.
DefaultRoleFact = "enc_role"
// DefaultPuppetboardURL is the base URL of the Puppetboard web UI.
DefaultPuppetboardURL = "https://puppetboard.k8s.syd1.au.unkin.net"
appName = "node-lookup"
configFileName = "config.yaml"
)
// Config mirrors node-lookup's config plus the puppetboard_url key used by the
// companion tools. Fields map 1:1 to config file keys and env vars.
type Config struct {
PuppetDBURL string `yaml:"puppetdb_url"`
RoleFact string `yaml:"role_fact"`
PuppetboardURL string `yaml:"puppetboard_url"`
}
// DefaultConfig returns the built-in defaults.
func DefaultConfig() Config {
return Config{
PuppetDBURL: DefaultPuppetDBURL,
RoleFact: DefaultRoleFact,
PuppetboardURL: DefaultPuppetboardURL,
}
}
// ConfigDir returns the XDG_CONFIG_HOME/node-lookup directory.
func ConfigDir() string {
base := os.Getenv("XDG_CONFIG_HOME")
if base == "" {
home, _ := os.UserHomeDir()
base = filepath.Join(home, ".config")
}
return filepath.Join(base, appName)
}
// ConfigPath returns the full path to the shared config file.
func ConfigPath() string {
return filepath.Join(ConfigDir(), configFileName)
}
// Load reads the config file (if present), then applies env var overrides.
// Precedence (lowest → highest): defaults < config file < env vars.
func Load() (Config, error) {
cfg := DefaultConfig()
path := ConfigPath()
data, err := os.ReadFile(path)
if err != nil && !os.IsNotExist(err) {
return cfg, fmt.Errorf("reading config %s: %w", path, err)
}
if err == nil {
if err := yaml.Unmarshal(data, &cfg); err != nil {
return cfg, fmt.Errorf("parsing config %s: %w", path, err)
}
}
if v := os.Getenv("NODE_LOOKUP_URL"); v != "" {
cfg.PuppetDBURL = v
}
if v := os.Getenv("NODE_LOOKUP_ROLE_FACT"); v != "" {
cfg.RoleFact = v
}
if v := os.Getenv("NODE_LOOKUP_PUPPETBOARD_URL"); v != "" {
cfg.PuppetboardURL = v
}
return cfg, nil
}
+177
View File
@@ -0,0 +1,177 @@
package puppet
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
// ---- config -----------------------------------------------------------------
func TestLoad_Defaults(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
t.Setenv("NODE_LOOKUP_URL", "")
t.Setenv("NODE_LOOKUP_ROLE_FACT", "")
t.Setenv("NODE_LOOKUP_PUPPETBOARD_URL", "")
cfg, err := Load()
if err != nil {
t.Fatal(err)
}
if cfg.PuppetDBURL != DefaultPuppetDBURL {
t.Fatalf("expected default puppetdb url, got %s", cfg.PuppetDBURL)
}
if cfg.PuppetboardURL != DefaultPuppetboardURL {
t.Fatalf("expected default puppetboard url, got %s", cfg.PuppetboardURL)
}
}
func TestLoad_FileAndEnvOverride(t *testing.T) {
dir := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", dir)
t.Setenv("NODE_LOOKUP_URL", "")
t.Setenv("NODE_LOOKUP_ROLE_FACT", "")
t.Setenv("NODE_LOOKUP_PUPPETBOARD_URL", "https://env.example.net")
cfgDir := filepath.Join(dir, appName)
if err := os.MkdirAll(cfgDir, 0o755); err != nil {
t.Fatal(err)
}
body := "puppetdb_url: http://file:8080/pdb/query/v4/facts\npuppetboard_url: https://file.example.net\n"
if err := os.WriteFile(filepath.Join(cfgDir, configFileName), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
cfg, err := Load()
if err != nil {
t.Fatal(err)
}
if cfg.PuppetDBURL != "http://file:8080/pdb/query/v4/facts" {
t.Fatalf("file override failed: %s", cfg.PuppetDBURL)
}
// env beats the file for puppetboard_url
if cfg.PuppetboardURL != "https://env.example.net" {
t.Fatalf("env should beat file: %s", cfg.PuppetboardURL)
}
}
// ---- NodesEndpoint ----------------------------------------------------------
func TestNodesEndpoint(t *testing.T) {
cases := map[string]string{
"http://puppetdbapi.service.consul:8080/pdb/query/v4/facts": "http://puppetdbapi.service.consul:8080/pdb/query/v4/nodes",
"http://h:8080/pdb/query/v4/facts/": "http://h:8080/pdb/query/v4/nodes",
"https://h/facts": "https://h/nodes",
}
for in, want := range cases {
if got := NodesEndpoint(in); got != want {
t.Errorf("NodesEndpoint(%q) = %q, want %q", in, got, want)
}
}
}
// ---- HostPageURL ------------------------------------------------------------
func TestHostPageURL(t *testing.T) {
if got := HostPageURL("https://pb.example.net", "h1.example.net"); got != "https://pb.example.net/node/h1.example.net" {
t.Fatalf("unexpected url: %s", got)
}
// trailing slash on the base is trimmed
if got := HostPageURL("https://pb.example.net/", "h1"); got != "https://pb.example.net/node/h1" {
t.Fatalf("trailing slash not handled: %s", got)
}
}
// ---- LookupNode -------------------------------------------------------------
func TestLookupNode_Found(t *testing.T) {
var gotQuery string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotQuery = r.URL.Query().Get("query")
_ = json.NewEncoder(w).Encode([]Node{{
Certname: "h1",
ReportTimestamp: "2026-07-15T04:05:06.000Z",
LatestReportStatus: "changed",
}})
}))
defer srv.Close()
node, err := LookupNode(srv.URL, "h1")
if err != nil {
t.Fatal(err)
}
if node == nil || node.ReportTimestamp != "2026-07-15T04:05:06.000Z" {
t.Fatalf("unexpected node: %+v", node)
}
if !strings.Contains(gotQuery, "certname") || !strings.Contains(gotQuery, "h1") {
t.Fatalf("query missing certname filter: %s", gotQuery)
}
}
func TestLookupNode_Unknown(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode([]Node{})
}))
defer srv.Close()
node, err := LookupNode(srv.URL, "nope")
if err != nil {
t.Fatal(err)
}
if node != nil {
t.Fatalf("expected nil node for unknown certname, got %+v", node)
}
}
func TestLookupNode_HTTPError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "boom", http.StatusInternalServerError)
}))
defer srv.Close()
if _, err := LookupNode(srv.URL, "h1"); err == nil {
t.Fatal("expected error for HTTP 500")
}
}
// ---- ReadHosts --------------------------------------------------------------
func TestReadHosts_ArgsWin(t *testing.T) {
// A real pipe with data present, but explicit args should take precedence.
r, w, _ := os.Pipe()
go func() { _, _ = w.WriteString("piped\n"); _ = w.Close() }()
defer func() { _ = r.Close() }()
got := ReadHosts(r, []string{"a", "b", "a"})
if strings.Join(got, ",") != "a,b" {
t.Fatalf("expected deduped args, got %v", got)
}
}
func TestReadHosts_StdinFirstField(t *testing.T) {
r, w, _ := os.Pipe()
go func() {
// node-lookup default output: "host value"; also a bare host and a dup.
_, _ = w.WriteString("host1 roles::web\nhost2 roles::db\nhost1 roles::web\nhost3\n")
_ = w.Close()
}()
defer func() { _ = r.Close() }()
got := ReadHosts(r, nil)
if strings.Join(got, ",") != "host1,host2,host3" {
t.Fatalf("expected first-field hosts deduped, got %v", got)
}
}
func TestReadHosts_NoInput(t *testing.T) {
// /dev/null is a char device: no args, no pipe data -> nil.
f, _ := os.Open(os.DevNull)
defer func() { _ = f.Close() }()
if got := ReadHosts(f, nil); got != nil {
t.Fatalf("expected nil for no input, got %v", got)
}
}
+71
View File
@@ -0,0 +1,71 @@
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
}
+66
View File
@@ -0,0 +1,66 @@
package puppet
import (
"bufio"
"os"
"strings"
)
// StdinReader returns a buffered reader over f and true only when f actually
// carries piped/redirected data. Terminals and character devices such as
// /dev/null return false, and an empty pipe or empty file (immediate EOF on
// peek) also returns false. This mirrors node-lookup's no-TTY behaviour: when
// invoked without a real pipe the caller can fall back to arguments instead of
// blocking on or silently consuming empty input.
func StdinReader(f *os.File) (*bufio.Reader, bool) {
fi, err := f.Stat()
if err != nil {
return nil, false
}
if (fi.Mode() & os.ModeCharDevice) != 0 {
return nil, false // terminal or /dev/null
}
r := bufio.NewReader(f)
if _, err := r.Peek(1); err != nil {
return nil, false // empty pipe / empty file (EOF)
}
return r, true
}
// ReadHosts resolves the list of hostnames to act on. Explicit args win; failing
// that it reads the first whitespace-separated field of each non-empty line from
// stdin (so `node-lookup -R | pburl` and `node-lookup -1 | pburl` both work).
// Order is preserved and duplicates are removed. Returns nil when neither args
// nor piped stdin data are present.
func ReadHosts(stdin *os.File, args []string) []string {
if len(args) > 0 {
return dedupe(args)
}
r, ok := StdinReader(stdin)
if !ok {
return nil
}
var hosts []string
sc := bufio.NewScanner(r)
for sc.Scan() {
fields := strings.Fields(sc.Text())
if len(fields) == 0 {
continue
}
hosts = append(hosts, fields[0])
}
return dedupe(hosts)
}
func dedupe(in []string) []string {
seen := make(map[string]struct{}, len(in))
out := make([]string, 0, len(in))
for _, s := range in {
if _, ok := seen[s]; ok {
continue
}
seen[s] = struct{}{}
out = append(out, s)
}
return out
}