diff --git a/changes/18118-run-script-updates b/changes/18118-run-script-updates new file mode 100644 index 0000000000..a2a42465ad --- /dev/null +++ b/changes/18118-run-script-updates @@ -0,0 +1 @@ +Added `--async` and `--quiet` to `fleetctl run-script` as well as allowing the contents of the script to be inline. diff --git a/cmd/fleetctl/scripts.go b/cmd/fleetctl/scripts.go index 984c2dd555..b38ca9e563 100644 --- a/cmd/fleetctl/scripts.go +++ b/cmd/fleetctl/scripts.go @@ -15,6 +15,14 @@ import ( "github.com/urfave/cli/v2" ) +// Helper function to convert a boolean to an integer +func boolToInt(b bool) int { + if b { + return 1 + } + return 0 +} + func runScriptCommand() *cli.Command { return &cli.Command{ Name: "run-script", @@ -42,6 +50,16 @@ func runScriptCommand() *cli.Command { Usage: `Available in Fleet Premium. ID of the team that the saved script belongs to. 0 targets hosts assigned to “No team” (default: 0).`, Required: false, }, + &cli.BoolFlag{ + Name: "async", + Usage: `Queue the script and don't wait for the return.`, + Required: false, + }, + &cli.BoolFlag{ + Name: "quiet", + Usage: `Suppress messages that are not the script output / error`, + Required: false, + }, configFlag(), contextFlag(), debugFlag(), @@ -61,15 +79,22 @@ func runScriptCommand() *cli.Command { return errors.New(fleet.RunScriptScriptsDisabledGloballyErrMsg) } + async := c.Bool("async") + quiet := c.Bool("quiet") + + // Require 1 and only 1 of these 3 options path := c.String("script-path") name := c.String("script-name") + args := c.Args().Len() - if path == "" && name == "" { - return errors.New("One of '--script-path' or '--script-name' must be specified.") + notEmpty := boolToInt(path != "") + boolToInt(name != "") + boolToInt(args > 0) + + if notEmpty < 1 { + return errors.New("One of '--script-path' or '--script-name' or '-- ' must be specified.") } - if path != "" && name != "" { - return errors.New("Only one of '--script-path' or '--script-name' is allowed.") + if notEmpty > 1 { + return errors.New("Only one of '--script-path' or '--script-name' or '-- ' is allowed.") } if path != "" { @@ -99,10 +124,17 @@ func runScriptCommand() *cli.Command { } var b []byte - if path != "" { - b, err = os.ReadFile(path) - if err != nil { - return err + if path != "" || args > 0 { + if path != "" { + b, err = os.ReadFile(path) + if err != nil { + return err + } + } + + if args > 0 { + commandString := strings.Join(c.Args().Slice(), " ") + b = []byte(commandString) } // validate script contents with isSavedScript flag set to false so that we check @@ -115,7 +147,21 @@ func runScriptCommand() *cli.Command { } } - fmt.Println("\nScript is running. Please wait for it to finish...") + if async { + res, err := client.RunHostScriptAsync(h.ID, b, name, c.Uint("team")) + if err != nil { + if strings.Contains(err.Error(), `Only one of 'script_contents' or 'team_id' is allowed`) { + return errors.New("Only one of '--script-path' or '--team' is allowed.") + } + return err + } + fmt.Fprintf(c.App.Writer, "%s\n", res.ExecutionID) + return nil + } + + if !quiet { + fmt.Println("\nScript is running. Please wait for it to finish...") + } res, err := client.RunHostScriptSync(h.ID, b, name, c.Uint("team")) if err != nil { @@ -125,8 +171,12 @@ func runScriptCommand() *cli.Command { return err } - if err := renderScriptResult(c, res); err != nil { - return err + if !quiet { + if err := renderScriptResult(c, res); err != nil { + return err + } + } else { + fmt.Fprintf(c.App.Writer, "%s", res.Output) } return nil diff --git a/cmd/fleetctl/scripts_test.go b/cmd/fleetctl/scripts_test.go index bc08c395e1..5730251fbe 100644 --- a/cmd/fleetctl/scripts_test.go +++ b/cmd/fleetctl/scripts_test.go @@ -70,6 +70,9 @@ hello world ------------------------------------------------------------------------------------- ` + expectedQuietOutputSuccess := `hello world +` + type testCase struct { name string scriptPath func() string @@ -77,6 +80,8 @@ hello world teamID *uint savedScriptContents func() ([]byte, error) scriptResult *fleet.HostScriptResult + quiet bool + async bool expectOutput string expectErrMsg string expectNotFound bool @@ -190,11 +195,11 @@ hello world name: "script-path and script-name disallowed", scriptPath: generateValidPath, scriptName: "foo", - expectErrMsg: `Only one of '--script-path' or '--script-name' is allowed.`, + expectErrMsg: `Only one of '--script-path' or '--script-name' or '-- ' is allowed.`, }, { name: "missing one of script-path and script-nqme", - expectErrMsg: `One of '--script-path' or '--script-name' must be specified.`, + expectErrMsg: `One of '--script-path' or '--script-name' or '-- ' must be specified.`, }, { name: "script-path and team disallowed", @@ -227,6 +232,16 @@ hello world }, expectOutput: expectedOutputSuccess, }, + { + name: "script quiet", + scriptPath: generateValidPath, + scriptResult: &fleet.HostScriptResult{ + ExitCode: ptr.Int64(0), + Output: "hello world\n", + }, + expectOutput: expectedQuietOutputSuccess, + quiet: true, + }, { name: "script failed", scriptPath: generateValidPath, @@ -392,6 +407,14 @@ Fleet records the last 10,000 characters to prevent downtime. args = append(args, "--script-name", c.scriptName) } + if c.quiet { + args = append(args, "--quiet") + } + + if c.async { + args = append(args, "--async") + } + if c.teamID != nil { args = append(args, "--team", fmt.Sprintf("%d", *c.teamID)) } diff --git a/server/service/client_scripts.go b/server/service/client_scripts.go index cca580050c..18285013fd 100644 --- a/server/service/client_scripts.go +++ b/server/service/client_scripts.go @@ -13,7 +13,15 @@ import ( func (c *Client) RunHostScriptSync(hostID uint, scriptContents []byte, scriptName string, teamID uint) (*fleet.HostScriptResult, error) { verb, path := "POST", "/api/latest/fleet/scripts/run/sync" + return c.runHostScript(verb, path, hostID, scriptContents, scriptName, teamID, http.StatusOK) +} +func (c *Client) RunHostScriptAsync(hostID uint, scriptContents []byte, scriptName string, teamID uint) (*fleet.HostScriptResult, error) { + verb, path := "POST", "/api/latest/fleet/scripts/run" + return c.runHostScript(verb, path, hostID, scriptContents, scriptName, teamID, http.StatusAccepted) +} + +func (c *Client) runHostScript(verb, path string, hostID uint, scriptContents []byte, scriptName string, teamID uint, successStatusCode int) (*fleet.HostScriptResult, error) { req := fleet.HostScriptRequestPayload{ HostID: hostID, ScriptName: scriptName, @@ -32,7 +40,7 @@ func (c *Client) RunHostScriptSync(hostID uint, scriptContents []byte, scriptNam defer res.Body.Close() switch res.StatusCode { - case http.StatusOK: + case successStatusCode: b, err := io.ReadAll(res.Body) if err != nil { return nil, fmt.Errorf("reading %s %s response: %w", verb, path, err) @@ -45,13 +53,10 @@ func (c *Client) RunHostScriptSync(hostID uint, scriptContents []byte, scriptNam if err != nil { return nil, err } - if strings.Contains(errMsg, fleet.RunScriptScriptsDisabledGloballyErrMsg) { return nil, errors.New(fleet.RunScriptScriptsDisabledGloballyErrMsg) } - return nil, errors.New(fleet.RunScriptForbiddenErrMsg) - case http.StatusPaymentRequired: if teamID > 0 { return nil, errors.New("Team id parameter requires Fleet Premium license.")