e070357d3f
## Why The unit tests stopped compiling after the `--pm` → `-p`/`-i` match-modifier refactor was left uncommitted, there was no RPM/completions distribution story, and invoking the tool without a TTY against an empty pipe silently returned nothing. This makes the project releasable and safe to run from agents/CI. ## Changes - Make stdin handling robust: replace the fragile `!isTerminal` check with `stdinReader()`, which only reads node names when stdin is a real pipe/redirect carrying data. Terminals, `/dev/null`, and empty/closed pipes now fall through to a normal query, so running without a TTY behaves like an interactive run. - Repair and expand `main_test.go` to match the current `buildQuery`/`run` signatures; add coverage for the match modifiers, all output modes, config precedence, and the new `stdinReader` logic. `httptest` stubs PuppetDB (no live deps). - Add nfpm packaging (`packaging/nfpm.yaml`, `scripts/build-rpm.sh`): installs the binary to `/usr/bin/node-lookup` and bundles generated bash/zsh/fish completions under the standard system paths. - Rework the Makefile to build into `dist/` and add `completions`/`rpm` targets. - Split PR CI into `build`, `test`, and `pre-commit` workflows and extend `release` to build the RPM and `PUT` it to the artifactapi `rpm-internal` repo. Every step sets a `serviceAccount` and k8s resources. The project directory has also been relocated under `prodenv`. Reviewed-on: #13 Co-authored-by: Ben Vincent <ben@unkin.net> Co-committed-by: Ben Vincent <ben@unkin.net>
702 lines
21 KiB
Go
702 lines
21 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// ---- helpers ----------------------------------------------------------------
|
|
|
|
func newTestServer(t *testing.T, facts []fact) *httptest.Server {
|
|
t.Helper()
|
|
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(facts)
|
|
}))
|
|
}
|
|
|
|
func rawJSON(v interface{}) json.RawMessage {
|
|
b, _ := json.Marshal(v)
|
|
return json.RawMessage(b)
|
|
}
|
|
|
|
// captureStdout redirects os.Stdout for the duration of fn and returns whatever
|
|
// was written. run() writes directly to os.Stdout, so the output modes are
|
|
// exercised end-to-end this way.
|
|
func captureStdout(t *testing.T, fn func()) string {
|
|
t.Helper()
|
|
old := os.Stdout
|
|
r, w, err := os.Pipe()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
os.Stdout = w
|
|
defer func() { os.Stdout = old }()
|
|
|
|
fn()
|
|
|
|
_ = w.Close()
|
|
var buf strings.Builder
|
|
_, _ = io.Copy(&buf, r)
|
|
return buf.String()
|
|
}
|
|
|
|
// ---- buildQuery -------------------------------------------------------------
|
|
|
|
func TestBuildQuery_NoFilters(t *testing.T) {
|
|
// With no node/fact/match, buildQuery falls back to a role-fact query.
|
|
q := buildQuery("", "", "", "enc_role", false, false, false)
|
|
if !strings.Contains(q, "enc_role") || !strings.Contains(q, "name") {
|
|
t.Fatalf("expected default role query, got %s", q)
|
|
}
|
|
}
|
|
|
|
func TestBuildQuery_Node(t *testing.T) {
|
|
q := buildQuery("host1", "", "", "enc_role", false, false, false)
|
|
if !strings.Contains(q, "certname") || !strings.Contains(q, "host1") {
|
|
t.Fatalf("unexpected query: %s", q)
|
|
}
|
|
}
|
|
|
|
func TestBuildQuery_FactAndMatch(t *testing.T) {
|
|
q := buildQuery("", "region", "syd1", "enc_role", false, false, false)
|
|
if !strings.Contains(q, "region") || !strings.Contains(q, "syd1") {
|
|
t.Fatalf("unexpected query: %s", q)
|
|
}
|
|
// exact match uses the "=" operator, never "~".
|
|
if strings.Contains(q, `"~"`) {
|
|
t.Fatalf("exact match should not use ~ operator: %s", q)
|
|
}
|
|
}
|
|
|
|
func TestBuildQuery_PartialMatch(t *testing.T) {
|
|
// -p turns the value match into a regex ("~") comparison.
|
|
q := buildQuery("", "enc_role", "dns", "enc_role", false, true, false)
|
|
if !strings.Contains(q, `"~"`) || !strings.Contains(q, "dns") {
|
|
t.Fatalf("expected partial (~) match query, got %s", q)
|
|
}
|
|
}
|
|
|
|
func TestBuildQuery_InverseMatch(t *testing.T) {
|
|
// -i wraps the value comparison in a "not".
|
|
q := buildQuery("", "enc_role", "dns", "enc_role", false, false, true)
|
|
if !strings.Contains(q, `"not"`) {
|
|
t.Fatalf("expected inverse (not) match query, got %s", q)
|
|
}
|
|
}
|
|
|
|
func TestBuildQuery_InversePartialMatch(t *testing.T) {
|
|
// -i and -p compose: a negated regex match.
|
|
q := buildQuery("", "enc_role", "dns", "enc_role", false, true, true)
|
|
if !strings.Contains(q, `"not"`) || !strings.Contains(q, `"~"`) {
|
|
t.Fatalf("expected inverse partial match query, got %s", q)
|
|
}
|
|
// The regex operator must sit inside the "not" wrapper, not beside it.
|
|
notIdx := strings.Index(q, `"not"`)
|
|
tildeIdx := strings.Index(q, `"~"`)
|
|
if notIdx < 0 || tildeIdx < 0 || tildeIdx < notIdx {
|
|
t.Fatalf("expected ~ nested inside not, got %s", q)
|
|
}
|
|
}
|
|
|
|
func TestBuildQuery_ShowRole(t *testing.T) {
|
|
q := buildQuery("", "", "", "my_role_fact", true, false, false)
|
|
if !strings.Contains(q, "my_role_fact") {
|
|
t.Fatalf("expected role fact in query, got %s", q)
|
|
}
|
|
}
|
|
|
|
func TestBuildQuery_CustomRoleFact(t *testing.T) {
|
|
q := buildQuery("", "", "", "custom_role", true, false, false)
|
|
if !strings.Contains(q, "custom_role") {
|
|
t.Fatalf("expected custom role fact, got %s", q)
|
|
}
|
|
}
|
|
|
|
func TestBuildQuery_FactBeatsRole(t *testing.T) {
|
|
// An explicit -F fact name takes precedence over the role fact.
|
|
q := buildQuery("", "region", "", "enc_role", true, false, false)
|
|
if !strings.Contains(q, "region") {
|
|
t.Fatalf("expected explicit fact name, got %s", q)
|
|
}
|
|
if strings.Contains(q, "enc_role") {
|
|
t.Fatalf("role fact should not appear when -F is set: %s", q)
|
|
}
|
|
}
|
|
|
|
func TestBuildQuery_ValidJSON(t *testing.T) {
|
|
// Whatever the flag combination, the query must be a valid JSON array.
|
|
q := buildQuery("host1", "region", "syd1", "enc_role", false, true, true)
|
|
var v []interface{}
|
|
if err := json.Unmarshal([]byte(q), &v); err != nil {
|
|
t.Fatalf("query is not valid JSON: %v (%s)", err, q)
|
|
}
|
|
if v[0] != "and" {
|
|
t.Fatalf("expected combined query to start with 'and', got %v", v[0])
|
|
}
|
|
}
|
|
|
|
// ---- valueString / valueAny -------------------------------------------------
|
|
|
|
func TestValueString_String(t *testing.T) {
|
|
if got := valueString(rawJSON("hello")); got != "hello" {
|
|
t.Fatalf("expected hello, got %s", got)
|
|
}
|
|
}
|
|
|
|
func TestValueString_Number(t *testing.T) {
|
|
if got := valueString(rawJSON(42)); got != "42" {
|
|
t.Fatalf("expected 42, got %s", got)
|
|
}
|
|
}
|
|
|
|
func TestValueString_Bool(t *testing.T) {
|
|
if got := valueString(rawJSON(true)); got != "true" {
|
|
t.Fatalf("expected true, got %s", got)
|
|
}
|
|
}
|
|
|
|
func TestValueString_Object(t *testing.T) {
|
|
raw := json.RawMessage(`{"a":1}`)
|
|
if got := valueString(raw); got != `{"a":1}` {
|
|
t.Fatalf("expected compact JSON, got %s", got)
|
|
}
|
|
}
|
|
|
|
func TestValueAny_TypesPreserved(t *testing.T) {
|
|
if v := valueAny(rawJSON("s")); v != "s" {
|
|
t.Fatalf("expected string, got %v", v)
|
|
}
|
|
if v := valueAny(rawJSON(3)); v != float64(3) {
|
|
t.Fatalf("expected float64(3), got %T %v", v, v)
|
|
}
|
|
if v := valueAny(rawJSON(true)); v != true {
|
|
t.Fatalf("expected bool true, got %v", v)
|
|
}
|
|
}
|
|
|
|
// ---- processResults ---------------------------------------------------------
|
|
|
|
func TestProcessResults_Sorted(t *testing.T) {
|
|
facts := []fact{
|
|
{Certname: "z-host", Value: rawJSON("val1")},
|
|
{Certname: "a-host", Value: rawJSON("val2")},
|
|
}
|
|
out := processResults(facts)
|
|
if out[0] != "a-host val2" || out[1] != "z-host val1" {
|
|
t.Fatalf("unexpected order: %v", out)
|
|
}
|
|
}
|
|
|
|
func TestProcessResults_Empty(t *testing.T) {
|
|
if out := processResults(nil); len(out) != 0 {
|
|
t.Fatalf("expected empty, got %v", out)
|
|
}
|
|
}
|
|
|
|
// ---- countResults -----------------------------------------------------------
|
|
|
|
func TestCountResults_Basic(t *testing.T) {
|
|
lines := []string{"host1 syd1", "host2 syd1", "host3 mel1"}
|
|
out := countResults(lines)
|
|
found := map[string]bool{}
|
|
for _, l := range out {
|
|
found[l] = true
|
|
}
|
|
if !found["2: syd1"] {
|
|
t.Fatalf("expected '2: syd1' in %v", out)
|
|
}
|
|
if !found["1: mel1"] {
|
|
t.Fatalf("expected '1: mel1' in %v", out)
|
|
}
|
|
}
|
|
|
|
func TestCountResults_Sorted(t *testing.T) {
|
|
// Lexicographic sort of the "N: value" strings: "1: z" < "2: a".
|
|
out := countResults([]string{"h1 z", "h2 a", "h3 a"})
|
|
if out[0] != "1: z" || out[1] != "2: a" {
|
|
t.Fatalf("unexpected order: %v", out)
|
|
}
|
|
}
|
|
|
|
// ---- queryPuppetDB ----------------------------------------------------------
|
|
|
|
func TestQueryPuppetDB_Success(t *testing.T) {
|
|
want := []fact{{Certname: "node1", Name: "enc_role", Value: rawJSON("roles::dns")}}
|
|
srv := newTestServer(t, want)
|
|
defer srv.Close()
|
|
|
|
got, err := queryPuppetDB(srv.URL, `["=","name","enc_role"]`)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(got) != 1 || got[0].Certname != "node1" {
|
|
t.Fatalf("unexpected result: %v", got)
|
|
}
|
|
}
|
|
|
|
func TestQueryPuppetDB_SendsQueryParam(t *testing.T) {
|
|
var got string
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
got = r.URL.Query().Get("query")
|
|
_ = json.NewEncoder(w).Encode([]fact{})
|
|
}))
|
|
defer srv.Close()
|
|
|
|
_, _ = queryPuppetDB(srv.URL, `["=","name","enc_role"]`)
|
|
if got != `["=","name","enc_role"]` {
|
|
t.Fatalf("query param not forwarded correctly, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestQueryPuppetDB_HTTPError(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
http.Error(w, "not found", http.StatusNotFound)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
if _, err := queryPuppetDB(srv.URL, `[]`); err == nil {
|
|
t.Fatal("expected error for 404")
|
|
}
|
|
}
|
|
|
|
func TestQueryPuppetDB_BadJSON(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
_, _ = w.Write([]byte("not json"))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
if _, err := queryPuppetDB(srv.URL, `[]`); err == nil {
|
|
t.Fatal("expected decode error")
|
|
}
|
|
}
|
|
|
|
func TestQueryPuppetDB_ConnectionRefused(t *testing.T) {
|
|
if _, err := queryPuppetDB("http://127.0.0.1:1/facts", `[]`); err == nil {
|
|
t.Fatal("expected connection error")
|
|
}
|
|
}
|
|
|
|
// ---- config -----------------------------------------------------------------
|
|
|
|
func TestLoadConfig_Defaults(t *testing.T) {
|
|
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
|
t.Setenv("NODE_LOOKUP_URL", "")
|
|
t.Setenv("NODE_LOOKUP_ROLE_FACT", "")
|
|
|
|
cfg, err := loadConfig()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if cfg.PuppetDBURL != defaultPuppetDBURL {
|
|
t.Fatalf("expected default URL, got %s", cfg.PuppetDBURL)
|
|
}
|
|
if cfg.RoleFact != defaultRoleFact {
|
|
t.Fatalf("expected default role fact, got %s", cfg.RoleFact)
|
|
}
|
|
}
|
|
|
|
func TestLoadConfig_EnvOverride(t *testing.T) {
|
|
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
|
t.Setenv("NODE_LOOKUP_URL", "http://custom:9090/facts")
|
|
t.Setenv("NODE_LOOKUP_ROLE_FACT", "my_role")
|
|
|
|
cfg, err := loadConfig()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if cfg.PuppetDBURL != "http://custom:9090/facts" {
|
|
t.Fatalf("env override failed: %s", cfg.PuppetDBURL)
|
|
}
|
|
if cfg.RoleFact != "my_role" {
|
|
t.Fatalf("env override failed: %s", cfg.RoleFact)
|
|
}
|
|
}
|
|
|
|
func TestLoadConfig_FileOverride(t *testing.T) {
|
|
dir := t.TempDir()
|
|
t.Setenv("XDG_CONFIG_HOME", dir)
|
|
t.Setenv("NODE_LOOKUP_URL", "")
|
|
t.Setenv("NODE_LOOKUP_ROLE_FACT", "")
|
|
|
|
cfgDir := filepath.Join(dir, appName)
|
|
if err := os.MkdirAll(cfgDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(cfgDir, configFileName), []byte("puppetdb_url: http://file:8080/facts\nrole_fact: file_role\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
cfg, err := loadConfig()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if cfg.PuppetDBURL != "http://file:8080/facts" {
|
|
t.Fatalf("file override failed: %s", cfg.PuppetDBURL)
|
|
}
|
|
if cfg.RoleFact != "file_role" {
|
|
t.Fatalf("file override failed: %s", cfg.RoleFact)
|
|
}
|
|
}
|
|
|
|
func TestLoadConfig_EnvOverridesFile(t *testing.T) {
|
|
dir := t.TempDir()
|
|
t.Setenv("XDG_CONFIG_HOME", dir)
|
|
t.Setenv("NODE_LOOKUP_URL", "http://env:9999/facts")
|
|
t.Setenv("NODE_LOOKUP_ROLE_FACT", "")
|
|
|
|
cfgDir := filepath.Join(dir, appName)
|
|
if err := os.MkdirAll(cfgDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(cfgDir, configFileName), []byte("puppetdb_url: http://file:8080/facts\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
cfg, err := loadConfig()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if cfg.PuppetDBURL != "http://env:9999/facts" {
|
|
t.Fatalf("env should beat file, got %s", cfg.PuppetDBURL)
|
|
}
|
|
}
|
|
|
|
func TestLoadConfig_InvalidYAML(t *testing.T) {
|
|
dir := t.TempDir()
|
|
t.Setenv("XDG_CONFIG_HOME", dir)
|
|
t.Setenv("NODE_LOOKUP_URL", "")
|
|
t.Setenv("NODE_LOOKUP_ROLE_FACT", "")
|
|
|
|
cfgDir := filepath.Join(dir, appName)
|
|
if err := os.MkdirAll(cfgDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(cfgDir, configFileName), []byte(":\tinvalid: yaml:\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if _, err := loadConfig(); err == nil {
|
|
t.Fatal("expected error for invalid YAML")
|
|
}
|
|
}
|
|
|
|
func TestWriteDefaultConfig(t *testing.T) {
|
|
dir := t.TempDir()
|
|
t.Setenv("XDG_CONFIG_HOME", dir)
|
|
|
|
if err := writeDefaultConfig(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
data, err := os.ReadFile(filepath.Join(dir, appName, configFileName))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(string(data), "puppetdb_url") {
|
|
t.Fatalf("config file missing puppetdb_url: %s", data)
|
|
}
|
|
}
|
|
|
|
func TestWriteDefaultConfig_AlreadyExists(t *testing.T) {
|
|
dir := t.TempDir()
|
|
t.Setenv("XDG_CONFIG_HOME", dir)
|
|
|
|
if err := writeDefaultConfig(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := writeDefaultConfig(); err == nil {
|
|
t.Fatal("expected error when config already exists")
|
|
}
|
|
}
|
|
|
|
// ---- allFactsForNode --------------------------------------------------------
|
|
|
|
func TestAllFactsForNode_ReturnsFacts(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, "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")
|
|
_ = json.NewEncoder(w).Encode([]fact{})
|
|
}))
|
|
defer srv.Close()
|
|
|
|
_, _ = allFactsForNode(srv.URL, "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()
|
|
|
|
if _, err := allFactsForNode(srv.URL, "node1"); err == nil {
|
|
t.Fatal("expected error for HTTP 500")
|
|
}
|
|
}
|
|
|
|
// ---- stdinReader (no-TTY behavior) ------------------------------------------
|
|
|
|
func TestStdinReader_CharDeviceFallsThrough(t *testing.T) {
|
|
// /dev/null is a character device — the same shape stdin has when the tool
|
|
// is invoked by an agent/CI without a TTY. It must NOT be treated as input.
|
|
f, err := os.Open(os.DevNull)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer func() { _ = f.Close() }()
|
|
|
|
if _, ok := stdinReader(f); ok {
|
|
t.Fatal("expected /dev/null to be treated as no stdin data")
|
|
}
|
|
}
|
|
|
|
func TestStdinReader_EmptyPipeFallsThrough(t *testing.T) {
|
|
// A closed, empty pipe (e.g. `true | node-lookup`) must fall through to a
|
|
// normal query rather than consuming empty input and printing nothing.
|
|
r, w, err := os.Pipe()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_ = w.Close() // no data written; reader sees immediate EOF
|
|
defer func() { _ = r.Close() }()
|
|
|
|
if _, ok := stdinReader(r); ok {
|
|
t.Fatal("expected empty pipe to be treated as no stdin data")
|
|
}
|
|
}
|
|
|
|
func TestStdinReader_PipeWithDataIsRead(t *testing.T) {
|
|
r, w, err := os.Pipe()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
go func() {
|
|
_, _ = io.WriteString(w, "node1\nnode2\n")
|
|
_ = w.Close()
|
|
}()
|
|
defer func() { _ = r.Close() }()
|
|
|
|
reader, ok := stdinReader(r)
|
|
if !ok {
|
|
t.Fatal("expected pipe with data to be treated as stdin input")
|
|
}
|
|
got, _ := reader.ReadString('\n')
|
|
if strings.TrimSpace(got) != "node1" {
|
|
t.Fatalf("expected first line 'node1', got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestStdinReader_RegularFileWithData(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "nodes.txt")
|
|
if err := os.WriteFile(path, []byte("host1\nhost2\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer func() { _ = f.Close() }()
|
|
|
|
if _, ok := stdinReader(f); !ok {
|
|
t.Fatal("expected redirected file with data to be treated as stdin input")
|
|
}
|
|
}
|
|
|
|
func TestStdinReader_EmptyFileFallsThrough(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "empty.txt")
|
|
if err := os.WriteFile(path, nil, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer func() { _ = f.Close() }()
|
|
|
|
if _, ok := stdinReader(f); ok {
|
|
t.Fatal("expected empty file to be treated as no stdin data")
|
|
}
|
|
}
|
|
|
|
// ---- run: validation --------------------------------------------------------
|
|
|
|
func TestRun_AllFacts_RequiresNode(t *testing.T) {
|
|
cfg := config{PuppetDBURL: "http://unused", RoleFact: "enc_role"}
|
|
err := run(cfg, "", "", "", false, false, 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_OutputFlagsRequireRoleOrFact(t *testing.T) {
|
|
cfg := config{PuppetDBURL: "http://unused", RoleFact: "enc_role"}
|
|
// -1 (nodeOnly) with neither -R nor -F must error before any HTTP call.
|
|
err := run(cfg, "", "", "", false, false, false, true, false, false, false, false, false)
|
|
if err == nil || !strings.Contains(err.Error(), "-R or -F") {
|
|
t.Fatalf("expected -R/-F requirement error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRun_MatchRequiresRoleOrFact(t *testing.T) {
|
|
cfg := config{PuppetDBURL: "http://unused", RoleFact: "enc_role"}
|
|
// -m with neither -R nor -F must error before any HTTP call.
|
|
err := run(cfg, "", "", "someval", false, false, false, false, false, false, false, false, false)
|
|
if err == nil || !strings.Contains(err.Error(), "-R or -F") {
|
|
t.Fatalf("expected -R/-F requirement error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// ---- run: output modes ------------------------------------------------------
|
|
|
|
type runArgs struct {
|
|
cfg config
|
|
nodeName, factName, match string
|
|
showRole, partial, inverse, nodeOnly, valueOnly, count bool
|
|
ansible, jsonMode, allFacts bool
|
|
}
|
|
|
|
// runToString invokes run against a mock PuppetDB returning facts, capturing
|
|
// stdout. nodeName defaults to "n1" so the stdin path is skipped.
|
|
func runToString(t *testing.T, facts []fact, mutate func(*runArgs)) string {
|
|
t.Helper()
|
|
srv := newTestServer(t, facts)
|
|
t.Cleanup(srv.Close)
|
|
|
|
a := runArgs{
|
|
cfg: config{PuppetDBURL: srv.URL, RoleFact: "enc_role"},
|
|
nodeName: "n1",
|
|
showRole: true,
|
|
}
|
|
if mutate != nil {
|
|
mutate(&a)
|
|
}
|
|
return captureStdout(t, func() {
|
|
if err := run(a.cfg, a.nodeName, a.factName, a.match, a.showRole, a.partial, a.inverse, a.nodeOnly, a.valueOnly, a.count, a.ansible, a.jsonMode, a.allFacts); err != nil {
|
|
t.Fatalf("run returned error: %v", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestRun_DefaultOutput(t *testing.T) {
|
|
facts := []fact{
|
|
{Certname: "hostb", Name: "enc_role", Value: rawJSON("roles::web")},
|
|
{Certname: "hosta", Name: "enc_role", Value: rawJSON("roles::db")},
|
|
}
|
|
out := strings.TrimSpace(runToString(t, facts, nil))
|
|
lines := strings.Split(out, "\n")
|
|
if len(lines) != 2 || lines[0] != "hosta roles::db" || lines[1] != "hostb roles::web" {
|
|
t.Fatalf("unexpected default output: %q", out)
|
|
}
|
|
}
|
|
|
|
func TestRun_NodeOnly(t *testing.T) {
|
|
facts := []fact{
|
|
{Certname: "hosta", Name: "enc_role", Value: rawJSON("roles::db")},
|
|
{Certname: "hostb", Name: "enc_role", Value: rawJSON("roles::web")},
|
|
}
|
|
out := strings.TrimSpace(runToString(t, facts, func(a *runArgs) { a.nodeOnly = true }))
|
|
if out != "hosta\nhostb" {
|
|
t.Fatalf("unexpected -1 output: %q", out)
|
|
}
|
|
}
|
|
|
|
func TestRun_ValueOnly(t *testing.T) {
|
|
facts := []fact{
|
|
{Certname: "hosta", Name: "enc_role", Value: rawJSON("roles::db")},
|
|
{Certname: "hostb", Name: "enc_role", Value: rawJSON("roles::web")},
|
|
}
|
|
out := strings.TrimSpace(runToString(t, facts, func(a *runArgs) { a.valueOnly = true }))
|
|
if out != "roles::db\nroles::web" {
|
|
t.Fatalf("unexpected -2 output: %q", out)
|
|
}
|
|
}
|
|
|
|
func TestRun_Count(t *testing.T) {
|
|
facts := []fact{
|
|
{Certname: "hosta", Name: "enc_role", Value: rawJSON("roles::web")},
|
|
{Certname: "hostb", Name: "enc_role", Value: rawJSON("roles::web")},
|
|
{Certname: "hostc", Name: "enc_role", Value: rawJSON("roles::db")},
|
|
}
|
|
out := strings.TrimSpace(runToString(t, facts, func(a *runArgs) { a.count = true }))
|
|
if !strings.Contains(out, "2: roles::web") || !strings.Contains(out, "1: roles::db") {
|
|
t.Fatalf("unexpected -C output: %q", out)
|
|
}
|
|
}
|
|
|
|
func TestRun_JSON(t *testing.T) {
|
|
facts := []fact{{Certname: "hosta", Name: "enc_role", Value: rawJSON("roles::db")}}
|
|
out := runToString(t, facts, func(a *runArgs) { a.jsonMode = true })
|
|
|
|
var parsed map[string]map[string]interface{}
|
|
if err := json.Unmarshal([]byte(out), &parsed); err != nil {
|
|
t.Fatalf("output is not valid JSON: %v (%s)", err, out)
|
|
}
|
|
if parsed["hosta"]["enc_role"] != "roles::db" {
|
|
t.Fatalf("unexpected JSON structure: %v", parsed)
|
|
}
|
|
}
|
|
|
|
func TestRun_Ansible(t *testing.T) {
|
|
facts := []fact{
|
|
{Certname: "hosta", Name: "enc_role", Value: rawJSON("roles::db")},
|
|
{Certname: "hostb", Name: "enc_role", Value: rawJSON("roles::web")},
|
|
}
|
|
out := runToString(t, facts, func(a *runArgs) { a.ansible = true })
|
|
if !strings.Contains(out, "all:") || !strings.Contains(out, "hosts:") {
|
|
t.Fatalf("expected Ansible inventory structure, got: %q", out)
|
|
}
|
|
if !strings.Contains(out, "hosta:") || !strings.Contains(out, "hostb:") {
|
|
t.Fatalf("expected both hosts in inventory, got: %q", out)
|
|
}
|
|
}
|
|
|
|
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)},
|
|
}
|
|
out := strings.TrimSpace(runToString(t, facts, func(a *runArgs) {
|
|
a.showRole = false
|
|
a.allFacts = true
|
|
}))
|
|
lines := strings.Split(out, "\n")
|
|
if len(lines) != 3 {
|
|
t.Fatalf("expected 3 lines, got %d: %q", len(lines), out)
|
|
}
|
|
if !strings.HasPrefix(lines[0], "aaa_fact") ||
|
|
!strings.HasPrefix(lines[1], "mmm_fact") ||
|
|
!strings.HasPrefix(lines[2], "zzz_fact") {
|
|
t.Fatalf("facts not sorted by name: %v", lines)
|
|
}
|
|
}
|