mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-05 13:29:44 +02:00
* feat(daemon): auto-update CLI when idle (MUL-2100) Add a periodic poller that checks GitHub for a newer multica release every hour and self-updates when the daemon is idle, reusing the same brew-or-download upgrade path the Runtimes-page "Update" button already runs. - Refactor handleUpdate to call a shared runUpdate(target) helper so both server-triggered and auto-triggered upgrades go through the same brew detection + atomic replace + restart. - New autoUpdateLoop gates each tick on: opt-out flag, Desktop launch source, dev-build version, an in-flight update, and active tasks. The idle gate guarantees we never interrupt a running agent — busy ticks silently retry at the next interval. - Config: MULTICA_DAEMON_AUTO_UPDATE=false to disable (also via --no-auto-update), MULTICA_DAEMON_AUTO_UPDATE_INTERVAL to retune the poll period. - IsNewerVersion / IsReleaseVersion helpers in the cli package, with tests covering patch/minor/major bumps, dev-describe strings, and malformed input. - Daemon-side tests cover every skip path (updating, active tasks, fetch failure, no-newer) plus the success path that fires triggerRestart while keeping the updating flag held to the end. Co-authored-by: multica-agent <github@multica.ai> * fix(daemon): close idle race + verify checksum in auto-update (MUL-2100) Two issues raised in PR #2679 review: 1. The first idle check in tryAutoUpdate only ran before the release-metadata fetch, so a poller that won the claim race during the fetch could end up handing handleTask a task that triggerRestart was about to cancel via root- ctx cancellation. Add a strict claim barrier: runRuntimePoller now tryEnterClaim()s before ClaimTask, and tryAutoUpdate flips pauseClaims under claimMu only after observing claimsInFlight + activeTasks == 0. Pollers that were already mid-claim hold claimsInFlight > 0, so the barrier refuses to engage and the update defers to the next tick. 2. The direct-download path replaced the running binary with whatever bytes GitHub returned, without checking checksums.txt. Pull the manifest first, buffer the archive, and reject on SHA-256 mismatch before extraction. The GoReleaser config already publishes checksums.txt; we just consume it. Also tighten parseReleaseVersion so it stops accepting dev-describe shapes like "v0.1.13-5-gabcdef0" through the patch trim, matching its docstring. The auto-update loop already guards on IsReleaseVersion, but the lenient parser was a footgun and the existing test name even said "not newer" while asserting the opposite. Tests: - TestTryAutoUpdate_DefersWhenClaimInFlightAtBarrier (new race coverage) - TestTryAutoUpdate_HoldsBarrierAcrossRestart / ReleasesBarrierOnUpgradeFailure - TestTryEnterClaim_RespectsBarrier - TestFindChecksumManifestAsset / TestParseChecksumManifest / TestVerifyAssetSHA256 - TestIsNewerVersion: dev-describe cases now expect false (matches docstring) Co-authored-by: multica-agent <github@multica.ai> * chore(daemon): default auto-update poll interval to 6h (MUL-2100) 1h was overly chatty for a release that lands at most a few times a week. Operators who want a different cadence can still set MULTICA_DAEMON_AUTO_UPDATE_INTERVAL or --auto-update-interval. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: multica-agent <github@multica.ai>
305 lines
8.6 KiB
Go
305 lines
8.6 KiB
Go
package cli
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestReleaseAssetCandidates(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
targetVersion string
|
|
goos string
|
|
goarch string
|
|
wantAssets []string
|
|
}{
|
|
{
|
|
name: "darwin prefers versioned then legacy candidate",
|
|
targetVersion: "v1.2.3",
|
|
goos: "darwin",
|
|
goarch: "arm64",
|
|
wantAssets: []string{
|
|
"multica-cli-1.2.3-darwin-arm64.tar.gz",
|
|
"multica_darwin_arm64.tar.gz",
|
|
},
|
|
},
|
|
{
|
|
name: "linux normalizes missing v in versioned candidate",
|
|
targetVersion: "1.2.3",
|
|
goos: "linux",
|
|
goarch: "amd64",
|
|
wantAssets: []string{
|
|
"multica-cli-1.2.3-linux-amd64.tar.gz",
|
|
"multica_linux_amd64.tar.gz",
|
|
},
|
|
},
|
|
{
|
|
name: "windows uses zip assets",
|
|
targetVersion: "1.2.3",
|
|
goos: "windows",
|
|
goarch: "amd64",
|
|
wantAssets: []string{
|
|
"multica-cli-1.2.3-windows-amd64.zip",
|
|
"multica_windows_amd64.zip",
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := releaseAssetCandidates(tt.targetVersion, tt.goos, tt.goarch)
|
|
if len(got) != len(tt.wantAssets) {
|
|
t.Fatalf("candidate count mismatch: got %d, want %d", len(got), len(tt.wantAssets))
|
|
}
|
|
for i := range got {
|
|
if got[i] != tt.wantAssets[i] {
|
|
t.Fatalf("candidate[%d] mismatch: got %q, want %q", i, got[i], tt.wantAssets[i])
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestFindReleaseAsset(t *testing.T) {
|
|
t.Run("prefers versioned asset when both names exist", func(t *testing.T) {
|
|
assets := []GitHubReleaseAsset{
|
|
{Name: "multica_darwin_amd64.tar.gz", BrowserDownloadURL: "old"},
|
|
{Name: "multica-cli-1.2.3-darwin-amd64.tar.gz", BrowserDownloadURL: "new"},
|
|
}
|
|
|
|
got, err := findReleaseAsset(assets, "v1.2.3", "darwin", "amd64")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if got.Name != "multica-cli-1.2.3-darwin-amd64.tar.gz" {
|
|
t.Fatalf("asset mismatch: got %q", got.Name)
|
|
}
|
|
})
|
|
|
|
t.Run("falls back to legacy asset when versioned is absent", func(t *testing.T) {
|
|
assets := []GitHubReleaseAsset{
|
|
{Name: "multica_linux_amd64.tar.gz", BrowserDownloadURL: "old"},
|
|
}
|
|
|
|
got, err := findReleaseAsset(assets, "1.2.3", "linux", "amd64")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if got.Name != "multica_linux_amd64.tar.gz" {
|
|
t.Fatalf("asset mismatch: got %q", got.Name)
|
|
}
|
|
})
|
|
|
|
t.Run("returns error when no candidate matches", func(t *testing.T) {
|
|
_, err := findReleaseAsset([]GitHubReleaseAsset{{Name: "checksums.txt"}}, "1.2.3", "linux", "amd64")
|
|
if err == nil {
|
|
t.Fatal("expected error, got nil")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestIsReleaseVersion(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
in string
|
|
want bool
|
|
}{
|
|
{"bare release", "0.1.13", true},
|
|
{"v-prefixed release", "v0.1.13", true},
|
|
{"surrounding whitespace", " v0.1.13 ", true},
|
|
{"dev describe", "v0.2.15-235-gdaf0e935", false},
|
|
{"dirty dev describe", "v0.2.15-235-gdaf0e935-dirty", false},
|
|
{"empty", "", false},
|
|
{"two components", "0.1", false},
|
|
{"four components", "0.1.2.3", false},
|
|
{"non-numeric", "1.0.x", false},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := IsReleaseVersion(tt.in); got != tt.want {
|
|
t.Fatalf("IsReleaseVersion(%q) = %v, want %v", tt.in, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestIsNewerVersion(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
latest, current string
|
|
want bool
|
|
}{
|
|
{"patch bump", "v0.1.14", "v0.1.13", true},
|
|
{"minor bump", "v0.2.0", "v0.1.99", true},
|
|
{"major bump", "v1.0.0", "v0.99.99", true},
|
|
{"same version", "v0.1.13", "v0.1.13", false},
|
|
{"older latest", "v0.1.12", "v0.1.13", false},
|
|
{"mixed v prefix", "0.1.14", "v0.1.13", true},
|
|
{"current is dev describe → unparseable → false", "v0.1.14", "v0.1.13-5-gabcdef0", false},
|
|
{"latest is dev describe → unparseable → false", "v0.1.14-1-gabcdef0", "v0.1.13", false},
|
|
{"latest unparseable → false", "garbage", "v0.1.13", false},
|
|
{"current unparseable → false", "v0.1.14", "garbage", false},
|
|
{"empty latest", "", "v0.1.13", false},
|
|
{"empty current", "v0.1.14", "", false},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := IsNewerVersion(tt.latest, tt.current); got != tt.want {
|
|
t.Fatalf("IsNewerVersion(%q, %q) = %v, want %v", tt.latest, tt.current, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestFindChecksumManifestAsset(t *testing.T) {
|
|
t.Run("finds checksums.txt among assets", func(t *testing.T) {
|
|
assets := []GitHubReleaseAsset{
|
|
{Name: "multica-cli-1.2.3-darwin-arm64.tar.gz"},
|
|
{Name: "checksums.txt", BrowserDownloadURL: "https://example/checksums.txt"},
|
|
{Name: "multica-cli-1.2.3-linux-amd64.tar.gz"},
|
|
}
|
|
got, err := findChecksumManifestAsset(assets)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if got.Name != "checksums.txt" || got.BrowserDownloadURL != "https://example/checksums.txt" {
|
|
t.Fatalf("got %+v", got)
|
|
}
|
|
})
|
|
|
|
t.Run("returns error when manifest missing", func(t *testing.T) {
|
|
_, err := findChecksumManifestAsset([]GitHubReleaseAsset{
|
|
{Name: "multica-cli-1.2.3-darwin-arm64.tar.gz"},
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected error when checksums.txt is absent")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestParseChecksumManifest(t *testing.T) {
|
|
manifest := []byte(strings.Join([]string{
|
|
"# generated by GoReleaser",
|
|
"",
|
|
"aaaa1111 multica-cli-1.2.3-darwin-arm64.tar.gz",
|
|
"bbbb2222 multica-cli-1.2.3-darwin-amd64.tar.gz",
|
|
"cccc3333\tmulti-tab-separator.tar.gz",
|
|
"DDDD4444 multica_linux_amd64.tar.gz",
|
|
}, "\n"))
|
|
|
|
t.Run("returns lowercase sha for matched entry", func(t *testing.T) {
|
|
got, err := parseChecksumManifest(manifest, "multica-cli-1.2.3-darwin-arm64.tar.gz")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if got != "aaaa1111" {
|
|
t.Fatalf("sha = %q, want aaaa1111", got)
|
|
}
|
|
})
|
|
|
|
t.Run("matches a tab-separated entry", func(t *testing.T) {
|
|
got, err := parseChecksumManifest(manifest, "multi-tab-separator.tar.gz")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if got != "cccc3333" {
|
|
t.Fatalf("sha = %q, want cccc3333", got)
|
|
}
|
|
})
|
|
|
|
t.Run("downcases an uppercase entry", func(t *testing.T) {
|
|
got, err := parseChecksumManifest(manifest, "multica_linux_amd64.tar.gz")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if got != "dddd4444" {
|
|
t.Fatalf("sha = %q, want dddd4444", got)
|
|
}
|
|
})
|
|
|
|
t.Run("returns error when asset is absent", func(t *testing.T) {
|
|
_, err := parseChecksumManifest(manifest, "not-in-manifest.tar.gz")
|
|
if err == nil {
|
|
t.Fatal("expected error for missing asset")
|
|
}
|
|
})
|
|
|
|
t.Run("skips blank lines and comments", func(t *testing.T) {
|
|
// If parsing broke on blank/comment lines we'd never reach the
|
|
// matching entry below them.
|
|
if _, err := parseChecksumManifest(manifest, "multica-cli-1.2.3-darwin-arm64.tar.gz"); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestVerifyAssetSHA256(t *testing.T) {
|
|
data := []byte("hello multica")
|
|
sum := sha256.Sum256(data)
|
|
good := hex.EncodeToString(sum[:])
|
|
|
|
t.Run("accepts matching sha", func(t *testing.T) {
|
|
if err := verifyAssetSHA256(data, good, "asset.tar.gz"); err != nil {
|
|
t.Fatalf("expected ok, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("accepts uppercase expected hex", func(t *testing.T) {
|
|
if err := verifyAssetSHA256(data, strings.ToUpper(good), "asset.tar.gz"); err != nil {
|
|
t.Fatalf("expected ok with uppercase expected, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("rejects mismatched sha", func(t *testing.T) {
|
|
err := verifyAssetSHA256([]byte("tampered"), good, "asset.tar.gz")
|
|
if err == nil {
|
|
t.Fatal("expected mismatch error")
|
|
}
|
|
if !strings.Contains(err.Error(), "asset.tar.gz") {
|
|
t.Fatalf("error should name the asset: %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("rejects empty expected", func(t *testing.T) {
|
|
if err := verifyAssetSHA256(data, "", "asset.tar.gz"); err == nil {
|
|
t.Fatal("expected error for empty expected sha")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestUpdateDownloadTimeoutOrDefault(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
timeout time.Duration
|
|
want time.Duration
|
|
}{
|
|
{
|
|
name: "uses default for zero",
|
|
timeout: 0,
|
|
want: DefaultUpdateDownloadTimeout,
|
|
},
|
|
{
|
|
name: "uses default for negative",
|
|
timeout: -1 * time.Second,
|
|
want: DefaultUpdateDownloadTimeout,
|
|
},
|
|
{
|
|
name: "keeps explicit timeout",
|
|
timeout: 10 * time.Minute,
|
|
want: 10 * time.Minute,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := updateDownloadTimeoutOrDefault(tt.timeout)
|
|
if got != tt.want {
|
|
t.Fatalf("timeout = %s, want %s", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|