update.go 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316
  1. // Package update implements the `vocat update` self-updater. It queries the
  2. // GitHub Releases API for a newer build, downloads the matching Linux binary
  3. // for the current architecture, verifies it against a published SHA256SUMS,
  4. // atomically replaces the running binary on disk, and restarts the vocat
  5. // systemd unit.
  6. //
  7. // Trust model: GitHub TLS guarantees the channel; the repository owner controls
  8. // which assets are published; SHA256SUMS guards integrity. There is no GPG
  9. // signature verification — an accepted trade-off for a closed-network testing
  10. // tool. The web UI's check-update button remains an intentional no-op; only the
  11. // CLI performs code replacement.
  12. package update
  13. import (
  14. "bytes"
  15. "context"
  16. "fmt"
  17. "log/slog"
  18. "os"
  19. "os/exec"
  20. "path/filepath"
  21. "runtime"
  22. "strings"
  23. "time"
  24. "vocat/internal/buildinfo"
  25. )
  26. // Options captures the resolved flags for an update invocation.
  27. type Options struct {
  28. Check bool // report-only
  29. Repo string // owner/name
  30. Target string // binary path to replace
  31. Force bool // reinstall even at equal version
  32. Token string // optional GitHub bearer token
  33. Help bool // print usage, do nothing
  34. }
  35. // Run executes the update subcommand. It returns nil on success or when an
  36. // update is reported-but-not-applied under --check; it returns an error only
  37. // when something concrete went wrong.
  38. func Run(logger *slog.Logger, args []string) error {
  39. opts, err := parseFlags(args)
  40. if err != nil {
  41. return err
  42. }
  43. if opts.Help {
  44. printUpdateUsage()
  45. return nil
  46. }
  47. if opts.Repo == "" {
  48. opts.Repo = strings.TrimSpace(os.Getenv("VOCAT_REPO"))
  49. }
  50. if opts.Token == "" {
  51. opts.Token = strings.TrimSpace(os.Getenv("GITHUB_TOKEN"))
  52. }
  53. if opts.Repo == "" {
  54. return fmt.Errorf("update: no repository configured (set --repo=owner/name or VOCAT_REPO)")
  55. }
  56. if opts.Target == "" {
  57. opts.Target = resolveDefaultTarget()
  58. }
  59. ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
  60. defer cancel()
  61. logger.Info("checking for updates", "repo", opts.Repo, "current", buildinfo.Version)
  62. release, err := LatestRelease(ctx, opts.Repo, opts.Token)
  63. if err != nil {
  64. return err
  65. }
  66. latest := strings.TrimPrefix(release.TagName, "v")
  67. if latest == "" {
  68. latest = release.TagName
  69. }
  70. if latest == buildinfo.Version && !opts.Force {
  71. logger.Info("already up to date", "version", buildinfo.Version)
  72. fmt.Printf("vocat %s is already the latest release.\n", buildinfo.Version)
  73. return nil
  74. }
  75. if opts.Check {
  76. fmt.Printf("update available: %s -> %s\n", buildinfo.Version, latest)
  77. if release.Body != "" {
  78. fmt.Println(strings.TrimSpace(release.Body))
  79. }
  80. return nil
  81. }
  82. logger.Info("update available", "current", buildinfo.Version, "latest", latest)
  83. return applyUpdate(ctx, logger, opts, release, latest)
  84. }
  85. func applyUpdate(ctx context.Context, logger *slog.Logger, opts Options, release *Release, latest string) error {
  86. assetNames := assetNamesFor(runtime.GOOS, runtime.GOARCH)
  87. var asset *Asset
  88. for _, name := range assetNames {
  89. if asset = findAsset(release, name); asset != nil {
  90. break
  91. }
  92. }
  93. if asset == nil {
  94. return fmt.Errorf("update: release %s has none of assets %q for %s/%s", release.TagName, assetNames, runtime.GOOS, runtime.GOARCH)
  95. }
  96. sumsAsset := findAsset(release, "SHA256SUMS")
  97. if sumsAsset == nil {
  98. return fmt.Errorf("update: release %s missing SHA256SUMS — refusing to install unverified", release.TagName)
  99. }
  100. // The temp file MUST live in the same directory as the target so os.Rename
  101. // stays on one filesystem; a cross-device rename fails with EXDEV.
  102. targetDir := filepath.Dir(opts.Target)
  103. if err := os.MkdirAll(targetDir, 0o755); err != nil {
  104. return fmt.Errorf("update: ensure target dir %s: %w", targetDir, err)
  105. }
  106. tmp, err := os.CreateTemp(targetDir, ".vocat-update-*")
  107. if err != nil {
  108. return fmt.Errorf("update: create temp file: %w", err)
  109. }
  110. tmpPath := tmp.Name()
  111. cleanup := func() { _ = os.Remove(tmpPath) }
  112. defer func() {
  113. if tmp != nil {
  114. _ = tmp.Close()
  115. }
  116. }()
  117. logger.Info("downloading binary", "asset", asset.Name, "size", asset.Size, "url", asset.BrowserDownloadURL)
  118. if err := downloadAsset(ctx, asset.BrowserDownloadURL, opts.Token, tmp); err != nil {
  119. cleanup()
  120. return err
  121. }
  122. if err := tmp.Close(); err != nil {
  123. cleanup()
  124. return fmt.Errorf("update: finalize temp file: %w", err)
  125. }
  126. tmp = nil
  127. var sums bytes.Buffer
  128. if err := downloadAsset(ctx, sumsAsset.BrowserDownloadURL, opts.Token, &sums); err != nil {
  129. cleanup()
  130. return err
  131. }
  132. expectedHash, err := ParseSHA256SUMS(sums.String(), asset.Name)
  133. if err != nil {
  134. cleanup()
  135. return err
  136. }
  137. ok, err := VerifyFileSHA256(tmpPath, expectedHash)
  138. if err != nil {
  139. cleanup()
  140. return err
  141. }
  142. if !ok {
  143. cleanup()
  144. return fmt.Errorf("update: sha256 mismatch for %s — refusing to install", asset.Name)
  145. }
  146. logger.Info("verified binary", "sha256", expectedHash)
  147. if err := os.Chmod(tmpPath, 0o755); err != nil {
  148. cleanup()
  149. return fmt.Errorf("update: chmod temp binary: %w", err)
  150. }
  151. if err := backupAndReplace(opts.Target, tmpPath); err != nil {
  152. cleanup()
  153. return err
  154. }
  155. logger.Info("installed new binary", "target", opts.Target, "version", latest)
  156. fmt.Printf("vocat updated to %s.\n", latest)
  157. if err := restartService(logger); err != nil {
  158. // The file replacement already succeeded; a restart failure is not
  159. // fatal — the operator can restart the service manually.
  160. fmt.Printf("Binary replaced, but automatic restart failed: %v\n", err)
  161. fmt.Println("Restart the vocat service manually to apply the new build.")
  162. }
  163. return nil
  164. }
  165. // backupAndReplace renames the current binary aside, then moves the verified
  166. // temp file into place. Both renames are atomic on the same filesystem. On
  167. // Linux the kernel holds the running binary's inode, so replacing it mid-flight
  168. // is safe.
  169. func backupAndReplace(target, tmp string) error {
  170. backup := target + ".previous"
  171. if _, err := os.Stat(target); err == nil {
  172. _ = os.Remove(backup)
  173. if err := os.Rename(target, backup); err != nil {
  174. return fmt.Errorf("update: move current binary aside: %w", err)
  175. }
  176. }
  177. if err := os.Rename(tmp, target); err != nil {
  178. // Best-effort rollback so the operator is not left without a binary.
  179. if _, statErr := os.Stat(backup); statErr == nil {
  180. _ = os.Rename(backup, target)
  181. }
  182. return fmt.Errorf("update: move new binary into place: %w", err)
  183. }
  184. _ = os.Remove(backup)
  185. return nil
  186. }
  187. // restartService restarts the vocat systemd unit. If systemctl is unavailable
  188. // (non-systemd hosts, containers), it returns an error the caller surfaces as
  189. // a non-fatal warning.
  190. func restartService(logger *slog.Logger) error {
  191. if _, err := exec.LookPath("systemctl"); err != nil {
  192. return fmt.Errorf("systemctl not found in PATH")
  193. }
  194. cmd := exec.Command("systemctl", "restart", "vocat")
  195. if out, err := cmd.CombinedOutput(); err != nil {
  196. logger.Warn("systemctl restart failed", "error", err, "output", string(out))
  197. return fmt.Errorf("systemctl restart vocat: %w", err)
  198. }
  199. return nil
  200. }
  201. // resolveDefaultTarget returns the conventional install path when present,
  202. // falling back to the running executable. This lets `vocat update` "just work"
  203. // on the standard systemd host without flags.
  204. func resolveDefaultTarget() string {
  205. const defaultPath = "/opt/vocat/bin/vocat"
  206. if _, err := os.Stat(defaultPath); err == nil {
  207. return defaultPath
  208. }
  209. exe, err := os.Executable()
  210. if err != nil {
  211. return defaultPath
  212. }
  213. resolved, err := filepath.EvalSymlinks(exe)
  214. if err != nil {
  215. return exe
  216. }
  217. return resolved
  218. }
  219. func findAsset(release *Release, name string) *Asset {
  220. for i := range release.Assets {
  221. if release.Assets[i].Name == name {
  222. return &release.Assets[i]
  223. }
  224. }
  225. return nil
  226. }
  227. func assetNamesFor(goos, goarch string) []string {
  228. if goos == "linux" && goarch == "arm" {
  229. // Official 32-bit ARM builds target GOARM=7. Keep the generic legacy
  230. // name as a fallback for installations consuming an older release.
  231. return []string{"vocat-linux-armv7", "vocat-linux-arm"}
  232. }
  233. return []string{fmt.Sprintf("vocat-%s-%s", goos, goarch)}
  234. }
  235. func printUpdateUsage() {
  236. fmt.Println(`Usage: vocat update [flags]
  237. Fetch the latest release from GitHub and replace this binary in place.
  238. Flags:
  239. --check Report whether an update is available, then exit.
  240. --force Reinstall even when already at the latest version.
  241. --repo owner/name GitHub repository (default: $VOCAT_REPO).
  242. --target path Binary to replace (default: /opt/vocat/bin/vocat if
  243. present, otherwise the running executable).
  244. --token token GitHub bearer token (default: $GITHUB_TOKEN).
  245. -h, --help Show this help.
  246. Environment:
  247. VOCAT_REPO Fallback for --repo.
  248. GITHUB_TOKEN Fallback for --token. Required for private repos and
  249. recommended to avoid unauthenticated rate limits.`)
  250. }
  251. func parseFlags(args []string) (Options, error) {
  252. var opts Options
  253. for i := 0; i < len(args); i++ {
  254. arg := args[i]
  255. switch {
  256. case arg == "--check":
  257. opts.Check = true
  258. case arg == "--force":
  259. opts.Force = true
  260. case arg == "--repo":
  261. i++
  262. if i >= len(args) {
  263. return opts, fmt.Errorf("update: --repo requires a value")
  264. }
  265. opts.Repo = args[i]
  266. case strings.HasPrefix(arg, "--repo="):
  267. opts.Repo = strings.TrimPrefix(arg, "--repo=")
  268. case arg == "--target":
  269. i++
  270. if i >= len(args) {
  271. return opts, fmt.Errorf("update: --target requires a value")
  272. }
  273. opts.Target = args[i]
  274. case strings.HasPrefix(arg, "--target="):
  275. opts.Target = strings.TrimPrefix(arg, "--target=")
  276. case arg == "--token":
  277. i++
  278. if i >= len(args) {
  279. return opts, fmt.Errorf("update: --token requires a value")
  280. }
  281. opts.Token = args[i]
  282. case strings.HasPrefix(arg, "--token="):
  283. opts.Token = strings.TrimPrefix(arg, "--token=")
  284. case arg == "-h" || arg == "--help":
  285. opts.Help = true
  286. default:
  287. return opts, fmt.Errorf("update: unknown flag %q", arg)
  288. }
  289. }
  290. return opts, nil
  291. }