updater.go 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228
  1. package lifecycle
  2. import (
  3. "context"
  4. "crypto/rand"
  5. "encoding/json"
  6. "errors"
  7. "fmt"
  8. "io"
  9. "log/slog"
  10. "mime"
  11. "net/http"
  12. "net/url"
  13. "os"
  14. "path"
  15. "path/filepath"
  16. "runtime"
  17. "strings"
  18. "time"
  19. "github.com/jmorganca/ollama/auth"
  20. "github.com/jmorganca/ollama/version"
  21. )
  22. var (
  23. UpdateCheckURLBase = "https://ollama.com/api/update"
  24. UpdateDownloaded = false
  25. UpdateCheckInterval = 60 * 60 * time.Second
  26. )
  27. // TODO - maybe move up to the API package?
  28. type UpdateResponse struct {
  29. UpdateURL string `json:"url"`
  30. UpdateVersion string `json:"version"`
  31. }
  32. func IsNewReleaseAvailable(ctx context.Context) (bool, UpdateResponse) {
  33. var updateResp UpdateResponse
  34. requestURL, err := url.Parse(UpdateCheckURLBase)
  35. if err != nil {
  36. return false, updateResp
  37. }
  38. query := requestURL.Query()
  39. query.Add("os", runtime.GOOS)
  40. query.Add("arch", runtime.GOARCH)
  41. query.Add("version", version.Version)
  42. query.Add("ts", fmt.Sprintf("%d", time.Now().Unix()))
  43. nonce, err := auth.NewNonce(rand.Reader, 16)
  44. if err != nil {
  45. return false, updateResp
  46. }
  47. query.Add("nonce", nonce)
  48. requestURL.RawQuery = query.Encode()
  49. data := []byte(fmt.Sprintf("%s,%s", http.MethodGet, requestURL.RequestURI()))
  50. signature, err := auth.Sign(ctx, data)
  51. if err != nil {
  52. return false, updateResp
  53. }
  54. req, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL.String(), nil)
  55. if err != nil {
  56. slog.Warn(fmt.Sprintf("failed to check for update: %s", err))
  57. return false, updateResp
  58. }
  59. req.Header.Set("Authorization", signature)
  60. req.Header.Set("User-Agent", fmt.Sprintf("ollama/%s (%s %s) Go/%s", version.Version, runtime.GOARCH, runtime.GOOS, runtime.Version()))
  61. slog.Debug("checking for available update", "requestURL", requestURL)
  62. resp, err := http.DefaultClient.Do(req)
  63. if err != nil {
  64. slog.Warn(fmt.Sprintf("failed to check for update: %s", err))
  65. return false, updateResp
  66. }
  67. defer resp.Body.Close()
  68. if resp.StatusCode == 204 {
  69. slog.Debug("check update response 204 (current version is up to date)")
  70. return false, updateResp
  71. }
  72. body, err := io.ReadAll(resp.Body)
  73. if err != nil {
  74. slog.Warn(fmt.Sprintf("failed to read body response: %s", err))
  75. }
  76. if resp.StatusCode != 200 {
  77. slog.Info(fmt.Sprintf("check update error %d - %.96s", resp.StatusCode, string(body)))
  78. return false, updateResp
  79. }
  80. err = json.Unmarshal(body, &updateResp)
  81. if err != nil {
  82. slog.Warn(fmt.Sprintf("malformed response checking for update: %s", err))
  83. return false, updateResp
  84. }
  85. // Extract the version string from the URL in the github release artifact path
  86. updateResp.UpdateVersion = path.Base(path.Dir(updateResp.UpdateURL))
  87. slog.Info("New update available at " + updateResp.UpdateURL)
  88. return true, updateResp
  89. }
  90. func DownloadNewRelease(ctx context.Context, updateResp UpdateResponse) error {
  91. // Do a head first to check etag info
  92. req, err := http.NewRequestWithContext(ctx, http.MethodHead, updateResp.UpdateURL, nil)
  93. if err != nil {
  94. return err
  95. }
  96. resp, err := http.DefaultClient.Do(req)
  97. if err != nil {
  98. return fmt.Errorf("error checking update: %w", err)
  99. }
  100. if resp.StatusCode != 200 {
  101. return fmt.Errorf("unexpected status attempting to download update %d", resp.StatusCode)
  102. }
  103. resp.Body.Close()
  104. etag := strings.Trim(resp.Header.Get("etag"), "\"")
  105. if etag == "" {
  106. slog.Debug("no etag detected, falling back to filename based dedup")
  107. etag = "_"
  108. }
  109. filename := Installer
  110. _, params, err := mime.ParseMediaType(resp.Header.Get("content-disposition"))
  111. if err == nil {
  112. filename = params["filename"]
  113. }
  114. stageFilename := filepath.Join(UpdateStageDir, etag, filename)
  115. // Check to see if we already have it downloaded
  116. _, err = os.Stat(stageFilename)
  117. if err == nil {
  118. slog.Info("update already downloaded")
  119. return nil
  120. }
  121. cleanupOldDownloads()
  122. req.Method = http.MethodGet
  123. resp, err = http.DefaultClient.Do(req)
  124. if err != nil {
  125. return fmt.Errorf("error checking update: %w", err)
  126. }
  127. defer resp.Body.Close()
  128. etag = strings.Trim(resp.Header.Get("etag"), "\"")
  129. if etag == "" {
  130. slog.Debug("no etag detected, falling back to filename based dedup") // TODO probably can get rid of this redundant log
  131. etag = "_"
  132. }
  133. stageFilename = filepath.Join(UpdateStageDir, etag, filename)
  134. _, err = os.Stat(filepath.Dir(stageFilename))
  135. if errors.Is(err, os.ErrNotExist) {
  136. if err := os.MkdirAll(filepath.Dir(stageFilename), 0o755); err != nil {
  137. return fmt.Errorf("create ollama dir %s: %v", filepath.Dir(stageFilename), err)
  138. }
  139. }
  140. payload, err := io.ReadAll(resp.Body)
  141. if err != nil {
  142. return fmt.Errorf("failed to read body response: %w", err)
  143. }
  144. fp, err := os.OpenFile(stageFilename, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o755)
  145. if err != nil {
  146. return fmt.Errorf("write payload %s: %w", stageFilename, err)
  147. }
  148. defer fp.Close()
  149. if n, err := fp.Write(payload); err != nil || n != len(payload) {
  150. return fmt.Errorf("write payload %s: %d vs %d -- %w", stageFilename, n, len(payload), err)
  151. }
  152. slog.Info("new update downloaded " + stageFilename)
  153. UpdateDownloaded = true
  154. return nil
  155. }
  156. func cleanupOldDownloads() {
  157. files, err := os.ReadDir(UpdateStageDir)
  158. if err != nil && errors.Is(err, os.ErrNotExist) {
  159. // Expected behavior on first run
  160. return
  161. } else if err != nil {
  162. slog.Warn(fmt.Sprintf("failed to list stage dir: %s", err))
  163. return
  164. }
  165. for _, file := range files {
  166. fullname := filepath.Join(UpdateStageDir, file.Name())
  167. slog.Debug("cleaning up old download: " + fullname)
  168. err = os.RemoveAll(fullname)
  169. if err != nil {
  170. slog.Warn(fmt.Sprintf("failed to cleanup stale update download %s", err))
  171. }
  172. }
  173. }
  174. func StartBackgroundUpdaterChecker(ctx context.Context, cb func(string) error) {
  175. go func() {
  176. // Don't blast an update message immediately after startup
  177. // time.Sleep(30 * time.Second)
  178. time.Sleep(3 * time.Second)
  179. for {
  180. available, resp := IsNewReleaseAvailable(ctx)
  181. if available {
  182. err := DownloadNewRelease(ctx, resp)
  183. if err != nil {
  184. slog.Error(fmt.Sprintf("failed to download new release: %s", err))
  185. }
  186. err = cb(resp.UpdateVersion)
  187. if err != nil {
  188. slog.Warn(fmt.Sprintf("failed to register update available with tray: %s", err))
  189. }
  190. }
  191. select {
  192. case <-ctx.Done():
  193. slog.Debug("stopping background update checker")
  194. return
  195. default:
  196. time.Sleep(UpdateCheckInterval)
  197. }
  198. }
  199. }()
  200. }