4 Commits

Author SHA1 Message Date
unkin-agent 499903ab2b Query piped nodes when counting with -R/-F (#22)
ci/woodpecker/tag/release Pipeline was successful
With piped input, `-C` skipped the query and counted the piped lines' second column, so `... | node-lookup -R -C` reported the upstream fact instead of each node's role.

- query each piped node for `-R`/`-F` with `-C` and count the results
- count piped lines directly only for bare `-C` with piped input; `-C` with `-j`/`-1`/`-2` still requires `-R`/`-F`
- validate flags before reading stdin so invalid combos never block on an open pipe
- add tests for piped count paths, rejected combos and validation ordering

Reviewed-on: #22
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-10-13 21:04:18 +11:00
unkin-agent e0633aa114 ci: use container-rpmbuilder image (#21)
CI images move off the packer-images monorepo to semver-released container-* repos.

- run RPM build steps on docker-internal/rpmbuilder:0.1.0-alma9

Reviewed-on: #21
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-10-05 21:34:43 +11:00
unkin-agent 9e3cef8ef8 ci: switch Go steps to estate gobuilder + S3 build cache (#20)
Why: unify on the estate gobuilder image and share the S3-backed Go
build cache across repos instead of pulling golang:1.25 cold each run.

- Swap build/test/pre-commit/release Go steps to gobuilder:0.1.2-alma9
- Add GOCACHEPROG (go-cache-plugin, absolute cache dir) + S3 cache env
- Source AWS creds from org-level GOCACHE secrets
- Leave lint step on golangci-lint image (binary absent from gobuilder)
- Leave rpm/upload/release steps untouched (no Go toolchain)

Reviewed-on: #20
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-10-02 23:56:15 +10:00
unkin-agent 35edc9c547 node-lookup: auto-qualify short node names to .main.unkin.net (#19)
ci/woodpecker/tag/release Pipeline was successful
Short `-n` node names silently returned nothing: `node-lookup -R -n ausyd1nxvm2120` found nothing while `-n ausyd1nxvm2120.main.unkin.net` worked, because the PuppetDB `certname` filter needs a FQDN. This auto-qualifies a dotless name before the lookup.

## Changes
- Add `qualifyNode()` pure helper: a dotless name gets `.<domain>` appended; a name already containing a dot (any domain, incl. `*.k8s.syd1.au.unkin.net`) is left unchanged; a single trailing dot is stripped first; empty input is preserved (same "no node" behavior as today).
- Apply normalization to the `-n` value and to stdin-sourced node names in `run()`, so both entry points behave consistently.
- Make the domain configurable: config key `domain`, `NODE_LOOKUP_DOMAIN` env var, and `--domain` flag, all defaulting to `main.unkin.net`.
- Surface `domain` in `config show` / `config init` output and document the new env var/flag/behavior in AGENTS.md.
- Add table-driven `qualifyNode` tests (short name appended, FQDN unchanged, multi-label other-domain FQDN unchanged, trailing-dot handling, empty input, custom domain) and a `NODE_LOOKUP_DOMAIN` env-override test.

Companion tools `pburl`/`pblastreport` take already-qualified hostnames (typically piped from `node-lookup`) via `puppet.ReadHosts` and do not share the `-n` code path, so they are intentionally left out to keep this PR atomic.

## Validation
- `gofmt -l .` clean, `go vet ./...` clean
- `go test -race ./...` pass
- `make build` builds all three binaries

Reviewed-on: #19
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-15 14:50:35 +10:00
7 changed files with 331 additions and 30 deletions
+13 -1
View File
@@ -3,7 +3,19 @@ when:
steps: steps:
- name: build - name: build
image: golang:1.25 image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/gobuilder:0.1.2-alma9
environment:
GOCACHE_S3_BUCKET: gocache
GOCACHE_S3_REGION: us-east-1
GOCACHE_S3_ENDPOINT_URL: https://s3.ceph.unkin.net
GOCACHE_S3_PATH_STYLE: "true"
GOCACHE_KEY_PREFIX: ci-node-lookup
GOCACHE_METRICS: "true"
GOCACHEPROG: go-cache-plugin --cache-dir=/tmp/gocache
AWS_ACCESS_KEY_ID:
from_secret: GOCACHE_AWS_ACCESS_KEY_ID
AWS_SECRET_ACCESS_KEY:
from_secret: GOCACHE_AWS_SECRET_ACCESS_KEY
commands: commands:
- make build - make build
backend_options: backend_options:
+13 -1
View File
@@ -3,7 +3,19 @@ when:
steps: steps:
- name: pre-commit - name: pre-commit
image: git.unkin.net/unkin/almalinux9-gobuilder:20260606 image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/gobuilder:0.1.2-alma9
environment:
GOCACHE_S3_BUCKET: gocache
GOCACHE_S3_REGION: us-east-1
GOCACHE_S3_ENDPOINT_URL: https://s3.ceph.unkin.net
GOCACHE_S3_PATH_STYLE: "true"
GOCACHE_KEY_PREFIX: ci-node-lookup
GOCACHE_METRICS: "true"
GOCACHEPROG: go-cache-plugin --cache-dir=/tmp/gocache
AWS_ACCESS_KEY_ID:
from_secret: GOCACHE_AWS_ACCESS_KEY_ID
AWS_SECRET_ACCESS_KEY:
from_secret: GOCACHE_AWS_SECRET_ACCESS_KEY
commands: commands:
- uvx pre-commit run --all-files - uvx pre-commit run --all-files
backend_options: backend_options:
+27 -3
View File
@@ -3,7 +3,19 @@ when:
steps: steps:
- name: test - name: test
image: golang:1.25 image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/gobuilder:0.1.2-alma9
environment:
GOCACHE_S3_BUCKET: gocache
GOCACHE_S3_REGION: us-east-1
GOCACHE_S3_ENDPOINT_URL: https://s3.ceph.unkin.net
GOCACHE_S3_PATH_STYLE: "true"
GOCACHE_KEY_PREFIX: ci-node-lookup
GOCACHE_METRICS: "true"
GOCACHEPROG: go-cache-plugin --cache-dir=/tmp/gocache
AWS_ACCESS_KEY_ID:
from_secret: GOCACHE_AWS_ACCESS_KEY_ID
AWS_SECRET_ACCESS_KEY:
from_secret: GOCACHE_AWS_SECRET_ACCESS_KEY
commands: commands:
- go test -race ./... - go test -race ./...
backend_options: backend_options:
@@ -21,7 +33,19 @@ steps:
# cross-platform binaries attached to the Gitea release. Each tool is a # cross-platform binaries attached to the Gitea release. Each tool is a
# separate main package, so they are built individually per os/arch. # separate main package, so they are built individually per os/arch.
- name: build - name: build
image: git.unkin.net/unkin/almalinux9-gobuilder:20260606 image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/gobuilder:0.1.2-alma9
environment:
GOCACHE_S3_BUCKET: gocache
GOCACHE_S3_REGION: us-east-1
GOCACHE_S3_ENDPOINT_URL: https://s3.ceph.unkin.net
GOCACHE_S3_PATH_STYLE: "true"
GOCACHE_KEY_PREFIX: ci-node-lookup
GOCACHE_METRICS: "true"
GOCACHEPROG: go-cache-plugin --cache-dir=/tmp/gocache
AWS_ACCESS_KEY_ID:
from_secret: GOCACHE_AWS_ACCESS_KEY_ID
AWS_SECRET_ACCESS_KEY:
from_secret: GOCACHE_AWS_SECRET_ACCESS_KEY
commands: commands:
- make build VERSION=${CI_COMMIT_TAG} - make build VERSION=${CI_COMMIT_TAG}
# Shell variables/expansions are escaped as $$ so Woodpecker leaves them # Shell variables/expansions are escaped as $$ so Woodpecker leaves them
@@ -51,7 +75,7 @@ steps:
# Package the built binary + generated shell completions into an RPM. # Package the built binary + generated shell completions into an RPM.
- name: package - name: package
image: git.unkin.net/unkin/almalinux9-rpmbuilder:latest image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/rpmbuilder:0.1.0-alma9
commands: commands:
- ./scripts/build-rpm.sh ${CI_COMMIT_TAG} - ./scripts/build-rpm.sh ${CI_COMMIT_TAG}
depends_on: [build] depends_on: [build]
+13 -1
View File
@@ -18,7 +18,19 @@ steps:
cpu: 2 cpu: 2
- name: test - name: test
image: golang:1.25 image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/gobuilder:0.1.2-alma9
environment:
GOCACHE_S3_BUCKET: gocache
GOCACHE_S3_REGION: us-east-1
GOCACHE_S3_ENDPOINT_URL: https://s3.ceph.unkin.net
GOCACHE_S3_PATH_STYLE: "true"
GOCACHE_KEY_PREFIX: ci-node-lookup
GOCACHE_METRICS: "true"
GOCACHEPROG: go-cache-plugin --cache-dir=/tmp/gocache
AWS_ACCESS_KEY_ID:
from_secret: GOCACHE_AWS_ACCESS_KEY_ID
AWS_SECRET_ACCESS_KEY:
from_secret: GOCACHE_AWS_SECRET_ACCESS_KEY
commands: commands:
- go test -v -race ./... - go test -v -race ./...
backend_options: backend_options:
+4 -1
View File
@@ -138,10 +138,12 @@ Show the active configuration (after all overrides applied):
| `NODE_LOOKUP_URL` | `puppetdb_url` | PuppetDB facts endpoint | | `NODE_LOOKUP_URL` | `puppetdb_url` | PuppetDB facts endpoint |
| `NODE_LOOKUP_ROLE_FACT` | `role_fact` | Fact name used by `-R` flag | | `NODE_LOOKUP_ROLE_FACT` | `role_fact` | Fact name used by `-R` flag |
| `NODE_LOOKUP_PUPPETBOARD_URL` | `puppetboard_url` | Puppetboard base URL (pburl / pblastreport) | | `NODE_LOOKUP_PUPPETBOARD_URL` | `puppetboard_url` | Puppetboard base URL (pburl / pblastreport) |
| `NODE_LOOKUP_DOMAIN` | `domain` | Domain appended to short (dotless) `-n` node names (default `main.unkin.net`) |
### CLI flag ### CLI flags
`--url <url>` overrides the PuppetDB URL for a single invocation (highest precedence). `--url <url>` overrides the PuppetDB URL for a single invocation (highest precedence).
`--domain <domain>` overrides the auto-qualify domain for a single invocation.
## Code Patterns ## Code Patterns
@@ -152,6 +154,7 @@ Show the active configuration (after all overrides applied):
- **`queryPuppetDB(url, query)`**: takes the URL as a parameter — never reads globals. - **`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. - **`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). `-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`). - **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`).
- **Short node names / `qualifyNode()`**: a `-n` value (and stdin-sourced node names) with no dot is auto-qualified to `<name>.<domain>` (domain defaults to `main.unkin.net`, overridable via `--domain`/`NODE_LOOKUP_DOMAIN`), so `-n ausyd1nxvm2120` resolves the same as its FQDN. A name that already contains a dot (any domain) is left unchanged; a single trailing dot is stripped; empty input is preserved.
- **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. - **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. - **SIGPIPE handling**: `signal.Ignore(syscall.SIGPIPE)` so pipes to `head` etc. work cleanly.
+66 -23
View File
@@ -22,6 +22,7 @@ const (
defaultPuppetDBURL = "http://puppetdbapi.service.consul:8080/pdb/query/v4/facts" defaultPuppetDBURL = "http://puppetdbapi.service.consul:8080/pdb/query/v4/facts"
defaultRoleFact = "enc_role" defaultRoleFact = "enc_role"
defaultPuppetboardURL = "https://puppetboard.k8s.syd1.au.unkin.net" defaultPuppetboardURL = "https://puppetboard.k8s.syd1.au.unkin.net"
defaultDomain = "main.unkin.net"
configFileName = "config.yaml" configFileName = "config.yaml"
appName = "node-lookup" appName = "node-lookup"
) )
@@ -37,6 +38,8 @@ type config struct {
// the shared config file also configures the companion tools (pburl, // the shared config file also configures the companion tools (pburl,
// pblastreport) that read this same file. // pblastreport) that read this same file.
PuppetboardURL string `yaml:"puppetboard_url"` PuppetboardURL string `yaml:"puppetboard_url"`
// Domain is appended to a short (dotless) -n node name to form its FQDN.
Domain string `yaml:"domain"`
} }
func defaultConfig() config { func defaultConfig() config {
@@ -44,6 +47,7 @@ func defaultConfig() config {
PuppetDBURL: defaultPuppetDBURL, PuppetDBURL: defaultPuppetDBURL,
RoleFact: defaultRoleFact, RoleFact: defaultRoleFact,
PuppetboardURL: defaultPuppetboardURL, PuppetboardURL: defaultPuppetboardURL,
Domain: defaultDomain,
} }
} }
@@ -87,6 +91,9 @@ func loadConfig() (config, error) {
if v := os.Getenv("NODE_LOOKUP_PUPPETBOARD_URL"); v != "" { if v := os.Getenv("NODE_LOOKUP_PUPPETBOARD_URL"); v != "" {
cfg.PuppetboardURL = v cfg.PuppetboardURL = v
} }
if v := os.Getenv("NODE_LOOKUP_DOMAIN"); v != "" {
cfg.Domain = v
}
return cfg, nil return cfg, nil
} }
@@ -105,7 +112,7 @@ func writeDefaultConfig() error {
cfg := defaultConfig() cfg := defaultConfig()
data, _ := yaml.Marshal(cfg) data, _ := yaml.Marshal(cfg)
header := []byte("# node-lookup configuration\n# Fields can be overridden with env vars: NODE_LOOKUP_URL, NODE_LOOKUP_ROLE_FACT, NODE_LOOKUP_PUPPETBOARD_URL\n# puppetboard_url is used by the companion tools (pburl, pblastreport).\n\n") header := []byte("# node-lookup configuration\n# Fields can be overridden with env vars: NODE_LOOKUP_URL, NODE_LOOKUP_ROLE_FACT, NODE_LOOKUP_PUPPETBOARD_URL, NODE_LOOKUP_DOMAIN\n# puppetboard_url is used by the companion tools (pburl, pblastreport).\n# domain is appended to short (dotless) -n node names to form their FQDN.\n\n")
if err := os.WriteFile(path, append(header, data...), 0o644); err != nil { if err := os.WriteFile(path, append(header, data...), 0o644); err != nil {
return fmt.Errorf("writing config: %w", err) return fmt.Errorf("writing config: %w", err)
} }
@@ -144,6 +151,21 @@ func nameFilter(names []string) []interface{} {
return or return or
} }
// qualifyNode auto-qualifies a short (dotless) node name by appending
// ".<domain>", so `-n ausyd1nxvm2120` resolves the same as its FQDN. A name
// that already contains a dot is treated as already-qualified (including names
// in other domains like *.k8s.syd1.au.unkin.net) and returned unchanged. A
// single trailing dot is stripped first, so a dotless name with a trailing dot
// is still qualified. Empty input is returned unchanged to preserve the
// existing "no node given" behavior.
func qualifyNode(name, domain string) string {
name = strings.TrimSuffix(name, ".")
if name == "" || strings.Contains(name, ".") {
return name
}
return name + "." + domain
}
func buildQuery(node, factName, match, roleFact string, showRole, partial, inverse bool) string { func buildQuery(node, factName, match, roleFact string, showRole, partial, inverse bool) string {
type filter = []interface{} type filter = []interface{}
var filters []filter var filters []filter
@@ -318,6 +340,8 @@ func allFactsForNode(puppetDBURL, node string) ([]fact, error) {
func run(cfg config, nodeName, factName, match string, showRole, partial, inverse, nodeOnly, valueOnly, count, ansible, jsonMode, allFacts bool) error { func run(cfg config, nodeName, factName, match string, showRole, partial, inverse, nodeOnly, valueOnly, count, ansible, jsonMode, allFacts bool) error {
signal.Ignore(syscall.SIGPIPE) signal.Ignore(syscall.SIGPIPE)
nodeName = qualifyNode(nodeName, cfg.Domain)
if allFacts { if allFacts {
if nodeName == "" { if nodeName == "" {
return fmt.Errorf("-a requires -n") return fmt.Errorf("-a requires -n")
@@ -333,15 +357,38 @@ func run(cfg config, nodeName, factName, match string, showRole, partial, invers
return nil return nil
} }
if (nodeOnly || valueOnly || count || ansible) && !showRole && factName == "" { hasQuery := showRole || factName != ""
return fmt.Errorf("-R or -F must be used with -1, -2, -C, or -A") // Bare -C counts already-looked-up piped lines; only it needs stdin to validate.
bareCount := count && !hasQuery && !nodeOnly && !valueOnly && !ansible && !jsonMode && nodeName == ""
countErr := fmt.Errorf("-R or -F must be used with -1, -2, -C, or -A")
if (nodeOnly || valueOnly || (count && !bareCount) || ansible) && !hasQuery {
return countErr
} }
if (match != "" || partial || inverse) && !showRole && factName == "" { if (match != "" || partial || inverse) && !hasQuery {
return fmt.Errorf("-R or -F must be used with -m, -p, or -i") return fmt.Errorf("-R or -F must be used with -m, -p, or -i")
} }
var reader *bufio.Reader
piped := false
if nodeName == "" {
reader, piped = stdinReader(os.Stdin)
}
if bareCount {
if !piped {
return countErr
}
var lines []string
scanner := bufio.NewScanner(reader)
for scanner.Scan() {
lines = append(lines, scanner.Text())
}
fmt.Println(strings.Join(countResults(lines), "\n"))
return nil
}
var collected []fact var collected []fact
var stdinLines []string
doQuery := func(node string) error { doQuery := func(node string) error {
query := buildQuery(node, factName, match, cfg.RoleFact, showRole, partial, inverse) query := buildQuery(node, factName, match, cfg.RoleFact, showRole, partial, inverse)
@@ -353,21 +400,15 @@ func run(cfg config, nodeName, factName, match string, showRole, partial, invers
return nil return nil
} }
if reader, ok := stdinReader(os.Stdin); ok && nodeName == "" { if piped {
scanner := bufio.NewScanner(reader) scanner := bufio.NewScanner(reader)
if count { for scanner.Scan() {
for scanner.Scan() { fields := strings.Fields(scanner.Text())
stdinLines = append(stdinLines, scanner.Text()) if len(fields) == 0 {
continue
} }
} else { if err := doQuery(qualifyNode(fields[0], cfg.Domain)); err != nil {
for scanner.Scan() { fmt.Fprintln(os.Stderr, "error:", err)
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 {
@@ -386,11 +427,7 @@ func run(cfg config, nodeName, factName, match string, showRole, partial, invers
_ = enc.Encode(factsByHost(collected, factName, cfg.RoleFact, showRole)) _ = enc.Encode(factsByHost(collected, factName, cfg.RoleFact, showRole))
case count: case count:
values := stdinLines fmt.Println(strings.Join(countResults(returnData), "\n"))
if len(values) == 0 {
values = returnData
}
fmt.Println(strings.Join(countResults(values), "\n"))
case ansible: case ansible:
// Attach each host's queried fact(s) as inventory host vars, e.g. // Attach each host's queried fact(s) as inventory host vars, e.g.
@@ -446,6 +483,7 @@ func main() {
jsonMode bool jsonMode bool
allFacts bool allFacts bool
puppetDBURL string puppetDBURL string
domain string
) )
rootCmd := &cobra.Command{ rootCmd := &cobra.Command{
@@ -461,6 +499,9 @@ func main() {
if cmd.Flags().Changed("url") { if cmd.Flags().Changed("url") {
cfg.PuppetDBURL = puppetDBURL cfg.PuppetDBURL = puppetDBURL
} }
if cmd.Flags().Changed("domain") {
cfg.Domain = domain
}
return nil return nil
}, },
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
@@ -482,6 +523,7 @@ func main() {
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)") f.BoolVarP(&allFacts, "all", "a", false, "Show all facts for a node (requires -n)")
f.StringVar(&domain, "domain", cfg.Domain, "Domain appended to short (dotless) -n node names (overrides config and NODE_LOOKUP_DOMAIN)")
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{
@@ -506,6 +548,7 @@ func main() {
fmt.Printf("puppetdb_url : %s\n", cfg.PuppetDBURL) fmt.Printf("puppetdb_url : %s\n", cfg.PuppetDBURL)
fmt.Printf("role_fact : %s\n", cfg.RoleFact) fmt.Printf("role_fact : %s\n", cfg.RoleFact)
fmt.Printf("puppetboard_url: %s\n", cfg.PuppetboardURL) fmt.Printf("puppetboard_url: %s\n", cfg.PuppetboardURL)
fmt.Printf("domain : %s\n", cfg.Domain)
return nil return nil
}, },
SilenceUsage: true, SilenceUsage: true,
+195
View File
@@ -8,7 +8,9 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"sync/atomic"
"testing" "testing"
"time"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
@@ -167,6 +169,35 @@ func TestSplitFactNames(t *testing.T) {
} }
} }
func TestQualifyNode(t *testing.T) {
const domain = "main.unkin.net"
cases := []struct {
name string
in string
want string
}{
{"short name appends domain", "ausyd1nxvm2120", "ausyd1nxvm2120.main.unkin.net"},
{"fqdn in default domain unchanged", "ausyd1nxvm2120.main.unkin.net", "ausyd1nxvm2120.main.unkin.net"},
{"multi-label fqdn other domain unchanged", "foo.k8s.syd1.au.unkin.net", "foo.k8s.syd1.au.unkin.net"},
{"short name with trailing dot qualified", "ausyd1nxvm2120.", "ausyd1nxvm2120.main.unkin.net"},
{"fqdn with trailing dot stripped", "foo.main.unkin.net.", "foo.main.unkin.net"},
{"empty unchanged", "", ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := qualifyNode(tc.in, domain); got != tc.want {
t.Fatalf("qualifyNode(%q, %q) = %q, want %q", tc.in, domain, got, tc.want)
}
})
}
}
func TestQualifyNode_CustomDomain(t *testing.T) {
if got := qualifyNode("host1", "example.com"); got != "host1.example.com" {
t.Fatalf("qualifyNode with custom domain = %q, want host1.example.com", got)
}
}
func TestBuildQuery_SingleFact_NoOr(t *testing.T) { func TestBuildQuery_SingleFact_NoOr(t *testing.T) {
q := buildQuery("", "ipaddress", "", "enc_role", false, false, false) q := buildQuery("", "ipaddress", "", "enc_role", false, false, false)
if strings.Contains(q, `"or"`) { if strings.Contains(q, `"or"`) {
@@ -397,6 +428,22 @@ func TestLoadConfig_Defaults(t *testing.T) {
if cfg.RoleFact != defaultRoleFact { if cfg.RoleFact != defaultRoleFact {
t.Fatalf("expected default role fact, got %s", cfg.RoleFact) t.Fatalf("expected default role fact, got %s", cfg.RoleFact)
} }
if cfg.Domain != defaultDomain {
t.Fatalf("expected default domain, got %s", cfg.Domain)
}
}
func TestLoadConfig_DomainEnvOverride(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
t.Setenv("NODE_LOOKUP_DOMAIN", "example.com")
cfg, err := loadConfig()
if err != nil {
t.Fatal(err)
}
if cfg.Domain != "example.com" {
t.Fatalf("domain env override failed: %s", cfg.Domain)
}
} }
func TestLoadConfig_EnvOverride(t *testing.T) { func TestLoadConfig_EnvOverride(t *testing.T) {
@@ -827,3 +874,151 @@ func TestRun_AllFacts_PrintsSortedByName(t *testing.T) {
t.Fatalf("facts not sorted by name: %v", lines) t.Fatalf("facts not sorted by name: %v", lines)
} }
} }
// ---- run: count with piped stdin --------------------------------------------
func withStdin(t *testing.T, content string) {
t.Helper()
path := filepath.Join(t.TempDir(), "stdin")
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
f, err := os.Open(path)
if err != nil {
t.Fatal(err)
}
old := os.Stdin
os.Stdin = f
t.Cleanup(func() { os.Stdin = old; _ = f.Close() })
}
const pipedLookup = "hosta.main.unkin.net puppetdbapi.service.consul\nhostb.main.unkin.net puppetdbapi.service.consul\n"
func TestRun_CountPipedWithRoleQueriesEachNode(t *testing.T) {
withStdin(t, pipedLookup)
var queried atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
queried.Add(1)
_ = json.NewEncoder(w).Encode([]fact{{Certname: "h", Name: "enc_role", Value: rawJSON("roles::vault")}})
}))
t.Cleanup(srv.Close)
cfg := config{PuppetDBURL: srv.URL, RoleFact: "enc_role"}
out := strings.TrimSpace(captureStdout(t, func() {
if err := run(cfg, "", "", "", true, false, false, false, false, true, false, false, false); err != nil {
t.Fatalf("run returned error: %v", err)
}
}))
if out != "2: roles::vault" {
t.Fatalf("expected role counts of piped nodes, got: %q", out)
}
if n := queried.Load(); n != 2 {
t.Fatalf("expected one query per piped node, got %d", n)
}
}
func TestRun_CountPipedWithoutQueryCountsInput(t *testing.T) {
withStdin(t, pipedLookup+"hostc.main.unkin.net other\n")
cfg := config{PuppetDBURL: "http://unused", RoleFact: "enc_role"}
out := strings.TrimSpace(captureStdout(t, func() {
if err := run(cfg, "", "", "", false, false, false, false, false, true, false, false, false); err != nil {
t.Fatalf("run returned error: %v", err)
}
}))
if out != "1: other\n2: puppetdbapi.service.consul" {
t.Fatalf("expected counts of piped values, got: %q", out)
}
}
func TestRun_CountWithoutStdinRequiresRoleOrFact(t *testing.T) {
devnull, err := os.Open(os.DevNull)
if err != nil {
t.Fatal(err)
}
old := os.Stdin
os.Stdin = devnull
t.Cleanup(func() { os.Stdin = old; _ = devnull.Close() })
err = run(config{PuppetDBURL: "http://unused"}, "", "", "", false, false, false, false, false, true, 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_CountPipedWithFactQueriesEachNode(t *testing.T) {
withStdin(t, pipedLookup)
var queried atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.Contains(r.URL.Query().Get("query"), "osfamily") {
t.Errorf("expected osfamily query, got %q", r.URL.Query().Get("query"))
}
queried.Add(1)
_ = json.NewEncoder(w).Encode([]fact{{Certname: "h", Name: "osfamily", Value: rawJSON("RedHat")}})
}))
t.Cleanup(srv.Close)
cfg := config{PuppetDBURL: srv.URL, RoleFact: "enc_role"}
out := strings.TrimSpace(captureStdout(t, func() {
if err := run(cfg, "", "osfamily", "", false, false, false, false, false, true, false, false, false); err != nil {
t.Fatalf("run returned error: %v", err)
}
}))
if out != "2: RedHat" {
t.Fatalf("expected fact counts of piped nodes, got: %q", out)
}
if n := queried.Load(); n != 2 {
t.Fatalf("expected one query per piped node, got %d", n)
}
}
func TestRun_CountPipedWithOtherOutputFlagsRequiresRoleOrFact(t *testing.T) {
for name, mutate := range map[string]func(*runArgs){
"json": func(a *runArgs) { a.jsonMode = true },
"nodeOnly": func(a *runArgs) { a.nodeOnly = true },
"valueOnly": func(a *runArgs) { a.valueOnly = true },
} {
t.Run(name, func(t *testing.T) {
withStdin(t, pipedLookup)
a := runArgs{cfg: config{PuppetDBURL: "http://unused"}, count: true}
mutate(&a)
var err error
out := captureStdout(t, func() {
err = run(a.cfg, "", "", "", false, false, false, a.nodeOnly, a.valueOnly, true, false, a.jsonMode, false)
})
if err == nil || !strings.Contains(err.Error(), "-R or -F") {
t.Fatalf("expected -R/-F requirement error, got: %v (output %q)", err, out)
}
})
}
}
func TestRun_InvalidFlagsErrorWithoutReadingOpenPipe(t *testing.T) {
for name, a := range map[string]runArgs{
"match": {match: "foo"},
"countJSON": {count: true, jsonMode: true},
} {
t.Run(name, func(t *testing.T) {
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
old := os.Stdin
os.Stdin = r
t.Cleanup(func() { os.Stdin = old; _ = w.Close(); _ = r.Close() })
done := make(chan error, 1)
go func() {
done <- run(config{PuppetDBURL: "http://unused"}, "", "", a.match, false, false, false, false, false, a.count, false, a.jsonMode, false)
}()
select {
case err := <-done:
if err == nil || !strings.Contains(err.Error(), "-R or -F") {
t.Fatalf("expected -R/-F requirement error, got: %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("run blocked on an open, empty stdin pipe before validating flags")
}
})
}
}