github.com/dnephin/dobi@v0.15.0/tasks/job/run.go (about) 1 package job 2 3 import ( 4 "bytes" 5 "fmt" 6 "io" 7 "io/ioutil" 8 "os" 9 "os/signal" 10 "strings" 11 "syscall" 12 "time" 13 14 "github.com/dnephin/dobi/config" 15 "github.com/dnephin/dobi/logging" 16 "github.com/dnephin/dobi/tasks/client" 17 "github.com/dnephin/dobi/tasks/context" 18 "github.com/dnephin/dobi/tasks/image" 19 "github.com/dnephin/dobi/tasks/mount" 20 "github.com/dnephin/dobi/tasks/task" 21 "github.com/dnephin/dobi/tasks/types" 22 "github.com/dnephin/dobi/utils/fs" 23 "github.com/docker/docker/pkg/term" 24 "github.com/docker/go-connections/nat" 25 docker "github.com/fsouza/go-dockerclient" 26 log "github.com/sirupsen/logrus" 27 ) 28 29 // DefaultUnixSocket to connect to the docker API 30 const DefaultUnixSocket = "/var/run/docker.sock" 31 32 func newRunTask(name task.Name, conf config.Resource) types.Task { 33 return &Task{name: name, config: conf.(*config.JobConfig)} 34 } 35 36 // Task is a task which runs a command in a container to produce a 37 // file or set of files. 38 type Task struct { 39 types.NoStop 40 name task.Name 41 config *config.JobConfig 42 outStream io.Writer 43 } 44 45 // Name returns the name of the task 46 func (t *Task) Name() task.Name { 47 return t.name 48 } 49 50 func (t *Task) logger() *log.Entry { 51 return logging.ForTask(t) 52 } 53 54 // Repr formats the task for logging 55 func (t *Task) Repr() string { 56 buff := &bytes.Buffer{} 57 58 if !t.config.Command.Empty() { 59 buff.WriteString(" " + t.config.Command.String()) 60 } 61 if !t.config.Command.Empty() && !t.config.Artifact.Empty() { 62 buff.WriteString(" ->") 63 } 64 if !t.config.Artifact.Empty() { 65 buff.WriteString(" " + t.config.Artifact.String()) 66 } 67 return fmt.Sprintf("%s%v", t.name.Format("job"), buff.String()) 68 } 69 70 // Run the job command in a container 71 func (t *Task) Run(ctx *context.ExecuteContext, depsModified bool) (bool, error) { 72 if !depsModified { 73 stale, err := t.isStale(ctx) 74 switch { 75 case err != nil: 76 return false, err 77 case !stale: 78 t.logger().Info("is fresh") 79 return false, nil 80 } 81 } 82 t.logger().Debug("is stale") 83 84 t.logger().Info("Start") 85 var err error 86 if ctx.Settings.BindMount { 87 err = t.runContainerWithBinds(ctx) 88 } else { 89 err = t.runWithBuildAndCopy(ctx) 90 } 91 if err != nil { 92 return false, err 93 } 94 t.logger().Info("Done") 95 return true, nil 96 } 97 98 // nolint: gocyclo 99 func (t *Task) isStale(ctx *context.ExecuteContext) (bool, error) { 100 if t.config.Artifact.Empty() { 101 return true, nil 102 } 103 104 artifactLastModified, err := t.artifactLastModified(ctx.WorkingDir) 105 if err != nil { 106 t.logger().Warnf("Failed to get artifact last modified: %s", err) 107 return true, err 108 } 109 110 if t.config.Sources.NoMatches() { 111 t.logger().Warnf("No sources found matching: %s", &t.config.Sources) 112 return true, nil 113 } 114 115 if len(t.config.Sources.Paths()) != 0 { 116 sourcesLastModified, err := fs.LastModified(&fs.LastModifiedSearch{ 117 Root: ctx.WorkingDir, 118 Paths: t.config.Sources.Paths(), 119 }) 120 if err != nil { 121 return true, err 122 } 123 if artifactLastModified.Before(sourcesLastModified) { 124 t.logger().Debug("artifact older than sources") 125 return true, nil 126 } 127 return false, nil 128 } 129 130 mountsLastModified, err := t.mountsLastModified(ctx) 131 if err != nil { 132 t.logger().Warnf("Failed to get mounts last modified: %s", err) 133 return true, err 134 } 135 136 if artifactLastModified.Before(mountsLastModified) { 137 t.logger().Debug("artifact older than mount files") 138 return true, nil 139 } 140 141 imageName := ctx.Resources.Image(t.config.Use) 142 taskImage, err := image.GetImage(ctx, imageName) 143 if err != nil { 144 return true, fmt.Errorf("failed to get image %q: %s", imageName, err) 145 } 146 if artifactLastModified.Before(taskImage.Created) { 147 t.logger().Debug("artifact older than image") 148 return true, nil 149 } 150 return false, nil 151 } 152 153 func (t *Task) artifactLastModified(workDir string) (time.Time, error) { 154 paths := t.config.Artifact.Paths() 155 // File or directory doesn't exist 156 if len(paths) == 0 { 157 return time.Time{}, nil 158 } 159 return fs.LastModified(&fs.LastModifiedSearch{Root: workDir, Paths: paths}) 160 } 161 162 // TODO: support a .mountignore file used to ignore mtime of files 163 func (t *Task) mountsLastModified(ctx *context.ExecuteContext) (time.Time, error) { 164 mountPaths := []string{} 165 ctx.Resources.EachMount(t.config.Mounts, func(name string, mount *config.MountConfig) { 166 mountPaths = append(mountPaths, mount.Bind) 167 }) 168 return fs.LastModified(&fs.LastModifiedSearch{Root: ctx.WorkingDir, Paths: mountPaths}) 169 } 170 171 func (t *Task) runContainerWithBinds(ctx *context.ExecuteContext) error { 172 name := containerName(ctx, t.name.Resource()) 173 imageName := image.GetImageName(ctx, ctx.Resources.Image(t.config.Use)) 174 options := t.createOptions(ctx, name, imageName) 175 176 defer removeContainerWithLogging(t.logger(), ctx.Client, name) 177 return t.runContainer(ctx, options) 178 } 179 180 func removeContainerWithLogging( 181 logger *log.Entry, 182 client client.DockerClient, 183 containerID string, 184 ) { 185 removed, err := removeContainer(logger, client, containerID) 186 if !removed && err == nil { 187 logger.WithFields(log.Fields{"container": containerID}).Warn( 188 "Container does not exist") 189 } 190 } 191 192 func (t *Task) runContainer( 193 ctx *context.ExecuteContext, 194 options docker.CreateContainerOptions, 195 ) error { 196 name := options.Name 197 container, err := ctx.Client.CreateContainer(options) 198 if err != nil { 199 return fmt.Errorf("failed creating container %q: %s", name, err) 200 } 201 202 chanSig := t.forwardSignals(ctx.Client, container.ID) 203 defer signal.Stop(chanSig) 204 205 closeWaiter, err := ctx.Client.AttachToContainerNonBlocking(docker.AttachToContainerOptions{ 206 Container: container.ID, 207 OutputStream: t.output(), 208 ErrorStream: os.Stderr, 209 InputStream: ioutil.NopCloser(os.Stdin), 210 Stream: true, 211 Stdin: t.config.Interactive, 212 RawTerminal: t.config.Interactive, 213 Stdout: true, 214 Stderr: true, 215 }) 216 if err != nil { 217 return fmt.Errorf("failed attaching to container %q: %s", name, err) 218 } 219 defer closeWaiter.Wait() // nolint: errcheck 220 221 if t.config.Interactive { 222 inFd, _ := term.GetFdInfo(os.Stdin) 223 state, err := term.SetRawTerminal(inFd) 224 if err != nil { 225 return err 226 } 227 defer func() { 228 if err := term.RestoreTerminal(inFd, state); err != nil { 229 t.logger().Warnf("Failed to restore fd %v: %s", inFd, err) 230 } 231 }() 232 } 233 234 if err := ctx.Client.StartContainer(container.ID, nil); err != nil { 235 return fmt.Errorf("failed starting container %q: %s", name, err) 236 } 237 238 initWindow(chanSig) 239 return t.wait(ctx.Client, container.ID) 240 } 241 242 func (t *Task) output() io.Writer { 243 if t.outStream == nil { 244 return os.Stdout 245 } 246 return io.MultiWriter(t.outStream, os.Stdout) 247 } 248 249 func (t *Task) createOptions( 250 ctx *context.ExecuteContext, 251 name string, 252 imageName string, 253 ) docker.CreateContainerOptions { 254 t.logger().Debugf("Image name %q", imageName) 255 256 interactive := t.config.Interactive 257 portBinds, exposedPorts := asPortBindings(t.config.Ports) 258 // TODO: only set Tty if running in a tty 259 opts := docker.CreateContainerOptions{ 260 Name: name, 261 Config: &docker.Config{ 262 Cmd: t.config.Command.Value(), 263 Image: imageName, 264 User: t.config.User, 265 OpenStdin: interactive, 266 Tty: interactive, 267 AttachStdin: interactive, 268 StdinOnce: interactive, 269 Labels: t.config.Labels, 270 AttachStderr: true, 271 AttachStdout: true, 272 Env: t.config.Env, 273 Entrypoint: t.config.Entrypoint.Value(), 274 WorkingDir: t.config.WorkingDir, 275 ExposedPorts: exposedPorts, 276 }, 277 HostConfig: &docker.HostConfig{ 278 Binds: getMountsForHostConfig(ctx, t.config.Mounts), 279 Privileged: t.config.Privileged, 280 NetworkMode: t.config.NetMode, 281 PortBindings: portBinds, 282 Devices: getDevices(t.config.Devices), 283 }, 284 } 285 if t.config.ProvideDocker { 286 opts = provideDocker(opts) 287 } 288 return opts 289 } 290 291 func getMountsForHostConfig(ctx *context.ExecuteContext, mounts []string) []string { 292 binds := []string{} 293 ctx.Resources.EachMount(mounts, func(name string, mountConfig *config.MountConfig) { 294 if !ctx.Settings.BindMount && mountConfig.IsBind() { 295 return 296 } 297 binds = append(binds, mount.AsBind(mountConfig, ctx.WorkingDir)) 298 }) 299 return binds 300 } 301 302 func getDevices(devices []config.Device) []docker.Device { 303 var dockerdevices []docker.Device 304 for _, dev := range devices { 305 if dev.Container == "" { 306 dev.Container = dev.Host 307 } 308 if dev.Permissions == "" { 309 dev.Permissions = "rwm" 310 } 311 dockerdevices = append(dockerdevices, 312 docker.Device{ 313 PathInContainer: dev.Container, 314 PathOnHost: dev.Host, 315 CgroupPermissions: dev.Permissions, 316 }) 317 } 318 return dockerdevices 319 } 320 321 func asPortBindings(ports []string) (map[docker.Port][]docker.PortBinding, map[docker.Port]struct{}) { // nolint: lll 322 binds := make(map[docker.Port][]docker.PortBinding) 323 exposed := make(map[docker.Port]struct{}) 324 for _, port := range ports { 325 parts := strings.SplitN(port, ":", 2) 326 proto, cport := nat.SplitProtoPort(parts[1]) 327 cport = cport + "/" + proto 328 binds[docker.Port(cport)] = []docker.PortBinding{{HostPort: parts[0]}} 329 exposed[docker.Port(cport)] = struct{}{} 330 } 331 return binds, exposed 332 } 333 334 func provideDocker(opts docker.CreateContainerOptions) docker.CreateContainerOptions { 335 if os.Getenv("DOCKER_HOST") == "" { 336 path := DefaultUnixSocket 337 opts.HostConfig.Binds = append(opts.HostConfig.Binds, path+":"+path) 338 } 339 for _, envVar := range os.Environ() { 340 if strings.HasPrefix(envVar, "DOCKER_") { 341 opts.Config.Env = append(opts.Config.Env, envVar) 342 } 343 } 344 return opts 345 } 346 347 func (t *Task) wait(client client.DockerClient, containerID string) error { 348 status, err := client.WaitContainer(containerID) 349 if err != nil { 350 return fmt.Errorf("failed to wait on container exit: %s", err) 351 } 352 if status != 0 { 353 return fmt.Errorf("exited with non-zero status code %d", status) 354 } 355 return nil 356 } 357 358 func (t *Task) forwardSignals( 359 client client.DockerClient, 360 containerID string, 361 ) chan<- os.Signal { 362 chanSig := make(chan os.Signal, 128) 363 364 signal.Notify(chanSig, syscall.SIGINT, syscall.SIGTERM, SIGWINCH) 365 366 go func() { 367 for sig := range chanSig { 368 logger := t.logger().WithField("signal", sig) 369 logger.Debug("received") 370 371 sysSignal, ok := sig.(syscall.Signal) 372 if !ok { 373 logger.Warnf("Failed to convert signal from %T", sig) 374 return 375 } 376 377 switch sysSignal { 378 case SIGWINCH: 379 handleWinSizeChangeSignal(logger, client, containerID) 380 default: 381 handleShutdownSignals(logger, client, containerID, sysSignal) 382 } 383 } 384 }() 385 return chanSig 386 } 387 388 func handleWinSizeChangeSignal( 389 logger log.FieldLogger, 390 client client.DockerClient, 391 containerID string, 392 ) { 393 winsize, err := term.GetWinsize(os.Stdin.Fd()) 394 if err != nil { 395 logger.WithError(err). 396 Error("Failed to get host's TTY window size") 397 return 398 } 399 400 err = client.ResizeContainerTTY(containerID, int(winsize.Height), int(winsize.Width)) 401 if err != nil { 402 logger.WithError(err). 403 Error("Failed to set container's TTY window size") 404 } 405 } 406 407 func handleShutdownSignals( 408 logger log.FieldLogger, 409 client client.DockerClient, 410 containerID string, 411 sig syscall.Signal, 412 ) { 413 if err := client.KillContainer(docker.KillContainerOptions{ 414 ID: containerID, 415 Signal: docker.Signal(sig), 416 }); err != nil { 417 logger.WithError(err). 418 Warn("Failed to forward signal") 419 } 420 }