github.com/quickfeed/quickfeed@v0.0.0-20240507093252-ed8ca812a09c/ci/docker.go (about)

     1  package ci
     2  
     3  import (
     4  	"archive/tar"
     5  	"bufio"
     6  	"bytes"
     7  	"context"
     8  	"encoding/json"
     9  	"errors"
    10  	"fmt"
    11  	"io"
    12  	"os"
    13  	"strings"
    14  	"time"
    15  
    16  	"github.com/docker/docker/api/types"
    17  	"github.com/docker/docker/api/types/container"
    18  	"github.com/docker/docker/api/types/mount"
    19  	"github.com/docker/docker/client"
    20  	"github.com/docker/docker/pkg/stdcopy"
    21  	"github.com/quickfeed/quickfeed/internal/multierr"
    22  	"go.uber.org/zap"
    23  )
    24  
    25  var (
    26  	DefaultContainerTimeout = time.Duration(10 * time.Minute)
    27  	QuickFeedPath           = "/quickfeed"
    28  	maxToScan               = 1_000_000 // bytes
    29  	maxLogSize              = 30_000    // bytes
    30  	lastSegmentSize         = 1_000     // bytes
    31  )
    32  
    33  // Docker is an implementation of the CI interface using Docker.
    34  type Docker struct {
    35  	client *client.Client
    36  	logger *zap.SugaredLogger
    37  }
    38  
    39  // NewDockerCI returns a runner to run CI tests.
    40  func NewDockerCI(logger *zap.SugaredLogger) (*Docker, error) {
    41  	cli, err := client.NewClientWithOpts(
    42  		client.FromEnv,
    43  		client.WithAPIVersionNegotiation(),
    44  	)
    45  	if err != nil {
    46  		return nil, err
    47  	}
    48  	return &Docker{
    49  		client: cli,
    50  		logger: logger,
    51  	}, nil
    52  }
    53  
    54  // Close ensures that the docker client is closed.
    55  func (d *Docker) Close() error {
    56  	var syncErr error
    57  	if d.logger != nil {
    58  		syncErr = d.logger.Sync()
    59  	}
    60  	closeErr := d.client.Close()
    61  	return multierr.Join(syncErr, closeErr)
    62  }
    63  
    64  // Run implements the CI interface. This method blocks until the job has been
    65  // completed or an error occurs, e.g., the context times out.
    66  func (d *Docker) Run(ctx context.Context, job *Job) (string, error) {
    67  	if d.client == nil {
    68  		return "", fmt.Errorf("cannot run job: %s; docker client not initialized", job.Name)
    69  	}
    70  
    71  	resp, err := d.createImage(ctx, job)
    72  	if err != nil {
    73  		return "", err
    74  	}
    75  	d.logger.Infof("Created container image '%s' for %s", job.Image, job.Name)
    76  	if err = d.client.ContainerStart(ctx, resp.ID, container.StartOptions{}); err != nil {
    77  		return "", err
    78  	}
    79  
    80  	d.logger.Infof("Waiting for container image '%s' for %s", job.Image, job.Name)
    81  	msg, err := d.waitForContainer(ctx, job, resp.ID)
    82  	if err != nil {
    83  		return msg, err
    84  	}
    85  
    86  	d.logger.Infof("Done waiting for container image '%s' for %s", job.Image, job.Name)
    87  	// extract the logs before removing the container below
    88  	logReader, err := d.client.ContainerLogs(ctx, resp.ID, container.LogsOptions{
    89  		ShowStdout: true,
    90  	})
    91  	if err != nil {
    92  		return "", err
    93  	}
    94  
    95  	d.logger.Infof("Removing container image '%s' for %s", job.Image, job.Name)
    96  	// remove the container when finished to prevent too many open files
    97  	err = d.client.ContainerRemove(ctx, resp.ID, container.RemoveOptions{})
    98  	if err != nil {
    99  		return "", err
   100  	}
   101  
   102  	var stdout bytes.Buffer
   103  	if _, err := stdcopy.StdCopy(&stdout, io.Discard, logReader); err != nil {
   104  		return "", err
   105  	}
   106  	if stdout.Len() > maxLogSize+lastSegmentSize {
   107  		return truncateLog(&stdout, maxLogSize, lastSegmentSize, maxToScan), nil
   108  	}
   109  	return stdout.String(), nil
   110  }
   111  
   112  // createImage creates an image for the given job.
   113  func (d *Docker) createImage(ctx context.Context, job *Job) (*container.CreateResponse, error) {
   114  	if job.Image == "" {
   115  		// image name should be specified in a run.sh file in the tests repository
   116  		return nil, fmt.Errorf("no image name specified for '%s'", job.Name)
   117  	}
   118  	if job.Dockerfile != "" {
   119  		d.logger.Infof("Removing image '%s' for '%s' prior to rebuild", job.Image, job.Name)
   120  		resp, err := d.client.ImageRemove(ctx, job.Image, types.ImageRemoveOptions{Force: true})
   121  		if err != nil {
   122  			d.logger.Debugf("Expected error (continuing): %v", err)
   123  			// continue because we may not have an image to remove
   124  		}
   125  		for _, r := range resp {
   126  			d.logger.Infof("Removed image '%s' for '%s'", r.Deleted, job.Name)
   127  		}
   128  
   129  		d.logger.Infof("Trying to build image: '%s' from Dockerfile", job.Image)
   130  		// Log first line of Dockerfile
   131  		d.logger.Infof("[%s] Dockerfile: %s ...", job.Image, job.Dockerfile[:strings.Index(job.Dockerfile, "\n")+1])
   132  		if err := d.buildImage(ctx, job); err != nil {
   133  			return nil, err
   134  		}
   135  	}
   136  
   137  	var hostConfig *container.HostConfig
   138  	if job.BindDir != "" {
   139  		hostConfig = &container.HostConfig{
   140  			Mounts: []mount.Mount{
   141  				{
   142  					Type:   mount.TypeBind,
   143  					Source: job.BindDir,
   144  					Target: QuickFeedPath,
   145  				},
   146  			},
   147  		}
   148  	}
   149  
   150  	create := func() (container.CreateResponse, error) {
   151  		return d.client.ContainerCreate(ctx, &container.Config{
   152  			Image: job.Image,
   153  			User:  fmt.Sprintf("%d:%d", os.Getuid(), os.Getgid()), // Run the image as the current user, e.g., quickfeed
   154  			Env:   job.Env,                                        // Set default environment variables
   155  			Cmd:   []string{"/bin/bash", "-c", strings.Join(job.Commands, "\n")},
   156  		}, hostConfig, nil, nil, job.Name)
   157  	}
   158  
   159  	resp, err := create()
   160  	if err != nil {
   161  		d.logger.Infof("Image '%s' not yet available for '%s': %v", job.Image, job.Name, err)
   162  		d.logger.Infof("Trying to pull image: '%s' from remote repository", job.Image)
   163  		if err := d.pullImage(ctx, job.Image); err != nil {
   164  			return nil, err
   165  		}
   166  		// try to create the container again
   167  		resp, err = create()
   168  	}
   169  	return &resp, err
   170  }
   171  
   172  // waitForContainer waits until the container stops or context times out.
   173  func (d *Docker) waitForContainer(ctx context.Context, job *Job, respID string) (string, error) {
   174  	statusCh, errCh := d.client.ContainerWait(ctx, respID, container.WaitConditionNotRunning)
   175  	select {
   176  	case err := <-errCh:
   177  		if err != nil {
   178  			d.logger.Errorf("Failed to stop container image '%s' for %s: %v", job.Image, job.Name, err)
   179  			if !errors.Is(err, context.DeadlineExceeded) {
   180  				return "", err
   181  			}
   182  			// stop runaway container whose deadline was exceeded
   183  			timeout := 1 // seconds to wait before forcefully killing the container
   184  			stopErr := d.client.ContainerStop(context.Background(), respID, container.StopOptions{Timeout: &timeout})
   185  			if stopErr != nil {
   186  				return "", stopErr
   187  			}
   188  			// remove the docker container (when stopped due to timeout) to prevent too many open files
   189  			rmErr := d.client.ContainerRemove(context.Background(), respID, container.RemoveOptions{})
   190  			if rmErr != nil {
   191  				return "", rmErr
   192  			}
   193  			// return message to user to be shown in the results log
   194  			return "Container timeout. Please check for infinite loops or other slowness.", err
   195  		}
   196  	case status := <-statusCh:
   197  		d.logger.Infof("Container: '%s' for %s: exited with status: %v", job.Image, job.Name, status.StatusCode)
   198  	}
   199  	return "", nil
   200  }
   201  
   202  // pullImage pulls an image from docker hub.
   203  // This can be slow and should be avoided if possible.
   204  func (d *Docker) pullImage(ctx context.Context, image string) error {
   205  	progress, err := d.client.ImagePull(ctx, image, types.ImagePullOptions{})
   206  	if err != nil {
   207  		return err
   208  	}
   209  	defer progress.Close()
   210  
   211  	_, err = io.Copy(io.Discard, progress)
   212  	return err
   213  }
   214  
   215  // buildImage builds and installs an image locally to be reused in a future run.
   216  func (d *Docker) buildImage(ctx context.Context, job *Job) error {
   217  	dockerFileContents := []byte(job.Dockerfile)
   218  	header := &tar.Header{
   219  		Name:     "Dockerfile",
   220  		Mode:     0o777,
   221  		Size:     int64(len(dockerFileContents)),
   222  		Typeflag: tar.TypeReg,
   223  	}
   224  	var buf bytes.Buffer
   225  	tarWriter := tar.NewWriter(&buf)
   226  	if err := tarWriter.WriteHeader(header); err != nil {
   227  		return err
   228  	}
   229  	if _, err := tarWriter.Write(dockerFileContents); err != nil {
   230  		return err
   231  	}
   232  	if err := tarWriter.Close(); err != nil {
   233  		return err
   234  	}
   235  
   236  	reader := bytes.NewReader(buf.Bytes())
   237  	opts := types.ImageBuildOptions{
   238  		Context:    reader,
   239  		Dockerfile: "Dockerfile",
   240  		Tags:       []string{job.Image},
   241  	}
   242  	res, err := d.client.ImageBuild(ctx, reader, opts)
   243  	if err != nil {
   244  		return err
   245  	}
   246  	defer res.Body.Close()
   247  
   248  	return printInfo(d.logger, res.Body)
   249  }
   250  
   251  func printInfo(logger *zap.SugaredLogger, rd io.Reader) error {
   252  	scanner := bufio.NewScanner(rd)
   253  	for scanner.Scan() {
   254  		out := &dockerJSON{}
   255  		if err := json.Unmarshal([]byte(scanner.Text()), out); err != nil {
   256  			return err
   257  		}
   258  		if out.Error != "" {
   259  			return errors.New(out.Error)
   260  		}
   261  		logger.Info(out)
   262  	}
   263  	return scanner.Err()
   264  }
   265  
   266  type dockerJSON struct {
   267  	Status string `json:"status"`
   268  	ID     string `json:"id"`
   269  	Stream string `json:"stream"`
   270  	Error  string `json:"error"`
   271  }
   272  
   273  func (s dockerJSON) String() string {
   274  	if len(s.Status) > 0 {
   275  		return s.Status + s.ID
   276  	}
   277  	return strings.TrimSpace(s.Stream)
   278  }