sharedlibraries/commandlineexecutor/commandlineexecutor.go (130 lines of code) (raw):
/*
Copyright 2022 Google LLC
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
/*
Package commandlineexecutor creates an interface to streamline execution of shell commands
across multiple platforms.
This package provides a simple interface to execute shell commands across multiple platforms.
It also provides a way to check if a command exists and to get the exit code from an error.
Example usage:
// Create a new commandlineexecutor.Params struct with the executable and arguments to run.
params := commandlineexecutor.Params{
Executable: "/bin/ls",
Args: []string{"-l", "/usr/local/google"},
}
// Execute the command.
result := commandlineexecutor.ExecuteCommand(context.Background(), params)
// Check the result for any errors.
if result.Error != nil {
log.Error(result.Error)
}
// Print the standard output and standard error.
fmt.Printf("Standard output:\n%s", result.StdOut)
fmt.Printf("Standard error:\n%s", result.StdErr)
// Check the exit code.
if result.ExitCode != 0 {
log.Error("Command failed with exit code", result.ExitCode)
}
*/
package commandlineexecutor
import (
"bytes"
"context"
"errors"
"fmt"
"os/exec"
"regexp"
"strconv"
"strings"
"time"
"github.com/GoogleCloudPlatform/workloadagentplatform/sharedlibraries/log"
)
var (
exitStatusPattern = regexp.MustCompile("exit status ([0-9]+)")
exists = CommandExists
exitCode = commandExitCode
run Run = nil
exeForPlatform SetupExeForPlatform = nil
)
type (
// Execute is a function to execute a command. Production callers
// to pass commandlineexecutor.ExecuteCommand while calling this package's APIs.
Execute func(context.Context, Params) Result
// Exists is a function to check if a command exists. Production callers
// to pass commandlineexecutor.CommandExists while calling this package's APIs.
Exists func(string) bool
// ExitCode is a function to get the exit code from an error. Production callers
// to pass commandlineexecutor.CommandExitCode while calling this package's APIs.
ExitCode func(err error) int
// Run is a testable version of the exec.Run method. Should only be used during testing.
Run func() error
// SetupExeForPlatform is a testable version of the setupExeForPlatform call.
// Should only be used during testing.
SetupExeForPlatform func(exe *exec.Cmd, params Params) error
// Params encapsulates the parameters used by the Exec* and RunWithEnv funcs.
Params struct {
Executable string
// One of ArgsToSplit or Args should be defined on the Params.
// ArgsToSplit should be preferred when issuing commands with a subshell and using "-c".
// An example would be an invocation like:
// Executable: "/bin/sh"
// ArgsToSplit: "-c 'ls /usr/sap/*/SYS/global/hdb/custom/config/global.ini'"
// In this case ArgsToSplit will be split up correctly as:
// []string{"-c", "'ls /usr/sap/*/SYS/global/hdb/custom/config/global.ini'"}
ArgsToSplit string
Args []string
Timeout int // defaults to 60, so timeout will occur in 60 seconds
User string
Env []string
Stdin string
}
// Result holds the stdout, stderr, exit code, and error from the execution.
Result struct {
StdOut, StdErr string
ExitCode int
Error error
ExecutableFound bool
ExitStatusParsed bool // Will be true if "exit status ([0-9]+)" is in the error result
}
)
/*
ExecuteCommand takes Params and returns a Result.
If the params.Executable does not exist it will return early with the Result.Error filled
If the Params ArgsToSplit is not empty then it will be split into an arguments array
Else the Args will be used as the arguments array
If the User is not empty then the command will be executed as that user
If Env is defined then that environment will be used to execute the command
The returned Result will contain the standard out, standard error, the exit code and an error if
one was encountered during execution.
*/
func ExecuteCommand(ctx context.Context, params Params) Result {
if !exists(params.Executable) {
log.CtxLogger(ctx).Debugw("Command executable not found", "executable", params.Executable)
msg := fmt.Sprintf("Command executable: %q not found.", params.Executable)
return Result{"", msg, 0, fmt.Errorf("command executable: %s not found", params.Executable), false, false}
}
stdout := new(bytes.Buffer)
stderr := new(bytes.Buffer)
// Timeout the command at 60 seconds by default.
timeout := 60 * time.Second
if params.Timeout > 0 {
timeout = time.Duration(params.Timeout) * time.Second
}
// Context tctx has a Timeout while running the commands.
tctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
args := params.Args
if params.ArgsToSplit != "" {
args = splitParams(params.ArgsToSplit)
}
exe := exec.CommandContext(tctx, params.Executable, args...)
exe.Stdin = strings.NewReader(params.Stdin)
exe.Stdout = stdout
exe.Stderr = stderr
var err error
if exeForPlatform != nil {
err = exeForPlatform(exe, params)
} else {
// We pass ctx because this calls back into ExecuteCommand which adds the timeout before running the command.
err = setupExeForPlatform(ctx, exe, params, ExecuteCommand)
}
if err != nil {
log.CtxLogger(ctx).Debugw("Could not setup the executable environment", "executable", params.Executable, "args", args, "error", err)
return Result{stdout.String(), stderr.String(), 0, err, true, false}
}
log.CtxLogger(ctx).Debugw("Executing command", "executable", params.Executable, "args", args,
"timeout", timeout, "user", params.User, "env", params.Env)
if run != nil {
err = run()
} else {
err = exe.Run()
}
if err != nil {
// Set the exit code based on the error first, then see if we can get it from the error message.
exitCode := exitCode(err)
m := exitStatusPattern.FindStringSubmatch(err.Error())
exitStatusParsed := false
if len(m) > 0 {
atoi, serr := strconv.Atoi(m[1])
if serr != nil {
log.CtxLogger(ctx).Debugw("Failed to get command exit code from string match", "executable", params.Executable,
"args", args, "error", serr)
} else {
// This is the case where we expect to have an Error but want the exit code from the "exit status #" string
exitCode = atoi
exitStatusParsed = true
}
} else {
log.CtxLogger(ctx).Debugw("Error encountered when executing command", "executable", params.Executable,
"args", args, "exitcode", exitCode, "error", err, "stdout", stdout.String(),
"stderr", stderr.String())
}
return Result{stdout.String(), stderr.String(), exitCode, err, true, exitStatusParsed}
}
// Exit code can assumed to be 0
log.CtxLogger(ctx).Debugw("Successfully executed command", "executable", params.Executable, "args", args,
"stdout", stdout.String(), "stderr", stderr.String())
return Result{stdout.String(), stderr.String(), 0, nil, true, false}
}
/*
CommandExists returns whether or not an executable command exists within the current os runtime
environment.
*/
func CommandExists(executable string) bool {
_, err := exec.LookPath(executable)
return err == nil
}
/*
commandExitCode returns the exit code attached to the error produced by a call to exec.Command or 0
if the error is null.
*/
func commandExitCode(err error) int {
var exitErr *exec.ExitError
if err != nil && errors.As(err, &exitErr) {
return exitErr.ExitCode()
}
return 0
}
/*
splitParams performs a custom splitting operation around spaces and substrings contained within
single quotes, exclusively, on command strings in order to parse them into a list of valid shell
arguments for exec.Command structs
ex:
bash -c 'ls $0 $1' /etc /home
becomes:
{"bash", "-c", "ls $0 $1", "/etc", "/home"}
*/
func splitParams(executable string) []string {
// This regex pattern matches substrings without spaces or those with spaces between single quotes
pattern := regexp.MustCompile(`[^\s']+|('([^']*)')`)
arr := pattern.FindAllString(executable, -1)
// This for loop removes the single quote characters surrounding the matched substring
for i := range arr {
// convert "'ls $0 $1'" to "ls $0 $1"
if arr[i][0] == '\'' && arr[i][len(arr[i])-1] == '\'' {
arr[i] = arr[i][1 : len(arr[i])-1]
}
}
// This for loop is to substitute backtick character \` with single quotes \'
// Single quotes are used to filter for a specific substring (using grep) matching a given expression.
// To prevent malformed matching, it is recommended to write the expression,
// normally contained within single quotes, to be contained within back ticks.
// This is helpful for bash commands using '-c' flag where the command statement
// is enclosed within single quotes.
//
// For eg: instead of
// grep -Eo '([0-9]{1,3}\.){1,3}[0-9]{1,3}\:[0-9]{3,5}'
// use
// grep -Eo `([0-9]{1,3}\.){1,3}[0-9]{1,3}\:[0-9]{3,5}`
for i := range arr {
arr[i] = strings.ReplaceAll(arr[i], "`", "'")
}
return arr
}