blob: 09431cc65fe8e84f37833ab1d4bda0ca9a87e925 [file] [log] [blame]
// Copyright 2019 The LUCI Authors.
//
// 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 cmdrunner
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
"go.chromium.org/luci/client/archiver"
"go.chromium.org/luci/client/isolated"
"go.chromium.org/luci/common/errors"
commonisolated "go.chromium.org/luci/common/isolated"
"go.chromium.org/luci/common/isolatedclient"
"go.chromium.org/luci/common/logging"
"go.chromium.org/luci/common/system/environ"
"go.chromium.org/luci/common/system/exec2"
"go.chromium.org/luci/common/system/filesystem"
)
const (
executableSuffixParameter = "${EXECUTABLE_SUFFIX}"
isolatedOutdirParameter = "${ISOLATED_OUTDIR}"
swarmingBotFileParameter = "${SWARMING_BOT_FILE}"
)
// ErrHardTimeout is error for timeout from Run command.
var ErrHardTimeout = errors.Reason("timeout happens").Err()
// replaceParameters replaces parameter tokens with appropriate values in a
// string.
func replaceParameters(ctx context.Context, arg, outDir, botFile string) (string, error) {
if runtime.GOOS == "windows" {
arg = strings.Replace(arg, executableSuffixParameter, ".exe", -1)
} else {
arg = strings.Replace(arg, executableSuffixParameter, "", -1)
}
replaceSlash := false
if strings.Contains(arg, isolatedOutdirParameter) {
if outDir == "" {
return "", errors.Reason("output directory is requested in command or env var, but not provided; please specify one").Err()
}
arg = strings.Replace(arg, isolatedOutdirParameter, outDir, -1)
replaceSlash = true
}
if strings.Contains(arg, swarmingBotFileParameter) {
if botFile != "" {
arg = strings.Replace(arg, swarmingBotFileParameter, botFile, -1)
replaceSlash = true
} else {
logging.Warningf(ctx, "swarmingBotFileParameter found in command or env var, but no bot_file specified. Leaving parameter unchanged.")
}
}
if replaceSlash {
arg = strings.Replace(arg, "/", string(filepath.Separator), -1)
}
return arg, nil
}
// processCommand replaces parameters in a command line.
func processCommand(ctx context.Context, command []string, outDir, botFile string) ([]string, error) {
newCommand := make([]string, 0, len(command))
for _, arg := range command {
newArg, err := replaceParameters(ctx, arg, outDir, botFile)
if err != nil {
return nil, fmt.Errorf("failed to replace parameter %s: %v", arg, err)
}
newCommand = append(newCommand, newArg)
}
return newCommand, nil
}
type cipdInfo struct {
binaryPath string
cacheDir string
}
// environSystem is used for mocking in test.
var environSystem = environ.System
// getCommandEnv returns full OS environment to run a command in.
// Sets up TEMP, puts directory with cipd binary in front of PATH, exposes
// CIPD_CACHE_DIR env var, and installs all env_prefixes.
func getCommandEnv(ctx context.Context, tmpDir string, cipdInfo *cipdInfo, runDir string, env environ.Env, envPrefixes map[string][]string, outDir, botFile string) (environ.Env, error) {
out := environSystem()
err := env.Iter(func(k, v string) error {
if v == "" {
out.Remove(k)
return nil
}
p, err := replaceParameters(ctx, v, outDir, botFile)
if err != nil {
return fmt.Errorf("failed to call replaceParameters for %s: %v", v, err)
}
out.Set(k, p)
return nil
})
if err != nil {
return nil, err
}
if cipdInfo != nil {
binDir := filepath.Dir(cipdInfo.binaryPath)
out.Set("PATH", binDir+string(filepath.ListSeparator)+out.GetEmpty("PATH"))
out.Set("CIPD_CACHE_DIR", cipdInfo.cacheDir)
}
for key, paths := range envPrefixes {
newPaths := make([]string, 0, len(paths))
for _, p := range paths {
newPaths = append(newPaths, filepath.Clean(filepath.Join(runDir, p)))
}
if cur, ok := out.Get(key); ok {
newPaths = append(newPaths, cur)
}
out.Set(key, strings.Join(newPaths, string(filepath.ListSeparator)))
}
// * python respects $TMPDIR, $TEMP, and $TMP in this order, regardless of
// platform. So $TMPDIR must be set on all platforms.
// https://github.com/python/cpython/blob/2.7/Lib/tempfile.py#L155
out.Set("TMPDIR", tmpDir)
if runtime.GOOS == "windows" {
// * chromium's base utils uses GetTempPath().
// https://cs.chromium.org/chromium/src/base/files/file_util_win.cc?q=GetTempPath
// * Go uses GetTempPath().
// * GetTempDir() uses %TMP%, then %TEMP%, then other stuff. So %TMP% must be
// set.
// https://docs.microsoft.com/en-us/windows/desktop/api/fileapi/nf-fileapi-gettemppathw
out.Set("TMP", tmpDir)
// https://blogs.msdn.microsoft.com/oldnewthing/20150417-00/?p=44213
out.Set("TEMP", tmpDir)
} else if runtime.GOOS == "darwin" {
// * Chromium uses an hack on macOS before calling into
// NSTemporaryDirectory().
// https://cs.chromium.org/chromium/src/base/files/file_util_mac.mm?q=GetTempDir
// https://developer.apple.com/documentation/foundation/1409211-nstemporarydirectory
out.Set("MAC_CHROMIUM_TMPDIR", tmpDir)
} else {
// TMPDIR is specified as the POSIX standard envvar for the temp directory.
// http://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap08.html
// * mktemp on linux respects $TMPDIR.
// * Chromium respects $TMPDIR on linux.
// https://cs.chromium.org/chromium/src/base/files/file_util_posix.cc?q=GetTempDir
// * Go uses $TMPDIR.
// https://go.googlesource.com/go/+/go1.10.3/src/os/file_unix.go#307
}
return out, nil
}
// Run runs the command.
func Run(ctx context.Context, command []string, cwd string, env environ.Env, hardTimeout time.Duration, gracePeriod time.Duration, lowerPriority bool, containment bool) (int, error) {
logging.Infof(ctx, "runCommand(%s, %s, %s, %s, %s, %s, %s)", command, cwd, env, hardTimeout, gracePeriod, lowerPriority, containment)
cmd := exec2.CommandContext(ctx, command[0], command[1:]...)
cmd.Env = env.Sorted()
cmd.Dir = cwd
// TODO(tikuta): handle STOP_SIGNALS
if err := cmd.Start(); err != nil {
fmt.Fprintf(os.Stderr, "failed to run command %s: %v\n", command, err)
fmt.Fprint(os.Stderr, `<The executable does not exist or a dependent library is missing>
<Check for missing .so/.dll in the .isolate or GN file>
`)
if _, ok := environ.System().Get("SWARMING_TASK_ID"); ok {
fmt.Fprint(os.Stderr, `<See the task's page for commands to help diagnose this issue by reproducing the task locally>
`)
}
return 1, errors.Annotate(err, "failed to start command").Err()
}
errCh := make(chan error, 1)
go func() {
err := cmd.Wait()
if e, ok := err.(*exec.ExitError); ok && e.Exited() {
// Ignore exited error.
err = nil
} else {
cmd.Kill()
}
errCh <- err
}()
select {
case err := <-errCh:
if err != nil {
return 1, err
}
return cmd.ProcessState.ExitCode(), nil
case <-time.After(hardTimeout):
}
if err := cmd.Terminate(); err != nil {
logging.Warningf(ctx, "failed to call Terminate: %v", err)
}
select {
case err := <-errCh:
if err != nil {
return 1, err
}
// Process exited fast enough after a nudge. Happy path.
return cmd.ProcessState.ExitCode(), ErrHardTimeout
case <-time.After(gracePeriod):
}
// Process didn't exit in time after a nudge, try to kill it.
cmd.Kill()
return 1, ErrHardTimeout
}
func linkOutputsToOutdir(runDir, outDir string, outputs []string) error {
if err := filesystem.CreateDirectories(outDir, outputs); err != nil {
return errors.Annotate(err, "failed to create directory").Err()
}
for _, output := range outputs {
src := filepath.Join(runDir, output)
dst := filepath.Join(outDir, output)
if err := filesystem.HardlinkRecursively(src, dst); err != nil {
return errors.Annotate(err, "failed to copy output from %s to %s", src, dst).Err()
}
}
return nil
}
// UploadStats is stats of upload or uploadThenDelete.
type UploadStats struct {
Duration time.Duration `json:"duration"`
ItemsCold []byte `json:"items_cold"`
ItemsHot []byte `json:"items_hot"`
}
func upload(ctx context.Context, client *isolatedclient.Client, baseDir, outDir string) (commonisolated.HexDigest, *UploadStats, error) {
start := time.Now()
absOutDir := filepath.Join(baseDir, outDir)
isEmpty, err := filesystem.IsEmptyDir(absOutDir)
if err != nil {
return "", nil, errors.Annotate(err, "failed to call IsEmptyDir(%s)", absOutDir).Err()
}
var stats UploadStats
var digest commonisolated.HexDigest
if !isEmpty {
arch := archiver.New(ctx, client, nil)
defer arch.Close() // Ignore "was already closed" error here.
items, err := isolated.ArchiveFiles(ctx, arch, baseDir, []string{outDir}, nil)
if err != nil {
return "", nil, errors.Annotate(err, "failed to upload files in %s", absOutDir).Err()
}
outDirItem := items[0]
outDirItem.WaitForHashed()
if err := outDirItem.Error(); err != nil {
return "", nil, errors.Annotate(err, "failed to upload isolated for %s", absOutDir).Err()
}
if err := arch.Close(); err != nil {
return "", nil, errors.Annotate(err, "failed to Close archiver").Err()
}
itemsHot, err := arch.Stats().PackedHits()
if err != nil {
return "", nil, errors.Annotate(err, "failed to call PackedHits").Err()
}
itemsCold, err := arch.Stats().PackedMisses()
if err != nil {
return "", nil, errors.Annotate(err, "failed to call PackedMisses").Err()
}
stats.ItemsHot = itemsHot
stats.ItemsCold = itemsCold
digest = outDirItem.Digest()
}
stats.Duration = time.Now().Sub(start)
return digest, &stats, nil
}
func uploadThenDelete(ctx context.Context, client *isolatedclient.Client, baseDir, outDir string) (digest commonisolated.HexDigest, stats *UploadStats, err error) {
start := time.Now()
defer func() {
absOutDir := filepath.Join(baseDir, outDir)
removeErr := filesystem.RemoveAll(absOutDir)
if err == nil && removeErr != nil {
err = errors.Annotate(removeErr, "failed to call RemoveAll(%s)", absOutDir).Err()
}
stats.Duration = time.Now().Sub(start)
}()
digest, stats, err = upload(ctx, client, baseDir, outDir)
if err != nil {
return "", nil, errors.Annotate(err, "failed to call upload").Err()
}
return digest, stats, nil
}
func changeTreeReadOnly(ctx context.Context, rootDir string, readOnly commonisolated.ReadOnlyValue) error {
switch readOnly {
case commonisolated.Writable:
if err := filesystem.MakeTreeWritable(ctx, rootDir); err != nil {
return errors.Annotate(err, "failed to call MakeTreeWritable(ctx, %s)", rootDir).Err()
}
return nil
case commonisolated.FilesReadOnly:
if err := filesystem.MakeTreeFilesReadOnly(ctx, rootDir); err != nil {
return errors.Annotate(err, "failed to call MakeTreeFilesReadOnly(ctx, %s)", rootDir).Err()
}
return nil
case commonisolated.DirsReadOnly:
if err := filesystem.MakeTreeReadOnly(ctx, rootDir); err != nil {
return errors.Annotate(err, "failed to call MakeTreeReadOnly(ctx, %s)", rootDir).Err()
}
return nil
}
return errors.Reason("unknown flag for changeTreeReadOnly(ctx, %s, %v)", rootDir, readOnly).Err()
}