shells/bash.go (407 lines of code) (raw):
package shells
import (
"bytes"
"context"
"fmt"
"path"
"runtime"
"strconv"
"strings"
"gitlab.com/gitlab-org/gitlab-runner/common"
"gitlab.com/gitlab-org/gitlab-runner/helpers"
"gitlab.com/gitlab-org/gitlab-runner/helpers/featureflags"
)
const (
Bash = "bash"
BashDetectShellScript = `if [ -x /usr/local/bin/bash ]; then
exec /usr/local/bin/bash $@
elif [ -x /usr/bin/bash ]; then
exec /usr/bin/bash $@
elif [ -x /bin/bash ]; then
exec /bin/bash $@
elif [ -x /usr/local/bin/sh ]; then
exec /usr/local/bin/sh $@
elif [ -x /usr/bin/sh ]; then
exec /usr/bin/sh $@
elif [ -x /bin/sh ]; then
exec /bin/sh $@
elif [ -x /busybox/sh ]; then
exec /busybox/sh $@
else
echo shell not found
exit 1
fi
`
// This script is indented to be run in docker or kubernetes containers only to ensure graceful shutdown of build,
// service and potentially other containers. It sends SIGTERM to all PIDs excluding itself and 1, in decreasing numeric
// order, positing that the higher PIDs are likely the processes blocking and thus preventing the container from
// shutting down cleanly. The inner while loop waits for up to 5 seconds for the last killed PID to exit before moving
// onto the next PID. Note that processes that are shells will ignore SIGTERM anyway, so this script is not as heavy
// handed as it might appear.
ContainerSigTermScriptForLinux = `PROCS=$(cd /proc && ls -rvd [0-9]*) &&
for P in $PROCS; do
if [ $$ -ne $P ] && [ $P -ne 1 ]; then
kill -TERM $P 2>/dev/null &&
ATTEMPTS=6 &&
while [ -e /proc/$P ] && [ $ATTEMPTS -gt 0 ]; do
sleep 1 && ATTEMPTS=$((ATTEMPTS-1));
done;
fi;
done
`
// bashJSONTerminationScript prints a json log-line to provide exit code context to
// executors that cannot directly retrieve the exit status of the script.
bashJSONTerminationScript = `runner_script_trap() {
exit_code=$?
out_json="{\"command_exit_code\": $exit_code, \"script\": \"$0\"}"
echo ""
echo "$out_json"
exit 0
}
trap runner_script_trap EXIT
`
// When the job is cancelled through the UI, GitLab Runner sends SIGTERM to
// all PIDs related to the stage script.
// On Bash version 4, the procession termination dumps the executed script in the job logs.
// To prevent this behaviour the TERM signals are trapped and cause the script to exit 1.
bashExitOnScriptTerminationSignal = `trap exit 1 TERM`
bashJSONInitializationScript = `start_json="{\"script\": \"$0\"}"
echo "$start_json"
`
)
type BashShell struct {
AbstractShell
Shell string
}
type BashWriter struct {
bytes.Buffer
TemporaryPath string
Shell string
indent int
checkForErrors bool
useNewEval bool
usePosixEscape bool
useJSONInitializationTermination bool
setPermissionsBeforeCleanup bool
}
func NewBashWriter(build *common.Build, shell string) *BashWriter {
return &BashWriter{
TemporaryPath: build.TmpProjectDir(),
Shell: shell,
checkForErrors: build.IsFeatureFlagOn(featureflags.EnableBashExitCodeCheck),
useNewEval: build.IsFeatureFlagOn(featureflags.UseNewEvalStrategy),
usePosixEscape: build.IsFeatureFlagOn(featureflags.PosixlyCorrectEscapes),
// useJSONInitializationTermination is only used for kubernetes executor when
// the feature flag FF_USE_LEGACY_KUBERNETES_EXECUTION_STRATEGY is set to false
useJSONInitializationTermination: build.Runner.Executor == common.ExecutorKubernetes &&
!build.IsFeatureFlagOn(featureflags.UseLegacyKubernetesExecutionStrategy),
setPermissionsBeforeCleanup: build.IsFeatureFlagOn(featureflags.SetPermissionsBeforeCleanup),
}
}
func (b *BashWriter) GetTemporaryPath() string {
return b.TemporaryPath
}
func (b *BashWriter) Line(text string) {
b.WriteString(strings.Repeat(" ", b.indent) + text + "\n")
}
func (b *BashWriter) Linef(format string, arguments ...interface{}) {
b.Line(fmt.Sprintf(format, arguments...))
}
func (b *BashWriter) CheckForErrors() {
if !b.checkForErrors {
return
}
b.Line("_runner_exit_code=$?; if [ $_runner_exit_code -ne 0 ]; then exit $_runner_exit_code; fi")
}
func (b *BashWriter) Indent() {
b.indent++
}
func (b *BashWriter) Unindent() {
b.indent--
}
func (b *BashWriter) Command(command string, arguments ...string) {
b.Line(b.buildCommand(b.escape, command, arguments...))
b.CheckForErrors()
}
// SetupGitCredHelper is the bash implementation of setting up the runner's default cred helper, which pulls out the job
// token from the environment.
func (b *BashWriter) SetupGitCredHelper(confFile, section, user string) {
helperSection := b.escape(section + ".helper")
userSection := b.escape(section + ".username")
emptyArg := `""`
b.Line(
fmt.Sprintf(
`git config -f %[1]s --replace-all %[2]s %[3]s && `+
`git config -f %[1]s --add %[2]s %[4]s && `+
`git config -f %[1]s %[5]s %[6]s`,
b.escape(confFile),
helperSection,
emptyArg,
b.escape(credHelperCommand),
userSection,
user,
),
)
b.CheckForErrors()
}
func (b *BashWriter) CommandArgExpand(command string, arguments ...string) {
b.Line(b.buildCommand(doubleQuote, command, arguments...))
b.CheckForErrors()
}
func (b *BashWriter) buildCommand(quoter stringQuoter, command string, arguments ...string) string {
list := []string{
b.escape(command),
}
for _, argument := range arguments {
list = append(list, quoter(argument))
}
return strings.Join(list, " ")
}
func (b *BashWriter) TmpFile(name string) string {
return b.cleanPath(path.Join(b.TemporaryPath, name))
}
func (b *BashWriter) cleanPath(name string) string {
return b.Absolute(name)
}
func (b *BashWriter) EnvVariableKey(name string) string {
return fmt.Sprintf("$%s", name)
}
// Intended to be used on unmodified paths only (i.e. paths that have not been
// cleaned with cleanPath()).
func (b *BashWriter) isTmpFile(path string) bool {
return strings.HasPrefix(path, b.TemporaryPath)
}
func (b *BashWriter) Variable(variable common.JobVariable) {
if variable.File {
variableFile := b.TmpFile(variable.Key)
b.Linef("mkdir -p %q", helpers.ToSlash(b.TemporaryPath))
b.Linef("printf '%%s' %s > %q", b.escape(variable.Value), variableFile)
b.Linef("export %s=%q", b.escape(variable.Key), variableFile)
} else {
if b.isTmpFile(variable.Value) {
variable.Value = b.cleanPath(variable.Value)
}
b.Linef("export %s=%s", b.escape(variable.Key), b.escape(variable.Value))
}
}
func (b *BashWriter) DotEnvVariables(baseFilename string, variables map[string]string) string {
dotEnvFile := b.TmpFile(baseFilename)
var sb strings.Builder
sb.WriteString(fmt.Sprintf("cat << EOF > %s\n", dotEnvFile))
sb.WriteString(helpers.DotEnvEscape(variables))
sb.WriteString("EOF\n")
b.Line(sb.String())
return dotEnvFile
}
func (b *BashWriter) SourceEnv(pathname string) {
b.Linef("mkdir -p %q", helpers.ToSlash(b.TemporaryPath))
b.Linef("touch %q", pathname)
b.Linef(`while read -r line; do export "$line"; done < %q`, pathname)
}
func (b *BashWriter) IfDirectory(path string) {
b.Linef("if [ -d %q ]; then", path)
b.Indent()
}
func (b *BashWriter) IfFile(path string) {
b.Linef("if [ -e %q ]; then", path)
b.Indent()
}
func (b *BashWriter) IfCmd(cmd string, arguments ...string) {
cmdline := b.buildCommand(b.escape, cmd, arguments...)
b.Linef("if %s >/dev/null 2>&1; then", cmdline)
b.Indent()
}
func (b *BashWriter) IfCmdWithOutput(cmd string, arguments ...string) {
cmdline := b.buildCommand(b.escape, cmd, arguments...)
b.Linef("if %s; then", cmdline)
b.Indent()
}
func (b *BashWriter) IfGitVersionIsAtLeast(version string) {
b.Linef(`current_ver="$(git version|cut -d ' ' -f 3)"`)
b.Linef(`required_ver=%q`, version)
b.Line(`minimum_ver="$(printf '%s\n%s' "$required_ver" "$current_ver" | sort --version-sort | head -n1)"`)
b.Linef(`if [ "$minimum_ver" = "$required_ver" ]; then`)
b.Printf("Git version at least %q", version)
b.Indent()
}
func (b *BashWriter) Else() {
b.Unindent()
b.Line("else")
b.Indent()
}
func (b *BashWriter) EndIf() {
b.Unindent()
b.Line("fi")
}
func (b *BashWriter) Cd(path string) {
b.Command("cd", path)
}
func (b *BashWriter) MkDir(path string) {
b.Command("mkdir", "-p", path)
}
func (b *BashWriter) MkTmpDir(name string) string {
path := path.Join(b.TemporaryPath, name)
b.MkDir(path)
return path
}
func (b *BashWriter) RmDir(path string) {
if b.setPermissionsBeforeCleanup {
b.IfDirectory(path)
b.Command("chmod", "-R", "u+rwX", path)
b.EndIf()
}
b.Command("rm", "-r", "-f", path)
}
func (b *BashWriter) RmFile(path string) {
b.Command("rm", "-f", path)
}
func (b *BashWriter) RmFilesRecursive(path string, name string) {
b.IfDirectory(path)
// `find -delete` is not portable; https://unix.stackexchange.com/a/194348
b.Linef("find %q -name %q -type f -exec rm -f {} +", path, name)
b.EndIf()
}
func (b *BashWriter) RmDirsRecursive(path string, name string) {
b.IfDirectory(path)
// `find -delete` is not portable; https://unix.stackexchange.com/a/194348
b.Linef("find %q -name %q -type d -exec rm -rf -- {} +", path, name)
b.EndIf()
}
func (b *BashWriter) Absolute(dir string) string {
if path.IsAbs(dir) || strings.HasPrefix(dir, "$PWD") {
return dir
}
return path.Join("$PWD", dir)
}
func (b *BashWriter) Join(elem ...string) string {
return path.Join(elem...)
}
func (b *BashWriter) Printf(format string, arguments ...interface{}) {
coloredText := helpers.ANSI_RESET + fmt.Sprintf(format, arguments...)
b.Line("echo " + b.escape(coloredText))
}
func (b *BashWriter) Noticef(format string, arguments ...interface{}) {
coloredText := helpers.ANSI_BOLD_GREEN + fmt.Sprintf(format, arguments...) + helpers.ANSI_RESET
b.Line("echo " + b.escape(coloredText))
}
func (b *BashWriter) Warningf(format string, arguments ...interface{}) {
coloredText := helpers.ANSI_YELLOW + fmt.Sprintf(format, arguments...) + helpers.ANSI_RESET
b.Line("echo " + b.escape(coloredText))
}
func (b *BashWriter) Errorf(format string, arguments ...interface{}) {
coloredText := helpers.ANSI_BOLD_RED + fmt.Sprintf(format, arguments...) + helpers.ANSI_RESET
b.Line("echo " + b.escape(coloredText))
}
func (b *BashWriter) EmptyLine() {
b.Line("echo")
}
func (b *BashWriter) SectionStart(id, command string, options []string) {
b.Line("printf '%b\\n' " +
"section_start:$(date +%s):section_" + id + stringifySectionOptions(options) +
"\r" + helpers.ANSI_CLEAR + b.escape(helpers.ANSI_BOLD_GREEN+command+helpers.ANSI_RESET))
}
func (b *BashWriter) SectionEnd(id string) {
b.Line("printf '%b\\n' " +
"section_end:$(date +%s):section_" + id +
"\r" + helpers.ANSI_CLEAR)
}
func (b *BashWriter) Finish(trace bool) string {
var buf strings.Builder
if b.Shell != "" {
buf.WriteString("#!/usr/bin/env " + b.Shell + "\n\n")
}
buf.WriteString(bashExitOnScriptTerminationSignal + "\n\n")
if b.useJSONInitializationTermination {
buf.WriteString(bashJSONInitializationScript)
buf.WriteString(bashJSONTerminationScript)
}
if trace {
buf.WriteString("set -o xtrace\n")
}
buf.WriteString("if set -o | grep pipefail > /dev/null; then set -o pipefail; fi; set -o errexit\n")
buf.WriteString("set +o noclobber\n")
if b.useNewEval {
buf.WriteString(": | (eval " + b.escape(b.String()) + ")\n")
} else {
buf.WriteString(": | eval " + b.escape(b.String()) + "\n")
}
buf.WriteString("exit 0\n")
return buf.String()
}
func (b *BashWriter) escape(input string) string {
if b.usePosixEscape {
return helpers.PosixShellEscape(input)
}
return helpers.ShellEscape(input)
}
func (b *BashShell) GetName() string {
return b.Shell
}
func (b *BashShell) GetEntrypointCommand(info common.ShellScriptInfo, probeFile string) []string {
script := b.bashDetectScript(info.Type == common.LoginShell)
if probeFile != "" {
script = fmt.Sprintf(">'%s'", probeFile) + "; " + script
}
return []string{"sh", "-c", script}
}
func (b *BashShell) bashDetectScript(loginShell bool) string {
args := ""
if loginShell {
args = "-l"
}
return strings.ReplaceAll(BashDetectShellScript, "$@", args)
}
func (b *BashShell) GetConfiguration(info common.ShellScriptInfo) (*common.ShellConfiguration, error) {
script := &common.ShellConfiguration{
Command: b.Shell,
CmdLine: b.Shell,
}
if info.Type == common.LoginShell {
script.CmdLine += " -l"
script.Arguments = []string{"-l"}
}
script.DockerCommand = []string{"sh", "-c", b.bashDetectScript(info.Type == common.LoginShell)}
if info.User == "" {
return script, nil
}
script.Command = "su"
if runtime.GOOS == OSLinux {
script.Arguments = []string{"-s", "/bin/" + b.Shell, info.User, "-c", script.CmdLine}
} else {
script.Arguments = []string{info.User, "-c", script.CmdLine}
}
script.CmdLine = script.Command
for _, arg := range script.Arguments {
script.CmdLine += " " + helpers.ShellEscape(arg)
}
return script, nil
}
func (b *BashShell) GenerateScript(
ctx context.Context,
buildStage common.BuildStage,
info common.ShellScriptInfo,
) (string, error) {
w := NewBashWriter(info.Build, b.Shell)
return b.generateScript(ctx, w, buildStage, info)
}
func (b *BashShell) generateScript(
ctx context.Context,
w ShellWriter,
buildStage common.BuildStage,
info common.ShellScriptInfo,
) (string, error) {
b.ensurePrepareStageHostnameMessage(w, buildStage, info)
err := b.writeScript(ctx, w, buildStage, info)
script := w.Finish(info.Build.IsDebugTraceEnabled())
return script, err
}
func (b *BashShell) ensurePrepareStageHostnameMessage(
w ShellWriter,
buildStage common.BuildStage,
info common.ShellScriptInfo,
) {
if buildStage == common.BuildStagePrepare {
if info.Build.Hostname != "" {
w.Line("echo " + strconv.Quote("Running on $(hostname) via "+info.Build.Hostname+"..."))
} else {
w.Line("echo " + strconv.Quote("Running on $(hostname)..."))
}
}
}
func (b *BashShell) GenerateSaveScript(info common.ShellScriptInfo, scriptPath, script string) (string, error) {
w := NewBashWriter(info.Build, b.Shell)
return b.generateSaveScript(w, scriptPath, script)
}
func (b *BashShell) generateSaveScript(w *BashWriter, scriptPath, script string) (string, error) {
w.Line(fmt.Sprintf("touch %s", scriptPath))
w.Line(fmt.Sprintf("chmod 777 %s", scriptPath))
w.Line(fmt.Sprintf("echo %s > %s", w.escape(script), scriptPath))
return w.String(), nil
}
func (b *BashShell) IsDefault() bool {
return runtime.GOOS != OSWindows && b.Shell == "bash"
}
func init() {
common.RegisterShell(WrapShell(&BashShell{Shell: "sh"}))
common.RegisterShell(WrapShell(&BashShell{Shell: "bash"}))
}