Labels
{labels.length === 0 ? (
diff --git a/frontend/pages/hosts/details/cards/Scripts/Scripts.tsx b/frontend/pages/hosts/details/cards/Scripts/Scripts.tsx
deleted file mode 100644
index 717f1fb107..0000000000
--- a/frontend/pages/hosts/details/cards/Scripts/Scripts.tsx
+++ /dev/null
@@ -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(
- ["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;
- renderFlash("error", error.data.errors[0].reason);
- setIsScriptRunning(false);
- }
- break;
- default:
- break;
- }
- };
-
- if (isErrorScriptData) {
- return ;
- }
- const scriptColumnConfigs = generateTableColumnConfigs(
- onActionSelection,
- config.server_settings.scripts_disabled
- );
- const data = generateDataSet(
- currentUser,
- host,
- hostScriptResponse?.scripts || []
- );
-
- return (
-
- Scripts
- {isLoadingScriptData && }
- {!isLoadingScriptData && data && data.length === 0 && (
-
- )}
- {!isLoadingScriptData && data && data.length > 0 && (
- <>>}
- showMarkAllPages={false}
- isAllPagesSelected={false}
- columnConfigs={scriptColumnConfigs}
- data={data}
- isLoading={isScriptRunning}
- onQueryChange={onQueryChange}
- disableNextPage={hostScriptResponse?.meta.has_next_results}
- defaultPageIndex={page}
- disableCount
- />
- )}
-
- );
-};
-
-export default Scripts;
diff --git a/frontend/pages/hosts/details/cards/Scripts/_styles.scss b/frontend/pages/hosts/details/cards/Scripts/_styles.scss
deleted file mode 100644
index 90784836ae..0000000000
--- a/frontend/pages/hosts/details/cards/Scripts/_styles.scss
+++ /dev/null
@@ -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;
- }
-}
diff --git a/frontend/pages/hosts/details/cards/Scripts/components/ScriptStatusCell/_styles.scss b/frontend/pages/hosts/details/cards/Scripts/components/ScriptStatusCell/_styles.scss
deleted file mode 100644
index 2b0b194bf0..0000000000
--- a/frontend/pages/hosts/details/cards/Scripts/components/ScriptStatusCell/_styles.scss
+++ /dev/null
@@ -1,3 +0,0 @@
-.script-status-cell {
-
-}
diff --git a/frontend/pages/hosts/details/cards/Scripts/index.ts b/frontend/pages/hosts/details/cards/Scripts/index.ts
deleted file mode 100644
index ee228e7774..0000000000
--- a/frontend/pages/hosts/details/cards/Scripts/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { default } from "./Scripts";
diff --git a/frontend/services/entities/activities.ts b/frontend/services/entities/activities.ts
index fa36ab809d..befbb47587 100644
--- a/frontend/services/entities/activities.ts
+++ b/frontend/services/entities/activities.ts
@@ -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 => {
+ 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 => {
+ 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);
+ },
};
diff --git a/frontend/services/entities/scripts.ts b/frontend/services/entities/scripts.ts
index 2dfe585793..8ed35f9a17 100644
--- a/frontend/services/entities/scripts.ts
+++ b/frontend/services/entities/scripts.ts
@@ -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);
},
diff --git a/frontend/utilities/endpoints.ts b/frontend/utilities/endpoints.ts
index 988d168717..3e63cdf85a 100644
--- a/frontend/utilities/endpoints.ts
+++ b/frontend/utilities/endpoints.ts
@@ -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 => {
diff --git a/frontend/utilities/helpers.tsx b/frontend/utilities/helpers.tsx
index d8d0a1e2c7..ec9b2d8afd 100644
--- a/frontend/utilities/helpers.tsx
+++ b/frontend/utilities/helpers.tsx
@@ -480,6 +480,16 @@ export const formatPackForClient = (pack: IPack): IPack => {
return pack;
};
+export const formatScriptNameForActivityItem = (name: string | undefined) => {
+ return name ? (
+ <>
+ the {name} script
+ >
+ ) : (
+ "a script"
+ );
+};
+
export const generateRole = (
teams: ITeam[],
globalRole: UserRole | null
@@ -876,6 +886,7 @@ export default {
formatFloatAsPercentage,
formatScheduledQueryForClient,
formatScheduledQueryForServer,
+ formatScriptNameForActivityItem,
formatGlobalScheduledQueryForClient,
formatGlobalScheduledQueryForServer,
formatTeamScheduledQueryForClient,
diff --git a/orbit/changes/15963-fleetd-agent b/orbit/changes/15963-fleetd-agent
new file mode 100644
index 0000000000..35e04a023a
--- /dev/null
+++ b/orbit/changes/15963-fleetd-agent
@@ -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.
\ No newline at end of file
diff --git a/orbit/pkg/scripts/scripts.go b/orbit/pkg/scripts/scripts.go
index bbd0f972b2..51efeb9c22 100644
--- a/orbit/pkg/scripts/scripts.go
+++ b/orbit/pkg/scripts/scripts.go
@@ -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,
diff --git a/orbit/pkg/scripts/scripts_test.go b/orbit/pkg/scripts/scripts_test.go
index c5ed0f7c9b..e7eb22f4f8 100644
--- a/orbit/pkg/scripts/scripts_test.go
+++ b/orbit/pkg/scripts/scripts_test.go
@@ -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
}
diff --git a/server/datastore/mysql/activities.go b/server/datastore/mysql/activities.go
index 85cc629cd1..e73b443196 100644
--- a/server/datastore/mysql/activities.go
+++ b/server/datastore/mysql/activities.go
@@ -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
+}
diff --git a/server/datastore/mysql/activities_test.go b/server/datastore/mysql/activities_test.go
index 9f0938bb05..a7c12715af 100644
--- a/server/datastore/mysql/activities_test.go
+++ b/server/datastore/mysql/activities_test.go
@@ -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)
+ }
+ }
+ }
+}
diff --git a/server/datastore/mysql/hosts.go b/server/datastore/mysql/hosts.go
index 6a5e843c35..9c15f2e860 100644
--- a/server/datastore/mysql/hosts.go
+++ b/server/datastore/mysql/hosts.go
@@ -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
diff --git a/server/datastore/mysql/hosts_test.go b/server/datastore/mysql/hosts_test.go
index b9c1cc2bb0..74060c3e97 100644
--- a/server/datastore/mysql/hosts_test.go
+++ b/server/datastore/mysql/hosts_test.go
@@ -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
diff --git a/server/datastore/mysql/migrations/tables/20240126020643_AddHostActivities.go b/server/datastore/mysql/migrations/tables/20240126020643_AddHostActivities.go
new file mode 100644
index 0000000000..13c05e4353
--- /dev/null
+++ b/server/datastore/mysql/migrations/tables/20240126020643_AddHostActivities.go
@@ -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
+}
diff --git a/server/datastore/mysql/migrations/tables/20240126020643_AddHostActivities_test.go b/server/datastore/mysql/migrations/tables/20240126020643_AddHostActivities_test.go
new file mode 100644
index 0000000000..711a30a9b8
--- /dev/null
+++ b/server/datastore/mysql/migrations/tables/20240126020643_AddHostActivities_test.go
@@ -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)
+}
diff --git a/server/datastore/mysql/schema.sql b/server/datastore/mysql/schema.sql
index 873f3296ea..825ac70acb 100644
--- a/server/datastore/mysql/schema.sql
+++ b/server/datastore/mysql/schema.sql
@@ -160,6 +160,16 @@ CREATE TABLE `eulas` (
/*!40101 SET character_set_client = @saved_cs_client */;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8 */;
+CREATE TABLE `host_activities` (
+ `host_id` int(10) unsigned NOT NULL,
+ `activity_id` int(10) unsigned NOT NULL,
+ PRIMARY KEY (`host_id`,`activity_id`),
+ KEY `fk_host_activities_activity_id` (`activity_id`),
+ CONSTRAINT `host_activities_ibfk_1` FOREIGN KEY (`activity_id`) REFERENCES `activities` (`id`) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+/*!40101 SET character_set_client = @saved_cs_client */;
+/*!40101 SET @saved_cs_client = @@character_set_client */;
+/*!40101 SET character_set_client = utf8 */;
CREATE TABLE `host_additional` (
`host_id` int(10) unsigned NOT NULL,
`additional` json DEFAULT NULL,
@@ -364,12 +374,16 @@ CREATE TABLE `host_script_results` (
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`script_id` int(10) unsigned DEFAULT NULL,
+ `user_id` int(10) unsigned DEFAULT NULL,
+ `sync_request` tinyint(1) NOT NULL DEFAULT '0',
PRIMARY KEY (`id`),
UNIQUE KEY `idx_host_script_results_execution_id` (`execution_id`),
KEY `idx_host_script_results_host_exit_created` (`host_id`,`exit_code`,`created_at`),
KEY `fk_host_script_results_script_id` (`script_id`),
KEY `idx_host_script_created_at` (`host_id`,`script_id`,`created_at`),
- CONSTRAINT `fk_host_script_results_script_id` FOREIGN KEY (`script_id`) REFERENCES `scripts` (`id`) ON DELETE SET NULL
+ KEY `fk_host_script_results_user_id` (`user_id`),
+ CONSTRAINT `fk_host_script_results_script_id` FOREIGN KEY (`script_id`) REFERENCES `scripts` (`id`) ON DELETE SET NULL,
+ CONSTRAINT `fk_host_script_results_user_id` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
/*!40101 SET @saved_cs_client = @@character_set_client */;
@@ -745,9 +759,9 @@ CREATE TABLE `migration_status_tables` (
`tstamp` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `id` (`id`)
-) ENGINE=InnoDB AUTO_INCREMENT=240 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+) ENGINE=InnoDB AUTO_INCREMENT=241 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
-INSERT INTO `migration_status_tables` VALUES (1,0,1,'2020-01-01 01:01:01'),(2,20161118193812,1,'2020-01-01 01:01:01'),(3,20161118211713,1,'2020-01-01 01:01:01'),(4,20161118212436,1,'2020-01-01 01:01:01'),(5,20161118212515,1,'2020-01-01 01:01:01'),(6,20161118212528,1,'2020-01-01 01:01:01'),(7,20161118212538,1,'2020-01-01 01:01:01'),(8,20161118212549,1,'2020-01-01 01:01:01'),(9,20161118212557,1,'2020-01-01 01:01:01'),(10,20161118212604,1,'2020-01-01 01:01:01'),(11,20161118212613,1,'2020-01-01 01:01:01'),(12,20161118212621,1,'2020-01-01 01:01:01'),(13,20161118212630,1,'2020-01-01 01:01:01'),(14,20161118212641,1,'2020-01-01 01:01:01'),(15,20161118212649,1,'2020-01-01 01:01:01'),(16,20161118212656,1,'2020-01-01 01:01:01'),(17,20161118212758,1,'2020-01-01 01:01:01'),(18,20161128234849,1,'2020-01-01 01:01:01'),(19,20161230162221,1,'2020-01-01 01:01:01'),(20,20170104113816,1,'2020-01-01 01:01:01'),(21,20170105151732,1,'2020-01-01 01:01:01'),(22,20170108191242,1,'2020-01-01 01:01:01'),(23,20170109094020,1,'2020-01-01 01:01:01'),(24,20170109130438,1,'2020-01-01 01:01:01'),(25,20170110202752,1,'2020-01-01 01:01:01'),(26,20170111133013,1,'2020-01-01 01:01:01'),(27,20170117025759,1,'2020-01-01 01:01:01'),(28,20170118191001,1,'2020-01-01 01:01:01'),(29,20170119234632,1,'2020-01-01 01:01:01'),(30,20170124230432,1,'2020-01-01 01:01:01'),(31,20170127014618,1,'2020-01-01 01:01:01'),(32,20170131232841,1,'2020-01-01 01:01:01'),(33,20170223094154,1,'2020-01-01 01:01:01'),(34,20170306075207,1,'2020-01-01 01:01:01'),(35,20170309100733,1,'2020-01-01 01:01:01'),(36,20170331111922,1,'2020-01-01 01:01:01'),(37,20170502143928,1,'2020-01-01 01:01:01'),(38,20170504130602,1,'2020-01-01 01:01:01'),(39,20170509132100,1,'2020-01-01 01:01:01'),(40,20170519105647,1,'2020-01-01 01:01:01'),(41,20170519105648,1,'2020-01-01 01:01:01'),(42,20170831234300,1,'2020-01-01 01:01:01'),(43,20170831234301,1,'2020-01-01 01:01:01'),(44,20170831234303,1,'2020-01-01 01:01:01'),(45,20171116163618,1,'2020-01-01 01:01:01'),(46,20171219164727,1,'2020-01-01 01:01:01'),(47,20180620164811,1,'2020-01-01 01:01:01'),(48,20180620175054,1,'2020-01-01 01:01:01'),(49,20180620175055,1,'2020-01-01 01:01:01'),(50,20191010101639,1,'2020-01-01 01:01:01'),(51,20191010155147,1,'2020-01-01 01:01:01'),(52,20191220130734,1,'2020-01-01 01:01:01'),(53,20200311140000,1,'2020-01-01 01:01:01'),(54,20200405120000,1,'2020-01-01 01:01:01'),(55,20200407120000,1,'2020-01-01 01:01:01'),(56,20200420120000,1,'2020-01-01 01:01:01'),(57,20200504120000,1,'2020-01-01 01:01:01'),(58,20200512120000,1,'2020-01-01 01:01:01'),(59,20200707120000,1,'2020-01-01 01:01:01'),(60,20201011162341,1,'2020-01-01 01:01:01'),(61,20201021104586,1,'2020-01-01 01:01:01'),(62,20201102112520,1,'2020-01-01 01:01:01'),(63,20201208121729,1,'2020-01-01 01:01:01'),(64,20201215091637,1,'2020-01-01 01:01:01'),(65,20210119174155,1,'2020-01-01 01:01:01'),(66,20210326182902,1,'2020-01-01 01:01:01'),(67,20210421112652,1,'2020-01-01 01:01:01'),(68,20210506095025,1,'2020-01-01 01:01:01'),(69,20210513115729,1,'2020-01-01 01:01:01'),(70,20210526113559,1,'2020-01-01 01:01:01'),(71,20210601000001,1,'2020-01-01 01:01:01'),(72,20210601000002,1,'2020-01-01 01:01:01'),(73,20210601000003,1,'2020-01-01 01:01:01'),(74,20210601000004,1,'2020-01-01 01:01:01'),(75,20210601000005,1,'2020-01-01 01:01:01'),(76,20210601000006,1,'2020-01-01 01:01:01'),(77,20210601000007,1,'2020-01-01 01:01:01'),(78,20210601000008,1,'2020-01-01 01:01:01'),(79,20210606151329,1,'2020-01-01 01:01:01'),(80,20210616163757,1,'2020-01-01 01:01:01'),(81,20210617174723,1,'2020-01-01 01:01:01'),(82,20210622160235,1,'2020-01-01 01:01:01'),(83,20210623100031,1,'2020-01-01 01:01:01'),(84,20210623133615,1,'2020-01-01 01:01:01'),(85,20210708143152,1,'2020-01-01 01:01:01'),(86,20210709124443,1,'2020-01-01 01:01:01'),(87,20210712155608,1,'2020-01-01 01:01:01'),(88,20210714102108,1,'2020-01-01 01:01:01'),(89,20210719153709,1,'2020-01-01 01:01:01'),(90,20210721171531,1,'2020-01-01 01:01:01'),(91,20210723135713,1,'2020-01-01 01:01:01'),(92,20210802135933,1,'2020-01-01 01:01:01'),(93,20210806112844,1,'2020-01-01 01:01:01'),(94,20210810095603,1,'2020-01-01 01:01:01'),(95,20210811150223,1,'2020-01-01 01:01:01'),(96,20210818151827,1,'2020-01-01 01:01:01'),(97,20210818151828,1,'2020-01-01 01:01:01'),(98,20210818182258,1,'2020-01-01 01:01:01'),(99,20210819131107,1,'2020-01-01 01:01:01'),(100,20210819143446,1,'2020-01-01 01:01:01'),(101,20210903132338,1,'2020-01-01 01:01:01'),(102,20210915144307,1,'2020-01-01 01:01:01'),(103,20210920155130,1,'2020-01-01 01:01:01'),(104,20210927143115,1,'2020-01-01 01:01:01'),(105,20210927143116,1,'2020-01-01 01:01:01'),(106,20211013133706,1,'2020-01-01 01:01:01'),(107,20211013133707,1,'2020-01-01 01:01:01'),(108,20211102135149,1,'2020-01-01 01:01:01'),(109,20211109121546,1,'2020-01-01 01:01:01'),(110,20211110163320,1,'2020-01-01 01:01:01'),(111,20211116184029,1,'2020-01-01 01:01:01'),(112,20211116184030,1,'2020-01-01 01:01:01'),(113,20211202092042,1,'2020-01-01 01:01:01'),(114,20211202181033,1,'2020-01-01 01:01:01'),(115,20211207161856,1,'2020-01-01 01:01:01'),(116,20211216131203,1,'2020-01-01 01:01:01'),(117,20211221110132,1,'2020-01-01 01:01:01'),(118,20220107155700,1,'2020-01-01 01:01:01'),(119,20220125105650,1,'2020-01-01 01:01:01'),(120,20220201084510,1,'2020-01-01 01:01:01'),(121,20220208144830,1,'2020-01-01 01:01:01'),(122,20220208144831,1,'2020-01-01 01:01:01'),(123,20220215152203,1,'2020-01-01 01:01:01'),(124,20220223113157,1,'2020-01-01 01:01:01'),(125,20220307104655,1,'2020-01-01 01:01:01'),(126,20220309133956,1,'2020-01-01 01:01:01'),(127,20220316155700,1,'2020-01-01 01:01:01'),(128,20220323152301,1,'2020-01-01 01:01:01'),(129,20220330100659,1,'2020-01-01 01:01:01'),(130,20220404091216,1,'2020-01-01 01:01:01'),(131,20220419140750,1,'2020-01-01 01:01:01'),(132,20220428140039,1,'2020-01-01 01:01:01'),(133,20220503134048,1,'2020-01-01 01:01:01'),(134,20220524102918,1,'2020-01-01 01:01:01'),(135,20220526123327,1,'2020-01-01 01:01:01'),(136,20220526123328,1,'2020-01-01 01:01:01'),(137,20220526123329,1,'2020-01-01 01:01:01'),(138,20220608113128,1,'2020-01-01 01:01:01'),(139,20220627104817,1,'2020-01-01 01:01:01'),(140,20220704101843,1,'2020-01-01 01:01:01'),(141,20220708095046,1,'2020-01-01 01:01:01'),(142,20220713091130,1,'2020-01-01 01:01:01'),(143,20220802135510,1,'2020-01-01 01:01:01'),(144,20220818101352,1,'2020-01-01 01:01:01'),(145,20220822161445,1,'2020-01-01 01:01:01'),(146,20220831100036,1,'2020-01-01 01:01:01'),(147,20220831100151,1,'2020-01-01 01:01:01'),(148,20220908181826,1,'2020-01-01 01:01:01'),(149,20220914154915,1,'2020-01-01 01:01:01'),(150,20220915165115,1,'2020-01-01 01:01:01'),(151,20220915165116,1,'2020-01-01 01:01:01'),(152,20220928100158,1,'2020-01-01 01:01:01'),(153,20221014084130,1,'2020-01-01 01:01:01'),(154,20221027085019,1,'2020-01-01 01:01:01'),(155,20221101103952,1,'2020-01-01 01:01:01'),(156,20221104144401,1,'2020-01-01 01:01:01'),(157,20221109100749,1,'2020-01-01 01:01:01'),(158,20221115104546,1,'2020-01-01 01:01:01'),(159,20221130114928,1,'2020-01-01 01:01:01'),(160,20221205112142,1,'2020-01-01 01:01:01'),(161,20221216115820,1,'2020-01-01 01:01:01'),(162,20221220195934,1,'2020-01-01 01:01:01'),(163,20221220195935,1,'2020-01-01 01:01:01'),(164,20221223174807,1,'2020-01-01 01:01:01'),(165,20221227163855,1,'2020-01-01 01:01:01'),(166,20221227163856,1,'2020-01-01 01:01:01'),(167,20230202224725,1,'2020-01-01 01:01:01'),(168,20230206163608,1,'2020-01-01 01:01:01'),(169,20230214131519,1,'2020-01-01 01:01:01'),(170,20230303135738,1,'2020-01-01 01:01:01'),(171,20230313135301,1,'2020-01-01 01:01:01'),(172,20230313141819,1,'2020-01-01 01:01:01'),(173,20230315104937,1,'2020-01-01 01:01:01'),(174,20230317173844,1,'2020-01-01 01:01:01'),(175,20230320133602,1,'2020-01-01 01:01:01'),(176,20230330100011,1,'2020-01-01 01:01:01'),(177,20230330134823,1,'2020-01-01 01:01:01'),(178,20230405232025,1,'2020-01-01 01:01:01'),(179,20230408084104,1,'2020-01-01 01:01:01'),(180,20230411102858,1,'2020-01-01 01:01:01'),(181,20230421155932,1,'2020-01-01 01:01:01'),(182,20230425082126,1,'2020-01-01 01:01:01'),(183,20230425105727,1,'2020-01-01 01:01:01'),(184,20230501154913,1,'2020-01-01 01:01:01'),(185,20230503101418,1,'2020-01-01 01:01:01'),(186,20230515144206,1,'2020-01-01 01:01:01'),(187,20230517140952,1,'2020-01-01 01:01:01'),(188,20230517152807,1,'2020-01-01 01:01:01'),(189,20230518114155,1,'2020-01-01 01:01:01'),(190,20230520153236,1,'2020-01-01 01:01:01'),(191,20230525151159,1,'2020-01-01 01:01:01'),(192,20230530122103,1,'2020-01-01 01:01:01'),(193,20230602111827,1,'2020-01-01 01:01:01'),(194,20230608103123,1,'2020-01-01 01:01:01'),(195,20230629140529,1,'2020-01-01 01:01:01'),(196,20230629140530,1,'2020-01-01 01:01:01'),(197,20230711144622,1,'2020-01-01 01:01:01'),(198,20230721135421,1,'2020-01-01 01:01:01'),(199,20230721161508,1,'2020-01-01 01:01:01'),(200,20230726115701,1,'2020-01-01 01:01:01'),(201,20230807100822,1,'2020-01-01 01:01:01'),(202,20230814150442,1,'2020-01-01 01:01:01'),(203,20230823122728,1,'2020-01-01 01:01:01'),(204,20230906152143,1,'2020-01-01 01:01:01'),(205,20230911163618,1,'2020-01-01 01:01:01'),(206,20230912101759,1,'2020-01-01 01:01:01'),(207,20230915101341,1,'2020-01-01 01:01:01'),(208,20230918132351,1,'2020-01-01 01:01:01'),(209,20231004144339,1,'2020-01-01 01:01:01'),(210,20231009094541,1,'2020-01-01 01:01:01'),(211,20231009094542,1,'2020-01-01 01:01:01'),(212,20231009094543,1,'2020-01-01 01:01:01'),(213,20231009094544,1,'2020-01-01 01:01:01'),(214,20231016091915,1,'2020-01-01 01:01:01'),(215,20231024174135,1,'2020-01-01 01:01:01'),(216,20231025120016,1,'2020-01-01 01:01:01'),(217,20231025160156,1,'2020-01-01 01:01:01'),(218,20231031165350,1,'2020-01-01 01:01:01'),(219,20231106144110,1,'2020-01-01 01:01:01'),(220,20231107130934,1,'2020-01-01 01:01:01'),(221,20231109115838,1,'2020-01-01 01:01:01'),(222,20231121054530,1,'2020-01-01 01:01:01'),(223,20231122101320,1,'2020-01-01 01:01:01'),(224,20231130132828,1,'2020-01-01 01:01:01'),(225,20231130132931,1,'2020-01-01 01:01:01'),(226,20231204155427,1,'2020-01-01 01:01:01'),(227,20231206142340,1,'2020-01-01 01:01:01'),(228,20231207102320,1,'2020-01-01 01:01:01'),(229,20231207102321,1,'2020-01-01 01:01:01'),(230,20231207133731,1,'2020-01-01 01:01:01'),(231,20231212094238,1,'2020-01-01 01:01:01'),(232,20231212095734,1,'2020-01-01 01:01:01'),(233,20231212161121,1,'2020-01-01 01:01:01'),(234,20231215122713,1,'2020-01-01 01:01:01'),(235,20231219143041,1,'2020-01-01 01:01:01'),(236,20231224070653,1,'2020-01-01 01:01:01'),(237,20240110134315,1,'2020-01-01 01:01:01'),(238,20240119091637,1,'2020-01-01 01:01:01'),(239,20240126020642,1,'2020-01-01 01:01:01');
+INSERT INTO `migration_status_tables` VALUES (1,0,1,'2020-01-01 01:01:01'),(2,20161118193812,1,'2020-01-01 01:01:01'),(3,20161118211713,1,'2020-01-01 01:01:01'),(4,20161118212436,1,'2020-01-01 01:01:01'),(5,20161118212515,1,'2020-01-01 01:01:01'),(6,20161118212528,1,'2020-01-01 01:01:01'),(7,20161118212538,1,'2020-01-01 01:01:01'),(8,20161118212549,1,'2020-01-01 01:01:01'),(9,20161118212557,1,'2020-01-01 01:01:01'),(10,20161118212604,1,'2020-01-01 01:01:01'),(11,20161118212613,1,'2020-01-01 01:01:01'),(12,20161118212621,1,'2020-01-01 01:01:01'),(13,20161118212630,1,'2020-01-01 01:01:01'),(14,20161118212641,1,'2020-01-01 01:01:01'),(15,20161118212649,1,'2020-01-01 01:01:01'),(16,20161118212656,1,'2020-01-01 01:01:01'),(17,20161118212758,1,'2020-01-01 01:01:01'),(18,20161128234849,1,'2020-01-01 01:01:01'),(19,20161230162221,1,'2020-01-01 01:01:01'),(20,20170104113816,1,'2020-01-01 01:01:01'),(21,20170105151732,1,'2020-01-01 01:01:01'),(22,20170108191242,1,'2020-01-01 01:01:01'),(23,20170109094020,1,'2020-01-01 01:01:01'),(24,20170109130438,1,'2020-01-01 01:01:01'),(25,20170110202752,1,'2020-01-01 01:01:01'),(26,20170111133013,1,'2020-01-01 01:01:01'),(27,20170117025759,1,'2020-01-01 01:01:01'),(28,20170118191001,1,'2020-01-01 01:01:01'),(29,20170119234632,1,'2020-01-01 01:01:01'),(30,20170124230432,1,'2020-01-01 01:01:01'),(31,20170127014618,1,'2020-01-01 01:01:01'),(32,20170131232841,1,'2020-01-01 01:01:01'),(33,20170223094154,1,'2020-01-01 01:01:01'),(34,20170306075207,1,'2020-01-01 01:01:01'),(35,20170309100733,1,'2020-01-01 01:01:01'),(36,20170331111922,1,'2020-01-01 01:01:01'),(37,20170502143928,1,'2020-01-01 01:01:01'),(38,20170504130602,1,'2020-01-01 01:01:01'),(39,20170509132100,1,'2020-01-01 01:01:01'),(40,20170519105647,1,'2020-01-01 01:01:01'),(41,20170519105648,1,'2020-01-01 01:01:01'),(42,20170831234300,1,'2020-01-01 01:01:01'),(43,20170831234301,1,'2020-01-01 01:01:01'),(44,20170831234303,1,'2020-01-01 01:01:01'),(45,20171116163618,1,'2020-01-01 01:01:01'),(46,20171219164727,1,'2020-01-01 01:01:01'),(47,20180620164811,1,'2020-01-01 01:01:01'),(48,20180620175054,1,'2020-01-01 01:01:01'),(49,20180620175055,1,'2020-01-01 01:01:01'),(50,20191010101639,1,'2020-01-01 01:01:01'),(51,20191010155147,1,'2020-01-01 01:01:01'),(52,20191220130734,1,'2020-01-01 01:01:01'),(53,20200311140000,1,'2020-01-01 01:01:01'),(54,20200405120000,1,'2020-01-01 01:01:01'),(55,20200407120000,1,'2020-01-01 01:01:01'),(56,20200420120000,1,'2020-01-01 01:01:01'),(57,20200504120000,1,'2020-01-01 01:01:01'),(58,20200512120000,1,'2020-01-01 01:01:01'),(59,20200707120000,1,'2020-01-01 01:01:01'),(60,20201011162341,1,'2020-01-01 01:01:01'),(61,20201021104586,1,'2020-01-01 01:01:01'),(62,20201102112520,1,'2020-01-01 01:01:01'),(63,20201208121729,1,'2020-01-01 01:01:01'),(64,20201215091637,1,'2020-01-01 01:01:01'),(65,20210119174155,1,'2020-01-01 01:01:01'),(66,20210326182902,1,'2020-01-01 01:01:01'),(67,20210421112652,1,'2020-01-01 01:01:01'),(68,20210506095025,1,'2020-01-01 01:01:01'),(69,20210513115729,1,'2020-01-01 01:01:01'),(70,20210526113559,1,'2020-01-01 01:01:01'),(71,20210601000001,1,'2020-01-01 01:01:01'),(72,20210601000002,1,'2020-01-01 01:01:01'),(73,20210601000003,1,'2020-01-01 01:01:01'),(74,20210601000004,1,'2020-01-01 01:01:01'),(75,20210601000005,1,'2020-01-01 01:01:01'),(76,20210601000006,1,'2020-01-01 01:01:01'),(77,20210601000007,1,'2020-01-01 01:01:01'),(78,20210601000008,1,'2020-01-01 01:01:01'),(79,20210606151329,1,'2020-01-01 01:01:01'),(80,20210616163757,1,'2020-01-01 01:01:01'),(81,20210617174723,1,'2020-01-01 01:01:01'),(82,20210622160235,1,'2020-01-01 01:01:01'),(83,20210623100031,1,'2020-01-01 01:01:01'),(84,20210623133615,1,'2020-01-01 01:01:01'),(85,20210708143152,1,'2020-01-01 01:01:01'),(86,20210709124443,1,'2020-01-01 01:01:01'),(87,20210712155608,1,'2020-01-01 01:01:01'),(88,20210714102108,1,'2020-01-01 01:01:01'),(89,20210719153709,1,'2020-01-01 01:01:01'),(90,20210721171531,1,'2020-01-01 01:01:01'),(91,20210723135713,1,'2020-01-01 01:01:01'),(92,20210802135933,1,'2020-01-01 01:01:01'),(93,20210806112844,1,'2020-01-01 01:01:01'),(94,20210810095603,1,'2020-01-01 01:01:01'),(95,20210811150223,1,'2020-01-01 01:01:01'),(96,20210818151827,1,'2020-01-01 01:01:01'),(97,20210818151828,1,'2020-01-01 01:01:01'),(98,20210818182258,1,'2020-01-01 01:01:01'),(99,20210819131107,1,'2020-01-01 01:01:01'),(100,20210819143446,1,'2020-01-01 01:01:01'),(101,20210903132338,1,'2020-01-01 01:01:01'),(102,20210915144307,1,'2020-01-01 01:01:01'),(103,20210920155130,1,'2020-01-01 01:01:01'),(104,20210927143115,1,'2020-01-01 01:01:01'),(105,20210927143116,1,'2020-01-01 01:01:01'),(106,20211013133706,1,'2020-01-01 01:01:01'),(107,20211013133707,1,'2020-01-01 01:01:01'),(108,20211102135149,1,'2020-01-01 01:01:01'),(109,20211109121546,1,'2020-01-01 01:01:01'),(110,20211110163320,1,'2020-01-01 01:01:01'),(111,20211116184029,1,'2020-01-01 01:01:01'),(112,20211116184030,1,'2020-01-01 01:01:01'),(113,20211202092042,1,'2020-01-01 01:01:01'),(114,20211202181033,1,'2020-01-01 01:01:01'),(115,20211207161856,1,'2020-01-01 01:01:01'),(116,20211216131203,1,'2020-01-01 01:01:01'),(117,20211221110132,1,'2020-01-01 01:01:01'),(118,20220107155700,1,'2020-01-01 01:01:01'),(119,20220125105650,1,'2020-01-01 01:01:01'),(120,20220201084510,1,'2020-01-01 01:01:01'),(121,20220208144830,1,'2020-01-01 01:01:01'),(122,20220208144831,1,'2020-01-01 01:01:01'),(123,20220215152203,1,'2020-01-01 01:01:01'),(124,20220223113157,1,'2020-01-01 01:01:01'),(125,20220307104655,1,'2020-01-01 01:01:01'),(126,20220309133956,1,'2020-01-01 01:01:01'),(127,20220316155700,1,'2020-01-01 01:01:01'),(128,20220323152301,1,'2020-01-01 01:01:01'),(129,20220330100659,1,'2020-01-01 01:01:01'),(130,20220404091216,1,'2020-01-01 01:01:01'),(131,20220419140750,1,'2020-01-01 01:01:01'),(132,20220428140039,1,'2020-01-01 01:01:01'),(133,20220503134048,1,'2020-01-01 01:01:01'),(134,20220524102918,1,'2020-01-01 01:01:01'),(135,20220526123327,1,'2020-01-01 01:01:01'),(136,20220526123328,1,'2020-01-01 01:01:01'),(137,20220526123329,1,'2020-01-01 01:01:01'),(138,20220608113128,1,'2020-01-01 01:01:01'),(139,20220627104817,1,'2020-01-01 01:01:01'),(140,20220704101843,1,'2020-01-01 01:01:01'),(141,20220708095046,1,'2020-01-01 01:01:01'),(142,20220713091130,1,'2020-01-01 01:01:01'),(143,20220802135510,1,'2020-01-01 01:01:01'),(144,20220818101352,1,'2020-01-01 01:01:01'),(145,20220822161445,1,'2020-01-01 01:01:01'),(146,20220831100036,1,'2020-01-01 01:01:01'),(147,20220831100151,1,'2020-01-01 01:01:01'),(148,20220908181826,1,'2020-01-01 01:01:01'),(149,20220914154915,1,'2020-01-01 01:01:01'),(150,20220915165115,1,'2020-01-01 01:01:01'),(151,20220915165116,1,'2020-01-01 01:01:01'),(152,20220928100158,1,'2020-01-01 01:01:01'),(153,20221014084130,1,'2020-01-01 01:01:01'),(154,20221027085019,1,'2020-01-01 01:01:01'),(155,20221101103952,1,'2020-01-01 01:01:01'),(156,20221104144401,1,'2020-01-01 01:01:01'),(157,20221109100749,1,'2020-01-01 01:01:01'),(158,20221115104546,1,'2020-01-01 01:01:01'),(159,20221130114928,1,'2020-01-01 01:01:01'),(160,20221205112142,1,'2020-01-01 01:01:01'),(161,20221216115820,1,'2020-01-01 01:01:01'),(162,20221220195934,1,'2020-01-01 01:01:01'),(163,20221220195935,1,'2020-01-01 01:01:01'),(164,20221223174807,1,'2020-01-01 01:01:01'),(165,20221227163855,1,'2020-01-01 01:01:01'),(166,20221227163856,1,'2020-01-01 01:01:01'),(167,20230202224725,1,'2020-01-01 01:01:01'),(168,20230206163608,1,'2020-01-01 01:01:01'),(169,20230214131519,1,'2020-01-01 01:01:01'),(170,20230303135738,1,'2020-01-01 01:01:01'),(171,20230313135301,1,'2020-01-01 01:01:01'),(172,20230313141819,1,'2020-01-01 01:01:01'),(173,20230315104937,1,'2020-01-01 01:01:01'),(174,20230317173844,1,'2020-01-01 01:01:01'),(175,20230320133602,1,'2020-01-01 01:01:01'),(176,20230330100011,1,'2020-01-01 01:01:01'),(177,20230330134823,1,'2020-01-01 01:01:01'),(178,20230405232025,1,'2020-01-01 01:01:01'),(179,20230408084104,1,'2020-01-01 01:01:01'),(180,20230411102858,1,'2020-01-01 01:01:01'),(181,20230421155932,1,'2020-01-01 01:01:01'),(182,20230425082126,1,'2020-01-01 01:01:01'),(183,20230425105727,1,'2020-01-01 01:01:01'),(184,20230501154913,1,'2020-01-01 01:01:01'),(185,20230503101418,1,'2020-01-01 01:01:01'),(186,20230515144206,1,'2020-01-01 01:01:01'),(187,20230517140952,1,'2020-01-01 01:01:01'),(188,20230517152807,1,'2020-01-01 01:01:01'),(189,20230518114155,1,'2020-01-01 01:01:01'),(190,20230520153236,1,'2020-01-01 01:01:01'),(191,20230525151159,1,'2020-01-01 01:01:01'),(192,20230530122103,1,'2020-01-01 01:01:01'),(193,20230602111827,1,'2020-01-01 01:01:01'),(194,20230608103123,1,'2020-01-01 01:01:01'),(195,20230629140529,1,'2020-01-01 01:01:01'),(196,20230629140530,1,'2020-01-01 01:01:01'),(197,20230711144622,1,'2020-01-01 01:01:01'),(198,20230721135421,1,'2020-01-01 01:01:01'),(199,20230721161508,1,'2020-01-01 01:01:01'),(200,20230726115701,1,'2020-01-01 01:01:01'),(201,20230807100822,1,'2020-01-01 01:01:01'),(202,20230814150442,1,'2020-01-01 01:01:01'),(203,20230823122728,1,'2020-01-01 01:01:01'),(204,20230906152143,1,'2020-01-01 01:01:01'),(205,20230911163618,1,'2020-01-01 01:01:01'),(206,20230912101759,1,'2020-01-01 01:01:01'),(207,20230915101341,1,'2020-01-01 01:01:01'),(208,20230918132351,1,'2020-01-01 01:01:01'),(209,20231004144339,1,'2020-01-01 01:01:01'),(210,20231009094541,1,'2020-01-01 01:01:01'),(211,20231009094542,1,'2020-01-01 01:01:01'),(212,20231009094543,1,'2020-01-01 01:01:01'),(213,20231009094544,1,'2020-01-01 01:01:01'),(214,20231016091915,1,'2020-01-01 01:01:01'),(215,20231024174135,1,'2020-01-01 01:01:01'),(216,20231025120016,1,'2020-01-01 01:01:01'),(217,20231025160156,1,'2020-01-01 01:01:01'),(218,20231031165350,1,'2020-01-01 01:01:01'),(219,20231106144110,1,'2020-01-01 01:01:01'),(220,20231107130934,1,'2020-01-01 01:01:01'),(221,20231109115838,1,'2020-01-01 01:01:01'),(222,20231121054530,1,'2020-01-01 01:01:01'),(223,20231122101320,1,'2020-01-01 01:01:01'),(224,20231130132828,1,'2020-01-01 01:01:01'),(225,20231130132931,1,'2020-01-01 01:01:01'),(226,20231204155427,1,'2020-01-01 01:01:01'),(227,20231206142340,1,'2020-01-01 01:01:01'),(228,20231207102320,1,'2020-01-01 01:01:01'),(229,20231207102321,1,'2020-01-01 01:01:01'),(230,20231207133731,1,'2020-01-01 01:01:01'),(231,20231212094238,1,'2020-01-01 01:01:01'),(232,20231212095734,1,'2020-01-01 01:01:01'),(233,20231212161121,1,'2020-01-01 01:01:01'),(234,20231215122713,1,'2020-01-01 01:01:01'),(235,20231219143041,1,'2020-01-01 01:01:01'),(236,20231224070653,1,'2020-01-01 01:01:01'),(237,20240110134315,1,'2020-01-01 01:01:01'),(238,20240119091637,1,'2020-01-01 01:01:01'),(239,20240126020642,1,'2020-01-01 01:01:01'),(240,20240126020643,1,'2020-01-01 01:01:01');
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8 */;
CREATE TABLE `mobile_device_management_solutions` (
diff --git a/server/datastore/mysql/scripts.go b/server/datastore/mysql/scripts.go
index 2e2c1280ce..98f6cf46df 100644
--- a/server/datastore/mysql/scripts.go
+++ b/server/datastore/mysql/scripts.go
@@ -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,
diff --git a/server/datastore/mysql/scripts_test.go b/server/datastore/mysql/scripts_test.go
index 9e01be2750..da3316459c 100644
--- a/server/datastore/mysql/scripts_test.go
+++ b/server/datastore/mysql/scripts_test.go
@@ -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) {
diff --git a/server/fleet/activities.go b/server/fleet/activities.go
index 2bdcfd91ed..7cda00fef0 100644
--- a/server/fleet/activities.go
+++ b/server/fleet/activities.go
@@ -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
}`
diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go
index 2bda2cb3f9..32bb011677 100644
--- a/server/fleet/datastore.go
+++ b/server/fleet/datastore.go
@@ -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)
diff --git a/server/fleet/scripts.go b/server/fleet/scripts.go
index 0b7913e88e..a2887d73b9 100644
--- a/server/fleet/scripts.go
+++ b/server/fleet/scripts.go
@@ -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.
diff --git a/server/fleet/service.go b/server/fleet/service.go
index dab1465d92..31b01bbfae 100644
--- a/server/fleet/service.go
+++ b/server/fleet/service.go
@@ -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
diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go
index 9bd0058726..d1ce400a67 100644
--- a/server/mock/datastore_mock.go
+++ b/server/mock/datastore_mock.go
@@ -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) {
diff --git a/server/service/activities.go b/server/service/activities.go
index 4c1eaf634f..c6da3774d8 100644
--- a/server/service/activities.go
+++ b/server/service/activities.go
@@ -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)
+}
diff --git a/server/service/handler.go b/server/service/handler.go
index 08b9c23c9c..952412013c 100644
--- a/server/service/handler.go
+++ b/server/service/handler.go
@@ -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
diff --git a/server/service/hosts_test.go b/server/service/hosts_test.go
index 96ecc97e52..d60255288a 100644
--- a/server/service/hosts_test.go
+++ b/server/service/hosts_test.go
@@ -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)
diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go
index f766c04a38..72b6345c96 100644
--- a/server/service/integration_core_test.go
+++ b/server/service/integration_core_test.go
@@ -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)
+ })
+ }
+}
diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go
index 96e436377a..c743917a0a 100644
--- a/server/service/integration_enterprise_test.go
+++ b/server/service/integration_enterprise_test.go
@@ -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() {
diff --git a/server/service/orbit.go b/server/service/orbit.go
index 758ed2fae4..91fb990907 100644
--- a/server/service/orbit.go
+++ b/server/service/orbit.go
@@ -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
}
/////////////////////////////////////////////////////////////////////////////////
diff --git a/server/service/orbit_test.go b/server/service/orbit_test.go
index 5900ace6d5..b294983de1 100644
--- a/server/service/orbit_test.go
+++ b/server/service/orbit_test.go
@@ -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
}
diff --git a/server/service/scripts.go b/server/service/scripts.go
index f38c2e242c..d5641d27b2 100644
--- a/server/service/scripts.go
+++ b/server/service/scripts.go
@@ -13,9 +13,12 @@ import (
"github.com/docker/go-units"
"github.com/fleetdm/fleet/v4/pkg/scripts"
+ "github.com/fleetdm/fleet/v4/server/authz"
+ "github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/contexts/logging"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/ptr"
+ "github.com/go-kit/kit/log/level"
)
////////////////////////////////////////////////////////////////////////////////
@@ -108,12 +111,172 @@ func runScriptSyncEndpoint(ctx context.Context, request interface{}, svc fleet.S
}, nil
}
-func (svc *Service) RunHostScript(ctx context.Context, request *fleet.HostScriptRequestPayload, waitForResult time.Duration) (*fleet.HostScriptResult, error) {
- // skipauth: No authorization check needed due to implementation returning
- // only license error.
- svc.authz.SkipAuthorization(ctx)
+const maxPendingScripts = 1000
- return nil, fleet.ErrMissingLicense
+func (svc *Service) RunHostScript(ctx context.Context, request *fleet.HostScriptRequestPayload, waitForResult time.Duration) (*fleet.HostScriptResult, error) {
+ // First check if scripts are disabled globally. If so, no need for further processing.
+ cfg, err := svc.ds.AppConfig(ctx)
+ if err != nil {
+ svc.authz.SkipAuthorization(ctx)
+ return nil, err
+ }
+
+ if cfg.ServerSettings.ScriptsDisabled {
+ svc.authz.SkipAuthorization(ctx)
+ return nil, fleet.NewUserMessageError(errors.New(fleet.RunScriptScriptsDisabledGloballyErrMsg), http.StatusForbidden)
+ }
+
+ // must load the host to get the team (cannot use lite, the last seen time is
+ // required to check if it is online) to authorize with the proper team id.
+ // We cannot first authorize if the user can list hosts, in case we
+ // eventually allow a write-only role (e.g. gitops).
+ host, err := svc.ds.Host(ctx, request.HostID)
+ if err != nil {
+ // if error is because the host does not exist, check first if the user
+ // had access to run a script (to prevent leaking valid host ids).
+ if fleet.IsNotFound(err) {
+ if err := svc.authz.Authorize(ctx, &fleet.HostScriptResult{}, fleet.ActionWrite); err != nil {
+ return nil, err
+ }
+ }
+ svc.authz.SkipAuthorization(ctx)
+ return nil, ctxerr.Wrap(ctx, err, "get host lite")
+ }
+
+ maxPending := maxPendingScripts
+
+ // must check that only one of script id or contents is provided before
+ // authorization, as the permissions are not the same if a script id is
+ // provided. There's no harm in returning the error if this validation fails,
+ // since both values are user-provided it doesn't leak any internal
+ // information.
+ if request.ScriptID != nil && request.ScriptContents != "" {
+ svc.authz.SkipAuthorization(ctx)
+ return nil, fleet.NewInvalidArgumentError("script_id", `Only one of "script_id" or "script_contents" can be provided.`)
+ }
+
+ // authorize with the host's team and the script id provided, as both affect
+ // the permissions.
+ if err := svc.authz.Authorize(ctx, &fleet.HostScriptResult{TeamID: host.TeamID, ScriptID: request.ScriptID}, fleet.ActionWrite); err != nil {
+ return nil, err
+ }
+
+ if request.ScriptID != nil {
+ script, err := svc.ds.Script(ctx, *request.ScriptID)
+ if err != nil {
+ if fleet.IsNotFound(err) {
+ return nil, fleet.NewInvalidArgumentError("script_id", `No script exists for the provided "script_id".`).
+ WithStatus(http.StatusNotFound)
+ }
+ return nil, err
+ }
+ var scriptTmID, hostTmID uint
+ if script.TeamID != nil {
+ scriptTmID = *script.TeamID
+ }
+ if host.TeamID != nil {
+ hostTmID = *host.TeamID
+ }
+ if scriptTmID != hostTmID {
+ return nil, fleet.NewInvalidArgumentError("script_id", `The script does not belong to the same team (or no team) as the host.`)
+ }
+
+ r, err := svc.ds.IsExecutionPendingForHost(ctx, request.HostID, *request.ScriptID)
+ if err != nil {
+ return nil, err
+ }
+
+ if len(r) > 0 {
+ return nil, fleet.NewInvalidArgumentError("script_id", `The script is already queued on the given host.`).WithStatus(http.StatusConflict)
+ }
+
+ contents, err := svc.ds.GetScriptContents(ctx, *request.ScriptID)
+ if err != nil {
+ if fleet.IsNotFound(err) {
+ return nil, fleet.NewInvalidArgumentError("script_id", `No script exists for the provided "script_id".`).
+ WithStatus(http.StatusNotFound)
+ }
+ return nil, err
+ }
+ request.ScriptContents = string(contents)
+ }
+
+ if err := fleet.ValidateHostScriptContents(request.ScriptContents); err != nil {
+ return nil, fleet.NewInvalidArgumentError("script_contents", err.Error())
+ }
+
+ asyncExecution := waitForResult <= 0
+
+ if !asyncExecution && host.Status(time.Now()) != fleet.StatusOnline {
+ return nil, fleet.NewInvalidArgumentError("host_id", fleet.RunScriptHostOfflineErrMsg)
+ }
+
+ pending, err := svc.ds.ListPendingHostScriptExecutions(ctx, request.HostID)
+ if err != nil {
+ return nil, ctxerr.Wrap(ctx, err, "list host pending script executions")
+ }
+ if len(pending) > maxPending {
+ return nil, fleet.NewInvalidArgumentError(
+ "script_id", "cannot queue more than 1000 scripts per host",
+ ).WithStatus(http.StatusConflict)
+ }
+
+ if !asyncExecution && len(pending) > 0 {
+ return nil, fleet.NewInvalidArgumentError("script_id", fleet.RunScriptAlreadyRunningErrMsg).WithStatus(http.StatusConflict)
+ }
+
+ // create the script execution request, the host will be notified of the
+ // script execution request via the orbit config's Notifications mechanism.
+ if ctxUser := authz.UserFromContext(ctx); ctxUser != nil {
+ request.UserID = &ctxUser.ID
+ }
+ request.SyncRequest = !asyncExecution
+ script, err := svc.ds.NewHostScriptExecutionRequest(ctx, request)
+ if err != nil {
+ return nil, ctxerr.Wrap(ctx, err, "create script execution request")
+ }
+ script.Hostname = host.DisplayName()
+
+ if asyncExecution {
+ // async execution, return
+ return script, nil
+ }
+
+ ctx, cancel := context.WithTimeout(ctx, waitForResult)
+ defer cancel()
+
+ // if waiting for a result times out, we still want to return the script's
+ // execution request information along with the error, so that the caller can
+ // use the execution id for later checks.
+ timeoutResult := script
+ checkInterval := time.Second
+ after := time.NewTimer(checkInterval)
+ for {
+ select {
+ case <-ctx.Done():
+ return timeoutResult, ctx.Err()
+ case <-after.C:
+ result, err := svc.ds.GetHostScriptExecutionResult(ctx, script.ExecutionID)
+ if err != nil {
+ // is that due to the context being canceled during the DB access?
+ if ctxErr := ctx.Err(); ctxErr != nil {
+ return timeoutResult, ctxErr
+ }
+ return nil, ctxerr.Wrap(ctx, err, "get script execution result")
+ }
+ if result.ExitCode != nil {
+ // a result was received from the host, return
+ result.Hostname = host.DisplayName()
+ return result, nil
+ }
+
+ // at a second to every attempt, until it reaches 5s (then check every 5s)
+ if checkInterval < 5*time.Second {
+ checkInterval += time.Second
+ }
+ after.Reset(checkInterval)
+ }
+ }
}
// //////////////////////////////////////////////////////////////////////////////
@@ -167,11 +330,36 @@ func getScriptResultEndpoint(ctx context.Context, request interface{}, svc fleet
}
func (svc *Service) GetScriptResult(ctx context.Context, execID string) (*fleet.HostScriptResult, error) {
- // skipauth: No authorization check needed due to implementation returning
- // only license error.
- svc.authz.SkipAuthorization(ctx)
+ scriptResult, err := svc.ds.GetHostScriptExecutionResult(ctx, execID)
+ if err != nil {
+ if fleet.IsNotFound(err) {
+ if err := svc.authz.Authorize(ctx, &fleet.HostScriptResult{}, fleet.ActionRead); err != nil {
+ return nil, err
+ }
+ }
+ svc.authz.SkipAuthorization(ctx)
+ return nil, ctxerr.Wrap(ctx, err, "get script result")
+ }
- return nil, fleet.ErrMissingLicense
+ host, err := svc.ds.HostLite(ctx, scriptResult.HostID)
+ if err != nil {
+ // if error is because the host does not exist, check first if the user
+ // had access to run a script (to prevent leaking valid host ids).
+ if fleet.IsNotFound(err) {
+ if err := svc.authz.Authorize(ctx, &fleet.HostScriptResult{}, fleet.ActionRead); err != nil {
+ return nil, err
+ }
+ }
+ svc.authz.SkipAuthorization(ctx)
+ return nil, ctxerr.Wrap(ctx, err, "get host lite")
+ }
+ if err := svc.authz.Authorize(ctx, &fleet.HostScriptResult{TeamID: host.TeamID}, fleet.ActionRead); err != nil {
+ return nil, err
+ }
+
+ scriptResult.Hostname = host.DisplayName()
+
+ return scriptResult, nil
}
////////////////////////////////////////////////////////////////////////////////
@@ -236,11 +424,60 @@ func createScriptEndpoint(ctx context.Context, request interface{}, svc fleet.Se
}
func (svc *Service) NewScript(ctx context.Context, teamID *uint, name string, r io.Reader) (*fleet.Script, error) {
- // skipauth: No authorization check needed due to implementation returning
- // only license error.
- svc.authz.SkipAuthorization(ctx)
+ if err := svc.authz.Authorize(ctx, &fleet.Script{TeamID: teamID}, fleet.ActionWrite); err != nil {
+ return nil, err
+ }
- return nil, fleet.ErrMissingLicense
+ b, err := io.ReadAll(r)
+ if err != nil {
+ return nil, ctxerr.Wrap(ctx, err, "read script contents")
+ }
+
+ script := &fleet.Script{
+ TeamID: teamID,
+ Name: name,
+ ScriptContents: string(b),
+ }
+ if err := script.Validate(); err != nil {
+ return nil, fleet.NewInvalidArgumentError("script", err.Error())
+ }
+
+ savedScript, err := svc.ds.NewScript(ctx, script)
+ if err != nil {
+ var (
+ existsErr fleet.AlreadyExistsError
+ fkErr fleet.ForeignKeyError
+ )
+ if errors.As(err, &existsErr) {
+ err = fleet.NewInvalidArgumentError("script", "A script with this name already exists.").WithStatus(http.StatusConflict)
+ } else if errors.As(err, &fkErr) {
+ err = fleet.NewInvalidArgumentError("team_id", "The team does not exist.").WithStatus(http.StatusNotFound)
+ }
+ return nil, ctxerr.Wrap(ctx, err, "create script")
+ }
+
+ var teamName *string
+ if teamID != nil && *teamID != 0 {
+ tm, err := svc.EnterpriseOverrides.TeamByIDOrName(ctx, teamID, nil)
+ if err != nil {
+ return nil, ctxerr.Wrap(ctx, err, "get team name for create script activity")
+ }
+ teamName = &tm.Name
+ }
+
+ if err := svc.ds.NewActivity(
+ ctx,
+ authz.UserFromContext(ctx),
+ fleet.ActivityTypeAddedScript{
+ TeamID: teamID,
+ TeamName: teamName,
+ ScriptName: script.Name,
+ },
+ ); err != nil {
+ return nil, ctxerr.Wrap(ctx, err, "new activity for create script")
+ }
+
+ return savedScript, nil
}
////////////////////////////////////////////////////////////////////////////////
@@ -268,11 +505,37 @@ func deleteScriptEndpoint(ctx context.Context, request interface{}, svc fleet.Se
}
func (svc *Service) DeleteScript(ctx context.Context, scriptID uint) error {
- // skipauth: No authorization check needed due to implementation returning
- // only license error.
- svc.authz.SkipAuthorization(ctx)
+ script, err := svc.authorizeScriptByID(ctx, scriptID, fleet.ActionWrite)
+ if err != nil {
+ return err
+ }
- return fleet.ErrMissingLicense
+ if err := svc.ds.DeleteScript(ctx, script.ID); err != nil {
+ return ctxerr.Wrap(ctx, err, "delete script")
+ }
+
+ var teamName *string
+ if script.TeamID != nil && *script.TeamID != 0 {
+ tm, err := svc.EnterpriseOverrides.TeamByIDOrName(ctx, script.TeamID, nil)
+ if err != nil {
+ return ctxerr.Wrap(ctx, err, "get team name for delete script activity")
+ }
+ teamName = &tm.Name
+ }
+
+ if err := svc.ds.NewActivity(
+ ctx,
+ authz.UserFromContext(ctx),
+ fleet.ActivityTypeDeletedScript{
+ TeamID: script.TeamID,
+ TeamName: teamName,
+ ScriptName: script.Name,
+ },
+ ); err != nil {
+ return ctxerr.Wrap(ctx, err, "new activity for delete script")
+ }
+
+ return nil
}
////////////////////////////////////////////////////////////////////////////////
@@ -305,11 +568,21 @@ func listScriptsEndpoint(ctx context.Context, request interface{}, svc fleet.Ser
}
func (svc *Service) ListScripts(ctx context.Context, teamID *uint, opt fleet.ListOptions) ([]*fleet.Script, *fleet.PaginationMetadata, error) {
- // skipauth: No authorization check needed due to implementation returning
- // only license error.
- svc.authz.SkipAuthorization(ctx)
+ if err := svc.authz.Authorize(ctx, &fleet.Script{TeamID: teamID}, fleet.ActionRead); err != nil {
+ return nil, nil, err
+ }
- return nil, nil, fleet.ErrMissingLicense
+ // cursor-based pagination is not supported for scripts
+ opt.After = ""
+ // custom ordering is not supported, always by name
+ opt.OrderKey = "name"
+ opt.OrderDirection = fleet.OrderAscending
+ // no matching query support
+ opt.MatchQuery = ""
+ // always include metadata for scripts
+ opt.IncludeMetadata = true
+
+ return svc.ds.ListScripts(ctx, teamID, opt)
}
////////////////////////////////////////////////////////////////////////////////
@@ -374,11 +647,19 @@ func getScriptEndpoint(ctx context.Context, request interface{}, svc fleet.Servi
}
func (svc *Service) GetScript(ctx context.Context, scriptID uint, withContent bool) (*fleet.Script, []byte, error) {
- // skipauth: No authorization check needed due to implementation returning
- // only license error.
- svc.authz.SkipAuthorization(ctx)
+ script, err := svc.authorizeScriptByID(ctx, scriptID, fleet.ActionRead)
+ if err != nil {
+ return nil, nil, err
+ }
- return nil, nil, fleet.ErrMissingLicense
+ var content []byte
+ if withContent {
+ content, err = svc.ds.GetScriptContents(ctx, scriptID)
+ if err != nil {
+ return nil, nil, err
+ }
+ }
+ return script, content, nil
}
////////////////////////////////////////////////////////////////////////////////
@@ -411,11 +692,39 @@ func getHostScriptDetailsEndpoint(ctx context.Context, request interface{}, svc
}
func (svc *Service) GetHostScriptDetails(ctx context.Context, hostID uint, opt fleet.ListOptions) ([]*fleet.HostScriptDetail, *fleet.PaginationMetadata, error) {
- // skipauth: No authorization check needed due to implementation returning
- // only license error.
- svc.authz.SkipAuthorization(ctx)
+ h, err := svc.ds.HostLite(ctx, hostID)
+ if err != nil {
+ if fleet.IsNotFound(err) {
+ // if error is because the host does not exist, check first if the user
+ // had global access (to prevent leaking valid host ids).
+ if err := svc.authz.Authorize(ctx, &fleet.Script{}, fleet.ActionRead); err != nil {
+ return nil, nil, err
+ }
+ }
+ return nil, nil, err
+ }
- return nil, nil, fleet.ErrMissingLicense
+ if err := svc.authz.Authorize(ctx, &fleet.Script{TeamID: h.TeamID}, fleet.ActionRead); err != nil {
+ return nil, nil, err
+ }
+
+ if h.Platform != "darwin" && h.Platform != "windows" {
+ // darwin and windows are supported for now, all other platforms return empty results
+ level.Debug(svc.logger).Log("msg", "unsupported platform for host script details", "platform", h.Platform, "host_id", h.ID)
+ return []*fleet.HostScriptDetail{}, &fleet.PaginationMetadata{}, nil
+ }
+
+ // cursor-based pagination is not supported for scripts
+ opt.After = ""
+ // custom ordering is not supported, always by name
+ opt.OrderKey = "name"
+ opt.OrderDirection = fleet.OrderAscending
+ // no matching query support
+ opt.MatchQuery = ""
+ // always include metadata for scripts
+ opt.IncludeMetadata = true
+
+ return svc.ds.GetHostScriptDetails(ctx, h.ID, h.TeamID, opt, h.Platform)
}
////////////////////////////////////////////////////////////////////////////////
@@ -446,9 +755,88 @@ func batchSetScriptsEndpoint(ctx context.Context, request interface{}, svc fleet
}
func (svc *Service) BatchSetScripts(ctx context.Context, maybeTmID *uint, maybeTmName *string, payloads []fleet.ScriptPayload, dryRun bool) error {
- // skipauth: No authorization check needed due to implementation returning
- // only license error.
- svc.authz.SkipAuthorization(ctx)
+ if maybeTmID != nil && maybeTmName != nil {
+ svc.authz.SkipAuthorization(ctx) // so that the error message is not replaced by "forbidden"
+ return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("team_name", "cannot specify both team_id and team_name"))
+ }
- return fleet.ErrMissingLicense
+ var teamID *uint
+ var teamName *string
+
+ if maybeTmID != nil || maybeTmName != nil {
+ team, err := svc.EnterpriseOverrides.TeamByIDOrName(ctx, maybeTmID, maybeTmName)
+ if err != nil {
+ return err
+ }
+ teamID = &team.ID
+ teamName = &team.Name
+ }
+
+ if err := svc.authz.Authorize(ctx, &fleet.Script{TeamID: teamID}, fleet.ActionWrite); err != nil {
+ return ctxerr.Wrap(ctx, err)
+ }
+
+ // any duplicate name in the provided set results in an error
+ scripts := make([]*fleet.Script, 0, len(payloads))
+ byName := make(map[string]bool, len(payloads))
+ for i, p := range payloads {
+ script := &fleet.Script{
+ ScriptContents: string(p.ScriptContents),
+ Name: p.Name,
+ TeamID: teamID,
+ }
+
+ if err := script.Validate(); err != nil {
+ return ctxerr.Wrap(ctx,
+ fleet.NewInvalidArgumentError(fmt.Sprintf("scripts[%d]", i), err.Error()))
+ }
+
+ if byName[script.Name] {
+ return ctxerr.Wrap(ctx,
+ fleet.NewInvalidArgumentError(fmt.Sprintf("scripts[%d]", i), fmt.Sprintf("Couldn’t edit scripts. More than one script has the same file name: %q", script.Name)),
+ "duplicate script by name")
+ }
+ byName[script.Name] = true
+ scripts = append(scripts, script)
+ }
+
+ if dryRun {
+ return nil
+ }
+
+ if err := svc.ds.BatchSetScripts(ctx, teamID, scripts); err != nil {
+ return ctxerr.Wrap(ctx, err, "batch saving scripts")
+ }
+
+ if err := svc.ds.NewActivity(ctx, authz.UserFromContext(ctx), &fleet.ActivityTypeEditedScript{
+ TeamID: teamID,
+ TeamName: teamName,
+ }); err != nil {
+ return ctxerr.Wrap(ctx, err, "logging activity for edited scripts")
+ }
+ return nil
+}
+
+func (svc *Service) authorizeScriptByID(ctx context.Context, scriptID uint, authzAction string) (*fleet.Script, error) {
+ // first, get the script because we don't know which team id it is for.
+ script, err := svc.ds.Script(ctx, scriptID)
+ if err != nil {
+ if fleet.IsNotFound(err) {
+ // couldn't get the script to have its team, authorize with a no-team
+ // script as a fallback - the requested script does not exist so there's
+ // no way to know what team it would be for, and returning a 404 without
+ // authorization would leak the existing/non existing ids.
+ if err := svc.authz.Authorize(ctx, &fleet.Script{}, authzAction); err != nil {
+ return nil, err
+ }
+ }
+ svc.authz.SkipAuthorization(ctx)
+ return nil, ctxerr.Wrap(ctx, err, "get script")
+ }
+
+ // do the actual authorization with the script's team id
+ if err := svc.authz.Authorize(ctx, script, authzAction); err != nil {
+ return nil, err
+ }
+ return script, nil
}
diff --git a/server/service/scripts_test.go b/server/service/scripts_test.go
index 5ab37390e8..d6f4ed63b2 100644
--- a/server/service/scripts_test.go
+++ b/server/service/scripts_test.go
@@ -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 {