github.com/jcarley/cli@v0.0.0-20180201210820-966d90434c30/lib/updater/updater.go (about)

     1  package updater
     2  
     3  // modified version of https://github.com/sanbornm/go-selfupdate/blob/master/selfupdate/selfupdate.go
     4  // 463b28194bdc57bd431b638b80fcbb20eeb0790a
     5  
     6  // Changes 9/10/15:
     7  //     strip all space from time read in from the cktime file
     8  //     removed all log statements
     9  // Changes 9/11/15:
    10  //     changed all usages of time to use validTime (this tells the program how long to wait before updating)
    11  //     added a ForcedUpgrade method to rewrite the valid cktime and do a BackgroundRun
    12  // Changes 6/6/16:
    13  //     removed all partial binary checking to cut down on release build time
    14  //     now every update is a full replacement
    15  
    16  // Update protocol:
    17  //
    18  //   GET hk.heroku.com/hk/linux-amd64.json
    19  //
    20  //   200 ok
    21  //   {
    22  //       "Version": "2",
    23  //       "Sha256": "..." // base64
    24  //   }
    25  //
    26  // then
    27  //
    28  //   GET hkpatch.s3.amazonaws.com/hk/1/2/linux-amd64
    29  //
    30  //   200 ok
    31  //   [bsdiff data]
    32  //
    33  // or
    34  //
    35  //   GET hkdist.s3.amazonaws.com/hk/2/linux-amd64.gz
    36  //
    37  //   200 ok
    38  //   [gzipped executable data]
    39  //
    40  //
    41  
    42  import (
    43  	"bytes"
    44  	"compress/gzip"
    45  	"crypto/sha256"
    46  	"encoding/json"
    47  	"errors"
    48  	"fmt"
    49  	"io"
    50  	"io/ioutil"
    51  	"net/http"
    52  	"os"
    53  	"path/filepath"
    54  	"runtime"
    55  	"strings"
    56  	"time"
    57  
    58  	"gopkg.in/inconshreveable/go-update.v0"
    59  
    60  	"github.com/Sirupsen/logrus"
    61  	"github.com/bugsnag/osext"
    62  	"github.com/daticahealth/cli/config"
    63  )
    64  
    65  const (
    66  	upcktimePath = "cktime"
    67  	plat         = runtime.GOOS + "-" + runtime.GOARCH
    68  )
    69  
    70  const validTime = 1 * 24 * time.Hour
    71  
    72  // AutoUpdater to perform full replacements on the CLI binary
    73  var AutoUpdater = &Updater{
    74  	CurrentVersion: config.VERSION,
    75  	APIURL:         "https://s3.amazonaws.com/cli-autoupdates/",
    76  	BinURL:         "https://s3.amazonaws.com/cli-autoupdates/",
    77  	DiffURL:        "https://s3.amazonaws.com/cli-autoupdates/",
    78  	Dir:            ".datica_update",
    79  	CmdName:        "datica",
    80  }
    81  
    82  // ErrHashMismatch represents a mismatch in the expected hash and the calculated hash
    83  var ErrHashMismatch = errors.New("new file hash mismatch after patch")
    84  var up = update.New()
    85  
    86  // Updater is the configuration and runtime data for doing an update.
    87  //
    88  // Note that ApiURL, BinURL and DiffURL should have the same value if all files are available at the same location.
    89  //
    90  // Example:
    91  //
    92  //  updater := &selfupdate.Updater{
    93  //  	CurrentVersion: version,
    94  //  	ApiURL:         "http://updates.yourdomain.com/",
    95  //  	BinURL:         "http://updates.yourdownmain.com/",
    96  //  	DiffURL:        "http://updates.yourdomain.com/",
    97  //  	Dir:            "update/",
    98  //  	CmdName:        "myapp", // app name
    99  //  }
   100  //  if updater != nil {
   101  //  	go updater.BackgroundRun()
   102  //  }
   103  type Updater struct {
   104  	CurrentVersion string // Currently running version.
   105  	APIURL         string // Base URL for API requests (json files).
   106  	CmdName        string // Command name is appended to the ApiURL like http://apiurl/CmdName/. This represents one binary.
   107  	BinURL         string // Base URL for full binary downloads.
   108  	DiffURL        string // Base URL for diff downloads.
   109  	Dir            string // Directory to store selfupdate state.
   110  	Info           struct {
   111  		Version string
   112  		Sha256  []byte
   113  	}
   114  }
   115  
   116  func (u *Updater) getExecRelativeDir(dir string) string {
   117  	filename, _ := osext.Executable()
   118  	path := filepath.Join(filepath.Dir(filename), dir)
   119  	return path
   120  }
   121  
   122  // BackgroundRun starts the update check and apply cycle.
   123  func (u *Updater) BackgroundRun() error {
   124  	os.MkdirAll(u.getExecRelativeDir(u.Dir), 0755)
   125  	if u.wantUpdate() {
   126  		if err := up.CanUpdate(); err != nil {
   127  			return err
   128  		}
   129  		if err := u.update(); err != nil {
   130  			return err
   131  		}
   132  	}
   133  	return nil
   134  }
   135  
   136  // ForcedUpgrade writes a time in the past to the cktime file and then triggers
   137  // the normal update process. This is useful when an update is required for
   138  // the program to continue functioning normally.
   139  func (u *Updater) ForcedUpgrade() error {
   140  	path := u.getExecRelativeDir(filepath.Join(u.Dir, upcktimePath))
   141  	writeTime(path, time.Now().Add(-1*validTime))
   142  	return u.BackgroundRun()
   143  }
   144  
   145  func (u *Updater) wantUpdate() bool {
   146  	path := u.getExecRelativeDir(filepath.Join(u.Dir, upcktimePath))
   147  	if u.CurrentVersion == "dev" || readTime(path).After(time.Now()) {
   148  		return false
   149  	}
   150  	return writeTime(path, time.Now().Add(validTime))
   151  }
   152  
   153  func (u *Updater) update() error {
   154  	path, err := osext.Executable()
   155  	if err != nil {
   156  		return err
   157  	}
   158  	old, err := os.Open(path)
   159  	if err != nil {
   160  		return err
   161  	}
   162  	defer old.Close()
   163  
   164  	err = u.FetchInfo()
   165  	if err != nil {
   166  		return err
   167  	}
   168  	if u.Info.Version <= u.CurrentVersion {
   169  		return nil
   170  	}
   171  
   172  	bin, err := u.fetchAndVerifyFullBin()
   173  	if err != nil {
   174  		if err == ErrHashMismatch {
   175  			logrus.Warnln("update: hash mismatch from full binary")
   176  		} else {
   177  			logrus.Warnln("update: error fetching full binary,", err)
   178  		}
   179  		logrus.Warnln("update: please update your CLI manually by downloading the latest version for your OS here https://github.com/daticahealth/cli/releases")
   180  		return err
   181  	}
   182  
   183  	// close the old binary before installing because on windows
   184  	// it can't be renamed if a handle to the file is still open
   185  	old.Close()
   186  
   187  	err, errRecover := up.FromStream(bytes.NewBuffer(bin))
   188  	if errRecover != nil {
   189  		return fmt.Errorf("update and recovery errors: %q %q", err, errRecover)
   190  	}
   191  	if err != nil {
   192  		return err
   193  	}
   194  	logrus.Println("update: your CLI has been successfully updated!")
   195  	return nil
   196  }
   197  
   198  // FetchInfo fetches and updates the info for latest CLI version available.
   199  func (u *Updater) FetchInfo() error {
   200  	r, err := fetch(u.APIURL + u.CmdName + "/" + plat + ".json")
   201  	if err != nil {
   202  		return err
   203  	}
   204  	defer r.Close()
   205  	err = json.NewDecoder(r).Decode(&u.Info)
   206  	if err != nil {
   207  		return err
   208  	}
   209  	if len(u.Info.Sha256) != sha256.Size {
   210  		return errors.New("bad cmd hash in info")
   211  	}
   212  	return nil
   213  }
   214  
   215  func (u *Updater) fetchAndVerifyFullBin() ([]byte, error) {
   216  	bin, err := u.fetchBin()
   217  	if err != nil {
   218  		return nil, err
   219  	}
   220  	verified := verifySha(bin, u.Info.Sha256)
   221  	if !verified {
   222  		return nil, ErrHashMismatch
   223  	}
   224  	return bin, nil
   225  }
   226  
   227  func (u *Updater) fetchBin() ([]byte, error) {
   228  	r, err := fetch(u.BinURL + u.CmdName + "/" + u.Info.Version + "/" + plat + ".gz")
   229  	if err != nil {
   230  		return nil, err
   231  	}
   232  	defer r.Close()
   233  	buf := new(bytes.Buffer)
   234  	gz, err := gzip.NewReader(r)
   235  	if err != nil {
   236  		return nil, err
   237  	}
   238  	if _, err = io.Copy(buf, gz); err != nil {
   239  		return nil, err
   240  	}
   241  
   242  	return buf.Bytes(), nil
   243  }
   244  
   245  func fetch(url string) (io.ReadCloser, error) {
   246  	resp, err := http.Get(url)
   247  	if err != nil {
   248  		return nil, err
   249  	}
   250  	if resp.StatusCode != 200 {
   251  		return nil, fmt.Errorf("bad http status from %s: %d", url, resp.StatusCode)
   252  	}
   253  	return resp.Body, nil
   254  }
   255  
   256  func readTime(path string) time.Time {
   257  	p, err := ioutil.ReadFile(path)
   258  	if os.IsNotExist(err) {
   259  		return time.Time{}
   260  	}
   261  	if err != nil {
   262  		return time.Now().Add(validTime)
   263  	}
   264  	t, err := time.Parse(time.RFC3339, strings.TrimSpace(string(p)))
   265  	if err != nil {
   266  		return time.Now().Add(validTime)
   267  	}
   268  	return t
   269  }
   270  
   271  func verifySha(bin []byte, sha []byte) bool {
   272  	h := sha256.New()
   273  	h.Write(bin)
   274  	return bytes.Equal(h.Sum(nil), sha)
   275  }
   276  
   277  func writeTime(path string, t time.Time) bool {
   278  	return ioutil.WriteFile(path, []byte(t.Format(time.RFC3339)), 0644) == nil
   279  }