Skip to content

Commit bd79cef

Browse files
committed
feat: add ConfigIntent resolution pipeline and registry enhancements
Introduces a declarative ConfigIntent object that encapsulates mode, overrides, and target version for config resolution. Callers populate an intent and receive a validated ConfigResult with diagnostics. - Add intent.go with ValidateIntent, ResolveIntent, ResolveIncrementalIntent - Add SinceVersion and RequiredForModes to ConfigField metadata - Preserve NodeMode and Version through legacy TOML round-trips - Update CLAUDE.md key files table
1 parent 2a6fb17 commit bd79cef

5 files changed

Lines changed: 771 additions & 3 deletions

File tree

CLAUDE.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ Shared Go library providing unified configuration types, mode-aware defaults, va
1717
| `types.go` | `NodeMode`, `Duration`, `WriteMode`, `ReadMode` |
1818
| `defaults.go` | `DefaultForMode()` — mode-aware baseline configs |
1919
| `validate.go` | `Validate()` — structured diagnostics (Error/Warning/Info) |
20+
| `intent.go` | `ConfigIntent`, `ConfigResult` — intent resolution pipeline for controller/sidecar/CLI |
21+
| `registry.go` | `ConfigField`, `Registry` — reflection-based field discovery and enrichment |
22+
| `enrichments.go` | `DefaultEnrichments()` — curated field metadata (descriptions, units, hot-reload) |
23+
| `migrate.go` | `Migration`, `MigrationRegistry` — versioned config schema migrations |
2024
| `resolve.go` | `ResolveEnv()``SEI_`/`SEID_` env var resolution via reflection |
2125
| `io.go` | `ReadConfigFromDir()`, `WriteConfigToDir()`, `ApplyOverrides()` |
2226
| `legacy.go` | Two-file TOML mapping types and `SeiConfig` ↔ legacy conversion |

intent.go

Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
package seiconfig
2+
3+
import "fmt"
4+
5+
// ConfigIntent declares the desired configuration state for a Sei node.
6+
// It is the portable contract between the controller, sidecar, and CLI.
7+
// sei-config owns the full resolution pipeline: intent -> validated SeiConfig.
8+
//
9+
// The controller builds an intent from the CRD spec, the sidecar resolves it.
10+
// The controller never calls DefaultForMode, ApplyOverrides, or Validate
11+
// directly — it just constructs an intent and sends it through.
12+
type ConfigIntent struct {
13+
// Mode is the node's operating role (validator, full, seed, archive, rpc, indexer).
14+
Mode NodeMode `json:"mode"`
15+
16+
// Overrides is a flat map of dotted TOML key paths to string values.
17+
// These are applied on top of mode defaults.
18+
Overrides map[string]string `json:"overrides,omitempty"`
19+
20+
// TargetVersion is the desired config schema version.
21+
// When zero, uses CurrentVersion (the latest known by this library).
22+
// Set explicitly when deploying a custom binary that expects a specific
23+
// config version.
24+
TargetVersion int `json:"targetVersion,omitempty"`
25+
26+
// Incremental means "read existing on-disk config and patch it" rather
27+
// than "generate from mode defaults." Used for day-2 changes.
28+
Incremental bool `json:"incremental,omitempty"`
29+
}
30+
31+
// ConfigResult is the output of intent resolution. It contains the resolved
32+
// config, diagnostics, and a validity flag.
33+
type ConfigResult struct {
34+
// Config is the fully resolved SeiConfig. Nil when Valid is false.
35+
Config *SeiConfig `json:"config,omitempty"`
36+
37+
// Version is the config schema version of the resolved config.
38+
Version int `json:"version"`
39+
40+
// Mode is the mode of the resolved config.
41+
Mode NodeMode `json:"mode"`
42+
43+
// Diagnostics contains all validation findings (errors, warnings, info).
44+
Diagnostics []Diagnostic `json:"diagnostics,omitempty"`
45+
46+
// Valid is true when no error-level diagnostics exist.
47+
Valid bool `json:"valid"`
48+
}
49+
50+
func (r *ConfigResult) addError(field, msg string) {
51+
r.Diagnostics = append(r.Diagnostics, Diagnostic{SeverityError, field, msg})
52+
r.Valid = false
53+
}
54+
55+
func (r *ConfigResult) addWarning(field, msg string) {
56+
r.Diagnostics = append(r.Diagnostics, Diagnostic{SeverityWarning, field, msg})
57+
}
58+
59+
// ValidateIntent checks whether a ConfigIntent is well-formed without
60+
// producing a resolved config. This enables dry-run validation by the
61+
// controller before submitting a task to the sidecar.
62+
//
63+
// Checks performed:
64+
// - Mode is valid
65+
// - TargetVersion is within the supported range
66+
// - All override keys exist in the Registry
67+
// - Version-required fields for the mode are satisfied
68+
func ValidateIntent(intent ConfigIntent) *ConfigResult {
69+
result := &ConfigResult{
70+
Version: resolveTargetVersion(intent.TargetVersion),
71+
Mode: intent.Mode,
72+
Valid: true,
73+
}
74+
75+
if !intent.Incremental {
76+
validateIntentMode(result, intent)
77+
}
78+
validateIntentVersion(result, intent)
79+
80+
registry := BuildRegistry()
81+
registry.EnrichAll(DefaultEnrichments())
82+
validateIntentOverrideKeys(result, intent, registry)
83+
validateIntentRequiredFields(result, intent, registry)
84+
85+
return result
86+
}
87+
88+
// ResolveIntent produces a fully resolved, validated SeiConfig from an intent.
89+
// This is the primary entry point for non-incremental (bootstrap) config
90+
// generation. The full pipeline is:
91+
//
92+
// 1. Resolve target version
93+
// 2. Generate mode defaults
94+
// 3. Apply overrides
95+
// 4. Validate the result
96+
// 5. Return ConfigResult
97+
func ResolveIntent(intent ConfigIntent) (*ConfigResult, error) {
98+
result := &ConfigResult{
99+
Version: resolveTargetVersion(intent.TargetVersion),
100+
Mode: intent.Mode,
101+
Valid: true,
102+
}
103+
104+
if intent.Mode == "" {
105+
return nil, fmt.Errorf("mode is required for non-incremental config resolution")
106+
}
107+
if !intent.Mode.IsValid() {
108+
return nil, fmt.Errorf("invalid mode %q", intent.Mode)
109+
}
110+
111+
cfg := DefaultForMode(intent.Mode)
112+
cfg.Version = result.Version
113+
114+
if err := ApplyOverrides(cfg, intent.Overrides); err != nil {
115+
return nil, fmt.Errorf("applying overrides: %w", err)
116+
}
117+
118+
vr := ValidateWithOpts(cfg, ValidateOpts{MaxVersion: result.Version})
119+
result.Diagnostics = vr.Diagnostics
120+
result.Valid = !vr.HasErrors()
121+
122+
if result.Valid {
123+
result.Config = cfg
124+
}
125+
126+
return result, nil
127+
}
128+
129+
// ResolveIncrementalIntent resolves an incremental intent against an existing
130+
// on-disk config. Used for day-2 patches where the base config already exists.
131+
func ResolveIncrementalIntent(intent ConfigIntent, current *SeiConfig) (*ConfigResult, error) {
132+
if current == nil {
133+
return nil, fmt.Errorf("current config is required for incremental resolution")
134+
}
135+
136+
result := &ConfigResult{
137+
Version: resolveTargetVersion(intent.TargetVersion),
138+
Valid: true,
139+
}
140+
141+
copied := *current
142+
cfg := &copied
143+
if intent.Mode != "" {
144+
cfg.Mode = intent.Mode
145+
}
146+
result.Mode = cfg.Mode
147+
148+
if err := ApplyOverrides(cfg, intent.Overrides); err != nil {
149+
return nil, fmt.Errorf("applying incremental overrides: %w", err)
150+
}
151+
152+
vr := ValidateWithOpts(cfg, ValidateOpts{MaxVersion: result.Version})
153+
result.Diagnostics = vr.Diagnostics
154+
result.Valid = !vr.HasErrors()
155+
156+
if result.Valid {
157+
result.Config = cfg
158+
result.Version = cfg.Version
159+
}
160+
161+
return result, nil
162+
}
163+
164+
func resolveTargetVersion(requested int) int {
165+
if requested > 0 {
166+
return requested
167+
}
168+
return CurrentVersion
169+
}
170+
171+
func validateIntentMode(result *ConfigResult, intent ConfigIntent) {
172+
if intent.Mode == "" {
173+
result.addError("mode", "mode is required for non-incremental config generation")
174+
return
175+
}
176+
if !intent.Mode.IsValid() {
177+
result.addError("mode", fmt.Sprintf(
178+
"unknown mode %q; valid modes: validator, full, seed, archive, rpc, indexer", intent.Mode))
179+
}
180+
}
181+
182+
func validateIntentVersion(result *ConfigResult, intent ConfigIntent) {
183+
tv := resolveTargetVersion(intent.TargetVersion)
184+
if tv < 1 {
185+
result.addError("targetVersion", "target version must be >= 1")
186+
}
187+
if tv > CurrentVersion {
188+
result.addError("targetVersion", fmt.Sprintf(
189+
"target version %d exceeds maximum supported version %d",
190+
tv, CurrentVersion))
191+
}
192+
}
193+
194+
func validateIntentOverrideKeys(result *ConfigResult, intent ConfigIntent, registry *Registry) {
195+
if len(intent.Overrides) == 0 {
196+
return
197+
}
198+
for key := range intent.Overrides {
199+
if registry.Field(key) == nil {
200+
result.addError("overrides."+key, fmt.Sprintf("unknown config field %q", key))
201+
}
202+
}
203+
}
204+
205+
func validateIntentRequiredFields(result *ConfigResult, intent ConfigIntent, registry *Registry) {
206+
if intent.Incremental {
207+
return
208+
}
209+
210+
tv := resolveTargetVersion(intent.TargetVersion)
211+
for _, field := range registry.Fields() {
212+
if field.SinceVersion <= 0 || field.SinceVersion > tv {
213+
continue
214+
}
215+
if len(field.RequiredForModes) == 0 {
216+
continue
217+
}
218+
required := false
219+
for _, m := range field.RequiredForModes {
220+
if m == intent.Mode {
221+
required = true
222+
break
223+
}
224+
}
225+
if !required {
226+
continue
227+
}
228+
229+
// Field is required for this mode+version. Check if it's in overrides
230+
// or has a non-zero default.
231+
if _, ok := intent.Overrides[field.Key]; ok {
232+
continue
233+
}
234+
235+
defaults := registry.DefaultsByMode(intent.Mode)
236+
if v, ok := defaults[field.Key]; ok && !isZeroValue(v) {
237+
continue
238+
}
239+
240+
result.addError(field.Key, fmt.Sprintf(
241+
"field %q is required for mode %q in config version %d",
242+
field.Key, intent.Mode, field.SinceVersion))
243+
}
244+
}
245+
246+
func isZeroValue(v any) bool {
247+
if v == nil {
248+
return true
249+
}
250+
switch val := v.(type) {
251+
case string:
252+
return val == ""
253+
case int:
254+
return val == 0
255+
case int64:
256+
return val == 0
257+
case uint:
258+
return val == 0
259+
case uint64:
260+
return val == 0
261+
case float64:
262+
return val == 0
263+
case bool:
264+
return !val
265+
default:
266+
return false
267+
}
268+
}

0 commit comments

Comments
 (0)