diff --git a/README.md b/README.md index 006bae9..d75750c 100644 --- a/README.md +++ b/README.md @@ -19,11 +19,29 @@ import "github.com/pilot-protocol/updater" ```go u := updater.New(updater.Config{ - Repo: "TeoSlayer/pilotprotocol", - CurrentVer: "v1.10.5", - BinaryNames: []string{"pilot-daemon", "pilotctl"}, + Repo: "TeoSlayer/pilotprotocol", + InstallDir: "/home/user/.pilot/bin", + Version: "v1.10.5", + CheckInterval: 1 * time.Hour, }) -u.Run(ctx) +u.Start() +``` + +### Pinning a version + +Set `PinnedVersion` to lock the updater to a specific release tag. When +set, the updater fetches the exact release (via +`/releases/tags/{tag}`), applies it if it differs from the current +install, then idles — it will **not** chase the latest release. Clear +`PinnedVersion` (set to `""`) to resume auto-updating. + +```go +u := updater.New(updater.Config{ + Repo: "TeoSlayer/pilotprotocol", + InstallDir: "/home/user/.pilot/bin", + PinnedVersion: "v1.10.5", // stay on this version +}) +u.Start() ``` The in-process `Service` adapter is used when embedding into the diff --git a/updater.go b/updater.go index beab11b..01b7585 100644 --- a/updater.go +++ b/updater.go @@ -42,6 +42,15 @@ type Config struct { Repo string // "owner/repo" InstallDir string Version string // updater's own version (used for user-agent) + + // PinnedVersion locks the updater to a specific release tag + // (e.g. "v1.10.5"). When set, the updater installs exactly that + // version — regardless of whether it is newer, older, or already + // current — and will not chase the latest release. An empty + // string (default) preserves the existing "always follow latest" + // behaviour. Set to an empty string to un-pin and resume + // auto-updating to the latest stable. + PinnedVersion string } // Updater periodically checks GitHub Releases for new versions and optionally applies them. @@ -125,6 +134,16 @@ func (u *Updater) checkLoop() { func (u *Updater) checkOnce() { slog.Debug("checking for updates") + // Pinned-version path: install a specific version regardless of + // whether it is newer or older than the current install. Once the + // pinned version is installed, subsequent ticks are no-ops until + // the pin is changed or cleared. + if u.config.PinnedVersion != "" { + u.checkPinnedVersion() + return + } + + // Default path: follow the latest release. release, err := u.fetchLatestRelease() if err != nil { slog.Error("failed to fetch latest release", "error", err) @@ -161,8 +180,69 @@ func (u *Updater) checkOnce() { u.touchRestartRecord() } +// checkPinnedVersion installs the exact release specified by +// Config.PinnedVersion if it is not already installed. Unlike the +// default latest-following path, it does not compare versions — it +// fetches the named release and applies it unconditionally when the +// current install differs from the pin. +func (u *Updater) checkPinnedVersion() { + pinned, err := ParseSemver(u.config.PinnedVersion) + if err != nil { + slog.Error("invalid pinned version", "version", u.config.PinnedVersion, "error", err) + return + } + + current, err := u.currentVersion() + if err != nil { + slog.Error("failed to get current version", "error", err) + return + } + + if current == pinned { + slog.Info("pinned version already installed", "version", pinned.String()) + return + } + + slog.Info("pinned version requested, installing", + "current", current.String(), + "pinned", pinned.String(), + ) + + release, err := u.fetchReleaseByTag(u.config.PinnedVersion) + if err != nil { + slog.Error("failed to fetch pinned release", "tag", u.config.PinnedVersion, "error", err) + return + } + + if err := u.applyUpdate(release); err != nil { + slog.Error("failed to apply pinned update", "error", err) + return + } + + slog.Info("pinned version installed", "version", pinned.String()) + u.touchRestartRecord() +} + func (u *Updater) fetchLatestRelease() (*GitHubRelease, error) { - url := fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", u.config.Repo) + return u.fetchRelease("") +} + +// fetchReleaseByTag fetches a specific release by its Git tag. +// Example tag: "v1.10.5". +func (u *Updater) fetchReleaseByTag(tag string) (*GitHubRelease, error) { + return u.fetchRelease(tag) +} + +// fetchRelease returns the GitHub release for the given tag. If tag is +// empty it fetches the latest release. +func (u *Updater) fetchRelease(tag string) (*GitHubRelease, error) { + var url string + if tag == "" { + url = fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", u.config.Repo) + } else { + url = fmt.Sprintf("https://api.github.com/repos/%s/releases/tags/%s", u.config.Repo, tag) + } + req, err := http.NewRequest("GET", url, nil) if err != nil { return nil, err diff --git a/zz_e2e_test.go b/zz_e2e_test.go new file mode 100644 index 0000000..aa3df56 --- /dev/null +++ b/zz_e2e_test.go @@ -0,0 +1,330 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package updater + +import ( + "archive/tar" + "compress/gzip" + "crypto/sha256" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "testing" +) + +// TestE2E_PinnedVersionLifecycle exercises the full lifecycle: +// 1. Node starts on v1.0.0, pinned to v2.0.0 → installs v2.0.0 +// 2. Pin cleared → updater follows latest (v3.0.0) +// 3. Downgrade pin to v1.0.0 → installs v1.0.0 +// 4. Pin cleared again → back to latest (v3.0.0) +func TestE2E_PinnedVersionLifecycle(t *testing.T) { + t.Parallel() + + installDir := t.TempDir() + archiveName := fmt.Sprintf("pilot-%s-%s.tar.gz", runtime.GOOS, runtime.GOARCH) + + // Seed install directory with v1.0.0 binaries. + os.WriteFile(filepath.Join(installDir, "pilot-daemon"), []byte("daemon-v1.0.0"), 0755) + os.WriteFile(filepath.Join(installDir, "pilotctl"), []byte("pilotctl-v1.0.0"), 0755) + os.WriteFile(filepath.Join(installDir, ".pilot-version"), []byte("v1.0.0\n"), 0644) + + // Build three release archives. + type releaseData struct { + archivePath string + hash string + } + releaseArchives := map[string]releaseData{} + for _, tag := range []string{"v1.0.0", "v2.0.0", "v3.0.0"} { + archivePath := filepath.Join(installDir, fmt.Sprintf("archive-%s.tar.gz", tag)) + files := map[string]string{ + "daemon": fmt.Sprintf("daemon-%s", tag), + "pilotctl": fmt.Sprintf("pilotctl-%s", tag), + } + createTarGzFile(t, archivePath, files) + data, _ := os.ReadFile(archivePath) + h := sha256.Sum256(data) + releaseArchives[tag] = releaseData{archivePath, fmt.Sprintf("%x", h)} + } + + // Mock GitHub API serving 3 releases. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := r.URL.Path + base := "http://" + r.Host + + // Asset downloads. + for tag, rd := range releaseArchives { + if path == "/download/archive-"+tag { + http.ServeFile(w, r, rd.archivePath) + return + } + if path == "/download/checksums-"+tag { + w.Write([]byte(fmt.Sprintf("%s %s\n", rd.hash, archiveName))) + return + } + } + + w.Header().Set("Content-Type", "application/json") + + // /releases/latest → v3.0.0 + if path == "/repos/test/repo/releases/latest" { + json.NewEncoder(w).Encode(GitHubRelease{ + TagName: "v3.0.0", + Assets: []GitHubAsset{ + {Name: archiveName, BrowserDownloadURL: base + "/download/archive-v3.0.0"}, + {Name: "checksums.txt", BrowserDownloadURL: base + "/download/checksums-v3.0.0"}, + }, + }) + return + } + + // /releases/tags/{tag} + for tag := range releaseArchives { + if path == "/repos/test/repo/releases/tags/"+tag { + json.NewEncoder(w).Encode(GitHubRelease{ + TagName: tag, + Assets: []GitHubAsset{ + {Name: archiveName, BrowserDownloadURL: base + "/download/archive-" + tag}, + {Name: "checksums.txt", BrowserDownloadURL: base + "/download/checksums-" + tag}, + }, + }) + return + } + } + + http.NotFound(w, r) + })) + defer srv.Close() + + newUpdater := func(pinned string) *Updater { + return &Updater{ + config: Config{ + Repo: "test/repo", + InstallDir: installDir, + PinnedVersion: pinned, + }, + client: newRewriteClient(srv), + stopCh: make(chan struct{}), + exitFn: func(int) {}, // suppress os.Exit + } + } + + readDaemon := func() string { + data, err := os.ReadFile(filepath.Join(installDir, "pilot-daemon")) + if err != nil { + t.Fatalf("read daemon: %v", err) + } + return string(data) + } + + readVersion := func() string { + data, err := os.ReadFile(filepath.Join(installDir, ".pilot-version")) + if err != nil { + t.Fatalf("read version: %v", err) + } + return string(data) + } + + // ── Step 1: Pin v2.0.0, current is v1.0.0 → should install v2.0.0 ── + t.Log("Step 1: pinning to v2.0.0 (current v1.0.0)") + u := newUpdater("v2.0.0") + u.checkOnce() + if got := readDaemon(); got != "daemon-v2.0.0" { + t.Errorf("Step 1 daemon = %q, want daemon-v2.0.0", got) + } + if got := readVersion(); got != "v2.0.0\n" { + t.Errorf("Step 1 version file = %q, want v2.0.0", got) + } + + // ── Step 2: Un-pin → should follow latest (v3.0.0) ── + t.Log("Step 2: un-pinning (should follow latest → v3.0.0)") + u = newUpdater("") // empty = follow latest + u.checkOnce() + if got := readDaemon(); got != "daemon-v3.0.0" { + t.Errorf("Step 2 daemon = %q, want daemon-v3.0.0", got) + } + if got := readVersion(); got != "v3.0.0\n" { + t.Errorf("Step 2 version file = %q, want v3.0.0", got) + } + + // ── Step 3: Downgrade pin to v1.0.0 ── + t.Log("Step 3: downgrade pin to v1.0.0") + u = newUpdater("v1.0.0") + u.checkOnce() + if got := readDaemon(); got != "daemon-v1.0.0" { + t.Errorf("Step 3 daemon = %q, want daemon-v1.0.0", got) + } + if got := readVersion(); got != "v1.0.0\n" { + t.Errorf("Step 3 version file = %q, want v1.0.0", got) + } + + // ── Step 4: Un-pin again → should go back to latest (v3.0.0) ── + t.Log("Step 4: un-pin again → back to latest (v3.0.0)") + u = newUpdater("") + u.checkOnce() + if got := readDaemon(); got != "daemon-v3.0.0" { + t.Errorf("Step 4 daemon = %q, want daemon-v3.0.0", got) + } + if got := readVersion(); got != "v3.0.0\n" { + t.Errorf("Step 4 version file = %q, want v3.0.0", got) + } + + // ── Step 5: Already on pinned version → no-op ── + t.Log("Step 5: pin to already-installed v3.0.0 → no-op") + u = newUpdater("v3.0.0") + u.checkOnce() + // No changes expected — the binary should still be daemon-v3.0.0. + if got := readDaemon(); got != "daemon-v3.0.0" { + t.Errorf("Step 5 daemon = %q, want daemon-v3.0.0 (unchanged)", got) + } + + t.Log("All steps passed — pinned version lifecycle works correctly") +} + +func createTarGzFile(t *testing.T, path string, files map[string]string) { + t.Helper() + f, err := os.Create(path) + if err != nil { + t.Fatal(err) + } + defer f.Close() + gw := gzip.NewWriter(f) + defer gw.Close() + tw := tar.NewWriter(gw) + defer tw.Close() + for name, content := range files { + tw.WriteHeader(&tar.Header{Name: name, Mode: 0755, Size: int64(len(content))}) + tw.Write([]byte(content)) + } +} + +// TestE2E_PinnedVersionUnchangedOnRestart verifies that when the pinned +// version is already installed, repeated checkOnce calls are no-ops +// (simulating multiple ticks / process restarts). +func TestE2E_PinnedVersionStaysPinnedAcrossTicks(t *testing.T) { + t.Parallel() + + installDir := t.TempDir() + archiveName := fmt.Sprintf("pilot-%s-%s.tar.gz", runtime.GOOS, runtime.GOARCH) + + // Current is v1.0.0, latest is v2.0.0, but we're pinned to v1.0.0. + os.WriteFile(filepath.Join(installDir, "pilot-daemon"), []byte("daemon-v1.0.0"), 0755) + os.WriteFile(filepath.Join(installDir, ".pilot-version"), []byte("v1.0.0\n"), 0644) + + // Build v2.0.0 archive (the one we do NOT want). + archivePath := filepath.Join(installDir, "archive-v2.0.0.tar.gz") + createTarGzFile(t, archivePath, map[string]string{ + "daemon": "daemon-v2.0.0-should-not-install", + }) + data, _ := os.ReadFile(archivePath) + h := sha256.Sum256(data) + v2hash := fmt.Sprintf("%x", h) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + base := "http://" + r.Host + w.Header().Set("Content-Type", "application/json") + // Latest is v2.0.0 — but we're pinned to v1.0.0, so should never hit this. + if r.URL.Path == "/repos/test/repo/releases/latest" { + json.NewEncoder(w).Encode(GitHubRelease{ + TagName: "v2.0.0", + Assets: []GitHubAsset{ + {Name: archiveName, BrowserDownloadURL: base + "/download/archive-v2.0.0"}, + {Name: "checksums.txt", BrowserDownloadURL: base + "/download/checksums-v2.0.0"}, + }, + }) + return + } + if r.URL.Path == "/download/archive-v2.0.0" { + http.ServeFile(w, r, archivePath) + return + } + if r.URL.Path == "/download/checksums-v2.0.0" { + w.Write([]byte(fmt.Sprintf("%s %s\n", v2hash, archiveName))) + return + } + http.NotFound(w, r) + })) + defer srv.Close() + + u := &Updater{ + config: Config{ + Repo: "test/repo", + InstallDir: installDir, + PinnedVersion: "v1.0.0", + }, + client: newRewriteClient(srv), + stopCh: make(chan struct{}), + exitFn: func(int) {}, + } + + // Run 5 ticks — should all be no-ops. + for i := 0; i < 5; i++ { + u.checkOnce() + data, _ := os.ReadFile(filepath.Join(installDir, "pilot-daemon")) + if string(data) != "daemon-v1.0.0" { + t.Fatalf("tick %d: daemon unexpectedly changed to %q", i, string(data)) + } + } + + t.Log("Pinned version stayed frozen across 5 ticks — no unwanted auto-update") +} + +// TestE2E_NoPinFollowsLatest verifies the default behaviour (no pin) still +// follows the latest release, including across multiple version jumps. +func TestE2E_NoPinFollowsLatest(t *testing.T) { + t.Parallel() + + installDir := t.TempDir() + archiveName := fmt.Sprintf("pilot-%s-%s.tar.gz", runtime.GOOS, runtime.GOARCH) + + os.WriteFile(filepath.Join(installDir, "pilot-daemon"), []byte("old"), 0755) + os.WriteFile(filepath.Join(installDir, ".pilot-version"), []byte("v1.0.0\n"), 0644) + + archivePath := filepath.Join(installDir, "archive-v5.0.0.tar.gz") + createTarGzFile(t, archivePath, map[string]string{"daemon": "daemon-v5.0.0"}) + data, _ := os.ReadFile(archivePath) + h := sha256.Sum256(data) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + base := "http://" + r.Host + w.Header().Set("Content-Type", "application/json") + switch { + case r.URL.Path == "/repos/test/repo/releases/latest": + json.NewEncoder(w).Encode(GitHubRelease{ + TagName: "v5.0.0", + Assets: []GitHubAsset{ + {Name: archiveName, BrowserDownloadURL: base + "/download/archive"}, + {Name: "checksums.txt", BrowserDownloadURL: base + "/download/checksums"}, + }, + }) + case r.URL.Path == "/download/archive": + http.ServeFile(w, r, archivePath) + case r.URL.Path == "/download/checksums": + w.Write([]byte(fmt.Sprintf("%x %s\n", h, archiveName))) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + var u *Updater + u = &Updater{ + config: Config{ + Repo: "test/repo", + InstallDir: installDir, + }, + client: newRewriteClient(srv), + stopCh: make(chan struct{}), + exitFn: func(int) {}, + } + + u.checkOnce() + data, _ = os.ReadFile(filepath.Join(installDir, "pilot-daemon")) + if string(data) != "daemon-v5.0.0" { + t.Errorf("no-pin mode did not follow latest: got %q, want daemon-v5.0.0", string(data)) + } + t.Log("No-pin mode correctly auto-updated to v5.0.0") +} diff --git a/zz_test.go b/zz_test.go index 64a8823..4bb69c8 100644 --- a/zz_test.go +++ b/zz_test.go @@ -13,7 +13,9 @@ import ( "os" "path/filepath" "runtime" + "strings" "testing" + "time" ) func TestParseSemver(t *testing.T) { @@ -394,6 +396,193 @@ func TestApplyUpdate_SkipsServerBinaries(t *testing.T) { } } +// TestCheckPinnedVersion_AlreadyInstalled verifies that checkPinnedVersion +// returns immediately (no network round-trip) when the current install +// already matches the pinned version. +func TestCheckPinnedVersion_AlreadyInstalled(t *testing.T) { + t.Parallel() + tmpDir := t.TempDir() + + // Current version matches the pin. + os.WriteFile(filepath.Join(tmpDir, "pilot-daemon"), []byte("stub"), 0755) + os.WriteFile(filepath.Join(tmpDir, ".pilot-version"), []byte("v1.10.5\n"), 0644) + + // A server that should never be hit — any request means a bug. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Error("unexpected HTTP request when pinned version is already installed") + })) + defer srv.Close() + + u := &Updater{ + config: Config{ + Repo: "test/repo", + InstallDir: tmpDir, + PinnedVersion: "v1.10.5", + }, + client: newRewriteClient(srv), + stopCh: make(chan struct{}), + } + + // Should be a no-op — no error, no network call. + u.checkOnce() +} + +// TestCheckPinnedVersion_InstallsWhenDifferent verifies that when the +// pinned version differs from the current install, the updater fetches +// and applies the pinned release. +func TestCheckPinnedVersion_InstallsWhenDifferent(t *testing.T) { + t.Parallel() + tmpDir := t.TempDir() + + // Current version is v1.9.0, pinned is v1.10.5. + os.WriteFile(filepath.Join(tmpDir, "pilot-daemon"), []byte("old-daemon"), 0755) + os.WriteFile(filepath.Join(tmpDir, "pilotctl"), []byte("old-pilotctl"), 0755) + os.WriteFile(filepath.Join(tmpDir, ".pilot-version"), []byte("v1.9.0\n"), 0644) + + // Build a release archive for the pinned version. + archiveDir := t.TempDir() + archiveName := fmt.Sprintf("pilot-%s-%s.tar.gz", runtime.GOOS, runtime.GOARCH) + archivePath := filepath.Join(archiveDir, archiveName) + createTestTarGz(t, archivePath, map[string]string{ + "daemon": "pinned-daemon", + "pilotctl": "pinned-pilotctl", + }) + archiveContent, _ := os.ReadFile(archivePath) + archiveHash := sha256.Sum256(archiveContent) + checksumsContent := fmt.Sprintf("%x %s\n", archiveHash, archiveName) + + // Mock server: serves the pinned release by tag. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.Contains(r.URL.Path, "/releases/tags/v1.10.5"): + json.NewEncoder(w).Encode(GitHubRelease{ + TagName: "v1.10.5", + Assets: []GitHubAsset{ + {Name: archiveName, BrowserDownloadURL: "http://" + r.Host + "/download/" + archiveName}, + {Name: "checksums.txt", BrowserDownloadURL: "http://" + r.Host + "/download/checksums.txt"}, + }, + }) + case r.URL.Path == "/download/"+archiveName: + w.Write(archiveContent) + case r.URL.Path == "/download/checksums.txt": + w.Write([]byte(checksumsContent)) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + u := &Updater{ + config: Config{ + Repo: "test/repo", + InstallDir: tmpDir, + PinnedVersion: "v1.10.5", + }, + client: newRewriteClient(srv), + stopCh: make(chan struct{}), + exitFn: func(int) {}, + } + + u.checkOnce() + + // Daemon should be replaced with the pinned version. + data, err := os.ReadFile(filepath.Join(tmpDir, "pilot-daemon")) + if err != nil { + t.Fatalf("read daemon: %v", err) + } + if string(data) != "pinned-daemon" { + t.Errorf("daemon = %q, want 'pinned-daemon'", string(data)) + } + + // Version file should reflect the pinned release tag. + ver, err := os.ReadFile(filepath.Join(tmpDir, ".pilot-version")) + if err != nil { + t.Fatalf("read version file: %v", err) + } + if string(ver) != "v1.10.5\n" { + t.Errorf(".pilot-version = %q, want 'v1.10.5\\n'", string(ver)) + } +} + +// TestCheckPinnedVersion_InvalidVersion verifies that an unparseable +// pinned version is logged and does not panic. +func TestCheckPinnedVersion_InvalidVersion(t *testing.T) { + t.Parallel() + tmpDir := t.TempDir() + os.WriteFile(filepath.Join(tmpDir, "pilot-daemon"), []byte("stub"), 0755) + os.WriteFile(filepath.Join(tmpDir, ".pilot-version"), []byte("v1.0.0\n"), 0644) + + u := &Updater{ + config: Config{ + Repo: "test/repo", + InstallDir: tmpDir, + PinnedVersion: "not-a-version", + }, + client: &http.Client{Timeout: time.Second}, + stopCh: make(chan struct{}), + } + + // Should not panic — just logs an error and returns. + u.checkOnce() +} + +// TestFetchReleaseByTag verifies the by-tag GitHub API endpoint. +func TestFetchReleaseByTag(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.Contains(r.URL.Path, "/releases/tags/v1.10.5") { + t.Errorf("unexpected path: %s", r.URL.Path) + } + ua := r.Header.Get("User-Agent") + if !strings.HasPrefix(ua, "pilot-updater/") { + t.Errorf("User-Agent = %q, want prefix pilot-updater/", ua) + } + json.NewEncoder(w).Encode(GitHubRelease{ + TagName: "v1.10.5", + Assets: []GitHubAsset{}, + }) + })) + defer srv.Close() + + u := &Updater{ + config: Config{Repo: "owner/repo", Version: "vTEST"}, + client: newRewriteClient(srv), + stopCh: make(chan struct{}), + } + + release, err := u.fetchReleaseByTag("v1.10.5") + if err != nil { + t.Fatalf("fetchReleaseByTag: %v", err) + } + if release.TagName != "v1.10.5" { + t.Errorf("TagName = %q, want v1.10.5", release.TagName) + } +} + +// TestFetchReleaseByTag_NotFound verifies the error path when a +// pinned tag does not exist. +func TestFetchReleaseByTag_NotFound(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "not found", http.StatusNotFound) + })) + defer srv.Close() + + u := &Updater{ + config: Config{Repo: "owner/repo"}, + client: newRewriteClient(srv), + stopCh: make(chan struct{}), + } + + _, err := u.fetchReleaseByTag("v99.99.99") + if err == nil { + t.Fatal("expected error for non-existent tag") + } + if !strings.Contains(err.Error(), "404") { + t.Errorf("error does not mention 404: %v", err) + } +} + // createTestTarGz creates a tar.gz archive with the given file name→content map. func createTestTarGz(t *testing.T, path string, files map[string]string) { t.Helper()