Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions sn-manager/internal/updater/updater.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
31 changes: 30 additions & 1 deletion sn-manager/internal/updater/updater_test.go
Original file line number Diff line number Diff line change
@@ -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{}
Expand Down
20 changes: 20 additions & 0 deletions sn-manager/internal/utils/chain_id.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
35 changes: 35 additions & 0 deletions sn-manager/internal/utils/chain_id_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down