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..238d90e5 100644 --- a/sdk/sei/sei.go +++ b/sdk/sei/sei.go @@ -20,7 +20,9 @@ package sei import ( "context" + "encoding/json" "os" + "regexp" "slices" "strings" ) @@ -233,5 +235,54 @@ 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) +} + +// 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 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 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) + } + 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 72dcdc0c..60ee0507 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 // JSON-encodable, and not null once encoded +} + // 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..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. @@ -12,6 +15,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 +59,48 @@ 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}, + {"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) { 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)