github.com/panekj/cli@v0.0.0-20230304125325-467dd2f3797e/cli/command/image/build/context.go (about) 1 package build 2 3 import ( 4 "archive/tar" 5 "bufio" 6 "bytes" 7 "fmt" 8 "io" 9 "net/http" 10 "os" 11 "path/filepath" 12 "runtime" 13 "strings" 14 "time" 15 16 "github.com/docker/docker/builder/remotecontext/git" 17 "github.com/docker/docker/pkg/archive" 18 "github.com/docker/docker/pkg/ioutils" 19 "github.com/docker/docker/pkg/pools" 20 "github.com/docker/docker/pkg/progress" 21 "github.com/docker/docker/pkg/streamformatter" 22 "github.com/docker/docker/pkg/stringid" 23 "github.com/moby/patternmatcher" 24 "github.com/pkg/errors" 25 exec "golang.org/x/sys/execabs" 26 ) 27 28 const ( 29 // DefaultDockerfileName is the Default filename with Docker commands, read by docker build 30 DefaultDockerfileName string = "Dockerfile" 31 // archiveHeaderSize is the number of bytes in an archive header 32 archiveHeaderSize = 512 33 ) 34 35 // ValidateContextDirectory checks if all the contents of the directory 36 // can be read and returns an error if some files can't be read 37 // symlinks which point to non-existing files don't trigger an error 38 func ValidateContextDirectory(srcPath string, excludes []string) error { 39 contextRoot, err := getContextRoot(srcPath) 40 if err != nil { 41 return err 42 } 43 44 pm, err := patternmatcher.New(excludes) 45 if err != nil { 46 return err 47 } 48 49 return filepath.Walk(contextRoot, func(filePath string, f os.FileInfo, err error) error { 50 if err != nil { 51 if os.IsPermission(err) { 52 return errors.Errorf("can't stat '%s'", filePath) 53 } 54 if os.IsNotExist(err) { 55 return errors.Errorf("file ('%s') not found or excluded by .dockerignore", filePath) 56 } 57 return err 58 } 59 60 // skip this directory/file if it's not in the path, it won't get added to the context 61 if relFilePath, err := filepath.Rel(contextRoot, filePath); err != nil { 62 return err 63 } else if skip, err := filepathMatches(pm, relFilePath); err != nil { 64 return err 65 } else if skip { 66 if f.IsDir() { 67 return filepath.SkipDir 68 } 69 return nil 70 } 71 72 // skip checking if symlinks point to non-existing files, such symlinks can be useful 73 // also skip named pipes, because they hanging on open 74 if f.Mode()&(os.ModeSymlink|os.ModeNamedPipe) != 0 { 75 return nil 76 } 77 78 if !f.IsDir() { 79 currentFile, err := os.Open(filePath) 80 if err != nil && os.IsPermission(err) { 81 return errors.Errorf("no permission to read from '%s'", filePath) 82 } 83 currentFile.Close() 84 } 85 return nil 86 }) 87 } 88 89 func filepathMatches(matcher *patternmatcher.PatternMatcher, file string) (bool, error) { 90 file = filepath.Clean(file) 91 if file == "." { 92 // Don't let them exclude everything, kind of silly. 93 return false, nil 94 } 95 return matcher.MatchesOrParentMatches(file) 96 } 97 98 // DetectArchiveReader detects whether the input stream is an archive or a 99 // Dockerfile and returns a buffered version of input, safe to consume in lieu 100 // of input. If an archive is detected, isArchive is set to true, and to false 101 // otherwise, in which case it is safe to assume input represents the contents 102 // of a Dockerfile. 103 func DetectArchiveReader(input io.ReadCloser) (rc io.ReadCloser, isArchive bool, err error) { 104 buf := bufio.NewReader(input) 105 106 magic, err := buf.Peek(archiveHeaderSize * 2) 107 if err != nil && err != io.EOF { 108 return nil, false, errors.Errorf("failed to peek context header from STDIN: %v", err) 109 } 110 111 return ioutils.NewReadCloserWrapper(buf, func() error { return input.Close() }), IsArchive(magic), nil 112 } 113 114 // WriteTempDockerfile writes a Dockerfile stream to a temporary file with a 115 // name specified by DefaultDockerfileName and returns the path to the 116 // temporary directory containing the Dockerfile. 117 func WriteTempDockerfile(rc io.ReadCloser) (dockerfileDir string, err error) { 118 // err is a named return value, due to the defer call below. 119 dockerfileDir, err = os.MkdirTemp("", "docker-build-tempdockerfile-") 120 if err != nil { 121 return "", errors.Errorf("unable to create temporary context directory: %v", err) 122 } 123 defer func() { 124 if err != nil { 125 _ = os.RemoveAll(dockerfileDir) 126 } 127 }() 128 129 f, err := os.Create(filepath.Join(dockerfileDir, DefaultDockerfileName)) 130 if err != nil { 131 return "", err 132 } 133 defer f.Close() 134 if _, err := io.Copy(f, rc); err != nil { 135 return "", err 136 } 137 return dockerfileDir, rc.Close() 138 } 139 140 // GetContextFromReader will read the contents of the given reader as either a 141 // Dockerfile or tar archive. Returns a tar archive used as a context and a 142 // path to the Dockerfile inside the tar. 143 func GetContextFromReader(rc io.ReadCloser, dockerfileName string) (out io.ReadCloser, relDockerfile string, err error) { 144 rc, isArchive, err := DetectArchiveReader(rc) 145 if err != nil { 146 return nil, "", err 147 } 148 149 if isArchive { 150 return rc, dockerfileName, nil 151 } 152 153 // Input should be read as a Dockerfile. 154 155 if dockerfileName == "-" { 156 return nil, "", errors.New("build context is not an archive") 157 } 158 159 dockerfileDir, err := WriteTempDockerfile(rc) 160 if err != nil { 161 return nil, "", err 162 } 163 164 tar, err := archive.Tar(dockerfileDir, archive.Uncompressed) 165 if err != nil { 166 return nil, "", err 167 } 168 169 return ioutils.NewReadCloserWrapper(tar, func() error { 170 err := tar.Close() 171 os.RemoveAll(dockerfileDir) 172 return err 173 }), DefaultDockerfileName, nil 174 } 175 176 // IsArchive checks for the magic bytes of a tar or any supported compression 177 // algorithm. 178 func IsArchive(header []byte) bool { 179 compression := archive.DetectCompression(header) 180 if compression != archive.Uncompressed { 181 return true 182 } 183 r := tar.NewReader(bytes.NewBuffer(header)) 184 _, err := r.Next() 185 return err == nil 186 } 187 188 // GetContextFromGitURL uses a Git URL as context for a `docker build`. The 189 // git repo is cloned into a temporary directory used as the context directory. 190 // Returns the absolute path to the temporary context directory, the relative 191 // path of the dockerfile in that context directory, and a non-nil error on 192 // success. 193 func GetContextFromGitURL(gitURL, dockerfileName string) (string, string, error) { 194 if _, err := exec.LookPath("git"); err != nil { 195 return "", "", errors.Wrapf(err, "unable to find 'git'") 196 } 197 absContextDir, err := git.Clone(gitURL) 198 if err != nil { 199 return "", "", errors.Wrapf(err, "unable to 'git clone' to temporary context directory") 200 } 201 202 absContextDir, err = ResolveAndValidateContextPath(absContextDir) 203 if err != nil { 204 return "", "", err 205 } 206 relDockerfile, err := getDockerfileRelPath(absContextDir, dockerfileName) 207 if err == nil && strings.HasPrefix(relDockerfile, ".."+string(filepath.Separator)) { 208 return "", "", errors.Errorf("the Dockerfile (%s) must be within the build context", dockerfileName) 209 } 210 211 return absContextDir, relDockerfile, err 212 } 213 214 // GetContextFromURL uses a remote URL as context for a `docker build`. The 215 // remote resource is downloaded as either a Dockerfile or a tar archive. 216 // Returns the tar archive used for the context and a path of the 217 // dockerfile inside the tar. 218 func GetContextFromURL(out io.Writer, remoteURL, dockerfileName string) (io.ReadCloser, string, error) { 219 response, err := getWithStatusError(remoteURL) 220 if err != nil { 221 return nil, "", errors.Errorf("unable to download remote context %s: %v", remoteURL, err) 222 } 223 progressOutput := streamformatter.NewProgressOutput(out) 224 225 // Pass the response body through a progress reader. 226 progReader := progress.NewProgressReader(response.Body, progressOutput, response.ContentLength, "", fmt.Sprintf("Downloading build context from remote url: %s", remoteURL)) 227 228 return GetContextFromReader(ioutils.NewReadCloserWrapper(progReader, func() error { return response.Body.Close() }), dockerfileName) 229 } 230 231 // getWithStatusError does an http.Get() and returns an error if the 232 // status code is 4xx or 5xx. 233 func getWithStatusError(url string) (resp *http.Response, err error) { 234 // #nosec G107 235 if resp, err = http.Get(url); err != nil { 236 return nil, err 237 } 238 if resp.StatusCode < http.StatusBadRequest { 239 return resp, nil 240 } 241 msg := fmt.Sprintf("failed to GET %s with status %s", url, resp.Status) 242 body, err := io.ReadAll(resp.Body) 243 resp.Body.Close() 244 if err != nil { 245 return nil, errors.Wrapf(err, "%s: error reading body", msg) 246 } 247 return nil, errors.Errorf("%s: %s", msg, bytes.TrimSpace(body)) 248 } 249 250 // GetContextFromLocalDir uses the given local directory as context for a 251 // `docker build`. Returns the absolute path to the local context directory, 252 // the relative path of the dockerfile in that context directory, and a non-nil 253 // error on success. 254 func GetContextFromLocalDir(localDir, dockerfileName string) (string, string, error) { 255 localDir, err := ResolveAndValidateContextPath(localDir) 256 if err != nil { 257 return "", "", err 258 } 259 260 // When using a local context directory, and the Dockerfile is specified 261 // with the `-f/--file` option then it is considered relative to the 262 // current directory and not the context directory. 263 if dockerfileName != "" && dockerfileName != "-" { 264 if dockerfileName, err = filepath.Abs(dockerfileName); err != nil { 265 return "", "", errors.Errorf("unable to get absolute path to Dockerfile: %v", err) 266 } 267 } 268 269 relDockerfile, err := getDockerfileRelPath(localDir, dockerfileName) 270 return localDir, relDockerfile, err 271 } 272 273 // ResolveAndValidateContextPath uses the given context directory for a `docker build` 274 // and returns the absolute path to the context directory. 275 func ResolveAndValidateContextPath(givenContextDir string) (string, error) { 276 absContextDir, err := filepath.Abs(givenContextDir) 277 if err != nil { 278 return "", errors.Errorf("unable to get absolute context directory of given context directory %q: %v", givenContextDir, err) 279 } 280 281 // The context dir might be a symbolic link, so follow it to the actual 282 // target directory. 283 // 284 // FIXME. We use isUNC (always false on non-Windows platforms) to workaround 285 // an issue in golang. On Windows, EvalSymLinks does not work on UNC file 286 // paths (those starting with \\). This hack means that when using links 287 // on UNC paths, they will not be followed. 288 if !isUNC(absContextDir) { 289 absContextDir, err = filepath.EvalSymlinks(absContextDir) 290 if err != nil { 291 return "", errors.Errorf("unable to evaluate symlinks in context path: %v", err) 292 } 293 } 294 295 stat, err := os.Lstat(absContextDir) 296 if err != nil { 297 return "", errors.Errorf("unable to stat context directory %q: %v", absContextDir, err) 298 } 299 300 if !stat.IsDir() { 301 return "", errors.Errorf("context must be a directory: %s", absContextDir) 302 } 303 return absContextDir, err 304 } 305 306 // getDockerfileRelPath returns the dockerfile path relative to the context 307 // directory 308 func getDockerfileRelPath(absContextDir, givenDockerfile string) (string, error) { 309 var err error 310 311 if givenDockerfile == "-" { 312 return givenDockerfile, nil 313 } 314 315 absDockerfile := givenDockerfile 316 if absDockerfile == "" { 317 // No -f/--file was specified so use the default relative to the 318 // context directory. 319 absDockerfile = filepath.Join(absContextDir, DefaultDockerfileName) 320 321 // Just to be nice ;-) look for 'dockerfile' too but only 322 // use it if we found it, otherwise ignore this check 323 if _, err = os.Lstat(absDockerfile); os.IsNotExist(err) { 324 altPath := filepath.Join(absContextDir, strings.ToLower(DefaultDockerfileName)) 325 if _, err = os.Lstat(altPath); err == nil { 326 absDockerfile = altPath 327 } 328 } 329 } 330 331 // If not already an absolute path, the Dockerfile path should be joined to 332 // the base directory. 333 if !filepath.IsAbs(absDockerfile) { 334 absDockerfile = filepath.Join(absContextDir, absDockerfile) 335 } 336 337 // Evaluate symlinks in the path to the Dockerfile too. 338 // 339 // FIXME. We use isUNC (always false on non-Windows platforms) to workaround 340 // an issue in golang. On Windows, EvalSymLinks does not work on UNC file 341 // paths (those starting with \\). This hack means that when using links 342 // on UNC paths, they will not be followed. 343 if !isUNC(absDockerfile) { 344 absDockerfile, err = filepath.EvalSymlinks(absDockerfile) 345 if err != nil { 346 return "", errors.Errorf("unable to evaluate symlinks in Dockerfile path: %v", err) 347 } 348 } 349 350 if _, err := os.Lstat(absDockerfile); err != nil { 351 if os.IsNotExist(err) { 352 return "", errors.Errorf("Cannot locate Dockerfile: %q", absDockerfile) 353 } 354 return "", errors.Errorf("unable to stat Dockerfile: %v", err) 355 } 356 357 relDockerfile, err := filepath.Rel(absContextDir, absDockerfile) 358 if err != nil { 359 return "", errors.Errorf("unable to get relative Dockerfile path: %v", err) 360 } 361 362 return relDockerfile, nil 363 } 364 365 // isUNC returns true if the path is UNC (one starting \\). It always returns 366 // false on Linux. 367 func isUNC(path string) bool { 368 return runtime.GOOS == "windows" && strings.HasPrefix(path, `\\`) 369 } 370 371 // AddDockerfileToBuildContext from a ReadCloser, returns a new archive and 372 // the relative path to the dockerfile in the context. 373 func AddDockerfileToBuildContext(dockerfileCtx io.ReadCloser, buildCtx io.ReadCloser) (io.ReadCloser, string, error) { 374 file, err := io.ReadAll(dockerfileCtx) 375 dockerfileCtx.Close() 376 if err != nil { 377 return nil, "", err 378 } 379 now := time.Now() 380 randomName := ".dockerfile." + stringid.GenerateRandomID()[:20] 381 382 buildCtx = archive.ReplaceFileTarWrapper(buildCtx, map[string]archive.TarModifierFunc{ 383 // Add the dockerfile with a random filename 384 randomName: func(_ string, _ *tar.Header, _ io.Reader) (*tar.Header, []byte, error) { 385 header := &tar.Header{ 386 Name: randomName, 387 Mode: 0o600, 388 ModTime: now, 389 Typeflag: tar.TypeReg, 390 AccessTime: now, 391 ChangeTime: now, 392 } 393 return header, file, nil 394 }, 395 // Update .dockerignore to include the random filename 396 ".dockerignore": func(_ string, h *tar.Header, content io.Reader) (*tar.Header, []byte, error) { 397 if h == nil { 398 h = &tar.Header{ 399 Name: ".dockerignore", 400 Mode: 0o600, 401 ModTime: now, 402 Typeflag: tar.TypeReg, 403 AccessTime: now, 404 ChangeTime: now, 405 } 406 } 407 408 b := &bytes.Buffer{} 409 if content != nil { 410 if _, err := b.ReadFrom(content); err != nil { 411 return nil, nil, err 412 } 413 } else { 414 b.WriteString(".dockerignore") 415 } 416 b.WriteString("\n" + randomName + "\n") 417 return h, b.Bytes(), nil 418 }, 419 }) 420 return buildCtx, randomName, nil 421 } 422 423 // Compress the build context for sending to the API 424 func Compress(buildCtx io.ReadCloser) (io.ReadCloser, error) { 425 pipeReader, pipeWriter := io.Pipe() 426 427 go func() { 428 compressWriter, err := archive.CompressStream(pipeWriter, archive.Gzip) 429 if err != nil { 430 pipeWriter.CloseWithError(err) 431 } 432 defer buildCtx.Close() 433 434 if _, err := pools.Copy(compressWriter, buildCtx); err != nil { 435 pipeWriter.CloseWithError( 436 errors.Wrap(err, "failed to compress context")) 437 compressWriter.Close() 438 return 439 } 440 compressWriter.Close() 441 pipeWriter.Close() 442 }() 443 444 return pipeReader, nil 445 }