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  }