Skip to content

Commit 5ec9b4e

Browse files
committed
feat: initial sei-config package with unified config types and IO
Implements the foundation of the shared configuration library for Sei nodes: - SeiConfig struct unifying all config.toml + app.toml fields into a single typed hierarchy (Chain, Network, Consensus, Mempool, StateSync, Storage, EVM, API, Metrics, and more) - NodeMode type with 6 modes: validator, full, seed, archive, rpc, indexer - DefaultForMode() with mode-aware defaults matching existing sei-chain behavior - Validate() returning typed diagnostics (Error/Warning/Info) with cross-field checks - ReadConfigFromDir/WriteConfigToDir for the legacy two-file layout (config.toml + app.toml) with atomic writes (temp file + rename) - ApplyOverrides() for dotted-key config patching (sidecar ConfigApplyTask) - ResolveEnv() with SEI_/SEID_ dual-prefix support and deprecation warnings - Custom Duration type for human-readable TOML duration values - Legacy types with exact TOML tag mapping for backward-compatible serialization - 22 passing tests covering defaults, validation, IO round-trips, overrides, env resolution, and type conversions
0 parents  commit 5ec9b4e

10 files changed

Lines changed: 2921 additions & 0 deletions

File tree

config.go

Lines changed: 464 additions & 0 deletions
Large diffs are not rendered by default.

config_test.go

Lines changed: 382 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,382 @@
1+
package seiconfig
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
"time"
8+
)
9+
10+
func TestDefaultForMode_AllModesValid(t *testing.T) {
11+
modes := []NodeMode{ModeValidator, ModeFull, ModeSeed, ModeArchive, ModeRPC, ModeIndexer}
12+
for _, mode := range modes {
13+
cfg := DefaultForMode(mode)
14+
if cfg.Mode != mode {
15+
t.Errorf("DefaultForMode(%s): got mode %s", mode, cfg.Mode)
16+
}
17+
if cfg.Version != CurrentVersion {
18+
t.Errorf("DefaultForMode(%s): got version %d, want %d", mode, cfg.Version, CurrentVersion)
19+
}
20+
21+
result := Validate(cfg)
22+
if result.HasErrors() {
23+
t.Errorf("DefaultForMode(%s) produced validation errors: %v", mode, result.Errors())
24+
}
25+
}
26+
}
27+
28+
func TestDefaultForMode_ValidatorDisablesServices(t *testing.T) {
29+
cfg := DefaultForMode(ModeValidator)
30+
31+
if cfg.API.REST.Enable {
32+
t.Error("validator should have REST API disabled")
33+
}
34+
if cfg.API.GRPC.Enable {
35+
t.Error("validator should have gRPC disabled")
36+
}
37+
if cfg.EVM.HTTPEnabled {
38+
t.Error("validator should have EVM HTTP disabled")
39+
}
40+
if cfg.EVM.WSEnabled {
41+
t.Error("validator should have EVM WS disabled")
42+
}
43+
if cfg.Storage.StateStore.Enable {
44+
t.Error("validator should have state store disabled")
45+
}
46+
}
47+
48+
func TestDefaultForMode_SeedHighConnections(t *testing.T) {
49+
cfg := DefaultForMode(ModeSeed)
50+
51+
if cfg.Network.P2P.MaxConnections != 1000 {
52+
t.Errorf("seed max_connections: got %d, want 1000", cfg.Network.P2P.MaxConnections)
53+
}
54+
if !cfg.Network.P2P.AllowDuplicateIP {
55+
t.Error("seed should allow duplicate IPs")
56+
}
57+
if cfg.Storage.PruningStrategy != "everything" {
58+
t.Errorf("seed pruning: got %s, want everything", cfg.Storage.PruningStrategy)
59+
}
60+
}
61+
62+
func TestDefaultForMode_ArchiveKeepsAll(t *testing.T) {
63+
cfg := DefaultForMode(ModeArchive)
64+
65+
if cfg.Storage.PruningStrategy != "nothing" {
66+
t.Errorf("archive pruning: got %s, want nothing", cfg.Storage.PruningStrategy)
67+
}
68+
if cfg.Storage.StateStore.KeepRecent != 0 {
69+
t.Errorf("archive state_store.keep_recent: got %d, want 0", cfg.Storage.StateStore.KeepRecent)
70+
}
71+
if cfg.Chain.MinRetainBlocks != 0 {
72+
t.Errorf("archive min_retain_blocks: got %d, want 0", cfg.Chain.MinRetainBlocks)
73+
}
74+
if cfg.EVM.MaxTraceLookbackBlocks != -1 {
75+
t.Errorf("archive max_trace_lookback_blocks: got %d, want -1", cfg.EVM.MaxTraceLookbackBlocks)
76+
}
77+
}
78+
79+
func TestDefaultForMode_FullEnablesServices(t *testing.T) {
80+
cfg := DefaultForMode(ModeFull)
81+
82+
if !cfg.API.REST.Enable {
83+
t.Error("full should have REST API enabled")
84+
}
85+
if !cfg.API.GRPC.Enable {
86+
t.Error("full should have gRPC enabled")
87+
}
88+
if !cfg.EVM.HTTPEnabled {
89+
t.Error("full should have EVM HTTP enabled")
90+
}
91+
if cfg.Network.RPC.ListenAddress != "tcp://0.0.0.0:26657" {
92+
t.Errorf("full RPC listen: got %s, want tcp://0.0.0.0:26657", cfg.Network.RPC.ListenAddress)
93+
}
94+
}
95+
96+
func TestValidate_InvalidMode(t *testing.T) {
97+
cfg := Default()
98+
cfg.Mode = "bogus"
99+
result := Validate(cfg)
100+
if !result.HasErrors() {
101+
t.Error("expected error for invalid mode")
102+
}
103+
}
104+
105+
func TestValidate_EmptyMinGasPrices(t *testing.T) {
106+
cfg := Default()
107+
cfg.Chain.MinGasPrices = ""
108+
result := Validate(cfg)
109+
if !result.HasErrors() {
110+
t.Error("expected error for empty min_gas_prices")
111+
}
112+
}
113+
114+
func TestValidate_InvalidPruningStrategy(t *testing.T) {
115+
cfg := Default()
116+
cfg.Storage.PruningStrategy = "aggressive"
117+
result := Validate(cfg)
118+
if !result.HasErrors() {
119+
t.Error("expected error for invalid pruning strategy")
120+
}
121+
}
122+
123+
func TestValidate_PruningEverythingWithSnapshots(t *testing.T) {
124+
cfg := Default()
125+
cfg.Storage.PruningStrategy = "everything"
126+
cfg.Storage.SnapshotInterval = 1000
127+
result := Validate(cfg)
128+
if !result.HasErrors() {
129+
t.Error("expected error for snapshots with everything pruning")
130+
}
131+
}
132+
133+
func TestValidate_InvalidLogFormat(t *testing.T) {
134+
cfg := Default()
135+
cfg.Logging.Format = "xml"
136+
result := Validate(cfg)
137+
if !result.HasErrors() {
138+
t.Error("expected error for invalid log format")
139+
}
140+
}
141+
142+
func TestValidate_EVMOnValidator(t *testing.T) {
143+
cfg := DefaultForMode(ModeValidator)
144+
cfg.EVM.HTTPEnabled = true
145+
result := Validate(cfg)
146+
hasWarning := false
147+
for _, d := range result.Diagnostics {
148+
if d.Severity == SeverityWarning && d.Field == "evm" {
149+
hasWarning = true
150+
break
151+
}
152+
}
153+
if !hasWarning {
154+
t.Error("expected warning for EVM on validator")
155+
}
156+
}
157+
158+
func TestWriteReadRoundTrip(t *testing.T) {
159+
dir := t.TempDir()
160+
161+
original := DefaultForMode(ModeFull)
162+
// Note: ChainID is stored in genesis.json, not config.toml/app.toml,
163+
// so it does not round-trip through the legacy two-file format.
164+
original.Chain.Moniker = "test-node"
165+
original.EVM.HTTPPort = 9545
166+
original.Storage.StateStore.KeepRecent = 50000
167+
168+
if err := WriteConfigToDir(original, dir); err != nil {
169+
t.Fatalf("WriteConfigToDir: %v", err)
170+
}
171+
172+
configPath := filepath.Join(dir, "config", "config.toml")
173+
appPath := filepath.Join(dir, "config", "app.toml")
174+
if _, err := os.Stat(configPath); err != nil {
175+
t.Fatalf("config.toml not created: %v", err)
176+
}
177+
if _, err := os.Stat(appPath); err != nil {
178+
t.Fatalf("app.toml not created: %v", err)
179+
}
180+
181+
loaded, err := ReadConfigFromDir(dir)
182+
if err != nil {
183+
t.Fatalf("ReadConfigFromDir: %v", err)
184+
}
185+
186+
if loaded.Chain.Moniker != "test-node" {
187+
t.Errorf("moniker: got %q, want %q", loaded.Chain.Moniker, "test-node")
188+
}
189+
if loaded.EVM.HTTPPort != 9545 {
190+
t.Errorf("evm.http_port: got %d, want 9545", loaded.EVM.HTTPPort)
191+
}
192+
if loaded.Storage.StateStore.KeepRecent != 50000 {
193+
t.Errorf("state_store.keep_recent: got %d, want 50000", loaded.Storage.StateStore.KeepRecent)
194+
}
195+
if loaded.Network.RPC.ListenAddress != "tcp://0.0.0.0:26657" {
196+
t.Errorf("rpc.listen_address: got %q", loaded.Network.RPC.ListenAddress)
197+
}
198+
}
199+
200+
func TestWriteReadRoundTrip_AllModes(t *testing.T) {
201+
modes := []NodeMode{ModeValidator, ModeFull, ModeSeed, ModeArchive, ModeRPC, ModeIndexer}
202+
for _, mode := range modes {
203+
t.Run(string(mode), func(t *testing.T) {
204+
dir := t.TempDir()
205+
original := DefaultForMode(mode)
206+
207+
if err := WriteConfigToDir(original, dir); err != nil {
208+
t.Fatalf("WriteConfigToDir: %v", err)
209+
}
210+
211+
loaded, err := ReadConfigFromDir(dir)
212+
if err != nil {
213+
t.Fatalf("ReadConfigFromDir: %v", err)
214+
}
215+
216+
if loaded.Chain.MinGasPrices != original.Chain.MinGasPrices {
217+
t.Errorf("min_gas_prices: got %q, want %q",
218+
loaded.Chain.MinGasPrices, original.Chain.MinGasPrices)
219+
}
220+
if loaded.Storage.PruningStrategy != original.Storage.PruningStrategy {
221+
t.Errorf("pruning: got %q, want %q",
222+
loaded.Storage.PruningStrategy, original.Storage.PruningStrategy)
223+
}
224+
})
225+
}
226+
}
227+
228+
func TestApplyOverrides(t *testing.T) {
229+
cfg := Default()
230+
overrides := map[string]string{
231+
"evm.http_port": "9545",
232+
"chain.min_gas_prices": "0.1usei",
233+
"storage.pruning": "custom",
234+
}
235+
236+
if err := ApplyOverrides(cfg, overrides); err != nil {
237+
t.Fatalf("ApplyOverrides: %v", err)
238+
}
239+
240+
if cfg.EVM.HTTPPort != 9545 {
241+
t.Errorf("evm.http_port: got %d, want 9545", cfg.EVM.HTTPPort)
242+
}
243+
if cfg.Chain.MinGasPrices != "0.1usei" {
244+
t.Errorf("chain.min_gas_prices: got %q, want %q", cfg.Chain.MinGasPrices, "0.1usei")
245+
}
246+
if cfg.Storage.PruningStrategy != "custom" {
247+
t.Errorf("storage.pruning: got %q, want %q", cfg.Storage.PruningStrategy, "custom")
248+
}
249+
}
250+
251+
func TestApplyOverrides_Empty(t *testing.T) {
252+
cfg := Default()
253+
original := cfg.EVM.HTTPPort
254+
if err := ApplyOverrides(cfg, nil); err != nil {
255+
t.Fatalf("ApplyOverrides(nil): %v", err)
256+
}
257+
if cfg.EVM.HTTPPort != original {
258+
t.Error("nil overrides should not change config")
259+
}
260+
}
261+
262+
func TestResolveEnv(t *testing.T) {
263+
cfg := Default()
264+
t.Setenv("SEI_CHAIN_MIN_GAS_PRICES", "0.5usei")
265+
266+
warnings := ResolveEnv(cfg)
267+
if cfg.Chain.MinGasPrices != "0.5usei" {
268+
t.Errorf("after ResolveEnv: got %q, want %q", cfg.Chain.MinGasPrices, "0.5usei")
269+
}
270+
for _, w := range warnings {
271+
t.Logf("warning: %s", w)
272+
}
273+
}
274+
275+
func TestResolveEnv_LegacyPrefix(t *testing.T) {
276+
cfg := Default()
277+
t.Setenv("SEID_CHAIN_MIN_GAS_PRICES", "0.3usei")
278+
279+
warnings := ResolveEnv(cfg)
280+
if cfg.Chain.MinGasPrices != "0.3usei" {
281+
t.Errorf("after ResolveEnv with SEID_: got %q, want %q", cfg.Chain.MinGasPrices, "0.3usei")
282+
}
283+
hasDeprecation := false
284+
for _, w := range warnings {
285+
if w != "" {
286+
hasDeprecation = true
287+
}
288+
}
289+
if !hasDeprecation {
290+
t.Error("expected deprecation warning for SEID_ prefix")
291+
}
292+
}
293+
294+
func TestResolveEnv_SEIPrecedence(t *testing.T) {
295+
cfg := Default()
296+
t.Setenv("SEI_CHAIN_MIN_GAS_PRICES", "0.5usei")
297+
t.Setenv("SEID_CHAIN_MIN_GAS_PRICES", "0.3usei")
298+
299+
ResolveEnv(cfg)
300+
if cfg.Chain.MinGasPrices != "0.5usei" {
301+
t.Errorf("SEI_ should take precedence: got %q, want %q", cfg.Chain.MinGasPrices, "0.5usei")
302+
}
303+
}
304+
305+
func TestDuration_MarshalUnmarshal(t *testing.T) {
306+
d := Dur(10 * time.Second)
307+
text, err := d.MarshalText()
308+
if err != nil {
309+
t.Fatalf("MarshalText: %v", err)
310+
}
311+
if string(text) != "10s" {
312+
t.Errorf("MarshalText: got %q, want %q", string(text), "10s")
313+
}
314+
315+
var d2 Duration
316+
if err := d2.UnmarshalText(text); err != nil {
317+
t.Fatalf("UnmarshalText: %v", err)
318+
}
319+
if d2.Duration != d.Duration {
320+
t.Errorf("round-trip: got %v, want %v", d2.Duration, d.Duration)
321+
}
322+
}
323+
324+
func TestDuration_InvalidParse(t *testing.T) {
325+
var d Duration
326+
if err := d.UnmarshalText([]byte("not-a-duration")); err == nil {
327+
t.Error("expected error for invalid duration")
328+
}
329+
}
330+
331+
func TestNodeMode_Validity(t *testing.T) {
332+
tests := []struct {
333+
mode NodeMode
334+
valid bool
335+
}{
336+
{ModeValidator, true},
337+
{ModeFull, true},
338+
{ModeSeed, true},
339+
{ModeArchive, true},
340+
{ModeRPC, true},
341+
{ModeIndexer, true},
342+
{"bogus", false},
343+
{"", false},
344+
}
345+
for _, tt := range tests {
346+
if got := tt.mode.IsValid(); got != tt.valid {
347+
t.Errorf("NodeMode(%q).IsValid() = %v, want %v", tt.mode, got, tt.valid)
348+
}
349+
}
350+
}
351+
352+
func TestNodeMode_IsFullnodeType(t *testing.T) {
353+
fullnodeTypes := []NodeMode{ModeFull, ModeArchive, ModeRPC, ModeIndexer}
354+
for _, m := range fullnodeTypes {
355+
if !m.IsFullnodeType() {
356+
t.Errorf("%s should be fullnode type", m)
357+
}
358+
}
359+
nonFullnodeTypes := []NodeMode{ModeValidator, ModeSeed}
360+
for _, m := range nonFullnodeTypes {
361+
if m.IsFullnodeType() {
362+
t.Errorf("%s should not be fullnode type", m)
363+
}
364+
}
365+
}
366+
367+
func TestWriteMode_Validity(t *testing.T) {
368+
if !WriteModeCosmosOnly.IsValid() {
369+
t.Error("cosmos_only should be valid")
370+
}
371+
if WriteMode("invalid").IsValid() {
372+
t.Error("'invalid' should not be valid")
373+
}
374+
}
375+
376+
func TestLegacyTendermintMode_ArchiveMapped(t *testing.T) {
377+
cfg := DefaultForMode(ModeArchive)
378+
tm := cfg.toLegacyTendermint()
379+
if tm.Mode != "full" {
380+
t.Errorf("archive should map to tendermint mode 'full', got %q", tm.Mode)
381+
}
382+
}

0 commit comments

Comments
 (0)