229 lines
6.2 KiB
Go
229 lines
6.2 KiB
Go
package lifecycle
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"mime"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/ollama/ollama/auth"
|
|
"github.com/ollama/ollama/version"
|
|
)
|
|
|
|
var (
|
|
UpdateCheckURLBase = "https://ollama.com/api/update"
|
|
UpdateDownloaded = false
|
|
UpdateCheckInterval = 60 * 60 * time.Second
|
|
)
|
|
|
|
// TODO - maybe move up to the API package?
|
|
type UpdateResponse struct {
|
|
UpdateURL string `json:"url"`
|
|
UpdateVersion string `json:"version"`
|
|
}
|
|
|
|
func IsNewReleaseAvailable(ctx context.Context) (bool, UpdateResponse) {
|
|
var updateResp UpdateResponse
|
|
|
|
requestURL, err := url.Parse(UpdateCheckURLBase)
|
|
if err != nil {
|
|
return false, updateResp
|
|
}
|
|
|
|
query := requestURL.Query()
|
|
query.Add("os", runtime.GOOS)
|
|
query.Add("arch", runtime.GOARCH)
|
|
query.Add("version", version.Version)
|
|
query.Add("ts", strconv.FormatInt(time.Now().Unix(), 10))
|
|
|
|
nonce, err := auth.NewNonce(rand.Reader, 16)
|
|
if err != nil {
|
|
return false, updateResp
|
|
}
|
|
|
|
query.Add("nonce", nonce)
|
|
requestURL.RawQuery = query.Encode()
|
|
|
|
data := []byte(fmt.Sprintf("%s,%s", http.MethodGet, requestURL.RequestURI()))
|
|
signature, err := auth.Sign(ctx, data)
|
|
if err != nil {
|
|
return false, updateResp
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL.String(), nil)
|
|
if err != nil {
|
|
slog.Warn(fmt.Sprintf("failed to check for update: %s", err))
|
|
return false, updateResp
|
|
}
|
|
req.Header.Set("Authorization", signature)
|
|
req.Header.Set("User-Agent", fmt.Sprintf("ollama/%s (%s %s) Go/%s", version.Version, runtime.GOARCH, runtime.GOOS, runtime.Version()))
|
|
|
|
slog.Debug("checking for available update", "requestURL", requestURL)
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
slog.Warn(fmt.Sprintf("failed to check for update: %s", err))
|
|
return false, updateResp
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode == http.StatusNoContent {
|
|
slog.Debug("check update response 204 (current version is up to date)")
|
|
return false, updateResp
|
|
}
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
slog.Warn(fmt.Sprintf("failed to read body response: %s", err))
|
|
}
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
slog.Info(fmt.Sprintf("check update error %d - %.96s", resp.StatusCode, string(body)))
|
|
return false, updateResp
|
|
}
|
|
err = json.Unmarshal(body, &updateResp)
|
|
if err != nil {
|
|
slog.Warn(fmt.Sprintf("malformed response checking for update: %s", err))
|
|
return false, updateResp
|
|
}
|
|
// Extract the version string from the URL in the github release artifact path
|
|
updateResp.UpdateVersion = path.Base(path.Dir(updateResp.UpdateURL))
|
|
|
|
slog.Info("New update available at " + updateResp.UpdateURL)
|
|
return true, updateResp
|
|
}
|
|
|
|
func DownloadNewRelease(ctx context.Context, updateResp UpdateResponse) error {
|
|
// Do a head first to check etag info
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodHead, updateResp.UpdateURL, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("error checking update: %w", err)
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("unexpected status attempting to download update %d", resp.StatusCode)
|
|
}
|
|
resp.Body.Close()
|
|
etag := strings.Trim(resp.Header.Get("etag"), "\"")
|
|
if etag == "" {
|
|
slog.Debug("no etag detected, falling back to filename based dedup")
|
|
etag = "_"
|
|
}
|
|
filename := Installer
|
|
_, params, err := mime.ParseMediaType(resp.Header.Get("content-disposition"))
|
|
if err == nil {
|
|
filename = params["filename"]
|
|
}
|
|
|
|
stageFilename := filepath.Join(UpdateStageDir, etag, filename)
|
|
|
|
// Check to see if we already have it downloaded
|
|
_, err = os.Stat(stageFilename)
|
|
if err == nil {
|
|
slog.Info("update already downloaded")
|
|
return nil
|
|
}
|
|
|
|
cleanupOldDownloads()
|
|
|
|
req.Method = http.MethodGet
|
|
resp, err = http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("error checking update: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
etag = strings.Trim(resp.Header.Get("etag"), "\"")
|
|
if etag == "" {
|
|
slog.Debug("no etag detected, falling back to filename based dedup") // TODO probably can get rid of this redundant log
|
|
etag = "_"
|
|
}
|
|
|
|
stageFilename = filepath.Join(UpdateStageDir, etag, filename)
|
|
|
|
_, err = os.Stat(filepath.Dir(stageFilename))
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
if err := os.MkdirAll(filepath.Dir(stageFilename), 0o755); err != nil {
|
|
return fmt.Errorf("create ollama dir %s: %v", filepath.Dir(stageFilename), err)
|
|
}
|
|
}
|
|
|
|
payload, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read body response: %w", err)
|
|
}
|
|
fp, err := os.OpenFile(stageFilename, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o755)
|
|
if err != nil {
|
|
return fmt.Errorf("write payload %s: %w", stageFilename, err)
|
|
}
|
|
defer fp.Close()
|
|
if n, err := fp.Write(payload); err != nil || n != len(payload) {
|
|
return fmt.Errorf("write payload %s: %d vs %d -- %w", stageFilename, n, len(payload), err)
|
|
}
|
|
slog.Info("new update downloaded " + stageFilename)
|
|
|
|
UpdateDownloaded = true
|
|
return nil
|
|
}
|
|
|
|
func cleanupOldDownloads() {
|
|
files, err := os.ReadDir(UpdateStageDir)
|
|
if err != nil && errors.Is(err, os.ErrNotExist) {
|
|
// Expected behavior on first run
|
|
return
|
|
} else if err != nil {
|
|
slog.Warn(fmt.Sprintf("failed to list stage dir: %s", err))
|
|
return
|
|
}
|
|
for _, file := range files {
|
|
fullname := filepath.Join(UpdateStageDir, file.Name())
|
|
slog.Debug("cleaning up old download: " + fullname)
|
|
err = os.RemoveAll(fullname)
|
|
if err != nil {
|
|
slog.Warn(fmt.Sprintf("failed to cleanup stale update download %s", err))
|
|
}
|
|
}
|
|
}
|
|
|
|
func StartBackgroundUpdaterChecker(ctx context.Context, cb func(string) error) {
|
|
go func() {
|
|
// Don't blast an update message immediately after startup
|
|
// time.Sleep(30 * time.Second)
|
|
time.Sleep(3 * time.Second)
|
|
|
|
for {
|
|
available, resp := IsNewReleaseAvailable(ctx)
|
|
if available {
|
|
err := DownloadNewRelease(ctx, resp)
|
|
if err != nil {
|
|
slog.Error(fmt.Sprintf("failed to download new release: %s", err))
|
|
}
|
|
err = cb(resp.UpdateVersion)
|
|
if err != nil {
|
|
slog.Warn(fmt.Sprintf("failed to register update available with tray: %s", err))
|
|
}
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
slog.Debug("stopping background update checker")
|
|
return
|
|
default:
|
|
time.Sleep(UpdateCheckInterval)
|
|
}
|
|
}
|
|
}()
|
|
}
|