Package as RPM, add completions, fix no-TTY behavior, repair tests
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful

The test suite no longer compiled after the -p/-i match-modifier refactor,
there was no RPM packaging or shell-completion story, and running without a
TTY against an empty pipe silently returned nothing.

- Replace the fragile !isTerminal stdin check with stdinReader(): only read
  node names when stdin is a real pipe/redirect carrying data, so /dev/null,
  terminals, and empty/closed pipes fall through to a normal query (works when
  invoked by an agent/CI with no TTY).
- Rewrite main_test.go to match the current buildQuery/run signatures and add
  coverage for match modifiers, output modes, config precedence, and the new
  stdinReader logic.
- Add nfpm packaging (packaging/nfpm.yaml, scripts/build-rpm.sh) that installs
  the binary to /usr/bin and bundles generated bash/zsh/fish completions.
- Rework the Makefile to build into dist/ and add completions/rpm targets.
- Split the PR CI into build + test + pre-commit workflows and extend release
  to build the RPM and PUT it to the artifactapi rpm-internal repo; every step
  now sets a serviceAccount and k8s resources.
This commit is contained in:
2026-07-04 22:49:09 +10:00
parent 990e2a2e43
commit ea5c43468f
12 changed files with 722 additions and 161 deletions
+52 -26
View File
@@ -110,7 +110,7 @@ type fact struct {
Value json.RawMessage `json:"value"`
}
func buildQuery(node, factName, match, partialMatch, roleFact string, showRole bool) string {
func buildQuery(node, factName, match, roleFact string, showRole, partial, inverse bool) string {
type filter = []interface{}
var filters []filter
@@ -124,9 +124,16 @@ func buildQuery(node, factName, match, partialMatch, roleFact string, showRole b
}
if match != "" {
filters = append(filters, filter{"=", "value", match})
} else if partialMatch != "" {
filters = append(filters, filter{"~", "value", partialMatch})
op := "="
if partial {
op = "~"
}
inner := filter{op, "value", match}
if inverse {
filters = append(filters, filter{"not", inner})
} else {
filters = append(filters, inner)
}
}
if len(filters) == 0 {
@@ -207,12 +214,26 @@ func countResults(lines []string) []string {
return out
}
func isTerminal(f *os.File) bool {
// stdinReader returns a buffered reader over f and true only when f actually
// 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()
if err != nil {
return false
return nil, false
}
return (fi.Mode() & os.ModeCharDevice) != 0
if (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) {
@@ -220,7 +241,7 @@ func allFactsForNode(puppetDBURL, node string) ([]fact, error) {
return queryPuppetDB(puppetDBURL, string(query))
}
func run(cfg config, nodeName, factName, match, partialMatch string, showRole, 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)
if allFacts {
@@ -241,12 +262,15 @@ func run(cfg config, nodeName, factName, match, partialMatch string, showRole, n
if (nodeOnly || valueOnly || count || ansible) && !showRole && factName == "" {
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 stdinLines []string
doQuery := func(node string) error {
query := buildQuery(node, factName, match, partialMatch, cfg.RoleFact, showRole)
query := buildQuery(node, factName, match, cfg.RoleFact, showRole, partial, inverse)
facts, err := queryPuppetDB(cfg.PuppetDBURL, query)
if err != nil {
return err
@@ -255,8 +279,8 @@ func run(cfg config, nodeName, factName, match, partialMatch string, showRole, n
return nil
}
if nodeName == "" && !isTerminal(os.Stdin) {
scanner := bufio.NewScanner(os.Stdin)
if reader, ok := stdinReader(os.Stdin); ok && nodeName == "" {
scanner := bufio.NewScanner(reader)
if count {
for scanner.Scan() {
stdinLines = append(stdinLines, scanner.Text())
@@ -349,18 +373,19 @@ func main() {
}
var (
nodeName string
factName string
showRole bool
match string
partialMatch string
nodeOnly bool
valueOnly bool
count bool
ansible bool
jsonMode bool
allFacts bool
puppetDBURL string
nodeName string
factName string
showRole bool
match string
partial bool
inverse bool
nodeOnly bool
valueOnly bool
count bool
ansible bool
jsonMode bool
allFacts bool
puppetDBURL string
)
rootCmd := &cobra.Command{
@@ -373,7 +398,7 @@ func main() {
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
return run(cfg, nodeName, factName, match, partialMatch, showRole, nodeOnly, valueOnly, count, ansible, jsonMode, allFacts)
return run(cfg, nodeName, factName, match, showRole, partial, inverse, nodeOnly, valueOnly, count, ansible, jsonMode, allFacts)
},
SilenceUsage: true,
}
@@ -382,8 +407,9 @@ func main() {
f.StringVarP(&nodeName, "node", "n", "", "Node name")
f.StringVarP(&factName, "fact", "F", "", "Fact name")
f.BoolVarP(&showRole, "role", "R", false, "Show role fact ("+defaultRoleFact+" by default)")
f.StringVarP(&match, "match", "m", "", "Exact value match")
f.StringVar(&partialMatch, "pm", "", "Partial match on value")
f.StringVarP(&match, "match", "m", "", "Value to match (use with -p and/or -i)")
f.BoolVarP(&partial, "partial", "p", false, "Partial/regex match modifier (combine with -m)")
f.BoolVarP(&inverse, "inverse", "i", false, "Inverse match modifier (combine with -m)")
f.BoolVarP(&nodeOnly, "nodeonly", "1", false, "Show only the node name")
f.BoolVarP(&valueOnly, "valueonly", "2", false, "Show only the value")
f.BoolVarP(&count, "count", "C", false, "Count fact occurrences")