Support listing software hosts count filtered by team (#4388)

This commit is contained in:
Martin Angers 2022-02-28 13:55:14 -05:00 committed by GitHub
parent ad9fb8b36f
commit 4930ca2d0e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 354 additions and 76 deletions

View File

@ -0,0 +1 @@
* Support filtering software hosts count per team.

View File

@ -0,0 +1,28 @@
package tables
import (
"database/sql"
"github.com/pkg/errors"
)
func init() {
MigrationClient.AddMigration(Up_20220223113157, Down_20220223113157)
}
func Up_20220223113157(tx *sql.Tx) error {
alterStmt := `ALTER TABLE software_host_counts
ADD COLUMN team_id INT(10) UNSIGNED NOT NULL DEFAULT 0,
DROP PRIMARY KEY,
ADD PRIMARY KEY (software_id, team_id),
ADD INDEX idx_software_host_counts_team_id_hosts_count_software_id (team_id,hosts_count,software_id),
DROP INDEX idx_software_host_counts_host_count_software_id`
if _, err := tx.Exec(alterStmt); err != nil {
return errors.Wrap(err, "alter software_host_counts table")
}
return nil
}
func Down_20220223113157(tx *sql.Tx) error {
return nil
}

View File

@ -0,0 +1,24 @@
package tables
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestUp_20220223113157(t *testing.T) {
db := applyUpToPrev(t)
execNoErr(t, db, `INSERT INTO software_host_counts (software_id, hosts_count) VALUES (1, 1)`)
execNoErr(t, db, `INSERT INTO software_host_counts (software_id, hosts_count) VALUES (2, 10)`)
// Apply current migration.
applyNext(t, db)
var count int
require.NoError(t, db.Get(&count, `SELECT count(*) FROM software_host_counts WHERE team_id = 0`))
assert.Equal(t, 2, count)
require.NoError(t, db.Get(&count, `SELECT SUM(hosts_count) FROM software_host_counts WHERE team_id = 0`))
assert.Equal(t, 11, count)
}

File diff suppressed because one or more lines are too long

View File

@ -347,6 +347,12 @@ func selectSoftwareSQL(hostID *uint, opts fleet.SoftwareListOptions) (string, []
goqu.I("shc.hosts_count"),
goqu.I("shc.updated_at").As("counts_updated_at"),
)
if opts.TeamID != nil {
ds = ds.Where(goqu.I("shc.team_id").Eq(opts.TeamID))
} else {
ds = ds.Where(goqu.I("shc.team_id").Eq(0))
}
}
ds = appendListOptionsToSelect(ds, opts.ListOptions)
@ -640,88 +646,122 @@ func (ds *Datastore) SoftwareByID(ctx context.Context, id uint) (*fleet.Software
// After aggregation, it cleans up unused software (e.g. software installed
// on removed hosts, software uninstalled on hosts, etc.)
func (ds *Datastore) CalculateHostsPerSoftware(ctx context.Context, updatedAt time.Time) error {
resetStmt := `
UPDATE software_host_counts
SET hosts_count = 0, updated_at = ?`
const (
resetStmt = `
UPDATE software_host_counts
SET hosts_count = 0, updated_at = ?`
queryStmt := `
SELECT count(*), software_id
FROM host_software
WHERE software_id > 0
GROUP BY software_id`
// team_id is added to the select list to have the same structure as
// the teamCountsStmt, making it easier to use a common implementation
globalCountsStmt = `
SELECT count(*), 0 as team_id, software_id
FROM host_software
WHERE software_id > 0
GROUP BY software_id`
insertStmt := `
INSERT INTO software_host_counts
(software_id, hosts_count, updated_at)
VALUES
%s
ON DUPLICATE KEY UPDATE
hosts_count = VALUES(hosts_count),
updated_at = VALUES(updated_at)`
valuesPart := `(?, ?, ?),`
teamCountsStmt = `
SELECT count(*), h.team_id, hs.software_id
FROM host_software hs
INNER JOIN hosts h
ON hs.host_id = h.id
WHERE h.team_id IS NOT NULL AND hs.software_id > 0
GROUP BY hs.software_id, h.team_id`
insertStmt = `
INSERT INTO software_host_counts
(software_id, hosts_count, team_id, updated_at)
VALUES
%s
ON DUPLICATE KEY UPDATE
hosts_count = VALUES(hosts_count),
updated_at = VALUES(updated_at)`
valuesPart = `(?, ?, ?, ?),`
cleanupSoftwareStmt = `
DELETE s
FROM software s
LEFT JOIN software_host_counts shc
ON s.id = shc.software_id
WHERE
shc.software_id IS NULL OR
(shc.team_id = 0 AND shc.hosts_count = 0)`
cleanupTeamStmt = `
DELETE shc
FROM software_host_counts shc
LEFT JOIN teams t
ON t.id = shc.team_id
WHERE
shc.team_id > 0 AND
t.id IS NULL`
)
// first, reset all counts to 0
if _, err := ds.writer.ExecContext(ctx, resetStmt, updatedAt); err != nil {
return ctxerr.Wrap(ctx, err, "reset all software_host_counts to 0")
}
// next get a cursor for the counts for each software
rows, err := ds.reader.QueryContext(ctx, queryStmt)
if err != nil {
return ctxerr.Wrap(ctx, err, "read counts from host_software")
}
defer rows.Close()
// use a loop to iterate to prevent loading all in one go in memory, as it
// could get pretty big at >100K hosts with 1000+ software each.
const batchSize = 100
var batchCount int
args := make([]interface{}, 0, batchSize*3)
for rows.Next() {
var count int
var sid uint
if err := rows.Scan(&count, &sid); err != nil {
return ctxerr.Wrap(ctx, err, "scan row into variables")
// next get a cursor for the global and team counts for each software
stmtLabel := []string{"global", "team"}
for i, countStmt := range []string{globalCountsStmt, teamCountsStmt} {
rows, err := ds.reader.QueryContext(ctx, countStmt)
if err != nil {
return ctxerr.Wrapf(ctx, err, "read %s counts from host_software", stmtLabel[i])
}
defer rows.Close()
args = append(args, sid, count, updatedAt)
batchCount++
// use a loop to iterate to prevent loading all in one go in memory, as it
// could get pretty big at >100K hosts with 1000+ software each. Use a write
// batch to prevent making too many single-row inserts.
const batchSize = 100
var batchCount int
args := make([]interface{}, 0, batchSize*4)
for rows.Next() {
var (
count int
teamID uint
sid uint
)
if batchCount == batchSize {
values := strings.TrimSuffix(strings.Repeat(valuesPart, batchCount), ",")
if _, err := ds.writer.ExecContext(ctx, fmt.Sprintf(insertStmt, values), args...); err != nil {
return ctxerr.Wrap(ctx, err, "insert batch into software_host_counts")
if err := rows.Scan(&count, &teamID, &sid); err != nil {
return ctxerr.Wrapf(ctx, err, "scan %s row into variables", stmtLabel[i])
}
args = args[:0]
batchCount = 0
args = append(args, sid, count, teamID, updatedAt)
batchCount++
if batchCount == batchSize {
values := strings.TrimSuffix(strings.Repeat(valuesPart, batchCount), ",")
if _, err := ds.writer.ExecContext(ctx, fmt.Sprintf(insertStmt, values), args...); err != nil {
return ctxerr.Wrapf(ctx, err, "insert %s batch into software_host_counts", stmtLabel[i])
}
args = args[:0]
batchCount = 0
}
}
}
if batchCount > 0 {
values := strings.TrimSuffix(strings.Repeat(valuesPart, batchCount), ",")
if _, err := ds.writer.ExecContext(ctx, fmt.Sprintf(insertStmt, values), args...); err != nil {
return ctxerr.Wrap(ctx, err, "insert last batch into software_host_counts")
if batchCount > 0 {
values := strings.TrimSuffix(strings.Repeat(valuesPart, batchCount), ",")
if _, err := ds.writer.ExecContext(ctx, fmt.Sprintf(insertStmt, values), args...); err != nil {
return ctxerr.Wrapf(ctx, err, "insert last %s batch into software_host_counts", stmtLabel[i])
}
}
}
if err := rows.Err(); err != nil {
return ctxerr.Wrap(ctx, err, "iterate over host_software counts")
if err := rows.Err(); err != nil {
return ctxerr.Wrapf(ctx, err, "iterate over %s host_software counts", stmtLabel[i])
}
rows.Close()
}
cleanupStmt := `
DELETE FROM
software
WHERE
NOT EXISTS (
SELECT 1
FROM
software_host_counts shc
WHERE
software.id = shc.software_id AND
shc.hosts_count > 0)`
if _, err := ds.writer.ExecContext(ctx, cleanupStmt); err != nil {
// remove any unused software (global counts = 0)
if _, err := ds.writer.ExecContext(ctx, cleanupSoftwareStmt); err != nil {
return ctxerr.Wrap(ctx, err, "delete unused software")
}
// remove any software count row for teams that don't exist anymore
if _, err := ds.writer.ExecContext(ctx, cleanupTeamStmt); err != nil {
return ctxerr.Wrap(ctx, err, "delete software_host_counts for non-existing teams")
}
return nil
}

View File

@ -10,6 +10,7 @@ import (
"time"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/fleetdm/fleet/v4/server/test"
"github.com/jmoiron/sqlx"
"github.com/stretchr/testify/assert"
@ -578,14 +579,14 @@ func testSoftwareCalculateHostsPerSoftware(t *testing.T, ds *Datastore) {
{Name: "bar", Version: "0.0.3", Source: "deb_packages"},
}
require.NoError(t, ds.UpdateHostSoftware(context.Background(), host1.ID, software1))
require.NoError(t, ds.UpdateHostSoftware(context.Background(), host2.ID, software2))
require.NoError(t, ds.UpdateHostSoftware(ctx, host1.ID, software1))
require.NoError(t, ds.UpdateHostSoftware(ctx, host2.ID, software2))
err := ds.CalculateHostsPerSoftware(ctx, time.Now())
require.NoError(t, err)
swOpts := fleet.SoftwareListOptions{WithHostCounts: true, ListOptions: fleet.ListOptions{OrderKey: "hosts_count", OrderDirection: fleet.OrderDescending}}
swCounts := listSoftwareCheckCount(t, ds, 4, 4, swOpts, false)
globalOpts := fleet.SoftwareListOptions{WithHostCounts: true, ListOptions: fleet.ListOptions{OrderKey: "hosts_count", OrderDirection: fleet.OrderDescending}}
globalCounts := listSoftwareCheckCount(t, ds, 4, 4, globalOpts, false)
want := []fleet.Software{
{Name: "foo", Version: "0.0.3", HostsCount: 2},
@ -593,25 +594,25 @@ func testSoftwareCalculateHostsPerSoftware(t *testing.T, ds *Datastore) {
{Name: "foo", Version: "v0.0.2", HostsCount: 1},
{Name: "bar", Version: "0.0.3", HostsCount: 1},
}
cmpNameVersionCount(want, swCounts)
cmpNameVersionCount(want, globalCounts)
// update host2, remove "bar" software
software2 = []fleet.Software{
{Name: "foo", Version: "v0.0.2", Source: "chrome_extensions"},
{Name: "foo", Version: "0.0.3", Source: "chrome_extensions"},
}
require.NoError(t, ds.UpdateHostSoftware(context.Background(), host2.ID, software2))
require.NoError(t, ds.UpdateHostSoftware(ctx, host2.ID, software2))
err = ds.CalculateHostsPerSoftware(ctx, time.Now())
require.NoError(t, err)
swCounts = listSoftwareCheckCount(t, ds, 3, 3, swOpts, false)
globalCounts = listSoftwareCheckCount(t, ds, 3, 3, globalOpts, false)
want = []fleet.Software{
{Name: "foo", Version: "0.0.3", HostsCount: 2},
{Name: "foo", Version: "0.0.1", HostsCount: 1},
{Name: "foo", Version: "v0.0.2", HostsCount: 1},
}
cmpNameVersionCount(want, swCounts)
cmpNameVersionCount(want, globalCounts)
// create a software entry without any host and any counts
_, err = ds.writer.ExecContext(ctx, `INSERT INTO software (name, version, source) VALUES ('baz', '0.0.1', 'testing')`)
@ -638,6 +639,127 @@ func testSoftwareCalculateHostsPerSoftware(t *testing.T, ds *Datastore) {
{Name: "foo", Version: "v0.0.2", HostsCount: 0},
}
cmpNameVersionCount(want, allSw)
// create 2 teams and assign a new host to each
team1, err := ds.NewTeam(ctx, &fleet.Team{Name: "team1"})
require.NoError(t, err)
team2, err := ds.NewTeam(ctx, &fleet.Team{Name: "team2"})
require.NoError(t, err)
host3 := test.NewHost(t, ds, "host3", "", "host3key", "host3uuid", time.Now())
require.NoError(t, ds.AddHostsToTeam(ctx, &team1.ID, []uint{host3.ID}))
host4 := test.NewHost(t, ds, "host4", "", "host4key", "host4uuid", time.Now())
require.NoError(t, ds.AddHostsToTeam(ctx, &team2.ID, []uint{host4.ID}))
// assign existing host1 to team1 too, so we have a team with multiple hosts
require.NoError(t, ds.AddHostsToTeam(context.Background(), &team1.ID, []uint{host1.ID}))
// use some software for host3 and host4
software3 := []fleet.Software{
{Name: "foo", Version: "0.0.3", Source: "chrome_extensions"},
}
software4 := []fleet.Software{
{Name: "foo", Version: "0.0.3", Source: "chrome_extensions"},
{Name: "bar", Version: "0.0.3", Source: "deb_packages"},
}
require.NoError(t, ds.UpdateHostSoftware(ctx, host3.ID, software3))
require.NoError(t, ds.UpdateHostSoftware(ctx, host4.ID, software4))
// at this point, there's no counts per team, only global counts
globalCounts = listSoftwareCheckCount(t, ds, 3, 3, globalOpts, false)
want = []fleet.Software{
{Name: "foo", Version: "0.0.3", HostsCount: 2},
{Name: "foo", Version: "0.0.1", HostsCount: 1},
{Name: "foo", Version: "v0.0.2", HostsCount: 1},
}
cmpNameVersionCount(want, globalCounts)
team1Opts := fleet.SoftwareListOptions{WithHostCounts: true, TeamID: ptr.Uint(team1.ID), ListOptions: fleet.ListOptions{OrderKey: "hosts_count", OrderDirection: fleet.OrderDescending}}
team1Counts := listSoftwareCheckCount(t, ds, 0, 0, team1Opts, false)
want = []fleet.Software{}
cmpNameVersionCount(want, team1Counts)
// after a call to Calculate, the global counts are updated and the team counts appear
err = ds.CalculateHostsPerSoftware(ctx, time.Now())
require.NoError(t, err)
globalCounts = listSoftwareCheckCount(t, ds, 4, 4, globalOpts, false)
want = []fleet.Software{
{Name: "foo", Version: "0.0.3", HostsCount: 4},
{Name: "foo", Version: "0.0.1", HostsCount: 1},
{Name: "foo", Version: "v0.0.2", HostsCount: 1},
{Name: "bar", Version: "0.0.3", HostsCount: 1},
}
cmpNameVersionCount(want, globalCounts)
team1Counts = listSoftwareCheckCount(t, ds, 2, 2, team1Opts, false)
want = []fleet.Software{
{Name: "foo", Version: "0.0.3", HostsCount: 2},
{Name: "foo", Version: "0.0.1", HostsCount: 1},
}
cmpNameVersionCount(want, team1Counts)
team2Opts := fleet.SoftwareListOptions{WithHostCounts: true, TeamID: ptr.Uint(team2.ID), ListOptions: fleet.ListOptions{OrderKey: "hosts_count", OrderDirection: fleet.OrderDescending}}
team2Counts := listSoftwareCheckCount(t, ds, 2, 2, team2Opts, false)
want = []fleet.Software{
{Name: "foo", Version: "0.0.3", HostsCount: 1},
{Name: "bar", Version: "0.0.3", HostsCount: 1},
}
cmpNameVersionCount(want, team2Counts)
// update host4 (team2), remove "bar" software
software4 = []fleet.Software{
{Name: "foo", Version: "0.0.3", Source: "chrome_extensions"},
}
require.NoError(t, ds.UpdateHostSoftware(ctx, host4.ID, software4))
err = ds.CalculateHostsPerSoftware(ctx, time.Now())
require.NoError(t, err)
globalCounts = listSoftwareCheckCount(t, ds, 3, 3, globalOpts, false)
want = []fleet.Software{
{Name: "foo", Version: "0.0.3", HostsCount: 4},
{Name: "foo", Version: "0.0.1", HostsCount: 1},
{Name: "foo", Version: "v0.0.2", HostsCount: 1},
}
cmpNameVersionCount(want, globalCounts)
team1Counts = listSoftwareCheckCount(t, ds, 2, 2, team1Opts, false)
want = []fleet.Software{
{Name: "foo", Version: "0.0.3", HostsCount: 2},
{Name: "foo", Version: "0.0.1", HostsCount: 1},
}
cmpNameVersionCount(want, team1Counts)
team2Counts = listSoftwareCheckCount(t, ds, 1, 1, team2Opts, false)
want = []fleet.Software{
{Name: "foo", Version: "0.0.3", HostsCount: 1},
}
cmpNameVersionCount(want, team2Counts)
// update host4 (team2), remove all software and delete team
software4 = []fleet.Software{}
require.NoError(t, ds.UpdateHostSoftware(ctx, host4.ID, software4))
require.NoError(t, ds.DeleteTeam(ctx, team2.ID))
// this call will remove team2 from the software host counts table
err = ds.CalculateHostsPerSoftware(ctx, time.Now())
require.NoError(t, err)
globalCounts = listSoftwareCheckCount(t, ds, 3, 3, globalOpts, false)
want = []fleet.Software{
{Name: "foo", Version: "0.0.3", HostsCount: 3},
{Name: "foo", Version: "0.0.1", HostsCount: 1},
{Name: "foo", Version: "v0.0.2", HostsCount: 1},
}
cmpNameVersionCount(want, globalCounts)
team1Counts = listSoftwareCheckCount(t, ds, 2, 2, team1Opts, false)
want = []fleet.Software{
{Name: "foo", Version: "0.0.3", HostsCount: 2},
{Name: "foo", Version: "0.0.1", HostsCount: 1},
}
cmpNameVersionCount(want, team1Counts)
listSoftwareCheckCount(t, ds, 0, 0, team2Opts, false)
}
func insertVulnSoftwareForTest(t *testing.T, ds *Datastore) {

View File

@ -2,7 +2,9 @@ package mysql
import (
"context"
"database/sql"
"fmt"
"io"
"io/ioutil"
"os"
"os/exec"
@ -10,6 +12,7 @@ import (
"runtime"
"strings"
"testing"
"text/tabwriter"
"time"
"github.com/WatchBeam/clock"
@ -337,3 +340,39 @@ func TruncateTables(t testing.TB, ds *Datastore, tables ...string) {
return nil
}))
}
// this is meant to be used for debugging/testing that statement uses an efficient
// plan (e.g. makes use of an index, avoids full scans, etc.) using the data already
// created for tests. Calls to this function should be temporary and removed when
// done investigating the plan, so it is expected that this function will be detected
// as unused.
func explainSQLStatement(w io.Writer, db sqlx.QueryerContext, stmt string, args ...interface{}) { //nolint:deadcode,unused
var rows []struct {
ID int `db:"id"`
SelectType string `db:"select_type"`
Table sql.NullString `db:"table"`
Partitions sql.NullString `db:"partitions"`
Type sql.NullString `db:"type"`
PossibleKeys sql.NullString `db:"possible_keys"`
Key sql.NullString `db:"key"`
KeyLen sql.NullInt64 `db:"key_len"`
Ref sql.NullString `db:"ref"`
Rows sql.NullInt64 `db:"rows"`
Filtered sql.NullFloat64 `db:"filtered"`
Extra sql.NullString `db:"Extra"`
}
if err := sqlx.SelectContext(context.Background(), db, &rows, "EXPLAIN "+stmt, args...); err != nil {
panic(err)
}
fmt.Fprint(w, "\n\n", strings.Repeat("-", 60), "\n", stmt, "\n", strings.Repeat("-", 60), "\n")
tw := tabwriter.NewWriter(w, 0, 1, 1, ' ', tabwriter.Debug)
fmt.Fprintln(tw, "id\tselect_type\ttable\tpartitions\ttype\tpossible_keys\tkey\tkey_len\tref\trows\tfiltered\textra")
for _, row := range rows {
fmt.Fprintf(tw, "%d\t%s\t%s\t%s\t%s\t%s\t%s\t%d\t%s\t%d\t%f\t%s\n", row.ID, row.SelectType, row.Table.String, row.Partitions.String,
row.Type.String, row.PossibleKeys.String, row.Key.String, row.KeyLen.Int64, row.Ref.String, row.Rows.Int64, row.Filtered.Float64, row.Extra.String)
}
if err := tw.Flush(); err != nil {
panic(err)
}
}

View File

@ -2894,6 +2894,14 @@ func (s *integrationTestSuite) TestPaginateListSoftware() {
}
}
// create a team and make the last 3 hosts part of it (meaning 3 that use
// sws[19], 2 for sws[18], and 1 for sws[17])
tm, err := s.ds.NewTeam(context.Background(), &fleet.Team{
Name: t.Name(),
})
require.NoError(t, err)
require.NoError(t, s.ds.AddHostsToTeam(context.Background(), &tm.ID, []uint{hosts[19].ID, hosts[18].ID, hosts[17].ID}))
assertResp := func(resp listSoftwareResponse, want []fleet.Software, ts time.Time, counts ...int) {
require.Len(t, resp.Software, len(want))
for i := range resp.Software {
@ -2915,6 +2923,11 @@ func (s *integrationTestSuite) TestPaginateListSoftware() {
s.DoJSON("GET", "/api/v1/fleet/software", nil, http.StatusOK, &lsResp, "order_key", "hosts_count", "order_direction", "desc")
assertResp(lsResp, nil, time.Time{})
// same with a team filter
lsResp = listSoftwareResponse{}
s.DoJSON("GET", "/api/v1/fleet/software", nil, http.StatusOK, &lsResp, "order_key", "hosts_count", "order_direction", "desc", "team_id", fmt.Sprintf("%d", tm.ID))
assertResp(lsResp, nil, time.Time{})
// calculate hosts counts
hostsCountTs := time.Now().UTC()
require.NoError(t, s.ds.CalculateHostsPerSoftware(context.Background(), hostsCountTs))
@ -2968,6 +2981,16 @@ func (s *integrationTestSuite) TestPaginateListSoftware() {
lsResp = listSoftwareResponse{}
s.DoJSON("GET", "/api/v1/fleet/software", nil, http.StatusOK, &lsResp, "vulnerable", "true", "per_page", "5", "page", "2", "order_key", "hosts_count", "order_direction", "desc")
assertResp(lsResp, nil, time.Time{})
// filter by the team, 2 by page
lsResp = listSoftwareResponse{}
s.DoJSON("GET", "/api/v1/fleet/software", nil, http.StatusOK, &lsResp, "per_page", "2", "page", "0", "order_key", "hosts_count", "order_direction", "desc", "team_id", fmt.Sprintf("%d", tm.ID))
assertResp(lsResp, []fleet.Software{sws[19], sws[18]}, hostsCountTs, 3, 2)
// filter by the team, 2 by page, next page
lsResp = listSoftwareResponse{}
s.DoJSON("GET", "/api/v1/fleet/software", nil, http.StatusOK, &lsResp, "per_page", "2", "page", "1", "order_key", "hosts_count", "order_direction", "desc", "team_id", fmt.Sprintf("%d", tm.ID))
assertResp(lsResp, []fleet.Software{sws[17]}, hostsCountTs, 1)
}
func (s *integrationTestSuite) TestChangeUserEmail() {