updater.go 6.2 KB

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