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:
Martin Angers 2024-01-29 09:37:54 -05:00 committed by GitHub
parent e52dc6d8fc
commit ca435eb244
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
88 changed files with 3218 additions and 1151 deletions

View 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.

View 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.

View file

@ -0,0 +1 @@
* Created the "script ran" activity linked to its host so the script executions can be listed per host.

View file

@ -0,0 +1 @@
- add UI for host details activity card

View file

@ -0,0 +1 @@
- removes the premium tier check for scripts feature on the controls page.

View 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.

View file

@ -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

View file

@ -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)
}

View file

@ -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("Couldnt 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
}

View file

@ -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 = (

View file

@ -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`}>

View file

@ -9,6 +9,10 @@
animation: fade-in 150ms ease-out;
}
&__hidden {
visibility: hidden;
}
&__content {
margin-top: $pad-large;
font-size: $x-small;

View file

@ -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;

View file

@ -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}>

View file

@ -106,4 +106,5 @@ export interface IActivityDetails {
deadline_days?: number;
grace_period_days?: number;
stats?: IScheduledQueryStats;
host_id?: number;
}

View file

@ -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;

View 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;
}

View file

@ -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,
})
}
>

View file

@ -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);

View file

@ -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", () => {

View file

@ -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" };

View file

@ -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}
/>

View file

@ -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])}

View file

@ -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 ?? ""

View file

@ -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;
};

View file

@ -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}
/>
)}

View file

@ -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;
}
}
}

View file

@ -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);

View file

@ -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),
};
});
};

View file

@ -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;
}
}
}
}

View file

@ -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",

View file

@ -0,0 +1 @@
export { default } from "./RunScriptModal";

View file

@ -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;
}

View 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 wont
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;

View file

@ -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;

View file

@ -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;
}
}

View file

@ -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;

View file

@ -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;
}
}
}

View file

@ -0,0 +1 @@
export { default } from "./PastActivity";

View file

@ -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;

View file

@ -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;
}
}

View file

@ -0,0 +1 @@
export { default } from "./PastActivityFeed";

View file

@ -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;

View file

@ -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;
}
}
}

View file

@ -0,0 +1 @@
export { default } from "./UpcomingActivity";

View file

@ -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;

View file

@ -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;
}
}

View file

@ -0,0 +1 @@
export { default } from "./UpcomingActivityFeed";

View 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;
}
}

View file

@ -0,0 +1 @@
export { default } from "./Activity";

View file

@ -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}

View file

@ -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">

View file

@ -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;

View file

@ -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;
}
}

View file

@ -1 +0,0 @@
export { default } from "./Scripts";

View file

@ -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);
},
};

View file

@ -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);
},

View file

@ -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 => {

View file

@ -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,

View 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.

View file

@ -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,

View file

@ -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
}

View file

@ -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
}

View file

@ -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)
}
}
}
}

View file

@ -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

View file

@ -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

View file

@ -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
}

View file

@ -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

View file

@ -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,

View file

@ -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) {

View file

@ -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
}`

View file

@ -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)

View file

@ -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.

View file

@ -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

View file

@ -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) {

View file

@ -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)
}

View file

@ -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

View file

@ -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)

View file

@ -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)
})
}
}

View file

@ -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() {

View file

@ -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
}
/////////////////////////////////////////////////////////////////////////////////

View file

@ -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
}

View file

@ -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("Couldnt 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
}

View file

@ -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 {