From 5ebec8e99268effa03e30cd4ab4fb5ea2f515e35 Mon Sep 17 00:00:00 2001 From: Matee ullah Malik Date: Fri, 24 Jul 2026 16:10:46 +0000 Subject: [PATCH] fix(sn-manager): gate unprepared v2.6 upgrades --- sn-manager/internal/updater/updater.go | 38 +++++++++++++++++++++ sn-manager/internal/updater/updater_test.go | 31 ++++++++++++++++- sn-manager/internal/utils/chain_id.go | 20 +++++++++++ sn-manager/internal/utils/chain_id_test.go | 35 +++++++++++++++++++ 4 files changed, 123 insertions(+), 1 deletion(-) diff --git a/sn-manager/internal/updater/updater.go b/sn-manager/internal/updater/updater.go index 92e616fe..e51f83a7 100644 --- a/sn-manager/internal/updater/updater.go +++ b/sn-manager/internal/updater/updater.go @@ -31,8 +31,31 @@ const ( // forceUpdateAfter is the age threshold after a release is published // beyond which updates are applied regardless of normal gates (idle, policy) forceUpdateAfter = 10 * time.Minute + // evmUpgradeMinVersion is the first SuperNode line that requires migration + // preparation before a pre-EVM node may update automatically. + evmUpgradeMinVersion = "v2.6.0" ) +func versionCore(version string) string { + version = strings.TrimSpace(version) + if i := strings.IndexByte(version, '-'); i >= 0 { + return version[:i] + } + return version +} + +func crossesEVMUpgradeBoundary(currentVersion, targetVersion string) bool { + return utils.CompareVersions(versionCore(currentVersion), evmUpgradeMinVersion) < 0 && + utils.CompareVersions(versionCore(targetVersion), evmUpgradeMinVersion) >= 0 +} + +func shouldBlockEVMUpgrade(currentVersion, targetVersion, evmKeyName string, configErr error) bool { + if !crossesEVMUpgradeBoundary(currentVersion, targetVersion) { + return false + } + return configErr != nil || strings.TrimSpace(evmKeyName) == "" +} + type AutoUpdater struct { config *config.Config homeDir string @@ -248,6 +271,21 @@ func (u *AutoUpdater) checkAndUpdateCombined(force bool) { return } + // A pre-v2.6 SuperNode must not cross the EVM boundary automatically until + // its transitional evm_key_name is configured. Already-v2.6 nodes are not + // gated because successful migration intentionally clears this field. + if crossesEVMUpgradeBoundary(currentSN, latest) { + evmKeyName, configErr := utils.ReadSupernodeEVMKeyName() + if shouldBlockEVMUpgrade(currentSN, latest, evmKeyName, configErr) { + if configErr != nil { + log.Printf("Automatic update to %s blocked: cannot read SuperNode evm_key_name: %v", latest, configErr) + } else { + log.Printf("Automatic update to %s blocked: supernode.evm_key_name is missing or empty", latest) + } + return + } + } + // Gate all updates (manager + SuperNode) on gateway idleness // to avoid disrupting traffic during a self-update. if !force { diff --git a/sn-manager/internal/updater/updater_test.go b/sn-manager/internal/updater/updater_test.go index bfb3dae4..e9f34dc6 100644 --- a/sn-manager/internal/updater/updater_test.go +++ b/sn-manager/internal/updater/updater_test.go @@ -1,6 +1,35 @@ package updater -import "testing" +import ( + "errors" + "testing" +) + +func TestShouldBlockEVMUpgrade(t *testing.T) { + tests := []struct { + name string + current string + target string + evmKeyName string + configErr error + want bool + }{ + {name: "missing key blocks 2.6 boundary", current: "v2.5.2", target: "v2.6.0", want: true}, + {name: "empty testnet key blocks 2.6 boundary", current: "v2.5.2-testnet", target: "v2.6.0-testnet", evmKeyName: " ", want: true}, + {name: "config read failure blocks 2.6 boundary", current: "v2.5.2", target: "v2.6.0", configErr: errors.New("read failed"), want: true}, + {name: "configured key allows 2.6 boundary", current: "v2.5.2", target: "v2.6.0", evmKeyName: "evm-key", want: false}, + {name: "missing key does not block 2.5 update", current: "v2.5.1", target: "v2.5.2", want: false}, + {name: "post-migration missing key does not roll back or block", current: "v2.6.0", target: "v2.6.1", want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := shouldBlockEVMUpgrade(tt.current, tt.target, tt.evmKeyName, tt.configErr); got != tt.want { + t.Fatalf("shouldBlockEVMUpgrade(%q, %q, %q, %v) = %v, want %v", tt.current, tt.target, tt.evmKeyName, tt.configErr, got, tt.want) + } + }) + } +} func TestShouldUpdate_TestnetTagAdvances(t *testing.T) { u := &AutoUpdater{} diff --git a/sn-manager/internal/utils/chain_id.go b/sn-manager/internal/utils/chain_id.go index af688978..0cd1a154 100644 --- a/sn-manager/internal/utils/chain_id.go +++ b/sn-manager/internal/utils/chain_id.go @@ -46,6 +46,26 @@ func ReadSupernodeChainID() (string, error) { return chainID, nil } +// ReadSupernodeEVMKeyName returns the transitional migration key configured in +// ~/.supernode/config.yml. Missing and blank values return an empty string. +func ReadSupernodeEVMKeyName() (string, error) { + path := SupernodeConfigPath() + data, err := os.ReadFile(path) + if err != nil { + return "", err + } + + var cfg struct { + Supernode struct { + EVMKeyName string `yaml:"evm_key_name"` + } `yaml:"supernode"` + } + if err := yaml.Unmarshal(data, &cfg); err != nil { + return "", fmt.Errorf("failed to parse supernode config %s: %w", path, err) + } + return strings.TrimSpace(cfg.Supernode.EVMKeyName), nil +} + func IsTestnetChainID(chainID string) bool { return strings.Contains(strings.ToLower(chainID), "testnet") } diff --git a/sn-manager/internal/utils/chain_id_test.go b/sn-manager/internal/utils/chain_id_test.go index b691217e..f4a2900b 100644 --- a/sn-manager/internal/utils/chain_id_test.go +++ b/sn-manager/internal/utils/chain_id_test.go @@ -65,6 +65,41 @@ func TestReadSupernodeChainID_MissingOrEmpty(t *testing.T) { } } +func TestReadSupernodeEVMKeyName(t *testing.T) { + tests := []struct { + name string + yaml string + want string + }{ + {name: "missing", yaml: "supernode:\n key_name: legacy\n"}, + {name: "empty", yaml: "supernode:\n evm_key_name: \"\"\n"}, + {name: "whitespace", yaml: "supernode:\n evm_key_name: \" \"\n"}, + {name: "present", yaml: "supernode:\n evm_key_name: evm-key\n", want: "evm-key"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + cfgDir := filepath.Join(home, ".supernode") + if err := os.MkdirAll(cfgDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(cfgDir, "config.yml"), []byte(tt.yaml), 0o644); err != nil { + t.Fatal(err) + } + + got, err := ReadSupernodeEVMKeyName() + if err != nil { + t.Fatalf("ReadSupernodeEVMKeyName: %v", err) + } + if got != tt.want { + t.Fatalf("ReadSupernodeEVMKeyName() = %q, want %q", got, tt.want) + } + }) + } +} + func TestLatestTestnetRelease_IgnoresDrafts(t *testing.T) { client := &githubtestutil.FakeClient{ Releases: []*github.Release{