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
+8 -1
View File
@@ -1,2 +1,9 @@
node-lookup
# built binaries (repo root only — not the cmd/ source dirs)
/node-lookup
/pburl
/pblastreport
# cross-compiled release artifacts (e.g. node-lookup-linux-amd64)
/node-lookup-*
/pburl-*
/pblastreport-*
dist/
+20 -6
View File
@@ -17,16 +17,22 @@ steps:
memory: 2Gi
cpu: 2
# Build the linux/amd64 binary into dist/ (consumed by the RPM step) plus the
# cross-platform binaries attached to the Gitea release.
# Build all binaries into dist/ (consumed by the RPM step) plus the
# cross-platform binaries attached to the Gitea release. Each tool is a
# separate main package, so they are built individually per os/arch.
- name: build
image: git.unkin.net/unkin/almalinux9-gobuilder:20260606
commands:
- make build VERSION=${CI_COMMIT_TAG}
- GOOS=linux GOARCH=amd64 go build -ldflags="-s -w -X main.version=${CI_COMMIT_TAG}" -o node-lookup-linux-amd64 ./...
- GOOS=linux GOARCH=arm64 go build -ldflags="-s -w -X main.version=${CI_COMMIT_TAG}" -o node-lookup-linux-arm64 ./...
- GOOS=darwin GOARCH=amd64 go build -ldflags="-s -w -X main.version=${CI_COMMIT_TAG}" -o node-lookup-darwin-amd64 ./...
- GOOS=darwin GOARCH=arm64 go build -ldflags="-s -w -X main.version=${CI_COMMIT_TAG}" -o node-lookup-darwin-arm64 ./...
- |
for entry in "node-lookup:." "pburl:./cmd/pburl" "pblastreport:./cmd/pblastreport"; do
name="${entry%%:*}"; pkg="${entry##*:}"
for osarch in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64; do
GOOS="${osarch%/*}" GOARCH="${osarch#*/}" \
go build -ldflags="-s -w -X main.version=${CI_COMMIT_TAG}" \
-o "${name}-${osarch%/*}-${osarch#*/}" "${pkg}"
done
done
depends_on: [test]
backend_options:
kubernetes:
@@ -113,6 +119,14 @@ steps:
node-lookup-linux-arm64 \
node-lookup-darwin-amd64 \
node-lookup-darwin-arm64 \
pburl-linux-amd64 \
pburl-linux-arm64 \
pburl-darwin-amd64 \
pburl-darwin-arm64 \
pblastreport-linux-amd64 \
pblastreport-linux-arm64 \
pblastreport-darwin-amd64 \
pblastreport-darwin-arm64 \
--login gitea --repo "${CI_REPO}"
depends_on: [upload-rpm]
backend_options:
+40 -5
View File
@@ -2,22 +2,39 @@
## Project Overview
`node-lookup` is a Go CLI tool that queries a PuppetDB API to retrieve and filter node facts.
This repo ships three related Puppet CLIs in one RPM:
- **`node-lookup`** — queries the PuppetDB API to retrieve and filter node facts.
- **`pburl`** — prints the Puppetboard node-page URL for each host (reads hosts
from args or piped `node-lookup` output). Output: `<host> <url>`.
- **`pblastreport`** — prints each host's last Puppet report time and its
Puppetboard URL. Output: `<host>\t<time>\t<url>`. Supports `--relative`/`-r`
(relative age) and `--timezone`/`-z <IANA>` (default: local timezone).
`node-lookup` is the module root; `pburl` and `pblastreport` live under `cmd/`
and share the `internal/puppet` package (config, PuppetDB `nodes` queries,
Puppetboard URL construction, stdin host reading).
## Structure
```
main.go # entire application source
main_test.go # unit tests (mock PuppetDB via httptest, no live deps)
main.go # node-lookup CLI source (module root, package main)
main_test.go # node-lookup unit tests (mock PuppetDB via httptest)
cmd/pburl/main.go # pburl CLI
cmd/pblastreport/main.go # pblastreport CLI (report.go: report-time formatting)
internal/puppet/ # shared: config, puppetdb nodes query, board URLs, stdin
go.mod # Go module (module name: node-lookup)
go.sum # dependency checksums
Makefile # build / test / lint / completions / rpm / version-bump targets
packaging/nfpm.yaml # nfpm spec (envsubst-templated) for the RPM
packaging/nfpm.yaml # nfpm spec (envsubst-templated) for the RPM (all 3 binaries)
scripts/build-rpm.sh # generates completions + packages the RPM with nfpm
.woodpecker/ # CI: build, test, pre-commit (PR) + release (tag)
dist/ # build output: binary, completions, RPM (not committed)
dist/ # build output: binaries, completions, RPM (not committed)
```
Every binary is a separate `main` package, so `make build` builds each with its
own `-o` (a single `go build ./...` can't emit multiple mains to one file).
## Build
```bash
@@ -74,6 +91,22 @@ installed. To load ad-hoc in the current shell, e.g. zsh:
echo -e "node1\nnode2" | ./node-lookup -R # pipe node names via stdin
```
### Companion tools
```bash
node-lookup -R | pburl # <host> <puppetboard-url> per line
pburl host1 host2 # hosts as args instead of stdin
node-lookup -R | pblastreport # <host> <last-report-time> <url>
pblastreport -r host1 # relative age (e.g. "3h ago")
pblastreport -z Asia/Singapore host1 # render the time in a specific IANA tz
```
Both read hostnames from arguments or the first field of each piped line (so
any `node-lookup` output mode works), de-duplicate, and share `node-lookup`'s
config file / env vars. `pblastreport` reads `report_timestamp` from the
PuppetDB v4 `nodes` endpoint (derived from the configured facts URL).
## Configuration
Precedence (lowest → highest): **defaults < config file < env vars < `--url` flag**
@@ -85,6 +118,7 @@ XDG location: `$XDG_CONFIG_HOME/node-lookup/config.yaml` (default: `~/.config/no
```yaml
puppetdb_url: http://puppetdbapi.service.consul:8080/pdb/query/v4/facts
role_fact: enc_role
puppetboard_url: https://puppetboard.k8s.syd1.au.unkin.net # used by pburl / pblastreport
```
Generate the default config file:
@@ -103,6 +137,7 @@ Show the active configuration (after all overrides applied):
|---|---|---|
| `NODE_LOOKUP_URL` | `puppetdb_url` | PuppetDB facts endpoint |
| `NODE_LOOKUP_ROLE_FACT` | `role_fact` | Fact name used by `-R` flag |
| `NODE_LOOKUP_PUPPETBOARD_URL` | `puppetboard_url` | Puppetboard base URL (pburl / pblastreport) |
### CLI flag
+21 -7
View File
@@ -1,17 +1,29 @@
BINARY := node-lookup
# All shipped binaries and the package path each is built from. node-lookup is
# the module root; the companion tools live under cmd/.
BINARIES := node-lookup pburl pblastreport
DIST := dist
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
GOFLAGS := -ldflags="-s -w -X main.version=$(VERSION)"
OS ?= $(shell go env GOOS)
ARCH ?= $(shell go env GOARCH)
# The Go package path for a binary: node-lookup is the module root, the
# companion tools live under cmd/. Usable inside a shell for-loop over $(BINARIES).
pkgpath = $$([ "$$b" = "node-lookup" ] && echo . || echo ./cmd/$$b)
.PHONY: all build test lint fmt clean install completions rpm rpm-package patch minor major _tag
all: build
# Build into dist/ so the nfpm packaging step (scripts/build-rpm.sh) can find it.
# Build every binary into dist/ so the nfpm packaging step
# (scripts/build-rpm.sh) can find them. Each main package needs its own -o, so
# they are built individually rather than with a single ./... invocation.
build:
CGO_ENABLED=0 GOOS=$(OS) GOARCH=$(ARCH) go build $(GOFLAGS) -o $(DIST)/$(BINARY) ./...
@for b in $(BINARIES); do \
echo "building $$b"; \
CGO_ENABLED=0 GOOS=$(OS) GOARCH=$(ARCH) go build $(GOFLAGS) -o $(DIST)/$$b $(pkgpath) || exit 1; \
done
test:
go test -v -race ./...
@@ -23,17 +35,19 @@ fmt:
gofmt -w .
clean:
rm -rf $(DIST) $(BINARY)
rm -rf $(DIST) $(BINARIES)
install:
go install $(GOFLAGS) ./...
# Generate bash/zsh/fish completions from the built binary into dist/completions.
# Generate bash/zsh/fish completions for every binary into dist/completions.
completions: build
@mkdir -p $(DIST)/completions
$(DIST)/$(BINARY) completion bash > $(DIST)/completions/$(BINARY).bash
$(DIST)/$(BINARY) completion zsh > $(DIST)/completions/_$(BINARY)
$(DIST)/$(BINARY) completion fish > $(DIST)/completions/$(BINARY).fish
@for b in $(BINARIES); do \
$(DIST)/$$b completion bash > $(DIST)/completions/$$b.bash; \
$(DIST)/$$b completion zsh > $(DIST)/completions/_$$b; \
$(DIST)/$$b completion fish > $(DIST)/completions/$$b.fish; \
done
# Build the binary then package it (with completions) into an RPM via nfpm.
rpm: build rpm-package
+97
View File
@@ -0,0 +1,97 @@
// Command pblastreport shows each host's last Puppet report time alongside its
// Puppetboard node-page URL.
//
// It reads hostnames from its arguments or from piped node-lookup output,
// queries PuppetDB for each node's report_timestamp, and prints a
// tab-separated "<host> <last-report> <puppetboard-url>" line:
//
// node-lookup -R | pblastreport
// pblastreport --relative host1.example.net
// pblastreport --timezone Asia/Singapore host1.example.net
package main
import (
"fmt"
"os"
"time"
"node-lookup/internal/puppet"
"github.com/spf13/cobra"
)
var version = "dev"
func main() {
cfg, err := puppet.Load()
if err != nil {
fmt.Fprintln(os.Stderr, "config error:", err)
os.Exit(1)
}
var (
relative bool
tz string
boardURL string
pdbURL string
)
root := &cobra.Command{
Use: "pblastreport [host...]",
Short: "Show each host's last Puppet report time and Puppetboard URL.",
Long: "Reads hostnames from arguments or piped node-lookup output and prints, per\n" +
"host, the time of its last Puppet report and its Puppetboard node-page URL.\n" +
"Times are shown in the local timezone unless --timezone is given, or as a\n" +
"relative age with --relative. Example: node-lookup -R | pblastreport -r",
Args: cobra.ArbitraryArgs,
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
if cmd.Flags().Changed("puppetboard-url") {
cfg.PuppetboardURL = boardURL
}
if cmd.Flags().Changed("url") {
cfg.PuppetDBURL = pdbURL
}
loc := time.Local
if tz != "" {
l, err := time.LoadLocation(tz)
if err != nil {
return fmt.Errorf("invalid timezone %q: %w", tz, err)
}
loc = l
}
hosts := puppet.ReadHosts(os.Stdin, args)
if len(hosts) == 0 {
return fmt.Errorf("no hosts given (pass as arguments or pipe node-lookup output)")
}
nodesURL := puppet.NodesEndpoint(cfg.PuppetDBURL)
now := time.Now()
for _, h := range hosts {
node, lookupErr := puppet.LookupNode(nodesURL, h)
when := formatWhen(node, lookupErr, relative, loc, now)
fmt.Printf("%s\t%s\t%s\n", h, when, puppet.HostPageURL(cfg.PuppetboardURL, h))
}
return nil
},
}
f := root.Flags()
f.BoolVarP(&relative, "relative", "r", false, "Show the report time as a relative age (e.g. '3h ago')")
f.StringVarP(&tz, "timezone", "z", "", "IANA timezone for the report time (e.g. Asia/Singapore); default local")
f.StringVar(&boardURL, "puppetboard-url", cfg.PuppetboardURL, "Puppetboard base URL (overrides config and NODE_LOOKUP_PUPPETBOARD_URL)")
f.StringVar(&pdbURL, "url", cfg.PuppetDBURL, "PuppetDB facts URL (overrides config and NODE_LOOKUP_URL)")
root.AddCommand(&cobra.Command{
Use: "version",
Short: "Print the version",
Run: func(cmd *cobra.Command, args []string) { fmt.Println(version) },
SilenceUsage: true,
})
if err := root.Execute(); err != nil {
os.Exit(1)
}
}
+53
View File
@@ -0,0 +1,53 @@
package main
import (
"fmt"
"time"
"node-lookup/internal/puppet"
)
// formatWhen renders the "last report" column for a node lookup result. It
// handles the lookup error, the unknown-node, and the never-reported cases so
// the output always has a value in every column.
func formatWhen(node *puppet.Node, lookupErr error, relative bool, loc *time.Location, now time.Time) string {
if lookupErr != nil {
return "error: " + lookupErr.Error()
}
if node == nil {
return "unknown node"
}
if node.ReportTimestamp == "" {
return "no report"
}
ts, err := time.Parse(time.RFC3339Nano, node.ReportTimestamp)
if err != nil {
return node.ReportTimestamp // fall back to the raw value
}
if relative {
return humanizeSince(now, ts)
}
return ts.In(loc).Format("2006-01-02 15:04:05 MST")
}
// humanizeSince renders the gap between now and ts as a coarse relative string
// ("42s ago", "9m ago", "3h ago", "5d ago"). Future timestamps (clock skew)
// render as "in <d>".
func humanizeSince(now, ts time.Time) string {
d := now.Sub(ts)
suffix := "ago"
if d < 0 {
d = -d
suffix = "from now"
}
switch {
case d < time.Minute:
return fmt.Sprintf("%ds %s", int(d.Seconds()), suffix)
case d < time.Hour:
return fmt.Sprintf("%dm %s", int(d.Minutes()), suffix)
case d < 24*time.Hour:
return fmt.Sprintf("%dh %s", int(d.Hours()), suffix)
default:
return fmt.Sprintf("%dd %s", int(d.Hours()/24), suffix)
}
}
+84
View File
@@ -0,0 +1,84 @@
package main
import (
"errors"
"strings"
"testing"
"time"
"node-lookup/internal/puppet"
)
func mustTime(t *testing.T, s string) time.Time {
t.Helper()
ts, err := time.Parse(time.RFC3339Nano, s)
if err != nil {
t.Fatal(err)
}
return ts
}
func TestFormatWhen_Absolute(t *testing.T) {
node := &puppet.Node{ReportTimestamp: "2026-07-15T04:05:06.000Z"}
now := mustTime(t, "2026-07-15T10:00:00Z")
got := formatWhen(node, nil, false, time.UTC, now)
if got != "2026-07-15 04:05:06 UTC" {
t.Fatalf("unexpected absolute time: %q", got)
}
}
func TestFormatWhen_TimezoneApplied(t *testing.T) {
loc, err := time.LoadLocation("Asia/Singapore") // UTC+8, no DST
if err != nil {
t.Skipf("tzdata unavailable: %v", err)
}
node := &puppet.Node{ReportTimestamp: "2026-07-15T04:05:06Z"}
now := mustTime(t, "2026-07-15T10:00:00Z")
got := formatWhen(node, nil, false, loc, now)
if !strings.HasPrefix(got, "2026-07-15 12:05:06") {
t.Fatalf("expected +08 time, got %q", got)
}
}
func TestFormatWhen_Relative(t *testing.T) {
node := &puppet.Node{ReportTimestamp: "2026-07-15T07:00:00Z"}
now := mustTime(t, "2026-07-15T10:00:00Z")
if got := formatWhen(node, nil, true, time.UTC, now); got != "3h ago" {
t.Fatalf("expected '3h ago', got %q", got)
}
}
func TestFormatWhen_EdgeCases(t *testing.T) {
now := mustTime(t, "2026-07-15T10:00:00Z")
if got := formatWhen(nil, errors.New("down"), false, time.UTC, now); !strings.HasPrefix(got, "error:") {
t.Fatalf("expected error passthrough, got %q", got)
}
if got := formatWhen(nil, nil, false, time.UTC, now); got != "unknown node" {
t.Fatalf("expected unknown node, got %q", got)
}
if got := formatWhen(&puppet.Node{ReportTimestamp: ""}, nil, false, time.UTC, now); got != "no report" {
t.Fatalf("expected no report, got %q", got)
}
if got := formatWhen(&puppet.Node{ReportTimestamp: "garbage"}, nil, false, time.UTC, now); got != "garbage" {
t.Fatalf("expected raw fallback, got %q", got)
}
}
func TestHumanizeSince(t *testing.T) {
base := mustTime(t, "2026-07-15T10:00:00Z")
cases := []struct {
ts string
want string
}{
{"2026-07-15T09:59:30Z", "30s ago"},
{"2026-07-15T09:45:00Z", "15m ago"},
{"2026-07-15T05:00:00Z", "5h ago"},
{"2026-07-13T10:00:00Z", "2d ago"},
{"2026-07-15T10:01:00Z", "1m from now"},
}
for _, c := range cases {
if got := humanizeSince(base, mustTime(t, c.ts)); got != c.want {
t.Errorf("humanizeSince(%s) = %q, want %q", c.ts, got, c.want)
}
}
}
+63
View File
@@ -0,0 +1,63 @@
// Command pburl prints the Puppetboard node-page URL for each host it is given.
//
// It reads hostnames from its arguments or from piped node-lookup output and
// emits "<host> <puppetboard-url>" per line:
//
// node-lookup -R | pburl
// pburl host1.example.net host2.example.net
package main
import (
"fmt"
"os"
"node-lookup/internal/puppet"
"github.com/spf13/cobra"
)
var version = "dev"
func main() {
cfg, err := puppet.Load()
if err != nil {
fmt.Fprintln(os.Stderr, "config error:", err)
os.Exit(1)
}
var boardURL string
root := &cobra.Command{
Use: "pburl [host...]",
Short: "Print the Puppetboard node-page URL for each host.",
Long: "Reads hostnames from arguments or piped node-lookup output and prints\n" +
"'<host> <puppetboard-url>' for each. Example: node-lookup -R | pburl",
Args: cobra.ArbitraryArgs,
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
if cmd.Flags().Changed("puppetboard-url") {
cfg.PuppetboardURL = boardURL
}
hosts := puppet.ReadHosts(os.Stdin, args)
if len(hosts) == 0 {
return fmt.Errorf("no hosts given (pass as arguments or pipe node-lookup output)")
}
for _, h := range hosts {
fmt.Printf("%s %s\n", h, puppet.HostPageURL(cfg.PuppetboardURL, h))
}
return nil
},
}
root.Flags().StringVar(&boardURL, "puppetboard-url", cfg.PuppetboardURL, "Puppetboard base URL (overrides config and NODE_LOOKUP_PUPPETBOARD_URL)")
root.AddCommand(&cobra.Command{
Use: "version",
Short: "Print the version",
Run: func(cmd *cobra.Command, args []string) { fmt.Println(version) },
SilenceUsage: true,
})
if err := root.Execute(); err != nil {
os.Exit(1)
}
}
+7 -4
View File
@@ -3,8 +3,11 @@ module node-lookup
go 1.25.7
require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/spf13/cobra v1.10.2 // indirect
github.com/spf13/pflag v1.0.9 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
github.com/spf13/cobra v1.10.2
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/spf13/pflag v1.0.9 // indirect
)
+1
View File
@@ -7,6 +7,7 @@ github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiT
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+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
}
+11 -1
View File
@@ -21,6 +21,7 @@ import (
const (
defaultPuppetDBURL = "http://puppetdbapi.service.consul:8080/pdb/query/v4/facts"
defaultRoleFact = "enc_role"
defaultPuppetboardURL = "https://puppetboard.k8s.syd1.au.unkin.net"
configFileName = "config.yaml"
appName = "node-lookup"
)
@@ -32,12 +33,17 @@ var version = "dev"
type config struct {
PuppetDBURL string `yaml:"puppetdb_url"`
RoleFact string `yaml:"role_fact"`
// PuppetboardURL is not used by node-lookup itself; it is scaffolded here so
// the shared config file also configures the companion tools (pburl,
// pblastreport) that read this same file.
PuppetboardURL string `yaml:"puppetboard_url"`
}
func defaultConfig() config {
return config{
PuppetDBURL: defaultPuppetDBURL,
RoleFact: defaultRoleFact,
PuppetboardURL: defaultPuppetboardURL,
}
}
@@ -78,6 +84,9 @@ func loadConfig() (config, error) {
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
}
@@ -96,7 +105,7 @@ func writeDefaultConfig() error {
cfg := defaultConfig()
data, _ := yaml.Marshal(cfg)
header := []byte("# node-lookup configuration\n# Fields can be overridden with env vars: NODE_LOOKUP_URL, NODE_LOOKUP_ROLE_FACT\n\n")
header := []byte("# node-lookup configuration\n# Fields can be overridden with env vars: NODE_LOOKUP_URL, NODE_LOOKUP_ROLE_FACT, NODE_LOOKUP_PUPPETBOARD_URL\n# puppetboard_url is used by the companion tools (pburl, pblastreport).\n\n")
if err := os.WriteFile(path, append(header, data...), 0o644); err != nil {
return fmt.Errorf("writing config: %w", err)
}
@@ -496,6 +505,7 @@ func main() {
fmt.Printf("config file : %s\n", configPath())
fmt.Printf("puppetdb_url : %s\n", cfg.PuppetDBURL)
fmt.Printf("role_fact : %s\n", cfg.RoleFact)
fmt.Printf("puppetboard_url: %s\n", cfg.PuppetboardURL)
return nil
},
SilenceUsage: true,
+37 -1
View File
@@ -23,13 +23,25 @@ provides:
- node-lookup
contents:
# The CLI binary.
# The CLI binaries: node-lookup and its companion tools.
- src: dist/node-lookup
dst: /usr/bin/node-lookup
file_info:
mode: 0755
owner: root
group: root
- src: dist/pburl
dst: /usr/bin/pburl
file_info:
mode: 0755
owner: root
group: root
- src: dist/pblastreport
dst: /usr/bin/pblastreport
file_info:
mode: 0755
owner: root
group: root
# Shell completions (generated by scripts/build-rpm.sh before packaging).
- src: dist/completions/node-lookup.bash
@@ -44,3 +56,27 @@ contents:
dst: /usr/share/fish/vendor_completions.d/node-lookup.fish
file_info:
mode: 0644
- src: dist/completions/pburl.bash
dst: /usr/share/bash-completion/completions/pburl
file_info:
mode: 0644
- src: dist/completions/_pburl
dst: /usr/share/zsh/site-functions/_pburl
file_info:
mode: 0644
- src: dist/completions/pburl.fish
dst: /usr/share/fish/vendor_completions.d/pburl.fish
file_info:
mode: 0644
- src: dist/completions/pblastreport.bash
dst: /usr/share/bash-completion/completions/pblastreport
file_info:
mode: 0644
- src: dist/completions/_pblastreport
dst: /usr/share/zsh/site-functions/_pblastreport
file_info:
mode: 0644
- src: dist/completions/pblastreport.fish
dst: /usr/share/fish/vendor_completions.d/pblastreport.fish
file_info:
mode: 0644
+15 -10
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env bash
#
# Package the (already built) node-lookup binary into an RPM with nfpm,
# bundling generated bash/zsh/fish shell completions.
# Package the (already built) node-lookup, pburl and pblastreport binaries into
# an RPM with nfpm, bundling generated bash/zsh/fish shell completions.
# Usage: scripts/build-rpm.sh [version] (version defaults to $CI_COMMIT_TAG)
#
set -euo pipefail
@@ -12,27 +12,32 @@ cd "${ROOT_DIR}"
VERSION="${1:-${CI_COMMIT_TAG:-0.0.0-dev}}"
VERSION="${VERSION#v}" # strip a leading v
BINARY="node-lookup"
BINARIES=(node-lookup pburl pblastreport)
DIST="dist"
if [ ! -f "${DIST}/${BINARY}" ]; then
echo "ERROR: ${DIST}/${BINARY} not found; run 'make build' first" >&2
for b in "${BINARIES[@]}"; do
if [ ! -f "${DIST}/${b}" ]; then
echo "ERROR: ${DIST}/${b} not found; run 'make build' first" >&2
exit 1
fi
done
# Generate shell completions from the freshly built binary so they always match
# the shipped flags/subcommands.
# Generate shell completions from the freshly built binaries so they always
# match the shipped flags/subcommands.
COMP_DIR="${DIST}/completions"
mkdir -p "${COMP_DIR}"
"./${DIST}/${BINARY}" completion bash >"${COMP_DIR}/${BINARY}.bash"
"./${DIST}/${BINARY}" completion zsh >"${COMP_DIR}/_${BINARY}"
"./${DIST}/${BINARY}" completion fish >"${COMP_DIR}/${BINARY}.fish"
for b in "${BINARIES[@]}"; do
"./${DIST}/${b}" completion bash >"${COMP_DIR}/${b}.bash"
"./${DIST}/${b}" completion zsh >"${COMP_DIR}/_${b}"
"./${DIST}/${b}" completion fish >"${COMP_DIR}/${b}.fish"
done
export PACKAGE_NAME="${BINARY}"
export PACKAGE_VERSION="${VERSION}"
export PACKAGE_RELEASE="1"
export PACKAGE_ARCH="amd64"
export PACKAGE_PLATFORM="linux"
export PACKAGE_DESCRIPTION="CLI tool that queries the PuppetDB API to look up and filter node facts"
export PACKAGE_DESCRIPTION="CLI tools for PuppetDB: node-lookup (fact lookup/filtering) plus pburl and pblastreport (Puppetboard URLs and last-report times)"
export PACKAGE_MAINTAINER="Ben Vincent <ben@unkin.net>"
export PACKAGE_HOMEPAGE="https://git.unkin.net/unkin/node-lookup"
export PACKAGE_LICENSE="MIT"