diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
index d73608b74b..01f627ddb8 100644
--- a/.github/pull_request_template.md
+++ b/.github/pull_request_template.md
@@ -5,7 +5,7 @@ If some of the following don't apply, delete the relevant line.
- [ ] Changes file added for user-visible changes in `changes/` or `orbit/changes/`.
See [Changes files](https://fleetdm.com/docs/contributing/committing-changes#changes-files) for more information.
- [ ] Documented any API changes (docs/Using-Fleet/REST-API.md or docs/Contributing/API-for-contributors.md)
-- [ ] Documented any permissions changes
+- [ ] Documented any permissions changes (docs/Using Fleet/manage-access.md)
- [ ] Input data is properly validated, `SELECT *` is avoided, 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.
- [ ] Added/updated tests
diff --git a/changes/issue-13305-api-run-script b/changes/issue-13305-api-run-script
new file mode 100644
index 0000000000..9b7cc90c01
--- /dev/null
+++ b/changes/issue-13305-api-run-script
@@ -0,0 +1 @@
+* Added `/scripts/run` and `scripts/run/sync` API endpoints to send a script to be executed on a host (and optionally wait for its results).
diff --git a/docs/REST API/rest-api.md b/docs/REST API/rest-api.md
index bbcdb5fe40..bd576cd916 100644
--- a/docs/REST API/rest-api.md
+++ b/docs/REST API/rest-api.md
@@ -10,6 +10,7 @@
- [Policies](#policies)
- [Queries](#queries)
- [Schedule (deprecated)](#schedule)
+- [Scripts](#scripts)
- [Sessions](#sessions)
- [Software](#software)
- [Targets](#targets)
@@ -3126,7 +3127,7 @@ Retrieves the disk encryption key for a host.
}
```
-### Get configuration profiles assigned to a host
+### Get configuration profiles assigned to a host
Requires Fleet's MDM properly [enabled and configured](https://fleetdm.com/docs/using-fleet/mdm-setup).
@@ -5626,7 +5627,7 @@ load balancer timeout.
## Schedule
-> The schedule API endpoints are deprecated as of Fleet 4.35. They are maintained for backwards compatibility.
+> The schedule API endpoints are deprecated as of Fleet 4.35. They are maintained for backwards compatibility.
> Please use the [queries](#queries) endpoints, which as of 4.35 have attributes such as `interval` and `platform` that enable scheduling.
- [Get schedule (deprecated)](#get-schedule)
@@ -5641,7 +5642,7 @@ These API routes let you control your scheduled queries.
### Get schedule
-> The schedule API endpoints are deprecated as of Fleet 4.35. They are maintained for backwards compatibility.
+> The schedule API endpoints are deprecated as of Fleet 4.35. They are maintained for backwards compatibility.
> Please use the [queries](#queries) endpoints, which as of 4.35 have attributes such as `interval` and `platform` that enable scheduling.
`GET /api/v1/fleet/global/schedule`
@@ -5715,7 +5716,7 @@ None.
### Add query to schedule
-> The schedule API endpoints are deprecated as of Fleet 4.35. They are maintained for backwards compatibility.
+> The schedule API endpoints are deprecated as of Fleet 4.35. They are maintained for backwards compatibility.
> Please use the [queries](#queries) endpoints, which as of 4.35 have attributes such as `interval` and `platform` that enable scheduling.
`POST /api/v1/fleet/global/schedule`
@@ -5776,7 +5777,7 @@ None.
### Edit query in schedule
-> The schedule API endpoints are deprecated as of Fleet 4.35. They are maintained for backwards compatibility.
+> The schedule API endpoints are deprecated as of Fleet 4.35. They are maintained for backwards compatibility.
> Please use the [queries](#queries) endpoints, which as of 4.35 have attributes such as `interval` and `platform` that enable scheduling.
`PATCH /api/v1/fleet/global/schedule/{id}`
@@ -5832,7 +5833,7 @@ None.
### Remove query from schedule
-> The schedule API endpoints are deprecated as of Fleet 4.35. They are maintained for backwards compatibility.
+> The schedule API endpoints are deprecated as of Fleet 4.35. They are maintained for backwards compatibility.
> Please use the [queries](#queries) endpoints, which as of 4.35 have attributes such as `interval` and `platform` that enable scheduling.
`DELETE /api/v1/fleet/global/schedule/{id}`
@@ -5854,7 +5855,7 @@ None.
### Team schedule
-> The schedule API endpoints are deprecated as of Fleet 4.35. They are maintained for backwards compatibility.
+> The schedule API endpoints are deprecated as of Fleet 4.35. They are maintained for backwards compatibility.
> Please use the [queries](#queries) endpoints, which as of 4.35 have attributes such as `interval` and `platform` that enable scheduling.
- [Get team schedule (deprecated)](#get-team-schedule)
@@ -5866,7 +5867,7 @@ This allows you to easily configure scheduled queries that will impact a whole t
#### Get team schedule
-> The schedule API endpoints are deprecated as of Fleet 4.35. They are maintained for backwards compatibility.
+> The schedule API endpoints are deprecated as of Fleet 4.35. They are maintained for backwards compatibility.
> Please use the [queries](#queries) endpoints, which as of 4.35 have attributes such as `interval` and `platform` that enable scheduling.
`GET /api/v1/fleet/teams/{id}/schedule`
@@ -5946,7 +5947,7 @@ This allows you to easily configure scheduled queries that will impact a whole t
#### Add query to team schedule
-> The schedule API endpoints are deprecated as of Fleet 4.35. They are maintained for backwards compatibility.
+> The schedule API endpoints are deprecated as of Fleet 4.35. They are maintained for backwards compatibility.
> Please use the [queries](#queries) endpoints, which as of 4.35 have attributes such as `interval` and `platform` that enable scheduling.
`POST /api/v1/fleet/teams/{id}/schedule`
@@ -6004,7 +6005,7 @@ This allows you to easily configure scheduled queries that will impact a whole t
#### Edit query in team schedule
-> The schedule API endpoints are deprecated as of Fleet 4.35. They are maintained for backwards compatibility.
+> The schedule API endpoints are deprecated as of Fleet 4.35. They are maintained for backwards compatibility.
> Please use the [queries](#queries) endpoints, which as of 4.35 have attributes such as `interval` and `platform` that enable scheduling.
`PATCH /api/v1/fleet/teams/{team_id}/schedule/{scheduled_query_id}`
@@ -6061,7 +6062,7 @@ This allows you to easily configure scheduled queries that will impact a whole t
#### Remove query from team schedule
-> The schedule API endpoints are deprecated as of Fleet 4.35. They are maintained for backwards compatibility.
+> The schedule API endpoints are deprecated as of Fleet 4.35. They are maintained for backwards compatibility.
> Please use the [queries](#queries) endpoints, which as of 4.35 have attributes such as `interval` and `platform` that enable scheduling.
`DELETE /api/v1/fleet/teams/{team_id}/schedule/{scheduled_query_id}`
@@ -6083,6 +6084,78 @@ This allows you to easily configure scheduled queries that will impact a whole t
---
+## Scripts
+
+- [Run script asynchronously](#run-script-asynchronously)
+- [Run script synchronously](#run-script-synchronously)
+
+
+### Run script asynchronously
+
+_Available in Fleet Premium_
+
+Creates a script execution request and returns the execution identifier to retrieve results at a later time.
+
+`POST /api/v1/fleet/scripts/run`
+
+#### Parameters
+
+| Name | Type | In | Description |
+| ---- | ------- | ---- | -------------------------------------------- |
+| host_id | integer | body | **Required**. The host id to run the script on. |
+| script_contents | string | body | **Required**. The contents of the script to run. |
+
+#### Example
+
+`POST /api/v1/fleet/scripts/run`
+
+##### Default response
+
+`Status: 202`
+
+```json
+{
+ "host_id": 1227,
+ "execution_id": "e797d6c6-3aae-11ee-be56-0242ac120002"
+}
+```
+
+### Run script synchronously
+
+_Available in Fleet Premium_
+
+Creates a script execution request and waits for a result to return (up to a 1 minute timeout).
+
+`POST /api/v1/fleet/scripts/run/sync`
+
+#### Parameters
+
+| Name | Type | In | Description |
+| ---- | ------- | ---- | -------------------------------------------- |
+| host_id | integer | body | **Required**. The host id to run the script on. |
+| script_contents | string | body | **Required**. The contents of the script to run. |
+
+#### Example
+
+`POST /api/v1/fleet/scripts/run/sync`
+
+##### Default response
+
+`Status: 200`
+
+```json
+{
+ "host_id": 1227,
+ "execution_id": "e797d6c6-3aae-11ee-be56-0242ac120002",
+ "script_contents": "echo 'hello'",
+ "output": "hello",
+ "runtime": 1,
+ "exit_code": 0
+}
+```
+
+---
+
## Sessions
- [Get session info](#get-session-info)
diff --git a/docs/Using Fleet/manage-access.md b/docs/Using Fleet/manage-access.md
index fcfa3b4ce1..2095bdf291 100644
--- a/docs/Using Fleet/manage-access.md
+++ b/docs/Using Fleet/manage-access.md
@@ -87,6 +87,7 @@ GitOps is an API-only and write-only role that can be used on CI/CD pipelines.
| View metadata of MDM macOS bootstrap packages\* | | | ✅ | ✅ | |
| Edit/upload MDM macOS bootstrap packages\* | | | ✅ | ✅ | ✅ |
| Enable/disable MDM macOS setup end user authentication\* | | | ✅ | ✅ | ✅ |
+| Run scripts on hosts\* | | | ✅ | ✅ | |
\* Applies only to Fleet Premium
@@ -149,6 +150,7 @@ Users that are members of multiple teams can be assigned different roles for eac
| View metadata of MDM macOS bootstrap packages | | | ✅ | ✅ | |
| Edit/upload MDM macOS bootstrap packages | | | ✅ | ✅ | ✅ |
| Enable/disable MDM macOS setup end user authentication | | | ✅ | ✅ | ✅ |
+| Run scripts on hosts | | | ✅ | ✅ | |
\* Applies only to [Fleet REST API](https://fleetdm.com/docs/using-fleet/rest-api)
@@ -156,4 +158,4 @@ Users that are members of multiple teams can be assigned different roles for eac
-
\ No newline at end of file
+
diff --git a/ee/server/service/hosts.go b/ee/server/service/hosts.go
index a2b4c8749f..6c06138c5b 100644
--- a/ee/server/service/hosts.go
+++ b/ee/server/service/hosts.go
@@ -2,7 +2,11 @@ package service
import (
"context"
+ "fmt"
+ "time"
+ "unicode/utf8"
+ "github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/fleet"
)
@@ -19,3 +23,109 @@ func (svc *Service) HostByIdentifier(ctx context.Context, identifier string, opt
opts.IncludePolicies = true
return svc.Service.HostByIdentifier(ctx, identifier, opts)
}
+
+func (svc *Service) RunHostScript(ctx context.Context, request *fleet.HostScriptRequestPayload, waitForResult time.Duration) (*fleet.HostScriptResult, error) {
+ const (
+ maxScriptRuneLen = 10000
+ maxPendingScriptAge = time.Minute // any script older than this is not considered pending anymore on that host
+ )
+
+ // must load the host (lite is enough, just for the team) to authorize
+ // with the proper team id. We cannot first authorize if the user can list
+ // hosts, because the user could have a write-only role (e.g. gitops).
+ host, err := svc.ds.HostLite(ctx, request.HostID)
+ if err != nil {
+ // if error is because the host does not exist, check first if the user
+ // had access to run a script (to prevent leaking valid host ids).
+ if fleet.IsNotFound(err) {
+ if err := svc.authz.Authorize(ctx, &fleet.HostScriptResult{}, fleet.ActionWrite); err != nil {
+ return nil, err
+ }
+ }
+ svc.authz.SkipAuthorization(ctx)
+ return nil, ctxerr.Wrap(ctx, err, "get host lite")
+ }
+ if err := svc.authz.Authorize(ctx, &fleet.HostScriptResult{TeamID: host.TeamID}, fleet.ActionWrite); err != nil {
+ return nil, err
+ }
+
+ if request.ScriptContents == "" {
+ return nil, fleet.NewInvalidArgumentError("script_contents", "a script to execute is required")
+ }
+ // look for the script length in bytes first, as rune counting a huge string
+ // can be expensive.
+ if len(request.ScriptContents) > utf8.UTFMax*maxScriptRuneLen {
+ return nil, fleet.NewInvalidArgumentError("script_contents", fmt.Sprintf("script is too long, must be at most %d characters", maxScriptRuneLen))
+ }
+ // now that we know that the script is at most 4*maxScriptRuneLen bytes long,
+ // we can safely count the runes for a precise check.
+ if utf8.RuneCountInString(request.ScriptContents) > maxScriptRuneLen {
+ return nil, fleet.NewInvalidArgumentError("script_contents", fmt.Sprintf("script is too long, must be at most %d characters", maxScriptRuneLen))
+ }
+
+ // TODO(mna): any other validation we want to apply to the script? What is the "must be bash/powershell" check?
+
+ pending, err := svc.ds.ListPendingHostScriptExecutions(ctx, request.HostID, maxPendingScriptAge)
+ if err != nil {
+ return nil, ctxerr.Wrap(ctx, err, "list host pending script executions")
+ }
+ if len(pending) > 0 {
+ // TODO(mna): there are a number of issues with that validation: it only
+ // really says that there was a script execution _request_ that was made < 1m
+ // ago, and that blocks executing any more scripts on that host, but the
+ // host may not even have received the previous script for execution yet,
+ // so if we accept more scripts after 1m, we may end up having multiple
+ // scripts to execute on the host at the same time (or more likely in
+ // sequence, but still). This may be good enough for now, I think the whole
+ // idea of locking if a script is pending is meant to be temporary anyway.
+ return nil, fleet.NewInvalidArgumentError("script_contents", "a script is currently executing on the host")
+ }
+
+ // create the script execution request
+ script, err := svc.ds.NewHostScriptExecutionRequest(ctx, request)
+ if err != nil {
+ return nil, ctxerr.Wrap(ctx, err, "create script execution request")
+ }
+ // TODO(mna): figure out how to send this to the host, either something to do
+ // here or via the DB checking if there are pending scripts for the host when
+ // sending queries or notifications.
+ if waitForResult <= 0 {
+ // async execution, return
+ return script, nil
+ }
+
+ ctx, cancel := context.WithTimeout(ctx, waitForResult)
+ defer cancel()
+
+ // if waiting for a result times out, we still want to return the script's
+ // execution request information along with the error, so that the caller can
+ // use the execution id for later checks.
+ timeoutResult := script
+ checkInterval := time.Second
+ after := time.NewTimer(checkInterval)
+ for {
+ select {
+ case <-ctx.Done():
+ return timeoutResult, ctx.Err()
+ case <-after.C:
+ result, err := svc.ds.GetHostScriptExecutionResult(ctx, script.ExecutionID)
+ if err != nil {
+ // is that due to the context being canceled during the DB access?
+ if ctxErr := ctx.Err(); ctxErr != nil {
+ return timeoutResult, ctxErr
+ }
+ return nil, ctxerr.Wrap(ctx, err, "get script execution result")
+ }
+ if result.ExitCode.Valid {
+ // a result was received from the host, return
+ return result, nil
+ }
+
+ // at a second to every attempt, until it reaches 5s (then check every 5s)
+ if checkInterval < 5*time.Second {
+ checkInterval += time.Second
+ }
+ after.Reset(checkInterval)
+ }
+ }
+}
diff --git a/server/authz/policy.rego b/server/authz/policy.rego
index 519c6eb6aa..6ff2d66f94 100644
--- a/server/authz/policy.rego
+++ b/server/authz/policy.rego
@@ -350,7 +350,7 @@ allow {
object.observer_can_run == false
is_null(subject.global_role)
action == run
-
+
is_null(object.team_id)
not is_null(object.host_targets.teams)
@@ -365,7 +365,7 @@ allow {
object.observer_can_run == false
is_null(subject.global_role)
action == run
-
+
team_role(subject, object.team_id) == [admin, maintainer, observer_plus][_]
not is_null(object.host_targets.teams)
@@ -395,7 +395,7 @@ allow {
object.observer_can_run == false
is_null(subject.global_role)
action == run
-
+
team_role(subject, object.team_id) == [admin, maintainer, observer_plus][_]
# there are no team targets
@@ -425,7 +425,7 @@ allow {
object.observer_can_run == true
is_null(subject.global_role)
action == run
-
+
is_null(object.team_id)
not is_null(object.host_targets.teams)
@@ -440,7 +440,7 @@ allow {
object.observer_can_run == true
is_null(subject.global_role)
action == run
-
+
team_role(subject, object.team_id) == [admin, maintainer, observer_plus, observer][_]
not is_null(object.host_targets.teams)
@@ -454,7 +454,7 @@ allow {
object.observer_can_run == true
is_null(subject.global_role)
action == run
-
+
is_null(object.team_id)
# If role is admin, maintainer, observer_plus or observer on any team.
@@ -470,7 +470,7 @@ allow {
object.observer_can_run == true
is_null(subject.global_role)
action == run
-
+
team_role(subject, object.team_id) == [admin, maintainer, observer_plus, observer][_]
# there are no team targets
@@ -814,3 +814,39 @@ allow {
not is_null(subject)
action == read
}
+
+##
+# Host Script Result (script execution and output)
+##
+
+# Global admins and maintainers can write (execute) scripts (not gitops as this
+# is not something that relates to fleetctl apply).
+allow {
+ object.type == "host_script_result"
+ subject.global_role == [admin, maintainer][_]
+ action == write
+}
+
+# Global admins, maintainers, observer_plus and observers can read scripts.
+allow {
+ object.type == "host_script_result"
+ subject.global_role == [admin, maintainer, observer, observer_plus][_]
+ action == read
+}
+
+# Team admin and maintainers can write (execute) scripts for their teams (not
+# gitops as this is not something that relates to fleetctl apply).
+allow {
+ object.type == "host_script_result"
+ not is_null(object.team_id)
+ team_role(subject, object.team_id) == [admin, maintainer][_]
+ action == write
+}
+
+# Team admins, maintainers, observer_plus and observers can read scripts for their teams.
+allow {
+ object.type == "host_script_result"
+ not is_null(object.team_id)
+ team_role(subject, object.team_id) == [admin, maintainer, observer_plus, observer][_]
+ action == read
+}
diff --git a/server/authz/policy_test.go b/server/authz/policy_test.go
index 0dd7f8efd2..e4cb991e29 100644
--- a/server/authz/policy_test.go
+++ b/server/authz/policy_test.go
@@ -1814,6 +1814,96 @@ func TestAuthorizeMDMAppleCommand(t *testing.T) {
})
}
+func TestAuthorizeHostScriptResult(t *testing.T) {
+ t.Parallel()
+
+ globalScript := &fleet.HostScriptResult{}
+ team1Script := &fleet.HostScriptResult{
+ TeamID: ptr.Uint(1),
+ }
+ runTestCases(t, []authTestCase{
+ {user: test.UserNoRoles, object: globalScript, action: write, allow: false},
+ {user: test.UserNoRoles, object: globalScript, action: read, allow: false},
+ {user: test.UserNoRoles, object: team1Script, action: write, allow: false},
+ {user: test.UserNoRoles, object: team1Script, action: read, allow: false},
+
+ {user: test.UserAdmin, object: globalScript, action: write, allow: true},
+ {user: test.UserAdmin, object: globalScript, action: read, allow: true},
+ {user: test.UserAdmin, object: team1Script, action: write, allow: true},
+ {user: test.UserAdmin, object: team1Script, action: read, allow: true},
+
+ {user: test.UserMaintainer, object: globalScript, action: write, allow: true},
+ {user: test.UserMaintainer, object: globalScript, action: read, allow: true},
+ {user: test.UserMaintainer, object: team1Script, action: write, allow: true},
+ {user: test.UserMaintainer, object: team1Script, action: read, allow: true},
+
+ {user: test.UserObserver, object: globalScript, action: write, allow: false},
+ {user: test.UserObserver, object: globalScript, action: read, allow: true},
+ {user: test.UserObserver, object: team1Script, action: write, allow: false},
+ {user: test.UserObserver, object: team1Script, action: read, allow: true},
+
+ {user: test.UserObserverPlus, object: globalScript, action: write, allow: false},
+ {user: test.UserObserverPlus, object: globalScript, action: read, allow: true},
+ {user: test.UserObserverPlus, object: team1Script, action: write, allow: false},
+ {user: test.UserObserverPlus, object: team1Script, action: read, allow: true},
+
+ {user: test.UserGitOps, object: globalScript, action: write, allow: false},
+ {user: test.UserGitOps, object: globalScript, action: read, allow: false},
+ {user: test.UserGitOps, object: team1Script, action: write, allow: false},
+ {user: test.UserGitOps, object: team1Script, action: read, allow: false},
+
+ {user: test.UserTeamAdminTeam1, object: globalScript, action: write, allow: false},
+ {user: test.UserTeamAdminTeam1, object: globalScript, action: read, allow: false},
+ {user: test.UserTeamAdminTeam1, object: team1Script, action: write, allow: true},
+ {user: test.UserTeamAdminTeam1, object: team1Script, action: read, allow: true},
+
+ {user: test.UserTeamAdminTeam2, object: globalScript, action: write, allow: false},
+ {user: test.UserTeamAdminTeam2, object: globalScript, action: read, allow: false},
+ {user: test.UserTeamAdminTeam2, object: team1Script, action: write, allow: false},
+ {user: test.UserTeamAdminTeam2, object: team1Script, action: read, allow: false},
+
+ {user: test.UserTeamMaintainerTeam1, object: globalScript, action: write, allow: false},
+ {user: test.UserTeamMaintainerTeam1, object: globalScript, action: read, allow: false},
+ {user: test.UserTeamMaintainerTeam1, object: team1Script, action: write, allow: true},
+ {user: test.UserTeamMaintainerTeam1, object: team1Script, action: read, allow: true},
+
+ {user: test.UserTeamMaintainerTeam2, object: globalScript, action: write, allow: false},
+ {user: test.UserTeamMaintainerTeam2, object: globalScript, action: read, allow: false},
+ {user: test.UserTeamMaintainerTeam2, object: team1Script, action: write, allow: false},
+ {user: test.UserTeamMaintainerTeam2, object: team1Script, action: read, allow: false},
+
+ {user: test.UserTeamObserverTeam1, object: globalScript, action: write, allow: false},
+ {user: test.UserTeamObserverTeam1, object: globalScript, action: read, allow: false},
+ {user: test.UserTeamObserverTeam1, object: team1Script, action: write, allow: false},
+ {user: test.UserTeamObserverTeam1, object: team1Script, action: read, allow: true},
+
+ {user: test.UserTeamObserverTeam2, object: globalScript, action: write, allow: false},
+ {user: test.UserTeamObserverTeam2, object: globalScript, action: read, allow: false},
+ {user: test.UserTeamObserverTeam2, object: team1Script, action: write, allow: false},
+ {user: test.UserTeamObserverTeam2, object: team1Script, action: read, allow: false},
+
+ {user: test.UserTeamObserverPlusTeam1, object: globalScript, action: write, allow: false},
+ {user: test.UserTeamObserverPlusTeam1, object: globalScript, action: read, allow: false},
+ {user: test.UserTeamObserverPlusTeam1, object: team1Script, action: write, allow: false},
+ {user: test.UserTeamObserverPlusTeam1, object: team1Script, action: read, allow: true},
+
+ {user: test.UserTeamObserverPlusTeam2, object: globalScript, action: write, allow: false},
+ {user: test.UserTeamObserverPlusTeam2, object: globalScript, action: read, allow: false},
+ {user: test.UserTeamObserverPlusTeam2, object: team1Script, action: write, allow: false},
+ {user: test.UserTeamObserverPlusTeam2, object: team1Script, action: read, allow: false},
+
+ {user: test.UserTeamGitOpsTeam1, object: globalScript, action: write, allow: false},
+ {user: test.UserTeamGitOpsTeam1, object: globalScript, action: read, allow: false},
+ {user: test.UserTeamGitOpsTeam1, object: team1Script, action: write, allow: false},
+ {user: test.UserTeamGitOpsTeam1, object: team1Script, action: read, allow: false},
+
+ {user: test.UserTeamGitOpsTeam2, object: globalScript, action: write, allow: false},
+ {user: test.UserTeamGitOpsTeam2, object: globalScript, action: read, allow: false},
+ {user: test.UserTeamGitOpsTeam2, object: team1Script, action: write, allow: false},
+ {user: test.UserTeamGitOpsTeam2, object: team1Script, action: read, allow: false},
+ })
+}
+
func TestJSONToInterfaceUser(t *testing.T) {
t.Parallel()
diff --git a/server/datastore/mysql/hosts.go b/server/datastore/mysql/hosts.go
index a0941f2da5..5f29b057c9 100644
--- a/server/datastore/mysql/hosts.go
+++ b/server/datastore/mysql/hosts.go
@@ -18,6 +18,7 @@ import (
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/go-kit/kit/log"
"github.com/go-kit/kit/log/level"
+ "github.com/google/uuid"
"github.com/jmoiron/sqlx"
)
@@ -464,6 +465,7 @@ var hostRefs = []string{
"host_disk_encryption_keys",
"host_software_installed_paths",
"host_dep_assignments",
+ "host_script_results",
}
// those host refs cannot be deleted using the host.id like the hostRefs above,
@@ -4187,3 +4189,108 @@ func (ds *Datastore) GetMatchingHostSerials(ctx context.Context, serials []strin
return result, nil
}
+
+func (ds *Datastore) NewHostScriptExecutionRequest(ctx context.Context, request *fleet.HostScriptRequestPayload) (*fleet.HostScriptResult, error) {
+ const (
+ insStmt = `INSERT INTO host_script_results (host_id, execution_id, script_contents, output) VALUES (?, ?, ?, '')`
+ getStmt = `SELECT id, host_id, execution_id, script_contents FROM host_script_results WHERE id = ?`
+ )
+
+ execID := uuid.New().String()
+ result, err := ds.writer(ctx).ExecContext(ctx, insStmt,
+ request.HostID,
+ execID,
+ request.ScriptContents,
+ )
+ if err != nil {
+ return nil, ctxerr.Wrap(ctx, err, "new host script execution request")
+ }
+
+ var script fleet.HostScriptResult
+ id, _ := result.LastInsertId()
+ if err := ds.writer(ctx).GetContext(ctx, &script, getStmt, id); err != nil {
+ return nil, ctxerr.Wrap(ctx, err, "getting the created host script result to return")
+ }
+ return &script, nil
+}
+
+func (ds *Datastore) SetHostScriptExecutionResult(ctx context.Context, result *fleet.HostScriptResultPayload) error {
+ const updStmt = `
+ UPDATE host_script_results SET
+ output = ?,
+ runtime = ?,
+ exit_code = ?
+ WHERE
+ host_id = ? AND
+ execution_id = ?`
+
+ const maxOutputRuneLen = 10000
+ output := result.Output
+ if len(output) > utf8.UTFMax*maxOutputRuneLen {
+ // truncate the bytes as we know the output is too long, no point
+ // converting more bytes than needed to runes.
+ output = output[len(output)-utf8.UTFMax*maxOutputRuneLen:]
+ }
+ if utf8.RuneCountInString(output) > maxOutputRuneLen {
+ outputRunes := []rune(output)
+ output = string(outputRunes[len(outputRunes)-maxOutputRuneLen:])
+ }
+
+ if _, err := ds.writer(ctx).ExecContext(ctx, updStmt,
+ output,
+ result.Runtime,
+ result.ExitCode,
+ result.HostID,
+ result.ExecutionID,
+ ); err != nil {
+ return ctxerr.Wrap(ctx, err, "update host script result")
+ }
+ return nil
+}
+
+func (ds *Datastore) ListPendingHostScriptExecutions(ctx context.Context, hostID uint, ignoreOlder time.Duration) ([]*fleet.HostScriptResult, error) {
+ const listStmt = `
+ SELECT
+ id,
+ host_id,
+ execution_id,
+ script_contents
+ FROM
+ host_script_results
+ WHERE
+ host_id = ? AND
+ exit_code IS NULL AND
+ created_at >= DATE_SUB(NOW(), INTERVAL ? SECOND)`
+
+ var results []*fleet.HostScriptResult
+ seconds := int(ignoreOlder.Seconds())
+ if err := sqlx.SelectContext(ctx, ds.reader(ctx), &results, listStmt, hostID, seconds); err != nil {
+ return nil, ctxerr.Wrap(ctx, err, "list pending host script results")
+ }
+ return results, nil
+}
+
+func (ds *Datastore) GetHostScriptExecutionResult(ctx context.Context, execID string) (*fleet.HostScriptResult, error) {
+ const getStmt = `
+ SELECT
+ id,
+ host_id,
+ execution_id,
+ script_contents,
+ output,
+ runtime,
+ exit_code
+ FROM
+ host_script_results
+ WHERE
+ execution_id = ?`
+
+ var result fleet.HostScriptResult
+ if err := sqlx.GetContext(ctx, ds.reader(ctx), &result, getStmt, execID); err != nil {
+ if err == sql.ErrNoRows {
+ return nil, ctxerr.Wrap(ctx, notFound("HostScriptResult").WithName(execID))
+ }
+ return nil, ctxerr.Wrap(ctx, err, "get host script result")
+ }
+ return &result, nil
+}
diff --git a/server/datastore/mysql/hosts_test.go b/server/datastore/mysql/hosts_test.go
index 2e7e3af4b1..c675e7a48a 100644
--- a/server/datastore/mysql/hosts_test.go
+++ b/server/datastore/mysql/hosts_test.go
@@ -151,6 +151,7 @@ func TestHosts(t *testing.T) {
{"ListHostsLiteByUUIDs", testHostsListHostsLiteByUUIDs},
{"GetMatchingHostSerials", testGetMatchingHostSerials},
{"ListHostsLiteByIDs", testHostsListHostsLiteByIDs},
+ {"HostScriptResult", testHostScriptResult},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
@@ -5775,6 +5776,8 @@ func testHostsDeleteHosts(t *testing.T, ds *Datastore) {
require.NoError(t, err)
err = ds.RecordHostBootstrapPackage(context.Background(), "command-uuid", host.UUID)
require.NoError(t, err)
+ _, err = ds.NewHostScriptExecutionRequest(context.Background(), &fleet.HostScriptRequestPayload{HostID: host.ID, ScriptContents: "foo"})
+ require.NoError(t, err)
// Check there's an entry for the host in all the associated tables.
for _, hostRef := range hostRefs {
@@ -7263,3 +7266,118 @@ func testHostsListHostsLiteByIDs(t *testing.T, ds *Datastore) {
})
}
}
+
+func testHostScriptResult(t *testing.T, ds *Datastore) {
+ ctx := context.Background()
+
+ // no script saved yet
+ pending, err := ds.ListPendingHostScriptExecutions(ctx, 1, time.Second)
+ require.NoError(t, err)
+ require.Empty(t, pending)
+
+ _, err = ds.GetHostScriptExecutionResult(ctx, "abc")
+ require.Error(t, err)
+ var nfe *notFoundError
+ require.ErrorAs(t, err, &nfe)
+
+ // create a createdScript execution request
+ createdScript, err := ds.NewHostScriptExecutionRequest(ctx, &fleet.HostScriptRequestPayload{
+ HostID: 1,
+ ScriptContents: "echo",
+ })
+ require.NoError(t, err)
+ require.NotZero(t, createdScript.ID)
+ require.NotEmpty(t, createdScript.ExecutionID)
+ require.Equal(t, uint(1), createdScript.HostID)
+ require.NotEmpty(t, createdScript.ExecutionID)
+ require.Equal(t, "echo", createdScript.ScriptContents)
+ require.False(t, createdScript.ExitCode.Valid)
+ require.Empty(t, createdScript.Output)
+
+ // the script execution is now listed as pending for this host
+ pending, err = ds.ListPendingHostScriptExecutions(ctx, 1, 10*time.Second)
+ require.NoError(t, err)
+ require.Len(t, pending, 1)
+ require.Equal(t, createdScript.ID, pending[0].ID)
+
+ // waiting for a second and an ignore of 0s ignores this script
+ time.Sleep(time.Second)
+ pending, err = ds.ListPendingHostScriptExecutions(ctx, 1, 0)
+ require.NoError(t, err)
+ require.Empty(t, pending)
+
+ // record a result for this execution
+ err = ds.SetHostScriptExecutionResult(ctx, &fleet.HostScriptResultPayload{
+ HostID: 1,
+ ExecutionID: createdScript.ExecutionID,
+ Output: "foo",
+ Runtime: 2,
+ ExitCode: 0,
+ })
+ require.NoError(t, err)
+
+ // it is not pending anymore
+ pending, err = ds.ListPendingHostScriptExecutions(ctx, 1, 10*time.Second)
+ require.NoError(t, err)
+ require.Empty(t, pending)
+
+ // the script result can be retrieved
+ script, err := ds.GetHostScriptExecutionResult(ctx, createdScript.ExecutionID)
+ require.NoError(t, err)
+ expectScript := *createdScript
+ expectScript.Output = "foo"
+ expectScript.Runtime = 2
+ expectScript.ExitCode = sql.NullInt64{Int64: 0, Valid: true}
+ require.Equal(t, &expectScript, script)
+
+ // create another script execution request
+ createdScript, err = ds.NewHostScriptExecutionRequest(ctx, &fleet.HostScriptRequestPayload{
+ HostID: 1,
+ ScriptContents: "echo2",
+ })
+ require.NoError(t, err)
+ require.NotZero(t, createdScript.ID)
+ require.NotEmpty(t, createdScript.ExecutionID)
+
+ // the script result can be retrieved even if it has no result yet
+ script, err = ds.GetHostScriptExecutionResult(ctx, createdScript.ExecutionID)
+ require.NoError(t, err)
+ require.Equal(t, createdScript, script)
+
+ // record a result for this execution, with an output that is too large
+ largeOutput := strings.Repeat("a", 1000) +
+ strings.Repeat("b", 1000) +
+ strings.Repeat("c", 1000) +
+ strings.Repeat("d", 1000) +
+ strings.Repeat("e", 1000) +
+ strings.Repeat("f", 1000) +
+ strings.Repeat("g", 1000) +
+ strings.Repeat("h", 1000) +
+ strings.Repeat("i", 1000) +
+ strings.Repeat("j", 1000) +
+ strings.Repeat("k", 1000)
+ expectedOutput := strings.Repeat("b", 1000) +
+ strings.Repeat("c", 1000) +
+ strings.Repeat("d", 1000) +
+ strings.Repeat("e", 1000) +
+ strings.Repeat("f", 1000) +
+ strings.Repeat("g", 1000) +
+ strings.Repeat("h", 1000) +
+ strings.Repeat("i", 1000) +
+ strings.Repeat("j", 1000) +
+ strings.Repeat("k", 1000)
+
+ err = ds.SetHostScriptExecutionResult(ctx, &fleet.HostScriptResultPayload{
+ HostID: 1,
+ ExecutionID: createdScript.ExecutionID,
+ Output: largeOutput,
+ Runtime: 10,
+ ExitCode: 1,
+ })
+ require.NoError(t, err)
+
+ // the script result can be retrieved
+ script, err = ds.GetHostScriptExecutionResult(ctx, createdScript.ExecutionID)
+ require.NoError(t, err)
+ require.Equal(t, expectedOutput, script.Output)
+}
diff --git a/server/datastore/mysql/migrations/tables/20230814150442_AddHostScriptResultsTable.go b/server/datastore/mysql/migrations/tables/20230814150442_AddHostScriptResultsTable.go
new file mode 100644
index 0000000000..5ba41602e1
--- /dev/null
+++ b/server/datastore/mysql/migrations/tables/20230814150442_AddHostScriptResultsTable.go
@@ -0,0 +1,67 @@
+package tables
+
+import (
+ "database/sql"
+ "fmt"
+)
+
+func init() {
+ MigrationClient.AddMigration(Up_20230814150442, Down_20230814150442)
+}
+
+func Up_20230814150442(tx *sql.Tx) error {
+ _, err := tx.Exec(`
+CREATE TABLE host_script_results (
+ id INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,
+ host_id INT(10) UNSIGNED NOT NULL,
+
+ -- execution_id is a unique identifier (e.g. UUID) generated for each
+ -- execution of a script.
+ execution_id VARCHAR(255) NOT NULL,
+
+ -- in the future, we may have a concept of "saved scripts" and in that case
+ -- the host_script_results may be associated with a script_id instead of
+ -- the actual script contents. If that's the case, it may be best to allow
+ -- this field to be NULL (if a saved script is used) but for now we don't
+ -- support this so I'm making it NOT NULL.
+ script_contents TEXT NOT NULL,
+
+ -- output is the combination of stdout and stderr from the script execution.
+ output TEXT NOT NULL,
+
+ -- runtime is the execution time of the script in seconds, rounded.
+ runtime INT(10) UNSIGNED NOT NULL DEFAULT 0,
+
+ -- the exit code of the script execution, large enough to not assume too
+ -- much about the possible range (e.g. https://stackoverflow.com/a/328423/1094941)
+ -- It can be NULL to represent that the script results have not been received
+ -- yet, and -1 if the script executed but was terminated abruptly (e.g. due to
+ -- a signal/timeout, same as how Go reports this: https://pkg.go.dev/os#ProcessState.ExitCode).
+ exit_code INT(10) NULL,
+
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+
+ PRIMARY KEY (id),
+
+ -- this index can be used to lookup results for a specific
+ -- execution (execution ids, e.g. when updating the row for results)
+ UNIQUE KEY idx_host_script_results_execution_id (execution_id),
+
+ -- this index can be used to lookup results for a host, to check if a host is currently
+ -- executing a script (by host_id and with exit_code = NULL), and an created_at condition
+ -- can be added to dismiss a pending execution that's been running for too long (e.g. host
+ -- was offline and never sent results, we should eventually start accepting a new
+ -- script execution).
+ KEY idx_host_script_results_host_exit_created (host_id, exit_code, created_at)
+)`)
+ if err != nil {
+ return fmt.Errorf("failed to create host_script_results table: %w", err)
+ }
+
+ return nil
+}
+
+func Down_20230814150442(tx *sql.Tx) error {
+ return nil
+}
diff --git a/server/datastore/mysql/migrations/tables/20230814150442_AddHostScriptResultsTable_test.go b/server/datastore/mysql/migrations/tables/20230814150442_AddHostScriptResultsTable_test.go
new file mode 100644
index 0000000000..2a5a87dd2e
--- /dev/null
+++ b/server/datastore/mysql/migrations/tables/20230814150442_AddHostScriptResultsTable_test.go
@@ -0,0 +1,88 @@
+package tables
+
+import (
+ "database/sql"
+ "testing"
+ "time"
+
+ "github.com/google/uuid"
+ "github.com/stretchr/testify/require"
+)
+
+func TestUp_20230814150442(t *testing.T) {
+ db := applyUpToPrev(t)
+
+ // Apply current migration.
+ applyNext(t, db)
+
+ // NOTE: output field must be provided explicitly (even if empty), because TEXT fields
+ // cannot have a default value.
+ insertStmt := `INSERT INTO host_script_results (
+ host_id, execution_id, script_contents, output
+ ) VALUES (?, ?, ?, '')`
+
+ hostID := 123
+ execID := uuid.New().String()
+ scriptContents := "echo 'hello world'"
+ res, err := db.Exec(insertStmt, hostID, execID, scriptContents)
+ require.NoError(t, err)
+
+ id, _ := res.LastInsertId()
+ require.Greater(t, id, int64(0))
+
+ type hostScriptResult struct {
+ ID int `db:"id"`
+ HostID int `db:"host_id"`
+ ExecutionID string `db:"execution_id"`
+ ScriptContents string `db:"script_contents"`
+ Output string `db:"output"`
+ Runtime int `db:"runtime"`
+ ExitCode sql.NullInt64 `db:"exit_code"`
+ CreatedAt time.Time `db:"created_at"`
+ UpdatedAt time.Time `db:"updated_at"`
+ }
+
+ // load the host we just created
+ var scriptResult hostScriptResult
+ selectStmt := `SELECT id, host_id, execution_id, script_contents, output, runtime, exit_code, created_at, updated_at
+ FROM host_script_results
+ WHERE id = ?`
+ err = db.Get(&scriptResult, selectStmt, id)
+ require.NoError(t, err)
+
+ require.Equal(t, int(id), scriptResult.ID)
+ require.Equal(t, hostID, scriptResult.HostID)
+ require.Equal(t, execID, scriptResult.ExecutionID)
+ require.Equal(t, scriptContents, scriptResult.ScriptContents)
+ require.Empty(t, scriptResult.Output)
+ require.Zero(t, scriptResult.Runtime)
+ require.False(t, scriptResult.ExitCode.Valid)
+ require.NotZero(t, scriptResult.CreatedAt)
+ require.NotZero(t, scriptResult.UpdatedAt)
+
+ // check pending executions for a given host
+ var countPending int
+ countPendingStmt := `SELECT COUNT(*)
+ FROM host_script_results
+ WHERE host_id = ? AND exit_code IS NULL`
+ err = db.Get(&countPending, countPendingStmt, hostID)
+ require.NoError(t, err)
+ require.Equal(t, 1, countPending)
+
+ // update the host we just created
+ output := `hello world`
+ runtime := 10
+ exitCode := int64(0)
+ updateStmt := `UPDATE host_script_results SET output = ?, runtime = ?, exit_code = ? WHERE host_id = ? AND execution_id = ?`
+ _, err = db.Exec(updateStmt, output, runtime, exitCode, hostID, execID)
+ require.NoError(t, err)
+
+ // reload the updated host result
+ err = db.Get(&scriptResult, selectStmt, id)
+ require.NoError(t, err)
+
+ require.Equal(t, output, scriptResult.Output)
+ require.Equal(t, runtime, scriptResult.Runtime)
+ require.True(t, scriptResult.ExitCode.Valid)
+ require.Equal(t, exitCode, scriptResult.ExitCode.Int64)
+}
diff --git a/server/datastore/mysql/schema.sql b/server/datastore/mysql/schema.sql
index e82b9fbece..9436cc74ad 100644
--- a/server/datastore/mysql/schema.sql
+++ b/server/datastore/mysql/schema.sql
@@ -329,6 +329,23 @@ CREATE TABLE `host_orbit_info` (
/*!40101 SET character_set_client = @saved_cs_client */;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8 */;
+CREATE TABLE `host_script_results` (
+ `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
+ `host_id` int(10) unsigned NOT NULL,
+ `execution_id` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
+ `script_contents` text COLLATE utf8mb4_unicode_ci NOT NULL,
+ `output` text COLLATE utf8mb4_unicode_ci NOT NULL,
+ `runtime` int(10) unsigned NOT NULL DEFAULT '0',
+ `exit_code` int(10) DEFAULT NULL,
+ `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `idx_host_script_results_execution_id` (`execution_id`),
+ KEY `idx_host_script_results_host_exit_created` (`host_id`,`exit_code`,`created_at`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+/*!40101 SET character_set_client = @saved_cs_client */;
+/*!40101 SET @saved_cs_client = @@character_set_client */;
+/*!40101 SET character_set_client = utf8 */;
CREATE TABLE `host_seen_times` (
`host_id` int(10) unsigned NOT NULL,
`seen_time` timestamp NULL DEFAULT NULL,
@@ -661,9 +678,9 @@ CREATE TABLE `migration_status_tables` (
`tstamp` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `id` (`id`)
-) ENGINE=InnoDB AUTO_INCREMENT=202 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+) ENGINE=InnoDB AUTO_INCREMENT=203 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
-INSERT INTO `migration_status_tables` VALUES (1,0,1,'2020-01-01 01:01:01'),(2,20161118193812,1,'2020-01-01 01:01:01'),(3,20161118211713,1,'2020-01-01 01:01:01'),(4,20161118212436,1,'2020-01-01 01:01:01'),(5,20161118212515,1,'2020-01-01 01:01:01'),(6,20161118212528,1,'2020-01-01 01:01:01'),(7,20161118212538,1,'2020-01-01 01:01:01'),(8,20161118212549,1,'2020-01-01 01:01:01'),(9,20161118212557,1,'2020-01-01 01:01:01'),(10,20161118212604,1,'2020-01-01 01:01:01'),(11,20161118212613,1,'2020-01-01 01:01:01'),(12,20161118212621,1,'2020-01-01 01:01:01'),(13,20161118212630,1,'2020-01-01 01:01:01'),(14,20161118212641,1,'2020-01-01 01:01:01'),(15,20161118212649,1,'2020-01-01 01:01:01'),(16,20161118212656,1,'2020-01-01 01:01:01'),(17,20161118212758,1,'2020-01-01 01:01:01'),(18,20161128234849,1,'2020-01-01 01:01:01'),(19,20161230162221,1,'2020-01-01 01:01:01'),(20,20170104113816,1,'2020-01-01 01:01:01'),(21,20170105151732,1,'2020-01-01 01:01:01'),(22,20170108191242,1,'2020-01-01 01:01:01'),(23,20170109094020,1,'2020-01-01 01:01:01'),(24,20170109130438,1,'2020-01-01 01:01:01'),(25,20170110202752,1,'2020-01-01 01:01:01'),(26,20170111133013,1,'2020-01-01 01:01:01'),(27,20170117025759,1,'2020-01-01 01:01:01'),(28,20170118191001,1,'2020-01-01 01:01:01'),(29,20170119234632,1,'2020-01-01 01:01:01'),(30,20170124230432,1,'2020-01-01 01:01:01'),(31,20170127014618,1,'2020-01-01 01:01:01'),(32,20170131232841,1,'2020-01-01 01:01:01'),(33,20170223094154,1,'2020-01-01 01:01:01'),(34,20170306075207,1,'2020-01-01 01:01:01'),(35,20170309100733,1,'2020-01-01 01:01:01'),(36,20170331111922,1,'2020-01-01 01:01:01'),(37,20170502143928,1,'2020-01-01 01:01:01'),(38,20170504130602,1,'2020-01-01 01:01:01'),(39,20170509132100,1,'2020-01-01 01:01:01'),(40,20170519105647,1,'2020-01-01 01:01:01'),(41,20170519105648,1,'2020-01-01 01:01:01'),(42,20170831234300,1,'2020-01-01 01:01:01'),(43,20170831234301,1,'2020-01-01 01:01:01'),(44,20170831234303,1,'2020-01-01 01:01:01'),(45,20171116163618,1,'2020-01-01 01:01:01'),(46,20171219164727,1,'2020-01-01 01:01:01'),(47,20180620164811,1,'2020-01-01 01:01:01'),(48,20180620175054,1,'2020-01-01 01:01:01'),(49,20180620175055,1,'2020-01-01 01:01:01'),(50,20191010101639,1,'2020-01-01 01:01:01'),(51,20191010155147,1,'2020-01-01 01:01:01'),(52,20191220130734,1,'2020-01-01 01:01:01'),(53,20200311140000,1,'2020-01-01 01:01:01'),(54,20200405120000,1,'2020-01-01 01:01:01'),(55,20200407120000,1,'2020-01-01 01:01:01'),(56,20200420120000,1,'2020-01-01 01:01:01'),(57,20200504120000,1,'2020-01-01 01:01:01'),(58,20200512120000,1,'2020-01-01 01:01:01'),(59,20200707120000,1,'2020-01-01 01:01:01'),(60,20201011162341,1,'2020-01-01 01:01:01'),(61,20201021104586,1,'2020-01-01 01:01:01'),(62,20201102112520,1,'2020-01-01 01:01:01'),(63,20201208121729,1,'2020-01-01 01:01:01'),(64,20201215091637,1,'2020-01-01 01:01:01'),(65,20210119174155,1,'2020-01-01 01:01:01'),(66,20210326182902,1,'2020-01-01 01:01:01'),(67,20210421112652,1,'2020-01-01 01:01:01'),(68,20210506095025,1,'2020-01-01 01:01:01'),(69,20210513115729,1,'2020-01-01 01:01:01'),(70,20210526113559,1,'2020-01-01 01:01:01'),(71,20210601000001,1,'2020-01-01 01:01:01'),(72,20210601000002,1,'2020-01-01 01:01:01'),(73,20210601000003,1,'2020-01-01 01:01:01'),(74,20210601000004,1,'2020-01-01 01:01:01'),(75,20210601000005,1,'2020-01-01 01:01:01'),(76,20210601000006,1,'2020-01-01 01:01:01'),(77,20210601000007,1,'2020-01-01 01:01:01'),(78,20210601000008,1,'2020-01-01 01:01:01'),(79,20210606151329,1,'2020-01-01 01:01:01'),(80,20210616163757,1,'2020-01-01 01:01:01'),(81,20210617174723,1,'2020-01-01 01:01:01'),(82,20210622160235,1,'2020-01-01 01:01:01'),(83,20210623100031,1,'2020-01-01 01:01:01'),(84,20210623133615,1,'2020-01-01 01:01:01'),(85,20210708143152,1,'2020-01-01 01:01:01'),(86,20210709124443,1,'2020-01-01 01:01:01'),(87,20210712155608,1,'2020-01-01 01:01:01'),(88,20210714102108,1,'2020-01-01 01:01:01'),(89,20210719153709,1,'2020-01-01 01:01:01'),(90,20210721171531,1,'2020-01-01 01:01:01'),(91,20210723135713,1,'2020-01-01 01:01:01'),(92,20210802135933,1,'2020-01-01 01:01:01'),(93,20210806112844,1,'2020-01-01 01:01:01'),(94,20210810095603,1,'2020-01-01 01:01:01'),(95,20210811150223,1,'2020-01-01 01:01:01'),(96,20210818151827,1,'2020-01-01 01:01:01'),(97,20210818151828,1,'2020-01-01 01:01:01'),(98,20210818182258,1,'2020-01-01 01:01:01'),(99,20210819131107,1,'2020-01-01 01:01:01'),(100,20210819143446,1,'2020-01-01 01:01:01'),(101,20210903132338,1,'2020-01-01 01:01:01'),(102,20210915144307,1,'2020-01-01 01:01:01'),(103,20210920155130,1,'2020-01-01 01:01:01'),(104,20210927143115,1,'2020-01-01 01:01:01'),(105,20210927143116,1,'2020-01-01 01:01:01'),(106,20211013133706,1,'2020-01-01 01:01:01'),(107,20211013133707,1,'2020-01-01 01:01:01'),(108,20211102135149,1,'2020-01-01 01:01:01'),(109,20211109121546,1,'2020-01-01 01:01:01'),(110,20211110163320,1,'2020-01-01 01:01:01'),(111,20211116184029,1,'2020-01-01 01:01:01'),(112,20211116184030,1,'2020-01-01 01:01:01'),(113,20211202092042,1,'2020-01-01 01:01:01'),(114,20211202181033,1,'2020-01-01 01:01:01'),(115,20211207161856,1,'2020-01-01 01:01:01'),(116,20211216131203,1,'2020-01-01 01:01:01'),(117,20211221110132,1,'2020-01-01 01:01:01'),(118,20220107155700,1,'2020-01-01 01:01:01'),(119,20220125105650,1,'2020-01-01 01:01:01'),(120,20220201084510,1,'2020-01-01 01:01:01'),(121,20220208144830,1,'2020-01-01 01:01:01'),(122,20220208144831,1,'2020-01-01 01:01:01'),(123,20220215152203,1,'2020-01-01 01:01:01'),(124,20220223113157,1,'2020-01-01 01:01:01'),(125,20220307104655,1,'2020-01-01 01:01:01'),(126,20220309133956,1,'2020-01-01 01:01:01'),(127,20220316155700,1,'2020-01-01 01:01:01'),(128,20220323152301,1,'2020-01-01 01:01:01'),(129,20220330100659,1,'2020-01-01 01:01:01'),(130,20220404091216,1,'2020-01-01 01:01:01'),(131,20220419140750,1,'2020-01-01 01:01:01'),(132,20220428140039,1,'2020-01-01 01:01:01'),(133,20220503134048,1,'2020-01-01 01:01:01'),(134,20220524102918,1,'2020-01-01 01:01:01'),(135,20220526123327,1,'2020-01-01 01:01:01'),(136,20220526123328,1,'2020-01-01 01:01:01'),(137,20220526123329,1,'2020-01-01 01:01:01'),(138,20220608113128,1,'2020-01-01 01:01:01'),(139,20220627104817,1,'2020-01-01 01:01:01'),(140,20220704101843,1,'2020-01-01 01:01:01'),(141,20220708095046,1,'2020-01-01 01:01:01'),(142,20220713091130,1,'2020-01-01 01:01:01'),(143,20220802135510,1,'2020-01-01 01:01:01'),(144,20220818101352,1,'2020-01-01 01:01:01'),(145,20220822161445,1,'2020-01-01 01:01:01'),(146,20220831100036,1,'2020-01-01 01:01:01'),(147,20220831100151,1,'2020-01-01 01:01:01'),(148,20220908181826,1,'2020-01-01 01:01:01'),(149,20220914154915,1,'2020-01-01 01:01:01'),(150,20220915165115,1,'2020-01-01 01:01:01'),(151,20220915165116,1,'2020-01-01 01:01:01'),(152,20220928100158,1,'2020-01-01 01:01:01'),(153,20221014084130,1,'2020-01-01 01:01:01'),(154,20221027085019,1,'2020-01-01 01:01:01'),(155,20221101103952,1,'2020-01-01 01:01:01'),(156,20221104144401,1,'2020-01-01 01:01:01'),(157,20221109100749,1,'2020-01-01 01:01:01'),(158,20221115104546,1,'2020-01-01 01:01:01'),(159,20221130114928,1,'2020-01-01 01:01:01'),(160,20221205112142,1,'2020-01-01 01:01:01'),(161,20221216115820,1,'2020-01-01 01:01:01'),(162,20221220195934,1,'2020-01-01 01:01:01'),(163,20221220195935,1,'2020-01-01 01:01:01'),(164,20221223174807,1,'2020-01-01 01:01:01'),(165,20221227163855,1,'2020-01-01 01:01:01'),(166,20221227163856,1,'2020-01-01 01:01:01'),(167,20230202224725,1,'2020-01-01 01:01:01'),(168,20230206163608,1,'2020-01-01 01:01:01'),(169,20230214131519,1,'2020-01-01 01:01:01'),(170,20230303135738,1,'2020-01-01 01:01:01'),(171,20230313135301,1,'2020-01-01 01:01:01'),(172,20230313141819,1,'2020-01-01 01:01:01'),(173,20230315104937,1,'2020-01-01 01:01:01'),(174,20230317173844,1,'2020-01-01 01:01:01'),(175,20230320133602,1,'2020-01-01 01:01:01'),(176,20230330100011,1,'2020-01-01 01:01:01'),(177,20230330134823,1,'2020-01-01 01:01:01'),(178,20230405232025,1,'2020-01-01 01:01:01'),(179,20230408084104,1,'2020-01-01 01:01:01'),(180,20230411102858,1,'2020-01-01 01:01:01'),(181,20230421155932,1,'2020-01-01 01:01:01'),(182,20230425082126,1,'2020-01-01 01:01:01'),(183,20230425105727,1,'2020-01-01 01:01:01'),(184,20230501154913,1,'2020-01-01 01:01:01'),(185,20230503101418,1,'2020-01-01 01:01:01'),(186,20230515144206,1,'2020-01-01 01:01:01'),(187,20230517140952,1,'2020-01-01 01:01:01'),(188,20230517152807,1,'2020-01-01 01:01:01'),(189,20230518114155,1,'2020-01-01 01:01:01'),(190,20230520153236,1,'2020-01-01 01:01:01'),(191,20230525151159,1,'2020-01-01 01:01:01'),(192,20230530122103,1,'2020-01-01 01:01:01'),(193,20230602111827,1,'2020-01-01 01:01:01'),(194,20230608103123,1,'2020-01-01 01:01:01'),(195,20230629140529,1,'2020-01-01 01:01:01'),(196,20230629140530,1,'2020-01-01 01:01:01'),(197,20230711144622,1,'2020-01-01 01:01:01'),(198,20230721135421,1,'2020-01-01 01:01:01'),(199,20230721161508,1,'2020-01-01 01:01:01'),(200,20230726115701,1,'2020-01-01 01:01:01'),(201,20230807100822,1,'2020-01-01 01:01:01');
+INSERT INTO `migration_status_tables` VALUES (1,0,1,'2020-01-01 01:01:01'),(2,20161118193812,1,'2020-01-01 01:01:01'),(3,20161118211713,1,'2020-01-01 01:01:01'),(4,20161118212436,1,'2020-01-01 01:01:01'),(5,20161118212515,1,'2020-01-01 01:01:01'),(6,20161118212528,1,'2020-01-01 01:01:01'),(7,20161118212538,1,'2020-01-01 01:01:01'),(8,20161118212549,1,'2020-01-01 01:01:01'),(9,20161118212557,1,'2020-01-01 01:01:01'),(10,20161118212604,1,'2020-01-01 01:01:01'),(11,20161118212613,1,'2020-01-01 01:01:01'),(12,20161118212621,1,'2020-01-01 01:01:01'),(13,20161118212630,1,'2020-01-01 01:01:01'),(14,20161118212641,1,'2020-01-01 01:01:01'),(15,20161118212649,1,'2020-01-01 01:01:01'),(16,20161118212656,1,'2020-01-01 01:01:01'),(17,20161118212758,1,'2020-01-01 01:01:01'),(18,20161128234849,1,'2020-01-01 01:01:01'),(19,20161230162221,1,'2020-01-01 01:01:01'),(20,20170104113816,1,'2020-01-01 01:01:01'),(21,20170105151732,1,'2020-01-01 01:01:01'),(22,20170108191242,1,'2020-01-01 01:01:01'),(23,20170109094020,1,'2020-01-01 01:01:01'),(24,20170109130438,1,'2020-01-01 01:01:01'),(25,20170110202752,1,'2020-01-01 01:01:01'),(26,20170111133013,1,'2020-01-01 01:01:01'),(27,20170117025759,1,'2020-01-01 01:01:01'),(28,20170118191001,1,'2020-01-01 01:01:01'),(29,20170119234632,1,'2020-01-01 01:01:01'),(30,20170124230432,1,'2020-01-01 01:01:01'),(31,20170127014618,1,'2020-01-01 01:01:01'),(32,20170131232841,1,'2020-01-01 01:01:01'),(33,20170223094154,1,'2020-01-01 01:01:01'),(34,20170306075207,1,'2020-01-01 01:01:01'),(35,20170309100733,1,'2020-01-01 01:01:01'),(36,20170331111922,1,'2020-01-01 01:01:01'),(37,20170502143928,1,'2020-01-01 01:01:01'),(38,20170504130602,1,'2020-01-01 01:01:01'),(39,20170509132100,1,'2020-01-01 01:01:01'),(40,20170519105647,1,'2020-01-01 01:01:01'),(41,20170519105648,1,'2020-01-01 01:01:01'),(42,20170831234300,1,'2020-01-01 01:01:01'),(43,20170831234301,1,'2020-01-01 01:01:01'),(44,20170831234303,1,'2020-01-01 01:01:01'),(45,20171116163618,1,'2020-01-01 01:01:01'),(46,20171219164727,1,'2020-01-01 01:01:01'),(47,20180620164811,1,'2020-01-01 01:01:01'),(48,20180620175054,1,'2020-01-01 01:01:01'),(49,20180620175055,1,'2020-01-01 01:01:01'),(50,20191010101639,1,'2020-01-01 01:01:01'),(51,20191010155147,1,'2020-01-01 01:01:01'),(52,20191220130734,1,'2020-01-01 01:01:01'),(53,20200311140000,1,'2020-01-01 01:01:01'),(54,20200405120000,1,'2020-01-01 01:01:01'),(55,20200407120000,1,'2020-01-01 01:01:01'),(56,20200420120000,1,'2020-01-01 01:01:01'),(57,20200504120000,1,'2020-01-01 01:01:01'),(58,20200512120000,1,'2020-01-01 01:01:01'),(59,20200707120000,1,'2020-01-01 01:01:01'),(60,20201011162341,1,'2020-01-01 01:01:01'),(61,20201021104586,1,'2020-01-01 01:01:01'),(62,20201102112520,1,'2020-01-01 01:01:01'),(63,20201208121729,1,'2020-01-01 01:01:01'),(64,20201215091637,1,'2020-01-01 01:01:01'),(65,20210119174155,1,'2020-01-01 01:01:01'),(66,20210326182902,1,'2020-01-01 01:01:01'),(67,20210421112652,1,'2020-01-01 01:01:01'),(68,20210506095025,1,'2020-01-01 01:01:01'),(69,20210513115729,1,'2020-01-01 01:01:01'),(70,20210526113559,1,'2020-01-01 01:01:01'),(71,20210601000001,1,'2020-01-01 01:01:01'),(72,20210601000002,1,'2020-01-01 01:01:01'),(73,20210601000003,1,'2020-01-01 01:01:01'),(74,20210601000004,1,'2020-01-01 01:01:01'),(75,20210601000005,1,'2020-01-01 01:01:01'),(76,20210601000006,1,'2020-01-01 01:01:01'),(77,20210601000007,1,'2020-01-01 01:01:01'),(78,20210601000008,1,'2020-01-01 01:01:01'),(79,20210606151329,1,'2020-01-01 01:01:01'),(80,20210616163757,1,'2020-01-01 01:01:01'),(81,20210617174723,1,'2020-01-01 01:01:01'),(82,20210622160235,1,'2020-01-01 01:01:01'),(83,20210623100031,1,'2020-01-01 01:01:01'),(84,20210623133615,1,'2020-01-01 01:01:01'),(85,20210708143152,1,'2020-01-01 01:01:01'),(86,20210709124443,1,'2020-01-01 01:01:01'),(87,20210712155608,1,'2020-01-01 01:01:01'),(88,20210714102108,1,'2020-01-01 01:01:01'),(89,20210719153709,1,'2020-01-01 01:01:01'),(90,20210721171531,1,'2020-01-01 01:01:01'),(91,20210723135713,1,'2020-01-01 01:01:01'),(92,20210802135933,1,'2020-01-01 01:01:01'),(93,20210806112844,1,'2020-01-01 01:01:01'),(94,20210810095603,1,'2020-01-01 01:01:01'),(95,20210811150223,1,'2020-01-01 01:01:01'),(96,20210818151827,1,'2020-01-01 01:01:01'),(97,20210818151828,1,'2020-01-01 01:01:01'),(98,20210818182258,1,'2020-01-01 01:01:01'),(99,20210819131107,1,'2020-01-01 01:01:01'),(100,20210819143446,1,'2020-01-01 01:01:01'),(101,20210903132338,1,'2020-01-01 01:01:01'),(102,20210915144307,1,'2020-01-01 01:01:01'),(103,20210920155130,1,'2020-01-01 01:01:01'),(104,20210927143115,1,'2020-01-01 01:01:01'),(105,20210927143116,1,'2020-01-01 01:01:01'),(106,20211013133706,1,'2020-01-01 01:01:01'),(107,20211013133707,1,'2020-01-01 01:01:01'),(108,20211102135149,1,'2020-01-01 01:01:01'),(109,20211109121546,1,'2020-01-01 01:01:01'),(110,20211110163320,1,'2020-01-01 01:01:01'),(111,20211116184029,1,'2020-01-01 01:01:01'),(112,20211116184030,1,'2020-01-01 01:01:01'),(113,20211202092042,1,'2020-01-01 01:01:01'),(114,20211202181033,1,'2020-01-01 01:01:01'),(115,20211207161856,1,'2020-01-01 01:01:01'),(116,20211216131203,1,'2020-01-01 01:01:01'),(117,20211221110132,1,'2020-01-01 01:01:01'),(118,20220107155700,1,'2020-01-01 01:01:01'),(119,20220125105650,1,'2020-01-01 01:01:01'),(120,20220201084510,1,'2020-01-01 01:01:01'),(121,20220208144830,1,'2020-01-01 01:01:01'),(122,20220208144831,1,'2020-01-01 01:01:01'),(123,20220215152203,1,'2020-01-01 01:01:01'),(124,20220223113157,1,'2020-01-01 01:01:01'),(125,20220307104655,1,'2020-01-01 01:01:01'),(126,20220309133956,1,'2020-01-01 01:01:01'),(127,20220316155700,1,'2020-01-01 01:01:01'),(128,20220323152301,1,'2020-01-01 01:01:01'),(129,20220330100659,1,'2020-01-01 01:01:01'),(130,20220404091216,1,'2020-01-01 01:01:01'),(131,20220419140750,1,'2020-01-01 01:01:01'),(132,20220428140039,1,'2020-01-01 01:01:01'),(133,20220503134048,1,'2020-01-01 01:01:01'),(134,20220524102918,1,'2020-01-01 01:01:01'),(135,20220526123327,1,'2020-01-01 01:01:01'),(136,20220526123328,1,'2020-01-01 01:01:01'),(137,20220526123329,1,'2020-01-01 01:01:01'),(138,20220608113128,1,'2020-01-01 01:01:01'),(139,20220627104817,1,'2020-01-01 01:01:01'),(140,20220704101843,1,'2020-01-01 01:01:01'),(141,20220708095046,1,'2020-01-01 01:01:01'),(142,20220713091130,1,'2020-01-01 01:01:01'),(143,20220802135510,1,'2020-01-01 01:01:01'),(144,20220818101352,1,'2020-01-01 01:01:01'),(145,20220822161445,1,'2020-01-01 01:01:01'),(146,20220831100036,1,'2020-01-01 01:01:01'),(147,20220831100151,1,'2020-01-01 01:01:01'),(148,20220908181826,1,'2020-01-01 01:01:01'),(149,20220914154915,1,'2020-01-01 01:01:01'),(150,20220915165115,1,'2020-01-01 01:01:01'),(151,20220915165116,1,'2020-01-01 01:01:01'),(152,20220928100158,1,'2020-01-01 01:01:01'),(153,20221014084130,1,'2020-01-01 01:01:01'),(154,20221027085019,1,'2020-01-01 01:01:01'),(155,20221101103952,1,'2020-01-01 01:01:01'),(156,20221104144401,1,'2020-01-01 01:01:01'),(157,20221109100749,1,'2020-01-01 01:01:01'),(158,20221115104546,1,'2020-01-01 01:01:01'),(159,20221130114928,1,'2020-01-01 01:01:01'),(160,20221205112142,1,'2020-01-01 01:01:01'),(161,20221216115820,1,'2020-01-01 01:01:01'),(162,20221220195934,1,'2020-01-01 01:01:01'),(163,20221220195935,1,'2020-01-01 01:01:01'),(164,20221223174807,1,'2020-01-01 01:01:01'),(165,20221227163855,1,'2020-01-01 01:01:01'),(166,20221227163856,1,'2020-01-01 01:01:01'),(167,20230202224725,1,'2020-01-01 01:01:01'),(168,20230206163608,1,'2020-01-01 01:01:01'),(169,20230214131519,1,'2020-01-01 01:01:01'),(170,20230303135738,1,'2020-01-01 01:01:01'),(171,20230313135301,1,'2020-01-01 01:01:01'),(172,20230313141819,1,'2020-01-01 01:01:01'),(173,20230315104937,1,'2020-01-01 01:01:01'),(174,20230317173844,1,'2020-01-01 01:01:01'),(175,20230320133602,1,'2020-01-01 01:01:01'),(176,20230330100011,1,'2020-01-01 01:01:01'),(177,20230330134823,1,'2020-01-01 01:01:01'),(178,20230405232025,1,'2020-01-01 01:01:01'),(179,20230408084104,1,'2020-01-01 01:01:01'),(180,20230411102858,1,'2020-01-01 01:01:01'),(181,20230421155932,1,'2020-01-01 01:01:01'),(182,20230425082126,1,'2020-01-01 01:01:01'),(183,20230425105727,1,'2020-01-01 01:01:01'),(184,20230501154913,1,'2020-01-01 01:01:01'),(185,20230503101418,1,'2020-01-01 01:01:01'),(186,20230515144206,1,'2020-01-01 01:01:01'),(187,20230517140952,1,'2020-01-01 01:01:01'),(188,20230517152807,1,'2020-01-01 01:01:01'),(189,20230518114155,1,'2020-01-01 01:01:01'),(190,20230520153236,1,'2020-01-01 01:01:01'),(191,20230525151159,1,'2020-01-01 01:01:01'),(192,20230530122103,1,'2020-01-01 01:01:01'),(193,20230602111827,1,'2020-01-01 01:01:01'),(194,20230608103123,1,'2020-01-01 01:01:01'),(195,20230629140529,1,'2020-01-01 01:01:01'),(196,20230629140530,1,'2020-01-01 01:01:01'),(197,20230711144622,1,'2020-01-01 01:01:01'),(198,20230721135421,1,'2020-01-01 01:01:01'),(199,20230721161508,1,'2020-01-01 01:01:01'),(200,20230726115701,1,'2020-01-01 01:01:01'),(201,20230807100822,1,'2020-01-01 01:01:01'),(202,20230814150442,1,'2020-01-01 01:01:01');
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8 */;
CREATE TABLE `mobile_device_management_solutions` (
diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go
index 09a4991ed6..524af371a8 100644
--- a/server/fleet/datastore.go
+++ b/server/fleet/datastore.go
@@ -1009,6 +1009,25 @@ type Datastore interface {
MDMWindowsInsertEnrolledDevice(ctx context.Context, device *MDMWindowsEnrolledDevice) error
// MDMWindowsDeleteEnrolledDevice deletes a give MDMWindowsEnrolledDevice entry from the database using the device id.
MDMWindowsDeleteEnrolledDevice(ctx context.Context, mdmDeviceID string) error
+
+ ///////////////////////////////////////////////////////////////////////////////
+ // Host Script Results
+
+ // NewHostScriptExecutionRequest creates a new host script result entry with
+ // just the script to run information (result is not yet available).
+ NewHostScriptExecutionRequest(ctx context.Context, request *HostScriptRequestPayload) (*HostScriptResult, error)
+ // SetHostScriptExecutionResult stores the result of a host script execution.
+ SetHostScriptExecutionResult(ctx context.Context, result *HostScriptResultPayload) error
+ // GetHostScriptExecutionResult returns the result of a host script
+ // execution. It returns the host script results even if no results have been
+ // received, it is the caller's responsibility to check if that was the case
+ // (with ExitCode being null).
+ GetHostScriptExecutionResult(ctx context.Context, execID string) (*HostScriptResult, error)
+ // ListPendingHostScriptExecutions returns all the pending host script
+ // executions, which are those that have yet to record a result. Entries
+ // older than the ignoreOlder duration are ignored, considered too old to be
+ // pending.
+ ListPendingHostScriptExecutions(ctx context.Context, hostID uint, ignoreOlder time.Duration) ([]*HostScriptResult, error)
}
const (
diff --git a/server/fleet/errors.go b/server/fleet/errors.go
index be0283a0c3..25ea21fe31 100644
--- a/server/fleet/errors.go
+++ b/server/fleet/errors.go
@@ -311,31 +311,43 @@ func (e *MDMNotConfiguredError) Error() string {
return "MDM features aren't turned on in Fleet. For more information about setting up MDM, please visit https://fleetdm.com/docs/using-fleet/mobile-device-management"
}
-// BadGatewayError is an error type that generates a 502 status code.
-type BadGatewayError struct {
+// GatewayError is an error type that generates a 502 or 504 status code.
+type GatewayError struct {
Message string
err error
+ code int
ErrorWithUUID
}
-// NewBadGatewayError returns a MDMBadGatewayError with the message and
-// error specified.
-func NewBadGatewayError(message string, err error) *BadGatewayError {
- return &BadGatewayError{
+// NewBadGatewayError returns a GatewayError with the message and
+// error specified and that returns a 502 status code.
+func NewBadGatewayError(message string, err error) *GatewayError {
+ return &GatewayError{
Message: message,
err: err,
+ code: http.StatusBadGateway,
+ }
+}
+
+// NewGatewayTimeoutError returns a GatewayError with the message and
+// error specified and that returns a 504 status code.
+func NewGatewayTimeoutError(message string, err error) *GatewayError {
+ return &GatewayError{
+ Message: message,
+ err: err,
+ code: http.StatusGatewayTimeout,
}
}
// StatusCode implements the kithttp.StatusCoder interface so we can customize the
// HTTP status code of the response returning this error.
-func (e *BadGatewayError) StatusCode() int {
- return http.StatusBadGateway
+func (e *GatewayError) StatusCode() int {
+ return e.code
}
// Error returns the error message.
-func (e *BadGatewayError) Error() string {
+func (e *GatewayError) Error() string {
msg := e.Message
if e.err != nil {
msg += ": " + e.err.Error()
diff --git a/server/fleet/hosts.go b/server/fleet/hosts.go
index 37a26b1a3e..3b57ad378b 100644
--- a/server/fleet/hosts.go
+++ b/server/fleet/hosts.go
@@ -2,6 +2,7 @@ package fleet
import (
"context"
+ "database/sql"
"encoding/json"
"errors"
"fmt"
@@ -1071,3 +1072,47 @@ type HostMacOSProfile struct {
// InstallDate is the date the profile was installed on the host as reported by the host's clock.
InstallDate time.Time `json:"install_date" db:"install_date"`
}
+
+type HostScriptRequestPayload struct {
+ HostID uint `json:"host_id"`
+ ScriptContents string `json:"script_contents"`
+}
+
+type HostScriptResultPayload struct {
+ HostID uint `json:"host_id"`
+ ExecutionID string `json:"execution_id"`
+ Output string `json:"output"`
+ Runtime int `json:"runtime"`
+ ExitCode int `json:"exit_code"`
+}
+
+// HostScriptResult represents a script result that was requested to execute on
+// a specific host. If no result was received yet for a script, the ExitCode
+// field is null and the output is empty.
+type HostScriptResult struct {
+ // ID is the unique row identifier of the host script result.
+ ID uint `json:"-" db:"id"`
+ // HostID is the host on which the script was executed.
+ HostID uint `json:"host_id" db:"host_id"`
+ // ExecutionID is a unique identifier for a single execution of the script.
+ ExecutionID string `json:"execution_id" db:"execution_id"`
+ // ScriptContents is the content of the script to execute.
+ ScriptContents string `json:"script_contents" db:"script_contents"`
+ // Output is the combined stdout/stderr output of the script. It is empty
+ // if no result was received yet.
+ Output string `json:"output" db:"output"`
+ // Runtime is the running time of the script in seconds, rounded.
+ Runtime int `json:"runtime" db:"runtime"`
+ // ExitCode is null if script execution result was never received from the
+ // host. It is -1 if it was received but the script did not terminate
+ // normally (same as how Go handles this: https://pkg.go.dev/os#ProcessState.ExitCode)
+ ExitCode sql.NullInt64 `json:"exit_code" db:"exit_code"`
+
+ // TeamID is only used for authorization, it must be set to the team id of
+ // the host when checking authorization and is otherwise not set.
+ TeamID *uint `json:"team_id" db:"-"`
+}
+
+func (hsr HostScriptResult) AuthzType() string {
+ return "host_script_result"
+}
diff --git a/server/fleet/service.go b/server/fleet/service.go
index a29e86983a..478b72bad3 100644
--- a/server/fleet/service.go
+++ b/server/fleet/service.go
@@ -787,4 +787,12 @@ type Service interface {
// GetMDMWindowsTOSContent returns TOS content
GetMDMWindowsTOSContent(ctx context.Context, redirectUri string, reqID string) (string, error)
+
+ ///////////////////////////////////////////////////////////////////////////////
+ // Host Script Execution
+
+ // RunHostScript executes a script on a host and optionally waits for the
+ // result if waitForResult is > 0. If it times out waiting for a result, it
+ // fails with a 504 Gateway Timeout error.
+ RunHostScript(ctx context.Context, request *HostScriptRequestPayload, waitForResult time.Duration) (*HostScriptResult, error)
}
diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go
index 5cecaab6ae..95ffe973e3 100644
--- a/server/mock/datastore_mock.go
+++ b/server/mock/datastore_mock.go
@@ -664,6 +664,14 @@ type MDMWindowsInsertEnrolledDeviceFunc func(ctx context.Context, device *fleet.
type MDMWindowsDeleteEnrolledDeviceFunc func(ctx context.Context, mdmDeviceID string) error
+type NewHostScriptExecutionRequestFunc func(ctx context.Context, request *fleet.HostScriptRequestPayload) (*fleet.HostScriptResult, error)
+
+type SetHostScriptExecutionResultFunc func(ctx context.Context, result *fleet.HostScriptResultPayload) error
+
+type GetHostScriptExecutionResultFunc func(ctx context.Context, execID string) (*fleet.HostScriptResult, error)
+
+type ListPendingHostScriptExecutionsFunc func(ctx context.Context, hostID uint, ignoreOlder time.Duration) ([]*fleet.HostScriptResult, error)
+
type DataStore struct {
HealthCheckFunc HealthCheckFunc
HealthCheckFuncInvoked bool
@@ -1634,6 +1642,18 @@ type DataStore struct {
MDMWindowsDeleteEnrolledDeviceFunc MDMWindowsDeleteEnrolledDeviceFunc
MDMWindowsDeleteEnrolledDeviceFuncInvoked bool
+ NewHostScriptExecutionRequestFunc NewHostScriptExecutionRequestFunc
+ NewHostScriptExecutionRequestFuncInvoked bool
+
+ SetHostScriptExecutionResultFunc SetHostScriptExecutionResultFunc
+ SetHostScriptExecutionResultFuncInvoked bool
+
+ GetHostScriptExecutionResultFunc GetHostScriptExecutionResultFunc
+ GetHostScriptExecutionResultFuncInvoked bool
+
+ ListPendingHostScriptExecutionsFunc ListPendingHostScriptExecutionsFunc
+ ListPendingHostScriptExecutionsFuncInvoked bool
+
mu sync.Mutex
}
@@ -3897,3 +3917,31 @@ func (s *DataStore) MDMWindowsDeleteEnrolledDevice(ctx context.Context, mdmDevic
s.mu.Unlock()
return s.MDMWindowsDeleteEnrolledDeviceFunc(ctx, mdmDeviceID)
}
+
+func (s *DataStore) NewHostScriptExecutionRequest(ctx context.Context, request *fleet.HostScriptRequestPayload) (*fleet.HostScriptResult, error) {
+ s.mu.Lock()
+ s.NewHostScriptExecutionRequestFuncInvoked = true
+ s.mu.Unlock()
+ return s.NewHostScriptExecutionRequestFunc(ctx, request)
+}
+
+func (s *DataStore) SetHostScriptExecutionResult(ctx context.Context, result *fleet.HostScriptResultPayload) error {
+ s.mu.Lock()
+ s.SetHostScriptExecutionResultFuncInvoked = true
+ s.mu.Unlock()
+ return s.SetHostScriptExecutionResultFunc(ctx, result)
+}
+
+func (s *DataStore) GetHostScriptExecutionResult(ctx context.Context, execID string) (*fleet.HostScriptResult, error) {
+ s.mu.Lock()
+ s.GetHostScriptExecutionResultFuncInvoked = true
+ s.mu.Unlock()
+ return s.GetHostScriptExecutionResultFunc(ctx, execID)
+}
+
+func (s *DataStore) ListPendingHostScriptExecutions(ctx context.Context, hostID uint, ignoreOlder time.Duration) ([]*fleet.HostScriptResult, error) {
+ s.mu.Lock()
+ s.ListPendingHostScriptExecutionsFuncInvoked = true
+ s.mu.Unlock()
+ return s.ListPendingHostScriptExecutionsFunc(ctx, hostID, ignoreOlder)
+}
diff --git a/server/service/handler.go b/server/service/handler.go
index e278618de6..285a9db430 100644
--- a/server/service/handler.go
+++ b/server/service/handler.go
@@ -439,6 +439,9 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC
ue.GET("/api/_version_/fleet/status/result_store", statusResultStoreEndpoint, nil)
ue.GET("/api/_version_/fleet/status/live_query", statusLiveQueryEndpoint, nil)
+ ue.POST("/api/_version_/fleet/scripts/run", runScriptEndpoint, runScriptRequest{})
+ ue.POST("/api/_version_/fleet/scripts/run/sync", runScriptSyncEndpoint, runScriptRequest{})
+
// Only Fleet MDM specific endpoints should be within the root /mdm/ path.
// NOTE: remember to update
// `service.mdmAppleConfigurationRequiredEndpoints` when you add an
diff --git a/server/service/hosts.go b/server/service/hosts.go
index 93d30e0da8..49072b9c78 100644
--- a/server/service/hosts.go
+++ b/server/service/hosts.go
@@ -5,6 +5,7 @@ import (
"context"
"encoding/csv"
"encoding/json"
+ "errors"
"fmt"
"io"
"net/http"
@@ -1588,3 +1589,99 @@ func (svc *Service) HostEncryptionKey(ctx context.Context, id uint) (*fleet.Host
return key, nil
}
+
+////////////////////////////////////////////////////////////////////////////////
+// Run Script on a Host (async)
+////////////////////////////////////////////////////////////////////////////////
+
+type runScriptRequest struct {
+ HostID uint `json:"host_id"`
+ ScriptContents string `json:"script_contents"`
+}
+
+type runScriptResponse struct {
+ Err error `json:"error,omitempty"`
+ HostID uint `json:"host_id,omitempty"`
+ ExecutionID string `json:"execution_id,omitempty"`
+}
+
+func (r runScriptResponse) error() error { return r.Err }
+func (r runScriptResponse) Status() int { return http.StatusAccepted }
+
+func runScriptEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) {
+ req := request.(*runScriptRequest)
+
+ var noWait time.Duration
+ result, err := svc.RunHostScript(ctx, &fleet.HostScriptRequestPayload{
+ HostID: req.HostID,
+ ScriptContents: req.ScriptContents,
+ }, noWait)
+ if err != nil {
+ return runScriptResponse{Err: err}, nil
+ }
+ return runScriptResponse{HostID: result.HostID, ExecutionID: result.ExecutionID}, nil
+}
+
+////////////////////////////////////////////////////////////////////////////////
+// Run Script on a Host (sync)
+////////////////////////////////////////////////////////////////////////////////
+
+type runScriptSyncResponse struct {
+ Err error `json:"error,omitempty"`
+ *fleet.HostScriptResult
+
+ // only set if the error was a timeout waiting for a result
+ ErrorMessage string `json:"error_message,omitempty"`
+}
+
+func (r runScriptSyncResponse) error() error { return r.Err }
+func (r runScriptSyncResponse) Status() int {
+ if r.ErrorMessage != "" {
+ return http.StatusGatewayTimeout
+ }
+ return http.StatusOK
+}
+
+// this is to be used only by tests, to be able to use a shorter timeout.
+var testRunScriptWaitForResult time.Duration
+
+func runScriptSyncEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) {
+ waitForResult := time.Minute
+ if testRunScriptWaitForResult != 0 {
+ waitForResult = testRunScriptWaitForResult
+ }
+
+ req := request.(*runScriptRequest)
+ result, err := svc.RunHostScript(ctx, &fleet.HostScriptRequestPayload{
+ HostID: req.HostID,
+ ScriptContents: req.ScriptContents,
+ }, waitForResult)
+ if err != nil {
+ if errors.Is(err, context.DeadlineExceeded) {
+ err = fleet.NewGatewayTimeoutError("script execution timed out waiting for a result", err)
+ // it should still return the execution id and host id in this situation,
+ // so the user knows what script request to look at in the UI. We cannot
+ // return an error (field Err) in this case, as the errorer interface's
+ // rendering logic would take over and only render the error part of the
+ // response struct. This is why we use the distinct ErrorMessage field to
+ // add the error message and status code to the response, along with the
+ // script request.
+ return runScriptSyncResponse{
+ HostScriptResult: result,
+ ErrorMessage: err.Error(),
+ }, nil
+ }
+ return runScriptSyncResponse{Err: err}, nil
+ }
+ return runScriptSyncResponse{
+ HostScriptResult: result,
+ }, nil
+}
+
+func (svc *Service) RunHostScript(ctx context.Context, request *fleet.HostScriptRequestPayload, waitForResult time.Duration) (*fleet.HostScriptResult, error) {
+ // skipauth: No authorization check needed due to implementation returning
+ // only license error.
+ svc.authz.SkipAuthorization(ctx)
+
+ return nil, fleet.ErrMissingLicense
+}
diff --git a/server/service/hosts_test.go b/server/service/hosts_test.go
index c32a0d234e..cecf167e4e 100644
--- a/server/service/hosts_test.go
+++ b/server/service/hosts_test.go
@@ -1080,3 +1080,155 @@ func TestHostMDMProfileDetail(t *testing.T) {
})
}
}
+
+func TestHostRunScript(t *testing.T) {
+ ds := new(mock.Store)
+ license := &fleet.LicenseInfo{Tier: fleet.TierPremium, Expiration: time.Now().Add(24 * time.Hour)}
+ svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{License: license, SkipCreateTestUsers: true})
+
+ // use a custom implementation of checkAuthErr as the service call will fail
+ // with a not found error for unknown host in case of authorization success,
+ // and the package-wide checkAuthErr requires no error.
+ checkAuthErr := func(t *testing.T, shouldFail bool, err error) {
+ if shouldFail {
+ require.Error(t, err)
+ require.Equal(t, (&authz.Forbidden{}).Error(), err.Error())
+ } else if err != nil {
+ require.NotEqual(t, (&authz.Forbidden{}).Error(), err.Error())
+ }
+ }
+
+ teamHost := &fleet.Host{ID: 1, Hostname: "host-team", TeamID: ptr.Uint(1)}
+ noTeamHost := &fleet.Host{ID: 2, Hostname: "host-no-team", TeamID: nil}
+ nonExistingHost := &fleet.Host{ID: 3, Hostname: "no-such-host", TeamID: nil}
+ ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
+ return &fleet.AppConfig{}, nil
+ }
+ ds.HostLiteFunc = func(ctx context.Context, hostID uint) (*fleet.Host, error) {
+ if hostID == 1 {
+ return teamHost, nil
+ }
+ if hostID == 2 {
+ return noTeamHost, nil
+ }
+ return nil, newNotFoundError()
+ }
+ ds.NewHostScriptExecutionRequestFunc = func(ctx context.Context, request *fleet.HostScriptRequestPayload) (*fleet.HostScriptResult, error) {
+ return &fleet.HostScriptResult{HostID: request.HostID, ScriptContents: request.ScriptContents, ExecutionID: "abc"}, nil
+ }
+ ds.ListPendingHostScriptExecutionsFunc = func(ctx context.Context, hostID uint, ignoreOlder time.Duration) ([]*fleet.HostScriptResult, error) {
+ return nil, nil
+ }
+
+ testCases := []struct {
+ name string
+ user *fleet.User
+ shouldFailTeamWrite bool
+ shouldFailGlobalWrite bool
+ }{
+ {
+ name: "global admin",
+ user: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)},
+ shouldFailTeamWrite: false,
+ shouldFailGlobalWrite: false,
+ },
+ {
+ name: "global maintainer",
+ user: &fleet.User{GlobalRole: ptr.String(fleet.RoleMaintainer)},
+ shouldFailTeamWrite: false,
+ shouldFailGlobalWrite: false,
+ },
+ {
+ name: "global observer",
+ user: &fleet.User{GlobalRole: ptr.String(fleet.RoleObserver)},
+ shouldFailTeamWrite: true,
+ shouldFailGlobalWrite: true,
+ },
+ {
+ name: "global observer+",
+ user: &fleet.User{GlobalRole: ptr.String(fleet.RoleObserverPlus)},
+ shouldFailTeamWrite: true,
+ shouldFailGlobalWrite: true,
+ },
+ {
+ name: "global gitops",
+ user: &fleet.User{GlobalRole: ptr.String(fleet.RoleGitOps)},
+ shouldFailTeamWrite: true,
+ shouldFailGlobalWrite: true,
+ },
+ {
+ name: "team admin, belongs to team",
+ user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleAdmin}}},
+ shouldFailTeamWrite: false,
+ shouldFailGlobalWrite: true,
+ },
+ {
+ name: "team maintainer, belongs to team",
+ user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleMaintainer}}},
+ shouldFailTeamWrite: false,
+ shouldFailGlobalWrite: true,
+ },
+ {
+ name: "team observer, belongs to team",
+ user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleObserver}}},
+ shouldFailTeamWrite: true,
+ shouldFailGlobalWrite: true,
+ },
+ {
+ name: "team observer+, belongs to team",
+ user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleObserverPlus}}},
+ shouldFailTeamWrite: true,
+ shouldFailGlobalWrite: true,
+ },
+ {
+ name: "team gitops, belongs to team",
+ user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleGitOps}}},
+ shouldFailTeamWrite: true,
+ shouldFailGlobalWrite: true,
+ },
+ {
+ name: "team admin, DOES NOT belong to team",
+ user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleAdmin}}},
+ shouldFailTeamWrite: true,
+ shouldFailGlobalWrite: true,
+ },
+ {
+ name: "team maintainer, DOES NOT belong to team",
+ user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleMaintainer}}},
+ shouldFailTeamWrite: true,
+ shouldFailGlobalWrite: true,
+ },
+ {
+ name: "team observer, DOES NOT belong to team",
+ user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleObserver}}},
+ shouldFailTeamWrite: true,
+ shouldFailGlobalWrite: true,
+ },
+ {
+ name: "team observer+, DOES NOT belong to team",
+ user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleObserverPlus}}},
+ shouldFailTeamWrite: true,
+ shouldFailGlobalWrite: true,
+ },
+ {
+ name: "team gitops, DOES NOT belong to team",
+ user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleGitOps}}},
+ shouldFailTeamWrite: true,
+ shouldFailGlobalWrite: true,
+ },
+ }
+ for _, tt := range testCases {
+ t.Run(tt.name, func(t *testing.T) {
+ ctx = viewer.NewContext(ctx, viewer.Viewer{User: tt.user})
+
+ _, err := svc.RunHostScript(ctx, &fleet.HostScriptRequestPayload{HostID: noTeamHost.ID, ScriptContents: "abc"}, 0)
+ checkAuthErr(t, tt.shouldFailGlobalWrite, err)
+ _, err = svc.RunHostScript(ctx, &fleet.HostScriptRequestPayload{HostID: teamHost.ID, ScriptContents: "abc"}, 0)
+ checkAuthErr(t, tt.shouldFailTeamWrite, err)
+
+ // a non-existing host is authorized as for global write (because we can't know what team it belongs to)
+ _, err = svc.RunHostScript(ctx, &fleet.HostScriptRequestPayload{HostID: nonExistingHost.ID, ScriptContents: "abc"}, 0)
+ checkAuthErr(t, tt.shouldFailGlobalWrite, err)
+ })
+ }
+}
diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go
index 430bebfe64..7102fbd316 100644
--- a/server/service/integration_core_test.go
+++ b/server/service/integration_core_test.go
@@ -4579,6 +4579,13 @@ func (s *integrationTestSuite) TestPremiumEndpointsWithoutLicense() {
// device migrate mdm endpoint returns an error if not premium
createHostAndDeviceToken(t, s.ds, "some-token")
s.Do("POST", fmt.Sprintf("/api/v1/fleet/device/%s/migrate_mdm", "some-token"), nil, http.StatusPaymentRequired)
+
+ // run a script
+ var runResp runScriptResponse
+ s.DoJSON("POST", "/api/latest/fleet/scripts/run", fleet.HostScriptRequestPayload{HostID: 1}, http.StatusPaymentRequired, &runResp)
+
+ // run a script sync
+ s.DoJSON("POST", "/api/latest/fleet/scripts/run/sync", fleet.HostScriptRequestPayload{HostID: 1}, http.StatusPaymentRequired, &runResp)
}
// TestGlobalPoliciesBrowsing tests that team users can browse (read) global policies (see #3722).
diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go
index cde0d77ca1..058ff9d768 100644
--- a/server/service/integration_enterprise_test.go
+++ b/server/service/integration_enterprise_test.go
@@ -3651,3 +3651,123 @@ func (s *integrationEnterpriseTestSuite) TestDesktopEndpointWithInvalidPolicy()
require.NoError(t, desktopRes.Err)
require.Equal(t, uint(0), *desktopRes.FailingPolicies)
}
+
+func (s *integrationEnterpriseTestSuite) TestRunHostScript() {
+ t := s.T()
+
+ testRunScriptWaitForResult = 2 * time.Second
+ defer func() { testRunScriptWaitForResult = 0 }()
+
+ ctx := context.Background()
+
+ host := createOrbitEnrolledHost(t, "linux", "", s.ds)
+
+ // attempt to run a script on a non-existing host
+ var runResp runScriptResponse
+ s.DoJSON("POST", "/api/latest/fleet/scripts/run", fleet.HostScriptRequestPayload{HostID: host.ID + 100, ScriptContents: "echo"}, http.StatusNotFound, &runResp)
+
+ // attempt to run an empty script
+ res := s.Do("POST", "/api/latest/fleet/scripts/run", fleet.HostScriptRequestPayload{HostID: host.ID, ScriptContents: ""}, http.StatusUnprocessableEntity)
+ errMsg := extractServerErrorText(res.Body)
+ require.Contains(t, errMsg, "a script to execute is required")
+
+ // attempt to run an overly long script
+ res = s.Do("POST", "/api/latest/fleet/scripts/run", fleet.HostScriptRequestPayload{HostID: host.ID, ScriptContents: strings.Repeat("a", 10001)}, http.StatusUnprocessableEntity)
+ errMsg = extractServerErrorText(res.Body)
+ require.Contains(t, errMsg, "script is too long")
+
+ // create a valid script execution request
+ s.DoJSON("POST", "/api/latest/fleet/scripts/run", fleet.HostScriptRequestPayload{HostID: host.ID, ScriptContents: "echo"}, http.StatusAccepted, &runResp)
+ require.Equal(t, host.ID, runResp.HostID)
+ require.NotEmpty(t, runResp.ExecutionID)
+
+ result, err := s.ds.GetHostScriptExecutionResult(ctx, runResp.ExecutionID)
+ require.NoError(t, err)
+ require.Equal(t, host.ID, result.HostID)
+ require.Equal(t, "echo", result.ScriptContents)
+ require.False(t, result.ExitCode.Valid)
+
+ // attempt to run a sync script on a non-existing host
+ var runSyncResp runScriptSyncResponse
+ s.DoJSON("POST", "/api/latest/fleet/scripts/run/sync", fleet.HostScriptRequestPayload{HostID: host.ID + 100, ScriptContents: "echo"}, http.StatusNotFound, &runSyncResp)
+
+ // attempt to sync run an empty script
+ res = s.Do("POST", "/api/latest/fleet/scripts/run/sync", fleet.HostScriptRequestPayload{HostID: host.ID, ScriptContents: ""}, http.StatusUnprocessableEntity)
+ errMsg = extractServerErrorText(res.Body)
+ require.Contains(t, errMsg, "a script to execute is required")
+
+ // attempt to sync run an overly long script
+ res = s.Do("POST", "/api/latest/fleet/scripts/run/sync", fleet.HostScriptRequestPayload{HostID: host.ID, ScriptContents: strings.Repeat("a", 10001)}, http.StatusUnprocessableEntity)
+ errMsg = extractServerErrorText(res.Body)
+ require.Contains(t, errMsg, "script is too long")
+
+ // attempt to create a valid sync script execution request, fails because the
+ // host has a pending script execution
+ res = s.Do("POST", "/api/latest/fleet/scripts/run/sync", fleet.HostScriptRequestPayload{HostID: host.ID, ScriptContents: "echo"}, http.StatusUnprocessableEntity)
+ errMsg = extractServerErrorText(res.Body)
+ require.Contains(t, errMsg, "a script is currently executing on the host")
+
+ // simulate a result being returned for the pending script
+ err = s.ds.SetHostScriptExecutionResult(ctx, &fleet.HostScriptResultPayload{
+ HostID: host.ID,
+ ExecutionID: runResp.ExecutionID,
+ ExitCode: 0,
+ Output: "ok",
+ })
+ require.NoError(t, err)
+
+ // create a valid sync script execution request, fails because the
+ // request will time-out waiting for a result.
+ runSyncResp = runScriptSyncResponse{}
+ s.DoJSON("POST", "/api/latest/fleet/scripts/run/sync", fleet.HostScriptRequestPayload{HostID: host.ID, ScriptContents: "echo"}, http.StatusGatewayTimeout, &runSyncResp)
+ require.Equal(t, host.ID, runSyncResp.HostID)
+ require.NotEmpty(t, runSyncResp.ExecutionID)
+ require.Contains(t, runSyncResp.ErrorMessage, "script execution timed out waiting for a result")
+
+ // simulate a result being returned for that pending script
+ err = s.ds.SetHostScriptExecutionResult(ctx, &fleet.HostScriptResultPayload{
+ HostID: host.ID,
+ ExecutionID: runSyncResp.ExecutionID,
+ ExitCode: 0,
+ Output: "ok",
+ })
+ require.NoError(t, err)
+
+ // create a valid sync script execution request, and simulate a result
+ // arriving before timeout.
+ testRunScriptWaitForResult = 5 * time.Second
+ ctx, cancel := context.WithTimeout(ctx, testRunScriptWaitForResult)
+ defer cancel()
+
+ go func() {
+ for range time.Tick(300 * time.Millisecond) {
+ pending, err := s.ds.ListPendingHostScriptExecutions(ctx, host.ID, 10*time.Second)
+ if err != nil {
+ t.Log(err)
+ return
+ }
+ if len(pending) > 0 {
+ // ignoring errors in this goroutine, the HTTP request below will fail if this fails
+ err = s.ds.SetHostScriptExecutionResult(ctx, &fleet.HostScriptResultPayload{
+ HostID: host.ID,
+ ExecutionID: pending[0].ExecutionID,
+ Output: "ok",
+ Runtime: 1,
+ ExitCode: 0,
+ })
+ if err != nil {
+ t.Log(err)
+ }
+ return
+ }
+ }
+ }()
+
+ runSyncResp = runScriptSyncResponse{}
+ s.DoJSON("POST", "/api/latest/fleet/scripts/run/sync", fleet.HostScriptRequestPayload{HostID: host.ID, ScriptContents: "echo"}, http.StatusOK, &runSyncResp)
+ require.Equal(t, host.ID, runSyncResp.HostID)
+ require.NotEmpty(t, runSyncResp.ExecutionID)
+ require.Equal(t, "ok", runSyncResp.Output)
+ require.Equal(t, int64(0), runSyncResp.ExitCode.Int64)
+ require.Empty(t, runSyncResp.ErrorMessage)
+}
diff --git a/server/service/integration_mdm_test.go b/server/service/integration_mdm_test.go
index 204dcee976..46669e8d20 100644
--- a/server/service/integration_mdm_test.go
+++ b/server/service/integration_mdm_test.go
@@ -1261,6 +1261,7 @@ func createHostThenEnrollMDM(ds fleet.Datastore, fleetServerURL string, t *testi
func (s *integrationMDMTestSuite) TestDEPProfileAssignment() {
t := s.T()
+
ctx := context.Background()
devices := []godep.Device{
{SerialNumber: uuid.New().String(), Model: "MacBook Pro", OS: "osx", OpType: "added"},