Add "batch script host results" API (#32174)

for #31536

# Details

This PR adds a new API as specced in [the API
PR](https://github.com/fleetdm/fleet/blob/9bf150580ba75fdfb6f5273e59cc14de45c7813d/docs/REST%20API/rest-api.md#list-hosts-targeted-in-batch-script)
for scheduled scripts.

# Checklist for submitter

If some of the following don't apply, delete the relevant line.

- [X] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files)
for more information.
- [X] Input data is properly validated, `SELECT *` is avoided, SQL
injection is prevented (using placeholders for values in statements)

## Testing

- [X] Added/updated automated tests
- [X] Where appropriate, [automated tests simulate multiple hosts and
test for host
isolation](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/reference/patterns-backend.md#unit-testing)
(updates to one hosts's records do not affect another)

- [X] QA'd all new/changed functionality manually
ran a batch script on 100 hosts and ran the API in Postman for each
status, then canceled the batch and ran the API to check the canceled
status.

---------

Co-authored-by: Lucas Manuel Rodriguez <lucas@fleetdm.com>
This commit is contained in:
Scott Gress
2025-08-27 16:39:43 -05:00
committed by GitHub
co-authored by Lucas Manuel Rodriguez
parent 399355bfad
commit a87498421b
10 changed files with 314 additions and 34 deletions
@@ -0,0 +1 @@
- Added "list hosts targeted in batch script" API
+107 -23
View File
@@ -1071,6 +1071,82 @@ func (ds *Datastore) ListHosts(ctx context.Context, filter fleet.TeamFilter, opt
return hosts, nil
}
func (ds *Datastore) ListBatchScriptHosts(ctx context.Context, batchScriptExecutionID string, batchScriptExecutionStatus fleet.BatchScriptExecutionStatus, opt fleet.ListOptions) (hosts []fleet.BatchScriptHost, meta *fleet.PaginationMetadata, count uint, err error) {
countStmt := `
SELECT COUNT(*)
FROM
hosts h
%s
WHERE
%s
`
sqlStmt := `
SELECT
h.id,
h.hostname as display_name,
? as status,
-- pending hosts will have "updated_at" set in the db, but since
-- we're using it to mean "executed at" we'll return it as empty.
CASE
WHEN ? != 'pending' THEN hsr.updated_at
ELSE NULL
END as updated_at,
COALESCE(LEFT(hsr.output, 100), '') as output,
COALESCE(hsr.execution_id, '') as execution_id
FROM
hosts h
%s
WHERE
%s
`
// Validate the batch status.
if !batchScriptExecutionStatus.IsValid() {
return nil, nil, 0, errors.New("invalid batch execution status")
}
queryParams := []interface{}{}
batchScriptExecutionJoin, batchScriptExecutionFilter, queryParams := ds.getBatchExecutionFilters(queryParams, fleet.HostListOptions{
BatchScriptExecutionIDFilter: &batchScriptExecutionID,
BatchScriptExecutionStatusFilter: batchScriptExecutionStatus,
})
// Count the total number of hosts matching the filters.
// Do this before we add more "where" params for the main query.
countStmt = fmt.Sprintf(countStmt, batchScriptExecutionJoin, batchScriptExecutionFilter)
dbReader := ds.reader(ctx)
if err = sqlx.GetContext(ctx, dbReader, &count, countStmt, queryParams...); err != nil {
return nil, nil, 0, ctxerr.Wrap(ctx, err, "list batch scripts count")
}
// Add in the params for the main query SELECT.
queryParams = append([]interface{}{batchScriptExecutionStatus, batchScriptExecutionStatus}, queryParams...) // make a copy so we don't modify the original slice
// Add in the paging params.
sqlStmt, queryParams = appendListOptionsWithCursorToSQL(sqlStmt, queryParams, &opt)
// Run the main query to get the list of hosts.
sqlStmt = fmt.Sprintf(sqlStmt, batchScriptExecutionJoin, batchScriptExecutionFilter)
if err = sqlx.SelectContext(ctx, ds.reader(ctx), &hosts, sqlStmt, queryParams...); err != nil {
return nil, nil, 0, ctxerr.Wrap(ctx, err, "list batch script hosts")
}
if opt.IncludeMetadata {
meta = &fleet.PaginationMetadata{
HasPreviousResults: opt.Page > 0,
TotalResults: count,
}
// `appendListOptionsWithCursorToSQL` used above to build the query statement will cause this discrepancy.
// This is intentional so that we can check whether we have another page.
if len(hosts) > int(opt.PerPage) { //nolint:gosec // dismiss G115
meta.HasNextResults = true
hosts = hosts[:len(hosts)-1]
}
}
return hosts, meta, count, nil
}
// TODO(Sarah): Do we need to reconcile mutually exclusive filters?
func (ds *Datastore) applyHostFilters(
ctx context.Context, opt fleet.HostListOptions, sqlStmt string, filter fleet.TeamFilter, selectParams []interface{},
@@ -1207,29 +1283,9 @@ func (ds *Datastore) applyHostFilters(
// BatchScriptExecutionIDFilter is set. This allows us to filter hosts based on the status of
// batch script executions.
batchScriptExecutionJoin := ""
batchScriptExecutionIDFilter := "TRUE"
batchScriptExecutionFilter := "TRUE"
if opt.BatchScriptExecutionIDFilter != nil {
batchScriptExecutionJoin = `LEFT JOIN batch_activity_host_results bsehr ON h.id = bsehr.host_id`
batchScriptExecutionIDFilter = `bsehr.batch_execution_id = ?`
whereParams = append(whereParams, *opt.BatchScriptExecutionIDFilter)
if opt.BatchScriptExecutionStatusFilter.IsValid() {
batchScriptExecutionJoin += ` LEFT JOIN host_script_results hsr ON bsehr.host_execution_id = hsr.execution_id`
switch opt.BatchScriptExecutionStatusFilter {
case fleet.BatchScriptExecutionRan:
batchScriptExecutionIDFilter += ` AND hsr.exit_code = 0`
case fleet.BatchScriptExecutionPending:
// Pending can mean "waiting for execution" or "waiting for results".
batchScriptExecutionJoin += ` LEFT JOIN upcoming_activities ua ON ua.execution_id = bsehr.host_execution_id`
batchScriptExecutionIDFilter += ` AND ((ua.execution_id IS NOT NULL) OR (hsr.host_id is NOT NULL AND hsr.exit_code IS NULL AND hsr.canceled = 0 AND bsehr.error IS NULL))`
case fleet.BatchScriptExecutionErrored:
// TODO - remove exit code condition when we split up "errored" and "failed"
batchScriptExecutionIDFilter += ` AND hsr.exit_code > 0`
case fleet.BatchScriptExecutionIncompatible:
batchScriptExecutionIDFilter += ` AND bsehr.error IS NOT NULL`
case fleet.BatchScriptExecutionCanceled:
batchScriptExecutionIDFilter += ` AND hsr.exit_code IS NULL AND hsr.canceled = 1`
}
}
batchScriptExecutionJoin, batchScriptExecutionFilter, whereParams = ds.getBatchExecutionFilters(whereParams, opt)
}
sqlStmt += fmt.Sprintf(
@@ -1272,7 +1328,7 @@ func (ds *Datastore) applyHostFilters(
softwareFilter,
munkiFilter,
lowDiskSpaceFilter,
batchScriptExecutionIDFilter,
batchScriptExecutionFilter,
)
now := ds.clock.Now()
@@ -1319,6 +1375,34 @@ func (ds *Datastore) applyHostFilters(
return sqlStmt, params, nil
}
func (*Datastore) getBatchExecutionFilters(whereParams []interface{}, opt fleet.HostListOptions) (batchScriptExecutionJoin string, batchScriptExecutionFilter string, updatedWhereParams []interface{}) {
batchScriptExecutionJoin = `LEFT JOIN batch_activity_host_results bsehr ON h.id = bsehr.host_id`
batchScriptExecutionFilter = `bsehr.batch_execution_id = ?`
whereParams = append(whereParams, *opt.BatchScriptExecutionIDFilter)
if opt.BatchScriptExecutionStatusFilter.IsValid() {
batchScriptExecutionJoin += ` LEFT JOIN host_script_results hsr ON bsehr.host_execution_id = hsr.execution_id`
switch opt.BatchScriptExecutionStatusFilter {
case fleet.BatchScriptExecutionRan:
batchScriptExecutionFilter += ` AND hsr.exit_code = 0 AND hsr.canceled = 0`
case fleet.BatchScriptExecutionPending:
// Pending can mean "waiting for execution" or "waiting for results".
// hsr.exit_code IS NULL <- this means the script has not reported back
// (hsr.canceled IS NULL OR hsr.canceled = 0) <- this can mean the script is running, or that it hasn't been activated yet,
// but either way we haven't canceled it.
// bsehr.error IS NULL <- this means the batch script framework didn't mark this host as incompatible
// with this script run.
batchScriptExecutionFilter += ` AND (hsr.exit_code IS NULL AND (hsr.canceled IS NULL OR hsr.canceled = 0) AND bsehr.error IS NULL)`
case fleet.BatchScriptExecutionErrored:
batchScriptExecutionFilter += ` AND hsr.exit_code > 0 AND hsr.canceled = 0`
case fleet.BatchScriptExecutionIncompatible:
batchScriptExecutionFilter += ` AND bsehr.error IS NOT NULL`
case fleet.BatchScriptExecutionCanceled:
batchScriptExecutionFilter += ` AND hsr.exit_code IS NULL AND hsr.canceled = 1`
}
}
return batchScriptExecutionJoin, batchScriptExecutionFilter, whereParams
}
func filterHostsByTeam(sql string, opt fleet.HostListOptions, params []interface{}) (string, []interface{}) {
if opt.TeamFilter == nil {
// default "all teams" option
+77 -1
View File
@@ -3470,9 +3470,37 @@ func testHostsListByBatchScriptExecutionStatus(t *testing.T, ds *Datastore) {
require.Contains(t, expectedHostIds, hosts[0].ID)
require.Contains(t, expectedHostIds, hosts[1].ID)
// Get the list of pending hosts. Check pagination by first getting a page of 1.
batchHosts, meta, hostCount, err := ds.ListBatchScriptHosts(context.Background(), execID, fleet.BatchScriptExecutionPending, fleet.ListOptions{IncludeMetadata: true, PerPage: 1, Page: 0, OrderKey: "hostname", OrderDirection: fleet.OrderDescending})
require.NoError(t, err)
require.Len(t, batchHosts, 1)
require.Equal(t, uint(3), hostCount)
require.Equal(t, host3.ID, batchHosts[0].ID)
require.Equal(t, host3.Hostname, batchHosts[0].DisplayName)
require.Equal(t, fleet.BatchScriptExecutionPending, batchHosts[0].Status)
require.True(t, meta.HasNextResults)
require.False(t, meta.HasPreviousResults)
// Get all of the pending hosts.
batchHosts, meta, hostCount, err = ds.ListBatchScriptHosts(context.Background(), execID, fleet.BatchScriptExecutionPending, fleet.ListOptions{IncludeMetadata: true})
require.NoError(t, err)
require.Len(t, batchHosts, 3)
require.Equal(t, uint(3), hostCount)
require.Equal(t, host1.ID, batchHosts[0].ID)
require.Equal(t, host1.Hostname, batchHosts[0].DisplayName)
require.Equal(t, fleet.BatchScriptExecutionPending, batchHosts[0].Status)
require.Equal(t, host2.ID, batchHosts[1].ID)
require.Equal(t, host2.Hostname, batchHosts[1].DisplayName)
require.Equal(t, fleet.BatchScriptExecutionPending, batchHosts[1].Status)
require.Equal(t, host3.ID, batchHosts[2].ID)
require.Equal(t, host3.Hostname, batchHosts[2].DisplayName)
require.Equal(t, fleet.BatchScriptExecutionPending, batchHosts[2].Status)
require.False(t, meta.HasNextResults)
require.False(t, meta.HasPreviousResults)
// Do another batch script execution with the same hosts, and verify that "pending" returns correctly.
// The SQL for retrieving "pending" hosts has to check both the host_script_results table (for hosts
// that have "activated" the script acrivity) and the upcoming_activities table (for hosts that
// that have "activated" the script activity) and the upcoming_activities table (for hosts that
// have not yet activated the script activity).
secondExecID, err := ds.BatchExecuteScript(ctx, &user.ID, script.ID, []uint{hostNoScripts.ID, hostWindows.ID, host1.ID, host2.ID, host3.ID})
require.NoError(t, err)
@@ -3542,6 +3570,54 @@ func testHostsListByBatchScriptExecutionStatus(t *testing.T, ds *Datastore) {
hosts = listHostsCheckCount(t, ds, fleet.TeamFilter{User: test.UserAdmin}, fleet.HostListOptions{BatchScriptExecutionIDFilter: &execID, BatchScriptExecutionStatusFilter: fleet.BatchScriptExecutionCanceled}, 1)
require.Equal(t, host3.ID, hosts[0].ID)
// List pending hosts for this batch. There should be none.
batchHosts, _, hostCount, err = ds.ListBatchScriptHosts(context.Background(), execID, fleet.BatchScriptExecutionPending, fleet.ListOptions{})
require.NoError(t, err)
require.Len(t, batchHosts, 0)
require.Equal(t, uint(0), hostCount)
// List errored hosts for this batch. There should be one.
batchHosts, _, hostCount, err = ds.ListBatchScriptHosts(context.Background(), execID, fleet.BatchScriptExecutionErrored, fleet.ListOptions{})
require.NoError(t, err)
require.Len(t, batchHosts, 1)
require.Equal(t, uint(1), hostCount)
require.Equal(t, host2.ID, batchHosts[0].ID)
require.Equal(t, host2.Hostname, batchHosts[0].DisplayName)
require.Equal(t, fleet.BatchScriptExecutionErrored, batchHosts[0].Status)
require.Equal(t, host2Upcoming[0].ExecutionID, batchHosts[0].ScriptExecutionID)
// List ran hosts for this batch. There should be one.
batchHosts, _, hostCount, err = ds.ListBatchScriptHosts(context.Background(), execID, fleet.BatchScriptExecutionRan, fleet.ListOptions{})
require.NoError(t, err)
require.Len(t, batchHosts, 1)
require.Equal(t, uint(1), hostCount)
require.Equal(t, host1.ID, batchHosts[0].ID)
require.Equal(t, host1.Hostname, batchHosts[0].DisplayName)
require.Equal(t, fleet.BatchScriptExecutionRan, batchHosts[0].Status)
require.Equal(t, host1Upcoming[0].ExecutionID, batchHosts[0].ScriptExecutionID)
require.Equal(t, "foo", batchHosts[0].ScriptOutput)
// List cancelled hosts for this batch. There should be one.
batchHosts, _, hostCount, err = ds.ListBatchScriptHosts(context.Background(), execID, fleet.BatchScriptExecutionCanceled, fleet.ListOptions{})
require.NoError(t, err)
require.Len(t, batchHosts, 1)
require.Equal(t, uint(1), hostCount)
require.Equal(t, host3.ID, batchHosts[0].ID)
require.Equal(t, host3.Hostname, batchHosts[0].DisplayName)
require.Equal(t, fleet.BatchScriptExecutionCanceled, batchHosts[0].Status)
// List incompatible hosts for this batch. There should be two.
batchHosts, _, hostCount, err = ds.ListBatchScriptHosts(context.Background(), execID, fleet.BatchScriptExecutionIncompatible, fleet.ListOptions{})
require.NoError(t, err)
require.Len(t, batchHosts, 2)
require.Equal(t, uint(2), hostCount)
require.Equal(t, hostNoScripts.ID, batchHosts[0].ID)
require.Equal(t, hostNoScripts.Hostname, batchHosts[0].DisplayName)
require.Equal(t, fleet.BatchScriptExecutionIncompatible, batchHosts[0].Status)
require.Equal(t, hostWindows.ID, batchHosts[1].ID)
require.Equal(t, hostWindows.Hostname, batchHosts[1].DisplayName)
require.Equal(t, fleet.BatchScriptExecutionIncompatible, batchHosts[1].Status)
}
func testHostsListMacOSSettingsDiskEncryptionStatus(t *testing.T, ds *Datastore) {
+1
View File
@@ -250,6 +250,7 @@ type Datastore interface {
Host(ctx context.Context, id uint) (*Host, error)
GetHostHealth(ctx context.Context, id uint) (*HostHealth, error)
ListHosts(ctx context.Context, filter TeamFilter, opt HostListOptions) ([]*Host, error)
ListBatchScriptHosts(ctx context.Context, batchScriptExecutionID string, batchScriptExecutionStatus BatchScriptExecutionStatus, opt ListOptions) ([]BatchScriptHost, *PaginationMetadata, uint, error)
// ListHostsLiteByUUIDs returns the "lite" version of hosts corresponding to
// the provided uuids and filtered according to the provided team filters. It
+16
View File
@@ -282,6 +282,22 @@ func (hsr HostScriptResult) AuthzType() string {
return "host_script_result"
}
type BatchScriptHost struct {
// ID is the host on which the script was executed.
ID uint `json:"id" db:"id"`
// Display name is the host's display name.
DisplayName string `json:"display_name" db:"display_name"`
// ExecutionID is a unique identifier for a single execution of the script.
ScriptExecutionID string `json:"script_execution_id" db:"execution_id"`
// Output is the combined stdout/stderr output of the script. It is empty
// if no result was received yet.
ScriptOutput string `json:"script_output_preview,omitempty" db:"output"`
// Executed at is the time the script was executed on the host (if at all).
ScriptExecutedAt *time.Time `json:"script_executed_at,omitempty" db:"updated_at"`
// Status is the status of the host's batch script run.
Status BatchScriptExecutionStatus `json:"script_status" db:"status"`
}
// UserMessage returns the user-friendly message to associate with the current
// state of the HostScriptResult. This is returned as part of the API endpoints
// for running a script synchronously (so that fleetctl can display it) and to
+2
View File
@@ -1195,6 +1195,8 @@ type Service interface {
BatchScriptExecutionStatus(ctx context.Context, batchExecutionID string) (*BatchActivity, error)
BatchScriptExecutionHostResults(ctx context.Context, batchExecutionID string, status BatchScriptExecutionStatus, opt ListOptions) ([]BatchScriptHost, *PaginationMetadata, uint, error)
BatchScriptExecutionList(ctx context.Context, filter BatchExecutionStatusFilter) ([]BatchActivity, int64, error)
// BatchScriptCancel cancels a batch script execution
+12
View File
@@ -193,6 +193,8 @@ type GetHostHealthFunc func(ctx context.Context, id uint) (*fleet.HostHealth, er
type ListHostsFunc func(ctx context.Context, filter fleet.TeamFilter, opt fleet.HostListOptions) ([]*fleet.Host, error)
type ListBatchScriptHostsFunc func(ctx context.Context, batchScriptExecutionID string, batchScriptExecutionStatus fleet.BatchScriptExecutionStatus, opt fleet.ListOptions) ([]fleet.BatchScriptHost, *fleet.PaginationMetadata, uint, error)
type ListHostsLiteByUUIDsFunc func(ctx context.Context, filter fleet.TeamFilter, uuids []string) ([]*fleet.Host, error)
type ListHostsLiteByIDsFunc func(ctx context.Context, ids []uint) ([]*fleet.Host, error)
@@ -1725,6 +1727,9 @@ type DataStore struct {
ListHostsFunc ListHostsFunc
ListHostsFuncInvoked bool
ListBatchScriptHostsFunc ListBatchScriptHostsFunc
ListBatchScriptHostsFuncInvoked bool
ListHostsLiteByUUIDsFunc ListHostsLiteByUUIDsFunc
ListHostsLiteByUUIDsFuncInvoked bool
@@ -4237,6 +4242,13 @@ func (s *DataStore) ListHosts(ctx context.Context, filter fleet.TeamFilter, opt
return s.ListHostsFunc(ctx, filter, opt)
}
func (s *DataStore) ListBatchScriptHosts(ctx context.Context, batchScriptExecutionID string, batchScriptExecutionStatus fleet.BatchScriptExecutionStatus, opt fleet.ListOptions) ([]fleet.BatchScriptHost, *fleet.PaginationMetadata, uint, error) {
s.mu.Lock()
s.ListBatchScriptHostsFuncInvoked = true
s.mu.Unlock()
return s.ListBatchScriptHostsFunc(ctx, batchScriptExecutionID, batchScriptExecutionStatus, opt)
}
func (s *DataStore) ListHostsLiteByUUIDs(ctx context.Context, filter fleet.TeamFilter, uuids []string) ([]*fleet.Host, error) {
s.mu.Lock()
s.ListHostsLiteByUUIDsFuncInvoked = true
+1
View File
@@ -503,6 +503,7 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC
ue.POST("/api/_version_/fleet/scripts/batch/{batch_execution_id:[a-zA-Z0-9-]+}/cancel", batchScriptCancelEndpoint, batchScriptCancelRequest{})
// Deprecated, will remove in favor of batchScriptExecutionStatusEndpoint when batch script details page is ready.
ue.GET("/api/_version_/fleet/scripts/batch/summary/{batch_execution_id:[a-zA-Z0-9-]+}", batchScriptExecutionSummaryEndpoint, batchScriptExecutionSummaryRequest{})
ue.GET("/api/_version_/fleet/scripts/batch/{batch_execution_id:[a-zA-Z0-9-]+}/host-results", batchScriptExecutionHostResultsEndpoint, batchScriptExecutionHostResultsRequest{})
ue.GET("/api/_version_/fleet/scripts/batch/{batch_execution_id:[a-zA-Z0-9-]+}", batchScriptExecutionStatusEndpoint, batchScriptExecutionStatusRequest{})
ue.GET("/api/_version_/fleet/scripts/batch", batchScriptExecutionListEndpoint, batchScriptExecutionListRequest{})
+18 -4
View File
@@ -6661,6 +6661,20 @@ func (s *integrationEnterpriseTestSuite) TestRunBatchScript() {
0,
)
// List pending hosts
var batchPendingHostsResp batchScriptExecutionHostResultsResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/scripts/batch/%s/host-results?status=pending", batchRes.BatchExecutionID), nil, http.StatusOK, &batchPendingHostsResp)
require.Len(t, batchPendingHostsResp.Hosts, 2)
require.Equal(t, batchPendingHostsResp.Count, uint(2))
require.Equal(t, batchPendingHostsResp.Meta.HasNextResults, false)
require.Equal(t, batchPendingHostsResp.Meta.HasPreviousResults, false)
require.Equal(t, batchPendingHostsResp.Hosts[0].Status, fleet.BatchScriptExecutionPending)
require.Equal(t, batchPendingHostsResp.Hosts[0].DisplayName, host1.DisplayName())
require.Equal(t, batchPendingHostsResp.Hosts[0].ID, host1.ID)
require.Equal(t, batchPendingHostsResp.Hosts[1].Status, fleet.BatchScriptExecutionPending)
require.Equal(t, batchPendingHostsResp.Hosts[1].DisplayName, host2.DisplayName())
require.Equal(t, batchPendingHostsResp.Hosts[1].ID, host2.ID)
// Another request so we can check the list endpoint
var batchRes2 batchScriptRunResponse
s.DoJSON("POST", "/api/latest/fleet/scripts/run/batch", batchScriptRunRequest{
@@ -6674,8 +6688,8 @@ func (s *integrationEnterpriseTestSuite) TestRunBatchScript() {
s.DoJSON("GET", "/api/latest/fleet/scripts/batch?team_id=0&per_page=1", nil, http.StatusOK, &batchListResp)
require.Len(t, batchListResp.BatchScriptExecutions, 1)
require.Equal(t, batchListResp.Count, uint(2))
require.Equal(t, batchListResp.HasNextResults, true)
require.Equal(t, batchListResp.HasPreviousResults, false)
require.Equal(t, batchListResp.Meta.HasNextResults, true)
require.Equal(t, batchListResp.Meta.HasPreviousResults, false)
require.Equal(t, batchListResp.BatchScriptExecutions[0].BatchExecutionID, batchRes2.BatchExecutionID)
require.Equal(t, *batchListResp.BatchScriptExecutions[0].ScriptID, script.ID)
require.Equal(t, *batchListResp.BatchScriptExecutions[0].NumTargeted, uint(1))
@@ -6684,8 +6698,8 @@ func (s *integrationEnterpriseTestSuite) TestRunBatchScript() {
s.DoJSON("GET", "/api/latest/fleet/scripts/batch?team_id=0&page=1&per_page=1", nil, http.StatusOK, &batchListResp)
require.Len(t, batchListResp.BatchScriptExecutions, 1)
require.Equal(t, batchListResp.Count, uint(2))
require.Equal(t, batchListResp.HasNextResults, false)
require.Equal(t, batchListResp.HasPreviousResults, true)
require.Equal(t, batchListResp.Meta.HasNextResults, false)
require.Equal(t, batchListResp.Meta.HasPreviousResults, true)
require.Equal(t, batchListResp.BatchScriptExecutions[0].BatchExecutionID, batchRes.BatchExecutionID)
require.Equal(t, *batchListResp.BatchScriptExecutions[0].ScriptID, script.ID)
require.Equal(t, *batchListResp.BatchScriptExecutions[0].NumTargeted, uint(2))
+79 -6
View File
@@ -951,6 +951,8 @@ type batchSetScriptsResponse struct {
Err error `json:"error,omitempty"`
}
func (r batchSetScriptsResponse) Error() error { return r.Err }
type batchScriptExecutionStatusRequest struct {
BatchExecutionID string `url:"batch_execution_id"`
}
@@ -985,15 +987,28 @@ type (
)
type batchScriptExecutionListResponse struct {
BatchScriptExecutions []fleet.BatchActivity `json:"batch_executions"`
Count uint `json:"count"`
Err error `json:"error,omitempty"`
fleet.PaginationMetadata
BatchScriptExecutions []fleet.BatchActivity `json:"batch_executions"`
Count uint `json:"count"`
Err error `json:"error,omitempty"`
Meta fleet.PaginationMetadata `json:"meta"`
}
func (r batchScriptExecutionListResponse) Error() error { return r.Err }
func (r batchSetScriptsResponse) Error() error { return r.Err }
type batchScriptExecutionHostResultsRequest struct {
BatchExecutionID string `url:"batch_execution_id"`
BatchExecutionStatus fleet.BatchScriptExecutionStatus `query:"status"`
ListOptions fleet.ListOptions `url:"list_options"`
}
type batchScriptExecutionHostResultsResponse struct {
Hosts []fleet.BatchScriptHost `json:"hosts"`
Count uint `json:"count"`
Err error `json:"error,omitempty"`
Meta fleet.PaginationMetadata `json:"meta"`
}
func (r batchScriptExecutionHostResultsResponse) Error() error { return r.Err }
func batchSetScriptsEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
req := request.(*batchSetScriptsRequest)
@@ -1103,6 +1118,15 @@ func batchScriptExecutionSummaryEndpoint(ctx context.Context, request interface{
}, nil
}
func batchScriptExecutionHostResultsEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
req := request.(*batchScriptExecutionHostResultsRequest)
hosts, meta, count, err := svc.BatchScriptExecutionHostResults(ctx, req.BatchExecutionID, req.BatchExecutionStatus, req.ListOptions)
if err != nil {
return batchScriptExecutionHostResultsResponse{Err: err}, nil
}
return batchScriptExecutionHostResultsResponse{Hosts: hosts, Meta: *meta, Count: count}, nil
}
func batchScriptExecutionStatusEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
req := request.(*batchScriptExecutionStatusRequest)
status, err := svc.BatchScriptExecutionStatus(ctx, req.BatchExecutionID)
@@ -1146,7 +1170,7 @@ func batchScriptExecutionListEndpoint(ctx context.Context, request interface{},
return batchScriptExecutionListResponse{
BatchScriptExecutions: list,
Count: uint(count), //nolint:gosec // dismiss G115
PaginationMetadata: fleet.PaginationMetadata{
Meta: fleet.PaginationMetadata{
HasNextResults: hasNextResults,
HasPreviousResults: hasPreviousResults,
},
@@ -1279,6 +1303,55 @@ func (svc *Service) BatchScriptExecutionStatus(ctx context.Context, batchExecuti
return &summary, nil
}
func (svc *Service) BatchScriptExecutionHostResults(ctx context.Context, batchExecutionID string, status fleet.BatchScriptExecutionStatus, opt fleet.ListOptions) (hosts []fleet.BatchScriptHost, meta *fleet.PaginationMetadata, count uint, err error) {
// Get the batch activity.
batchActivity, err := svc.ds.GetBatchActivity(ctx, batchExecutionID)
if err != nil {
svc.authz.SkipAuthorization(ctx)
return nil, nil, 0, ctxerr.Wrap(ctx, err, "getting batch activity")
}
if batchActivity.ScriptID == nil {
svc.authz.SkipAuthorization(ctx)
return nil, nil, 0, ctxerr.Wrap(ctx, err, "batch activity has no script ID")
}
// Get the script referred to by the batch activity.
script, err := svc.ds.Script(ctx, *batchActivity.ScriptID)
if err != nil {
svc.authz.SkipAuthorization(ctx)
return nil, nil, 0, ctxerr.Wrap(ctx, err, "getting script")
}
if script == nil {
svc.authz.SkipAuthorization(ctx)
return nil, nil, 0, ctxerr.Wrap(ctx, err, "script not found")
}
// Authorize based on the script's team ID.
if err = svc.authz.Authorize(ctx, &fleet.Script{TeamID: script.TeamID}, fleet.ActionRead); err != nil {
return nil, nil, 0, err
}
// Validate the supplied batch execution status.
if !status.IsValid() {
return nil, nil, 0, fleet.NewInvalidArgumentError("batch_execution_status", "invalid batch execution status")
}
// Always include pagination info.
opt.IncludeMetadata = true
// Default sort order is name ascending.
if opt.OrderKey == "" {
opt.OrderKey = "display_name"
opt.OrderDirection = fleet.OrderAscending
}
hosts, meta, count, err = svc.ds.ListBatchScriptHosts(ctx, batchExecutionID, status, opt)
if err != nil {
return nil, nil, 0, ctxerr.Wrap(ctx, err, "list batch script hosts")
}
return hosts, meta, count, nil
}
func (svc *Service) BatchScriptExecutionList(ctx context.Context, filter fleet.BatchExecutionStatusFilter) ([]fleet.BatchActivity, int64, error) {
if err := svc.authz.Authorize(ctx, &fleet.Script{TeamID: filter.TeamID}, fleet.ActionRead); err != nil {
return nil, 0, err