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
175 changes: 175 additions & 0 deletions internal/cliutil/configvalue.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
package cliutil

import (
"encoding/json"
"fmt"
"regexp"
"strings"

"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
)

// MaxConfigValues mirrors the CRD's MaxItems on spec.configValues.
const MaxConfigValues = 100

var (
configValueFileNamePattern = regexp.MustCompile(`^[A-Za-z0-9_-]+\.toml$`)
configValueKeyPattern = regexp.MustCompile(`^[A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+)*$`)
)

// ParseConfigValue parses a `<file>.toml:<dotted.key>=<value>` expression
// into a spec.configValues entry. The value parses as JSON when it parses
// (number, bool, array, table, JSON-quoted string); otherwise it is stored
// as a string. The CRD constraints on fileName and key are checked here so
// a typo fails at render time rather than at Flux apply.
func ParseConfigValue(expr string) (map[string]interface{}, error) {
colon := strings.Index(expr, ":")
if colon < 0 {
return nil, fmt.Errorf("missing ':' — expected <file>.toml:<dotted.key>=<value>")
}
fileName := expr[:colon]
rest := expr[colon+1:]
eq := strings.Index(rest, "=")
if eq < 0 {
return nil, fmt.Errorf("missing '=' — expected <file>.toml:<dotted.key>=<value>")
}
key := rest[:eq]
val := rest[eq+1:]

if !configValueFileNamePattern.MatchString(fileName) || len(fileName) > 64 {
return nil, fmt.Errorf("fileName %q must match %s (at most 64 chars); only TOML files are accepted", fileName, configValueFileNamePattern.String())
}
if !configValueKeyPattern.MatchString(key) || len(key) > 256 {
return nil, fmt.Errorf("key %q must be a dotted TOML path matching %s (at most 256 chars)", key, configValueKeyPattern.String())
}
if val == "" {
return nil, fmt.Errorf("empty value for %s:%s — the CRD requires a value; to set an empty string pass '\"\"'", fileName, key)
}

parsed := parseTypedValue(val)
if parsed == nil {
return nil, fmt.Errorf("value null for %s:%s is rejected by the CRD; remove the entry instead of nulling it", fileName, key)
}
if containsNull(parsed) {
return nil, fmt.Errorf("value for %s:%s contains a nested null, which has no TOML representation and fails at plan-build", fileName, key)
}
return map[string]interface{}{
"fileName": fileName,
"key": key,
"value": parsed,
}, nil
}

// parseTypedValue decodes val as JSON, keeping integers exact (int64) rather
// than widening them to float64, so a TOML int like max_num_peers=9007199254740993
// survives the round trip. Anything that is not JSON is a plain string.
func parseTypedValue(val string) interface{} {
dec := json.NewDecoder(strings.NewReader(val))
dec.UseNumber()
var parsed interface{}
if err := dec.Decode(&parsed); err != nil {
return val
}
if dec.More() {
return val
}
return narrowNumbers(parsed)
}

func narrowNumbers(v interface{}) interface{} {
switch t := v.(type) {
case json.Number:
if i, err := t.Int64(); err == nil {
return i
}
if f, err := t.Float64(); err == nil {
return f
}
return t.String()
case []interface{}:
for i := range t {
t[i] = narrowNumbers(t[i])
}
case map[string]interface{}:
for k := range t {
t[k] = narrowNumbers(t[k])
}
}
return v
}

func containsNull(v interface{}) bool {
switch t := v.(type) {
case nil:
return true
case []interface{}:
for _, e := range t {
if containsNull(e) {
return true
}
}
case map[string]interface{}:
for _, e := range t {
if containsNull(e) {
return true
}
}
}
return false
}

// ApplyConfigValues merges parsed --config-value entries into the list at
// fieldPath (spec.configValues). An entry whose (fileName, key) already
// exists — from the preset or from --set — is replaced in place so the flag
// never duplicates a key the controller would then reject; new entries
// append in flag order.
func ApplyConfigValues(root map[string]interface{}, exprs []string, fieldPath ...string) error {
if len(exprs) == 0 {
return nil
}
existing, _, err := unstructured.NestedSlice(root, fieldPath...)
if err != nil {
return fmt.Errorf("read existing %s: %w", strings.Join(fieldPath, "."), err)
}
for _, expr := range exprs {
entry, parseErr := ParseConfigValue(expr)
if parseErr != nil {
return UsageError("apply --config-value %q: %s", expr, parseErr.Error())
}
replaced := false
for i, raw := range existing {
m, ok := raw.(map[string]interface{})
if ok && m["fileName"] == entry["fileName"] && m["key"] == entry["key"] {
existing[i] = entry
replaced = true
break
}
}
if !replaced {
existing = append(existing, entry)
}
}
if len(existing) > MaxConfigValues {
return UsageError("%s has %d entries; the CRD accepts at most %d", strings.Join(fieldPath, "."), len(existing), MaxConfigValues)
}
if dup := duplicateConfigKey(existing); dup != "" {
return UsageError("%s lists %s more than once (from the preset or --set); the controller rejects duplicate (fileName, key) pairs", strings.Join(fieldPath, "."), dup)
}
return unstructured.SetNestedSlice(root, existing, fieldPath...)
}

func duplicateConfigKey(entries []interface{}) string {
seen := map[string]bool{}
for _, raw := range entries {
m, ok := raw.(map[string]interface{})
if !ok {
continue
}
id := fmt.Sprintf("%v:%v", m["fileName"], m["key"])
if seen[id] {
return id
}
seen[id] = true
}
return ""
}
111 changes: 111 additions & 0 deletions internal/cliutil/configvalue_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
package cliutil

import (
"fmt"
"reflect"
"strings"
"testing"
)

func TestParseConfigValue(t *testing.T) {
cases := []struct {
name string
expr string
want map[string]interface{}
wantErr string
}{
{"bool", "config.toml:evm-only=true", map[string]interface{}{"fileName": "config.toml", "key": "evm-only", "value": true}, ""},
{"integer stays int64", "config.toml:p2p.max_num_inbound_peers=9007199254740993", map[string]interface{}{"fileName": "config.toml", "key": "p2p.max_num_inbound_peers", "value": int64(9007199254740993)}, ""},
{"float", "app.toml:x.ratio=0.25", map[string]interface{}{"fileName": "app.toml", "key": "x.ratio", "value": 0.25}, ""},
{"numbers inside array", "app.toml:x.list=[1,2.5]", map[string]interface{}{"fileName": "app.toml", "key": "x.list", "value": []interface{}{int64(1), 2.5}}, ""},
{"nested key bool", "app.toml:giga_executor.occ_enabled=false", map[string]interface{}{"fileName": "app.toml", "key": "giga_executor.occ_enabled", "value": false}, ""},
{"bare string", "app.toml:state-store.sc-write-mode=async", map[string]interface{}{"fileName": "app.toml", "key": "state-store.sc-write-mode", "value": "async"}, ""},
{"quoted numeric string", `config.toml:consensus.timeout_commit="400ms"`, map[string]interface{}{"fileName": "config.toml", "key": "consensus.timeout_commit", "value": "400ms"}, ""},
{"array", `app.toml:evm.enabled_legacy_sei_apis=["a","b"]`, map[string]interface{}{"fileName": "app.toml", "key": "evm.enabled_legacy_sei_apis", "value": []interface{}{"a", "b"}}, ""},
{"value containing equals", "app.toml:x.y=a=b", map[string]interface{}{"fileName": "app.toml", "key": "x.y", "value": "a=b"}, ""},
{"missing colon", "config.toml=true", nil, "missing ':'"},
{"missing equals", "config.toml:evm-only", nil, "missing '='"},
{"non toml file", "autobahn.json:foo=1", nil, "only TOML files"},
{"bad key", "config.toml:evm..only=true", nil, "dotted TOML path"},
{"empty value", "config.toml:evm-only=", nil, "empty value"},
{"null", "config.toml:evm-only=null", nil, "rejected by the CRD"},
{"nested null", `app.toml:t={"a":null}`, nil, "nested null"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := ParseConfigValue(tc.expr)
if tc.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("want error containing %q, got %v", tc.wantErr, err)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !reflect.DeepEqual(got, tc.want) {
t.Fatalf("got %#v, want %#v", got, tc.want)
}
})
}
}

func TestApplyConfigValues_MergesByFileAndKey(t *testing.T) {
root := map[string]interface{}{
"spec": map[string]interface{}{
"configValues": []interface{}{
map[string]interface{}{"fileName": "config.toml", "key": "evm-only", "value": false},
map[string]interface{}{"fileName": "app.toml", "key": "evm.http_port", "value": int64(8545)},
},
},
}
err := ApplyConfigValues(root, []string{
"config.toml:evm-only=true",
"app.toml:giga_executor.enabled=true",
}, "spec", "configValues")
if err != nil {
t.Fatalf("apply: %v", err)
}
got := root["spec"].(map[string]interface{})["configValues"].([]interface{})
want := []interface{}{
map[string]interface{}{"fileName": "config.toml", "key": "evm-only", "value": true},
map[string]interface{}{"fileName": "app.toml", "key": "evm.http_port", "value": int64(8545)},
map[string]interface{}{"fileName": "app.toml", "key": "giga_executor.enabled", "value": true},
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %#v\nwant %#v", got, want)
}
}

func TestApplyConfigValues_RejectsOverMaxItems(t *testing.T) {
exprs := make([]string, 0, MaxConfigValues+1)
for i := 0; i <= MaxConfigValues; i++ {
exprs = append(exprs, fmt.Sprintf("app.toml:k%d=1", i))
}
root := map[string]interface{}{"spec": map[string]interface{}{}}
err := ApplyConfigValues(root, exprs, "spec", "configValues")
if err == nil || !strings.Contains(err.Error(), "at most 100") {
t.Fatalf("want max-items error, got %v", err)
}
}

func TestApplyConfigValues_RejectsPreexistingDuplicate(t *testing.T) {
root := map[string]interface{}{"spec": map[string]interface{}{"configValues": []interface{}{
map[string]interface{}{"fileName": "app.toml", "key": "a.b", "value": int64(1)},
map[string]interface{}{"fileName": "app.toml", "key": "a.b", "value": int64(2)},
}}}
err := ApplyConfigValues(root, []string{"app.toml:c.d=true"}, "spec", "configValues")
if err == nil || !strings.Contains(err.Error(), "app.toml:a.b more than once") {
t.Fatalf("expected duplicate error, got %v", err)
}
}

func TestApplyConfigValues_NoopWhenEmpty(t *testing.T) {
root := map[string]interface{}{"spec": map[string]interface{}{}}
if err := ApplyConfigValues(root, nil, "spec", "configValues"); err != nil {
t.Fatalf("apply: %v", err)
}
if _, found := root["spec"].(map[string]interface{})["configValues"]; found {
t.Fatal("empty flag list must not create spec.configValues")
}
}
20 changes: 19 additions & 1 deletion seinetwork/apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ func applyAction(ctx context.Context, c *cli.Command) error {
throughput: c.String("throughput"),
nodeIsolation: c.String("node-isolation"),
sets: c.StringSlice("set"),
configValues: c.StringSlice("config-value"),
genesisAccounts: c.StringSlice("genesis-account"),
genesisOverrides: c.StringSlice("genesis-override"),
}
Expand Down Expand Up @@ -105,7 +106,20 @@ var applyCmd = cli.Command{
"\n\n" +
"Layering, lowest precedence first: preset YAML, discrete flags " +
"(--chain-id, --image, --replicas, --cpu, --memory, --storage, " +
"--iops, --throughput, --node-isolation), --set. " +
"--iops, --throughput, --node-isolation), --set, then --config-value (merged into " +
"spec.configValues by (fileName, key), so it never duplicates an " +
"entry --set or the preset placed there). " +
"\n\n" +
"--config-value sets a typed TOML key on EVERY validator (spec 002/003). " +
"Editing configValues on a live SeiNetwork restarts the whole " +
"validator pool at once; block production stops until more than 2/3 " +
"are back. Prefer editing a follower SeiNode for a running chain. " +
"Values are unvalidated by the controller beyond shape: a bad key " +
"surfaces as ConfigValuesValid=False on the CR, not at apply. " +
"The genesis-chain preset also carries spec.configOverrides (raw " +
"TOML merge-patch, the legacy surface); setting the same key in both " +
"is not detected here and the controller decides precedence, so " +
"keep a key in one place. " +
"\n\n" +
"--cpu/--memory/--storage each override one dimension of the " +
"preset's resource footprint; unspecified dimensions keep the " +
Expand Down Expand Up @@ -194,6 +208,10 @@ var applyCmd = cli.Command{
Name: "set",
Usage: "Strategic-merge override, dotted path with optional list-index suffix (e.g. --set spec.image=foo, --set spec.configOverrides.evm.http_port=8545). Wins on collision with discrete flags. Repeatable.",
},
&cli.StringSliceFlag{
Name: "config-value",
Usage: `Append a typed entry to spec.configValues: --config-value <file>.toml:<dotted.key>=<value> (e.g. --config-value config.toml:evm-only=true, --config-value app.toml:giga_executor.enabled=true). The file must be a TOML file name (^[A-Za-z0-9_-]+\.toml$); the key a dotted TOML path. Values parse as JSON when possible (bool, number, array, table); otherwise as strings — wrap in JSON quotes to force a string ("400ms"). null is refused. Same (file, key) as an existing entry replaces it. At most 100 entries. Repeatable. Prefer this over --set spec.configValues=[...], which replaces the whole list.`,
},
&cli.StringSliceFlag{
Name: "genesis-account",
Usage: "Append a GenesisAccount to spec.genesis.accounts: --genesis-account <address>:<balance> (e.g. --genesis-account sei1abc...:1000000000usei). Balance accepts the standard cosmos coin format (comma-separated denominations). Repeatable. --set spec.genesis.accounts[N]... overrides on collision.",
Expand Down
7 changes: 7 additions & 0 deletions seinetwork/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ type renderArgs struct {
throughput string
nodeIsolation string
sets []string
configValues []string
genesisAccounts []string
genesisOverrides []string
}
Expand Down Expand Up @@ -150,6 +151,12 @@ func render(args renderArgs) (*unstructured.Unstructured, error) {
}
}

// After --set so an entry --set placed in the list is merged by
// (fileName, key) rather than duplicated.
if err := cliutil.ApplyConfigValues(u.Object, args.configValues, "spec", "configValues"); err != nil {
return nil, err
}

// Final resource guard — nothing below writes spec.resources, so this
// sees whatever --set left behind.
if err := cliutil.RejectCPULimit(u.Object); err != nil {
Expand Down
33 changes: 33 additions & 0 deletions seinetwork/render_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -562,6 +562,39 @@ func TestRender_PerformanceTierFitsPresetDefault(t *testing.T) {
}
}

// --config-value lands typed entries on spec.configValues and merges by
// (fileName, key) with whatever --set already placed there, so the flag
// can refine a list without the whole-list replacement --set implies.
func TestRender_ConfigValuesMergeWithSet(t *testing.T) {
args := resourceArgs()
args.sets = []string{"spec.configValues[0].fileName=config.toml", "spec.configValues[0].key=evm-only", "spec.configValues[0].value=false"}
args.configValues = []string{"config.toml:evm-only=true", "app.toml:giga_executor.enabled=true", `config.toml:consensus.timeout_commit="400ms"`}
got, err := render(args)
if err != nil {
t.Fatalf("render: %v", err)
}
list, found, _ := unstructured.NestedSlice(got.Object, "spec", "configValues")
if !found || len(list) != 3 {
t.Fatalf("want 3 configValues, got %v", list)
}
first := list[0].(map[string]interface{})
if first["key"] != "evm-only" || first["value"] != true {
t.Fatalf("--config-value must replace the --set entry in place, got %v", first)
}
third := list[2].(map[string]interface{})
if third["value"] != "400ms" {
t.Fatalf("JSON-quoted value must stay a string, got %T %v", third["value"], third["value"])
}
}

func TestRender_ConfigValueRejectsNonTOMLFile(t *testing.T) {
args := resourceArgs()
args.configValues = []string{"autobahn.json:x=1"}
if _, err := render(args); err == nil || !strings.Contains(err.Error(), "TOML") {
t.Fatalf("want TOML-only refusal, got %v", err)
}
}

func TestRender_NodeIsolation(t *testing.T) {
args := resourceArgs()
args.nodeIsolation = "dedicated"
Expand Down
Loading
Loading