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" ) // Release represents a LocalAI release 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"` } // Asset represents a release asset type Asset struct { Name string `json:"name"` BrowserDownloadURL string `json:"browser_download_url"` Size int64 `json:"size"` } // ReleaseManager handles LocalAI release management type ReleaseManager struct { // GitHubOwner is the GitHub repository owner GitHubOwner string // GitHubRepo is the GitHub repository name GitHubRepo string // BinaryPath is where the LocalAI binary is stored locally BinaryPath string // CurrentVersion is the currently installed version CurrentVersion string // ChecksumsPath is where checksums are stored ChecksumsPath string // MetadataPath is where version metadata is stored MetadataPath string // HTTPClient is the HTTP client used for downloads HTTPClient *http.Client } // NewReleaseManager creates a new release manager 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, }, } } // GetLatestRelease fetches the latest release information from GitHub 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) } // Parse the JSON response properly 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) } // Validate the release data if release.Version == "" { return nil, fmt.Errorf("no version found in release data") } return release, nil } // DownloadRelease downloads a specific version of LocalAI func (rm *ReleaseManager) DownloadRelease(version string, progressCallback func(float64)) error { // Ensure the binary directory exists if err := os.MkdirAll(rm.BinaryPath, 0755); err != nil { return fmt.Errorf("failed to create binary directory: %w", err) } // Determine the binary name based on OS and architecture binaryName := rm.GetBinaryName(version) localPath := filepath.Join(rm.BinaryPath, "local-ai") // Download the binary 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) } // Download and verify checksums 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)) // First, check if there's already a checksum file (either manually placed or previously downloaded) // and honor that, skipping download entirely in such case 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 { // No existing checksum file found, try to download 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) // Continue without verification - log warning but don't fail } } // Verify the checksum if we have a checksum file 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") // Save checksums persistently for future verification 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") } // Make the binary executable if err := os.Chmod(localPath, 0755); err != nil { return fmt.Errorf("failed to make binary executable: %w", err) } return nil } // GetBinaryName returns the appropriate binary name for the current platform func (rm *ReleaseManager) GetBinaryName(version string) string { versionStr := strings.TrimPrefix(version, "v") os := runtime.GOOS arch := runtime.GOARCH // Map Go arch names to the release naming convention switch arch { case "amd64": arch = "amd64" case "arm64": arch = "arm64" default: arch = "amd64" // fallback } return fmt.Sprintf("local-ai-v%s-%s-%s", versionStr, os, arch) } // downloadFile downloads a file from a URL to a local path with optional progress callback func (rm *ReleaseManager) downloadFile(url, filepath string, progressCallback func(float64)) error { return rm.downloadFileWithRetry(url, filepath, progressCallback, 3) } // downloadFileWithRetry downloads a file from a URL with retry logic 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 } // Create a progress reader if callback is provided 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) } // saveChecksums saves checksums persistently for future verification func (rm *ReleaseManager) saveChecksums(version, checksumPath, binaryName string) error { // Ensure checksums directory exists if err := os.MkdirAll(rm.ChecksumsPath, 0755); err != nil { return fmt.Errorf("failed to create checksums directory: %w", err) } // Read the downloaded checksums file checksumData, err := os.ReadFile(checksumPath) if err != nil { return fmt.Errorf("failed to read checksums file: %w", err) } // Save to persistent location with version info 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) } // Also save a "latest" checksums file for the current version 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) } // Save version metadata 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 } // saveVersionMetadata saves the installed version information func (rm *ReleaseManager) saveVersionMetadata(version string) error { // Ensure metadata directory exists if err := os.MkdirAll(rm.MetadataPath, 0755); err != nil { return fmt.Errorf("failed to create metadata directory: %w", err) } // Create metadata structure 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(), } // Marshal to JSON metadataData, err := json.MarshalIndent(metadata, "", " ") if err != nil { return fmt.Errorf("failed to marshal metadata: %w", err) } // Save metadata file 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 } // progressReader wraps an io.Reader to provide download progress 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 } // VerifyChecksum verifies the downloaded file against the provided checksums func (rm *ReleaseManager) VerifyChecksum(filePath, checksumPath, binaryName string) error { // Calculate the SHA256 of the downloaded file 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)) // Read the checksums file 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 // Checksum verified } return fmt.Errorf("checksum mismatch: expected %s, got %s", expectedHash, calculatedHash) } } } return fmt.Errorf("checksum not found for %s", binaryName) } // GetInstalledVersion returns the currently installed version func (rm *ReleaseManager) GetInstalledVersion() string { // Fallback: Check if the LocalAI binary exists and try to get its version binaryPath := rm.GetBinaryPath() if _, err := os.Stat(binaryPath); os.IsNotExist(err) { return "" // No version installed } // try to get version from metadata if version := rm.loadVersionMetadata(); version != "" { return version } // Try to run the binary to get the version (fallback method) version, err := exec.Command(binaryPath, "--version").Output() if err != nil { // If binary exists but --version fails, try to determine from filename or other means log.Printf("Binary exists but --version failed: %v", err) return "" } stringVersion := strings.TrimSpace(string(version)) stringVersion = strings.TrimRight(stringVersion, "\n") return stringVersion } // loadVersionMetadata loads the installed version from metadata file func (rm *ReleaseManager) loadVersionMetadata() string { metadataPath := filepath.Join(rm.MetadataPath, "installed-version.json") // Check if metadata file exists if _, err := os.Stat(metadataPath); os.IsNotExist(err) { return "" } // Read metadata file metadataData, err := os.ReadFile(metadataPath) if err != nil { log.Printf("Failed to read metadata file: %v", err) return "" } // Parse metadata 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 "" } // Verify that the binary path in metadata matches current binary path 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 } // GetBinaryPath returns the path to the LocalAI binary func (rm *ReleaseManager) GetBinaryPath() string { return filepath.Join(rm.BinaryPath, "local-ai") } // IsUpdateAvailable checks if an update is available 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 == "" { // No version installed, offer to download latest 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 } // IsLocalAIInstalled checks if LocalAI binary exists and is valid func (rm *ReleaseManager) IsLocalAIInstalled() bool { binaryPath := rm.GetBinaryPath() if _, err := os.Stat(binaryPath); os.IsNotExist(err) { return false } // Verify the binary integrity if err := rm.VerifyInstalledBinary(); err != nil { log.Printf("Binary integrity check failed: %v", err) // Remove corrupted binary if removeErr := os.Remove(binaryPath); removeErr != nil { log.Printf("Failed to remove corrupted binary: %v", removeErr) } return false } return true } // VerifyInstalledBinary verifies the installed binary against saved checksums func (rm *ReleaseManager) VerifyInstalledBinary() error { binaryPath := rm.GetBinaryPath() // Check if we have saved checksums latestChecksumsPath := filepath.Join(rm.ChecksumsPath, "checksums-latest.txt") if _, err := os.Stat(latestChecksumsPath); os.IsNotExist(err) { return fmt.Errorf("no saved checksums found") } // Get the binary name for the current version from metadata currentVersion := rm.loadVersionMetadata() if currentVersion == "" { return fmt.Errorf("cannot determine current version from metadata") } binaryName := rm.GetBinaryName(currentVersion) // Verify against saved checksums return rm.VerifyChecksum(binaryPath, latestChecksumsPath, binaryName) } // CleanupPartialDownloads removes any partial or corrupted downloads func (rm *ReleaseManager) CleanupPartialDownloads() error { binaryPath := rm.GetBinaryPath() // Check if binary exists but is corrupted if _, err := os.Stat(binaryPath); err == nil { // Binary exists, verify it 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) } // Clear metadata since binary is corrupted rm.clearVersionMetadata() } } // Clean up any temporary checksum files 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 } // clearVersionMetadata clears the version metadata (used when binary is corrupted or removed) 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") } }