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 }