mirror of
https://github.com/empayre/fleet.git
synced 2024-11-06 00:45:19 +00:00
Implement script execution on the fleetd agent (disabled by default) (#13569)
This commit is contained in:
parent
c0cb278a1f
commit
090b142c49
1
changes/issue-13307-run-script-on-agent
Normal file
1
changes/issue-13307-run-script-on-agent
Normal file
@ -0,0 +1 @@
|
||||
* Added support in fleetd to execute scripts and send back results (disabled by default).
|
@ -625,9 +625,9 @@ func (a *agent) execScripts(execIDs []string, orbitClient *service.OrbitClient)
|
||||
// send a no-op result without executing if script exec is disabled
|
||||
if err := orbitClient.SaveHostScriptResult(&fleet.HostScriptResultPayload{
|
||||
ExecutionID: execID,
|
||||
Output: "script execution is disabled",
|
||||
Output: "script execution disabled",
|
||||
Runtime: 0,
|
||||
ExitCode: -1,
|
||||
ExitCode: -2,
|
||||
}); err != nil {
|
||||
log.Println("save disabled host script result:", err)
|
||||
return
|
||||
|
@ -624,6 +624,7 @@ func main() {
|
||||
windowsMDMEnrollmentCommandFrequency = time.Hour
|
||||
)
|
||||
configFetcher := update.ApplyRenewEnrollmentProfileConfigFetcherMiddleware(orbitClient, renewEnrollmentProfileCommandFrequency, fleetURL)
|
||||
configFetcher = update.ApplyRunScriptsConfigFetcherMiddleware(configFetcher, c.Bool("enable-scripts"), orbitClient)
|
||||
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
|
22
orbit/pkg/scripts/exec_nonwindows.go
Normal file
22
orbit/pkg/scripts/exec_nonwindows.go
Normal file
@ -0,0 +1,22 @@
|
||||
//go:build !windows
|
||||
|
||||
package scripts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func execCmd(ctx context.Context, scriptPath string) (output []byte, exitCode int, err error) {
|
||||
// initialize to -1 in case the process never starts
|
||||
exitCode = -1
|
||||
|
||||
cmd := exec.CommandContext(ctx, "/bin/sh", scriptPath)
|
||||
cmd.Dir = filepath.Dir(scriptPath)
|
||||
output, err = cmd.CombinedOutput()
|
||||
if cmd.ProcessState != nil {
|
||||
exitCode = cmd.ProcessState.ExitCode()
|
||||
}
|
||||
return output, exitCode, err
|
||||
}
|
23
orbit/pkg/scripts/exec_windows.go
Normal file
23
orbit/pkg/scripts/exec_windows.go
Normal file
@ -0,0 +1,23 @@
|
||||
//go:build windows
|
||||
|
||||
package scripts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func execCmd(ctx context.Context, scriptPath string) (output []byte, exitCode int, err error) {
|
||||
// initialize to -1 in case the process never starts
|
||||
exitCode = -1
|
||||
|
||||
// for Windows, we execute the file with powershell.
|
||||
cmd := exec.CommandContext(ctx, "powershell", "-ExecutionPolicy", "Bypass", "-File", scriptPath)
|
||||
cmd.Dir = filepath.Dir(scriptPath)
|
||||
output, err = cmd.CombinedOutput()
|
||||
if cmd.ProcessState != nil {
|
||||
exitCode = cmd.ProcessState.ExitCode()
|
||||
}
|
||||
return output, exitCode, err
|
||||
}
|
181
orbit/pkg/scripts/scripts.go
Normal file
181
orbit/pkg/scripts/scripts.go
Normal file
@ -0,0 +1,181 @@
|
||||
// Package scripts implements support to execute scripts on the host when
|
||||
// requested by the Fleet server.
|
||||
package scripts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
)
|
||||
|
||||
const scriptExecTimeout = 30 * time.Second
|
||||
|
||||
// Client defines the methods required for the API requests to the server. The
|
||||
// fleet.OrbitClient type satisfies this interface.
|
||||
type Client interface {
|
||||
GetHostScript(execID string) (*fleet.HostScriptResult, error)
|
||||
SaveHostScriptResult(result *fleet.HostScriptResultPayload) error
|
||||
}
|
||||
|
||||
// Runner is the type that processes scripts to execute, taking care of
|
||||
// retrieving each script, saving it in a temporary directory, executing it and
|
||||
// saving the results.
|
||||
type Runner struct {
|
||||
Client Client
|
||||
ScriptExecutionEnabled bool
|
||||
|
||||
// tempDirFn is the function to call to get the temporary directory to use,
|
||||
// inside of which the script-specific subdirectories will be created. If nil,
|
||||
// the user's temp dir will be used (can be set to t.TempDir in tests).
|
||||
tempDirFn func() string
|
||||
|
||||
// execCmdFn can be set for tests to mock actual execution of the script. If
|
||||
// nil, execCmd will be used, which has a different implementation on Windows
|
||||
// and non-Windows platforms.
|
||||
execCmdFn func(ctx context.Context, scriptPath string) ([]byte, int, error)
|
||||
|
||||
// can be set for tests to replace os.RemoveAll, which is called to remove
|
||||
// the script's temporary directory after execution.
|
||||
removeAllFn func(string) error
|
||||
}
|
||||
|
||||
// Run processes all scripts identified by the execution IDs.
|
||||
func (r *Runner) Run(execIDs []string) error {
|
||||
var errs []error
|
||||
|
||||
for _, execID := range execIDs {
|
||||
if !r.ScriptExecutionEnabled {
|
||||
if err := r.runOneDisabled(execID); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if err := r.runOne(execID); 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 nil
|
||||
}
|
||||
|
||||
func (r *Runner) runOne(execID string) (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.Valid {
|
||||
// already a result stored for this execution, skip, it shouldn't be sent
|
||||
// again by Fleet.
|
||||
return nil
|
||||
}
|
||||
|
||||
runDir, err := r.createRunDir(execID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create run directory: %w", err)
|
||||
}
|
||||
// prevent destruction of dir if this env var is set
|
||||
if os.Getenv("FLEET_PREVENT_SCRIPT_TEMPDIR_DELETION") == "" {
|
||||
defer func() {
|
||||
fn := os.RemoveAll
|
||||
if r.removeAllFn != nil {
|
||||
fn = r.removeAllFn
|
||||
}
|
||||
err := fn(runDir)
|
||||
if finalErr == nil && err != nil {
|
||||
finalErr = fmt.Errorf("remove temp dir: %w", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
ext := ".sh"
|
||||
if runtime.GOOS == "windows" {
|
||||
ext = ".ps1"
|
||||
}
|
||||
scriptFile := filepath.Join(runDir, "script"+ext)
|
||||
// the file does not need the executable bit set, it will be executed as
|
||||
// argument to powershell or /bin/sh.
|
||||
if err := os.WriteFile(scriptFile, []byte(script.ScriptContents), 0600); err != nil {
|
||||
return fmt.Errorf("write script file: %w", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), scriptExecTimeout)
|
||||
defer cancel()
|
||||
|
||||
execCmdFn := r.execCmdFn
|
||||
if execCmdFn == nil {
|
||||
execCmdFn = execCmd
|
||||
}
|
||||
start := time.Now()
|
||||
output, exitCode, execErr := execCmdFn(ctx, scriptFile)
|
||||
duration := time.Since(start)
|
||||
|
||||
// report the output or the error
|
||||
if execErr != nil {
|
||||
output = append(output, []byte(fmt.Sprintf("\nscript execution error: %v", execErr))...)
|
||||
}
|
||||
|
||||
// sanity-check the size of the output sent to the server, the actual
|
||||
// trimming to 10K chars is done by the API endpoint, we just make sure not
|
||||
// to send a ridiculously big payload that is sure to be over 10K chars.
|
||||
if len(output) > (utf8.UTFMax * maxOutputRuneLen) {
|
||||
output = output[len(output)-(utf8.UTFMax*maxOutputRuneLen):]
|
||||
}
|
||||
|
||||
err = r.Client.SaveHostScriptResult(&fleet.HostScriptResultPayload{
|
||||
ExecutionID: execID,
|
||||
Output: string(output),
|
||||
Runtime: int(duration.Seconds()),
|
||||
ExitCode: exitCode,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("save script result: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Runner) createRunDir(execID string) (string, error) {
|
||||
var tempDir string // empty tempDir means use system default
|
||||
if r.tempDirFn != nil {
|
||||
tempDir = r.tempDirFn()
|
||||
}
|
||||
// MkdirTemp will only allow read/write by current user (root), which is what
|
||||
// we want.
|
||||
return os.MkdirTemp(tempDir, "fleet-"+execID+"-*")
|
||||
}
|
||||
|
||||
func (r *Runner) runOneDisabled(execID string) error {
|
||||
err := r.Client.SaveHostScriptResult(&fleet.HostScriptResultPayload{
|
||||
ExecutionID: execID,
|
||||
Output: "script execution disabled",
|
||||
ExitCode: -2, // fleetctl knows that -2 means script was disabled on host
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("save script result: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
387
orbit/pkg/scripts/scripts_test.go
Normal file
387
orbit/pkg/scripts/scripts_test.go
Normal file
@ -0,0 +1,387 @@
|
||||
package scripts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRunner(t *testing.T) {
|
||||
cases := []struct {
|
||||
desc string
|
||||
|
||||
// setup
|
||||
client *mockClient
|
||||
execer *mockExecCmd
|
||||
enabled bool
|
||||
execIDs []string
|
||||
|
||||
// expected
|
||||
errContains string
|
||||
execCalls int
|
||||
}{
|
||||
{
|
||||
desc: "no exec ids",
|
||||
client: &mockClient{},
|
||||
execer: &mockExecCmd{},
|
||||
enabled: true,
|
||||
execCalls: 0,
|
||||
},
|
||||
{
|
||||
desc: "one exec id, success",
|
||||
client: &mockClient{scripts: map[string]*fleet.HostScriptResult{"a": {}}},
|
||||
execer: &mockExecCmd{},
|
||||
enabled: true,
|
||||
execIDs: []string{"a"},
|
||||
execCalls: 1,
|
||||
},
|
||||
{
|
||||
desc: "one exec id disabled, success",
|
||||
client: &mockClient{scripts: map[string]*fleet.HostScriptResult{"a": {}}},
|
||||
execer: &mockExecCmd{},
|
||||
enabled: false,
|
||||
execIDs: []string{"a"},
|
||||
execCalls: 0,
|
||||
},
|
||||
{
|
||||
desc: "one ok, one unknown",
|
||||
client: &mockClient{scripts: map[string]*fleet.HostScriptResult{"a": {}}},
|
||||
execer: &mockExecCmd{},
|
||||
enabled: true,
|
||||
execIDs: []string{"a", "b"},
|
||||
execCalls: 1,
|
||||
errContains: "no such script: b",
|
||||
},
|
||||
{
|
||||
desc: "one ok, one unknown, disabled",
|
||||
client: &mockClient{scripts: map[string]*fleet.HostScriptResult{"a": {}}},
|
||||
execer: &mockExecCmd{},
|
||||
enabled: false,
|
||||
execIDs: []string{"a", "b"},
|
||||
execCalls: 0,
|
||||
errContains: "", // no error because when scripts are disabled, the script is not fetched (save will not update anything)
|
||||
},
|
||||
{
|
||||
desc: "multiple, success",
|
||||
client: &mockClient{scripts: map[string]*fleet.HostScriptResult{"a": {}, "b": {}, "c": {}}},
|
||||
execer: &mockExecCmd{},
|
||||
enabled: true,
|
||||
execIDs: []string{"a", "b", "c"},
|
||||
execCalls: 3,
|
||||
},
|
||||
{
|
||||
desc: "multiple, disabled, success",
|
||||
client: &mockClient{scripts: map[string]*fleet.HostScriptResult{"a": {}, "b": {}, "c": {}}},
|
||||
execer: &mockExecCmd{},
|
||||
enabled: false,
|
||||
execIDs: []string{"a", "b", "c"},
|
||||
execCalls: 0,
|
||||
},
|
||||
{
|
||||
desc: "failed to get script",
|
||||
client: &mockClient{getErr: io.ErrUnexpectedEOF, scripts: map[string]*fleet.HostScriptResult{"a": {}}},
|
||||
execer: &mockExecCmd{},
|
||||
enabled: true,
|
||||
execIDs: []string{"a"},
|
||||
execCalls: 0,
|
||||
errContains: "get host script: unexpected EOF",
|
||||
},
|
||||
{
|
||||
desc: "failed to save script",
|
||||
client: &mockClient{saveErr: io.ErrUnexpectedEOF, scripts: map[string]*fleet.HostScriptResult{"a": {}}},
|
||||
execer: &mockExecCmd{},
|
||||
enabled: true,
|
||||
execIDs: []string{"a"},
|
||||
execCalls: 1,
|
||||
errContains: "save script result: unexpected EOF",
|
||||
},
|
||||
{
|
||||
desc: "run returns error",
|
||||
client: &mockClient{scripts: map[string]*fleet.HostScriptResult{"a": {}}},
|
||||
execer: &mockExecCmd{err: io.ErrUnexpectedEOF},
|
||||
enabled: true,
|
||||
execIDs: []string{"a"},
|
||||
execCalls: 1,
|
||||
errContains: "", // no error reported, the run error is included in the results
|
||||
},
|
||||
{
|
||||
desc: "failed to save script, disabled",
|
||||
client: &mockClient{saveErr: io.ErrUnexpectedEOF, scripts: map[string]*fleet.HostScriptResult{"a": {}}},
|
||||
execer: &mockExecCmd{},
|
||||
enabled: false,
|
||||
execIDs: []string{"a"},
|
||||
execCalls: 0,
|
||||
errContains: "save script result: unexpected EOF",
|
||||
},
|
||||
{
|
||||
desc: "script with existing results",
|
||||
client: &mockClient{scripts: map[string]*fleet.HostScriptResult{"a": {ExitCode: sql.NullInt64{Valid: true}}}},
|
||||
execer: &mockExecCmd{},
|
||||
enabled: true,
|
||||
execIDs: []string{"a"},
|
||||
execCalls: 0,
|
||||
errContains: "", // no errors reported, script is just skipped
|
||||
},
|
||||
{
|
||||
desc: "multiple errors reported, one get fails, one non-existing",
|
||||
client: &mockClient{getErr: errFailOnce, scripts: map[string]*fleet.HostScriptResult{"a": {ExitCode: sql.NullInt64{Valid: true}}}},
|
||||
execer: &mockExecCmd{},
|
||||
enabled: true,
|
||||
execIDs: []string{"a", "b"},
|
||||
execCalls: 0,
|
||||
errContains: "get host script: fail once\nget host script: no such script: b",
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.desc, func(t *testing.T) {
|
||||
runner := &Runner{
|
||||
Client: c.client,
|
||||
ScriptExecutionEnabled: c.enabled,
|
||||
tempDirFn: t.TempDir,
|
||||
execCmdFn: c.execer.run,
|
||||
}
|
||||
err := runner.Run(c.execIDs)
|
||||
if c.errContains != "" {
|
||||
require.ErrorContains(t, err, c.errContains)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
require.Equal(t, c.execCalls, c.execer.count)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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'"}}}
|
||||
execer := &mockExecCmd{output: []byte("output"), exitCode: 0, err: nil}
|
||||
runner := &Runner{
|
||||
Client: client,
|
||||
ScriptExecutionEnabled: true,
|
||||
tempDirFn: func() string { return tempDir },
|
||||
execCmdFn: execer.run,
|
||||
}
|
||||
|
||||
err := runner.Run([]string{"a"})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, execer.count)
|
||||
require.Equal(t, "output", client.results["a"].Output)
|
||||
|
||||
// ensure the temp directory was removed after execution
|
||||
entries, err := os.ReadDir(tempDir)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, entries)
|
||||
})
|
||||
|
||||
t.Run("remove fails, returns original error", func(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
// client will fail saving the results, this is the error that should be
|
||||
// returned (i.e. the remove dir error should not override it).
|
||||
client := &mockClient{saveErr: io.ErrUnexpectedEOF, scripts: map[string]*fleet.HostScriptResult{"a": {ScriptContents: "echo 'Hi'"}}}
|
||||
execer := &mockExecCmd{output: []byte("output"), exitCode: 0, err: nil}
|
||||
|
||||
runner := &Runner{
|
||||
Client: client,
|
||||
ScriptExecutionEnabled: true,
|
||||
tempDirFn: func() string { return tempDir },
|
||||
execCmdFn: execer.run,
|
||||
removeAllFn: func(s string) error { return errors.New("remove failed") },
|
||||
}
|
||||
|
||||
err := runner.Run([]string{"a"})
|
||||
require.ErrorContains(t, err, "save script result: unexpected EOF")
|
||||
require.Equal(t, 1, execer.count)
|
||||
})
|
||||
|
||||
t.Run("remove fails, returns this error if the rest succeeded", func(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
client := &mockClient{scripts: map[string]*fleet.HostScriptResult{"a": {ScriptContents: "echo 'Hi'"}}}
|
||||
execer := &mockExecCmd{output: []byte("output"), exitCode: 0, err: nil}
|
||||
|
||||
runner := &Runner{
|
||||
Client: client,
|
||||
ScriptExecutionEnabled: true,
|
||||
tempDirFn: func() string { return tempDir },
|
||||
execCmdFn: execer.run,
|
||||
removeAllFn: func(s string) error { return errors.New("remove failed") },
|
||||
}
|
||||
|
||||
err := runner.Run([]string{"a"})
|
||||
require.ErrorContains(t, err, "remove temp dir: remove failed")
|
||||
require.Equal(t, 1, execer.count)
|
||||
})
|
||||
|
||||
t.Run("keeps temp dir", func(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'"}}}
|
||||
execer := &mockExecCmd{output: []byte("output"), exitCode: 0, err: nil}
|
||||
runner := &Runner{
|
||||
Client: client,
|
||||
ScriptExecutionEnabled: true,
|
||||
tempDirFn: func() string { return tempDir },
|
||||
execCmdFn: execer.run,
|
||||
}
|
||||
|
||||
err := runner.Run([]string{"a"})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, execer.count)
|
||||
require.Equal(t, "output", client.results["a"].Output)
|
||||
|
||||
entries, err := os.ReadDir(tempDir)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, entries, 1)
|
||||
|
||||
// the entry is the script's execution directory
|
||||
require.True(t, entries[0].IsDir())
|
||||
require.Contains(t, entries[0].Name(), "fleet-a-")
|
||||
|
||||
runDir := filepath.Join(tempDir, entries[0].Name())
|
||||
runEntries, err := os.ReadDir(runDir)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, runEntries, 1) // run directory contains the script
|
||||
|
||||
b, err := os.ReadFile(filepath.Join(runDir, runEntries[0].Name()))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "echo 'Hi'", string(b))
|
||||
})
|
||||
}
|
||||
|
||||
func TestRunnerResults(t *testing.T) {
|
||||
output40K := strings.Repeat("a", 4000) +
|
||||
strings.Repeat("b", 4000) +
|
||||
strings.Repeat("c", 4000) +
|
||||
strings.Repeat("d", 4000) +
|
||||
strings.Repeat("e", 4000) +
|
||||
strings.Repeat("f", 4000) +
|
||||
strings.Repeat("g", 4000) +
|
||||
strings.Repeat("h", 4000) +
|
||||
strings.Repeat("i", 4000) +
|
||||
strings.Repeat("j", 4000)
|
||||
|
||||
output44K := output40K + strings.Repeat("k", 4000)
|
||||
|
||||
errSuffix := "\nscript execution error: " + io.ErrUnexpectedEOF.Error()
|
||||
|
||||
cases := []struct {
|
||||
desc string
|
||||
output string
|
||||
exitCode int
|
||||
runErr error
|
||||
wantOutput string
|
||||
}{
|
||||
{
|
||||
desc: "exactly the limit",
|
||||
output: output40K,
|
||||
exitCode: 1,
|
||||
runErr: nil,
|
||||
wantOutput: output40K,
|
||||
},
|
||||
{
|
||||
desc: "too many bytes",
|
||||
output: output44K,
|
||||
exitCode: 1,
|
||||
runErr: nil,
|
||||
wantOutput: output44K[strings.Index(output44K, "b"):],
|
||||
},
|
||||
{
|
||||
desc: "empty with error",
|
||||
output: "",
|
||||
exitCode: -1,
|
||||
runErr: io.ErrUnexpectedEOF,
|
||||
wantOutput: errSuffix,
|
||||
},
|
||||
{
|
||||
desc: "limit with error",
|
||||
output: output40K,
|
||||
exitCode: -1,
|
||||
runErr: io.ErrUnexpectedEOF,
|
||||
wantOutput: output40K[len(errSuffix):] + errSuffix,
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.desc, func(t *testing.T) {
|
||||
client := &mockClient{scripts: map[string]*fleet.HostScriptResult{"a": {ScriptContents: "echo 'Hi'"}}}
|
||||
execer := &mockExecCmd{output: []byte(c.output), exitCode: c.exitCode, err: c.runErr}
|
||||
runner := &Runner{
|
||||
Client: client,
|
||||
ScriptExecutionEnabled: true,
|
||||
tempDirFn: t.TempDir,
|
||||
execCmdFn: execer.run,
|
||||
}
|
||||
err := runner.Run([]string{"a"})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, execer.count)
|
||||
require.Equal(t, c.wantOutput, client.results["a"].Output)
|
||||
require.Equal(t, c.exitCode, client.results["a"].ExitCode)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type mockExecCmd struct {
|
||||
output []byte
|
||||
exitCode int
|
||||
err error
|
||||
count int
|
||||
execFn func() ([]byte, int, error)
|
||||
}
|
||||
|
||||
func (m *mockExecCmd) run(ctx context.Context, scriptPath string) ([]byte, int, error) {
|
||||
m.count++
|
||||
if m.execFn != nil {
|
||||
return m.execFn()
|
||||
}
|
||||
return m.output, m.exitCode, m.err
|
||||
}
|
||||
|
||||
var errFailOnce = errors.New("fail once")
|
||||
|
||||
type mockClient struct {
|
||||
scripts map[string]*fleet.HostScriptResult
|
||||
results map[string]*fleet.HostScriptResultPayload
|
||||
getErr error
|
||||
saveErr error
|
||||
}
|
||||
|
||||
func (m *mockClient) GetHostScript(execID string) (*fleet.HostScriptResult, error) {
|
||||
if m.getErr != nil {
|
||||
err := m.getErr
|
||||
if err == errFailOnce {
|
||||
m.getErr = nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
script := m.scripts[execID]
|
||||
if script == nil {
|
||||
return nil, fmt.Errorf("no such script: %s", execID)
|
||||
}
|
||||
return script, nil
|
||||
}
|
||||
|
||||
func (m *mockClient) SaveHostScriptResult(result *fleet.HostScriptResultPayload) error {
|
||||
if m.results == nil {
|
||||
m.results = make(map[string]*fleet.HostScriptResultPayload)
|
||||
}
|
||||
m.results[result.ExecutionID] = result
|
||||
|
||||
err := m.saveErr
|
||||
if err == errFailOnce {
|
||||
m.saveErr = nil
|
||||
}
|
||||
return err
|
||||
}
|
@ -2,10 +2,13 @@ package update
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"runtime"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/orbit/pkg/profiles"
|
||||
"github.com/fleetdm/fleet/v4/orbit/pkg/scripts"
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
@ -274,3 +277,123 @@ func (w *windowsMDMEnrollmentConfigFetcher) attemptUnenrollment() {
|
||||
log.Info().Msg("successfully called UnregisterDeviceWithManagement to unenroll Windows device")
|
||||
}
|
||||
}
|
||||
|
||||
type runScriptsConfigFetcher struct {
|
||||
// Fetcher is the OrbitConfigFetcher that will be wrapped. It is responsible
|
||||
// for actually returning the orbit configuration or an error.
|
||||
Fetcher OrbitConfigFetcher
|
||||
|
||||
// ScriptsExecutionEnabled indicates if this agent allows scripts execution.
|
||||
// If it doesn't, scripts are not executed, but a response is returned to the
|
||||
// Fleet server so it knows the agent processed the request. Note that this
|
||||
// should be set to the value of the --scripts-enabled command-line flag. An
|
||||
// additional, dynamic check is done automatically by the
|
||||
// runScriptsConfigFetcher if this field is false to get the value from the
|
||||
// MDM configuration profile.
|
||||
ScriptsExecutionEnabled bool
|
||||
|
||||
// ScriptsClient is the client to use to fetch the script to execute and save
|
||||
// back its results.
|
||||
ScriptsClient scripts.Client
|
||||
|
||||
// the dynamic scripts enabled check is done to check via mdm configuration
|
||||
// profile if the host is allowed to run dynamic scripts. It is only done
|
||||
// on macos and only if ScriptsExecutionEnabled is false.
|
||||
dynamicScriptsEnabled atomic.Bool
|
||||
dynamicScriptsEnabledCheckInterval time.Duration
|
||||
// for tests, if set will use this instead of profiles.GetFleetdConfig.
|
||||
testGetFleetdConfig func() (*fleet.MDMAppleFleetdConfig, error)
|
||||
|
||||
// for tests, to be able to mock command execution. If nil, will use
|
||||
// (scripts.Runner{...}).Run. To help with testing, the function receives as
|
||||
// argument the scripts.Runner value that would've executed the call.
|
||||
runScriptsFn func(*scripts.Runner, []string) error
|
||||
|
||||
// ensures only one script execution runs at a time
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func ApplyRunScriptsConfigFetcherMiddleware(fetcher OrbitConfigFetcher, scriptsEnabled bool, scriptsClient scripts.Client) OrbitConfigFetcher {
|
||||
scriptsFetcher := &runScriptsConfigFetcher{
|
||||
Fetcher: fetcher,
|
||||
ScriptsExecutionEnabled: scriptsEnabled,
|
||||
ScriptsClient: scriptsClient,
|
||||
dynamicScriptsEnabledCheckInterval: time.Minute,
|
||||
}
|
||||
// start the dynamic check for scripts enabled if required
|
||||
scriptsFetcher.runDynamicScriptsEnabledCheck()
|
||||
return scriptsFetcher
|
||||
}
|
||||
|
||||
func (h *runScriptsConfigFetcher) runDynamicScriptsEnabledCheck() {
|
||||
getFleetdConfig := h.testGetFleetdConfig
|
||||
if getFleetdConfig == nil {
|
||||
getFleetdConfig = profiles.GetFleetdConfig
|
||||
}
|
||||
|
||||
// only run on macos and only if scripts are disabled by default for the
|
||||
// agent (but always run if a test get fleetd config function is set).
|
||||
if (runtime.GOOS == "darwin" && !h.ScriptsExecutionEnabled) || (h.testGetFleetdConfig != nil) {
|
||||
go func() {
|
||||
runCheck := func() {
|
||||
cfg, err := getFleetdConfig()
|
||||
if err != nil {
|
||||
if err != profiles.ErrNotImplemented {
|
||||
// note that an unenrolled host will not return an error, it will
|
||||
// return the zero-value struct, so this logging should not be too
|
||||
// noisy unless something goes wrong.
|
||||
log.Info().Err(err).Msg("get fleetd configuration failed")
|
||||
}
|
||||
return
|
||||
}
|
||||
h.dynamicScriptsEnabled.Store(cfg.EnableScripts)
|
||||
}
|
||||
|
||||
// check immediately at startup, before checking at the interval
|
||||
runCheck()
|
||||
|
||||
// check every minute
|
||||
for range time.Tick(h.dynamicScriptsEnabledCheckInterval) {
|
||||
runCheck()
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
// GetConfig calls the wrapped Fetcher's GetConfig method, and if the fleet
|
||||
// server sent a list of scripts to execute, starts a goroutine to execute
|
||||
// them.
|
||||
func (h *runScriptsConfigFetcher) GetConfig() (*fleet.OrbitConfig, error) {
|
||||
cfg, err := h.Fetcher.GetConfig()
|
||||
|
||||
if err == nil && len(cfg.Notifications.PendingScriptExecutionIDs) > 0 {
|
||||
if h.mu.TryLock() {
|
||||
log.Debug().Msgf("received request to run scripts %v", cfg.Notifications.PendingScriptExecutionIDs)
|
||||
|
||||
runner := &scripts.Runner{
|
||||
// scripts are always enabled if the agent is started with the
|
||||
// --scripts-enabled flag. If it is not started with this flag, then
|
||||
// scripts are enabled only if the mdm profile says so.
|
||||
ScriptExecutionEnabled: h.ScriptsExecutionEnabled || h.dynamicScriptsEnabled.Load(),
|
||||
Client: h.ScriptsClient,
|
||||
}
|
||||
fn := runner.Run
|
||||
if h.runScriptsFn != nil {
|
||||
fn = func(execIDs []string) error {
|
||||
return h.runScriptsFn(runner, execIDs)
|
||||
}
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer h.mu.Unlock()
|
||||
|
||||
if err := fn(cfg.Notifications.PendingScriptExecutionIDs); err != nil {
|
||||
log.Info().Err(err).Msg("running scripts failed")
|
||||
return
|
||||
}
|
||||
log.Debug().Msgf("running scripts %v succeeded", cfg.Notifications.PendingScriptExecutionIDs)
|
||||
}()
|
||||
}
|
||||
}
|
||||
return cfg, err
|
||||
}
|
||||
|
@ -5,9 +5,11 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/orbit/pkg/scripts"
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
"github.com/fleetdm/fleet/v4/server/ptr"
|
||||
"github.com/rs/zerolog/log"
|
||||
@ -363,3 +365,211 @@ func TestWindowsMDMEnrollmentPrevented(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunScripts(t *testing.T) {
|
||||
var logBuf bytes.Buffer
|
||||
|
||||
oldLog := log.Logger
|
||||
log.Logger = log.Output(&logBuf)
|
||||
t.Cleanup(func() { log.Logger = oldLog })
|
||||
|
||||
var (
|
||||
callsCount atomic.Int64
|
||||
runFailure error
|
||||
blockRun chan struct{}
|
||||
)
|
||||
|
||||
mockRun := func(r *scripts.Runner, ids []string) error {
|
||||
callsCount.Add(1)
|
||||
if blockRun != nil {
|
||||
<-blockRun
|
||||
}
|
||||
return runFailure
|
||||
}
|
||||
|
||||
waitForRun := func(t *testing.T, r *runScriptsConfigFetcher) {
|
||||
var ok bool
|
||||
for start := time.Now(); !ok && time.Since(start) < time.Second; {
|
||||
ok = r.mu.TryLock()
|
||||
}
|
||||
require.True(t, ok, "timed out waiting for the lock to become available")
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
t.Run("no pending scripts", func(t *testing.T) {
|
||||
t.Cleanup(func() { callsCount.Store(0); logBuf.Reset() })
|
||||
|
||||
fetcher := &dummyConfigFetcher{
|
||||
cfg: &fleet.OrbitConfig{Notifications: fleet.OrbitConfigNotifications{
|
||||
PendingScriptExecutionIDs: nil,
|
||||
}},
|
||||
}
|
||||
runner := &runScriptsConfigFetcher{
|
||||
Fetcher: fetcher,
|
||||
runScriptsFn: mockRun,
|
||||
}
|
||||
cfg, err := runner.GetConfig()
|
||||
require.NoError(t, err) // the dummy fetcher never returns an error
|
||||
require.Equal(t, fetcher.cfg, cfg) // the wrapper properly returns the expected config
|
||||
|
||||
// the lock should be available because no goroutine was started
|
||||
require.True(t, runner.mu.TryLock())
|
||||
require.Zero(t, callsCount.Load()) // no calls to execute scripts
|
||||
require.Empty(t, logBuf.String()) // no logs written
|
||||
})
|
||||
|
||||
t.Run("pending scripts succeed", func(t *testing.T) {
|
||||
t.Cleanup(func() { callsCount.Store(0); logBuf.Reset() })
|
||||
|
||||
fetcher := &dummyConfigFetcher{
|
||||
cfg: &fleet.OrbitConfig{Notifications: fleet.OrbitConfigNotifications{
|
||||
PendingScriptExecutionIDs: []string{"a", "b", "c"},
|
||||
}},
|
||||
}
|
||||
runner := &runScriptsConfigFetcher{
|
||||
Fetcher: fetcher,
|
||||
runScriptsFn: mockRun,
|
||||
}
|
||||
cfg, err := runner.GetConfig()
|
||||
require.NoError(t, err) // the dummy fetcher never returns an error
|
||||
require.Equal(t, fetcher.cfg, cfg) // the wrapper properly returns the expected config
|
||||
|
||||
waitForRun(t, runner)
|
||||
require.Equal(t, int64(1), callsCount.Load()) // all scripts executed in a single run
|
||||
require.Contains(t, logBuf.String(), "received request to run scripts [a b c]")
|
||||
require.Contains(t, logBuf.String(), "running scripts [a b c] succeeded")
|
||||
})
|
||||
|
||||
t.Run("pending scripts failed", func(t *testing.T) {
|
||||
t.Cleanup(func() { callsCount.Store(0); logBuf.Reset(); runFailure = nil })
|
||||
|
||||
fetcher := &dummyConfigFetcher{
|
||||
cfg: &fleet.OrbitConfig{Notifications: fleet.OrbitConfigNotifications{
|
||||
PendingScriptExecutionIDs: []string{"a", "b", "c"},
|
||||
}},
|
||||
}
|
||||
|
||||
runFailure = io.ErrUnexpectedEOF
|
||||
runner := &runScriptsConfigFetcher{
|
||||
Fetcher: fetcher,
|
||||
runScriptsFn: mockRun,
|
||||
}
|
||||
|
||||
cfg, err := runner.GetConfig()
|
||||
require.NoError(t, err) // the dummy fetcher never returns an error
|
||||
require.Equal(t, fetcher.cfg, cfg) // the wrapper properly returns the expected config
|
||||
|
||||
waitForRun(t, runner)
|
||||
require.Equal(t, int64(1), callsCount.Load()) // all scripts executed in a single run
|
||||
require.Contains(t, logBuf.String(), "received request to run scripts [a b c]")
|
||||
require.Contains(t, logBuf.String(), "running scripts failed")
|
||||
require.Contains(t, logBuf.String(), io.ErrUnexpectedEOF.Error())
|
||||
})
|
||||
|
||||
t.Run("concurrent run prevented", func(t *testing.T) {
|
||||
t.Cleanup(func() { callsCount.Store(0); logBuf.Reset(); blockRun = nil })
|
||||
|
||||
fetcher := &dummyConfigFetcher{
|
||||
cfg: &fleet.OrbitConfig{Notifications: fleet.OrbitConfigNotifications{
|
||||
PendingScriptExecutionIDs: []string{"a", "b", "c"},
|
||||
}},
|
||||
}
|
||||
|
||||
blockRun = make(chan struct{})
|
||||
runner := &runScriptsConfigFetcher{
|
||||
Fetcher: fetcher,
|
||||
runScriptsFn: mockRun,
|
||||
}
|
||||
|
||||
cfg, err := runner.GetConfig()
|
||||
require.NoError(t, err) // the dummy fetcher never returns an error
|
||||
require.Equal(t, fetcher.cfg, cfg) // the wrapper properly returns the expected config
|
||||
|
||||
// call it again, while the previous run is still running
|
||||
cfg, err = runner.GetConfig()
|
||||
require.NoError(t, err) // the dummy fetcher never returns an error
|
||||
require.Equal(t, fetcher.cfg, cfg) // the wrapper properly returns the expected config
|
||||
|
||||
// unblock the initial run
|
||||
close(blockRun)
|
||||
|
||||
waitForRun(t, runner)
|
||||
require.Equal(t, int64(1), callsCount.Load()) // only called once because of mutex
|
||||
require.Contains(t, logBuf.String(), "received request to run scripts [a b c]")
|
||||
require.Contains(t, logBuf.String(), "running scripts [a b c] succeeded")
|
||||
})
|
||||
|
||||
t.Run("dynamic enabling of scripts", func(t *testing.T) {
|
||||
t.Cleanup(logBuf.Reset)
|
||||
|
||||
fetcher := &dummyConfigFetcher{
|
||||
cfg: &fleet.OrbitConfig{Notifications: fleet.OrbitConfigNotifications{
|
||||
PendingScriptExecutionIDs: []string{"a"},
|
||||
}},
|
||||
}
|
||||
|
||||
var (
|
||||
scriptsEnabledCalls []bool
|
||||
dynamicEnabled atomic.Bool
|
||||
|
||||
dynamicInterval = 300 * time.Millisecond
|
||||
)
|
||||
|
||||
runner := &runScriptsConfigFetcher{
|
||||
Fetcher: fetcher,
|
||||
ScriptsExecutionEnabled: false,
|
||||
runScriptsFn: func(r *scripts.Runner, s []string) error {
|
||||
scriptsEnabledCalls = append(scriptsEnabledCalls, r.ScriptExecutionEnabled)
|
||||
return nil
|
||||
},
|
||||
testGetFleetdConfig: func() (*fleet.MDMAppleFleetdConfig, error) {
|
||||
return &fleet.MDMAppleFleetdConfig{
|
||||
EnableScripts: dynamicEnabled.Load(),
|
||||
}, nil
|
||||
},
|
||||
dynamicScriptsEnabledCheckInterval: dynamicInterval,
|
||||
}
|
||||
|
||||
// the static Scripts Enabled flag is false, so it relies on the dynamic check
|
||||
runner.runDynamicScriptsEnabledCheck()
|
||||
|
||||
// first call, scripts are disabled
|
||||
cfg, err := runner.GetConfig()
|
||||
require.NoError(t, err) // the dummy fetcher never returns an error
|
||||
require.Equal(t, fetcher.cfg, cfg) // the wrapper properly returns the expected config
|
||||
waitForRun(t, runner)
|
||||
|
||||
// swap scripts execution to true and wait to ensure the dynamic check
|
||||
// did run.
|
||||
dynamicEnabled.Store(true)
|
||||
time.Sleep(dynamicInterval + 100*time.Millisecond)
|
||||
|
||||
// second call, scripts are enabled (change exec ID to "b")
|
||||
cfg.Notifications.PendingScriptExecutionIDs[0] = "b"
|
||||
cfg, err = runner.GetConfig()
|
||||
require.NoError(t, err) // the dummy fetcher never returns an error
|
||||
require.Equal(t, fetcher.cfg, cfg) // the wrapper properly returns the expected config
|
||||
waitForRun(t, runner)
|
||||
|
||||
// swap scripts execution back to false and wait to ensure the dynamic
|
||||
// check did run.
|
||||
dynamicEnabled.Store(false)
|
||||
time.Sleep(dynamicInterval + 100*time.Millisecond)
|
||||
|
||||
// third call, scripts are disabled (change exec ID to "c")
|
||||
cfg.Notifications.PendingScriptExecutionIDs[0] = "c"
|
||||
cfg, err = runner.GetConfig()
|
||||
require.NoError(t, err) // the dummy fetcher never returns an error
|
||||
require.Equal(t, fetcher.cfg, cfg) // the wrapper properly returns the expected config
|
||||
waitForRun(t, runner)
|
||||
|
||||
// validate the Scripts Enabled flags that were passed to the runScriptsFn
|
||||
require.Equal(t, []bool{false, true, false}, scriptsEnabledCalls)
|
||||
require.Contains(t, logBuf.String(), "received request to run scripts [a]")
|
||||
require.Contains(t, logBuf.String(), "running scripts [a] succeeded")
|
||||
require.Contains(t, logBuf.String(), "received request to run scripts [b]")
|
||||
require.Contains(t, logBuf.String(), "running scripts [b] succeeded")
|
||||
require.Contains(t, logBuf.String(), "received request to run scripts [c]")
|
||||
require.Contains(t, logBuf.String(), "running scripts [c] succeeded")
|
||||
})
|
||||
}
|
||||
|
@ -4219,7 +4219,7 @@ func (ds *Datastore) SetHostScriptExecutionResult(ctx context.Context, result *f
|
||||
if len(output) > utf8.UTFMax*maxOutputRuneLen {
|
||||
// truncate the bytes as we know the output is too long, no point
|
||||
// converting more bytes than needed to runes.
|
||||
output = output[len(output)-utf8.UTFMax*maxOutputRuneLen:]
|
||||
output = output[len(output)-(utf8.UTFMax*maxOutputRuneLen):]
|
||||
}
|
||||
if utf8.RuneCountInString(output) > maxOutputRuneLen {
|
||||
outputRunes := []rune(output)
|
||||
|
@ -3802,6 +3802,7 @@ func (s *integrationEnterpriseTestSuite) TestRunHostScript() {
|
||||
require.Equal(t, host.ID, runSyncResp.HostID)
|
||||
require.NotEmpty(t, runSyncResp.ExecutionID)
|
||||
require.Equal(t, "ok", runSyncResp.Output)
|
||||
require.True(t, runSyncResp.ExitCode.Valid)
|
||||
require.Equal(t, int64(0), runSyncResp.ExitCode.Int64)
|
||||
require.Empty(t, runSyncResp.ErrorMessage)
|
||||
|
||||
|
@ -163,7 +163,7 @@ func getOrbitConfigEndpoint(ctx context.Context, request interface{}, svc fleet.
|
||||
}
|
||||
|
||||
func (svc *Service) GetOrbitConfig(ctx context.Context) (fleet.OrbitConfig, error) {
|
||||
const pendingScriptMaxAge = 24 * time.Hour
|
||||
const pendingScriptMaxAge = time.Minute
|
||||
|
||||
// this is not a user-authenticated endpoint
|
||||
svc.authz.SkipAuthorization(ctx)
|
||||
|
84
tools/run-scripts/main.go
Normal file
84
tools/run-scripts/main.go
Normal file
@ -0,0 +1,84 @@
|
||||
// Command run-scripts is a tool for testing script execution on hosts exactly
|
||||
// as Orbit would do following reception of a Fleet server notification of
|
||||
// pending script(s) to execute.
|
||||
//
|
||||
// It allows to run such scripts without having to build and deploy orbit on
|
||||
// the target host and without having to enroll that host in fleet and have the
|
||||
// fleet server send script execution requests to it.
|
||||
//
|
||||
// The results of script execution, as reported by the host, are printed to the
|
||||
// standard output.
|
||||
//
|
||||
// Usage on the host:
|
||||
//
|
||||
// run-scripts
|
||||
// run-scripts -exec-id my-specific-id -content 'echo "Hello, world!"'
|
||||
// run-scripts -scripts-disabled -content 'echo "Hello, world!"'
|
||||
// run-scripts -scripts-count 10
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/orbit/pkg/scripts"
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func main() {
|
||||
execIDFlag := flag.String("exec-id", "", "Execution ID of the script, will be auto-generated if empty.")
|
||||
contentFlag := flag.String("content", "echo \"Hello\"", "Content of the script to execute.")
|
||||
scriptsDisabledFlag := flag.Bool("scripts-disabled", false, "Disable execution of scripts on the host.")
|
||||
scriptsCountFlag := flag.Int("scripts-count", 1, "Number of scripts to execute. If > 1, the content will all be the same and exec-id will be auto-generated.")
|
||||
|
||||
flag.Parse()
|
||||
|
||||
if *scriptsCountFlag < 1 {
|
||||
log.Fatal("scripts-count must be >= 1")
|
||||
}
|
||||
|
||||
cli := mockClient{content: *contentFlag}
|
||||
runner := &scripts.Runner{
|
||||
ScriptExecutionEnabled: !*scriptsDisabledFlag,
|
||||
Client: cli,
|
||||
}
|
||||
|
||||
execIDs := make([]string, *scriptsCountFlag)
|
||||
for i := range execIDs {
|
||||
if *execIDFlag != "" && len(execIDs) == 1 {
|
||||
execIDs[i] = *execIDFlag
|
||||
break
|
||||
}
|
||||
execIDs[i] = uuid.New().String()
|
||||
}
|
||||
|
||||
if err := runner.Run(execIDs); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
type mockClient struct {
|
||||
content string
|
||||
}
|
||||
|
||||
func (m mockClient) GetHostScript(execID string) (*fleet.HostScriptResult, error) {
|
||||
return &fleet.HostScriptResult{
|
||||
HostID: 1,
|
||||
ExecutionID: execID,
|
||||
ScriptContents: m.content,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m mockClient) SaveHostScriptResult(result *fleet.HostScriptResultPayload) error {
|
||||
fmt.Printf(`
|
||||
Script result for %q:
|
||||
Exit code: %d
|
||||
Runtime: %d second(s)
|
||||
Output:
|
||||
%s
|
||||
---
|
||||
`, result.ExecutionID, result.ExitCode, result.Runtime, result.Output)
|
||||
return nil
|
||||
}
|
Loading…
Reference in New Issue
Block a user