3 Commits

Author SHA1 Message Date
unkinben 13b0f12edf Merge pull request 'feat/all-facts-flag' (#1) from feat/all-facts-flag into master
Reviewed-on: #1
2026-03-25 17:01:07 +11:00
unkinben 2acff78d02 Add tests for -a (all facts) flag 2026-03-25 15:15:23 +11:00
unkinben e62e69bbbc Add -a flag to show all facts for a node
-a requires -n and prints all PuppetDB facts for the specified node
as 'fact_name  value' sorted alphabetically by fact name, useful for
discovering available facts to query against.
2026-03-25 15:08:18 +11:00
3 changed files with 159 additions and 41 deletions
+2 -3
View File
@@ -29,7 +29,7 @@ Requires Go 1.21+. Dependencies: `github.com/spf13/cobra` (CLI), `gopkg.in/yaml.
./node-lookup -n <hostname> # lookup a specific node ./node-lookup -n <hostname> # lookup a specific node
./node-lookup -F <fact_name> # filter by fact name ./node-lookup -F <fact_name> # filter by fact name
./node-lookup -m <value> # exact value match ./node-lookup -m <value> # exact value match
./node-lookup -p <pattern> # partial/regex match on value (also --pm) ./node-lookup --pm <pattern> # partial/regex match on value
./node-lookup -R -1 # node names only ./node-lookup -R -1 # node names only
./node-lookup -R -2 # values only ./node-lookup -R -2 # values only
./node-lookup -R -C # count occurrences ./node-lookup -R -C # count occurrences
@@ -94,7 +94,6 @@ No test suite exists. Manual testing requires access to the Consul/PuppetDB envi
## Gotchas ## Gotchas
- `-1`, `-2`, `-C`, and `-A` all require `-R` or `-F`; the tool exits with an error otherwise. - `-1`, `-2`, `-C`, and `-A` all require `-R` or `-F`; the tool exits with an error otherwise.
- `-C` (count) with stdin extracts the first field of each line as the node name, queries PuppetDB per node, then counts the resulting values. - `-C` (count) with stdin reads all lines as pre-fetched `"node value"` output for counting — it does **not** query PuppetDB per line.
- JSON output (`-j`) builds `{ hostname: { factname: value } }` where the fact key is the `-F` value, the `role_fact` config value (if `-R`), or `"value"` as fallback. - JSON output (`-j`) builds `{ hostname: { factname: value } }` where the fact key is the `-F` value, the `role_fact` config value (if `-R`), or `"value"` as fallback.
- `config init` fails if the config file already exists (will not overwrite). - `config init` fails if the config file already exists (will not overwrite).
- `--pm` has shorthand `-p`. Use `-p <pattern>` or `--pm <pattern>` — not `-pm <pattern>` (pflag parses single-dash multi-char as combined shorthands).
+49 -32
View File
@@ -108,7 +108,7 @@ type fact struct {
Value json.RawMessage `json:"value"` Value json.RawMessage `json:"value"`
} }
func buildQuery(node, factName, match, partialMatch, inverseMatch, roleFact string, showRole bool) string { func buildQuery(node, factName, match, partialMatch, roleFact string, showRole bool) string {
type filter = []interface{} type filter = []interface{}
var filters []filter var filters []filter
@@ -125,8 +125,6 @@ func buildQuery(node, factName, match, partialMatch, inverseMatch, roleFact stri
filters = append(filters, filter{"=", "value", match}) filters = append(filters, filter{"=", "value", match})
} else if partialMatch != "" { } else if partialMatch != "" {
filters = append(filters, filter{"~", "value", partialMatch}) filters = append(filters, filter{"~", "value", partialMatch})
} else if inverseMatch != "" {
filters = append(filters, filter{"not", filter{"~", "value", inverseMatch}})
} }
if len(filters) == 0 { if len(filters) == 0 {
@@ -215,34 +213,61 @@ func isTerminal(f *os.File) bool {
return (fi.Mode() & os.ModeCharDevice) != 0 return (fi.Mode() & os.ModeCharDevice) != 0
} }
func run(cfg config, nodeName, factName, match, partialMatch, inverseMatch string, showRole, nodeOnly, valueOnly, count, ansible, jsonMode bool) error { func allFactsForNode(puppetDBURL, node string) ([]fact, error) {
query, _ := json.Marshal([]interface{}{"=", "certname", node})
return queryPuppetDB(puppetDBURL, string(query))
}
func run(cfg config, nodeName, factName, match, partialMatch string, showRole, nodeOnly, valueOnly, count, ansible, jsonMode, allFacts bool) error {
signal.Ignore(syscall.SIGPIPE) signal.Ignore(syscall.SIGPIPE)
if allFacts {
if nodeName == "" {
return fmt.Errorf("-a requires -n")
}
facts, err := allFactsForNode(cfg.PuppetDBURL, nodeName)
if err != nil {
return err
}
sort.Slice(facts, func(i, j int) bool { return facts[i].Name < facts[j].Name })
for _, f := range facts {
fmt.Printf("%-40s %s\n", f.Name, valueString(f.Value))
}
return nil
}
if (nodeOnly || valueOnly || count || ansible) && !showRole && factName == "" { if (nodeOnly || valueOnly || count || ansible) && !showRole && factName == "" {
return fmt.Errorf("-R or -F must be used with -1, -2, -C, or -A") return fmt.Errorf("-R or -F must be used with -1, -2, -C, or -A")
} }
var allFacts []fact var collected []fact
var stdinLines []string
doQuery := func(node string) error { doQuery := func(node string) error {
query := buildQuery(node, factName, match, partialMatch, inverseMatch, cfg.RoleFact, showRole) query := buildQuery(node, factName, match, partialMatch, cfg.RoleFact, showRole)
facts, err := queryPuppetDB(cfg.PuppetDBURL, query) facts, err := queryPuppetDB(cfg.PuppetDBURL, query)
if err != nil { if err != nil {
return err return err
} }
allFacts = append(allFacts, facts...) collected = append(collected, facts...)
return nil return nil
} }
if nodeName == "" && !isTerminal(os.Stdin) { if nodeName == "" && !isTerminal(os.Stdin) {
scanner := bufio.NewScanner(os.Stdin) scanner := bufio.NewScanner(os.Stdin)
for scanner.Scan() { if count {
fields := strings.Fields(scanner.Text()) for scanner.Scan() {
if len(fields) == 0 { stdinLines = append(stdinLines, scanner.Text())
continue
} }
if err := doQuery(fields[0]); err != nil { } else {
fmt.Fprintln(os.Stderr, "error:", err) for scanner.Scan() {
fields := strings.Fields(scanner.Text())
if len(fields) == 0 {
continue
}
if err := doQuery(fields[0]); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
}
} }
} }
} else { } else {
@@ -251,12 +276,12 @@ func run(cfg config, nodeName, factName, match, partialMatch, inverseMatch strin
} }
} }
returnData := processResults(allFacts) returnData := processResults(collected)
switch { switch {
case jsonMode: case jsonMode:
hostFactMap := map[string]map[string]interface{}{} hostFactMap := map[string]map[string]interface{}{}
for _, f := range allFacts { for _, f := range collected {
if _, ok := hostFactMap[f.Certname]; !ok { if _, ok := hostFactMap[f.Certname]; !ok {
hostFactMap[f.Certname] = map[string]interface{}{} hostFactMap[f.Certname] = map[string]interface{}{}
} }
@@ -276,7 +301,11 @@ func run(cfg config, nodeName, factName, match, partialMatch, inverseMatch strin
enc.Encode(hostFactMap) enc.Encode(hostFactMap)
case count: case count:
fmt.Println(strings.Join(countResults(returnData), "\n")) values := stdinLines
if len(values) == 0 {
values = returnData
}
fmt.Println(strings.Join(countResults(values), "\n"))
case ansible: case ansible:
hosts := map[string]interface{}{} hosts := map[string]interface{}{}
@@ -311,18 +340,6 @@ func run(cfg config, nodeName, factName, match, partialMatch, inverseMatch strin
} }
func main() { func main() {
for i, arg := range os.Args {
if arg == "-pm" {
os.Args[i] = "--pm"
} else if strings.HasPrefix(arg, "-pm=") {
os.Args[i] = "--pm=" + arg[4:]
} else if arg == "-im" {
os.Args[i] = "--im"
} else if strings.HasPrefix(arg, "-im=") {
os.Args[i] = "--im=" + arg[4:]
}
}
cfg, err := loadConfig() cfg, err := loadConfig()
if err != nil { if err != nil {
fmt.Fprintln(os.Stderr, "config error:", err) fmt.Fprintln(os.Stderr, "config error:", err)
@@ -335,12 +352,12 @@ func main() {
showRole bool showRole bool
match string match string
partialMatch string partialMatch string
inverseMatch string
nodeOnly bool nodeOnly bool
valueOnly bool valueOnly bool
count bool count bool
ansible bool ansible bool
jsonMode bool jsonMode bool
allFacts bool
puppetDBURL string puppetDBURL string
) )
@@ -354,7 +371,7 @@ func main() {
return nil return nil
}, },
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
return run(cfg, nodeName, factName, match, partialMatch, inverseMatch, showRole, nodeOnly, valueOnly, count, ansible, jsonMode) return run(cfg, nodeName, factName, match, partialMatch, showRole, nodeOnly, valueOnly, count, ansible, jsonMode, allFacts)
}, },
SilenceUsage: true, SilenceUsage: true,
} }
@@ -364,13 +381,13 @@ func main() {
f.StringVarP(&factName, "fact", "F", "", "Fact name") f.StringVarP(&factName, "fact", "F", "", "Fact name")
f.BoolVarP(&showRole, "role", "R", false, "Show role fact ("+defaultRoleFact+" by default)") f.BoolVarP(&showRole, "role", "R", false, "Show role fact ("+defaultRoleFact+" by default)")
f.StringVarP(&match, "match", "m", "", "Exact value match") f.StringVarP(&match, "match", "m", "", "Exact value match")
f.StringVar(&partialMatch, "pm", "", "Partial/regex match on value") f.StringVar(&partialMatch, "pm", "", "Partial match on value")
f.StringVar(&inverseMatch, "im", "", "Inverse partial/regex match on value")
f.BoolVarP(&nodeOnly, "nodeonly", "1", false, "Show only the node name") f.BoolVarP(&nodeOnly, "nodeonly", "1", false, "Show only the node name")
f.BoolVarP(&valueOnly, "valueonly", "2", false, "Show only the value") f.BoolVarP(&valueOnly, "valueonly", "2", false, "Show only the value")
f.BoolVarP(&count, "count", "C", false, "Count fact occurrences") f.BoolVarP(&count, "count", "C", false, "Count fact occurrences")
f.BoolVarP(&ansible, "ansible", "A", false, "Output as Ansible inventory") f.BoolVarP(&ansible, "ansible", "A", false, "Output as Ansible inventory")
f.BoolVarP(&jsonMode, "json", "j", false, "Emit valid JSON for all output") f.BoolVarP(&jsonMode, "json", "j", false, "Emit valid JSON for all output")
f.BoolVarP(&allFacts, "all", "a", false, "Show all facts for a node (requires -n)")
rootCmd.PersistentFlags().StringVar(&puppetDBURL, "url", cfg.PuppetDBURL, "PuppetDB facts URL (overrides config and NODE_LOOKUP_URL)") rootCmd.PersistentFlags().StringVar(&puppetDBURL, "url", cfg.PuppetDBURL, "PuppetDB facts URL (overrides config and NODE_LOOKUP_URL)")
configCmd := &cobra.Command{ configCmd := &cobra.Command{
+108 -6
View File
@@ -2,6 +2,7 @@ package main
import ( import (
"encoding/json" "encoding/json"
"io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os" "os"
@@ -36,42 +37,42 @@ func rawJSON(v interface{}) json.RawMessage {
// ---- buildQuery ------------------------------------------------------------- // ---- buildQuery -------------------------------------------------------------
func TestBuildQuery_NoFilters(t *testing.T) { func TestBuildQuery_NoFilters(t *testing.T) {
q := buildQuery("", "", "", "", "", "enc_role", false) q := buildQuery("", "", "", "", "enc_role", false)
if !strings.Contains(q, "enc_role") { if !strings.Contains(q, "enc_role") {
t.Fatalf("expected default role query, got %s", q) t.Fatalf("expected default role query, got %s", q)
} }
} }
func TestBuildQuery_Node(t *testing.T) { func TestBuildQuery_Node(t *testing.T) {
q := buildQuery("host1", "", "", "", "", "enc_role", false) q := buildQuery("host1", "", "", "", "enc_role", false)
if !strings.Contains(q, "certname") || !strings.Contains(q, "host1") { if !strings.Contains(q, "certname") || !strings.Contains(q, "host1") {
t.Fatalf("unexpected query: %s", q) t.Fatalf("unexpected query: %s", q)
} }
} }
func TestBuildQuery_FactAndMatch(t *testing.T) { func TestBuildQuery_FactAndMatch(t *testing.T) {
q := buildQuery("", "region", "syd1", "", "", "enc_role", false) q := buildQuery("", "region", "syd1", "", "enc_role", false)
if !strings.Contains(q, "region") || !strings.Contains(q, "syd1") { if !strings.Contains(q, "region") || !strings.Contains(q, "syd1") {
t.Fatalf("unexpected query: %s", q) t.Fatalf("unexpected query: %s", q)
} }
} }
func TestBuildQuery_PartialMatch(t *testing.T) { func TestBuildQuery_PartialMatch(t *testing.T) {
q := buildQuery("", "enc_role", "", "dns", "", "enc_role", false) q := buildQuery("", "enc_role", "", "dns", "enc_role", false)
if !strings.Contains(q, "~") || !strings.Contains(q, "dns") { if !strings.Contains(q, "~") || !strings.Contains(q, "dns") {
t.Fatalf("expected partial match query, got %s", q) t.Fatalf("expected partial match query, got %s", q)
} }
} }
func TestBuildQuery_ShowRole(t *testing.T) { func TestBuildQuery_ShowRole(t *testing.T) {
q := buildQuery("", "", "", "", "", "my_role_fact", true) q := buildQuery("", "", "", "", "my_role_fact", true)
if !strings.Contains(q, "my_role_fact") { if !strings.Contains(q, "my_role_fact") {
t.Fatalf("expected role fact in query, got %s", q) t.Fatalf("expected role fact in query, got %s", q)
} }
} }
func TestBuildQuery_CustomRoleFact(t *testing.T) { func TestBuildQuery_CustomRoleFact(t *testing.T) {
q := buildQuery("", "", "", "", "", "custom_role", true) q := buildQuery("", "", "", "", "custom_role", true)
if !strings.Contains(q, "custom_role") { if !strings.Contains(q, "custom_role") {
t.Fatalf("expected custom role fact, got %s", q) t.Fatalf("expected custom role fact, got %s", q)
} }
@@ -321,3 +322,104 @@ func TestWriteDefaultConfig_AlreadyExists(t *testing.T) {
t.Fatal("expected error when config already exists") t.Fatal("expected error when config already exists")
} }
} }
// ---- allFactsForNode --------------------------------------------------------
func TestAllFactsForNode_ReturnsSortedFacts(t *testing.T) {
facts := []fact{
{Certname: "node1", Name: "zebra", Value: rawJSON("z-val")},
{Certname: "node1", Name: "alpha", Value: rawJSON("a-val")},
{Certname: "node1", Name: "middle", Value: rawJSON(42)},
}
srv := newTestServer(t, facts)
defer srv.Close()
got, err := allFactsForNode(srv.URL+"/pdb/query/v4/facts", "node1")
if err != nil {
t.Fatal(err)
}
if len(got) != 3 {
t.Fatalf("expected 3 facts, got %d", len(got))
}
}
func TestAllFactsForNode_QueryContainsCertname(t *testing.T) {
var receivedQuery string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
receivedQuery = r.URL.Query().Get("query")
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode([]fact{})
}))
defer srv.Close()
allFactsForNode(srv.URL+"/pdb/query/v4/facts", "mynode.example.com")
if !strings.Contains(receivedQuery, "mynode.example.com") {
t.Fatalf("expected certname in query, got: %s", receivedQuery)
}
if !strings.Contains(receivedQuery, "certname") {
t.Fatalf("expected 'certname' in query, got: %s", receivedQuery)
}
}
func TestAllFactsForNode_HTTPError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "internal error", http.StatusInternalServerError)
}))
defer srv.Close()
_, err := allFactsForNode(srv.URL+"/pdb/query/v4/facts", "node1")
if err == nil {
t.Fatal("expected error for HTTP 500")
}
}
// ---- run with -a flag -------------------------------------------------------
func TestRun_AllFacts_RequiresNode(t *testing.T) {
cfg := config{PuppetDBURL: "http://unused", RoleFact: "enc_role"}
err := run(cfg, "", "", "", "", false, false, false, false, false, false, true)
if err == nil || !strings.Contains(err.Error(), "-a requires -n") {
t.Fatalf("expected -a requires -n error, got: %v", err)
}
}
func TestRun_AllFacts_PrintsSortedByName(t *testing.T) {
facts := []fact{
{Certname: "node1", Name: "zzz_fact", Value: rawJSON("last")},
{Certname: "node1", Name: "aaa_fact", Value: rawJSON("first")},
{Certname: "node1", Name: "mmm_fact", Value: rawJSON(true)},
}
srv := newTestServer(t, facts)
defer srv.Close()
old := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w
cfg := config{PuppetDBURL: srv.URL + "/pdb/query/v4/facts", RoleFact: "enc_role"}
err := run(cfg, "node1", "", "", "", false, false, false, false, false, false, true)
w.Close()
os.Stdout = old
var buf strings.Builder
io.Copy(&buf, r)
out := buf.String()
if err != nil {
t.Fatal(err)
}
lines := strings.Split(strings.TrimSpace(out), "\n")
if len(lines) != 3 {
t.Fatalf("expected 3 lines, got %d: %q", len(lines), out)
}
if !strings.HasPrefix(lines[0], "aaa_fact") {
t.Errorf("first line should be aaa_fact, got: %s", lines[0])
}
if !strings.HasPrefix(lines[1], "mmm_fact") {
t.Errorf("second line should be mmm_fact, got: %s", lines[1])
}
if !strings.HasPrefix(lines[2], "zzz_fact") {
t.Errorf("third line should be zzz_fact, got: %s", lines[2])
}
}