| package launcher |
|
|
| import ( |
| "bufio" |
| "crypto/sha256" |
| "encoding/hex" |
| "encoding/json" |
| "fmt" |
| "io" |
| "log" |
| "net/http" |
| "os" |
| "os/exec" |
| "path/filepath" |
| "runtime" |
| "strings" |
| "time" |
|
|
| "github.com/mudler/LocalAI/internal" |
| ) |
|
|
| |
| type Release struct { |
| Version string `json:"tag_name"` |
| Name string `json:"name"` |
| Body string `json:"body"` |
| PublishedAt time.Time `json:"published_at"` |
| Assets []Asset `json:"assets"` |
| } |
|
|
| |
| type Asset struct { |
| Name string `json:"name"` |
| BrowserDownloadURL string `json:"browser_download_url"` |
| Size int64 `json:"size"` |
| } |
|
|
| |
| type ReleaseManager struct { |
| |
| GitHubOwner string |
| |
| GitHubRepo string |
| |
| BinaryPath string |
| |
| CurrentVersion string |
| |
| ChecksumsPath string |
| |
| MetadataPath string |
| |
| HTTPClient *http.Client |
| } |
|
|
| |
| func NewReleaseManager() *ReleaseManager { |
| homeDir, _ := os.UserHomeDir() |
| binaryPath := filepath.Join(homeDir, ".localai", "bin") |
| checksumsPath := filepath.Join(homeDir, ".localai", "checksums") |
| metadataPath := filepath.Join(homeDir, ".localai", "metadata") |
|
|
| return &ReleaseManager{ |
| GitHubOwner: "mudler", |
| GitHubRepo: "LocalAI", |
| BinaryPath: binaryPath, |
| CurrentVersion: internal.PrintableVersion(), |
| ChecksumsPath: checksumsPath, |
| MetadataPath: metadataPath, |
| HTTPClient: &http.Client{ |
| Timeout: 30 * time.Second, |
| }, |
| } |
| } |
|
|
| |
| func (rm *ReleaseManager) GetLatestRelease() (*Release, error) { |
| url := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", rm.GitHubOwner, rm.GitHubRepo) |
|
|
| resp, err := rm.HTTPClient.Get(url) |
| if err != nil { |
| return nil, fmt.Errorf("failed to fetch latest release: %w", err) |
| } |
| defer resp.Body.Close() |
|
|
| if resp.StatusCode != http.StatusOK { |
| return nil, fmt.Errorf("failed to fetch latest release: status %d", resp.StatusCode) |
| } |
|
|
| |
| body, err := io.ReadAll(resp.Body) |
| if err != nil { |
| return nil, fmt.Errorf("failed to read response body: %w", err) |
| } |
|
|
| release := &Release{} |
| if err := json.Unmarshal(body, release); err != nil { |
| return nil, fmt.Errorf("failed to parse JSON response: %w", err) |
| } |
|
|
| |
| if release.Version == "" { |
| return nil, fmt.Errorf("no version found in release data") |
| } |
|
|
| return release, nil |
| } |
|
|
| |
| func (rm *ReleaseManager) DownloadRelease(version string, progressCallback func(float64)) error { |
| |
| if err := os.MkdirAll(rm.BinaryPath, 0755); err != nil { |
| return fmt.Errorf("failed to create binary directory: %w", err) |
| } |
|
|
| |
| binaryName := rm.GetBinaryName(version) |
| localPath := filepath.Join(rm.BinaryPath, "local-ai") |
|
|
| |
| downloadURL := fmt.Sprintf("https://github.com/%s/%s/releases/download/%s/%s", |
| rm.GitHubOwner, rm.GitHubRepo, version, binaryName) |
|
|
| if err := rm.downloadFile(downloadURL, localPath, progressCallback); err != nil { |
| return fmt.Errorf("failed to download binary: %w", err) |
| } |
|
|
| |
| checksumURL := fmt.Sprintf("https://github.com/%s/%s/releases/download/%s/LocalAI-%s-checksums.txt", |
| rm.GitHubOwner, rm.GitHubRepo, version, version) |
|
|
| checksumPath := filepath.Join(rm.BinaryPath, "checksums.txt") |
| manualChecksumPath := filepath.Join(rm.ChecksumsPath, fmt.Sprintf("checksums-%s.txt", version)) |
|
|
| |
| |
| var downloadErr error |
| if _, err := os.Stat(manualChecksumPath); err == nil { |
| log.Printf("Using existing checksums from: %s", manualChecksumPath) |
| checksumPath = manualChecksumPath |
| } else if _, err := os.Stat(checksumPath); err == nil { |
| log.Printf("Using existing checksums from: %s", checksumPath) |
| } else { |
| |
| downloadErr = rm.downloadFile(checksumURL, checksumPath, nil) |
|
|
| if downloadErr != nil { |
| log.Printf("Warning: failed to download checksums: %v", downloadErr) |
| log.Printf("Warning: Checksum verification will be skipped. For security, you can manually place checksums at: %s", manualChecksumPath) |
| log.Printf("Download checksums from: %s", checksumURL) |
| |
| } |
| } |
|
|
| |
| if _, err := os.Stat(checksumPath); err == nil { |
| if err := rm.VerifyChecksum(localPath, checksumPath, binaryName); err != nil { |
| return fmt.Errorf("checksum verification failed: %w", err) |
| } |
| log.Printf("Checksum verification successful") |
|
|
| |
| if downloadErr == nil { |
| if err := rm.saveChecksums(version, checksumPath, binaryName); err != nil { |
| log.Printf("Warning: failed to save checksums: %v", err) |
| } |
| } |
| } else { |
| log.Printf("Warning: Proceeding without checksum verification") |
| } |
|
|
| |
| if err := os.Chmod(localPath, 0755); err != nil { |
| return fmt.Errorf("failed to make binary executable: %w", err) |
| } |
|
|
| return nil |
| } |
|
|
| |
| func (rm *ReleaseManager) GetBinaryName(version string) string { |
| versionStr := strings.TrimPrefix(version, "v") |
| os := runtime.GOOS |
| arch := runtime.GOARCH |
|
|
| |
| switch arch { |
| case "amd64": |
| arch = "amd64" |
| case "arm64": |
| arch = "arm64" |
| default: |
| arch = "amd64" |
| } |
|
|
| return fmt.Sprintf("local-ai-v%s-%s-%s", versionStr, os, arch) |
| } |
|
|
| |
| func (rm *ReleaseManager) downloadFile(url, filepath string, progressCallback func(float64)) error { |
| return rm.downloadFileWithRetry(url, filepath, progressCallback, 3) |
| } |
|
|
| |
| func (rm *ReleaseManager) downloadFileWithRetry(url, filepath string, progressCallback func(float64), maxRetries int) error { |
| var lastErr error |
|
|
| for attempt := 1; attempt <= maxRetries; attempt++ { |
| if attempt > 1 { |
| log.Printf("Retrying download (attempt %d/%d): %s", attempt, maxRetries, url) |
| time.Sleep(time.Duration(attempt) * time.Second) |
| } |
|
|
| resp, err := rm.HTTPClient.Get(url) |
| if err != nil { |
| lastErr = err |
| continue |
| } |
|
|
| if resp.StatusCode != http.StatusOK { |
| resp.Body.Close() |
| lastErr = fmt.Errorf("bad status: %s", resp.Status) |
| continue |
| } |
|
|
| out, err := os.Create(filepath) |
| if err != nil { |
| resp.Body.Close() |
| return err |
| } |
|
|
| |
| var reader io.Reader = resp.Body |
| if progressCallback != nil && resp.ContentLength > 0 { |
| reader = &progressReader{ |
| Reader: resp.Body, |
| Total: resp.ContentLength, |
| Callback: progressCallback, |
| } |
| } |
|
|
| _, err = io.Copy(out, reader) |
| resp.Body.Close() |
| out.Close() |
|
|
| if err != nil { |
| lastErr = err |
| os.Remove(filepath) |
| continue |
| } |
|
|
| return nil |
| } |
|
|
| return fmt.Errorf("failed after %d attempts: %w", maxRetries, lastErr) |
| } |
|
|
| |
| func (rm *ReleaseManager) saveChecksums(version, checksumPath, binaryName string) error { |
| |
| if err := os.MkdirAll(rm.ChecksumsPath, 0755); err != nil { |
| return fmt.Errorf("failed to create checksums directory: %w", err) |
| } |
|
|
| |
| checksumData, err := os.ReadFile(checksumPath) |
| if err != nil { |
| return fmt.Errorf("failed to read checksums file: %w", err) |
| } |
|
|
| |
| persistentPath := filepath.Join(rm.ChecksumsPath, fmt.Sprintf("checksums-%s.txt", version)) |
| if err := os.WriteFile(persistentPath, checksumData, 0644); err != nil { |
| return fmt.Errorf("failed to write persistent checksums: %w", err) |
| } |
|
|
| |
| latestPath := filepath.Join(rm.ChecksumsPath, "checksums-latest.txt") |
| if err := os.WriteFile(latestPath, checksumData, 0644); err != nil { |
| return fmt.Errorf("failed to write latest checksums: %w", err) |
| } |
|
|
| |
| if err := rm.saveVersionMetadata(version); err != nil { |
| log.Printf("Warning: failed to save version metadata: %v", err) |
| } |
|
|
| log.Printf("Checksums saved for version %s", version) |
| return nil |
| } |
|
|
| |
| func (rm *ReleaseManager) saveVersionMetadata(version string) error { |
| |
| if err := os.MkdirAll(rm.MetadataPath, 0755); err != nil { |
| return fmt.Errorf("failed to create metadata directory: %w", err) |
| } |
|
|
| |
| metadata := struct { |
| Version string `json:"version"` |
| InstalledAt time.Time `json:"installed_at"` |
| BinaryPath string `json:"binary_path"` |
| }{ |
| Version: version, |
| InstalledAt: time.Now(), |
| BinaryPath: rm.GetBinaryPath(), |
| } |
|
|
| |
| metadataData, err := json.MarshalIndent(metadata, "", " ") |
| if err != nil { |
| return fmt.Errorf("failed to marshal metadata: %w", err) |
| } |
|
|
| |
| metadataPath := filepath.Join(rm.MetadataPath, "installed-version.json") |
| if err := os.WriteFile(metadataPath, metadataData, 0644); err != nil { |
| return fmt.Errorf("failed to write metadata file: %w", err) |
| } |
|
|
| log.Printf("Version metadata saved: %s", version) |
| return nil |
| } |
|
|
| |
| type progressReader struct { |
| io.Reader |
| Total int64 |
| Current int64 |
| Callback func(float64) |
| } |
|
|
| func (pr *progressReader) Read(p []byte) (int, error) { |
| n, err := pr.Reader.Read(p) |
| pr.Current += int64(n) |
| if pr.Callback != nil { |
| progress := float64(pr.Current) / float64(pr.Total) |
| pr.Callback(progress) |
| } |
| return n, err |
| } |
|
|
| |
| func (rm *ReleaseManager) VerifyChecksum(filePath, checksumPath, binaryName string) error { |
| |
| file, err := os.Open(filePath) |
| if err != nil { |
| return fmt.Errorf("failed to open file for checksum: %w", err) |
| } |
| defer file.Close() |
|
|
| hasher := sha256.New() |
| if _, err := io.Copy(hasher, file); err != nil { |
| return fmt.Errorf("failed to calculate checksum: %w", err) |
| } |
|
|
| calculatedHash := hex.EncodeToString(hasher.Sum(nil)) |
|
|
| |
| checksumFile, err := os.Open(checksumPath) |
| if err != nil { |
| return fmt.Errorf("failed to open checksums file: %w", err) |
| } |
| defer checksumFile.Close() |
|
|
| scanner := bufio.NewScanner(checksumFile) |
| for scanner.Scan() { |
| line := strings.TrimSpace(scanner.Text()) |
| if strings.Contains(line, binaryName) { |
| parts := strings.Fields(line) |
| if len(parts) >= 2 { |
| expectedHash := parts[0] |
| if calculatedHash == expectedHash { |
| return nil |
| } |
| return fmt.Errorf("checksum mismatch: expected %s, got %s", expectedHash, calculatedHash) |
| } |
| } |
| } |
|
|
| return fmt.Errorf("checksum not found for %s", binaryName) |
| } |
|
|
| |
| func (rm *ReleaseManager) GetInstalledVersion() string { |
|
|
| |
| binaryPath := rm.GetBinaryPath() |
| if _, err := os.Stat(binaryPath); os.IsNotExist(err) { |
| return "" |
| } |
|
|
| |
| if version := rm.loadVersionMetadata(); version != "" { |
| return version |
| } |
|
|
| |
| version, err := exec.Command(binaryPath, "--version").Output() |
| if err != nil { |
| |
| log.Printf("Binary exists but --version failed: %v", err) |
| return "" |
| } |
|
|
| stringVersion := strings.TrimSpace(string(version)) |
| stringVersion = strings.TrimRight(stringVersion, "\n") |
|
|
| return stringVersion |
| } |
|
|
| |
| func (rm *ReleaseManager) loadVersionMetadata() string { |
| metadataPath := filepath.Join(rm.MetadataPath, "installed-version.json") |
|
|
| |
| if _, err := os.Stat(metadataPath); os.IsNotExist(err) { |
| return "" |
| } |
|
|
| |
| metadataData, err := os.ReadFile(metadataPath) |
| if err != nil { |
| log.Printf("Failed to read metadata file: %v", err) |
| return "" |
| } |
|
|
| |
| var metadata struct { |
| Version string `json:"version"` |
| InstalledAt time.Time `json:"installed_at"` |
| BinaryPath string `json:"binary_path"` |
| } |
|
|
| if err := json.Unmarshal(metadataData, &metadata); err != nil { |
| log.Printf("Failed to parse metadata file: %v", err) |
| return "" |
| } |
|
|
| |
| if metadata.BinaryPath != rm.GetBinaryPath() { |
| log.Printf("Binary path mismatch in metadata, ignoring") |
| return "" |
| } |
|
|
| log.Printf("Loaded version from metadata: %s (installed at %s)", metadata.Version, metadata.InstalledAt.Format("2006-01-02 15:04:05")) |
| return metadata.Version |
| } |
|
|
| |
| func (rm *ReleaseManager) GetBinaryPath() string { |
| return filepath.Join(rm.BinaryPath, "local-ai") |
| } |
|
|
| |
| func (rm *ReleaseManager) IsUpdateAvailable() (bool, string, error) { |
| log.Printf("IsUpdateAvailable: checking for updates...") |
|
|
| latest, err := rm.GetLatestRelease() |
| if err != nil { |
| log.Printf("IsUpdateAvailable: failed to get latest release: %v", err) |
| return false, "", err |
| } |
| log.Printf("IsUpdateAvailable: latest release version: %s", latest.Version) |
|
|
| current := rm.GetInstalledVersion() |
| log.Printf("IsUpdateAvailable: current installed version: %s", current) |
|
|
| if current == "" { |
| |
| log.Printf("IsUpdateAvailable: no version installed, offering latest: %s", latest.Version) |
| return true, latest.Version, nil |
| } |
|
|
| updateAvailable := latest.Version != current |
| log.Printf("IsUpdateAvailable: update available: %v (latest: %s, current: %s)", updateAvailable, latest.Version, current) |
| return updateAvailable, latest.Version, nil |
| } |
|
|
| |
| func (rm *ReleaseManager) IsLocalAIInstalled() bool { |
| binaryPath := rm.GetBinaryPath() |
| if _, err := os.Stat(binaryPath); os.IsNotExist(err) { |
| return false |
| } |
|
|
| |
| if err := rm.VerifyInstalledBinary(); err != nil { |
| log.Printf("Binary integrity check failed: %v", err) |
| |
| if removeErr := os.Remove(binaryPath); removeErr != nil { |
| log.Printf("Failed to remove corrupted binary: %v", removeErr) |
| } |
| return false |
| } |
|
|
| return true |
| } |
|
|
| |
| func (rm *ReleaseManager) VerifyInstalledBinary() error { |
| binaryPath := rm.GetBinaryPath() |
|
|
| |
| latestChecksumsPath := filepath.Join(rm.ChecksumsPath, "checksums-latest.txt") |
| if _, err := os.Stat(latestChecksumsPath); os.IsNotExist(err) { |
| return fmt.Errorf("no saved checksums found") |
| } |
|
|
| |
| currentVersion := rm.loadVersionMetadata() |
| if currentVersion == "" { |
| return fmt.Errorf("cannot determine current version from metadata") |
| } |
|
|
| binaryName := rm.GetBinaryName(currentVersion) |
|
|
| |
| return rm.VerifyChecksum(binaryPath, latestChecksumsPath, binaryName) |
| } |
|
|
| |
| func (rm *ReleaseManager) CleanupPartialDownloads() error { |
| binaryPath := rm.GetBinaryPath() |
|
|
| |
| if _, err := os.Stat(binaryPath); err == nil { |
| |
| if verifyErr := rm.VerifyInstalledBinary(); verifyErr != nil { |
| log.Printf("Found corrupted binary, removing: %v", verifyErr) |
| if removeErr := os.Remove(binaryPath); removeErr != nil { |
| log.Printf("Failed to remove corrupted binary: %v", removeErr) |
| } |
| |
| rm.clearVersionMetadata() |
| } |
| } |
|
|
| |
| tempChecksumsPath := filepath.Join(rm.BinaryPath, "checksums.txt") |
| if _, err := os.Stat(tempChecksumsPath); err == nil { |
| if removeErr := os.Remove(tempChecksumsPath); removeErr != nil { |
| log.Printf("Failed to remove temporary checksums: %v", removeErr) |
| } |
| } |
|
|
| return nil |
| } |
|
|
| |
| func (rm *ReleaseManager) clearVersionMetadata() { |
| metadataPath := filepath.Join(rm.MetadataPath, "installed-version.json") |
| if err := os.Remove(metadataPath); err != nil && !os.IsNotExist(err) { |
| log.Printf("Failed to clear version metadata: %v", err) |
| } else { |
| log.Printf("Version metadata cleared") |
| } |
| } |
|
|