mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-13 10:26:38 +08:00
feat(core): expose read-only config view, generic getter and redacted dump
补齐 Value/String/Bool/Int/Duration/Strings/WasSet/Origin 只读访问器与 按 secret 脱敏的 Entries 导出,新增 core.ConfigGet[T] 泛型读取入口,并用 编译期断言钉住 ConfigRegistry 对 ConfigExtension 的完整实现。
This commit is contained in:
@@ -0,0 +1,31 @@
|
|||||||
|
// Copyright 2026 Arctel.net
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
package core
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"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
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
// 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)
|
||||||
|
}
|
||||||
@@ -45,9 +45,16 @@ const (
|
|||||||
OriginDefault = "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.
|
// durationType distinguishes time.Duration from plain int64 during tag walking and decoding.
|
||||||
var durationType = reflect.TypeFor[time.Duration]()
|
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
|
// ConfigSource abstracts where raw configuration values come from, keeping the
|
||||||
// micro-kernel free of concrete loaders such as viper.
|
// micro-kernel free of concrete loaders such as viper.
|
||||||
type ConfigSource interface {
|
type ConfigSource interface {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"reflect"
|
"reflect"
|
||||||
"sort"
|
"sort"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Resolve computes the effective value of every declared key. Priority is, in order:
|
// Resolve computes the effective value of every declared key. Priority is, in order:
|
||||||
@@ -148,8 +149,8 @@ func assignFields(elem reflect.Value, fields []configField, values map[string]an
|
|||||||
|
|
||||||
// Entries returns the effective configuration as redacted, key-sorted entries.
|
// Entries returns the effective configuration as redacted, key-sorted entries.
|
||||||
func (r *ConfigRegistry) Entries() []ConfigEntry {
|
func (r *ConfigRegistry) Entries() []ConfigEntry {
|
||||||
r.mu.RLock()
|
r.mu.Lock()
|
||||||
defer r.mu.RUnlock()
|
defer r.mu.Unlock()
|
||||||
|
|
||||||
keys := append([]string(nil), r.order...)
|
keys := append([]string(nil), r.order...)
|
||||||
sort.Strings(keys)
|
sort.Strings(keys)
|
||||||
@@ -157,10 +158,126 @@ func (r *ConfigRegistry) Entries() []ConfigEntry {
|
|||||||
out := make([]ConfigEntry, 0, len(keys))
|
out := make([]ConfigEntry, 0, len(keys))
|
||||||
for _, key := range keys {
|
for _, key := range keys {
|
||||||
d := r.decls[key]
|
d := r.decls[key]
|
||||||
|
if _, done := r.values[key]; !done && r.src != nil {
|
||||||
|
_ = r.resolveLocked(key)
|
||||||
|
}
|
||||||
out = append(out, ConfigEntry{
|
out = append(out, ConfigEntry{
|
||||||
Key: d.key, PluginID: d.pluginID, Env: d.env,
|
Key: d.key,
|
||||||
Origin: r.origins[key], Value: "pending",
|
PluginID: d.pluginID,
|
||||||
|
Env: d.env,
|
||||||
|
Origin: r.origins[key],
|
||||||
|
Value: formatEntryValue(r.values[key], d.secret),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return out
|
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]
|
||||||
|
}
|
||||||
|
|||||||
@@ -164,3 +164,82 @@ func TestResolveReportsTypeMismatchOnBadEnvironmentValue(t *testing.T) {
|
|||||||
assert.Contains(t, err.Error(), "worker.concurrency")
|
assert.Contains(t, err.Error(), "worker.concurrency")
|
||||||
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")
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user