diff --git a/cmd/seid/cmd/configmanager/configmanager.go b/cmd/seid/cmd/configmanager/configmanager.go index dd32f12d14..df40eba675 100644 --- a/cmd/seid/cmd/configmanager/configmanager.go +++ b/cmd/seid/cmd/configmanager/configmanager.go @@ -1,73 +1,116 @@ -// Package configmanager is the selection seam between the legacy config -// loader and the (forthcoming) sei-config-backed manager, gated by the -// SEI_CONFIG_MANAGER environment variable. -// -// PR1 ships the seam only: LegacyConfigManager re-enters the unchanged -// legacy handler verbatim (the default), and SeiConfigManager is a -// not-yet-implemented stub that does not import the sei-config library. -// The v2 body lands in a follow-up PR. See PLT-775 and the canonical -// design (bdchatham-designs designs/config-manager/DESIGN.md). package configmanager import ( + "errors" "fmt" + "os" + "path" + "strings" "github.com/spf13/cobra" + "github.com/spf13/viper" + seiconfig "github.com/sei-protocol/sei-config" + "github.com/sei-protocol/seilog" + + "github.com/sei-protocol/sei-chain/sei-cosmos/client/flags" "github.com/sei-protocol/sei-chain/sei-cosmos/server" ) -// EnvVar is the experimental gate that selects the configuration manager. +var logger = seilog.NewLogger("cmd", "seid", "configmanager") + +// EnvVar gates which configuration manager seid uses. const EnvVar = "SEI_CONFIG_MANAGER" // ConfigManager resolves a seid node's configuration during PersistentPreRunE. -// -// Load-bearing contract: an implementation must leave both channels the app -// consumes — serverCtx.Config and serverCtx.Viper — populated exactly as the -// legacy path does, and must be all-or-nothing with respect to on-disk state -// (no partial config.toml/app.toml materialization on error). A v2 manager -// that authors the two files from the sei-config library must write BOTH -// atomically and then re-enter the legacy read tail so the channels are -// produced identically — it must not feed app.New from an in-memory struct. -// -// The Apply signature matches server.InterceptConfigsPreRunHandler so the -// legacy implementation forwards verbatim. This is an internal, -// single-consumer contract (only root.go calls it) and is free to grow — an -// explicit home dir or a Prepare/Apply split — when the v2 write-then-re-enter -// body lands in a follow-up PR; the node dir is resolvable from cmd, so the -// signature is sufficient for PR1's seam. +// An implementation must leave serverCtx.Config and serverCtx.Viper populated +// exactly as the legacy path does. The Apply signature matches +// server.InterceptConfigsPreRunHandler so the legacy manager forwards verbatim. type ConfigManager interface { Apply(cmd *cobra.Command, customAppConfigTemplate string, customAppConfig any) error } -// LegacyConfigManager is the default manager: it re-enters the unchanged -// legacy handler verbatim, so the legacy path stays byte-for-byte unaffected. +// LegacyConfigManager is the default manager. It forwards to the legacy handler +// unchanged, leaving the legacy path byte-for-byte unaffected. type LegacyConfigManager struct{} -// Apply delegates to the legacy interception handler unchanged. +// Apply forwards to the legacy interception handler unchanged. func (LegacyConfigManager) Apply(cmd *cobra.Command, customAppConfigTemplate string, customAppConfig any) error { return server.InterceptConfigsPreRunHandler(cmd, customAppConfigTemplate, customAppConfig) } -// SeiConfigManager will resolve configuration through the sei-config library -// behind SEI_CONFIG_MANAGER=v2. PR1 ships the seam only; the real body lands -// in a follow-up PR. It intentionally does not import sei-config. +// SeiConfigManager validates the config through the sei-config library, then +// re-enters the legacy handler on the operator's original files. It never +// writes, migrates, or refuses boot. type SeiConfigManager struct{} -// Apply is not yet implemented in PR1. It returns a hard error rather than -// silently falling back to the legacy path, so a v2 invocation is observable. -func (SeiConfigManager) Apply(_ *cobra.Command, _ string, _ any) error { - return fmt.Errorf("%s=v2 not yet implemented (PR1 seam only)", EnvVar) +// Apply runs the advisory validation pass, then re-enters the legacy handler on +// the operator's original files. Nothing in the validation pass refuses boot. +func (SeiConfigManager) Apply(cmd *cobra.Command, customAppConfigTemplate string, customAppConfig any) error { + validateAdvisory(cmd) + return server.InterceptConfigsPreRunHandler(cmd, customAppConfigTemplate, customAppConfig) +} + +// validateAdvisory resolves the home dir, reads the on-disk config, and logs any +// validation diagnostics via seilog at Warn. Every step is advisory: a failure — +// or a panic in the sei-config read/validate, whose fidelity is still being +// hardened — is logged and swallowed so the pass can never change what the node +// boots on. A missing config file is normal (the legacy handler creates it) and +// is not surfaced. Keeping this a distinct step from Apply is what lets the +// generate path add its authoring/render step as a sibling. +func validateAdvisory(cmd *cobra.Command) { + defer func() { + if r := recover(); r != nil { + logger.Error("config validation panicked (advisory; recovered, node will boot)", "panic", r) + } + }() + + home, err := resolveHomeDir(cmd) + if err != nil { + logger.Warn("could not resolve home dir for config validation (advisory)", "error", err) + return + } + cfg, err := seiconfig.ReadConfigFromDir(home) + if err != nil { + if !errors.Is(err, os.ErrNotExist) { + logger.Warn("could not read config for validation (advisory)", "error", err) + } + return + } + if diags := seiconfig.Validate(cfg).Diagnostics; len(diags) > 0 { + msgs := make([]string, len(diags)) + for i, d := range diags { + msgs[i] = d.String() + } + logger.Warn("advisory config validation diagnostics (not enforced; node will boot)", + "count", len(diags), "diagnostics", msgs) + } +} + +// resolveHomeDir resolves --home the same way the legacy handler does +// (sei-cosmos/server/util.go), so v2 validates the directory the handler reads. +func resolveHomeDir(cmd *cobra.Command) (string, error) { + v := viper.New() + if err := v.BindPFlags(cmd.Flags()); err != nil { + return "", err + } + if err := v.BindPFlags(cmd.PersistentFlags()); err != nil { + return "", err + } + exe, err := os.Executable() + if err != nil { + return "", err + } + v.SetEnvPrefix(path.Base(exe)) + v.SetEnvKeyReplacer(strings.NewReplacer(".", "_", "-", "_")) + v.AutomaticEnv() + return v.GetString(flags.FlagHome), nil } -// Select returns the ConfigManager chosen by the SEI_CONFIG_MANAGER value: -// unset or "legacy" -> LegacyConfigManager (the default); "v2" -> -// SeiConfigManager; any other value -> error (never a silent fallback). -// getenv is injected for testability; production callers pass os.Getenv. -// -// The value is matched exactly — no trimming or case-folding. This is -// deliberate: normalizing would broaden the gate, and the error names the -// legal tokens so an operator can self-correct. +// Select maps SEI_CONFIG_MANAGER to a manager: unset or "legacy" -> Legacy, +// "v2" -> Sei, anything else -> error. The value is matched exactly (no +// trimming or case-folding) and never falls back silently. getenv is injected +// for tests; callers pass os.Getenv. func Select(getenv func(string) string) (ConfigManager, error) { switch v := getenv(EnvVar); v { case "", "legacy": diff --git a/cmd/seid/cmd/configmanager/configmanager_test.go b/cmd/seid/cmd/configmanager/configmanager_test.go index 7320346f67..16f191a054 100644 --- a/cmd/seid/cmd/configmanager/configmanager_test.go +++ b/cmd/seid/cmd/configmanager/configmanager_test.go @@ -1,9 +1,15 @@ package configmanager import ( + "os" "testing" + "github.com/spf13/cobra" "github.com/stretchr/testify/require" + + seiconfig "github.com/sei-protocol/sei-config" + + "github.com/sei-protocol/sei-chain/sei-cosmos/client/flags" ) // TestSelect covers the dispatch table: unset and "legacy" select the @@ -34,8 +40,28 @@ func TestSelect(t *testing.T) { } } -// TestSeiConfigManagerNotImplemented asserts the v2 stub fails hard rather -// than silently behaving like legacy (PR1 ships the seam only). -func TestSeiConfigManagerNotImplemented(t *testing.T) { - require.Error(t, (SeiConfigManager{}).Apply(nil, "", nil)) +// TestResolveHomeDir_Flag confirms resolveHomeDir reads the --home flag — the +// value v2 validates against must be the dir the re-entered handler reads. (Env +// precedence follows viper, mirrored from the legacy handler; the end-to-end +// env-driven case is exercised by TestConfigManagerLegacyVsV2Differential_EnvHome +// in the cmd package, which resolves the test-binary-basename prefix and asserts +// legacy/v2 parity.) +func TestResolveHomeDir_Flag(t *testing.T) { + cmd := &cobra.Command{} + cmd.Flags().String(flags.FlagHome, "", "") + require.NoError(t, cmd.Flags().Set(flags.FlagHome, "/tmp/seid-test-home")) + + got, err := resolveHomeDir(cmd) + require.NoError(t, err) + require.Equal(t, "/tmp/seid-test-home", got) +} + +// TestReadConfigFromDirMissingIsErrNotExist pins the contract validateAdvisory's +// silent-skip depends on: a missing config file must yield an error that +// errors.Is(os.ErrNotExist) recognizes, so a fresh-home boot skips the advisory +// read quietly instead of logging a spurious warning. If sei-config ever swaps +// to a custom not-found error, this fails here rather than going noisy in prod. +func TestReadConfigFromDirMissingIsErrNotExist(t *testing.T) { + _, err := seiconfig.ReadConfigFromDir(t.TempDir()) + require.ErrorIs(t, err, os.ErrNotExist) } diff --git a/cmd/seid/cmd/configmanager/doc.go b/cmd/seid/cmd/configmanager/doc.go new file mode 100644 index 0000000000..3db2733fe2 --- /dev/null +++ b/cmd/seid/cmd/configmanager/doc.go @@ -0,0 +1,23 @@ +// Package configmanager selects how seid loads its configuration, behind the +// SEI_CONFIG_MANAGER gate. Every manager boots the node identically; the only +// variable is an advisory validation pass that never rewrites a file and never +// refuses boot. +// +// SEI_CONFIG_MANAGER picks the manager: unset or "legacy" uses the legacy +// loader unchanged; "v2" uses the sei-config-backed manager. root.go calls +// Select once, during PersistentPreRunE. +// +// Both managers boot from the same two channels — serverCtx.Config and +// serverCtx.Viper — and v2 populates them exactly as legacy does: it re-enters +// the legacy reader on the operator's own files instead of rewriting them. Its +// validation pass is advisory because sei-config's read fidelity is still being +// hardened, and a gap in the model must not fail a valid node. +// +// The node boots on those two channels, never on the SeiConfig model; that is +// why differential tests suffice — proving v2's channels equal legacy's, which +// legacy is already trusted to boot on, is the whole correctness argument. +// +// Two things are deferred: making validation fatal, and authoring a canonical +// sei.toml to render the legacy files from (the generate path). See PLT-775 and +// the design (bdchatham-designs designs/config-manager/DESIGN.md). +package configmanager diff --git a/cmd/seid/cmd/configmanager_differential_test.go b/cmd/seid/cmd/configmanager_differential_test.go new file mode 100644 index 0000000000..6777a55ceb --- /dev/null +++ b/cmd/seid/cmd/configmanager_differential_test.go @@ -0,0 +1,360 @@ +package cmd + +import ( + "context" + "errors" + "io" + "os" + "path" + "path/filepath" + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/sdk/trace" + + "github.com/sei-protocol/sei-chain/cmd/seid/cmd/configmanager" + "github.com/sei-protocol/sei-chain/sei-cosmos/client/flags" + "github.com/sei-protocol/sei-chain/sei-cosmos/server" +) + +// errStopPreRun aborts the command after the config-resolution PreRunE, before +// StartCmd's RunE tries to boot a node. +var errStopPreRun = errors.New("stop after prerun") + +// runConfigManager runs mgr.Apply inside a StartCmd's PreRunE against homeDir +// supplied via the --home flag, using seid's real app-config template, and +// returns the populated server context (the two channels start.go/app.New +// consume). It mirrors the harness in sei-cosmos/server/util_test.go. +func runConfigManager(t *testing.T, mgr configmanager.ConfigManager, homeDir string) *server.Context { + t.Helper() + return execConfigManager(t, mgr, startCmdForHome(t, homeDir)) +} + +// startCmdForHome builds a StartCmd with --home set to homeDir. +func startCmdForHome(t *testing.T, homeDir string) *cobra.Command { + t.Helper() + cmd := server.StartCmd(nil, "/foobar", []trace.TracerProviderOption{}) + require.NoError(t, cmd.Flags().Set(flags.FlagHome, homeDir)) + return cmd +} + +// runConfigManagerEnvHome is runConfigManager's twin that supplies homeDir +// through the environment instead of --home, exercising the +// SetEnvPrefix/AutomaticEnv/replacer machinery that resolveHomeDir mirrors from +// the legacy handler (the flag-driven path never touches it). The env prefix is +// path.Base(os.Executable()) — the test binary — derived identically by BOTH +// the legacy handler (sei-cosmos/server/util.go:152,162-164) and v2's +// resolveHomeDir, so both resolve the same home. viper's lookup key for "home" +// is ToUpper(prefix + "_" + "home") with the ".","-" -> "_" replacer applied. +func runConfigManagerEnvHome(t *testing.T, mgr configmanager.ConfigManager, homeDir string) *server.Context { + t.Helper() + exe, err := os.Executable() + require.NoError(t, err) + envKey := strings.NewReplacer(".", "_", "-", "_").Replace( + strings.ToUpper(path.Base(exe) + "_" + flags.FlagHome)) + t.Setenv(envKey, homeDir) + + // Leave --home unset: an unchanged flag default ranks below AutomaticEnv in + // viper's precedence, so the env value is what resolves. + cmd := server.StartCmd(nil, "/foobar", []trace.TracerProviderOption{}) + return execConfigManager(t, mgr, cmd) +} + +// execConfigManager runs mgr.Apply on the happy path (Apply succeeds; boot is +// aborted with errStopPreRun) and returns the populated server context. The +// caller configures how home is supplied (flag vs env) on cmd beforehand. +func execConfigManager(t *testing.T, mgr configmanager.ConfigManager, cmd *cobra.Command) *server.Context { + t.Helper() + ctx, err := runManager(t, mgr, cmd) + require.NoError(t, err) + return ctx +} + +// runManager runs mgr.Apply inside cmd's PreRunE and returns the populated +// server context and the error Apply returned. Apply is the only boot-refusing +// call, so on the happy path it returns nil and boot is aborted with +// errStopPreRun; on a real config error it returns that error and runManager +// surfaces it unchanged. Advisory diagnostics go to seilog (not cmd's stderr), +// so they are not captured here — the invariants under test are the returned +// context and error, not the log text. The caller sets home on cmd beforehand. +func runManager(t *testing.T, mgr configmanager.ConfigManager, cmd *cobra.Command) (*server.Context, error) { + t.Helper() + template, appCfg := initAppConfig() + cmd.SetErr(io.Discard) // swallow cobra's own error echo; advisory logs go to seilog + + var applyErr error + cmd.PreRunE = func(c *cobra.Command, _ []string) error { + if applyErr = mgr.Apply(c, template, appCfg); applyErr != nil { + return applyErr + } + return errStopPreRun + } + + serverCtx := &server.Context{} + ctx := context.WithValue(context.Background(), server.ServerContextKey, serverCtx) + execErr := cmd.ExecuteContext(ctx) + if applyErr == nil { + require.ErrorIs(t, execErr, errStopPreRun) + } + return serverCtx, applyErr +} + +// appTOMLPath and cfgTOMLPath are the two files the legacy creator writes into a +// home and both managers then read. +func appTOMLPath(home string) string { return filepath.Join(home, "config", "app.toml") } +func cfgTOMLPath(home string) string { return filepath.Join(home, "config", "config.toml") } + +// seedDefaultConfig generates a complete, realistic config (all Sei sections) by +// letting the legacy creator write into a fresh home, and returns that home. +func seedDefaultConfig(t *testing.T) string { + t.Helper() + home := t.TempDir() + _ = runConfigManager(t, configmanager.LegacyConfigManager{}, home) + return home +} + +// appendToFile appends s to the file at path (both managers must still agree on +// the result). +func appendToFile(t *testing.T, path, s string) { + t.Helper() + b, err := os.ReadFile(path) + require.NoError(t, err) + require.NoError(t, os.WriteFile(path, append(b, []byte(s)...), 0o600)) +} + +// prependToFile prepends s to the file at path. +func prependToFile(t *testing.T, path, s string) { + t.Helper() + b, err := os.ReadFile(path) + require.NoError(t, err) + require.NoError(t, os.WriteFile(path, append([]byte(s), b...), 0o600)) +} + +// replaceInFile replaces oldStr with newStr in the file at path, asserting +// oldStr was present so a corpus mutation can never silently become a no-op. +func replaceInFile(t *testing.T, path, oldStr, newStr string) { + t.Helper() + b, err := os.ReadFile(path) + require.NoError(t, err) + require.Contains(t, string(b), oldStr, "replace target %q not found — fixture would be vacuous", oldStr) + require.NoError(t, os.WriteFile(path, []byte(strings.ReplaceAll(string(b), oldStr, newStr)), 0o600)) +} + +// corpusCase is one realistic on-disk config shape, applied to a freshly-seeded +// default home. It is the shared unit both the table-driven differential and the +// fuzz target consume, so the set of "interesting shapes" lives in one place. +type corpusCase struct { + name string + mutate func(t *testing.T, home string) +} + +// configCorpus is the single source of the config shapes the parity proof runs +// over. Each case mutates a default home in place; parity must hold for all of +// them because v2 re-enters the legacy reader regardless of what it read. +func configCorpus() []corpusCase { + return []corpusCase{ + {"default", func(t *testing.T, home string) {}}, + {"leading-comments-and-blanks", func(t *testing.T, home string) { + prependToFile(t, appTOMLPath(home), "# corpus: a leading comment\n\n") + }}, + {"unknown-section", func(t *testing.T, home string) { + appendToFile(t, appTOMLPath(home), "\n[sei-corpus-unknown]\nkey = \"value\"\n") + }}, + {"quoted-scalar", func(t *testing.T, home string) { + // A real numeric field written as a quoted string (sei-config #36's + // lenient-decode shape). Both paths re-enter the same legacy reader, so + // the channels match regardless — parity holds because v2's read is + // advisory. Coercion of quoted primitives is verified in sei-config's + // own tests; the differential only observes channel parity, not the + // advisory read's outcome. + replaceInFile(t, appTOMLPath(home), "ss-keep-recent = 100000", `ss-keep-recent = "100000"`) + }}, + {"cosmos-only-write-mode", func(t *testing.T, home string) { + // The version-skew class: a config carrying the deprecated + // state-commit.sc-write-mode "cosmos_only". sei-config still accepts it + // as valid, so v2 raises no diagnostic today; the point here is that + // both managers read it identically (parity). It becomes a *caught* + // case only once fatal validation + a sei-config deprecation rule land. + replaceInFile(t, appTOMLPath(home), `sc-write-mode = "memiavl_only"`, `sc-write-mode = "cosmos_only"`) + }}, + } +} + +// TestConfigManagerLegacyVsV2Differential is the core safety property: the v2 +// manager must produce the SAME consumed config as the legacy path. v2 reads +// the config (to validate it) and then re-enters the legacy reader on the +// operator's ORIGINAL files — it does not rewrite — so the two paths read the +// SAME home and any difference is a real divergence, not a path artifact. +// +// It compares parsed semantics: +// - serverCtx.Config (the *tmcfg.Config the node runs on), and +// - serverCtx.Viper.AllSettings() (the AppOptions every Sei section reads via +// appOpts.Get), both at end-of-PersistentPreRunE and after the start.go +// chain-id mutation. +// +// A realistic fixture (all 11 Sei sections) is generated by letting the legacy +// handler create the full default config once; both managers then read it. +func TestConfigManagerLegacyVsV2Differential(t *testing.T) { + home := t.TempDir() + + // Generate a complete, realistic config via the legacy creator (fresh home). + _ = runConfigManager(t, configmanager.LegacyConfigManager{}, home) + + // Both managers read the same populated home. v2 is passthrough (no rewrite), + // so RootDir and every other path-derived field match by construction. + legacyCtx := runConfigManager(t, configmanager.LegacyConfigManager{}, home) + v2Ctx := runConfigManager(t, configmanager.SeiConfigManager{}, home) + + require.Equal(t, legacyCtx.Config, v2Ctx.Config, + "serverCtx.Config differs between legacy and v2") + require.Equal(t, legacyCtx.Viper.AllSettings(), v2Ctx.Viper.AllSettings(), + "serverCtx.Viper settings differ between legacy and v2") + + // The start.go chain-id mutation is identical on both vipers; assert parity + // holds after it too (covers the post-mutation snapshot). + const chainID = "differential-test-1" + legacyCtx.Viper.Set(flags.FlagChainID, chainID) + v2Ctx.Viper.Set(flags.FlagChainID, chainID) + require.Equal(t, legacyCtx.Viper.AllSettings(), v2Ctx.Viper.AllSettings(), + "settings diverge after the start.go chain-id mutation") +} + +// TestConfigManagerLegacyVsV2Differential_EnvHome exercises the env-precedence +// half of resolveHomeDir's mirror of the legacy handler — the flag-driven +// differential above never touches SetEnvPrefix/AutomaticEnv. When home is +// supplied via the environment (not --home), v2 must resolve the SAME home the +// legacy handler does; otherwise v2 would advisorily validate one dir while the +// re-entered legacy reader boots on another — a silent drift the advisory +// design cannot surface (no error, no diagnostic). This pins the seam so a +// future change to the legacy env-resolution can't diverge undetected. +func TestConfigManagerLegacyVsV2Differential_EnvHome(t *testing.T) { + home := t.TempDir() + + // Populate a complete realistic config in `home` via the fresh-home legacy + // creator, driven entirely through the env var (no --home). + _ = runConfigManagerEnvHome(t, configmanager.LegacyConfigManager{}, home) + + legacyCtx := runConfigManagerEnvHome(t, configmanager.LegacyConfigManager{}, home) + v2Ctx := runConfigManagerEnvHome(t, configmanager.SeiConfigManager{}, home) + + // Non-vacuous guard: the env var actually drove resolution. If the key were + // wrong, both would fall back to StartCmd's "/foobar" default (and the + // legacy creator would fail writing under it) — this asserts the env path + // resolved to the temp home, for both managers. + require.Equal(t, home, v2Ctx.Viper.GetString(flags.FlagHome), + "env-provided home did not drive v2 resolution") + require.Equal(t, home, legacyCtx.Viper.GetString(flags.FlagHome), + "env-provided home did not drive legacy resolution") + + require.Equal(t, legacyCtx.Config, v2Ctx.Config, + "serverCtx.Config differs between legacy and v2 on the env-home path") + require.Equal(t, legacyCtx.Viper.AllSettings(), v2Ctx.Viper.AllSettings(), + "serverCtx.Viper settings differ between legacy and v2 on the env-home path") +} + +// TestConfigManagerLegacyVsV2Differential_Corpus widens the parity proof from +// the single default fixture to a corpus of realistic on-disk shapes. Parity is +// by construction (v2 re-enters the legacy reader), so any shape an operator +// could present must produce identical channels — including shapes that exercise +// sei-config's own reader (quoted scalars, unknown keys), whose advisory read +// still must not perturb what the node boots on. +func TestConfigManagerLegacyVsV2Differential_Corpus(t *testing.T) { + for _, tc := range configCorpus() { + t.Run(tc.name, func(t *testing.T) { + home := seedDefaultConfig(t) + tc.mutate(t, home) + + legacyCtx := runConfigManager(t, configmanager.LegacyConfigManager{}, home) + v2Ctx := runConfigManager(t, configmanager.SeiConfigManager{}, home) + + require.Equal(t, legacyCtx.Config, v2Ctx.Config, + "serverCtx.Config differs between legacy and v2 (%s)", tc.name) + require.Equal(t, legacyCtx.Viper.AllSettings(), v2Ctx.Viper.AllSettings(), + "serverCtx.Viper settings differ between legacy and v2 (%s)", tc.name) + }) + } +} + +// TestConfigManagerV2AdvisoryNeverRefusesBoot pins the advisory invariant: on a +// valid config, v2 boots exactly as legacy does (Apply returns nil, both +// channels match), regardless of any diagnostics it prints. v2 adds +// observability, never a new boot outcome. +func TestConfigManagerV2AdvisoryNeverRefusesBoot(t *testing.T) { + home := seedDefaultConfig(t) + + v2Ctx, v2Err := runManager(t, configmanager.SeiConfigManager{}, startCmdForHome(t, home)) + require.NoError(t, v2Err, "advisory validation must never refuse boot on a valid config") + + legacyCtx := runConfigManager(t, configmanager.LegacyConfigManager{}, home) + require.Equal(t, legacyCtx.Config, v2Ctx.Config) + require.Equal(t, legacyCtx.Viper.AllSettings(), v2Ctx.Viper.AllSettings()) +} + +// TestConfigManagerV2FreshHomeBoots exercises the fresh-home first-boot path: v2's +// advisory read hits os.ErrNotExist (no config yet), silently skips, then +// re-enters the legacy handler, which creates the files. It must not refuse boot. +// Every other test pre-seeds the config, so this is the only cover of the +// ErrNotExist branch — the common case for a brand-new node. +func TestConfigManagerV2FreshHomeBoots(t *testing.T) { + home := t.TempDir() + v2Ctx, v2Err := runManager(t, configmanager.SeiConfigManager{}, startCmdForHome(t, home)) + require.NoError(t, v2Err, "v2 on a fresh home must not refuse boot on the missing-config read") + require.NotNil(t, v2Ctx.Config) +} + +// TestConfigManagerV2AdvisoryReadErrorMatchesLegacy pins the other half of the +// invariant: when the config is unreadable, v2 must not mask the failure or +// invent a new one. It logs an advisory read error (via seilog), then re-enters +// the legacy handler and returns exactly the error legacy returns. +func TestConfigManagerV2AdvisoryReadErrorMatchesLegacy(t *testing.T) { + home := seedDefaultConfig(t) + require.NoError(t, os.WriteFile(cfgTOMLPath(home), []byte("this is ] not [ valid toml"), 0o600)) + + _, legacyErr := runManager(t, configmanager.LegacyConfigManager{}, startCmdForHome(t, home)) + _, v2Err := runManager(t, configmanager.SeiConfigManager{}, startCmdForHome(t, home)) + + require.Error(t, legacyErr, "corrupt config.toml should fail the legacy reader") + require.Equal(t, legacyErr.Error(), v2Err.Error(), + "v2 must return the same boot error as legacy, not mask or add one") +} + +// FuzzConfigManagerLegacyVsV2Parity is the exhaustive form of the corpus: it +// crosses every corpus shape with an arbitrary appended app.toml suffix, and +// asserts legacy and v2 reach the same outcome — identical channels when both +// succeed, the identical error when both fail. Parity is by construction, so the +// fuzzer should never find a divergence. Under `go test` (no -fuzz) it runs the +// seed corpus (each shape × a few suffixes), a deterministic differential in CI; +// under -fuzz it explores suffixes against every shape. +func FuzzConfigManagerLegacyVsV2Parity(f *testing.F) { + corpus := configCorpus() + for i := range corpus { + f.Add(uint(i), "") + f.Add(uint(i), "\n# a trailing comment\n") + f.Add(uint(i), "\nnot valid toml ][") + } + + // corpusIdx is unsigned so a fuzzed index maps to a case with a plain modulo + // — no sign guard, no math.MinInt negation edge. + f.Fuzz(func(t *testing.T, corpusIdx uint, appTOMLSuffix string) { + tc := corpus[corpusIdx%uint(len(corpus))] + + home := seedDefaultConfig(t) + tc.mutate(t, home) + appendToFile(t, appTOMLPath(home), appTOMLSuffix) + + legacyCtx, legacyErr := runManager(t, configmanager.LegacyConfigManager{}, startCmdForHome(t, home)) + v2Ctx, v2Err := runManager(t, configmanager.SeiConfigManager{}, startCmdForHome(t, home)) + + if (legacyErr == nil) != (v2Err == nil) { + t.Fatalf("divergent outcome (case %q, suffix %q): legacyErr=%v v2Err=%v", tc.name, appTOMLSuffix, legacyErr, v2Err) + } + if legacyErr != nil { + require.Equal(t, legacyErr.Error(), v2Err.Error(), "divergent error (case %q, suffix %q)", tc.name, appTOMLSuffix) + return + } + require.Equal(t, legacyCtx.Config, v2Ctx.Config, "Config diverges (case %q, suffix %q)", tc.name, appTOMLSuffix) + require.Equal(t, legacyCtx.Viper.AllSettings(), v2Ctx.Viper.AllSettings(), "settings diverge (case %q, suffix %q)", tc.name, appTOMLSuffix) + }) +} diff --git a/go.mod b/go.mod index 1b94a8aef9..45413d7a33 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.25.6 require ( cosmossdk.io/errors v1.0.2 github.com/99designs/keyring v1.2.1 - github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c + github.com/BurntSushi/toml v1.5.0 github.com/adlio/schema v1.3.9 github.com/alitto/pond v1.8.3 github.com/armon/go-metrics v0.4.1 @@ -71,6 +71,7 @@ require ( github.com/sasha-s/go-deadlock v0.3.5 github.com/segmentio/kafka-go v0.4.50 github.com/sei-protocol/goutils v0.0.2 + github.com/sei-protocol/sei-config v0.0.21 github.com/sei-protocol/sei-load v0.0.0-20251007135253-78fbdc141082 github.com/sei-protocol/sei-tm-db v0.0.5 github.com/sei-protocol/seilog v0.0.3 @@ -187,7 +188,7 @@ require ( github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect - github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/golang/snappy v1.0.0 // indirect diff --git a/go.sum b/go.sum index f9a82a3eff..9ace91cdd0 100644 --- a/go.sum +++ b/go.sum @@ -619,8 +619,8 @@ github.com/AzureAD/microsoft-authentication-library-for-go v1.0.0/go.mod h1:kgDm github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= -github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c h1:pxW6RcqyfI9/kWtOwnv/G+AzdKuy2ZrqINhenH4HyNs= -github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= +github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/CloudyKit/fastprinter v0.0.0-20200109182630-33d98a066a53/go.mod h1:+3IMCy2vIlbG1XG/0ggNQv0SvxCAIpPM5b1nCz56Xno= github.com/CloudyKit/jet/v6 v6.2.0/go.mod h1:d3ypHeIRNo2+XyqnGA8s+aphtcVpjP5hPwP/Lzo7Ro4= @@ -1083,8 +1083,8 @@ github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9 github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/go-stack/stack v1.8.1/go.mod h1:dcoOX6HbPZSZptuspn9bctJ+N/CnF5gGygcUP3XYfe4= github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= -github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= -github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo= github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= @@ -1808,6 +1808,8 @@ github.com/sei-protocol/go-ethereum v1.15.7-sei-17 h1:jUDHZqcKAiLi8nR0isZPZn8nDZ github.com/sei-protocol/go-ethereum v1.15.7-sei-17/go.mod h1:+S9k+jFzlyVTNcYGvqFhzN/SFhI6vA+aOY4T5tLSPL0= github.com/sei-protocol/goutils v0.0.2 h1:Bfa7Sv+4CVLNM20QcpvGb81B8C5HkQC/kW1CQpIbXDA= github.com/sei-protocol/goutils v0.0.2/go.mod h1:iYE2DuJfEnM+APPehr2gOUXfuLuPsVxorcDO+Tzq9q8= +github.com/sei-protocol/sei-config v0.0.21 h1:0VXOz91v9YeM2dmpKi1XaIFy+p3EamDAJtnj02XLKIw= +github.com/sei-protocol/sei-config v0.0.21/go.mod h1:zcEdLzyIH2AyP0/QRBE3s4Y9eGn0C/qAUx1c4o4EROU= github.com/sei-protocol/sei-load v0.0.0-20251007135253-78fbdc141082 h1:f2sY8OcN60UL1/6POx+HDMZ4w04FTZtSScnrFSnGZHg= github.com/sei-protocol/sei-load v0.0.0-20251007135253-78fbdc141082/go.mod h1:V0fNURAjS6A8+sA1VllegjNeSobay3oRUW5VFZd04bA= github.com/sei-protocol/sei-tm-db v0.0.5 h1:3WONKdSXEqdZZeLuWYfK5hP37TJpfaUa13vAyAlvaQY=