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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 96 additions & 24 deletions cmd/seid/cmd/configmanager/configmanager.go
Original file line number Diff line number Diff line change
@@ -1,19 +1,28 @@
// 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.
// loader and the 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).
// LegacyConfigManager re-enters the unchanged legacy handler verbatim (the
// default). SeiConfigManager reads the existing config through the sei-config
// library to *validate* it, then re-enters the same reader on the operator's
// original files (it does not rewrite them), so both channels are produced
// identically to legacy. See PLT-775 and the canonical design
// (bdchatham-designs designs/config-manager/DESIGN.md).
Comment thread
claude[bot] marked this conversation as resolved.
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/sei-chain/sei-cosmos/client/flags"
"github.com/sei-protocol/sei-chain/sei-cosmos/server"
)

Expand All @@ -24,18 +33,17 @@ const EnvVar = "SEI_CONFIG_MANAGER"
//
// 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.
// legacy path does. The boot path does not re-render the legacy files: v2 reads
// the config to validate it, then re-enters the unchanged legacy reader on the
// operator's existing files, so the channels are identical to legacy by
// construction. (Authoring the canonical sei.toml and rendering the legacy
// files from it is the generate path; any implementation that writes config
// must be all-or-nothing on disk.)
//
// 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.
// legacy implementation forwards verbatim. This is an internal, single-consumer
// contract (only root.go calls it) and is free to grow when the generate path
// lands; the node dir is resolvable from cmd.
type ConfigManager interface {
Apply(cmd *cobra.Command, customAppConfigTemplate string, customAppConfig any) error
}
Expand All @@ -49,15 +57,79 @@ func (LegacyConfigManager) Apply(cmd *cobra.Command, customAppConfigTemplate str
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 resolves configuration through the sei-config library,
// selected by SEI_CONFIG_MANAGER=v2. It reads the config through the unified
// SeiConfig model and surfaces validation diagnostics, then re-enters the
// legacy reader on the operator's original files — it does NOT rewrite them,
// migrate (that is the explicit `seid config migrate`), or author sei.toml (the
// generate path). So the produced config is identical to legacy by
// construction; v2's boot value-add is the validation pass.
//
// Validation is ADVISORY in this MVP: issues are logged, not enforced, and a
// read/validate problem never refuses boot. sei-config's read fidelity for a
// real seid config is still being hardened, so a model gap must not break an
// otherwise-valid node. Promoting validation to fatal (the design's
// refuse-on-error criterion) is the un-defer once the read round-trips real
// fixtures.
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 surfaces sei-config validation diagnostics (advisory) and re-enters the
// legacy handler on the operator's original files. It does not write to disk,
// and never refuses boot on a read or validate problem.
//
// Validation runs against the on-disk config.toml/app.toml only (via
// ReadConfigFromDir) — NOT the fully-merged AppOptions the node boots on (no
// flag/env/in-code-default layering). So a default node logs a benign advisory
// today (e.g. `storage.pruning` empty, because the template omits the key that
// cosmos defaults in code); this is expected, not a regression. That lower
// fidelity is acceptable precisely because validation is advisory here — parity
// comes from re-entry, not from the validated struct.
func (SeiConfigManager) Apply(cmd *cobra.Command, customAppConfigTemplate string, customAppConfig any) error {
if home, err := resolveHomeDir(cmd); err != nil {
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "config-manager v2: could not resolve home dir for validation (advisory): %v\n", err)
} else if cfg, err := seiconfig.ReadConfigFromDir(home); err != nil {
// A missing config.toml/app.toml (fresh home, or a partial home with one
// file absent) is normal — the legacy handler creates it. Any other read
// error is advisory, not fatal.
if !errors.Is(err, os.ErrNotExist) {
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "config-manager v2: could not read config for validation (advisory): %v\n", err)
}
} else if diags := seiconfig.Validate(cfg).Diagnostics; len(diags) > 0 {
// Advisory in this MVP: the node still boots. Surface ALL diagnostics —
// each carries its own [ERROR]/[WARNING]/[INFO] severity — so warnings
// are not silently dropped alongside errors. SeverityError findings (e.g.
// sc-write-mode) are the class legacy panics on later at app.New();
// surfacing them here is earlier warning, not enforcement. Fatal
// refuse-on-error is the un-defer.
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "config-manager v2: ADVISORY config validation diagnostics (not enforced; the node will boot, but SeverityError items may surface later, e.g. at app.New()): %v\n", diags)
}

// Re-enter the unchanged legacy reader on the operator's original files.
return server.InterceptConfigsPreRunHandler(cmd, customAppConfigTemplate, customAppConfig)
}

// resolveHomeDir mirrors the legacy handler's home resolution exactly
// (sei-cosmos/server/util.go: BindPFlags over the command's flags + the SEID_
// env prefix + AutomaticEnv, then GetString(flags.FlagHome)), so the directory
// we materialize into is the same one the re-entered handler reads from.
// Resolving this single value the same way is not reimplementing the read tail
// — the read/merge/log-level tail stays delegated to InterceptConfigsPreRunHandler.
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:
Expand Down
21 changes: 17 additions & 4 deletions cmd/seid/cmd/configmanager/configmanager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ package configmanager
import (
"testing"

"github.com/spf13/cobra"
"github.com/stretchr/testify/require"

"github.com/sei-protocol/sei-chain/sei-cosmos/client/flags"
)

// TestSelect covers the dispatch table: unset and "legacy" select the
Expand Down Expand Up @@ -34,8 +37,18 @@ 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)
}
146 changes: 146 additions & 0 deletions cmd/seid/cmd/configmanager_differential_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
package cmd

import (
"context"
"errors"
"os"
"path"
"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()
cmd := server.StartCmd(nil, "/foobar", []trace.TracerProviderOption{})
require.NoError(t, cmd.Flags().Set(flags.FlagHome, homeDir))
return execConfigManager(t, mgr, 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 inside cmd's PreRunE (aborting before the
// node boots) 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()
template, appCfg := initAppConfig()
cmd.PreRunE = func(c *cobra.Command, _ []string) error {
if err := mgr.Apply(c, template, appCfg); err != nil {
return err
}
return errStopPreRun
}

serverCtx := &server.Context{}
ctx := context.WithValue(context.Background(), server.ServerContextKey, serverCtx)
require.ErrorIs(t, cmd.ExecuteContext(ctx), errStopPreRun)
return serverCtx
}

// 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")
}
5 changes: 3 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading