merge: feat(core): implement cordis configuration extension and migrate all plugins

This commit is contained in:
ryan
2026-08-29 10:54:28 +08:00
87 changed files with 3117 additions and 828 deletions
+154 -13
View File
@@ -68,22 +68,54 @@ func WithShutdownTimeout(timeout time.Duration) AppOption {
}
}
// WithConfigSource installs the raw configuration source adapter, typically built by an
// infrastructure package outside the kernel, before any plugin is applied.
func WithConfigSource(src ConfigSource) AppOption {
return func(a *App) {
if src == nil {
return
}
// Installed during Prepare so the option order, including WithContext, is irrelevant.
a.configSource = src
}
}
// WithConfigDecl lets the composition root declare the configuration it reads itself,
// so host-level values take part in conflict validation and the redacted report. The
// bindings are registered during Prepare, so option order does not matter.
func WithConfigDecl(pluginID string, bindings ...ConfigBinding) AppOption {
return func(a *App) {
if len(bindings) == 0 {
return
}
if a.hostDeclOwner == "" {
a.hostDeclOwner = pluginID
}
a.hostDeclBindings = append(a.hostDeclBindings, bindings...)
}
}
// App is the unified assembly entrypoint and runtime aspect dispatcher of the Cordis micro-kernel.
// It manages plugin collection, dependency mounting, migration execution, profile-based driver startup,
// and graceful signal-driven LIFO shutdown.
type App struct {
mu sync.RWMutex
ctx *Context
profile Profile
plugins []Plugin
pluginMap map[string]Plugin
fibers []*Fiber
fiberMap map[string]*Fiber
applied bool
running bool
startedDrivers []Driver
migrationEngine MigrationEngine
shutdownTimeout time.Duration
mu sync.RWMutex
ctx *Context
profile Profile
plugins []Plugin
pluginMap map[string]Plugin
fibers []*Fiber
fiberMap map[string]*Fiber
applied bool
running bool
startedDrivers []Driver
migrationEngine MigrationEngine
shutdownTimeout time.Duration
configSource ConfigSource
hostDeclOwner string
hostDeclBindings []ConfigBinding
prepared bool
applyErr error
}
// NewApp creates a new Cordis application instance with default options.
@@ -162,6 +194,11 @@ func (a *App) Use(plugins ...Plugin) *App {
a.fiberMap[name] = f
}
a.pluginMap[name] = p
if gated, ok := p.(ConfigGatedPlugin); ok && a.applyErr == nil {
// Gates are evaluated before Apply, so their keys must be declared at mount time.
a.applyErr = a.ctx.Config().Declare(name, gated.DeclareConfig()...)
}
}
return a
@@ -227,10 +264,29 @@ func (a *App) Reconcile() error {
}
func (a *App) reconcileLocked() error {
if err := a.prepareLocked(); err != nil {
return err
}
for {
progress := false
for _, f := range a.fibers {
if f.State() == FiberPending && f.DependenciesSatisfied(a.ctx) {
if f.State() != FiberPending {
continue
}
gated, skip, err := a.evaluateGateLocked(f)
if err != nil {
return err
}
if gated && skip {
if err := f.Skip(); err != nil {
return fmt.Errorf("core: skip gated fiber %q failed: %w", f.Name(), err)
}
continue
}
if f.DependenciesSatisfied(a.ctx) {
if err := f.Load(); err != nil {
return fmt.Errorf("core: load fiber %q failed: %w", f.Name(), err)
}
@@ -255,6 +311,24 @@ func (a *App) reconcileLocked() error {
return nil
}
// evaluateGateLocked reports whether a configuration-gated plugin is excluded by the
// resolved values. Plugins that do not implement the gate interface are never skipped.
func (a *App) evaluateGateLocked(f *Fiber) (gated bool, skip bool, err error) {
gatedPlugin, ok := f.plugin.(ConfigGatedPlugin)
if !ok {
return false, false, nil
}
view := a.ctx.Config()
if !view.Resolved() {
return true, false, fmt.Errorf(
"core: plugin %q is configuration-gated but the App has no ConfigSource; "+
"pass core.WithConfigSource or remove DeclareConfig", f.Name())
}
return true, !gatedPlugin.ConfigEnabled(view), nil
}
// ApplyPlugins applies all registered plugins on the application Context via reactive reconciliation.
// It is idempotent and only applies plugins once per App instance.
func (a *App) ApplyPlugins() error {
@@ -264,11 +338,78 @@ func (a *App) ApplyPlugins() error {
return nil
}
a.applied = true
declaredErr, prepareErr := a.applyErr, a.prepareLocked()
a.mu.Unlock()
if declaredErr != nil {
return declaredErr
}
if prepareErr != nil {
return prepareErr
}
return a.Reconcile()
}
// Prepare resolves declared configuration and establishes the resolution barrier that
// gates and plugin Bind calls depend on. It is idempotent and runs implicitly from
// ApplyPlugins; callers that need resolved values earlier — for example to size a
// shutdown budget — invoke it explicitly right after mounting plugins.
func (a *App) Prepare() error {
a.mu.Lock()
defer a.mu.Unlock()
if a.applyErr != nil {
return a.applyErr
}
return a.prepareLocked()
}
// prepareLocked installs the injected source, registers host declarations and resolves
// every declared key once. An App without a ConfigSource leaves configuration unused,
// so kernel-level usage stays opt-in for embedders that configure nothing.
func (a *App) prepareLocked() error {
if a.prepared {
return nil
}
if a.configSource == nil {
a.prepared = true
return nil
}
config := a.ctx.Config()
config.SetSource(a.configSource)
if err := config.Declare(a.hostDeclOwner, a.hostDeclBindings...); err != nil {
return err
}
if err := config.Resolve(); err != nil {
return err
}
a.prepared = true
return nil
}
// ShutdownTimeout returns the graceful shutdown budget for the application.
func (a *App) ShutdownTimeout() time.Duration {
a.mu.RLock()
defer a.mu.RUnlock()
return a.shutdownTimeout
}
// SetShutdownTimeout replaces the graceful shutdown budget, ignoring non-positive
// values so a missing configuration key can never shrink the kernel fallback to zero.
func (a *App) SetShutdownTimeout(timeout time.Duration) *App {
a.mu.Lock()
defer a.mu.Unlock()
if timeout > 0 {
a.shutdownTimeout = timeout
}
return a
}
// RunMigrations dispatches migration execution across all registered plugin migration entries.
func (a *App) RunMigrations() error {
entries := a.ctx.Migrations().Entries()
+90
View File
@@ -504,3 +504,93 @@ func TestAppIdempotencyAndErrorStates(t *testing.T) {
assert.Contains(t, err.Error(), "sql migrate error")
assert.False(t, app3.IsRunning())
}
// newGateSource builds a configuration source whose only key decides the test gates.
func newGateSource(enabled bool) *mapSource {
return &mapSource{
values: map[string]any{"gate.enabled": enabled},
env: map[string]string{},
}
}
func TestAppPrepareResolvesThenGatesDuringReconcile(t *testing.T) {
primary := &gatedPlugin{name: "cache", enabled: true}
fallback := &gatedPlugin{name: "cache_memory", enabled: false}
app := core.NewApp(core.WithConfigSource(newGateSource(true)))
app.Use(primary, fallback)
require.NoError(t, app.Prepare())
cacheFiber, ok := app.Fiber("cache")
require.True(t, ok)
require.Equal(t, core.FiberPending, cacheFiber.State(), "Prepare only builds the resolution barrier")
assert.True(t, app.Context().Config().Resolved())
require.NoError(t, app.Reconcile())
assert.Equal(t, core.FiberActive, cacheFiber.State())
memoryFiber, ok := app.Fiber("cache_memory")
require.True(t, ok)
assert.Equal(t, core.FiberSkipped, memoryFiber.State())
assert.False(t, fallback.applied, "the gated-out provider must never reach Apply")
}
func TestAppGatesPluginsMountedAfterPrepare(t *testing.T) {
app := core.NewApp(core.WithConfigSource(newGateSource(true)))
require.NoError(t, app.Prepare())
late := &gatedPlugin{name: "cache_memory", enabled: false}
app.Use(late)
require.NoError(t, app.Reconcile())
fiber, ok := app.Fiber("cache_memory")
require.True(t, ok)
assert.Equal(t, core.FiberSkipped, fiber.State(),
"plugins mounted after Prepare must still be gated")
}
func TestAppApplyPluginsGatesImplicitly(t *testing.T) {
app := core.NewApp(core.WithConfigSource(newGateSource(false)))
app.Use(&gatedPlugin{name: "cache", enabled: true})
require.NoError(t, app.ApplyPlugins())
fiber, ok := app.Fiber("cache")
require.True(t, ok)
assert.Equal(t, core.FiberSkipped, fiber.State(),
"ApplyPlugins must resolve and gate without an explicit Prepare call")
}
func TestAppPrepareReportsConfigurationErrors(t *testing.T) {
src := &mapSource{
values: map[string]any{"gate.enabled": "yes"},
env: map[string]string{},
}
app := core.NewApp(core.WithConfigSource(src))
app.Use(&gatedPlugin{name: "cache", enabled: true})
err := app.Prepare()
require.Error(t, err)
assert.Contains(t, err.Error(), "gate.enabled")
}
func TestAppGatedPluginWithoutConfigSourceFailsFast(t *testing.T) {
app := core.NewApp()
app.Use(&gatedPlugin{name: "cache", enabled: true})
err := app.ApplyPlugins()
require.Error(t, err)
assert.Contains(t, err.Error(), "cache")
assert.Contains(t, err.Error(), "ConfigSource")
}
func TestAppSetShutdownTimeoutIgnoresNonPositive(t *testing.T) {
app := core.NewApp()
app.SetShutdownTimeout(0)
assert.Equal(t, 10*time.Second, app.ShutdownTimeout(), "zero must not shrink the kernel fallback")
app.SetShutdownTimeout(45 * time.Second)
assert.Equal(t, 45*time.Second, app.ShutdownTimeout())
}
+100
View File
@@ -0,0 +1,100 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package core
import (
"fmt"
"strings"
"Wavelet/core/extpoints"
)
// ConfigGet reads one resolved configuration value with its declared type. It is the
// generic counterpart of the fallback accessors on ConfigView, used when a caller must
// distinguish "unset" from "set to the zero value".
func ConfigGet[T any](view extpoints.ConfigView, key string) (T, error) {
var zero T
if view == nil {
return zero, extpoints.ErrConfigNotResolved
}
raw, ok := view.Value(key)
if !ok {
return zero, fmt.Errorf("%w: %s", extpoints.ErrConfigUnknownKey, key)
}
value, ok := raw.(T)
if !ok {
return zero, fmt.Errorf("%w: key %q holds %T, want %T", extpoints.ErrConfigType, key, raw, zero)
}
return value, nil
}
// MapSource implements ConfigSource backed by an in-memory map, ideal for unit tests.
type MapSource struct {
values map[string]any
env map[string]string
}
// NewMapSource creates a new MapSource with the provided key-value mappings.
func NewMapSource(values map[string]any) *MapSource {
vals := make(map[string]any, len(values))
for k, v := range values {
vals[k] = v
}
return &MapSource{
values: vals,
env: make(map[string]string),
}
}
// Lookup returns the value at the given path, supporting both flat keys and nested maps.
func (m *MapSource) Lookup(path string) (any, bool) {
if m == nil || m.values == nil {
return nil, false
}
if v, ok := m.values[path]; ok {
return v, true
}
parts := strings.Split(path, ".")
var cur any = m.values
for _, part := range parts {
mCur, ok := cur.(map[string]any)
if !ok {
return nil, false
}
cur, ok = mCur[part]
if !ok {
return nil, false
}
}
return cur, true
}
// LookupEnv returns the environment variable value.
func (m *MapSource) LookupEnv(name string) (string, bool) {
if m == nil || m.env == nil {
return "", false
}
v, ok := m.env[name]
return v, ok
}
// SetEnv sets an environment variable for testing.
func (m *MapSource) SetEnv(name, value string) {
if m.env == nil {
m.env = make(map[string]string)
}
m.env[name] = value
}
// Describe describes the MapSource.
func (m *MapSource) Describe() string {
return "<map source>"
}
// WithConfigValues returns an AppOption that installs a MapSource with the given key-value mappings.
func WithConfigValues(values map[string]any) AppOption {
return WithConfigSource(NewMapSource(values))
}
+92
View File
@@ -0,0 +1,92 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package core_test
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"Wavelet/core"
"Wavelet/core/extpoints"
)
// mapSource implements extpoints.ConfigSource over static maps.
type mapSource struct {
values map[string]any
env map[string]string
}
func (m *mapSource) Lookup(path string) (any, bool) {
v, ok := m.values[path]
return v, ok
}
func (m *mapSource) LookupEnv(name string) (string, bool) {
v, ok := m.env[name]
return v, ok
}
func (m *mapSource) Describe() string { return "map" }
type otelConfig struct {
SamplingRate float64 `config:"sampling_rate" env:"OTEL_SAMPLING_RATE"`
}
// newOtelRegistry declares the otel section against a source carrying the given file values.
func newOtelRegistry(t *testing.T, values map[string]any) extpoints.ConfigExtension {
t.Helper()
r := extpoints.NewConfigRegistry(&mapSource{values: values, env: map[string]string{}})
require.NoError(t, r.Declare("host", extpoints.ConfigBinding{Prefix: "otel", Target: &otelConfig{}}))
require.NoError(t, r.Resolve())
return r
}
func TestConfigGetReturnsDeclaredType(t *testing.T) {
view := newOtelRegistry(t, map[string]any{"otel.sampling_rate": 0.25})
rate, err := core.ConfigGet[float64](view, "otel.sampling_rate")
require.NoError(t, err)
assert.Equal(t, 0.25, rate)
}
func TestConfigGetRejectsTypeMismatch(t *testing.T) {
view := newOtelRegistry(t, map[string]any{"otel.sampling_rate": 0.25})
text, err := core.ConfigGet[string](view, "otel.sampling_rate")
require.ErrorIs(t, err, extpoints.ErrConfigType)
assert.Empty(t, text)
}
func TestConfigGetRejectsUndeclaredKey(t *testing.T) {
view := newOtelRegistry(t, nil)
_, err := core.ConfigGet[float64](view, "otel.unregistered")
require.ErrorIs(t, err, extpoints.ErrConfigUnknownKey)
}
func TestConfigGetRejectsNilView(t *testing.T) {
_, err := core.ConfigGet[float64](nil, "otel.sampling_rate")
require.ErrorIs(t, err, extpoints.ErrConfigNotResolved)
}
func TestContextConfigIsSharedAcrossForks(t *testing.T) {
ctx := core.NewContext(nil)
child := ctx.Fork()
require.NotNil(t, ctx.Config())
assert.Same(t, ctx.Config(), child.Config(), "configuration declarations are process-wide facts")
require.NoError(t, child.Config().Declare("cache",
extpoints.ConfigBinding{Prefix: "otel", Target: &otelConfig{}}))
declared := false
for _, entry := range ctx.Config().Entries() {
declared = declared || entry.Key == "otel.sampling_rate"
}
assert.True(t, declared, "a declaration made in a plugin scope must be visible to the root")
assert.False(t, ctx.Config().Resolved())
}
+10
View File
@@ -27,6 +27,7 @@ type Context struct {
tasks extpoints.TaskExtension
schedules extpoints.ScheduleExtension
settings extpoints.SettingExtension
config extpoints.ConfigExtension
mu sync.RWMutex
children []*Context
@@ -56,6 +57,7 @@ func NewContext(base context.Context) *Context {
tasks: extpoints.NewTaskRegistry(),
schedules: extpoints.NewScheduleRegistry(),
settings: extpoints.NewSettingRegistry(),
config: extpoints.NewConfigRegistry(nil),
values: make(map[any]any),
}
}
@@ -167,6 +169,7 @@ func (c *Context) ForkWithContext(base context.Context) *Context {
tasks: c.tasks,
schedules: c.schedules,
settings: c.settings,
config: c.config,
values: make(map[any]any),
}
@@ -235,6 +238,13 @@ func (c *Context) Setting() extpoints.SettingExtension {
return c.Settings()
}
// Config returns the process-level configuration extension point. The registry is
// shared by every fork because configuration declarations are global facts, and it
// carries no per-scope disposers: values are resolved once before Apply runs.
func (c *Context) Config() extpoints.ConfigExtension {
return c.config
}
// OnDispose registers a cleanup callback function to be executed when this Context is disposed.
// It accepts func() error, func(), or Disposer.
func (c *Context) OnDispose(fn any) {
+284
View File
@@ -0,0 +1,284 @@
// 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"
)
// RedactedValue replaces the printed value of keys declared with secret:"true".
const RedactedValue = "******"
// durationType distinguishes time.Duration from plain int64 during tag walking and decoding.
var durationType = reflect.TypeFor[time.Duration]()
// ConfigRegistry must satisfy the full extension contract, so a missing accessor is a
// compile error rather than a runtime surprise inside a plugin Apply.
var _ ConfigExtension = (*ConfigRegistry)(nil)
// 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
resolved bool
}
// 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
}
+283
View File
@@ -0,0 +1,283 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package extpoints
import (
"errors"
"fmt"
"reflect"
"sort"
"time"
)
// Resolve computes the effective value of every declared key. Priority is, in order:
// an explicit environment override, an auto-enable trigger, the configuration file,
// then the declared default. Resolution is idempotent; later declarations resolve lazily.
func (r *ConfigRegistry) Resolve() error {
r.mu.Lock()
defer r.mu.Unlock()
if r.src == nil {
return ErrConfigNoSource
}
var errs []error
for _, key := range r.order {
if _, done := r.values[key]; done {
continue
}
if err := r.resolveLocked(key); err != nil {
errs = append(errs, err)
}
}
r.resolved = true
return errors.Join(errs...)
}
// Resolved reports whether Resolve has already run.
func (r *ConfigRegistry) Resolved() bool {
r.mu.RLock()
defer r.mu.RUnlock()
return r.resolved
}
// resolveLocked computes the effective value of one key. The caller must hold r.mu.
func (r *ConfigRegistry) resolveLocked(key string) error {
d, ok := r.decls[key]
if !ok {
return fmt.Errorf("%w: %s", ErrConfigUnknownKey, key)
}
if d.env != "" {
if raw, found := r.src.LookupEnv(d.env); found {
value, err := convertValue(raw, d.typ)
if err != nil {
return fmt.Errorf("%w: key %q from environment %s: %w", ErrConfigType, key, d.env, err)
}
r.values[key], r.origins[key] = value, OriginEnv
return nil
}
}
if d.autoEnable != "" && d.typ.Kind() == reflect.Bool {
if _, found := r.src.LookupEnv(d.autoEnable); found {
r.values[key], r.origins[key] = true, OriginAutoEnable
return nil
}
}
if raw, found := r.src.Lookup(key); found {
value, err := convertValue(raw, d.typ)
if err != nil {
return fmt.Errorf("%w: key %q from %s: %w", ErrConfigType, key, r.src.Describe(), err)
}
r.values[key], r.origins[key] = value, OriginFile
return nil
}
if d.def != "" {
value, err := convertValue(d.def, d.typ)
if err != nil {
return fmt.Errorf("%w: default %q for key %q: %w", ErrConfigType, d.def, key, err)
}
r.values[key], r.origins[key] = value, OriginDefault
return nil
}
r.values[key] = reflect.New(d.typ).Elem().Interface()
r.origins[key] = ""
return nil
}
// Bind resolves the tagged fields of target and assigns them in place. Prefixes that
// were never declared self-register, so only gates need DeclareConfig.
func (r *ConfigRegistry) Bind(prefix string, target any) error {
r.mu.Lock()
defer r.mu.Unlock()
if r.src == nil {
return ErrConfigNoSource
}
if !r.resolved {
return fmt.Errorf("%w: Bind(%q, %T) ran before App.Prepare", ErrConfigNotResolved, prefix, target)
}
elem, err := bindingStruct(target, prefix)
if err != nil {
return err
}
fields, err := walkConfigFields(elem.Type(), prefix)
if err != nil {
return err
}
for _, f := range fields {
if _, declared := r.decls[f.key]; !declared {
if err := r.addDecl("bind:"+prefix, f); err != nil {
return err
}
}
if _, done := r.values[f.key]; !done {
if err := r.resolveLocked(f.key); err != nil {
return err
}
}
}
return assignFields(elem, fields, r.values)
}
// assignFields writes resolved values into a freshly walked target struct.
func assignFields(elem reflect.Value, fields []configField, values map[string]any) error {
for _, f := range fields {
field := elem.FieldByName(fieldNameForPath(elem.Type(), f.path))
if !field.IsValid() || !field.CanSet() {
return fmt.Errorf("%w: field for key %q is not settable", ErrConfigTarget, f.key)
}
value := reflect.ValueOf(values[f.key])
if !value.Type().AssignableTo(field.Type()) {
return fmt.Errorf("%w: key %q resolves to %s, field expects %s",
ErrConfigType, f.key, value.Type(), field.Type())
}
field.Set(value)
}
return nil
}
// Entries returns the effective configuration as redacted, key-sorted entries.
func (r *ConfigRegistry) Entries() []ConfigEntry {
r.mu.Lock()
defer r.mu.Unlock()
keys := append([]string(nil), r.order...)
sort.Strings(keys)
out := make([]ConfigEntry, 0, len(keys))
for _, key := range keys {
d := r.decls[key]
if _, done := r.values[key]; !done && r.src != nil {
_ = r.resolveLocked(key)
}
out = append(out, ConfigEntry{
Key: d.key,
PluginID: d.pluginID,
Env: d.env,
Origin: r.origins[key],
Value: formatEntryValue(r.values[key], d.secret),
})
}
return out
}
// formatEntryValue renders one effective value for diagnostics, masking secrets.
func formatEntryValue(value any, secret bool) string {
if secret {
return RedactedValue
}
if value == nil {
return ""
}
return fmt.Sprint(value)
}
// Value returns the resolved value for key, lazily resolving a declared key that has
// not been computed yet. Missing and unresolvable keys report false rather than an
// error so gates and diagnostics can keep using the fallback accessors.
func (r *ConfigRegistry) Value(key string) (any, bool) {
r.mu.Lock()
defer r.mu.Unlock()
if _, done := r.values[key]; !done {
if r.src == nil {
return nil, false
}
if _, declared := r.decls[key]; !declared {
return nil, false
}
if err := r.resolveLocked(key); err != nil {
return nil, false
}
}
value, ok := r.values[key]
return value, ok
}
// String returns the string value of key or fallback when absent or mismatched.
func (r *ConfigRegistry) String(key, fallback string) string {
if value, ok := r.Value(key); ok {
if converted, err := convertString(value); err == nil {
return converted.(string)
}
}
return fallback
}
// Bool returns the boolean value of key or fallback when absent or mismatched.
func (r *ConfigRegistry) Bool(key string, fallback bool) bool {
if value, ok := r.Value(key); ok {
if converted, err := convertBool(value); err == nil {
return converted.(bool)
}
}
return fallback
}
// Int returns the int value of key or fallback when absent or mismatched.
func (r *ConfigRegistry) Int(key string, fallback int) int {
if value, ok := r.Value(key); ok {
converted, err := convertNumeric(value, reflect.TypeFor[int](), signedNumbers)
if err == nil {
return converted.(int)
}
}
return fallback
}
// Duration returns the time.Duration value of key or fallback when absent or mismatched.
func (r *ConfigRegistry) Duration(key string, fallback time.Duration) time.Duration {
if value, ok := r.Value(key); ok {
if converted, err := convertDuration(value); err == nil {
return converted.(time.Duration)
}
}
return fallback
}
// Strings returns the []string value of key, or nil when absent.
func (r *ConfigRegistry) Strings(key string) []string {
value, ok := r.Value(key)
if !ok {
return nil
}
converted, err := convertSlice(value, reflect.TypeFor[[]string]())
if err != nil {
return nil
}
list, _ := converted.([]string)
return list
}
// WasSet reports whether an environment variable is present, regardless of its value.
func (r *ConfigRegistry) WasSet(envName string) bool {
r.mu.RLock()
defer r.mu.RUnlock()
if r.src == nil {
return false
}
_, found := r.src.LookupEnv(envName)
return found
}
// Origin reports where a key's effective value came from; "" means the zero value.
func (r *ConfigRegistry) Origin(key string) string {
r.mu.RLock()
defer r.mu.RUnlock()
return r.origins[key]
}
+245
View File
@@ -0,0 +1,245 @@
// 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")
}
// queueConfig is a composite element mirroring worker.queues in config.yaml.
type queueConfig struct {
Name string `config:"name"`
Priority int `config:"priority"`
}
type workerConfig struct {
Concurrency int `config:"concurrency" env:"WORKER_CONCURRENCY"`
Queues []queueConfig `config:"queues"`
}
type sessionConfig struct {
Secret string `config:"session_secret" env:"APP_SESSION_SECRET" secret:"true"`
Age int `config:"session_age" env:"APP_SESSION_AGE" default:"86400"`
}
func TestResolveScalarDurationAndSlice(t *testing.T) {
src := newFakeSource()
src.values["redis.db"] = 1
src.values["redis.dial_timeout"] = "5s"
src.values["redis.addrs"] = []any{"127.0.0.1:6379"}
src.env["REDIS_KEY_PREFIX"] = "refresh:"
r := extpoints.NewConfigRegistry(src)
require.NoError(t, r.Declare("cache", extpoints.ConfigBinding{Prefix: "redis", Target: &redisConfig{}}))
require.NoError(t, r.Resolve())
var got redisConfig
require.NoError(t, r.Bind("redis", &got))
assert.Equal(t, redisConfig{
Addrs: []string{"127.0.0.1:6379"}, DB: 1, KeyPrefix: "refresh:", Dial: 5 * time.Second,
}, got)
}
func TestResolveFillsSliceFromScalarEnvironmentValue(t *testing.T) {
src := newFakeSource()
src.env["REDIS_ADDR"] = "redis:6379"
r := extpoints.NewConfigRegistry(src)
require.NoError(t, r.Declare("cache", extpoints.ConfigBinding{Prefix: "redis", Target: &redisConfig{}}))
require.NoError(t, r.Resolve())
var got redisConfig
require.NoError(t, r.Bind("redis", &got))
assert.Equal(t, []string{"redis:6379"}, got.Addrs)
}
func TestResolveCompositeSliceOfStructs(t *testing.T) {
src := newFakeSource()
src.values["worker.concurrency"] = 20
src.values["worker.queues"] = []any{
map[string]any{"name": "webhook", "priority": 10},
map[string]any{"name": "default", "priority": 3},
}
r := extpoints.NewConfigRegistry(src)
require.NoError(t, r.Declare("asynq_worker", extpoints.ConfigBinding{Prefix: "worker", Target: &workerConfig{}}))
require.NoError(t, r.Resolve())
var got workerConfig
require.NoError(t, r.Bind("worker", &got))
assert.Equal(t, workerConfig{
Concurrency: 20,
Queues: []queueConfig{{Name: "webhook", Priority: 10}, {Name: "default", Priority: 3}},
}, got)
}
func TestResolveReportsTypeMismatchOnBadEnvironmentValue(t *testing.T) {
src := newFakeSource()
src.env["WORKER_CONCURRENCY"] = "many"
r := extpoints.NewConfigRegistry(src)
require.NoError(t, r.Declare("asynq_worker", extpoints.ConfigBinding{Prefix: "worker", Target: &workerConfig{}}))
err := r.Resolve()
require.ErrorIs(t, err, extpoints.ErrConfigType)
assert.Contains(t, err.Error(), "worker.concurrency")
assert.Contains(t, err.Error(), "WORKER_CONCURRENCY")
}
func TestViewAccessorsAndOrigins(t *testing.T) {
src := newFakeSource()
src.values["redis.db"] = 1
src.env["REDIS_ADDR"] = "redis:6379"
src.env["REDIS_ENABLED"] = "false"
r := extpoints.NewConfigRegistry(src)
require.NoError(t, r.Declare("cache", extpoints.ConfigBinding{Prefix: "redis", Target: &redisConfig{}}))
require.NoError(t, r.Declare("auth", extpoints.ConfigBinding{Prefix: "app", Target: &sessionConfig{}}))
require.NoError(t, r.Resolve())
assert.Equal(t, extpoints.OriginEnv, r.Origin("redis.addrs"))
assert.Equal(t, []string{"redis:6379"}, r.Strings("redis.addrs"))
assert.False(t, r.Bool("redis.enabled", true))
assert.Equal(t, 1, r.Int("redis.db", 0))
assert.Equal(t, "86400", r.String("app.session_age", "0"))
assert.Equal(t, "fallback", r.String("redis.missing", "fallback"))
assert.Zero(t, r.Duration("redis.dial_timeout", 0))
assert.True(t, r.WasSet("REDIS_ADDR"))
assert.False(t, r.WasSet("REDIS_NOPE"))
}
func TestAutoEnableBeatsFileValueButLosesToExplicitEnv(t *testing.T) {
src := newFakeSource()
src.env["REDIS_ADDR"] = "redis:6379"
src.values["redis.enabled"] = false
r := extpoints.NewConfigRegistry(src)
require.NoError(t, r.Declare("cache", extpoints.ConfigBinding{Prefix: "redis", Target: &redisConfig{}}))
require.NoError(t, r.Resolve())
assert.True(t, r.Bool("redis.enabled", false), "REDIS_ADDR presence implies enabled")
assert.Equal(t, extpoints.OriginAutoEnable, r.Origin("redis.enabled"))
explicit := newFakeSource()
explicit.env["REDIS_ADDR"] = "redis:6379"
explicit.env["REDIS_ENABLED"] = "false"
r2 := extpoints.NewConfigRegistry(explicit)
require.NoError(t, r2.Declare("cache", extpoints.ConfigBinding{Prefix: "redis", Target: &redisConfig{}}))
require.NoError(t, r2.Resolve())
assert.False(t, r2.Bool("redis.enabled", true), "explicit REDIS_ENABLED must win over auto-enable")
assert.Equal(t, extpoints.OriginEnv, r2.Origin("redis.enabled"))
}
func TestEntriesRedactSecretsAndReportDefaults(t *testing.T) {
r := extpoints.NewConfigRegistry(newFakeSource())
require.NoError(t, r.Declare("auth", extpoints.ConfigBinding{Prefix: "app", Target: &sessionConfig{}}))
require.NoError(t, r.Resolve())
entries := map[string]extpoints.ConfigEntry{}
for _, e := range r.Entries() {
entries[e.Key] = e
}
assert.Equal(t, extpoints.RedactedValue, entries["app.session_secret"].Value)
assert.Equal(t, extpoints.OriginDefault, entries["app.session_age"].Origin)
assert.Equal(t, "86400", entries["app.session_age"].Value)
}
func TestBindRejectsReadsBeforeSourceIsRegistered(t *testing.T) {
r := extpoints.NewConfigRegistry(nil)
require.NoError(t, r.Declare("cache", extpoints.ConfigBinding{Prefix: "redis", Target: &redisConfig{}}))
assert.ErrorIs(t, r.Resolve(), extpoints.ErrConfigNoSource)
var cfg redisConfig
assert.ErrorIs(t, r.Bind("redis", &cfg), extpoints.ErrConfigNoSource)
}
func TestBindRejectsReadsBeforeResolution(t *testing.T) {
r := extpoints.NewConfigRegistry(newFakeSource())
require.NoError(t, r.Declare("cache", extpoints.ConfigBinding{Prefix: "redis", Target: &redisConfig{}}))
var cfg redisConfig
err := r.Bind("redis", &cfg)
require.ErrorIs(t, err, extpoints.ErrConfigNotResolved)
assert.Contains(t, err.Error(), "App.Prepare")
}
+243
View File
@@ -0,0 +1,243 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package extpoints
import (
"fmt"
"reflect"
"strconv"
"strings"
"time"
)
// convertValue coerces a raw value coming from the configuration file or an
// environment variable into the declared Go type.
func convertValue(raw any, typ reflect.Type) (any, error) {
if typ == durationType {
return convertDuration(raw)
}
switch typ.Kind() {
case reflect.Bool:
return convertBool(raw)
case reflect.String:
return convertString(raw)
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return convertNumeric(raw, typ, signedNumbers)
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
return convertNumeric(raw, typ, unsignedNumbers)
case reflect.Float32, reflect.Float64:
return convertNumeric(raw, typ, floatingNumbers)
case reflect.Slice:
return convertSlice(raw, typ)
case reflect.Struct:
return convertStruct(raw, typ)
default:
return nil, fmt.Errorf("%w: %s is not a supported configuration type", ErrConfigType, typ)
}
}
func convertBool(raw any) (any, error) {
switch v := raw.(type) {
case bool:
return v, nil
case string:
parsed, err := strconv.ParseBool(strings.TrimSpace(v))
if err != nil {
return nil, fmt.Errorf("%w: %q is not a boolean", ErrConfigType, v)
}
return parsed, nil
default:
return nil, fmt.Errorf("%w: %v is not a boolean", ErrConfigType, raw)
}
}
func convertString(raw any) (any, error) {
switch v := raw.(type) {
case string:
return v, nil
case bool, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64:
return fmt.Sprint(v), nil
default:
return nil, fmt.Errorf("%w: %v is not a string", ErrConfigType, raw)
}
}
// numericString extracts the textual form of a value so environment overrides,
// which always arrive as strings, share one parsing path with file values.
func numericString(raw any) (string, bool) {
switch v := raw.(type) {
case string:
return strings.TrimSpace(v), true
case bool, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64:
return fmt.Sprint(v), true
default:
return "", false
}
}
// numericKind selects which strconv family converts a raw value.
type numericKind int
const (
signedNumbers numericKind = iota
unsignedNumbers
floatingNumbers
)
// convertNumeric parses a raw value into the numeric type declared by typ. The three
// numeric families share one implementation because they differ only in the strconv
// call and the reflect setter.
func convertNumeric(raw any, typ reflect.Type, family numericKind) (any, error) {
text, ok := numericString(raw)
if !ok {
return nil, fmt.Errorf("%w: %v is not a %s", ErrConfigType, raw, typ)
}
out := reflect.New(typ).Elem()
var err error
switch family {
case signedNumbers:
parsed, parseErr := strconv.ParseInt(text, 10, typ.Bits())
out.SetInt(parsed)
err = parseErr
case unsignedNumbers:
parsed, parseErr := strconv.ParseUint(text, 10, typ.Bits())
out.SetUint(parsed)
err = parseErr
default:
parsed, parseErr := strconv.ParseFloat(text, typ.Bits())
out.SetFloat(parsed)
err = parseErr
}
if err != nil {
return nil, fmt.Errorf("%w: %q is not a valid %s", ErrConfigType, text, typ)
}
return out.Interface(), nil
}
// convertDuration accepts both Go duration strings such as "200ms" and integer
// nanoseconds, mirroring what the previous viper based decoding supported.
func convertDuration(raw any) (any, error) {
if v, ok := raw.(time.Duration); ok {
return v, nil
}
text, ok := numericString(raw)
if !ok {
return nil, fmt.Errorf("%w: %v is not a duration", ErrConfigType, raw)
}
if parsed, err := time.ParseDuration(text); err == nil {
return parsed, nil
}
nanos, err := strconv.ParseInt(text, 10, 64)
if err != nil {
return nil, fmt.Errorf("%w: %q is not a valid duration", ErrConfigType, text)
}
return time.Duration(nanos), nil
}
// convertSlice promotes a scalar into a single-element slice so that a value such as
// REDIS_ADDR=redis:6379 can populate the redis.addrs list.
func convertSlice(raw any, typ reflect.Type) (any, error) {
items, ok := sliceItems(raw)
if !ok {
items = []any{raw}
}
out := reflect.MakeSlice(typ, 0, len(items))
for _, item := range items {
converted, err := convertValue(item, typ.Elem())
if err != nil {
return nil, err
}
out = reflect.Append(out, reflect.ValueOf(converted))
}
return out.Interface(), nil
}
// sliceItems normalises the several slice shapes a loader may produce.
func sliceItems(raw any) ([]any, bool) {
switch v := raw.(type) {
case []any:
return v, true
case []string:
items := make([]any, len(v))
for i, s := range v {
items[i] = s
}
return items, true
}
rv := reflect.ValueOf(raw)
if rv.IsValid() && rv.Kind() == reflect.Slice {
items := make([]any, rv.Len())
for i := range items {
items[i] = rv.Index(i).Interface()
}
return items, true
}
return nil, false
}
func convertStruct(raw any, typ reflect.Type) (any, error) {
table, ok := asStringMap(raw)
if !ok {
return nil, fmt.Errorf("%w: %v is not a mapping, cannot decode into %s", ErrConfigType, raw, typ)
}
fields, err := walkConfigFields(typ, "")
if err != nil {
return nil, err
}
out := reflect.New(typ).Elem()
for _, f := range fields {
item, present := table[f.path]
if !present || item == nil {
continue
}
converted, err := convertValue(item, f.typ)
if err != nil {
return nil, fmt.Errorf("%w: %s.%s: %w", ErrConfigType, typ.Name(), f.key, err)
}
out.FieldByName(fieldNameForPath(typ, f.path)).Set(reflect.ValueOf(converted))
}
return out.Interface(), nil
}
// asStringMap normalises the two map shapes produced by YAML decoders.
func asStringMap(raw any) (map[string]any, bool) {
switch v := raw.(type) {
case map[string]any:
return v, true
case map[any]any:
out := make(map[string]any, len(v))
for key, val := range v {
name, ok := key.(string)
if !ok {
return nil, false
}
out[name] = val
}
return out, true
default:
return nil, false
}
}
// fieldNameForPath maps a declared config path back to the Go struct field carrying it.
func fieldNameForPath(t reflect.Type, path string) string {
for i := 0; i < t.NumField(); i++ {
if t.Field(i).Tag.Get("config") == path {
return t.Field(i).Name
}
}
return ""
}
+24
View File
@@ -27,6 +27,10 @@ const (
// FiberDisposed indicates the plugin has been completely unmounted and its context disposed.
FiberDisposed FiberState = "DISPOSED"
// FiberSkipped indicates the plugin never activated because its configuration gate
// evaluated to false, so an alternative provider took over.
FiberSkipped FiberState = "SKIPPED"
)
// Fiber wraps a Plugin instance with a dedicated scoped Context and manages its
@@ -134,6 +138,26 @@ func (f *Fiber) Load() error {
return nil
}
// Skip transitions a pending plugin to FiberSkipped and releases its scoped Context.
// Active plugins are left untouched, which makes the call safe to replay on every
// reconciliation pass, including for plugins mounted after the first gate evaluation.
func (f *Fiber) Skip() error {
f.mu.Lock()
if f.state != FiberPending {
f.mu.Unlock()
return nil
}
f.state = FiberSkipped
f.mu.Unlock()
return f.ctx.Dispose()
}
// Skipped reports whether the plugin was excluded by its configuration gate.
func (f *Fiber) Skipped() bool {
return f.State() == FiberSkipped
}
// Unload tears down the plugin: ACTIVE -> UNLOADING -> DISPOSED.
func (f *Fiber) Unload() error {
f.mu.Lock()
+54
View File
@@ -5,6 +5,7 @@ package core_test
import (
"Wavelet/core"
"Wavelet/core/extpoints"
"context"
"reflect"
"testing"
@@ -108,3 +109,56 @@ func TestFiber_UnsatisfiedDependencyReturnsError(t *testing.T) {
assert.Error(t, err)
assert.Contains(t, err.Error(), "unsatisfied dependencies")
}
// gateConfig is the configuration section consumed by gatedPlugin below.
type gateConfig struct {
Enabled bool `config:"enabled" env:"GATE_ENABLED"`
}
// gatedPlugin implements core.ConfigGatedPlugin: two instances with opposite
// expectations model the mutually exclusive cache / cache_memory driver pair.
type gatedPlugin struct {
name string
enabled bool
applied bool
}
func (g *gatedPlugin) Name() string { return g.name }
func (g *gatedPlugin) Apply(_ *core.Context) error {
g.applied = true
return nil
}
func (g *gatedPlugin) DeclareConfig() []extpoints.ConfigBinding {
return []extpoints.ConfigBinding{{Prefix: "gate", Target: &gateConfig{}}}
}
func (g *gatedPlugin) ConfigEnabled(view extpoints.ConfigView) bool {
return view.Bool("gate.enabled", false) == g.enabled
}
func TestFiberSkipMovesToSkippedStateAndDisposesScope(t *testing.T) {
root := core.NewContext(nil)
plugin := &gatedPlugin{name: "cache", enabled: true}
f := core.NewFiber(root, plugin)
require.Equal(t, core.FiberPending, f.State())
require.NoError(t, f.Skip())
assert.Equal(t, core.FiberSkipped, f.State())
assert.True(t, f.Skipped())
assert.False(t, plugin.applied, "a skipped plugin must never reach Apply")
assert.NoError(t, f.Unload(), "unloading a skipped fiber is a no-op")
}
func TestFiberSkipIsInertForActiveFibers(t *testing.T) {
root := core.NewContext(nil)
f := core.NewFiber(root, &gatedPlugin{name: "cache", enabled: true})
require.NoError(t, f.Load())
require.NoError(t, f.Skip())
assert.Equal(t, core.FiberActive, f.State(), "Skip only applies to pending fibers")
assert.False(t, f.Skipped())
}
+29
View File
@@ -154,3 +154,32 @@ type SettingExtension = extpoints.SettingExtension
// SettingSchema re-exports extpoints.SettingSchema.
type SettingSchema = extpoints.SettingSchema
// ConfigExtension re-exports extpoints.ConfigExtension.
type ConfigExtension = extpoints.ConfigExtension
// ConfigSource re-exports extpoints.ConfigSource.
type ConfigSource = extpoints.ConfigSource
// ConfigBinding re-exports extpoints.ConfigBinding.
type ConfigBinding = extpoints.ConfigBinding
// ConfigView re-exports extpoints.ConfigView.
type ConfigView = extpoints.ConfigView
// ConfigEntry re-exports extpoints.ConfigEntry.
type ConfigEntry = extpoints.ConfigEntry
// ConfigGatedPlugin is an optional interface for plugins whose activation depends on
// configuration. The kernel evaluates the gate during reconciliation, before the
// plugin's Apply runs, so every key read by ConfigEnabled must be published through
// DeclareConfig.
type ConfigGatedPlugin interface {
Plugin
// DeclareConfig publishes the configuration bindings consumed by ConfigEnabled.
DeclareConfig() []extpoints.ConfigBinding
// ConfigEnabled reports whether this plugin should activate for the resolved values.
ConfigEnabled(view extpoints.ConfigView) bool
}