From 9a050749f00dddd7f8474a753d415302af8b2e44 Mon Sep 17 00:00:00 2001 From: j-rafique Date: Fri, 5 Sep 2025 12:13:16 +0500 Subject: [PATCH] Add unit tests in updater & reafctor --- sn-manager/README.md | 14 +- sn-manager/cmd/start.go | 55 ++- sn-manager/internal/manager/manager.go | 179 +------- sn-manager/internal/manager/monitor.go | 170 ++++++++ sn-manager/internal/updater/gateway_guard.go | 94 +++++ sn-manager/internal/updater/updater.go | 394 +++++++++--------- sn-manager/internal/updater/updater_test.go | 60 +++ .../internal/updater/updater_window_test.go | 66 +++ sn-manager/internal/utils/semver_test.go | 39 ++ sn-manager/internal/utils/tar_test.go | 78 ++++ sn-manager/internal/version/manager_test.go | 44 ++ 11 files changed, 810 insertions(+), 383 deletions(-) create mode 100644 sn-manager/internal/manager/monitor.go create mode 100644 sn-manager/internal/updater/gateway_guard.go create mode 100644 sn-manager/internal/updater/updater_test.go create mode 100644 sn-manager/internal/updater/updater_window_test.go create mode 100644 sn-manager/internal/utils/semver_test.go create mode 100644 sn-manager/internal/utils/tar_test.go create mode 100644 sn-manager/internal/version/manager_test.go diff --git a/sn-manager/README.md b/sn-manager/README.md index a6723a53..57b4ecc5 100644 --- a/sn-manager/README.md +++ b/sn-manager/README.md @@ -200,7 +200,7 @@ Auto-update checks run every 10 minutes when enabled. ## Version Update Scenarios -The auto-updater follows stable-only, same-major update rules and defers updates while the gateway is busy. Summary: +The auto-updater follows stable-only, same-major update rules and coordinates updates around the gateway state to minimize disruption while avoiding being stuck. Summary: | Current | Available | Auto-Upgrade Enabled | Auto Updates? | Manual Option | |---|---|---|---|---| @@ -211,17 +211,23 @@ The auto-updater follows stable-only, same-major update rules and defers updates | v1.7.4 | v1.7.4 (stable) | Yes | ❌ | — | | v1.7.5 | v1.7.4 (stable) | Yes | ❌ | — | | Any | Any | No | ❌ | `sn-manager get [version] && sn-manager use [version]` | -| Any | Any | Yes, but gateway busy | ❌ (deferred) | Manual allowed | +| Any | Any | Yes, but gateway busy | ⏳ Deferred (max 1 hour), then ✅ | Manual allowed | Mechanics and notes: - Stable-only: auto-updater targets latest stable GitHub release (non-draft, non-prerelease). - Same-major only: SuperNode and sn-manager auto-update only when the latest is the same major version (the number before the first dot). Example: 1.7 → 1.8 = allowed; 1.x → 2.0 = manual. -- Gateway-aware: updates are applied only when the gateway reports no running tasks; otherwise they are deferred. -- Gateway errors: repeated check failures over a 5-minute window request a clean SuperNode restart (no version change) to recover. +- Gateway idle: updates are applied when the gateway reports no running tasks. +- Gateway busy: if tasks are running, updates are deferred for up to 1 hour for the target version; after that hard window, the update proceeds to avoid being stuck. +- Gateway unresponsive: if an update is available, it proceeds immediately to break the deadlock; if no update is available, a clean SuperNode restart is requested via marker. - Combined tarball: when updating, sn-manager downloads a single tarball once, then updates itself first (if eligible), then installs/activates the new SuperNode version. - Config is updated to reflect the new `updates.current_version` after a successful SuperNode update. - Manual installs: you can always override with `sn-manager get ` and `sn-manager use `; pre-releases are supported manually. +### Update Timing + +- Checks run every 10 minutes when auto-upgrade is enabled. +- On every `sn-manager start`, the updater runs an immediate check and bypasses the gateway check once so that initial updates can be applied even if the gateway is not yet available. + ## Start/Stop Behavior sn-manager start and supernode start clear the stop marker; supernode stop sets it. How the manager and SuperNode processes behave for each command, plus systemd nuances: diff --git a/sn-manager/cmd/start.go b/sn-manager/cmd/start.go index 0166bc10..30a82c8e 100644 --- a/sn-manager/cmd/start.go +++ b/sn-manager/cmd/start.go @@ -125,6 +125,9 @@ func runStart(cmd *cobra.Command, args []string) error { var autoUpdater *updater.AutoUpdater if cfg.Updates.AutoUpgrade { autoUpdater = updater.New(home, cfg, appVersion) + // On every manager start, bypass the gateway check once so an + // update can be applied even if the gateway isn't up yet. + autoUpdater.SkipGatewayCheckOnce() autoUpdater.Start(ctx) } @@ -168,39 +171,47 @@ func runStart(cmd *cobra.Command, args []string) error { } } -// ensureBinaryExists ensures we have at least one SuperNode binary +// ensureBinaryExists ensures there is an installed and active SuperNode binary. +// Steps: +// 1) If versions exist: ensure a current symlink and sync config. +// 2) If no versions exist: download, install, and activate the latest release. func ensureBinaryExists(home string, cfg *config.Config) error { versionMgr := version.NewManager(home) - - // Check if we have any versions installed versions, err := versionMgr.ListVersions() if err != nil { return err } - if len(versions) > 0 { - // We have versions, make sure current is set - current, err := versionMgr.GetCurrentVersion() - if err != nil || current == "" { - // Set the first available version as current - if err := versionMgr.SetCurrentVersion(versions[0]); err != nil { - return fmt.Errorf("failed to set current version: %w", err) - } - current = versions[0] - } + return ensureCurrentVersionSet(versionMgr, cfg, home, versions) + } + return downloadAndInstallLatest(versionMgr, home, cfg) +} - // Update config if current version is not set or different - if cfg.Updates.CurrentVersion != current { - cfg.Updates.CurrentVersion = current - configPath := filepath.Join(home, "config.yml") - if err := config.Save(cfg, configPath); err != nil { - return fmt.Errorf("failed to update config with current version: %w", err) - } +// ensureCurrentVersionSet sets a current version and syncs config if needed. +// ensureCurrentVersionSet atomically ensures a current version is set and +// persists it to the sn-manager config when changed. +func ensureCurrentVersionSet(versionMgr *version.Manager, cfg *config.Config, home string, versions []string) error { + current, err := versionMgr.GetCurrentVersion() + if err != nil || current == "" { + if err := versionMgr.SetCurrentVersion(versions[0]); err != nil { + return fmt.Errorf("failed to set current version: %w", err) + } + current = versions[0] + } + if cfg.Updates.CurrentVersion != current { + cfg.Updates.CurrentVersion = current + configPath := filepath.Join(home, "config.yml") + if err := config.Save(cfg, configPath); err != nil { + return fmt.Errorf("failed to update config with current version: %w", err) } - return nil } + return nil +} - // No versions installed, download latest tarball and extract supernode +// downloadAndInstallLatest downloads, installs, and activates the latest SuperNode. +// downloadAndInstallLatest downloads the combined release, extracts supernode, +// installs the version, activates it, and syncs config. +func downloadAndInstallLatest(versionMgr *version.Manager, home string, cfg *config.Config) error { fmt.Println("No SuperNode binary found. Downloading latest version...") client := github.NewClient(config.GitHubRepo) diff --git a/sn-manager/internal/manager/manager.go b/sn-manager/internal/manager/manager.go index fd176121..95154f95 100644 --- a/sn-manager/internal/manager/manager.go +++ b/sn-manager/internal/manager/manager.go @@ -14,6 +14,15 @@ import ( "github.com/LumeraProtocol/supernode/v2/sn-manager/internal/config" ) +// Constants for process management +const ( + DefaultShutdownTimeout = 30 * time.Second + ProcessCheckInterval = 5 * time.Second + CrashBackoffDelay = 2 * time.Second + StopMarkerFile = ".stop_requested" + RestartMarkerFile = ".needs_restart" +) + // Manager handles the SuperNode process lifecycle type Manager struct { config *config.Config @@ -171,177 +180,9 @@ func (m *Manager) cleanup() { } } -// Constants for process management -const ( - DefaultShutdownTimeout = 30 * time.Second - ProcessCheckInterval = 5 * time.Second - CrashBackoffDelay = 2 * time.Second - StopMarkerFile = ".stop_requested" - RestartMarkerFile = ".needs_restart" -) - -// Monitor continuously supervises the SuperNode process -// It ensures SuperNode is always running unless a stop marker is present -func (m *Manager) Monitor(ctx context.Context) error { - - // Create ticker for periodic checks - ticker := time.NewTicker(ProcessCheckInterval) - defer ticker.Stop() - - // Channel to monitor process exits - processExitCh := make(chan error, 1) - - // Function to arm the process wait goroutine - armProcessWait := func() { - processExitCh = make(chan error, 1) - go func() { - if err := m.Wait(); err != nil { - processExitCh <- err - } else { - processExitCh <- nil - } - }() - } - - // Initial check and start if needed - stopMarkerPath := filepath.Join(m.homeDir, StopMarkerFile) - if _, err := os.Stat(stopMarkerPath); os.IsNotExist(err) { - // No stop marker, ensure SuperNode is running - if !m.IsRunning() { - log.Println("Starting SuperNode...") - if err := m.Start(ctx); err != nil { - log.Printf("Failed to start SuperNode: %v", err) - } else { - armProcessWait() - } - } else { - // Already running, arm the wait - armProcessWait() - } - } else { - log.Println("Stop marker present, SuperNode will not be started") - } - - // Main supervision loop - for { - select { - case <-ctx.Done(): - // Context cancelled, stop monitoring - return ctx.Err() - - case err := <-processExitCh: - // SuperNode process exited - if err != nil { - log.Printf("SuperNode exited with error: %v", err) - } else { - log.Printf("SuperNode exited normally") - } - - // Cleanup internal state after exit - m.mu.Lock() - m.cleanup() - m.mu.Unlock() - - // Check if we should restart - if _, err := os.Stat(stopMarkerPath); err == nil { - log.Println("Stop marker present, not restarting SuperNode") - continue - } - - // Apply backoff to prevent rapid restart loops - time.Sleep(CrashBackoffDelay) - - // Restart SuperNode - log.Println("Restarting SuperNode after crash...") - if err := m.Start(ctx); err != nil { - log.Printf("Failed to restart SuperNode: %v", err) - continue - } - armProcessWait() - log.Println("SuperNode restarted successfully") - - case <-ticker.C: - // Periodic check for various conditions - - // 1. Check if stop marker was removed and we should start - if !m.IsRunning() { - if _, err := os.Stat(stopMarkerPath); os.IsNotExist(err) { - log.Println("Stop marker removed, starting SuperNode...") - if err := m.Start(ctx); err != nil { - log.Printf("Failed to start SuperNode: %v", err) - } else { - armProcessWait() - log.Println("SuperNode started") - } - } - } - - // 2. Check if binary was updated and needs restart - restartMarkerPath := filepath.Join(m.homeDir, RestartMarkerFile) - if _, err := os.Stat(restartMarkerPath); err == nil { - if m.IsRunning() { - log.Println("Binary update detected, restarting SuperNode...") - - // Remove the restart marker - if err := os.Remove(restartMarkerPath); err != nil && !os.IsNotExist(err) { - log.Printf("Warning: failed to remove restart marker: %v", err) - } - - // Create temporary stop marker for clean restart - tmpStopMarker := []byte("update") - os.WriteFile(stopMarkerPath, tmpStopMarker, 0644) - - // Stop current process - if err := m.Stop(); err != nil { - log.Printf("Failed to stop for update: %v", err) - if err := os.Remove(stopMarkerPath); err != nil && !os.IsNotExist(err) { - log.Printf("Warning: failed to remove stop marker: %v", err) - } - continue - } - - // Brief pause - time.Sleep(CrashBackoffDelay) - - // Remove temporary stop marker - if err := os.Remove(stopMarkerPath); err != nil && !os.IsNotExist(err) { - log.Printf("Warning: failed to remove stop marker: %v", err) - } - - // Start with new binary - log.Println("Starting with updated binary...") - if err := m.Start(ctx); err != nil { - log.Printf("Failed to start updated binary: %v", err) - } else { - armProcessWait() - log.Println("SuperNode restarted with new binary") - } - } - } - - // 3. Health check - ensure process is actually alive - if m.IsRunning() { - // Process thinks it's running, verify it really is - m.mu.RLock() - proc := m.process - m.mu.RUnlock() - - if proc != nil { - if err := proc.Signal(syscall.Signal(0)); err != nil { - // Process is dead but not cleaned up - log.Println("Detected stale process, cleaning up...") - m.mu.Lock() - m.cleanup() - m.mu.Unlock() - } - } - } - } - } -} +// Monitor and related helpers moved to monitor.go // GetConfig returns the manager configuration func (m *Manager) GetConfig() *config.Config { return m.config } - diff --git a/sn-manager/internal/manager/monitor.go b/sn-manager/internal/manager/monitor.go new file mode 100644 index 00000000..33410262 --- /dev/null +++ b/sn-manager/internal/manager/monitor.go @@ -0,0 +1,170 @@ +package manager + +import ( + "context" + "log" + "os" + "path/filepath" + "syscall" + "time" +) + +// Note: This monitor consumes the update marker ".needs_restart" written by +// the updater after a SuperNode binary update. When present, we perform a +// clean stop and restart to activate the new binary. +// +// Monitor continuously supervises the SuperNode process. +// Steps per cycle: +// 1) Start once if allowed (no stop marker present) +// 2) Wait for process exit and handle restarts unless stop requested +// 3) Periodic checks: +// - Auto-start if stop marker removed +// - Restart when update marker is present (".needs_restart" from updater) +// - Health check for stale processes +func (m *Manager) Monitor(ctx context.Context) error { + ticker := time.NewTicker(ProcessCheckInterval) + defer ticker.Stop() + + processExitCh := make(chan error, 1) + + // Step 1: Initial start + m.initialStartIfAllowed(ctx, &processExitCh) + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case err := <-processExitCh: + // Step 2: Handle process exit + m.handleProcessExit(ctx, err, &processExitCh) + case <-ticker.C: + // Step 3: Periodic checks + m.periodicChecks(ctx, &processExitCh) + } + } +} + +// initialStartIfAllowed starts SuperNode if there's no stop marker, and arms the wait. +func (m *Manager) initialStartIfAllowed(ctx context.Context, processExitCh *chan error) { + stopMarkerPath := filepath.Join(m.homeDir, StopMarkerFile) + if _, err := os.Stat(stopMarkerPath); os.IsNotExist(err) { + if !m.IsRunning() { + log.Println("Starting SuperNode...") + if err := m.Start(ctx); err != nil { + log.Printf("Failed to start SuperNode: %v", err) + return + } + m.armProcessWait(processExitCh) + } else { + m.armProcessWait(processExitCh) + } + } else { + log.Println("Stop marker present, SuperNode will not be started") + } +} + +// armProcessWait launches a goroutine that waits on the process and reports the result. +func (m *Manager) armProcessWait(processExitCh *chan error) { + ch := make(chan error, 1) + *processExitCh = ch + go func() { + if err := m.Wait(); err != nil { + ch <- err + } else { + ch <- nil + } + }() +} + +// handleProcessExit cleans up state and restarts unless a stop marker is present. +func (m *Manager) handleProcessExit(ctx context.Context, err error, processExitCh *chan error) { + if err != nil { + log.Printf("SuperNode exited with error: %v", err) + } else { + log.Printf("SuperNode exited normally") + } + + m.mu.Lock() + m.cleanup() + m.mu.Unlock() + + stopMarkerPath := filepath.Join(m.homeDir, StopMarkerFile) + if _, err := os.Stat(stopMarkerPath); err == nil { + log.Println("Stop marker present, not restarting SuperNode") + return + } + + time.Sleep(CrashBackoffDelay) + log.Println("Restarting SuperNode after crash...") + if err := m.Start(ctx); err != nil { + log.Printf("Failed to restart SuperNode: %v", err) + return + } + m.armProcessWait(processExitCh) + log.Println("SuperNode restarted successfully") +} + +// periodicChecks performs periodic supervision tasks: +// - Start on stop-marker removal +// - Restart on update-marker +// - Process liveness check +func (m *Manager) periodicChecks(ctx context.Context, processExitCh *chan error) { + stopMarkerPath := filepath.Join(m.homeDir, StopMarkerFile) + if !m.IsRunning() { + if _, err := os.Stat(stopMarkerPath); os.IsNotExist(err) { + log.Println("Stop marker removed, starting SuperNode...") + if err := m.Start(ctx); err != nil { + log.Printf("Failed to start SuperNode: %v", err) + } else { + m.armProcessWait(processExitCh) + log.Println("SuperNode started") + } + } + } + + // Restart when updated binary is ready + restartMarkerPath := filepath.Join(m.homeDir, RestartMarkerFile) + if _, err := os.Stat(restartMarkerPath); err == nil { + if m.IsRunning() { + log.Println("Binary update detected, restarting SuperNode...") + if err := os.Remove(restartMarkerPath); err != nil && !os.IsNotExist(err) { + log.Printf("Warning: failed to remove restart marker: %v", err) + } + // Temporary stop marker for clean restart + _ = os.WriteFile(stopMarkerPath, []byte("update"), 0644) + if err := m.Stop(); err != nil { + log.Printf("Failed to stop for update: %v", err) + if err := os.Remove(stopMarkerPath); err != nil && !os.IsNotExist(err) { + log.Printf("Warning: failed to remove stop marker: %v", err) + } + return + } + time.Sleep(CrashBackoffDelay) + if err := os.Remove(stopMarkerPath); err != nil && !os.IsNotExist(err) { + log.Printf("Warning: failed to remove stop marker: %v", err) + } + log.Println("Starting with updated binary...") + if err := m.Start(ctx); err != nil { + log.Printf("Failed to start updated binary: %v", err) + } else { + m.armProcessWait(processExitCh) + log.Println("SuperNode restarted with new binary") + } + } + } + + // Health check + if m.IsRunning() { + m.mu.RLock() + proc := m.process + m.mu.RUnlock() + if proc != nil { + if err := proc.Signal(syscall.Signal(0)); err != nil { + log.Println("Detected stale process, cleaning up...") + m.mu.Lock() + m.cleanup() + m.mu.Unlock() + } + } + } +} diff --git a/sn-manager/internal/updater/gateway_guard.go b/sn-manager/internal/updater/gateway_guard.go new file mode 100644 index 00000000..be437a94 --- /dev/null +++ b/sn-manager/internal/updater/gateway_guard.go @@ -0,0 +1,94 @@ +package updater + +import ( + "fmt" + "io" + "log" + "net/http" + "os" + "path/filepath" + "time" + + pb "github.com/LumeraProtocol/supernode/v2/gen/supernode" + "github.com/LumeraProtocol/supernode/v2/supernode/node/supernode/gateway" + "google.golang.org/protobuf/encoding/protojson" +) + +const gatewayTimeout = 15 * time.Second + +// gatewayGuardIface provides the minimal surface used by AutoUpdater so tests +// can inject a fake implementation. +type gatewayGuardIface interface { + isIdle() (bool, bool) + requestRestartNow(reason string) +} + +// gatewayGuard encapsulates gateway idleness checks and restart triggers. +// Usage within updater: +// - When idle: safe to proceed with updates. +// - When busy: updater may defer updates for a bounded time. +// - On errors/unresponsive gateway: updater either proceeds (if updates exist) +// or requests a clean SuperNode restart via a marker file. +type gatewayGuard struct { + url string + timeout time.Duration + homeDir string +} + +func newGatewayGuard(homeDir string) *gatewayGuard { + url := fmt.Sprintf("http://localhost:%d/api/v1/status", gateway.DefaultGatewayPort) + return &gatewayGuard{ + url: url, + timeout: gatewayTimeout, + homeDir: homeDir, + } +} + +// isIdle returns (idle, isError). isError=true means the check failed. +func (g *gatewayGuard) isIdle() (bool, bool) { + client := &http.Client{Timeout: g.timeout} + + resp, err := client.Get(g.url) + if err != nil { + log.Printf("Failed to check gateway status: %v", err) + return false, true + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + log.Printf("Gateway returned status %d, not safe to update", resp.StatusCode) + return false, true + } + + var status pb.StatusResponse + body, err := io.ReadAll(resp.Body) + if err != nil { + log.Printf("Failed to read gateway response: %v", err) + return false, true + } + if err := protojson.Unmarshal(body, &status); err != nil { + log.Printf("Failed to decode gateway response: %v", err) + return false, true + } + + totalTasks := 0 + for _, service := range status.RunningTasks { + totalTasks += int(service.TaskCount) + } + + if totalTasks > 0 { + log.Printf("Gateway busy: %d running tasks", totalTasks) + return false, false + } + return true, false +} + +// requestRestartNow writes a restart marker immediately with a reason. +func (g *gatewayGuard) requestRestartNow(reason string) { + marker := filepath.Join(g.homeDir, ".needs_restart") + if err := os.WriteFile(marker, []byte(reason), 0644); err != nil { + log.Printf("Failed to write restart marker: %v", err) + } else { + log.Printf("Gateway not responding and no updates available; requesting immediate SuperNode restart") + } +} diff --git a/sn-manager/internal/updater/updater.go b/sn-manager/internal/updater/updater.go index 02e69ee5..fb0d33ad 100644 --- a/sn-manager/internal/updater/updater.go +++ b/sn-manager/internal/updater/updater.go @@ -3,51 +3,52 @@ package updater import ( "context" "fmt" - "io" "log" - "net/http" "os" "path/filepath" "strings" "time" - pb "github.com/LumeraProtocol/supernode/v2/gen/supernode" "github.com/LumeraProtocol/supernode/v2/sn-manager/internal/config" "github.com/LumeraProtocol/supernode/v2/sn-manager/internal/github" "github.com/LumeraProtocol/supernode/v2/sn-manager/internal/utils" "github.com/LumeraProtocol/supernode/v2/sn-manager/internal/version" - "github.com/LumeraProtocol/supernode/v2/supernode/node/supernode/gateway" - "google.golang.org/protobuf/encoding/protojson" ) -const gatewayTimeout = 15 * time.Second +// Package updater coordinates update checks and installs for both sn-manager +// and SuperNode. It aims to keep nodes current while minimizing disruption and +// breaking out of stuck scenarios (e.g., gateway unresponsive/busy indefinitely). +// +// Core principles: +// - Stable-only, same-major updates are eligible automatically. +// - sn-manager updates first; then SuperNode; manager restarts itself last. +// - Gateway-aware timing with bounded deferral: proceed after a hard 1h cap. +// - On unresponsive gateway and available update, proceed to avoid deadlock. +// - On unresponsive gateway with no update, request a clean restart. type AutoUpdater struct { - config *config.Config - homeDir string - githubClient github.GithubClient - versionMgr *version.Manager - gatewayURL string - ticker *time.Ticker - stopCh chan struct{} - managerVersion string - // Gateway error backoff state - gwErrCount int - gwErrWindowStart time.Time + config *config.Config + homeDir string + githubClient github.GithubClient + versionMgr *version.Manager + guard gatewayGuardIface + ticker *time.Ticker + stopCh chan struct{} + managerVersion string + skipGatewayOnce bool + busyDeferStart time.Time + busyDeferFor string } // Use protobuf JSON decoding for gateway responses (int64s encoded as strings) func New(homeDir string, cfg *config.Config, managerVersion string) *AutoUpdater { - // Use the correct gateway endpoint with imported constants - gatewayURL := fmt.Sprintf("http://localhost:%d/api/v1/status", gateway.DefaultGatewayPort) - return &AutoUpdater{ config: cfg, homeDir: homeDir, githubClient: github.NewClient(config.GitHubRepo), versionMgr: version.NewManager(homeDir), - gatewayURL: gatewayURL, + guard: newGatewayGuard(homeDir), stopCh: make(chan struct{}), managerVersion: managerVersion, } @@ -64,13 +65,13 @@ func (u *AutoUpdater) Start(ctx context.Context) { u.ticker = time.NewTicker(interval) // Run an immediate check on startup so restarts don't wait a full interval - u.checkAndUpdateCombined() + u.checkAndUpdate() go func() { for { select { case <-u.ticker.C: - u.checkAndUpdateCombined() + u.checkAndUpdate() case <-u.stopCh: return case <-ctx.Done(): @@ -80,6 +81,12 @@ func (u *AutoUpdater) Start(ctx context.Context) { }() } +// SkipGatewayCheckOnce requests the next update cycle to bypass the gateway +// idleness check (useful on first start before the gateway is up). +func (u *AutoUpdater) SkipGatewayCheckOnce() { + u.skipGatewayOnce = true +} + func (u *AutoUpdater) Stop() { if u.ticker != nil { u.ticker.Stop() @@ -125,245 +132,256 @@ func (u *AutoUpdater) ShouldUpdate(current, latest string) bool { return false } -// isGatewayIdle returns (idle, isError). When isError is true, -// the gateway could not be reliably checked (network/error/invalid). -// When isError is false and idle is false, the gateway is busy. -func (u *AutoUpdater) isGatewayIdle() (bool, bool) { - client := &http.Client{Timeout: gatewayTimeout} - - resp, err := client.Get(u.gatewayURL) - if err != nil { - log.Printf("Failed to check gateway status: %v", err) - // Error contacting gateway - return false, true +// checkAndUpdate performs an update check and applies any eligible updates. +// Steps: +// 1) Fetch latest stable version from GitHub +// 2) Determine if sn-manager and/or SuperNode need updates (stable-only, same major) +// 3) Ensure update window (gateway policy + defer window) +// 4) Download release tarball (once) +// 5) Extract required binaries +// 6) Apply sn-manager update first (prepare self-restart) +// 7) Apply SuperNode update (write restart marker) +// 8) Restart sn-manager if it was updated +func (u *AutoUpdater) checkAndUpdate() { + latest, ok := u.fetchLatestVersion() + if !ok { + return } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - log.Printf("Gateway returned status %d, not safe to update", resp.StatusCode) - return false, true + managerNeeds, supernodeNeeds := u.computeUpdateNeeds(latest) + if !managerNeeds && !supernodeNeeds { + return } - var status pb.StatusResponse - body, err := io.ReadAll(resp.Body) - if err != nil { - log.Printf("Failed to read gateway response: %v", err) - return false, true - } - if err := protojson.Unmarshal(body, &status); err != nil { - log.Printf("Failed to decode gateway response: %v", err) - return false, true + if !u.ensureUpdateWindow(managerNeeds, supernodeNeeds, latest) { + return } - totalTasks := 0 - for _, service := range status.RunningTasks { - totalTasks += int(service.TaskCount) + tarPath, cleanup, ok := u.downloadRelease(latest) + if !ok { + return } + defer cleanup() - if totalTasks > 0 { - log.Printf("Gateway busy: %d running tasks", totalTasks) - return false, false + exePath, tmpManager, tmpSN, ok := u.prepareTempPaths(latest) + if !ok { + return } - return true, false -} + extractedManager, extractedSN := u.extractTargets(tarPath, managerNeeds, supernodeNeeds, tmpManager, tmpSN) + + managerUpdated := u.applyManagerUpdate(extractedManager, tmpManager, exePath, latest) + u.applySupernodeUpdate(extractedSN, tmpSN, latest) -// checkAndUpdateCombined performs a single release check and, if needed, -// downloads the release tarball once to update sn-manager and SuperNode. -// Order: update sn-manager first (prepare new binary), then SuperNode, then -// trigger restart if manager was updated. -func (u *AutoUpdater) checkAndUpdateCombined() { + u.maybeRestartSelf(managerUpdated) +} - // Fetch latest stable release once +// fetchLatestVersion gets the tag for the latest stable release from GitHub. +func (u *AutoUpdater) fetchLatestVersion() (string, bool) { release, err := u.githubClient.GetLatestStableRelease() if err != nil { log.Printf("Failed to check releases: %v", err) - return + return "", false } - latest := strings.TrimSpace(release.TagName) if latest == "" { - return + return "", false } + return latest, true +} - // Determine if sn-manager should update (same criteria: stable, same major) - managerNeedsUpdate := false +// computeUpdateNeeds decides whether manager or SuperNode should update +// under the stable-only, same-major policy. +func (u *AutoUpdater) computeUpdateNeeds(latest string) (managerNeeds, supernodeNeeds bool) { ver := strings.TrimSpace(u.managerVersion) if ver != "" && ver != "dev" && !strings.EqualFold(ver, "unknown") { if utils.SameMajor(ver, latest) && utils.CompareVersions(ver, latest) < 0 { - managerNeedsUpdate = true + managerNeeds = true } } - - // Determine if SuperNode should update using existing policy currentSN := u.config.Updates.CurrentVersion - supernodeNeedsUpdate := u.ShouldUpdate(currentSN, latest) + supernodeNeeds = u.ShouldUpdate(currentSN, latest) + return +} - if !managerNeedsUpdate && !supernodeNeedsUpdate { - return +// maxBusyDefer is a hard limit for how long we defer +// updates when the gateway reports running tasks. +// Do not make this configurable; fixed to 1 hour. +const maxBusyDefer = 1 * time.Hour + +// ensureUpdateWindow applies the gateway policy and defer window. +// Behavior summary (hard 1h defer limit when busy): +// - One-time bypass per manager start (first cycle). +// - Gateway unresponsive: proceed if updates exist; else request a restart. +// - Gateway busy: defer up to maxBusyDefer (1h) for the target version; then proceed. +// - Gateway idle: proceed immediately. +func (u *AutoUpdater) ensureUpdateWindow(managerNeeds, supernodeNeeds bool, latest string) bool { + if u.skipGatewayOnce { + log.Println("Bypassing gateway check for initial update in this session") + u.skipGatewayOnce = false + // clear any prior defer state + u.busyDeferStart = time.Time{} + u.busyDeferFor = "" + return true } - - // Gate all updates (manager + SuperNode) on gateway idleness - // to avoid disrupting traffic during a self-update. - if idle, isErr := u.isGatewayIdle(); !idle { + if idle, isErr := u.guard.isIdle(); !idle { if isErr { - // Track errors and possibly request a clean SuperNode restart - u.handleGatewayError() + // If gateway is not responding and an update is available, proceed. + if managerNeeds || supernodeNeeds { + log.Println("Gateway not responding; proceeding with update since one is available") + u.busyDeferStart = time.Time{} + u.busyDeferFor = "" + return true + } + // No update available: request immediate restart. + u.guard.requestRestartNow("gateway-unresponsive-no-update") } else { - log.Println("Gateway busy, deferring updates") + // Gateway is responding but busy with tasks + if managerNeeds || supernodeNeeds { + // Track deferral time per target version + if u.busyDeferFor != latest { + u.busyDeferFor = latest + u.busyDeferStart = time.Now() + log.Printf("Gateway busy; deferring update to %s (max %s)", latest, maxBusyDefer) + return false + } + // Same version still pending; check if defer window exceeded + elapsed := time.Since(u.busyDeferStart) + if elapsed >= maxBusyDefer { + log.Printf("Gateway busy for %s; exceeding max defer window, proceeding with update to %s", elapsed.Round(time.Second), latest) + u.busyDeferStart = time.Time{} + u.busyDeferFor = "" + return true + } + // Continue deferring until max window reached + remaining := (maxBusyDefer - elapsed).Round(time.Second) + log.Printf("Gateway busy; deferring update to %s. Remaining defer window: %s", latest, remaining) + return false + } + log.Println("Gateway busy, no updates available; nothing to do") } - return + return false + } + // Idle: clear any prior defer state + if !u.busyDeferStart.IsZero() { + u.busyDeferStart = time.Time{} + u.busyDeferFor = "" } + return true +} - // Download the combined release tarball once +// downloadRelease downloads the single release tarball for the given version. +// The returned cleanup removes the tarball after use. +func (u *AutoUpdater) downloadRelease(latest string) (string, func(), bool) { tarURL, err := u.githubClient.GetReleaseTarballURL(latest) if err != nil { log.Printf("Failed to get tarball URL: %v", err) - return + return "", func() {}, false } - // Ensure downloads directory exists downloadsDir := filepath.Join(u.homeDir, "downloads") if err := os.MkdirAll(downloadsDir, 0755); err != nil { log.Printf("Failed to create downloads directory: %v", err) - return + return "", func() {}, false } - tarPath := filepath.Join(downloadsDir, fmt.Sprintf("release-%s.tar.gz", latest)) if err := utils.DownloadFile(tarURL, tarPath, nil); err != nil { log.Printf("Failed to download tarball: %v", err) - return + return "", func() {}, false } - defer func() { + cleanup := func() { if err := os.Remove(tarPath); err != nil && !os.IsNotExist(err) { log.Printf("Warning: failed to remove tarball: %v", err) } - }() + } + return tarPath, cleanup, true +} - // Prepare paths for extraction targets - exePath, err := os.Executable() +// prepareTempPaths computes the absolute path to the running sn-manager +// executable and temp paths where extracted binaries are staged. +func (u *AutoUpdater) prepareTempPaths(latest string) (exePath, tmpManager, tmpSN string, ok bool) { + exe, err := os.Executable() if err != nil { log.Printf("Cannot determine executable path: %v", err) - return + return "", "", "", false } - exePath, _ = filepath.EvalSymlinks(exePath) - tmpManager := exePath + ".new" - tmpSN := filepath.Join(u.homeDir, "downloads", fmt.Sprintf("supernode-%s.tmp", latest)) + exe, _ = filepath.EvalSymlinks(exe) + tmpManager = exe + ".new" + tmpSN = filepath.Join(u.homeDir, "downloads", fmt.Sprintf("supernode-%s.tmp", latest)) + return exe, tmpManager, tmpSN, true +} - // Build extraction targets by base name +// extractTargets extracts requested binaries from the tarball to the given +// temp paths. Returns which targets were found and extracted. +func (u *AutoUpdater) extractTargets(tarPath string, managerNeeds, supernodeNeeds bool, tmpManager, tmpSN string) (extractedManager, extractedSN bool) { targets := map[string]string{} - if managerNeedsUpdate { + if managerNeeds { targets["sn-manager"] = tmpManager } - if supernodeNeedsUpdate { + if supernodeNeeds { targets["supernode"] = tmpSN } - found, err := utils.ExtractMultipleFromTarGz(tarPath, targets) if err != nil { log.Printf("Extraction error: %v", err) - return + return false, false } + return managerNeeds && found["sn-manager"], supernodeNeeds && found["supernode"] +} - extractedManager := managerNeedsUpdate && found["sn-manager"] - extractedSN := supernodeNeedsUpdate && found["supernode"] - - // Apply sn-manager update first - managerUpdated := false - if managerNeedsUpdate { - if extractedManager { - if err := os.Rename(tmpManager, exePath); err != nil { - os.Remove(tmpManager) - log.Printf("Cannot replace sn-manager (%s). Update manually: %v", exePath, err) - } else { - if dirF, err := os.Open(filepath.Dir(exePath)); err == nil { - _ = dirF.Sync() - dirF.Close() - } - managerUpdated = true - log.Printf("sn-manager updated to %s", latest) - } - } else { - log.Printf("sn-manager binary not found in tarball; skipping") - } +// applyManagerUpdate replaces the current sn-manager binary if it was +// extracted from the tarball. Returns true if an update was applied. +func (u *AutoUpdater) applyManagerUpdate(extracted bool, tmpManager, exePath, latest string) bool { + if !extracted { + return false } - - // Apply SuperNode update (idle already verified) and extracted - if supernodeNeedsUpdate { - if extractedSN { - if err := u.versionMgr.InstallVersion(latest, tmpSN); err != nil { - log.Printf("Failed to install SuperNode: %v", err) - } else { - if err := u.versionMgr.SetCurrentVersion(latest); err != nil { - log.Printf("Failed to activate SuperNode %s: %v", latest, err) - } else { - u.config.Updates.CurrentVersion = latest - if err := config.Save(u.config, filepath.Join(u.homeDir, "config.yml")); err != nil { - log.Printf("Failed to save config: %v", err) - } - if err := os.WriteFile(filepath.Join(u.homeDir, ".needs_restart"), []byte(latest), 0644); err != nil { - log.Printf("Failed to write restart marker: %v", err) - } - log.Printf("SuperNode updated to %s", latest) - } - } - if err := os.Remove(tmpSN); err != nil && !os.IsNotExist(err) { - log.Printf("Warning: failed to remove temp supernode: %v", err) - } - } else { - log.Printf("supernode binary not found in tarball; skipping") - } + if err := os.Rename(tmpManager, exePath); err != nil { + _ = os.Remove(tmpManager) + log.Printf("Cannot replace sn-manager (%s). Update manually: %v", exePath, err) + return false } - - // If manager updated, restart service after completing all work - if managerUpdated { - log.Printf("Self-update applied, restarting service...") - go func() { - time.Sleep(500 * time.Millisecond) - os.Exit(3) - }() + if dirF, err := os.Open(filepath.Dir(exePath)); err == nil { + _ = dirF.Sync() + dirF.Close() } + log.Printf("sn-manager updated to %s", latest) + return true } -// handleGatewayError increments an error counter in a rolling 5-minute window -// and when the threshold is reached, requests a clean SuperNode restart by -// writing the standard restart marker consumed by the manager monitor. -func (u *AutoUpdater) handleGatewayError() { - const ( - window = 5 * time.Minute - retries = 3 // attempts within window before restart - ) - now := time.Now() - if u.gwErrWindowStart.IsZero() { - u.gwErrWindowStart = now - u.gwErrCount = 1 - log.Printf("Gateway check error (1/%d); starting 5m observation window", retries) +// applySupernodeUpdate installs and activates the new SuperNode binary if +// extracted, updates config and writes a restart marker for the manager loop. +func (u *AutoUpdater) applySupernodeUpdate(extracted bool, tmpSN, latest string) { + if !extracted { return } - - elapsed := now.Sub(u.gwErrWindowStart) - if elapsed >= window { - // Window elapsed; decide based on accumulated errors - if u.gwErrCount >= retries { - marker := filepath.Join(u.homeDir, ".needs_restart") - if err := os.WriteFile(marker, []byte("gateway-error-recover"), 0644); err != nil { - log.Printf("Failed to write restart marker after gateway errors: %v", err) - } else { - log.Printf("Gateway errors persisted (%d/%d) over >=5m; requesting SuperNode restart to recover gateway", u.gwErrCount, retries) + if err := u.versionMgr.InstallVersion(latest, tmpSN); err != nil { + log.Printf("Failed to install SuperNode: %v", err) + } else { + if err := u.versionMgr.SetCurrentVersion(latest); err != nil { + log.Printf("Failed to activate SuperNode %s: %v", latest, err) + } else { + u.config.Updates.CurrentVersion = latest + if err := config.Save(u.config, filepath.Join(u.homeDir, "config.yml")); err != nil { + log.Printf("Failed to save config: %v", err) } + if err := os.WriteFile(filepath.Join(u.homeDir, ".needs_restart"), []byte(latest), 0644); err != nil { + log.Printf("Failed to write restart marker: %v", err) + } + log.Printf("SuperNode updated to %s", latest) } - // Start a new window beginning now, with this error as the first hit - u.gwErrWindowStart = now - u.gwErrCount = 1 - return } + if err := os.Remove(tmpSN); err != nil && !os.IsNotExist(err) { + log.Printf("Warning: failed to remove temp supernode: %v", err) + } +} - // Still within the window; increment and possibly announce threshold reached - u.gwErrCount++ - if u.gwErrCount < retries { - log.Printf("Gateway check error (%d/%d) within 5m; will retry", u.gwErrCount, retries) +// maybeRestartSelf restarts the process when the manager was updated. +func (u *AutoUpdater) maybeRestartSelf(managerUpdated bool) { + if !managerUpdated { return } - // Threshold reached but do not restart until full window elapses - remaining := window - elapsed - log.Printf("Gateway error threshold reached; waiting %s before requesting SuperNode restart", remaining.Truncate(time.Second)) + log.Printf("Self-update applied, restarting service...") + go func() { + time.Sleep(500 * time.Millisecond) + os.Exit(3) + }() } diff --git a/sn-manager/internal/updater/updater_test.go b/sn-manager/internal/updater/updater_test.go new file mode 100644 index 00000000..8e3038d7 --- /dev/null +++ b/sn-manager/internal/updater/updater_test.go @@ -0,0 +1,60 @@ +package updater + +import ( + "testing" + + "github.com/LumeraProtocol/supernode/v2/sn-manager/internal/config" +) + +func TestShouldUpdatePolicy(t *testing.T) { + cfg := config.DefaultConfig() + u := New(t.TempDir(), cfg, "1.2.0") + + // skip prerelease targets + if u.ShouldUpdate("1.2.0", "1.2.1-beta") { + t.Fatalf("should not update to prerelease") + } + // same-major minor bump + if !u.ShouldUpdate("1.2.0", "1.3.0") { + t.Fatalf("should update within same major") + } + // major jump blocked + if u.ShouldUpdate("1.9.0", "2.0.0") { + t.Fatalf("should not auto update across major") + } + // current is prerelease -> should update to same base stable + if !u.ShouldUpdate("1.2.1-rc.1", "1.2.1") { + t.Fatalf("should update prerelease to stable of same base") + } +} + +func TestComputeUpdateNeeds(t *testing.T) { + cfg := config.DefaultConfig() + cfg.Updates.CurrentVersion = "1.2.0" + u := New(t.TempDir(), cfg, "1.1.0") + + m, s := u.computeUpdateNeeds("1.2.1") + if !m { + t.Fatalf("manager should need update within same major when behind") + } + if !s { + t.Fatalf("supernode should need update from 1.2.0 -> 1.2.1") + } + + // manager newer than latest -> no manager update + u2 := New(t.TempDir(), cfg, "1.3.0") + m, s = u2.computeUpdateNeeds("1.2.1") + if m { + t.Fatalf("manager should not need update when newer") + } + if !s { + t.Fatalf("supernode should still compare with config version") + } + + // manager unknown versions should be ignored for manager update + u3 := New(t.TempDir(), cfg, "dev") + m, _ = u3.computeUpdateNeeds("1.2.1") + if m { + t.Fatalf("manager dev should not trigger manager update") + } +} diff --git a/sn-manager/internal/updater/updater_window_test.go b/sn-manager/internal/updater/updater_window_test.go new file mode 100644 index 00000000..521ce70e --- /dev/null +++ b/sn-manager/internal/updater/updater_window_test.go @@ -0,0 +1,66 @@ +package updater + +import ( + "testing" + "time" + + "github.com/LumeraProtocol/supernode/v2/sn-manager/internal/config" +) + +type fakeGuard struct { + idle bool + err bool + restartCalled bool +} + +func (f *fakeGuard) isIdle() (bool, bool) { return f.idle, f.err } +func (f *fakeGuard) requestRestartNow(reason string) { f.restartCalled = true } + +// Test busy gateway defers until the max window, then proceeds. +func TestEnsureUpdateWindow_BusyDeferWindow(t *testing.T) { + cfg := config.DefaultConfig() + u := New(t.TempDir(), cfg, "1.0.0") + + fg := &fakeGuard{idle: false, err: false} + u.guard = fg + + // First call: busy and update available -> defer (return false) and set tracking + if ok := u.ensureUpdateWindow(true, true, "1.2.3"); ok { + t.Fatalf("expected initial busy defer to return false") + } + if u.busyDeferFor != "1.2.3" || u.busyDeferStart.IsZero() { + t.Fatalf("defer tracking not set") + } + + // Simulate time passed beyond max window + u.busyDeferStart = time.Now().Add(-maxBusyDefer - time.Minute) + if ok := u.ensureUpdateWindow(true, true, "1.2.3"); !ok { + t.Fatalf("expected proceed after exceeding max defer window") + } + if u.busyDeferFor != "" || !u.busyDeferStart.IsZero() { + t.Fatalf("expected defer tracking cleared") + } +} + +// Test unresponsive gateway: proceed if update exists; request restart otherwise. +func TestEnsureUpdateWindow_Unresponsive(t *testing.T) { + cfg := config.DefaultConfig() + u := New(t.TempDir(), cfg, "1.0.0") + + fg := &fakeGuard{idle: false, err: true} + u.guard = fg + + // Update available -> proceed + if ok := u.ensureUpdateWindow(true, false, "1.2.3"); !ok { + t.Fatalf("expected proceed when gateway error and update available") + } + + // No update -> request restart + fg.restartCalled = false + if ok := u.ensureUpdateWindow(false, false, "1.2.3"); ok { + t.Fatalf("expected no proceed when no update available") + } + if !fg.restartCalled { + t.Fatalf("expected restart marker request on unresponsive with no update") + } +} diff --git a/sn-manager/internal/utils/semver_test.go b/sn-manager/internal/utils/semver_test.go new file mode 100644 index 00000000..488b7278 --- /dev/null +++ b/sn-manager/internal/utils/semver_test.go @@ -0,0 +1,39 @@ +package utils + +import "testing" + +func TestCompareVersions_CoreAndPrerelease(t *testing.T) { + cases := []struct { + a, b string + want int + }{ + {"1.2.3", "1.2.3", 0}, + {"v1.2.3", "1.2.3", 0}, + {"1.2.3", "1.2.4", -1}, + {"1.3.0", "1.2.9", 1}, + {"2.0.0", "1.9.9", 1}, + {"1.2.3-beta", "1.2.3", -1}, // prerelease lower than normal + {"1.2.3", "1.2.3-beta", 1}, + {"1.2.3-alpha", "1.2.3-beta", -1}, + {"1.2.3-2", "1.2.3-10", -1}, + {"1.2.3-rc.1", "1.2.3-rc.2", -1}, + {"1.2.3+build", "1.2.3+meta", 0}, // build metadata ignored + } + for _, c := range cases { + if got := CompareVersions(c.a, c.b); got != c.want { + t.Fatalf("CompareVersions(%q,%q)=%d want %d", c.a, c.b, got, c.want) + } + } +} + +func TestSameMajor(t *testing.T) { + if !SameMajor("1.2.3", "1.9.0") { + t.Fatal("expected same major") + } + if SameMajor("1.2.3", "2.0.0") { + t.Fatal("expected different major") + } + if !SameMajor("v1.0.0-alpha", "1.0.0+build") { + t.Fatal("expected same major with prefixes and suffixes") + } +} diff --git a/sn-manager/internal/utils/tar_test.go b/sn-manager/internal/utils/tar_test.go new file mode 100644 index 00000000..df5a8afe --- /dev/null +++ b/sn-manager/internal/utils/tar_test.go @@ -0,0 +1,78 @@ +package utils + +import ( + "archive/tar" + "compress/gzip" + "io" + "os" + "path/filepath" + "testing" +) + +// helper to create a tar.gz at path with files: map[name]content +func writeTarGz(t *testing.T, path string, files map[string]string) { + t.Helper() + f, err := os.Create(path) + if err != nil { + t.Fatalf("create tar: %v", err) + } + defer f.Close() + gz := gzip.NewWriter(f) + defer gz.Close() + tw := tar.NewWriter(gz) + defer tw.Close() + for name, content := range files { + hdr := &tar.Header{Name: name, Mode: 0755, Size: int64(len(content))} + if err := tw.WriteHeader(hdr); err != nil { + t.Fatalf("hdr: %v", err) + } + if _, err := io.WriteString(tw, content); err != nil { + t.Fatalf("write: %v", err) + } + } +} + +func TestExtractMultipleFromTarGz(t *testing.T) { + dir := t.TempDir() + tarPath := filepath.Join(dir, "bundle.tar.gz") + files := map[string]string{ + "supernode": "SNBIN", + "sn-manager": "MGRBIN", + "README.txt": "ignorable", + } + writeTarGz(t, tarPath, files) + + outSN := filepath.Join(dir, "out-supernode") + outMGR := filepath.Join(dir, "out-sn-manager") + found, err := ExtractMultipleFromTarGz(tarPath, map[string]string{ + "supernode": outSN, + "sn-manager": outMGR, + }) + if err != nil { + t.Fatalf("extract: %v", err) + } + if !found["supernode"] || !found["sn-manager"] { + t.Fatalf("expected both binaries to be found: %v", found) + } + if b, _ := os.ReadFile(outSN); string(b) != "SNBIN" { + t.Fatalf("supernode contents wrong: %q", string(b)) + } + if b, _ := os.ReadFile(outMGR); string(b) != "MGRBIN" { + t.Fatalf("sn-manager contents wrong: %q", string(b)) + } +} + +func TestExtractFileFromTarGz(t *testing.T) { + dir := t.TempDir() + tarPath := filepath.Join(dir, "bundle.tar.gz") + files := map[string]string{"supernode": "X", "sn-manager": "Y"} + writeTarGz(t, tarPath, files) + + out := filepath.Join(dir, "only-supernode") + if err := ExtractFileFromTarGz(tarPath, "supernode", out); err != nil { + t.Fatalf("extract file: %v", err) + } + if b, _ := os.ReadFile(out); string(b) != "X" { + t.Fatalf("content mismatch: %q", string(b)) + } +} diff --git a/sn-manager/internal/version/manager_test.go b/sn-manager/internal/version/manager_test.go new file mode 100644 index 00000000..d8dda9b4 --- /dev/null +++ b/sn-manager/internal/version/manager_test.go @@ -0,0 +1,44 @@ +package version + +import ( + "os" + "path/filepath" + "runtime" + "testing" +) + +func TestInstallAndSwitchVersion(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink semantics differ on Windows") + } + dir := t.TempDir() + m := NewManager(dir) + + // create a fake binary file to install + tmpBin := filepath.Join(dir, "fake-supernode") + if err := os.WriteFile(tmpBin, []byte("#!/bin/sh\necho supernode\n"), 0755); err != nil { + t.Fatalf("write fake bin: %v", err) + } + + // install v1.0.0 + if err := m.InstallVersion("v1.0.0", tmpBin); err != nil { + t.Fatalf("install: %v", err) + } + if !m.IsVersionInstalled("v1.0.0") { + t.Fatalf("version not installed") + } + // set current + if err := m.SetCurrentVersion("v1.0.0"); err != nil { + t.Fatalf("set current: %v", err) + } + cur, err := m.GetCurrentVersion() + if err != nil || cur != "v1.0.0" { + t.Fatalf("current=%q err=%v", cur, err) + } + + // list should include v1.0.0 + vs, err := m.ListVersions() + if err != nil || len(vs) == 0 { + t.Fatalf("list versions: %v %v", vs, err) + } +}