mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-03 15:06:36 +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"
|
||||
)
|
||||
|
||||
// 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 {
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 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.
|
||||
func (r *ConfigRegistry) Entries() []ConfigEntry {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
keys := append([]string(nil), r.order...)
|
||||
sort.Strings(keys)
|
||||
@@ -157,10 +158,126 @@ func (r *ConfigRegistry) Entries() []ConfigEntry {
|
||||
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: "pending",
|
||||
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]
|
||||
}
|
||||
|
||||
@@ -164,3 +164,82 @@ func TestResolveReportsTypeMismatchOnBadEnvironmentValue(t *testing.T) {
|
||||
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