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/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..db5a4d70 100644 --- a/sn-manager/internal/updater/preflight.go +++ b/sn-manager/internal/updater/preflight.go @@ -23,12 +23,11 @@ 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 + // 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 { @@ -37,8 +36,8 @@ func (d preflightDecision) String() string { return "allow" case preflightBlock: return "block" - case preflightRollback: - return "rollback" + case preflightUnknown: + return "unknown" } return "unknown" } @@ -54,12 +53,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 +63,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, ) } @@ -92,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 trigger a block or a rollback. +// 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") @@ -161,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 cause a block or rollback. -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 new file mode 100644 index 00000000..46d4a4a8 --- /dev/null +++ b/sn-manager/internal/updater/preflight_state.go @@ -0,0 +1,98 @@ +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 +} + +// 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 + 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..e1920cd8 --- /dev/null +++ b/sn-manager/internal/updater/preflight_state_test.go @@ -0,0 +1,71 @@ +package updater + +import ( + "os" + "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") + } +} + +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 56331f66..38fb30be 100644 --- a/sn-manager/internal/updater/preflight_test.go +++ b/sn-manager/internal/updater/preflight_test.go @@ -14,10 +14,11 @@ 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-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}, {"evm no-key below threshold 2.5.0", preflightInputs{true, "", "v2.4.5-testnet", "v2.5.0-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/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..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 @@ -222,65 +235,74 @@ func (u *AutoUpdater) checkAndUpdateCombined(force bool) { } } - // EVM preflight (post-fetch, pre-install). This has two branches: - // - // (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. + // 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. // - // Both branches only fire 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. + // 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() - if decision == preflightRollback { - from := u.config.Updates.CurrentVersion - if err := u.performRollback(from, reason); err != nil { - log.Printf("rollback failed: %v", err) + 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 } - 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 @@ -288,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 } @@ -315,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 { @@ -367,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 { @@ -393,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 d8b4dabe..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" @@ -24,7 +25,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"` @@ -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) @@ -63,8 +109,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/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 3ec8fd92..3c22d7ae 100644 --- a/supernode/cmd/evmigration.go +++ b/supernode/cmd/evmigration.go @@ -79,11 +79,24 @@ 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 } 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 } @@ -91,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 { @@ -179,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). @@ -198,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 e8431197..2dc9e27d 100644 --- a/supernode/cmd/evmigration_test.go +++ b/supernode/cmd/evmigration_test.go @@ -143,6 +143,68 @@ 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(), "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) { kr := newTestKeyring(t) addLegacyKey(t, kr, "mykey") 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 {