diff --git a/documentation/en/SUMMARY.md b/documentation/en/SUMMARY.md index f58e4ef41..fc8baa154 100644 --- a/documentation/en/SUMMARY.md +++ b/documentation/en/SUMMARY.md @@ -15,6 +15,7 @@ * [Curio Service](curio-service.md) * [Storage Configuration](storage-configuration.md) * [Configuration](configuration/README.md) + * [Configuration editor fidelity](configuration/configuration-editor.md) * [Listen Address](configuration/listen-address.md) * [Prometheus Metrics](configuration/prometheus-metrics.md) * [Metrics Reference](configuration/metrics-reference.md) diff --git a/documentation/en/configuration/configuration-editor.md b/documentation/en/configuration/configuration-editor.md new file mode 100644 index 000000000..780cbc245 --- /dev/null +++ b/documentation/en/configuration/configuration-editor.md @@ -0,0 +1,62 @@ +# Configuration editor fidelity + +The Curio Configuration editor reflects the current Curio configuration model. +Nested `Dynamic[T]` fields expose `T`, not the internal synchronization wrapper. +Durations are Go duration strings; FIL amounts are strings validated by the +runtime config decoder. The generated source comments supply field help. +The separately built Skiff editor intentionally exposes its smaller model. + +A layer is a sparse set of overrides, not a complete effective configuration. +Checking a field includes an override. Zero, false, empty arrays, and values +equal to the public default must remain explicit when saved: they can override +an earlier layer. Unchecking a field removes that override, allowing earlier +layers or defaults to apply. Saving preserves values, not TOML comments or +formatting. Configuration history is not rewritten. + +Supported TOML field names are normalized to canonical Go field names for the +editor; literal map keys are not renamed. The existing legacy address-table +and empty-Dynamic-table compatibility path remains in use. Truly unknown keys +stop editing/saving with an error instead of being silently removed. Use a +compatible binary or explicitly review such a layer; do not delete keys just +to dismiss the error. Unrepresentable JavaScript integers also stop the editor +instead of silently rounding a stored value. + +The editor checks the loaded layer against the actual schema and verifies that +JSON Editor retained every existing value before enabling Save. It preserves +empty strings in arrays. The save handler independently validates the submitted +and existing layers using the runtime decoder. This is not optimistic locking +between simultaneous human editors, nor a change to runtime policy validation, +dynamic reload, restart requirements, or database schema. + +## Regression checks + +`TestUIConfigModelCompleteness` walks the actual GET schema and Curio model, +including nested references, structs, pointers, arrays, slices, maps, durations, +FIL, and Dynamic inner types. There are no field exclusions. An unsupported +future type or excluded model field fails and requires explicit review. +`TestUISchemaDocumentation` compares generated help with schema descriptions. + +`TestUIGenericLayerConfigRoundTrip` exercises production layer load/save +preparation and runtime loading for subsystem settings, durations, and queue +limits. Additional tests protect explicit default overrides, +unknown keys, case normalization, and nested address/FIL values. + +`TestUICustomConfigRoundTrip` additionally covers multiple SDR pacing intervals +and release controls. `layer-editor-curio.test.mjs` exercises those actual field +paths, including explicit false/zero overrides and untouched unknown values. + +Database-free commands (use the repository's working native build environment, +with database connection variables and integration opt-ins absent): + +```sh +go test -tags=cgo,fvm,nosupraseal -count=1 ./web/api/config ./deps/config +go test -race -tags=cgo,fvm,nosupraseal -count=1 ./web/api/config ./deps/config +node --test web/static/config/layer-editor.test.mjs +make cfgdoc-gen +make fiximports +``` + +These tests do not execute `harmony_config` SQL. Real GUI-instance validation +must separately verify the serving binary, schema response, loaded assets, +and a disposable layer's save/reload behavior. A field rendered in a local +browser fixture does not prove which binary a deployed GUI is serving. diff --git a/web/api/config/config.go b/web/api/config/config.go index bcd1a0c87..31a8321f7 100644 --- a/web/api/config/config.go +++ b/web/api/config/config.go @@ -6,6 +6,7 @@ import ( "net/http" "reflect" "strconv" + "strings" "time" "github.com/gorilla/mux" @@ -22,8 +23,8 @@ import ( var log = logging.Logger("config-ui") // durationPattern validates Go time.ParseDuration strings (e.g. "1h30m", "1m1s", "30s"). -// Each clause is optional, but at least one number+unit pair is required. -const durationPattern = `^(\d+(\.\d+)?(h|m|s|ms|us|µs|ns))+$` +// Range and field-specific validation remain with the config loader/consumer. +const durationPattern = `^[+-]?(0|(([0-9]+(\.[0-9]*)?|\.[0-9]+)(ns|us|µs|μs|ms|s|m|h))+)$` type cfg struct { *deps.Deps @@ -68,7 +69,9 @@ func uiSchemaMapper(i reflect.Type) *jsonschema.Schema { if mapped := uiSchemaSpecialType(inner); mapped != nil { return mapped } - return (&jsonschema.Reflector{Mapper: uiSchemaMapper}).ReflectFromType(inner) + // A mapper returns a subschema, not a separate document. Root-relative + // references in a nested ReflectFromType result otherwise escape its $defs. + return (&jsonschema.Reflector{Mapper: uiSchemaMapper, DoNotReference: true, Anonymous: true}).ReflectFromType(inner) } return uiSchemaSpecialType(i) } @@ -76,8 +79,8 @@ func uiSchemaMapper(i reflect.Type) *jsonschema.Schema { func uiSchemaSpecialType(i reflect.Type) *jsonschema.Schema { if i == reflect.TypeOf(types.MustParseFIL("1 Fil")) { return &jsonschema.Schema{ - Type: "string", - Pattern: "1 fil/0.03 fil/0.31/1 attofil", + Type: "string", + Description: "Decimal FIL amount, optionally suffixed with FIL or attoFIL; validated by the config loader.", } } if i == reflect.TypeFor[time.Duration]() { @@ -176,9 +179,65 @@ func buildUISchema() *jsonschema.Schema { } } allOpt(sch) + addUIFieldDocs(sch, sch, reflect.TypeOf(uiSchemaRoot())) return sch } +// Follow the actual model so inline Dynamic elements receive the same help as +// named definitions. No list of individual configuration fields is maintained. +func addUIFieldDocs(root, node *jsonschema.Schema, typ reflect.Type) { + if inner, ok := config.DynamicInnerType(typ); ok { + typ = inner + } + for typ.Kind() == reflect.Pointer { + typ = typ.Elem() + } + if node == nil { + return + } + if node.Ref != "" { + node = root.Definitions[strings.TrimPrefix(node.Ref, "#/$defs/")] + } + if node == nil { + return + } + if typ == reflect.TypeFor[types.FIL]() || typ == reflect.TypeFor[time.Duration]() { + return + } + switch typ.Kind() { + case reflect.Struct: + for _, field := range config.Doc[typ.Name()] { + if node.Properties == nil { + continue + } + if prop, ok := node.Properties.Get(field.Name); ok && field.Comment != "" { + prop.Description = field.Comment + } + } + for i := 0; i < typ.NumField(); i++ { + field := typ.Field(i) + if !field.IsExported() || node.Properties == nil { + continue + } + name := field.Name + if tag := strings.Split(field.Tag.Get("json"), ",")[0]; tag != "" { + name = tag + } + if field.Anonymous && field.Tag.Get("json") == "" { + addUIFieldDocs(root, node, field.Type) + continue + } + if prop, ok := node.Properties.Get(name); ok { + addUIFieldDocs(root, prop, field.Type) + } + } + case reflect.Array, reflect.Slice: + addUIFieldDocs(root, node.Items, typ.Elem()) + case reflect.Map: + addUIFieldDocs(root, node.AdditionalProperties, typ.Elem()) + } +} + func (c *cfg) getLayers(w http.ResponseWriter, r *http.Request) { var layers []string apihelper.OrHTTPFail(w, c.DB.Select(context.Background(), &layers, `SELECT title FROM harmony_config ORDER BY title`)) @@ -209,7 +268,7 @@ func (c *cfg) setLayer(w http.ResponseWriter, r *http.Request) { apihelper.OrHTTPFail(w, dec.Decode(&configStruct)) var existingToml string - _ = c.DB.QueryRow(context.Background(), `SELECT config FROM harmony_config WHERE title = $1`, layer).Scan(&existingToml) + apihelper.OrHTTPFail(w, c.DB.QueryRow(r.Context(), `SELECT config FROM harmony_config WHERE title = $1`, layer).Scan(&existingToml)) configStr, err := uiPrepareLayerSave(layer, configStruct, existingToml) apihelper.OrHTTPFail(w, err) diff --git a/web/api/config/duration_pattern_test.go b/web/api/config/duration_pattern_test.go index ff3184359..389e9e491 100644 --- a/web/api/config/duration_pattern_test.go +++ b/web/api/config/duration_pattern_test.go @@ -10,6 +10,7 @@ func TestDurationPattern(t *testing.T) { re := regexp.MustCompile(durationPattern) valid := []string{ + "0", "0s", ".5h", "1.h", "+1m", "-1m", "1μs", "43m45s", "25m20s", "0h0m0s", "8h0m0s", "1h30m", diff --git a/web/api/config/ui_completeness_test.go b/web/api/config/ui_completeness_test.go new file mode 100644 index 000000000..e9993b715 --- /dev/null +++ b/web/api/config/ui_completeness_test.go @@ -0,0 +1,210 @@ +package config + +import ( + "encoding/json" + "fmt" + "net/http/httptest" + "reflect" + "strings" + "testing" + "time" + + "github.com/invopop/jsonschema" + "github.com/stretchr/testify/require" + + depsconfig "github.com/filecoin-project/curio/deps/config" + + "github.com/filecoin-project/lotus/chain/types" +) + +// Resolve JSON pointers against the document root, not a nested $defs object. +// This is deliberately independent of the production mapper. +func schemaNode(root, node map[string]any) (map[string]any, error) { + seen := map[string]bool{} + for { + ref, _ := node["$ref"].(string) + if ref == "" { + return node, nil + } + if !strings.HasPrefix(ref, "#/") || seen[ref] { + return nil, fmt.Errorf("invalid or cyclic reference %q", ref) + } + seen[ref] = true + var v any = root + for _, p := range strings.Split(strings.TrimPrefix(ref, "#/"), "/") { + m, ok := v.(map[string]any) + if !ok { + return nil, fmt.Errorf("unresolved reference %q", ref) + } + v = m[strings.ReplaceAll(strings.ReplaceAll(p, "~1", "/"), "~0", "~")] + } + var ok bool + node, ok = v.(map[string]any) + if !ok { + return nil, fmt.Errorf("unresolved reference %q", ref) + } + } +} + +func schemaMap(t *testing.T, s *jsonschema.Schema) map[string]any { + t.Helper() + b, err := json.Marshal(s) + require.NoError(t, err) + var root map[string]any + require.NoError(t, json.Unmarshal(b, &root)) + return root +} + +func checkConfigSchema(root, node map[string]any, typ reflect.Type, path string) error { + if inner, ok := depsconfig.DynamicInnerType(typ); ok { + typ = inner + } + for typ.Kind() == reflect.Pointer { + typ = typ.Elem() + } + node, err := schemaNode(root, node) + if err != nil { + return fmt.Errorf("%s: %w", path, err) + } + want := "" + switch { + case typ == reflect.TypeFor[time.Duration](), typ == reflect.TypeFor[types.FIL](): + want = "string" + case typ.Kind() == reflect.Struct: + want = "object" + case typ.Kind() == reflect.Map: + want = "object" + case typ.Kind() == reflect.Slice || typ.Kind() == reflect.Array: + want = "array" + case typ.Kind() == reflect.Bool: + want = "boolean" + case typ.Kind() == reflect.String: + want = "string" + case typ.Kind() >= reflect.Int && typ.Kind() <= reflect.Uint64: + want = "integer" + case typ.Kind() == reflect.Float32 || typ.Kind() == reflect.Float64: + want = "number" + default: + return fmt.Errorf("%s: unsupported model type %s requires explicit review", path, typ) + } + if node["type"] != want { + return fmt.Errorf("%s: schema type %v, want %s for %s", path, node["type"], want, typ) + } + if want == "object" && typ.Kind() == reflect.Struct { + props, _ := node["properties"].(map[string]any) + for i := 0; i < typ.NumField(); i++ { + field := typ.Field(i) + if !field.IsExported() { + continue + } + name := field.Name + if tag := strings.Split(field.Tag.Get("json"), ",")[0]; tag != "" { + name = tag + } + if name == "-" || field.Tag.Get("toml") == "-" { + return fmt.Errorf("%s.%s needs a documented exclusion", path, field.Name) + } + if field.Anonymous && field.Tag.Get("json") == "" { + if err := checkConfigSchema(root, node, field.Type, path); err != nil { + return err + } + continue + } + child, ok := props[name].(map[string]any) + if !ok { + return fmt.Errorf("%s.%s: missing schema property", path, name) + } + if err := checkConfigSchema(root, child, field.Type, path+"."+name); err != nil { + return err + } + } + } + if want == "array" { + child, _ := node["items"].(map[string]any) + return checkConfigSchema(root, child, typ.Elem(), path+"[]") + } + if typ.Kind() == reflect.Map { + child, _ := node["additionalProperties"].(map[string]any) + return checkConfigSchema(root, child, typ.Elem(), path+".*") + } + return nil +} + +func TestUIConfigModelCompleteness(t *testing.T) { + // Exercise the actual GET handler, including serialization of $defs and $ref. + rr := httptest.NewRecorder() + getSch(rr, httptest.NewRequest("GET", "/api/config/schema", nil)) + require.Equal(t, 200, rr.Code) + var root map[string]any + require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &root)) + require.NoError(t, checkConfigSchema(root, root, reflect.TypeOf(uiSchemaRoot()), "Configuration")) +} + +type schemaNestedFixture struct { + Delay time.Duration + Amount types.FIL + Enabled bool +} +type schemaDynamicFixture struct{ OnlyHere schemaNestedFixture } +type schemaContainerFixture struct { + Named *schemaNestedFixture + Inline struct { + Limit int + Values map[string][]*schemaNestedFixture + } + Dynamic *depsconfig.Dynamic[[]schemaDynamicFixture] + Duration *depsconfig.Dynamic[time.Duration] + Enabled *depsconfig.Dynamic[bool] + Limit *depsconfig.Dynamic[int] +} + +func TestUIConfigSchemaTypeShapes(t *testing.T) { + s := (&jsonschema.Reflector{Mapper: uiSchemaMapper}).Reflect(schemaContainerFixture{}) + root := schemaMap(t, s) + require.NoError(t, checkConfigSchema(root, root, reflect.TypeFor[schemaContainerFixture](), "fixture")) +} + +func TestUISchemaDocumentation(t *testing.T) { + root := schemaMap(t, buildUISchema()) + var walk func(map[string]any, reflect.Type, string) + walk = func(node map[string]any, typ reflect.Type, path string) { + if inner, ok := depsconfig.DynamicInnerType(typ); ok { + typ = inner + } + for typ.Kind() == reflect.Pointer { + typ = typ.Elem() + } + resolved, err := schemaNode(root, node) + require.NoError(t, err, path) + if typ == reflect.TypeFor[types.FIL]() || typ == reflect.TypeFor[time.Duration]() { + return + } + switch typ.Kind() { + case reflect.Struct: + props, _ := resolved["properties"].(map[string]any) + for _, doc := range depsconfig.Doc[typ.Name()] { + if doc.Comment == "" { + continue + } + p, ok := props[doc.Name].(map[string]any) + require.True(t, ok, path+"."+doc.Name) + require.Equal(t, doc.Comment, p["description"], path+"."+doc.Name) + } + for i := 0; i < typ.NumField(); i++ { + f := typ.Field(i) + if !f.IsExported() { + continue + } + p, _ := props[f.Name].(map[string]any) + walk(p, f.Type, path+"."+f.Name) + } + case reflect.Slice, reflect.Array: + p, _ := resolved["items"].(map[string]any) + walk(p, typ.Elem(), path+"[]") + case reflect.Map: + p, _ := resolved["additionalProperties"].(map[string]any) + walk(p, typ.Elem(), path+".*") + } + } + walk(root, reflect.TypeOf(uiSchemaRoot()), "Configuration") +} diff --git a/web/api/config/ui_config_common.go b/web/api/config/ui_config_common.go index 8e4652fce..8958e6ab5 100644 --- a/web/api/config/ui_config_common.go +++ b/web/api/config/ui_config_common.go @@ -5,7 +5,6 @@ import ( "github.com/BurntSushi/toml" - "github.com/filecoin-project/curio/deps" depsconfig "github.com/filecoin-project/curio/deps/config" ) @@ -37,12 +36,18 @@ func prepareCurioLayerSave(_ string, configStruct map[string]any) (string, error return "", err } - curioCfg := depsconfig.DefaultCurioConfig() - if _, err := deps.LoadConfigWithUpgrades(tomlData.String(), curioCfg); err != nil { + layer, err := editableCurioLayer(tomlData.String()) + if err != nil { return "", err } - - return formatLayerTOML(curioCfg) + // A layer is a sparse set of explicit overrides, not a full effective config. + // Comparing against defaults comments out intentional false/zero overrides + // and can synthesize empty address entries. Keep exactly the submitted keys. + tomlData.Reset() + if err := toml.NewEncoder(&tomlData).Encode(layer); err != nil { + return "", err + } + return tomlData.String(), nil } func formatLayerTOML(curioCfg *depsconfig.CurioConfig) (string, error) { diff --git a/web/api/config/ui_config_common_test.go b/web/api/config/ui_config_common_test.go index b36dcd42d..df12e5124 100644 --- a/web/api/config/ui_config_common_test.go +++ b/web/api/config/ui_config_common_test.go @@ -21,7 +21,9 @@ func TestBuildUISchema(t *testing.T) { func TestSchemaDynamicFieldsUseInnerType(t *testing.T) { ref := jsonschema.Reflector{Mapper: uiSchemaMapper} - sch := ref.Reflect(uiSchemaRoot()) + // This specifically asserts Curio fields; the Skiff build deliberately + // uses a different root. Actual selected-root completeness is tested below. + sch := ref.Reflect(depsconfig.CurioConfig{}) _, isWrapper := sch.Definitions["Dynamic[int]"] assert.False(t, isWrapper, "Dynamic[int] must not appear as a schema object") diff --git a/web/api/config/ui_config_curio.go b/web/api/config/ui_config_curio.go index d82607b8f..be6b1da29 100644 --- a/web/api/config/ui_config_curio.go +++ b/web/api/config/ui_config_curio.go @@ -15,9 +15,13 @@ func uiDefaultJSON() (map[string]any, error) { } func uiLayerJSON(layerToml string) (map[string]any, error) { - return tomlToJSONMap(layerToml) + return editableCurioLayer(layerToml) } func uiPrepareLayerSave(layer string, submitted map[string]any, existingToml string) (string, error) { + // Even if an older editor omitted an unknown key, do not silently destroy it. + if _, err := editableCurioLayer(existingToml); err != nil { + return "", err + } return prepareCurioLayerSave(layer, submitted) } diff --git a/web/api/config/ui_layer.go b/web/api/config/ui_layer.go new file mode 100644 index 000000000..01e984839 --- /dev/null +++ b/web/api/config/ui_layer.go @@ -0,0 +1,109 @@ +package config + +import ( + "fmt" + "reflect" + "sort" + "strings" + + depsconfig "github.com/filecoin-project/curio/deps/config" +) + +// editableCurioLayer validates with the runtime decoder but retains sparse +// layer values. Truly unknown fields stop editing instead of being dropped by +// a typed decode. This neither changes runtime loading nor repairs stored data. +func editableCurioLayer(text string) (map[string]any, error) { + cfg := depsconfig.DefaultCurioConfig() + md, err := depsconfig.LoadConfigWithUpgrades(text, cfg) + if err != nil { + return nil, err + } + unknown := md.Undecoded() + if len(unknown) > 0 { + names := make([]string, len(unknown)) + for i, k := range unknown { + names[i] = k.String() + } + sort.Strings(names) + return nil, fmt.Errorf("configuration contains unsupported fields (%s); no changes saved; use a compatible editor or explicitly review the layer", strings.Join(names, ", ")) + } + raw, err := tomlToJSONMap(text) + if err != nil { + return nil, err + } + v, err := canonicalLayerKeys(raw, reflect.TypeFor[depsconfig.CurioConfig]()) + if err != nil { + return nil, err + } + return v.(map[string]any), nil +} + +// TOML matches Go fields case-insensitively; JSON Editor does not. Normalize +// supported key names only, preserving literal map keys and scalar values. +func canonicalLayerKeys(value any, typ reflect.Type) (any, error) { + if inner, ok := depsconfig.DynamicInnerType(typ); ok { + typ = inner + } + for typ.Kind() == reflect.Pointer { + typ = typ.Elem() + } + switch typ.Kind() { + case reflect.Struct: + object, ok := value.(map[string]any) + if !ok { + return value, nil + } + out := make(map[string]any, len(object)) + for key, v := range object { + field, ok := typ.FieldByNameFunc(func(name string) bool { return strings.EqualFold(key, name) }) + if !ok { + return nil, fmt.Errorf("unsupported configuration field %s", key) + } + if _, exists := out[field.Name]; exists { + return nil, fmt.Errorf("duplicate configuration field %s", field.Name) + } + normalized, err := canonicalLayerKeys(v, field.Type) + if err != nil { + return nil, err + } + out[field.Name] = normalized + } + return out, nil + case reflect.Slice, reflect.Array: + // LoadConfigWithUpgrades supports the legacy single [addresses] table. + if typ.Elem() == reflect.TypeFor[depsconfig.CurioAddresses]() { + if object, ok := value.(map[string]any); ok { + value = []any{object} + } + } + rv := reflect.ValueOf(value) + if rv.Kind() != reflect.Slice && rv.Kind() != reflect.Array { + return value, nil + } + out := make([]any, rv.Len()) + for i := range out { + v, err := canonicalLayerKeys(rv.Index(i).Interface(), typ.Elem()) + if err != nil { + return nil, err + } + out[i] = v + } + return out, nil + case reflect.Map: + object, ok := value.(map[string]any) + if !ok { + return value, nil + } + out := make(map[string]any, len(object)) + for key, v := range object { + n, err := canonicalLayerKeys(v, typ.Elem()) + if err != nil { + return nil, err + } + out[key] = n + } + return out, nil + default: + return value, nil + } +} diff --git a/web/api/config/ui_layer_roundtrip_test.go b/web/api/config/ui_layer_roundtrip_test.go new file mode 100644 index 000000000..2c50f5281 --- /dev/null +++ b/web/api/config/ui_layer_roundtrip_test.go @@ -0,0 +1,190 @@ +//go:build !skiff + +package config + +import ( + "bytes" + "encoding/json" + "os" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/filecoin-project/curio/deps" + depsconfig "github.com/filecoin-project/curio/deps/config" +) + +func editorGenericJSON(t *testing.T, layer string) map[string]any { + t.Helper() + m, err := uiLayerJSON(layer) + require.NoError(t, err) + b, err := json.Marshal(m) + require.NoError(t, err) + var submitted map[string]any + d := json.NewDecoder(bytes.NewReader(b)) + d.UseNumber() + require.NoError(t, d.Decode(&submitted)) + return submitted +} + +func TestUIGenericLayerConfigRoundTrip(t *testing.T) { + const layer = `[Subsystems] +EnableSealSDR = true +SealSDRMaxTasks = 4 +[Ingest] +MaxQueueSDR = 0 +MaxQueueTrees = 9 +MaxQueuePoRep = 11 +MaxQueueDealSector = 12 +MaxQueueDownload = 13 +MaxQueueCommP = 14 +MaxMarketRunningPipelines = 15 +MaxQueueSnapEncode = 16 +MaxQueueSnapProve = 18 +MaxDealWaitTime = "2h3m" +` + m := editorGenericJSON(t, layer) + for _, edit := range []bool{false, true} { + if edit { + m["Subsystems"].(map[string]any)["SealSDRMaxTasks"] = json.Number("8") + } + out, err := uiPrepareLayerSave("synthetic", m, layer) + require.NoError(t, err) + cfg := depsconfig.DefaultCurioConfig() + _, err = depsconfig.LoadConfigWithUpgrades(out, cfg) + require.NoError(t, err) + require.True(t, cfg.Subsystems.EnableSealSDR) + require.Equal(t, 0, cfg.Ingest.MaxQueueSDR.Get()) + require.Equal(t, 2*time.Hour+3*time.Minute, cfg.Ingest.MaxDealWaitTime.Get()) + require.Equal(t, m, editorGenericJSON(t, out), "every explicit value survives without synthesizing defaults") + } +} + +func TestUIGenericLayerPreservesExplicitDefaults(t *testing.T) { + const layer = `[Subsystems] +EnableSealSDR = false +SealSDRMaxTasks = 0 +[Ingest] +MaxQueueSDR = 8 +` + m := editorGenericJSON(t, layer) + out, err := uiPrepareLayerSave("override", m, layer) + require.NoError(t, err) + require.Equal(t, m, editorGenericJSON(t, out), "a layer default is still an override of earlier layers") +} + +func TestUIGenericLayerUnknownKeysFailClosed(t *testing.T) { + const layer = "[Subsystems]\nEnableSealSDR=true\nFutureUnsupportedOption=23\n" + _, err := uiLayerJSON(layer) + require.Error(t, err) + m := map[string]any{"Subsystems": map[string]any{"EnableSealSDR": true, "FutureUnsupportedOption": json.Number("23")}} + _, err = uiPrepareLayerSave("unknown", m, layer) + require.Error(t, err) + delete(m["Subsystems"].(map[string]any), "FutureUnsupportedOption") + _, err = uiPrepareLayerSave("unknown", m, layer) + require.Error(t, err, "editor omission must not silently destroy an existing unknown key") +} + +func TestUIGenericLayerSchemaSemantics(t *testing.T) { + root := schemaMap(t, buildUISchema()) + node, err := schemaNode(root, root) + require.NoError(t, err) + for path, want := range map[string]string{ + "Subsystems.EnableSealSDR": "boolean", + "Subsystems.SealSDRMaxTasks": "integer", + "Ingest.MaxQueueSDR": "integer", + "Ingest.MaxDealWaitTime": "string", + "Fees.MaxWindowPoStGasFee": "string", + } { + current := node + for _, key := range strings.Split(path, ".") { + props := current["properties"].(map[string]any) + p, ok := props[key].(map[string]any) + require.True(t, ok, path) + current, err = schemaNode(root, p) + require.NoError(t, err, path) + } + require.Equal(t, want, current["type"], path) + } +} + +func TestUIGenericLayerCanonicalKeysAndNestedValues(t *testing.T) { + const layer = `[subsystems] +enablesealsdr = true +[market.storagemarketconfig.mk12] +publishmsgperiod = "1m" +[[addresses]] +mineraddresses = ["t01000"] +[addresses.balancemanager.mk12collateral] +collaterallowthreshold = "3 FIL" +collateralhighthreshold = "7 FIL" +[[market.storagemarketconfig.piecelocator]] +URL = "https://example.invalid" +[market.storagemarketconfig.piecelocator.Headers] +X-Custom = ["", "value"] +` + m := editorGenericJSON(t, layer) + require.Equal(t, true, m["Subsystems"].(map[string]any)["EnableSealSDR"]) + out, err := uiPrepareLayerSave("synthetic", m, layer) + require.NoError(t, err) + require.Equal(t, m, editorGenericJSON(t, out)) + cfg := depsconfig.DefaultCurioConfig() + _, err = depsconfig.LoadConfigWithUpgrades(out, cfg) + require.NoError(t, err) + require.Equal(t, "3 FIL", cfg.Addresses.Get()[0].BalanceManager.MK12Collateral.CollateralLowThreshold.String()) +} + +func TestUIGenericLayerDefaultOverridesEarlierLayer(t *testing.T) { + const earlier = `[Subsystems] +EnableSealSDR=true +[Ingest] +MaxDealWaitTime="1h" +MaxQueueSDR=9 +` + const override = `[Subsystems] +EnableSealSDR=false +[Ingest] +MaxDealWaitTime="0s" +MaxQueueSDR=0 +` + out, err := uiPrepareLayerSave("override", editorGenericJSON(t, override), override) + require.NoError(t, err) + cfg := depsconfig.DefaultCurioConfig() + _, err = depsconfig.LoadConfigWithUpgrades(earlier, cfg) + require.NoError(t, err) + _, err = depsconfig.LoadConfigWithUpgrades(out, cfg) + require.NoError(t, err) + require.False(t, cfg.Subsystems.EnableSealSDR) + require.Zero(t, cfg.Ingest.MaxDealWaitTime.Get()) + require.Zero(t, cfg.Ingest.MaxQueueSDR.Get()) +} + +func TestUIGenericLayerLegacyAddressesAndInvalidValues(t *testing.T) { + const legacy = "[addresses]\nMinerAddresses=[\"t01000\"]\n" + m := editorGenericJSON(t, legacy) + _, ok := m["Addresses"].([]any) + require.True(t, ok, "legacy single address table must become a schema-compatible array") + out, err := uiPrepareLayerSave("legacy", m, legacy) + require.NoError(t, err) + require.Equal(t, m, editorGenericJSON(t, out)) + for _, invalid := range []string{ + "[Ingest]\nMaxDealWaitTime=\"forever\"\n", + "[Fees]\nMaxWindowPoStGasFee=\"not money\"\n", + "[Subsystems]\nEnableSealSDR=\"true\"\n", + } { + _, err := uiLayerJSON(invalid) + require.Error(t, err, "runtime decoder still validates typed values") + } +} + +func TestUIGenericDefaultConfigurationDocumentation(t *testing.T) { + // Match the generator's source of truth, not a hand-maintained field list. + defaults, err := deps.GetDefaultConfig(true) + require.NoError(t, err) + doc, err := os.ReadFile("../../../documentation/en/configuration/default-curio-configuration.md") + require.NoError(t, err) + want := "---\ndescription: The default curio configuration\n---\n\n# Default Curio Configuration\n\n```toml\n" + defaults + "```\n" + require.Equal(t, want, string(doc), "regenerate with the config-default command used by docsgen-cli") +} diff --git a/web/static/config/edit.html b/web/static/config/edit.html index 526d8afc6..8a028819e 100644 --- a/web/static/config/edit.html +++ b/web/static/config/edit.html @@ -249,13 +249,20 @@
- @@ -511,4 +517,4 @@