Performance improvements for Host Reports (41540)

Resolves #41540

* Added new computed column to determinate whether query_result has
data.
* Added new index to query_results to to cover all query patterns.
* Refactored queries used in host report page to improve performance.
* Fixed various bugs with around query filtering for host reports.
This commit is contained in:
Juan Fernandez 2026-03-26 07:04:18 -04:00 committed by GitHub
parent 9537f35923
commit 9dc573fb17
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 312 additions and 71 deletions

View file

@ -0,0 +1,2 @@
* Improved database query performance for the Host Details > Reports page by adding a `has_data` virtual generated column to `query_results`.
* Added `(query_id, has_data, host_id, last_fetched)` index on query_results.

View file

@ -0,0 +1,26 @@
package tables
import (
"database/sql"
)
func init() {
MigrationClient.AddMigration(Up_20260324223334, Down_20260324223334)
}
func Up_20260324223334(tx *sql.Tx) error {
return withSteps([]migrationStep{
basicMigrationStep(
`ALTER TABLE query_results ADD COLUMN has_data TINYINT(1) GENERATED ALWAYS AS (data IS NOT NULL) VIRTUAL`,
"adding has_data virtual column to query_results",
),
basicMigrationStep(
`ALTER TABLE query_results ADD INDEX idx_query_id_has_data_host_id_last_fetched (query_id, has_data, host_id, last_fetched)`,
"adding idx_query_id_has_data_host_id_last_fetched index to query_results",
),
}, tx)
}
func Down_20260324223334(tx *sql.Tx) error {
return nil
}

View file

@ -83,7 +83,7 @@ func (ds *Datastore) QueryResultRows(ctx context.Context, queryID uint, filter f
h.hostname, h.computer_name, h.hardware_model, h.hardware_serial
FROM query_results qr
LEFT JOIN hosts h ON (qr.host_id=h.id)
WHERE query_id = ? AND data IS NOT NULL AND %s
WHERE query_id = ? AND has_data = 1 AND %s
`, ds.whereFilterHostsByTeams(filter, "h"))
results := []*fleet.ScheduledQueryResultRow{}
@ -99,7 +99,7 @@ func (ds *Datastore) QueryResultRows(ctx context.Context, queryID uint, filter f
// excluding rows with null data
func (ds *Datastore) ResultCountForQuery(ctx context.Context, queryID uint) (int, error) {
var count int
err := sqlx.GetContext(ctx, ds.reader(ctx), &count, `SELECT COUNT(*) FROM query_results WHERE query_id = ? AND data IS NOT NULL`, queryID)
err := sqlx.GetContext(ctx, ds.reader(ctx), &count, `SELECT COUNT(*) FROM query_results WHERE query_id = ? AND has_data = 1`, queryID)
if err != nil {
return 0, ctxerr.Wrap(ctx, err, "counting query results for query")
}
@ -111,7 +111,7 @@ func (ds *Datastore) ResultCountForQuery(ctx context.Context, queryID uint) (int
// excluding rows with null data
func (ds *Datastore) ResultCountForQueryAndHost(ctx context.Context, queryID, hostID uint) (int, error) {
var count int
err := sqlx.GetContext(ctx, ds.reader(ctx), &count, `SELECT COUNT(*) FROM query_results WHERE query_id = ? AND host_id = ? AND data IS NOT NULL`, queryID, hostID)
err := sqlx.GetContext(ctx, ds.reader(ctx), &count, `SELECT COUNT(*) FROM query_results WHERE query_id = ? AND host_id = ? AND has_data = 1`, queryID, hostID)
if err != nil {
return 0, ctxerr.Wrap(ctx, err, "counting query results for query and host")
}
@ -191,7 +191,7 @@ func (ds *Datastore) CleanupExcessQueryResultRows(ctx context.Context, maxQueryR
SELECT query_id, id,
ROW_NUMBER() OVER (PARTITION BY query_id ORDER BY id DESC) as rn
FROM query_results
WHERE query_id IN (?) AND data IS NOT NULL
WHERE query_id IN (?) AND has_data = 1
) cutoff
WHERE rn = ?
`
@ -214,7 +214,7 @@ func (ds *Datastore) CleanupExcessQueryResultRows(ctx context.Context, maxQueryR
for _, c := range queryCutoffs {
deleteStmt := `
DELETE FROM query_results
WHERE query_id = ? AND id < ? AND data IS NOT NULL
WHERE query_id = ? AND id < ? AND has_data = 1
LIMIT ?
`
for {
@ -240,7 +240,7 @@ func (ds *Datastore) CleanupExcessQueryResultRows(ctx context.Context, maxQueryR
countStmt := `
SELECT query_id, COUNT(*) as count
FROM query_results
WHERE query_id IN (?) AND data IS NOT NULL
WHERE query_id IN (?) AND has_data = 1
GROUP BY query_id
`
for batch := range slices.Chunk(queryIDs, queryIDBatchSize) {
@ -293,17 +293,11 @@ type hostReportRow struct {
// the provided filtering, sorting, and pagination options. maxQueryReportRows
// is the configured report cap; a query whose total result count (across all
// hosts) meets or exceeds this value is considered clipped.
//
// The implementation uses three queries to avoid N correlated subqueries:
// 1. A paginated query list (no joins to query_results).
// 2. A single aggregation over query_results for the page's query IDs to
// compute per-query and per-host counts and the most recent fetch time.
// 3. A window-function query to fetch the most recent result row for
// each query on the page.
func (ds *Datastore) ListHostReports(
ctx context.Context,
hostID uint,
teamID *uint,
hostPlatform string,
opts fleet.ListHostReportsOptions,
maxQueryReportRows int,
) ([]*fleet.HostReport, int, *fleet.PaginationMetadata, error) {
@ -329,6 +323,24 @@ func (ds *Datastore) ListHostReports(
whereClause += " AND q.discard_data = 0 AND q.logging_type = 'snapshot'"
}
// Filter by label membership: include queries that have no labels, or whose
// labels overlap with the labels the host belongs to.
whereClause += `
AND (NOT EXISTS (
SELECT 1 FROM query_labels ql WHERE ql.query_id = q.id
) OR EXISTS (
SELECT 1
FROM query_labels ql
JOIN label_membership lm ON lm.label_id = ql.label_id AND lm.host_id = ?
WHERE ql.query_id = q.id
))`
whereArgs = append(whereArgs, hostID)
// Filter by platform: include queries with no platform restriction, or
// whose platform list contains the host's normalized platform.
whereClause += " AND (q.platform = '' OR FIND_IN_SET(?, q.platform) > 0)"
whereArgs = append(whereArgs, hostPlatform)
matchQuery := strings.TrimSpace(opts.ListOptions.MatchQuery)
if matchQuery != "" {
whereClause, whereArgs = searchLike(whereClause, whereArgs, matchQuery, "q.name")
@ -336,18 +348,15 @@ func (ds *Datastore) ListHostReports(
countStmt := "SELECT COUNT(*) FROM queries q " + whereClause
// The list query LEFT JOINs a per-host aggregation so that last_result_fetched
// is available for ORDER BY. The JOIN uses hostID as its only argument, which
// must be prepended before the WHERE-clause args.
// Do a LATERAL subquery for each row in queries q so that everything stays in index space
listStmt := `
SELECT q.id, q.name, q.description, q.discard_data, q.logging_type, qr_stats.last_result_fetched
FROM queries q
LEFT JOIN (
SELECT query_id, MAX(last_fetched) AS last_result_fetched
LEFT JOIN LATERAL (
SELECT MAX(last_fetched) AS last_result_fetched
FROM query_results
WHERE host_id = ?
GROUP BY query_id
) qr_stats ON q.id = qr_stats.query_id
WHERE query_id = q.id AND host_id = ?
) qr_stats ON TRUE
` + whereClause
listArgs := append([]any{hostID}, whereArgs...)
@ -401,37 +410,55 @@ func (ds *Datastore) ListHostReports(
queryIDs = append(queryIDs, r.QueryID)
}
// Batch-fetch result stats for the page in a single aggregation query.
// n_query_results counts all hosts (used for report_clipped).
// n_host_results is scoped to the current host (used for has_more_results).
// last_result_fetched is already available from the list query LEFT JOIN.
type statsRow struct {
// Fetch the total non-null result count per query across all hosts, used to
// determine report_clipped.
type totalCountRow struct {
QueryID uint `db:"query_id"`
NQueryResults int `db:"n_query_results"`
NHostResults int `db:"n_host_results"`
}
statsStmt, statsArgs, err := sqlx.In(`
SELECT
query_id,
COUNT(data) AS n_query_results,
SUM(CASE WHEN host_id = ? AND data IS NOT NULL THEN 1 ELSE 0 END) AS n_host_results
totalStmt, totalArgs, err := sqlx.In(`
SELECT query_id, COUNT(*) AS n_query_results
FROM query_results
WHERE query_id IN (?)
WHERE query_id IN (?) AND has_data = 1
GROUP BY query_id
`, hostID, queryIDs)
`, queryIDs)
if err != nil {
return nil, 0, nil, ctxerr.Wrap(ctx, err, "building stats query for host reports")
return nil, 0, nil, ctxerr.Wrap(ctx, err, "building total count query for host reports")
}
var statsRows []statsRow
if err := sqlx.SelectContext(ctx, dbReader, &statsRows, dbReader.Rebind(statsStmt), statsArgs...); err != nil {
return nil, 0, nil, ctxerr.Wrap(ctx, err, "fetching host report stats")
var totalCountRows []totalCountRow
if err := sqlx.SelectContext(ctx, dbReader, &totalCountRows, dbReader.Rebind(totalStmt), totalArgs...); err != nil {
return nil, 0, nil, ctxerr.Wrap(ctx, err, "fetching total result counts for host reports")
}
statsByQueryID := make(map[uint]*statsRow, len(statsRows))
for i := range statsRows {
statsByQueryID[statsRows[i].QueryID] = &statsRows[i]
nQueryResultsByID := make(map[uint]int, len(totalCountRows))
for _, r := range totalCountRows {
nQueryResultsByID[r.QueryID] = r.NQueryResults
}
// Batch-fetch the most recent result row for each query on the page.
// Fetch the host-specific result count per query, used to populate
// NHostResults.
type hostCountRow struct {
QueryID uint `db:"query_id"`
NHostResults int `db:"n_host_results"`
}
hostCountStmt, hostCountArgs, err := sqlx.In(`
SELECT query_id, COUNT(*) AS n_host_results
FROM query_results
WHERE query_id IN (?) AND host_id = ? AND has_data = 1
GROUP BY query_id
`, queryIDs, hostID)
if err != nil {
return nil, 0, nil, ctxerr.Wrap(ctx, err, "building host count query for host reports")
}
var hostCountRows []hostCountRow
if err := sqlx.SelectContext(ctx, dbReader, &hostCountRows, dbReader.Rebind(hostCountStmt), hostCountArgs...); err != nil {
return nil, 0, nil, ctxerr.Wrap(ctx, err, "fetching host result counts for host reports")
}
nHostResultsByID := make(map[uint]int, len(hostCountRows))
for _, r := range hostCountRows {
nHostResultsByID[r.QueryID] = r.NHostResults
}
// Fetch the single most recent result row per query for this host.
type firstDataRow struct {
QueryID uint `db:"query_id"`
Data *json.RawMessage `db:"data"`
@ -444,7 +471,7 @@ func (ds *Datastore) ListHostReports(
data,
ROW_NUMBER() OVER (PARTITION BY query_id ORDER BY last_fetched DESC) AS rn
FROM query_results
WHERE query_id IN (?) AND host_id = ? AND data IS NOT NULL
WHERE query_id IN (?) AND host_id = ? AND has_data = 1
) ranked
WHERE rn = 1
`, queryIDs, hostID)
@ -475,10 +502,8 @@ func (ds *Datastore) ListHostReports(
t := qr.LastResultFetched.Time
r.LastFetched = &t
}
if stats, ok := statsByQueryID[qr.QueryID]; ok {
r.NHostResults = stats.NHostResults
r.ReportClipped = stats.NQueryResults >= maxQueryReportRows
}
r.NHostResults = nHostResultsByID[qr.QueryID]
r.ReportClipped = nQueryResultsByID[qr.QueryID] >= maxQueryReportRows
if data, ok := firstDataByQueryID[qr.QueryID]; ok {
var cols map[string]string
if err := json.Unmarshal(*data, &cols); err != nil {

View file

@ -895,7 +895,7 @@ func testListHostReports(t *testing.T, ds *Datastore) {
// IncludeReportsDontStoreResults defaults to false: only include discard_data=0 AND logging_type='snapshot'.
ListOptions: fleet.ListOptions{OrderKey: "name", IncludeMetadata: true},
}
reports, total, meta, err := ds.ListHostReports(ctx, host.ID, nil, opts, fleet.DefaultMaxQueryReportRows)
reports, total, meta, err := ds.ListHostReports(ctx, host.ID, nil, "", opts, fleet.DefaultMaxQueryReportRows)
require.NoError(t, err)
// Should get qSave1 and qSave2 but NOT qDiscard (doesn't satisfy discard_data=0 AND logging_type='snapshot').
assert.Equal(t, 2, total)
@ -911,7 +911,7 @@ func testListHostReports(t *testing.T, ds *Datastore) {
IncludeReportsDontStoreResults: true,
ListOptions: fleet.ListOptions{OrderKey: "name", IncludeMetadata: true},
}
reports, total, _, err := ds.ListHostReports(ctx, host.ID, nil, opts, fleet.DefaultMaxQueryReportRows)
reports, total, _, err := ds.ListHostReports(ctx, host.ID, nil, "", opts, fleet.DefaultMaxQueryReportRows)
require.NoError(t, err)
// All 4 queries are returned including both don't-store-results variants.
assert.Equal(t, 4, total)
@ -930,7 +930,7 @@ func testListHostReports(t *testing.T, ds *Datastore) {
IncludeReportsDontStoreResults: true,
ListOptions: fleet.ListOptions{OrderKey: "name"},
}
reports, _, _, err := ds.ListHostReports(ctx, host.ID, nil, opts, fleet.DefaultMaxQueryReportRows)
reports, _, _, err := ds.ListHostReports(ctx, host.ID, nil, "", opts, fleet.DefaultMaxQueryReportRows)
require.NoError(t, err)
require.Len(t, reports, 4)
@ -956,7 +956,7 @@ func testListHostReports(t *testing.T, ds *Datastore) {
opts := fleet.ListHostReportsOptions{
ListOptions: fleet.ListOptions{OrderKey: "name"},
}
reports, _, _, err := ds.ListHostReports(ctx, host.ID, nil, opts, fleet.DefaultMaxQueryReportRows)
reports, _, _, err := ds.ListHostReports(ctx, host.ID, nil, "", opts, fleet.DefaultMaxQueryReportRows)
require.NoError(t, err)
require.Len(t, reports, 2)
@ -975,7 +975,7 @@ func testListHostReports(t *testing.T, ds *Datastore) {
opts := fleet.ListHostReportsOptions{
ListOptions: fleet.ListOptions{OrderKey: "name"},
}
reports, _, _, err := ds.ListHostReports(ctx, host.ID, nil, opts, fleet.DefaultMaxQueryReportRows)
reports, _, _, err := ds.ListHostReports(ctx, host.ID, nil, "", opts, fleet.DefaultMaxQueryReportRows)
require.NoError(t, err)
require.Len(t, reports, 2)
@ -994,7 +994,7 @@ func testListHostReports(t *testing.T, ds *Datastore) {
MatchQuery: "Alpha",
},
}
reports, total, _, err := ds.ListHostReports(ctx, host.ID, nil, opts, fleet.DefaultMaxQueryReportRows)
reports, total, _, err := ds.ListHostReports(ctx, host.ID, nil, "", opts, fleet.DefaultMaxQueryReportRows)
require.NoError(t, err)
assert.Equal(t, 1, total)
require.Len(t, reports, 1)
@ -1012,7 +1012,7 @@ func testListHostReports(t *testing.T, ds *Datastore) {
OrderDirection: fleet.OrderAscending,
},
}
reports, _, _, err := ds.ListHostReports(ctx, host.ID, nil, opts, fleet.DefaultMaxQueryReportRows)
reports, _, _, err := ds.ListHostReports(ctx, host.ID, nil, "", opts, fleet.DefaultMaxQueryReportRows)
require.NoError(t, err)
require.Len(t, reports, 2)
assert.Equal(t, "Save Query Alpha", reports[0].Name) // has results
@ -1020,7 +1020,7 @@ func testListHostReports(t *testing.T, ds *Datastore) {
// DESC: non-null values first (newest to oldest), NULLs at bottom.
opts.ListOptions.OrderDirection = fleet.OrderDescending
reports, _, _, err = ds.ListHostReports(ctx, host.ID, nil, opts, fleet.DefaultMaxQueryReportRows)
reports, _, _, err = ds.ListHostReports(ctx, host.ID, nil, "", opts, fleet.DefaultMaxQueryReportRows)
require.NoError(t, err)
require.Len(t, reports, 2)
assert.Equal(t, "Save Query Alpha", reports[0].Name) // has results
@ -1037,7 +1037,7 @@ func testListHostReports(t *testing.T, ds *Datastore) {
IncludeMetadata: true,
},
}
reports, total, meta, err := ds.ListHostReports(ctx, host.ID, nil, opts, fleet.DefaultMaxQueryReportRows)
reports, total, meta, err := ds.ListHostReports(ctx, host.ID, nil, "", opts, fleet.DefaultMaxQueryReportRows)
require.NoError(t, err)
assert.Equal(t, 2, total)
require.Len(t, reports, 1)
@ -1047,7 +1047,7 @@ func testListHostReports(t *testing.T, ds *Datastore) {
// Second page.
opts.ListOptions.Page = 1
reports2, total2, meta2, err := ds.ListHostReports(ctx, host.ID, nil, opts, fleet.DefaultMaxQueryReportRows)
reports2, total2, meta2, err := ds.ListHostReports(ctx, host.ID, nil, "", opts, fleet.DefaultMaxQueryReportRows)
require.NoError(t, err)
assert.Equal(t, 2, total2)
require.Len(t, reports2, 1)
@ -1074,7 +1074,7 @@ func testListHostReports(t *testing.T, ds *Datastore) {
opts := fleet.ListHostReportsOptions{
ListOptions: fleet.ListOptions{OrderKey: "name"},
}
reports, total, _, err := ds.ListHostReports(ctx, host.ID, nil, opts, fleet.DefaultMaxQueryReportRows)
reports, total, _, err := ds.ListHostReports(ctx, host.ID, nil, "", opts, fleet.DefaultMaxQueryReportRows)
require.NoError(t, err)
// Team-Only query should not appear because host has no team.
assert.Equal(t, 2, total)
@ -1097,7 +1097,7 @@ func testListHostReports(t *testing.T, ds *Datastore) {
opts := fleet.ListHostReportsOptions{
ListOptions: fleet.ListOptions{OrderKey: "name"},
}
reports, total, _, err := ds.ListHostReports(ctx, teamHost.ID, &team.ID, opts, fleet.DefaultMaxQueryReportRows)
reports, total, _, err := ds.ListHostReports(ctx, teamHost.ID, &team.ID, "", opts, fleet.DefaultMaxQueryReportRows)
require.NoError(t, err)
// Should see the 2 global save queries plus the team-scoped query.
assert.Equal(t, 3, total)
@ -1130,7 +1130,7 @@ func testListHostReports(t *testing.T, ds *Datastore) {
opts := fleet.ListHostReportsOptions{
ListOptions: fleet.ListOptions{OrderKey: "name"},
}
reports, _, _, err := ds.ListHostReports(ctx, host.ID, nil, opts, capacity)
reports, _, _, err := ds.ListHostReports(ctx, host.ID, nil, "", opts, capacity)
require.NoError(t, err)
require.Len(t, reports, 2)
@ -1143,4 +1143,185 @@ func testListHostReports(t *testing.T, ds *Datastore) {
assert.Equal(t, "Save Query Beta", beta.Name)
assert.False(t, beta.ReportClipped)
})
t.Run("platform_filtering", func(t *testing.T) {
// Create a darwin-only query and a linux-only query.
qDarwin, err := ds.NewQuery(ctx, &fleet.Query{
Name: "Darwin Only Query",
Query: "SELECT 7",
AuthorID: &user.ID,
Saved: true,
Logging: fleet.LoggingSnapshot,
Platform: "darwin",
})
require.NoError(t, err)
_, err = ds.NewQuery(ctx, &fleet.Query{
Name: "Linux Only Query",
Query: "SELECT 8",
AuthorID: &user.ID,
Saved: true,
Logging: fleet.LoggingSnapshot,
Platform: "linux",
})
require.NoError(t, err)
// host has platform "darwin" (set by test.NewHost via osqueryID heuristic;
// we'll use a darwin host for clarity).
darwinHost := test.NewHost(t, ds, "darwin-host", "10.1.1.1", "darwin-key", "darwin-serial", time.Now())
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx, `UPDATE hosts SET platform = 'darwin' WHERE id = ?`, darwinHost.ID)
return err
})
opts := fleet.ListHostReportsOptions{
IncludeReportsDontStoreResults: true,
ListOptions: fleet.ListOptions{OrderKey: "name"},
}
reports, _, _, err := ds.ListHostReports(ctx, darwinHost.ID, nil, "darwin", opts, fleet.DefaultMaxQueryReportRows)
require.NoError(t, err)
names := make([]string, 0, len(reports))
for _, r := range reports {
names = append(names, r.Name)
}
assert.Contains(t, names, qDarwin.Name, "darwin host must see darwin-platform query")
assert.NotContains(t, names, "Linux Only Query", "darwin host must not see linux-only query")
// Queries with no platform restriction must always be visible.
assert.Contains(t, names, qSave1.Name, "darwin host must see platform-unrestricted query")
})
t.Run("label_filtering", func(t *testing.T) {
// Create a label and a query scoped to that label.
label, err := ds.NewLabel(ctx, &fleet.Label{Name: "label-filter-test", Query: "SELECT 1"})
require.NoError(t, err)
qLabeled, err := ds.NewQuery(ctx, &fleet.Query{
Name: "Labeled Query Eta",
Query: "SELECT 6",
AuthorID: &user.ID,
Saved: true,
Logging: fleet.LoggingSnapshot,
LabelsIncludeAny: []fleet.LabelIdent{{LabelName: label.Name}},
})
require.NoError(t, err)
opts := fleet.ListHostReportsOptions{
IncludeReportsDontStoreResults: true,
ListOptions: fleet.ListOptions{OrderKey: "name"},
}
// host is NOT a member of the label — labeled query must be excluded.
reports, _, _, err := ds.ListHostReports(ctx, host.ID, nil, "", opts, fleet.DefaultMaxQueryReportRows)
require.NoError(t, err)
names := make([]string, 0, len(reports))
for _, r := range reports {
names = append(names, r.Name)
}
assert.NotContains(t, names, qLabeled.Name, "host without label must not see label-scoped query")
// Add host to the label.
err = ds.RecordLabelQueryExecutions(ctx, host, map[uint]*bool{label.ID: new(true)}, time.Now(), false)
require.NoError(t, err)
// host IS now a member of the label — labeled query must be included.
reports, _, _, err = ds.ListHostReports(ctx, host.ID, nil, "", opts, fleet.DefaultMaxQueryReportRows)
require.NoError(t, err)
names = names[:0]
for _, r := range reports {
names = append(names, r.Name)
}
assert.Contains(t, names, qLabeled.Name, "host with matching label must see label-scoped query")
// Queries with NO labels are always visible regardless of host membership.
assert.Contains(t, names, qSave1.Name, "unlabeled query must always be visible")
})
t.Run("combined_platform_label_team_filters", func(t *testing.T) {
// This test verifies that all three filters (platform, label, team) are
// applied simultaneously and are each independently capable of excluding
// a query.
//
// Setup:
// host: linux platform, member of labelA, no team (global)
// team: teamB
//
// Queries (all saved, IncludeReportsDontStoreResults=true):
// qAll no platform, no label, global → VISIBLE
// qLinux linux, no label, global → VISIBLE (platform matches)
// qLabelA no platform, labelA, global → VISIBLE (label matches)
// qLinuxLabelA linux, labelA, global → VISIBLE (both match)
// qDarwin darwin, no label, global → EXCLUDED by platform
// qLabelB no platform, labelB, global → EXCLUDED by label
// qTeamB no platform, no label, teamB → EXCLUDED by team
// qDarwinLabelA darwin, labelA, global → EXCLUDED by platform (despite label)
// qLinuxLabelB linux, labelB, global → EXCLUDED by label (despite platform)
team, err := ds.NewTeam(ctx, &fleet.Team{Name: "combined-filter-team"})
require.NoError(t, err)
labelA, err := ds.NewLabel(ctx, &fleet.Label{Name: "combined-labelA", Query: "SELECT 1"})
require.NoError(t, err)
labelB, err := ds.NewLabel(ctx, &fleet.Label{Name: "combined-labelB", Query: "SELECT 1"})
require.NoError(t, err)
newQ := func(name, platform string, labels []fleet.LabelIdent, teamID *uint) {
t.Helper()
_, err := ds.NewQuery(ctx, &fleet.Query{
Name: name,
Query: "SELECT 1",
AuthorID: &user.ID,
Saved: true,
Logging: fleet.LoggingSnapshot,
Platform: platform,
LabelsIncludeAny: labels,
TeamID: teamID,
})
require.NoError(t, err)
}
newQ("combined-qAll", "", nil, nil)
newQ("combined-qLinux", "linux", nil, nil)
newQ("combined-qLabelA", "", []fleet.LabelIdent{{LabelName: labelA.Name}}, nil)
newQ("combined-qLinuxLabelA", "linux", []fleet.LabelIdent{{LabelName: labelA.Name}}, nil)
newQ("combined-qDarwin", "darwin", nil, nil)
newQ("combined-qLabelB", "", []fleet.LabelIdent{{LabelName: labelB.Name}}, nil)
newQ("combined-qTeamB", "", nil, &team.ID)
newQ("combined-qDarwinLabelA", "darwin", []fleet.LabelIdent{{LabelName: labelA.Name}}, nil)
newQ("combined-qLinuxLabelB", "linux", []fleet.LabelIdent{{LabelName: labelB.Name}}, nil)
linuxHost := test.NewHost(t, ds, "combined-linux-host", "10.2.2.2", "combined-key", "combined-serial", time.Now())
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx, `UPDATE hosts SET platform = 'ubuntu' WHERE id = ?`, linuxHost.ID)
return err
})
// Add the host to labelA only.
err = ds.RecordLabelQueryExecutions(ctx, linuxHost, map[uint]*bool{labelA.ID: new(true)}, time.Now(), false)
require.NoError(t, err)
opts := fleet.ListHostReportsOptions{
IncludeReportsDontStoreResults: true,
ListOptions: fleet.ListOptions{OrderKey: "name"},
}
// host has no team → pass nil teamID; PlatformFromHost("ubuntu") = "linux"
reports, _, _, err := ds.ListHostReports(ctx, linuxHost.ID, nil, "linux", opts, fleet.DefaultMaxQueryReportRows)
require.NoError(t, err)
names := make(map[string]bool, len(reports))
for _, r := range reports {
names[r.Name] = true
}
assert.True(t, names["combined-qAll"], "unrestricted query must be visible")
assert.True(t, names["combined-qLinux"], "matching-platform query must be visible")
assert.True(t, names["combined-qLabelA"], "matching-label query must be visible")
assert.True(t, names["combined-qLinuxLabelA"], "matching platform+label query must be visible")
assert.False(t, names["combined-qDarwin"], "wrong-platform query must be excluded")
assert.False(t, names["combined-qLabelB"], "non-member label query must be excluded")
assert.False(t, names["combined-qTeamB"], "other-team query must be excluded")
assert.False(t, names["combined-qDarwinLabelA"], "wrong platform must exclude even if label matches")
assert.False(t, names["combined-qLinuxLabelB"], "non-member label must exclude even if platform matches")
})
}

File diff suppressed because one or more lines are too long

View file

@ -583,7 +583,7 @@ type Datastore interface {
// host (nil for global). maxQueryReportRows is the configured report cap used to determine
// whether each query's report has been clipped. It returns the list of reports, the total
// count (without pagination), optional pagination metadata, and any error.
ListHostReports(ctx context.Context, hostID uint, teamID *uint, opts ListHostReportsOptions, maxQueryReportRows int) ([]*HostReport, int, *PaginationMetadata, error)
ListHostReports(ctx context.Context, hostID uint, teamID *uint, hostPlatform string, opts ListHostReportsOptions, maxQueryReportRows int) ([]*HostReport, int, *PaginationMetadata, error)
///////////////////////////////////////////////////////////////////////////////
// TeamStore

View file

@ -443,7 +443,7 @@ type CleanupDiscardedQueryResultsFunc func(ctx context.Context) error
type CleanupExcessQueryResultRowsFunc func(ctx context.Context, maxQueryReportRows int, opts ...fleet.CleanupExcessQueryResultRowsOptions) (map[uint]int, error)
type ListHostReportsFunc func(ctx context.Context, hostID uint, teamID *uint, opts fleet.ListHostReportsOptions, maxQueryReportRows int) ([]*fleet.HostReport, int, *fleet.PaginationMetadata, error)
type ListHostReportsFunc func(ctx context.Context, hostID uint, teamID *uint, hostPlatform string, opts fleet.ListHostReportsOptions, maxQueryReportRows int) ([]*fleet.HostReport, int, *fleet.PaginationMetadata, error)
type NewTeamFunc func(ctx context.Context, team *fleet.Team) (*fleet.Team, error)
@ -6022,11 +6022,11 @@ func (s *DataStore) CleanupExcessQueryResultRows(ctx context.Context, maxQueryRe
return s.CleanupExcessQueryResultRowsFunc(ctx, maxQueryReportRows, opts...)
}
func (s *DataStore) ListHostReports(ctx context.Context, hostID uint, teamID *uint, opts fleet.ListHostReportsOptions, maxQueryReportRows int) ([]*fleet.HostReport, int, *fleet.PaginationMetadata, error) {
func (s *DataStore) ListHostReports(ctx context.Context, hostID uint, teamID *uint, hostPlatform string, opts fleet.ListHostReportsOptions, maxQueryReportRows int) ([]*fleet.HostReport, int, *fleet.PaginationMetadata, error) {
s.mu.Lock()
s.ListHostReportsFuncInvoked = true
s.mu.Unlock()
return s.ListHostReportsFunc(ctx, hostID, teamID, opts, maxQueryReportRows)
return s.ListHostReportsFunc(ctx, hostID, teamID, hostPlatform, opts, maxQueryReportRows)
}
func (s *DataStore) NewTeam(ctx context.Context, team *fleet.Team) (*fleet.Team, error) {

View file

@ -2048,7 +2048,7 @@ func (svc *Service) ListHostReports(
opts.ListOptions.OrderDirection = fleet.OrderDescending
}
reports, total, meta, err := svc.ds.ListHostReports(ctx, hostID, host.TeamID, opts, maxQueryReportRows)
reports, total, meta, err := svc.ds.ListHostReports(ctx, hostID, host.TeamID, fleet.PlatformFromHost(host.Platform), opts, maxQueryReportRows)
if err != nil {
return nil, 0, nil, ctxerr.Wrap(ctx, err, "list host reports from datastore")
}

View file

@ -66,7 +66,7 @@ func TestListHostReports(t *testing.T) {
}
var capturedTeamID *uint
ds.ListHostReportsFunc = func(ctx context.Context, hostID uint, tID *uint, opts fleet.ListHostReportsOptions, maxQueryReportRows int) ([]*fleet.HostReport, int, *fleet.PaginationMetadata, error) {
ds.ListHostReportsFunc = func(ctx context.Context, hostID uint, tID *uint, hostPlatform string, opts fleet.ListHostReportsOptions, maxQueryReportRows int) ([]*fleet.HostReport, int, *fleet.PaginationMetadata, error) {
capturedTeamID = tID
return sampleReports, len(sampleReports), nil, nil
}
@ -154,7 +154,9 @@ func TestListHostReportsDatastorePassthrough(t *testing.T) {
ds := new(mock.Store)
teamID := uint(5)
host := &fleet.Host{ID: 7, TeamID: &teamID}
// Platform is "ubuntu" so PlatformFromHost maps it to "linux" — a non-trivial
// conversion that would be missed if the service passed host.Platform raw.
host := &fleet.Host{ID: 7, TeamID: &teamID, Platform: "ubuntu"}
admin := &fleet.User{
ID: 1,
@ -171,11 +173,13 @@ func TestListHostReportsDatastorePassthrough(t *testing.T) {
capturedHostID := uint(0)
capturedTeamID := (*uint)(nil)
capturedPlatform := ""
capturedOpts := fleet.ListHostReportsOptions{}
ds.ListHostReportsFunc = func(ctx context.Context, hostID uint, tID *uint, opts fleet.ListHostReportsOptions, maxQueryReportRows int) ([]*fleet.HostReport, int, *fleet.PaginationMetadata, error) {
ds.ListHostReportsFunc = func(ctx context.Context, hostID uint, tID *uint, hostPlatform string, opts fleet.ListHostReportsOptions, maxQueryReportRows int) ([]*fleet.HostReport, int, *fleet.PaginationMetadata, error) {
capturedHostID = hostID
capturedTeamID = tID
capturedPlatform = hostPlatform
capturedOpts = opts
return nil, 0, nil, nil
}
@ -199,6 +203,7 @@ func TestListHostReportsDatastorePassthrough(t *testing.T) {
assert.Equal(t, host.ID, capturedHostID)
assert.Equal(t, &teamID, capturedTeamID)
assert.Equal(t, fleet.PlatformFromHost(host.Platform), capturedPlatform)
assert.True(t, capturedOpts.IncludeReportsDontStoreResults)
assert.Equal(t, uint(1), capturedOpts.ListOptions.Page)
assert.Equal(t, uint(10), capturedOpts.ListOptions.PerPage)