Files
multica/server/internal/cli/update.go
Matt Van Horn 6b7294aa5b fix(daemon): use brew prefix symlink for self-restart so Linux Cellar deletion does not orphan runtimes (#2076)
* fix(daemon): use brew prefix symlink for self-restart so Linux Cellar deletion does not orphan runtimes

After brew upgrade on Linux, os.Executable() resolves /proc/self/exe to
the Cellar path (e.g. .../Cellar/multica/0.2.9/bin/multica), which
brew cleanup deletes. The previous IsBrewInstall() short-circuit skipped
EvalSymlinks to 'preserve' the symlink, but on Linux there was nothing
to preserve - the path was already resolved.

Use cli.GetBrewPrefix() to resolve the stable symlink path
<brewPrefix>/bin/multica for brew installs. Fall back to
EvalSymlinks(os.Executable()) with a warning log when GetBrewPrefix()
returns empty (brew binary missing from PATH).

Introduce package-level function vars (isBrewInstall, getBrewPrefix) so
the daemon test can override them without modifying the cli package.

Closes #1624

* fix(daemon): harden brew-prefix fallback and document the WHY

When `brew --prefix` is unavailable but the binary is under a known Cellar
root, recover the prefix from cli.MatchKnownBrewPrefix and target
<prefix>/bin/multica instead of falling back to the resolved Cellar path
(which brew cleanup just deleted).

- Extract knownBrewPrefixes + MatchKnownBrewPrefix in cli/update.go and
  reuse from IsBrewInstall to keep one source of truth for the install-root
  list.
- Add a WHY comment above the brew branch in triggerRestart explaining the
  /proc/self/exe -> Cellar -> deleted-by-brew-cleanup chain.
- Cover both fallback paths (matched / unmatched) in daemon_test.go.

---------

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
2026-05-08 12:08:56 +08:00

341 lines
9.7 KiB
Go

package cli
import (
"archive/tar"
"archive/zip"
"bytes"
"compress/gzip"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
)
const DefaultUpdateDownloadTimeout = 120 * time.Second
// GitHubRelease is the subset of the GitHub releases API response we need.
type GitHubRelease struct {
TagName string `json:"tag_name"`
HTMLURL string `json:"html_url"`
Assets []GitHubReleaseAsset `json:"assets"`
}
type GitHubReleaseAsset struct {
Name string `json:"name"`
BrowserDownloadURL string `json:"browser_download_url"`
}
func releaseArchiveExtension(goos string) string {
if goos == "windows" {
return "zip"
}
return "tar.gz"
}
func normalizeReleaseTag(targetVersion string) string {
tag := strings.TrimSpace(targetVersion)
if !strings.HasPrefix(tag, "v") {
tag = "v" + tag
}
return tag
}
func releaseAssetCandidates(targetVersion, goos, goarch string) []string {
tag := normalizeReleaseTag(targetVersion)
version := strings.TrimPrefix(tag, "v")
ext := releaseArchiveExtension(goos)
// Prefer the versioned name (current scheme); fall back to the legacy
// `multica_{os}_{arch}` name for releases that still ship it.
return []string{
fmt.Sprintf("multica-cli-%s-%s-%s.%s", version, goos, goarch, ext),
fmt.Sprintf("multica_%s_%s.%s", goos, goarch, ext),
}
}
func findReleaseAsset(assets []GitHubReleaseAsset, targetVersion, goos, goarch string) (*GitHubReleaseAsset, error) {
for _, candidate := range releaseAssetCandidates(targetVersion, goos, goarch) {
for i := range assets {
if assets[i].Name == candidate {
return &assets[i], nil
}
}
}
candidates := strings.Join(releaseAssetCandidates(targetVersion, goos, goarch), ", ")
return nil, fmt.Errorf("no matching release asset for %s/%s (tried: %s)", goos, goarch, candidates)
}
func fetchReleaseByTag(tag string) (*GitHubRelease, error) {
client := &http.Client{Timeout: 10 * time.Second}
req, err := http.NewRequest(http.MethodGet, "https://api.github.com/repos/multica-ai/multica/releases/tags/"+tag, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/vnd.github+json")
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("GitHub API returned %d", resp.StatusCode)
}
var release GitHubRelease
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
return nil, err
}
return &release, nil
}
// FetchLatestRelease fetches the latest release tag from the multica GitHub repo.
func FetchLatestRelease() (*GitHubRelease, error) {
client := &http.Client{Timeout: 10 * time.Second}
req, err := http.NewRequest(http.MethodGet, "https://api.github.com/repos/multica-ai/multica/releases/latest", nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/vnd.github+json")
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("GitHub API returned %d", resp.StatusCode)
}
var release GitHubRelease
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
return nil, err
}
return &release, nil
}
// knownBrewPrefixes lists the install roots Homebrew uses on each platform.
// Order is irrelevant — the prefixes do not nest.
var knownBrewPrefixes = []string{"/opt/homebrew", "/usr/local", "/home/linuxbrew/.linuxbrew"}
// MatchKnownBrewPrefix returns the Homebrew prefix whose Cellar contains path,
// or "" if path is not under a known Cellar. It is the offline equivalent of
// `brew --prefix`: callers reach for it when `brew --prefix` is unavailable
// (brew not on PATH) but the binary's path still betrays its install root.
func MatchKnownBrewPrefix(path string) string {
for _, prefix := range knownBrewPrefixes {
if strings.HasPrefix(path, prefix+"/Cellar/") {
return prefix
}
}
return ""
}
// IsBrewInstall checks whether the running multica binary was installed via Homebrew.
func IsBrewInstall() bool {
exePath, err := os.Executable()
if err != nil {
return false
}
resolved, err := filepath.EvalSymlinks(exePath)
if err != nil {
resolved = exePath
}
brewPrefix := GetBrewPrefix()
if brewPrefix != "" && strings.HasPrefix(resolved, brewPrefix) {
return true
}
return MatchKnownBrewPrefix(resolved) != ""
}
// GetBrewPrefix returns the Homebrew prefix by running `brew --prefix`, or empty string.
func GetBrewPrefix() string {
out, err := exec.Command("brew", "--prefix").Output()
if err != nil {
return ""
}
return strings.TrimSpace(string(out))
}
// UpdateViaBrew runs `brew upgrade multica-ai/tap/multica`.
// Returns the combined output and any error.
func UpdateViaBrew() (string, error) {
cmd := exec.Command("brew", "upgrade", "multica-ai/tap/multica")
out, err := cmd.CombinedOutput()
if err != nil {
return string(out), fmt.Errorf("brew upgrade failed: %w", err)
}
return string(out), nil
}
func updateDownloadTimeoutOrDefault(timeout time.Duration) time.Duration {
if timeout <= 0 {
return DefaultUpdateDownloadTimeout
}
return timeout
}
// UpdateViaDownload downloads the latest release binary from GitHub and replaces
// the current executable in-place. Returns the combined output message and any error.
func UpdateViaDownload(targetVersion string) (string, error) {
return UpdateViaDownloadWithTimeout(targetVersion, DefaultUpdateDownloadTimeout)
}
// UpdateViaDownloadWithTimeout downloads the latest release binary with a caller-selected timeout.
func UpdateViaDownloadWithTimeout(targetVersion string, downloadTimeout time.Duration) (string, error) {
// Determine current binary path.
exePath, err := os.Executable()
if err != nil {
return "", fmt.Errorf("resolve executable path: %w", err)
}
exePath, err = filepath.EvalSymlinks(exePath)
if err != nil {
return "", fmt.Errorf("resolve symlink: %w", err)
}
tag := normalizeReleaseTag(targetVersion)
release, err := fetchReleaseByTag(tag)
if err != nil {
return "", fmt.Errorf("fetch release metadata: %w", err)
}
asset, err := findReleaseAsset(release.Assets, tag, runtime.GOOS, runtime.GOARCH)
if err != nil {
return "", err
}
downloadURL := asset.BrowserDownloadURL
assetName := asset.Name
// Download the archive.
client := &http.Client{Timeout: updateDownloadTimeoutOrDefault(downloadTimeout)}
resp, err := client.Get(downloadURL)
if err != nil {
return "", fmt.Errorf("download failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("download failed: HTTP %d from %s", resp.StatusCode, downloadURL)
}
// Extract the binary from the archive.
binaryName := "multica"
if runtime.GOOS == "windows" {
binaryName = "multica.exe"
}
var binaryData []byte
if runtime.GOOS == "windows" {
binaryData, err = extractBinaryFromZip(resp.Body, binaryName)
} else {
binaryData, err = extractBinaryFromTarGz(resp.Body, binaryName)
}
if err != nil {
return "", fmt.Errorf("extract binary: %w", err)
}
// Atomic replace: write to temp file, then rename over the original.
dir := filepath.Dir(exePath)
tmpFile, err := os.CreateTemp(dir, "multica-update-*")
if err != nil {
return "", fmt.Errorf("create temp file: %w", err)
}
tmpPath := tmpFile.Name()
if _, err := tmpFile.Write(binaryData); err != nil {
tmpFile.Close()
os.Remove(tmpPath)
return "", fmt.Errorf("write temp file: %w", err)
}
tmpFile.Close()
// Preserve original file permissions.
info, err := os.Stat(exePath)
if err != nil {
os.Remove(tmpPath)
return "", fmt.Errorf("stat original binary: %w", err)
}
if err := os.Chmod(tmpPath, info.Mode()); err != nil {
os.Remove(tmpPath)
return "", fmt.Errorf("chmod temp file: %w", err)
}
// Replace the original binary. On Windows this moves the running executable
// aside first; on Unix a plain rename over the running inode is fine.
if err := replaceBinary(tmpPath, exePath); err != nil {
os.Remove(tmpPath)
return "", fmt.Errorf("replace binary: %w", err)
}
return fmt.Sprintf("Downloaded %s and replaced %s", assetName, exePath), nil
}
// extractBinaryFromTarGz reads a .tar.gz stream and returns the contents of the
// named file entry.
func extractBinaryFromTarGz(r io.Reader, name string) ([]byte, error) {
gz, err := gzip.NewReader(r)
if err != nil {
return nil, fmt.Errorf("gzip reader: %w", err)
}
defer gz.Close()
tr := tar.NewReader(gz)
for {
hdr, err := tr.Next()
if err == io.EOF {
return nil, fmt.Errorf("binary %q not found in archive", name)
}
if err != nil {
return nil, fmt.Errorf("read tar: %w", err)
}
// Match the binary name (may be prefixed with a directory).
if filepath.Base(hdr.Name) == name && hdr.Typeflag == tar.TypeReg {
data, err := io.ReadAll(tr)
if err != nil {
return nil, fmt.Errorf("read binary: %w", err)
}
return data, nil
}
}
}
// extractBinaryFromZip reads a .zip stream and returns the contents of the
// named file entry. The zip format requires random access, so the full archive
// is buffered in memory.
func extractBinaryFromZip(r io.Reader, name string) ([]byte, error) {
buf, err := io.ReadAll(r)
if err != nil {
return nil, fmt.Errorf("read zip data: %w", err)
}
zr, err := zip.NewReader(bytes.NewReader(buf), int64(len(buf)))
if err != nil {
return nil, fmt.Errorf("zip reader: %w", err)
}
for _, f := range zr.File {
if filepath.Base(f.Name) == name && !f.FileInfo().IsDir() {
rc, err := f.Open()
if err != nil {
return nil, fmt.Errorf("open zip entry: %w", err)
}
defer rc.Close()
data, err := io.ReadAll(rc)
if err != nil {
return nil, fmt.Errorf("read binary: %w", err)
}
return data, nil
}
}
return nil, fmt.Errorf("binary %q not found in archive", name)
}