Synthesise the /facts/<source-fact> drilldown
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful

/fact-names advertises the fact, so its drilldown must not be a dead link.

- Serve the source fact's own path from the /facts merge that produces the
  records, so certname set, owner and environment match /facts.
- Filter /facts/<source-fact>/<value> by the owning backend.
- Keep the aggregate, query-gate and disabled paths answering as before.
This commit is contained in:
2026-09-06 16:09:55 +10:00
parent cc71902a0d
commit 148be4fe0f
6 changed files with 399 additions and 99 deletions
+77
View File
@@ -456,6 +456,83 @@ func TestSourceFactInjectionAndGating(t *testing.T) {
})
}
// /fact-names advertises the fact, so its drilldown has to answer with the same
// records /facts carries rather than the empty set the backends hold.
func TestSourceFactDrilldown(t *testing.T) {
want := map[string]string{
nodeAlpha: backendAName,
nodeBeta: backendBName,
nodeGamma: backendBName,
nodeShared: backendBName, // won on freshness, not on configured order
}
path := factsPath + "/" + defaultSourceFact
t.Run("one record per node, attributed as /facts attributes it", func(t *testing.T) {
resp := get(t, path, nil)
rows := resp.rows(t)
if got := e2eCertnames(rows); !equalStrings(got, allNodes) {
t.Fatalf("%s certnames = %v, want %v", path, got, allNodes)
}
if len(rows) != len(allNodes) {
t.Fatalf("%s returned %d records for %d nodes", path, len(rows), len(allNodes))
}
unfiltered := get(t, factsPath, nil).rows(t)
for _, row := range rows {
cn, _ := row["certname"].(string)
if row["name"] != defaultSourceFact {
t.Errorf("%s returned a record named %v", path, row["name"])
}
if row["value"] != want[cn] {
t.Errorf("%s for %s = %v, want %q", path, cn, row["value"], want[cn])
}
// The drilldown must not disagree with the response it stands for.
if got, ok := factValue(unfiltered, cn, defaultSourceFact); !ok || got != row["value"] {
t.Errorf("%s for %s = %v, want the %s value %v", path, cn, row["value"], factsPath, got)
}
if _, ok := row["environment"]; !ok {
t.Errorf("%s record for %s has no environment key", path, cn)
}
}
if got := resp.header.Get(backendsHeader); got != "2/2" {
t.Errorf("%s = %q, want %q", backendsHeader, got, "2/2")
}
})
t.Run("the value sub-route filters by owning backend", func(t *testing.T) {
for _, tc := range []struct {
value string
want []string
}{
{backendAName, []string{nodeAlpha}},
{backendBName, []string{nodeBeta, nodeGamma, nodeShared}},
{"nosuchbackend", nil},
} {
rows := get(t, path+"/"+tc.value, nil).rows(t)
if got := e2eCertnames(rows); !equalStrings(got, tc.want) {
t.Errorf("%s/%s certnames = %v, want %v", path, tc.value, got, tc.want)
}
}
})
t.Run("a certname query narrows the drilldown", func(t *testing.T) {
rows := get(t, path, query(`["=","certname","`+nodeAlpha+`"]`)).rows(t)
if got := e2eCertnames(rows); !equalStrings(got, []string{nodeAlpha}) {
t.Errorf("certname-filtered %s = %v, want only %s", path, got, nodeAlpha)
}
})
// An aggregate carries no certname to attribute, so it stays summed.
t.Run("an aggregate is still summed", func(t *testing.T) {
ctx := context.Background()
const q = `["extract",[["function","count"]]]`
wantA := backendCount(ctx, t, h.a, path, q)
wantB := backendCount(ctx, t, h.b, path, q)
if got := countOf(t, get(t, path, query(q)).rows(t)); got != wantA+wantB {
t.Errorf("%s count = %d, want %d (%s=%d + %s=%d)", path, got, wantA+wantB, h.a.name, wantA, h.b.name, wantB)
}
})
}
// X-Backends has to report what the response was actually built from, not what
// is configured.
func TestBackendsHeaderReportsContributors(t *testing.T) {