Skip to content
Merged
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
9 changes: 9 additions & 0 deletions sdk/sei/provider/k8s/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
32 changes: 32 additions & 0 deletions sdk/sei/provider/k8s/render_test.go
Original file line number Diff line number Diff line change
@@ -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"
)

Expand Down Expand Up @@ -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)
}
}
51 changes: 51 additions & 0 deletions sdk/sei/sei.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ package sei

import (
"context"
"encoding/json"
"os"
"regexp"
"slices"
"strings"
)
Expand Down Expand Up @@ -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
}
15 changes: 15 additions & 0 deletions sdk/sei/spec.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,13 +86,28 @@ 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
// 1. -> spec.fullNode.snapshot{stateSync{}, rpcServers}.
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
Expand Down
54 changes: 53 additions & 1 deletion sdk/sei/spec_test.go
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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 {
Expand Down Expand Up @@ -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) {
Expand Down
7 changes: 7 additions & 0 deletions test/integration/benchmark_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
17 changes: 11 additions & 6 deletions test/integration/harness_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Loading