diff --git a/AGENTS.md b/AGENTS.md index 8eada4b..4cfa2ff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -68,7 +68,7 @@ installed. To load ad-hoc in the current shell, e.g. zsh: ./node-lookup -R -1 # node names only ./node-lookup -R -2 # values only ./node-lookup -R -C # count occurrences -./node-lookup -R -A # output as Ansible YAML inventory +./node-lookup -R -A # output as Ansible YAML inventory (queried facts become host vars) ./node-lookup -j # output as JSON { host → { fact → value } } ./node-lookup --url http://host:8080/... # override PuppetDB URL for this invocation echo -e "node1\nnode2" | ./node-lookup -R # pipe node names via stdin @@ -116,7 +116,7 @@ Show the active configuration (after all overrides applied): - **Match value / `matchValue()`**: the value to match comes from `-m/--match` or, if that is empty, an optional positional argument. The positional fallback exists because pflag does not attach a space-separated value to a string flag grouped with a bool flag, so in `-pm k8s` the `k8s` arrives as a positional. `-m` still wins when both are given. - **`queryPuppetDB(url, query)`**: takes the URL as a parameter — never reads globals. - **`processResults()`**: iterates facts, returns sorted `"certname value"` strings. JSON string values are unquoted; other JSON types rendered as compact JSON. -- **Output modes**: JSON (`-j`), count (`-C`), Ansible YAML (`-A`), node-only (`-1`), value-only (`-2`), default (node + value). +- **Output modes**: JSON (`-j`), count (`-C`), Ansible YAML (`-A`), node-only (`-1`), value-only (`-2`), default (node + value). `-j` and `-A` share `factsByHost()`, so both attach the queried fact(s) per host — as an object under the host (`-j`) or as inventory host vars (`-A`). - **Stdin support**: `stdinReader()` reads node names from stdin only when it is a real pipe/redirect carrying data (and no `-n` given). Terminals, `/dev/null`, and empty/closed pipes fall through to a normal query — so running without a TTY (e.g. invoked by an agent or CI) behaves like an interactive run instead of consuming empty input. - **SIGPIPE handling**: `signal.Ignore(syscall.SIGPIPE)` so pipes to `head` etc. work cleanly. diff --git a/main.go b/main.go index e1cdd98..623857f 100644 --- a/main.go +++ b/main.go @@ -276,6 +276,31 @@ func matchValue(flagMatch string, args []string) string { return "" } +// factsByHost groups collected facts into {certname: {factname: value}}. Each +// value is keyed by the fact's real name, so multiple -F facts each appear +// under the host; it falls back to the -F string, the role fact (with -R), or +// "value" only when a result carries no name. Shared by the -j and -A outputs. +func factsByHost(collected []fact, factName, roleFact string, showRole bool) map[string]map[string]interface{} { + out := map[string]map[string]interface{}{} + for _, f := range collected { + if _, ok := out[f.Certname]; !ok { + out[f.Certname] = map[string]interface{}{} + } + key := f.Name + if key == "" { + if key = factName; key == "" { + if showRole { + key = roleFact + } else { + key = "value" + } + } + } + out[f.Certname][key] = valueAny(f.Value) + } + return out +} + func allFactsForNode(puppetDBURL, node string) ([]fact, error) { query, _ := json.Marshal([]interface{}{"=", "certname", node}) return queryPuppetDB(puppetDBURL, string(query)) @@ -346,30 +371,10 @@ func run(cfg config, nodeName, factName, match string, showRole, partial, invers switch { case jsonMode: - hostFactMap := map[string]map[string]interface{}{} - for _, f := range collected { - if _, ok := hostFactMap[f.Certname]; !ok { - hostFactMap[f.Certname] = map[string]interface{}{} - } - // Key by the fact's actual name so multiple -F facts (e.g. - // ipaddress,enc_role) each appear under the host. Fall back to the - // computed key only if the result carries no name. - key := f.Name - if key == "" { - if key = factName; key == "" { - if showRole { - key = cfg.RoleFact - } else { - key = "value" - } - } - } - hostFactMap[f.Certname][key] = valueAny(f.Value) - } enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") enc.SetEscapeHTML(false) - _ = enc.Encode(hostFactMap) + _ = enc.Encode(factsByHost(collected, factName, cfg.RoleFact, showRole)) case count: values := stdinLines @@ -379,10 +384,11 @@ func run(cfg config, nodeName, factName, match string, showRole, partial, invers fmt.Println(strings.Join(countResults(values), "\n")) case ansible: + // Attach each host's queried fact(s) as inventory host vars, e.g. + // `-F ipaddress,enc_role -A` yields hosts with ipaddress + enc_role set. hosts := map[string]interface{}{} - for _, line := range returnData { - host := strings.Fields(line)[0] - hosts[host] = map[string]interface{}{} + for host, vars := range factsByHost(collected, factName, cfg.RoleFact, showRole) { + hosts[host] = vars } inventory := map[string]interface{}{ "all": map[string]interface{}{"hosts": hosts}, diff --git a/main_test.go b/main_test.go index 125b643..b36af7a 100644 --- a/main_test.go +++ b/main_test.go @@ -9,6 +9,8 @@ import ( "path/filepath" "strings" "testing" + + "gopkg.in/yaml.v3" ) // ---- helpers ---------------------------------------------------------------- @@ -772,6 +774,37 @@ func TestRun_Ansible(t *testing.T) { if !strings.Contains(out, "hosta:") || !strings.Contains(out, "hostb:") { t.Fatalf("expected both hosts in inventory, got: %q", out) } + // The queried fact is attached as a host var. + if !strings.Contains(out, "enc_role: roles::db") || !strings.Contains(out, "enc_role: roles::web") { + t.Fatalf("expected fact host vars in inventory, got: %q", out) + } +} + +func TestRun_Ansible_MultipleFacts(t *testing.T) { + // -F ipaddress,enc_role -A must include both facts as host vars. + facts := []fact{ + {Certname: "hosta", Name: "ipaddress", Value: rawJSON("198.18.0.1")}, + {Certname: "hosta", Name: "enc_role", Value: rawJSON("roles::dns")}, + } + out := runToString(t, facts, func(a *runArgs) { + a.showRole = false + a.factName = "ipaddress,enc_role" + a.ansible = true + }) + + // Parse it back as YAML and assert the structure precisely. + var inv struct { + All struct { + Hosts map[string]map[string]interface{} `yaml:"hosts"` + } `yaml:"all"` + } + if err := yaml.Unmarshal([]byte(out), &inv); err != nil { + t.Fatalf("inventory is not valid YAML: %v (%s)", err, out) + } + host := inv.All.Hosts["hosta"] + if host["ipaddress"] != "198.18.0.1" || host["enc_role"] != "roles::dns" { + t.Fatalf("expected both facts as host vars, got: %v", host) + } } func TestRun_AllFacts_PrintsSortedByName(t *testing.T) {