From 424ffef18547882850aab2ac6f2ed67b64fe2401 Mon Sep 17 00:00:00 2001 From: Sarah Gillespie <73313222+gillespi314@users.noreply.github.com> Date: Tue, 6 Feb 2024 10:02:38 -0600 Subject: [PATCH] Add count to host upcoming activities API response (#16511) --- changes/16426-add-upcoming-activity-count | 2 ++ server/datastore/mysql/activities.go | 20 ++++++++++++++------ server/datastore/mysql/activities_test.go | 16 ++++++++-------- server/fleet/meta.go | 4 ++++ server/fleet/service.go | 3 ++- server/service/activities.go | 13 +++++++++++-- server/service/integration_core_test.go | 23 ++++++++++++++++++++++- 7 files changed, 63 insertions(+), 18 deletions(-) create mode 100644 changes/16426-add-upcoming-activity-count diff --git a/changes/16426-add-upcoming-activity-count b/changes/16426-add-upcoming-activity-count new file mode 100644 index 0000000000..782518c462 --- /dev/null +++ b/changes/16426-add-upcoming-activity-count @@ -0,0 +1,2 @@ +- Updated `GET /api/v1/fleet/hosts/:id/activities/upcoming` response to include the count of all + upcoming activities for the host. diff --git a/server/datastore/mysql/activities.go b/server/datastore/mysql/activities.go index ec349f0343..b10cb86c09 100644 --- a/server/datastore/mysql/activities.go +++ b/server/datastore/mysql/activities.go @@ -186,6 +186,16 @@ func (ds *Datastore) MarkActivitiesAsStreamed(ctx context.Context, activityIDs [ } func (ds *Datastore) ListHostUpcomingActivities(ctx context.Context, hostID uint, opt fleet.ListOptions) ([]*fleet.Activity, *fleet.PaginationMetadata, error) { + const countStmt = `SELECT COUNT(*) FROM host_script_results WHERE host_id = ? AND exit_code IS NULL` + var count uint + if err := sqlx.GetContext(ctx, ds.reader(ctx), &count, countStmt, hostID); err != nil { + return nil, nil, ctxerr.Wrap(ctx, err, "count upcoming activities") + } + if count == 0 { + return []*fleet.Activity{}, &fleet.PaginationMetadata{}, nil + } + + // NOTE: Be sure to update both the count and list statements if the list query is modified const listStmt = ` SELECT hsr.execution_id as uuid, @@ -229,12 +239,10 @@ func (ds *Datastore) ListHostUpcomingActivities(ctx context.Context, hostID uint } var metaData *fleet.PaginationMetadata - if opt.IncludeMetadata { - metaData = &fleet.PaginationMetadata{HasPreviousResults: opt.Page > 0} - if len(activities) > int(opt.PerPage) { - metaData.HasNextResults = true - activities = activities[:len(activities)-1] - } + metaData = &fleet.PaginationMetadata{HasPreviousResults: opt.Page > 0, TotalResults: count} + if len(activities) > int(opt.PerPage) { + metaData.HasNextResults = true + activities = activities[:len(activities)-1] } return activities, metaData, nil diff --git a/server/datastore/mysql/activities_test.go b/server/datastore/mysql/activities_test.go index a7c12715af..8ffccc5b6e 100644 --- a/server/datastore/mysql/activities_test.go +++ b/server/datastore/mysql/activities_test.go @@ -375,49 +375,49 @@ func testListHostUpcomingActivities(t *testing.T, ds *Datastore) { opts: fleet.ListOptions{PerPage: 2}, hostID: h1.ID, wantExecs: []string{h1A, h1B}, - wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: false}, + wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: false, TotalResults: 5}, }, { opts: fleet.ListOptions{Page: 1, PerPage: 2}, hostID: h1.ID, wantExecs: []string{h1C, h1D}, - wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: true}, + wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: true, TotalResults: 5}, }, { opts: fleet.ListOptions{Page: 2, PerPage: 2}, hostID: h1.ID, wantExecs: []string{h1E}, - wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true}, + wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true, TotalResults: 5}, }, { opts: fleet.ListOptions{PerPage: 3}, hostID: h1.ID, wantExecs: []string{h1A, h1B, h1C}, - wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: false}, + wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: false, TotalResults: 5}, }, { opts: fleet.ListOptions{Page: 1, PerPage: 3}, hostID: h1.ID, wantExecs: []string{h1D, h1E}, - wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true}, + wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true, TotalResults: 5}, }, { opts: fleet.ListOptions{Page: 2, PerPage: 3}, hostID: h1.ID, wantExecs: []string{}, - wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true}, + wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true, TotalResults: 5}, }, { opts: fleet.ListOptions{PerPage: 3}, hostID: h2.ID, wantExecs: []string{h2A}, - wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: false}, + wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: false, TotalResults: 1}, }, { opts: fleet.ListOptions{}, hostID: h3.ID, wantExecs: []string{}, - wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: false}, + wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: false, TotalResults: 0}, }, } for _, c := range cases { diff --git a/server/fleet/meta.go b/server/fleet/meta.go index b8a1678789..7ed8d59353 100644 --- a/server/fleet/meta.go +++ b/server/fleet/meta.go @@ -8,4 +8,8 @@ type ObjectMetadata struct { type PaginationMetadata struct { HasNextResults bool `json:"has_next_results"` HasPreviousResults bool `json:"has_previous_results"` + // TotalResults is the total number of results found for the query (as opposed to the number + // of results returned in the current paginated response). This field is not always set so callers + // must take care to confirm whether a non-zero value should be expected in their specific use cases. + TotalResults uint `json:"-"` } diff --git a/server/fleet/service.go b/server/fleet/service.go index 8e50350af9..c95be50f7d 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -538,7 +538,8 @@ type Service interface { // ListHostUpcomingActivities lists the upcoming activities for the specified // host. Those are activities that are queued or scheduled to run on the host - // but haven't run yet. + // but haven't run yet. It also returns the total (unpaginated) count of upcoming + // activities. ListHostUpcomingActivities(ctx context.Context, hostID uint, opt ListOptions) ([]*Activity, *PaginationMetadata, error) // ListHostPastActivities lists the activities that have already happened for the specified host. diff --git a/server/service/activities.go b/server/service/activities.go index c6da3774d8..a2976b3e14 100644 --- a/server/service/activities.go +++ b/server/service/activities.go @@ -56,14 +56,23 @@ type listHostUpcomingActivitiesRequest struct { ListOptions fleet.ListOptions `url:"list_options"` } +type listHostUpcomingActivitiesResponse struct { + Meta *fleet.PaginationMetadata `json:"meta"` + Activities []*fleet.Activity `json:"activities"` + Count uint `json:"count"` + Err error `json:"error,omitempty"` +} + +func (r listHostUpcomingActivitiesResponse) error() error { return r.Err } + func listHostUpcomingActivitiesEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { req := request.(*listHostUpcomingActivitiesRequest) acts, meta, err := svc.ListHostUpcomingActivities(ctx, req.HostID, req.ListOptions) if err != nil { - return listActivitiesResponse{Err: err}, nil + return listHostUpcomingActivitiesResponse{Err: err}, nil } - return listActivitiesResponse{Meta: meta, Activities: acts}, nil + return listHostUpcomingActivitiesResponse{Meta: meta, Activities: acts, Count: meta.TotalResults}, nil } // ListHostUpcomingActivities returns a slice of upcoming activities for the diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go index 92dc483135..32e3e13134 100644 --- a/server/service/integration_core_test.go +++ b/server/service/integration_core_test.go @@ -9849,10 +9849,11 @@ func (s *integrationTestSuite) TestListHostUpcomingActivities() { } for _, c := range cases { t.Run(fmt.Sprintf("%#v", c.queries), func(t *testing.T) { - var listResp listActivitiesResponse + var listResp listHostUpcomingActivitiesResponse queryArgs := c.queries s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/activities/upcoming", host1.ID), nil, http.StatusOK, &listResp, queryArgs...) + require.Equal(t, uint(5), listResp.Count) require.Equal(t, len(c.wantExecs), len(listResp.Activities)) require.Equal(t, c.wantMeta, listResp.Meta) @@ -9873,4 +9874,24 @@ func (s *integrationTestSuite) TestListHostUpcomingActivities() { require.Equal(t, c.wantExecs, gotExecs) }) } + + // Test with a host that has no upcoming activities + host2, err := s.ds.NewHost(ctx, &fleet.Host{ + DetailUpdatedAt: time.Now(), + LabelUpdatedAt: time.Now(), + PolicyUpdatedAt: time.Now(), + SeenTime: time.Now().Add(-1 * time.Minute), + OsqueryHostID: ptr.String(t.Name() + "2"), + NodeKey: ptr.String(t.Name() + "2"), + UUID: uuid.New().String(), + Hostname: fmt.Sprintf("%sfoo2.local", t.Name()), + Platform: "darwin", + }) + require.NoError(t, err) + + var listResp listHostUpcomingActivitiesResponse + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/activities/upcoming", host2.ID), nil, http.StatusOK, &listResp) + require.Equal(t, uint(0), listResp.Count) + require.Empty(t, listResp.Activities) + require.Equal(t, &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: false}, listResp.Meta) }