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 }