github.com/ouraigua/jenkins-library@v0.0.0-20231028010029-fbeaf2f3aa9b/integration/docker_test_executor.go (about) 1 //go:build integration 2 // +build integration 3 4 package main 5 6 import ( 7 "archive/tar" 8 "bufio" 9 "bytes" 10 "fmt" 11 "io" 12 "math/rand" 13 "os" 14 "path" 15 "strconv" 16 "strings" 17 "testing" 18 "time" 19 20 "github.com/magiconair/properties/assert" 21 "github.com/pkg/errors" 22 23 "github.com/SAP/jenkins-library/pkg/command" 24 "github.com/SAP/jenkins-library/pkg/log" 25 ) 26 27 // The functions in this file provide a convenient way to integration test the piper binary in docker containers. 28 // It follows the "given, when, then" approach for structuring tests. 29 // The general concept is that per test one container is started, one piper command is run and outcomes are asserted. 30 // Please note that so far this was only tested with debian/ubuntu based containers. 31 // 32 // Non-exhaustive list of assumptions those functions make: 33 // - the following commands are available in the container: sh, chown, sleep 34 // - If the option TestDir is not provided, the test project must be in the container image in the directory /project 35 36 // IntegrationTestDockerExecRunnerBundle is used to construct an instance of IntegrationTestDockerExecRunner 37 // This is what a test uses to specify the container it requires 38 type IntegrationTestDockerExecRunnerBundle struct { 39 Image string 40 User string 41 TestDir []string 42 Mounts map[string]string 43 Environment map[string]string 44 Setup []string 45 Network string 46 ExecNoLogin bool 47 } 48 49 // IntegrationTestDockerExecRunner keeps the state of an instance of a docker runner 50 type IntegrationTestDockerExecRunner struct { 51 // Runner is the ExecRunner to which all executions are forwarded in the end. 52 Runner command.Command 53 Image string 54 User string 55 TestDir []string 56 Mounts map[string]string 57 Environment map[string]string 58 Setup []string 59 Network string 60 ContainerName string 61 ExecNoLogin bool 62 } 63 64 func givenThisContainer(t *testing.T, bundle IntegrationTestDockerExecRunnerBundle) IntegrationTestDockerExecRunner { 65 66 runner := command.Command{} 67 containerName := generateContainerName() 68 69 testRunner := IntegrationTestDockerExecRunner{ 70 Runner: runner, 71 Image: bundle.Image, 72 User: bundle.User, 73 Mounts: bundle.Mounts, 74 Environment: bundle.Environment, 75 Setup: bundle.Setup, 76 Network: bundle.Network, 77 ExecNoLogin: bundle.ExecNoLogin, 78 ContainerName: containerName, 79 } 80 81 wd, _ := os.Getwd() 82 localPiper := path.Join(wd, "..", "piper") 83 if localPiper == "" { 84 t.Fatal("Could not locate piper binary to test") 85 } 86 87 params := []string{"run", "--detach", "-v", localPiper + ":/piper", "--name=" + testRunner.ContainerName} 88 if testRunner.User != "" { 89 params = append(params, fmt.Sprintf("--user=%s", testRunner.User)) 90 } 91 if len(bundle.TestDir) > 0 { 92 projectDir := path.Join(wd, path.Join(bundle.TestDir...)) 93 // 1. Copy test files to a temp dir in order to avoid non-repeatable test executions because of changed state 94 // 2. Don't remove the temp dir to allow investigation of failed tests. Maybe add an option for cleaning it later? 95 tempDir, err := os.MkdirTemp("", "piper-integration-test") 96 if err != nil { 97 t.Fatal(err) 98 } 99 100 err = copyDir(projectDir, tempDir) 101 if err != nil { 102 t.Fatalf("Failed to copy files from %s into %s", projectDir, tempDir) 103 } 104 params = append(params, "-v", fmt.Sprintf("%s:/project", tempDir)) 105 } 106 107 if len(testRunner.Environment) > 0 { 108 for envVarName, envVarValue := range testRunner.Environment { 109 params = append(params, "--env", fmt.Sprintf("%s=%s", envVarName, envVarValue)) 110 } 111 } 112 113 if testRunner.Mounts != nil { 114 wd, _ := os.Getwd() 115 for src, dst := range testRunner.Mounts { 116 localSrc := path.Join(wd, src) 117 params = append(params, "-v", fmt.Sprintf("%s:%s", localSrc, dst)) 118 } 119 } 120 121 if testRunner.Network != "" { 122 params = append(params, "--network", testRunner.Network) 123 } 124 params = append(params, testRunner.Image, "sleep", "2000") 125 126 err := testRunner.Runner.RunExecutable("docker", params...) 127 if err != nil { 128 t.Fatalf("Starting test container has failed %s", err) 129 } 130 131 if len(bundle.TestDir) > 0 && testRunner.User != "" { 132 err = testRunner.Runner.RunExecutable("docker", "exec", "-u=root", testRunner.ContainerName, "chown", "-R", testRunner.User, "/project") 133 if err != nil { 134 t.Fatalf("Chown /project has failed %s", err) 135 } 136 } 137 138 if err = testRunner.Runner.RunExecutable( 139 "docker", "exec", testRunner.ContainerName, "sh", "-c", 140 strings.Join(testRunner.Setup, "\n"), 141 ); err != nil { 142 t.Fatalf("Running setup script in test container has failed %s", err) 143 } 144 145 setupPiperBinary(t, testRunner, localPiper) 146 147 return testRunner 148 } 149 150 // generateContainerName creates a name with a common prefix and a random number, so we can start a new container for each test method 151 // We don't rely on docker's random name generator for two reasons 152 // First, it is easier to save the name here compared to getting it from stdout 153 // Second, the common prefix allows batch stopping/deleting of containers if so desired 154 // The test code will not automatically delete containers as they might be useful for debugging 155 func generateContainerName() string { 156 var seededRand = rand.New(rand.NewSource(time.Now().UnixNano())) 157 return "piper-integration-test-" + strconv.Itoa(seededRand.Int()) 158 } 159 160 // setupPiperBinary copies a wrapper script for calling the piper binary into the container and verifies that the piper binary is executable inside the container 161 // The wrapper script (piper-command-wrapper.sh) only calls the piper binary and redirects its output into a file 162 // The purpose of this is to capture piper's stdout/stderr in order to assert on the output 163 // This is not possible via "docker logs", cf https://github.com/moby/moby/issues/8662 164 func setupPiperBinary(t *testing.T, testRunner IntegrationTestDockerExecRunner, localPiper string) { 165 err := testRunner.Runner.RunExecutable("docker", "cp", "piper-command-wrapper.sh", testRunner.ContainerName+":/piper-wrapper") 166 if err != nil { 167 t.Fatalf("Copying command wrapper to container has failed %s", err) 168 } 169 err = testRunner.Runner.RunExecutable("docker", "exec", "-u=root", testRunner.ContainerName, "chmod", "+x", "/piper-wrapper") 170 if err != nil { 171 t.Fatalf("Making command wrapper in container executable has failed %s", err) 172 } 173 err = testRunner.Runner.RunExecutable("docker", "exec", testRunner.ContainerName, "/bin/sh", "/piper-wrapper", "/piper", "version") 174 if err != nil { 175 t.Fatalf("Running piper failed. "+ 176 "Please check that '%s' is the correct binary, and is compiled for this configuration: 'GOOS=linux GOARCH=amd64'. Error text: %s", localPiper, err) 177 } 178 } 179 180 func (d *IntegrationTestDockerExecRunner) whenRunningPiperCommand(command string, parameters ...string) error { 181 args := []string{"exec", "--workdir", "/project", d.ContainerName, "/bin/sh"} 182 183 if !d.ExecNoLogin { 184 args = append(args, "-l") 185 } 186 187 args = append(args, "/piper-wrapper", "/piper", command) 188 err := d.Runner.RunExecutable("docker", append(args, parameters...)...) 189 if err != nil { 190 stdOut, outputErr := d.getPiperOutput() 191 if outputErr != nil { 192 return errors.Wrap(outputErr, "unable to get output after Piper command failure") 193 } 194 return errors.Wrapf(err, "piper output: \n%s", stdOut.String()) 195 } 196 return err 197 } 198 199 func (d *IntegrationTestDockerExecRunner) runScriptInsideContainer(script string) error { 200 args := []string{"exec", "--workdir", "/project", d.ContainerName, "/bin/sh"} 201 202 if !d.ExecNoLogin { 203 args = append(args, "-l") 204 } 205 206 args = append(args, "-c", script) 207 return d.Runner.RunExecutable("docker", args...) 208 } 209 210 func (d *IntegrationTestDockerExecRunner) assertHasNoOutput(t *testing.T, inconsistencies ...string) { 211 count := len(inconsistencies) 212 buffer, err := d.getPiperOutput() 213 if err != nil { 214 t.Fatalf("Failed to get log output of container %s", d.ContainerName) 215 } 216 scanner := bufio.NewScanner(buffer) 217 for scanner.Scan() && (len(inconsistencies) != 0) { 218 for i, str := range inconsistencies { 219 if strings.Contains(scanner.Text(), str) { 220 inconsistencies = append(inconsistencies[:i], inconsistencies[i+1:]...) 221 break 222 } 223 } 224 } 225 assert.Equal(t, len(inconsistencies), count, fmt.Sprintf( 226 "[assertHasNoOutput] Unexpected command output:\n%s\n%s\n", buffer.String(), strings.Join(inconsistencies, "\n")), 227 ) 228 } 229 230 func (d *IntegrationTestDockerExecRunner) assertHasOutput(t *testing.T, consistencies ...string) { 231 buffer, err := d.getPiperOutput() 232 if err != nil { 233 t.Fatalf("Failed to get log output of container %s", d.ContainerName) 234 } 235 scanner := bufio.NewScanner(buffer) 236 for scanner.Scan() && (len(consistencies) != 0) { 237 for i, str := range consistencies { 238 if strings.Contains(scanner.Text(), str) { 239 consistencies = append(consistencies[:i], consistencies[i+1:]...) 240 break 241 } 242 } 243 } 244 assert.Equal(t, len(consistencies), 0, fmt.Sprintf( 245 "[assertHasOutput] Unexpected command output:\n%s\n%s\n", buffer.String(), strings.Join(consistencies, "\n")), 246 ) 247 } 248 249 func (d *IntegrationTestDockerExecRunner) getPiperOutput() (*bytes.Buffer, error) { 250 buffer := new(bytes.Buffer) 251 d.Runner.Stdout(buffer) 252 err := d.Runner.RunExecutable("docker", "exec", d.ContainerName, "cat", "/tmp/test-log.txt") 253 d.Runner.Stdout(log.Writer()) 254 return buffer, err 255 } 256 257 func (d *IntegrationTestDockerExecRunner) assertHasFiles(t *testing.T, consistencies ...string) { 258 buffer := new(bytes.Buffer) 259 d.Runner.Stderr(buffer) 260 if d.Runner.RunExecutable( 261 "docker", 262 append(append(make([]string, 0), "exec", d.ContainerName, "stat"), consistencies...)..., 263 ) != nil { 264 t.Fatalf("[assertHasFiles] Assertion has failed: %v", errors.New(buffer.String())) 265 } 266 } 267 268 func (d *IntegrationTestDockerExecRunner) assertFileContentEquals(t *testing.T, fileWant string, contentWant string) { 269 d.assertHasFiles(t, fileWant) 270 271 buffer := new(bytes.Buffer) 272 d.Runner.Stdout(buffer) 273 err := d.Runner.RunExecutable("docker", "cp", d.ContainerName+":/"+fileWant, "-") 274 if err != nil { 275 t.Fatalf("Copy file has failed. Expected file %s to exist in container. %s", fileWant, err) 276 } 277 278 tarReader := tar.NewReader(buffer) 279 header, err := tarReader.Next() 280 if err == io.EOF { 281 t.Fatal("Empty tar received") 282 } 283 if err != nil { 284 t.Fatalf("Cant read tar: %s", err) 285 } 286 if header.Typeflag != tar.TypeReg { 287 t.Fatalf("Expected a file, but received %c", header.Typeflag) 288 } 289 str := new(bytes.Buffer) 290 _, err = io.Copy(str, tarReader) 291 if err != nil { 292 t.Fatalf("unable to get tar file content: %s", err) 293 } 294 295 assert.Equal(t, str.String(), contentWant, fmt.Sprintf("Unexpected content of file '%s'", fileWant)) 296 } 297 298 func (d *IntegrationTestDockerExecRunner) terminate(t *testing.T) { 299 err := d.Runner.RunExecutable("docker", "rm", "-f", d.ContainerName) 300 if err != nil { 301 t.Fatalf("Failed to terminate container '%s'", d.ContainerName) 302 } 303 }