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 }