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  }