github.com/hugh712/snapd@v0.0.0-20200910133618-1a99902bd583/store/store_download.go (about)

     1  // -*- Mode: Go; indent-tabs-mode: t -*-
     2  
     3  /*
     4   * Copyright (C) 2014-2020 Canonical Ltd
     5   *
     6   * This program is free software: you can redistribute it and/or modify
     7   * it under the terms of the GNU General Public License version 3 as
     8   * published by the Free Software Foundation.
     9   *
    10   * This program is distributed in the hope that it will be useful,
    11   * but WITHOUT ANY WARRANTY; without even the implied warranty of
    12   * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    13   * GNU General Public License for more details.
    14   *
    15   * You should have received a copy of the GNU General Public License
    16   * along with this program.  If not, see <http://www.gnu.org/licenses/>.
    17   *
    18   */
    19  
    20  // Package store has support to use the Ubuntu Store for querying and downloading of snaps, and the related services.
    21  package store
    22  
    23  import (
    24  	"context"
    25  	"crypto"
    26  	"errors"
    27  	"fmt"
    28  	"io"
    29  	"net/http"
    30  	"net/url"
    31  	"os"
    32  	"os/exec"
    33  	"path/filepath"
    34  	"strings"
    35  	"time"
    36  
    37  	"github.com/juju/ratelimit"
    38  	"gopkg.in/retry.v1"
    39  
    40  	"github.com/snapcore/snapd/dirs"
    41  	"github.com/snapcore/snapd/httputil"
    42  	"github.com/snapcore/snapd/i18n"
    43  	"github.com/snapcore/snapd/logger"
    44  	"github.com/snapcore/snapd/osutil"
    45  	"github.com/snapcore/snapd/overlord/auth"
    46  	"github.com/snapcore/snapd/progress"
    47  	"github.com/snapcore/snapd/snap"
    48  	"github.com/snapcore/snapd/snapdtool"
    49  )
    50  
    51  var downloadRetryStrategy = retry.LimitCount(7, retry.LimitTime(90*time.Second,
    52  	retry.Exponential{
    53  		Initial: 500 * time.Millisecond,
    54  		Factor:  2.5,
    55  	},
    56  ))
    57  
    58  // Deltas enabled by default on classic, but allow opting in or out on both classic and core.
    59  func useDeltas() bool {
    60  	// only xdelta3 is supported for now, so check the binary exists here
    61  	// TODO: have a per-format checker instead
    62  	if _, err := getXdelta3Cmd(); err != nil {
    63  		return false
    64  	}
    65  
    66  	return osutil.GetenvBool("SNAPD_USE_DELTAS_EXPERIMENTAL", true)
    67  }
    68  
    69  func (s *Store) cdnHeader() (string, error) {
    70  	if s.noCDN {
    71  		return "none", nil
    72  	}
    73  
    74  	if s.dauthCtx == nil {
    75  		return "", nil
    76  	}
    77  
    78  	// set Snap-CDN from cloud instance information
    79  	// if available
    80  
    81  	// TODO: do we want a more complex retry strategy
    82  	// where we first to send this header and if the
    83  	// operation fails that way to even get the connection
    84  	// then we retry without sending this?
    85  
    86  	cloudInfo, err := s.dauthCtx.CloudInfo()
    87  	if err != nil {
    88  		return "", err
    89  	}
    90  
    91  	if cloudInfo != nil {
    92  		cdnParams := []string{fmt.Sprintf("cloud-name=%q", cloudInfo.Name)}
    93  		if cloudInfo.Region != "" {
    94  			cdnParams = append(cdnParams, fmt.Sprintf("region=%q", cloudInfo.Region))
    95  		}
    96  		if cloudInfo.AvailabilityZone != "" {
    97  			cdnParams = append(cdnParams, fmt.Sprintf("availability-zone=%q", cloudInfo.AvailabilityZone))
    98  		}
    99  
   100  		return strings.Join(cdnParams, " "), nil
   101  	}
   102  
   103  	return "", nil
   104  }
   105  
   106  type HashError struct {
   107  	name           string
   108  	sha3_384       string
   109  	targetSha3_384 string
   110  }
   111  
   112  func (e HashError) Error() string {
   113  	return fmt.Sprintf("sha3-384 mismatch for %q: got %s but expected %s", e.name, e.sha3_384, e.targetSha3_384)
   114  }
   115  
   116  type DownloadOptions struct {
   117  	RateLimit           int64
   118  	IsAutoRefresh       bool
   119  	LeavePartialOnError bool
   120  }
   121  
   122  // Download downloads the snap addressed by download info and returns its
   123  // filename.
   124  // The file is saved in temporary storage, and should be removed
   125  // after use to prevent the disk from running out of space.
   126  func (s *Store) Download(ctx context.Context, name string, targetPath string, downloadInfo *snap.DownloadInfo, pbar progress.Meter, user *auth.UserState, dlOpts *DownloadOptions) error {
   127  	if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {
   128  		return err
   129  	}
   130  
   131  	if err := s.cacher.Get(downloadInfo.Sha3_384, targetPath); err == nil {
   132  		logger.Debugf("Cache hit for SHA3_384 …%.5s.", downloadInfo.Sha3_384)
   133  		return nil
   134  	}
   135  
   136  	if useDeltas() {
   137  		logger.Debugf("Available deltas returned by store: %v", downloadInfo.Deltas)
   138  
   139  		if len(downloadInfo.Deltas) == 1 {
   140  			err := s.downloadAndApplyDelta(name, targetPath, downloadInfo, pbar, user, dlOpts)
   141  			if err == nil {
   142  				return nil
   143  			}
   144  			// We revert to normal downloads if there is any error.
   145  			logger.Noticef("Cannot download or apply deltas for %s: %v", name, err)
   146  		}
   147  	}
   148  
   149  	partialPath := targetPath + ".partial"
   150  	w, err := os.OpenFile(partialPath, os.O_RDWR|os.O_CREATE, 0600)
   151  	if err != nil {
   152  		return err
   153  	}
   154  	resume, err := w.Seek(0, os.SEEK_END)
   155  	if err != nil {
   156  		return err
   157  	}
   158  	defer func() {
   159  		fi, _ := w.Stat()
   160  		if cerr := w.Close(); cerr != nil && err == nil {
   161  			err = cerr
   162  		}
   163  		if err == nil {
   164  			return
   165  		}
   166  		if dlOpts == nil || !dlOpts.LeavePartialOnError || fi == nil || fi.Size() == 0 {
   167  			os.Remove(w.Name())
   168  		}
   169  	}()
   170  	if resume > 0 {
   171  		logger.Debugf("Resuming download of %q at %d.", partialPath, resume)
   172  	} else {
   173  		logger.Debugf("Starting download of %q.", partialPath)
   174  	}
   175  
   176  	authAvail, err := s.authAvailable(user)
   177  	if err != nil {
   178  		return err
   179  	}
   180  
   181  	url := downloadInfo.AnonDownloadURL
   182  	if url == "" || authAvail {
   183  		url = downloadInfo.DownloadURL
   184  	}
   185  
   186  	if downloadInfo.Size == 0 || resume < downloadInfo.Size {
   187  		err = download(ctx, name, downloadInfo.Sha3_384, url, user, s, w, resume, pbar, dlOpts)
   188  		if err != nil {
   189  			logger.Debugf("download of %q failed: %#v", url, err)
   190  		}
   191  	} else {
   192  		// we're done! check the hash though
   193  		h := crypto.SHA3_384.New()
   194  		if _, err := w.Seek(0, os.SEEK_SET); err != nil {
   195  			return err
   196  		}
   197  		if _, err := io.Copy(h, w); err != nil {
   198  			return err
   199  		}
   200  		actualSha3 := fmt.Sprintf("%x", h.Sum(nil))
   201  		if downloadInfo.Sha3_384 != actualSha3 {
   202  			err = HashError{name, actualSha3, downloadInfo.Sha3_384}
   203  		}
   204  	}
   205  	// If hashsum is incorrect retry once
   206  	if _, ok := err.(HashError); ok {
   207  		logger.Debugf("Hashsum error on download: %v", err.Error())
   208  		logger.Debugf("Truncating and trying again from scratch.")
   209  		err = w.Truncate(0)
   210  		if err != nil {
   211  			return err
   212  		}
   213  		_, err = w.Seek(0, os.SEEK_SET)
   214  		if err != nil {
   215  			return err
   216  		}
   217  		err = download(ctx, name, downloadInfo.Sha3_384, url, user, s, w, 0, pbar, nil)
   218  		if err != nil {
   219  			logger.Debugf("download of %q failed: %#v", url, err)
   220  		}
   221  	}
   222  
   223  	if err != nil {
   224  		return err
   225  	}
   226  
   227  	if err := os.Rename(w.Name(), targetPath); err != nil {
   228  		return err
   229  	}
   230  
   231  	if err := w.Sync(); err != nil {
   232  		return err
   233  	}
   234  
   235  	return s.cacher.Put(downloadInfo.Sha3_384, targetPath)
   236  }
   237  
   238  func downloadReqOpts(storeURL *url.URL, cdnHeader string, opts *DownloadOptions) *requestOptions {
   239  	reqOptions := requestOptions{
   240  		Method:       "GET",
   241  		URL:          storeURL,
   242  		ExtraHeaders: map[string]string{},
   243  		// FIXME: use the new headers? with
   244  		// APILevel: apiV2Endps,
   245  	}
   246  	if cdnHeader != "" {
   247  		reqOptions.ExtraHeaders["Snap-CDN"] = cdnHeader
   248  	}
   249  	if opts != nil && opts.IsAutoRefresh {
   250  		reqOptions.ExtraHeaders["Snap-Refresh-Reason"] = "scheduled"
   251  	}
   252  
   253  	return &reqOptions
   254  }
   255  
   256  var ratelimitReader = ratelimit.Reader
   257  
   258  var download = downloadImpl
   259  
   260  // download writes an http.Request showing a progress.Meter
   261  func downloadImpl(ctx context.Context, name, sha3_384, downloadURL string, user *auth.UserState, s *Store, w io.ReadWriteSeeker, resume int64, pbar progress.Meter, dlOpts *DownloadOptions) error {
   262  	if dlOpts == nil {
   263  		dlOpts = &DownloadOptions{}
   264  	}
   265  
   266  	storeURL, err := url.Parse(downloadURL)
   267  	if err != nil {
   268  		return err
   269  	}
   270  
   271  	cdnHeader, err := s.cdnHeader()
   272  	if err != nil {
   273  		return err
   274  	}
   275  
   276  	var finalErr error
   277  	var dlSize float64
   278  	startTime := time.Now()
   279  	for attempt := retry.Start(downloadRetryStrategy, nil); attempt.Next(); {
   280  		reqOptions := downloadReqOpts(storeURL, cdnHeader, dlOpts)
   281  
   282  		httputil.MaybeLogRetryAttempt(reqOptions.URL.String(), attempt, startTime)
   283  
   284  		h := crypto.SHA3_384.New()
   285  
   286  		if resume > 0 {
   287  			reqOptions.ExtraHeaders["Range"] = fmt.Sprintf("bytes=%d-", resume)
   288  			// seed the sha3 with the already local file
   289  			if _, err := w.Seek(0, os.SEEK_SET); err != nil {
   290  				return err
   291  			}
   292  			n, err := io.Copy(h, w)
   293  			if err != nil {
   294  				return err
   295  			}
   296  			if n != resume {
   297  				return fmt.Errorf("resume offset wrong: %d != %d", resume, n)
   298  			}
   299  		}
   300  
   301  		if cancelled(ctx) {
   302  			return fmt.Errorf("The download has been cancelled: %s", ctx.Err())
   303  		}
   304  		var resp *http.Response
   305  		cli := s.newHTTPClient(nil)
   306  		resp, finalErr = s.doRequest(ctx, cli, reqOptions, user)
   307  
   308  		if cancelled(ctx) {
   309  			return fmt.Errorf("The download has been cancelled: %s", ctx.Err())
   310  		}
   311  		if finalErr != nil {
   312  			if httputil.ShouldRetryAttempt(attempt, finalErr) {
   313  				continue
   314  			}
   315  			break
   316  		}
   317  		if resume > 0 && resp.StatusCode != 206 {
   318  			logger.Debugf("server does not support resume")
   319  			if _, err := w.Seek(0, os.SEEK_SET); err != nil {
   320  				return err
   321  			}
   322  			h = crypto.SHA3_384.New()
   323  			resume = 0
   324  		}
   325  		if httputil.ShouldRetryHttpResponse(attempt, resp) {
   326  			resp.Body.Close()
   327  			continue
   328  		}
   329  
   330  		defer resp.Body.Close()
   331  
   332  		switch resp.StatusCode {
   333  		case 200, 206: // OK, Partial Content
   334  		case 402: // Payment Required
   335  
   336  			return fmt.Errorf("please buy %s before installing it.", name)
   337  		default:
   338  			return &DownloadError{Code: resp.StatusCode, URL: resp.Request.URL}
   339  		}
   340  
   341  		if pbar == nil {
   342  			pbar = progress.Null
   343  		}
   344  		dlSize = float64(resp.ContentLength)
   345  		pbar.Start(name, dlSize)
   346  		mw := io.MultiWriter(w, h, pbar)
   347  		var limiter io.Reader
   348  		limiter = resp.Body
   349  		if limit := dlOpts.RateLimit; limit > 0 {
   350  			bucket := ratelimit.NewBucketWithRate(float64(limit), 2*limit)
   351  			limiter = ratelimitReader(resp.Body, bucket)
   352  		}
   353  		_, finalErr = io.Copy(mw, limiter)
   354  		pbar.Finished()
   355  		if finalErr != nil {
   356  			if httputil.ShouldRetryAttempt(attempt, finalErr) {
   357  				// error while downloading should resume
   358  				var seekerr error
   359  				resume, seekerr = w.Seek(0, os.SEEK_END)
   360  				if seekerr == nil {
   361  					continue
   362  				}
   363  				// if seek failed, then don't retry end return the original error
   364  			}
   365  			break
   366  		}
   367  
   368  		if cancelled(ctx) {
   369  			return fmt.Errorf("The download has been cancelled: %s", ctx.Err())
   370  		}
   371  
   372  		actualSha3 := fmt.Sprintf("%x", h.Sum(nil))
   373  		if sha3_384 != "" && sha3_384 != actualSha3 {
   374  			finalErr = HashError{name, actualSha3, sha3_384}
   375  		}
   376  		break
   377  	}
   378  	if finalErr == nil {
   379  		// not using quantity.FormatFoo as this is just for debug
   380  		dt := time.Since(startTime)
   381  		r := dlSize / dt.Seconds()
   382  		var p rune
   383  		for _, p = range " kMGTPEZY" {
   384  			if r < 1000 {
   385  				break
   386  			}
   387  			r /= 1000
   388  		}
   389  
   390  		logger.Debugf("Download succeeded in %.03fs (%.0f%cB/s).", dt.Seconds(), r, p)
   391  	}
   392  	return finalErr
   393  }
   394  
   395  // DownloadStream will copy the snap from the request to the io.Reader
   396  func (s *Store) DownloadStream(ctx context.Context, name string, downloadInfo *snap.DownloadInfo, resume int64, user *auth.UserState) (io.ReadCloser, int, error) {
   397  	// XXX: coverage of this is rather poor
   398  	if path := s.cacher.GetPath(downloadInfo.Sha3_384); path != "" {
   399  		logger.Debugf("Cache hit for SHA3_384 …%.5s.", downloadInfo.Sha3_384)
   400  		file, err := os.OpenFile(path, os.O_RDONLY, 0600)
   401  		if err != nil {
   402  			return nil, 0, err
   403  		}
   404  		if resume == 0 {
   405  			return file, 200, nil
   406  		}
   407  		_, err = file.Seek(resume, os.SEEK_SET)
   408  		if err != nil {
   409  			return nil, 0, err
   410  		}
   411  		return file, 206, nil
   412  	}
   413  
   414  	authAvail, err := s.authAvailable(user)
   415  	if err != nil {
   416  		return nil, 0, err
   417  	}
   418  
   419  	downloadURL := downloadInfo.AnonDownloadURL
   420  	if downloadURL == "" || authAvail {
   421  		downloadURL = downloadInfo.DownloadURL
   422  	}
   423  
   424  	storeURL, err := url.Parse(downloadURL)
   425  	if err != nil {
   426  		return nil, 0, err
   427  	}
   428  
   429  	cdnHeader, err := s.cdnHeader()
   430  	if err != nil {
   431  		return nil, 0, err
   432  	}
   433  
   434  	resp, err := doDownloadReq(ctx, storeURL, cdnHeader, resume, s, user)
   435  	if err != nil {
   436  		return nil, 0, err
   437  	}
   438  	return resp.Body, resp.StatusCode, nil
   439  }
   440  
   441  var doDownloadReq = doDownloadReqImpl
   442  
   443  func doDownloadReqImpl(ctx context.Context, storeURL *url.URL, cdnHeader string, resume int64, s *Store, user *auth.UserState) (*http.Response, error) {
   444  	reqOptions := downloadReqOpts(storeURL, cdnHeader, nil)
   445  	if resume > 0 {
   446  		reqOptions.ExtraHeaders["Range"] = fmt.Sprintf("bytes=%d-", resume)
   447  	}
   448  	cli := s.newHTTPClient(nil)
   449  	return s.doRequest(ctx, cli, reqOptions, user)
   450  }
   451  
   452  // downloadDelta downloads the delta for the preferred format, returning the path.
   453  func (s *Store) downloadDelta(deltaName string, downloadInfo *snap.DownloadInfo, w io.ReadWriteSeeker, pbar progress.Meter, user *auth.UserState, dlOpts *DownloadOptions) error {
   454  
   455  	if len(downloadInfo.Deltas) != 1 {
   456  		return errors.New("store returned more than one download delta")
   457  	}
   458  
   459  	deltaInfo := downloadInfo.Deltas[0]
   460  
   461  	if deltaInfo.Format != s.deltaFormat {
   462  		return fmt.Errorf("store returned unsupported delta format %q (only xdelta3 currently)", deltaInfo.Format)
   463  	}
   464  
   465  	authAvail, err := s.authAvailable(user)
   466  	if err != nil {
   467  		return err
   468  	}
   469  
   470  	url := deltaInfo.AnonDownloadURL
   471  	if url == "" || authAvail {
   472  		url = deltaInfo.DownloadURL
   473  	}
   474  
   475  	return download(context.TODO(), deltaName, deltaInfo.Sha3_384, url, user, s, w, 0, pbar, dlOpts)
   476  }
   477  
   478  func getXdelta3Cmd(args ...string) (*exec.Cmd, error) {
   479  	if osutil.ExecutableExists("xdelta3") {
   480  		return exec.Command("xdelta3", args...), nil
   481  	}
   482  	return snapdtool.CommandFromSystemSnap("/usr/bin/xdelta3", args...)
   483  }
   484  
   485  // applyDelta generates a target snap from a previously downloaded snap and a downloaded delta.
   486  var applyDelta = func(name string, deltaPath string, deltaInfo *snap.DeltaInfo, targetPath string, targetSha3_384 string) error {
   487  	snapBase := fmt.Sprintf("%s_%d.snap", name, deltaInfo.FromRevision)
   488  	snapPath := filepath.Join(dirs.SnapBlobDir, snapBase)
   489  
   490  	if !osutil.FileExists(snapPath) {
   491  		return fmt.Errorf("snap %q revision %d not found at %s", name, deltaInfo.FromRevision, snapPath)
   492  	}
   493  
   494  	if deltaInfo.Format != "xdelta3" {
   495  		return fmt.Errorf("cannot apply unsupported delta format %q (only xdelta3 currently)", deltaInfo.Format)
   496  	}
   497  
   498  	partialTargetPath := targetPath + ".partial"
   499  
   500  	xdelta3Args := []string{"-d", "-s", snapPath, deltaPath, partialTargetPath}
   501  	cmd, err := getXdelta3Cmd(xdelta3Args...)
   502  	if err != nil {
   503  		return err
   504  	}
   505  
   506  	if err := cmd.Run(); err != nil {
   507  		if err := os.Remove(partialTargetPath); err != nil {
   508  			logger.Noticef("failed to remove partial delta target %q: %s", partialTargetPath, err)
   509  		}
   510  		return err
   511  	}
   512  
   513  	if err := os.Chmod(partialTargetPath, 0600); err != nil {
   514  		return err
   515  	}
   516  
   517  	bsha3_384, _, err := osutil.FileDigest(partialTargetPath, crypto.SHA3_384)
   518  	if err != nil {
   519  		return err
   520  	}
   521  	sha3_384 := fmt.Sprintf("%x", bsha3_384)
   522  	if targetSha3_384 != "" && sha3_384 != targetSha3_384 {
   523  		if err := os.Remove(partialTargetPath); err != nil {
   524  			logger.Noticef("failed to remove partial delta target %q: %s", partialTargetPath, err)
   525  		}
   526  		return HashError{name, sha3_384, targetSha3_384}
   527  	}
   528  
   529  	if err := os.Rename(partialTargetPath, targetPath); err != nil {
   530  		return osutil.CopyFile(partialTargetPath, targetPath, 0)
   531  	}
   532  
   533  	return nil
   534  }
   535  
   536  // downloadAndApplyDelta downloads and then applies the delta to the current snap.
   537  func (s *Store) downloadAndApplyDelta(name, targetPath string, downloadInfo *snap.DownloadInfo, pbar progress.Meter, user *auth.UserState, dlOpts *DownloadOptions) error {
   538  	deltaInfo := &downloadInfo.Deltas[0]
   539  
   540  	deltaPath := fmt.Sprintf("%s.%s-%d-to-%d.partial", targetPath, deltaInfo.Format, deltaInfo.FromRevision, deltaInfo.ToRevision)
   541  	deltaName := fmt.Sprintf(i18n.G("%s (delta)"), name)
   542  
   543  	w, err := os.OpenFile(deltaPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600)
   544  	if err != nil {
   545  		return err
   546  	}
   547  	defer func() {
   548  		if cerr := w.Close(); cerr != nil && err == nil {
   549  			err = cerr
   550  		}
   551  		os.Remove(deltaPath)
   552  	}()
   553  
   554  	err = s.downloadDelta(deltaName, downloadInfo, w, pbar, user, dlOpts)
   555  	if err != nil {
   556  		return err
   557  	}
   558  
   559  	logger.Debugf("Successfully downloaded delta for %q at %s", name, deltaPath)
   560  	if err := applyDelta(name, deltaPath, deltaInfo, targetPath, downloadInfo.Sha3_384); err != nil {
   561  		return err
   562  	}
   563  
   564  	logger.Debugf("Successfully applied delta for %q at %s, saving %d bytes.", name, deltaPath, downloadInfo.Size-deltaInfo.Size)
   565  	return nil
   566  }
   567  
   568  func (s *Store) CacheDownloads() int {
   569  	return s.cfg.CacheDownloads
   570  }
   571  
   572  func (s *Store) SetCacheDownloads(fileCount int) {
   573  	s.cfg.CacheDownloads = fileCount
   574  	if fileCount > 0 {
   575  		s.cacher = NewCacheManager(dirs.SnapDownloadCacheDir, fileCount)
   576  	} else {
   577  		s.cacher = &nullCache{}
   578  	}
   579  }