updater.go raw

   1  package main
   2  
   3  import (
   4  	"fmt"
   5  	"io"
   6  	"net/http"
   7  	"os"
   8  	"os/exec"
   9  	"path/filepath"
  10  	"sort"
  11  	"strings"
  12  	"time"
  13  
  14  	"next.orly.dev/pkg/lol/chk"
  15  	"next.orly.dev/pkg/lol/log"
  16  )
  17  
  18  // Updater manages versioned binary updates with symlinks.
  19  // Directory structure:
  20  //
  21  //	~/.local/share/orly/bin/
  22  //	  versions/
  23  //	    v0.55.10/
  24  //	      orly
  25  //	      orly-db-badger
  26  //	      orly-acl-follows
  27  //	      orly-launcher
  28  //	    v0.55.11/
  29  //	      ...
  30  //	  current -> versions/v0.55.11 (symlink)
  31  //	  orly -> current/orly (symlink)
  32  //	  orly-db-badger -> current/orly-db-badger (symlink)
  33  //	  ...
  34  type Updater struct {
  35  	binDir      string // Base directory for binaries
  36  	versionsDir string // Directory containing version subdirectories
  37  }
  38  
  39  // VersionInfo contains information about an installed version.
  40  type VersionInfo struct {
  41  	Version     string    `json:"version"`
  42  	InstalledAt time.Time `json:"installed_at"`
  43  	IsCurrent   bool      `json:"is_current"`
  44  	Binaries    []string  `json:"binaries"`
  45  }
  46  
  47  // NewUpdater creates a new Updater.
  48  func NewUpdater(binDir string) *Updater {
  49  	return &Updater{
  50  		binDir:      binDir,
  51  		versionsDir: filepath.Join(binDir, "versions"),
  52  	}
  53  }
  54  
  55  // CurrentVersion returns the currently active version.
  56  func (u *Updater) CurrentVersion() string {
  57  	currentLink := filepath.Join(u.binDir, "current")
  58  	target, err := os.Readlink(currentLink)
  59  	if err != nil {
  60  		return "unknown"
  61  	}
  62  	// Extract version from path like "versions/v0.55.10"
  63  	return filepath.Base(target)
  64  }
  65  
  66  // ListVersions returns all installed versions.
  67  func (u *Updater) ListVersions() []VersionInfo {
  68  	var versions []VersionInfo
  69  
  70  	entries, err := os.ReadDir(u.versionsDir)
  71  	if err != nil {
  72  		return versions
  73  	}
  74  
  75  	currentVersion := u.CurrentVersion()
  76  
  77  	for _, entry := range entries {
  78  		if !entry.IsDir() {
  79  			continue
  80  		}
  81  
  82  		versionDir := filepath.Join(u.versionsDir, entry.Name())
  83  		info, err := entry.Info()
  84  		if err != nil {
  85  			continue
  86  		}
  87  
  88  		// List binaries in this version
  89  		binaries, _ := u.listBinaries(versionDir)
  90  
  91  		versions = append(versions, VersionInfo{
  92  			Version:     entry.Name(),
  93  			InstalledAt: info.ModTime(),
  94  			IsCurrent:   entry.Name() == currentVersion,
  95  			Binaries:    binaries,
  96  		})
  97  	}
  98  
  99  	// Sort by version descending (newest first)
 100  	sort.Slice(versions, func(i, j int) bool {
 101  		return versions[i].Version > versions[j].Version
 102  	})
 103  
 104  	return versions
 105  }
 106  
 107  // listBinaries returns the list of binary files in a directory.
 108  func (u *Updater) listBinaries(dir string) ([]string, error) {
 109  	var binaries []string
 110  	entries, err := os.ReadDir(dir)
 111  	if err != nil {
 112  		return nil, err
 113  	}
 114  	for _, entry := range entries {
 115  		if entry.IsDir() {
 116  			continue
 117  		}
 118  		info, err := entry.Info()
 119  		if err != nil {
 120  			continue
 121  		}
 122  		// Check if executable
 123  		if info.Mode()&0111 != 0 {
 124  			binaries = append(binaries, entry.Name())
 125  		}
 126  	}
 127  	return binaries, nil
 128  }
 129  
 130  // Update downloads binaries from URLs and installs them as a new version.
 131  func (u *Updater) Update(version string, urls map[string]string) ([]string, error) {
 132  	// Create version directory
 133  	versionDir := filepath.Join(u.versionsDir, version)
 134  	if err := os.MkdirAll(versionDir, 0755); err != nil {
 135  		return nil, fmt.Errorf("failed to create version directory: %w", err)
 136  	}
 137  
 138  	var downloadedFiles []string
 139  
 140  	// Download each binary
 141  	for name, url := range urls {
 142  		destPath := filepath.Join(versionDir, name)
 143  		log.I.F("downloading %s from %s", name, url)
 144  
 145  		if err := u.downloadFile(destPath, url); chk.E(err) {
 146  			// Clean up on failure
 147  			os.RemoveAll(versionDir)
 148  			return nil, fmt.Errorf("failed to download %s: %w", name, err)
 149  		}
 150  
 151  		// Make executable
 152  		if err := os.Chmod(destPath, 0755); err != nil {
 153  			os.RemoveAll(versionDir)
 154  			return nil, fmt.Errorf("failed to chmod %s: %w", name, err)
 155  		}
 156  
 157  		downloadedFiles = append(downloadedFiles, name)
 158  	}
 159  
 160  	// Update symlinks
 161  	if err := u.activateVersion(version); chk.E(err) {
 162  		return downloadedFiles, fmt.Errorf("failed to activate version: %w", err)
 163  	}
 164  
 165  	log.I.F("successfully updated to version %s", version)
 166  	return downloadedFiles, nil
 167  }
 168  
 169  // downloadFile downloads a file from a URL.
 170  func (u *Updater) downloadFile(destPath, url string) error {
 171  	resp, err := http.Get(url)
 172  	if err != nil {
 173  		return err
 174  	}
 175  	defer resp.Body.Close()
 176  
 177  	if resp.StatusCode != http.StatusOK {
 178  		return fmt.Errorf("HTTP %d: %s", resp.StatusCode, resp.Status)
 179  	}
 180  
 181  	out, err := os.Create(destPath)
 182  	if err != nil {
 183  		return err
 184  	}
 185  	defer out.Close()
 186  
 187  	_, err = io.Copy(out, resp.Body)
 188  	return err
 189  }
 190  
 191  // activateVersion updates symlinks to point to the specified version.
 192  func (u *Updater) activateVersion(version string) error {
 193  	versionDir := filepath.Join(u.versionsDir, version)
 194  
 195  	// Verify version directory exists
 196  	if _, err := os.Stat(versionDir); os.IsNotExist(err) {
 197  		return fmt.Errorf("version %s not found", version)
 198  	}
 199  
 200  	// Update 'current' symlink
 201  	currentLink := filepath.Join(u.binDir, "current")
 202  	tempLink := currentLink + ".tmp"
 203  
 204  	// Create new symlink to temp location
 205  	relPath, _ := filepath.Rel(u.binDir, versionDir)
 206  	if err := os.Symlink(relPath, tempLink); err != nil {
 207  		return fmt.Errorf("failed to create temp symlink: %w", err)
 208  	}
 209  
 210  	// Atomic rename
 211  	if err := os.Rename(tempLink, currentLink); err != nil {
 212  		os.Remove(tempLink)
 213  		return fmt.Errorf("failed to update current symlink: %w", err)
 214  	}
 215  
 216  	// Update individual binary symlinks
 217  	binaries, err := u.listBinaries(versionDir)
 218  	if err != nil {
 219  		return fmt.Errorf("failed to list binaries: %w", err)
 220  	}
 221  
 222  	for _, binary := range binaries {
 223  		binaryLink := filepath.Join(u.binDir, binary)
 224  		tempBinaryLink := binaryLink + ".tmp"
 225  		targetPath := filepath.Join("current", binary)
 226  
 227  		// Create new symlink
 228  		if err := os.Symlink(targetPath, tempBinaryLink); err != nil {
 229  			log.W.F("failed to create symlink for %s: %v", binary, err)
 230  			continue
 231  		}
 232  
 233  		// Atomic rename
 234  		if err := os.Rename(tempBinaryLink, binaryLink); err != nil {
 235  			os.Remove(tempBinaryLink)
 236  			log.W.F("failed to update symlink for %s: %v", binary, err)
 237  		}
 238  	}
 239  
 240  	return nil
 241  }
 242  
 243  // Rollback reverts to the previous version.
 244  func (u *Updater) Rollback() error {
 245  	versions := u.ListVersions()
 246  	if len(versions) < 2 {
 247  		return fmt.Errorf("no previous version available for rollback")
 248  	}
 249  
 250  	// Find current version index
 251  	currentVersion := u.CurrentVersion()
 252  	var previousVersion string
 253  
 254  	for i, v := range versions {
 255  		if v.Version == currentVersion && i+1 < len(versions) {
 256  			previousVersion = versions[i+1].Version
 257  			break
 258  		}
 259  	}
 260  
 261  	if previousVersion == "" {
 262  		return fmt.Errorf("could not determine previous version")
 263  	}
 264  
 265  	log.I.F("rolling back from %s to %s", currentVersion, previousVersion)
 266  	return u.activateVersion(previousVersion)
 267  }
 268  
 269  // GetBinaryVersion attempts to get the version from a binary using -v flag.
 270  func (u *Updater) GetBinaryVersion(binaryPath string) string {
 271  	cmd := exec.Command(binaryPath, "-v")
 272  	output, err := cmd.Output()
 273  	if err != nil {
 274  		// Try --version
 275  		cmd = exec.Command(binaryPath, "--version")
 276  		output, err = cmd.Output()
 277  		if err != nil {
 278  			return "unknown"
 279  		}
 280  	}
 281  	return strings.TrimSpace(string(output))
 282  }
 283  
 284  // EnsureDirectories creates the required directory structure.
 285  func (u *Updater) EnsureDirectories() error {
 286  	return os.MkdirAll(u.versionsDir, 0755)
 287  }
 288