mirror of
https://github.com/fleetdm/fleet
synced 2026-05-24 09:28:54 +00:00
Queued scripts feature (#16300)
This is the feature branch for the [queued scripts](https://github.com/fleetdm/fleet/issues/15529) story. --------- Co-authored-by: Jahziel Villasana-Espinoza <jahziel@fleetdm.com> Co-authored-by: Gabriel Hernandez <ghernandez345@gmail.com> Co-authored-by: Sarah Gillespie <73313222+gillespi314@users.noreply.github.com> Co-authored-by: Roberto Dip <dip.jesusr@gmail.com>
This commit is contained in:
parent
e52dc6d8fc
commit
ca435eb244
88 changed files with 3218 additions and 1151 deletions
1
changes/15957-queued-scripts-db-changes
Normal file
1
changes/15957-queued-scripts-db-changes
Normal file
|
|
@ -0,0 +1 @@
|
|||
* Added database migration to record the user that requests a script execution and to create the `host_activities` table to associate activities to specific hosts.
|
||||
2
changes/15959-run-script-modal
Normal file
2
changes/15959-run-script-modal
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
- Added "Run script" action to host details page, which relocates functionality from the "Scripts"
|
||||
tab into a new modal UI.
|
||||
1
changes/15960-queued-scripts-create-host-activity
Normal file
1
changes/15960-queued-scripts-create-host-activity
Normal file
|
|
@ -0,0 +1 @@
|
|||
* Created the "script ran" activity linked to its host so the script executions can be listed per host.
|
||||
1
changes/issue-15959-add-host-details-activity
Normal file
1
changes/issue-15959-add-host-details-activity
Normal file
|
|
@ -0,0 +1 @@
|
|||
- add UI for host details activity card
|
||||
1
changes/issue-15959-make-scripts-free
Normal file
1
changes/issue-15959-make-scripts-free
Normal file
|
|
@ -0,0 +1 @@
|
|||
- removes the premium tier check for scripts feature on the controls page.
|
||||
2
changes/mna-15958-queued-scripts-api
Normal file
2
changes/mna-15958-queued-scripts-api
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
- Adds 2 new scripts related endpoints (`/hosts/:id/activity` and `/hosts/:id/activity/upcoming`) as
|
||||
well as validation and functionality changes for enqueuing scripts.
|
||||
|
|
@ -42,10 +42,6 @@ func TestRunScriptCommand(t *testing.T) {
|
|||
ds.ListHostBatteriesFunc = func(ctx context.Context, hid uint) ([]*fleet.HostBattery, error) {
|
||||
return nil, nil
|
||||
}
|
||||
ds.NewActivityFunc = func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails) error {
|
||||
require.IsType(t, fleet.ActivityTypeRanScript{}, activity)
|
||||
return nil
|
||||
}
|
||||
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
|
||||
return &fleet.AppConfig{ServerSettings: fleet.ServerSettings{ScriptsDisabled: false}}, nil
|
||||
}
|
||||
|
|
@ -231,7 +227,7 @@ Fleet records the last 10,000 characters to prevent downtime.
|
|||
}
|
||||
return &h, nil
|
||||
}
|
||||
ds.ListPendingHostScriptExecutionsFunc = func(ctx context.Context, hid uint, maxAge time.Duration) ([]*fleet.HostScriptResult, error) {
|
||||
ds.ListPendingHostScriptExecutionsFunc = func(ctx context.Context, hid uint) ([]*fleet.HostScriptResult, error) {
|
||||
require.Equal(t, uint(42), hid)
|
||||
if c.expectPending {
|
||||
return []*fleet.HostScriptResult{{HostID: uint(42)}}, nil
|
||||
|
|
|
|||
|
|
@ -1,44 +0,0 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
|
||||
hostctx "github.com/fleetdm/fleet/v4/server/contexts/host"
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
)
|
||||
|
||||
func (svc *Service) GetHostScript(ctx context.Context, execID string) (*fleet.HostScriptResult, error) {
|
||||
// this is not a user-authenticated endpoint
|
||||
svc.authz.SkipAuthorization(ctx)
|
||||
|
||||
host, ok := hostctx.FromContext(ctx)
|
||||
if !ok {
|
||||
return nil, fleet.OrbitError{Message: "internal error: missing host from request context"}
|
||||
}
|
||||
|
||||
// get the script's details
|
||||
script, err := svc.ds.GetHostScriptExecutionResult(ctx, execID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// ensure it cannot get access to a different host's script
|
||||
if script.HostID != host.ID {
|
||||
return nil, ctxerr.Wrap(ctx, notFoundError{}, "no script found for this host")
|
||||
}
|
||||
return script, nil
|
||||
}
|
||||
|
||||
func (svc *Service) SaveHostScriptResult(ctx context.Context, result *fleet.HostScriptResultPayload) error {
|
||||
// this is not a user-authenticated endpoint
|
||||
svc.authz.SkipAuthorization(ctx)
|
||||
|
||||
host, ok := hostctx.FromContext(ctx)
|
||||
if !ok {
|
||||
return fleet.OrbitError{Message: "internal error: missing host from request context"}
|
||||
}
|
||||
|
||||
// always use the authenticated host's ID as host_id
|
||||
result.HostID = host.ID
|
||||
return svc.ds.SetHostScriptExecutionResult(ctx, result)
|
||||
}
|
||||
|
|
@ -1,462 +0,0 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/pkg/scripts"
|
||||
"github.com/fleetdm/fleet/v4/server/authz"
|
||||
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
"github.com/go-kit/kit/log/level"
|
||||
)
|
||||
|
||||
func (svc *Service) RunHostScript(ctx context.Context, request *fleet.HostScriptRequestPayload, waitForResult time.Duration) (*fleet.HostScriptResult, error) {
|
||||
// First check if scripts are disabled globally. If so, no need for further processing.
|
||||
cfg, err := svc.ds.AppConfig(ctx)
|
||||
if err != nil {
|
||||
svc.authz.SkipAuthorization(ctx)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if cfg.ServerSettings.ScriptsDisabled {
|
||||
svc.authz.SkipAuthorization(ctx)
|
||||
return nil, fleet.NewUserMessageError(errors.New(fleet.RunScriptScriptsDisabledGloballyErrMsg), http.StatusForbidden)
|
||||
}
|
||||
|
||||
// must load the host to get the team (cannot use lite, the last seen time is
|
||||
// required to check if it is online) to authorize with the proper team id.
|
||||
// We cannot first authorize if the user can list hosts, in case we
|
||||
// eventually allow a write-only role (e.g. gitops).
|
||||
host, err := svc.ds.Host(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")
|
||||
}
|
||||
|
||||
// must check that only one of script id or contents is provided before
|
||||
// authorization, as the permissions are not the same if a script id is
|
||||
// provided. There's no harm in returning the error if this validation fails,
|
||||
// since both values are user-provided it doesn't leak any internal
|
||||
// information.
|
||||
if request.ScriptID != nil && request.ScriptContents != "" {
|
||||
svc.authz.SkipAuthorization(ctx)
|
||||
return nil, fleet.NewInvalidArgumentError("script_id", `Only one of "script_id" or "script_contents" can be provided.`)
|
||||
}
|
||||
|
||||
// authorize with the host's team and the script id provided, as both affect
|
||||
// the permissions.
|
||||
if err := svc.authz.Authorize(ctx, &fleet.HostScriptResult{TeamID: host.TeamID, ScriptID: request.ScriptID}, fleet.ActionWrite); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if request.ScriptID != nil {
|
||||
script, err := svc.ds.Script(ctx, *request.ScriptID)
|
||||
if err != nil {
|
||||
if fleet.IsNotFound(err) {
|
||||
return nil, fleet.NewInvalidArgumentError("script_id", `No script exists for the provided "script_id".`).
|
||||
WithStatus(http.StatusNotFound)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
var scriptTmID, hostTmID uint
|
||||
if script.TeamID != nil {
|
||||
scriptTmID = *script.TeamID
|
||||
}
|
||||
if host.TeamID != nil {
|
||||
hostTmID = *host.TeamID
|
||||
}
|
||||
if scriptTmID != hostTmID {
|
||||
return nil, fleet.NewInvalidArgumentError("script_id", `The script does not belong to the same team (or no team) as the host.`)
|
||||
}
|
||||
|
||||
contents, err := svc.ds.GetScriptContents(ctx, *request.ScriptID)
|
||||
if err != nil {
|
||||
if fleet.IsNotFound(err) {
|
||||
return nil, fleet.NewInvalidArgumentError("script_id", `No script exists for the provided "script_id".`).
|
||||
WithStatus(http.StatusNotFound)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
request.ScriptContents = string(contents)
|
||||
}
|
||||
|
||||
if err := fleet.ValidateHostScriptContents(request.ScriptContents); err != nil {
|
||||
return nil, fleet.NewInvalidArgumentError("script_contents", err.Error())
|
||||
}
|
||||
|
||||
// host must be online
|
||||
if host.Status(time.Now()) != fleet.StatusOnline {
|
||||
return nil, fleet.NewInvalidArgumentError("host_id", fleet.RunScriptHostOfflineErrMsg)
|
||||
}
|
||||
|
||||
// it is important that the "ignoreOlder" parameter in this call is the same
|
||||
// everywhere (which is here and in the "get orbit config" endpoint to send
|
||||
// the notification of scripts pending execution to the host).
|
||||
pending, err := svc.ds.ListPendingHostScriptExecutions(ctx, request.HostID, scripts.MaxServerWaitTime)
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "list host pending script executions")
|
||||
}
|
||||
if len(pending) > 0 {
|
||||
return nil, fleet.NewInvalidArgumentError(
|
||||
"script_contents", fleet.RunScriptAlreadyRunningErrMsg,
|
||||
).WithStatus(http.StatusConflict)
|
||||
}
|
||||
|
||||
// create the script execution request, the host will be notified of the
|
||||
// script execution request via the orbit config's Notifications mechanism.
|
||||
script, err := svc.ds.NewHostScriptExecutionRequest(ctx, request)
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "create script execution request")
|
||||
}
|
||||
script.Hostname = host.DisplayName()
|
||||
|
||||
asyncExecution := waitForResult <= 0
|
||||
|
||||
err = svc.ds.NewActivity(
|
||||
ctx,
|
||||
authz.UserFromContext(ctx),
|
||||
fleet.ActivityTypeRanScript{
|
||||
HostID: host.ID,
|
||||
HostDisplayName: host.DisplayName(),
|
||||
ScriptExecutionID: script.ExecutionID,
|
||||
Async: asyncExecution,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "create activity for script execution request")
|
||||
}
|
||||
|
||||
if asyncExecution {
|
||||
// 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 != nil {
|
||||
// a result was received from the host, return
|
||||
result.Hostname = host.DisplayName()
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (svc *Service) GetScriptResult(ctx context.Context, execID string) (*fleet.HostScriptResult, error) {
|
||||
scriptResult, err := svc.ds.GetHostScriptExecutionResult(ctx, execID)
|
||||
if err != nil {
|
||||
if fleet.IsNotFound(err) {
|
||||
if err := svc.authz.Authorize(ctx, &fleet.HostScriptResult{}, fleet.ActionRead); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
svc.authz.SkipAuthorization(ctx)
|
||||
return nil, ctxerr.Wrap(ctx, err, "get script result")
|
||||
}
|
||||
|
||||
host, err := svc.ds.HostLite(ctx, scriptResult.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.ActionRead); 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.ActionRead); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
scriptResult.Hostname = host.DisplayName()
|
||||
|
||||
return scriptResult, nil
|
||||
}
|
||||
|
||||
func (svc *Service) NewScript(ctx context.Context, teamID *uint, name string, r io.Reader) (*fleet.Script, error) {
|
||||
if err := svc.authz.Authorize(ctx, &fleet.Script{TeamID: teamID}, fleet.ActionWrite); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
b, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "read script contents")
|
||||
}
|
||||
|
||||
script := &fleet.Script{
|
||||
TeamID: teamID,
|
||||
Name: name,
|
||||
ScriptContents: string(b),
|
||||
}
|
||||
if err := script.Validate(); err != nil {
|
||||
return nil, fleet.NewInvalidArgumentError("script", err.Error())
|
||||
}
|
||||
|
||||
savedScript, err := svc.ds.NewScript(ctx, script)
|
||||
if err != nil {
|
||||
var (
|
||||
existsErr fleet.AlreadyExistsError
|
||||
fkErr fleet.ForeignKeyError
|
||||
)
|
||||
if errors.As(err, &existsErr) {
|
||||
err = fleet.NewInvalidArgumentError("script", "A script with this name already exists.").WithStatus(http.StatusConflict)
|
||||
} else if errors.As(err, &fkErr) {
|
||||
err = fleet.NewInvalidArgumentError("team_id", "The team does not exist.").WithStatus(http.StatusNotFound)
|
||||
}
|
||||
return nil, ctxerr.Wrap(ctx, err, "create script")
|
||||
}
|
||||
|
||||
var teamName *string
|
||||
if teamID != nil && *teamID != 0 {
|
||||
tm, err := svc.teamByIDOrName(ctx, teamID, nil)
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "get team name for create script activity")
|
||||
}
|
||||
teamName = &tm.Name
|
||||
}
|
||||
|
||||
if err := svc.ds.NewActivity(
|
||||
ctx,
|
||||
authz.UserFromContext(ctx),
|
||||
fleet.ActivityTypeAddedScript{
|
||||
TeamID: teamID,
|
||||
TeamName: teamName,
|
||||
ScriptName: script.Name,
|
||||
},
|
||||
); err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "new activity for create script")
|
||||
}
|
||||
|
||||
return savedScript, nil
|
||||
}
|
||||
|
||||
func (svc *Service) DeleteScript(ctx context.Context, scriptID uint) error {
|
||||
script, err := svc.authorizeScriptByID(ctx, scriptID, fleet.ActionWrite)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := svc.ds.DeleteScript(ctx, script.ID); err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "delete script")
|
||||
}
|
||||
|
||||
var teamName *string
|
||||
if script.TeamID != nil && *script.TeamID != 0 {
|
||||
tm, err := svc.teamByIDOrName(ctx, script.TeamID, nil)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "get team name for delete script activity")
|
||||
}
|
||||
teamName = &tm.Name
|
||||
}
|
||||
|
||||
if err := svc.ds.NewActivity(
|
||||
ctx,
|
||||
authz.UserFromContext(ctx),
|
||||
fleet.ActivityTypeDeletedScript{
|
||||
TeamID: script.TeamID,
|
||||
TeamName: teamName,
|
||||
ScriptName: script.Name,
|
||||
},
|
||||
); err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "new activity for delete script")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (svc *Service) ListScripts(ctx context.Context, teamID *uint, opt fleet.ListOptions) ([]*fleet.Script, *fleet.PaginationMetadata, error) {
|
||||
if err := svc.authz.Authorize(ctx, &fleet.Script{TeamID: teamID}, fleet.ActionRead); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// cursor-based pagination is not supported for scripts
|
||||
opt.After = ""
|
||||
// custom ordering is not supported, always by name
|
||||
opt.OrderKey = "name"
|
||||
opt.OrderDirection = fleet.OrderAscending
|
||||
// no matching query support
|
||||
opt.MatchQuery = ""
|
||||
// always include metadata for scripts
|
||||
opt.IncludeMetadata = true
|
||||
|
||||
return svc.ds.ListScripts(ctx, teamID, opt)
|
||||
}
|
||||
|
||||
func (svc *Service) GetScript(ctx context.Context, scriptID uint, withContent bool) (*fleet.Script, []byte, error) {
|
||||
script, err := svc.authorizeScriptByID(ctx, scriptID, fleet.ActionRead)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
var content []byte
|
||||
if withContent {
|
||||
content, err = svc.ds.GetScriptContents(ctx, scriptID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
return script, content, nil
|
||||
}
|
||||
|
||||
func (svc *Service) authorizeScriptByID(ctx context.Context, scriptID uint, authzAction string) (*fleet.Script, error) {
|
||||
// first, get the script because we don't know which team id it is for.
|
||||
script, err := svc.ds.Script(ctx, scriptID)
|
||||
if err != nil {
|
||||
if fleet.IsNotFound(err) {
|
||||
// couldn't get the script to have its team, authorize with a no-team
|
||||
// script as a fallback - the requested script does not exist so there's
|
||||
// no way to know what team it would be for, and returning a 404 without
|
||||
// authorization would leak the existing/non existing ids.
|
||||
if err := svc.authz.Authorize(ctx, &fleet.Script{}, authzAction); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
svc.authz.SkipAuthorization(ctx)
|
||||
return nil, ctxerr.Wrap(ctx, err, "get script")
|
||||
}
|
||||
|
||||
// do the actual authorization with the script's team id
|
||||
if err := svc.authz.Authorize(ctx, script, authzAction); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return script, nil
|
||||
}
|
||||
|
||||
func (svc *Service) GetHostScriptDetails(ctx context.Context, hostID uint, opt fleet.ListOptions) ([]*fleet.HostScriptDetail, *fleet.PaginationMetadata, error) {
|
||||
h, err := svc.ds.HostLite(ctx, hostID)
|
||||
if err != nil {
|
||||
if fleet.IsNotFound(err) {
|
||||
// if error is because the host does not exist, check first if the user
|
||||
// had global access (to prevent leaking valid host ids).
|
||||
if err := svc.authz.Authorize(ctx, &fleet.Script{}, fleet.ActionRead); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if err := svc.authz.Authorize(ctx, &fleet.Script{TeamID: h.TeamID}, fleet.ActionRead); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if h.Platform != "darwin" && h.Platform != "windows" {
|
||||
// darwin and windows are supported for now, all other platforms return empty results
|
||||
level.Debug(svc.logger).Log("msg", "unsupported platform for host script details", "platform", h.Platform, "host_id", h.ID)
|
||||
return []*fleet.HostScriptDetail{}, &fleet.PaginationMetadata{}, nil
|
||||
}
|
||||
|
||||
// cursor-based pagination is not supported for scripts
|
||||
opt.After = ""
|
||||
// custom ordering is not supported, always by name
|
||||
opt.OrderKey = "name"
|
||||
opt.OrderDirection = fleet.OrderAscending
|
||||
// no matching query support
|
||||
opt.MatchQuery = ""
|
||||
// always include metadata for scripts
|
||||
opt.IncludeMetadata = true
|
||||
|
||||
return svc.ds.GetHostScriptDetails(ctx, h.ID, h.TeamID, opt, h.Platform)
|
||||
}
|
||||
|
||||
func (svc *Service) BatchSetScripts(ctx context.Context, maybeTmID *uint, maybeTmName *string, payloads []fleet.ScriptPayload, dryRun bool) error {
|
||||
if maybeTmID != nil && maybeTmName != nil {
|
||||
svc.authz.SkipAuthorization(ctx) // so that the error message is not replaced by "forbidden"
|
||||
return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("team_name", "cannot specify both team_id and team_name"))
|
||||
}
|
||||
|
||||
var teamID *uint
|
||||
var teamName *string
|
||||
|
||||
if maybeTmID != nil || maybeTmName != nil {
|
||||
team, err := svc.teamByIDOrName(ctx, maybeTmID, maybeTmName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
teamID = &team.ID
|
||||
teamName = &team.Name
|
||||
}
|
||||
|
||||
if err := svc.authz.Authorize(ctx, &fleet.Script{TeamID: teamID}, fleet.ActionWrite); err != nil {
|
||||
return ctxerr.Wrap(ctx, err)
|
||||
}
|
||||
|
||||
// any duplicate name in the provided set results in an error
|
||||
scripts := make([]*fleet.Script, 0, len(payloads))
|
||||
byName := make(map[string]bool, len(payloads))
|
||||
for i, p := range payloads {
|
||||
script := &fleet.Script{
|
||||
ScriptContents: string(p.ScriptContents),
|
||||
Name: p.Name,
|
||||
TeamID: teamID,
|
||||
}
|
||||
|
||||
if err := script.Validate(); err != nil {
|
||||
return ctxerr.Wrap(ctx,
|
||||
fleet.NewInvalidArgumentError(fmt.Sprintf("scripts[%d]", i), err.Error()))
|
||||
}
|
||||
|
||||
if byName[script.Name] {
|
||||
return ctxerr.Wrap(ctx,
|
||||
fleet.NewInvalidArgumentError(fmt.Sprintf("scripts[%d]", i), fmt.Sprintf("Couldn’t edit scripts. More than one script has the same file name: %q", script.Name)),
|
||||
"duplicate script by name")
|
||||
}
|
||||
byName[script.Name] = true
|
||||
scripts = append(scripts, script)
|
||||
}
|
||||
|
||||
if dryRun {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := svc.ds.BatchSetScripts(ctx, teamID, scripts); err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "batch saving scripts")
|
||||
}
|
||||
|
||||
if err := svc.ds.NewActivity(ctx, authz.UserFromContext(ctx), &fleet.ActivityTypeEditedScript{
|
||||
TeamID: teamID,
|
||||
TeamName: teamName,
|
||||
}); err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "logging activity for edited scripts")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -1,8 +1,5 @@
|
|||
import {
|
||||
IHostScript,
|
||||
IScript,
|
||||
IScriptResultResponse,
|
||||
} from "services/entities/scripts";
|
||||
import { IScriptResultResponse } from "services/entities/scripts";
|
||||
import { IScript, IHostScript } from "interfaces/script";
|
||||
|
||||
const DEFAULT_SCRIPT_MOCK: IScript = {
|
||||
id: 1,
|
||||
|
|
@ -26,6 +23,7 @@ const DEFAULT_SCRIPT_RESULT_MOCK: IScriptResultResponse = {
|
|||
message: "",
|
||||
runtime: 0,
|
||||
host_timeout: false,
|
||||
script_id: 1,
|
||||
};
|
||||
|
||||
export const createMockScriptResult = (
|
||||
|
|
|
|||
|
|
@ -15,6 +15,10 @@ export interface IModalProps {
|
|||
onEnter?: () => void;
|
||||
/** default 650px, large 800px, xlarge 850px, auto auto-width */
|
||||
width?: ModalWidth;
|
||||
/** isHidden can be set true to hide the modal when opening another modal */
|
||||
isHidden?: boolean;
|
||||
/** isLoading can be set true to enable targeting elements by loading state */
|
||||
isLoading?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
|
|
@ -24,6 +28,8 @@ const Modal = ({
|
|||
onExit,
|
||||
onEnter,
|
||||
width = "medium",
|
||||
isHidden = false,
|
||||
isLoading = false,
|
||||
className,
|
||||
}: IModalProps): JSX.Element => {
|
||||
const { hideFlash } = useContext(NotificationContext);
|
||||
|
|
@ -69,8 +75,16 @@ const Modal = ({
|
|||
);
|
||||
|
||||
return (
|
||||
<div className={`${baseClass}__background`}>
|
||||
<div className={modalContainerClassName}>
|
||||
<div
|
||||
className={`${baseClass}__background ${
|
||||
isHidden ? `${baseClass}__hidden` : ""
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`${modalContainerClassName} ${
|
||||
isLoading ? `${className}__loading` : ""
|
||||
}`}
|
||||
>
|
||||
<div className={`${baseClass}__header`}>
|
||||
<span>{title}</span>
|
||||
<div className={`${baseClass}__ex`}>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,10 @@
|
|||
animation: fade-in 150ms ease-out;
|
||||
}
|
||||
|
||||
&__hidden {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
&__content {
|
||||
margin-top: $pad-large;
|
||||
font-size: $x-small;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import React from "react";
|
||||
import classnames from "classnames";
|
||||
|
||||
interface ITabsWrapperProps {
|
||||
children: React.ReactChild | React.ReactChild[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/*
|
||||
|
|
@ -10,8 +12,13 @@ interface ITabsWrapperProps {
|
|||
*/
|
||||
const baseClass = "component__tabs-wrapper";
|
||||
|
||||
const TabsWrapper = ({ children }: ITabsWrapperProps): JSX.Element => {
|
||||
return <div className={baseClass}>{children}</div>;
|
||||
const TabsWrapper = ({
|
||||
children,
|
||||
className,
|
||||
}: ITabsWrapperProps): JSX.Element => {
|
||||
const classNames = classnames(baseClass, className);
|
||||
|
||||
return <div className={classNames}>{children}</div>;
|
||||
};
|
||||
|
||||
export default TabsWrapper;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,10 @@
|
|||
import React, { createContext, useReducer, ReactNode } from "react";
|
||||
import React, {
|
||||
createContext,
|
||||
useReducer,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useMemo,
|
||||
} from "react";
|
||||
import { INotification } from "interfaces/notification";
|
||||
import { noop } from "lodash";
|
||||
|
||||
|
|
@ -55,9 +61,8 @@ export const NotificationContext = createContext<InitialStateType>(
|
|||
const NotificationProvider = ({ children }: Props) => {
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
|
||||
const value = {
|
||||
notification: state.notification,
|
||||
renderFlash: (
|
||||
const renderFlash = useCallback(
|
||||
(
|
||||
alertType: "success" | "error" | "warning-filled" | null,
|
||||
message: JSX.Element | string | null,
|
||||
undoAction?: (evt: React.MouseEvent<HTMLButtonElement>) => void
|
||||
|
|
@ -69,10 +74,21 @@ const NotificationProvider = ({ children }: Props) => {
|
|||
undoAction,
|
||||
});
|
||||
},
|
||||
hideFlash: () => {
|
||||
dispatch({ type: actions.HIDE_FLASH });
|
||||
},
|
||||
};
|
||||
[]
|
||||
);
|
||||
|
||||
const hideFlash = useCallback(() => {
|
||||
dispatch({ type: actions.HIDE_FLASH });
|
||||
}, []);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
notification: state.notification,
|
||||
renderFlash,
|
||||
hideFlash,
|
||||
}),
|
||||
[state.notification, renderFlash, hideFlash]
|
||||
);
|
||||
|
||||
return (
|
||||
<NotificationContext.Provider value={value}>
|
||||
|
|
|
|||
|
|
@ -106,4 +106,5 @@ export interface IActivityDetails {
|
|||
deadline_days?: number;
|
||||
grace_period_days?: number;
|
||||
stats?: IScheduledQueryStats;
|
||||
host_id?: number;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -104,6 +104,15 @@ export interface IConfigFeatures {
|
|||
enable_software_inventory: boolean;
|
||||
}
|
||||
|
||||
export interface IConfigServerSettings {
|
||||
server_url: string;
|
||||
live_query_disabled: boolean;
|
||||
enable_analytics: boolean;
|
||||
deferred_save_host: boolean;
|
||||
query_reports_disabled: boolean;
|
||||
scripts_disabled: boolean;
|
||||
}
|
||||
|
||||
export interface IConfig {
|
||||
org_info: {
|
||||
org_name: string;
|
||||
|
|
@ -112,14 +121,7 @@ export interface IConfig {
|
|||
contact_url: string;
|
||||
};
|
||||
sandbox_enabled: boolean;
|
||||
server_settings: {
|
||||
server_url: string;
|
||||
live_query_disabled: boolean;
|
||||
enable_analytics: boolean;
|
||||
deferred_save_host: boolean;
|
||||
query_reports_disabled: boolean;
|
||||
scripts_disabled: boolean;
|
||||
};
|
||||
server_settings: IConfigServerSettings;
|
||||
smtp_settings: {
|
||||
enable_smtp: boolean;
|
||||
configured: boolean;
|
||||
|
|
|
|||
23
frontend/interfaces/script.ts
Normal file
23
frontend/interfaces/script.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
export interface IScript {
|
||||
id: number;
|
||||
team_id: number | null;
|
||||
name: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export const SCRIPT_SUPPORTED_PLATFORMS = ["darwin", "windows"] as const; // TODO: revisit this approach to white-list supported platforms (which would require a more robust approach to identifying linux flavors)
|
||||
|
||||
export type IScriptExecutionStatus = "ran" | "pending" | "error";
|
||||
|
||||
export interface ILastExecution {
|
||||
execution_id: string;
|
||||
executed_at: string;
|
||||
status: IScriptExecutionStatus;
|
||||
}
|
||||
|
||||
export interface IHostScript {
|
||||
script_id: number;
|
||||
name: string;
|
||||
last_execution: ILastExecution | null;
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import { formatDistanceToNowStrict } from "date-fns";
|
|||
import { ActivityType, IActivity, IActivityDetails } from "interfaces/activity";
|
||||
import {
|
||||
addGravatarUrlToResource,
|
||||
formatScriptNameForActivityItem,
|
||||
getPerformanceImpactDescription,
|
||||
internationalTimeFormat,
|
||||
} from "utilities/helpers";
|
||||
|
|
@ -610,20 +611,26 @@ const TAGGED_TEMPLATES = {
|
|||
disabledWindowsMdm: (activity: IActivity) => {
|
||||
return <> told Fleet to turn off Windows MDM features.</>;
|
||||
},
|
||||
// TODO: Combine ranScript template with host details page templates
|
||||
// frontend/pages/hosts/details/cards/Activity/PastActivity/PastActivity.tsx and
|
||||
// frontend/pages/hosts/details/cards/Activity/UpcomingActivity/UpcomingActivity.tsx
|
||||
ranScript: (
|
||||
activity: IActivity,
|
||||
onDetailsClick?: (type: ActivityType, details: IActivityDetails) => void
|
||||
) => {
|
||||
const { script_name, host_display_name, script_execution_id } =
|
||||
activity.details || {};
|
||||
return (
|
||||
<>
|
||||
{" "}
|
||||
ran a script on {activity.details?.host_display_name}.{" "}
|
||||
ran {formatScriptNameForActivityItem(script_name)} on{" "}
|
||||
{host_display_name}.{" "}
|
||||
<Button
|
||||
className={`${baseClass}__show-query-link`}
|
||||
variant="text-link"
|
||||
onClick={() =>
|
||||
onDetailsClick?.(ActivityType.RanScript, {
|
||||
script_execution_id: activity.details?.script_execution_id,
|
||||
script_execution_id,
|
||||
})
|
||||
}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -7,14 +7,13 @@ import { AppContext } from "context/app";
|
|||
import PATHS from "router/paths";
|
||||
import scriptAPI, {
|
||||
IListScriptsQueryKey,
|
||||
IScript,
|
||||
IScriptsResponse,
|
||||
} from "services/entities/scripts";
|
||||
import { IScript } from "interfaces/script";
|
||||
|
||||
import CustomLink from "components/CustomLink";
|
||||
import Spinner from "components/Spinner";
|
||||
import DataError from "components/DataError";
|
||||
import PremiumFeatureMessage from "components/PremiumFeatureMessage";
|
||||
import InfoBanner from "components/InfoBanner";
|
||||
import ScriptListHeading from "./components/ScriptListHeading";
|
||||
import ScriptListItem from "./components/ScriptListItem";
|
||||
|
|
@ -34,7 +33,6 @@ interface IScriptsProps {
|
|||
}
|
||||
|
||||
const Scripts = ({ router, currentPage, teamIdForApi }: IScriptsProps) => {
|
||||
const { isPremiumTier } = useContext(AppContext);
|
||||
const [showDeleteScriptModal, setShowDeleteScriptModal] = useState(false);
|
||||
|
||||
const selectedScript = useRef<IScript | null>(null);
|
||||
|
|
@ -79,15 +77,6 @@ const Scripts = ({ router, currentPage, teamIdForApi }: IScriptsProps) => {
|
|||
const { config } = useContext(AppContext);
|
||||
if (!config) return null;
|
||||
|
||||
// The user is not a premium tier, so show the premium feature message.
|
||||
if (!isPremiumTier) {
|
||||
return (
|
||||
<PremiumFeatureMessage
|
||||
className={`${baseClass}__premium-feature-message`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const onClickDelete = (script: IScript) => {
|
||||
selectedScript.current = script;
|
||||
setShowDeleteScriptModal(true);
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import React from "react";
|
|||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
|
||||
import { IScript } from "services/entities/scripts";
|
||||
import { IScript } from "interfaces/script";
|
||||
import ScriptListItem from "./ScriptListItem";
|
||||
|
||||
describe("ScriptListItem", () => {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@ import { format, formatDistanceToNow } from "date-fns";
|
|||
import FileSaver from "file-saver";
|
||||
|
||||
import { NotificationContext } from "context/notification";
|
||||
import scriptAPI, { IScript } from "services/entities/scripts";
|
||||
import scriptAPI from "services/entities/scripts";
|
||||
import { IScript } from "interfaces/script";
|
||||
|
||||
import Icon from "components/Icon";
|
||||
import Button from "components/buttons/Button";
|
||||
|
|
@ -27,6 +28,7 @@ const getFileRenderDetails = (
|
|||
case "py":
|
||||
return { graphicName: "file-py", platform: null };
|
||||
case "sh":
|
||||
// TODO: what about .sh files that are for linux?
|
||||
return { graphicName: "file-sh", platform: "macOS" };
|
||||
case "ps1":
|
||||
return { graphicName: "file-ps1", platform: "Windows" };
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ const ScriptPackageUploader = ({
|
|||
graphicName={["file-sh", "file-ps1"]}
|
||||
message="Shell (.sh) for macOS or PowerShell (.ps1) for Windows"
|
||||
additionalInfo="Script will run with “#!/bin/sh”on macOS."
|
||||
accept=".sh,.ps1,.yml"
|
||||
accept=".sh,.ps1"
|
||||
onFileUpload={onUploadFile}
|
||||
isLoading={showLoading}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -405,7 +405,7 @@ const DeviceUserPage = ({
|
|||
osSettings={host?.mdm.os_settings}
|
||||
deviceUser
|
||||
/>
|
||||
<TabsWrapper>
|
||||
<TabsWrapper className={`${baseClass}__tabs-wrapper`}>
|
||||
<Tabs
|
||||
selectedIndex={findSelectedTab(location.pathname)}
|
||||
onSelect={(i) => router.push(tabPaths[i])}
|
||||
|
|
|
|||
|
|
@ -45,14 +45,18 @@ const HostActionsDropdown = ({
|
|||
currentUser,
|
||||
hostTeamId
|
||||
);
|
||||
const isTeamObserver = permissions.isTeamObserver(currentUser, hostTeamId);
|
||||
const isGlobalObserver = permissions.isGlobalObserver(currentUser);
|
||||
|
||||
const options = generateHostActionOptions({
|
||||
hostPlatform,
|
||||
isPremiumTier,
|
||||
isGlobalAdmin,
|
||||
isGlobalMaintainer,
|
||||
isGlobalObserver,
|
||||
isTeamAdmin,
|
||||
isTeamMaintainer,
|
||||
isTeamObserver,
|
||||
isHostOnline: hostStatus === "online",
|
||||
isEnrolledInMdm: ["On (automatic)", "On (manual)"].includes(
|
||||
hostMdmEnrollemntStatus ?? ""
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import React from "react";
|
|||
import { IDropdownOption } from "interfaces/dropdownOption";
|
||||
import { cloneDeep } from "lodash";
|
||||
import PremiumFeatureIconWithTooltip from "components/PremiumFeatureIconWithTooltip";
|
||||
import { SCRIPT_SUPPORTED_PLATFORMS } from "interfaces/script";
|
||||
|
||||
const DEFAULT_OPTIONS = [
|
||||
{
|
||||
|
|
@ -15,6 +16,11 @@ const DEFAULT_OPTIONS = [
|
|||
value: "query",
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
label: "Run script",
|
||||
value: "runScript",
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
label: "Show disk encryption key",
|
||||
value: "diskEncryption",
|
||||
|
|
@ -38,8 +44,10 @@ interface IHostActionConfigOptions {
|
|||
isPremiumTier: boolean;
|
||||
isGlobalAdmin: boolean;
|
||||
isGlobalMaintainer: boolean;
|
||||
isGlobalObserver: boolean;
|
||||
isTeamAdmin: boolean;
|
||||
isTeamMaintainer: boolean;
|
||||
isTeamObserver: boolean;
|
||||
isHostOnline: boolean;
|
||||
isEnrolledInMdm: boolean;
|
||||
isFleetMdm: boolean;
|
||||
|
|
@ -87,6 +95,28 @@ const canShowDiskEncryption = (config: IHostActionConfigOptions) => {
|
|||
return isPremiumTier && doesStoreEncryptionKey;
|
||||
};
|
||||
|
||||
const canRunScript = ({
|
||||
hostPlatform,
|
||||
isGlobalAdmin,
|
||||
isGlobalMaintainer,
|
||||
isGlobalObserver,
|
||||
isTeamAdmin,
|
||||
isTeamMaintainer,
|
||||
isTeamObserver,
|
||||
}: IHostActionConfigOptions) => {
|
||||
return (
|
||||
(isGlobalAdmin ||
|
||||
isGlobalMaintainer ||
|
||||
isGlobalObserver ||
|
||||
isTeamAdmin ||
|
||||
isTeamMaintainer ||
|
||||
isTeamObserver) &&
|
||||
// TODO: revisit this approach to white-list supported platforms (which
|
||||
// would require a more robust approach to identifying linux flavors)
|
||||
!!SCRIPT_SUPPORTED_PLATFORMS.find((p) => p === hostPlatform)
|
||||
);
|
||||
};
|
||||
|
||||
const filterOutOptions = (
|
||||
options: IDropdownOption[],
|
||||
config: IHostActionConfigOptions
|
||||
|
|
@ -107,6 +137,15 @@ const filterOutOptions = (
|
|||
options = options.filter((option) => option.value !== "delete");
|
||||
}
|
||||
|
||||
if (!canRunScript(config)) {
|
||||
options = options.filter((option) => option.value !== "runScript");
|
||||
}
|
||||
|
||||
// TODO: refactor to filter in one pass using predefined filters specified for each of the
|
||||
// DEFAULT_OPTIONS. Note that as currently, structured the default is to include all options. For
|
||||
// example, "Query" is implicitly included by default because there is no equivalent `canQuery`
|
||||
// filter being applied here. This is a bit confusing since
|
||||
|
||||
return options;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ import teamAPI, { ILoadTeamsResponse } from "services/entities/teams";
|
|||
import { AppContext } from "context/app";
|
||||
import { QueryContext } from "context/query";
|
||||
import { NotificationContext } from "context/notification";
|
||||
|
||||
import { IActivityDetails } from "interfaces/activity";
|
||||
import {
|
||||
IHost,
|
||||
IDeviceMappingResponse,
|
||||
|
|
@ -53,13 +55,16 @@ import {
|
|||
import permissions from "utilities/permissions";
|
||||
import ScriptDetailsModal from "pages/DashboardPage/cards/ActivityFeed/components/ScriptDetailsModal";
|
||||
import { DOCUMENT_TITLE_SUFFIX } from "utilities/constants";
|
||||
import activitiesAPI, {
|
||||
IActivitiesResponse,
|
||||
} from "services/entities/activities";
|
||||
|
||||
import HostSummaryCard from "../cards/HostSummary";
|
||||
import AboutCard from "../cards/About";
|
||||
import ActivityCard from "../cards/Activity";
|
||||
import AgentOptionsCard from "../cards/AgentOptions";
|
||||
import LabelsCard from "../cards/Labels";
|
||||
import MunkiIssuesCard from "../cards/MunkiIssues";
|
||||
import ScriptsCard from "../cards/Scripts";
|
||||
import SoftwareCard from "../cards/Software";
|
||||
import UsersCard from "../cards/Users";
|
||||
import PoliciesCard from "../cards/Policies";
|
||||
|
|
@ -74,9 +79,11 @@ import DiskEncryptionKeyModal from "./modals/DiskEncryptionKeyModal";
|
|||
import HostActionDropdown from "./HostActionsDropdown/HostActionsDropdown";
|
||||
import OSSettingsModal from "../OSSettingsModal";
|
||||
import BootstrapPackageModal from "./modals/BootstrapPackageModal";
|
||||
import RunScriptModal from "./modals/RunScriptModal";
|
||||
import SelectQueryModal from "./modals/SelectQueryModal";
|
||||
import { isSupportedPlatform } from "./modals/DiskEncryptionKeyModal/DiskEncryptionKeyModal";
|
||||
import HostDetailsBanners from "./components/HostDetailsBanners";
|
||||
import { IShowActivityDetailsData } from "../cards/Activity/Activity";
|
||||
|
||||
const baseClass = "host-details";
|
||||
|
||||
|
|
@ -111,6 +118,8 @@ interface IHostDetailsSubNavItem {
|
|||
pathname: string;
|
||||
}
|
||||
|
||||
const DEFAULT_ACTIVITY_PAGE_SIZE = 8;
|
||||
|
||||
const HostDetailsPage = ({
|
||||
route,
|
||||
router,
|
||||
|
|
@ -139,6 +148,7 @@ const HostDetailsPage = ({
|
|||
const [showDeleteHostModal, setShowDeleteHostModal] = useState(false);
|
||||
const [showTransferHostModal, setShowTransferHostModal] = useState(false);
|
||||
const [showSelectQueryModal, setShowSelectQueryModal] = useState(false);
|
||||
const [showRunScriptModal, setShowRunScriptModal] = useState(false);
|
||||
const [showPolicyDetailsModal, setPolicyDetailsModal] = useState(false);
|
||||
const [showOSSettingsModal, setShowOSSettingsModal] = useState(false);
|
||||
const [showUnenrollMdmModal, setShowUnenrollMdmModal] = useState(false);
|
||||
|
|
@ -146,7 +156,7 @@ const HostDetailsPage = ({
|
|||
const [showBootstrapPackageModal, setShowBootstrapPackageModal] = useState(
|
||||
false
|
||||
);
|
||||
const [showScriptDetailsModal, setShowScriptDetailsModal] = useState(false);
|
||||
const [scriptDetailsId, setScriptDetailsId] = useState("");
|
||||
const [selectedPolicy, setSelectedPolicy] = useState<IHostPolicy | null>(
|
||||
null
|
||||
);
|
||||
|
|
@ -160,9 +170,11 @@ const HostDetailsPage = ({
|
|||
const [usersSearchString, setUsersSearchString] = useState("");
|
||||
const [pathname, setPathname] = useState("");
|
||||
|
||||
// used to track the current script execution id we want to show in the show
|
||||
// details modal.
|
||||
const scriptExecutionId = useRef<string | null>(null);
|
||||
// activity states
|
||||
const [activeActivityTab, setActiveActivityTab] = useState<
|
||||
"past" | "upcoming"
|
||||
>("past");
|
||||
const [activityPage, setActivityPage] = useState(0);
|
||||
|
||||
const { data: fleetQueries, error: fleetQueriesError } = useQuery<
|
||||
IListQueriesResponse,
|
||||
|
|
@ -327,6 +339,80 @@ const HostDetailsPage = ({
|
|||
}
|
||||
);
|
||||
|
||||
// get activities data. This is at the host details level because we want to
|
||||
// wait to show the host details page until we have the activities data.
|
||||
const {
|
||||
data: pastActivities,
|
||||
isFetching: pastActivitiesIsFetching,
|
||||
isLoading: pastActivitiesIsLoading,
|
||||
isError: pastActivitiesIsError,
|
||||
refetch: refetchPastActivities,
|
||||
} = useQuery<
|
||||
IActivitiesResponse,
|
||||
Error,
|
||||
IActivitiesResponse,
|
||||
Array<{
|
||||
scope: string;
|
||||
pageIndex: number;
|
||||
perPage: number;
|
||||
activeTab: "past" | "upcoming";
|
||||
}>
|
||||
>(
|
||||
[
|
||||
{
|
||||
scope: "past-activities",
|
||||
pageIndex: activityPage,
|
||||
perPage: DEFAULT_ACTIVITY_PAGE_SIZE,
|
||||
activeTab: activeActivityTab,
|
||||
},
|
||||
],
|
||||
({ queryKey: [{ pageIndex: page, perPage }] }) => {
|
||||
return activitiesAPI.getHostPastActivities(hostIdFromURL, page, perPage);
|
||||
},
|
||||
{
|
||||
keepPreviousData: true,
|
||||
staleTime: 2000,
|
||||
}
|
||||
);
|
||||
|
||||
const {
|
||||
data: upcomingActivities,
|
||||
isFetching: upcomingActivitiesIsFetching,
|
||||
isLoading: upcomingActivitiesIsLoading,
|
||||
isError: upcomingActivitiesIsError,
|
||||
refetch: refetchUpcomingActivities,
|
||||
} = useQuery<
|
||||
IActivitiesResponse,
|
||||
Error,
|
||||
IActivitiesResponse,
|
||||
Array<{
|
||||
scope: string;
|
||||
pageIndex: number;
|
||||
perPage: number;
|
||||
activeTab: "past" | "upcoming";
|
||||
}>
|
||||
>(
|
||||
[
|
||||
{
|
||||
scope: "upcoming-activities",
|
||||
pageIndex: activityPage,
|
||||
perPage: DEFAULT_ACTIVITY_PAGE_SIZE,
|
||||
activeTab: activeActivityTab,
|
||||
},
|
||||
],
|
||||
({ queryKey: [{ pageIndex: page, perPage }] }) => {
|
||||
return activitiesAPI.getHostUpcomingActivities(
|
||||
hostIdFromURL,
|
||||
page,
|
||||
perPage
|
||||
);
|
||||
},
|
||||
{
|
||||
keepPreviousData: true,
|
||||
staleTime: 2000,
|
||||
}
|
||||
);
|
||||
|
||||
const featuresConfig = host?.team_id
|
||||
? teams?.find((t) => t.id === host.team_id)?.features
|
||||
: config?.features;
|
||||
|
|
@ -471,6 +557,23 @@ const HostDetailsPage = ({
|
|||
}
|
||||
};
|
||||
|
||||
const onChangeActivityTab = (tabIndex: number) => {
|
||||
setActiveActivityTab(tabIndex === 0 ? "past" : "upcoming");
|
||||
setActivityPage(0);
|
||||
};
|
||||
|
||||
const onShowActivityDetails = useCallback(
|
||||
({ type, details }: IShowActivityDetailsData) => {
|
||||
switch (type) {
|
||||
case "ran_script":
|
||||
setScriptDetailsId(details?.script_execution_id || "");
|
||||
break;
|
||||
default: // do nothing
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const onLabelClick = (label: ILabel) => {
|
||||
return label.name === "All Hosts"
|
||||
? router.push(PATHS.MANAGE_HOSTS)
|
||||
|
|
@ -492,15 +595,9 @@ const HostDetailsPage = ({
|
|||
);
|
||||
};
|
||||
|
||||
const onCancelScriptDetailsModal = () => {
|
||||
setShowScriptDetailsModal(false);
|
||||
scriptExecutionId.current = null;
|
||||
};
|
||||
|
||||
const onShowScriptDetails = (executionId: string) => {
|
||||
scriptExecutionId.current = executionId;
|
||||
setShowScriptDetailsModal(true);
|
||||
};
|
||||
const onCancelScriptDetailsModal = useCallback(() => {
|
||||
setScriptDetailsId("");
|
||||
}, [setScriptDetailsId]);
|
||||
|
||||
const onTransferHostSubmit = async (team: ITeam) => {
|
||||
setIsUpdatingHost(true);
|
||||
|
|
@ -534,6 +631,12 @@ const HostDetailsPage = ({
|
|||
[]
|
||||
);
|
||||
|
||||
const onCloseRunScriptModal = useCallback(() => {
|
||||
setShowRunScriptModal(false);
|
||||
refetchPastActivities();
|
||||
refetchUpcomingActivities();
|
||||
}, [refetchPastActivities, refetchUpcomingActivities]);
|
||||
|
||||
const onSelectHostAction = (action: string) => {
|
||||
switch (action) {
|
||||
case "transfer":
|
||||
|
|
@ -551,7 +654,10 @@ const HostDetailsPage = ({
|
|||
case "delete":
|
||||
setShowDeleteHostModal(true);
|
||||
break;
|
||||
default:
|
||||
case "runScript":
|
||||
setShowRunScriptModal(true);
|
||||
break;
|
||||
default: // do nothing
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -573,7 +679,12 @@ const HostDetailsPage = ({
|
|||
);
|
||||
};
|
||||
|
||||
if (!host || isLoadingHost) {
|
||||
if (
|
||||
!host ||
|
||||
isLoadingHost ||
|
||||
pastActivitiesIsLoading ||
|
||||
upcomingActivitiesIsLoading
|
||||
) {
|
||||
return <Spinner />;
|
||||
}
|
||||
const failingPoliciesCount = host?.issues.failing_policies_count || 0;
|
||||
|
|
@ -584,11 +695,6 @@ const HostDetailsPage = ({
|
|||
title: "details",
|
||||
pathname: PATHS.HOST_DETAILS(hostIdFromURL),
|
||||
},
|
||||
{
|
||||
name: "Scripts",
|
||||
title: "scripts",
|
||||
pathname: PATHS.HOST_SCRIPTS(hostIdFromURL),
|
||||
},
|
||||
{
|
||||
name: "Software",
|
||||
title: "software",
|
||||
|
|
@ -613,26 +719,15 @@ const HostDetailsPage = ({
|
|||
},
|
||||
];
|
||||
|
||||
// we want the scripts tabs on the list for only mac and windows hosts and premium tier atm.
|
||||
// We filter it out for other platforms and non premium.
|
||||
// TODO: improve this code. We can pull the tab list component out
|
||||
// into its own component later.
|
||||
|
||||
const showScripts =
|
||||
["darwin", "windows"].includes(host?.platform ?? "") && isPremiumTier;
|
||||
const filteredSubNavTabs = showScripts
|
||||
? hostDetailsSubNav
|
||||
: hostDetailsSubNav.filter((navItem) => navItem.title !== "scripts");
|
||||
|
||||
const getTabIndex = (path: string): number => {
|
||||
return filteredSubNavTabs.findIndex((navItem) => {
|
||||
return hostDetailsSubNav.findIndex((navItem) => {
|
||||
// tab stays highlighted for paths that ends with same pathname
|
||||
return path.endsWith(navItem.pathname);
|
||||
});
|
||||
};
|
||||
|
||||
const navigateToNav = (i: number): void => {
|
||||
const navPath = filteredSubNavTabs[i].pathname;
|
||||
const navPath = hostDetailsSubNav[i].pathname;
|
||||
router.push(navPath);
|
||||
};
|
||||
|
||||
|
|
@ -659,8 +754,6 @@ const HostDetailsPage = ({
|
|||
name: host?.mdm.macos_setup?.bootstrap_package_name,
|
||||
};
|
||||
|
||||
const page = (location.query.page && parseInt(location.query.page, 10)) || 0;
|
||||
|
||||
return (
|
||||
<MainContent className={baseClass}>
|
||||
<>
|
||||
|
|
@ -691,36 +784,56 @@ const HostDetailsPage = ({
|
|||
renderActionButtons={renderActionButtons}
|
||||
osSettings={host?.mdm.os_settings}
|
||||
/>
|
||||
<TabsWrapper>
|
||||
<TabsWrapper className={`${baseClass}__tabs-wrapper`}>
|
||||
<Tabs
|
||||
selectedIndex={getTabIndex(location.pathname)}
|
||||
onSelect={(i) => navigateToNav(i)}
|
||||
>
|
||||
<TabList>
|
||||
{filteredSubNavTabs.map((navItem) => {
|
||||
{hostDetailsSubNav.map((navItem) => {
|
||||
// Bolding text when the tab is active causes a layout shift
|
||||
// so we add a hidden pseudo element with the same text string
|
||||
return <Tab key={navItem.title}>{navItem.name}</Tab>;
|
||||
})}
|
||||
</TabList>
|
||||
<TabPanel>
|
||||
<TabPanel className={`${baseClass}__details-panel`}>
|
||||
<AboutCard
|
||||
aboutData={aboutData}
|
||||
deviceMapping={deviceMapping}
|
||||
munki={macadmins?.munki}
|
||||
mdm={mdm}
|
||||
/>
|
||||
<div className="col-2">
|
||||
<AgentOptionsCard
|
||||
osqueryData={osqueryData}
|
||||
wrapFleetHelper={wrapFleetHelper}
|
||||
isChromeOS={host?.platform === "chrome"}
|
||||
/>
|
||||
<LabelsCard
|
||||
labels={host?.labels || []}
|
||||
onLabelClick={onLabelClick}
|
||||
/>
|
||||
</div>
|
||||
<ActivityCard
|
||||
activeTab={activeActivityTab}
|
||||
activities={
|
||||
activeActivityTab === "past"
|
||||
? pastActivities
|
||||
: upcomingActivities
|
||||
}
|
||||
isLoading={
|
||||
activeActivityTab === "past"
|
||||
? pastActivitiesIsFetching
|
||||
: upcomingActivitiesIsFetching
|
||||
}
|
||||
isError={
|
||||
activeActivityTab === "past"
|
||||
? pastActivitiesIsError
|
||||
: upcomingActivitiesIsError
|
||||
}
|
||||
onChangeTab={onChangeActivityTab}
|
||||
onNextPage={() => setActivityPage(activityPage + 1)}
|
||||
onPreviousPage={() => setActivityPage(activityPage - 1)}
|
||||
onShowDetails={onShowActivityDetails}
|
||||
/>
|
||||
<AgentOptionsCard
|
||||
osqueryData={osqueryData}
|
||||
wrapFleetHelper={wrapFleetHelper}
|
||||
isChromeOS={host?.platform === "chrome"}
|
||||
/>
|
||||
<LabelsCard
|
||||
labels={host?.labels || []}
|
||||
onLabelClick={onLabelClick}
|
||||
/>
|
||||
<UsersCard
|
||||
users={host?.users || []}
|
||||
usersState={usersState}
|
||||
|
|
@ -729,14 +842,6 @@ const HostDetailsPage = ({
|
|||
hostUsersEnabled={featuresConfig?.enable_host_users}
|
||||
/>
|
||||
</TabPanel>
|
||||
{showScripts && (
|
||||
<TabPanel>
|
||||
<ScriptsCard
|
||||
{...{ currentUser, host, page, router }}
|
||||
onShowDetails={onShowScriptDetails}
|
||||
/>
|
||||
</TabPanel>
|
||||
)}
|
||||
<TabPanel>
|
||||
<SoftwareCard
|
||||
isLoading={isLoadingHost}
|
||||
|
|
@ -799,6 +904,15 @@ const HostDetailsPage = ({
|
|||
hostsTeamId={host?.team_id}
|
||||
/>
|
||||
)}
|
||||
{showRunScriptModal && (
|
||||
<RunScriptModal
|
||||
host={host}
|
||||
currentUser={currentUser}
|
||||
scriptDetailsId={scriptDetailsId}
|
||||
setScriptDetailsId={setScriptDetailsId}
|
||||
onClose={onCloseRunScriptModal}
|
||||
/>
|
||||
)}
|
||||
{!!host && showTransferHostModal && (
|
||||
<TransferHostModal
|
||||
onCancel={() => setShowTransferHostModal(false)}
|
||||
|
|
@ -842,9 +956,9 @@ const HostDetailsPage = ({
|
|||
onClose={() => setShowBootstrapPackageModal(false)}
|
||||
/>
|
||||
)}
|
||||
{showScriptDetailsModal && scriptExecutionId.current && (
|
||||
{!!scriptDetailsId && (
|
||||
<ScriptDetailsModal
|
||||
scriptExecutionId={scriptExecutionId.current}
|
||||
scriptExecutionId={scriptDetailsId}
|
||||
onCancel={onCancelScriptDetailsModal}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,22 +1,46 @@
|
|||
.host-details {
|
||||
.component__tabs-wrapper {
|
||||
&__tabs-wrapper {
|
||||
.react-tabs__tab {
|
||||
display: inline-flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
|
||||
.col-2 {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
// grid layout styles for the host details page
|
||||
&__details-panel {
|
||||
display: grid;
|
||||
gap: $pad-medium
|
||||
}
|
||||
|
||||
.section.osquery {
|
||||
margin-right: $pad-small;
|
||||
}
|
||||
@media screen and (min-width: $break-md) {
|
||||
&__details-panel {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-areas:
|
||||
"about about"
|
||||
"activity agent-options"
|
||||
"activity labels"
|
||||
"users users";
|
||||
}
|
||||
|
||||
.section.labels {
|
||||
margin-left: $pad-small;
|
||||
.about {
|
||||
grid-area: about;
|
||||
}
|
||||
|
||||
.activity-card {
|
||||
grid-area: activity;
|
||||
}
|
||||
|
||||
.agent-options {
|
||||
grid-area: agent-options;
|
||||
}
|
||||
|
||||
.labels-card {
|
||||
grid-area: labels;
|
||||
}
|
||||
|
||||
.section--users {
|
||||
grid-area: users;
|
||||
}
|
||||
}
|
||||
|
||||
.about,
|
||||
|
|
@ -67,4 +91,6 @@
|
|||
width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,176 @@
|
|||
import React, { useCallback, useContext, useMemo, useState } from "react";
|
||||
import { useQuery } from "react-query";
|
||||
import { AxiosResponse } from "axios";
|
||||
|
||||
import { AppContext } from "context/app";
|
||||
import { NotificationContext } from "context/notification";
|
||||
|
||||
import { IApiError } from "interfaces/errors";
|
||||
import { IHost } from "interfaces/host";
|
||||
import { IUser } from "interfaces/user";
|
||||
|
||||
import scriptsAPI, {
|
||||
IHostScriptsQueryKey,
|
||||
IHostScriptsResponse,
|
||||
} from "services/entities/scripts";
|
||||
import { IHostScript } from "interfaces/script";
|
||||
|
||||
import Button from "components/buttons/Button";
|
||||
import DataError from "components/DataError/DataError";
|
||||
import EmptyTable from "components/EmptyTable";
|
||||
import Modal from "components/Modal";
|
||||
import Spinner from "components/Spinner/Spinner";
|
||||
|
||||
import TableContainer, {
|
||||
ITableQueryData,
|
||||
} from "components/TableContainer/TableContainer";
|
||||
|
||||
import { generateTableColumnConfigs } from "./ScriptsTableConfig";
|
||||
|
||||
const baseClass = "run-script-modal";
|
||||
|
||||
interface IScriptsProps {
|
||||
currentUser: IUser | null;
|
||||
host: IHost;
|
||||
scriptDetailsId: string;
|
||||
setScriptDetailsId: React.Dispatch<React.SetStateAction<string>>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const EmptyComponent = () => <></>;
|
||||
|
||||
const RunScriptModal = ({
|
||||
currentUser,
|
||||
host,
|
||||
scriptDetailsId,
|
||||
setScriptDetailsId,
|
||||
onClose,
|
||||
}: IScriptsProps) => {
|
||||
const [page, setPage] = useState<number>(0);
|
||||
const [runScriptRequested, setRunScriptRequested] = useState(false);
|
||||
|
||||
const { renderFlash } = useContext(NotificationContext);
|
||||
const { config } = useContext(AppContext);
|
||||
|
||||
const {
|
||||
data: hostScriptResponse,
|
||||
isError,
|
||||
isLoading,
|
||||
isFetching,
|
||||
refetch: refetchHostScripts,
|
||||
} = useQuery<
|
||||
IHostScriptsResponse,
|
||||
IApiError,
|
||||
IHostScriptsResponse,
|
||||
IHostScriptsQueryKey[]
|
||||
>(
|
||||
[{ scope: "host_scripts", host_id: host.id, page, per_page: 10 }],
|
||||
({ queryKey }) => scriptsAPI.getHostScripts(queryKey[0]),
|
||||
{
|
||||
refetchOnWindowFocus: false,
|
||||
retry: false,
|
||||
staleTime: 3000,
|
||||
onSuccess: () => {
|
||||
setRunScriptRequested(false);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const onSelectAction = useCallback(
|
||||
async (action: string, script: IHostScript) => {
|
||||
switch (action) {
|
||||
case "showDetails": {
|
||||
setScriptDetailsId(script.last_execution?.execution_id || "");
|
||||
break;
|
||||
}
|
||||
case "run": {
|
||||
try {
|
||||
setRunScriptRequested(true);
|
||||
await scriptsAPI.runScript({
|
||||
host_id: host.id,
|
||||
script_id: script.script_id,
|
||||
});
|
||||
renderFlash("success", "Script successfully queued for execution");
|
||||
refetchHostScripts();
|
||||
} catch (e) {
|
||||
const error = e as AxiosResponse<IApiError>;
|
||||
console.log(error);
|
||||
renderFlash("error", error.data.errors[0].reason);
|
||||
setRunScriptRequested(false);
|
||||
}
|
||||
break;
|
||||
}
|
||||
default: // do nothing
|
||||
}
|
||||
},
|
||||
[host.id, refetchHostScripts, renderFlash, setScriptDetailsId]
|
||||
);
|
||||
|
||||
const onQueryChange = useCallback(({ pageIndex }: ITableQueryData) => {
|
||||
setPage(pageIndex);
|
||||
}, []);
|
||||
|
||||
const scriptColumnConfigs = useMemo(
|
||||
() =>
|
||||
generateTableColumnConfigs(
|
||||
currentUser,
|
||||
host.team_id,
|
||||
!!config?.server_settings?.scripts_disabled,
|
||||
onSelectAction
|
||||
),
|
||||
[currentUser, host.team_id, config, onSelectAction]
|
||||
);
|
||||
|
||||
if (!config) return null;
|
||||
|
||||
const isShowingScriptDetails = !!scriptDetailsId; // used to set css visibility for this modal to hidden when the script details modal is open
|
||||
const tableData = hostScriptResponse?.scripts;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={"Run script"}
|
||||
onExit={onClose}
|
||||
onEnter={onClose}
|
||||
className={`${baseClass}`}
|
||||
isHidden={isShowingScriptDetails}
|
||||
isLoading={runScriptRequested || isFetching || isLoading}
|
||||
>
|
||||
<>
|
||||
<div className={`${baseClass}__modal-content`}>
|
||||
{isLoading && <Spinner />}
|
||||
{!isLoading && isError && <DataError />}
|
||||
{!isLoading && !isError && tableData && tableData.length === 0 && (
|
||||
<EmptyTable
|
||||
header="No scripts are available for this host"
|
||||
info="Expecting to see scripts? Try selecting “Refetch” to ask this host to report new vitals."
|
||||
/>
|
||||
)}
|
||||
{!isLoading && !isError && tableData && tableData.length > 0 && (
|
||||
<TableContainer
|
||||
resultsTitle=""
|
||||
emptyComponent={EmptyComponent}
|
||||
showMarkAllPages={false}
|
||||
isAllPagesSelected={false}
|
||||
columnConfigs={scriptColumnConfigs}
|
||||
data={tableData}
|
||||
isLoading={runScriptRequested || isFetching}
|
||||
onQueryChange={onQueryChange}
|
||||
disableNextPage={!hostScriptResponse?.meta.has_next_results}
|
||||
defaultPageIndex={page}
|
||||
pageSize={10}
|
||||
disableCount
|
||||
disableTableHeader
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className={`modal-cta-wrap`}>
|
||||
<Button onClick={onClose} variant="brand">
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(RunScriptModal);
|
||||
|
|
@ -1,14 +1,14 @@
|
|||
import React from "react";
|
||||
import ReactTooltip from "react-tooltip";
|
||||
import { noop } from "lodash";
|
||||
|
||||
import { COLORS } from "styles/var/colors";
|
||||
|
||||
import { IDropdownOption } from "interfaces/dropdownOption";
|
||||
import { IHostScript, ILastExecution } from "services/entities/scripts";
|
||||
import { IHostScript, ILastExecution } from "interfaces/script";
|
||||
import { IUser } from "interfaces/user";
|
||||
|
||||
import DropdownCell from "components/TableContainer/DataTable/DropdownCell";
|
||||
import { IHost } from "interfaces/host";
|
||||
import {
|
||||
isGlobalAdmin,
|
||||
isTeamMaintainer,
|
||||
|
|
@ -58,7 +58,7 @@ const ScriptRunActionDropdownLabel = ({
|
|||
delayHide={100}
|
||||
delayUpdate={500}
|
||||
>
|
||||
You can only run the script when the host is online.
|
||||
Script is already running.
|
||||
</ReactTooltip>
|
||||
</>
|
||||
) : (
|
||||
|
|
@ -66,10 +66,47 @@ const ScriptRunActionDropdownLabel = ({
|
|||
);
|
||||
};
|
||||
|
||||
const generateActionDropdownOptions = (
|
||||
currentUser: IUser | null,
|
||||
teamId: number | null,
|
||||
{ script_id, last_execution }: IHostScript
|
||||
): IDropdownOption[] => {
|
||||
const hasRunPermission =
|
||||
!!currentUser &&
|
||||
(isGlobalAdmin(currentUser) ||
|
||||
isTeamAdmin(currentUser, teamId) ||
|
||||
isGlobalMaintainer(currentUser) ||
|
||||
isTeamMaintainer(currentUser, teamId) ||
|
||||
// TODO - refactor all permissions to be clear and granular
|
||||
// each of these (confusingly) cover both observer and observer+
|
||||
isGlobalObserver(currentUser) ||
|
||||
isTeamObserver(currentUser, teamId));
|
||||
const options: IDropdownOption[] = [
|
||||
{
|
||||
label: "Show details",
|
||||
disabled: last_execution === null,
|
||||
value: "showDetails",
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<ScriptRunActionDropdownLabel
|
||||
scriptId={script_id}
|
||||
disabled={last_execution?.status === "pending"}
|
||||
/>
|
||||
),
|
||||
disabled: last_execution?.status === "pending",
|
||||
value: "run",
|
||||
},
|
||||
];
|
||||
return hasRunPermission ? options : options.slice(0, 1);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export const generateTableColumnConfigs = (
|
||||
actionSelectHandler: (value: string, script: IHostScript) => void,
|
||||
disableActions = false
|
||||
currentUser: IUser | null,
|
||||
hostTeamId: number | null,
|
||||
scriptsDisabled: boolean,
|
||||
onSelectAction: (value: string, script: IHostScript) => void
|
||||
) => {
|
||||
return [
|
||||
{
|
||||
|
|
@ -92,85 +129,45 @@ export const generateTableColumnConfigs = (
|
|||
Header: "",
|
||||
disableSortBy: true,
|
||||
accessor: "actions",
|
||||
Cell: (cellProps: IDropdownCellProps) =>
|
||||
disableActions ? (
|
||||
<span>
|
||||
<TooltipWrapper
|
||||
position="top"
|
||||
tipContent={
|
||||
<div>Running scripts is disabled in organization settings</div>
|
||||
}
|
||||
>
|
||||
<DropdownCell
|
||||
options={cellProps.cell.value}
|
||||
onChange={(value: string) =>
|
||||
actionSelectHandler(value, cellProps.row.original)
|
||||
Cell: (cellProps: IDropdownCellProps) => {
|
||||
if (scriptsDisabled) {
|
||||
return (
|
||||
<span>
|
||||
<TooltipWrapper
|
||||
position="top"
|
||||
tipContent={
|
||||
<div>
|
||||
Running scripts is disabled in organization settings
|
||||
</div>
|
||||
}
|
||||
placeholder={"Actions"}
|
||||
disabled={disableActions}
|
||||
/>
|
||||
</TooltipWrapper>
|
||||
</span>
|
||||
) : (
|
||||
>
|
||||
<DropdownCell
|
||||
options={[] as IDropdownOption[]}
|
||||
onChange={noop}
|
||||
placeholder={"Actions"}
|
||||
disabled={scriptsDisabled}
|
||||
/>
|
||||
</TooltipWrapper>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const opts = generateActionDropdownOptions(
|
||||
currentUser,
|
||||
hostTeamId,
|
||||
cellProps.row.original
|
||||
);
|
||||
return (
|
||||
<DropdownCell
|
||||
options={cellProps.cell.value}
|
||||
options={opts}
|
||||
onChange={(value: string) =>
|
||||
actionSelectHandler(value, cellProps.row.original)
|
||||
onSelectAction(value, cellProps.row.original)
|
||||
}
|
||||
placeholder={"Actions"}
|
||||
disabled={disableActions}
|
||||
disabled={scriptsDisabled}
|
||||
/>
|
||||
),
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
const generateActionDropdownOptions = (
|
||||
currentUser: IUser | null,
|
||||
host: IHost,
|
||||
{ script_id, last_execution }: IHostScript
|
||||
): IDropdownOption[] => {
|
||||
const [hostTeamId, isHostOnline] = [host.team_id, host.status === "online"];
|
||||
|
||||
const hasRunPermission =
|
||||
!!currentUser &&
|
||||
(isGlobalAdmin(currentUser) ||
|
||||
isTeamAdmin(currentUser, hostTeamId) ||
|
||||
isGlobalMaintainer(currentUser) ||
|
||||
isTeamMaintainer(currentUser, hostTeamId) ||
|
||||
// TODO - refactor all permissions to be clear and granular
|
||||
// each of these (confusingly) cover both observer and observer+
|
||||
isGlobalObserver(currentUser) ||
|
||||
isTeamObserver(currentUser, hostTeamId));
|
||||
const options: IDropdownOption[] = [
|
||||
{
|
||||
label: "Show details",
|
||||
disabled: last_execution === null,
|
||||
value: "showDetails",
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<ScriptRunActionDropdownLabel
|
||||
scriptId={script_id}
|
||||
disabled={!isHostOnline}
|
||||
/>
|
||||
),
|
||||
disabled: !isHostOnline,
|
||||
value: "run",
|
||||
},
|
||||
];
|
||||
return hasRunPermission ? options : options.slice(0, 1);
|
||||
};
|
||||
|
||||
export const generateDataSet = (
|
||||
currentUser: IUser | null,
|
||||
host: IHost,
|
||||
scripts: IHostScript[]
|
||||
) => {
|
||||
return scripts.map((script) => {
|
||||
return {
|
||||
...script,
|
||||
actions: generateActionDropdownOptions(currentUser, host, script),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
.run-script-modal {
|
||||
box-sizing: border-box;
|
||||
width: 811px;
|
||||
|
||||
&__modal-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $pad-xlarge;
|
||||
}
|
||||
|
||||
.modal-cta-wrap {
|
||||
margin: $pad-xsmall 0 0 0;
|
||||
}
|
||||
|
||||
&__loading {
|
||||
.modal__header,
|
||||
.modal-cta-wrap {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.Select-option {
|
||||
.dropdown__option {
|
||||
[data-id="tooltip"] {
|
||||
font-size: $xx-small;
|
||||
font-style: normal;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +1,7 @@
|
|||
import React from "react";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
|
||||
import {
|
||||
ILastExecution,
|
||||
IScriptExecutionStatus,
|
||||
} from "services/entities/scripts";
|
||||
import { ILastExecution, IScriptExecutionStatus } from "interfaces/script";
|
||||
|
||||
import StatusIndicatorWithIcon, {
|
||||
IndicatorStatus,
|
||||
|
|
@ -30,8 +27,7 @@ const STATUS_DISPLAY_CONFIG: Record<
|
|||
pending: {
|
||||
displayText: "Pending",
|
||||
iconStatus: "pendingPartial",
|
||||
tooltip: () =>
|
||||
"Script is running. To see if the script finished, refresh the page.",
|
||||
tooltip: () => "Script is running or will run when the host comes online.",
|
||||
},
|
||||
error: {
|
||||
displayText: "Error",
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./RunScriptModal";
|
||||
|
|
@ -222,16 +222,20 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
.component__tabs-wrapper {
|
||||
&__tabs-wrapper {
|
||||
background-color: $ui-off-white;
|
||||
width: 100%;
|
||||
|
||||
.react-tabs__tab {
|
||||
padding: 6px 0px 16px 0px;
|
||||
margin-right: $pad-xxlarge;
|
||||
}
|
||||
.react-tabs__tab--selected {
|
||||
background-color: $ui-off-white;
|
||||
// direct descendant of selector allows us to only change the first level of
|
||||
// tab styling and not change the tabs inside the cards.
|
||||
> .react-tabs > .react-tabs__tab-list {
|
||||
.react-tabs__tab {
|
||||
padding: 6px 0px 16px 0px;
|
||||
margin-right: $pad-xxlarge;
|
||||
}
|
||||
.react-tabs__tab--selected {
|
||||
background-color: $ui-off-white;
|
||||
}
|
||||
}
|
||||
|
||||
.focus-visible {
|
||||
|
|
@ -242,6 +246,7 @@
|
|||
margin-top: $pad-medium;
|
||||
}
|
||||
}
|
||||
|
||||
.col-50 {
|
||||
flex: 2;
|
||||
}
|
||||
|
|
@ -306,3 +311,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// we dont need the margin on the host details page as we are not using grid css
|
||||
// for the spacing.
|
||||
.host-details__tabs-wrapper .section {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
|
|
|||
111
frontend/pages/hosts/details/cards/Activity/Activity.tsx
Normal file
111
frontend/pages/hosts/details/cards/Activity/Activity.tsx
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import React, { useRef, useState } from "react";
|
||||
import { Tab, TabList, TabPanel, Tabs } from "react-tabs";
|
||||
|
||||
import ScriptDetailsModal from "pages/DashboardPage/cards/ActivityFeed/components/ScriptDetailsModal";
|
||||
import { IActivityDetails } from "interfaces/activity";
|
||||
import { IActivitiesResponse } from "services/entities/activities";
|
||||
|
||||
import Card from "components/Card";
|
||||
import TabsWrapper from "components/TabsWrapper";
|
||||
import Spinner from "components/Spinner";
|
||||
import TooltipWrapper from "components/TooltipWrapper";
|
||||
|
||||
import PastActivityFeed from "./PastActivityFeed";
|
||||
import UpcomingActivityFeed from "./UpcomingActivityFeed";
|
||||
|
||||
const baseClass = "activity-card";
|
||||
|
||||
export interface IShowActivityDetailsData {
|
||||
type: string;
|
||||
details?: IActivityDetails;
|
||||
}
|
||||
|
||||
export type ShowActivityDetailsHandler = (
|
||||
data: IShowActivityDetailsData
|
||||
) => void;
|
||||
|
||||
const UpcomingTooltip = () => {
|
||||
return (
|
||||
<TooltipWrapper
|
||||
position="top-start"
|
||||
tipContent={
|
||||
<>
|
||||
Upcoming activities will run as listed. Failure of one activity won’t
|
||||
cancel other activities.
|
||||
<br />
|
||||
<br />
|
||||
Currently, only scripts are guaranteed to run in order.
|
||||
</>
|
||||
}
|
||||
className={`${baseClass}__upcoming-tooltip`}
|
||||
>
|
||||
Activities run as listed
|
||||
</TooltipWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
interface IActivityProps {
|
||||
activeTab: "past" | "upcoming";
|
||||
activities?: IActivitiesResponse; // TODO: type
|
||||
isLoading?: boolean;
|
||||
isError?: boolean;
|
||||
onChangeTab: (index: number, last: number, event: Event) => void;
|
||||
onNextPage: () => void;
|
||||
onPreviousPage: () => void;
|
||||
onShowDetails: ShowActivityDetailsHandler;
|
||||
}
|
||||
|
||||
const Activity = ({
|
||||
activeTab,
|
||||
activities,
|
||||
isLoading,
|
||||
isError,
|
||||
onChangeTab,
|
||||
onNextPage,
|
||||
onPreviousPage,
|
||||
onShowDetails,
|
||||
}: IActivityProps) => {
|
||||
// TODO: add count to upcoming activities tab when available via API
|
||||
return (
|
||||
<Card borderRadiusSize="large" includeShadow className={baseClass}>
|
||||
{isLoading && (
|
||||
<div className={`${baseClass}__loading-overlay`}>
|
||||
<Spinner />
|
||||
</div>
|
||||
)}
|
||||
<h2>Activity</h2>
|
||||
<TabsWrapper>
|
||||
<Tabs
|
||||
selectedIndex={activeTab === "past" ? 0 : 1}
|
||||
onSelect={onChangeTab}
|
||||
>
|
||||
<TabList>
|
||||
<Tab>Past</Tab>
|
||||
<Tab>Upcoming</Tab>
|
||||
</TabList>
|
||||
<TabPanel>
|
||||
<PastActivityFeed
|
||||
activities={activities}
|
||||
onDetailsClick={onShowDetails}
|
||||
isError={isError}
|
||||
onNextPage={onNextPage}
|
||||
onPreviousPage={onPreviousPage}
|
||||
/>
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<UpcomingTooltip />
|
||||
<UpcomingActivityFeed
|
||||
activities={activities}
|
||||
onDetailsClick={onShowDetails}
|
||||
isError={isError}
|
||||
onNextPage={onNextPage}
|
||||
onPreviousPage={onPreviousPage}
|
||||
/>
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
</TabsWrapper>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default Activity;
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
import React from "react";
|
||||
import classnames from "classnames";
|
||||
|
||||
const baseClass = "empty-feed";
|
||||
|
||||
interface IEmptyFeedProps {
|
||||
title: string;
|
||||
message: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const EmptyFeed = ({ title, message, className }: IEmptyFeedProps) => {
|
||||
const classNames = classnames(baseClass, className);
|
||||
|
||||
return (
|
||||
<div className={classNames}>
|
||||
<p className={`${baseClass}__title`}>{title}</p>
|
||||
<p>{message}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmptyFeed;
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
.empty-feed {
|
||||
margin-top: $pad-xxlarge;
|
||||
|
||||
p {
|
||||
font-size: $x-small;
|
||||
margin: $pad-medium 0;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-weight: $bold;
|
||||
font-size: $small;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
import React from "react";
|
||||
import ReactTooltip from "react-tooltip";
|
||||
import { formatDistanceToNowStrict } from "date-fns";
|
||||
|
||||
import Avatar from "components/Avatar";
|
||||
import Icon from "components/Icon";
|
||||
import Button from "components/buttons/Button";
|
||||
|
||||
import { COLORS } from "styles/var/colors";
|
||||
import { DEFAULT_GRAVATAR_LINK } from "utilities/constants";
|
||||
import {
|
||||
addGravatarUrlToResource,
|
||||
formatScriptNameForActivityItem,
|
||||
internationalTimeFormat,
|
||||
} from "utilities/helpers";
|
||||
import { IActivity } from "interfaces/activity";
|
||||
import { ShowActivityDetailsHandler } from "../Activity";
|
||||
|
||||
const baseClass = "past-activity";
|
||||
|
||||
interface IPastActivityProps {
|
||||
activity: IActivity;
|
||||
onDetailsClick: ShowActivityDetailsHandler;
|
||||
}
|
||||
// TODO: Combine this with ./UpcomingActivity/UpcomingActivity.tsx and
|
||||
// frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx
|
||||
const PastActivity = ({ activity, onDetailsClick }: IPastActivityProps) => {
|
||||
const { actor_email } = activity;
|
||||
const { gravatar_url } = actor_email
|
||||
? addGravatarUrlToResource({ email: actor_email })
|
||||
: { gravatar_url: DEFAULT_GRAVATAR_LINK };
|
||||
const activityCreatedAt = new Date(activity.created_at);
|
||||
|
||||
return (
|
||||
<div className={baseClass}>
|
||||
<Avatar
|
||||
className={`${baseClass}__avatar-image`}
|
||||
user={{ gravatar_url }}
|
||||
size="small"
|
||||
hasWhiteBackground
|
||||
/>
|
||||
<div className={`${baseClass}__details-wrapper`}>
|
||||
<div className={"activity-details"}>
|
||||
<span className={`${baseClass}__details-topline`}>
|
||||
<b>{activity.actor_full_name}</b>
|
||||
<>
|
||||
{" "}
|
||||
ran{" "}
|
||||
{formatScriptNameForActivityItem(
|
||||
activity.details?.script_name
|
||||
)}{" "}
|
||||
on this host.{" "}
|
||||
<Button
|
||||
className={`${baseClass}__show-query-link`}
|
||||
variant="text-link"
|
||||
onClick={() => onDetailsClick?.(activity)}
|
||||
>
|
||||
Show details{" "}
|
||||
<Icon className={`${baseClass}__show-query-icon`} name="eye" />
|
||||
</Button>
|
||||
</>
|
||||
</span>
|
||||
<br />
|
||||
<span
|
||||
className={`${baseClass}__details-bottomline`}
|
||||
data-tip
|
||||
data-for={`activity-${activity.id}`}
|
||||
>
|
||||
{formatDistanceToNowStrict(activityCreatedAt, {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</span>
|
||||
<ReactTooltip
|
||||
className="date-tooltip"
|
||||
place="top"
|
||||
type="dark"
|
||||
effect="solid"
|
||||
id={`activity-${activity.id}`}
|
||||
backgroundColor={COLORS["tooltip-bg"]}
|
||||
>
|
||||
{internationalTimeFormat(activityCreatedAt)}
|
||||
</ReactTooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`${baseClass}__dash`} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PastActivity;
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
.past-activity {
|
||||
display: grid; // Grid system is used to create variable dashed line lengths
|
||||
grid-template-columns: 16px 16px 1fr;
|
||||
grid-template-rows: 32px max-content;
|
||||
|
||||
.avatar-wrapper {
|
||||
grid-column-start: 1;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
&__dash {
|
||||
border-right: 1px dashed $ui-fleet-black-10;
|
||||
grid-column-start: 1;
|
||||
grid-row-start: 2;
|
||||
grid-row-end: 3;
|
||||
}
|
||||
|
||||
&__details-wrapper {
|
||||
grid-column-start: 3;
|
||||
grid-row-start: 1;
|
||||
grid-row-end: 3;
|
||||
padding-left: $pad-large;
|
||||
padding-bottom: $pad-large;
|
||||
|
||||
.premium-icon-tip {
|
||||
position: relative;
|
||||
top: 4px;
|
||||
padding-right: $pad-xsmall;
|
||||
}
|
||||
|
||||
.activity-details {
|
||||
margin: 0;
|
||||
line-height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
&__details-topline {
|
||||
font-size: $x-small;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
&__details-content {
|
||||
margin-right: $pad-xsmall;
|
||||
}
|
||||
|
||||
&__details-bottomline {
|
||||
font-size: $xx-small;
|
||||
color: $ui-fleet-black-25;
|
||||
}
|
||||
|
||||
&__show-query-icon {
|
||||
margin-left: $pad-xsmall;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
.past-activity__dash {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.past-activity__details {
|
||||
padding-bottom: $pad-xxlarge;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./PastActivity";
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
import React from "react";
|
||||
|
||||
import { IActivity, IActivityDetails } from "interfaces/activity";
|
||||
import { IActivitiesResponse } from "services/entities/activities";
|
||||
|
||||
// @ts-ignore
|
||||
import FleetIcon from "components/icons/FleetIcon";
|
||||
import Button from "components/buttons/Button";
|
||||
import DataError from "components/DataError";
|
||||
|
||||
import EmptyFeed from "../EmptyFeed/EmptyFeed";
|
||||
import PastActivity from "../PastActivity/PastActivity";
|
||||
import { ShowActivityDetailsHandler } from "../Activity";
|
||||
|
||||
const baseClass = "past-activity-feed";
|
||||
|
||||
interface IPastActivityFeedProps {
|
||||
activities?: IActivitiesResponse;
|
||||
isError?: boolean;
|
||||
onDetailsClick: ShowActivityDetailsHandler;
|
||||
onNextPage: () => void;
|
||||
onPreviousPage: () => void;
|
||||
}
|
||||
|
||||
const PastActivityFeed = ({
|
||||
activities,
|
||||
isError = false,
|
||||
onDetailsClick,
|
||||
onNextPage,
|
||||
onPreviousPage,
|
||||
}: IPastActivityFeedProps) => {
|
||||
if (isError) {
|
||||
return <DataError className={`${baseClass}__error`} />;
|
||||
}
|
||||
|
||||
if (!activities) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { activities: activitiesList, meta } = activities;
|
||||
|
||||
if (activitiesList === null || activitiesList.length === 0) {
|
||||
return (
|
||||
<EmptyFeed
|
||||
title="No Activity"
|
||||
message="When a script runs on a host, it shows up here."
|
||||
className={`${baseClass}__empty-feed`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={baseClass}>
|
||||
<div>
|
||||
{activitiesList.map((activity: IActivity) => (
|
||||
<PastActivity activity={activity} onDetailsClick={onDetailsClick} />
|
||||
))}
|
||||
</div>
|
||||
<div className={`${baseClass}__pagination`}>
|
||||
<Button
|
||||
disabled={!meta.has_previous_results}
|
||||
onClick={onPreviousPage}
|
||||
variant="unstyled"
|
||||
className={`${baseClass}__load-activities-button`}
|
||||
>
|
||||
<>
|
||||
<FleetIcon name="chevronleft" /> Previous
|
||||
</>
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!meta.has_next_results}
|
||||
onClick={onNextPage}
|
||||
variant="unstyled"
|
||||
className={`${baseClass}__load-activities-button`}
|
||||
>
|
||||
<>
|
||||
Next <FleetIcon name="chevronright" />
|
||||
</>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PastActivityFeed;
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
.past-activity-feed {
|
||||
margin-top: $pad-large;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
min-height: 500px;
|
||||
|
||||
&__empty-feed {
|
||||
min-height: 484px;
|
||||
}
|
||||
|
||||
&__pagination {
|
||||
position: absolute;
|
||||
bottom: 0px;
|
||||
right: 0px;
|
||||
|
||||
.button {
|
||||
font-weight: $bold;
|
||||
}
|
||||
}
|
||||
|
||||
&__load-activities-button {
|
||||
color: $core-vibrant-blue;
|
||||
vertical-align: bottom;
|
||||
padding: 6px;
|
||||
|
||||
.fleeticon-chevronleft,
|
||||
.fleeticon-chevronright {
|
||||
&:before {
|
||||
font-size: 0.5rem;
|
||||
font-weight: $bold;
|
||||
position: relative;
|
||||
top: -2px;
|
||||
}
|
||||
}
|
||||
|
||||
.fleeticon-chevronleft {
|
||||
margin-right: $pad-small;
|
||||
}
|
||||
|
||||
.fleeticon-chevronright {
|
||||
margin-left: $pad-small;
|
||||
}
|
||||
|
||||
&:first-of-type {
|
||||
margin-right: $pad-large;
|
||||
}
|
||||
|
||||
&:hover:not(.button--disabled),
|
||||
&:focus {
|
||||
background-color: $ui-vibrant-blue-10;
|
||||
}
|
||||
}
|
||||
|
||||
&__error {
|
||||
margin: $pad-xlarge 0;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./PastActivityFeed";
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
import React from "react";
|
||||
import ReactTooltip from "react-tooltip";
|
||||
import { formatDistanceToNowStrict } from "date-fns";
|
||||
|
||||
import { IActivity } from "interfaces/activity";
|
||||
import { COLORS } from "styles/var/colors";
|
||||
import { DEFAULT_GRAVATAR_LINK } from "utilities/constants";
|
||||
import {
|
||||
addGravatarUrlToResource,
|
||||
formatScriptNameForActivityItem,
|
||||
internationalTimeFormat,
|
||||
} from "utilities/helpers";
|
||||
|
||||
import Avatar from "components/Avatar";
|
||||
import Icon from "components/Icon";
|
||||
import Button from "components/buttons/Button";
|
||||
import { ShowActivityDetailsHandler } from "../Activity";
|
||||
|
||||
const baseClass = "upcoming-activity";
|
||||
|
||||
interface IUpcomingActivityProps {
|
||||
activity: IActivity;
|
||||
onDetailsClick: ShowActivityDetailsHandler;
|
||||
}
|
||||
|
||||
// TODO: Combine this with ./UpcomingActivity/UpcomingActivity.tsx and
|
||||
// frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx
|
||||
const UpcomingActivity = ({
|
||||
activity,
|
||||
onDetailsClick,
|
||||
}: IUpcomingActivityProps) => {
|
||||
const { actor_email } = activity;
|
||||
const { gravatar_url } = actor_email
|
||||
? addGravatarUrlToResource({ email: actor_email })
|
||||
: { gravatar_url: DEFAULT_GRAVATAR_LINK };
|
||||
const activityCreatedAt = new Date(activity.created_at);
|
||||
|
||||
return (
|
||||
<div className={baseClass}>
|
||||
<Avatar
|
||||
className={`${baseClass}__avatar-image`}
|
||||
user={{ gravatar_url }}
|
||||
size="small"
|
||||
hasWhiteBackground
|
||||
/>
|
||||
<div className={`${baseClass}__details-wrapper`}>
|
||||
<div className={"activity-details"}>
|
||||
<span className={`${baseClass}__details-topline`}>
|
||||
<b>{activity.actor_full_name}</b>
|
||||
<>
|
||||
{" "}
|
||||
told Fleet to run{" "}
|
||||
{formatScriptNameForActivityItem(
|
||||
activity.details?.script_name
|
||||
)}{" "}
|
||||
on this host.{" "}
|
||||
<Button
|
||||
className={`${baseClass}__show-query-link`}
|
||||
variant="text-link"
|
||||
onClick={() => onDetailsClick?.(activity)}
|
||||
>
|
||||
Show details{" "}
|
||||
<Icon className={`${baseClass}__show-query-icon`} name="eye" />
|
||||
</Button>
|
||||
</>
|
||||
</span>
|
||||
<br />
|
||||
<span
|
||||
className={`${baseClass}__details-bottomline`}
|
||||
data-tip
|
||||
data-for={`activity-${activity.id}`}
|
||||
>
|
||||
{formatDistanceToNowStrict(activityCreatedAt, {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</span>
|
||||
<ReactTooltip
|
||||
className="date-tooltip"
|
||||
place="top"
|
||||
type="dark"
|
||||
effect="solid"
|
||||
id={`activity-${activity.id}`}
|
||||
backgroundColor={COLORS["tooltip-bg"]}
|
||||
>
|
||||
{internationalTimeFormat(activityCreatedAt)}
|
||||
</ReactTooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`${baseClass}__dash`} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UpcomingActivity;
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
.upcoming-activity {
|
||||
display: grid; // Grid system is used to create variable dashed line lengths
|
||||
grid-template-columns: 16px 16px 1fr;
|
||||
grid-template-rows: 32px max-content;
|
||||
|
||||
.avatar-wrapper {
|
||||
grid-column-start: 1;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
&__dash {
|
||||
border-right: 1px dashed $ui-fleet-black-10;
|
||||
grid-column-start: 1;
|
||||
grid-row-start: 2;
|
||||
grid-row-end: 3;
|
||||
}
|
||||
|
||||
&__details-wrapper {
|
||||
grid-column-start: 3;
|
||||
grid-row-start: 1;
|
||||
grid-row-end: 3;
|
||||
padding-left: $pad-large;
|
||||
padding-bottom: $pad-large;
|
||||
|
||||
.premium-icon-tip {
|
||||
position: relative;
|
||||
top: 4px;
|
||||
padding-right: $pad-xsmall;
|
||||
}
|
||||
|
||||
.activity-details {
|
||||
margin: 0;
|
||||
line-height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
&__details-topline {
|
||||
font-size: $x-small;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
&__details-content {
|
||||
margin-right: $pad-xsmall;
|
||||
}
|
||||
|
||||
&__details-bottomline {
|
||||
font-size: $xx-small;
|
||||
color: $ui-fleet-black-25;
|
||||
}
|
||||
|
||||
&__show-query-icon {
|
||||
margin-left: $pad-xsmall;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
.upcoming-activity__dash {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.upcoming-activity__details {
|
||||
padding-bottom: $pad-xxlarge;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./UpcomingActivity";
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
import React from "react";
|
||||
|
||||
import { IActivity, IActivityDetails } from "interfaces/activity";
|
||||
import { IActivitiesResponse } from "services/entities/activities";
|
||||
|
||||
// @ts-ignore
|
||||
import FleetIcon from "components/icons/FleetIcon";
|
||||
import DataError from "components/DataError";
|
||||
import Button from "components/buttons/Button";
|
||||
|
||||
import EmptyFeed from "../EmptyFeed/EmptyFeed";
|
||||
import UpcomingActivity from "../UpcomingActivity/UpcomingActivity";
|
||||
import { ShowActivityDetailsHandler } from "../Activity";
|
||||
|
||||
const baseClass = "upcoming-activity-feed";
|
||||
|
||||
interface IUpcomingActivityFeedProps {
|
||||
activities?: IActivitiesResponse;
|
||||
isError?: boolean;
|
||||
onDetailsClick: ShowActivityDetailsHandler;
|
||||
onNextPage: () => void;
|
||||
onPreviousPage: () => void;
|
||||
}
|
||||
|
||||
const UpcomingActivityFeed = ({
|
||||
activities,
|
||||
isError = false,
|
||||
onDetailsClick,
|
||||
onNextPage,
|
||||
onPreviousPage,
|
||||
}: IUpcomingActivityFeedProps) => {
|
||||
if (isError) {
|
||||
return <DataError />;
|
||||
}
|
||||
|
||||
if (!activities) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { activities: activitiesList, meta } = activities;
|
||||
|
||||
if (activitiesList === null || activitiesList.length === 0) {
|
||||
return (
|
||||
<EmptyFeed
|
||||
title="No pending activity "
|
||||
message="When you run a script on an offline host, it will appear here."
|
||||
className={`${baseClass}__empty-feed`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={baseClass}>
|
||||
<div>
|
||||
{activitiesList.map((activity: IActivity) => (
|
||||
<UpcomingActivity
|
||||
activity={activity}
|
||||
onDetailsClick={onDetailsClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className={`${baseClass}__pagination`}>
|
||||
<Button
|
||||
disabled={!meta.has_previous_results}
|
||||
onClick={onPreviousPage}
|
||||
variant="unstyled"
|
||||
className={`${baseClass}__load-activities-button`}
|
||||
>
|
||||
<>
|
||||
<FleetIcon name="chevronleft" /> Previous
|
||||
</>
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!meta.has_next_results}
|
||||
onClick={onNextPage}
|
||||
variant="unstyled"
|
||||
className={`${baseClass}__load-activities-button`}
|
||||
>
|
||||
<>
|
||||
Next <FleetIcon name="chevronright" />
|
||||
</>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UpcomingActivityFeed;
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
.upcoming-activity-feed {
|
||||
margin-top: $pad-large;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
min-height: 500px;
|
||||
|
||||
&__empty-feed {
|
||||
min-height: 484px;
|
||||
}
|
||||
|
||||
&__pagination {
|
||||
position: absolute;
|
||||
bottom: 0px;
|
||||
right: 0px;
|
||||
|
||||
.button {
|
||||
font-weight: $bold;
|
||||
}
|
||||
}
|
||||
|
||||
&__load-activities-button {
|
||||
color: $core-vibrant-blue;
|
||||
vertical-align: bottom;
|
||||
padding: 6px;
|
||||
|
||||
.fleeticon-chevronleft,
|
||||
.fleeticon-chevronright {
|
||||
&:before {
|
||||
font-size: 0.5rem;
|
||||
font-weight: $bold;
|
||||
position: relative;
|
||||
top: -2px;
|
||||
}
|
||||
}
|
||||
|
||||
.fleeticon-chevronleft {
|
||||
margin-right: $pad-small;
|
||||
}
|
||||
|
||||
.fleeticon-chevronright {
|
||||
margin-left: $pad-small;
|
||||
}
|
||||
|
||||
&:first-of-type {
|
||||
margin-right: $pad-large;
|
||||
}
|
||||
|
||||
&:hover:not(.button--disabled),
|
||||
&:focus {
|
||||
background-color: $ui-vibrant-blue-10;
|
||||
}
|
||||
}
|
||||
|
||||
&__error {
|
||||
margin: $pad-xlarge 0;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./UpcomingActivityFeed";
|
||||
37
frontend/pages/hosts/details/cards/Activity/_styles.scss
Normal file
37
frontend/pages/hosts/details/cards/Activity/_styles.scss
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
.activity-card {
|
||||
padding: $pad-xxlarge;
|
||||
position: relative;
|
||||
|
||||
h2 {
|
||||
font-size: 20px;
|
||||
margin: 0 0 $pad-large;
|
||||
}
|
||||
|
||||
&__upcoming-count {
|
||||
padding: $pad-xxsmall $pad-xsmall;
|
||||
color: $core-white;
|
||||
background-color: $core-vibrant-blue;
|
||||
border-radius: $border-radius;
|
||||
font-weight: $bold;
|
||||
margin-left: $pad-small;
|
||||
}
|
||||
|
||||
&__loading-overlay {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 3;
|
||||
border-radius: 16px;
|
||||
background-color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
// styles to render the tooltip outside of the content area.
|
||||
&__upcoming-tooltip {
|
||||
font-size: $xx-small;
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
right: 0;
|
||||
}
|
||||
}
|
||||
1
frontend/pages/hosts/details/cards/Activity/index.ts
Normal file
1
frontend/pages/hosts/details/cards/Activity/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./Activity";
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import classnames from "classnames";
|
||||
import TooltipWrapper from "components/TooltipWrapper";
|
||||
import React from "react";
|
||||
|
||||
|
|
@ -18,6 +19,8 @@ const AgentOptions = ({
|
|||
wrapFleetHelper,
|
||||
isChromeOS = false,
|
||||
}: IAgentOptionsProps): JSX.Element => {
|
||||
const classNames = classnames(baseClass, "section", "osquery");
|
||||
|
||||
let configTLSRefresh;
|
||||
let loggerTLSPeriod;
|
||||
let distributedInterval;
|
||||
|
|
@ -44,7 +47,7 @@ const AgentOptions = ({
|
|||
}
|
||||
|
||||
return (
|
||||
<div className={`${baseClass} section osquery col-50`}>
|
||||
<div className={classNames}>
|
||||
{isChromeOS ? (
|
||||
<TooltipWrapper
|
||||
tipContent={CHROMEOS_AGENT_OPTIONS_TOOLTIP_MESSAGE}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,9 @@ import React from "react";
|
|||
import Button from "components/buttons/Button";
|
||||
import { ILabel } from "interfaces/label";
|
||||
import { enforceFleetSentenceCasing } from "utilities/strings/stringUtils";
|
||||
import classnames from "classnames";
|
||||
|
||||
const baseClass = "labels-card";
|
||||
|
||||
interface ILabelsProps {
|
||||
onLabelClick: (label: ILabel) => void;
|
||||
|
|
@ -10,6 +13,8 @@ interface ILabelsProps {
|
|||
}
|
||||
|
||||
const Labels = ({ onLabelClick, labels }: ILabelsProps): JSX.Element => {
|
||||
const classNames = classnames(baseClass, "section", "labels");
|
||||
|
||||
const labelItems = labels.map((label: ILabel) => {
|
||||
return (
|
||||
<li className="list__item" key={label.id}>
|
||||
|
|
@ -25,7 +30,7 @@ const Labels = ({ onLabelClick, labels }: ILabelsProps): JSX.Element => {
|
|||
});
|
||||
|
||||
return (
|
||||
<div className="section labels col-50">
|
||||
<div className={classNames}>
|
||||
<p className="section__header">Labels</p>
|
||||
{labels.length === 0 ? (
|
||||
<p className="info-flex__item">
|
||||
|
|
|
|||
|
|
@ -1,145 +0,0 @@
|
|||
import React, { useContext, useState } from "react";
|
||||
import { useQuery } from "react-query";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { InjectedRouter } from "react-router";
|
||||
|
||||
import PATHS from "router/paths";
|
||||
import scriptsAPI, {
|
||||
IHostScript,
|
||||
IHostScriptsResponse,
|
||||
} from "services/entities/scripts";
|
||||
import { IApiError } from "interfaces/errors";
|
||||
import { NotificationContext } from "context/notification";
|
||||
|
||||
import Card from "components/Card";
|
||||
import TableContainer from "components/TableContainer";
|
||||
import EmptyTable from "components/EmptyTable";
|
||||
import DataError from "components/DataError";
|
||||
import Spinner from "components/Spinner";
|
||||
import { ITableQueryData } from "components/TableContainer/TableContainer";
|
||||
import { IHost } from "interfaces/host";
|
||||
import { IUser } from "interfaces/user";
|
||||
import { AppContext } from "context/app";
|
||||
|
||||
import {
|
||||
generateDataSet,
|
||||
generateTableColumnConfigs,
|
||||
} from "./ScriptsTableConfig";
|
||||
|
||||
const baseClass = "host-scripts-section";
|
||||
|
||||
interface IScriptsProps {
|
||||
currentUser: IUser | null;
|
||||
host?: IHost;
|
||||
router: InjectedRouter;
|
||||
page?: number;
|
||||
onShowDetails: (scriptExecutionId: string) => void;
|
||||
}
|
||||
|
||||
const Scripts = ({
|
||||
currentUser,
|
||||
host,
|
||||
page = 0,
|
||||
router,
|
||||
onShowDetails,
|
||||
}: IScriptsProps) => {
|
||||
const [isScriptRunning, setIsScriptRunning] = useState(false);
|
||||
const { renderFlash } = useContext(NotificationContext);
|
||||
|
||||
const hostId = host?.id;
|
||||
|
||||
const {
|
||||
data: hostScriptResponse,
|
||||
isLoading: isLoadingScriptData,
|
||||
isError: isErrorScriptData,
|
||||
refetch: refetchScriptsData,
|
||||
} = useQuery<IHostScriptsResponse, IApiError>(
|
||||
["scripts", hostId, page],
|
||||
() => scriptsAPI.getHostScripts(hostId as number, page),
|
||||
{
|
||||
refetchOnWindowFocus: false,
|
||||
retry: false,
|
||||
enabled: Boolean(hostId),
|
||||
onSuccess: () => {
|
||||
setIsScriptRunning(false);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const { config } = useContext(AppContext);
|
||||
if (!config) return null;
|
||||
|
||||
if (!host) return null;
|
||||
|
||||
const onQueryChange = (data: ITableQueryData) => {
|
||||
router.push(`${PATHS.HOST_SCRIPTS(host.id)}?page=${data.pageIndex}`);
|
||||
};
|
||||
|
||||
const onActionSelection = async (action: string, script: IHostScript) => {
|
||||
switch (action) {
|
||||
case "showDetails":
|
||||
if (!script.last_execution) return;
|
||||
onShowDetails(script.last_execution.execution_id);
|
||||
break;
|
||||
case "run":
|
||||
try {
|
||||
setIsScriptRunning(true);
|
||||
await scriptsAPI.runScript({
|
||||
host_id: host.id,
|
||||
script_id: script.script_id,
|
||||
});
|
||||
refetchScriptsData();
|
||||
} catch (e) {
|
||||
const error = e as AxiosResponse<IApiError>;
|
||||
renderFlash("error", error.data.errors[0].reason);
|
||||
setIsScriptRunning(false);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
if (isErrorScriptData) {
|
||||
return <DataError card />;
|
||||
}
|
||||
const scriptColumnConfigs = generateTableColumnConfigs(
|
||||
onActionSelection,
|
||||
config.server_settings.scripts_disabled
|
||||
);
|
||||
const data = generateDataSet(
|
||||
currentUser,
|
||||
host,
|
||||
hostScriptResponse?.scripts || []
|
||||
);
|
||||
|
||||
return (
|
||||
<Card className={baseClass} borderRadiusSize="large" includeShadow>
|
||||
<h2>Scripts</h2>
|
||||
{isLoadingScriptData && <Spinner />}
|
||||
{!isLoadingScriptData && data && data.length === 0 && (
|
||||
<EmptyTable
|
||||
header="No scripts are available for this host"
|
||||
info="Expecting to see scripts? Try selecting “Refetch” to ask this host to report new vitals."
|
||||
/>
|
||||
)}
|
||||
{!isLoadingScriptData && data && data.length > 0 && (
|
||||
<TableContainer
|
||||
resultsTitle=""
|
||||
emptyComponent={() => <></>}
|
||||
showMarkAllPages={false}
|
||||
isAllPagesSelected={false}
|
||||
columnConfigs={scriptColumnConfigs}
|
||||
data={data}
|
||||
isLoading={isScriptRunning}
|
||||
onQueryChange={onQueryChange}
|
||||
disableNextPage={hostScriptResponse?.meta.has_next_results}
|
||||
defaultPageIndex={page}
|
||||
disableCount
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default Scripts;
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
.host-scripts-section {
|
||||
margin-top: $pad-medium;
|
||||
padding: $pad-xxlarge;
|
||||
|
||||
h2 {
|
||||
font-size: $medium;
|
||||
font-weight: $bold;
|
||||
margin: 0 0 $pad-medium 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
.name__header {
|
||||
width: 50%;
|
||||
}
|
||||
.last_execution__header {
|
||||
width: 25%;
|
||||
}
|
||||
.actions__header {
|
||||
width: 25%;
|
||||
}
|
||||
}
|
||||
|
||||
.data-table-block .data-table__wrapper {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.table-container__header-left {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.dropdown__option {
|
||||
[data-id="tooltip"][id^="run-script"] {
|
||||
font-size: $xx-small;
|
||||
font-style: normal;
|
||||
}
|
||||
}
|
||||
|
||||
.status-indicator-with-icon__value {
|
||||
display: initial;
|
||||
align-items: initial;
|
||||
vertical-align: initial;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
.script-status-cell {
|
||||
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
export { default } from "./Scripts";
|
||||
|
|
@ -9,7 +9,7 @@ const ORDER_KEY = "created_at";
|
|||
const ORDER_DIRECTION = "desc";
|
||||
|
||||
export interface IActivitiesResponse {
|
||||
activities: IActivity[];
|
||||
activities: IActivity[] | null;
|
||||
meta: {
|
||||
has_next_results: boolean;
|
||||
has_previous_results: boolean;
|
||||
|
|
@ -36,4 +36,42 @@ export default {
|
|||
|
||||
return sendRequest("GET", path);
|
||||
},
|
||||
|
||||
getHostPastActivities: (
|
||||
id: number,
|
||||
page = DEFAULT_PAGE,
|
||||
perPage = DEFAULT_PAGE_SIZE
|
||||
): Promise<IActivitiesResponse> => {
|
||||
const { HOST_PAST_ACTIVITIES } = endpoints;
|
||||
|
||||
const queryParams = {
|
||||
page,
|
||||
per_page: perPage,
|
||||
};
|
||||
|
||||
const queryString = buildQueryStringFromParams(queryParams);
|
||||
|
||||
const path = `${HOST_PAST_ACTIVITIES(id)}?${queryString}`;
|
||||
|
||||
return sendRequest("GET", path);
|
||||
},
|
||||
|
||||
getHostUpcomingActivities: (
|
||||
id: number,
|
||||
page = DEFAULT_PAGE,
|
||||
perPage = DEFAULT_PAGE_SIZE
|
||||
): Promise<IActivitiesResponse> => {
|
||||
const { HOST_UPCOMING_ACTIVITIES } = endpoints;
|
||||
|
||||
const queryParams = {
|
||||
page,
|
||||
per_page: perPage,
|
||||
};
|
||||
|
||||
const queryString = buildQueryStringFromParams(queryParams);
|
||||
|
||||
const path = `${HOST_UPCOMING_ACTIVITIES(id)}?${queryString}`;
|
||||
|
||||
return sendRequest("GET", path);
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,15 +1,8 @@
|
|||
import { IScript, IHostScript } from "interfaces/script";
|
||||
import sendRequest from "services";
|
||||
import endpoints from "utilities/endpoints";
|
||||
import { buildQueryStringFromParams } from "utilities/url";
|
||||
|
||||
export interface IScript {
|
||||
id: number;
|
||||
team_id: number | null;
|
||||
name: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/** Single script response from GET /script/:id */
|
||||
export type IScriptResponse = IScript;
|
||||
|
||||
|
|
@ -40,6 +33,7 @@ export interface IScriptResultResponse {
|
|||
host_id: number;
|
||||
execution_id: string;
|
||||
script_contents: string;
|
||||
script_id: number;
|
||||
exit_code: number | null;
|
||||
output: string;
|
||||
message: string;
|
||||
|
|
@ -47,18 +41,17 @@ export interface IScriptResultResponse {
|
|||
host_timeout: boolean;
|
||||
}
|
||||
|
||||
export type IScriptExecutionStatus = "ran" | "pending" | "error";
|
||||
|
||||
export interface ILastExecution {
|
||||
execution_id: string;
|
||||
executed_at: string;
|
||||
status: IScriptExecutionStatus;
|
||||
/**
|
||||
* Request params for for GET /hosts/:id/scripts
|
||||
*/
|
||||
export interface IHostScriptsRequestParams {
|
||||
host_id: number;
|
||||
page?: number;
|
||||
per_page?: number;
|
||||
}
|
||||
|
||||
export interface IHostScript {
|
||||
script_id: number;
|
||||
name: string;
|
||||
last_execution: ILastExecution | null;
|
||||
export interface IHostScriptsQueryKey extends IHostScriptsRequestParams {
|
||||
scope: "host_scripts";
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -94,13 +87,13 @@ export interface IScriptRunResponse {
|
|||
}
|
||||
|
||||
export default {
|
||||
getHostScripts(id: number, page?: number) {
|
||||
getHostScripts({ host_id, page, per_page }: IHostScriptsRequestParams) {
|
||||
const { HOST_SCRIPTS } = endpoints;
|
||||
const path = `${HOST_SCRIPTS(host_id)}?${buildQueryStringFromParams({
|
||||
page,
|
||||
per_page,
|
||||
})}`;
|
||||
|
||||
let path = HOST_SCRIPTS(id);
|
||||
if (page) {
|
||||
path = `${path}?${buildQueryStringFromParams({ page })}`;
|
||||
}
|
||||
return sendRequest("GET", path);
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,15 @@
|
|||
const API_VERSION = "latest";
|
||||
|
||||
export default {
|
||||
// activities
|
||||
ACTIVITIES: `/${API_VERSION}/fleet/activities`,
|
||||
HOST_PAST_ACTIVITIES: (id: number): string => {
|
||||
return `/${API_VERSION}/fleet/hosts/${id}/activities`;
|
||||
},
|
||||
HOST_UPCOMING_ACTIVITIES: (id: number): string => {
|
||||
return `/${API_VERSION}/fleet/hosts/${id}/activities/upcoming`;
|
||||
},
|
||||
|
||||
CHANGE_PASSWORD: `/${API_VERSION}/fleet/change_password`,
|
||||
CONFIG: `/${API_VERSION}/fleet/config`,
|
||||
CONFIRM_EMAIL_CHANGE: (token: string): string => {
|
||||
|
|
|
|||
|
|
@ -480,6 +480,16 @@ export const formatPackForClient = (pack: IPack): IPack => {
|
|||
return pack;
|
||||
};
|
||||
|
||||
export const formatScriptNameForActivityItem = (name: string | undefined) => {
|
||||
return name ? (
|
||||
<>
|
||||
the <b>{name}</b> script
|
||||
</>
|
||||
) : (
|
||||
"a script"
|
||||
);
|
||||
};
|
||||
|
||||
export const generateRole = (
|
||||
teams: ITeam[],
|
||||
globalRole: UserRole | null
|
||||
|
|
@ -876,6 +886,7 @@ export default {
|
|||
formatFloatAsPercentage,
|
||||
formatScheduledQueryForClient,
|
||||
formatScheduledQueryForServer,
|
||||
formatScriptNameForActivityItem,
|
||||
formatGlobalScheduledQueryForClient,
|
||||
formatGlobalScheduledQueryForServer,
|
||||
formatTeamScheduledQueryForClient,
|
||||
|
|
|
|||
2
orbit/changes/15963-fleetd-agent
Normal file
2
orbit/changes/15963-fleetd-agent
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
- Updated script running logic to stop running scripts if the script content can't be fetched from
|
||||
Fleet, which will preserve the order in which the scripts are queued.
|
||||
|
|
@ -9,7 +9,6 @@ import (
|
|||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
|
|
@ -59,42 +58,34 @@ func (r *Runner) Run(execIDs []string) error {
|
|||
continue
|
||||
}
|
||||
|
||||
if err := r.runOne(execID); err != nil {
|
||||
script, err := r.Client.GetHostScript(execID)
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("get host script: %w", err))
|
||||
// Stop here since we want to preserve the order in which scripts are queued.
|
||||
break
|
||||
}
|
||||
|
||||
if err := r.runOne(script); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
|
||||
if len(errs) > 0 {
|
||||
// NOTE: when we upgrade to Go1.20, we can use errors.Join, but for now we
|
||||
// just concatenate the error messages in a single error that will be logged
|
||||
// by orbit.
|
||||
var sb strings.Builder
|
||||
for i, e := range errs {
|
||||
if i > 0 {
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
sb.WriteString(e.Error())
|
||||
}
|
||||
return errors.New(sb.String())
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Runner) runOne(execID string) (finalErr error) {
|
||||
func (r *Runner) runOne(script *fleet.HostScriptResult) (finalErr error) {
|
||||
const maxOutputRuneLen = 10000
|
||||
|
||||
script, err := r.Client.GetHostScript(execID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get host script: %w", err)
|
||||
}
|
||||
|
||||
if script.ExitCode != nil {
|
||||
// already a result stored for this execution, skip, it shouldn't be sent
|
||||
// again by Fleet.
|
||||
return nil
|
||||
}
|
||||
|
||||
runDir, err := r.createRunDir(execID)
|
||||
runDir, err := r.createRunDir(script.ExecutionID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create run directory: %w", err)
|
||||
}
|
||||
|
|
@ -147,7 +138,7 @@ func (r *Runner) runOne(execID string) (finalErr error) {
|
|||
}
|
||||
|
||||
err = r.Client.SaveHostScriptResult(&fleet.HostScriptResultPayload{
|
||||
ExecutionID: execID,
|
||||
ExecutionID: script.ExecutionID,
|
||||
Output: string(output),
|
||||
Runtime: int(duration.Seconds()),
|
||||
ExitCode: exitCode,
|
||||
|
|
|
|||
|
|
@ -132,13 +132,22 @@ func TestRunner(t *testing.T) {
|
|||
errContains: "", // no errors reported, script is just skipped
|
||||
},
|
||||
{
|
||||
desc: "multiple errors reported, one get fails, one non-existing",
|
||||
desc: "first script get error",
|
||||
client: &mockClient{getErr: errFailOnce, scripts: map[string]*fleet.HostScriptResult{"a": {ExitCode: ptr.Int64(0)}}},
|
||||
execer: &mockExecCmd{},
|
||||
enabled: true,
|
||||
execIDs: []string{"a", "b"},
|
||||
execCalls: 0,
|
||||
errContains: "get host script: fail once\nget host script: no such script: b",
|
||||
errContains: "get host script: fail once",
|
||||
},
|
||||
{
|
||||
desc: "middle script get error",
|
||||
client: &mockClient{scripts: map[string]*fleet.HostScriptResult{"a": {ExitCode: ptr.Int64(0)}, "b": {}, "c": {}}, erroredScripts: map[string]error{"b": errFailOnce}},
|
||||
execer: &mockExecCmd{},
|
||||
enabled: true,
|
||||
execIDs: []string{"a", "b", "c"},
|
||||
execCalls: 0,
|
||||
errContains: "get host script: fail once",
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
|
|
@ -164,7 +173,7 @@ func TestRunnerTempDir(t *testing.T) {
|
|||
t.Run("deletes temp dir", func(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
client := &mockClient{scripts: map[string]*fleet.HostScriptResult{"a": {ScriptContents: "echo 'Hi'"}}}
|
||||
client := &mockClient{scripts: map[string]*fleet.HostScriptResult{"a": {ScriptContents: "echo 'Hi'", ExecutionID: "a"}}}
|
||||
execer := &mockExecCmd{output: []byte("output"), exitCode: 0, err: nil}
|
||||
runner := &Runner{
|
||||
Client: client,
|
||||
|
|
@ -228,7 +237,7 @@ func TestRunnerTempDir(t *testing.T) {
|
|||
tempDir := t.TempDir()
|
||||
t.Setenv("FLEET_PREVENT_SCRIPT_TEMPDIR_DELETION", "1")
|
||||
|
||||
client := &mockClient{scripts: map[string]*fleet.HostScriptResult{"a": {ScriptContents: "echo 'Hi'"}}}
|
||||
client := &mockClient{scripts: map[string]*fleet.HostScriptResult{"a": {ScriptContents: "echo 'Hi'", ExecutionID: "a"}}}
|
||||
execer := &mockExecCmd{output: []byte("output"), exitCode: 0, err: nil}
|
||||
runner := &Runner{
|
||||
Client: client,
|
||||
|
|
@ -315,7 +324,7 @@ func TestRunnerResults(t *testing.T) {
|
|||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.desc, func(t *testing.T) {
|
||||
client := &mockClient{scripts: map[string]*fleet.HostScriptResult{"a": {ScriptContents: "echo 'Hi'"}}}
|
||||
client := &mockClient{scripts: map[string]*fleet.HostScriptResult{"a": {ScriptContents: "echo 'Hi'", ExecutionID: "a"}}}
|
||||
execer := &mockExecCmd{output: []byte(c.output), exitCode: c.exitCode, err: c.runErr}
|
||||
runner := &Runner{
|
||||
Client: client,
|
||||
|
|
@ -351,10 +360,11 @@ func (m *mockExecCmd) run(ctx context.Context, scriptPath string) ([]byte, int,
|
|||
var errFailOnce = errors.New("fail once")
|
||||
|
||||
type mockClient struct {
|
||||
scripts map[string]*fleet.HostScriptResult
|
||||
results map[string]*fleet.HostScriptResultPayload
|
||||
getErr error
|
||||
saveErr error
|
||||
scripts map[string]*fleet.HostScriptResult
|
||||
results map[string]*fleet.HostScriptResultPayload
|
||||
getErr error
|
||||
saveErr error
|
||||
erroredScripts map[string]error
|
||||
}
|
||||
|
||||
func (m *mockClient) GetHostScript(execID string) (*fleet.HostScriptResult, error) {
|
||||
|
|
@ -366,10 +376,19 @@ func (m *mockClient) GetHostScript(execID string) (*fleet.HostScriptResult, erro
|
|||
return nil, err
|
||||
}
|
||||
|
||||
if m.erroredScripts == nil {
|
||||
m.erroredScripts = make(map[string]error)
|
||||
}
|
||||
|
||||
script := m.scripts[execID]
|
||||
if script == nil {
|
||||
return nil, fmt.Errorf("no such script: %s", execID)
|
||||
}
|
||||
|
||||
if err, ok := m.erroredScripts[execID]; ok {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return script, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -40,16 +40,35 @@ func (ds *Datastore) NewActivity(ctx context.Context, user *fleet.User, activity
|
|||
cols = append(cols, "user_email")
|
||||
}
|
||||
|
||||
insertStmt := `INSERT INTO activities (%s) VALUES (%s)`
|
||||
sql := fmt.Sprintf(insertStmt, strings.Join(cols, ","), strings.Repeat("?,", len(cols)-1)+"?")
|
||||
_, err = ds.writer(ctx).ExecContext(ctx,
|
||||
sql,
|
||||
args...,
|
||||
)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "new activity")
|
||||
}
|
||||
return nil
|
||||
return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error {
|
||||
const insertActStmt = `INSERT INTO activities (%s) VALUES (%s)`
|
||||
sql := fmt.Sprintf(insertActStmt, strings.Join(cols, ","), strings.Repeat("?,", len(cols)-1)+"?")
|
||||
res, err := tx.ExecContext(ctx, sql, args...)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "new activity")
|
||||
}
|
||||
|
||||
// this supposes a reasonable amount of hosts per activity, to revisit if we
|
||||
// get in the 10K+.
|
||||
if ah, ok := activity.(fleet.ActivityHosts); ok {
|
||||
const insertActHostStmt = `INSERT INTO host_activities (host_id, activity_id) VALUES `
|
||||
|
||||
var sb strings.Builder
|
||||
if hostIDs := ah.HostIDs(); len(hostIDs) > 0 {
|
||||
sb.WriteString(insertActHostStmt)
|
||||
actID, _ := res.LastInsertId()
|
||||
for _, hid := range hostIDs {
|
||||
sb.WriteString(fmt.Sprintf("(%d, %d),", hid, actID))
|
||||
}
|
||||
|
||||
stmt := strings.TrimSuffix(sb.String(), ",")
|
||||
if _, err := tx.ExecContext(ctx, stmt); err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "insert host activity")
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// ListActivities returns a slice of activities performed across the organization
|
||||
|
|
@ -164,3 +183,94 @@ func (ds *Datastore) MarkActivitiesAsStreamed(ctx context.Context, activityIDs [
|
|||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ds *Datastore) ListHostUpcomingActivities(ctx context.Context, hostID uint, opt fleet.ListOptions) ([]*fleet.Activity, *fleet.PaginationMetadata, error) {
|
||||
const listStmt = `
|
||||
SELECT
|
||||
hsr.execution_id as uuid,
|
||||
u.name as name,
|
||||
u.id as user_id,
|
||||
u.gravatar_url as gravatar_url,
|
||||
u.email as user_email,
|
||||
? as activity_type,
|
||||
hsr.created_at as created_at,
|
||||
JSON_OBJECT(
|
||||
'host_id', hsr.host_id,
|
||||
'host_display_name', COALESCE(hdn.display_name, ''),
|
||||
'script_name', COALESCE(scr.name, ''),
|
||||
'script_execution_id', hsr.execution_id,
|
||||
'async', NOT hsr.sync_request
|
||||
) as details
|
||||
FROM
|
||||
host_script_results hsr
|
||||
LEFT OUTER JOIN
|
||||
users u ON u.id = hsr.user_id
|
||||
LEFT OUTER JOIN
|
||||
host_display_names hdn ON hdn.host_id = hsr.host_id
|
||||
LEFT OUTER JOIN
|
||||
scripts scr ON scr.id = hsr.script_id
|
||||
WHERE
|
||||
hsr.host_id = ? AND
|
||||
hsr.exit_code IS NULL
|
||||
`
|
||||
|
||||
args := []any{fleet.ActivityTypeRanScript{}.ActivityName(), hostID}
|
||||
stmt, args := appendListOptionsWithCursorToSQL(listStmt, args, &opt)
|
||||
|
||||
var activities []*fleet.Activity
|
||||
if err := sqlx.SelectContext(ctx, ds.reader(ctx), &activities, stmt, args...); err != nil {
|
||||
return nil, nil, ctxerr.Wrap(ctx, err, "select upcoming activities")
|
||||
}
|
||||
|
||||
var metaData *fleet.PaginationMetadata
|
||||
if opt.IncludeMetadata {
|
||||
metaData = &fleet.PaginationMetadata{HasPreviousResults: opt.Page > 0}
|
||||
if len(activities) > int(opt.PerPage) {
|
||||
metaData.HasNextResults = true
|
||||
activities = activities[:len(activities)-1]
|
||||
}
|
||||
}
|
||||
|
||||
return activities, metaData, nil
|
||||
}
|
||||
|
||||
func (ds *Datastore) ListHostPastActivities(ctx context.Context, hostID uint, opt fleet.ListOptions) ([]*fleet.Activity, *fleet.PaginationMetadata, error) {
|
||||
const listStmt = `
|
||||
SELECT
|
||||
ha.activity_id as id,
|
||||
a.user_email as user_email,
|
||||
a.user_name as name,
|
||||
a.activity_type as activity_type,
|
||||
a.details as details,
|
||||
u.gravatar_url as gravatar_url,
|
||||
a.created_at as created_at,
|
||||
u.id as user_id
|
||||
FROM
|
||||
host_activities ha
|
||||
JOIN activities a
|
||||
ON ha.activity_id = a.id
|
||||
LEFT OUTER JOIN
|
||||
users u ON u.id = a.user_id
|
||||
WHERE
|
||||
ha.host_id = ?
|
||||
`
|
||||
|
||||
args := []any{hostID}
|
||||
stmt, args := appendListOptionsWithCursorToSQL(listStmt, args, &opt)
|
||||
|
||||
var activities []*fleet.Activity
|
||||
if err := sqlx.SelectContext(ctx, ds.reader(ctx), &activities, stmt, args...); err != nil {
|
||||
return nil, nil, ctxerr.Wrap(ctx, err, "select upcoming activities")
|
||||
}
|
||||
|
||||
var metaData *fleet.PaginationMetadata
|
||||
if opt.IncludeMetadata {
|
||||
metaData = &fleet.PaginationMetadata{HasPreviousResults: opt.Page > 0}
|
||||
if len(activities) > int(opt.PerPage) {
|
||||
metaData.HasNextResults = true
|
||||
activities = activities[:len(activities)-1]
|
||||
}
|
||||
}
|
||||
|
||||
return activities, metaData, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,9 +6,11 @@ import (
|
|||
"fmt"
|
||||
"sort"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
"github.com/fleetdm/fleet/v4/server/ptr"
|
||||
"github.com/fleetdm/fleet/v4/server/test"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
|
@ -25,6 +27,8 @@ func TestActivity(t *testing.T) {
|
|||
{"ListActivitiesStreamed", testListActivitiesStreamed},
|
||||
{"EmptyUser", testActivityEmptyUser},
|
||||
{"PaginationMetadata", testActivityPaginationMetadata},
|
||||
{"ListHostUpcomingActivities", testListHostUpcomingActivities},
|
||||
{"ListHostPastActivities", testListHostPastActivities},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
|
|
@ -37,6 +41,7 @@ func TestActivity(t *testing.T) {
|
|||
type dummyActivity struct {
|
||||
name string `json:"-"`
|
||||
details map[string]interface{}
|
||||
hostIDs []uint
|
||||
}
|
||||
|
||||
func (d dummyActivity) MarshalJSON() ([]byte, error) {
|
||||
|
|
@ -55,6 +60,10 @@ func (d dummyActivity) Documentation() (activity string, details string, details
|
|||
return "", "", ""
|
||||
}
|
||||
|
||||
func (d dummyActivity) HostIDs() []uint {
|
||||
return d.hostIDs
|
||||
}
|
||||
|
||||
func testActivityUsernameChange(t *testing.T, ds *Datastore) {
|
||||
u := &fleet.User{
|
||||
Password: []byte("asd"),
|
||||
|
|
@ -289,3 +298,258 @@ func testActivityPaginationMetadata(t *testing.T, ds *Datastore) {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
func testListHostUpcomingActivities(t *testing.T, ds *Datastore) {
|
||||
ctx := context.Background()
|
||||
|
||||
u := test.NewUser(t, ds, "user1", "user1@example.com", false)
|
||||
|
||||
// create three hosts
|
||||
h1 := test.NewHost(t, ds, "h1.local", "10.10.10.1", "1", "1", time.Now())
|
||||
h2 := test.NewHost(t, ds, "h2.local", "10.10.10.2", "2", "2", time.Now())
|
||||
h3 := test.NewHost(t, ds, "h3.local", "10.10.10.3", "3", "3", time.Now())
|
||||
|
||||
// create a couple of named scripts
|
||||
scr1, err := ds.NewScript(ctx, &fleet.Script{
|
||||
Name: "A",
|
||||
ScriptContents: "A",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
scr2, err := ds.NewScript(ctx, &fleet.Script{
|
||||
Name: "B",
|
||||
ScriptContents: "B",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// create some script requests for h1
|
||||
hsr, err := ds.NewHostScriptExecutionRequest(ctx, &fleet.HostScriptRequestPayload{HostID: h1.ID, ScriptID: &scr1.ID, ScriptContents: scr1.ScriptContents, UserID: &u.ID})
|
||||
require.NoError(t, err)
|
||||
h1A := hsr.ExecutionID
|
||||
hsr, err = ds.NewHostScriptExecutionRequest(ctx, &fleet.HostScriptRequestPayload{HostID: h1.ID, ScriptID: &scr2.ID, ScriptContents: scr2.ScriptContents, UserID: &u.ID})
|
||||
require.NoError(t, err)
|
||||
h1B := hsr.ExecutionID
|
||||
hsr, err = ds.NewHostScriptExecutionRequest(ctx, &fleet.HostScriptRequestPayload{HostID: h1.ID, ScriptContents: "C", UserID: &u.ID})
|
||||
require.NoError(t, err)
|
||||
h1C := hsr.ExecutionID
|
||||
hsr, err = ds.NewHostScriptExecutionRequest(ctx, &fleet.HostScriptRequestPayload{HostID: h1.ID, ScriptContents: "D"})
|
||||
require.NoError(t, err)
|
||||
h1D := hsr.ExecutionID
|
||||
hsr, err = ds.NewHostScriptExecutionRequest(ctx, &fleet.HostScriptRequestPayload{HostID: h1.ID, ScriptContents: "E"})
|
||||
require.NoError(t, err)
|
||||
h1E := hsr.ExecutionID
|
||||
|
||||
// create a single pending request for h2, as well as a non-pending one
|
||||
hsr, err = ds.NewHostScriptExecutionRequest(ctx, &fleet.HostScriptRequestPayload{HostID: h2.ID, ScriptID: &scr1.ID, ScriptContents: scr1.ScriptContents, UserID: &u.ID})
|
||||
require.NoError(t, err)
|
||||
h2A := hsr.ExecutionID
|
||||
hsr, err = ds.NewHostScriptExecutionRequest(ctx, &fleet.HostScriptRequestPayload{HostID: h2.ID, ScriptContents: "F", UserID: &u.ID})
|
||||
require.NoError(t, err)
|
||||
_, err = ds.SetHostScriptExecutionResult(ctx, &fleet.HostScriptResultPayload{HostID: h2.ID, ExecutionID: hsr.ExecutionID, Output: "ok", ExitCode: 0})
|
||||
require.NoError(t, err)
|
||||
h2F := hsr.ExecutionID
|
||||
|
||||
// no script request for h3
|
||||
|
||||
execIDsWithUser := map[string]bool{
|
||||
h1A: true,
|
||||
h1B: true,
|
||||
h1C: true,
|
||||
h1D: false,
|
||||
h1E: false,
|
||||
h2A: true,
|
||||
h2F: true,
|
||||
}
|
||||
execIDsScriptName := map[string]string{
|
||||
h1A: scr1.Name,
|
||||
h1B: scr2.Name,
|
||||
h2A: scr1.Name,
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
opts fleet.ListOptions
|
||||
hostID uint
|
||||
wantExecs []string
|
||||
wantMeta *fleet.PaginationMetadata
|
||||
}{
|
||||
{
|
||||
opts: fleet.ListOptions{PerPage: 2},
|
||||
hostID: h1.ID,
|
||||
wantExecs: []string{h1A, h1B},
|
||||
wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: false},
|
||||
},
|
||||
{
|
||||
opts: fleet.ListOptions{Page: 1, PerPage: 2},
|
||||
hostID: h1.ID,
|
||||
wantExecs: []string{h1C, h1D},
|
||||
wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: true},
|
||||
},
|
||||
{
|
||||
opts: fleet.ListOptions{Page: 2, PerPage: 2},
|
||||
hostID: h1.ID,
|
||||
wantExecs: []string{h1E},
|
||||
wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true},
|
||||
},
|
||||
{
|
||||
opts: fleet.ListOptions{PerPage: 3},
|
||||
hostID: h1.ID,
|
||||
wantExecs: []string{h1A, h1B, h1C},
|
||||
wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: false},
|
||||
},
|
||||
{
|
||||
opts: fleet.ListOptions{Page: 1, PerPage: 3},
|
||||
hostID: h1.ID,
|
||||
wantExecs: []string{h1D, h1E},
|
||||
wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true},
|
||||
},
|
||||
{
|
||||
opts: fleet.ListOptions{Page: 2, PerPage: 3},
|
||||
hostID: h1.ID,
|
||||
wantExecs: []string{},
|
||||
wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true},
|
||||
},
|
||||
{
|
||||
opts: fleet.ListOptions{PerPage: 3},
|
||||
hostID: h2.ID,
|
||||
wantExecs: []string{h2A},
|
||||
wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: false},
|
||||
},
|
||||
{
|
||||
opts: fleet.ListOptions{},
|
||||
hostID: h3.ID,
|
||||
wantExecs: []string{},
|
||||
wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: false},
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(fmt.Sprintf("%v: %#v", c.hostID, c.opts), func(t *testing.T) {
|
||||
// always include metadata
|
||||
c.opts.IncludeMetadata = true
|
||||
c.opts.OrderKey = "created_at"
|
||||
c.opts.OrderDirection = fleet.OrderAscending
|
||||
|
||||
acts, meta, err := ds.ListHostUpcomingActivities(ctx, c.hostID, c.opts)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, len(c.wantExecs), len(acts))
|
||||
require.Equal(t, c.wantMeta, meta)
|
||||
|
||||
for i, a := range acts {
|
||||
wantExec := c.wantExecs[i]
|
||||
|
||||
var details map[string]any
|
||||
require.NotNil(t, a.Details, "result %d", i)
|
||||
require.NoError(t, json.Unmarshal([]byte(*a.Details), &details), "result %d", i)
|
||||
|
||||
require.Equal(t, wantExec, details["script_execution_id"], "result %d", i)
|
||||
require.Equal(t, c.hostID, uint(details["host_id"].(float64)), "result %d", i)
|
||||
require.Equal(t, execIDsScriptName[wantExec], details["script_name"], "result %d", i)
|
||||
if execIDsWithUser[wantExec] {
|
||||
require.NotNil(t, a.ActorID, "result %d", i)
|
||||
require.Equal(t, u.ID, *a.ActorID, "result %d", i)
|
||||
require.NotNil(t, a.ActorFullName, "result %d", i)
|
||||
require.Equal(t, u.Name, *a.ActorFullName, "result %d", i)
|
||||
require.NotNil(t, a.ActorEmail, "result %d", i)
|
||||
require.Equal(t, u.Email, *a.ActorEmail, "result %d", i)
|
||||
} else {
|
||||
require.Nil(t, a.ActorID, "result %d", i)
|
||||
require.Nil(t, a.ActorFullName, "result %d", i)
|
||||
require.Nil(t, a.ActorEmail, "result %d", i)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func testListHostPastActivities(t *testing.T, ds *Datastore) {
|
||||
ctx := context.Background()
|
||||
|
||||
getDetails := func(a *fleet.Activity) map[string]any {
|
||||
details := make(map[string]any)
|
||||
err := json.Unmarshal([]byte(*a.Details), &details)
|
||||
require.NoError(t, err)
|
||||
|
||||
return details
|
||||
}
|
||||
|
||||
u := test.NewUser(t, ds, "user1", "user1@example.com", false)
|
||||
h1 := test.NewHost(t, ds, "h1.local", "10.10.10.1", "1", "1", time.Now())
|
||||
activities := []dummyActivity{
|
||||
{
|
||||
name: "ran_script",
|
||||
details: map[string]any{"host_id": float64(h1.ID), "host_display_name": h1.DisplayName(), "script_execution_id": "exec_1", "script_name": "script_1.sh", "async": true},
|
||||
hostIDs: []uint{h1.ID},
|
||||
},
|
||||
|
||||
{
|
||||
name: "ran_script",
|
||||
details: map[string]any{"host_id": float64(h1.ID), "host_display_name": h1.DisplayName(), "script_execution_id": "exec_2", "async": false},
|
||||
hostIDs: []uint{h1.ID},
|
||||
},
|
||||
}
|
||||
|
||||
for _, a := range activities {
|
||||
require.NoError(t, ds.NewActivity(context.Background(), u, a))
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
expActs []dummyActivity
|
||||
opts fleet.ListActivitiesOptions
|
||||
expMeta *fleet.PaginationMetadata
|
||||
}{
|
||||
{
|
||||
name: "fetch page one",
|
||||
expActs: []dummyActivity{activities[0]},
|
||||
expMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: false},
|
||||
opts: fleet.ListActivitiesOptions{
|
||||
ListOptions: fleet.ListOptions{
|
||||
Page: 0,
|
||||
PerPage: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "fetch page two",
|
||||
expActs: []dummyActivity{activities[1]},
|
||||
expMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true},
|
||||
opts: fleet.ListActivitiesOptions{
|
||||
ListOptions: fleet.ListOptions{
|
||||
Page: 1,
|
||||
PerPage: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "fetch all activities",
|
||||
expActs: activities,
|
||||
expMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: false},
|
||||
opts: fleet.ListActivitiesOptions{
|
||||
ListOptions: fleet.ListOptions{
|
||||
Page: 0,
|
||||
PerPage: 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
c.opts.ListOptions.IncludeMetadata = true
|
||||
acts, meta, err := ds.ListHostPastActivities(ctx, h1.ID, c.opts.ListOptions)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, acts, len(c.expActs))
|
||||
require.Equal(t, c.expMeta, meta)
|
||||
|
||||
// check fields in activities
|
||||
for i, ra := range acts {
|
||||
require.Equal(t, u.Email, *ra.ActorEmail)
|
||||
require.Equal(t, u.Name, *ra.ActorFullName)
|
||||
require.Equal(t, "ran_script", ra.Type)
|
||||
require.Equal(t, u.GravatarURL, *ra.ActorGravatar)
|
||||
require.Equal(t, u.ID, *ra.ActorID)
|
||||
details := getDetails(ra)
|
||||
for k, v := range details {
|
||||
require.Equal(t, c.expActs[i].details[k], v)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -492,6 +492,7 @@ var hostRefs = []string{
|
|||
"host_software_installed_paths",
|
||||
"host_script_results",
|
||||
"query_results",
|
||||
"host_activities",
|
||||
}
|
||||
|
||||
// NOTE: The following tables are explicity excluded from hostRefs list and accordingly are not
|
||||
|
|
|
|||
|
|
@ -6340,6 +6340,16 @@ func testHostsDeleteHosts(t *testing.T, ds *Datastore) {
|
|||
`, host.UUID)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = ds.NewActivity( // automatically creates the host_activities entry
|
||||
context.Background(),
|
||||
user1,
|
||||
fleet.ActivityTypeRanScript{
|
||||
HostID: host.ID,
|
||||
HostDisplayName: host.DisplayName(),
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Check there's an entry for the host in all the associated tables.
|
||||
for _, hostRef := range hostRefs {
|
||||
var ok bool
|
||||
|
|
|
|||
|
|
@ -0,0 +1,57 @@
|
|||
package tables
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
func init() {
|
||||
MigrationClient.AddMigration(Up_20240126020643, Down_20240126020643)
|
||||
}
|
||||
|
||||
func Up_20240126020643(tx *sql.Tx) error {
|
||||
// add user_id to host_script_results so that we can display the "actor" in
|
||||
// the upcoming activities for a host (who requested the script execution).
|
||||
// Unlike for activities, we don't copy over all the user's information,
|
||||
// instead we just link to the existing user and set it to NULL if the user
|
||||
// gets deleted. This is because the script executions are expected to run
|
||||
// soon after the request is made, it should be a rare occurrence for the
|
||||
// requesting user to be deleted before it runs.
|
||||
//
|
||||
// sync_request indicates if the script execution was requested via the
|
||||
// synchronous API. We need this information to generate the proper activity
|
||||
// details later on when the results are received.
|
||||
const alterStmt = `
|
||||
ALTER TABLE host_script_results
|
||||
ADD COLUMN user_id INT(10) UNSIGNED DEFAULT NULL,
|
||||
ADD COLUMN sync_request TINYINT(1) NOT NULL DEFAULT '0',
|
||||
ADD CONSTRAINT fk_host_script_results_user_id FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE SET NULL;
|
||||
`
|
||||
if _, err := tx.Exec(alterStmt); err != nil {
|
||||
return fmt.Errorf("add user_id to host_script_results: %w", err)
|
||||
}
|
||||
|
||||
// Note that we don't create FKs to hosts for performance reasons (ingestion
|
||||
// of data at scale). FK is created for activities, those entries should be
|
||||
// deleted if for some reason the activity is deleted.
|
||||
const hostActivitiesStmt = `
|
||||
CREATE TABLE IF NOT EXISTS host_activities (
|
||||
host_id INT(10) UNSIGNED NOT NULL,
|
||||
activity_id INT(10) UNSIGNED NOT NULL,
|
||||
|
||||
PRIMARY KEY (host_id, activity_id),
|
||||
FOREIGN KEY fk_host_activities_activity_id (activity_id) REFERENCES activities (id) ON DELETE CASCADE
|
||||
);
|
||||
`
|
||||
if _, err := tx.Exec(hostActivitiesStmt); err != nil {
|
||||
return errors.Wrap(err, "create host_activities table")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func Down_20240126020643(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
package tables
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestUp_20240126020643(t *testing.T) {
|
||||
db := applyUpToPrev(t)
|
||||
|
||||
// create a couple users
|
||||
u1 := execNoErrLastID(t, db, `INSERT INTO users (name, email, password, salt) VALUES (?, ?, ?, ?)`, "u1", "u1@b.c", "1234", "salt")
|
||||
u2 := execNoErrLastID(t, db, `INSERT INTO users (name, email, password, salt) VALUES (?, ?, ?, ?)`, "u2", "u2@b.c", "1234", "salt")
|
||||
|
||||
// create an activity
|
||||
act1 := execNoErrLastID(t, db, `INSERT INTO activities (user_id, user_name, user_email, activity_type) VALUES (?, ?, ?, ?)`, u1, "u1", "u1@b.c", "act1")
|
||||
|
||||
// create a host execution request in the past
|
||||
minutesAgo := time.Now().UTC().Add(-5 * time.Minute).Truncate(time.Second)
|
||||
hsr1 := execNoErrLastID(t, db, `INSERT INTO host_script_results (host_id, execution_id, script_contents, output, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)`, 1, uuid.NewString(), "echo 'hello'", "", minutesAgo, minutesAgo)
|
||||
|
||||
// Apply current migration.
|
||||
applyNext(t, db)
|
||||
|
||||
// existing host execution request's timestamp hasn't changed (despite added column)
|
||||
type timestamps struct {
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at"`
|
||||
}
|
||||
|
||||
var ts timestamps
|
||||
err := db.Get(&ts, `SELECT created_at, updated_at FROM host_script_results WHERE id = ?`, hsr1)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, minutesAgo, ts.CreatedAt)
|
||||
assert.Equal(t, minutesAgo, ts.UpdatedAt)
|
||||
|
||||
// create a new host execution request with user u1 and one with u2
|
||||
hsr2 := execNoErrLastID(t, db, `INSERT INTO host_script_results (host_id, execution_id, script_contents, output, user_id) VALUES (?, ?, ?, ?, ?)`, 1, uuid.NewString(), "echo 'hello'", "", u1)
|
||||
hsr3 := execNoErrLastID(t, db, `INSERT INTO host_script_results (host_id, execution_id, script_contents, output, user_id) VALUES (?, ?, ?, ?, ?)`, 1, uuid.NewString(), "echo 'hello'", "", u2)
|
||||
|
||||
// create a host activity entry for act1
|
||||
execNoErr(t, db, `INSERT INTO host_activities (host_id, activity_id) VALUES (?, ?)`, 1, act1)
|
||||
|
||||
// delete user u1
|
||||
execNoErr(t, db, `DELETE FROM users WHERE id = ?`, u1)
|
||||
|
||||
var userID sql.NullInt64
|
||||
// hsr2 now has a NULL user id, but hsr3 still has user id u2
|
||||
err = db.Get(&userID, `SELECT user_id FROM host_script_results WHERE id = ?`, hsr2)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, userID.Valid)
|
||||
err = db.Get(&userID, `SELECT user_id FROM host_script_results WHERE id = ?`, hsr3)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, userID.Valid)
|
||||
assert.Equal(t, u2, userID.Int64)
|
||||
|
||||
// host activity entry exists for host 1
|
||||
var actID sql.NullInt64
|
||||
err = db.Get(&actID, `SELECT activity_id FROM host_activities WHERE host_id = ?`, 1)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, actID.Valid)
|
||||
assert.Equal(t, act1, actID.Int64)
|
||||
|
||||
// delete activity act1
|
||||
execNoErr(t, db, `DELETE FROM activities WHERE id = ?`, act1)
|
||||
|
||||
// host activity entry does not exist anymore
|
||||
err = db.Get(&actID, `SELECT activity_id FROM host_activities WHERE host_id = ?`, 1)
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, sql.ErrNoRows)
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -15,8 +15,8 @@ import (
|
|||
|
||||
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, script_id) VALUES (?, ?, ?, '', ?)`
|
||||
getStmt = `SELECT id, host_id, execution_id, script_contents, created_at, script_id FROM host_script_results WHERE id = ?`
|
||||
insStmt = `INSERT INTO host_script_results (host_id, execution_id, script_contents, output, script_id, user_id, sync_request) VALUES (?, ?, ?, '', ?, ?, ?)`
|
||||
getStmt = `SELECT id, host_id, execution_id, script_contents, created_at, script_id, user_id, sync_request FROM host_script_results WHERE id = ?`
|
||||
)
|
||||
|
||||
execID := uuid.New().String()
|
||||
|
|
@ -25,6 +25,8 @@ func (ds *Datastore) NewHostScriptExecutionRequest(ctx context.Context, request
|
|||
execID,
|
||||
request.ScriptContents,
|
||||
request.ScriptID,
|
||||
request.UserID,
|
||||
request.SyncRequest,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "new host script execution request")
|
||||
|
|
@ -38,7 +40,7 @@ func (ds *Datastore) NewHostScriptExecutionRequest(ctx context.Context, request
|
|||
return &script, nil
|
||||
}
|
||||
|
||||
func (ds *Datastore) SetHostScriptExecutionResult(ctx context.Context, result *fleet.HostScriptResultPayload) error {
|
||||
func (ds *Datastore) SetHostScriptExecutionResult(ctx context.Context, result *fleet.HostScriptResultPayload) (*fleet.HostScriptResult, error) {
|
||||
const updStmt = `
|
||||
UPDATE host_script_results SET
|
||||
output = ?,
|
||||
|
|
@ -60,19 +62,29 @@ func (ds *Datastore) SetHostScriptExecutionResult(ctx context.Context, result *f
|
|||
output = string(outputRunes[len(outputRunes)-maxOutputRuneLen:])
|
||||
}
|
||||
|
||||
if _, err := ds.writer(ctx).ExecContext(ctx, updStmt,
|
||||
res, 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")
|
||||
)
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "update host script result")
|
||||
}
|
||||
return nil
|
||||
|
||||
var hsr *fleet.HostScriptResult
|
||||
if n, _ := res.RowsAffected(); n > 0 {
|
||||
// it did update, so return the updated result
|
||||
hsr, err = ds.getHostScriptExecutionResultDB(ctx, ds.writer(ctx), result.ExecutionID)
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "load updated host script result")
|
||||
}
|
||||
}
|
||||
return hsr, nil
|
||||
}
|
||||
|
||||
func (ds *Datastore) ListPendingHostScriptExecutions(ctx context.Context, hostID uint, ignoreOlder time.Duration) ([]*fleet.HostScriptResult, error) {
|
||||
func (ds *Datastore) ListPendingHostScriptExecutions(ctx context.Context, hostID uint) ([]*fleet.HostScriptResult, error) {
|
||||
const listStmt = `
|
||||
SELECT
|
||||
id,
|
||||
|
|
@ -84,18 +96,41 @@ func (ds *Datastore) ListPendingHostScriptExecutions(ctx context.Context, hostID
|
|||
host_script_results
|
||||
WHERE
|
||||
host_id = ? AND
|
||||
exit_code IS NULL AND
|
||||
created_at >= DATE_SUB(NOW(), INTERVAL ? SECOND)`
|
||||
exit_code IS NULL
|
||||
ORDER BY
|
||||
created_at ASC`
|
||||
|
||||
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")
|
||||
if err := sqlx.SelectContext(ctx, ds.reader(ctx), &results, listStmt, hostID); err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "list pending host script executions")
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (ds *Datastore) IsExecutionPendingForHost(ctx context.Context, hostID uint, scriptID uint) ([]*uint, error) {
|
||||
const getStmt = `
|
||||
SELECT
|
||||
1
|
||||
FROM
|
||||
host_script_results
|
||||
WHERE
|
||||
host_id = ? AND
|
||||
script_id = ? AND
|
||||
exit_code IS NULL
|
||||
`
|
||||
|
||||
var results []*uint
|
||||
if err := sqlx.SelectContext(ctx, ds.reader(ctx), &results, getStmt, hostID, scriptID); err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "is execution pending for host")
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (ds *Datastore) GetHostScriptExecutionResult(ctx context.Context, execID string) (*fleet.HostScriptResult, error) {
|
||||
return ds.getHostScriptExecutionResultDB(ctx, ds.reader(ctx), execID)
|
||||
}
|
||||
|
||||
func (ds *Datastore) getHostScriptExecutionResultDB(ctx context.Context, q sqlx.QueryerContext, execID string) (*fleet.HostScriptResult, error) {
|
||||
const getStmt = `
|
||||
SELECT
|
||||
id,
|
||||
|
|
@ -106,14 +141,16 @@ func (ds *Datastore) GetHostScriptExecutionResult(ctx context.Context, execID st
|
|||
output,
|
||||
runtime,
|
||||
exit_code,
|
||||
created_at
|
||||
created_at,
|
||||
user_id,
|
||||
sync_request
|
||||
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 := sqlx.GetContext(ctx, q, &result, getStmt, execID); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, ctxerr.Wrap(ctx, notFound("HostScriptResult").WithName(execID))
|
||||
}
|
||||
|
|
@ -280,7 +317,7 @@ FROM
|
|||
LEFT JOIN (
|
||||
SELECT
|
||||
id,
|
||||
host_id,
|
||||
host_id,
|
||||
script_id,
|
||||
execution_id,
|
||||
created_at,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import (
|
|||
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
"github.com/fleetdm/fleet/v4/server/ptr"
|
||||
"github.com/fleetdm/fleet/v4/server/test"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
|
@ -39,7 +40,7 @@ func testHostScriptResult(t *testing.T, ds *Datastore) {
|
|||
ctx := context.Background()
|
||||
|
||||
// no script saved yet
|
||||
pending, err := ds.ListPendingHostScriptExecutions(ctx, 1, time.Second)
|
||||
pending, err := ds.ListPendingHostScriptExecutions(ctx, 1)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, pending)
|
||||
|
||||
|
|
@ -48,10 +49,13 @@ func testHostScriptResult(t *testing.T, ds *Datastore) {
|
|||
var nfe *notFoundError
|
||||
require.ErrorAs(t, err, &nfe)
|
||||
|
||||
// create a createdScript execution request
|
||||
// create a createdScript execution request (with a user)
|
||||
u := test.NewUser(t, ds, "Bob", "bob@example.com", true)
|
||||
createdScript, err := ds.NewHostScriptExecutionRequest(ctx, &fleet.HostScriptRequestPayload{
|
||||
HostID: 1,
|
||||
ScriptContents: "echo",
|
||||
UserID: &u.ID,
|
||||
SyncRequest: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotZero(t, createdScript.ID)
|
||||
|
|
@ -61,21 +65,18 @@ func testHostScriptResult(t *testing.T, ds *Datastore) {
|
|||
require.Equal(t, "echo", createdScript.ScriptContents)
|
||||
require.Nil(t, createdScript.ExitCode)
|
||||
require.Empty(t, createdScript.Output)
|
||||
require.NotNil(t, createdScript.UserID)
|
||||
require.Equal(t, u.ID, *createdScript.UserID)
|
||||
require.True(t, createdScript.SyncRequest)
|
||||
|
||||
// the script execution is now listed as pending for this host
|
||||
pending, err = ds.ListPendingHostScriptExecutions(ctx, 1, 10*time.Second)
|
||||
pending, err = ds.ListPendingHostScriptExecutions(ctx, 1)
|
||||
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{
|
||||
_, err = ds.SetHostScriptExecutionResult(ctx, &fleet.HostScriptResultPayload{
|
||||
HostID: 1,
|
||||
ExecutionID: createdScript.ExecutionID,
|
||||
Output: "foo",
|
||||
|
|
@ -85,7 +86,7 @@ func testHostScriptResult(t *testing.T, ds *Datastore) {
|
|||
require.NoError(t, err)
|
||||
|
||||
// it is not pending anymore
|
||||
pending, err = ds.ListPendingHostScriptExecutions(ctx, 1, 10*time.Second)
|
||||
pending, err = ds.ListPendingHostScriptExecutions(ctx, 1)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, pending)
|
||||
|
||||
|
|
@ -98,7 +99,7 @@ func testHostScriptResult(t *testing.T, ds *Datastore) {
|
|||
expectScript.ExitCode = ptr.Int64(0)
|
||||
require.Equal(t, &expectScript, script)
|
||||
|
||||
// create another script execution request
|
||||
// create another script execution request (null user id this time)
|
||||
createdScript, err = ds.NewHostScriptExecutionRequest(ctx, &fleet.HostScriptRequestPayload{
|
||||
HostID: 1,
|
||||
ScriptContents: "echo2",
|
||||
|
|
@ -106,6 +107,8 @@ func testHostScriptResult(t *testing.T, ds *Datastore) {
|
|||
require.NoError(t, err)
|
||||
require.NotZero(t, createdScript.ID)
|
||||
require.NotEmpty(t, createdScript.ExecutionID)
|
||||
require.Nil(t, createdScript.UserID)
|
||||
require.False(t, createdScript.SyncRequest)
|
||||
|
||||
// the script result can be retrieved even if it has no result yet
|
||||
script, err = ds.GetHostScriptExecutionResult(ctx, createdScript.ExecutionID)
|
||||
|
|
@ -135,7 +138,7 @@ func testHostScriptResult(t *testing.T, ds *Datastore) {
|
|||
strings.Repeat("j", 1000) +
|
||||
strings.Repeat("k", 1000)
|
||||
|
||||
err = ds.SetHostScriptExecutionResult(ctx, &fleet.HostScriptResultPayload{
|
||||
_, err = ds.SetHostScriptExecutionResult(ctx, &fleet.HostScriptResultPayload{
|
||||
HostID: 1,
|
||||
ExecutionID: createdScript.ExecutionID,
|
||||
Output: largeOutput,
|
||||
|
|
@ -385,9 +388,9 @@ func testGetHostScriptDetails(t *testing.T, ds *Datastore) {
|
|||
|
||||
insertResults := func(t *testing.T, hostID uint, script *fleet.Script, createdAt time.Time, execID string, exitCode *int64) {
|
||||
stmt := `
|
||||
INSERT INTO
|
||||
host_script_results (%s host_id, created_at, execution_id, exit_code, script_contents, output)
|
||||
VALUES
|
||||
INSERT INTO
|
||||
host_script_results (%s host_id, created_at, execution_id, exit_code, script_contents, output)
|
||||
VALUES
|
||||
(%s ?,?,?,?,?,?)`
|
||||
|
||||
args := []interface{}{}
|
||||
|
|
@ -535,6 +538,13 @@ VALUES
|
|||
require.Len(t, res, 1)
|
||||
require.Equal(t, "script-6.ps1", res[0].Name)
|
||||
})
|
||||
|
||||
t.Run("can check if pending host script results exist", func(t *testing.T) {
|
||||
insertResults(t, 42, scripts[2], now.Add(-2*time.Minute), "execution-3-4", nil)
|
||||
r, err := ds.IsExecutionPendingForHost(ctx, 42, scripts[2].ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, r, 1)
|
||||
})
|
||||
}
|
||||
|
||||
func testBatchSetScripts(t *testing.T, ds *Datastore) {
|
||||
|
|
|
|||
|
|
@ -89,6 +89,13 @@ type ActivityDetails interface {
|
|||
Documentation() (activity string, details string, detailsExample string)
|
||||
}
|
||||
|
||||
// ActivityHosts is the optional additional interface that can be implemented
|
||||
// by activities that are related to hosts.
|
||||
type ActivityHosts interface {
|
||||
ActivityDetails
|
||||
HostIDs() []uint
|
||||
}
|
||||
|
||||
type ActivityTypeCreatedPack struct {
|
||||
ID uint `json:"pack_id"`
|
||||
Name string `json:"pack_name"`
|
||||
|
|
@ -493,7 +500,17 @@ func (a ActivityTypeUserAddedBySSO) Documentation() (activity string, details st
|
|||
|
||||
type Activity struct {
|
||||
CreateTimestamp
|
||||
ID uint `json:"id" db:"id"`
|
||||
|
||||
// ID is the activity id in the activities table, it is omitted for upcoming
|
||||
// activities as those are "virtual activities" generated from entries in
|
||||
// queues (e.g. pending host_script_results).
|
||||
ID uint `json:"id,omitempty" db:"id"`
|
||||
|
||||
// UUID is the activity UUID for the upcoming activities, as identified in
|
||||
// the relevant queue (e.g. pending host_script_results). It is omitted for
|
||||
// past activities as those are "real activities" with an activity id.
|
||||
UUID string `json:"uuid,omitempty" db:"uuid"`
|
||||
|
||||
ActorFullName *string `json:"actor_full_name,omitempty" db:"name"`
|
||||
ActorID *uint `json:"actor_id,omitempty" db:"user_id"`
|
||||
ActorGravatar *string `json:"actor_gravatar,omitempty" db:"gravatar_url"`
|
||||
|
|
@ -1074,6 +1091,7 @@ type ActivityTypeRanScript struct {
|
|||
HostID uint `json:"host_id"`
|
||||
HostDisplayName string `json:"host_display_name"`
|
||||
ScriptExecutionID string `json:"script_execution_id"`
|
||||
ScriptName string `json:"script_name"`
|
||||
Async bool `json:"async"`
|
||||
}
|
||||
|
||||
|
|
@ -1081,15 +1099,21 @@ func (a ActivityTypeRanScript) ActivityName() string {
|
|||
return "ran_script"
|
||||
}
|
||||
|
||||
func (a ActivityTypeRanScript) HostIDs() []uint {
|
||||
return []uint{a.HostID}
|
||||
}
|
||||
|
||||
func (a ActivityTypeRanScript) Documentation() (activity, details, detailsExample string) {
|
||||
return `Generated when a script is sent to be run for a host.`,
|
||||
`This activity contains the following fields:
|
||||
- "host_id": ID of the host.
|
||||
- "host_display_name": Display name of the host.
|
||||
- "script_execution_id": Execution ID of the script run.
|
||||
- "script_name": Name of the script (empty if it was an anonymous script).
|
||||
- "async": Whether the script was executed asynchronously.`, `{
|
||||
"host_id": 1,
|
||||
"host_display_name": "Anna's MacBook Pro",
|
||||
"script_name": "set-timezones.sh",
|
||||
"script_execution_id": "d6cffa75-b5b5-41ef-9230-15073c8a88cf",
|
||||
"async": false
|
||||
}`
|
||||
|
|
|
|||
|
|
@ -537,6 +537,9 @@ type Datastore interface {
|
|||
NewActivity(ctx context.Context, user *User, activity ActivityDetails) error
|
||||
ListActivities(ctx context.Context, opt ListActivitiesOptions) ([]*Activity, *PaginationMetadata, error)
|
||||
MarkActivitiesAsStreamed(ctx context.Context, activityIDs []uint) error
|
||||
ListHostUpcomingActivities(ctx context.Context, hostID uint, opt ListOptions) ([]*Activity, *PaginationMetadata, error)
|
||||
ListHostPastActivities(ctx context.Context, hostID uint, opt ListOptions) ([]*Activity, *PaginationMetadata, error)
|
||||
IsExecutionPendingForHost(ctx context.Context, hostID uint, scriptID uint) ([]*uint, error)
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// StatisticsStore
|
||||
|
|
@ -1213,18 +1216,19 @@ type Datastore interface {
|
|||
// 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
|
||||
// SetHostScriptExecutionResult stores the result of a host script execution
|
||||
// and returns the updated host script result record. Note that it does not
|
||||
// fail if the script execution request does not exist, in this case it will
|
||||
// return nil, nil.
|
||||
SetHostScriptExecutionResult(ctx context.Context, result *HostScriptResultPayload) (*HostScriptResult, 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)
|
||||
// executions, which are those that have yet to record a result.
|
||||
ListPendingHostScriptExecutions(ctx context.Context, hostID uint) ([]*HostScriptResult, error)
|
||||
|
||||
// NewScript creates a new saved script.
|
||||
NewScript(ctx context.Context, script *Script) (*Script, error)
|
||||
|
|
|
|||
|
|
@ -135,6 +135,12 @@ type HostScriptRequestPayload struct {
|
|||
HostID uint `json:"host_id"`
|
||||
ScriptID *uint `json:"script_id"`
|
||||
ScriptContents string `json:"script_contents"`
|
||||
// UserID is filled automatically from the context's user (the authenticated
|
||||
// user that made the API request).
|
||||
UserID *uint `json:"-"`
|
||||
// SyncRequest is filled automatically based on the endpoint used to create
|
||||
// the execution request (synchronous or asynchronous).
|
||||
SyncRequest bool `json:"-"`
|
||||
}
|
||||
|
||||
type HostScriptResultPayload struct {
|
||||
|
|
@ -173,6 +179,15 @@ type HostScriptResult struct {
|
|||
// ScriptID is the id of the saved script to execute, or nil if this was an
|
||||
// anonymous script execution.
|
||||
ScriptID *uint `json:"script_id" db:"script_id"`
|
||||
// UserID is the id of the user that requested execution. It is not part of
|
||||
// the rendered JSON as it is only returned by the
|
||||
// /hosts/:id/activities/upcoming endpoint which doesn't use this struct as
|
||||
// return type.
|
||||
UserID *uint `json:"-" db:"user_id"`
|
||||
// SyncRequest is used to determine when creating the script ran activity if
|
||||
// the request was synchronous or asynchronous. It is otherwise not returned
|
||||
// as part of any API endpoint.
|
||||
SyncRequest bool `json:"-" db:"sync_request"`
|
||||
|
||||
// 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.
|
||||
|
|
|
|||
|
|
@ -527,12 +527,21 @@ type Service interface {
|
|||
// What we call "Activities" are administrative operations,
|
||||
// logins, running a live query, etc.
|
||||
NewActivity(ctx context.Context, user *User, activity ActivityDetails) error
|
||||
|
||||
// ListActivities lists the activities stored in the datastore.
|
||||
//
|
||||
// What we call "Activities" are administrative operations,
|
||||
// logins, running a live query, etc.
|
||||
ListActivities(ctx context.Context, opt ListActivitiesOptions) ([]*Activity, *PaginationMetadata, error)
|
||||
|
||||
// ListHostUpcomingActivities lists the upcoming activities for the specified
|
||||
// host. Those are activities that are queued or scheduled to run on the host
|
||||
// but haven't run yet.
|
||||
ListHostUpcomingActivities(ctx context.Context, hostID uint, opt ListOptions) ([]*Activity, *PaginationMetadata, error)
|
||||
|
||||
// ListHostPastActivities lists the activities that have already happened for the specified host.
|
||||
ListHostPastActivities(ctx context.Context, hostID uint, opt ListOptions) ([]*Activity, *PaginationMetadata, error)
|
||||
|
||||
// /////////////////////////////////////////////////////////////////////////////
|
||||
// UserRolesService
|
||||
|
||||
|
|
|
|||
|
|
@ -394,6 +394,12 @@ type ListActivitiesFunc func(ctx context.Context, opt fleet.ListActivitiesOption
|
|||
|
||||
type MarkActivitiesAsStreamedFunc func(ctx context.Context, activityIDs []uint) error
|
||||
|
||||
type ListHostUpcomingActivitiesFunc func(ctx context.Context, hostID uint, opt fleet.ListOptions) ([]*fleet.Activity, *fleet.PaginationMetadata, error)
|
||||
|
||||
type ListHostPastActivitiesFunc func(ctx context.Context, hostID uint, opt fleet.ListOptions) ([]*fleet.Activity, *fleet.PaginationMetadata, error)
|
||||
|
||||
type IsExecutionPendingForHostFunc func(ctx context.Context, hostID uint, scriptID uint) ([]*uint, error)
|
||||
|
||||
type ShouldSendStatisticsFunc func(ctx context.Context, frequency time.Duration, config config.FleetConfig) (fleet.StatisticsPayload, bool, error)
|
||||
|
||||
type RecordStatisticsSentFunc func(ctx context.Context) error
|
||||
|
|
@ -776,11 +782,11 @@ type BatchSetMDMProfilesFunc func(ctx context.Context, tmID *uint, macProfiles [
|
|||
|
||||
type NewHostScriptExecutionRequestFunc func(ctx context.Context, request *fleet.HostScriptRequestPayload) (*fleet.HostScriptResult, error)
|
||||
|
||||
type SetHostScriptExecutionResultFunc func(ctx context.Context, result *fleet.HostScriptResultPayload) error
|
||||
type SetHostScriptExecutionResultFunc func(ctx context.Context, result *fleet.HostScriptResultPayload) (*fleet.HostScriptResult, 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 ListPendingHostScriptExecutionsFunc func(ctx context.Context, hostID uint) ([]*fleet.HostScriptResult, error)
|
||||
|
||||
type NewScriptFunc func(ctx context.Context, script *fleet.Script) (*fleet.Script, error)
|
||||
|
||||
|
|
@ -1361,6 +1367,15 @@ type DataStore struct {
|
|||
MarkActivitiesAsStreamedFunc MarkActivitiesAsStreamedFunc
|
||||
MarkActivitiesAsStreamedFuncInvoked bool
|
||||
|
||||
ListHostUpcomingActivitiesFunc ListHostUpcomingActivitiesFunc
|
||||
ListHostUpcomingActivitiesFuncInvoked bool
|
||||
|
||||
ListHostPastActivitiesFunc ListHostPastActivitiesFunc
|
||||
ListHostPastActivitiesFuncInvoked bool
|
||||
|
||||
IsExecutionPendingForHostFunc IsExecutionPendingForHostFunc
|
||||
IsExecutionPendingForHostFuncInvoked bool
|
||||
|
||||
ShouldSendStatisticsFunc ShouldSendStatisticsFunc
|
||||
ShouldSendStatisticsFuncInvoked bool
|
||||
|
||||
|
|
@ -3283,6 +3298,27 @@ func (s *DataStore) MarkActivitiesAsStreamed(ctx context.Context, activityIDs []
|
|||
return s.MarkActivitiesAsStreamedFunc(ctx, activityIDs)
|
||||
}
|
||||
|
||||
func (s *DataStore) ListHostUpcomingActivities(ctx context.Context, hostID uint, opt fleet.ListOptions) ([]*fleet.Activity, *fleet.PaginationMetadata, error) {
|
||||
s.mu.Lock()
|
||||
s.ListHostUpcomingActivitiesFuncInvoked = true
|
||||
s.mu.Unlock()
|
||||
return s.ListHostUpcomingActivitiesFunc(ctx, hostID, opt)
|
||||
}
|
||||
|
||||
func (s *DataStore) ListHostPastActivities(ctx context.Context, hostID uint, opt fleet.ListOptions) ([]*fleet.Activity, *fleet.PaginationMetadata, error) {
|
||||
s.mu.Lock()
|
||||
s.ListHostPastActivitiesFuncInvoked = true
|
||||
s.mu.Unlock()
|
||||
return s.ListHostPastActivitiesFunc(ctx, hostID, opt)
|
||||
}
|
||||
|
||||
func (s *DataStore) IsExecutionPendingForHost(ctx context.Context, hostID uint, scriptID uint) ([]*uint, error) {
|
||||
s.mu.Lock()
|
||||
s.IsExecutionPendingForHostFuncInvoked = true
|
||||
s.mu.Unlock()
|
||||
return s.IsExecutionPendingForHostFunc(ctx, hostID, scriptID)
|
||||
}
|
||||
|
||||
func (s *DataStore) ShouldSendStatistics(ctx context.Context, frequency time.Duration, config config.FleetConfig) (fleet.StatisticsPayload, bool, error) {
|
||||
s.mu.Lock()
|
||||
s.ShouldSendStatisticsFuncInvoked = true
|
||||
|
|
@ -4620,7 +4656,7 @@ func (s *DataStore) NewHostScriptExecutionRequest(ctx context.Context, request *
|
|||
return s.NewHostScriptExecutionRequestFunc(ctx, request)
|
||||
}
|
||||
|
||||
func (s *DataStore) SetHostScriptExecutionResult(ctx context.Context, result *fleet.HostScriptResultPayload) error {
|
||||
func (s *DataStore) SetHostScriptExecutionResult(ctx context.Context, result *fleet.HostScriptResultPayload) (*fleet.HostScriptResult, error) {
|
||||
s.mu.Lock()
|
||||
s.SetHostScriptExecutionResultFuncInvoked = true
|
||||
s.mu.Unlock()
|
||||
|
|
@ -4634,11 +4670,11 @@ func (s *DataStore) GetHostScriptExecutionResult(ctx context.Context, execID str
|
|||
return s.GetHostScriptExecutionResultFunc(ctx, execID)
|
||||
}
|
||||
|
||||
func (s *DataStore) ListPendingHostScriptExecutions(ctx context.Context, hostID uint, ignoreOlder time.Duration) ([]*fleet.HostScriptResult, error) {
|
||||
func (s *DataStore) ListPendingHostScriptExecutions(ctx context.Context, hostID uint) ([]*fleet.HostScriptResult, error) {
|
||||
s.mu.Lock()
|
||||
s.ListPendingHostScriptExecutionsFuncInvoked = true
|
||||
s.mu.Unlock()
|
||||
return s.ListPendingHostScriptExecutionsFunc(ctx, hostID, ignoreOlder)
|
||||
return s.ListPendingHostScriptExecutionsFunc(ctx, hostID)
|
||||
}
|
||||
|
||||
func (s *DataStore) NewScript(ctx context.Context, script *fleet.Script) (*fleet.Script, error) {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package service
|
|||
import (
|
||||
"context"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
)
|
||||
|
||||
|
|
@ -45,3 +46,99 @@ func (svc *Service) ListActivities(ctx context.Context, opt fleet.ListActivities
|
|||
func (svc *Service) NewActivity(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails) error {
|
||||
return svc.ds.NewActivity(ctx, user, activity)
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
// List host upcoming activities
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
type listHostUpcomingActivitiesRequest struct {
|
||||
HostID uint `url:"id"`
|
||||
ListOptions fleet.ListOptions `url:"list_options"`
|
||||
}
|
||||
|
||||
func listHostUpcomingActivitiesEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) {
|
||||
req := request.(*listHostUpcomingActivitiesRequest)
|
||||
acts, meta, err := svc.ListHostUpcomingActivities(ctx, req.HostID, req.ListOptions)
|
||||
if err != nil {
|
||||
return listActivitiesResponse{Err: err}, nil
|
||||
}
|
||||
|
||||
return listActivitiesResponse{Meta: meta, Activities: acts}, nil
|
||||
}
|
||||
|
||||
// ListHostUpcomingActivities returns a slice of upcoming activities for the
|
||||
// specified host.
|
||||
func (svc *Service) ListHostUpcomingActivities(ctx context.Context, hostID uint, opt fleet.ListOptions) ([]*fleet.Activity, *fleet.PaginationMetadata, error) {
|
||||
// First ensure the user has access to list hosts, then check the specific
|
||||
// host once team_id is loaded.
|
||||
if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionList); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
host, err := svc.ds.HostLite(ctx, hostID)
|
||||
if err != nil {
|
||||
return nil, nil, ctxerr.Wrap(ctx, err, "get host")
|
||||
}
|
||||
// Authorize again with team loaded now that we have team_id
|
||||
if err := svc.authz.Authorize(ctx, host, fleet.ActionRead); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// cursor-based pagination is not supported for upcoming activities
|
||||
opt.After = ""
|
||||
// custom ordering is not supported, always by date (oldest first)
|
||||
opt.OrderKey = "created_at"
|
||||
opt.OrderDirection = fleet.OrderAscending
|
||||
// no matching query support
|
||||
opt.MatchQuery = ""
|
||||
// always include metadata
|
||||
opt.IncludeMetadata = true
|
||||
|
||||
return svc.ds.ListHostUpcomingActivities(ctx, hostID, opt)
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
// List host past activities
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
type listHostPastActivitiesRequest struct {
|
||||
HostID uint `url:"id"`
|
||||
ListOptions fleet.ListOptions `url:"list_options"`
|
||||
}
|
||||
|
||||
func listHostPastActivitiesEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) {
|
||||
req := request.(*listHostPastActivitiesRequest)
|
||||
acts, meta, err := svc.ListHostPastActivities(ctx, req.HostID, req.ListOptions)
|
||||
if err != nil {
|
||||
return listActivitiesResponse{Err: err}, nil
|
||||
}
|
||||
|
||||
return &listActivitiesResponse{Meta: meta, Activities: acts}, nil
|
||||
}
|
||||
|
||||
func (svc *Service) ListHostPastActivities(ctx context.Context, hostID uint, opt fleet.ListOptions) ([]*fleet.Activity, *fleet.PaginationMetadata, error) {
|
||||
// First ensure the user has access to list hosts, then check the specific
|
||||
// host once team_id is loaded.
|
||||
if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionList); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
host, err := svc.ds.HostLite(ctx, hostID)
|
||||
if err != nil {
|
||||
return nil, nil, ctxerr.Wrap(ctx, err, "get host")
|
||||
}
|
||||
// Authorize again with team loaded now that we have team_id
|
||||
if err := svc.authz.Authorize(ctx, host, fleet.ActionRead); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// cursor-based pagination is not supported for past activities
|
||||
opt.After = ""
|
||||
// custom ordering is not supported, always by date (oldest first)
|
||||
opt.OrderKey = "created_at"
|
||||
opt.OrderDirection = fleet.OrderDescending
|
||||
// no matching query support
|
||||
opt.MatchQuery = ""
|
||||
// always include metadata
|
||||
opt.IncludeMetadata = true
|
||||
|
||||
return svc.ds.ListHostPastActivities(ctx, hostID, opt)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -467,6 +467,8 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC
|
|||
ue.POST("/api/_version_/fleet/scripts/batch", batchSetScriptsEndpoint, batchSetScriptsRequest{})
|
||||
|
||||
ue.GET("/api/_version_/fleet/hosts/{id:[0-9]+}/scripts", getHostScriptDetailsEndpoint, getHostScriptDetailsRequest{})
|
||||
ue.GET("/api/_version_/fleet/hosts/{id:[0-9]+}/activities/upcoming", listHostUpcomingActivitiesEndpoint, listHostUpcomingActivitiesRequest{})
|
||||
ue.GET("/api/_version_/fleet/hosts/{id:[0-9]+}/activities", listHostPastActivitiesEndpoint, listHostPastActivitiesRequest{})
|
||||
|
||||
// Only Fleet MDM specific endpoints should be within the root /mdm/ path.
|
||||
// NOTE: remember to update
|
||||
|
|
|
|||
|
|
@ -585,6 +585,9 @@ func TestHostAuth(t *testing.T) {
|
|||
ds.SetOrUpdateCustomHostDeviceMappingFunc = func(ctx context.Context, hostID uint, email, source string) ([]*fleet.HostDeviceMapping, error) {
|
||||
return nil, nil
|
||||
}
|
||||
ds.ListHostUpcomingActivitiesFunc = func(ctx context.Context, hostID uint, opt fleet.ListOptions) ([]*fleet.Activity, *fleet.PaginationMetadata, error) {
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
|
|
@ -684,6 +687,9 @@ func TestHostAuth(t *testing.T) {
|
|||
_, err = svc.HostByIdentifier(ctx, "1", opts)
|
||||
checkAuthErr(t, tt.shouldFailTeamRead, err)
|
||||
|
||||
_, _, err = svc.ListHostUpcomingActivities(ctx, 1, fleet.ListOptions{})
|
||||
checkAuthErr(t, tt.shouldFailTeamRead, err)
|
||||
|
||||
_, err = svc.GetHost(ctx, 2, opts)
|
||||
checkAuthErr(t, tt.shouldFailGlobalRead, err)
|
||||
|
||||
|
|
@ -693,6 +699,9 @@ func TestHostAuth(t *testing.T) {
|
|||
_, err = svc.HostByIdentifier(ctx, "2", opts)
|
||||
checkAuthErr(t, tt.shouldFailGlobalRead, err)
|
||||
|
||||
_, _, err = svc.ListHostUpcomingActivities(ctx, 2, fleet.ListOptions{})
|
||||
checkAuthErr(t, tt.shouldFailGlobalRead, err)
|
||||
|
||||
err = svc.DeleteHost(ctx, 1)
|
||||
checkAuthErr(t, tt.shouldFailTeamWrite, err)
|
||||
|
||||
|
|
|
|||
|
|
@ -5332,41 +5332,6 @@ func (s *integrationTestSuite) TestPremiumEndpointsWithoutLicense() {
|
|||
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)
|
||||
|
||||
// get script result
|
||||
var scriptResultResp getScriptResultResponse
|
||||
s.DoJSON("GET", "/api/latest/fleet/scripts/results/test-id", nil, http.StatusPaymentRequired, &scriptResultResp)
|
||||
|
||||
// create a saved script
|
||||
body, headers := generateNewScriptMultipartRequest(t,
|
||||
"myscript.sh", []byte(`echo "hello"`), s.token, nil)
|
||||
s.DoRawWithHeaders("POST", "/api/latest/fleet/scripts", body.Bytes(), http.StatusPaymentRequired, headers)
|
||||
|
||||
// delete a saved script
|
||||
var delScriptResp deleteScriptResponse
|
||||
s.DoJSON("DELETE", "/api/latest/fleet/scripts/123", nil, http.StatusPaymentRequired, &delScriptResp)
|
||||
|
||||
// list saved scripts
|
||||
var listScriptsResp listScriptsResponse
|
||||
s.DoJSON("GET", "/api/latest/fleet/scripts", nil, http.StatusPaymentRequired, &listScriptsResp, "per_page", "10")
|
||||
|
||||
// get a saved script
|
||||
var getScriptResp getScriptResponse
|
||||
s.DoJSON("GET", "/api/latest/fleet/scripts/123", nil, http.StatusPaymentRequired, &getScriptResp)
|
||||
|
||||
// get host script details
|
||||
var getHostScriptDetailsResp getHostScriptDetailsResponse
|
||||
s.DoJSON("GET", "/api/latest/fleet/hosts/123/scripts", nil, http.StatusPaymentRequired, &getHostScriptDetailsResp)
|
||||
|
||||
// batch set scripts
|
||||
s.Do("POST", "/api/v1/fleet/scripts/batch", batchSetScriptsRequest{Scripts: nil}, http.StatusPaymentRequired)
|
||||
|
||||
// software titles
|
||||
// a normal request works fine
|
||||
var resp listSoftwareTitlesResponse
|
||||
|
|
@ -5386,6 +5351,48 @@ func (s *integrationTestSuite) TestPremiumEndpointsWithoutLicense() {
|
|||
)
|
||||
}
|
||||
|
||||
func (s *integrationTestSuite) TestScriptsEndpointsWithoutLicense() {
|
||||
t := s.T()
|
||||
|
||||
// this is just checking that the endpoints do not fail with "no license", the actual tests
|
||||
// for scripts endpoints are in the enterprise integrations tests.
|
||||
|
||||
// run a script
|
||||
var runResp runScriptResponse
|
||||
s.DoJSON("POST", "/api/latest/fleet/scripts/run", fleet.HostScriptRequestPayload{HostID: 1}, http.StatusNotFound, &runResp)
|
||||
|
||||
// run a script sync
|
||||
s.DoJSON("POST", "/api/latest/fleet/scripts/run/sync", fleet.HostScriptRequestPayload{HostID: 1}, http.StatusNotFound, &runResp)
|
||||
|
||||
// get script result
|
||||
var scriptResultResp getScriptResultResponse
|
||||
s.DoJSON("GET", "/api/latest/fleet/scripts/results/test-id", nil, http.StatusNotFound, &scriptResultResp)
|
||||
|
||||
// create a saved script
|
||||
body, headers := generateNewScriptMultipartRequest(t,
|
||||
"myscript.sh", []byte(`echo "hello"`), s.token, nil)
|
||||
s.DoRawWithHeaders("POST", "/api/latest/fleet/scripts", body.Bytes(), http.StatusOK, headers)
|
||||
|
||||
// delete a saved script
|
||||
var delScriptResp deleteScriptResponse
|
||||
s.DoJSON("DELETE", "/api/latest/fleet/scripts/123", nil, http.StatusNotFound, &delScriptResp)
|
||||
|
||||
// list saved scripts
|
||||
var listScriptsResp listScriptsResponse
|
||||
s.DoJSON("GET", "/api/latest/fleet/scripts", nil, http.StatusOK, &listScriptsResp, "per_page", "10")
|
||||
|
||||
// get a saved script
|
||||
var getScriptResp getScriptResponse
|
||||
s.DoJSON("GET", "/api/latest/fleet/scripts/123", nil, http.StatusNotFound, &getScriptResp)
|
||||
|
||||
// get host script details
|
||||
var getHostScriptDetailsResp getHostScriptDetailsResponse
|
||||
s.DoJSON("GET", "/api/latest/fleet/hosts/123/scripts", nil, http.StatusNotFound, &getHostScriptDetailsResp)
|
||||
|
||||
// batch set scripts
|
||||
s.Do("POST", "/api/v1/fleet/scripts/batch", batchSetScriptsRequest{Scripts: nil}, http.StatusNoContent)
|
||||
}
|
||||
|
||||
// TestGlobalPoliciesBrowsing tests that team users can browse (read) global policies (see #3722).
|
||||
func (s *integrationTestSuite) TestGlobalPoliciesBrowsing() {
|
||||
t := s.T()
|
||||
|
|
@ -7604,9 +7611,9 @@ func (s *integrationTestSuite) TestOrbitConfigNotifications() {
|
|||
s.DoJSON("POST", "/api/fleet/orbit/config", json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *hFleetMDM.OrbitNodeKey)), http.StatusOK, &resp)
|
||||
require.False(t, resp.Notifications.RenewEnrollmentProfile)
|
||||
|
||||
// the scripts orbit endpoints are premium-only
|
||||
s.Do("POST", "/api/fleet/orbit/scripts/request", json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *hFleetMDM.OrbitNodeKey)), http.StatusPaymentRequired)
|
||||
s.Do("POST", "/api/fleet/orbit/scripts/result", json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *hFleetMDM.OrbitNodeKey)), http.StatusPaymentRequired)
|
||||
// the scripts orbit endpoints are accessible without license
|
||||
s.Do("POST", "/api/fleet/orbit/scripts/request", json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *hFleetMDM.OrbitNodeKey)), http.StatusNotFound)
|
||||
s.Do("POST", "/api/fleet/orbit/scripts/result", json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *hFleetMDM.OrbitNodeKey)), http.StatusBadRequest)
|
||||
}
|
||||
|
||||
func (s *integrationTestSuite) TestTryingToEnrollWithTheWrongSecret() {
|
||||
|
|
@ -9642,3 +9649,200 @@ func (s *integrationTestSuite) TestHostDeviceToken() {
|
|||
}
|
||||
s.DoJSON("POST", "/api/fleet/orbit/device_token", body, http.StatusConflict, &response{})
|
||||
}
|
||||
|
||||
func (s *integrationTestSuite) TestHostPastActivities() {
|
||||
t := s.T()
|
||||
ctx := context.Background()
|
||||
user := s.users["admin1@example.com"]
|
||||
getDetails := func(a *fleet.Activity) fleet.ActivityTypeRanScript {
|
||||
var details fleet.ActivityTypeRanScript
|
||||
err := json.Unmarshal([]byte(*a.Details), &details)
|
||||
require.NoError(t, err)
|
||||
|
||||
return details
|
||||
}
|
||||
|
||||
host := createOrbitEnrolledHost(t, "linux", "", s.ds)
|
||||
err := s.ds.MarkHostsSeen(ctx, []uint{host.ID}, time.Now())
|
||||
require.NoError(t, err)
|
||||
|
||||
// create a valid script execution request
|
||||
savedScript, err := s.ds.NewScript(ctx, &fleet.Script{
|
||||
TeamID: nil,
|
||||
Name: "saved.sh",
|
||||
ScriptContents: "echo 'hello world'",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
var runResp runScriptResponse
|
||||
s.DoJSON("POST", "/api/latest/fleet/scripts/run", fleet.HostScriptRequestPayload{HostID: host.ID, ScriptID: &savedScript.ID}, http.StatusAccepted, &runResp)
|
||||
require.Equal(t, host.ID, runResp.HostID)
|
||||
require.NotEmpty(t, runResp.ExecutionID)
|
||||
|
||||
execID1 := 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 'hello world'", result.ScriptContents)
|
||||
require.Nil(t, result.ExitCode)
|
||||
|
||||
var orbitPostScriptResp orbitPostScriptResultResponse
|
||||
s.DoJSON("POST", "/api/fleet/orbit/scripts/result",
|
||||
json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q, "execution_id": %q, "exit_code": 0, "output": "ok"}`, *host.OrbitNodeKey, result.ExecutionID)),
|
||||
http.StatusOK, &orbitPostScriptResp)
|
||||
|
||||
var listResp listActivitiesResponse
|
||||
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/activities", host.ID), nil, http.StatusOK, &listResp)
|
||||
|
||||
require.Len(t, listResp.Activities, 1)
|
||||
require.Equal(t, user.Email, *listResp.Activities[0].ActorEmail)
|
||||
require.Equal(t, user.Name, *listResp.Activities[0].ActorFullName)
|
||||
require.Equal(t, user.GravatarURL, *listResp.Activities[0].ActorGravatar)
|
||||
require.Equal(t, "ran_script", *&listResp.Activities[0].Type)
|
||||
d := getDetails(listResp.Activities[0])
|
||||
require.Equal(t, execID1, d.ScriptExecutionID)
|
||||
require.Equal(t, savedScript.Name, d.ScriptName)
|
||||
require.Equal(t, host.DisplayName(), d.HostDisplayName)
|
||||
require.Equal(t, host.ID, d.HostID)
|
||||
require.Equal(t, true, d.Async)
|
||||
|
||||
// sleep to have the created_at timestamps differ
|
||||
time.Sleep(time.Second)
|
||||
|
||||
// Execute another script in order to test query params
|
||||
s.DoJSON("POST", "/api/latest/fleet/scripts/run", fleet.HostScriptRequestPayload{HostID: host.ID, ScriptContents: "echo 'foobar'"}, http.StatusAccepted, &runResp)
|
||||
require.Equal(t, host.ID, runResp.HostID)
|
||||
require.NotEmpty(t, runResp.ExecutionID)
|
||||
|
||||
execID2 := 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 'foobar'", result.ScriptContents)
|
||||
require.Nil(t, result.ExitCode)
|
||||
|
||||
s.DoJSON("POST", "/api/fleet/orbit/scripts/result",
|
||||
json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q, "execution_id": %q, "exit_code": 0, "output": "ok"}`, *host.OrbitNodeKey, result.ExecutionID)),
|
||||
http.StatusOK, &orbitPostScriptResp)
|
||||
|
||||
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/activities", host.ID), nil, http.StatusOK, &listResp, "page", "0", "per_page", "1")
|
||||
|
||||
require.Len(t, listResp.Activities, 1)
|
||||
d = getDetails(listResp.Activities[0])
|
||||
|
||||
require.Equal(t, execID2, d.ScriptExecutionID)
|
||||
|
||||
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/activities", host.ID), nil, http.StatusOK, &listResp, "page", "1", "per_page", "1")
|
||||
|
||||
require.Len(t, listResp.Activities, 1)
|
||||
d = getDetails(listResp.Activities[0])
|
||||
require.Equal(t, execID1, d.ScriptExecutionID)
|
||||
}
|
||||
|
||||
func (s *integrationTestSuite) TestListHostUpcomingActivities() {
|
||||
t := s.T()
|
||||
ctx := context.Background()
|
||||
|
||||
// there is already a datastore-layer test that verifies that correct values
|
||||
// are returned for users, saved scripts, etc. so this is more focused on
|
||||
// verifying that the service layer passes the proper options and the
|
||||
// rendering of the response.
|
||||
|
||||
host1, err := s.ds.NewHost(ctx, &fleet.Host{
|
||||
DetailUpdatedAt: time.Now(),
|
||||
LabelUpdatedAt: time.Now(),
|
||||
PolicyUpdatedAt: time.Now(),
|
||||
SeenTime: time.Now().Add(-1 * time.Minute),
|
||||
OsqueryHostID: ptr.String(t.Name()),
|
||||
NodeKey: ptr.String(t.Name()),
|
||||
UUID: uuid.New().String(),
|
||||
Hostname: fmt.Sprintf("%sfoo.local", t.Name()),
|
||||
Platform: "darwin",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
hsr, err := s.ds.NewHostScriptExecutionRequest(ctx, &fleet.HostScriptRequestPayload{HostID: host1.ID, ScriptContents: "A"})
|
||||
require.NoError(t, err)
|
||||
h1A := hsr.ExecutionID
|
||||
hsr, err = s.ds.NewHostScriptExecutionRequest(ctx, &fleet.HostScriptRequestPayload{HostID: host1.ID, ScriptContents: "B"})
|
||||
require.NoError(t, err)
|
||||
h1B := hsr.ExecutionID
|
||||
hsr, err = s.ds.NewHostScriptExecutionRequest(ctx, &fleet.HostScriptRequestPayload{HostID: host1.ID, ScriptContents: "C"})
|
||||
require.NoError(t, err)
|
||||
h1C := hsr.ExecutionID
|
||||
hsr, err = s.ds.NewHostScriptExecutionRequest(ctx, &fleet.HostScriptRequestPayload{HostID: host1.ID, ScriptContents: "D"})
|
||||
require.NoError(t, err)
|
||||
h1D := hsr.ExecutionID
|
||||
hsr, err = s.ds.NewHostScriptExecutionRequest(ctx, &fleet.HostScriptRequestPayload{HostID: host1.ID, ScriptContents: "E"})
|
||||
require.NoError(t, err)
|
||||
h1E := hsr.ExecutionID
|
||||
|
||||
cases := []struct {
|
||||
queries []string // alternate query name and value
|
||||
wantExecs []string
|
||||
wantMeta *fleet.PaginationMetadata
|
||||
}{
|
||||
{
|
||||
wantExecs: []string{h1A, h1B, h1C, h1D, h1E},
|
||||
wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: false},
|
||||
},
|
||||
{
|
||||
queries: []string{"per_page", "2"},
|
||||
wantExecs: []string{h1A, h1B},
|
||||
wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: false},
|
||||
},
|
||||
{
|
||||
queries: []string{"per_page", "2", "page", "1"},
|
||||
wantExecs: []string{h1C, h1D},
|
||||
wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: true},
|
||||
},
|
||||
{
|
||||
queries: []string{"per_page", "2", "page", "2"},
|
||||
wantExecs: []string{h1E},
|
||||
wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true},
|
||||
},
|
||||
{
|
||||
queries: []string{"per_page", "3"},
|
||||
wantExecs: []string{h1A, h1B, h1C},
|
||||
wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: false},
|
||||
},
|
||||
{
|
||||
queries: []string{"per_page", "3", "page", "1"},
|
||||
wantExecs: []string{h1D, h1E},
|
||||
wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true},
|
||||
},
|
||||
{
|
||||
queries: []string{"per_page", "3", "page", "2"},
|
||||
wantExecs: nil,
|
||||
wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true},
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(fmt.Sprintf("%#v", c.queries), func(t *testing.T) {
|
||||
var listResp listActivitiesResponse
|
||||
queryArgs := c.queries
|
||||
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/activities/upcoming", host1.ID), nil, http.StatusOK, &listResp, queryArgs...)
|
||||
|
||||
require.Equal(t, len(c.wantExecs), len(listResp.Activities))
|
||||
require.Equal(t, c.wantMeta, listResp.Meta)
|
||||
|
||||
var gotExecs []string
|
||||
if len(listResp.Activities) > 0 {
|
||||
gotExecs = make([]string, len(listResp.Activities))
|
||||
for i, a := range listResp.Activities {
|
||||
require.Zero(t, a.ID)
|
||||
require.NotEmpty(t, a.UUID)
|
||||
require.Equal(t, fleet.ActivityTypeRanScript{}.ActivityName(), a.Type)
|
||||
|
||||
var details map[string]any
|
||||
require.NotNil(t, a.Details)
|
||||
require.NoError(t, json.Unmarshal(*a.Details, &details))
|
||||
gotExecs[i] = details["script_execution_id"].(string)
|
||||
}
|
||||
}
|
||||
require.Equal(t, c.wantExecs, gotExecs)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4449,16 +4449,6 @@ func (s *integrationEnterpriseTestSuite) TestRunHostScript() {
|
|||
require.Equal(t, host.ID, runResp.HostID)
|
||||
require.NotEmpty(t, runResp.ExecutionID)
|
||||
|
||||
// an activity was created for the async script execution
|
||||
s.lastActivityMatches(
|
||||
fleet.ActivityTypeRanScript{}.ActivityName(),
|
||||
fmt.Sprintf(
|
||||
`{"host_id": %d, "host_display_name": %q, "script_execution_id": %q, "async": true}`,
|
||||
host.ID, host.DisplayName(), runResp.ExecutionID,
|
||||
),
|
||||
0,
|
||||
)
|
||||
|
||||
result, err := s.ds.GetHostScriptExecutionResult(ctx, runResp.ExecutionID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, host.ID, result.HostID)
|
||||
|
|
@ -4588,7 +4578,7 @@ func (s *integrationEnterpriseTestSuite) TestRunHostScript() {
|
|||
resultsCh := make(chan *fleet.HostScriptResultPayload, 1)
|
||||
go func() {
|
||||
for range time.Tick(300 * time.Millisecond) {
|
||||
pending, err := s.ds.ListPendingHostScriptExecutions(ctx, host.ID, 10*time.Second)
|
||||
pending, err := s.ds.ListPendingHostScriptExecutions(ctx, host.ID)
|
||||
if err != nil {
|
||||
t.Log(err)
|
||||
return
|
||||
|
|
@ -4600,7 +4590,7 @@ func (s *integrationEnterpriseTestSuite) TestRunHostScript() {
|
|||
case r := <-resultsCh:
|
||||
r.ExecutionID = pending[0].ExecutionID
|
||||
// ignoring errors in this goroutine, the HTTP request below will fail if this fails
|
||||
err = s.ds.SetHostScriptExecutionResult(ctx, r)
|
||||
_, err = s.ds.SetHostScriptExecutionResult(ctx, r)
|
||||
if err != nil {
|
||||
t.Log(err)
|
||||
}
|
||||
|
|
@ -4625,16 +4615,6 @@ func (s *integrationEnterpriseTestSuite) TestRunHostScript() {
|
|||
require.Equal(t, int64(0), *runSyncResp.ExitCode)
|
||||
require.False(t, runSyncResp.HostTimeout)
|
||||
|
||||
// an activity was created for the sync script execution
|
||||
s.lastActivityMatches(
|
||||
fleet.ActivityTypeRanScript{}.ActivityName(),
|
||||
fmt.Sprintf(
|
||||
`{"host_id": %d, "host_display_name": %q, "script_execution_id": %q, "async": false}`,
|
||||
host.ID, host.DisplayName(), runSyncResp.ExecutionID,
|
||||
),
|
||||
0,
|
||||
)
|
||||
|
||||
// simulate a scripts disabled result
|
||||
resultsCh <- &fleet.HostScriptResultPayload{
|
||||
HostID: host.ID,
|
||||
|
|
@ -4662,11 +4642,8 @@ func (s *integrationEnterpriseTestSuite) TestRunHostScript() {
|
|||
errMsg = extractServerErrorText(res.Body)
|
||||
require.Contains(t, errMsg, fleet.RunScriptHostOfflineErrMsg)
|
||||
|
||||
// attempt to create an async script execution request, fails because the host
|
||||
// is offline.
|
||||
res = s.Do("POST", "/api/latest/fleet/scripts/run", fleet.HostScriptRequestPayload{HostID: host.ID, ScriptContents: "echo"}, http.StatusUnprocessableEntity)
|
||||
errMsg = extractServerErrorText(res.Body)
|
||||
require.Contains(t, errMsg, fleet.RunScriptHostOfflineErrMsg)
|
||||
// attempt to create an async script execution request, succeeds because script is added to queue.
|
||||
res = s.Do("POST", "/api/latest/fleet/scripts/run", fleet.HostScriptRequestPayload{HostID: host.ID, ScriptContents: "echo"}, http.StatusAccepted)
|
||||
}
|
||||
|
||||
func (s *integrationEnterpriseTestSuite) TestRunHostSavedScript() {
|
||||
|
|
@ -4725,16 +4702,6 @@ func (s *integrationEnterpriseTestSuite) TestRunHostSavedScript() {
|
|||
require.Equal(t, host.ID, runResp.HostID)
|
||||
require.NotEmpty(t, runResp.ExecutionID)
|
||||
|
||||
// an activity was created for the async script execution
|
||||
s.lastActivityMatches(
|
||||
fleet.ActivityTypeRanScript{}.ActivityName(),
|
||||
fmt.Sprintf(
|
||||
`{"host_id": %d, "host_display_name": %q, "script_execution_id": %q, "async": true}`,
|
||||
host.ID, host.DisplayName(), runResp.ExecutionID,
|
||||
),
|
||||
0,
|
||||
)
|
||||
|
||||
var scriptResultResp getScriptResultResponse
|
||||
s.DoJSON("GET", "/api/latest/fleet/scripts/results/"+runResp.ExecutionID, nil, http.StatusOK, &scriptResultResp)
|
||||
require.Equal(t, host.ID, scriptResultResp.HostID)
|
||||
|
|
@ -4765,18 +4732,22 @@ func (s *integrationEnterpriseTestSuite) TestRunHostSavedScript() {
|
|||
err = s.ds.MarkHostsSeen(ctx, []uint{host.ID}, time.Now())
|
||||
require.NoError(t, err)
|
||||
|
||||
// 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, ScriptID: &savedNoTmScript.ID}, http.StatusConflict)
|
||||
errMsg = extractServerErrorText(res.Body)
|
||||
require.Contains(t, errMsg, fleet.RunScriptAlreadyRunningErrMsg)
|
||||
|
||||
// save a result via the orbit endpoint
|
||||
var orbitPostScriptResp orbitPostScriptResultResponse
|
||||
s.DoJSON("POST", "/api/fleet/orbit/scripts/result",
|
||||
json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q, "execution_id": %q, "exit_code": 0, "output": "ok"}`, *host.OrbitNodeKey, scriptResultResp.ExecutionID)),
|
||||
http.StatusOK, &orbitPostScriptResp)
|
||||
|
||||
// an activity was created for the script execution
|
||||
s.lastActivityMatches(
|
||||
fleet.ActivityTypeRanScript{}.ActivityName(),
|
||||
fmt.Sprintf(
|
||||
`{"host_id": %d, "host_display_name": %q, "script_name": %q, "script_execution_id": %q, "async": true}`,
|
||||
host.ID, host.DisplayName(), savedNoTmScript.Name, scriptResultResp.ExecutionID,
|
||||
),
|
||||
0,
|
||||
)
|
||||
|
||||
// verify that orbit does not receive any pending script anymore
|
||||
orbitResp = orbitGetConfigResponse{}
|
||||
s.DoJSON("POST", "/api/fleet/orbit/config",
|
||||
|
|
@ -4808,6 +4779,59 @@ func (s *integrationEnterpriseTestSuite) TestRunHostSavedScript() {
|
|||
require.False(t, scriptResultResp.HostTimeout)
|
||||
require.Contains(t, scriptResultResp.Message, fleet.RunScriptAlreadyRunningErrMsg)
|
||||
require.Nil(t, scriptResultResp.ScriptID)
|
||||
|
||||
// Verify that we can't enqueue more than 1k scripts
|
||||
|
||||
// Make the host offline so that scripts enqueue
|
||||
err = s.ds.MarkHostsSeen(ctx, []uint{host.ID}, time.Now().Add(-time.Hour))
|
||||
require.NoError(t, err)
|
||||
for i := 0; i < 1000; i++ {
|
||||
script, err := s.ds.NewScript(ctx, &fleet.Script{
|
||||
TeamID: nil,
|
||||
Name: fmt.Sprintf("script_1k_%d.sh", i),
|
||||
ScriptContents: fmt.Sprintf("echo %d", i),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = s.ds.NewHostScriptExecutionRequest(ctx, &fleet.HostScriptRequestPayload{HostID: host.ID, ScriptID: &script.ID})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
script, err := s.ds.NewScript(ctx, &fleet.Script{
|
||||
TeamID: nil,
|
||||
Name: "script_1k_1000.sh",
|
||||
ScriptContents: "echo 1000",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
s.DoJSON("POST", "/api/latest/fleet/scripts/run", fleet.HostScriptRequestPayload{HostID: host.ID, ScriptID: &script.ID}, http.StatusConflict, &runResp)
|
||||
}
|
||||
|
||||
func (s *integrationEnterpriseTestSuite) TestEnqueueSameScriptTwice() {
|
||||
t := s.T()
|
||||
ctx := context.Background()
|
||||
|
||||
host := createOrbitEnrolledHost(t, "linux", "", s.ds)
|
||||
script, err := s.ds.NewScript(ctx, &fleet.Script{
|
||||
TeamID: nil,
|
||||
Name: "script.sh",
|
||||
ScriptContents: "echo 'hi from script'",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Make the host offline so that scripts enqueue
|
||||
err = s.ds.MarkHostsSeen(ctx, []uint{host.ID}, time.Now().Add(-time.Hour))
|
||||
require.NoError(t, err)
|
||||
|
||||
var runResp runScriptResponse
|
||||
s.DoJSON("POST", "/api/latest/fleet/scripts/run", fleet.HostScriptRequestPayload{HostID: host.ID, ScriptID: &script.ID}, http.StatusAccepted, &runResp)
|
||||
require.Equal(t, host.ID, runResp.HostID)
|
||||
require.NotEmpty(t, runResp.ExecutionID)
|
||||
|
||||
// Should fail because the same script is already enqueued for this host.
|
||||
resp := s.Do("POST", "/api/latest/fleet/scripts/run", fleet.HostScriptRequestPayload{HostID: host.ID, ScriptID: &script.ID}, http.StatusConflict)
|
||||
errorMsg := extractServerErrorText(resp.Body)
|
||||
require.Contains(t, errorMsg, "The script is already queued on the given host")
|
||||
}
|
||||
|
||||
func (s *integrationEnterpriseTestSuite) TestOrbitConfigExtensions() {
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import (
|
|||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/pkg/scripts"
|
||||
"github.com/fleetdm/fleet/v4/server"
|
||||
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
|
||||
hostctx "github.com/fleetdm/fleet/v4/server/contexts/host"
|
||||
|
|
@ -231,7 +230,7 @@ func (svc *Service) GetOrbitConfig(ctx context.Context) (fleet.OrbitConfig, erro
|
|||
// it is important that the "ignoreOlder" parameter in this call is the
|
||||
// same everywhere (which is here and in RunScript to check if there is
|
||||
// already a pending script).
|
||||
pending, err := svc.ds.ListPendingHostScriptExecutions(ctx, host.ID, scripts.MaxServerWaitTime)
|
||||
pending, err := svc.ds.ListPendingHostScriptExecutions(ctx, host.ID)
|
||||
if err != nil {
|
||||
return fleet.OrbitConfig{}, err
|
||||
}
|
||||
|
|
@ -502,11 +501,24 @@ func getOrbitScriptEndpoint(ctx context.Context, request interface{}, svc fleet.
|
|||
}
|
||||
|
||||
func (svc *Service) GetHostScript(ctx context.Context, execID string) (*fleet.HostScriptResult, error) {
|
||||
// skipauth: No authorization check needed due to implementation returning
|
||||
// only license error.
|
||||
// this is not a user-authenticated endpoint
|
||||
svc.authz.SkipAuthorization(ctx)
|
||||
|
||||
return nil, fleet.ErrMissingLicense
|
||||
host, ok := hostctx.FromContext(ctx)
|
||||
if !ok {
|
||||
return nil, fleet.OrbitError{Message: "internal error: missing host from request context"}
|
||||
}
|
||||
|
||||
// get the script's details
|
||||
script, err := svc.ds.GetHostScriptExecutionResult(ctx, execID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// ensure it cannot get access to a different host's script
|
||||
if script.HostID != host.ID {
|
||||
return nil, ctxerr.Wrap(ctx, newNotFoundError(), "no script found for this host")
|
||||
}
|
||||
return script, nil
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////
|
||||
|
|
@ -543,11 +555,55 @@ func postOrbitScriptResultEndpoint(ctx context.Context, request interface{}, svc
|
|||
}
|
||||
|
||||
func (svc *Service) SaveHostScriptResult(ctx context.Context, result *fleet.HostScriptResultPayload) error {
|
||||
// skipauth: No authorization check needed due to implementation returning
|
||||
// only license error.
|
||||
// this is not a user-authenticated endpoint
|
||||
svc.authz.SkipAuthorization(ctx)
|
||||
|
||||
return fleet.ErrMissingLicense
|
||||
host, ok := hostctx.FromContext(ctx)
|
||||
if !ok {
|
||||
return fleet.OrbitError{Message: "internal error: missing host from request context"}
|
||||
}
|
||||
if result == nil {
|
||||
return ctxerr.Wrap(ctx, &fleet.BadRequestError{Message: "missing script result"}, "save host script result")
|
||||
}
|
||||
|
||||
// always use the authenticated host's ID as host_id
|
||||
result.HostID = host.ID
|
||||
hsr, err := svc.ds.SetHostScriptExecutionResult(ctx, result)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "save host script result")
|
||||
}
|
||||
|
||||
if hsr != nil {
|
||||
var user *fleet.User
|
||||
if hsr.UserID != nil {
|
||||
user, err = svc.ds.UserByID(ctx, *hsr.UserID)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "get host script execution user")
|
||||
}
|
||||
}
|
||||
var scriptName string
|
||||
if hsr.ScriptID != nil {
|
||||
scr, err := svc.ds.Script(ctx, *hsr.ScriptID)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "get saved script")
|
||||
}
|
||||
scriptName = scr.Name
|
||||
}
|
||||
if err := svc.ds.NewActivity(
|
||||
ctx,
|
||||
user,
|
||||
fleet.ActivityTypeRanScript{
|
||||
HostID: host.ID,
|
||||
HostDisplayName: host.DisplayName(),
|
||||
ScriptExecutionID: hsr.ExecutionID,
|
||||
ScriptName: scriptName,
|
||||
Async: !hsr.SyncRequest,
|
||||
},
|
||||
); err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "create activity for script execution request")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import (
|
|||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/pkg/optjson"
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
|
|
@ -23,7 +22,7 @@ func TestGetOrbitConfigNudge(t *testing.T) {
|
|||
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
|
||||
return appCfg, nil
|
||||
}
|
||||
ds.ListPendingHostScriptExecutionsFunc = func(ctx context.Context, hostID uint, ignoreOlder time.Duration) ([]*fleet.HostScriptResult, error) {
|
||||
ds.ListPendingHostScriptExecutionsFunc = func(ctx context.Context, hostID uint) ([]*fleet.HostScriptResult, error) {
|
||||
return nil, nil
|
||||
}
|
||||
ctx = test.HostContext(ctx, &fleet.Host{
|
||||
|
|
@ -76,7 +75,7 @@ func TestGetOrbitConfigNudge(t *testing.T) {
|
|||
ds.TeamAgentOptionsFunc = func(ctx context.Context, id uint) (*json.RawMessage, error) {
|
||||
return ptr.RawMessage(json.RawMessage(`{}`)), nil
|
||||
}
|
||||
ds.ListPendingHostScriptExecutionsFunc = func(ctx context.Context, hostID uint, ignoreOlder time.Duration) ([]*fleet.HostScriptResult, error) {
|
||||
ds.ListPendingHostScriptExecutionsFunc = func(ctx context.Context, hostID uint) ([]*fleet.HostScriptResult, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
|
|
@ -127,7 +126,7 @@ func TestGetOrbitConfigNudge(t *testing.T) {
|
|||
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
|
||||
return appCfg, nil
|
||||
}
|
||||
ds.ListPendingHostScriptExecutionsFunc = func(ctx context.Context, hostID uint, ignoreOlder time.Duration) ([]*fleet.HostScriptResult, error) {
|
||||
ds.ListPendingHostScriptExecutionsFunc = func(ctx context.Context, hostID uint) ([]*fleet.HostScriptResult, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,9 +13,12 @@ import (
|
|||
|
||||
"github.com/docker/go-units"
|
||||
"github.com/fleetdm/fleet/v4/pkg/scripts"
|
||||
"github.com/fleetdm/fleet/v4/server/authz"
|
||||
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
|
||||
"github.com/fleetdm/fleet/v4/server/contexts/logging"
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
"github.com/fleetdm/fleet/v4/server/ptr"
|
||||
"github.com/go-kit/kit/log/level"
|
||||
)
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
|
|
@ -108,12 +111,172 @@ func runScriptSyncEndpoint(ctx context.Context, request interface{}, svc fleet.S
|
|||
}, 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)
|
||||
const maxPendingScripts = 1000
|
||||
|
||||
return nil, fleet.ErrMissingLicense
|
||||
func (svc *Service) RunHostScript(ctx context.Context, request *fleet.HostScriptRequestPayload, waitForResult time.Duration) (*fleet.HostScriptResult, error) {
|
||||
// First check if scripts are disabled globally. If so, no need for further processing.
|
||||
cfg, err := svc.ds.AppConfig(ctx)
|
||||
if err != nil {
|
||||
svc.authz.SkipAuthorization(ctx)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if cfg.ServerSettings.ScriptsDisabled {
|
||||
svc.authz.SkipAuthorization(ctx)
|
||||
return nil, fleet.NewUserMessageError(errors.New(fleet.RunScriptScriptsDisabledGloballyErrMsg), http.StatusForbidden)
|
||||
}
|
||||
|
||||
// must load the host to get the team (cannot use lite, the last seen time is
|
||||
// required to check if it is online) to authorize with the proper team id.
|
||||
// We cannot first authorize if the user can list hosts, in case we
|
||||
// eventually allow a write-only role (e.g. gitops).
|
||||
host, err := svc.ds.Host(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")
|
||||
}
|
||||
|
||||
maxPending := maxPendingScripts
|
||||
|
||||
// must check that only one of script id or contents is provided before
|
||||
// authorization, as the permissions are not the same if a script id is
|
||||
// provided. There's no harm in returning the error if this validation fails,
|
||||
// since both values are user-provided it doesn't leak any internal
|
||||
// information.
|
||||
if request.ScriptID != nil && request.ScriptContents != "" {
|
||||
svc.authz.SkipAuthorization(ctx)
|
||||
return nil, fleet.NewInvalidArgumentError("script_id", `Only one of "script_id" or "script_contents" can be provided.`)
|
||||
}
|
||||
|
||||
// authorize with the host's team and the script id provided, as both affect
|
||||
// the permissions.
|
||||
if err := svc.authz.Authorize(ctx, &fleet.HostScriptResult{TeamID: host.TeamID, ScriptID: request.ScriptID}, fleet.ActionWrite); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if request.ScriptID != nil {
|
||||
script, err := svc.ds.Script(ctx, *request.ScriptID)
|
||||
if err != nil {
|
||||
if fleet.IsNotFound(err) {
|
||||
return nil, fleet.NewInvalidArgumentError("script_id", `No script exists for the provided "script_id".`).
|
||||
WithStatus(http.StatusNotFound)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
var scriptTmID, hostTmID uint
|
||||
if script.TeamID != nil {
|
||||
scriptTmID = *script.TeamID
|
||||
}
|
||||
if host.TeamID != nil {
|
||||
hostTmID = *host.TeamID
|
||||
}
|
||||
if scriptTmID != hostTmID {
|
||||
return nil, fleet.NewInvalidArgumentError("script_id", `The script does not belong to the same team (or no team) as the host.`)
|
||||
}
|
||||
|
||||
r, err := svc.ds.IsExecutionPendingForHost(ctx, request.HostID, *request.ScriptID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(r) > 0 {
|
||||
return nil, fleet.NewInvalidArgumentError("script_id", `The script is already queued on the given host.`).WithStatus(http.StatusConflict)
|
||||
}
|
||||
|
||||
contents, err := svc.ds.GetScriptContents(ctx, *request.ScriptID)
|
||||
if err != nil {
|
||||
if fleet.IsNotFound(err) {
|
||||
return nil, fleet.NewInvalidArgumentError("script_id", `No script exists for the provided "script_id".`).
|
||||
WithStatus(http.StatusNotFound)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
request.ScriptContents = string(contents)
|
||||
}
|
||||
|
||||
if err := fleet.ValidateHostScriptContents(request.ScriptContents); err != nil {
|
||||
return nil, fleet.NewInvalidArgumentError("script_contents", err.Error())
|
||||
}
|
||||
|
||||
asyncExecution := waitForResult <= 0
|
||||
|
||||
if !asyncExecution && host.Status(time.Now()) != fleet.StatusOnline {
|
||||
return nil, fleet.NewInvalidArgumentError("host_id", fleet.RunScriptHostOfflineErrMsg)
|
||||
}
|
||||
|
||||
pending, err := svc.ds.ListPendingHostScriptExecutions(ctx, request.HostID)
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "list host pending script executions")
|
||||
}
|
||||
if len(pending) > maxPending {
|
||||
return nil, fleet.NewInvalidArgumentError(
|
||||
"script_id", "cannot queue more than 1000 scripts per host",
|
||||
).WithStatus(http.StatusConflict)
|
||||
}
|
||||
|
||||
if !asyncExecution && len(pending) > 0 {
|
||||
return nil, fleet.NewInvalidArgumentError("script_id", fleet.RunScriptAlreadyRunningErrMsg).WithStatus(http.StatusConflict)
|
||||
}
|
||||
|
||||
// create the script execution request, the host will be notified of the
|
||||
// script execution request via the orbit config's Notifications mechanism.
|
||||
if ctxUser := authz.UserFromContext(ctx); ctxUser != nil {
|
||||
request.UserID = &ctxUser.ID
|
||||
}
|
||||
request.SyncRequest = !asyncExecution
|
||||
script, err := svc.ds.NewHostScriptExecutionRequest(ctx, request)
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "create script execution request")
|
||||
}
|
||||
script.Hostname = host.DisplayName()
|
||||
|
||||
if asyncExecution {
|
||||
// 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 != nil {
|
||||
// a result was received from the host, return
|
||||
result.Hostname = host.DisplayName()
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// //////////////////////////////////////////////////////////////////////////////
|
||||
|
|
@ -167,11 +330,36 @@ func getScriptResultEndpoint(ctx context.Context, request interface{}, svc fleet
|
|||
}
|
||||
|
||||
func (svc *Service) GetScriptResult(ctx context.Context, execID string) (*fleet.HostScriptResult, error) {
|
||||
// skipauth: No authorization check needed due to implementation returning
|
||||
// only license error.
|
||||
svc.authz.SkipAuthorization(ctx)
|
||||
scriptResult, err := svc.ds.GetHostScriptExecutionResult(ctx, execID)
|
||||
if err != nil {
|
||||
if fleet.IsNotFound(err) {
|
||||
if err := svc.authz.Authorize(ctx, &fleet.HostScriptResult{}, fleet.ActionRead); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
svc.authz.SkipAuthorization(ctx)
|
||||
return nil, ctxerr.Wrap(ctx, err, "get script result")
|
||||
}
|
||||
|
||||
return nil, fleet.ErrMissingLicense
|
||||
host, err := svc.ds.HostLite(ctx, scriptResult.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.ActionRead); 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.ActionRead); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
scriptResult.Hostname = host.DisplayName()
|
||||
|
||||
return scriptResult, nil
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
|
|
@ -236,11 +424,60 @@ func createScriptEndpoint(ctx context.Context, request interface{}, svc fleet.Se
|
|||
}
|
||||
|
||||
func (svc *Service) NewScript(ctx context.Context, teamID *uint, name string, r io.Reader) (*fleet.Script, error) {
|
||||
// skipauth: No authorization check needed due to implementation returning
|
||||
// only license error.
|
||||
svc.authz.SkipAuthorization(ctx)
|
||||
if err := svc.authz.Authorize(ctx, &fleet.Script{TeamID: teamID}, fleet.ActionWrite); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return nil, fleet.ErrMissingLicense
|
||||
b, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "read script contents")
|
||||
}
|
||||
|
||||
script := &fleet.Script{
|
||||
TeamID: teamID,
|
||||
Name: name,
|
||||
ScriptContents: string(b),
|
||||
}
|
||||
if err := script.Validate(); err != nil {
|
||||
return nil, fleet.NewInvalidArgumentError("script", err.Error())
|
||||
}
|
||||
|
||||
savedScript, err := svc.ds.NewScript(ctx, script)
|
||||
if err != nil {
|
||||
var (
|
||||
existsErr fleet.AlreadyExistsError
|
||||
fkErr fleet.ForeignKeyError
|
||||
)
|
||||
if errors.As(err, &existsErr) {
|
||||
err = fleet.NewInvalidArgumentError("script", "A script with this name already exists.").WithStatus(http.StatusConflict)
|
||||
} else if errors.As(err, &fkErr) {
|
||||
err = fleet.NewInvalidArgumentError("team_id", "The team does not exist.").WithStatus(http.StatusNotFound)
|
||||
}
|
||||
return nil, ctxerr.Wrap(ctx, err, "create script")
|
||||
}
|
||||
|
||||
var teamName *string
|
||||
if teamID != nil && *teamID != 0 {
|
||||
tm, err := svc.EnterpriseOverrides.TeamByIDOrName(ctx, teamID, nil)
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "get team name for create script activity")
|
||||
}
|
||||
teamName = &tm.Name
|
||||
}
|
||||
|
||||
if err := svc.ds.NewActivity(
|
||||
ctx,
|
||||
authz.UserFromContext(ctx),
|
||||
fleet.ActivityTypeAddedScript{
|
||||
TeamID: teamID,
|
||||
TeamName: teamName,
|
||||
ScriptName: script.Name,
|
||||
},
|
||||
); err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "new activity for create script")
|
||||
}
|
||||
|
||||
return savedScript, nil
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
|
|
@ -268,11 +505,37 @@ func deleteScriptEndpoint(ctx context.Context, request interface{}, svc fleet.Se
|
|||
}
|
||||
|
||||
func (svc *Service) DeleteScript(ctx context.Context, scriptID uint) error {
|
||||
// skipauth: No authorization check needed due to implementation returning
|
||||
// only license error.
|
||||
svc.authz.SkipAuthorization(ctx)
|
||||
script, err := svc.authorizeScriptByID(ctx, scriptID, fleet.ActionWrite)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return fleet.ErrMissingLicense
|
||||
if err := svc.ds.DeleteScript(ctx, script.ID); err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "delete script")
|
||||
}
|
||||
|
||||
var teamName *string
|
||||
if script.TeamID != nil && *script.TeamID != 0 {
|
||||
tm, err := svc.EnterpriseOverrides.TeamByIDOrName(ctx, script.TeamID, nil)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "get team name for delete script activity")
|
||||
}
|
||||
teamName = &tm.Name
|
||||
}
|
||||
|
||||
if err := svc.ds.NewActivity(
|
||||
ctx,
|
||||
authz.UserFromContext(ctx),
|
||||
fleet.ActivityTypeDeletedScript{
|
||||
TeamID: script.TeamID,
|
||||
TeamName: teamName,
|
||||
ScriptName: script.Name,
|
||||
},
|
||||
); err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "new activity for delete script")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
|
|
@ -305,11 +568,21 @@ func listScriptsEndpoint(ctx context.Context, request interface{}, svc fleet.Ser
|
|||
}
|
||||
|
||||
func (svc *Service) ListScripts(ctx context.Context, teamID *uint, opt fleet.ListOptions) ([]*fleet.Script, *fleet.PaginationMetadata, error) {
|
||||
// skipauth: No authorization check needed due to implementation returning
|
||||
// only license error.
|
||||
svc.authz.SkipAuthorization(ctx)
|
||||
if err := svc.authz.Authorize(ctx, &fleet.Script{TeamID: teamID}, fleet.ActionRead); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return nil, nil, fleet.ErrMissingLicense
|
||||
// cursor-based pagination is not supported for scripts
|
||||
opt.After = ""
|
||||
// custom ordering is not supported, always by name
|
||||
opt.OrderKey = "name"
|
||||
opt.OrderDirection = fleet.OrderAscending
|
||||
// no matching query support
|
||||
opt.MatchQuery = ""
|
||||
// always include metadata for scripts
|
||||
opt.IncludeMetadata = true
|
||||
|
||||
return svc.ds.ListScripts(ctx, teamID, opt)
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
|
|
@ -374,11 +647,19 @@ func getScriptEndpoint(ctx context.Context, request interface{}, svc fleet.Servi
|
|||
}
|
||||
|
||||
func (svc *Service) GetScript(ctx context.Context, scriptID uint, withContent bool) (*fleet.Script, []byte, error) {
|
||||
// skipauth: No authorization check needed due to implementation returning
|
||||
// only license error.
|
||||
svc.authz.SkipAuthorization(ctx)
|
||||
script, err := svc.authorizeScriptByID(ctx, scriptID, fleet.ActionRead)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return nil, nil, fleet.ErrMissingLicense
|
||||
var content []byte
|
||||
if withContent {
|
||||
content, err = svc.ds.GetScriptContents(ctx, scriptID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
return script, content, nil
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
|
|
@ -411,11 +692,39 @@ func getHostScriptDetailsEndpoint(ctx context.Context, request interface{}, svc
|
|||
}
|
||||
|
||||
func (svc *Service) GetHostScriptDetails(ctx context.Context, hostID uint, opt fleet.ListOptions) ([]*fleet.HostScriptDetail, *fleet.PaginationMetadata, error) {
|
||||
// skipauth: No authorization check needed due to implementation returning
|
||||
// only license error.
|
||||
svc.authz.SkipAuthorization(ctx)
|
||||
h, err := svc.ds.HostLite(ctx, hostID)
|
||||
if err != nil {
|
||||
if fleet.IsNotFound(err) {
|
||||
// if error is because the host does not exist, check first if the user
|
||||
// had global access (to prevent leaking valid host ids).
|
||||
if err := svc.authz.Authorize(ctx, &fleet.Script{}, fleet.ActionRead); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return nil, nil, fleet.ErrMissingLicense
|
||||
if err := svc.authz.Authorize(ctx, &fleet.Script{TeamID: h.TeamID}, fleet.ActionRead); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if h.Platform != "darwin" && h.Platform != "windows" {
|
||||
// darwin and windows are supported for now, all other platforms return empty results
|
||||
level.Debug(svc.logger).Log("msg", "unsupported platform for host script details", "platform", h.Platform, "host_id", h.ID)
|
||||
return []*fleet.HostScriptDetail{}, &fleet.PaginationMetadata{}, nil
|
||||
}
|
||||
|
||||
// cursor-based pagination is not supported for scripts
|
||||
opt.After = ""
|
||||
// custom ordering is not supported, always by name
|
||||
opt.OrderKey = "name"
|
||||
opt.OrderDirection = fleet.OrderAscending
|
||||
// no matching query support
|
||||
opt.MatchQuery = ""
|
||||
// always include metadata for scripts
|
||||
opt.IncludeMetadata = true
|
||||
|
||||
return svc.ds.GetHostScriptDetails(ctx, h.ID, h.TeamID, opt, h.Platform)
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
|
|
@ -446,9 +755,88 @@ func batchSetScriptsEndpoint(ctx context.Context, request interface{}, svc fleet
|
|||
}
|
||||
|
||||
func (svc *Service) BatchSetScripts(ctx context.Context, maybeTmID *uint, maybeTmName *string, payloads []fleet.ScriptPayload, dryRun bool) error {
|
||||
// skipauth: No authorization check needed due to implementation returning
|
||||
// only license error.
|
||||
svc.authz.SkipAuthorization(ctx)
|
||||
if maybeTmID != nil && maybeTmName != nil {
|
||||
svc.authz.SkipAuthorization(ctx) // so that the error message is not replaced by "forbidden"
|
||||
return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("team_name", "cannot specify both team_id and team_name"))
|
||||
}
|
||||
|
||||
return fleet.ErrMissingLicense
|
||||
var teamID *uint
|
||||
var teamName *string
|
||||
|
||||
if maybeTmID != nil || maybeTmName != nil {
|
||||
team, err := svc.EnterpriseOverrides.TeamByIDOrName(ctx, maybeTmID, maybeTmName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
teamID = &team.ID
|
||||
teamName = &team.Name
|
||||
}
|
||||
|
||||
if err := svc.authz.Authorize(ctx, &fleet.Script{TeamID: teamID}, fleet.ActionWrite); err != nil {
|
||||
return ctxerr.Wrap(ctx, err)
|
||||
}
|
||||
|
||||
// any duplicate name in the provided set results in an error
|
||||
scripts := make([]*fleet.Script, 0, len(payloads))
|
||||
byName := make(map[string]bool, len(payloads))
|
||||
for i, p := range payloads {
|
||||
script := &fleet.Script{
|
||||
ScriptContents: string(p.ScriptContents),
|
||||
Name: p.Name,
|
||||
TeamID: teamID,
|
||||
}
|
||||
|
||||
if err := script.Validate(); err != nil {
|
||||
return ctxerr.Wrap(ctx,
|
||||
fleet.NewInvalidArgumentError(fmt.Sprintf("scripts[%d]", i), err.Error()))
|
||||
}
|
||||
|
||||
if byName[script.Name] {
|
||||
return ctxerr.Wrap(ctx,
|
||||
fleet.NewInvalidArgumentError(fmt.Sprintf("scripts[%d]", i), fmt.Sprintf("Couldn’t edit scripts. More than one script has the same file name: %q", script.Name)),
|
||||
"duplicate script by name")
|
||||
}
|
||||
byName[script.Name] = true
|
||||
scripts = append(scripts, script)
|
||||
}
|
||||
|
||||
if dryRun {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := svc.ds.BatchSetScripts(ctx, teamID, scripts); err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "batch saving scripts")
|
||||
}
|
||||
|
||||
if err := svc.ds.NewActivity(ctx, authz.UserFromContext(ctx), &fleet.ActivityTypeEditedScript{
|
||||
TeamID: teamID,
|
||||
TeamName: teamName,
|
||||
}); err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "logging activity for edited scripts")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (svc *Service) authorizeScriptByID(ctx context.Context, scriptID uint, authzAction string) (*fleet.Script, error) {
|
||||
// first, get the script because we don't know which team id it is for.
|
||||
script, err := svc.ds.Script(ctx, scriptID)
|
||||
if err != nil {
|
||||
if fleet.IsNotFound(err) {
|
||||
// couldn't get the script to have its team, authorize with a no-team
|
||||
// script as a fallback - the requested script does not exist so there's
|
||||
// no way to know what team it would be for, and returning a 404 without
|
||||
// authorization would leak the existing/non existing ids.
|
||||
if err := svc.authz.Authorize(ctx, &fleet.Script{}, authzAction); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
svc.authz.SkipAuthorization(ctx)
|
||||
return nil, ctxerr.Wrap(ctx, err, "get script")
|
||||
}
|
||||
|
||||
// do the actual authorization with the script's team id
|
||||
if err := svc.authz.Authorize(ctx, script, authzAction); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return script, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,19 +50,16 @@ func TestHostRunScript(t *testing.T) {
|
|||
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) {
|
||||
ds.ListPendingHostScriptExecutionsFunc = func(ctx context.Context, hostID uint) ([]*fleet.HostScriptResult, error) {
|
||||
return nil, nil
|
||||
}
|
||||
ds.NewActivityFunc = func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails) error {
|
||||
require.IsType(t, fleet.ActivityTypeRanScript{}, activity)
|
||||
return nil
|
||||
}
|
||||
ds.ScriptFunc = func(ctx context.Context, id uint) (*fleet.Script, error) {
|
||||
return &fleet.Script{ID: id}, nil
|
||||
}
|
||||
ds.GetScriptContentsFunc = func(ctx context.Context, id uint) ([]byte, error) {
|
||||
return []byte("echo"), nil
|
||||
}
|
||||
ds.IsExecutionPendingForHostFunc = func(ctx context.Context, hostID, scriptID uint) ([]*uint, error) { return nil, nil }
|
||||
|
||||
t.Run("authorization checks", func(t *testing.T) {
|
||||
testCases := []struct {
|
||||
|
|
|
|||
Loading…
Reference in a new issue