mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-08 01:16:45 +08:00
* fix(test): isolate cmd/multica suite home resolution on Windows os.UserHomeDir reads USERPROFILE, not HOME, so every test that redirected only HOME kept resolving ~/.multica against the real home: mkProfiles stopped isolating at all, and a full-suite run wrote SaveCLIConfig fixtures over a real default-profile config.json. Redirect HOME and USERPROFILE process-wide in TestMain, and make mkProfiles redirect both so per-test isolation holds on Windows too. On unix nothing changes: USERPROFILE is unused there. Co-authored-by: multica-agent <[email protected]> * feat(cli): register daemon boot autostart on every platform multica daemon start now registers the profile's daemon to come back at login/boot so a reboot no longer takes the runtime offline: an HKCU Run key on Windows, a launchd LaunchAgent on macOS, and a systemd user unit (or XDG autostart fallback without systemd) on Linux. New 'daemon autostart enable|disable|status' manages the registration; --no-autostart opts a single start out, and daemons spawned by a manager (MULTICA_LAUNCHED_BY, i.e. the Desktop app's own start-at-login pref) are never registered. The registered command runs 'daemon start --foreground' per profile and snapshots the shell's PATH so login sessions keep finding agent CLIs. EnsureHiddenConsole now hides a console that exists only for the daemon itself — a Run-key launch would otherwise park a visible console window for the daemon's whole lifetime. Co-authored-by: multica-agent <[email protected]> * refactor(cli): make boot autostart registration opt-in per review Registration now happens only through 'multica daemon autostart enable': daemon start (and setup / restart / --foreground) merely print the enable hint when nothing is registered and silently refresh an entry Multica owns — marked by a comment line in a unit/.desktop file or a ManagedBy key in a plist (the Run value name doubles as the marker on Windows). Refresh skips anything without the marker and skips entirely under an external systemd unit (INVOCATION_ID with a cgroup that is not ours), so a hand-written unit like the one in #7091 is never overwritten. --no-autostart is gone (there is nothing to opt out of), loginctl enable-linger is no longer run automatically (enable prints the command instead), and 'autostart enable' now spells out that only PATH carries into the login session while shell-exported variables do not. Also fixes the binary-update restart handoff under our own systemd unit: spawning a successor and exiting 0 lets systemd's cgroup cleanup kill it while Restart=on-failure never fires, leaving the runtime offline after the first auto-update. The daemon now exits with daemonSystemdHandoffExitStatus (42) instead; the generated unit carries RestartForceExitStatus for it, and linux tests cover the unit-ownership detection behind both the handoff and the refresh gate. Review items also addressed: gofmt applied to cmd_daemon_test.go and proc_windows.go, the Desktop wording corrected to its app-start daemon preference (it has no OS login item), and CLI_AND_DAEMON.md plus the daemon start / autostart help text follow the opt-in flow. Co-authored-by: multica-agent <[email protected]> * fix(test): redirect USERPROFILE alongside HOME in cmd/multica tests Every t.Setenv("HOME", ...) in this package now goes through redirectTestHome, which sets both home environment variables. Production resolves ~/.multica via os.UserHomeDir — USERPROFILE on Windows — so a HOME-only redirect split the fixture's write path from the code's read path: on Windows the fixture landed where the code never looked (or, before TestMain's scratch home, in the real ~/.multica). CI on ubuntu is unaffected; the Windows host suite stops depending on whatever happens to be in the real profile directory. Co-authored-by: multica-agent <[email protected]> * fix(cli): record autostart with the stable brew path, shared with the restart target The exit-42 systemd handoff restarts whatever ExecStart recorded, and autostartSpecFor recorded os.Executable() verbatim — on brew installs (Homebrew on Linux too, reached through install.sh) that is the versioned keg path, which `brew upgrade` deletes when it cleans the old keg: systemd then fails ExecStart with 203/EXEC and gives up after StartLimitBurst, leaving the runtime offline until someone starts it by hand; a LaunchAgent would point at a missing file at the next login. Fix per review: both consumers now resolve through one shared helper, cli.StableExecutablePath, with identical detection order — the path's own known-Cellar shape first, then `brew --prefix` — so the two cannot drift. The daemon's restartTargetBinary keeps its seam-based per-process cache but delegates the mapping; autostartSpecFor runs cli.StableSelfExecutable before recording. cli.IsBrewInstall shares the same detection core. Tests pin the keg -> <prefix>/bin/multica mapping at the helper level for all three known prefixes, and end to end through autostartSpecFor feeding a brew keg path, as requested in the review. Co-authored-by: multica-agent <[email protected]> * fix(cli): key autostart ownership on file presence, not enablement Review round 3: on Linux Enabled only means "linked in default.target.wants", so the enable/disable guard (Enabled && !Managed) missed a hand-written unit at our path that was unlinked — or linked under another target such as graphical-session.target — and disable deleted it; separately, the XDG sweeper deleted an unmarked multica-daemon.desktop outright from enable, disable, and the silent daemon start refresh. - autostartState gains Present ("a file/value exists at our path"); refuseUnmanagedAutostart and the status note key off Present, while the enabled/disabled verdict still keys off Enabled. - platformRemoveAutostart re-checks the marker on the unit BEFORE systemctl disable (refusing must not unlink the user's unit as a side effect) and refuses to delete an unmarked file; removeXdgAutostartFile removes only marked files, which covers all three of its call sites (enable's stale sweep, the daemon start refresh, disable). - sync no longer hints enable at a foreign unlinked file, and never heals an owned-but-unlinked entry — refresh must not re-enable what the user manually disabled — it hints instead. - linux tests cover unlinked / other-target-linked / default-linked unmarked units on the read side, the removal backstop, a managed unit coexisting with an unmarked .desktop, and the mark-checked XDG sweeper; shared guard/status/sync tests move to the presence contract. Co-authored-by: multica-agent <[email protected]> * fix(cli): keep the running service's restart policy when disabling autostart Review round 4 (P2): on Linux, `autostart disable` deleted the managed unit file after unlinking it. The running daemon kept going, but systemd lost the unit — LoadState=not-found, Restart=no — so the next binary-update handoff (exit 42) exited into a service that could no longer restart it and the runtime stayed offline until a manual start. Disable now removes the enablement only: unlink every wants entry (systemctl --user disable, with a glob fallback for a missing user bus) and KEEP the marked unit file, so the supervised process retains its restart policy while nothing starts at the next login. Re-enabling re-links the same file. A second disable is a no-op again (change is keyed off links actually present, in any target). The macOS equivalent is fixed in the same spirit: the plist is deleted WITHOUT launchctl bootout, which used to terminate the daemon launchd was supervising — disable means "don't start at the next login", not "kill this session". enable/disable now read the state once and share it with the ownership guard; enable's wording keys off the pre-state, because re-linking a kept unit often changes no file content and "already enabled" would describe the state the user just left. The daemon-start hint now fires only when nothing is registered at all — never at a disabled-or-foreign file the user chose or owns — and the refresh still never re-links. Regression coverage for running service → disable → handoff: TestDisableKeepsRunningServiceRecoverable asserts the unit file survives with RestartForceExitStatus intact, no wants link remains, the handoff still detects our own unit (exit-42 path), a subsequent sync does not re-enable, and a second disable reports no change. Docs note the keep-the-file semantics on both Linux and macOS. Co-authored-by: multica-agent <[email protected]> * test(cli): update the coexistence test to the keep-the-unit disable contract CI caught the round-4 test asserting the pre-round-5 behavior: it wrote a marked unit WITHOUT a wants link and expected platformRemoveAutostart to delete it. Disable now keeps the marked unit (unlinking its enablement) so a running service retains its restart policy — so the test links the unit (giving disable real work, changed=true), asserts the unit survives unlinked, and keeps its original point: the hand-written .desktop beside it is never touched. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: ZhangRui <[email protected]> Co-authored-by: multica-agent <[email protected]> Co-authored-by: Bohan-J <[email protected]>
562 lines
18 KiB
Go
562 lines
18 KiB
Go
package cli
|
||
|
||
import (
|
||
"archive/tar"
|
||
"archive/zip"
|
||
"bufio"
|
||
"bytes"
|
||
"compress/gzip"
|
||
"context"
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"net/url"
|
||
"os"
|
||
"os/exec"
|
||
"path/filepath"
|
||
"runtime"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/multica-ai/multica/server/internal/selfexec"
|
||
)
|
||
|
||
// ChecksumManifestName is the asset name GoReleaser publishes for the
|
||
// checksum manifest (`checksum.name_template: "checksums.txt"` in
|
||
// .goreleaser.yml). Kept as a constant rather than inlined so a future rename
|
||
// changes one place.
|
||
const ChecksumManifestName = "checksums.txt"
|
||
|
||
const defaultReleaseAPIBaseURL = "https://api.github.com"
|
||
|
||
// MULTICA_RELEASE_API_BASE_URL points at a GitHub Releases API-compatible
|
||
// mirror. MULTICA_RELEASE_DOWNLOAD_BASE_URL points at an artifact mirror
|
||
// serving /<tag>/<asset-name>; when unset, the release-provided URLs win.
|
||
const releaseAPIBaseURLEnv = "MULTICA_RELEASE_API_BASE_URL"
|
||
const releaseDownloadBaseURLEnv = "MULTICA_RELEASE_DOWNLOAD_BASE_URL"
|
||
|
||
const DefaultUpdateDownloadTimeout = 120 * time.Second
|
||
|
||
func releaseAPIBaseURL() string {
|
||
if value := strings.TrimRight(strings.TrimSpace(os.Getenv(releaseAPIBaseURLEnv)), "/"); value != "" {
|
||
return value
|
||
}
|
||
return defaultReleaseAPIBaseURL
|
||
}
|
||
|
||
func releaseDownloadBaseURL() string {
|
||
return strings.TrimRight(strings.TrimSpace(os.Getenv(releaseDownloadBaseURLEnv)), "/")
|
||
}
|
||
|
||
func releaseAssetDownloadURL(assetName, targetVersion, fallback string) string {
|
||
base := releaseDownloadBaseURL()
|
||
if base == "" {
|
||
return fallback
|
||
}
|
||
return fmt.Sprintf("%s/%s/%s", base, url.PathEscape(normalizeReleaseTag(targetVersion)), url.PathEscape(assetName))
|
||
}
|
||
|
||
// 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"`
|
||
}
|
||
|
||
// IsReleaseVersion reports whether v looks like a tagged release version
|
||
// (e.g. "0.1.13", "v0.1.13") rather than a dev build (e.g. an empty version
|
||
// or a `git describe`–style "v0.2.15-235-gdaf0e935"). The auto-update poller
|
||
// uses this to skip self-update for source builds, where downgrading to a
|
||
// public release would clobber unreleased changes.
|
||
func IsReleaseVersion(v string) bool {
|
||
s := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(v), "v"))
|
||
if s == "" {
|
||
return false
|
||
}
|
||
parts := strings.Split(s, ".")
|
||
if len(parts) != 3 {
|
||
return false
|
||
}
|
||
for _, p := range parts {
|
||
if p == "" {
|
||
return false
|
||
}
|
||
for _, r := range p {
|
||
if r < '0' || r > '9' {
|
||
return false
|
||
}
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
// IsNewerVersion reports whether latest is strictly newer than current. Both
|
||
// arguments may carry an optional "v" prefix; non-numeric tails are ignored
|
||
// (a 4th component, pre-release tag, etc.). Returns false if either side
|
||
// cannot be parsed — the caller treats that as "stay on current".
|
||
func IsNewerVersion(latest, current string) bool {
|
||
l, ok := parseReleaseVersion(latest)
|
||
if !ok {
|
||
return false
|
||
}
|
||
c, ok := parseReleaseVersion(current)
|
||
if !ok {
|
||
return false
|
||
}
|
||
for i := 0; i < 3; i++ {
|
||
if l[i] != c[i] {
|
||
return l[i] > c[i]
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// parseReleaseVersion extracts the three numeric components of v. Returns
|
||
// (parts, true) on success; (_, false) when v is missing, malformed, or
|
||
// carries any non-numeric tail (a dev-describe suffix, a 4th component, a
|
||
// pre-release tag, etc.). The strict shape is intentional: this is the only
|
||
// parser used by IsNewerVersion, and the autoUpdateLoop must never silently
|
||
// downgrade a developer build to a public release just because the
|
||
// dev-describe patch happened to look numeric after trimming.
|
||
func parseReleaseVersion(v string) ([3]int, bool) {
|
||
s := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(v), "v"))
|
||
if s == "" {
|
||
return [3]int{}, false
|
||
}
|
||
parts := strings.Split(s, ".")
|
||
if len(parts) != 3 {
|
||
return [3]int{}, false
|
||
}
|
||
var out [3]int
|
||
for i, p := range parts {
|
||
if p == "" {
|
||
return [3]int{}, false
|
||
}
|
||
for _, r := range p {
|
||
if r < '0' || r > '9' {
|
||
return [3]int{}, false
|
||
}
|
||
}
|
||
n, err := strconv.Atoi(p)
|
||
if err != nil {
|
||
return [3]int{}, false
|
||
}
|
||
out[i] = n
|
||
}
|
||
return out, true
|
||
}
|
||
|
||
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)
|
||
}
|
||
|
||
// findChecksumManifestAsset locates the GoReleaser-generated checksums.txt
|
||
// among a release's assets. Required for the direct-download path's SHA-256
|
||
// verification — if it is missing we refuse to replace the binary rather
|
||
// than fall back to unverified install, because the auto-update poller runs
|
||
// unattended and an unverified binary swap is a supply-chain risk.
|
||
func findChecksumManifestAsset(assets []GitHubReleaseAsset) (*GitHubReleaseAsset, error) {
|
||
for i := range assets {
|
||
if assets[i].Name == ChecksumManifestName {
|
||
return &assets[i], nil
|
||
}
|
||
}
|
||
return nil, fmt.Errorf("checksum manifest %q not present in release", ChecksumManifestName)
|
||
}
|
||
|
||
// parseChecksumManifest reads a GoReleaser-style "<sha256> <filename>"
|
||
// manifest and returns the lowercase hex SHA-256 for assetName. Returns an
|
||
// error if the asset is absent so a typo (or the wrong manifest from a
|
||
// different release) fails closed rather than silently disabling
|
||
// verification.
|
||
func parseChecksumManifest(manifest []byte, assetName string) (string, error) {
|
||
scanner := bufio.NewScanner(bytes.NewReader(manifest))
|
||
for scanner.Scan() {
|
||
line := strings.TrimSpace(scanner.Text())
|
||
if line == "" || strings.HasPrefix(line, "#") {
|
||
continue
|
||
}
|
||
fields := strings.Fields(line)
|
||
// GoReleaser's default separator is two spaces; some tools use one
|
||
// or pad with tabs. strings.Fields handles all of those at once.
|
||
if len(fields) < 2 {
|
||
continue
|
||
}
|
||
if fields[1] == assetName {
|
||
return strings.ToLower(fields[0]), nil
|
||
}
|
||
}
|
||
if err := scanner.Err(); err != nil {
|
||
return "", fmt.Errorf("read checksum manifest: %w", err)
|
||
}
|
||
return "", fmt.Errorf("checksum for %q not found in manifest", assetName)
|
||
}
|
||
|
||
// verifyAssetSHA256 returns nil when the SHA-256 of data matches the lowercase
|
||
// hex expected value, or an error otherwise. The error includes both digests
|
||
// so a corrupted asset is diagnosable from the log without re-downloading.
|
||
func verifyAssetSHA256(data []byte, expectedHex, assetName string) error {
|
||
if expectedHex == "" {
|
||
return fmt.Errorf("empty expected checksum for %q", assetName)
|
||
}
|
||
sum := sha256.Sum256(data)
|
||
actual := hex.EncodeToString(sum[:])
|
||
if !strings.EqualFold(actual, expectedHex) {
|
||
return fmt.Errorf("checksum mismatch for %q: expected %s, got %s", assetName, expectedHex, actual)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func fetchReleaseByTag(tag string) (*GitHubRelease, error) {
|
||
client := &http.Client{Timeout: 10 * time.Second}
|
||
req, err := http.NewRequest(http.MethodGet, releaseAPIBaseURL()+"/repos/multica-ai/multica/releases/tags/"+url.PathEscape(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("release 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, releaseAPIBaseURL()+"/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("release 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.
|
||
// The check itself lives in brewInstallForPath (stable_exe.go) so it stays
|
||
// one implementation with the stable-path resolver that depends on it.
|
||
func IsBrewInstall() bool {
|
||
exePath, err := selfexec.Resolve()
|
||
if err != nil {
|
||
return false
|
||
}
|
||
return brewInstallForPath(exePath)
|
||
}
|
||
|
||
// GetBrewPrefix returns the Homebrew prefix by running `brew --prefix`, or empty string.
|
||
// Bounded: a wedged brew (broken shellenv, unreachable network home) must not
|
||
// park the caller — the daemon's reload loop reaches here periodically.
|
||
func GetBrewPrefix() string {
|
||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||
defer cancel()
|
||
cmd := exec.CommandContext(ctx, "brew", "--prefix")
|
||
cmd.WaitDelay = 2 * time.Second
|
||
out, err := cmd.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
|
||
}
|
||
|
||
// fetchURLBytes does a GET with the given timeout and returns the response
|
||
// body in full. Used for the checksum manifest (tiny) and the release
|
||
// archive (single-digit MB). The checksum verification path requires buffered
|
||
// bytes so streaming would just push the buffer into the caller anyway.
|
||
func fetchURLBytes(url string, timeout time.Duration) ([]byte, error) {
|
||
client := &http.Client{Timeout: updateDownloadTimeoutOrDefault(timeout)}
|
||
resp, err := client.Get(url)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer resp.Body.Close()
|
||
if resp.StatusCode != http.StatusOK {
|
||
return nil, fmt.Errorf("HTTP %d from %s", resp.StatusCode, url)
|
||
}
|
||
return io.ReadAll(resp.Body)
|
||
}
|
||
|
||
// 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 := selfexec.Resolve()
|
||
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
|
||
}
|
||
manifestAsset, err := findChecksumManifestAsset(release.Assets)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
downloadURL := releaseAssetDownloadURL(asset.Name, tag, asset.BrowserDownloadURL)
|
||
assetName := asset.Name
|
||
|
||
// Pull the checksum manifest first so a release that is half-published
|
||
// (archives uploaded but checksums.txt not yet) fails before we eat the
|
||
// archive's bandwidth.
|
||
timeout := updateDownloadTimeoutOrDefault(downloadTimeout)
|
||
manifestURL := releaseAssetDownloadURL(manifestAsset.Name, tag, manifestAsset.BrowserDownloadURL)
|
||
manifestData, err := fetchURLBytes(manifestURL, timeout)
|
||
if err != nil {
|
||
return "", fmt.Errorf("download checksum manifest: %w", err)
|
||
}
|
||
expectedSum, err := parseChecksumManifest(manifestData, assetName)
|
||
if err != nil {
|
||
return "", fmt.Errorf("parse checksum manifest: %w", err)
|
||
}
|
||
|
||
// Buffer the archive into memory so we can verify the full SHA-256
|
||
// before writing anything to disk. Release archives are ~10–30 MB; the
|
||
// extraction code already buffers zip archives in full (random access
|
||
// requirement), so this is not a new memory cost on Windows. For tar.gz
|
||
// it adds a single in-RAM copy, which is preferable to running the
|
||
// untrusted bytes through gzip+tar extraction before the SHA-256 check.
|
||
archiveData, err := fetchURLBytes(downloadURL, timeout)
|
||
if err != nil {
|
||
return "", fmt.Errorf("download failed: %w", err)
|
||
}
|
||
|
||
if err := verifyAssetSHA256(archiveData, expectedSum, assetName); err != nil {
|
||
// Do NOT extract or replace; the next poll tick will retry. A
|
||
// corrupted asset is rare enough that retrying through the same
|
||
// CDN is the right default; persistent failures will surface in
|
||
// the daemon log.
|
||
return "", fmt.Errorf("verify download: %w", err)
|
||
}
|
||
|
||
// 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(bytes.NewReader(archiveData), binaryName)
|
||
} else {
|
||
binaryData, err = extractBinaryFromTarGz(bytes.NewReader(archiveData), 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)
|
||
}
|