Files
node-lookup/cmd/pblastreport/report_test.go
T
unkinben f296056360
ci/woodpecker/tag/release Pipeline failed
Add pburl and pblastreport companion tools to the RPM (#15)
## 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>
2026-07-16 22:37:26 +10:00

85 lines
2.4 KiB
Go

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)
}
}
}