| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316 |
- // Package update implements the `vocat update` self-updater. It queries the
- // GitHub Releases API for a newer build, downloads the matching Linux binary
- // for the current architecture, verifies it against a published SHA256SUMS,
- // atomically replaces the running binary on disk, and restarts the vocat
- // systemd unit.
- //
- // Trust model: GitHub TLS guarantees the channel; the repository owner controls
- // which assets are published; SHA256SUMS guards integrity. There is no GPG
- // signature verification — an accepted trade-off for a closed-network testing
- // tool. The web UI's check-update button remains an intentional no-op; only the
- // CLI performs code replacement.
- package update
- import (
- "bytes"
- "context"
- "fmt"
- "log/slog"
- "os"
- "os/exec"
- "path/filepath"
- "runtime"
- "strings"
- "time"
- "vocat/internal/buildinfo"
- )
- // Options captures the resolved flags for an update invocation.
- type Options struct {
- Check bool // report-only
- Repo string // owner/name
- Target string // binary path to replace
- Force bool // reinstall even at equal version
- Token string // optional GitHub bearer token
- Help bool // print usage, do nothing
- }
- // Run executes the update subcommand. It returns nil on success or when an
- // update is reported-but-not-applied under --check; it returns an error only
- // when something concrete went wrong.
- func Run(logger *slog.Logger, args []string) error {
- opts, err := parseFlags(args)
- if err != nil {
- return err
- }
- if opts.Help {
- printUpdateUsage()
- return nil
- }
- if opts.Repo == "" {
- opts.Repo = strings.TrimSpace(os.Getenv("VOCAT_REPO"))
- }
- if opts.Token == "" {
- opts.Token = strings.TrimSpace(os.Getenv("GITHUB_TOKEN"))
- }
- if opts.Repo == "" {
- return fmt.Errorf("update: no repository configured (set --repo=owner/name or VOCAT_REPO)")
- }
- if opts.Target == "" {
- opts.Target = resolveDefaultTarget()
- }
- ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
- defer cancel()
- logger.Info("checking for updates", "repo", opts.Repo, "current", buildinfo.Version)
- release, err := LatestRelease(ctx, opts.Repo, opts.Token)
- if err != nil {
- return err
- }
- latest := strings.TrimPrefix(release.TagName, "v")
- if latest == "" {
- latest = release.TagName
- }
- if latest == buildinfo.Version && !opts.Force {
- logger.Info("already up to date", "version", buildinfo.Version)
- fmt.Printf("vocat %s is already the latest release.\n", buildinfo.Version)
- return nil
- }
- if opts.Check {
- fmt.Printf("update available: %s -> %s\n", buildinfo.Version, latest)
- if release.Body != "" {
- fmt.Println(strings.TrimSpace(release.Body))
- }
- return nil
- }
- logger.Info("update available", "current", buildinfo.Version, "latest", latest)
- return applyUpdate(ctx, logger, opts, release, latest)
- }
- func applyUpdate(ctx context.Context, logger *slog.Logger, opts Options, release *Release, latest string) error {
- assetNames := assetNamesFor(runtime.GOOS, runtime.GOARCH)
- var asset *Asset
- for _, name := range assetNames {
- if asset = findAsset(release, name); asset != nil {
- break
- }
- }
- if asset == nil {
- return fmt.Errorf("update: release %s has none of assets %q for %s/%s", release.TagName, assetNames, runtime.GOOS, runtime.GOARCH)
- }
- sumsAsset := findAsset(release, "SHA256SUMS")
- if sumsAsset == nil {
- return fmt.Errorf("update: release %s missing SHA256SUMS — refusing to install unverified", release.TagName)
- }
- // The temp file MUST live in the same directory as the target so os.Rename
- // stays on one filesystem; a cross-device rename fails with EXDEV.
- targetDir := filepath.Dir(opts.Target)
- if err := os.MkdirAll(targetDir, 0o755); err != nil {
- return fmt.Errorf("update: ensure target dir %s: %w", targetDir, err)
- }
- tmp, err := os.CreateTemp(targetDir, ".vocat-update-*")
- if err != nil {
- return fmt.Errorf("update: create temp file: %w", err)
- }
- tmpPath := tmp.Name()
- cleanup := func() { _ = os.Remove(tmpPath) }
- defer func() {
- if tmp != nil {
- _ = tmp.Close()
- }
- }()
- logger.Info("downloading binary", "asset", asset.Name, "size", asset.Size, "url", asset.BrowserDownloadURL)
- if err := downloadAsset(ctx, asset.BrowserDownloadURL, opts.Token, tmp); err != nil {
- cleanup()
- return err
- }
- if err := tmp.Close(); err != nil {
- cleanup()
- return fmt.Errorf("update: finalize temp file: %w", err)
- }
- tmp = nil
- var sums bytes.Buffer
- if err := downloadAsset(ctx, sumsAsset.BrowserDownloadURL, opts.Token, &sums); err != nil {
- cleanup()
- return err
- }
- expectedHash, err := ParseSHA256SUMS(sums.String(), asset.Name)
- if err != nil {
- cleanup()
- return err
- }
- ok, err := VerifyFileSHA256(tmpPath, expectedHash)
- if err != nil {
- cleanup()
- return err
- }
- if !ok {
- cleanup()
- return fmt.Errorf("update: sha256 mismatch for %s — refusing to install", asset.Name)
- }
- logger.Info("verified binary", "sha256", expectedHash)
- if err := os.Chmod(tmpPath, 0o755); err != nil {
- cleanup()
- return fmt.Errorf("update: chmod temp binary: %w", err)
- }
- if err := backupAndReplace(opts.Target, tmpPath); err != nil {
- cleanup()
- return err
- }
- logger.Info("installed new binary", "target", opts.Target, "version", latest)
- fmt.Printf("vocat updated to %s.\n", latest)
- if err := restartService(logger); err != nil {
- // The file replacement already succeeded; a restart failure is not
- // fatal — the operator can restart the service manually.
- fmt.Printf("Binary replaced, but automatic restart failed: %v\n", err)
- fmt.Println("Restart the vocat service manually to apply the new build.")
- }
- return nil
- }
- // backupAndReplace renames the current binary aside, then moves the verified
- // temp file into place. Both renames are atomic on the same filesystem. On
- // Linux the kernel holds the running binary's inode, so replacing it mid-flight
- // is safe.
- func backupAndReplace(target, tmp string) error {
- backup := target + ".previous"
- if _, err := os.Stat(target); err == nil {
- _ = os.Remove(backup)
- if err := os.Rename(target, backup); err != nil {
- return fmt.Errorf("update: move current binary aside: %w", err)
- }
- }
- if err := os.Rename(tmp, target); err != nil {
- // Best-effort rollback so the operator is not left without a binary.
- if _, statErr := os.Stat(backup); statErr == nil {
- _ = os.Rename(backup, target)
- }
- return fmt.Errorf("update: move new binary into place: %w", err)
- }
- _ = os.Remove(backup)
- return nil
- }
- // restartService restarts the vocat systemd unit. If systemctl is unavailable
- // (non-systemd hosts, containers), it returns an error the caller surfaces as
- // a non-fatal warning.
- func restartService(logger *slog.Logger) error {
- if _, err := exec.LookPath("systemctl"); err != nil {
- return fmt.Errorf("systemctl not found in PATH")
- }
- cmd := exec.Command("systemctl", "restart", "vocat")
- if out, err := cmd.CombinedOutput(); err != nil {
- logger.Warn("systemctl restart failed", "error", err, "output", string(out))
- return fmt.Errorf("systemctl restart vocat: %w", err)
- }
- return nil
- }
- // resolveDefaultTarget returns the conventional install path when present,
- // falling back to the running executable. This lets `vocat update` "just work"
- // on the standard systemd host without flags.
- func resolveDefaultTarget() string {
- const defaultPath = "/opt/vocat/bin/vocat"
- if _, err := os.Stat(defaultPath); err == nil {
- return defaultPath
- }
- exe, err := os.Executable()
- if err != nil {
- return defaultPath
- }
- resolved, err := filepath.EvalSymlinks(exe)
- if err != nil {
- return exe
- }
- return resolved
- }
- func findAsset(release *Release, name string) *Asset {
- for i := range release.Assets {
- if release.Assets[i].Name == name {
- return &release.Assets[i]
- }
- }
- return nil
- }
- func assetNamesFor(goos, goarch string) []string {
- if goos == "linux" && goarch == "arm" {
- // Official 32-bit ARM builds target GOARM=7. Keep the generic legacy
- // name as a fallback for installations consuming an older release.
- return []string{"vocat-linux-armv7", "vocat-linux-arm"}
- }
- return []string{fmt.Sprintf("vocat-%s-%s", goos, goarch)}
- }
- func printUpdateUsage() {
- fmt.Println(`Usage: vocat update [flags]
- Fetch the latest release from GitHub and replace this binary in place.
- Flags:
- --check Report whether an update is available, then exit.
- --force Reinstall even when already at the latest version.
- --repo owner/name GitHub repository (default: $VOCAT_REPO).
- --target path Binary to replace (default: /opt/vocat/bin/vocat if
- present, otherwise the running executable).
- --token token GitHub bearer token (default: $GITHUB_TOKEN).
- -h, --help Show this help.
- Environment:
- VOCAT_REPO Fallback for --repo.
- GITHUB_TOKEN Fallback for --token. Required for private repos and
- recommended to avoid unauthenticated rate limits.`)
- }
- func parseFlags(args []string) (Options, error) {
- var opts Options
- for i := 0; i < len(args); i++ {
- arg := args[i]
- switch {
- case arg == "--check":
- opts.Check = true
- case arg == "--force":
- opts.Force = true
- case arg == "--repo":
- i++
- if i >= len(args) {
- return opts, fmt.Errorf("update: --repo requires a value")
- }
- opts.Repo = args[i]
- case strings.HasPrefix(arg, "--repo="):
- opts.Repo = strings.TrimPrefix(arg, "--repo=")
- case arg == "--target":
- i++
- if i >= len(args) {
- return opts, fmt.Errorf("update: --target requires a value")
- }
- opts.Target = args[i]
- case strings.HasPrefix(arg, "--target="):
- opts.Target = strings.TrimPrefix(arg, "--target=")
- case arg == "--token":
- i++
- if i >= len(args) {
- return opts, fmt.Errorf("update: --token requires a value")
- }
- opts.Token = args[i]
- case strings.HasPrefix(arg, "--token="):
- opts.Token = strings.TrimPrefix(arg, "--token=")
- case arg == "-h" || arg == "--help":
- opts.Help = true
- default:
- return opts, fmt.Errorf("update: unknown flag %q", arg)
- }
- }
- return opts, nil
- }
|