github.com/buildpacks/pack@v0.33.3-0.20240516162812-884dd1837311/pkg/blob/downloader.go (about)

     1  package blob
     2  
     3  import (
     4  	"context"
     5  	"crypto/sha256"
     6  	"fmt"
     7  	"io"
     8  	"net/http"
     9  	"net/url"
    10  	"os"
    11  	"path/filepath"
    12  
    13  	"github.com/mitchellh/ioprogress"
    14  	"github.com/pkg/errors"
    15  
    16  	"github.com/buildpacks/pack/internal/paths"
    17  	"github.com/buildpacks/pack/internal/style"
    18  )
    19  
    20  const (
    21  	cacheDirPrefix = "c"
    22  	cacheVersion   = "2"
    23  )
    24  
    25  type Logger interface {
    26  	Debugf(fmt string, v ...interface{})
    27  	Infof(fmt string, v ...interface{})
    28  	Writer() io.Writer
    29  }
    30  
    31  type DownloaderOption func(d *downloader)
    32  
    33  func WithClient(client *http.Client) DownloaderOption {
    34  	return func(d *downloader) {
    35  		d.client = client
    36  	}
    37  }
    38  
    39  type Downloader interface {
    40  	Download(ctx context.Context, pathOrURI string) (Blob, error)
    41  }
    42  
    43  type downloader struct {
    44  	logger       Logger
    45  	baseCacheDir string
    46  	client       *http.Client
    47  }
    48  
    49  func NewDownloader(logger Logger, baseCacheDir string, opts ...DownloaderOption) Downloader {
    50  	d := &downloader{
    51  		logger:       logger,
    52  		baseCacheDir: baseCacheDir,
    53  		client:       http.DefaultClient,
    54  	}
    55  
    56  	for _, opt := range opts {
    57  		opt(d)
    58  	}
    59  
    60  	return d
    61  }
    62  
    63  func (d *downloader) Download(ctx context.Context, pathOrURI string) (Blob, error) {
    64  	if paths.IsURI(pathOrURI) {
    65  		parsedURL, err := url.Parse(pathOrURI)
    66  		if err != nil {
    67  			return nil, errors.Wrapf(err, "parsing path/uri %s", style.Symbol(pathOrURI))
    68  		}
    69  
    70  		var path string
    71  		switch parsedURL.Scheme {
    72  		case "file":
    73  			path, err = paths.URIToFilePath(pathOrURI)
    74  		case "http", "https":
    75  			path, err = d.handleHTTP(ctx, pathOrURI)
    76  			if err != nil {
    77  				// retry as we sometimes see `wsarecv: An existing connection was forcibly closed by the remote host.` on Windows
    78  				path, err = d.handleHTTP(ctx, pathOrURI)
    79  			}
    80  		default:
    81  			err = fmt.Errorf("unsupported protocol %s in URI %s", style.Symbol(parsedURL.Scheme), style.Symbol(pathOrURI))
    82  		}
    83  		if err != nil {
    84  			return nil, err
    85  		}
    86  
    87  		return &blob{path: path}, nil
    88  	}
    89  
    90  	path := d.handleFile(pathOrURI)
    91  
    92  	return &blob{path: path}, nil
    93  }
    94  
    95  func (d *downloader) handleFile(path string) string {
    96  	path, err := filepath.Abs(path)
    97  	if err != nil {
    98  		return ""
    99  	}
   100  
   101  	return path
   102  }
   103  
   104  func (d *downloader) handleHTTP(ctx context.Context, uri string) (string, error) {
   105  	cacheDir := d.versionedCacheDir()
   106  
   107  	if err := os.MkdirAll(cacheDir, 0750); err != nil {
   108  		return "", err
   109  	}
   110  
   111  	cachePath := filepath.Join(cacheDir, fmt.Sprintf("%x", sha256.Sum256([]byte(uri))))
   112  
   113  	etagFile := cachePath + ".etag"
   114  	etagExists, err := fileExists(etagFile)
   115  	if err != nil {
   116  		return "", err
   117  	}
   118  
   119  	etag := ""
   120  	if etagExists {
   121  		bytes, err := os.ReadFile(filepath.Clean(etagFile))
   122  		if err != nil {
   123  			return "", err
   124  		}
   125  		etag = string(bytes)
   126  	}
   127  
   128  	reader, etag, err := d.downloadAsStream(ctx, uri, etag)
   129  	if err != nil {
   130  		return "", err
   131  	} else if reader == nil {
   132  		return cachePath, nil
   133  	}
   134  	defer reader.Close()
   135  
   136  	fh, err := os.Create(cachePath)
   137  	if err != nil {
   138  		return "", errors.Wrapf(err, "create cache path %s", style.Symbol(cachePath))
   139  	}
   140  	defer fh.Close()
   141  
   142  	_, err = io.Copy(fh, reader)
   143  	if err != nil {
   144  		return "", errors.Wrap(err, "writing cache")
   145  	}
   146  
   147  	if err = os.WriteFile(etagFile, []byte(etag), 0744); err != nil {
   148  		return "", errors.Wrap(err, "writing etag")
   149  	}
   150  
   151  	return cachePath, nil
   152  }
   153  
   154  func (d *downloader) downloadAsStream(ctx context.Context, uri string, etag string) (io.ReadCloser, string, error) {
   155  	req, err := http.NewRequest("GET", uri, nil)
   156  	if err != nil {
   157  		return nil, "", err
   158  	}
   159  	req = req.WithContext(ctx)
   160  
   161  	if etag != "" {
   162  		req.Header.Set("If-None-Match", etag)
   163  	}
   164  
   165  	resp, err := d.client.Do(req) //nolint:bodyclose
   166  	if err != nil {
   167  		return nil, "", err
   168  	}
   169  
   170  	if resp.StatusCode >= 200 && resp.StatusCode < 300 {
   171  		d.logger.Infof("Downloading from %s", style.Symbol(uri))
   172  		return withProgress(d.logger.Writer(), resp.Body, resp.ContentLength), resp.Header.Get("Etag"), nil
   173  	}
   174  
   175  	if resp.StatusCode == 304 {
   176  		d.logger.Debugf("Using cached version of %s", style.Symbol(uri))
   177  		return nil, etag, nil
   178  	}
   179  
   180  	return nil, "", fmt.Errorf(
   181  		"could not download from %s, code http status %s",
   182  		style.Symbol(uri), style.SymbolF("%d", resp.StatusCode),
   183  	)
   184  }
   185  
   186  func withProgress(writer io.Writer, rc io.ReadCloser, length int64) io.ReadCloser {
   187  	return &progressReader{
   188  		Closer: rc,
   189  		Reader: &ioprogress.Reader{
   190  			Reader:   rc,
   191  			Size:     length,
   192  			DrawFunc: ioprogress.DrawTerminalf(writer, ioprogress.DrawTextFormatBytes),
   193  		},
   194  	}
   195  }
   196  
   197  type progressReader struct {
   198  	*ioprogress.Reader
   199  	io.Closer
   200  }
   201  
   202  func (d *downloader) versionedCacheDir() string {
   203  	return filepath.Join(d.baseCacheDir, cacheDirPrefix+cacheVersion)
   204  }
   205  
   206  func fileExists(file string) (bool, error) {
   207  	_, err := os.Stat(file)
   208  	if err != nil {
   209  		if os.IsNotExist(err) {
   210  			return false, nil
   211  		}
   212  		return false, err
   213  	}
   214  	return true, nil
   215  }