github.com/dnephin/dobi@v0.15.0/tasks/job/build.go (about)

     1  package job
     2  
     3  import (
     4  	"archive/tar"
     5  	"bytes"
     6  	"fmt"
     7  	"io"
     8  	"io/ioutil"
     9  	"os"
    10  	"path/filepath"
    11  	"sort"
    12  	"strings"
    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/docker/cli/cli/command/image/build"
    20  	"github.com/docker/docker/pkg/archive"
    21  	docker "github.com/fsouza/go-dockerclient"
    22  	"github.com/pkg/errors"
    23  	log "github.com/sirupsen/logrus"
    24  )
    25  
    26  func (t *Task) runWithBuildAndCopy(ctx *context.ExecuteContext) error {
    27  	name := containerName(ctx, t.name.Resource())
    28  	imageName := fmt.Sprintf("%s:job-%s",
    29  		ctx.Resources.Image(t.config.Use).Image, name)
    30  
    31  	if err := t.buildImageWithMounts(ctx, imageName); err != nil {
    32  		return err
    33  	}
    34  	defer removeImage(t.logger(), ctx.Client, imageName)
    35  
    36  	defer removeContainerWithLogging(t.logger(), ctx.Client, name)
    37  	options := t.createOptions(ctx, name, imageName)
    38  	runErr := t.runContainer(ctx, options)
    39  	copyErr := copyFilesToHost(t.logger(), ctx, t.config, name)
    40  	if runErr != nil {
    41  		return runErr
    42  	}
    43  	return copyErr
    44  }
    45  
    46  func (t *Task) buildImageWithMounts(ctx *context.ExecuteContext, imageName string) error {
    47  	baseImage := image.GetImageName(ctx, ctx.Resources.Image(t.config.Use))
    48  	mounts := getBindMounts(ctx, t.config)
    49  
    50  	dockerfile := buildDockerfileWithCopy(baseImage, mounts)
    51  	buildContext, dockerfileName, err := buildTarContext(dockerfile, mounts)
    52  	if err != nil {
    53  		return err
    54  	}
    55  	return image.Stream(os.Stdout, func(out io.Writer) error {
    56  		opts := buildImageOptions(ctx, out)
    57  		opts.InputStream = buildContext
    58  		opts.Name = imageName
    59  		opts.Dockerfile = dockerfileName
    60  		return ctx.Client.BuildImage(opts)
    61  	})
    62  }
    63  
    64  func getBindMounts(ctx *context.ExecuteContext, cfg *config.JobConfig) []config.MountConfig {
    65  	mounts := []config.MountConfig{}
    66  	ctx.Resources.EachMount(cfg.Mounts, func(_ string, mount *config.MountConfig) {
    67  		if !mount.IsBind() {
    68  			return
    69  		}
    70  		mounts = append(mounts, *mount)
    71  	})
    72  	return mounts
    73  }
    74  
    75  func buildDockerfileWithCopy(baseImage string, mounts []config.MountConfig) *bytes.Buffer {
    76  	buf := bytes.NewBufferString("FROM " + baseImage + "\n")
    77  
    78  	sortMountsByShortestPath(mounts)
    79  	for _, mount := range mounts {
    80  		buf.WriteString(fmt.Sprintf("COPY %s %s\n", mount.Bind, mount.Path))
    81  	}
    82  	return buf
    83  }
    84  
    85  func sortMountsByShortestPath(mounts []config.MountConfig) {
    86  	sort.Slice(mounts, func(i, j int) bool {
    87  		return mounts[i].Path < mounts[j].Path
    88  	})
    89  }
    90  
    91  func buildTarContext(
    92  	dockerfile io.Reader,
    93  	mounts []config.MountConfig,
    94  ) (io.Reader, string, error) {
    95  	paths := []string{}
    96  	for _, mount := range mounts {
    97  		paths = append(paths, mount.Bind)
    98  	}
    99  	buildCtx, err := archive.TarWithOptions(".", &archive.TarOptions{
   100  		IncludeFiles: paths,
   101  	})
   102  	if err != nil {
   103  		return nil, "", err
   104  	}
   105  	return build.AddDockerfileToBuildContext(ioutil.NopCloser(dockerfile), buildCtx)
   106  }
   107  
   108  func buildImageOptions(ctx *context.ExecuteContext, out io.Writer) docker.BuildImageOptions {
   109  	return docker.BuildImageOptions{
   110  		RmTmpContainer: true,
   111  		OutputStream:   out,
   112  		RawJSONStream:  true,
   113  		SuppressOutput: ctx.Settings.Quiet,
   114  		AuthConfigs:    ctx.GetAuthConfigs(),
   115  	}
   116  }
   117  
   118  func removeImage(logger *log.Entry, client client.DockerClient, imageID string) {
   119  	if err := client.RemoveImage(imageID); err != nil {
   120  		logger.Warnf("failed to remove %q: %s", imageID, err)
   121  	}
   122  }
   123  
   124  // TODO: optimize by performing only one copy from container per directory
   125  // if there are overlapping artifact paths
   126  func copyFilesToHost(
   127  	logger *log.Entry,
   128  	ctx *context.ExecuteContext,
   129  	cfg *config.JobConfig,
   130  	containerID string,
   131  ) error {
   132  	mounts := getBindMounts(ctx, cfg)
   133  	for _, artifact := range cfg.Artifact.Globs() {
   134  		artifactPath, err := getArtifactPath(ctx.WorkingDir, artifact, mounts)
   135  		if err != nil {
   136  			return err
   137  		}
   138  		logger.Debugf("Copying %s from container directory %s",
   139  			artifact, artifactPath.containerDir())
   140  		buf := new(bytes.Buffer)
   141  		opts := docker.DownloadFromContainerOptions{
   142  			Path:         artifactPath.containerDir(),
   143  			OutputStream: buf,
   144  		}
   145  		if err := ctx.Client.DownloadFromContainer(containerID, opts); err != nil {
   146  			return err
   147  		}
   148  		if err := unpack(buf, artifactPath); err != nil {
   149  			return err
   150  		}
   151  	}
   152  	return nil
   153  }
   154  
   155  // artifactPath stores the absolute paths of an artifact
   156  type artifactPath struct {
   157  	mountBind    string
   158  	mountPath    string
   159  	artifactGlob string
   160  }
   161  
   162  func newArtifactPath(mountBind, mountPath, glob string) artifactPath {
   163  	return artifactPath{
   164  		mountBind:    mountBind,
   165  		mountPath:    mountPath,
   166  		artifactGlob: glob,
   167  	}
   168  }
   169  
   170  // containerDir used as the path for a container copy API call
   171  func (p artifactPath) containerDir() string {
   172  	return filepathDirWithDirectorySlash(p.containerGlob())
   173  }
   174  
   175  // containerGlob used to match files in the archive returned by the API
   176  func (p artifactPath) containerGlob() string {
   177  	return rebasePath(p.artifactGlob, p.mountBind, p.mountPath)
   178  }
   179  
   180  // the host prefix to prepend to the archive paths
   181  func (p artifactPath) hostPath(path string) string {
   182  	return rebasePath(path, p.mountPath, p.mountBind)
   183  }
   184  
   185  // pathFromArchive strips the archive directory from the path and returns the
   186  // absolute path to the file in a container
   187  func (p artifactPath) pathFromArchive(path string) string {
   188  	parts := strings.SplitN(path, string(filepath.Separator), 2)
   189  	if len(parts) == 1 || parts[1] == "" {
   190  		return p.containerDir()
   191  	}
   192  	return filepathJoinPreserveDirectorySlash(p.containerDir(), parts[1])
   193  }
   194  
   195  func getArtifactPath(
   196  	workingDir string,
   197  	glob string,
   198  	mounts []config.MountConfig,
   199  ) (artifactPath, error) {
   200  	absGlob := filepathJoinPreserveDirectorySlash(workingDir, glob)
   201  
   202  	sortMountsByLongestBind(mounts)
   203  	for _, mount := range mounts {
   204  		absBindPath := filepathJoinPreserveDirectorySlash(workingDir, mount.Bind)
   205  
   206  		if mount.File && hasPathPrefix(absGlob, absBindPath) {
   207  			return newArtifactPath(absBindPath, mount.Path, absGlob), nil
   208  		}
   209  
   210  		if hasPathPrefix(filepathDirWithDirectorySlash(absGlob), absBindPath) {
   211  			return newArtifactPath(absBindPath, mount.Path, absGlob), nil
   212  		}
   213  	}
   214  	return artifactPath{}, errors.Errorf("no mount found for artifact %s", glob)
   215  }
   216  
   217  func sortMountsByLongestBind(mounts []config.MountConfig) {
   218  	sort.Slice(mounts, func(i, j int) bool {
   219  		return mounts[i].Bind >= mounts[j].Bind
   220  	})
   221  }
   222  
   223  // hasPathPrefix returns true if path is under the directory prefix
   224  func hasPathPrefix(path, prefix string) bool {
   225  	sep := string(filepath.Separator)
   226  	pathParts := strings.Split(filepath.Clean(path), sep)
   227  	prefixParts := strings.Split(filepath.Clean(prefix), sep)
   228  
   229  	if len(prefixParts) > len(pathParts) {
   230  		return false
   231  	}
   232  	for index, prefixItem := range prefixParts {
   233  		if prefixItem != pathParts[index] {
   234  			return false
   235  		}
   236  	}
   237  	return true
   238  }
   239  
   240  func filepathJoinPreserveDirectorySlash(elem ...string) string {
   241  	trailingSlash := ""
   242  	if endsWithSlash(elem[len(elem)-1]) {
   243  		trailingSlash = string(filepath.Separator)
   244  	}
   245  	return filepath.Join(elem...) + trailingSlash
   246  }
   247  
   248  func filepathDirWithDirectorySlash(path string) string {
   249  	return filepath.Dir(path) + string(filepath.Separator)
   250  }
   251  
   252  func rebasePath(path, oldPrefix, newPrefix string) string {
   253  	relativePath := strings.TrimPrefix(path, oldPrefix)
   254  	if relativePath == "" {
   255  		return newPrefix
   256  	}
   257  	return filepathJoinPreserveDirectorySlash(newPrefix, relativePath)
   258  }
   259  
   260  func unpack(source io.Reader, path artifactPath) error {
   261  	tarReader := tar.NewReader(source)
   262  
   263  	for {
   264  		header, err := tarReader.Next()
   265  		switch {
   266  		case err == io.EOF:
   267  			return nil
   268  		case err != nil:
   269  			return err
   270  		}
   271  
   272  		containerPath := path.pathFromArchive(header.Name)
   273  		match, err := fileMatchesGlob(containerPath, path.containerGlob())
   274  		switch {
   275  		case err != nil:
   276  			return err
   277  		case !match:
   278  			continue
   279  		}
   280  
   281  		if err := createFromTar(tarReader, header, path); err != nil {
   282  			return err
   283  		}
   284  	}
   285  }
   286  
   287  func fileMatchesGlob(path string, glob string) (bool, error) {
   288  	// Directory glob should match entire tree
   289  	if endsWithSlash(glob) && strings.HasPrefix(path, glob) {
   290  		return true, nil
   291  	}
   292  
   293  	return filepath.Match(glob, path)
   294  }
   295  
   296  func endsWithSlash(path string) bool {
   297  	return strings.HasSuffix(path, string(filepath.Separator))
   298  }
   299  
   300  // create files and directories from tar archive entries
   301  func createFromTar(tarReader io.Reader, header *tar.Header, path artifactPath) error {
   302  	hostPath := path.hostPath(path.pathFromArchive(header.Name))
   303  	fileMode := header.FileInfo().Mode()
   304  
   305  	switch header.Typeflag {
   306  	case tar.TypeDir:
   307  		logging.Log.Debugf("Creating dir %s", hostPath)
   308  		return os.MkdirAll(hostPath, fileMode)
   309  
   310  	case tar.TypeReg, tar.TypeRegA:
   311  		logging.Log.Debugf("Creating file %s", hostPath)
   312  		if err := os.MkdirAll(filepath.Dir(hostPath), 0755); err != nil {
   313  			return err
   314  		}
   315  		file, err := os.OpenFile(hostPath, os.O_RDWR|os.O_CREATE, fileMode)
   316  		if err != nil {
   317  			return err
   318  		}
   319  		_, err = io.Copy(file, tarReader)
   320  		return err
   321  
   322  	case tar.TypeSymlink:
   323  		logging.Log.Debugf("Creating symlink %s", hostPath)
   324  		if err := os.MkdirAll(filepath.Dir(hostPath), 0755); err != nil {
   325  			return err
   326  		}
   327  
   328  		return os.Symlink(header.Linkname, hostPath)
   329  
   330  	default:
   331  		logging.Log.Warnf("Unhandled file type from archive %s: %s",
   332  			string(header.Typeflag),
   333  			header.Name)
   334  	}
   335  
   336  	return nil
   337  }