From c77b5358e26bf5f098d45cdae11a36af1c7555a9 Mon Sep 17 00:00:00 2001 From: ryan Date: Sat, 29 Aug 2026 09:32:53 +0800 Subject: [PATCH] feat(core): add configuration declaration registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 配置读取框架的内核侧抽象:插件用带 config/env/default/autoEnable/secret tag 的结构体声明自己读哪些字段,注册表按 key 归集并对重复声明做一致性 校验,为后续按声明解析与门禁求值提供基础。 --- backend/core/extpoints/config.go | 276 +++++++++++++++++++++++ backend/core/extpoints/config_resolve.go | 25 ++ backend/core/extpoints/config_test.go | 86 +++++++ 3 files changed, 387 insertions(+) create mode 100644 backend/core/extpoints/config.go create mode 100644 backend/core/extpoints/config_resolve.go create mode 100644 backend/core/extpoints/config_test.go diff --git a/backend/core/extpoints/config.go b/backend/core/extpoints/config.go new file mode 100644 index 00000000..b9a4b452 --- /dev/null +++ b/backend/core/extpoints/config.go @@ -0,0 +1,276 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package extpoints + +import ( + "errors" + "fmt" + "reflect" + "strings" + "sync" + "time" +) + +// Sentinel errors returned by the configuration extension point. +var ( + // ErrConfigConflict is returned when the same key is declared with disagreeing metadata. + ErrConfigConflict = errors.New("extpoints: conflicting configuration declarations") + + // ErrConfigType is returned when a value cannot be converted to the declared type. + ErrConfigType = errors.New("extpoints: configuration value type mismatch") + + // ErrConfigUnknownKey is returned when a configuration key was never declared. + ErrConfigUnknownKey = errors.New("extpoints: unknown configuration key") + + // ErrConfigNotResolved is returned when typed reads happen before resolution. + ErrConfigNotResolved = errors.New("extpoints: configuration not resolved; run App.Prepare first") + + // ErrConfigTarget is returned when a binding target is not an addressable struct pointer. + ErrConfigTarget = errors.New("extpoints: configuration binding target must be a non-nil struct pointer") + + // ErrConfigNoSource is returned when resolution is attempted without a registered source. + ErrConfigNoSource = errors.New("extpoints: no configuration source registered") +) + +// Configuration origin labels reported by ConfigView.Origin and ConfigEntry.Origin. +const ( + // OriginEnv marks a value that came from an environment variable. + OriginEnv = "env" + // OriginAutoEnable marks a boolean enabled by the presence of another environment variable. + OriginAutoEnable = "auto-enable" + // OriginFile marks a value that came from the configuration file. + OriginFile = "file" + // OriginDefault marks a value that came from a declaration default. + OriginDefault = "default" +) + +// durationType distinguishes time.Duration from plain int64 during tag walking and decoding. +var durationType = reflect.TypeFor[time.Duration]() + +// ConfigSource abstracts where raw configuration values come from, keeping the +// micro-kernel free of concrete loaders such as viper. +type ConfigSource interface { + // Lookup returns the raw value stored at a dotted path in the configuration file. + Lookup(path string) (any, bool) + // LookupEnv returns the raw value of an environment variable. + LookupEnv(name string) (string, bool) + // Describe returns a human readable identity for the source, used in diagnostics. + Describe() string +} + +// ConfigBinding declares that a plugin reads every `config` tagged field of Target +// under a dotted configuration prefix. +type ConfigBinding struct { + // Prefix is the dotted configuration path, e.g. "redis". An empty prefix means + // each field's `config` tag is already a full path. + Prefix string + // Target must be a non-nil pointer to a struct carrying `config` tags. + Target any +} + +// configField is a single leaf discovered while walking a binding struct's tags. +// key is the fully qualified dotted path used for resolution; path is the raw `config` +// tag value used to locate the Go field again during Bind. +type configField struct { + key string + path string + env string + autoEnable string + def string + secret bool + typ reflect.Type +} + +// configDecl is the registered form of a configField, attributed to its declaring plugin. +type configDecl struct { + key string + pluginID string + env string + autoEnable string + def string + secret bool + typ reflect.Type +} + +// ConfigEntry is a redacted, self-describing view of one effective configuration key. +type ConfigEntry struct { + Key string + PluginID string + Env string + Origin string + Value string +} + +// ConfigView is the read-only surface over effective configuration values. +// Keys are dotted paths such as "redis.enabled". +type ConfigView interface { + Value(key string) (any, bool) + String(key, fallback string) string + Bool(key string, fallback bool) bool + Int(key string, fallback int) int + Duration(key string, fallback time.Duration) time.Duration + Strings(key string) []string + WasSet(envName string) bool + Origin(key string) string +} + +// ConfigExtension is the plugin-facing configuration extension point mounted on the +// root Context and shared by every forked plugin scope. +type ConfigExtension interface { + ConfigView + + // SetSource installs the raw value source after construction, letting the composition + // root build the adapter once the kernel Context already exists. + SetSource(src ConfigSource) + // Declare registers plugin-owned configuration bindings before Apply runs. + Declare(pluginID string, bindings ...ConfigBinding) error + // Bind resolves and assigns the configuration values for a tagged struct. + Bind(prefix string, target any) error + // Resolve computes the effective value of every declared key once. + Resolve() error + // Resolved reports whether Resolve has already run. + Resolved() bool + // Entries returns the redacted effective configuration ordered by key. + Entries() []ConfigEntry +} + +// ConfigRegistry implements ConfigExtension. Declarations are additive; values are +// computed once by Resolve and reused by every later read. +type ConfigRegistry struct { + mu sync.RWMutex + src ConfigSource + decls map[string]*configDecl + order []string + values map[string]any + origins map[string]string +} + +// NewConfigRegistry creates an empty configuration registry. A nil src is allowed so +// that the kernel can construct the registry before the composition root injects one. +func NewConfigRegistry(src ConfigSource) *ConfigRegistry { + return &ConfigRegistry{ + src: src, + decls: make(map[string]*configDecl), + values: make(map[string]any), + origins: make(map[string]string), + } +} + +// SetSource installs the raw value source. It is intended for the composition root, +// which builds the adapter after the kernel Context already exists. +func (r *ConfigRegistry) SetSource(src ConfigSource) { + r.mu.Lock() + defer r.mu.Unlock() + r.src = src +} + +// Declare registers every `config` tagged leaf of each binding's target struct. +// Repeated declarations of the same key are accepted only when their env, default, +// auto-enable and secret metadata agree; disagreement is ErrConfigConflict. +func (r *ConfigRegistry) Declare(pluginID string, bindings ...ConfigBinding) error { + r.mu.Lock() + defer r.mu.Unlock() + + for _, b := range bindings { + if err := r.declareBinding(pluginID, b); err != nil { + return err + } + } + return nil +} + +func (r *ConfigRegistry) declareBinding(pluginID string, b ConfigBinding) error { + target, err := bindingStruct(b.Target, b.Prefix) + if err != nil { + return err + } + + fields, err := walkConfigFields(target.Type(), b.Prefix) + if err != nil { + return err + } + for _, f := range fields { + if err := r.addDecl(pluginID, f); err != nil { + return err + } + } + return nil +} + +// bindingStruct validates that a binding or bind target is a usable struct pointer. +func bindingStruct(target any, prefix string) (reflect.Value, error) { + rv := reflect.ValueOf(target) + if !rv.IsValid() || rv.Kind() != reflect.Pointer || rv.IsNil() || rv.Elem().Kind() != reflect.Struct { + return reflect.Value{}, fmt.Errorf("%w: prefix %q received %T", ErrConfigTarget, prefix, target) + } + return rv.Elem(), nil +} + +// walkConfigFields collects leaf configuration declarations from `config` tagged fields. +// A field without a `config` tag is skipped, except for embedded structs which are +// recursed into so their own tags resolve under the same prefix. +func walkConfigFields(t reflect.Type, prefix string) ([]configField, error) { + var out []configField + + for i := 0; i < t.NumField(); i++ { + sf := t.Field(i) + if sf.PkgPath != "" { + continue + } + + path := sf.Tag.Get("config") + if path == "-" { + continue + } + if path == "" { + if sf.Type.Kind() == reflect.Struct && sf.Type != durationType { + nested, err := walkConfigFields(sf.Type, prefix) + if err != nil { + return nil, err + } + out = append(out, nested...) + } + continue + } + + out = append(out, configField{ + key: joinKey(prefix, path), + path: path, + env: sf.Tag.Get("env"), + autoEnable: sf.Tag.Get("autoEnable"), + def: sf.Tag.Get("default"), + secret: strings.EqualFold(sf.Tag.Get("secret"), "true"), + typ: sf.Type, + }) + } + + return out, nil +} + +func joinKey(prefix, path string) string { + if prefix == "" { + return path + } + return prefix + "." + path +} + +// addDecl records one leaf, enforcing the shared-declaration consistency rule. +func (r *ConfigRegistry) addDecl(pluginID string, f configField) error { + if existing, ok := r.decls[f.key]; ok { + if existing.env != f.env || existing.def != f.def || + existing.autoEnable != f.autoEnable || existing.secret != f.secret { + return fmt.Errorf( + "%w: key %q declared by plugin %q and plugin %q with disagreeing env/default/autoEnable/secret metadata", + ErrConfigConflict, f.key, existing.pluginID, pluginID) + } + return nil + } + + r.decls[f.key] = &configDecl{ + key: f.key, pluginID: pluginID, env: f.env, + autoEnable: f.autoEnable, def: f.def, secret: f.secret, typ: f.typ, + } + r.order = append(r.order, f.key) + return nil +} diff --git a/backend/core/extpoints/config_resolve.go b/backend/core/extpoints/config_resolve.go new file mode 100644 index 00000000..00546aa5 --- /dev/null +++ b/backend/core/extpoints/config_resolve.go @@ -0,0 +1,25 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package extpoints + +import "sort" + +// Entries returns the effective configuration as redacted, key-sorted entries. +func (r *ConfigRegistry) Entries() []ConfigEntry { + r.mu.RLock() + defer r.mu.RUnlock() + + keys := append([]string(nil), r.order...) + sort.Strings(keys) + + out := make([]ConfigEntry, 0, len(keys)) + for _, key := range keys { + d := r.decls[key] + out = append(out, ConfigEntry{ + Key: d.key, PluginID: d.pluginID, Env: d.env, + Origin: r.origins[key], Value: "pending", + }) + } + return out +} diff --git a/backend/core/extpoints/config_test.go b/backend/core/extpoints/config_test.go new file mode 100644 index 00000000..3021866e --- /dev/null +++ b/backend/core/extpoints/config_test.go @@ -0,0 +1,86 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package extpoints_test + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "Wavelet/core/extpoints" +) + +// fakeSource is an in-memory extpoints.ConfigSource used by configuration engine tests. +type fakeSource struct { + values map[string]any + env map[string]string +} + +func newFakeSource() *fakeSource { + return &fakeSource{values: map[string]any{}, env: map[string]string{}} +} + +func (f *fakeSource) Lookup(path string) (any, bool) { + v, ok := f.values[path] + return v, ok +} + +func (f *fakeSource) LookupEnv(name string) (string, bool) { + v, ok := f.env[name] + return v, ok +} + +func (f *fakeSource) Describe() string { return "fake" } + +// redisConfig mirrors how a plugin declares the configuration it reads. +type redisConfig struct { + Enabled bool `config:"enabled" env:"REDIS_ENABLED" default:"false" autoEnable:"REDIS_ADDR"` + Addrs []string `config:"addrs" env:"REDIS_ADDR"` + DB int `config:"db" env:"REDIS_DB"` + KeyPrefix string `config:"key_prefix" env:"REDIS_KEY_PREFIX"` + Dial time.Duration `config:"dial_timeout" env:"REDIS_DIAL_TIMEOUT"` + Ignored string `config:"-"` + private string +} + +func TestDeclareRegistersTaggedLeafKeys(t *testing.T) { + r := extpoints.NewConfigRegistry(newFakeSource()) + + require.NoError(t, r.Declare("cache", extpoints.ConfigBinding{Prefix: "redis", Target: &redisConfig{}})) + + keys := make([]string, 0) + for _, e := range r.Entries() { + keys = append(keys, e.Key) + } + assert.Equal(t, []string{ + "redis.addrs", "redis.db", "redis.dial_timeout", "redis.enabled", "redis.key_prefix", + }, keys) +} + +func TestDeclareRejectsNonStructPointerTarget(t *testing.T) { + r := extpoints.NewConfigRegistry(newFakeSource()) + + assert.ErrorIs(t, r.Declare("cache", extpoints.ConfigBinding{Prefix: "redis", Target: redisConfig{}}), + extpoints.ErrConfigTarget) + assert.ErrorIs(t, r.Declare("cache", extpoints.ConfigBinding{Prefix: "redis", Target: (*redisConfig)(nil)}), + extpoints.ErrConfigTarget) +} + +func TestDeclareAllowsIdenticalDuplicateAndRejectsConflictingMetadata(t *testing.T) { + r := extpoints.NewConfigRegistry(newFakeSource()) + binding := extpoints.ConfigBinding{Prefix: "redis", Target: &redisConfig{}} + require.NoError(t, r.Declare("cache", binding)) + require.NoError(t, r.Declare("cache_memory", binding), "identical shared declarations must be allowed") + + type conflictingConfig struct { + Enabled bool `config:"enabled" env:"REDIS_ON" default:"true"` + } + err := r.Declare("driver_http", extpoints.ConfigBinding{Prefix: "redis", Target: &conflictingConfig{}}) + require.ErrorIs(t, err, extpoints.ErrConfigConflict) + assert.Contains(t, err.Error(), "redis.enabled") + assert.Contains(t, err.Error(), "cache") + assert.Contains(t, err.Error(), "driver_http") +}