Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
b8e9513
feat(configmanager): SeiConfigManager v2 body — validate-passthrough …
bdchatham Jun 30, 2026
b5a71b7
review(configmanager): xreview fixes (doc-drift, louder error advisor…
bdchatham Jun 30, 2026
13b3a44
fix(configmanager): lint errcheck + surface validation warnings (agen…
bdchatham Jul 1, 2026
9756ba4
test(configmanager): cover env-precedence home resolution (DWC-1)
bdchatham Jul 1, 2026
393578d
docs(configmanager): fix stale env-case comment on TestResolveHomeDir…
bdchatham Jul 1, 2026
120a683
docs(configmanager): move narrative to doc.go, trim to human-audience…
bdchatham Jul 1, 2026
d0bf870
test(configmanager): rich parity surface — corpus, advisory-invarianc…
bdchatham Jul 1, 2026
35842d9
refactor(configmanager): named-step Apply, shared corpus, reduction doc
bdchatham Jul 1, 2026
bf69ff0
test(configmanager): fuzz corpusIdx as uint; de-shadow replaceInFile
bdchatham Jul 1, 2026
3606e93
refactor(configmanager): log advisory diagnostics via seilog, not fmt…
bdchatham Jul 1, 2026
8e3384b
refactor(configmanager): keep advisory diagnostics structured in the log
bdchatham Jul 1, 2026
19d724d
fix(configmanager): recover panics in the advisory validation pass
bdchatham Jul 1, 2026
16e32f4
fix(configmanager): log recovered validation panic at Error, not Warn
bdchatham Jul 1, 2026
0c2b78e
test(configmanager): cover the fresh-home ErrNotExist advisory branch
bdchatham Jul 1, 2026
15781a2
test(configmanager): make quoted-scalar corpus case real, fix overclaim
bdchatham Jul 1, 2026
84c42e5
Merge branch 'main' into feat/configmanager-v2-body-plt775
bdchatham Jul 1, 2026
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
129 changes: 86 additions & 43 deletions cmd/seid/cmd/configmanager/configmanager.go
Original file line number Diff line number Diff line change
@@ -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) {
Comment thread
bdchatham marked this conversation as resolved.
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 {
Comment thread
bdchatham marked this conversation as resolved.
case "", "legacy":
Expand Down
34 changes: 30 additions & 4 deletions cmd/seid/cmd/configmanager/configmanager_test.go
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
}
23 changes: 23 additions & 0 deletions cmd/seid/cmd/configmanager/doc.go
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading