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 }