mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-29 14:06:36 +08:00
feat(core): add configuration declaration registry
配置读取框架的内核侧抽象:插件用带 config/env/default/autoEnable/secret tag 的结构体声明自己读哪些字段,注册表按 key 归集并对重复声明做一致性 校验,为后续按声明解析与门禁求值提供基础。
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
Reference in New Issue
Block a user