Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 547333ecd2 |
@@ -1,18 +0,0 @@
|
|||||||
when:
|
|
||||||
- event: pull_request
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: build
|
|
||||||
image: golang:1.25
|
|
||||||
commands:
|
|
||||||
- make build
|
|
||||||
backend_options:
|
|
||||||
kubernetes:
|
|
||||||
serviceAccountName: default
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
memory: 512Mi
|
|
||||||
cpu: 1
|
|
||||||
limits:
|
|
||||||
memory: 2Gi
|
|
||||||
cpu: 2
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
when:
|
|
||||||
- event: pull_request
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: pre-commit
|
|
||||||
image: git.unkin.net/unkin/almalinux9-gobuilder:20260606
|
|
||||||
commands:
|
|
||||||
- uvx pre-commit run --all-files
|
|
||||||
backend_options:
|
|
||||||
kubernetes:
|
|
||||||
serviceAccountName: default
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
memory: 512Mi
|
|
||||||
cpu: 1
|
|
||||||
limits:
|
|
||||||
memory: 2Gi
|
|
||||||
cpu: 2
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
when:
|
|
||||||
- event: tag
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: test
|
|
||||||
image: golang:1.25
|
|
||||||
commands:
|
|
||||||
- go test -race ./...
|
|
||||||
backend_options:
|
|
||||||
kubernetes:
|
|
||||||
serviceAccountName: default
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
memory: 512Mi
|
|
||||||
cpu: 1
|
|
||||||
limits:
|
|
||||||
memory: 2Gi
|
|
||||||
cpu: 2
|
|
||||||
|
|
||||||
# Build the linux/amd64 binary into dist/ (consumed by the RPM step) plus the
|
|
||||||
# cross-platform binaries attached to the Gitea release.
|
|
||||||
- name: build
|
|
||||||
image: git.unkin.net/unkin/almalinux9-gobuilder:20260606
|
|
||||||
commands:
|
|
||||||
- make build VERSION=${CI_COMMIT_TAG}
|
|
||||||
- GOOS=linux GOARCH=amd64 go build -ldflags="-s -w -X main.version=${CI_COMMIT_TAG}" -o node-lookup-linux-amd64 ./...
|
|
||||||
- GOOS=linux GOARCH=arm64 go build -ldflags="-s -w -X main.version=${CI_COMMIT_TAG}" -o node-lookup-linux-arm64 ./...
|
|
||||||
- GOOS=darwin GOARCH=amd64 go build -ldflags="-s -w -X main.version=${CI_COMMIT_TAG}" -o node-lookup-darwin-amd64 ./...
|
|
||||||
- GOOS=darwin GOARCH=arm64 go build -ldflags="-s -w -X main.version=${CI_COMMIT_TAG}" -o node-lookup-darwin-arm64 ./...
|
|
||||||
depends_on: [test]
|
|
||||||
backend_options:
|
|
||||||
kubernetes:
|
|
||||||
serviceAccountName: default
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
memory: 512Mi
|
|
||||||
cpu: 1
|
|
||||||
limits:
|
|
||||||
memory: 2Gi
|
|
||||||
cpu: 2
|
|
||||||
|
|
||||||
# Package the built binary + generated shell completions into an RPM.
|
|
||||||
- name: package
|
|
||||||
image: git.unkin.net/unkin/almalinux9-rpmbuilder:latest
|
|
||||||
commands:
|
|
||||||
- ./scripts/build-rpm.sh ${CI_COMMIT_TAG}
|
|
||||||
depends_on: [build]
|
|
||||||
backend_options:
|
|
||||||
kubernetes:
|
|
||||||
serviceAccountName: default
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
memory: 512Mi
|
|
||||||
cpu: 1
|
|
||||||
limits:
|
|
||||||
memory: 2Gi
|
|
||||||
cpu: 2
|
|
||||||
|
|
||||||
# Publish the RPM to the artifactapi local rpm repo (a real yum repo;
|
|
||||||
# repodata regenerates automatically).
|
|
||||||
- name: upload-rpm
|
|
||||||
image: git.unkin.net/unkin/almalinux9-base:20260606
|
|
||||||
commands:
|
|
||||||
- |
|
|
||||||
HOST="https://artifactapi.k8s.syd1.au.unkin.net"
|
|
||||||
REPO="rpm-internal"
|
|
||||||
for rpm in dist/*.rpm; do
|
|
||||||
FILE=$$(basename "$$rpm")
|
|
||||||
# artifactapi has no HEAD route (returns 405); probe with GET against
|
|
||||||
# the served path (RPMs are stored under Packages/) to avoid re-upload.
|
|
||||||
code=$$(curl -s -o /dev/null -w '%{http_code}' "$$HOST/api/v2/remotes/$$REPO/files/Packages/$$FILE" || true)
|
|
||||||
if [ "$$code" = "200" ]; then
|
|
||||||
echo "$$FILE already exists in $$REPO (HTTP $$code); skipping upload"
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
echo "Uploading $$FILE to $$REPO (existence probe returned $$code)"
|
|
||||||
curl -f -X PUT \
|
|
||||||
"$$HOST/api/v2/remotes/$$REPO/files/$$FILE" \
|
|
||||||
-H "Content-Type: application/x-rpm" \
|
|
||||||
--data-binary @"$$rpm"
|
|
||||||
done
|
|
||||||
depends_on: [package]
|
|
||||||
backend_options:
|
|
||||||
kubernetes:
|
|
||||||
serviceAccountName: default
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
memory: 128Mi
|
|
||||||
cpu: 100m
|
|
||||||
limits:
|
|
||||||
memory: 512Mi
|
|
||||||
cpu: 500m
|
|
||||||
|
|
||||||
# Cut a Gitea release with the cross-platform binaries attached.
|
|
||||||
- name: release
|
|
||||||
image: git.unkin.net/unkin/almalinux9-base:20260606
|
|
||||||
environment:
|
|
||||||
RELEASER_TOKEN:
|
|
||||||
from_secret: RELEASER_TOKEN
|
|
||||||
commands:
|
|
||||||
- |
|
|
||||||
curl --output /usr/local/bin/tea https://artifactapi.k8s.syd1.au.unkin.net/api/v1/remote/gitea-dl/tea/0.12.0/tea-0.12.0-linux-amd64 && chmod +x /usr/local/bin/tea
|
|
||||||
tea logins add --name gitea --url https://git.unkin.net --token "$${RELEASER_TOKEN}" --no-version-check
|
|
||||||
PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
|
|
||||||
if [ -n "$PREV_TAG" ]; then
|
|
||||||
NOTES=$(git log "${PREV_TAG}..${CI_COMMIT_TAG}" --pretty=format:"- %s")
|
|
||||||
else
|
|
||||||
NOTES=$(git log --pretty=format:"- %s")
|
|
||||||
fi
|
|
||||||
tea releases create --tag "${CI_COMMIT_TAG}" --title "${CI_COMMIT_TAG}" --note "${NOTES}" --login gitea --repo "${CI_REPO}"
|
|
||||||
tea releases assets create "${CI_COMMIT_TAG}" \
|
|
||||||
node-lookup-linux-amd64 \
|
|
||||||
node-lookup-linux-arm64 \
|
|
||||||
node-lookup-darwin-amd64 \
|
|
||||||
node-lookup-darwin-arm64 \
|
|
||||||
--login gitea --repo "${CI_REPO}"
|
|
||||||
depends_on: [upload-rpm]
|
|
||||||
backend_options:
|
|
||||||
kubernetes:
|
|
||||||
serviceAccountName: default
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
memory: 128Mi
|
|
||||||
cpu: 100m
|
|
||||||
limits:
|
|
||||||
memory: 512Mi
|
|
||||||
cpu: 500m
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
when:
|
|
||||||
- event: pull_request
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: lint
|
|
||||||
image: golangci/golangci-lint:latest
|
|
||||||
commands:
|
|
||||||
- golangci-lint run ./...
|
|
||||||
backend_options:
|
|
||||||
kubernetes:
|
|
||||||
serviceAccountName: default
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
memory: 512Mi
|
|
||||||
cpu: 1
|
|
||||||
limits:
|
|
||||||
memory: 2Gi
|
|
||||||
cpu: 2
|
|
||||||
|
|
||||||
- name: test
|
|
||||||
image: golang:1.25
|
|
||||||
commands:
|
|
||||||
- go test -v -race ./...
|
|
||||||
backend_options:
|
|
||||||
kubernetes:
|
|
||||||
serviceAccountName: default
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
memory: 512Mi
|
|
||||||
cpu: 1
|
|
||||||
limits:
|
|
||||||
memory: 2Gi
|
|
||||||
cpu: 2
|
|
||||||
@@ -7,51 +7,20 @@
|
|||||||
## Structure
|
## Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
main.go # entire application source
|
main.go # entire application source
|
||||||
main_test.go # unit tests (mock PuppetDB via httptest, no live deps)
|
go.mod # Go module (module name: node-lookup)
|
||||||
go.mod # Go module (module name: node-lookup)
|
go.sum # dependency checksums
|
||||||
go.sum # dependency checksums
|
node-lookup # compiled binary (not committed)
|
||||||
Makefile # build / test / lint / completions / rpm / version-bump targets
|
|
||||||
packaging/nfpm.yaml # nfpm spec (envsubst-templated) for the RPM
|
|
||||||
scripts/build-rpm.sh # generates completions + packages the RPM with nfpm
|
|
||||||
.woodpecker/ # CI: build, test, pre-commit (PR) + release (tag)
|
|
||||||
dist/ # build output: binary, completions, RPM (not committed)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make build # -> dist/node-lookup (CGO disabled, static)
|
|
||||||
# or directly:
|
|
||||||
go build -o node-lookup ./...
|
go build -o node-lookup ./...
|
||||||
```
|
```
|
||||||
|
|
||||||
Requires Go 1.21+. Dependencies: `github.com/spf13/cobra` (CLI), `gopkg.in/yaml.v3` (Ansible output).
|
Requires Go 1.21+. Dependencies: `github.com/spf13/cobra` (CLI), `gopkg.in/yaml.v3` (Ansible output).
|
||||||
|
|
||||||
## Packaging (RPM)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make rpm # build the binary + package it into dist/*.rpm via nfpm
|
|
||||||
```
|
|
||||||
|
|
||||||
`scripts/build-rpm.sh` generates bash/zsh/fish completions from the built binary
|
|
||||||
and bundles them alongside `/usr/bin/node-lookup`. On a `v*` tag the release
|
|
||||||
pipeline builds the RPM and `PUT`s it to the artifactapi `rpm-internal` repo.
|
|
||||||
|
|
||||||
## Shell completions
|
|
||||||
|
|
||||||
Cobra provides a `completion` subcommand:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
node-lookup completion bash # or zsh / fish / powershell
|
|
||||||
```
|
|
||||||
|
|
||||||
The RPM installs completions to the standard system paths
|
|
||||||
(`/usr/share/bash-completion/completions/`, `/usr/share/zsh/site-functions/`,
|
|
||||||
`/usr/share/fish/vendor_completions.d/`), so they work automatically once
|
|
||||||
installed. To load ad-hoc in the current shell, e.g. zsh:
|
|
||||||
`source <(node-lookup completion zsh)`.
|
|
||||||
|
|
||||||
## Running the Tool
|
## Running the Tool
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -59,10 +28,8 @@ installed. To load ad-hoc in the current shell, e.g. zsh:
|
|||||||
./node-lookup -R # show all nodes with role fact
|
./node-lookup -R # show all nodes with role fact
|
||||||
./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 (-m)
|
./node-lookup -m <value> # exact value match
|
||||||
./node-lookup -pm <value> # partial/regex match (-p -m combined)
|
./node-lookup -p <pattern> # partial/regex match on value (also --pm)
|
||||||
./node-lookup -im <value> # inverse exact match (-i -m combined)
|
|
||||||
./node-lookup -ipm <value> # inverse partial match (-i -p -m combined)
|
|
||||||
./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
|
||||||
@@ -109,11 +76,11 @@ Show the active configuration (after all overrides applied):
|
|||||||
## Code Patterns
|
## Code Patterns
|
||||||
|
|
||||||
- **`loadConfig()`**: reads config file → applies env vars → returns `config` struct. Called once at startup in `main()`.
|
- **`loadConfig()`**: reads config file → applies env vars → returns `config` struct. Called once at startup in `main()`.
|
||||||
- **`buildQuery()`**: returns a PuppetDB PQL-compatible JSON array string. Uses `roleFact` from config (not hardcoded). Match modifiers: `-p` (partial/regex, uses `~` op), `-i` (inverse, wraps with `not`), composable.
|
- **`buildQuery()`**: returns a PuppetDB PQL-compatible JSON array string. Uses `roleFact` from config (not hardcoded).
|
||||||
- **`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).
|
- **Output modes**: JSON (`-j`), count (`-C`), Ansible YAML (`-A`), node-only (`-1`), value-only (`-2`), default (node + value).
|
||||||
- **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**: when stdin is not a TTY and no `-n` is given, node names are read line-by-line and queried individually (one HTTP request per node).
|
||||||
- **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.
|
||||||
|
|
||||||
## CLI Framework
|
## CLI Framework
|
||||||
@@ -122,19 +89,12 @@ Uses [Cobra](https://github.com/spf13/cobra). Root command is the query command.
|
|||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
```bash
|
No test suite exists. Manual testing requires access to the Consul/PuppetDB environment or a mock HTTP server.
|
||||||
make test # go test -v -race ./...
|
|
||||||
```
|
|
||||||
|
|
||||||
`main_test.go` covers query construction (all `-m`/`-p`/`-i` combinations), value
|
|
||||||
rendering, result processing/counting, config precedence (defaults < file < env),
|
|
||||||
`writeDefaultConfig`, the `stdinReader` no-TTY behavior, and every `run()` output
|
|
||||||
mode (default, `-1`, `-2`, `-C`, `-j`, `-A`, `-a`). PuppetDB is stubbed with
|
|
||||||
`httptest` — no live Consul/PuppetDB access is required.
|
|
||||||
|
|
||||||
## 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 reads all lines as pre-fetched `"node value"` output for counting — it does **not** query PuppetDB per line.
|
- `-C` (count) with stdin extracts the first field of each line as the node name, queries PuppetDB per node, then counts the resulting values.
|
||||||
- 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).
|
||||||
|
|||||||
@@ -1,17 +1,12 @@
|
|||||||
BINARY := node-lookup
|
BINARY := node-lookup
|
||||||
DIST := dist
|
GOFLAGS := -ldflags="-s -w"
|
||||||
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
|
|
||||||
GOFLAGS := -ldflags="-s -w -X main.version=$(VERSION)"
|
|
||||||
OS ?= $(shell go env GOOS)
|
|
||||||
ARCH ?= $(shell go env GOARCH)
|
|
||||||
|
|
||||||
.PHONY: all build test lint fmt clean install completions rpm rpm-package patch minor major _tag
|
.PHONY: all build test lint clean install
|
||||||
|
|
||||||
all: build
|
all: build
|
||||||
|
|
||||||
# Build into dist/ so the nfpm packaging step (scripts/build-rpm.sh) can find it.
|
|
||||||
build:
|
build:
|
||||||
CGO_ENABLED=0 GOOS=$(OS) GOARCH=$(ARCH) go build $(GOFLAGS) -o $(DIST)/$(BINARY) ./...
|
go build $(GOFLAGS) -o $(BINARY) ./...
|
||||||
|
|
||||||
test:
|
test:
|
||||||
go test -v -race ./...
|
go test -v -race ./...
|
||||||
@@ -19,48 +14,8 @@ test:
|
|||||||
lint:
|
lint:
|
||||||
golangci-lint run ./...
|
golangci-lint run ./...
|
||||||
|
|
||||||
fmt:
|
|
||||||
gofmt -w .
|
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm -rf $(DIST) $(BINARY)
|
rm -f $(BINARY)
|
||||||
|
|
||||||
install:
|
install:
|
||||||
go install $(GOFLAGS) ./...
|
go install $(GOFLAGS) ./...
|
||||||
|
|
||||||
# Generate bash/zsh/fish completions from the built binary into dist/completions.
|
|
||||||
completions: build
|
|
||||||
@mkdir -p $(DIST)/completions
|
|
||||||
$(DIST)/$(BINARY) completion bash > $(DIST)/completions/$(BINARY).bash
|
|
||||||
$(DIST)/$(BINARY) completion zsh > $(DIST)/completions/_$(BINARY)
|
|
||||||
$(DIST)/$(BINARY) completion fish > $(DIST)/completions/$(BINARY).fish
|
|
||||||
|
|
||||||
# Build the binary then package it (with completions) into an RPM via nfpm.
|
|
||||||
rpm: build rpm-package
|
|
||||||
|
|
||||||
# Package an already-built binary into an RPM (used by CI after the build step).
|
|
||||||
rpm-package:
|
|
||||||
./scripts/build-rpm.sh $(VERSION)
|
|
||||||
|
|
||||||
# Bump helpers — reads the latest semver tag and creates the next one.
|
|
||||||
# If no tag exists yet, starts from v0.0.0.
|
|
||||||
_LATEST := $(shell git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$$' | head -1)
|
|
||||||
_BASE := $(if $(_LATEST),$(_LATEST),v0.0.0)
|
|
||||||
_MAJ := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f1)
|
|
||||||
_MIN := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f2)
|
|
||||||
_PAT := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f3)
|
|
||||||
|
|
||||||
patch:
|
|
||||||
@NEW=v$(_MAJ).$(_MIN).$(shell expr $(_PAT) + 1); \
|
|
||||||
git tag $$NEW && echo "Tagged $$NEW" && $(MAKE) _tag TAG=$$NEW
|
|
||||||
|
|
||||||
minor:
|
|
||||||
@NEW=v$(_MAJ).$(shell expr $(_MIN) + 1).0; \
|
|
||||||
git tag $$NEW && echo "Tagged $$NEW" && $(MAKE) _tag TAG=$$NEW
|
|
||||||
|
|
||||||
major:
|
|
||||||
@NEW=v$(shell expr $(_MAJ) + 1).0.0; \
|
|
||||||
git tag $$NEW && echo "Tagged $$NEW" && $(MAKE) _tag TAG=$$NEW
|
|
||||||
|
|
||||||
_tag:
|
|
||||||
git push origin $(TAG)
|
|
||||||
|
|||||||
@@ -25,8 +25,6 @@ const (
|
|||||||
appName = "node-lookup"
|
appName = "node-lookup"
|
||||||
)
|
)
|
||||||
|
|
||||||
var version = "dev"
|
|
||||||
|
|
||||||
// config holds all configurable values. Fields map 1:1 to config file keys,
|
// config holds all configurable values. Fields map 1:1 to config file keys,
|
||||||
// env vars (NODE_LOOKUP_*), and (where applicable) CLI flags.
|
// env vars (NODE_LOOKUP_*), and (where applicable) CLI flags.
|
||||||
type config struct {
|
type config struct {
|
||||||
@@ -110,7 +108,7 @@ type fact struct {
|
|||||||
Value json.RawMessage `json:"value"`
|
Value json.RawMessage `json:"value"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildQuery(node, factName, match, roleFact string, showRole, partial, inverse bool) string {
|
func buildQuery(node, factName, match, partialMatch, inverseMatch, roleFact string, showRole bool) string {
|
||||||
type filter = []interface{}
|
type filter = []interface{}
|
||||||
var filters []filter
|
var filters []filter
|
||||||
|
|
||||||
@@ -124,16 +122,11 @@ func buildQuery(node, factName, match, roleFact string, showRole, partial, inver
|
|||||||
}
|
}
|
||||||
|
|
||||||
if match != "" {
|
if match != "" {
|
||||||
op := "="
|
filters = append(filters, filter{"=", "value", match})
|
||||||
if partial {
|
} else if partialMatch != "" {
|
||||||
op = "~"
|
filters = append(filters, filter{"~", "value", partialMatch})
|
||||||
}
|
} else if inverseMatch != "" {
|
||||||
inner := filter{op, "value", match}
|
filters = append(filters, filter{"not", filter{"~", "value", inverseMatch}})
|
||||||
if inverse {
|
|
||||||
filters = append(filters, filter{"not", inner})
|
|
||||||
} else {
|
|
||||||
filters = append(filters, inner)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(filters) == 0 {
|
if len(filters) == 0 {
|
||||||
@@ -157,7 +150,7 @@ func queryPuppetDB(puppetDBURL, query string) ([]fact, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("request failed: %w", err)
|
return nil, fmt.Errorf("request failed: %w", err)
|
||||||
}
|
}
|
||||||
defer func() { _ = resp.Body.Close() }()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
body, _ := io.ReadAll(resp.Body)
|
body, _ := io.ReadAll(resp.Body)
|
||||||
@@ -183,7 +176,7 @@ func valueString(raw json.RawMessage) string {
|
|||||||
|
|
||||||
func valueAny(raw json.RawMessage) interface{} {
|
func valueAny(raw json.RawMessage) interface{} {
|
||||||
var v interface{}
|
var v interface{}
|
||||||
_ = json.Unmarshal(raw, &v)
|
json.Unmarshal(raw, &v)
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,86 +207,42 @@ func countResults(lines []string) []string {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// stdinReader returns a buffered reader over f and true only when f actually
|
func isTerminal(f *os.File) bool {
|
||||||
// carries piped/redirected data to consume as node names. Terminals and
|
|
||||||
// character devices such as /dev/null return false, and an empty pipe or empty
|
|
||||||
// file (immediate EOF on peek) also returns false. This means running without a
|
|
||||||
// TTY — e.g. invoked by an agent or CI where stdin is /dev/null or a closed
|
|
||||||
// pipe — falls through to a normal query instead of silently consuming empty
|
|
||||||
// input and printing nothing.
|
|
||||||
func stdinReader(f *os.File) (*bufio.Reader, bool) {
|
|
||||||
fi, err := f.Stat()
|
fi, err := f.Stat()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, false
|
return false
|
||||||
}
|
}
|
||||||
if (fi.Mode() & os.ModeCharDevice) != 0 {
|
return (fi.Mode() & os.ModeCharDevice) != 0
|
||||||
return nil, false // terminal or /dev/null
|
|
||||||
}
|
|
||||||
r := bufio.NewReader(f)
|
|
||||||
if _, err := r.Peek(1); err != nil {
|
|
||||||
return nil, false // empty pipe / empty file (EOF)
|
|
||||||
}
|
|
||||||
return r, true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func allFactsForNode(puppetDBURL, node string) ([]fact, error) {
|
func run(cfg config, nodeName, factName, match, partialMatch, inverseMatch string, showRole, nodeOnly, valueOnly, count, ansible, jsonMode bool) error {
|
||||||
query, _ := json.Marshal([]interface{}{"=", "certname", node})
|
|
||||||
return queryPuppetDB(puppetDBURL, string(query))
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
||||||
|
|
||||||
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")
|
||||||
}
|
}
|
||||||
if (match != "" || partial || inverse) && !showRole && factName == "" {
|
|
||||||
return fmt.Errorf("-R or -F must be used with -m, -p, or -i")
|
|
||||||
}
|
|
||||||
|
|
||||||
var collected []fact
|
var allFacts []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, partialMatch, inverseMatch, 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
|
||||||
}
|
}
|
||||||
collected = append(collected, facts...)
|
allFacts = append(allFacts, facts...)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if reader, ok := stdinReader(os.Stdin); ok && nodeName == "" {
|
if nodeName == "" && !isTerminal(os.Stdin) {
|
||||||
scanner := bufio.NewScanner(reader)
|
scanner := bufio.NewScanner(os.Stdin)
|
||||||
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(fields[0]); 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 {
|
||||||
@@ -302,12 +251,12 @@ func run(cfg config, nodeName, factName, match string, showRole, partial, invers
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
returnData := processResults(collected)
|
returnData := processResults(allFacts)
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case jsonMode:
|
case jsonMode:
|
||||||
hostFactMap := map[string]map[string]interface{}{}
|
hostFactMap := map[string]map[string]interface{}{}
|
||||||
for _, f := range collected {
|
for _, f := range allFacts {
|
||||||
if _, ok := hostFactMap[f.Certname]; !ok {
|
if _, ok := hostFactMap[f.Certname]; !ok {
|
||||||
hostFactMap[f.Certname] = map[string]interface{}{}
|
hostFactMap[f.Certname] = map[string]interface{}{}
|
||||||
}
|
}
|
||||||
@@ -324,14 +273,10 @@ func run(cfg config, nodeName, factName, match string, showRole, partial, invers
|
|||||||
enc := json.NewEncoder(os.Stdout)
|
enc := json.NewEncoder(os.Stdout)
|
||||||
enc.SetIndent("", " ")
|
enc.SetIndent("", " ")
|
||||||
enc.SetEscapeHTML(false)
|
enc.SetEscapeHTML(false)
|
||||||
_ = enc.Encode(hostFactMap)
|
enc.Encode(hostFactMap)
|
||||||
|
|
||||||
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:
|
||||||
hosts := map[string]interface{}{}
|
hosts := map[string]interface{}{}
|
||||||
@@ -343,7 +288,7 @@ func run(cfg config, nodeName, factName, match string, showRole, partial, invers
|
|||||||
"all": map[string]interface{}{"hosts": hosts},
|
"all": map[string]interface{}{"hosts": hosts},
|
||||||
}
|
}
|
||||||
b, _ := yaml.Marshal(inventory)
|
b, _ := yaml.Marshal(inventory)
|
||||||
_, _ = os.Stdout.Write(b)
|
os.Stdout.Write(b)
|
||||||
|
|
||||||
case nodeOnly:
|
case nodeOnly:
|
||||||
for _, line := range returnData {
|
for _, line := range returnData {
|
||||||
@@ -366,6 +311,18 @@ func run(cfg config, nodeName, factName, match string, showRole, partial, invers
|
|||||||
}
|
}
|
||||||
|
|
||||||
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)
|
||||||
@@ -373,19 +330,18 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
nodeName string
|
nodeName string
|
||||||
factName string
|
factName string
|
||||||
showRole bool
|
showRole bool
|
||||||
match string
|
match string
|
||||||
partial bool
|
partialMatch string
|
||||||
inverse bool
|
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
|
|
||||||
)
|
)
|
||||||
|
|
||||||
rootCmd := &cobra.Command{
|
rootCmd := &cobra.Command{
|
||||||
@@ -398,7 +354,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, showRole, partial, inverse, nodeOnly, valueOnly, count, ansible, jsonMode, allFacts)
|
return run(cfg, nodeName, factName, match, partialMatch, inverseMatch, showRole, nodeOnly, valueOnly, count, ansible, jsonMode)
|
||||||
},
|
},
|
||||||
SilenceUsage: true,
|
SilenceUsage: true,
|
||||||
}
|
}
|
||||||
@@ -407,15 +363,14 @@ func main() {
|
|||||||
f.StringVarP(&nodeName, "node", "n", "", "Node name")
|
f.StringVarP(&nodeName, "node", "n", "", "Node name")
|
||||||
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", "", "Value to match (use with -p and/or -i)")
|
f.StringVarP(&match, "match", "m", "", "Exact value match")
|
||||||
f.BoolVarP(&partial, "partial", "p", false, "Partial/regex match modifier (combine with -m)")
|
f.StringVar(&partialMatch, "pm", "", "Partial/regex match on value")
|
||||||
f.BoolVarP(&inverse, "inverse", "i", false, "Inverse match modifier (combine with -m)")
|
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{
|
||||||
@@ -447,14 +402,6 @@ func main() {
|
|||||||
configCmd.AddCommand(configInitCmd, configShowCmd)
|
configCmd.AddCommand(configInitCmd, configShowCmd)
|
||||||
rootCmd.AddCommand(configCmd)
|
rootCmd.AddCommand(configCmd)
|
||||||
|
|
||||||
versionCmd := &cobra.Command{
|
|
||||||
Use: "version",
|
|
||||||
Short: "Print the version",
|
|
||||||
Run: func(cmd *cobra.Command, args []string) { fmt.Println(version) },
|
|
||||||
SilenceUsage: true,
|
|
||||||
}
|
|
||||||
rootCmd.AddCommand(versionCmd)
|
|
||||||
|
|
||||||
if err := rootCmd.Execute(); err != nil {
|
if err := rootCmd.Execute(); err != nil {
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|||||||
+60
-438
@@ -2,7 +2,6 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"io"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
@@ -13,11 +12,19 @@ import (
|
|||||||
|
|
||||||
// ---- helpers ----------------------------------------------------------------
|
// ---- helpers ----------------------------------------------------------------
|
||||||
|
|
||||||
|
func mustMarshal(v interface{}) []byte {
|
||||||
|
b, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
func newTestServer(t *testing.T, facts []fact) *httptest.Server {
|
func newTestServer(t *testing.T, facts []fact) *httptest.Server {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
_ = json.NewEncoder(w).Encode(facts)
|
json.NewEncoder(w).Encode(facts)
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,161 +33,74 @@ func rawJSON(v interface{}) json.RawMessage {
|
|||||||
return json.RawMessage(b)
|
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 -------------------------------------------------------------
|
// ---- buildQuery -------------------------------------------------------------
|
||||||
|
|
||||||
func TestBuildQuery_NoFilters(t *testing.T) {
|
func TestBuildQuery_NoFilters(t *testing.T) {
|
||||||
// With no node/fact/match, buildQuery falls back to a role-fact query.
|
q := buildQuery("", "", "", "", "", "enc_role", false)
|
||||||
q := buildQuery("", "", "", "enc_role", false, false, false)
|
if !strings.Contains(q, "enc_role") {
|
||||||
if !strings.Contains(q, "enc_role") || !strings.Contains(q, "name") {
|
|
||||||
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, false, 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, false, 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)
|
||||||
}
|
}
|
||||||
// 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) {
|
func TestBuildQuery_PartialMatch(t *testing.T) {
|
||||||
// -p turns the value match into a regex ("~") comparison.
|
q := buildQuery("", "enc_role", "", "dns", "", "enc_role", false)
|
||||||
q := buildQuery("", "enc_role", "dns", "enc_role", false, true, 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_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) {
|
func TestBuildQuery_ShowRole(t *testing.T) {
|
||||||
q := buildQuery("", "", "", "my_role_fact", true, false, false)
|
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, false, false)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildQuery_FactBeatsRole(t *testing.T) {
|
// ---- valueString ------------------------------------------------------------
|
||||||
// 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) {
|
func TestValueString_String(t *testing.T) {
|
||||||
if got := valueString(rawJSON("hello")); got != "hello" {
|
raw := rawJSON("hello")
|
||||||
|
if got := valueString(raw); got != "hello" {
|
||||||
t.Fatalf("expected hello, got %s", got)
|
t.Fatalf("expected hello, got %s", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestValueString_Number(t *testing.T) {
|
func TestValueString_Number(t *testing.T) {
|
||||||
if got := valueString(rawJSON(42)); got != "42" {
|
raw := rawJSON(42)
|
||||||
|
if got := valueString(raw); got != "42" {
|
||||||
t.Fatalf("expected 42, got %s", got)
|
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) {
|
func TestValueString_Object(t *testing.T) {
|
||||||
raw := json.RawMessage(`{"a":1}`)
|
raw := json.RawMessage(`{"a":1}`)
|
||||||
if got := valueString(raw); got != `{"a":1}` {
|
got := valueString(raw)
|
||||||
|
if got != `{"a":1}` {
|
||||||
t.Fatalf("expected compact JSON, got %s", got)
|
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 ---------------------------------------------------------
|
// ---- processResults ---------------------------------------------------------
|
||||||
|
|
||||||
func TestProcessResults_Sorted(t *testing.T) {
|
func TestProcessResults_Sorted(t *testing.T) {
|
||||||
@@ -195,7 +115,8 @@ func TestProcessResults_Sorted(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestProcessResults_Empty(t *testing.T) {
|
func TestProcessResults_Empty(t *testing.T) {
|
||||||
if out := processResults(nil); len(out) != 0 {
|
out := processResults(nil)
|
||||||
|
if len(out) != 0 {
|
||||||
t.Fatalf("expected empty, got %v", out)
|
t.Fatalf("expected empty, got %v", out)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -203,7 +124,11 @@ func TestProcessResults_Empty(t *testing.T) {
|
|||||||
// ---- countResults -----------------------------------------------------------
|
// ---- countResults -----------------------------------------------------------
|
||||||
|
|
||||||
func TestCountResults_Basic(t *testing.T) {
|
func TestCountResults_Basic(t *testing.T) {
|
||||||
lines := []string{"host1 syd1", "host2 syd1", "host3 mel1"}
|
lines := []string{
|
||||||
|
"host1 syd1",
|
||||||
|
"host2 syd1",
|
||||||
|
"host3 mel1",
|
||||||
|
}
|
||||||
out := countResults(lines)
|
out := countResults(lines)
|
||||||
found := map[string]bool{}
|
found := map[string]bool{}
|
||||||
for _, l := range out {
|
for _, l := range out {
|
||||||
@@ -218,8 +143,9 @@ func TestCountResults_Basic(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCountResults_Sorted(t *testing.T) {
|
func TestCountResults_Sorted(t *testing.T) {
|
||||||
// Lexicographic sort of the "N: value" strings: "1: z" < "2: a".
|
lines := []string{"h1 z", "h2 a", "h3 a"}
|
||||||
out := countResults([]string{"h1 z", "h2 a", "h3 a"})
|
out := countResults(lines)
|
||||||
|
// lexicographic sort: "1: z" < "2: a"
|
||||||
if out[0] != "1: z" || out[1] != "2: a" {
|
if out[0] != "1: z" || out[1] != "2: a" {
|
||||||
t.Fatalf("unexpected order: %v", out)
|
t.Fatalf("unexpected order: %v", out)
|
||||||
}
|
}
|
||||||
@@ -228,11 +154,13 @@ func TestCountResults_Sorted(t *testing.T) {
|
|||||||
// ---- queryPuppetDB ----------------------------------------------------------
|
// ---- queryPuppetDB ----------------------------------------------------------
|
||||||
|
|
||||||
func TestQueryPuppetDB_Success(t *testing.T) {
|
func TestQueryPuppetDB_Success(t *testing.T) {
|
||||||
want := []fact{{Certname: "node1", Name: "enc_role", Value: rawJSON("roles::dns")}}
|
want := []fact{
|
||||||
|
{Certname: "node1", Name: "enc_role", Value: rawJSON("roles::dns")},
|
||||||
|
}
|
||||||
srv := newTestServer(t, want)
|
srv := newTestServer(t, want)
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
got, err := queryPuppetDB(srv.URL, `["=","name","enc_role"]`)
|
got, err := queryPuppetDB(srv.URL+"/pdb/query/v4/facts", `["=","name","enc_role"]`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -241,44 +169,33 @@ func TestQueryPuppetDB_Success(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
func TestQueryPuppetDB_HTTPError(t *testing.T) {
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
http.Error(w, "not found", http.StatusNotFound)
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
}))
|
}))
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
if _, err := queryPuppetDB(srv.URL, `[]`); err == nil {
|
_, err := queryPuppetDB(srv.URL+"/pdb/query/v4/facts", `["=","name","enc_role"]`)
|
||||||
|
if err == nil {
|
||||||
t.Fatal("expected error for 404")
|
t.Fatal("expected error for 404")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestQueryPuppetDB_BadJSON(t *testing.T) {
|
func TestQueryPuppetDB_BadJSON(t *testing.T) {
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
_, _ = w.Write([]byte("not json"))
|
w.Write([]byte("not json"))
|
||||||
}))
|
}))
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
if _, err := queryPuppetDB(srv.URL, `[]`); err == nil {
|
_, err := queryPuppetDB(srv.URL+"/pdb/query/v4/facts", `["=","name","enc_role"]`)
|
||||||
|
if err == nil {
|
||||||
t.Fatal("expected decode error")
|
t.Fatal("expected decode error")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestQueryPuppetDB_ConnectionRefused(t *testing.T) {
|
func TestQueryPuppetDB_ConnectionRefused(t *testing.T) {
|
||||||
if _, err := queryPuppetDB("http://127.0.0.1:1/facts", `[]`); err == nil {
|
_, err := queryPuppetDB("http://127.0.0.1:1/facts", `["=","name","enc_role"]`)
|
||||||
|
if err == nil {
|
||||||
t.Fatal("expected connection error")
|
t.Fatal("expected connection error")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -326,12 +243,8 @@ func TestLoadConfig_FileOverride(t *testing.T) {
|
|||||||
t.Setenv("NODE_LOOKUP_ROLE_FACT", "")
|
t.Setenv("NODE_LOOKUP_ROLE_FACT", "")
|
||||||
|
|
||||||
cfgDir := filepath.Join(dir, appName)
|
cfgDir := filepath.Join(dir, appName)
|
||||||
if err := os.MkdirAll(cfgDir, 0o755); err != nil {
|
os.MkdirAll(cfgDir, 0o755)
|
||||||
t.Fatal(err)
|
os.WriteFile(filepath.Join(cfgDir, configFileName), []byte("puppetdb_url: http://file:8080/facts\nrole_fact: file_role\n"), 0o644)
|
||||||
}
|
|
||||||
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()
|
cfg, err := loadConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -352,12 +265,8 @@ func TestLoadConfig_EnvOverridesFile(t *testing.T) {
|
|||||||
t.Setenv("NODE_LOOKUP_ROLE_FACT", "")
|
t.Setenv("NODE_LOOKUP_ROLE_FACT", "")
|
||||||
|
|
||||||
cfgDir := filepath.Join(dir, appName)
|
cfgDir := filepath.Join(dir, appName)
|
||||||
if err := os.MkdirAll(cfgDir, 0o755); err != nil {
|
os.MkdirAll(cfgDir, 0o755)
|
||||||
t.Fatal(err)
|
os.WriteFile(filepath.Join(cfgDir, configFileName), []byte("puppetdb_url: http://file:8080/facts\n"), 0o644)
|
||||||
}
|
|
||||||
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()
|
cfg, err := loadConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -375,14 +284,11 @@ func TestLoadConfig_InvalidYAML(t *testing.T) {
|
|||||||
t.Setenv("NODE_LOOKUP_ROLE_FACT", "")
|
t.Setenv("NODE_LOOKUP_ROLE_FACT", "")
|
||||||
|
|
||||||
cfgDir := filepath.Join(dir, appName)
|
cfgDir := filepath.Join(dir, appName)
|
||||||
if err := os.MkdirAll(cfgDir, 0o755); err != nil {
|
os.MkdirAll(cfgDir, 0o755)
|
||||||
t.Fatal(err)
|
os.WriteFile(filepath.Join(cfgDir, configFileName), []byte(":\tinvalid: yaml:\n"), 0o644)
|
||||||
}
|
|
||||||
if err := os.WriteFile(filepath.Join(cfgDir, configFileName), []byte(":\tinvalid: yaml:\n"), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := loadConfig(); err == nil {
|
_, err := loadConfig()
|
||||||
|
if err == nil {
|
||||||
t.Fatal("expected error for invalid YAML")
|
t.Fatal("expected error for invalid YAML")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -394,7 +300,9 @@ func TestWriteDefaultConfig(t *testing.T) {
|
|||||||
if err := writeDefaultConfig(); err != nil {
|
if err := writeDefaultConfig(); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
data, err := os.ReadFile(filepath.Join(dir, appName, configFileName))
|
|
||||||
|
path := filepath.Join(dir, appName, configFileName)
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -407,295 +315,9 @@ func TestWriteDefaultConfig_AlreadyExists(t *testing.T) {
|
|||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
t.Setenv("XDG_CONFIG_HOME", dir)
|
t.Setenv("XDG_CONFIG_HOME", dir)
|
||||||
|
|
||||||
if err := writeDefaultConfig(); err != nil {
|
writeDefaultConfig()
|
||||||
t.Fatal(err)
|
err := writeDefaultConfig()
|
||||||
}
|
if err == nil {
|
||||||
if err := writeDefaultConfig(); err == nil {
|
|
||||||
t.Fatal("expected error when config already exists")
|
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
---
|
|
||||||
# nfpm config for building the node-lookup RPM.
|
|
||||||
# Rendered through envsubst (see scripts/build-rpm.sh) then fed to `nfpm pkg`.
|
|
||||||
|
|
||||||
name: ${PACKAGE_NAME}
|
|
||||||
version: ${PACKAGE_VERSION}
|
|
||||||
release: ${PACKAGE_RELEASE}
|
|
||||||
arch: ${PACKAGE_ARCH}
|
|
||||||
platform: ${PACKAGE_PLATFORM}
|
|
||||||
section: default
|
|
||||||
priority: extra
|
|
||||||
description: "${PACKAGE_DESCRIPTION}"
|
|
||||||
|
|
||||||
maintainer: ${PACKAGE_MAINTAINER}
|
|
||||||
homepage: ${PACKAGE_HOMEPAGE}
|
|
||||||
license: ${PACKAGE_LICENSE}
|
|
||||||
|
|
||||||
disable_globbing: false
|
|
||||||
|
|
||||||
replaces:
|
|
||||||
- node-lookup
|
|
||||||
provides:
|
|
||||||
- node-lookup
|
|
||||||
|
|
||||||
contents:
|
|
||||||
# The CLI binary.
|
|
||||||
- src: dist/node-lookup
|
|
||||||
dst: /usr/bin/node-lookup
|
|
||||||
file_info:
|
|
||||||
mode: 0755
|
|
||||||
owner: root
|
|
||||||
group: root
|
|
||||||
|
|
||||||
# Shell completions (generated by scripts/build-rpm.sh before packaging).
|
|
||||||
- src: dist/completions/node-lookup.bash
|
|
||||||
dst: /usr/share/bash-completion/completions/node-lookup
|
|
||||||
file_info:
|
|
||||||
mode: 0644
|
|
||||||
- src: dist/completions/_node-lookup
|
|
||||||
dst: /usr/share/zsh/site-functions/_node-lookup
|
|
||||||
file_info:
|
|
||||||
mode: 0644
|
|
||||||
- src: dist/completions/node-lookup.fish
|
|
||||||
dst: /usr/share/fish/vendor_completions.d/node-lookup.fish
|
|
||||||
file_info:
|
|
||||||
mode: 0644
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
#
|
|
||||||
# Package the (already built) node-lookup binary into an RPM with nfpm,
|
|
||||||
# bundling generated bash/zsh/fish shell completions.
|
|
||||||
# Usage: scripts/build-rpm.sh [version] (version defaults to $CI_COMMIT_TAG)
|
|
||||||
#
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
||||||
cd "${ROOT_DIR}"
|
|
||||||
|
|
||||||
VERSION="${1:-${CI_COMMIT_TAG:-0.0.0-dev}}"
|
|
||||||
VERSION="${VERSION#v}" # strip a leading v
|
|
||||||
BINARY="node-lookup"
|
|
||||||
DIST="dist"
|
|
||||||
|
|
||||||
if [ ! -f "${DIST}/${BINARY}" ]; then
|
|
||||||
echo "ERROR: ${DIST}/${BINARY} not found; run 'make build' first" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Generate shell completions from the freshly built binary so they always match
|
|
||||||
# the shipped flags/subcommands.
|
|
||||||
COMP_DIR="${DIST}/completions"
|
|
||||||
mkdir -p "${COMP_DIR}"
|
|
||||||
"./${DIST}/${BINARY}" completion bash >"${COMP_DIR}/${BINARY}.bash"
|
|
||||||
"./${DIST}/${BINARY}" completion zsh >"${COMP_DIR}/_${BINARY}"
|
|
||||||
"./${DIST}/${BINARY}" completion fish >"${COMP_DIR}/${BINARY}.fish"
|
|
||||||
|
|
||||||
export PACKAGE_NAME="${BINARY}"
|
|
||||||
export PACKAGE_VERSION="${VERSION}"
|
|
||||||
export PACKAGE_RELEASE="1"
|
|
||||||
export PACKAGE_ARCH="amd64"
|
|
||||||
export PACKAGE_PLATFORM="linux"
|
|
||||||
export PACKAGE_DESCRIPTION="CLI tool that queries the PuppetDB API to look up and filter node facts"
|
|
||||||
export PACKAGE_MAINTAINER="Ben Vincent <ben@unkin.net>"
|
|
||||||
export PACKAGE_HOMEPAGE="https://git.unkin.net/unkin/node-lookup"
|
|
||||||
export PACKAGE_LICENSE="MIT"
|
|
||||||
|
|
||||||
envsubst <packaging/nfpm.yaml >"${DIST}/nfpm.yaml"
|
|
||||||
nfpm pkg --config "${DIST}/nfpm.yaml" --target "${DIST}" --packager rpm
|
|
||||||
|
|
||||||
echo "Built:"
|
|
||||||
ls -1 "${DIST}"/*.rpm
|
|
||||||
Reference in New Issue
Block a user