Add pburl and pblastreport companion tools to the RPM
node-lookup output is useful 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 tools close that gap and ship in the same RPM so they are available wherever node-lookup is. - Add pburl: reads hostnames from args or piped node-lookup output 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, and no-TTY-safe stdin host reading. - Add puppetboard_url config key (env NODE_LOOKUP_PUPPETBOARD_URL) to the shared config so config init/show scaffold it for the whole tool family. - Build all three binaries individually (each is its own main package) and generate per-binary bash/zsh/fish completions in the Makefile, build-rpm.sh, and nfpm spec; cross-compile and attach all three per os/arch in the release pipeline. - Document the tools, config key, and env var in AGENTS.md.
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user