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 }