From dc4cd5d46ff445ed86fd2605e329c958b8b842e4 Mon Sep 17 00:00:00 2001 From: Brandon Chatham Date: Fri, 11 Sep 2026 01:46:14 +0000 Subject: [PATCH 1/2] network/node apply: add repeatable --config-value for spec.configValues (PLT-1246) Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- internal/cliutil/configvalue.go | 121 +++++++++++++++++++++++++++ internal/cliutil/configvalue_test.go | 97 +++++++++++++++++++++ seinetwork/apply.go | 16 +++- seinetwork/render.go | 7 ++ seinetwork/render_test.go | 33 ++++++++ seinode/apply.go | 15 +++- seinode/render.go | 7 ++ seinode/render_test.go | 33 ++++++++ 8 files changed, 327 insertions(+), 2 deletions(-) create mode 100644 internal/cliutil/configvalue.go create mode 100644 internal/cliutil/configvalue_test.go diff --git a/internal/cliutil/configvalue.go b/internal/cliutil/configvalue.go new file mode 100644 index 0000000..9bef42d --- /dev/null +++ b/internal/cliutil/configvalue.go @@ -0,0 +1,121 @@ +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 `.toml:=` 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 .toml:=") + } + fileName := expr[:colon] + rest := expr[colon+1:] + eq := strings.Index(rest, "=") + if eq < 0 { + return nil, fmt.Errorf("missing '=' — expected .toml:=") + } + 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) + } + + var parsed interface{} + if jsonErr := json.Unmarshal([]byte(val), &parsed); jsonErr != nil { + parsed = 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 +} + +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) + } + return unstructured.SetNestedSlice(root, existing, fieldPath...) +} diff --git a/internal/cliutil/configvalue_test.go b/internal/cliutil/configvalue_test.go new file mode 100644 index 0000000..8956818 --- /dev/null +++ b/internal/cliutil/configvalue_test.go @@ -0,0 +1,97 @@ +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}, ""}, + {"nested key number", "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_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") + } +} diff --git a/seinetwork/apply.go b/seinetwork/apply.go index 9639724..a92ddd5 100644 --- a/seinetwork/apply.go +++ b/seinetwork/apply.go @@ -30,6 +30,7 @@ func applyAction(ctx context.Context, c *cli.Command) error { iops: c.String("iops"), throughput: c.String("throughput"), sets: c.StringSlice("set"), + configValues: c.StringSlice("config-value"), genesisAccounts: c.StringSlice("genesis-account"), genesisOverrides: c.StringSlice("genesis-override"), } @@ -104,7 +105,16 @@ var applyCmd = cli.Command{ "\n\n" + "Layering, lowest precedence first: preset YAML, discrete flags " + "(--chain-id, --image, --replicas, --cpu, --memory, --storage, " + - "--iops, --throughput), --set. " + + "--iops, --throughput), --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. " + "\n\n" + "--cpu/--memory/--storage each override one dimension of the " + "preset's resource footprint; unspecified dimensions keep the " + @@ -189,6 +199,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 .toml:= (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
: (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.", diff --git a/seinetwork/render.go b/seinetwork/render.go index 9124a1e..95d3748 100644 --- a/seinetwork/render.go +++ b/seinetwork/render.go @@ -24,6 +24,7 @@ type renderArgs struct { iops string throughput string sets []string + configValues []string genesisAccounts []string genesisOverrides []string } @@ -146,6 +147,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 { diff --git a/seinetwork/render_test.go b/seinetwork/render_test.go index 6f59981..ad49ae5 100644 --- a/seinetwork/render_test.go +++ b/seinetwork/render_test.go @@ -561,3 +561,36 @@ 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) + } +} diff --git a/seinode/apply.go b/seinode/apply.go index a4a2114..baae925 100644 --- a/seinode/apply.go +++ b/seinode/apply.go @@ -32,6 +32,7 @@ func applyAction(ctx context.Context, c *cli.Command) error { iops: c.String("iops"), throughput: c.String("throughput"), sets: c.StringSlice("set"), + configValues: c.StringSlice("config-value"), overrides: c.StringSlice("override"), } dryRun := c.Bool("dry-run") @@ -102,7 +103,15 @@ var applyCmd = cli.Command{ "\n\n" + "Layering, lowest precedence first: preset YAML, discrete flags " + "(--chain-id, --image, --network, --external-address, --cpu, " + - "--memory, --storage, --iops, --throughput), --override, --set. " + + "--memory, --storage, --iops, --throughput), --override, --set, " + + "then --config-value (merged into spec.configValues by (fileName, key)). " + + "\n\n" + + "--override writes the allow-listed spec.overrides map (validated " + + "keys, string values). --config-value writes spec.configValues " + + "(spec 002): any TOML file, typed JSON values, no allow-list — the " + + "controller validates shape only and reports a bad key as " + + "ConfigValuesValid=False. Changing configValues on a Running node " + + "restarts seid. " + "\n\n" + "--cpu/--memory/--storage each override one dimension of the " + "preset's resource footprint; unspecified dimensions keep the " + @@ -191,6 +200,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.peers[0].label.namespace=other-ns). Wins on collision with discrete flags. Repeatable.", }, + &cli.StringSliceFlag{ + Name: "config-value", + Usage: `Append a typed entry to spec.configValues: --config-value .toml:= (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: "override", Usage: "Set a key in spec.overrides: --override = (e.g. --override evm.enabled_legacy_sei_apis=sei_getLogs,sei_getBlockByNumber). Keys are dotted TOML paths consumed by the controller's config-apply pipeline; --set cannot reach this map because its parser splits on every dot. Repeatable.", diff --git a/seinode/render.go b/seinode/render.go index ef02ca7..6f45929 100644 --- a/seinode/render.go +++ b/seinode/render.go @@ -36,6 +36,7 @@ type renderArgs struct { iops string throughput string sets []string + configValues []string overrides []string } @@ -155,6 +156,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 { diff --git a/seinode/render_test.go b/seinode/render_test.go index ac9ac72..ace5cb9 100644 --- a/seinode/render_test.go +++ b/seinode/render_test.go @@ -686,3 +686,36 @@ 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) + } +} From ec156fce7a9cec67c541bf0b94a01e1a090fca77 Mon Sep 17 00:00:00 2001 From: Brandon Chatham Date: Fri, 11 Sep 2026 01:55:44 +0000 Subject: [PATCH 2/2] config-value: keep integers exact, refuse duplicate pairs, note configOverrides overlap Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- internal/cliutil/configvalue.go | 62 ++++++++++++++++++++++++++-- internal/cliutil/configvalue_test.go | 16 ++++++- seinetwork/apply.go | 4 ++ 3 files changed, 77 insertions(+), 5 deletions(-) diff --git a/internal/cliutil/configvalue.go b/internal/cliutil/configvalue.go index 9bef42d..da55b09 100644 --- a/internal/cliutil/configvalue.go +++ b/internal/cliutil/configvalue.go @@ -46,10 +46,7 @@ func ParseConfigValue(expr string) (map[string]interface{}, error) { return nil, fmt.Errorf("empty value for %s:%s — the CRD requires a value; to set an empty string pass '\"\"'", fileName, key) } - var parsed interface{} - if jsonErr := json.Unmarshal([]byte(val), &parsed); jsonErr != nil { - parsed = val - } + 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) } @@ -63,6 +60,44 @@ func ParseConfigValue(expr string) (map[string]interface{}, error) { }, 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: @@ -117,5 +152,24 @@ func ApplyConfigValues(root map[string]interface{}, exprs []string, fieldPath .. 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 "" +} diff --git a/internal/cliutil/configvalue_test.go b/internal/cliutil/configvalue_test.go index 8956818..509b48b 100644 --- a/internal/cliutil/configvalue_test.go +++ b/internal/cliutil/configvalue_test.go @@ -15,7 +15,10 @@ func TestParseConfigValue(t *testing.T) { wantErr string }{ {"bool", "config.toml:evm-only=true", map[string]interface{}{"fileName": "config.toml", "key": "evm-only", "value": true}, ""}, - {"nested key number", "app.toml:giga_executor.occ_enabled=false", map[string]interface{}{"fileName": "app.toml", "key": "giga_executor.occ_enabled", "value": false}, ""}, + {"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"}}, ""}, @@ -86,6 +89,17 @@ func TestApplyConfigValues_RejectsOverMaxItems(t *testing.T) { } } +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 { diff --git a/seinetwork/apply.go b/seinetwork/apply.go index a92ddd5..0c60d2c 100644 --- a/seinetwork/apply.go +++ b/seinetwork/apply.go @@ -115,6 +115,10 @@ var applyCmd = cli.Command{ "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 " +