github.com/tiagovtristao/plz@v13.4.0+incompatible/src/test/container.go (about)

     1  //+build !bootstrap
     2  
     3  // Support for containerising tests. Currently Docker only.
     4  
     5  package test
     6  
     7  import (
     8  	"archive/tar"
     9  	"context"
    10  	"fmt"
    11  	"io"
    12  	"io/ioutil"
    13  	"os"
    14  	"path"
    15  	"strings"
    16  	"sync"
    17  	"time"
    18  
    19  	"docker.io/go-docker"
    20  	"docker.io/go-docker/api"
    21  	"docker.io/go-docker/api/types"
    22  	"docker.io/go-docker/api/types/container"
    23  	"docker.io/go-docker/api/types/mount"
    24  
    25  	"github.com/thought-machine/please/src/build"
    26  	"github.com/thought-machine/please/src/core"
    27  )
    28  
    29  var dockerClient *docker.Client
    30  var dockerClientOnce sync.Once
    31  
    32  func runContainerisedTest(tid int, state *core.BuildState, target *core.BuildTarget) (out []byte, err error) {
    33  	const testDir = "/tmp/test"
    34  	const resultsFile = testDir + "/test.results"
    35  
    36  	dockerClientOnce.Do(func() {
    37  		dockerClient, err = docker.NewClient(docker.DefaultDockerHost, api.DefaultVersion, nil, nil)
    38  		if err != nil {
    39  			log.Error("%s", err)
    40  		} else {
    41  			dockerClient.NegotiateAPIVersion(context.Background())
    42  			log.Debug("Docker client negotiated API version %s", dockerClient.ClientVersion())
    43  		}
    44  	})
    45  	if err != nil {
    46  		return nil, err
    47  	} else if dockerClient == nil {
    48  		return nil, fmt.Errorf("failed to initialise Docker client")
    49  	}
    50  
    51  	targetTestDir := path.Join(core.RepoRoot, target.TestDir())
    52  	replacedCmd := build.ReplaceTestSequences(state, target, target.GetTestCommand(state))
    53  	replacedCmd += " " + strings.Join(state.TestArgs, " ")
    54  	// Gentle hack: remove the absolute path from the command
    55  	replacedCmd = strings.Replace(replacedCmd, targetTestDir, targetTestDir, -1)
    56  
    57  	env := core.TestEnvironment(state, target, testDir)
    58  	env.Replace("RESULTS_FILE", resultsFile)
    59  	env.Replace("GTEST_OUTPUT", "xml:"+resultsFile)
    60  
    61  	config := &container.Config{
    62  		Image: state.Config.Docker.DefaultImage,
    63  		// TODO(peterebden): Do we still need LC_ALL here? It was kinda hacky before...
    64  		Env:        append(env, "LC_ALL=C.UTF-8"),
    65  		WorkingDir: testDir,
    66  		Cmd:        []string{"bash", "-uo", "pipefail", "-c", replacedCmd},
    67  		Tty:        true, // This makes it a lot easier to read later on.
    68  	}
    69  	hostConfig := &container.HostConfig{}
    70  	// Bind-mount individual files in (not a directory) to avoid ownership issues.
    71  	for out := range core.IterRuntimeFiles(state.Graph, target, false) {
    72  		hostConfig.Mounts = append(hostConfig.Mounts, mount.Mount{
    73  			Type:   mount.TypeBind,
    74  			Source: path.Join(core.RepoRoot, out.Src),
    75  			Target: path.Join(config.WorkingDir, out.Tmp),
    76  		})
    77  	}
    78  	if target.ContainerSettings != nil {
    79  		if target.ContainerSettings.DockerImage != "" {
    80  			config.Image = target.ContainerSettings.DockerImage
    81  		}
    82  		config.User = target.ContainerSettings.DockerUser
    83  		if target.ContainerSettings.Tmpfs != "" {
    84  			hostConfig.Tmpfs = map[string]string{target.ContainerSettings.Tmpfs: "exec"}
    85  		}
    86  	}
    87  	log.Debug("Running %s in container. Equivalent command: docker run -it --rm -e %s -w %s -v %s:%s -u \"%s\" %s %s",
    88  		target.Label, strings.Join(config.Env, " -e "), config.WorkingDir, targetTestDir, config.WorkingDir,
    89  		config.User, config.Image, strings.Join(config.Cmd, " "))
    90  	c, err := dockerClient.ContainerCreate(context.Background(), config, hostConfig, nil, "")
    91  	if err != nil && docker.IsErrNotFound(err) {
    92  		// Image doesn't exist, need to try to pull it.
    93  		// N.B. This is where we would authenticate if needed. Right now we are not doing anything.
    94  		state.LogBuildResult(tid, target.Label, core.TargetTesting, "Pulling image...")
    95  		r, err := dockerClient.ImagePull(context.Background(), config.Image, types.ImagePullOptions{})
    96  		if err != nil {
    97  			return nil, fmt.Errorf("Failed to pull image: %s", err)
    98  		}
    99  		defer r.Close()
   100  		// I assume we have to exhaust this Reader before continuing. The docs are not super clear on how we know at what point the pull has completed.
   101  		if _, err := io.Copy(ioutil.Discard, r); err != nil {
   102  			return nil, fmt.Errorf("Failed to pull image: %s", err)
   103  		}
   104  		state.LogBuildResult(tid, target.Label, core.TargetTesting, "Testing...")
   105  		c, err = dockerClient.ContainerCreate(context.Background(), config, hostConfig, nil, "")
   106  		if err != nil {
   107  			return nil, fmt.Errorf("Failed to create container: %s", err)
   108  		}
   109  	} else if err != nil {
   110  		return nil, fmt.Errorf("Failed to create container: %s", err)
   111  	}
   112  	for _, warning := range c.Warnings {
   113  		log.Warning("%s creating container: %s", target.Label, warning)
   114  	}
   115  	defer func() {
   116  		if err := dockerClient.ContainerStop(context.Background(), c.ID, nil); err != nil {
   117  			log.Warning("Failed to stop container for %s: %s", target.Label, err)
   118  			return // ContainerRemove will fail if it's not stopped.
   119  		}
   120  		if err := dockerClient.ContainerRemove(context.Background(), c.ID, types.ContainerRemoveOptions{
   121  			RemoveVolumes: true,
   122  			Force:         true,
   123  		}); err != nil {
   124  			log.Warning("Failed to remove container for %s: %s", target.Label, err)
   125  		}
   126  	}()
   127  	if err := dockerClient.ContainerStart(context.Background(), c.ID, types.ContainerStartOptions{}); err != nil {
   128  		return nil, fmt.Errorf("Failed to start container: %s", err)
   129  	}
   130  
   131  	timeout := target.TestTimeout
   132  	if timeout == 0 {
   133  		timeout = time.Duration(state.Config.Test.Timeout)
   134  	}
   135  	ctx, cancel := context.WithTimeout(context.Background(), timeout)
   136  	defer cancel()
   137  	waitChan, errChan := dockerClient.ContainerWait(ctx, c.ID, container.WaitConditionNotRunning)
   138  	var status int64
   139  	select {
   140  	case body := <-waitChan:
   141  		status = body.StatusCode
   142  	case err := <-errChan:
   143  		return nil, fmt.Errorf("Container failed running: %s", err)
   144  	}
   145  	// Now retrieve the results and any other files.
   146  	if !target.NoTestOutput {
   147  		retrieveFile(state, target, c.ID, resultsFile, true)
   148  	}
   149  	if state.NeedCoverage {
   150  		retrieveFile(state, target, c.ID, path.Join(testDir, "test.coverage"), false)
   151  	}
   152  	for _, output := range target.TestOutputs {
   153  		retrieveFile(state, target, c.ID, path.Join(testDir, output), false)
   154  	}
   155  	r, err := dockerClient.ContainerLogs(context.Background(), c.ID, types.ContainerLogsOptions{
   156  		ShowStdout: true,
   157  		ShowStderr: true,
   158  	})
   159  	if err != nil {
   160  		return nil, err
   161  	}
   162  	defer r.Close()
   163  	b, err := ioutil.ReadAll(r)
   164  	if err != nil {
   165  		return nil, fmt.Errorf("Error retrieving container output: %s", err)
   166  	} else if status != 0 {
   167  		return b, fmt.Errorf("Exit code %d", status)
   168  	}
   169  	return b, nil
   170  }
   171  
   172  func runPossiblyContainerisedTest(tid int, state *core.BuildState, target *core.BuildTarget) (out []byte, err error) {
   173  	if target.Containerise {
   174  		if state.Config.Test.DefaultContainer == core.ContainerImplementationNone {
   175  			log.Warning("Target %s specifies that it should be tested in a container, but test "+
   176  				"containers are disabled in your .plzconfig.", target.Label)
   177  			return runTest(state, target)
   178  		}
   179  		out, err = runContainerisedTest(tid, state, target)
   180  		if err != nil && state.Config.Docker.AllowLocalFallback {
   181  			log.Warning("Failed to run %s containerised: %s %s. Falling back to local version.",
   182  				target.Label, out, err)
   183  			return runTest(state, target)
   184  		}
   185  		return out, err
   186  	}
   187  	return runTest(state, target)
   188  }
   189  
   190  // retrieveFile retrieves a single file (or directory) from a Docker container.
   191  func retrieveFile(state *core.BuildState, target *core.BuildTarget, cid string, filename string, warn bool) {
   192  	if err := retrieveOneFile(state, target, cid, filename); err != nil {
   193  		if warn {
   194  			log.Warning("Failed to retrieve output for %s: %s", target.Label, err)
   195  		} else {
   196  			log.Debug("Failed to retrieve output for %s: %s", target.Label, err)
   197  		}
   198  	}
   199  }
   200  
   201  // retrieveOneFile retrieves a single file from a Docker container.
   202  func retrieveOneFile(state *core.BuildState, target *core.BuildTarget, cid string, filename string) error {
   203  	log.Debug("Attempting to retrieve file %s for %s...", filename, target.Label)
   204  	r, _, err := dockerClient.CopyFromContainer(context.Background(), cid, filename)
   205  	if err != nil {
   206  		return err
   207  	}
   208  	defer r.Close()
   209  	// Files come out as a tarball (this isn't documented but seems empirically true).
   210  	tr := tar.NewReader(r)
   211  	for {
   212  		hdr, err := tr.Next()
   213  		if err == io.EOF {
   214  			break // End of archive
   215  		} else if err != nil {
   216  			return err
   217  		} else if hdr.Mode&int64(os.ModeDir) != 0 || strings.HasSuffix(hdr.Name, "/") {
   218  			continue // Don't do anything specific with directories, only the files in them.
   219  		}
   220  		out := path.Join(target.TestDir(), hdr.Name)
   221  		if err := os.MkdirAll(path.Dir(out), core.DirPermissions); err != nil {
   222  			return err
   223  		}
   224  		f, err := os.Create(out)
   225  		if err != nil {
   226  			return err
   227  		}
   228  		if _, err := io.Copy(f, tr); err != nil {
   229  			return err
   230  		}
   231  	}
   232  	return nil
   233  }