8800d5ce35
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.
178 lines
5.3 KiB
Go
178 lines
5.3 KiB
Go
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)
|
|
}
|
|
}
|