From 29d4218daea1fb378868fcb5a0f23d30464b9356 Mon Sep 17 00:00:00 2001 From: Matee ullah Malik Date: Wed, 22 Jul 2026 15:07:39 +0000 Subject: [PATCH 1/2] fix(sn-manager): remove automatic EVM rollback Treat evm_key_name as a transitional migration input rather than a permanent readiness marker. Already-EVM-capable supernodes now remain on the forward update path after migration removes that field; pre-v2.6 nodes without it remain blocked.\n\nRemove the v2.5.0 rollback/download/config mutation path, preserve historical marker visibility, and make supernode startup explicitly reject unsupported active key types.\n\nVerified with sn-manager unit/vet/race tests, full supernode tests, integration tests, independent review, and a 10-validator/10-supernode EVM devnet restart rehearsal. --- sn-manager/cmd/status.go | 7 +- sn-manager/internal/updater/preflight.go | 22 +- .../internal/updater/preflight_state.go | 86 ++++++++ .../internal/updater/preflight_state_test.go | 29 +++ sn-manager/internal/updater/preflight_test.go | 8 +- sn-manager/internal/updater/rollback.go | 188 ------------------ sn-manager/internal/updater/rollback_test.go | 111 ----------- sn-manager/internal/updater/updater.go | 25 +-- sn-manager/internal/utils/chain_id.go | 6 +- supernode/cmd/evmigration.go | 10 + supernode/cmd/evmigration_test.go | 12 ++ 11 files changed, 160 insertions(+), 344 deletions(-) create mode 100644 sn-manager/internal/updater/preflight_state.go create mode 100644 sn-manager/internal/updater/preflight_state_test.go delete mode 100644 sn-manager/internal/updater/rollback.go delete mode 100644 sn-manager/internal/updater/rollback_test.go diff --git a/sn-manager/cmd/status.go b/sn-manager/cmd/status.go index 1d769f8e..cdeb6202 100644 --- a/sn-manager/cmd/status.go +++ b/sn-manager/cmd/status.go @@ -17,9 +17,8 @@ var statusCmd = &cobra.Command{ RunE: runStatus, } -// printPreflightStatus prints update-blocked / rolled-back state, if any. -// Both markers live in the manager home directory and are written by the -// auto-updater's EVM preflight/rollback code path (see internal/updater/). +// printPreflightStatus prints current update-blocked state and any historical +// rolled-back marker left by v2.6.1. func printPreflightStatus(home string) { if data, err := os.ReadFile(updater.BlockLogPath(home)); err == nil && len(data) > 0 { fmt.Println(" Update Blocked: true") @@ -31,7 +30,7 @@ func printPreflightStatus(home string) { } } if data, err := os.ReadFile(updater.RollbackLogPath(home)); err == nil && len(data) > 0 { - fmt.Println(" Rolled Back: true") + fmt.Println(" Historical Rollback Marker (v2.6.1): present") for _, line := range splitStatusLines(string(data)) { if line == "" { continue diff --git a/sn-manager/internal/updater/preflight.go b/sn-manager/internal/updater/preflight.go index 0924cb51..8001e8cd 100644 --- a/sn-manager/internal/updater/preflight.go +++ b/sn-manager/internal/updater/preflight.go @@ -23,12 +23,8 @@ const ( // preflightAllow: proceed with the normal update flow. preflightAllow preflightDecision = iota // preflightBlock: the target version requires evm_key_name but the node - // has not migrated. Refuse to install; keep the current binary in place. + // is not prepared to migrate. Refuse to install; keep the current binary. preflightBlock - // preflightRollback: the currently-installed version is already - // EVM-required but the node has not migrated — this node is stuck and - // must be reverted to the last pre-EVM release. - preflightRollback ) func (d preflightDecision) String() string { @@ -37,8 +33,6 @@ func (d preflightDecision) String() string { return "allow" case preflightBlock: return "block" - case preflightRollback: - return "rollback" } return "unknown" } @@ -54,12 +48,8 @@ type preflightInputs struct { } // decidePreflight is a pure function: no I/O, no logging. Given the four -// axes of the invariant table, it returns one of allow/block/rollback plus a +// axes of the invariant table, it returns allow or block plus a // human-readable reason. -// -// Rollback fires BEFORE block: a stuck node (currently on an EVM-required -// binary with no evm_key_name) is by definition also a would-block condition -// for any forward target, but rollback is the correct remediation. func decidePreflight(in preflightInputs) (preflightDecision, string) { if !in.chainHasEVM { return preflightAllow, "chain has no evm module active" @@ -68,8 +58,8 @@ func decidePreflight(in preflightInputs) (preflightDecision, string) { return preflightAllow, "supernode config has evm_key_name set" } if utils.IsV260OrAbove(in.currentVersion) { - return preflightRollback, fmt.Sprintf( - "chain has evm module active, current supernode %s requires evm_key_name but config has none", + return preflightAllow, fmt.Sprintf( + "current supernode %s is evm-capable; startup owns migration validation", in.currentVersion, ) } @@ -93,7 +83,7 @@ const chainEVMProbeTimeout = 15 * time.Second // (false, err) — query failed (dial error, timeout, gRPC error) // // The caller MUST treat the error case as "unknown" and fail-open (allow) — -// a transient chain outage must never trigger a block or a rollback. +// a transient chain outage must never block an update. func queryEVMModuleActive(ctx context.Context, grpcAddr string) (bool, error) { if strings.TrimSpace(grpcAddr) == "" { return false, fmt.Errorf("empty grpc_addr") @@ -164,7 +154,7 @@ func parseGRPCAddr(raw string) (host string, useTLS bool, err error) { // preflightCheck loads the current runtime state (chain evm status, supernode // evm_key_name, current installed version) and returns a decision for the // proposed target version. On chain-query failure it FAILS OPEN (returns -// allow) — a transient chain outage must never cause a block or rollback. +// allow) — a transient chain outage must never block an update. func (u *AutoUpdater) preflightCheck(ctx context.Context, targetVersion string) (preflightDecision, string) { grpcAddr, _ := utils.ReadSupernodeGRPCAddr() hasEVM, err := queryEVMModuleActive(ctx, grpcAddr) diff --git a/sn-manager/internal/updater/preflight_state.go b/sn-manager/internal/updater/preflight_state.go new file mode 100644 index 00000000..ea7743b5 --- /dev/null +++ b/sn-manager/internal/updater/preflight_state.go @@ -0,0 +1,86 @@ +package updater + +import ( + "fmt" + "log" + "os" + "path/filepath" + "time" +) + +const ( + updateBlockedLogName = "update-blocked.log" + // Retained so status can display markers written by v2.6.1. New versions + // never create this marker because automatic rollback has been removed. + rolledBackLogName = "rolled-back.log" +) + +// Paths are exposed via helpers so cmd/status.go can read current block state +// and historical v2.6.1 rollback state without duplicating location literals. +func BlockLogPath(homeDir string) string { return filepath.Join(homeDir, updateBlockedLogName) } +func RollbackLogPath(homeDir string) string { return filepath.Join(homeDir, rolledBackLogName) } + +// writeBlockLog records a preflight block. Written atomically. The mtime of +// the supernode config at the moment of the block is captured so subsequent +// check cycles can detect operator remediation. +func writeBlockLog(homeDir, reason, target string, snCfgMTime time.Time) error { + body := fmt.Sprintf( + "blocked_at: %s\ntarget_version: %s\nreason: %s\nsupernode_config_mtime: %s\nmigration_doc: https://github.com/LumeraProtocol/supernode/blob/master/docs/evm-migration.md\n", + time.Now().UTC().Format(time.RFC3339), + target, + reason, + snCfgMTime.UTC().Format(time.RFC3339Nano), + ) + return writeAtomic(BlockLogPath(homeDir), []byte(body)) +} + +// clearBlockLog is used when the operator has fixed their config (mtime +// advanced past what the block log recorded) so a subsequent update attempt +// is no longer blocked by the sticky marker. +func clearBlockLog(homeDir string) { + if err := os.Remove(BlockLogPath(homeDir)); err != nil && !os.IsNotExist(err) { + log.Printf("preflight: failed to remove stale block log: %v", err) + } +} + +// readBlockLogMTime returns the supernode_config_mtime recorded in an existing +// block log, or the zero time + false if no block log is present or parsable. +func readBlockLogMTime(homeDir string) (time.Time, bool) { + data, err := os.ReadFile(BlockLogPath(homeDir)) + if err != nil { + return time.Time{}, false + } + for _, line := range splitLines(string(data)) { + const prefix = "supernode_config_mtime: " + if len(line) > len(prefix) && line[:len(prefix)] == prefix { + t, err := time.Parse(time.RFC3339Nano, line[len(prefix):]) + if err == nil { + return t, true + } + } + } + return time.Time{}, false +} + +func splitLines(s string) []string { + var out []string + start := 0 + for i := 0; i < len(s); i++ { + if s[i] == '\n' { + out = append(out, s[start:i]) + start = i + 1 + } + } + if start < len(s) { + out = append(out, s[start:]) + } + return out +} + +func writeAtomic(path string, data []byte) error { + tmp := path + ".tmp" + if err := os.WriteFile(tmp, data, 0o644); err != nil { + return err + } + return os.Rename(tmp, path) +} diff --git a/sn-manager/internal/updater/preflight_state_test.go b/sn-manager/internal/updater/preflight_state_test.go new file mode 100644 index 00000000..130716e0 --- /dev/null +++ b/sn-manager/internal/updater/preflight_state_test.go @@ -0,0 +1,29 @@ +package updater + +import ( + "testing" + "time" +) + +// TestWriteReadBlockLogMTime round-trips the sticky block marker so the +// updater can detect operator remediation. +func TestWriteReadBlockLogMTime(t *testing.T) { + home := t.TempDir() + mt := time.Date(2026, 7, 6, 8, 40, 0, 0, time.UTC) + if err := writeBlockLog(home, "reason", "v2.6.0-testnet", mt); err != nil { + t.Fatalf("writeBlockLog: %v", err) + } + got, ok := readBlockLogMTime(home) + if !ok { + t.Fatalf("readBlockLogMTime not ok") + } + if !got.Equal(mt) { + t.Fatalf("readBlockLogMTime = %v, want %v", got, mt) + } + + // clearBlockLog removes it + clearBlockLog(home) + if _, ok := readBlockLogMTime(home); ok { + t.Fatalf("expected block log to be cleared") + } +} diff --git a/sn-manager/internal/updater/preflight_test.go b/sn-manager/internal/updater/preflight_test.go index 56331f66..2854d591 100644 --- a/sn-manager/internal/updater/preflight_test.go +++ b/sn-manager/internal/updater/preflight_test.go @@ -14,10 +14,10 @@ func TestDecidePreflight_Table(t *testing.T) { {"no-evm chain forward", preflightInputs{false, "", "v2.5.0-testnet", "v2.6.0-testnet"}, preflightAllow}, {"no-evm chain already 2.6", preflightInputs{false, "", "v2.6.0-testnet", "v2.6.0-testnet"}, preflightAllow}, {"evm no-key forward to 2.6", preflightInputs{true, "", "v2.5.0-testnet", "v2.6.0-testnet"}, preflightBlock}, - {"evm no-key stuck at 2.6", preflightInputs{true, "", "v2.6.0-testnet", "v2.6.0-testnet"}, preflightRollback}, - {"evm no-key stuck target 2.6.1", preflightInputs{true, "", "v2.6.0-testnet", "v2.6.1-testnet"}, preflightRollback}, - {"evm migrated forward to 2.6", preflightInputs{true, "evm-key", "v2.5.0-testnet", "v2.6.0-testnet"}, preflightAllow}, - {"evm migrated at 2.6", preflightInputs{true, "evm-key", "v2.6.0-testnet", "v2.6.0-testnet"}, preflightAllow}, + {"evm migrated config at 2.6 omits transitional key", preflightInputs{true, "", "v2.6.0-testnet", "v2.6.0-testnet"}, preflightAllow}, + {"evm migrated config can update beyond 2.6", preflightInputs{true, "", "v2.6.0-testnet", "v2.6.1-testnet"}, preflightAllow}, + {"evm prepared forward to 2.6", preflightInputs{true, "evm-key", "v2.5.0-testnet", "v2.6.0-testnet"}, preflightAllow}, + {"evm prepared at 2.6", preflightInputs{true, "evm-key", "v2.6.0-testnet", "v2.6.0-testnet"}, preflightAllow}, {"evm no-key below threshold 2.5.1", preflightInputs{true, "", "v2.5.0-testnet", "v2.5.1-testnet"}, preflightAllow}, {"evm no-key below threshold 2.5.0", preflightInputs{true, "", "v2.4.5-testnet", "v2.5.0-testnet"}, preflightAllow}, } diff --git a/sn-manager/internal/updater/rollback.go b/sn-manager/internal/updater/rollback.go deleted file mode 100644 index 49054083..00000000 --- a/sn-manager/internal/updater/rollback.go +++ /dev/null @@ -1,188 +0,0 @@ -package updater - -import ( - "fmt" - "log" - "os" - "path/filepath" - "time" - - "github.com/LumeraProtocol/supernode/v2/sn-manager/internal/config" - "github.com/LumeraProtocol/supernode/v2/sn-manager/internal/utils" -) - -// rollbackTargetVersion is the HARDCODED rollback target — the last pre-EVM -// testnet release. This is intentionally not dynamic and not configurable: -// the incident that motivated this code shipped because operators trusted -// the update pipeline to pick a target automatically. -const rollbackTargetVersion = "v2.5.0-testnet" - -const ( - updateBlockedLogName = "update-blocked.log" - rolledBackLogName = "rolled-back.log" -) - -// blockLogPath / rollbackLogPath / etc. are exposed via helpers so cmd/status.go -// can read them without duplicating the location literals. -func BlockLogPath(homeDir string) string { return filepath.Join(homeDir, updateBlockedLogName) } -func RollbackLogPath(homeDir string) string { return filepath.Join(homeDir, rolledBackLogName) } - -// writeBlockLog records a preflight block. Written atomically. The mtime of -// the supernode config at the moment of the block is captured so subsequent -// check cycles can detect operator remediation. -func writeBlockLog(homeDir, reason, target string, snCfgMTime time.Time) error { - body := fmt.Sprintf( - "blocked_at: %s\ntarget_version: %s\nreason: %s\nsupernode_config_mtime: %s\nmigration_doc: https://github.com/LumeraProtocol/supernode/blob/master/docs/evm-migration.md\n", - time.Now().UTC().Format(time.RFC3339), - target, - reason, - snCfgMTime.UTC().Format(time.RFC3339Nano), - ) - return writeAtomic(BlockLogPath(homeDir), []byte(body)) -} - -// clearBlockLog is used when the operator has fixed their config (mtime -// advanced past what the block log recorded) so a subsequent update attempt -// is no longer blocked by the sticky marker. -func clearBlockLog(homeDir string) { - if err := os.Remove(BlockLogPath(homeDir)); err != nil && !os.IsNotExist(err) { - log.Printf("preflight: failed to remove stale block log: %v", err) - } -} - -// readBlockLogMTime returns the supernode_config_mtime recorded in an existing -// block log, or the zero time + false if no block log is present or parsable. -func readBlockLogMTime(homeDir string) (time.Time, bool) { - data, err := os.ReadFile(BlockLogPath(homeDir)) - if err != nil { - return time.Time{}, false - } - for _, line := range splitLines(string(data)) { - const prefix = "supernode_config_mtime: " - if len(line) > len(prefix) && line[:len(prefix)] == prefix { - t, err := time.Parse(time.RFC3339Nano, line[len(prefix):]) - if err == nil { - return t, true - } - } - } - return time.Time{}, false -} - -func splitLines(s string) []string { - var out []string - start := 0 - for i := 0; i < len(s); i++ { - if s[i] == '\n' { - out = append(out, s[start:i]) - start = i + 1 - } - } - if start < len(s) { - out = append(out, s[start:]) - } - return out -} - -func writeRollbackLog(homeDir, from, to, reason string) error { - body := fmt.Sprintf( - "rolled_back_at: %s\nrolled_back_from: %s\nrolled_back_to: %s\nreason: %s\nmigration_doc: https://github.com/LumeraProtocol/supernode/blob/master/docs/evm-migration.md\n", - time.Now().UTC().Format(time.RFC3339), - from, - to, - reason, - ) - return writeAtomic(RollbackLogPath(homeDir), []byte(body)) -} - -func writeAtomic(path string, data []byte) error { - tmp := path + ".tmp" - if err := os.WriteFile(tmp, data, 0o644); err != nil { - return err - } - return os.Rename(tmp, path) -} - -// performRollback installs (downloading if necessary) rollbackTargetVersion, -// swaps the current symlink to it, updates sn-manager config, writes the -// rollback log, and drops a .needs_restart marker so the manager monitor -// picks up the swap on its next tick. -// -// The from/reason strings are used for the log only. -func (u *AutoUpdater) performRollback(from, reason string) error { - target := rollbackTargetVersion - - // 1. Ensure the target binary is installed. If not, download it. - if !u.versionMgr.IsVersionInstalled(target) { - if err := u.downloadRollbackTarget(target); err != nil { - return fmt.Errorf("download rollback target %s: %w", target, err) - } - } - - // 2. Activate. - if err := u.versionMgr.SetCurrentVersion(target); err != nil { - return fmt.Errorf("activate rollback target %s: %w", target, err) - } - - // 3. Persist in sn-manager config. - u.config.Updates.CurrentVersion = target - if err := config.Save(u.config, filepath.Join(u.homeDir, "config.yml")); err != nil { - return fmt.Errorf("save sn-manager config: %w", err) - } - - // 4. Rollback log + restart marker. - if err := writeRollbackLog(u.homeDir, from, target, reason); err != nil { - log.Printf("rollback: failed to write rollback log: %v", err) - } - if err := os.WriteFile(filepath.Join(u.homeDir, ".needs_restart"), []byte(target), 0o644); err != nil { - log.Printf("rollback: failed to write restart marker: %v", err) - } - log.Printf("rollback: reverted supernode %s → %s (%s)", from, target, reason) - return nil -} - -// downloadRollbackTarget fetches the rollback release tarball, extracts the -// supernode binary into a temp file, and hands it to versionMgr.InstallVersion. -// Reuses the same tarball-based pipeline as checkAndUpdateCombined; keeps the -// rollback release binary at ~/.sn-manager/binaries//supernode. -func (u *AutoUpdater) downloadRollbackTarget(target string) error { - tarURL, err := u.githubClient.GetReleaseTarballURL(target) - if err != nil { - return fmt.Errorf("get tarball URL: %w", err) - } - - downloadsDir := filepath.Join(u.homeDir, "downloads") - if err := os.MkdirAll(downloadsDir, 0o755); err != nil { - return fmt.Errorf("mkdir downloads: %w", err) - } - - tarPath := filepath.Join(downloadsDir, fmt.Sprintf("rollback-%s.tar.gz", target)) - if err := utils.DownloadFile(tarURL, tarPath, nil); err != nil { - return fmt.Errorf("download tarball: %w", err) - } - defer func() { - if err := os.Remove(tarPath); err != nil && !os.IsNotExist(err) { - log.Printf("rollback: failed to remove tarball: %v", err) - } - }() - - tmpSN := filepath.Join(downloadsDir, fmt.Sprintf("supernode-%s.tmp", target)) - targets := map[string]string{"supernode": tmpSN} - found, err := utils.ExtractMultipleFromTarGz(tarPath, targets) - if err != nil { - return fmt.Errorf("extract: %w", err) - } - if !found["supernode"] { - return fmt.Errorf("supernode binary not found in rollback tarball %s", target) - } - defer func() { - if err := os.Remove(tmpSN); err != nil && !os.IsNotExist(err) { - log.Printf("rollback: failed to remove temp supernode: %v", err) - } - }() - - if err := u.versionMgr.InstallVersion(target, tmpSN); err != nil { - return fmt.Errorf("install: %w", err) - } - return nil -} diff --git a/sn-manager/internal/updater/rollback_test.go b/sn-manager/internal/updater/rollback_test.go deleted file mode 100644 index 9453f93a..00000000 --- a/sn-manager/internal/updater/rollback_test.go +++ /dev/null @@ -1,111 +0,0 @@ -package updater - -import ( - "os" - "path/filepath" - "testing" - "time" - - "github.com/LumeraProtocol/supernode/v2/sn-manager/internal/config" - "github.com/LumeraProtocol/supernode/v2/sn-manager/internal/version" -) - -// newTestUpdater constructs an AutoUpdater bound to a temp home directory. -// versionMgr and github client are the real ones — tests either pre-install -// binaries on disk or avoid calling the network code path. -func newTestUpdater(t *testing.T, home string) *AutoUpdater { - t.Helper() - if err := os.MkdirAll(home, 0o755); err != nil { - t.Fatalf("mkdir home: %v", err) - } - cfg := config.DefaultConfig() - cfg.Updates.CurrentVersion = "v2.6.0-testnet" - if err := config.Save(cfg, filepath.Join(home, "config.yml")); err != nil { - t.Fatalf("save cfg: %v", err) - } - return &AutoUpdater{ - config: cfg, - homeDir: home, - versionMgr: version.NewManager(home), - } -} - -// TestPerformRollback_HappyPath pre-installs the rollback target binary on -// disk (avoiding the network path), then asserts that performRollback: -// - swaps the current symlink to the rollback target -// - persists sn-manager config.yml with the rolled-back version -// - writes a .needs_restart marker -// - writes ~/.sn-manager/rolled-back.log -func TestPerformRollback_HappyPath(t *testing.T) { - home := t.TempDir() - u := newTestUpdater(t, home) - - // Pre-install rollback target so downloadRollbackTarget is NOT invoked. - target := rollbackTargetVersion - dir := u.versionMgr.GetVersionDir(target) - if err := os.MkdirAll(dir, 0o755); err != nil { - t.Fatalf("mkdir: %v", err) - } - if err := os.WriteFile(filepath.Join(dir, "supernode"), []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { - t.Fatalf("write binary: %v", err) - } - - if err := u.performRollback("v2.6.0-testnet", "unit-test reason"); err != nil { - t.Fatalf("performRollback: %v", err) - } - - // Symlink activated - got, err := u.versionMgr.GetCurrentVersion() - if err != nil { - t.Fatalf("GetCurrentVersion: %v", err) - } - if got != target { - t.Fatalf("current version = %s, want %s", got, target) - } - - // Config persisted - reloaded, err := config.Load(filepath.Join(home, "config.yml")) - if err != nil { - t.Fatalf("reload cfg: %v", err) - } - if reloaded.Updates.CurrentVersion != target { - t.Fatalf("config current_version = %s, want %s", reloaded.Updates.CurrentVersion, target) - } - - // Restart marker - if _, err := os.Stat(filepath.Join(home, ".needs_restart")); err != nil { - t.Fatalf(".needs_restart missing: %v", err) - } - - // Rollback log - logData, err := os.ReadFile(RollbackLogPath(home)) - if err != nil { - t.Fatalf("read rollback log: %v", err) - } - if len(logData) == 0 { - t.Fatalf("rollback log is empty") - } -} - -// TestWriteReadBlockLogMTime round-trips the sticky block marker so the -// updater can detect operator remediation. -func TestWriteReadBlockLogMTime(t *testing.T) { - home := t.TempDir() - mt := time.Date(2026, 7, 6, 8, 40, 0, 0, time.UTC) - if err := writeBlockLog(home, "reason", "v2.6.0-testnet", mt); err != nil { - t.Fatalf("writeBlockLog: %v", err) - } - got, ok := readBlockLogMTime(home) - if !ok { - t.Fatalf("readBlockLogMTime not ok") - } - if !got.Equal(mt) { - t.Fatalf("readBlockLogMTime = %v, want %v", got, mt) - } - - // clearBlockLog removes it - clearBlockLog(home) - if _, ok := readBlockLogMTime(home); ok { - t.Fatalf("expected block log to be cleared") - } -} diff --git a/sn-manager/internal/updater/updater.go b/sn-manager/internal/updater/updater.go index 3c4a48d3..289d7523 100644 --- a/sn-manager/internal/updater/updater.go +++ b/sn-manager/internal/updater/updater.go @@ -222,18 +222,15 @@ func (u *AutoUpdater) checkAndUpdateCombined(force bool) { } } - // EVM preflight (post-fetch, pre-install). This has two branches: + // EVM preflight (post-fetch, pre-install). Forward-update would install an + // EVM-required target on a pre-EVM node that has no evm_key_name, so + // refuse the install and drop a sticky marker until the operator remediates. + // Already-EVM-capable versions proceed normally: the supernode startup path + // is the authority for active-key type and migration validation. // - // (1) rollback: the currently-installed supernode is EVM-required and - // the config has no evm_key_name — the node is in a crash loop and - // must be reverted to the pre-EVM release (see rollback.go). - // (2) block: forward-update would install an EVM-required target on a - // node that has no evm_key_name — refuse the install and drop a - // sticky marker until the operator remediates. - // - // Both branches only fire when the chain has the evm module active. + // The branch only fires when the chain has the evm module active. // A chain-query failure fails OPEN (allow) so a transient outage cannot - // itself trigger a block or a rollback. See preflight.go. + // itself block an update. See preflight.go. // // The forward-block branch is the ONLY auto-update forward path this // preflight gates. Operator-explicit code paths in cmd/{start,init,use,get}.go @@ -243,14 +240,6 @@ func (u *AutoUpdater) checkAndUpdateCombined(force bool) { decision, reason := u.preflightCheck(pfCtx, latest) pfCancel() - if decision == preflightRollback { - from := u.config.Updates.CurrentVersion - if err := u.performRollback(from, reason); err != nil { - log.Printf("rollback failed: %v", err) - } - return - } - if decision == preflightBlock { // Sticky: don't retry until the operator changes ~/.supernode/config.yml. snCfgMTime, _ := utils.SupernodeConfigMTime() diff --git a/sn-manager/internal/utils/chain_id.go b/sn-manager/internal/utils/chain_id.go index d8b4dabe..16d6c961 100644 --- a/sn-manager/internal/utils/chain_id.go +++ b/sn-manager/internal/utils/chain_id.go @@ -24,7 +24,7 @@ func SupernodeConfigPath() string { } // supernodeYAML captures the fields sn-manager reads from ~/.supernode/config.yml. -// Keep this struct in sync with new preflight/rollback readers below. +// Keep this struct in sync with new preflight readers below. type supernodeYAML struct { Supernode struct { EVMKeyName string `yaml:"evm_key_name"` @@ -63,8 +63,8 @@ func ReadSupernodeChainID() (string, error) { // ReadSupernodeEVMKeyName returns supernode.evm_key_name (trimmed). An empty // string is returned if the field is absent or empty. It is NOT an error for -// the field to be missing — an unmigrated node is the exact condition the -// preflight/rollback logic looks for. +// the field to be missing — an unprepared pre-EVM node is the condition the +// forward-update preflight looks for. func ReadSupernodeEVMKeyName() (string, error) { cfg, _, err := readSupernodeYAML() if err != nil { diff --git a/supernode/cmd/evmigration.go b/supernode/cmd/evmigration.go index 3ec8fd92..244a5c47 100644 --- a/supernode/cmd/evmigration.go +++ b/supernode/cmd/evmigration.go @@ -84,6 +84,16 @@ func validateLegacyMigrationSetup(kr cKeyring.Keyring, keyName, evmKeyName strin return false, err } if !legacy { + isEVM, err := isEthSecp256k1Key(kr, keyName) + if err != nil { + return false, err + } + if !isEVM { + return false, fmt.Errorf( + "supernode.key_name %q is not an eth_secp256k1 key; only legacy secp256k1 keys awaiting migration or active eth_secp256k1 keys are supported", + keyName, + ) + } return false, nil } diff --git a/supernode/cmd/evmigration_test.go b/supernode/cmd/evmigration_test.go index e8431197..c18d652a 100644 --- a/supernode/cmd/evmigration_test.go +++ b/supernode/cmd/evmigration_test.go @@ -143,6 +143,18 @@ func TestValidateLegacyMigrationSetup_NoMigrationNeeded(t *testing.T) { assert.False(t, legacy) } +func TestValidateLegacyMigrationSetup_RejectsUnsupportedActiveKeyType(t *testing.T) { + kr := newTestKeyring(t) + priv := ed25519.GenPrivKey() + _, err := kr.SaveOfflineKey("unsupported", priv.PubKey()) + require.NoError(t, err) + + legacy, err := validateLegacyMigrationSetup(kr, "unsupported", "") + require.Error(t, err) + assert.False(t, legacy) + assert.Contains(t, err.Error(), "supernode.key_name \"unsupported\" is not an eth_secp256k1 key") +} + func TestValidateLegacyMigrationSetup_LegacyKeyWithoutEVMKeyName(t *testing.T) { kr := newTestKeyring(t) addLegacyKey(t, kr, "mykey") From b67cc7d41370232d1cf91ef3fb18dd17cf20cabd Mon Sep 17 00:00:00 2001 From: Matee ullah Malik Date: Wed, 22 Jul 2026 21:47:50 +0000 Subject: [PATCH 2/2] fix(sn-manager): harden EVM migration updates Prevent automatic downgrades and stale-config activation across the EVM migration boundary. Serialize config and binary mutations, preserve confirmed compatibility blocks, select release channels from the configured chain, and validate migration key material before destructive cleanup. Add regression, race, CI, and operator recovery coverage. --- .github/workflows/tests.yml | 20 ++ docs/evm-migration.md | 40 ++++ pkg/configlock/lock.go | 41 ++++ pkg/configlock/lock_test.go | 48 +++++ pkg/github/testutil/fake_client.go | 2 + sn-manager/cmd/check.go | 33 ++-- sn-manager/cmd/get.go | 18 +- sn-manager/cmd/init.go | 84 +++++++- sn-manager/cmd/init_test.go | 77 ++++++++ sn-manager/cmd/start.go | 54 +++++- sn-manager/internal/updater/preflight.go | 38 ++-- .../internal/updater/preflight_state.go | 12 ++ .../internal/updater/preflight_state_test.go | 42 ++++ sn-manager/internal/updater/preflight_test.go | 10 + sn-manager/internal/updater/updater.go | 179 +++++++++++++++--- sn-manager/internal/updater/updater_test.go | 93 ++++++++- sn-manager/internal/utils/chain_id.go | 46 +++++ sn-manager/internal/utils/chain_id_test.go | 36 ++++ sn-manager/internal/version/manager.go | 69 ++++++- sn-manager/internal/version/manager_test.go | 102 +++++++++- supernode/cmd/evmigration.go | 25 ++- supernode/cmd/evmigration_test.go | 52 ++++- supernode/config/save.go | 7 + 23 files changed, 1044 insertions(+), 84 deletions(-) create mode 100644 pkg/configlock/lock.go create mode 100644 pkg/configlock/lock_test.go create mode 100644 sn-manager/cmd/init_test.go diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index d7d6e2d9..f81a01db 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -25,6 +25,26 @@ jobs: - name: Run unit tests run: go test $(go list ./... | grep -v '/tests') -v + sn-manager-tests: + name: sn-manager-tests + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v6.0.1 + - name: Setup Go and system deps + uses: ./.github/actions/setup-env + with: + bust_lumera_retag: 'true' + - name: Verify nested module + working-directory: sn-manager + run: | + go mod tidy + git diff --exit-code -- go.mod go.sum + go vet ./... + go test ./... + go test -race ./internal/updater ./internal/utils ./internal/version + integration-tests: name: integration-tests runs-on: ubuntu-latest diff --git a/docs/evm-migration.md b/docs/evm-migration.md index 3cff7459..f6f0d823 100644 --- a/docs/evm-migration.md +++ b/docs/evm-migration.md @@ -80,6 +80,46 @@ The migration is: | `migration tx was not confirmed` | Tx was not included in a block within 60s | Restart to retry; the check is idempotent | | `failed to save updated config` | Config file write error after successful migration | Manually update config as instructed in the error message | +### Recovery from the v2.6.1 automatic rollback + +A v2.6.1 sn-manager can mistake the intentionally removed `evm_key_name` field +for an incomplete migration and select v2.5.0. The old manager evaluates that +preflight before self-update, so a normal combined release cannot reliably +self-deliver the fix. Do not run v2.5.x against a migrated EVM keyring. + +Recover one node at a time: + +1. Stop the sn-manager service so the old process cannot change the selected + version while recovery is in progress. +2. Replace the **sn-manager binary itself** out of band with the corrected + release (package/configuration management or a controlled manual binary + swap). Replacing only the managed SuperNode binary is insufficient. +3. Confirm the post-migration SuperNode config is canonical: `key_name` names a + local `eth_secp256k1` signing key, `identity` is that key's Lumera address, + and `evm_key_name` is absent. Do not recreate the transitional field after a + completed migration. +4. Confirm that the network-approved recovery release is also the **current + latest release in that network's channel** (`-testnet` for testnet, stable + for mainnet). Startup performs mandatory monotonic synchronization even when + periodic auto-upgrade is disabled; it may advance to a newer channel release + but will never downgrade. Then explicitly install and select the approved + EVM-capable release: + + ```bash + sn-manager get + sn-manager use + ``` + + These are operator-explicit commands; automatic update paths never + downgrade and continue to block unprepared legacy nodes. +5. Start sn-manager and verify both axes independently: + - process: selected version, SuperNode startup/status endpoint, EVM-module + detection, and epoch-report submission; + - chain: expected SuperNode state after the normal epoch recovery predicate. + +A historical `rolled-back.log` marker is diagnostic only. It is not proof that +key migration succeeded and is never used to bypass runtime key validation. + ### Multisig legacy accounts A supernode daemon holds a single signing key and cannot run the K-of-N signing diff --git a/pkg/configlock/lock.go b/pkg/configlock/lock.go new file mode 100644 index 00000000..1ceffe65 --- /dev/null +++ b/pkg/configlock/lock.go @@ -0,0 +1,41 @@ +// Package configlock coordinates SuperNode config writers with sn-manager's +// update preflight. The stable sidecar inode is intentionally retained so all +// processes contend on the same advisory lock. +package configlock + +import ( + "fmt" + "os" + "sync" + + "golang.org/x/sys/unix" +) + +// Acquire obtains an exclusive advisory lock for configPath. The returned +// release function is idempotent and must be called by the owner. +func Acquire(configPath string) (func() error, error) { + lockPath := configPath + ".lock" + file, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + return nil, fmt.Errorf("open config lock %s: %w", lockPath, err) + } + if err := unix.Flock(int(file.Fd()), unix.LOCK_EX); err != nil { + _ = file.Close() + return nil, fmt.Errorf("lock config %s: %w", configPath, err) + } + + var once sync.Once + var releaseErr error + release := func() error { + once.Do(func() { + if err := unix.Flock(int(file.Fd()), unix.LOCK_UN); err != nil { + releaseErr = fmt.Errorf("unlock config %s: %w", configPath, err) + } + if err := file.Close(); err != nil && releaseErr == nil { + releaseErr = fmt.Errorf("close config lock %s: %w", lockPath, err) + } + }) + return releaseErr + } + return release, nil +} diff --git a/pkg/configlock/lock_test.go b/pkg/configlock/lock_test.go new file mode 100644 index 00000000..416a726f --- /dev/null +++ b/pkg/configlock/lock_test.go @@ -0,0 +1,48 @@ +package configlock + +import ( + "path/filepath" + "testing" + "time" +) + +func TestAcquireSerializesConfigWriters(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.yml") + releaseFirst, err := Acquire(configPath) + if err != nil { + t.Fatal(err) + } + + acquiredSecond := make(chan func() error, 1) + errSecond := make(chan error, 1) + go func() { + release, err := Acquire(configPath) + if err != nil { + errSecond <- err + return + } + acquiredSecond <- release + }() + + select { + case <-acquiredSecond: + t.Fatal("second writer acquired lock before first released it") + case err := <-errSecond: + t.Fatalf("second writer failed: %v", err) + case <-time.After(50 * time.Millisecond): + } + + if err := releaseFirst(); err != nil { + t.Fatal(err) + } + select { + case releaseSecond := <-acquiredSecond: + if err := releaseSecond(); err != nil { + t.Fatal(err) + } + case err := <-errSecond: + t.Fatalf("second writer failed: %v", err) + case <-time.After(time.Second): + t.Fatal("second writer did not acquire lock after release") + } +} diff --git a/pkg/github/testutil/fake_client.go b/pkg/github/testutil/fake_client.go index e7651ea0..50ab78d3 100644 --- a/pkg/github/testutil/fake_client.go +++ b/pkg/github/testutil/fake_client.go @@ -18,6 +18,7 @@ type FakeClient struct { CallsLatestStable int CallsListReleases int + CallsTarballURL int } func (f *FakeClient) GetLatestRelease() (*github.Release, error) { @@ -48,5 +49,6 @@ func (f *FakeClient) GetRelease(tag string) (*github.Release, error) { } func (f *FakeClient) GetReleaseTarballURL(version string) (string, error) { + f.CallsTarballURL++ return "", errors.New("not implemented") } diff --git a/sn-manager/cmd/check.go b/sn-manager/cmd/check.go index 4910eb06..5c6ab53f 100644 --- a/sn-manager/cmd/check.go +++ b/sn-manager/cmd/check.go @@ -8,6 +8,7 @@ import ( "github.com/LumeraProtocol/supernode/v2/sn-manager/internal/config" "github.com/LumeraProtocol/supernode/v2/sn-manager/internal/updater" "github.com/LumeraProtocol/supernode/v2/sn-manager/internal/utils" + "github.com/LumeraProtocol/supernode/v2/sn-manager/internal/version" "github.com/spf13/cobra" ) @@ -35,14 +36,23 @@ func runCheck(cmd *cobra.Command, args []string) error { // Create GitHub client client := github.NewClient(config.GitHubRepo) - // Get latest stable release - release, err := client.GetLatestStableRelease() + // Select the same release channel as automatic updates. + snapshot, err := utils.ReadSupernodeUpdateSnapshot() if err != nil { - return fmt.Errorf("failed to check for stable updates: %w", err) + return fmt.Errorf("failed to read SuperNode update configuration: %w", err) + } + release, err := utils.LatestReleaseForChainID(client, snapshot.ChainID) + if err != nil { + return fmt.Errorf("failed to check updates for chain %q: %w", snapshot.ChainID, err) + } + managerHome := config.GetManagerHome() + currentVersion, err := version.NewManager(managerHome).GetCurrentVersion() + if err != nil { + return fmt.Errorf("failed to read active SuperNode version: %w", err) } fmt.Printf("\nLatest release: %s\n", release.TagName) - fmt.Printf("Current version: %s\n", cfg.Updates.CurrentVersion) + fmt.Printf("Current version: %s\n", currentVersion) // Report manager version and if it would update under the same policy mv := strings.TrimSpace(appVersion) if mv != "" && mv != "dev" && !strings.EqualFold(mv, "unknown") { @@ -53,21 +63,20 @@ func runCheck(cmd *cobra.Command, args []string) error { } // Compare versions - cmp := utils.CompareVersions(cfg.Updates.CurrentVersion, release.TagName) + cmp := utils.CompareVersions(currentVersion, release.TagName) if cmp < 0 { - // Use the same logic as auto-updater to determine update eligibility - managerHome := config.GetManagerHome() + // Use the same logic as auto-updater to determine update eligibility. autoUpdater := updater.New(managerHome, cfg, appVersion, nil) - wouldAutoUpdate := autoUpdater.ShouldUpdate(cfg.Updates.CurrentVersion, release.TagName) + wouldAutoUpdate := autoUpdater.ShouldUpdate(currentVersion, release.TagName) if wouldAutoUpdate { - fmt.Printf("\n✓ Update available: %s → %s\n", cfg.Updates.CurrentVersion, release.TagName) + fmt.Printf("\n✓ Update available: %s → %s\n", currentVersion, release.TagName) fmt.Printf("Published: %s\n", release.PublishedAt.Format("2006-01-02 15:04:05")) fmt.Println("\n✓ This update will be applied automatically if auto-upgrade is enabled") fmt.Println(" Or manually with: sn-manager get") } else { - fmt.Printf("\n⚠ Major update available: %s → %s\n", cfg.Updates.CurrentVersion, release.TagName) + fmt.Printf("\n⚠ Major update available: %s → %s\n", currentVersion, release.TagName) fmt.Printf("Published: %s\n", release.PublishedAt.Format("2006-01-02 15:04:05")) fmt.Println("\n⚠ Major version updates require manual installation:") fmt.Printf(" sn-manager get %s\n", release.TagName) @@ -80,9 +89,9 @@ func runCheck(cmd *cobra.Command, args []string) error { fmt.Println(release.Body) } } else if cmp == 0 { - fmt.Println("\n✓ You are running the latest stable version") + fmt.Println("\n✓ You are running the latest release for this chain") } else { - fmt.Printf("\n⚠ You are running a newer version than the latest stable release\n") + fmt.Printf("\n⚠ You are running a newer version than the latest release for this chain\n") } return nil diff --git a/sn-manager/cmd/get.go b/sn-manager/cmd/get.go index 7244c10f..6a30bb31 100644 --- a/sn-manager/cmd/get.go +++ b/sn-manager/cmd/get.go @@ -31,9 +31,13 @@ func runGet(cmd *cobra.Command, args []string) error { var targetVersion string if len(args) == 0 { - release, err := client.GetLatestStableRelease() + snapshot, err := utils.ReadSupernodeUpdateSnapshot() if err != nil { - return fmt.Errorf("failed to get latest release: %w", err) + return fmt.Errorf("failed to read SuperNode update configuration: %w", err) + } + release, err := utils.LatestReleaseForChainID(client, snapshot.ChainID) + if err != nil { + return fmt.Errorf("failed to get latest release for chain %q: %w", snapshot.ChainID, err) } targetVersion = release.TagName } else { @@ -42,6 +46,16 @@ func runGet(cmd *cobra.Command, args []string) error { fmt.Printf("Target version: %s\n", targetVersion) + releaseInstallLock, err := versionMgr.AcquireInstallLock() + if err != nil { + return fmt.Errorf("failed to lock release installation: %w", err) + } + defer func() { + if releaseErr := releaseInstallLock(); releaseErr != nil { + log.Printf("Warning: failed to release installation lock: %v", releaseErr) + } + }() + if versionMgr.IsVersionInstalled(targetVersion) { fmt.Printf("Already installed\n") return nil diff --git a/sn-manager/cmd/init.go b/sn-manager/cmd/init.go index cebbeb83..4fc3f4c2 100644 --- a/sn-manager/cmd/init.go +++ b/sn-manager/cmd/init.go @@ -6,12 +6,14 @@ import ( "os" "os/exec" "path/filepath" + "strings" "github.com/AlecAivazis/survey/v2" "github.com/LumeraProtocol/supernode/v2/pkg/github" "github.com/LumeraProtocol/supernode/v2/sn-manager/internal/config" "github.com/LumeraProtocol/supernode/v2/sn-manager/internal/utils" "github.com/LumeraProtocol/supernode/v2/sn-manager/internal/version" + snconfig "github.com/LumeraProtocol/supernode/v2/supernode/config" "github.com/spf13/cobra" ) @@ -34,9 +36,44 @@ type initFlags struct { force bool autoUpgrade bool nonInteractive bool + chainID string supernodeArgs []string } +func chainIDFromInitArgs(args []string) string { + var chainID string + for i := 0; i < len(args); i++ { + if args[i] == "--chain-id" && i+1 < len(args) { + chainID = strings.TrimSpace(args[i+1]) + i++ + continue + } + if strings.HasPrefix(args[i], "--chain-id=") { + chainID = strings.TrimSpace(strings.TrimPrefix(args[i], "--chain-id=")) + } + } + return chainID +} + +func resolveInitChainID(flags *initFlags) error { + explicitChainID := flags.chainID + snapshot, err := utils.ReadSupernodeUpdateSnapshot() + if err != nil { + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("cannot determine release channel from existing SuperNode config: %w", err) + } + if explicitChainID != "" && explicitChainID != snapshot.ChainID { + return fmt.Errorf("--chain-id %q conflicts with existing SuperNode config chain_id %q", explicitChainID, snapshot.ChainID) + } + flags.chainID = snapshot.ChainID + if explicitChainID == "" { + flags.supernodeArgs = append(flags.supernodeArgs, "--chain-id", flags.chainID) + } + return nil +} + func parseInitFlags(args []string) *initFlags { flags := &initFlags{ autoUpgrade: true, @@ -66,11 +103,16 @@ func parseInitFlags(args []string) *initFlags { } } + flags.chainID = chainIDFromInitArgs(flags.supernodeArgs) return flags } func promptForManagerConfig(flags *initFlags) error { if flags.nonInteractive { + if flags.chainID == "" { + flags.chainID = snconfig.DefaultChainID + flags.supernodeArgs = append(flags.supernodeArgs, "--chain-id", flags.chainID) + } return nil } @@ -90,6 +132,18 @@ func promptForManagerConfig(flags *initFlags) error { } flags.autoUpgrade = (autoUpgradeChoice == autoUpgradeOptions[0]) + if flags.chainID == "" { + chainID := snconfig.DefaultChainID + if err := survey.AskOne(&survey.Input{ + Message: "Lumera chain ID:", + Default: snconfig.DefaultChainID, + }, &chainID, survey.WithValidator(survey.Required)); err != nil { + return err + } + flags.chainID = strings.TrimSpace(chainID) + flags.supernodeArgs = append(flags.supernodeArgs, "--chain-id", flags.chainID) + } + // No interval prompt; check interval is fixed at 10 minutes. return nil @@ -104,8 +158,12 @@ func runInit(cmd *cobra.Command, args []string) error { } } - // Parse flags + // Parse flags and reconcile the requested channel with any existing + // SuperNode configuration before selecting a release. flags := parseInitFlags(args) + if err := resolveInitChainID(flags); err != nil { + return err + } // Step 1: Initialize sn-manager fmt.Println("Step 1: Initializing sn-manager...") @@ -166,15 +224,29 @@ func runInit(cmd *cobra.Command, args []string) error { versionMgr := version.NewManager(managerHome) client := github.NewClient(config.GitHubRepo) - // Get latest stable release - release, err := client.GetLatestStableRelease() + // Select the release channel from the same chain ID forwarded to + // `supernode init`; testnet initialization must never bootstrap stable. + release, err := utils.LatestReleaseForChainID(client, flags.chainID) if err != nil { - return fmt.Errorf("failed to get latest stable release: %w", err) + return fmt.Errorf("failed to get latest release for chain %q: %w", flags.chainID, err) } targetVersion := release.TagName fmt.Printf("Latest version: %s\n", targetVersion) + releaseInstallLock, err := versionMgr.AcquireInstallLock() + if err != nil { + return fmt.Errorf("failed to lock release installation: %w", err) + } + installLockReleased := false + defer func() { + if !installLockReleased { + if releaseErr := releaseInstallLock(); releaseErr != nil { + log.Printf("Warning: failed to release installation lock: %v", releaseErr) + } + } + }() + // Check if already installed if versionMgr.IsVersionInstalled(targetVersion) { fmt.Printf("✓ SuperNode %s already installed, skipping download\n", targetVersion) @@ -225,6 +297,10 @@ func runInit(cmd *cobra.Command, args []string) error { if err := config.Save(cfg, configPath); err != nil { return fmt.Errorf("failed to update config: %w", err) } + installLockReleased = true + if err := releaseInstallLock(); err != nil { + return fmt.Errorf("failed to release installation lock: %w", err) + } fmt.Printf("✓ SuperNode %s ready\n", targetVersion) diff --git a/sn-manager/cmd/init_test.go b/sn-manager/cmd/init_test.go new file mode 100644 index 00000000..9511f656 --- /dev/null +++ b/sn-manager/cmd/init_test.go @@ -0,0 +1,77 @@ +package cmd + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestChainIDFromInitArgs(t *testing.T) { + cases := []struct { + name string + args []string + want string + }{ + {"separate", []string{"--chain-id", "lumera-testnet-2", "--yes"}, "lumera-testnet-2"}, + {"equals", []string{"--chain-id=lumera-mainnet-1"}, "lumera-mainnet-1"}, + {"absent", []string{"--yes"}, ""}, + {"last wins", []string{"--chain-id", "old", "--chain-id=new"}, "new"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := chainIDFromInitArgs(tc.args); got != tc.want { + t.Fatalf("chainIDFromInitArgs(%v) = %q, want %q", tc.args, got, tc.want) + } + }) + } +} + +func writeExistingInitConfig(t *testing.T, chainID string) { + t.Helper() + home := t.TempDir() + t.Setenv("HOME", home) + dir := filepath.Join(home, ".supernode") + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "config.yml"), []byte("lumera:\n chain_id: "+chainID+"\n"), 0o600); err != nil { + t.Fatal(err) + } +} + +func TestResolveInitChainIDUsesExistingConfig(t *testing.T) { + writeExistingInitConfig(t, "lumera-testnet-2") + flags := parseInitFlags([]string{"--yes"}) + if err := resolveInitChainID(flags); err != nil { + t.Fatal(err) + } + if flags.chainID != "lumera-testnet-2" { + t.Fatalf("chain ID = %q", flags.chainID) + } + if got := chainIDFromInitArgs(flags.supernodeArgs); got != flags.chainID { + t.Fatalf("forwarded chain ID = %q, selected = %q", got, flags.chainID) + } +} + +func TestResolveInitChainIDRejectsExistingConfigMismatch(t *testing.T) { + writeExistingInitConfig(t, "lumera-testnet-2") + flags := parseInitFlags([]string{"--chain-id=lumera-mainnet-1", "--yes"}) + err := resolveInitChainID(flags) + if err == nil || !strings.Contains(err.Error(), "conflicts") { + t.Fatalf("expected chain conflict, got %v", err) + } +} + +func TestPromptForManagerConfigNonInteractiveForwardsDefaultChainID(t *testing.T) { + flags := parseInitFlags([]string{"--yes"}) + if err := promptForManagerConfig(flags); err != nil { + t.Fatal(err) + } + if flags.chainID == "" { + t.Fatal("non-interactive init must resolve a release channel") + } + if got := chainIDFromInitArgs(flags.supernodeArgs); got != flags.chainID { + t.Fatalf("forwarded chain ID = %q, selected = %q", got, flags.chainID) + } +} diff --git a/sn-manager/cmd/start.go b/sn-manager/cmd/start.go index 6deb1583..ca823765 100644 --- a/sn-manager/cmd/start.go +++ b/sn-manager/cmd/start.go @@ -11,6 +11,7 @@ import ( "strings" "syscall" + "github.com/LumeraProtocol/supernode/v2/pkg/configlock" "github.com/LumeraProtocol/supernode/v2/pkg/github" "github.com/LumeraProtocol/supernode/v2/sn-manager/internal/config" "github.com/LumeraProtocol/supernode/v2/sn-manager/internal/manager" @@ -216,11 +217,15 @@ func ensureBinaryExists(home string, cfg *config.Config) error { // 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 { + // Automatic startup may initialize a missing symlink, but must not + // overwrite a concurrently selected newer version. + if _, err := versionMgr.SetCurrentVersionIfNewer(versions[0]); err != nil { return fmt.Errorf("failed to set current version: %w", err) } - current = versions[0] + current, err = versionMgr.GetCurrentVersion() + if err != nil { + return fmt.Errorf("failed to read current version after activation: %w", err) + } } // Update config if current version is not set or different @@ -234,13 +239,37 @@ func ensureBinaryExists(home string, cfg *config.Config) error { return nil } - // No versions installed, download latest tarball and extract supernode + // No versions installed. Serialize release staging before taking the config + // lock; all update paths use install -> config -> version -> symlink order. + releaseInstallLock, err := versionMgr.AcquireInstallLock() + if err != nil { + return fmt.Errorf("failed to lock release installation: %w", err) + } + defer func() { + if releaseErr := releaseInstallLock(); releaseErr != nil { + log.Printf("Warning: failed to release installation lock: %v", releaseErr) + } + }() + + // Hold the shared SuperNode config lock while selecting the network channel + // and installing the first binary. + releaseConfigLock, err := configlock.Acquire(utils.SupernodeConfigPath()) + if err != nil { + return fmt.Errorf("failed to lock SuperNode config: %w", err) + } + defer func() { _ = releaseConfigLock() }() + + snapshot, err := utils.ReadSupernodeUpdateSnapshot() + if err != nil { + return fmt.Errorf("failed to read SuperNode update configuration: %w", err) + } + fmt.Println("No SuperNode binary found. Downloading latest version...") client := github.NewClient(config.GitHubRepo) - release, err := client.GetLatestStableRelease() + release, err := utils.LatestReleaseForChainID(client, snapshot.ChainID) if err != nil { - return fmt.Errorf("failed to get latest stable release: %w", err) + return fmt.Errorf("failed to get latest release for chain %q: %w", snapshot.ChainID, err) } targetVersion := release.TagName @@ -284,13 +313,18 @@ func ensureBinaryExists(home string, cfg *config.Config) error { log.Printf("Warning: failed to remove temp file: %v", err) } - // Set as current version - if err := versionMgr.SetCurrentVersion(targetVersion); err != nil { + // Automatic startup initializes a missing current symlink but never + // overwrites an equal or newer version selected concurrently. + if _, err := versionMgr.SetCurrentVersionIfNewer(targetVersion); err != nil { return fmt.Errorf("failed to set current version: %w", err) } + currentVersion, err := versionMgr.GetCurrentVersion() + if err != nil { + return fmt.Errorf("failed to read current version after activation: %w", err) + } - // Update config - cfg.Updates.CurrentVersion = targetVersion + // Update config from the authoritative active symlink. + cfg.Updates.CurrentVersion = currentVersion configPath := filepath.Join(home, "config.yml") if err := config.Save(cfg, configPath); err != nil { return fmt.Errorf("failed to save config: %w", err) diff --git a/sn-manager/internal/updater/preflight.go b/sn-manager/internal/updater/preflight.go index 8001e8cd..db5a4d70 100644 --- a/sn-manager/internal/updater/preflight.go +++ b/sn-manager/internal/updater/preflight.go @@ -25,6 +25,9 @@ const ( // preflightBlock: the target version requires evm_key_name but the node // is not prepared to migrate. Refuse to install; keep the current binary. preflightBlock + // preflightUnknown: the chain/config probe was inconclusive. The updater + // may fail open only when no unchanged, previously confirmed block exists. + preflightUnknown ) func (d preflightDecision) String() string { @@ -33,6 +36,8 @@ func (d preflightDecision) String() string { return "allow" case preflightBlock: return "block" + case preflightUnknown: + return "unknown" } return "unknown" } @@ -82,8 +87,8 @@ const chainEVMProbeTimeout = 15 * time.Second // (false, nil) — chain answered and EVM module is absent // (false, err) — query failed (dial error, timeout, gRPC error) // -// The caller MUST treat the error case as "unknown" and fail-open (allow) — -// a transient chain outage must never block an update. +// The caller treats the error case as "unknown". Unknown may fail open only +// when no confirmed compatibility block exists; confirmed unsafe evidence wins. func queryEVMModuleActive(ctx context.Context, grpcAddr string) (bool, error) { if strings.TrimSpace(grpcAddr) == "" { return false, fmt.Errorf("empty grpc_addr") @@ -151,25 +156,20 @@ func parseGRPCAddr(raw string) (host string, useTLS bool, err error) { return host, useTLS, nil } -// preflightCheck loads the current runtime state (chain evm status, supernode -// evm_key_name, current installed version) and returns a decision for the -// proposed target version. On chain-query failure it FAILS OPEN (returns -// allow) — a transient chain outage must never block an update. -func (u *AutoUpdater) preflightCheck(ctx context.Context, targetVersion string) (preflightDecision, string) { - grpcAddr, _ := utils.ReadSupernodeGRPCAddr() - hasEVM, err := queryEVMModuleActive(ctx, grpcAddr) +// preflightCheck evaluates a coherent SuperNode config snapshot captured before +// release-channel selection. On chain-query failure it returns unknown so the +// caller can preserve a previously confirmed block; otherwise the existing +// fail-open policy still applies when no prior block exists. +func (u *AutoUpdater) preflightCheck(ctx context.Context, targetVersion, currentVersion string, snapshot utils.SupernodeUpdateSnapshot) (preflightDecision, string) { + hasEVM, err := queryEVMModuleActive(ctx, snapshot.GRPCAddr) if err != nil { - log.Printf("preflight: chain evm probe failed (fail-open): %v", err) - return preflightAllow, "chain unreachable; fail-open" + log.Printf("preflight: chain evm probe failed (inconclusive): %v", err) + return preflightUnknown, "chain unreachable; compatibility unknown" } - evmKey, _ := utils.ReadSupernodeEVMKeyName() - - in := preflightInputs{ + return decidePreflight(preflightInputs{ chainHasEVM: hasEVM, - evmKeyName: evmKey, - currentVersion: u.config.Updates.CurrentVersion, + evmKeyName: snapshot.EVMKeyName, + currentVersion: currentVersion, targetVersion: targetVersion, - } - decision, reason := decidePreflight(in) - return decision, reason + }) } diff --git a/sn-manager/internal/updater/preflight_state.go b/sn-manager/internal/updater/preflight_state.go index ea7743b5..46d4a4a8 100644 --- a/sn-manager/internal/updater/preflight_state.go +++ b/sn-manager/internal/updater/preflight_state.go @@ -62,6 +62,18 @@ func readBlockLogMTime(homeDir string) (time.Time, bool) { return time.Time{}, false } +// shouldPreserveBlockOnUnknown returns true whenever a previously confirmed +// block exists. A config change triggers re-evaluation, but an inconclusive +// result is not evidence of remediation; only a definitive allow clears it. +func confirmedBlockWins(homeDir string, allowWasUnknown bool) bool { + return allowWasUnknown && shouldPreserveBlockOnUnknown(homeDir) +} + +func shouldPreserveBlockOnUnknown(homeDir string) bool { + _, err := os.Stat(BlockLogPath(homeDir)) + return err == nil || !os.IsNotExist(err) +} + func splitLines(s string) []string { var out []string start := 0 diff --git a/sn-manager/internal/updater/preflight_state_test.go b/sn-manager/internal/updater/preflight_state_test.go index 130716e0..e1920cd8 100644 --- a/sn-manager/internal/updater/preflight_state_test.go +++ b/sn-manager/internal/updater/preflight_state_test.go @@ -1,6 +1,7 @@ package updater import ( + "os" "testing" "time" ) @@ -27,3 +28,44 @@ func TestWriteReadBlockLogMTime(t *testing.T) { t.Fatalf("expected block log to be cleared") } } + +func TestShouldPreserveBlockOnUnknown(t *testing.T) { + home := t.TempDir() + if shouldPreserveBlockOnUnknown(home) { + t.Fatal("unknown may fail open when no prior block exists") + } + blockedMTime := time.Date(2026, 7, 22, 10, 0, 0, 0, time.UTC) + if err := writeBlockLog(home, "unprepared", "v2.6.1-testnet", blockedMTime); err != nil { + t.Fatal(err) + } + + if !shouldPreserveBlockOnUnknown(home) { + t.Fatal("an existing confirmed block must survive an inconclusive probe") + } +} + +func TestConfirmedBlockPublishedAfterUnknownWins(t *testing.T) { + home := t.TempDir() + if confirmedBlockWins(home, true) { + t.Fatal("unknown may initially fail open without confirmed evidence") + } + if err := writeBlockLog(home, "confirmed concurrently", "v2.6.1-testnet", time.Date(2026, 7, 22, 12, 0, 0, 0, time.UTC)); err != nil { + t.Fatal(err) + } + if !confirmedBlockWins(home, true) { + t.Fatal("confirmed block published after unknown must win before mutation") + } + if confirmedBlockWins(home, false) { + t.Fatal("a definitive allow may clear prior evidence") + } +} + +func TestShouldPreserveBlockOnUnknownWithMalformedMarker(t *testing.T) { + home := t.TempDir() + if err := os.WriteFile(BlockLogPath(home), []byte("truncated"), 0o644); err != nil { + t.Fatal(err) + } + if !shouldPreserveBlockOnUnknown(home) { + t.Fatal("a malformed existing marker must fail closed on an unknown probe") + } +} diff --git a/sn-manager/internal/updater/preflight_test.go b/sn-manager/internal/updater/preflight_test.go index 2854d591..38fb30be 100644 --- a/sn-manager/internal/updater/preflight_test.go +++ b/sn-manager/internal/updater/preflight_test.go @@ -16,6 +16,7 @@ func TestDecidePreflight_Table(t *testing.T) { {"evm no-key forward to 2.6", preflightInputs{true, "", "v2.5.0-testnet", "v2.6.0-testnet"}, preflightBlock}, {"evm migrated config at 2.6 omits transitional key", preflightInputs{true, "", "v2.6.0-testnet", "v2.6.0-testnet"}, preflightAllow}, {"evm migrated config can update beyond 2.6", preflightInputs{true, "", "v2.6.0-testnet", "v2.6.1-testnet"}, preflightAllow}, + {"evm-capable current with older target is not an update decision", preflightInputs{true, "", "v2.6.1-testnet", "v2.5.0-testnet"}, preflightAllow}, {"evm prepared forward to 2.6", preflightInputs{true, "evm-key", "v2.5.0-testnet", "v2.6.0-testnet"}, preflightAllow}, {"evm prepared at 2.6", preflightInputs{true, "evm-key", "v2.6.0-testnet", "v2.6.0-testnet"}, preflightAllow}, {"evm no-key below threshold 2.5.1", preflightInputs{true, "", "v2.5.0-testnet", "v2.5.1-testnet"}, preflightAllow}, @@ -69,3 +70,12 @@ func TestQueryEVMModuleActive_FailOpenOnEmpty(t *testing.T) { t.Fatalf("expected error for empty grpc_addr") } } + +func TestPreflightUnknownIsDistinctFromAllow(t *testing.T) { + if preflightUnknown == preflightAllow { + t.Fatal("inconclusive chain probes must not be represented as allow") + } + if got := preflightUnknown.String(); got != "unknown" { + t.Fatalf("preflightUnknown.String() = %q, want unknown", got) + } +} diff --git a/sn-manager/internal/updater/updater.go b/sn-manager/internal/updater/updater.go index 289d7523..5054cbc2 100644 --- a/sn-manager/internal/updater/updater.go +++ b/sn-manager/internal/updater/updater.go @@ -12,6 +12,7 @@ import ( "time" pb "github.com/LumeraProtocol/supernode/v2/gen/supernode" + "github.com/LumeraProtocol/supernode/v2/pkg/configlock" "github.com/LumeraProtocol/supernode/v2/pkg/github" "github.com/LumeraProtocol/supernode/v2/sn-manager/internal/config" "github.com/LumeraProtocol/supernode/v2/sn-manager/internal/utils" @@ -196,15 +197,27 @@ func (u *AutoUpdater) ForceSyncToLatest(_ context.Context) { u.checkAndUpdateCombined(true) } +// shouldForceUpdate keeps forced/startup synchronization monotonic. Force may +// bypass idleness and same-major policy, but it is never authorization to +// replace a binary with an equal or older release. Explicit `get`/`use` remain +// the operator-controlled version-selection path. +func shouldForceUpdate(current, target string) bool { + return utils.CompareVersions(current, target) < 0 +} + // checkAndUpdateCombined performs a single release check and, if needed, // downloads the release tarball once to update sn-manager and SuperNode. // If force is true, bypass gateway idleness and version policy checks. func (u *AutoUpdater) checkAndUpdateCombined(force bool) { - chainID, _ := utils.ReadSupernodeChainID() + snapshot, err := utils.ReadSupernodeUpdateSnapshot() + if err != nil { + log.Printf("Cannot read SuperNode update configuration; automatic update aborted: %v", err) + return + } // Fetch latest release once (testnet prefers "-testnet" tags, otherwise stable) - release, err := utils.LatestReleaseForChainID(u.githubClient, chainID) + release, err := utils.LatestReleaseForChainID(u.githubClient, snapshot.ChainID) if err != nil { log.Printf("Failed to check releases: %v", err) return @@ -228,48 +241,68 @@ func (u *AutoUpdater) checkAndUpdateCombined(force bool) { // Already-EVM-capable versions proceed normally: the supernode startup path // is the authority for active-key type and migration validation. // - // The branch only fires when the chain has the evm module active. - // A chain-query failure fails OPEN (allow) so a transient outage cannot - // itself block an update. See preflight.go. + // A chain-query failure is inconclusive. It fails open only when there is no + // previously confirmed compatibility block; known unsafe evidence wins over + // unknown evidence until a definitive allow clears the marker. // - // The forward-block branch is the ONLY auto-update forward path this - // preflight gates. Operator-explicit code paths in cmd/{start,init,use,get}.go - // intentionally bypass the preflight — the operator is asking for a - // specific version and has accepted the consequences. + // Automatic updates (including cmd/start.go's forced startup sync) pass this + // gate. Only operator-explicit init/use/get version-selection paths bypass it. + // Use the active symlink, not the cached manager config, for compatibility + // and version-order decisions. + currentSN, err := u.versionMgr.GetCurrentVersion() + if err != nil { + log.Printf("Cannot determine active SuperNode version; automatic update aborted: %v", err) + return + } + pfCtx, pfCancel := context.WithTimeout(context.Background(), chainEVMProbeTimeout+2*time.Second) - decision, reason := u.preflightCheck(pfCtx, latest) + decision, reason := u.preflightCheck(pfCtx, latest, currentSN, snapshot) pfCancel() + allowWasUnknown := false + if decision == preflightUnknown { + // Unknown evidence can never erase a previously confirmed block. Config + // changes trigger a new probe, but only a definitive allow may clear it. + if shouldPreserveBlockOnUnknown(u.homeDir) { + log.Printf("update to %s remains blocked (compatibility probe inconclusive): %s", latest, reason) + return + } + log.Printf("preflight compatibility unknown for %s; no prior block, applying fail-open policy", latest) + allowWasUnknown = true + decision = preflightAllow + } + if decision == preflightBlock { - // Sticky: don't retry until the operator changes ~/.supernode/config.yml. + // Persist confirmed unsafe evidence while coordinated with config writers. + // Even if the config changed after the probe, retain a sticky block until + // a later definitive allow is validated against a current snapshot. + releaseConfigLock, lockErr := configlock.Acquire(utils.SupernodeConfigPath()) + if lockErr != nil { + log.Printf("Cannot lock SuperNode config to persist update block: %v", lockErr) + return + } snCfgMTime, _ := utils.SupernodeConfigMTime() - if prevMTime, ok := readBlockLogMTime(u.homeDir); ok { - if snCfgMTime.Equal(prevMTime) { - log.Printf("update to %s blocked (config unchanged since previous block): %s", latest, reason) - return - } - // mtime advanced — operator has touched the config; clear the - // sticky marker and re-evaluate on this pass. The current - // decision was made against the CURRENT config state, so if - // we're still in block state now, re-record. + if prevMTime, ok := readBlockLogMTime(u.homeDir); ok && snCfgMTime.Equal(prevMTime) { + _ = releaseConfigLock() + log.Printf("update to %s blocked (config unchanged since previous block): %s", latest, reason) + return } if err := writeBlockLog(u.homeDir, reason, latest, snCfgMTime); err != nil { log.Printf("failed to write block log: %v", err) } + if err := releaseConfigLock(); err != nil { + log.Printf("Failed to release SuperNode config lock: %v", err) + } log.Printf("update to %s blocked: %s", latest, reason) return } - // decision == preflightAllow: if a stale block-log exists from a - // previous cycle, clear it so `sn-manager status` reflects reality. - clearBlockLog(u.homeDir) - // Determine if sn-manager should update (same criteria: stable, same major) managerNeedsUpdate := false ver := strings.TrimSpace(u.managerVersion) if ver != "" && ver != "dev" && !strings.EqualFold(ver, "unknown") { if force { - managerNeedsUpdate = !strings.EqualFold(ver, latest) + managerNeedsUpdate = shouldForceUpdate(ver, latest) } else { if utils.SameMajor(ver, latest) && utils.CompareVersions(ver, latest) < 0 { managerNeedsUpdate = true @@ -277,16 +310,43 @@ func (u *AutoUpdater) checkAndUpdateCombined(force bool) { } } - // Determine if SuperNode should update using existing policy - currentSN := u.config.Updates.CurrentVersion + // Determine whether the active SuperNode symlink should update. The manager + // config is only a cache and may be stale after an explicit `sn-manager use`. + currentSN, err = u.versionMgr.GetCurrentVersion() + if err != nil { + log.Printf("Cannot determine active SuperNode version; automatic update aborted: %v", err) + return + } supernodeNeedsUpdate := false if force { - supernodeNeedsUpdate = !strings.EqualFold(strings.TrimPrefix(currentSN, "v"), strings.TrimPrefix(latest, "v")) + supernodeNeedsUpdate = shouldForceUpdate(currentSN, latest) } else { supernodeNeedsUpdate = u.ShouldUpdate(currentSN, latest) } if !managerNeedsUpdate && !supernodeNeedsUpdate { + // A definitive allow may clear prior blocked evidence only after proving + // that the config used by the probe is still current under the writer lock. + releaseConfigLock, lockErr := configlock.Acquire(utils.SupernodeConfigPath()) + if lockErr != nil { + log.Printf("Cannot lock SuperNode config to clear update block: %v", lockErr) + return + } + configCurrent, currentErr := utils.IsCurrentSupernodeConfig(snapshot) + newBlockWon := confirmedBlockWins(u.homeDir, allowWasUnknown) + if currentErr == nil && configCurrent && !newBlockWon { + clearBlockLog(u.homeDir) + } + if err := releaseConfigLock(); err != nil { + log.Printf("Failed to release SuperNode config lock: %v", err) + } + if newBlockWon { + log.Printf("compatibility probe was inconclusive; retaining concurrently confirmed update block") + } else if currentErr != nil { + log.Printf("Cannot revalidate SuperNode config; retaining update block: %v", currentErr) + } else if !configCurrent { + log.Printf("SuperNode config changed during update check; retaining update block for re-evaluation") + } return } @@ -304,6 +364,19 @@ func (u *AutoUpdater) checkAndUpdateCombined(force bool) { } } + // Serialize all release staging paths across sn-manager processes. Lock order + // is install -> SuperNode config -> per-version install -> active symlink. + releaseInstallLock, err := u.versionMgr.AcquireInstallLock() + if err != nil { + log.Printf("Cannot lock release installation; automatic update aborted: %v", err) + return + } + defer func() { + if err := releaseInstallLock(); err != nil { + log.Printf("Failed to release installation lock: %v", err) + } + }() + // Download the combined release tarball once tarURL, err := u.githubClient.GetReleaseTarballURL(latest) if err != nil { @@ -356,6 +429,51 @@ func (u *AutoUpdater) checkAndUpdateCombined(force bool) { extractedManager := managerNeedsUpdate && found["sn-manager"] extractedSN := supernodeNeedsUpdate && found["supernode"] + // Coordinate with SuperNode's config writer, then revalidate the snapshot. + // Holding this lock through both binary mutations closes the check/use race + // with migration's post-DeliverTx config rewrite. + releaseConfigLock, err := configlock.Acquire(utils.SupernodeConfigPath()) + if err != nil { + _ = os.Remove(tmpManager) + _ = os.Remove(tmpSN) + log.Printf("Cannot lock SuperNode config; automatic update aborted: %v", err) + return + } + defer func() { + if err := releaseConfigLock(); err != nil { + log.Printf("Failed to release SuperNode config lock: %v", err) + } + }() + + // Release selection and compatibility preflight were made from snapshot. + // Abort before either binary is mutated if an operator/process rewrote the + // SuperNode config while the release was downloading. + configCurrent, err := utils.IsCurrentSupernodeConfig(snapshot) + if err != nil || !configCurrent { + _ = os.Remove(tmpManager) + _ = os.Remove(tmpSN) + if err != nil { + log.Printf("Cannot revalidate SuperNode config; automatic update aborted: %v", err) + } else { + log.Printf("SuperNode config changed during update check; automatic update aborted for re-evaluation") + } + return + } + + // Unknown evidence cannot override a definitive block published by another + // process after this process's initial marker check. The config lock makes + // this marker check atomic with the following clear and binary mutations. + if confirmedBlockWins(u.homeDir, allowWasUnknown) { + _ = os.Remove(tmpManager) + _ = os.Remove(tmpSN) + log.Printf("compatibility probe was inconclusive; concurrently confirmed update block wins") + return + } + + // The definitive allow and release selection are still based on the locked, + // current snapshot; it is now safe to clear previously blocked evidence. + clearBlockLog(u.homeDir) + // Apply sn-manager update first managerUpdated := false if managerNeedsUpdate { @@ -382,8 +500,11 @@ func (u *AutoUpdater) checkAndUpdateCombined(force bool) { 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 { + activated, err := u.versionMgr.SetCurrentVersionIfNewer(latest) + if err != nil { log.Printf("Failed to activate SuperNode %s: %v", latest, err) + } else if !activated { + log.Printf("Skipped activating SuperNode %s because the active version is equal or newer", latest) } else { u.config.Updates.CurrentVersion = latest if err := config.Save(u.config, filepath.Join(u.homeDir, "config.yml")); err != nil { diff --git a/sn-manager/internal/updater/updater_test.go b/sn-manager/internal/updater/updater_test.go index bfb3dae4..acab13a8 100644 --- a/sn-manager/internal/updater/updater_test.go +++ b/sn-manager/internal/updater/updater_test.go @@ -1,6 +1,15 @@ package updater -import "testing" +import ( + "os" + "path/filepath" + "testing" + + "github.com/LumeraProtocol/supernode/v2/pkg/github" + githubtestutil "github.com/LumeraProtocol/supernode/v2/pkg/github/testutil" + managerconfig "github.com/LumeraProtocol/supernode/v2/sn-manager/internal/config" + "github.com/LumeraProtocol/supernode/v2/sn-manager/internal/version" +) func TestShouldUpdate_TestnetTagAdvances(t *testing.T) { u := &AutoUpdater{} @@ -48,3 +57,85 @@ func TestShouldUpdate_PrereleaseToStableSameBase(t *testing.T) { t.Fatalf("expected prerelease to stable update for same base") } } + +func TestShouldForceUpdate_OnlyMovesForward(t *testing.T) { + cases := []struct { + name string + current string + target string + want bool + }{ + {"upgrade", "v2.6.0-testnet", "v2.6.1-testnet", true}, + {"equal", "v2.6.1-testnet", "v2.6.1-testnet", false}, + {"downgrade", "v2.6.1-testnet", "v2.5.0-testnet", false}, + {"stable to prerelease", "v2.6.1", "v2.6.1-rc.1", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := shouldForceUpdate(tc.current, tc.target); got != tc.want { + t.Fatalf("shouldForceUpdate(%q, %q) = %v, want %v", tc.current, tc.target, got, tc.want) + } + }) + } +} + +func TestCheckAndUpdateCombined_ConfigReadFailureDoesNotSelectReleaseChannel(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + client := &githubtestutil.FakeClient{ + LatestStable: &github.Release{TagName: "v2.6.1"}, + } + u := &AutoUpdater{ + config: &managerconfig.Config{Updates: managerconfig.UpdateConfig{CurrentVersion: "v2.6.1"}}, + homeDir: t.TempDir(), + githubClient: client, + managerVersion: "v2.6.1", + } + + u.checkAndUpdateCombined(true) + + if client.CallsLatestStable != 0 || client.CallsListReleases != 0 { + t.Fatalf("config read failure must abort before release selection; stable=%d list=%d", client.CallsLatestStable, client.CallsListReleases) + } +} + +func TestCheckAndUpdateCombined_UsesActiveSymlinkNotStaleConfig(t *testing.T) { + supernodeHome := t.TempDir() + t.Setenv("HOME", supernodeHome) + if err := os.MkdirAll(filepath.Join(supernodeHome, ".supernode"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(supernodeHome, ".supernode", "config.yml"), []byte("lumera:\n chain_id: lumera-testnet-2\n"), 0o600); err != nil { + t.Fatal(err) + } + + managerHome := t.TempDir() + versionMgr := version.NewManager(managerHome) + source := filepath.Join(managerHome, "supernode") + if err := os.WriteFile(source, []byte("binary"), 0o755); err != nil { + t.Fatal(err) + } + if err := versionMgr.InstallVersion("v2.6.1-testnet", source); err != nil { + t.Fatal(err) + } + if err := versionMgr.SetCurrentVersion("v2.6.1-testnet"); err != nil { + t.Fatal(err) + } + + client := &githubtestutil.FakeClient{Releases: []*github.Release{{TagName: "v2.5.1-testnet"}}} + u := &AutoUpdater{ + config: &managerconfig.Config{Updates: managerconfig.UpdateConfig{CurrentVersion: "v2.5.0-testnet"}}, + homeDir: managerHome, + githubClient: client, + versionMgr: versionMgr, + managerVersion: "v2.6.1-testnet", + } + + u.checkAndUpdateCombined(true) + + if client.CallsTarballURL != 0 { + t.Fatal("automatic update used stale config and attempted to download an older release") + } + if active, err := versionMgr.GetCurrentVersion(); err != nil || active != "v2.6.1-testnet" { + t.Fatalf("active version = %q, err=%v", active, err) + } +} diff --git a/sn-manager/internal/utils/chain_id.go b/sn-manager/internal/utils/chain_id.go index 16d6c961..7f50ac9f 100644 --- a/sn-manager/internal/utils/chain_id.go +++ b/sn-manager/internal/utils/chain_id.go @@ -1,6 +1,7 @@ package utils import ( + "crypto/sha256" "fmt" "os" "path/filepath" @@ -35,6 +36,51 @@ type supernodeYAML struct { } `yaml:"lumera"` } +// SupernodeUpdateSnapshot is one coherent read of every SuperNode config field +// used to select and preflight an automatic update. Its digest lets the updater +// reject a decision if config changed while a release was downloaded. +type SupernodeUpdateSnapshot struct { + ChainID string + GRPCAddr string + EVMKeyName string + digest [sha256.Size]byte +} + +// ReadSupernodeUpdateSnapshot reads and validates update inputs from one file +// image. In particular, an unreadable or missing chain ID cannot silently +// select the mainnet/stable release channel. +func ReadSupernodeUpdateSnapshot() (SupernodeUpdateSnapshot, error) { + path := SupernodeConfigPath() + data, err := os.ReadFile(path) + if err != nil { + return SupernodeUpdateSnapshot{}, err + } + var cfg supernodeYAML + if err := yaml.Unmarshal(data, &cfg); err != nil { + return SupernodeUpdateSnapshot{}, fmt.Errorf("failed to parse supernode config %s: %w", path, err) + } + chainID := strings.TrimSpace(cfg.Lumera.ChainID) + if chainID == "" { + return SupernodeUpdateSnapshot{}, fmt.Errorf("chain_id not set in %s", path) + } + return SupernodeUpdateSnapshot{ + ChainID: chainID, + GRPCAddr: strings.TrimSpace(cfg.Lumera.GRPCAddr), + EVMKeyName: strings.TrimSpace(cfg.Supernode.EVMKeyName), + digest: sha256.Sum256(data), + }, nil +} + +// IsCurrentSupernodeConfig reports whether the config bytes still match the +// snapshot used for release-channel selection and compatibility preflight. +func IsCurrentSupernodeConfig(snapshot SupernodeUpdateSnapshot) (bool, error) { + data, err := os.ReadFile(SupernodeConfigPath()) + if err != nil { + return false, err + } + return sha256.Sum256(data) == snapshot.digest, nil +} + func readSupernodeYAML() (*supernodeYAML, string, error) { path := SupernodeConfigPath() data, err := os.ReadFile(path) diff --git a/sn-manager/internal/utils/chain_id_test.go b/sn-manager/internal/utils/chain_id_test.go index 6094306e..bca694cb 100644 --- a/sn-manager/internal/utils/chain_id_test.go +++ b/sn-manager/internal/utils/chain_id_test.go @@ -84,6 +84,42 @@ func TestReadSupernodeGRPCAddr(t *testing.T) { } } +func TestReadSupernodeUpdateSnapshotAndDetectReplacement(t *testing.T) { + tmp := t.TempDir() + t.Setenv("HOME", tmp) + cfgDir := filepath.Join(tmp, ".supernode") + if err := os.MkdirAll(cfgDir, 0o755); err != nil { + t.Fatal(err) + } + cfgPath := filepath.Join(cfgDir, "config.yml") + original := []byte("supernode:\n evm_key_name: evm-key\nlumera:\n chain_id: lumera-testnet-2\n grpc_addr: grpc.testnet.lumera.io:443\n") + if err := os.WriteFile(cfgPath, original, 0o644); err != nil { + t.Fatal(err) + } + + snapshot, err := ReadSupernodeUpdateSnapshot() + if err != nil { + t.Fatal(err) + } + if snapshot.ChainID != "lumera-testnet-2" || snapshot.GRPCAddr != "grpc.testnet.lumera.io:443" || snapshot.EVMKeyName != "evm-key" { + t.Fatalf("unexpected snapshot: %+v", snapshot) + } + if current, err := IsCurrentSupernodeConfig(snapshot); err != nil || !current { + t.Fatalf("expected current snapshot, current=%v err=%v", current, err) + } + + replacement := filepath.Join(cfgDir, "config.yml.new") + if err := os.WriteFile(replacement, []byte("lumera:\n chain_id: lumera-testnet-2\n grpc_addr: other:443\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Rename(replacement, cfgPath); err != nil { + t.Fatal(err) + } + if current, err := IsCurrentSupernodeConfig(snapshot); err != nil || current { + t.Fatalf("expected replacement to invalidate snapshot, current=%v err=%v", current, err) + } +} + func TestReadSupernodeChainID_MissingOrEmpty(t *testing.T) { tmp := t.TempDir() t.Setenv("HOME", tmp) diff --git a/sn-manager/internal/version/manager.go b/sn-manager/internal/version/manager.go index 5aa361b1..3900c608 100644 --- a/sn-manager/internal/version/manager.go +++ b/sn-manager/internal/version/manager.go @@ -1,6 +1,7 @@ package version import ( + "errors" "fmt" "io" "log" @@ -8,9 +9,12 @@ import ( "path/filepath" "sort" + "github.com/LumeraProtocol/supernode/v2/pkg/configlock" "github.com/LumeraProtocol/supernode/v2/sn-manager/internal/utils" ) +var ErrNoCurrentVersion = errors.New("no version currently set") + // Manager handles version storage and symlink management type Manager struct { homeDir string @@ -50,8 +54,27 @@ func (m *Manager) IsVersionInstalled(version string) bool { return err == nil } +// AcquireInstallLock serializes release download, extraction, and installation +// across sn-manager processes that share this home directory. +func (m *Manager) AcquireInstallLock() (func() error, error) { + return configlock.Acquire(filepath.Join(m.homeDir, "install")) +} + // InstallVersion installs a binary to the version directory atomically func (m *Manager) InstallVersion(version string, binaryPath string) error { + if err := os.MkdirAll(m.GetBinariesDir(), 0o755); err != nil { + return fmt.Errorf("failed to create binaries directory: %w", err) + } + release, err := configlock.Acquire(filepath.Join(m.GetBinariesDir(), "."+version)) + if err != nil { + return fmt.Errorf("failed to lock version installation: %w", err) + } + defer func() { + if releaseErr := release(); releaseErr != nil { + log.Printf("Warning: failed to release version install lock: %v", releaseErr) + } + }() + // Create version directory versionDir := m.GetVersionDir(version) if err := os.MkdirAll(versionDir, 0755); err != nil { @@ -99,8 +122,50 @@ func (m *Manager) InstallVersion(version string, binaryPath string) error { return nil } -// SetCurrentVersion updates the current symlink to point to a version atomically +// SetCurrentVersion updates the current symlink to point to a version atomically. +// It is operator-explicit and therefore permits selecting any installed version. func (m *Manager) SetCurrentVersion(version string) error { + release, err := configlock.Acquire(m.GetCurrentLink()) + if err != nil { + return err + } + if err := m.setCurrentVersionUnlocked(version); err != nil { + _ = release() + return err + } + return release() +} + +// SetCurrentVersionIfNewer atomically compares the active symlink and advances +// it only when version has higher semantic precedence. Automatic update paths +// use this method so a concurrent explicit `use` cannot turn into a downgrade. +func (m *Manager) SetCurrentVersionIfNewer(version string) (bool, error) { + release, err := configlock.Acquire(m.GetCurrentLink()) + if err != nil { + return false, err + } + defer func() { _ = release() }() + + current, err := m.GetCurrentVersion() + if err != nil { + if !errors.Is(err, ErrNoCurrentVersion) { + return false, err + } + if err := m.setCurrentVersionUnlocked(version); err != nil { + return false, err + } + return true, nil + } + if utils.CompareVersions(current, version) >= 0 { + return false, nil + } + if err := m.setCurrentVersionUnlocked(version); err != nil { + return false, err + } + return true, nil +} + +func (m *Manager) setCurrentVersionUnlocked(version string) error { // Verify version exists if !m.IsVersionInstalled(version) { return fmt.Errorf("version %s is not installed", version) @@ -138,7 +203,7 @@ func (m *Manager) GetCurrentVersion() (string, error) { target, err := os.Readlink(currentLink) if err != nil { if os.IsNotExist(err) { - return "", fmt.Errorf("no version currently set") + return "", ErrNoCurrentVersion } return "", fmt.Errorf("failed to read current version: %w", err) } diff --git a/sn-manager/internal/version/manager_test.go b/sn-manager/internal/version/manager_test.go index de24cce9..4d6d0a34 100644 --- a/sn-manager/internal/version/manager_test.go +++ b/sn-manager/internal/version/manager_test.go @@ -1,8 +1,10 @@ package version import ( + "bytes" "os" "path/filepath" + "sync" "testing" ) @@ -46,6 +48,105 @@ func TestInstallAndActivateVersion(t *testing.T) { } } +func TestSetCurrentVersionIfNewerActivatesWhenCurrentMissing(t *testing.T) { + home := t.TempDir() + m := NewManager(home) + src := filepath.Join(home, "src-supernode") + if err := os.WriteFile(src, []byte("fake-binary"), 0o755); err != nil { + t.Fatal(err) + } + if err := m.InstallVersion("v2.6.1-testnet", src); err != nil { + t.Fatal(err) + } + changed, err := m.SetCurrentVersionIfNewer("v2.6.1-testnet") + if err != nil { + t.Fatal(err) + } + if !changed { + t.Fatal("missing current symlink should activate target") + } +} + +func TestSetCurrentVersionIfNewerNeverDowngrades(t *testing.T) { + home := t.TempDir() + m := NewManager(home) + src := filepath.Join(home, "src-supernode") + if err := os.WriteFile(src, []byte("fake-binary"), 0o755); err != nil { + t.Fatal(err) + } + for _, version := range []string{"v2.5.0-testnet", "v2.6.1-testnet", "v2.7.0-testnet"} { + if err := m.InstallVersion(version, src); err != nil { + t.Fatal(err) + } + } + if err := m.SetCurrentVersion("v2.6.1-testnet"); err != nil { + t.Fatal(err) + } + + changed, err := m.SetCurrentVersionIfNewer("v2.5.0-testnet") + if err != nil { + t.Fatal(err) + } + if changed { + t.Fatal("older target must not change active version") + } + if got, _ := m.GetCurrentVersion(); got != "v2.6.1-testnet" { + t.Fatalf("active version downgraded to %q", got) + } + + changed, err = m.SetCurrentVersionIfNewer("v2.7.0-testnet") + if err != nil { + t.Fatal(err) + } + if !changed { + t.Fatal("newer target should activate") + } + if got, _ := m.GetCurrentVersion(); got != "v2.7.0-testnet" { + t.Fatalf("active version = %q", got) + } +} + +func TestInstallVersionSerializesSameTarget(t *testing.T) { + home := t.TempDir() + m1, m2 := NewManager(home), NewManager(home) + a := bytes.Repeat([]byte("a"), 1<<20) + b := bytes.Repeat([]byte("b"), 1<<20) + srcA, srcB := filepath.Join(home, "a"), filepath.Join(home, "b") + if err := os.WriteFile(srcA, a, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(srcB, b, 0o755); err != nil { + t.Fatal(err) + } + + var wg sync.WaitGroup + errs := make(chan error, 2) + for _, item := range []struct { + manager *Manager + source string + }{{m1, srcA}, {m2, srcB}} { + wg.Add(1) + go func(manager *Manager, source string) { + defer wg.Done() + errs <- manager.InstallVersion("v2.6.1-testnet", source) + }(item.manager, item.source) + } + wg.Wait() + close(errs) + for err := range errs { + if err != nil { + t.Fatalf("concurrent install failed: %v", err) + } + } + got, err := os.ReadFile(m1.GetVersionBinary("v2.6.1-testnet")) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, a) && !bytes.Equal(got, b) { + t.Fatal("installed binary is a partial or mixed concurrent write") + } +} + func TestListVersions_SortsNewestFirst(t *testing.T) { home := t.TempDir() m := NewManager(home) @@ -72,4 +173,3 @@ func TestListVersions_SortsNewestFirst(t *testing.T) { t.Fatalf("expected newest first, got %v", versions) } } - diff --git a/supernode/cmd/evmigration.go b/supernode/cmd/evmigration.go index 244a5c47..3c22d7ae 100644 --- a/supernode/cmd/evmigration.go +++ b/supernode/cmd/evmigration.go @@ -79,6 +79,9 @@ func legacyKeyMigrationInstructions(keyName string) string { } func validateLegacyMigrationSetup(kr cKeyring.Keyring, keyName, evmKeyName string) (bool, error) { + if err := requireLocalSigningKey(kr, keyName); err != nil { + return false, err + } legacy, err := isLegacyKey(kr, keyName) if err != nil { return false, err @@ -101,6 +104,9 @@ func validateLegacyMigrationSetup(kr cKeyring.Keyring, keyName, evmKeyName strin if evmKeyName == "" { return true, fmt.Errorf("%s", legacyKeyMigrationInstructions(keyName)) } + if err := requireLocalSigningKey(kr, evmKeyName); err != nil { + return true, fmt.Errorf("evm_key_name %q: %w\n\n%s", evmKeyName, err, legacyKeyMigrationInstructions(keyName)) + } isEVM, err := isEthSecp256k1Key(kr, evmKeyName) if err != nil { @@ -189,6 +195,20 @@ func requireEVMChainWithQuerier(ctx context.Context, client moduleVersionQuerier return fmt.Errorf("failed to query chain module versions after %d attempts: %w", requireEVMChainQueryRetries, lastErr) } +// requireLocalSigningKey rejects public-only, multisig, and hardware-backed +// records before startup accepts a key for the daemon's in-process signing +// flow. Algorithm classification remains separate so its helpers stay pure. +func requireLocalSigningKey(kr cKeyring.Keyring, keyName string) error { + rec, err := kr.Key(keyName) + if err != nil { + return fmt.Errorf("key %q not found in keyring: %w", keyName, err) + } + if rec.GetType() != cKeyring.TypeLocal { + return fmt.Errorf("key %q must be a local signing key, got %s", keyName, rec.GetType()) + } + return nil +} + // isLegacyKey returns true if the key stored in the keyring under keyName uses // the pre-EVM secp256k1 algorithm (coin type 118) rather than the EVM-compatible // eth_secp256k1 (coin type 60). @@ -208,9 +228,8 @@ func isLegacyKey(kr cKeyring.Keyring, keyName string) (bool, error) { // isEthSecp256k1Key returns true only if the key stored in the keyring under // keyName uses the EVM-compatible eth_secp256k1 algorithm. It explicitly // type-asserts the public key rather than treating "anything that is not legacy -// secp256k1" as EVM, so multisig/offline/ledger or any other non-EVM key type is -// rejected at this pre-flight gate instead of failing later during signing or -// proof validation. +// secp256k1" as EVM. Record capability (local/offline/ledger/multisig) is +// enforced separately by requireLocalSigningKey at the migration preflight. func isEthSecp256k1Key(kr cKeyring.Keyring, keyName string) (bool, error) { rec, err := kr.Key(keyName) if err != nil { diff --git a/supernode/cmd/evmigration_test.go b/supernode/cmd/evmigration_test.go index c18d652a..2dc9e27d 100644 --- a/supernode/cmd/evmigration_test.go +++ b/supernode/cmd/evmigration_test.go @@ -152,7 +152,57 @@ func TestValidateLegacyMigrationSetup_RejectsUnsupportedActiveKeyType(t *testing legacy, err := validateLegacyMigrationSetup(kr, "unsupported", "") require.Error(t, err) assert.False(t, legacy) - assert.Contains(t, err.Error(), "supernode.key_name \"unsupported\" is not an eth_secp256k1 key") + assert.Contains(t, err.Error(), "must be a local signing key") +} + +func TestValidateLegacyMigrationSetup_RejectsOfflineActiveEVMKey(t *testing.T) { + kr := newTestKeyring(t) + addEVMKey(t, kr, "source") + rec, err := kr.Key("source") + require.NoError(t, err) + pubKey, err := rec.GetPubKey() + require.NoError(t, err) + _, err = kr.SaveOfflineKey("offline-evm", pubKey) + require.NoError(t, err) + + legacy, err := validateLegacyMigrationSetup(kr, "offline-evm", "") + require.Error(t, err) + assert.False(t, legacy) + assert.Contains(t, err.Error(), "must be a local signing key") +} + +func TestValidateLegacyMigrationSetup_RejectsOfflineLegacySource(t *testing.T) { + kr := newTestKeyring(t) + addLegacyKey(t, kr, "source") + rec, err := kr.Key("source") + require.NoError(t, err) + pubKey, err := rec.GetPubKey() + require.NoError(t, err) + _, err = kr.SaveOfflineKey("offline-legacy", pubKey) + require.NoError(t, err) + addEVMKey(t, kr, "evm") + + legacy, err := validateLegacyMigrationSetup(kr, "offline-legacy", "evm") + require.Error(t, err) + assert.False(t, legacy) + assert.Contains(t, err.Error(), "must be a local signing key") +} + +func TestValidateLegacyMigrationSetup_RejectsOfflineEVMTarget(t *testing.T) { + kr := newTestKeyring(t) + addLegacyKey(t, kr, "legacy") + addEVMKey(t, kr, "source") + rec, err := kr.Key("source") + require.NoError(t, err) + pubKey, err := rec.GetPubKey() + require.NoError(t, err) + _, err = kr.SaveOfflineKey("offline-evm", pubKey) + require.NoError(t, err) + + legacy, err := validateLegacyMigrationSetup(kr, "legacy", "offline-evm") + require.Error(t, err) + assert.True(t, legacy) + assert.Contains(t, err.Error(), "must be a local signing key") } func TestValidateLegacyMigrationSetup_LegacyKeyWithoutEVMKeyName(t *testing.T) { diff --git a/supernode/config/save.go b/supernode/config/save.go index 80b58ab8..92d326ee 100644 --- a/supernode/config/save.go +++ b/supernode/config/save.go @@ -6,6 +6,7 @@ import ( "path/filepath" "time" + "github.com/LumeraProtocol/supernode/v2/pkg/configlock" "gopkg.in/yaml.v3" ) @@ -17,6 +18,12 @@ func SaveConfig(config *Config, filename string) error { return fmt.Errorf("failed to create directory for config file: %w", err) } + release, err := configlock.Acquire(filename) + if err != nil { + return err + } + defer func() { _ = release() }() + // Marshal config to YAML data, err := yaml.Marshal(config) if err != nil {