From 6dee5dfce2a67d3e9420073c24b6f3184dbc12a4 Mon Sep 17 00:00:00 2001 From: bdchatham Date: Tue, 13 Oct 2026 08:34:45 -0700 Subject: [PATCH 1/2] fix(integration): turn off EVM rate limiting on the benchmark's RPC nodes seid now limits EVM JSON-RPC to 200 requests a second per client IP by default (sei-chain#4403). The nightly benchmark sends all its load from one sei-load pod. Since 2026-10-08 the steady-state run fails before it sends, because the funder's balance reads get 429. The saturation run still passes its loose inclusion gate, but its included count fell about tenfold. sei-config does not know the rate-limit keys, so spec.overrides rejects them. The SDK's NodeSpec gains ConfigValues, which maps to spec.configValues and takes any key. The benchmark sets evm.rate_limiting_enabled = false in app.toml on its RPC followers. Co-Authored-By: Claude Opus 5.5 --- sdk/sei/provider/k8s/render.go | 9 ++++++++ sdk/sei/provider/k8s/render_test.go | 32 +++++++++++++++++++++++++++++ sdk/sei/sei.go | 19 +++++++++++++++++ sdk/sei/spec.go | 15 ++++++++++++++ sdk/sei/spec_test.go | 20 ++++++++++++++++++ test/integration/benchmark_test.go | 7 +++++++ test/integration/harness_test.go | 17 +++++++++------ 7 files changed, 113 insertions(+), 6 deletions(-) diff --git a/sdk/sei/provider/k8s/render.go b/sdk/sei/provider/k8s/render.go index c6626456..de43d655 100644 --- a/sdk/sei/provider/k8s/render.go +++ b/sdk/sei/provider/k8s/render.go @@ -130,6 +130,15 @@ func renderNode(spec sei.NodeSpec, namespace, networkNS string) *seiv1alpha1.Sei if len(spec.Config) > 0 { node.Spec.Overrides = maps.Clone(spec.Config) } + for _, v := range spec.ConfigValues { + // validateNodeSpec has already refused a value with no JSON form. + raw, _ := json.Marshal(v.Value) + node.Spec.ConfigValues = append(node.Spec.ConfigValues, seiv1alpha1.ConfigValue{ + FileName: v.FileName, + Key: v.Key, + Value: apiextensionsv1.JSON{Raw: raw}, + }) + } if ss := spec.StateSync; ss != nil { // Bootstrap via CometBFT state sync: the witnesses are the caller's bare // host:port RpcServers; genesis block-sync leaves Snapshot nil (unchanged). diff --git a/sdk/sei/provider/k8s/render_test.go b/sdk/sei/provider/k8s/render_test.go index 78859406..ba6d0f71 100644 --- a/sdk/sei/provider/k8s/render_test.go +++ b/sdk/sei/provider/k8s/render_test.go @@ -1,9 +1,13 @@ package k8s import ( + "reflect" "slices" "testing" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + + seiv1alpha1 "github.com/sei-protocol/sei-k8s-controller/api/v1alpha1" "github.com/sei-protocol/sei-k8s-controller/sdk/sei" ) @@ -218,3 +222,31 @@ func TestRenderNode_ConfigIntoOverrides(t *testing.T) { t.Errorf("node overrides = %v, want config.moniker=rpc-0", node.Spec.Overrides) } } + +// appToml is the config file the ConfigValues render cases write to. +const appToml = "app.toml" + +func TestRenderNode_ConfigValuesKeepTheirJSONType(t *testing.T) { + spec := sei.NodeSpec{ + Name: rpc0Name, Network: testNet, Image: testImage, + ConfigValues: []sei.ConfigValue{ + {FileName: appToml, Key: "evm.rate_limiting_enabled", Value: false}, + {FileName: appToml, Key: "giga.execution.parse_workers", Value: 16}, + }, + } + node := renderNode(spec, testNS, testNS) + want := []seiv1alpha1.ConfigValue{ + {FileName: appToml, Key: "evm.rate_limiting_enabled", Value: apiextensionsv1.JSON{Raw: []byte("false")}}, + {FileName: appToml, Key: "giga.execution.parse_workers", Value: apiextensionsv1.JSON{Raw: []byte("16")}}, + } + if !reflect.DeepEqual(node.Spec.ConfigValues, want) { + t.Errorf("node configValues = %+v, want %+v", node.Spec.ConfigValues, want) + } +} + +func TestRenderNode_NoConfigValuesLeavesTheFieldUnset(t *testing.T) { + node := renderNode(sei.NodeSpec{Name: rpc0Name, Network: testNet, Image: testImage}, testNS, testNS) + if node.Spec.ConfigValues != nil { + t.Errorf("node configValues = %+v, want nil", node.Spec.ConfigValues) + } +} diff --git a/sdk/sei/sei.go b/sdk/sei/sei.go index 7f2465a7..ae9eaa17 100644 --- a/sdk/sei/sei.go +++ b/sdk/sei/sei.go @@ -20,6 +20,7 @@ package sei import ( "context" + "encoding/json" "os" "slices" "strings" @@ -233,5 +234,23 @@ func validateNodeSpec(s NodeSpec) error { return usageErr("NodeSpec.StateSync.RpcServers must have >= 2 DISTINCT entries (the state-sync witness floor: served CRD MinItems=2 listType=set + the controller's canonical-syncer floor), got %d distinct", distinct) } } + return validateConfigValues(s.ConfigValues) +} + +// validateConfigValues refuses an entry the CRD would reject: no file name, no +// key, or a value with no JSON form. The k8s provider renders each value +// through json.Marshal, so a value that passes here always renders. +func validateConfigValues(values []ConfigValue) error { + for _, v := range values { + if strings.TrimSpace(v.FileName) == "" || strings.TrimSpace(v.Key) == "" { + return usageErr("NodeSpec.ConfigValues entry needs a FileName and a Key, got %q and %q", v.FileName, v.Key) + } + if v.Value == nil { + return usageErr("NodeSpec.ConfigValues %s %s: Value is required (the CRD rejects null)", v.FileName, v.Key) + } + if _, err := json.Marshal(v.Value); err != nil { + return usageErr("NodeSpec.ConfigValues %s %s: Value has no JSON form: %v", v.FileName, v.Key, err) + } + } return nil } diff --git a/sdk/sei/spec.go b/sdk/sei/spec.go index 72dcdc0c..6fc5fd71 100644 --- a/sdk/sei/spec.go +++ b/sdk/sei/spec.go @@ -86,6 +86,12 @@ type NodeSpec struct { Config map[string]string // -> spec.overrides (TOML-path keys) Labels map[string]string // extra labels on the SeiNode object, merged UNDER the canonical sei.io/role + sei.io/seinetwork (which win on key conflict) + // ConfigValues sets typed values in seid's config files with no check + // against the sei-config key list, so it reaches a seid key that Config + // rejects as unknown. It wins over Config on the same key. -> + // spec.configValues. + ConfigValues []ConfigValue + // StateSync bootstraps the node through CometBFT state sync instead of // genesis block-sync; nil => genesis block-sync (unchanged). Set it to bring // a follower up from a peer-served snapshot rather than replaying from height @@ -93,6 +99,15 @@ type NodeSpec struct { StateSync *NodeStateSync } +// ConfigValue is one typed value at a dotted TOML path in a seid config file. +// Mirrors seiv1alpha1.ConfigValue. Value keeps its Go type through JSON into +// the TOML file, so false lands as a TOML boolean and 16 as an integer. +type ConfigValue struct { + FileName string // config.toml or app.toml + Key string // dotted TOML path, e.g. evm.rate_limiting_enabled + Value any // a JSON-encodable, non-nil value +} + // NodeStateSync configures a SeiNode to bootstrap via CometBFT state sync. type NodeStateSync struct { // RpcServers are the light-client witnesses used for trust-point acquisition diff --git a/sdk/sei/spec_test.go b/sdk/sei/spec_test.go index 00e46ff5..e0e1de97 100644 --- a/sdk/sei/spec_test.go +++ b/sdk/sei/spec_test.go @@ -12,6 +12,13 @@ const ( witnessB = "b:26657" ) +// appToml and rateLimitKey name the config value the ConfigValues validation +// cases set. +const ( + appToml = "app.toml" + rateLimitKey = "evm.rate_limiting_enabled" +) + func TestValidateNetworkSpec(t *testing.T) { good := NetworkSpec{Name: "net", Image: "img", Validators: 1} cases := []struct { @@ -49,6 +56,19 @@ func TestValidateNodeSpec(t *testing.T) { {"statesync one witness", func(s *NodeSpec) { s.StateSync = &NodeStateSync{RpcServers: []string{witnessA}} }, true}, {"statesync two identical witnesses", func(s *NodeSpec) { s.StateSync = &NodeStateSync{RpcServers: []string{witnessA, witnessA}} }, true}, {"statesync two distinct witnesses", func(s *NodeSpec) { s.StateSync = &NodeStateSync{RpcServers: []string{witnessA, witnessB}} }, false}, + {"config value", func(s *NodeSpec) { + s.ConfigValues = []ConfigValue{{FileName: appToml, Key: rateLimitKey, Value: false}} + }, false}, + {"config value after valid statesync", func(s *NodeSpec) { + s.StateSync = &NodeStateSync{RpcServers: []string{witnessA, witnessB}} + s.ConfigValues = []ConfigValue{{FileName: appToml, Key: rateLimitKey}} + }, true}, + {"config value missing file name", func(s *NodeSpec) { s.ConfigValues = []ConfigValue{{Key: rateLimitKey, Value: false}} }, true}, + {"config value missing key", func(s *NodeSpec) { s.ConfigValues = []ConfigValue{{FileName: appToml, Value: false}} }, true}, + {"config value nil", func(s *NodeSpec) { s.ConfigValues = []ConfigValue{{FileName: appToml, Key: rateLimitKey}} }, true}, + {"config value with no JSON form", func(s *NodeSpec) { + s.ConfigValues = []ConfigValue{{FileName: appToml, Key: rateLimitKey, Value: make(chan int)}} + }, true}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { diff --git a/test/integration/benchmark_test.go b/test/integration/benchmark_test.go index 34f3eeb5..73561065 100644 --- a/test/integration/benchmark_test.go +++ b/test/integration/benchmark_test.go @@ -115,6 +115,13 @@ func TestNightlyBenchmark(t *testing.T) { "evm.worker_queue_size": "4000", "evm.max_tx_pool_txs": "10000", }, + // seid limits EVM JSON-RPC to 200 requests a second per client IP + // by default. sei-load sends from one pod, so with the limit on, + // the run measures the limiter rather than the chain, and the + // funder's balance reads fail with 429. + rpcConfigValues: []sei.ConfigValue{ + {FileName: "app.toml", Key: "evm.rate_limiting_enabled", Value: false}, + }, } if w.fund { root, err := keygen.DeriveEVM() diff --git a/test/integration/harness_test.go b/test/integration/harness_test.go index 4c1007dd..bfc961ac 100644 --- a/test/integration/harness_test.go +++ b/test/integration/harness_test.go @@ -96,6 +96,10 @@ type spec struct { rpcConfig map[string]string // extra per-RPC-node config overlaid on storageConfig (e.g. EVM tuning) accounts []sei.GenesisAccount // genesis-funded non-validator accounts (release funds an admin); nil funds none + // rpcConfigValues are per-RPC-node seid keys that sei-config does not know, + // so rpcConfig cannot carry them. + rpcConfigValues []sei.ConfigValue + // seiload inputs (load suite) seiloadImage string // sei-load benchmark image seiloadProfile string // profile name in the seiload-profiles ConfigMap @@ -204,12 +208,13 @@ func provision(ctx context.Context, t *testing.T, c *sei.Client, s spec) (*chain for i := range s.rpcNodes { name := rpcNodeName(s.chainID, i) node, err := createRPCNode(ctx, t, c, sei.NodeSpec{ - Name: name, - Network: s.chainID, - Namespace: s.namespace, - Image: s.seidImage, - Labels: runLabels, - Config: mergeConfig(s.storageConfig, s.rpcConfig), + Name: name, + Network: s.chainID, + Namespace: s.namespace, + Image: s.seidImage, + Labels: runLabels, + Config: mergeConfig(s.storageConfig, s.rpcConfig), + ConfigValues: s.rpcConfigValues, }) if node != nil { ch.rpcNodes = append(ch.rpcNodes, node) From 0fad38154e326cb08676735bc0c72ef6c5d0f372 Mon Sep 17 00:00:00 2001 From: bdchatham Date: Tue, 13 Oct 2026 08:47:53 -0700 Subject: [PATCH 2/2] fix(sdk): refuse every config value the CRD would reject validateConfigValues checked only for blank fields and an untyped nil. A typed nil such as a nil *bool encodes as null without an error, and a malformed file name, a malformed key or a repeated (file, key) pair passed here and failed at the API server instead. The check now reads the encoded value and mirrors the CRD's patterns, lengths, item limit and map keys. Co-Authored-By: Claude Opus 5.5 --- sdk/sei/sei.go | 46 +++++++++++++++++++++++++++++++++++++------- sdk/sei/spec.go | 2 +- sdk/sei/spec_test.go | 34 +++++++++++++++++++++++++++++++- 3 files changed, 73 insertions(+), 9 deletions(-) diff --git a/sdk/sei/sei.go b/sdk/sei/sei.go index ae9eaa17..238d90e5 100644 --- a/sdk/sei/sei.go +++ b/sdk/sei/sei.go @@ -22,6 +22,7 @@ import ( "context" "encoding/json" "os" + "regexp" "slices" "strings" ) @@ -237,20 +238,51 @@ func validateNodeSpec(s NodeSpec) error { return validateConfigValues(s.ConfigValues) } -// validateConfigValues refuses an entry the CRD would reject: no file name, no -// key, or a value with no JSON form. The k8s provider renders each value +// The limits below mirror the CRD's validation of seiv1alpha1.ConfigValue, so a +// bad entry fails here as a usage error rather than at the API server. +const ( + maxConfigValues = 100 // spec.configValues MaxItems + maxConfigFileNameLen = 64 + maxConfigKeyLen = 256 + configFileNamePattern = `^[A-Za-z0-9_-]+\.toml$` + configKeyPattern = `^[A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+)*$` +) + +var ( + configFileNameRE = regexp.MustCompile(configFileNamePattern) + configKeyRE = regexp.MustCompile(configKeyPattern) +) + +// validateConfigValues refuses what the CRD would reject: too many entries, a +// malformed file name or key, a repeated (file, key) pair, and a value whose +// JSON form is null or does not exist. The k8s provider renders each value // through json.Marshal, so a value that passes here always renders. func validateConfigValues(values []ConfigValue) error { + if len(values) > maxConfigValues { + return usageErr("NodeSpec.ConfigValues has %d entries, want at most %d", len(values), maxConfigValues) + } + seen := make(map[[2]string]bool, len(values)) for _, v := range values { - if strings.TrimSpace(v.FileName) == "" || strings.TrimSpace(v.Key) == "" { - return usageErr("NodeSpec.ConfigValues entry needs a FileName and a Key, got %q and %q", v.FileName, v.Key) + if len(v.FileName) > maxConfigFileNameLen || !configFileNameRE.MatchString(v.FileName) { + return usageErr("NodeSpec.ConfigValues file name %q must match %s, at most %d characters", v.FileName, configFileNamePattern, maxConfigFileNameLen) } - if v.Value == nil { - return usageErr("NodeSpec.ConfigValues %s %s: Value is required (the CRD rejects null)", v.FileName, v.Key) + if len(v.Key) > maxConfigKeyLen || !configKeyRE.MatchString(v.Key) { + return usageErr("NodeSpec.ConfigValues key %q must match %s, at most %d characters", v.Key, configKeyPattern, maxConfigKeyLen) + } + entry := [2]string{v.FileName, v.Key} + if seen[entry] { + return usageErr("NodeSpec.ConfigValues sets %s %s twice", v.FileName, v.Key) } - if _, err := json.Marshal(v.Value); err != nil { + seen[entry] = true + // A typed nil, such as a nil *bool, map or slice, encodes as null + // without an error, so the check reads the encoded form. + raw, err := json.Marshal(v.Value) + if err != nil { return usageErr("NodeSpec.ConfigValues %s %s: Value has no JSON form: %v", v.FileName, v.Key, err) } + if string(raw) == "null" { + return usageErr("NodeSpec.ConfigValues %s %s: Value is required (the CRD rejects null)", v.FileName, v.Key) + } } return nil } diff --git a/sdk/sei/spec.go b/sdk/sei/spec.go index 6fc5fd71..60ee0507 100644 --- a/sdk/sei/spec.go +++ b/sdk/sei/spec.go @@ -105,7 +105,7 @@ type NodeSpec struct { type ConfigValue struct { FileName string // config.toml or app.toml Key string // dotted TOML path, e.g. evm.rate_limiting_enabled - Value any // a JSON-encodable, non-nil value + Value any // JSON-encodable, and not null once encoded } // NodeStateSync configures a SeiNode to bootstrap via CometBFT state sync. diff --git a/sdk/sei/spec_test.go b/sdk/sei/spec_test.go index e0e1de97..6d29f4fc 100644 --- a/sdk/sei/spec_test.go +++ b/sdk/sei/spec_test.go @@ -1,6 +1,9 @@ package sei -import "testing" +import ( + "fmt" + "testing" +) // witnessA and witnessB are the two distinct bare host:port light-client // witnesses reused across the state-sync validation tests in this package. @@ -69,6 +72,35 @@ func TestValidateNodeSpec(t *testing.T) { {"config value with no JSON form", func(s *NodeSpec) { s.ConfigValues = []ConfigValue{{FileName: appToml, Key: rateLimitKey, Value: make(chan int)}} }, true}, + {"config value typed nil pointer", func(s *NodeSpec) { + s.ConfigValues = []ConfigValue{{FileName: appToml, Key: rateLimitKey, Value: (*bool)(nil)}} + }, true}, + {"config value nil map", func(s *NodeSpec) { + s.ConfigValues = []ConfigValue{{FileName: appToml, Key: rateLimitKey, Value: map[string]any(nil)}} + }, true}, + {"config value file name without .toml", func(s *NodeSpec) { + s.ConfigValues = []ConfigValue{{FileName: "app", Key: rateLimitKey, Value: false}} + }, true}, + {"config value key with an empty segment", func(s *NodeSpec) { + s.ConfigValues = []ConfigValue{{FileName: appToml, Key: "evm..rate_limiting_enabled", Value: false}} + }, true}, + {"config value set twice", func(s *NodeSpec) { + s.ConfigValues = []ConfigValue{ + {FileName: appToml, Key: rateLimitKey, Value: false}, + {FileName: appToml, Key: rateLimitKey, Value: true}, + } + }, true}, + {"same key in two files", func(s *NodeSpec) { + s.ConfigValues = []ConfigValue{ + {FileName: appToml, Key: rateLimitKey, Value: false}, + {FileName: "config.toml", Key: rateLimitKey, Value: false}, + } + }, false}, + {"config values over the CRD limit", func(s *NodeSpec) { + for i := range 101 { + s.ConfigValues = append(s.ConfigValues, ConfigValue{FileName: appToml, Key: fmt.Sprintf("k%d", i), Value: i}) + } + }, true}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) {