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  }