From 0aff1746e270b709d6a91643f9cff2a45d49e41f Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Fri, 9 Oct 2026 23:41:57 +1100 Subject: [PATCH 1/2] Answer AST name queries for the source fact on /facts --- README.md | 6 +- factroutes_test.go | 64 ++++++++++++++++++++ server.go | 57 +++++++++++++++--- source.go | 142 +++++++++++++++++++++++++++++++++++++++++++++ source_test.go | 73 ++++++++++++++++++++++- 5 files changed, 331 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index bc53acc..c7e3008 100644 --- a/README.md +++ b/README.md @@ -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/` 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`). diff --git a/factroutes_test.go b/factroutes_test.go index 2648eb8..7446f2b 100644 --- a/factroutes_test.go +++ b/factroutes_test.go @@ -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()) + } +} diff --git a/server.go b/server.go index fbda220..48fdc1b 100644 --- a/server.go +++ b/server.go @@ -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/ 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. diff --git a/source.go b/source.go index 2aeeed1..7f6a039 100644 --- a/source.go +++ b/source.go @@ -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 +} diff --git a/source_test.go b/source_test.go index f33d287..88d4db9 100644 --- a/source_test.go +++ b/source_test.go @@ -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") + } +} From 97ddc378ffcf58f2635ce20255dc2c3483701d0b Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Fri, 9 Oct 2026 23:47:56 +1100 Subject: [PATCH 2/2] Narrow the source-fact /facts fetch to the query's certnames --- README.md | 4 ++- factroutes_test.go | 64 ++++++++++++++++++++++++++++++++++++++++++++++ server.go | 20 ++++++++++++--- server_test.go | 5 ++++ source.go | 35 +++++++++++++++++++++++-- source_test.go | 22 ++++++++++++++++ 6 files changed, 143 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index c7e3008..f7e5553 100644 --- a/README.md +++ b/README.md @@ -251,7 +251,9 @@ matters more. `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 synthetic records are added after being filtered by the query locally. + Their fetch carries only the query's top-level `and` conjuncts on + `certname`, so a single-host lookup stays single-host; - the path is `/facts/` 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`). diff --git a/factroutes_test.go b/factroutes_test.go index 7446f2b..67635b5 100644 --- a/factroutes_test.go +++ b/factroutes_test.go @@ -6,6 +6,8 @@ import ( "net/url" "reflect" "slices" + "sync" + "sync/atomic" "testing" ) @@ -767,3 +769,65 @@ func TestHandler_FactsQuerySourceFactDisabled(t *testing.T) { t.Errorf("disabled feature synthesised %d records: %s", n, rec.Body.String()) } } + +// The synthetic set is fetched with the query's top-level certname conjuncts +// only, so a single-host lookup never pulls the whole estate. +func TestHandler_FactsQuerySourceFactNarrowsUpstream(t *testing.T) { + a, b := sourceFactBackends(t) + var mu sync.Mutex + var got []string + h3Only := map[*fakeBackend]string{ + a: `[` + factEnv("h3", "osfamily", "RedHat", "prod") + `]`, + b: `[` + factEnv("h3", "osfamily", "Debian", "dev") + `]`, + } + for fb, body := range h3Only { + fb.handle = func(w http.ResponseWriter, r *http.Request) bool { + if r.URL.Path != factsPath { + return false + } + q := r.URL.Query().Get("query") + mu.Lock() + got = append(got, q) + mu.Unlock() + if q == `["=","certname","h3"]` { + _, _ = w.Write([]byte(body)) + return true + } + return false + } + } + srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeFreshness)) + + query := `["and",["=","certname","h3"],["=","environment","dev"],["=","name","` + defaultSourceFact + `"]]` + rec := doGet(t, srv.Handler(), factsPath, query) + if vals, n := sourceValues(t, rec.Body.Bytes(), defaultSourceFact); n != 1 || vals["h3"] != "b" { + t.Fatalf("got %v (%d records), want h3=b: %s", vals, n, rec.Body.String()) + } + for _, q := range got { + if q == "" { + t.Fatalf("an upstream /facts fetch was unfiltered: %q", got) + } + } + if !slices.Contains(got, `["=","certname","h3"]`) { + t.Fatalf("synthetic fetch was not narrowed to the certname: %q", got) + } +} + +// A backend lost between the two fetches is reported as missing. +func TestHandler_FactsQuerySourceFactCountsSecondFetch(t *testing.T) { + a, b := sourceFactBackends(t) + var calls atomic.Int32 + b.handle = func(w http.ResponseWriter, r *http.Request) bool { + if r.URL.Path == factsPath && calls.Add(1) > 1 { + http.Error(w, "boom", http.StatusInternalServerError) + return true + } + return false + } + srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeFreshness)) + + rec := doGet(t, srv.Handler(), factsPath, `["=","name","`+defaultSourceFact+`"]`) + if h := rec.Header().Get(backendsHeader); h != "1/2" { + t.Errorf("%s = %q, want 1/2", backendsHeader, h) + } +} diff --git a/server.go b/server.go index 48fdc1b..bc2f1b7 100644 --- a/server.go +++ b/server.go @@ -9,6 +9,7 @@ import ( "log" "net/http" "net/url" + "slices" "strconv" "strings" "sync" @@ -398,8 +399,9 @@ func (s *Server) serveFacts(w http.ResponseWriter, r *http.Request) { // 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. +// synthetic records come from the same merge serveSourceFact uses, fetched for +// the query's certname scope, 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) { @@ -408,7 +410,7 @@ func (s *Server) serveSourceFactQuery(w http.ResponseWriter, r *http.Request, in return cachedResponse{}, err } recs := s.mergeFactsWith(inject)(alive) - synth, _, err := s.sourceFacts(ctx, s.newSourceInjector("", true), nil) + synth, scoped, err := s.sourceFacts(ctx, s.newSourceInjector("", true), queryParams(certnameScope(ast))) if err != nil { return cachedResponse{}, err } @@ -422,11 +424,21 @@ func (s *Server) serveSourceFactQuery(w http.ResponseWriter, r *http.Request, in } } resp := cachedResponse{Body: encodeRecords(recs), Records: -1} - s.countBackends(&resp, alive) + s.countBackends(&resp, aliveInBoth(alive, scoped)) return resp, nil }) } +func aliveInBoth(a, b []backendResult) []backendResult { + var out []backendResult + for _, x := range a { + if slices.ContainsFunc(b, func(y backendResult) bool { return y.name == x.name }) { + out = append(out, x) + } + } + return out +} + // 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) { diff --git a/server_test.go b/server_test.go index c9f982f..83d7933 100644 --- a/server_test.go +++ b/server_test.go @@ -44,6 +44,8 @@ type fakeBackend struct { // rawPaths records the still-escaped request paths, so tests can assert an // MBean name's percent-encoding survived the proxy. rawPaths []string + // handle, when set, runs first and answers the request itself by returning true. + handle func(w http.ResponseWriter, r *http.Request) bool } func newFakeBackend(t *testing.T, nodesBody, factsBody string) *fakeBackend { @@ -63,6 +65,9 @@ func newFakeBackend(t *testing.T, nodesBody, factsBody string) *fakeBackend { fb.gotParams[r.URL.Path] = r.URL.Query() fb.rawPaths = append(fb.rawPaths, r.URL.EscapedPath()) fb.mu.Unlock() + if fb.handle != nil && fb.handle(w, r) { + return + } if fb.dead { panic(http.ErrAbortHandler) } diff --git a/source.go b/source.go index 7f6a039..9c83da2 100644 --- a/source.go +++ b/source.go @@ -210,8 +210,9 @@ func constrainsField(parts []json.RawMessage, field string) bool { // 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. +// ponytail: only a name match that is true for the owned fact selects it, so +// ["not",["=","name","osfamily"]] stays on the gated path and synthesises +// nothing; widen namesFact if a client needs negated selections. func (si *sourceInjector) sourceQuery(query string) []json.RawMessage { if si == nil { return nil @@ -346,3 +347,33 @@ func matchFact(parts []json.RawMessage, f factFields) (match, ok bool) { } return false, false } + +// certnameScope returns the top-level and's certname comparisons as a query, +// or "" when there are none. Pushing them upstream keeps every record of a +// selected node on every backend, so the merge's ownership is unchanged; an +// environment comparison is not pushed because it can hide the owner's records +// when backends disagree on a node's environment. +func certnameScope(ast []json.RawMessage) string { + var op string + if len(ast) < 2 || json.Unmarshal(ast[0], &op) != nil || op != "and" { + return "" + } + var keep []string + for _, p := range ast[1:] { + var sub []json.RawMessage + var field string + if json.Unmarshal(p, &sub) != nil || len(sub) != 3 || json.Unmarshal(sub[1], &field) != nil || field != "certname" { + continue + } + if _, ok := matchFact(sub, factFields{}); ok { + keep = append(keep, string(p)) + } + } + switch len(keep) { + case 0: + return "" + case 1: + return keep[0] + } + return `["and",` + strings.Join(keep, ",") + `]` +} diff --git a/source_test.go b/source_test.go index 88d4db9..70895f9 100644 --- a/source_test.go +++ b/source_test.go @@ -637,3 +637,25 @@ func TestSourceInjector_SourceQuery(t *testing.T) { t.Error("disabled injector selected a query") } } + +func TestCertnameScope(t *testing.T) { + tests := map[string]string{ + `["=","name","x"]`: ``, + `["and",["=","name","x"],["=","environment","prod"]]`: ``, + `["or",["=","certname","h1"],["=","name","x"]]`: ``, + `["and",["not",["=","certname","h1"]],["=","name","x"]]`: ``, + `["and",["=","certname","h1"],["=","name","x"]]`: `["=","certname","h1"]`, + `["and",["~","certname","["],["=","name","x"]]`: ``, + `["and",["in","certname",["extract","certname",["=","a","b"]]],["=","name","x"]]`: ``, + `["and",["in","certname",["array",["h1","h2"]]],["~","certname","^h"],["=","name","x"]]`: `["and",["in","certname",["array",["h1","h2"]]],["~","certname","^h"]]`, + } + for in, want := range tests { + var ast []json.RawMessage + if err := json.Unmarshal([]byte(in), &ast); err != nil { + t.Fatal(err) + } + if got := certnameScope(ast); got != want { + t.Errorf("certnameScope(%s) = %q, want %q", in, got, want) + } + } +}