diff --git a/internal/cliutil/configvalue.go b/internal/cliutil/configvalue.go new file mode 100644 index 0000000..da55b09 --- /dev/null +++ b/internal/cliutil/configvalue.go @@ -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 `.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) + } + + 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 "" +} diff --git a/internal/cliutil/configvalue_test.go b/internal/cliutil/configvalue_test.go new file mode 100644 index 0000000..509b48b --- /dev/null +++ b/internal/cliutil/configvalue_test.go @@ -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") + } +} diff --git a/seinetwork/apply.go b/seinetwork/apply.go index 5364c7f..9f8683b 100644 --- a/seinetwork/apply.go +++ b/seinetwork/apply.go @@ -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"), } @@ -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 " + @@ -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 .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 0daa2ec..611197e 100644 --- a/seinetwork/render.go +++ b/seinetwork/render.go @@ -25,6 +25,7 @@ type renderArgs struct { throughput string nodeIsolation string sets []string + configValues []string genesisAccounts []string genesisOverrides []string } @@ -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 { diff --git a/seinetwork/render_test.go b/seinetwork/render_test.go index 3b23724..bd2bb09 100644 --- a/seinetwork/render_test.go +++ b/seinetwork/render_test.go @@ -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" diff --git a/seinode/apply.go b/seinode/apply.go index 9d6e47b..a9f4f7f 100644 --- a/seinode/apply.go +++ b/seinode/apply.go @@ -33,6 +33,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"), overrides: c.StringSlice("override"), } dryRun := c.Bool("dry-run") @@ -103,7 +104,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, --node-isolation), --override, --set. " + + "--memory, --storage, --iops, --throughput, --node-isolation), --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 " + @@ -196,6 +205,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 f3f1a2a..92a785d 100644 --- a/seinode/render.go +++ b/seinode/render.go @@ -37,6 +37,7 @@ type renderArgs struct { throughput string nodeIsolation string sets []string + configValues []string overrides []string } @@ -160,6 +161,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 6ec6434..bd1556a 100644 --- a/seinode/render_test.go +++ b/seinode/render_test.go @@ -687,6 +687,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"