internal/shells/bash.go (102 lines of code) (raw):

package shells import ( "bytes" "fmt" "os" "os/exec" "strings" "golang.org/x/sys/unix" "github.com/Azure/InnovationEngine/internal/lib" ) func appendToBashHistory(command string, filePath string) error { file, err := os.OpenFile(filePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) if err != nil { return fmt.Errorf("failed to open file: %w", err) } defer file.Close() // Lock the file to prevent other processes from writing to it concurrently // and then unlock after we're done writing to it. if err := unix.Flock(int(file.Fd()), unix.LOCK_EX); err != nil { return fmt.Errorf("failed to lock file: %w", err) } defer unix.Flock(int(file.Fd()), unix.LOCK_UN) // Append the command and a newline to the file _, err = file.WriteString(command + "\n") if err != nil { return fmt.Errorf("failed to write to file: %w", err) } return nil } type CommandOutput struct { StdOut string StdErr string } type BashCommandConfiguration struct { EnvironmentVariables map[string]string InheritEnvironment bool InteractiveCommand bool WriteToHistory bool } var ExecuteBashCommand = executeBashCommandImpl // Executes a bash command and returns the output or error. func executeBashCommandImpl( command string, config BashCommandConfiguration, ) (CommandOutput, error) { commandWithStateSaved := []string{ "set -e", command, "IE_LAST_COMMAND_EXIT_CODE=\"$?\"", "env > " + lib.DefaultEnvironmentStateFile, "exit $IE_LAST_COMMAND_EXIT_CODE", } commandToExecute := exec.Command("bash", "-c", strings.Join(commandWithStateSaved, "\n")) var stdoutBuffer, stderrBuffer bytes.Buffer // If the command requires interaction, we provide the user with the ability // to interact with the command. However, we cannot capture the buffer this // way. if config.InteractiveCommand { commandToExecute.Stdout = os.Stdout commandToExecute.Stderr = os.Stderr commandToExecute.Stdin = os.Stdin } else { commandToExecute.Stdout = &stdoutBuffer commandToExecute.Stderr = &stderrBuffer } if config.InheritEnvironment { commandToExecute.Env = os.Environ() } // Sharing environment variable state between isolated shell executions is a // bit tough, but how we handle it is by storing the environment variables // after a command is executed within a file and then loading that file // before executing the next command. This allows us to share state between // isolated command calls. envFromPreviousStep, err := lib.LoadEnvironmentStateFile(lib.DefaultEnvironmentStateFile) if err == nil { merged := lib.MergeMaps(config.EnvironmentVariables, envFromPreviousStep) for k, v := range merged { commandToExecute.Env = append(commandToExecute.Env, fmt.Sprintf("%s=%s", k, v)) } } else { for k, v := range config.EnvironmentVariables { commandToExecute.Env = append(commandToExecute.Env, fmt.Sprintf("%s=%s", k, v)) } } if config.WriteToHistory { homeDir, err := lib.GetHomeDirectory() if err != nil { return CommandOutput{}, fmt.Errorf("failed to get home directory: %w", err) } err = appendToBashHistory(command, homeDir+"/.bash_history") if err != nil { return CommandOutput{}, fmt.Errorf("failed to write command to history: %w", err) } } err = commandToExecute.Run() // TODO(vmarcella): Find a better way to handle this. if config.InteractiveCommand { return CommandOutput{}, err } standardOutput, standardError := stdoutBuffer.String(), stderrBuffer.String() if err != nil { return CommandOutput{ StdOut: standardOutput, StdErr: standardError, }, fmt.Errorf( "command exited with '%w' and the message '%s'", err, standardError, ) } return CommandOutput{ StdOut: standardOutput, StdErr: standardError, }, nil }