diff --git a/backend/core/config.go b/backend/core/config.go new file mode 100644 index 00000000..5d8b1080 --- /dev/null +++ b/backend/core/config.go @@ -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 +} diff --git a/backend/core/config_test.go b/backend/core/config_test.go new file mode 100644 index 00000000..79b317e8 --- /dev/null +++ b/backend/core/config_test.go @@ -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) +} diff --git a/backend/core/extpoints/config.go b/backend/core/extpoints/config.go index 2258cd6a..b81e53f9 100644 --- a/backend/core/extpoints/config.go +++ b/backend/core/extpoints/config.go @@ -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 { diff --git a/backend/core/extpoints/config_resolve.go b/backend/core/extpoints/config_resolve.go index d28a17b9..09207c30 100644 --- a/backend/core/extpoints/config_resolve.go +++ b/backend/core/extpoints/config_resolve.go @@ -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] +} diff --git a/backend/core/extpoints/config_test.go b/backend/core/extpoints/config_test.go index f89ecce8..5315ec50 100644 --- a/backend/core/extpoints/config_test.go +++ b/backend/core/extpoints/config_test.go @@ -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") +}