github.com/khulnasoft/cli@v0.0.0-20240402070845-01bcad7beefa/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  }