github.com/thajeztah/cli@v0.0.0-20240223162942-dc6bfac81a8b/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 "os/exec" 12 "path/filepath" 13 "runtime" 14 "strings" 15 "time" 16 17 "github.com/docker/docker/builder/remotecontext/git" 18 "github.com/docker/docker/pkg/archive" 19 "github.com/docker/docker/pkg/ioutils" 20 "github.com/docker/docker/pkg/pools" 21 "github.com/docker/docker/pkg/progress" 22 "github.com/docker/docker/pkg/streamformatter" 23 "github.com/docker/docker/pkg/stringid" 24 "github.com/moby/patternmatcher" 25 "github.com/pkg/errors" 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 if dockerfileName != "" { 159 return nil, "", errors.New("ambiguous Dockerfile source: both stdin and flag correspond to Dockerfiles") 160 } 161 162 dockerfileDir, err := WriteTempDockerfile(rc) 163 if err != nil { 164 return nil, "", err 165 } 166 167 tarArchive, err := archive.Tar(dockerfileDir, archive.Uncompressed) 168 if err != nil { 169 return nil, "", err 170 } 171 172 return ioutils.NewReadCloserWrapper(tarArchive, func() error { 173 err := tarArchive.Close() 174 os.RemoveAll(dockerfileDir) 175 return err 176 }), DefaultDockerfileName, nil 177 } 178 179 // IsArchive checks for the magic bytes of a tar or any supported compression 180 // algorithm. 181 func IsArchive(header []byte) bool { 182 compression := archive.DetectCompression(header) 183 if compression != archive.Uncompressed { 184 return true 185 } 186 r := tar.NewReader(bytes.NewBuffer(header)) 187 _, err := r.Next() 188 return err == nil 189 } 190 191 // GetContextFromGitURL uses a Git URL as context for a `docker build`. The 192 // git repo is cloned into a temporary directory used as the context directory. 193 // Returns the absolute path to the temporary context directory, the relative 194 // path of the dockerfile in that context directory, and a non-nil error on 195 // success. 196 func GetContextFromGitURL(gitURL, dockerfileName string) (string, string, error) { 197 if _, err := exec.LookPath("git"); err != nil { 198 return "", "", errors.Wrapf(err, "unable to find 'git'") 199 } 200 absContextDir, err := git.Clone(gitURL) 201 if err != nil { 202 return "", "", errors.Wrapf(err, "unable to 'git clone' to temporary context directory") 203 } 204 205 absContextDir, err = ResolveAndValidateContextPath(absContextDir) 206 if err != nil { 207 return "", "", err 208 } 209 relDockerfile, err := getDockerfileRelPath(absContextDir, dockerfileName) 210 if err == nil && strings.HasPrefix(relDockerfile, ".."+string(filepath.Separator)) { 211 return "", "", errors.Errorf("the Dockerfile (%s) must be within the build context", dockerfileName) 212 } 213 214 return absContextDir, relDockerfile, err 215 } 216 217 // GetContextFromURL uses a remote URL as context for a `docker build`. The 218 // remote resource is downloaded as either a Dockerfile or a tar archive. 219 // Returns the tar archive used for the context and a path of the 220 // dockerfile inside the tar. 221 func GetContextFromURL(out io.Writer, remoteURL, dockerfileName string) (io.ReadCloser, string, error) { 222 response, err := getWithStatusError(remoteURL) 223 if err != nil { 224 return nil, "", errors.Errorf("unable to download remote context %s: %v", remoteURL, err) 225 } 226 progressOutput := streamformatter.NewProgressOutput(out) 227 228 // Pass the response body through a progress reader. 229 progReader := progress.NewProgressReader(response.Body, progressOutput, response.ContentLength, "", fmt.Sprintf("Downloading build context from remote url: %s", remoteURL)) 230 231 return GetContextFromReader(ioutils.NewReadCloserWrapper(progReader, func() error { return response.Body.Close() }), dockerfileName) 232 } 233 234 // getWithStatusError does an http.Get() and returns an error if the 235 // status code is 4xx or 5xx. 236 func getWithStatusError(url string) (resp *http.Response, err error) { 237 //#nosec G107 -- Ignore G107: Potential HTTP request made with variable url 238 if resp, err = http.Get(url); err != nil { 239 return nil, err 240 } 241 if resp.StatusCode < http.StatusBadRequest { 242 return resp, nil 243 } 244 msg := fmt.Sprintf("failed to GET %s with status %s", url, resp.Status) 245 body, err := io.ReadAll(resp.Body) 246 resp.Body.Close() 247 if err != nil { 248 return nil, errors.Wrapf(err, "%s: error reading body", msg) 249 } 250 return nil, errors.Errorf("%s: %s", msg, bytes.TrimSpace(body)) 251 } 252 253 // GetContextFromLocalDir uses the given local directory as context for a 254 // `docker build`. Returns the absolute path to the local context directory, 255 // the relative path of the dockerfile in that context directory, and a non-nil 256 // error on success. 257 func GetContextFromLocalDir(localDir, dockerfileName string) (string, string, error) { 258 localDir, err := ResolveAndValidateContextPath(localDir) 259 if err != nil { 260 return "", "", err 261 } 262 263 // When using a local context directory, and the Dockerfile is specified 264 // with the `-f/--file` option then it is considered relative to the 265 // current directory and not the context directory. 266 if dockerfileName != "" && dockerfileName != "-" { 267 if dockerfileName, err = filepath.Abs(dockerfileName); err != nil { 268 return "", "", errors.Errorf("unable to get absolute path to Dockerfile: %v", err) 269 } 270 } 271 272 relDockerfile, err := getDockerfileRelPath(localDir, dockerfileName) 273 return localDir, relDockerfile, err 274 } 275 276 // ResolveAndValidateContextPath uses the given context directory for a `docker build` 277 // and returns the absolute path to the context directory. 278 func ResolveAndValidateContextPath(givenContextDir string) (string, error) { 279 absContextDir, err := filepath.Abs(givenContextDir) 280 if err != nil { 281 return "", errors.Errorf("unable to get absolute context directory of given context directory %q: %v", givenContextDir, err) 282 } 283 284 // The context dir might be a symbolic link, so follow it to the actual 285 // target directory. 286 // 287 // FIXME. We use isUNC (always false on non-Windows platforms) to workaround 288 // an issue in golang. On Windows, EvalSymLinks does not work on UNC file 289 // paths (those starting with \\). This hack means that when using links 290 // on UNC paths, they will not be followed. 291 if !isUNC(absContextDir) { 292 absContextDir, err = filepath.EvalSymlinks(absContextDir) 293 if err != nil { 294 return "", errors.Errorf("unable to evaluate symlinks in context path: %v", err) 295 } 296 } 297 298 stat, err := os.Lstat(absContextDir) 299 if err != nil { 300 return "", errors.Errorf("unable to stat context directory %q: %v", absContextDir, err) 301 } 302 303 if !stat.IsDir() { 304 return "", errors.Errorf("context must be a directory: %s", absContextDir) 305 } 306 return absContextDir, err 307 } 308 309 // getDockerfileRelPath returns the dockerfile path relative to the context 310 // directory 311 func getDockerfileRelPath(absContextDir, givenDockerfile string) (string, error) { 312 var err error 313 314 if givenDockerfile == "-" { 315 return givenDockerfile, nil 316 } 317 318 absDockerfile := givenDockerfile 319 if absDockerfile == "" { 320 // No -f/--file was specified so use the default relative to the 321 // context directory. 322 absDockerfile = filepath.Join(absContextDir, DefaultDockerfileName) 323 324 // Just to be nice ;-) look for 'dockerfile' too but only 325 // use it if we found it, otherwise ignore this check 326 if _, err = os.Lstat(absDockerfile); os.IsNotExist(err) { 327 altPath := filepath.Join(absContextDir, strings.ToLower(DefaultDockerfileName)) 328 if _, err = os.Lstat(altPath); err == nil { 329 absDockerfile = altPath 330 } 331 } 332 } 333 334 // If not already an absolute path, the Dockerfile path should be joined to 335 // the base directory. 336 if !filepath.IsAbs(absDockerfile) { 337 absDockerfile = filepath.Join(absContextDir, absDockerfile) 338 } 339 340 // Evaluate symlinks in the path to the Dockerfile too. 341 // 342 // FIXME. We use isUNC (always false on non-Windows platforms) to workaround 343 // an issue in golang. On Windows, EvalSymLinks does not work on UNC file 344 // paths (those starting with \\). This hack means that when using links 345 // on UNC paths, they will not be followed. 346 if !isUNC(absDockerfile) { 347 absDockerfile, err = filepath.EvalSymlinks(absDockerfile) 348 if err != nil { 349 return "", errors.Errorf("unable to evaluate symlinks in Dockerfile path: %v", err) 350 } 351 } 352 353 if _, err := os.Lstat(absDockerfile); err != nil { 354 if os.IsNotExist(err) { 355 return "", errors.Errorf("Cannot locate Dockerfile: %q", absDockerfile) 356 } 357 return "", errors.Errorf("unable to stat Dockerfile: %v", err) 358 } 359 360 relDockerfile, err := filepath.Rel(absContextDir, absDockerfile) 361 if err != nil { 362 return "", errors.Errorf("unable to get relative Dockerfile path: %v", err) 363 } 364 365 return relDockerfile, nil 366 } 367 368 // isUNC returns true if the path is UNC (one starting \\). It always returns 369 // false on Linux. 370 func isUNC(path string) bool { 371 return runtime.GOOS == "windows" && strings.HasPrefix(path, `\\`) 372 } 373 374 // AddDockerfileToBuildContext from a ReadCloser, returns a new archive and 375 // the relative path to the dockerfile in the context. 376 func AddDockerfileToBuildContext(dockerfileCtx io.ReadCloser, buildCtx io.ReadCloser) (io.ReadCloser, string, error) { 377 file, err := io.ReadAll(dockerfileCtx) 378 dockerfileCtx.Close() 379 if err != nil { 380 return nil, "", err 381 } 382 now := time.Now() 383 randomName := ".dockerfile." + stringid.GenerateRandomID()[:20] 384 385 buildCtx = archive.ReplaceFileTarWrapper(buildCtx, map[string]archive.TarModifierFunc{ 386 // Add the dockerfile with a random filename 387 randomName: func(_ string, _ *tar.Header, _ io.Reader) (*tar.Header, []byte, error) { 388 header := &tar.Header{ 389 Name: randomName, 390 Mode: 0o600, 391 ModTime: now, 392 Typeflag: tar.TypeReg, 393 AccessTime: now, 394 ChangeTime: now, 395 } 396 return header, file, nil 397 }, 398 // Update .dockerignore to include the random filename 399 ".dockerignore": func(_ string, h *tar.Header, content io.Reader) (*tar.Header, []byte, error) { 400 if h == nil { 401 h = &tar.Header{ 402 Name: ".dockerignore", 403 Mode: 0o600, 404 ModTime: now, 405 Typeflag: tar.TypeReg, 406 AccessTime: now, 407 ChangeTime: now, 408 } 409 } 410 411 b := &bytes.Buffer{} 412 if content != nil { 413 if _, err := b.ReadFrom(content); err != nil { 414 return nil, nil, err 415 } 416 } else { 417 b.WriteString(".dockerignore") 418 } 419 b.WriteString("\n" + randomName + "\n") 420 return h, b.Bytes(), nil 421 }, 422 }) 423 return buildCtx, randomName, nil 424 } 425 426 // Compress the build context for sending to the API 427 func Compress(buildCtx io.ReadCloser) (io.ReadCloser, error) { 428 pipeReader, pipeWriter := io.Pipe() 429 430 go func() { 431 compressWriter, err := archive.CompressStream(pipeWriter, archive.Gzip) 432 if err != nil { 433 pipeWriter.CloseWithError(err) 434 } 435 defer buildCtx.Close() 436 437 if _, err := pools.Copy(compressWriter, buildCtx); err != nil { 438 pipeWriter.CloseWithError(errors.Wrap(err, "failed to compress context")) 439 compressWriter.Close() 440 return 441 } 442 compressWriter.Close() 443 pipeWriter.Close() 444 }() 445 446 return pipeReader, nil 447 }