Add battery info in host details response (#6394)

This commit is contained in:
Martin Angers 2022-06-28 14:11:49 -04:00 committed by GitHub
parent ae3ebf09ec
commit 539be8ee09
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 537 additions and 31 deletions

View File

@ -3,7 +3,7 @@
If some of the following don't apply, delete the relevant line.
- [ ] Changes file added for user-visible changes (in `changes/` and/or `orbit/changes/`).
- [ ] Documented any API changes (docs/Using-Fleet/REST-API.md)
- [ ] Documented any API changes (docs/Using-Fleet/REST-API.md or docs/Contributing/API-for-contributors.md)
- [ ] Documented any permissions changes
- [ ] Ensured that input data is properly validated, SQL injection is prevented (using placeholders for values in statements)
- [ ] Added support on fleet's osquery simulator `cmd/osquery-perf` for new osquery data ingestion features.

View File

@ -0,0 +1 @@
* Added battery condition to host details API response for macOS hosts.

View File

@ -307,6 +307,9 @@ func TestGetHosts(t *testing.T) {
ds.ListPacksForHostFunc = func(ctx context.Context, hid uint) (packs []*fleet.Pack, err error) {
return make([]*fleet.Pack, 0), nil
}
ds.ListHostBatteriesFunc = func(ctx context.Context, hid uint) (batteries []*fleet.HostBattery, err error) {
return nil, nil
}
defaultPolicyQuery := "select 1 from osquery_info where start_time > 1;"
ds.ListPoliciesForHostFunc = func(ctx context.Context, host *fleet.Host) ([]*fleet.HostPolicy, error) {
return []*fleet.HostPolicy{

View File

@ -16,6 +16,7 @@ import (
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"text/template"
@ -660,6 +661,27 @@ func (a *agent) googleChromeProfiles() []map[string]string {
return result
}
func (a *agent) batteries() []map[string]string {
count := rand.Intn(3) // return between 0 and 2 batteries
result := make([]map[string]string, count)
for i := range result {
health := "Good"
cycleCount := rand.Intn(2000)
switch {
case cycleCount > 1500:
health = "Poor"
case cycleCount > 1000:
health = "Fair"
}
result[i] = map[string]string{
"serial_number": fmt.Sprintf("%04d", i),
"cycle_count": strconv.Itoa(cycleCount),
"health": health,
}
}
return result
}
func (a *agent) processQuery(name, query string) (handled bool, results []map[string]string, status *fleet.OsqueryStatus) {
const (
hostPolicyQueryPrefix = "fleet_policy_query_"
@ -695,6 +717,12 @@ func (a *agent) processQuery(name, query string) (handled bool, results []map[st
results = a.googleChromeProfiles()
}
return true, results, &ss
case name == hostDetailQueryPrefix+"battery":
ss := fleet.OsqueryStatus(rand.Intn(2))
if ss == fleet.StatusOK {
results = a.batteries()
}
return true, results, &ss
default:
// Look for results in the template file.
if t := a.templates.Lookup(name); t == nil {

View File

@ -2134,7 +2134,13 @@ If the scheduled queries haven't run on the host yet, the stats have zero values
"issues": {
"failing_policies_count": 2,
"total_issues_count": 2
}
},
"batteries": [
{
"cycle_count": 999,
"health": "Good"
}
]
}
}
```
@ -2201,7 +2207,13 @@ Returns the information of the host specified using the `uuid`, `osquery_host_id
"team_name": null,
"gigs_disk_space_available": 45.86,
"percent_disk_space_available": 73,
"pack_stats": null
"pack_stats": null,
"batteries": [
{
"cycle_count": 999,
"health": "Good"
}
]
}
}
```

View File

@ -282,6 +282,7 @@ var hostRefs = []string{
"host_mdm",
"host_munki_info",
"host_device_auth",
"host_batteries",
}
func (ds *Datastore) DeleteHost(ctx context.Context, hid uint) error {
@ -1244,6 +1245,71 @@ func (ds *Datastore) ReplaceHostDeviceMapping(ctx context.Context, hid uint, map
})
}
func (ds *Datastore) ReplaceHostBatteries(ctx context.Context, hid uint, mappings []*fleet.HostBattery) error {
const (
replaceStmt = `
INSERT INTO
host_batteries (
host_id,
serial_number,
cycle_count,
health
)
VALUES
%s
ON DUPLICATE KEY UPDATE
cycle_count = VALUES(cycle_count),
health = VALUES(health),
updated_at = CURRENT_TIMESTAMP
`
valuesPart = `(?, ?, ?, ?),`
deleteExceptStmt = `
DELETE FROM
host_batteries
WHERE
host_id = ? AND
serial_number NOT IN (?)
`
deleteAllStmt = `
DELETE FROM
host_batteries
WHERE
host_id = ?
`
)
replaceArgs := make([]interface{}, 0, len(mappings)*4)
deleteNotIn := make([]string, 0, len(mappings))
for _, hb := range mappings {
deleteNotIn = append(deleteNotIn, hb.SerialNumber)
replaceArgs = append(replaceArgs, hid, hb.SerialNumber, hb.CycleCount, hb.Health)
}
return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error {
// first, insert the new batteries or update the existing ones
if len(replaceArgs) > 0 {
if _, err := tx.ExecContext(ctx, fmt.Sprintf(replaceStmt, strings.TrimSuffix(strings.Repeat(valuesPart, len(mappings)), ",")), replaceArgs...); err != nil {
return ctxerr.Wrap(ctx, err, "upsert host batteries")
}
}
// then, delete the old ones
if len(deleteNotIn) > 0 {
delStmt, args, err := sqlx.In(deleteExceptStmt, hid, deleteNotIn)
if err != nil {
return ctxerr.Wrap(ctx, err, "generating host batteries delete NOT IN statement")
}
if _, err := tx.ExecContext(ctx, delStmt, args...); err != nil {
return ctxerr.Wrap(ctx, err, "delete host batteries")
}
} else if _, err := tx.ExecContext(ctx, deleteAllStmt, hid); err != nil {
return ctxerr.Wrap(ctx, err, "delete all host batteries")
}
return nil
})
}
func (ds *Datastore) updateOrInsert(ctx context.Context, updateQuery string, insertQuery string, args ...interface{}) error {
res, err := ds.writer.ExecContext(ctx, updateQuery, args...)
if err != nil {
@ -1820,3 +1886,23 @@ func (ds *Datastore) HostIDsByOSVersion(
return ids, nil
}
func (ds *Datastore) ListHostBatteries(ctx context.Context, hid uint) ([]*fleet.HostBattery, error) {
const stmt = `
SELECT
host_id,
serial_number,
cycle_count,
health
FROM
host_batteries
WHERE
host_id = ?
`
var batteries []*fleet.HostBattery
if err := sqlx.SelectContext(ctx, ds.reader, &batteries, stmt, hid); err != nil {
return nil, ctxerr.Wrap(ctx, err, "select host batteries")
}
return batteries, nil
}

View File

@ -118,6 +118,7 @@ func TestHosts(t *testing.T) {
{"DeleteHosts", testHostsDeleteHosts},
{"HostIDsByOSVersion", testHostIDsByOSVersion},
{"ShouldCleanTeamPolicies", testShouldCleanTeamPolicies},
{"ReplaceHostBatteries", testHostsReplaceHostBatteries},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
@ -4172,6 +4173,9 @@ func testHostsDeleteHosts(t *testing.T, ds *Datastore) {
// Update device_auth_token.
err = ds.SetOrUpdateDeviceAuthToken(context.Background(), host.ID, "foo")
require.NoError(t, err)
// Update host_batteries
err = ds.ReplaceHostBatteries(context.Background(), host.ID, []*fleet.HostBattery{{HostID: host.ID, SerialNumber: "a"}})
require.NoError(t, err)
// Check there's an entry for the host in all the associated tables.
for _, hostRef := range hostRefs {
@ -4260,3 +4264,93 @@ func testHostIDsByOSVersion(t *testing.T, ds *Datastore) {
}
})
}
func testHostsReplaceHostBatteries(t *testing.T, ds *Datastore) {
ctx := context.Background()
h1, err := ds.NewHost(ctx, &fleet.Host{
ID: 1,
OsqueryHostID: "1",
NodeKey: "1",
Platform: "linux",
Hostname: "host1",
DetailUpdatedAt: time.Now(),
LabelUpdatedAt: time.Now(),
PolicyUpdatedAt: time.Now(),
SeenTime: time.Now(),
})
require.NoError(t, err)
h2, err := ds.NewHost(ctx, &fleet.Host{
ID: 2,
OsqueryHostID: "2",
NodeKey: "2",
Platform: "linux",
Hostname: "host2",
DetailUpdatedAt: time.Now(),
LabelUpdatedAt: time.Now(),
PolicyUpdatedAt: time.Now(),
SeenTime: time.Now(),
})
require.NoError(t, err)
err = ds.ReplaceHostBatteries(ctx, h1.ID, nil)
require.NoError(t, err)
bat1, err := ds.ListHostBatteries(ctx, h1.ID)
require.NoError(t, err)
require.Len(t, bat1, 0)
h1Bat := []*fleet.HostBattery{
{HostID: h1.ID, SerialNumber: "a", CycleCount: 1, Health: "Good"},
{HostID: h1.ID, SerialNumber: "b", CycleCount: 2, Health: "Good"},
}
err = ds.ReplaceHostBatteries(ctx, h1.ID, h1Bat)
require.NoError(t, err)
bat1, err = ds.ListHostBatteries(ctx, h1.ID)
require.NoError(t, err)
require.ElementsMatch(t, h1Bat, bat1)
bat2, err := ds.ListHostBatteries(ctx, h2.ID)
require.NoError(t, err)
require.Len(t, bat2, 0)
// update "a", remove "b", add "c"
h1Bat = []*fleet.HostBattery{
{HostID: h1.ID, SerialNumber: "a", CycleCount: 2, Health: "Good"},
{HostID: h1.ID, SerialNumber: "c", CycleCount: 3, Health: "Bad"},
}
err = ds.ReplaceHostBatteries(ctx, h1.ID, h1Bat)
require.NoError(t, err)
bat1, err = ds.ListHostBatteries(ctx, h1.ID)
require.NoError(t, err)
require.ElementsMatch(t, h1Bat, bat1)
// add "d" to h2
h2Bat := []*fleet.HostBattery{
{HostID: h2.ID, SerialNumber: "d", CycleCount: 1, Health: "Good"},
}
err = ds.ReplaceHostBatteries(ctx, h2.ID, h2Bat)
require.NoError(t, err)
bat2, err = ds.ListHostBatteries(ctx, h2.ID)
require.NoError(t, err)
require.ElementsMatch(t, h2Bat, bat2)
// remove all from h1
h1Bat = []*fleet.HostBattery{}
err = ds.ReplaceHostBatteries(ctx, h1.ID, h1Bat)
require.NoError(t, err)
bat1, err = ds.ListHostBatteries(ctx, h1.ID)
require.NoError(t, err)
require.Len(t, bat1, 0)
// h2 unchanged
bat2, err = ds.ListHostBatteries(ctx, h2.ID)
require.NoError(t, err)
require.ElementsMatch(t, h2Bat, bat2)
}

View File

@ -0,0 +1,37 @@
package tables
import (
"database/sql"
"github.com/pkg/errors"
)
func init() {
MigrationClient.AddMigration(Up_20220627104817, Down_20220627104817)
}
func Up_20220627104817(tx *sql.Tx) error {
// there may be many batteries per host, so the primary key is an
// auto-increment, not the host_id.
_, err := tx.Exec(`
CREATE TABLE host_batteries (
id INT(10) UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
host_id INT(10) UNSIGNED NOT NULL,
serial_number VARCHAR(255) NOT NULL,
cycle_count INT(10) NOT NULL,
health VARCHAR(10) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY idx_host_batteries_host_id_serial_number (host_id, serial_number)
)`)
if err != nil {
return errors.Wrapf(err, "create table")
}
return nil
}
func Down_20220627104817(tx *sql.Tx) error {
return nil
}

View File

@ -0,0 +1,39 @@
package tables
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestUp_20220627104817(t *testing.T) {
db := applyUpToPrev(t)
applyNext(t, db)
query := `
INSERT INTO host_batteries (
host_id,
serial_number,
cycle_count,
health
)
VALUES (?, ?, ?, ?)
`
_, err := db.Exec(query, 1, "abc", 2, "Good")
require.NoError(t, err)
var (
hostID uint
serialNumber string
cycleCount int
health string
)
err = db.QueryRow(`SELECT host_id, serial_number, cycle_count, health FROM host_batteries WHERE host_id = ?`, 1).
Scan(&hostID, &serialNumber, &cycleCount, &health)
require.NoError(t, err)
require.Equal(t, uint(1), hostID)
require.Equal(t, "abc", serialNumber)
require.Equal(t, 2, cycleCount)
require.Equal(t, "Good", health)
}

File diff suppressed because one or more lines are too long

View File

@ -214,6 +214,8 @@ type Datastore interface {
CountHosts(ctx context.Context, filter TeamFilter, opt HostListOptions) (int, error)
CountHostsInLabel(ctx context.Context, filter TeamFilter, lid uint, opt HostListOptions) (int, error)
ListHostDeviceMapping(ctx context.Context, id uint) ([]*HostDeviceMapping, error)
// ListHostBatteries returns the list of batteries for the given host ID.
ListHostBatteries(ctx context.Context, id uint) ([]*HostBattery, error)
// LoadHostByDeviceAuthToken loads the host identified by the device auth token.
// If the token is invalid it returns a NotFoundError.
@ -529,6 +531,9 @@ type Datastore interface {
ReplaceHostDeviceMapping(ctx context.Context, id uint, mappings []*HostDeviceMapping) error
// ReplaceHostBatteries creates or updates the battery mappings of a host.
ReplaceHostBatteries(ctx context.Context, id uint, mappings []*HostBattery) error
// VerifyEnrollSecret checks that the provided secret matches an active enroll secret. If it is successfully
// matched, that secret is returned. Otherwise, an error is returned.
VerifyEnrollSecret(ctx context.Context, secret string) (*EnrollSecret, error)

View File

@ -157,6 +157,11 @@ type HostDetail struct {
Packs []*Pack `json:"packs"`
// Policies is the list of policies and whether it passes for the host
Policies *[]*HostPolicy `json:"policies,omitempty"`
// Batteries is the list of batteries for the host. It is a pointer to a
// slice so that when set, it gets marhsaled even if the slice is empty,
// but when unset, it doesn't get marshaled (e.g. we don't return that
// information for the List Hosts endpoint).
Batteries *[]*HostBattery `json:"batteries,omitempty"`
}
const (
@ -286,6 +291,15 @@ type HostMDM struct {
ServerURL string `json:"server_url"`
}
// HostBattery represents a host's battery, as reported by the osquery battery
// table.
type HostBattery struct {
HostID uint `json:"-" db:"host_id"`
SerialNumber string `json:"-" db:"serial_number"`
CycleCount int `json:"cycle_count" db:"cycle_count"`
Health string `json:"health" db:"health"`
}
type MacadminsData struct {
Munki *HostMunkiInfo `json:"munki"`
MDM *HostMDM `json:"mobile_device_management"`

View File

@ -178,6 +178,8 @@ type CountHostsInLabelFunc func(ctx context.Context, filter fleet.TeamFilter, li
type ListHostDeviceMappingFunc func(ctx context.Context, id uint) ([]*fleet.HostDeviceMapping, error)
type ListHostBatteriesFunc func(ctx context.Context, id uint) ([]*fleet.HostBattery, error)
type LoadHostByDeviceAuthTokenFunc func(ctx context.Context, authToken string) (*fleet.Host, error)
type SetOrUpdateDeviceAuthTokenFunc func(ctx context.Context, hostID uint, authToken string) error
@ -400,6 +402,8 @@ type SetOrUpdateMDMDataFunc func(ctx context.Context, hostID uint, enrolled bool
type ReplaceHostDeviceMappingFunc func(ctx context.Context, id uint, mappings []*fleet.HostDeviceMapping) error
type ReplaceHostBatteriesFunc func(ctx context.Context, id uint, mappings []*fleet.HostBattery) error
type VerifyEnrollSecretFunc func(ctx context.Context, secret string) (*fleet.EnrollSecret, error)
type EnrollHostFunc func(ctx context.Context, osqueryHostId string, nodeKey string, teamID *uint, cooldown time.Duration) (*fleet.Host, error)
@ -666,6 +670,9 @@ type DataStore struct {
ListHostDeviceMappingFunc ListHostDeviceMappingFunc
ListHostDeviceMappingFuncInvoked bool
ListHostBatteriesFunc ListHostBatteriesFunc
ListHostBatteriesFuncInvoked bool
LoadHostByDeviceAuthTokenFunc LoadHostByDeviceAuthTokenFunc
LoadHostByDeviceAuthTokenFuncInvoked bool
@ -999,6 +1006,9 @@ type DataStore struct {
ReplaceHostDeviceMappingFunc ReplaceHostDeviceMappingFunc
ReplaceHostDeviceMappingFuncInvoked bool
ReplaceHostBatteriesFunc ReplaceHostBatteriesFunc
ReplaceHostBatteriesFuncInvoked bool
VerifyEnrollSecretFunc VerifyEnrollSecretFunc
VerifyEnrollSecretFuncInvoked bool
@ -1439,6 +1449,11 @@ func (s *DataStore) ListHostDeviceMapping(ctx context.Context, id uint) ([]*flee
return s.ListHostDeviceMappingFunc(ctx, id)
}
func (s *DataStore) ListHostBatteries(ctx context.Context, id uint) ([]*fleet.HostBattery, error) {
s.ListHostBatteriesFuncInvoked = true
return s.ListHostBatteriesFunc(ctx, id)
}
func (s *DataStore) LoadHostByDeviceAuthToken(ctx context.Context, authToken string) (*fleet.Host, error) {
s.LoadHostByDeviceAuthTokenFuncInvoked = true
return s.LoadHostByDeviceAuthTokenFunc(ctx, authToken)
@ -1994,6 +2009,11 @@ func (s *DataStore) ReplaceHostDeviceMapping(ctx context.Context, id uint, mappi
return s.ReplaceHostDeviceMappingFunc(ctx, id, mappings)
}
func (s *DataStore) ReplaceHostBatteries(ctx context.Context, id uint, mappings []*fleet.HostBattery) error {
s.ReplaceHostBatteriesFuncInvoked = true
return s.ReplaceHostBatteriesFunc(ctx, id, mappings)
}
func (s *DataStore) VerifyEnrollSecret(ctx context.Context, secret string) (*fleet.EnrollSecret, error) {
s.VerifyEnrollSecretFuncInvoked = true
return s.VerifyEnrollSecretFunc(ctx, secret)

View File

@ -717,6 +717,11 @@ func (svc *Service) getHostDetails(ctx context.Context, host *fleet.Host, opts f
return nil, ctxerr.Wrap(ctx, err, "get packs for host")
}
bats, err := svc.ds.ListHostBatteries(ctx, host.ID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "get batteries for host")
}
var policies *[]*fleet.HostPolicy
if opts.IncludePolicies {
hp, err := svc.ds.ListPoliciesForHost(ctx, host)
@ -731,7 +736,13 @@ func (svc *Service) getHostDetails(ctx context.Context, host *fleet.Host, opts f
policies = &hp
}
return &fleet.HostDetail{Host: *host, Labels: labels, Packs: packs, Policies: policies}, nil
return &fleet.HostDetail{
Host: *host,
Labels: labels,
Packs: packs,
Policies: policies,
Batteries: &bats,
}, nil
}
func (svc *Service) hostIDsFromFilters(ctx context.Context, opt fleet.HostListOptions, lid *uint) ([]uint, error) {

View File

@ -50,6 +50,10 @@ func TestHostDetails(t *testing.T) {
ds.ListPoliciesForHostFunc = func(ctx context.Context, host *fleet.Host) ([]*fleet.HostPolicy, error) {
return nil, nil
}
expectedBats := []*fleet.HostBattery{{HostID: host.ID, SerialNumber: "a"}}
ds.ListHostBatteriesFunc = func(ctx context.Context, hostID uint) ([]*fleet.HostBattery, error) {
return expectedBats, nil
}
opts := fleet.HostDetailOptions{
IncludeCVEScores: false,
@ -59,6 +63,8 @@ func TestHostDetails(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, expectedLabels, hostDetail.Labels)
assert.Equal(t, expectedPacks, hostDetail.Packs)
require.NotNil(t, hostDetail.Batteries)
assert.Equal(t, expectedBats, *hostDetail.Batteries)
}
func TestHostAuth(t *testing.T) {
@ -107,6 +113,9 @@ func TestHostAuth(t *testing.T) {
ds.ListPoliciesForHostFunc = func(ctx context.Context, host *fleet.Host) ([]*fleet.HostPolicy, error) {
return nil, nil
}
ds.ListHostBatteriesFunc = func(ctx context.Context, hostID uint) ([]*fleet.HostBattery, error) {
return nil, nil
}
ds.DeleteHostsFunc = func(ctx context.Context, ids []uint) error {
return nil
}

View File

@ -4627,6 +4627,10 @@ func (s *integrationTestSuite) TestDeviceAuthenticatedEndpoints() {
})
require.NoError(t, s.ds.SetOrUpdateMDMData(context.Background(), hosts[0].ID, true, "url", false))
require.NoError(t, s.ds.SetOrUpdateMunkiVersion(context.Background(), hosts[0].ID, "1.3.0"))
// create a battery for hosts[0]
require.NoError(t, s.ds.ReplaceHostBatteries(context.Background(), hosts[0].ID, []*fleet.HostBattery{
{HostID: hosts[0].ID, SerialNumber: "a", CycleCount: 1, Health: "Good"},
}))
// create an auth token for hosts[0]
token := "much_valid"
@ -4652,6 +4656,8 @@ func (s *integrationTestSuite) TestDeviceAuthenticatedEndpoints() {
require.False(t, getHostResp.Host.RefetchRequested)
require.Equal(t, "http://example.com/logo", getHostResp.OrgLogoURL)
require.Nil(t, getHostResp.Host.Policies)
require.NotNil(t, getHostResp.Host.Batteries)
require.Equal(t, &fleet.HostBattery{CycleCount: 1, Health: "Good"}, (*getHostResp.Host.Batteries)[0])
hostDevResp := getHostResp.Host
// make request for same host on the host details API endpoint, responses should match, except for policies
@ -5124,6 +5130,47 @@ func (s *integrationTestSuite) TestSSODisabled() {
require.Contains(t, string(body), "/login?status=org_disabled") // html contains a script that redirects to this path
}
func (s *integrationTestSuite) TestGetHostBatteries() {
t := s.T()
host, err := s.ds.NewHost(context.Background(), &fleet.Host{
DetailUpdatedAt: time.Now(),
LabelUpdatedAt: time.Now(),
PolicyUpdatedAt: time.Now(),
SeenTime: time.Now(),
NodeKey: strings.ReplaceAll(t.Name(), "/", "_") + "1",
UUID: t.Name() + "1",
Hostname: t.Name() + "foo.local",
PrimaryIP: "192.168.1.1",
PrimaryMac: "30-65-EC-6F-C4-58",
})
require.NoError(t, err)
bats := []*fleet.HostBattery{
{HostID: host.ID, SerialNumber: "a", CycleCount: 1, Health: "Good"},
{HostID: host.ID, SerialNumber: "b", CycleCount: 2, Health: "Poor"},
}
require.NoError(t, s.ds.ReplaceHostBatteries(context.Background(), host.ID, bats))
var getHostResp getHostResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", host.ID), nil, http.StatusOK, &getHostResp)
require.Equal(t, host.ID, getHostResp.Host.ID)
// only cycle count and health are returned
require.ElementsMatch(t, []*fleet.HostBattery{
{CycleCount: 1, Health: "Good"},
{CycleCount: 2, Health: "Poor"},
}, *getHostResp.Host.Batteries)
// same for get host by identifier
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/identifier/%s", host.NodeKey), nil, http.StatusOK, &getHostResp)
require.Equal(t, host.ID, getHostResp.Host.ID)
// only cycle count and health are returned
require.ElementsMatch(t, []*fleet.HostBattery{
{CycleCount: 1, Health: "Good"},
{CycleCount: 2, Health: "Poor"},
}, *getHostResp.Host.Batteries)
}
// this test can be deleted once the "v1" version is removed.
func (s *integrationTestSuite) TestAPIVersion_v1_2022_04() {
t := s.T()

View File

@ -187,7 +187,7 @@ func TestAgentOptionsForHost(t *testing.T) {
}
// One of these queries is the disk space, only one of the two works in a platform
var expectedDetailQueries = len(osquery_utils.GetDetailQueries(&fleet.AppConfig{HostSettings: fleet.HostSettings{EnableHostUsers: true}}, config.FleetConfig{})) - 1
var expectedDetailQueries = osquery_utils.GetDetailQueries(&fleet.AppConfig{HostSettings: fleet.HostSettings{EnableHostUsers: true}}, config.FleetConfig{})
func TestEnrollAgent(t *testing.T) {
ds := new(mock.Store)
@ -556,7 +556,8 @@ func TestHostDetailQueries(t *testing.T) {
queries, discovery, err = svc.detailQueriesForHost(context.Background(), &host)
require.NoError(t, err)
require.Len(t, queries, expectedDetailQueries+2)
// +1 because 2 additional queries, but -1 due to removed disk space query (only 1 of 2 active for a given platform)
require.Equal(t, len(expectedDetailQueries)+1, len(queries), distQueriesMapKeys(queries))
verifyDiscovery(t, queries, discovery)
for name := range queries {
assert.True(t,
@ -610,7 +611,8 @@ func TestLabelQueries(t *testing.T) {
// should be turned on so that we can quickly fill labels)
queries, discovery, acc, err := svc.GetDistributedQueries(ctx)
require.NoError(t, err)
require.Len(t, queries, expectedDetailQueries)
// -1 due to removed disk space query (only 1 of 2 active for a given platform)
require.Equal(t, len(expectedDetailQueries)-1, len(queries), distQueriesMapKeys(queries))
verifyDiscovery(t, queries, discovery)
assert.NotZero(t, acc)
@ -700,7 +702,8 @@ func TestLabelQueries(t *testing.T) {
ctx = hostctx.NewContext(ctx, host)
queries, discovery, acc, err = svc.GetDistributedQueries(ctx)
require.NoError(t, err)
require.Len(t, queries, expectedDetailQueries+3)
// +3 for label queries, -1 due to removed disk space query (only 1 of 2 active for a given platform)
require.Equal(t, len(expectedDetailQueries)+2, len(queries), distQueriesMapKeys(queries))
verifyDiscovery(t, queries, discovery)
assert.Zero(t, acc)
@ -768,7 +771,13 @@ func TestDetailQueriesWithEmptyStrings(t *testing.T) {
// queries)
queries, discovery, acc, err := svc.GetDistributedQueries(ctx)
require.NoError(t, err)
require.Len(t, queries, expectedDetailQueries-2)
// -4 due to windows not having battery, mdm, munki_info and removed disk space query (only 1 of 2 active for a given platform)
if !assert.Equal(t, len(expectedDetailQueries)-4, len(queries)) {
// this is just to print the diff between the expected and actual query
// keys when the count assertion fails, to help debugging - they are not
// expected to match.
require.ElementsMatch(t, osqueryMapKeys(expectedDetailQueries), distQueriesMapKeys(queries))
}
verifyDiscovery(t, queries, discovery)
assert.NotZero(t, acc)
@ -920,7 +929,10 @@ func TestDetailQueriesWithEmptyStrings(t *testing.T) {
queries, discovery, acc, err = svc.GetDistributedQueries(ctx)
require.NoError(t, err)
require.Len(t, queries, expectedDetailQueries)
// somehow confusingly, the query response above changed the host's platform
// from windows to darwin, so now it has all expected queries except the
// extra disk space one.
require.Equal(t, len(expectedDetailQueries)-1, len(queries), distQueriesMapKeys(queries))
verifyDiscovery(t, queries, discovery)
assert.Zero(t, acc)
}
@ -974,7 +986,14 @@ func TestDetailQueries(t *testing.T) {
// queries)
queries, discovery, acc, err := svc.GetDistributedQueries(ctx)
require.NoError(t, err)
require.Len(t, queries, expectedDetailQueries-1)
// -4 due to linux platform, so battery, mdm and munki are missing, and the extra disk space query,
// then +1 due to software inventory being enabled.
if !assert.Equal(t, len(expectedDetailQueries)-3, len(queries)) {
// this is just to print the diff between the expected and actual query
// keys when the count assertion fails, to help debugging - they are not
// expected to match.
require.ElementsMatch(t, osqueryMapKeys(expectedDetailQueries), distQueriesMapKeys(queries))
}
verifyDiscovery(t, queries, discovery)
assert.NotZero(t, acc)
@ -1230,7 +1249,9 @@ func TestDetailQueries(t *testing.T) {
queries, discovery, acc, err = svc.GetDistributedQueries(ctx)
require.NoError(t, err)
require.Len(t, queries, expectedDetailQueries+1)
// host platform changed to darwin, so all queries are present - that is, -1 for the
// extra disk space query, +1 for the software inventory enabled.
require.Equal(t, len(expectedDetailQueries), len(queries), distQueriesMapKeys(queries))
verifyDiscovery(t, queries, discovery)
assert.Zero(t, acc)
}
@ -1354,7 +1375,13 @@ func TestDistributedQueryResults(t *testing.T) {
// Now we should get the active distributed query
queries, discovery, acc, err := svc.GetDistributedQueries(hostCtx)
require.NoError(t, err)
require.Len(t, queries, expectedDetailQueries-1)
// -4 for the non-windows queries, +1 for the distributed query for campaign ID 42
if !assert.Equal(t, len(expectedDetailQueries)-3, len(queries)) {
// this is just to print the diff between the expected and actual query
// keys when the count assertion fails, to help debugging - they are not
// expected to match.
require.ElementsMatch(t, osqueryMapKeys(expectedDetailQueries), distQueriesMapKeys(queries))
}
verifyDiscovery(t, queries, discovery)
queryKey := fmt.Sprintf("%s%d", hostDistributedQueryPrefix, campaign.ID)
assert.Equal(t, "select * from time", queries[queryKey])
@ -2198,7 +2225,8 @@ func TestPolicyQueries(t *testing.T) {
queries, discovery, _, err := svc.GetDistributedQueries(ctx)
require.NoError(t, err)
require.Len(t, queries, expectedDetailQueries+2)
// all queries -1 for the extra disk space one, and +2 for the policy queries
require.Equal(t, len(expectedDetailQueries)+1, len(queries), distQueriesMapKeys(queries))
verifyDiscovery(t, queries, discovery)
checkPolicyResults := func(queries map[string]string) {
@ -2254,7 +2282,8 @@ func TestPolicyQueries(t *testing.T) {
ctx = hostctx.NewContext(context.Background(), host)
queries, discovery, _, err = svc.GetDistributedQueries(ctx)
require.NoError(t, err)
require.Len(t, queries, expectedDetailQueries)
// all standard queries minus the extra disk space
require.Equal(t, len(expectedDetailQueries)-1, len(queries), distQueriesMapKeys(queries))
verifyDiscovery(t, queries, discovery)
noPolicyResults(queries)
@ -2263,7 +2292,8 @@ func TestPolicyQueries(t *testing.T) {
queries, discovery, _, err = svc.GetDistributedQueries(ctx)
require.NoError(t, err)
require.Len(t, queries, expectedDetailQueries+2)
// all standard queries minus the extra disk space, +2 policy queries
require.Equal(t, len(expectedDetailQueries)+1, len(queries), distQueriesMapKeys(queries))
verifyDiscovery(t, queries, discovery)
checkPolicyResults(queries)
@ -2291,7 +2321,8 @@ func TestPolicyQueries(t *testing.T) {
ctx = hostctx.NewContext(context.Background(), host)
queries, discovery, _, err = svc.GetDistributedQueries(ctx)
require.NoError(t, err)
require.Len(t, queries, expectedDetailQueries)
// all standard queries minus the extra disk space
require.Equal(t, len(expectedDetailQueries)-1, len(queries), distQueriesMapKeys(queries))
verifyDiscovery(t, queries, discovery)
noPolicyResults(queries)
@ -2300,7 +2331,8 @@ func TestPolicyQueries(t *testing.T) {
ctx = hostctx.NewContext(context.Background(), host)
queries, discovery, _, err = svc.GetDistributedQueries(ctx)
require.NoError(t, err)
require.Len(t, queries, expectedDetailQueries+2)
// all standard queries minus the extra disk space, +2 policy queries
require.Equal(t, len(expectedDetailQueries)+1, len(queries), distQueriesMapKeys(queries))
verifyDiscovery(t, queries, discovery)
checkPolicyResults(queries)
@ -2330,7 +2362,8 @@ func TestPolicyQueries(t *testing.T) {
ctx = hostctx.NewContext(context.Background(), host)
queries, discovery, _, err = svc.GetDistributedQueries(ctx)
require.NoError(t, err)
require.Len(t, queries, expectedDetailQueries)
// all standard queries minus the extra disk space
require.Equal(t, len(expectedDetailQueries)-1, len(queries), distQueriesMapKeys(queries))
verifyDiscovery(t, queries, discovery)
noPolicyResults(queries)
}
@ -2395,7 +2428,8 @@ func TestPolicyWebhooks(t *testing.T) {
queries, discovery, _, err := svc.GetDistributedQueries(ctx)
require.NoError(t, err)
require.Len(t, queries, expectedDetailQueries+3)
// all queries -1 for extra disk space, +3 for policies
require.Equal(t, len(expectedDetailQueries)+2, len(queries), distQueriesMapKeys(queries))
verifyDiscovery(t, queries, discovery)
checkPolicyResults := func(queries map[string]string) {
@ -2508,7 +2542,8 @@ func TestPolicyWebhooks(t *testing.T) {
ctx = hostctx.NewContext(context.Background(), host)
queries, discovery, _, err = svc.GetDistributedQueries(ctx)
require.NoError(t, err)
require.Len(t, queries, expectedDetailQueries)
// all standard queries minus the extra disk space
require.Equal(t, len(expectedDetailQueries)-1, len(queries), distQueriesMapKeys(queries))
verifyDiscovery(t, queries, discovery)
noPolicyResults(queries)
@ -2517,7 +2552,8 @@ func TestPolicyWebhooks(t *testing.T) {
queries, discovery, _, err = svc.GetDistributedQueries(ctx)
require.NoError(t, err)
require.Len(t, queries, expectedDetailQueries+3)
// all queries -1 for extra disk space, +3 for policies
require.Equal(t, len(expectedDetailQueries)+2, len(queries), distQueriesMapKeys(queries))
verifyDiscovery(t, queries, discovery)
checkPolicyResults(queries)
@ -2640,7 +2676,8 @@ func TestLiveQueriesFailing(t *testing.T) {
queries, discovery, _, err := svc.GetDistributedQueries(ctx)
require.NoError(t, err)
require.Len(t, queries, expectedDetailQueries)
// all queries minus the extra disk space
require.Equal(t, len(expectedDetailQueries)-1, len(queries), distQueriesMapKeys(queries))
verifyDiscovery(t, queries, discovery)
logs, err := ioutil.ReadAll(buf)
@ -2694,3 +2731,21 @@ func TestFleetDesktopOrbitInfo(t *testing.T) {
require.Len(t, queries, 0)
require.Len(t, discovery, 0)
}
func distQueriesMapKeys(m map[string]string) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, strings.TrimPrefix(k, "fleet_detail_query_"))
}
sort.Strings(keys)
return keys
}
func osqueryMapKeys(m map[string]osquery_utils.DetailQuery) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}

View File

@ -344,6 +344,14 @@ var extraDetailQueries = map[string]DetailQuery{
DirectIngestFunc: directIngestChromeProfiles,
Discovery: discoveryTable("google_chrome_profiles"),
},
"battery": {
Query: `SELECT serial_number, cycle_count, health FROM battery;`,
Platforms: []string{"darwin"},
DirectIngestFunc: directIngestBattery,
// the "battery" table doesn't need a Discovery query as it is an official
// osquery table on darwin (https://osquery.io/schema/5.3.0#battery), it is
// always present.
},
OrbitInfoQueryName: OrbitInfoDetailQuery,
}
@ -362,9 +370,9 @@ func discoveryTable(tableName string) string {
return fmt.Sprintf("SELECT 1 FROM osquery_registry WHERE active = true AND registry = 'table' AND name = '%s';", tableName)
}
const usersQueryStr = `WITH cached_groups AS (select * from groups)
SELECT uid, username, type, groupname, shell
FROM users LEFT JOIN cached_groups USING (gid)
const usersQueryStr = `WITH cached_groups AS (select * from groups)
SELECT uid, username, type, groupname, shell
FROM users LEFT JOIN cached_groups USING (gid)
WHERE type <> 'special' AND shell NOT LIKE '%/false' AND shell NOT LIKE '%/nologin' AND shell NOT LIKE '%/shutdown' AND shell NOT LIKE '%/halt' AND username NOT LIKE '%$' AND username NOT LIKE '\_%' ESCAPE '\' AND NOT (username = 'sync' AND shell ='/bin/sync' AND directory <> '')`
func withCachedUsers(query string) string {
@ -627,6 +635,28 @@ func directIngestChromeProfiles(ctx context.Context, logger log.Logger, host *fl
return ds.ReplaceHostDeviceMapping(ctx, host.ID, mapping)
}
func directIngestBattery(ctx context.Context, logger log.Logger, host *fleet.Host, ds fleet.Datastore, rows []map[string]string, failed bool) error {
if failed {
level.Error(logger).Log("op", "directIngestBattery", "err", "failed")
return nil
}
mapping := make([]*fleet.HostBattery, 0, len(rows))
for _, row := range rows {
cycleCount, err := strconv.ParseInt(EmptyToZero(row["cycle_count"]), 10, 64)
if err != nil {
return err
}
mapping = append(mapping, &fleet.HostBattery{
HostID: host.ID,
SerialNumber: row["serial_number"],
CycleCount: int(cycleCount),
Health: row["health"],
})
}
return ds.ReplaceHostBatteries(ctx, host.ID, mapping)
}
func directIngestOrbitInfo(ctx context.Context, logger log.Logger, host *fleet.Host, ds fleet.Datastore, rows []map[string]string, failed bool) error {
if len(rows) != 1 {
return ctxerr.Errorf(ctx, "invalid number of orbit_info rows: %d", len(rows))

View File

@ -293,7 +293,7 @@ func sortedKeysCompare(t *testing.T, m map[string]DetailQuery, expectedKeys []st
func TestGetDetailQueries(t *testing.T) {
queriesNoConfig := GetDetailQueries(nil, config.FleetConfig{})
require.Len(t, queriesNoConfig, 12)
require.Len(t, queriesNoConfig, 13)
baseQueries := []string{
"network_interface",
"os_version",
@ -307,15 +307,16 @@ func TestGetDetailQueries(t *testing.T) {
"munki_info",
"google_chrome_profiles",
"orbit_info",
"battery",
}
sortedKeysCompare(t, queriesNoConfig, baseQueries)
queriesWithUsers := GetDetailQueries(&fleet.AppConfig{HostSettings: fleet.HostSettings{EnableHostUsers: true}}, config.FleetConfig{App: config.AppConfig{EnableScheduledQueryStats: true}})
require.Len(t, queriesWithUsers, 14)
require.Len(t, queriesWithUsers, 15)
sortedKeysCompare(t, queriesWithUsers, append(baseQueries, "users", "scheduled_query_stats"))
queriesWithUsersAndSoftware := GetDetailQueries(&fleet.AppConfig{HostSettings: fleet.HostSettings{EnableHostUsers: true, EnableSoftwareInventory: true}}, config.FleetConfig{App: config.AppConfig{EnableScheduledQueryStats: true}})
require.Len(t, queriesWithUsersAndSoftware, 17)
require.Len(t, queriesWithUsersAndSoftware, 18)
sortedKeysCompare(t, queriesWithUsersAndSoftware,
append(baseQueries, "users", "software_macos", "software_linux", "software_windows", "scheduled_query_stats"))
}