You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
287 lines
7.0 KiB
287 lines
7.0 KiB
package main |
|
|
|
import ( |
|
"fmt" |
|
"io" |
|
"net/http" |
|
"os" |
|
"os/exec" |
|
"path/filepath" |
|
"sort" |
|
"strings" |
|
"time" |
|
|
|
"lol.mleku.dev/chk" |
|
"lol.mleku.dev/log" |
|
) |
|
|
|
// Updater manages versioned binary updates with symlinks. |
|
// Directory structure: |
|
// |
|
// ~/.local/share/orly/bin/ |
|
// versions/ |
|
// v0.55.10/ |
|
// orly |
|
// orly-db-badger |
|
// orly-acl-follows |
|
// orly-launcher |
|
// v0.55.11/ |
|
// ... |
|
// current -> versions/v0.55.11 (symlink) |
|
// orly -> current/orly (symlink) |
|
// orly-db-badger -> current/orly-db-badger (symlink) |
|
// ... |
|
type Updater struct { |
|
binDir string // Base directory for binaries |
|
versionsDir string // Directory containing version subdirectories |
|
} |
|
|
|
// VersionInfo contains information about an installed version. |
|
type VersionInfo struct { |
|
Version string `json:"version"` |
|
InstalledAt time.Time `json:"installed_at"` |
|
IsCurrent bool `json:"is_current"` |
|
Binaries []string `json:"binaries"` |
|
} |
|
|
|
// NewUpdater creates a new Updater. |
|
func NewUpdater(binDir string) *Updater { |
|
return &Updater{ |
|
binDir: binDir, |
|
versionsDir: filepath.Join(binDir, "versions"), |
|
} |
|
} |
|
|
|
// CurrentVersion returns the currently active version. |
|
func (u *Updater) CurrentVersion() string { |
|
currentLink := filepath.Join(u.binDir, "current") |
|
target, err := os.Readlink(currentLink) |
|
if err != nil { |
|
return "unknown" |
|
} |
|
// Extract version from path like "versions/v0.55.10" |
|
return filepath.Base(target) |
|
} |
|
|
|
// ListVersions returns all installed versions. |
|
func (u *Updater) ListVersions() []VersionInfo { |
|
var versions []VersionInfo |
|
|
|
entries, err := os.ReadDir(u.versionsDir) |
|
if err != nil { |
|
return versions |
|
} |
|
|
|
currentVersion := u.CurrentVersion() |
|
|
|
for _, entry := range entries { |
|
if !entry.IsDir() { |
|
continue |
|
} |
|
|
|
versionDir := filepath.Join(u.versionsDir, entry.Name()) |
|
info, err := entry.Info() |
|
if err != nil { |
|
continue |
|
} |
|
|
|
// List binaries in this version |
|
binaries, _ := u.listBinaries(versionDir) |
|
|
|
versions = append(versions, VersionInfo{ |
|
Version: entry.Name(), |
|
InstalledAt: info.ModTime(), |
|
IsCurrent: entry.Name() == currentVersion, |
|
Binaries: binaries, |
|
}) |
|
} |
|
|
|
// Sort by version descending (newest first) |
|
sort.Slice(versions, func(i, j int) bool { |
|
return versions[i].Version > versions[j].Version |
|
}) |
|
|
|
return versions |
|
} |
|
|
|
// listBinaries returns the list of binary files in a directory. |
|
func (u *Updater) listBinaries(dir string) ([]string, error) { |
|
var binaries []string |
|
entries, err := os.ReadDir(dir) |
|
if err != nil { |
|
return nil, err |
|
} |
|
for _, entry := range entries { |
|
if entry.IsDir() { |
|
continue |
|
} |
|
info, err := entry.Info() |
|
if err != nil { |
|
continue |
|
} |
|
// Check if executable |
|
if info.Mode()&0111 != 0 { |
|
binaries = append(binaries, entry.Name()) |
|
} |
|
} |
|
return binaries, nil |
|
} |
|
|
|
// Update downloads binaries from URLs and installs them as a new version. |
|
func (u *Updater) Update(version string, urls map[string]string) ([]string, error) { |
|
// Create version directory |
|
versionDir := filepath.Join(u.versionsDir, version) |
|
if err := os.MkdirAll(versionDir, 0755); err != nil { |
|
return nil, fmt.Errorf("failed to create version directory: %w", err) |
|
} |
|
|
|
var downloadedFiles []string |
|
|
|
// Download each binary |
|
for name, url := range urls { |
|
destPath := filepath.Join(versionDir, name) |
|
log.I.F("downloading %s from %s", name, url) |
|
|
|
if err := u.downloadFile(destPath, url); chk.E(err) { |
|
// Clean up on failure |
|
os.RemoveAll(versionDir) |
|
return nil, fmt.Errorf("failed to download %s: %w", name, err) |
|
} |
|
|
|
// Make executable |
|
if err := os.Chmod(destPath, 0755); err != nil { |
|
os.RemoveAll(versionDir) |
|
return nil, fmt.Errorf("failed to chmod %s: %w", name, err) |
|
} |
|
|
|
downloadedFiles = append(downloadedFiles, name) |
|
} |
|
|
|
// Update symlinks |
|
if err := u.activateVersion(version); chk.E(err) { |
|
return downloadedFiles, fmt.Errorf("failed to activate version: %w", err) |
|
} |
|
|
|
log.I.F("successfully updated to version %s", version) |
|
return downloadedFiles, nil |
|
} |
|
|
|
// downloadFile downloads a file from a URL. |
|
func (u *Updater) downloadFile(destPath, url string) error { |
|
resp, err := http.Get(url) |
|
if err != nil { |
|
return err |
|
} |
|
defer resp.Body.Close() |
|
|
|
if resp.StatusCode != http.StatusOK { |
|
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, resp.Status) |
|
} |
|
|
|
out, err := os.Create(destPath) |
|
if err != nil { |
|
return err |
|
} |
|
defer out.Close() |
|
|
|
_, err = io.Copy(out, resp.Body) |
|
return err |
|
} |
|
|
|
// activateVersion updates symlinks to point to the specified version. |
|
func (u *Updater) activateVersion(version string) error { |
|
versionDir := filepath.Join(u.versionsDir, version) |
|
|
|
// Verify version directory exists |
|
if _, err := os.Stat(versionDir); os.IsNotExist(err) { |
|
return fmt.Errorf("version %s not found", version) |
|
} |
|
|
|
// Update 'current' symlink |
|
currentLink := filepath.Join(u.binDir, "current") |
|
tempLink := currentLink + ".tmp" |
|
|
|
// Create new symlink to temp location |
|
relPath, _ := filepath.Rel(u.binDir, versionDir) |
|
if err := os.Symlink(relPath, tempLink); err != nil { |
|
return fmt.Errorf("failed to create temp symlink: %w", err) |
|
} |
|
|
|
// Atomic rename |
|
if err := os.Rename(tempLink, currentLink); err != nil { |
|
os.Remove(tempLink) |
|
return fmt.Errorf("failed to update current symlink: %w", err) |
|
} |
|
|
|
// Update individual binary symlinks |
|
binaries, err := u.listBinaries(versionDir) |
|
if err != nil { |
|
return fmt.Errorf("failed to list binaries: %w", err) |
|
} |
|
|
|
for _, binary := range binaries { |
|
binaryLink := filepath.Join(u.binDir, binary) |
|
tempBinaryLink := binaryLink + ".tmp" |
|
targetPath := filepath.Join("current", binary) |
|
|
|
// Create new symlink |
|
if err := os.Symlink(targetPath, tempBinaryLink); err != nil { |
|
log.W.F("failed to create symlink for %s: %v", binary, err) |
|
continue |
|
} |
|
|
|
// Atomic rename |
|
if err := os.Rename(tempBinaryLink, binaryLink); err != nil { |
|
os.Remove(tempBinaryLink) |
|
log.W.F("failed to update symlink for %s: %v", binary, err) |
|
} |
|
} |
|
|
|
return nil |
|
} |
|
|
|
// Rollback reverts to the previous version. |
|
func (u *Updater) Rollback() error { |
|
versions := u.ListVersions() |
|
if len(versions) < 2 { |
|
return fmt.Errorf("no previous version available for rollback") |
|
} |
|
|
|
// Find current version index |
|
currentVersion := u.CurrentVersion() |
|
var previousVersion string |
|
|
|
for i, v := range versions { |
|
if v.Version == currentVersion && i+1 < len(versions) { |
|
previousVersion = versions[i+1].Version |
|
break |
|
} |
|
} |
|
|
|
if previousVersion == "" { |
|
return fmt.Errorf("could not determine previous version") |
|
} |
|
|
|
log.I.F("rolling back from %s to %s", currentVersion, previousVersion) |
|
return u.activateVersion(previousVersion) |
|
} |
|
|
|
// GetBinaryVersion attempts to get the version from a binary using -v flag. |
|
func (u *Updater) GetBinaryVersion(binaryPath string) string { |
|
cmd := exec.Command(binaryPath, "-v") |
|
output, err := cmd.Output() |
|
if err != nil { |
|
// Try --version |
|
cmd = exec.Command(binaryPath, "--version") |
|
output, err = cmd.Output() |
|
if err != nil { |
|
return "unknown" |
|
} |
|
} |
|
return strings.TrimSpace(string(output)) |
|
} |
|
|
|
// EnsureDirectories creates the required directory structure. |
|
func (u *Updater) EnsureDirectories() error { |
|
return os.MkdirAll(u.versionsDir, 0755) |
|
}
|
|
|