github.com/quickfeed/quickfeed@v0.0.0-20240507093252-ed8ca812a09c/ci/docker_test.go (about) 1 package ci_test 2 3 import ( 4 "bytes" 5 "context" 6 "errors" 7 "fmt" 8 "os" 9 "os/exec" 10 "path/filepath" 11 "strings" 12 "syscall" 13 "testing" 14 "time" 15 16 "github.com/docker/docker/client" 17 "github.com/quickfeed/quickfeed/ci" 18 "github.com/quickfeed/quickfeed/internal/qtest" 19 "github.com/quickfeed/quickfeed/kit/sh" 20 ) 21 22 var docker bool 23 24 func init() { 25 if os.Getenv("DOCKER_TESTS") != "" { 26 docker = true 27 } 28 cli, err := client.NewClientWithOpts(client.FromEnv) 29 if err != nil { 30 docker = false 31 } 32 if _, err := cli.Ping(context.Background()); err != nil { 33 docker = false 34 } 35 } 36 37 func dockerClient(t *testing.T) (*ci.Docker, func()) { 38 t.Helper() 39 docker, err := ci.NewDockerCI(qtest.Logger(t)) 40 if err != nil { 41 t.Fatalf("Failed to set up docker client: %v", err) 42 } 43 return docker, func() { _ = docker.Close() } 44 } 45 46 // deleteDockerImages deletes the given images. 47 // Used for tests that need fresh start, e.g., for pulling or building and image. 48 func deleteDockerImages(t *testing.T, images ...string) { 49 t.Helper() 50 args := append([]string{"image", "rm", "--force"}, images...) 51 dockerOut, err := sh.OutputA("docker", args...) 52 if err != nil { 53 t.Fatal(err) 54 } 55 t.Log(string(dockerOut)) 56 } 57 58 func TestDocker(t *testing.T) { 59 if !docker { 60 t.SkipNow() 61 } 62 63 const ( 64 script = `echo -n "hello world"` 65 wantOut = "hello world" 66 image = "golang:latest" 67 ) 68 docker, closeFn := dockerClient(t) 69 defer closeFn() 70 71 out, err := docker.Run(context.Background(), &ci.Job{ 72 Name: t.Name() + "-" + qtest.RandomString(t), 73 Image: image, 74 Commands: []string{script}, 75 }) 76 if err != nil { 77 t.Fatal(err) 78 } 79 80 if out != wantOut { 81 t.Errorf("docker.Run(%#v) = %#v, want %#v", script, out, wantOut) 82 } 83 } 84 85 func TestDockerMultilineScript(t *testing.T) { 86 if !docker { 87 t.SkipNow() 88 } 89 90 cmds := []string{ 91 `echo -n "hello world\n"`, 92 `echo -n "join my world"`, 93 } 94 const ( 95 wantOut = "hello world\\njoin my world" 96 image = "golang:latest" 97 ) 98 docker, closeFn := dockerClient(t) 99 defer closeFn() 100 101 out, err := docker.Run(context.Background(), &ci.Job{ 102 Name: t.Name() + "-" + qtest.RandomString(t), 103 Image: image, 104 Commands: cmds, 105 }) 106 if err != nil { 107 t.Fatal(err) 108 } 109 110 if out != wantOut { 111 t.Errorf("docker.Run(%#v) = %#v, want %#v", cmds, out, wantOut) 112 } 113 } 114 115 // Note that this test will fail if the content of ./testdata changes. 116 func TestDockerBindDir(t *testing.T) { 117 if !docker { 118 t.SkipNow() 119 } 120 121 const ( 122 script = `ls /quickfeed` 123 wantOut = "Dockerfile\nassignments\nrun.sh\ntests\n" // content of testdata (or /quickfeed inside the container) 124 image = "golang:latest" 125 ) 126 docker, closeFn := dockerClient(t) 127 defer closeFn() 128 129 // bindDir is the ./testdata directory to map into /quickfeed. 130 bindDir, err := filepath.Abs("./testdata") 131 if err != nil { 132 t.Fatal(err) 133 } 134 out, err := docker.Run(context.Background(), &ci.Job{ 135 Name: t.Name() + "-" + qtest.RandomString(t), 136 Image: image, 137 BindDir: bindDir, 138 Commands: []string{script}, 139 }) 140 if err != nil { 141 t.Fatal(err) 142 } 143 144 if out != wantOut { 145 t.Errorf("docker.Run(%#v) = %#v, want %#v", script, out, wantOut) 146 } 147 } 148 149 func TestDockerEnvVars(t *testing.T) { 150 if !docker { 151 t.SkipNow() 152 } 153 154 envVars := []string{ 155 "TESTS=/quickfeed/tests", 156 "ASSIGNMENTS=/quickfeed/assignments", 157 "SUBMITTED=/quickfeed/submitted", 158 } 159 // check that the default environment variables are accessible from the container 160 cmds := []string{ 161 `echo $TESTS`, 162 `echo $ASSIGNMENTS`, 163 `echo $SUBMITTED`, 164 } 165 166 const ( 167 wantOut = "/quickfeed/tests\n/quickfeed/assignments\n/quickfeed/submitted\n" 168 image = "golang:latest" 169 ) 170 docker, closeFn := dockerClient(t) 171 defer closeFn() 172 173 // dir is the directory to map into /quickfeed in the docker container. 174 dir := t.TempDir() 175 if err := os.Mkdir(filepath.Join(dir, "tests"), 0o700); err != nil { 176 t.Error(err) 177 } 178 if err := os.Mkdir(filepath.Join(dir, "assignments"), 0o700); err != nil { 179 t.Error(err) 180 } 181 182 out, err := docker.Run(context.Background(), &ci.Job{ 183 Name: t.Name() + "-" + qtest.RandomString(t), 184 Image: image, 185 BindDir: dir, 186 Env: envVars, 187 Commands: cmds, 188 }) 189 if err != nil { 190 t.Fatal(err) 191 } 192 193 if out != wantOut { 194 t.Errorf("docker.Run(%#v) = %#v, want %#v", cmds, out, wantOut) 195 } 196 } 197 198 func TestDockerBuild(t *testing.T) { 199 if !docker { 200 t.SkipNow() 201 } 202 203 const ( 204 script = `echo -n "hello world"` 205 wantOut = "hello world" 206 image = "quickfeed:go" 207 image2 = "golang:latest" 208 dockerfile = `FROM golang:latest 209 RUN apt update && apt install -y git bash build-essential && rm -rf /var/lib/apt/lists/* 210 RUN wget -O- -nv https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s v1.41.1 211 WORKDIR /quickfeed` 212 ) 213 deleteDockerImages(t, image, image2) 214 215 docker, closeFn := dockerClient(t) 216 defer closeFn() 217 218 // To build an image, we need a job with both image name 219 // and Dockerfile content. 220 out, err := docker.Run(context.Background(), &ci.Job{ 221 Name: t.Name() + "-" + qtest.RandomString(t), 222 Image: image, 223 Dockerfile: dockerfile, 224 Commands: []string{script}, 225 }) 226 if err != nil { 227 t.Fatal(err) 228 } 229 230 if out != wantOut { 231 t.Errorf("docker.Run(%#v) = %#v, want %#v", script, out, wantOut) 232 } 233 } 234 235 func TestDockerBuildRebuild(t *testing.T) { 236 if !docker { 237 t.SkipNow() 238 } 239 240 const ( 241 script = `echo -n "hello world"` 242 script2 = `echo -n "hello quickfeed"` 243 wantOut = "hello world" 244 wantOut2 = "hello quickfeed" 245 image = "dat320:latest" 246 image2 = "golang:latest" 247 dockerfile = `FROM golang:latest 248 RUN apt update && apt install -y git bash build-essential && rm -rf /var/lib/apt/lists/* 249 RUN wget -O- -nv https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s v1.41.1 250 WORKDIR /quickfeed` 251 dockerfile2 = `FROM golang:latest 252 RUN apt update && apt install -y git bash build-essential && rm -rf /var/lib/apt/lists/* 253 RUN wget -O- -nv https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s v1.42.1 254 WORKDIR /quickfeed` 255 ) 256 257 docker, closeFn := dockerClient(t) 258 defer closeFn() 259 260 out, err := docker.Run(context.Background(), &ci.Job{ 261 Name: t.Name() + "-" + qtest.RandomString(t), 262 Image: image, 263 Dockerfile: dockerfile, 264 Commands: []string{script}, 265 }) 266 if err != nil { 267 t.Fatal(err) 268 } 269 if out != wantOut { 270 t.Errorf("docker.Run(%#v) = %#v, want %#v", script, out, wantOut) 271 } 272 273 out2, err := docker.Run(context.Background(), &ci.Job{ 274 Name: t.Name() + "-" + qtest.RandomString(t), 275 Image: image, 276 Dockerfile: dockerfile2, 277 Commands: []string{script2}, 278 }) 279 if err != nil { 280 t.Fatal(err) 281 } 282 if out2 != wantOut2 { 283 t.Errorf("docker.Run(%#v) = %#v, want %#v", script2, out2, wantOut2) 284 } 285 } 286 287 func TestDockerRunAsNonRoot(t *testing.T) { 288 if !docker { 289 t.SkipNow() 290 } 291 292 envVars := []string{ 293 "HOME=/quickfeed", 294 "TESTS=/quickfeed/tests", 295 "ASSIGNMENTS=/quickfeed/assignments", 296 } 297 wantOut := []string{ 298 "HOME: /quickfeed", 299 "/quickfeed/tests", 300 "/quickfeed/.cache/go-build", 301 "=== RUN TestX", 302 "x_test.go:10: hallo", 303 "--- PASS: TestX ", 304 } 305 306 const ( 307 script = `echo "HOME: $HOME" 308 echo "hello" > hello.txt 309 cd tests 310 cat << EOF > go.mod 311 module tests 312 313 go 1.19 314 EOF 315 pwd 316 go env GOCACHE 317 go test -v 318 ` 319 image = "quickfeed:go" 320 dockerfile = `FROM golang:latest 321 WORKDIR /quickfeed 322 ` 323 ) 324 325 docker, closeFn := dockerClient(t) 326 defer closeFn() 327 328 // dir is the directory to map into /quickfeed in the docker container. 329 dir := t.TempDir() 330 if err := os.Mkdir(filepath.Join(dir, "tests"), 0o700); err != nil { 331 t.Error(err) 332 } 333 if err := os.Mkdir(filepath.Join(dir, "assignments"), 0o700); err != nil { 334 t.Error(err) 335 } 336 337 xTestGo, err := os.ReadFile("testdata/tests/x_test.go") 338 if err != nil { 339 t.Fatal(err) 340 } 341 if err = os.WriteFile(filepath.Join(dir, "tests", "x_test.go"), xTestGo, 0o600); err != nil { 342 t.Fatal(err) 343 } 344 345 out, err := docker.Run(context.Background(), &ci.Job{ 346 Name: t.Name() + "-" + qtest.RandomString(t), 347 Image: image, 348 Dockerfile: dockerfile, 349 BindDir: dir, 350 Env: envVars, 351 Commands: []string{script}, 352 }) 353 if err != nil { 354 t.Fatal(err) 355 } 356 fInfo, err := os.Stat(filepath.Join(dir, "hello.txt")) 357 if err != nil { 358 t.Fatal(err) 359 } 360 stat := fInfo.Sys().(*syscall.Stat_t) 361 if int(stat.Uid) != os.Getuid() { 362 t.Errorf("hello.txt has owner %d, expected %d", stat.Uid, os.Getuid()) 363 } 364 if int(stat.Gid) != os.Getgid() { 365 t.Errorf("hello.txt has group %d, expected %d", stat.Gid, os.Getgid()) 366 } 367 368 for _, line := range wantOut { 369 if !strings.Contains(out, line) { 370 t.Errorf("Expected %q not found in output: %q", line, out) 371 } 372 } 373 374 if t.Failed() { 375 // Print output from container. 376 t.Log(out) 377 // Print output from local filesystem (non-container). 378 out2, err := sh.Output("ls -l " + dir) 379 if err != nil { 380 t.Fatal(err) 381 } 382 t.Log(out2) 383 } 384 } 385 386 func TestDockerPull(t *testing.T) { 387 if !docker { 388 t.SkipNow() 389 } 390 391 const ( 392 script = `python -c "print('Hello, world!')"` 393 wantOut = "Hello, world!\n" 394 image = "python:latest" 395 ) 396 deleteDockerImages(t, image) 397 398 docker, closeFn := dockerClient(t) 399 defer closeFn() 400 401 // To pull an image, we need only a job with image name; 402 // no Dockerfile content should be provided when pulling. 403 out, err := docker.Run(context.Background(), &ci.Job{ 404 Name: t.Name() + "-" + qtest.RandomString(t), 405 Image: image, 406 Commands: []string{script}, 407 }) 408 if err != nil { 409 t.Fatal(err) 410 } 411 412 if out != wantOut { 413 t.Errorf("docker.Run(%#v) = %#v, want %#v", script, out, wantOut) 414 } 415 } 416 417 func TestDockerPullFromNonDockerHubRepositories(t *testing.T) { 418 if !docker { 419 t.SkipNow() 420 } 421 const ( 422 script = `echo "Hello, world!"` 423 wantOut = "Hello, world!\n" 424 image = "mcr.microsoft.com/dotnet/sdk:6.0" 425 ) 426 deleteDockerImages(t, image) 427 428 docker, closeFn := dockerClient(t) 429 defer closeFn() 430 431 // To pull an image, we need only a job with image name; 432 // no Dockerfile content should be provided when pulling. 433 out, err := docker.Run(context.Background(), &ci.Job{ 434 Name: t.Name() + "-" + qtest.RandomString(t), 435 Image: image, 436 Commands: []string{script}, 437 }) 438 if err != nil { 439 t.Fatal(err) 440 } 441 442 if out != wantOut { 443 t.Errorf("docker.Run(%#v) = %#v, want %#v", script, out, wantOut) 444 } 445 } 446 447 func TestDockerTimeout(t *testing.T) { 448 if !docker { 449 t.SkipNow() 450 } 451 452 const ( 453 script = `echo -n "hello," && sleep 10` 454 wantOut = `Container timeout. Please check for infinite loops or other slowness.` 455 image = "golang:latest" 456 ) 457 458 // Note that the timeout value below is sensitive to startup time of the container. 459 // If the timeout is too short, the Run() call may not reach the ContainerWait() call. 460 // Hence, if this test fails, you may try to increase the timeout. 461 ctx, cancel := context.WithTimeout(context.Background(), 5000*time.Millisecond) 462 defer cancel() 463 464 docker, closeFn := dockerClient(t) 465 defer closeFn() 466 467 out, err := docker.Run(ctx, &ci.Job{ 468 Name: t.Name() + "-" + qtest.RandomString(t), 469 Image: image, 470 Commands: []string{script}, 471 }) 472 t.Log("Expecting ERROR line above; not test failure") 473 if out != wantOut { 474 t.Errorf("docker.Run(%#v) = %#v, want %#v", script, out, wantOut) 475 } 476 if !errors.Is(err, context.DeadlineExceeded) { 477 t.Errorf("docker.Run(%#v) = %#v, want %#v", script, err.Error(), context.DeadlineExceeded.Error()) 478 } 479 if err == nil { 480 t.Errorf("docker.Run(%#v) unexpectedly returned without error", script) 481 } 482 } 483 484 func TestDockerOpenFileDescriptors(t *testing.T) { 485 // This is mainly for debugging the 'too many open file descriptors' issue 486 if !docker { 487 t.SkipNow() 488 } 489 490 const ( 491 script = `echo -n "hello, " && sleep 2 && echo -n "world!"` 492 wantOut = "hello, world!" 493 image = "golang:latest" 494 numContainers = 5 495 ) 496 docker, closeFn := dockerClient(t) 497 defer closeFn() 498 499 errCh := make(chan error, numContainers) 500 for i := 0; i < numContainers; i++ { 501 go func(j int) { 502 name := fmt.Sprintf(t.Name()+"-%d-%s", j, qtest.RandomString(t)) 503 out, err := docker.Run(context.Background(), &ci.Job{ 504 Name: name, 505 Image: image, 506 Commands: []string{script}, 507 }) 508 if err != nil { 509 errCh <- err 510 } 511 if out != wantOut { 512 t.Errorf("docker.Run(%#v) = %#v, want %#v", script, out, wantOut) 513 } 514 errCh <- nil 515 }(i) 516 } 517 afterContainersStarted := countOpenFiles(t) 518 519 for i := 0; i < numContainers; i++ { 520 err := <-errCh 521 if err != nil { 522 t.Fatal(err) 523 } 524 } 525 close(errCh) 526 afterContainersFinished := countOpenFiles(t) 527 if afterContainersFinished > afterContainersStarted { 528 t.Errorf("finished %d > started %d", afterContainersFinished, afterContainersStarted) 529 } 530 } 531 532 func countOpenFiles(t *testing.T) int { 533 t.Helper() 534 out, err := exec.Command("/bin/sh", "-c", fmt.Sprintf("lsof -p %v", os.Getpid())).Output() 535 if err != nil { 536 t.Fatal(err) 537 } 538 return bytes.Count(out, []byte("\n")) 539 }