diff --git a/main.go b/main.go index 652207a..234febc 100644 --- a/main.go +++ b/main.go @@ -213,14 +213,34 @@ func isTerminal(f *os.File) bool { return (fi.Mode() & os.ModeCharDevice) != 0 } -func run(cfg config, nodeName, factName, match, partialMatch 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) + 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 == "" { 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 { @@ -229,7 +249,7 @@ func run(cfg config, nodeName, factName, match, partialMatch string, showRole, n if err != nil { return err } - allFacts = append(allFacts, facts...) + collected = append(collected, facts...) return nil } @@ -256,12 +276,12 @@ func run(cfg config, nodeName, factName, match, partialMatch string, showRole, n } } - returnData := processResults(allFacts) + returnData := processResults(collected) switch { case jsonMode: hostFactMap := map[string]map[string]interface{}{} - for _, f := range allFacts { + for _, f := range collected { if _, ok := hostFactMap[f.Certname]; !ok { hostFactMap[f.Certname] = map[string]interface{}{} } @@ -337,6 +357,7 @@ func main() { count bool ansible bool jsonMode bool + allFacts bool puppetDBURL string ) @@ -350,7 +371,7 @@ func main() { return nil }, RunE: func(cmd *cobra.Command, args []string) error { - return run(cfg, nodeName, factName, match, partialMatch, showRole, nodeOnly, valueOnly, count, ansible, jsonMode) + return run(cfg, nodeName, factName, match, partialMatch, showRole, nodeOnly, valueOnly, count, ansible, jsonMode, allFacts) }, SilenceUsage: true, } @@ -366,6 +387,7 @@ func main() { f.BoolVarP(&count, "count", "C", false, "Count fact occurrences") f.BoolVarP(&ansible, "ansible", "A", false, "Output as Ansible inventory") 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)") configCmd := &cobra.Command{ diff --git a/main_test.go b/main_test.go index 49cc8fe..d22424a 100644 --- a/main_test.go +++ b/main_test.go @@ -2,6 +2,7 @@ package main import ( "encoding/json" + "io" "net/http" "net/http/httptest" "os" @@ -321,3 +322,104 @@ func TestWriteDefaultConfig_AlreadyExists(t *testing.T) { 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]) + } +}