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
20 changes: 20 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,27 @@
- 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:

Check warning

Code scanning / CodeQL

Workflow does not contain permissions Medium test

Actions job or workflow does not limit the permissions of the GITHUB_TOKEN. Consider setting an explicit permissions block, using the following as a minimal starting point: {contents: read}
name: integration-tests
runs-on: ubuntu-latest

Expand Down
40 changes: 40 additions & 0 deletions docs/evm-migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <v2.6-or-newer-network-tag>
sn-manager use <v2.6-or-newer-network-tag>
```

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
Expand Down
41 changes: 41 additions & 0 deletions pkg/configlock/lock.go
Original file line number Diff line number Diff line change
@@ -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
}
48 changes: 48 additions & 0 deletions pkg/configlock/lock_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
2 changes: 2 additions & 0 deletions pkg/github/testutil/fake_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ type FakeClient struct {

CallsLatestStable int
CallsListReleases int
CallsTarballURL int
}

func (f *FakeClient) GetLatestRelease() (*github.Release, error) {
Expand Down Expand Up @@ -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")
}
33 changes: 21 additions & 12 deletions sn-manager/cmd/check.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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") {
Expand All @@ -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)
Expand All @@ -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
Expand Down
18 changes: 16 additions & 2 deletions sn-manager/cmd/get.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand Down
Loading
Loading