Answer AST name queries for the source fact on /facts
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful

This commit is contained in:
2026-10-09 23:41:57 +11:00
parent 7e2d5f7115
commit 0aff1746e2
5 changed files with 331 additions and 11 deletions
+5 -1
View File
@@ -247,7 +247,11 @@ matters more.
friends ask for specific facts, and the synthetic record is not one of them.
Only the outer query is inspected: a `name` filter inside an `in`/`select_facts`
subquery narrows which *nodes* match, not which facts come back, so injection
still happens;
still happens. The exception is a query whose `name` comparison (`=`, `~` or
`in` an `array`) selects `pdbmux_source` itself, built only from
`and`/`or`/`not` over `=`, `~` and `in`-`array` on `certname`, `environment`,
`name` and `value`: it is still fanned out for the real facts it names, and
the synthetic records are added after being filtered by the query locally;
- the path is `/facts/<name>` for any other fact. The path segment is the same
outer `name` constraint, so only the fact's own path carries the record;
- injection is turned off (see `source_fact_enabled`).
+64
View File
@@ -703,3 +703,67 @@ func TestHandler_FactRoutesAreNotCached(t *testing.T) {
})
}
}
// node-lookup and puppetboard filter /facts by name with an AST rather than the
// drilldown path; those queries select the synthetic records the path serves.
func TestHandler_FactsQuerySelectsSourceFact(t *testing.T) {
src := defaultSourceFact
tests := []struct {
query string
want map[string]string
}{
{`["=","name","` + src + `"]`, map[string]string{"h1": "a", "h2": "b", "h3": "b"}},
{`["and",["=","name","` + src + `"]]`, map[string]string{"h1": "a", "h2": "b", "h3": "b"}},
{`["and",["=","name","` + src + `"],["=","value","b"]]`, map[string]string{"h2": "b", "h3": "b"}},
{`["and",["=","name","` + src + `"],["not",["~","value","^b$"]]]`, map[string]string{"h1": "a"}},
{`["and",["=","certname","h3"],["=","name","` + src + `"]]`, map[string]string{"h3": "b"}},
{`["and",["or",["=","name","osfamily"],["=","name","` + src + `"]]]`, map[string]string{"h1": "a", "h2": "b", "h3": "b"}},
{`["in","name",["array",["osfamily","` + src + `"]]]`, map[string]string{"h1": "a", "h2": "b", "h3": "b"}},
{`["and",["or",["=","name","` + src + `"]],["=","environment","prod"]]`, map[string]string{"h1": "a"}},
}
for _, tc := range tests {
t.Run(tc.query, func(t *testing.T) {
a, b := sourceFactBackends(t)
a.bodies[factsPath] = `[` + factEnv("h1", "osfamily", "RedHat", "prod") + `,` + fact("h1", src, "stale", "") + `]`
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeFreshness))
rec := doGet(t, srv.Handler(), factsPath, tc.query)
if rec.Code != http.StatusOK {
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
}
got, n := sourceValues(t, rec.Body.Bytes(), src)
if n != len(tc.want) || !reflect.DeepEqual(got, tc.want) {
t.Fatalf("got %v (%d records), want %v: %s", got, n, tc.want, rec.Body.String())
}
})
}
}
// The query still reaches the backends, so the real facts it names come back
// beside the synthetic ones.
func TestHandler_FactsQueryMixedNamesKeepsRealFacts(t *testing.T) {
a, b := sourceFactBackends(t)
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeFreshness))
rec := doGet(t, srv.Handler(), factsPath, `["in","name",["array",["osfamily","`+defaultSourceFact+`"]]]`)
if os, _ := sourceValues(t, rec.Body.Bytes(), "osfamily"); len(os) != 3 {
t.Errorf("osfamily = %v, want all three nodes: %s", os, rec.Body.String())
}
if h := rec.Header().Get(backendsHeader); h != "2/2" {
t.Errorf("%s = %q, want 2/2", backendsHeader, h)
}
}
// With the feature off the name is an ordinary fact, so the query is fanned out
// untouched and nothing is synthesised.
func TestHandler_FactsQuerySourceFactDisabled(t *testing.T) {
a, b := sourceFactBackends(t)
cfg := testConfig(a.srv.URL, b.srv.URL, mergeFreshness)
cfg.SourceFactEnabled = false
srv := newTestServer(cfg)
rec := doGet(t, srv.Handler(), factsPath, `["=","name","`+defaultSourceFact+`"]`)
if _, n := sourceValues(t, rec.Body.Bytes(), defaultSourceFact); n != 0 {
t.Errorf("disabled feature synthesised %d records: %s", n, rec.Body.String())
}
}
+49 -8
View File
@@ -387,7 +387,54 @@ func (s *Server) serveUnion(w http.ResponseWriter, r *http.Request, path string,
}
func (s *Server) serveFacts(w http.ResponseWriter, r *http.Request) {
s.serveMerged(w, r, factsPath, s.mergeFactsResponse(r))
query := r.URL.Query().Get("query")
inject := s.newSourceInjector(query, true)
if ast := inject.sourceQuery(query); ast != nil {
s.serveSourceFactQuery(w, r, inject, ast)
return
}
s.serveMerged(w, r, factsPath, s.mergeFactsWith(inject))
}
// serveSourceFactQuery answers a /facts query that selects the owned fact by
// name. The query still goes upstream for the real facts it selects; the
// synthetic records come from the same merge serveSourceFact uses and are
// filtered by the query locally, since no backend holds them.
func (s *Server) serveSourceFactQuery(w http.ResponseWriter, r *http.Request, inject *sourceInjector, ast []json.RawMessage) {
params := queryParams(r.URL.Query().Get("query"))
s.serveCached(w, r, factsPath, params, func(ctx context.Context) (cachedResponse, error) {
alive, err := s.aliveResults(ctx, factsPath, params)
if err != nil {
return cachedResponse{}, err
}
recs := s.mergeFactsWith(inject)(alive)
synth, _, err := s.sourceFacts(ctx, s.newSourceInjector("", true), nil)
if err != nil {
return cachedResponse{}, err
}
for _, raw := range synth {
var f factFields
if json.Unmarshal(raw, &f) != nil {
continue
}
if match, _ := matchFact(ast, f); match {
recs = append(recs, raw)
}
}
resp := cachedResponse{Body: encodeRecords(recs), Records: -1}
s.countBackends(&resp, alive)
return resp, nil
})
}
// sourceFacts is the owned fact's synthetic record set for the nodes params
// selects, taken from the /facts merge so owner and environment match it.
func (s *Server) sourceFacts(ctx context.Context, inject *sourceInjector, params url.Values) ([]json.RawMessage, []backendResult, error) {
alive, err := s.aliveResults(ctx, factsPath, params)
if err != nil {
return nil, nil, err
}
return sourceFactRecords(s.mergeFactsWith(inject)(alive), inject.name, "", false), alive, nil
}
// Both path forms are the facts entity with a name (and value) constraint ANDed
@@ -427,7 +474,6 @@ func (s *Server) serveSourceFact(w http.ResponseWriter, r *http.Request, inject
}
params := queryParams(r.URL.Query().Get("query"))
merge := s.mergeFactsWith(inject)
var filter recordFilter
if valued {
filter = func(recs []json.RawMessage) []json.RawMessage {
@@ -437,11 +483,10 @@ func (s *Server) serveSourceFact(w http.ResponseWriter, r *http.Request, inject
// The stored set is the whole owned-fact record set, so every value of it
// keys, and waits on, the same fetch.
s.serveFiltered(w, r, factsPath+"/"+inject.name, params, filter, func(ctx context.Context) (cachedResponse, error) {
alive, err := s.aliveResults(ctx, factsPath, params)
recs, alive, err := s.sourceFacts(ctx, inject, params)
if err != nil {
return cachedResponse{}, err
}
recs := sourceFactRecords(merge(alive), inject.name, "", false)
resp := cachedResponse{Body: encodeRecords(recs), Records: -1}
s.countBackends(&resp, alive)
return resp, nil
@@ -766,10 +811,6 @@ func (s *Server) mergeNodesResponse(r *http.Request) func([]backendResult) []jso
}
}
func (s *Server) mergeFactsResponse(r *http.Request) func([]backendResult) []json.RawMessage {
return s.mergeFactsWith(s.newSourceInjector(r.URL.Query().Get("query"), true))
}
// The path segment of /facts/<name> is the same outer `name` constraint the
// query gate already rules injection out on, so nothing is synthesised here:
// the source fact's own path is diverted to serveSourceFact before this.
+142
View File
@@ -3,6 +3,8 @@ package main
import (
"encoding/json"
"log"
"regexp"
"slices"
"strings"
)
@@ -204,3 +206,143 @@ func constrainsField(parts []json.RawMessage, field string) bool {
var name string
return json.Unmarshal(parts[1], &name) == nil && name == field
}
// sourceQuery returns the parsed /facts query when one of its name comparisons
// selects the owned fact and the whole query can be evaluated locally against a
// fact record; nil otherwise, which leaves the query on the gated path.
// ponytail: a negated name match (["not",["=","name","x"]]) does not select the
// owned fact; add three-valued evaluation if a client needs it.
func (si *sourceInjector) sourceQuery(query string) []json.RawMessage {
if si == nil {
return nil
}
var ast []json.RawMessage
if json.Unmarshal([]byte(query), &ast) != nil {
return nil
}
probe := factFields{Name: si.name}
if _, ok := matchFact(ast, probe); !ok || !namesFact(ast, probe) {
return nil
}
return ast
}
// factFields are the queryable columns of the facts entity (openvoxdb
// engine.clj facts-query). value is a string here: the synthetic value always is.
type factFields struct {
Certname string `json:"certname"`
Environment string `json:"environment"`
Name string `json:"name"`
Value string `json:"value"`
}
func (f factFields) column(name string) (string, bool) {
switch name {
case "certname":
return f.Certname, true
case "environment":
return f.Environment, true
case "name":
return f.Name, true
case "value":
return f.Value, true
}
return "", false
}
// namesFact reports whether a name comparison in the boolean skeleton matches f.
func namesFact(parts []json.RawMessage, f factFields) bool {
var op, field string
if len(parts) < 2 || json.Unmarshal(parts[0], &op) != nil {
return false
}
switch op {
case "and", "or", "not":
for _, p := range parts[1:] {
var sub []json.RawMessage
if json.Unmarshal(p, &sub) == nil && namesFact(sub, f) {
return true
}
}
return false
}
if json.Unmarshal(parts[1], &field) != nil || field != "name" {
return false
}
match, _ := matchFact(parts, f)
return match
}
// matchFact evaluates a facts query against one record with openvoxdb's
// semantics for and/or/not, =, ~ and in-array. ok is false for anything else,
// including subqueries, so a caller can refuse rather than guess.
func matchFact(parts []json.RawMessage, f factFields) (match, ok bool) {
var op string
if len(parts) == 0 || json.Unmarshal(parts[0], &op) != nil {
return false, false
}
switch op {
case "and", "or":
match = op == "and"
for _, p := range parts[1:] {
var sub []json.RawMessage
if json.Unmarshal(p, &sub) != nil {
return false, false
}
m, ok := matchFact(sub, f)
if !ok {
return false, false
}
if op == "and" {
match = match && m
} else {
match = match || m
}
}
return match, true
case "not":
var sub []json.RawMessage
if len(parts) != 2 || json.Unmarshal(parts[1], &sub) != nil {
return false, false
}
m, ok := matchFact(sub, f)
return !m, ok
}
var field string
if len(parts) != 3 || json.Unmarshal(parts[1], &field) != nil {
return false, false
}
got, known := f.column(field)
if !known {
return false, false
}
switch op {
case "=":
var want any
if json.Unmarshal(parts[2], &want) != nil {
return false, false
}
return want == any(got), true
case "~":
var pattern string
if json.Unmarshal(parts[2], &pattern) != nil {
return false, false
}
re, err := regexp.Compile(pattern)
if err != nil {
return false, false
}
return re.MatchString(got), true
case "in":
var arr []json.RawMessage
var tag string
var values []any
if json.Unmarshal(parts[2], &arr) != nil || len(arr) != 2 ||
json.Unmarshal(arr[0], &tag) != nil || tag != "array" ||
json.Unmarshal(arr[1], &values) != nil {
return false, false
}
return slices.Contains(values, any(got)), true
}
return false, false
}
+71 -2
View File
@@ -234,7 +234,8 @@ func TestHandler_UpstreamSourceFactSuppressedOnEveryGateState(t *testing.T) {
}{
{"injection on", "", "a"},
{"gated by extract", `["extract",["certname","name","value"],["=","certname","h1"]]`, ""},
{"gated by name filter", `["=","name","` + defaultSourceFact + `"]`, ""},
{"gated by name filter", `["or",["=","name","role"],["=","name","os"]]`, ""},
{"selected by name filter", `["or",["=","name","role"],["=","name","` + defaultSourceFact + `"]]`, "a"},
{"gated by nested extract", `["and",["=","certname","h1"],["extract",["certname"]]]`, ""},
}
for _, tc := range tests {
@@ -391,7 +392,6 @@ func TestHandler_SourceNotInjectedWhenNameFiltered(t *testing.T) {
for _, q := range []string{
`["=","name","role"]`,
`["and",["=","certname","h1"],["=","name","role"]]`,
`["=","name","` + defaultSourceFact + `"]`,
} {
rec := doGet(t, srv.Handler(), factsPath, q)
if _, n := sourceValues(t, rec.Body.Bytes(), defaultSourceFact); n != 0 {
@@ -568,3 +568,72 @@ func TestMergeFacts_SourceOrderedAfterOwnersFacts(t *testing.T) {
t.Errorf("merged = %v, want %v", got, want)
}
}
func TestMatchFact(t *testing.T) {
f := factFields{Certname: "h1", Environment: "prod", Name: defaultSourceFact, Value: "a"}
tests := []struct {
query string
match, ok bool
}{
{`["=","name","` + defaultSourceFact + `"]`, true, true},
{`["=","name","osfamily"]`, false, true},
{`["=","value","a"]`, true, true},
{`["=","value",1]`, false, true},
{`["=","value",{"a":1}]`, false, true},
{`["~","value","^a$"]`, true, true},
{`["~","certname","^h"]`, true, true},
{`["not",["=","value","a"]]`, false, true},
{`["in","name",["array",["osfamily","` + defaultSourceFact + `"]]]`, true, true},
{`["in","name",["array",["osfamily"]]]`, false, true},
{`["and",["=","certname","h1"],["=","environment","prod"]]`, true, true},
{`["and",["=","certname","h1"],["=","environment","dev"]]`, false, true},
{`["or",["=","certname","h2"],["=","environment","prod"]]`, true, true},
{`["and"]`, true, true},
{`["or"]`, false, true},
{`["and",["=","name","` + defaultSourceFact + `"],["null?","value",false]]`, false, false},
{`["in","certname",["extract",["certname"],["select_nodes",["=","certname","h1"]]]]`, false, false},
{`["=","fact_contents","x"]`, false, false},
{`["~","value","("]`, false, false},
{`["<","value","b"]`, false, false},
{`["extract",["certname"],["=","name","x"]]`, false, false},
}
for _, tc := range tests {
var ast []json.RawMessage
if err := json.Unmarshal([]byte(tc.query), &ast); err != nil {
t.Fatal(err)
}
if m, ok := matchFact(ast, f); m != tc.match || ok != tc.ok {
t.Errorf("matchFact(%s) = %v, %v; want %v, %v", tc.query, m, ok, tc.match, tc.ok)
}
}
}
func TestSourceInjector_SourceQuery(t *testing.T) {
si := &sourceInjector{name: defaultSourceFact}
tests := []struct {
query string
want bool
}{
{`["=","name","` + defaultSourceFact + `"]`, true},
{`["and",["=","name","` + defaultSourceFact + `"]]`, true},
{`["and",["or",["=","name","ipaddress"],["=","name","` + defaultSourceFact + `"]]]`, true},
{`["and",["=","name","` + defaultSourceFact + `"],["not",["~","value","new"]]]`, true},
{`["in","name",["array",["ipaddress","` + defaultSourceFact + `"]]]`, true},
{`["~","name","^pdbmux"]`, true},
{`["=","name","ipaddress"]`, false},
{`["=","certname","h1"]`, false},
{`["=","value","` + defaultSourceFact + `"]`, false},
{`["and",["=","name","` + defaultSourceFact + `"],["in","certname",["extract",["certname"],["select_nodes",["=","certname","h1"]]]]]`, false},
{``, false},
{`not json`, false},
}
for _, tc := range tests {
if got := si.sourceQuery(tc.query) != nil; got != tc.want {
t.Errorf("sourceQuery(%s) = %v, want %v", tc.query, got, tc.want)
}
}
var off *sourceInjector
if off.sourceQuery(`["=","name","`+defaultSourceFact+`"]`) != nil {
t.Error("disabled injector selected a query")
}
}