mirror of
https://github.com/fleetdm/fleet
synced 2026-04-21 13:37:30 +00:00
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:
parent
9537f35923
commit
9dc573fb17
9 changed files with 312 additions and 71 deletions
2
changes/41540-host-details-reports-db-optimizations
Normal file
2
changes/41540-host-details-reports-db-optimizations
Normal 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.
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue