1package agent
2
3import (
4 "context"
5 "crypto/sha256"
6 "encoding/hex"
7 "errors"
8 "fmt"
9 "io"
10 "net/http"
11 "os"
12 "os/exec"
13 "path/filepath"
14 "runtime"
15 "strings"
16 "time"
17)
18
19// runUpdateCommand dispatches `zot update`. Returns (handled=true, err)
20// if rawArgs starts with "update"; otherwise (handled=false, nil) so
21// the main router falls through to the regular flag parser. Mirrors
22// the shape of runBotCommand / runExtCommand on purpose: identical
23// dispatch shape keeps the router in cli.go uniform.
24//
25// The command:
26//
27// 1. Resolves the latest release tag via the GitHub API (same code
28// path the in-TUI update banner uses, so we never disagree about
29// "what is latest").
30// 2. Picks the asset matching the current GOOS/GOARCH using the
31// name template defined in .goreleaser.yaml.
32// 3. Downloads checksums.txt and the asset to a temp directory.
33// 4. Verifies the asset's sha256 against checksums.txt.
34// 5. Extracts the zot binary from the archive.
35// 6. Atomically replaces the running binary with the new one.
36//
37// Refuses to operate on dev builds (version == "0.0.0") because there
38// is no meaningful "is newer" comparison and we'd happily downgrade a
39// freshly-compiled local binary back to whatever's on GitHub.
40func runUpdateCommand(rawArgs []string, version string) (handled bool, err error) {
41 if len(rawArgs) == 0 || rawArgs[0] != "update" {
42 return false, nil
43 }
44 // Accept --help/-h for parity with the rest of the CLI.
45 for _, a := range rawArgs[1:] {
46 switch a {
47 case "-h", "--help", "help":
48 printUpdateHelp()
49 return true, nil
50 case "--check":
51 return true, runUpdateCheck(version)
52 default:
53 printUpdateHelp()
54 return true, fmt.Errorf("unknown update flag: %s", a)
55 }
56 }
57 return true, runUpdate(version)
58}
59
60func printUpdateHelp() {
61 fmt.Fprintln(os.Stderr, `zot update — replace the current zot binary with the latest release
62
63usage:
64 zot update download and install the newest release
65 zot update --check show what update is available; install nothing
66 zot update --help show this help
67
68notes:
69 * The binary must be writable by the current user. On a system-wide
70 install (e.g. /usr/local/bin/zot owned by root) re-run with sudo.
71 * Dev builds (version 0.0.0) are refused. They typically come from
72 a local source build and shouldn't be silently replaced with a
73 release binary.
74 * Honours $GITHUB_TOKEN if set, so private-repo releases work.
75 * After the binary is installed, every extension under
76 $ZOT_HOME/extensions/ that is a git checkout is fast-forward
77 pulled (no merge, no rebase). Dirty worktrees are stashed and
78 restored. Extensions without a .git directory, disabled
79 extensions, and pulls that fail (offline, diverged, etc.) are
80 skipped per-extension and never abort the overall update. zot
81 does NOT run any build step after pulling — authors are expected
82 to commit a working binary, or you can rebuild manually and
83 /reload-ext.`)
84}
85
86// runUpdateCheck just prints what would happen without doing the
87// download. Useful as a sanity probe and as something the user can
88// pipe into scripts.
89func runUpdateCheck(version string) error {
90 if version == "" || version == "dev" || version == "0.0.0" {
91 fmt.Println("zot: dev build (version 0.0.0) — `zot update` is disabled")
92 return nil
93 }
94 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
95 defer cancel()
96 tag, url, err := fetchLatestRelease(ctx)
97 if err != nil {
98 return fmt.Errorf("query latest release: %w", err)
99 }
100 latest := strings.TrimPrefix(tag, "v")
101 current := versionOnly(version)
102 if !versionLess(current, latest) {
103 fmt.Printf("zot %s is up to date (latest: %s)\n", current, latest)
104 return nil
105 }
106 fmt.Printf("zot %s -> %s available\n release: %s\n run 'zot update' to install\n", current, latest, url)
107 return nil
108}
109
110// runUpdate is the meat of `zot update`.
111func runUpdate(version string) error {
112 if version == "" || version == "dev" || version == "0.0.0" {
113 return errors.New("dev build (version 0.0.0): `zot update` is disabled. Build a release tag or download from https://github.com/patriceckhart/zot/releases")
114 }
115 current := versionOnly(version)
116
117 ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
118 defer cancel()
119
120 fmt.Println("zot update: querying latest release...")
121 tag, releaseURL, err := fetchLatestRelease(ctx)
122 if err != nil {
123 return fmt.Errorf("query latest release: %w", err)
124 }
125 latest := strings.TrimPrefix(tag, "v")
126
127 if !versionLess(current, latest) {
128 fmt.Printf("zot %s is already up to date.\n", current)
129 return nil
130 }
131 fmt.Printf("zot update: %s -> %s\n", current, latest)
132 fmt.Printf("zot update: release page %s\n", releaseURL)
133
134 // Pick the archive matching this platform.
135 assetName, archiveFmt, err := releaseAssetName(latest)
136 if err != nil {
137 return err
138 }
139 fmt.Printf("zot update: target asset %s\n", assetName)
140
141 // We assume the standard GoReleaser layout: assets live under
142 // https://github.com/<owner>/<repo>/releases/download/<tag>/<file>
143 base := strings.TrimSuffix(releaseURL, "/")
144 // releaseURL points at /releases/tag/<tag>; flip it to /releases/download/<tag>
145 base = strings.Replace(base, "/releases/tag/", "/releases/download/", 1)
146
147 assetURL := base + "/" + assetName
148 sumsURL := base + "/checksums.txt"
149
150 tmp, err := os.MkdirTemp("", "zot-update-")
151 if err != nil {
152 return fmt.Errorf("create temp dir: %w", err)
153 }
154 // Best effort: remove the temp dir at the end. If extraction
155 // fails midway we leave it behind on purpose for diagnosis;
156 // users can clear /tmp themselves.
157 defer func() { _ = os.RemoveAll(tmp) }()
158
159 fmt.Println("zot update: downloading checksums.txt...")
160 sumsPath := filepath.Join(tmp, "checksums.txt")
161 if err := downloadFile(ctx, sumsURL, sumsPath); err != nil {
162 return fmt.Errorf("download checksums: %w", err)
163 }
164 wantSum, err := lookupChecksum(sumsPath, assetName)
165 if err != nil {
166 return err
167 }
168
169 fmt.Println("zot update: downloading archive...")
170 archivePath := filepath.Join(tmp, assetName)
171 if err := downloadFile(ctx, assetURL, archivePath); err != nil {
172 return fmt.Errorf("download archive: %w", err)
173 }
174
175 fmt.Println("zot update: verifying checksum...")
176 gotSum, err := sha256File(archivePath)
177 if err != nil {
178 return fmt.Errorf("hash archive: %w", err)
179 }
180 if !strings.EqualFold(gotSum, wantSum) {
181 return fmt.Errorf("checksum mismatch for %s: got %s, want %s", assetName, gotSum, wantSum)
182 }
183
184 fmt.Println("zot update: extracting...")
185 extractDir := filepath.Join(tmp, "extracted")
186 if err := os.MkdirAll(extractDir, 0o755); err != nil {
187 return fmt.Errorf("mkdir extract: %w", err)
188 }
189 if err := extractArchive(archivePath, archiveFmt, extractDir); err != nil {
190 return fmt.Errorf("extract archive: %w", err)
191 }
192
193 newBin := filepath.Join(extractDir, "zot")
194 if runtime.GOOS == "windows" {
195 newBin = filepath.Join(extractDir, "zot.exe")
196 }
197 if st, err := os.Stat(newBin); err != nil || st.IsDir() {
198 return fmt.Errorf("extracted archive does not contain a zot binary at %s", newBin)
199 }
200
201 curBin, err := os.Executable()
202 if err != nil {
203 return fmt.Errorf("resolve current binary path: %w", err)
204 }
205 // Resolve symlinks so 'zot' on $PATH that points at /usr/local/bin
206 // gets actually replaced rather than us writing into the symlink
207 // target's directory while leaving the link stale.
208 if resolved, err := filepath.EvalSymlinks(curBin); err == nil {
209 curBin = resolved
210 }
211
212 fmt.Printf("zot update: replacing %s\n", curBin)
213 if err := replaceBinary(curBin, newBin); err != nil {
214 return fmt.Errorf("replace binary: %w", err)
215 }
216 fmt.Printf("zot update: installed %s\n", latest)
217
218 // Best-effort: also refresh installed extensions that live in
219 // git checkouts. Failures here are advisory and never abort the
220 // overall update — the binary swap already succeeded.
221 updateAllExtensions(ZotHome())
222
223 return nil
224}
225
226// releaseAssetName returns the archive filename for the current
227// platform and the format (tar.gz / zip) used to extract it. Must
228// stay in sync with archives.name_template in .goreleaser.yaml:
229//
230// {{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}
231func releaseAssetName(version string) (name, format string, err error) {
232 goos := runtime.GOOS
233 goarch := runtime.GOARCH
234 switch goos {
235 case "linux", "darwin":
236 // supported
237 case "windows":
238 // supported
239 default:
240 return "", "", fmt.Errorf("unsupported OS for zot update: %s (download manually from the release page)", goos)
241 }
242 switch goarch {
243 case "amd64", "arm64":
244 // supported
245 default:
246 return "", "", fmt.Errorf("unsupported CPU arch for zot update: %s", goarch)
247 }
248 if goos == "windows" && goarch == "arm64" {
249 return "", "", errors.New("windows/arm64 release artifacts are not published; download manually")
250 }
251 ext := "tar.gz"
252 if goos == "windows" {
253 ext = "zip"
254 }
255 return fmt.Sprintf("zot_%s_%s_%s.%s", version, goos, goarch, ext), ext, nil
256}
257
258// downloadFile fetches url to dst, streaming through io.Copy so big
259// archives don't balloon memory.
260func downloadFile(ctx context.Context, url, dst string) error {
261 req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
262 if err != nil {
263 return err
264 }
265 if tok := os.Getenv("GITHUB_TOKEN"); tok != "" {
266 req.Header.Set("authorization", "Bearer "+tok)
267 }
268 client := &http.Client{Timeout: 90 * time.Second}
269 resp, err := client.Do(req)
270 if err != nil {
271 return err
272 }
273 defer resp.Body.Close()
274 if resp.StatusCode != 200 {
275 return fmt.Errorf("GET %s: HTTP %d", url, resp.StatusCode)
276 }
277 f, err := os.Create(dst)
278 if err != nil {
279 return err
280 }
281 defer f.Close()
282 if _, err := io.Copy(f, resp.Body); err != nil {
283 return err
284 }
285 return nil
286}
287
288// lookupChecksum parses a GoReleaser checksums.txt file and returns
289// the sha256 hex for the named asset. Format is:
290//
291// <sha256> <filename>
292//
293// one entry per line, two spaces between columns.
294func lookupChecksum(path, asset string) (string, error) {
295 b, err := os.ReadFile(path)
296 if err != nil {
297 return "", fmt.Errorf("read checksums: %w", err)
298 }
299 for _, line := range strings.Split(string(b), "\n") {
300 line = strings.TrimSpace(line)
301 if line == "" {
302 continue
303 }
304 fields := strings.Fields(line)
305 if len(fields) < 2 {
306 continue
307 }
308 if fields[1] == asset {
309 return fields[0], nil
310 }
311 }
312 return "", fmt.Errorf("checksum for %s not listed in checksums.txt", asset)
313}
314
315func sha256File(path string) (string, error) {
316 f, err := os.Open(path)
317 if err != nil {
318 return "", err
319 }
320 defer f.Close()
321 h := sha256.New()
322 if _, err := io.Copy(h, f); err != nil {
323 return "", err
324 }
325 return hex.EncodeToString(h.Sum(nil)), nil
326}
327
328// extractArchive shells out to the system tar / unzip rather than
329// pulling in a Go archive lib. Reasoning: zot already depends on
330// system tar implicitly in a few places, every supported platform
331// ships tar (BSD tar on macOS handles gzip natively, GNU tar on
332// Linux, bsdtar on Windows 10+), and the dependency-free release
333// archives are simple enough that we don't need format-detection
334// gymnastics.
335func extractArchive(archive, format, dst string) error {
336 switch format {
337 case "tar.gz":
338 cmd := exec.Command("tar", "-xzf", archive, "-C", dst)
339 out, err := cmd.CombinedOutput()
340 if err != nil {
341 return fmt.Errorf("tar: %v: %s", err, strings.TrimSpace(string(out)))
342 }
343 return nil
344 case "zip":
345 // PowerShell ships everywhere on supported Windows
346 // versions and Expand-Archive doesn't need elevation.
347 ps := fmt.Sprintf("Expand-Archive -LiteralPath %q -DestinationPath %q -Force", archive, dst)
348 cmd := exec.Command("powershell", "-NoProfile", "-Command", ps)
349 out, err := cmd.CombinedOutput()
350 if err != nil {
351 return fmt.Errorf("powershell Expand-Archive: %v: %s", err, strings.TrimSpace(string(out)))
352 }
353 return nil
354 default:
355 return fmt.Errorf("unknown archive format: %s", format)
356 }
357}
358
359// replaceBinary writes the new binary in place of the old one,
360// preserving the old binary's permissions. On Unix we rename
361// in-place (which works while the binary is running because the
362// kernel keeps the in-memory inode alive until the process exits).
363// On Windows the running .exe is locked, so we rename it aside
364// first, then move the new one in.
365func replaceBinary(cur, newBin string) error {
366 info, err := os.Stat(cur)
367 if err != nil {
368 return fmt.Errorf("stat current binary: %w", err)
369 }
370 mode := info.Mode().Perm()
371 if mode == 0 {
372 mode = 0o755
373 }
374
375 if runtime.GOOS == "windows" {
376 // Rename current aside so we can drop the new one in.
377 bak := cur + ".old"
378 // Clean up any stale .old from a previous update.
379 _ = os.Remove(bak)
380 if err := os.Rename(cur, bak); err != nil {
381 return fmt.Errorf("rename current to .old: %w", err)
382 }
383 if err := os.Rename(newBin, cur); err != nil {
384 // Try to put the old one back.
385 _ = os.Rename(bak, cur)
386 return fmt.Errorf("install new binary: %w", err)
387 }
388 // The .old file is locked until this process exits; leave
389 // it behind. Next `zot update` cleans it up via the
390 // os.Remove(bak) above.
391 return nil
392 }
393
394 // Unix: atomic rename if we're on the same filesystem.
395 if err := os.Rename(newBin, cur); err == nil {
396 _ = os.Chmod(cur, mode)
397 return nil
398 }
399 // Cross-fs (temp dir on tmpfs vs binary on a different mount)
400 // — fall back to copy + chmod + remove.
401 if err := copyFile(newBin, cur); err != nil {
402 return fmt.Errorf("copy new binary into place: %w", err)
403 }
404 _ = os.Chmod(cur, mode)
405 return nil
406}
407
408func copyFile(src, dst string) error {
409 in, err := os.Open(src)
410 if err != nil {
411 return err
412 }
413 defer in.Close()
414 tmp := dst + ".new"
415 out, err := os.Create(tmp)
416 if err != nil {
417 return err
418 }
419 if _, err := io.Copy(out, in); err != nil {
420 _ = out.Close()
421 _ = os.Remove(tmp)
422 return err
423 }
424 if err := out.Close(); err != nil {
425 _ = os.Remove(tmp)
426 return err
427 }
428 return os.Rename(tmp, dst)
429}
430
431// versionOnly strips the build metadata that main.go appends to the
432// version string, so "0.0.5 (43da5e5, 2026-05-12)" becomes "0.0.5".
433func versionOnly(v string) string {
434 if i := strings.IndexAny(v, " ("); i > 0 {
435 return strings.TrimSpace(v[:i])
436 }
437 return v
438}