pkg/gcpbuildpack/exec.go (186 lines of code) (raw):
// Copyright 2020 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
//
// http://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 gcpbuildpack
import (
"bytes"
"fmt"
"io"
"os"
"os/exec"
"strings"
"sync"
"time"
"github.com/GoogleCloudPlatform/buildpacks/pkg/buildererror"
"golang.org/x/sys/unix"
)
var (
divider = strings.Repeat("-", 80)
)
// ExecResult bundles exec results.
type ExecResult struct {
ExitCode int
Stdout string
Stderr string
Combined string
}
type execParams struct {
cmd []string
dir string
env []string
userAttribution bool
userTiming bool
messageProducer MessageProducer
logCommandOverride *bool
logOutputOverride *bool
}
// ExecOption configures Exec functions.
type ExecOption func(o *execParams)
// WithEnv sets environment variables (of the form "KEY=value").
func WithEnv(env ...string) ExecOption {
return func(o *execParams) {
o.env = append(o.env, env...)
}
}
// WithWorkDir sets a specific working directory.
func WithWorkDir(dir string) ExecOption {
return func(o *execParams) {
o.dir = dir
}
}
// WithUserAttribution indicates that failure and timing both are attributed to the user.
var WithUserAttribution = func(o *execParams) {
o.userAttribution = true
o.userTiming = true
}
// WithUserTimingAttribution indicates that only timing is attributed to the user.
var WithUserTimingAttribution = func(o *execParams) {
o.userTiming = true
}
// WithMessageProducer sets a custom MessageProducer to produce the error message.
func WithMessageProducer(mp MessageProducer) ExecOption {
return func(o *execParams) {
o.messageProducer = mp
}
}
// WithLogCommand logs or silences the shell command itself from being printed. This takes
// precedence over any other options.
func WithLogCommand(shouldLog bool) ExecOption {
return func(o *execParams) {
o.logCommandOverride = &shouldLog
}
}
// WithLogOutput logs or silences the shell command's output from being printed. This takes
// precedence over any other options.
func WithLogOutput(shouldLog bool) ExecOption {
return func(o *execParams) {
o.logOutputOverride = &shouldLog
}
}
// WithCombinedTail keeps the tail of the combined stdout/stderr for the error message.
var WithCombinedTail = WithMessageProducer(KeepCombinedTail)
// WithCombinedHead keeps the head of the combined stdout/stderr for the error message.
var WithCombinedHead = WithMessageProducer(KeepCombinedHead)
// WithStderrTail keeps the tail of stderr for the error message.
var WithStderrTail = WithMessageProducer(KeepStderrTail)
// WithStderrHead keeps the head of stderr for the error message.
var WithStderrHead = WithMessageProducer(KeepStderrHead)
// WithStdoutTail keeps the tail of stdout for the error message.
var WithStdoutTail = WithMessageProducer(KeepStdoutTail)
// WithStdoutHead keeps the head of stdout for the error message.
var WithStdoutHead = WithMessageProducer(KeepStdoutHead)
// Exec runs the given command (with args) under the default configuration, allowing the caller to handle the error.
func (ctx *Context) Exec(cmd []string, opts ...ExecOption) (*ExecResult, error) {
params := execParams{cmd: cmd, messageProducer: KeepCombinedTail}
for _, o := range opts {
o(¶ms)
}
start := time.Now()
result, err := ctx.configuredExec(params)
if params.userTiming {
ctx.stats.user += time.Since(start)
}
if err == nil {
return result, nil
}
message := err.Error()
if result != nil {
message = params.messageProducer(result)
}
var be *buildererror.Error
if params.userAttribution {
be = UserErrorf(message)
} else {
be = buildererror.Errorf(buildererror.StatusInternal, message)
}
be.ID = buildererror.GenerateErrorID(params.cmd...)
return result, be
}
func (ctx *Context) configuredExec(params execParams) (*ExecResult, error) {
if len(params.cmd) < 1 {
return nil, fmt.Errorf("no command provided")
}
if params.cmd[0] == "" {
return nil, fmt.Errorf("empty command provided")
}
defaultShouldLog := true
if !params.userAttribution && !ctx.debug {
// For "system" commands, we will only log if the debug flag is present.
defaultShouldLog = false
}
readableCmd := strings.Join(params.cmd, " ")
if len(params.env) > 0 {
env := strings.Join(params.env, " ")
readableCmd = fmt.Sprintf("%s (%s)", readableCmd, env)
}
logCmd := defaultShouldLog
if params.logCommandOverride != nil {
logCmd = *params.logCommandOverride
}
if logCmd {
ctx.Logf(divider)
ctx.Logf("Running %q", readableCmd)
}
status := buildererror.StatusInternal
defer func(start time.Time) {
truncated := readableCmd
if len(truncated) > 60 {
truncated = truncated[:60] + "..."
}
if logCmd {
ctx.Logf("Done %q (%v)", truncated, time.Since(start))
}
ctx.Span(ctx.createSpanName(params.cmd), start, status)
}(time.Now())
exitCode := 0
ecmd := ctx.execCmd(params.cmd[0], params.cmd[1:]...)
if params.dir != "" {
ecmd.Dir = params.dir
}
if len(params.env) > 0 {
ecmd.Env = append(append(ecmd.Env, os.Environ()...), params.env...)
}
logOutput := defaultShouldLog
if params.logOutputOverride != nil {
logOutput = *params.logOutputOverride
}
var outb, errb bytes.Buffer
combinedb := lockingBuffer{ctx: ctx, log: logOutput}
ecmd.Stdout = io.MultiWriter(&outb, &combinedb)
ecmd.Stderr = io.MultiWriter(&errb, &combinedb)
if err := ecmd.Run(); err != nil {
if ee, ok := err.(*exec.ExitError); ok {
// The command returned a non-zero result.
exitCode = ee.ExitCode()
} else if pe, ok := err.(*os.PathError); ok && pe.Err == unix.ENOENT {
// ENOENT normally occurs if the command cannot
// be found, but also occurs with scripts using
// CR-LF line endings. Unix uses LF as its line
// ending, so a script with a shebang using CR-LF
// will result in the kernel attempting to
// resolve an executable name with the trailing
// CR. This search will almost certainly fail and
// otherwise results in an confusing ENOENT.
return nil, fmt.Errorf("executing command %q: %v: if %q is a script, ensure that it has Unix-style LF line endings", readableCmd, err, params.cmd[0])
} else {
return nil, fmt.Errorf("executing command %q: %v", readableCmd, err)
}
}
result := &ExecResult{
ExitCode: exitCode,
Stdout: strings.TrimSpace(string(outb.Bytes())),
Stderr: strings.TrimSpace(string(errb.Bytes())),
Combined: strings.TrimSpace(string(combinedb.Bytes())),
}
if exitCode != 0 {
return result, fmt.Errorf("executing command %q: exit code %d", readableCmd, exitCode)
}
status = buildererror.StatusOk
return result, nil
}
type lockingBuffer struct {
buf bytes.Buffer
sync.Mutex
// log tells the buffer to also log the output to stderr.
log bool
ctx *Context
}
func (lb *lockingBuffer) Write(p []byte) (int, error) {
lb.Lock()
defer lb.Unlock()
if lb.log {
lb.ctx.Logf(string(p))
}
return lb.buf.Write(p)
}
func (lb *lockingBuffer) Bytes() []byte {
return lb.buf.Bytes()
}