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