From 8bd59511a8fc5d37891bed1535c817b87476a99c Mon Sep 17 00:00:00 2001 From: ryan Date: Sat, 29 Aug 2026 10:15:32 +0800 Subject: [PATCH] refactor(config): make legacy loader reentrant and add engine parity test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit load(configPath, testMode) 用私有 viper 实例替代包级全局,使同一输入可反复 求值;对拍测试以入库的 config.example.yaml 为必备基准(本地 config.yaml 存在时加测),在四类 env 场景下逐 key 比对新引擎与旧装载器,并以变异检验 确认其能发现漂移。 --- backend/pkg/config/config.go | 44 ++-- backend/pkg/config/parity_test.go | 327 ++++++++++++++++++++++++++++++ 2 files changed, 351 insertions(+), 20 deletions(-) create mode 100644 backend/pkg/config/parity_test.go diff --git a/backend/pkg/config/config.go b/backend/pkg/config/config.go index 52e18829..0f0eb949 100644 --- a/backend/pkg/config/config.go +++ b/backend/pkg/config/config.go @@ -55,19 +55,15 @@ func isTest() bool { return false } -func init() { - // 加载配置文件路径 - configPath := os.Getenv("CONFIG_PATH") - if configPath == "" { - configPath = findConfigPath("config.yaml") - } +// load reads configuration from configPath, applies defaults and environment overrides, +// and optionally disables external services for in-test runs. It uses a private viper +// instance so a caller can resolve the same inputs repeatedly, which the parity test +// against the kernel configuration engine requires. +func load(configPath string, testMode bool) *configModel { + v := viper.New() + v.SetConfigFile(configPath) - // 设置配置文件 - viper.SetConfigFile(configPath) - viper.AutomaticEnv() - - // 读取配置文件(可选:找不到文件时使用空默认值 + 环境变量) - if err := viper.ReadInConfig(); err != nil { + if err := v.ReadInConfig(); err != nil { var notFound viper.ConfigFileNotFoundError if !errors.As(err, ¬Found) { // 文件存在但读取/解析失败 @@ -76,15 +72,15 @@ func init() { } } log.Println("[Config] no config file found, using environment variables only") - viper.SetConfigType("yaml") - if err := viper.ReadConfig(strings.NewReader("")); err != nil { + v.SetConfigType("yaml") + if err := v.ReadConfig(strings.NewReader("")); err != nil { log.Fatalf("[Config] failed to init empty config: %v\n", err) } } // 解析配置到结构体 var c configModel - if err := viper.Unmarshal(&c); err != nil { + if err := v.Unmarshal(&c); err != nil { log.Fatalf("[Config] parse config failed: %v\n", err) } @@ -95,18 +91,26 @@ func init() { applyDefaults(&c) // Disable standard DB/Redis initializations during tests to prevent connection attempts. - if isTest() { + if testMode { c.Database.Enabled = false c.Database.SQLitePath = ":memory:" c.Redis.Enabled = false c.ClickHouse.Enabled = false } - // 设置全局配置 - Config = &c + return &c +} - // 打印配置 - printConfig(&c) +func init() { + // 加载配置文件路径 + configPath := os.Getenv("CONFIG_PATH") + if configPath == "" { + configPath = findConfigPath("config.yaml") + } + + // 设置全局配置并打印 + Config = load(configPath, isTest()) + printConfig(Config) } func applyDefaults(c *configModel) { diff --git a/backend/pkg/config/parity_test.go b/backend/pkg/config/parity_test.go new file mode 100644 index 00000000..02918aa0 --- /dev/null +++ b/backend/pkg/config/parity_test.go @@ -0,0 +1,327 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Temporary migration harness: proves the kernel configuration engine resolves every +// key identically to pkg/config. Delete this file together with backend/pkg/config in P4. +package config + +import ( + "fmt" + "os" + "path/filepath" + "reflect" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "Wavelet/core/extpoints" +) + +// yamlSource is a test-local ConfigSource over the repository configuration file. +// It deliberately does not import plugins/infra/config: backend/pkg must not depend on +// upper layers even in tests, and the adapter has its own coverage in its package tests. +type yamlSource struct { + v *viper.Viper +} + +func newYAMLSource(t *testing.T, path string) *yamlSource { + t.Helper() + + v := viper.New() + v.SetConfigFile(path) + require.NoError(t, v.ReadInConfig()) + return &yamlSource{v: v} +} + +func (s *yamlSource) Lookup(path string) (any, bool) { + if !s.v.IsSet(path) { + return nil, false + } + return s.v.Get(path), true +} + +func (s *yamlSource) LookupEnv(name string) (string, bool) { return os.LookupEnv(name) } + +func (s *yamlSource) Describe() string { return s.v.ConfigFileUsed() } + +// The mirror declarations below reproduce the legacy loader's coverage exactly, which is +// why several keys declare no env: the legacy applyEnvOverrides only honoured an +// environment variable for a subset of fields. Widening that set is a declaration-time +// choice available to each plugin in P3, not an engine behaviour change. +type engineAppConfig struct { + AppName string `config:"app_name" env:"APP_NAME"` + Env string `config:"env" env:"APP_ENV"` + Addr string `config:"addr" env:"APP_ADDR"` + NodeID int64 `config:"node_id" env:"APP_NODE_ID"` + APIPrefix string `config:"api_prefix" env:"APP_API_PREFIX"` + GracefulShutdownTimeout int `config:"graceful_shutdown_timeout" env:"APP_GRACEFUL_SHUTDOWN_TIMEOUT"` + SessionCookieName string `config:"session_cookie_name" env:"APP_SESSION_COOKIE_NAME"` + SessionSecret string `config:"session_secret" env:"APP_SESSION_SECRET" secret:"true"` + SessionDomain string `config:"session_domain" env:"APP_SESSION_DOMAIN"` + SessionAge int `config:"session_age" env:"APP_SESSION_AGE" default:"86400"` + SessionHTTPOnly bool `config:"session_http_only" env:"APP_SESSION_HTTP_ONLY"` + SessionSecure bool `config:"session_secure" env:"APP_SESSION_SECURE"` +} + +type engineReplicaConfig struct { + Host string `config:"host"` + Port int `config:"port"` + Username string `config:"username"` + Password string `config:"password"` +} + +type engineDatabaseConfig struct { + Enabled bool `config:"enabled" env:"DB_ENABLED" default:"false" autoEnable:"DB_HOST"` + SQLitePath string `config:"sqlite_path" env:"SQLITE_PATH"` + Host string `config:"host" env:"DB_HOST"` + Port int `config:"port" env:"DB_PORT"` + Username string `config:"username" env:"DB_USERNAME"` + Password string `config:"password" env:"DB_PASSWORD" secret:"true"` + Database string `config:"database" env:"DB_NAME"` + MaxIdleConn int `config:"max_idle_conn" env:"DB_MAX_IDLE_CONN"` + MaxOpenConn int `config:"max_open_conn" env:"DB_MAX_OPEN_CONN"` + ConnMaxLifetime int `config:"conn_max_lifetime"` + ConnMaxIdleTime int `config:"conn_max_idle_time"` + LogLevel string `config:"log_level" env:"DB_LOG_LEVEL"` + SSLMode string `config:"ssl_mode" env:"DB_SSL_MODE"` + TimeZone string `config:"time_zone" env:"DB_TIMEZONE"` + ApplicationName string `config:"application_name"` + SearchPath string `config:"search_path"` + PreferSimpleProtocol bool `config:"prefer_simple_protocol"` + StatementCacheCapacity int `config:"statement_cache_capacity"` + DefaultQueryExecMode string `config:"default_query_exec_mode"` + Replicas []engineReplicaConfig `config:"replicas"` + SlowThreshold time.Duration `config:"slow_threshold"` +} + +type engineRedisConfig struct { + Enabled bool `config:"enabled" env:"REDIS_ENABLED" default:"false" autoEnable:"REDIS_ADDR"` + Addrs []string `config:"addrs" env:"REDIS_ADDR"` + Username string `config:"username" env:"REDIS_USERNAME"` + Password string `config:"password" env:"REDIS_PASSWORD" secret:"true"` + DB int `config:"db" env:"REDIS_DB"` + ClusterMode bool `config:"cluster_mode"` + MasterName string `config:"master_name"` + KeyPrefix string `config:"key_prefix" env:"REDIS_KEY_PREFIX"` + PoolSize int `config:"pool_size" env:"REDIS_POOL_SIZE"` + MinIdleConn int `config:"min_idle_conn"` + DialTimeout int `config:"dial_timeout"` + ReadTimeout int `config:"read_timeout"` + WriteTimeout int `config:"write_timeout"` + MaxRetries int `config:"max_retries"` + PoolTimeout int `config:"pool_timeout"` + ConnMaxIdleTime int `config:"conn_max_idle_time"` + MaintNotifications bool `config:"maint_notifications" env:"REDIS_MAINT_NOTIFICATIONS"` +} + +type engineClickHouseConfig struct { + Enabled bool `config:"enabled" env:"CLICKHOUSE_ENABLED" default:"false" autoEnable:"CLICKHOUSE_HOST"` + Hosts []string `config:"hosts" env:"CLICKHOUSE_HOST"` + Username string `config:"username" env:"CLICKHOUSE_USERNAME"` + Password string `config:"password" env:"CLICKHOUSE_PASSWORD" secret:"true"` + Database string `config:"database" env:"CLICKHOUSE_NAME"` + MaxIdleConn int `config:"max_idle_conn"` + MaxOpenConn int `config:"max_open_conn"` + ConnMaxLifetime int `config:"conn_max_lifetime"` + DialTimeout int `config:"dial_timeout"` + BlockBufferSize uint8 `config:"block_buffer_size"` +} + +type engineLogConfig struct { + Level string `config:"level" env:"LOG_LEVEL"` + Format string `config:"format" env:"LOG_FORMAT"` + Output string `config:"output" env:"LOG_OUTPUT"` + FilePath string `config:"file_path"` + MaxSize int `config:"max_size"` + MaxAge int `config:"max_age"` + MaxBackups int `config:"max_backups"` + Compress bool `config:"compress"` +} + +type engineOtelConfig struct { + SamplingRate float64 `config:"sampling_rate" env:"OTEL_SAMPLING_RATE"` + TracerName string `config:"tracer_name" env:"OTEL_TRACER_NAME" default:"github.com/Rain-kl/Wavelet"` +} + +type engineQueueConfig struct { + Name string `config:"name"` + Priority int `config:"priority"` +} + +type engineWorkerConfig struct { + Concurrency int `config:"concurrency" env:"WORKER_CONCURRENCY"` + StrictPriority bool `config:"strict_priority" env:"WORKER_STRICT_PRIORITY"` + Queues []engineQueueConfig `config:"queues"` +} + +// durationType mirrors the engine's own notion of a scalar duration field. +var durationType = reflect.TypeFor[time.Duration]() + +// flatten exports a struct into dotted leaf paths rendered as text. Both sides of the +// parity assertion use distinct Go types for the same shape, so values are compared +// textually instead of handing cmp a cross-type diff. +func flatten(prefix string, v reflect.Value, out map[string]string) { + t := v.Type() + + for i := 0; i < t.NumField(); i++ { + field := t.Field(i) + if field.PkgPath != "" { + continue + } + + fv := v.Field(i) + path := prefix + "." + field.Name + if fv.Kind() == reflect.Struct && fv.Type() != durationType { + flatten(path, fv, out) + continue + } + out[path] = fmt.Sprint(fv.Interface()) + } +} + +// configCandidates returns the configuration files to run parity against. The tracked +// example file is mandatory so the proof never skips silently in a fresh clone. A local +// gitignored config.yaml is compared as an extra scenario only when it sits next to the +// example file: the upward search used to locate it can otherwise leave the current +// checkout and silently read a sibling worktree's configuration instead. +func configCandidates(t *testing.T) []string { + t.Helper() + + example := findConfigPath("config.example.yaml") + info, err := os.Stat(example) + if err != nil || info.IsDir() { + t.Fatalf("tracked config.example.yaml is unreachable from the test working directory: %v", err) + } + + candidates := []string{example} + local := filepath.Join(filepath.Dir(example), "config.yaml") + if localInfo, localErr := os.Stat(local); localErr == nil && !localInfo.IsDir() { + candidates = append(candidates, local) + } + return candidates +} + +// bindSections declares every legacy section against the engine and binds them out. +func bindSections(t *testing.T, path string) map[string]string { + t.Helper() + + engine := extpoints.NewConfigRegistry(newYAMLSource(t, path)) + require.NoError(t, engine.Declare("parity", + extpoints.ConfigBinding{Prefix: "app", Target: &engineAppConfig{}}, + extpoints.ConfigBinding{Prefix: "database", Target: &engineDatabaseConfig{}}, + extpoints.ConfigBinding{Prefix: "redis", Target: &engineRedisConfig{}}, + extpoints.ConfigBinding{Prefix: "clickhouse", Target: &engineClickHouseConfig{}}, + extpoints.ConfigBinding{Prefix: "log", Target: &engineLogConfig{}}, + extpoints.ConfigBinding{Prefix: "otel", Target: &engineOtelConfig{}}, + extpoints.ConfigBinding{Prefix: "worker", Target: &engineWorkerConfig{}}, + )) + require.NoError(t, engine.Resolve()) + + var ( + app engineAppConfig + database engineDatabaseConfig + redis engineRedisConfig + clickhouse engineClickHouseConfig + log engineLogConfig + otel engineOtelConfig + worker engineWorkerConfig + ) + targets := []struct { + prefix string + target any + }{ + {"app", &app}, {"database", &database}, {"redis", &redis}, {"clickhouse", &clickhouse}, + {"log", &log}, {"otel", &otel}, {"worker", &worker}, + } + for _, item := range targets { + require.NoError(t, engine.Bind(item.prefix, item.target)) + } + + flat := map[string]string{} + flatten("app", reflect.ValueOf(app), flat) + flatten("database", reflect.ValueOf(database), flat) + flatten("redis", reflect.ValueOf(redis), flat) + flatten("clickhouse", reflect.ValueOf(clickhouse), flat) + flatten("log", reflect.ValueOf(log), flat) + flatten("otel", reflect.ValueOf(otel), flat) + flatten("worker", reflect.ValueOf(worker), flat) + return flat +} + +// legacySections resolves the same inputs with the legacy loader and flattens them. +func legacySections(t *testing.T, path string) map[string]string { + t.Helper() + + legacy := load(path, false) + + flat := map[string]string{} + flatten("app", reflect.ValueOf(legacy.App), flat) + flatten("database", reflect.ValueOf(legacy.Database), flat) + flatten("redis", reflect.ValueOf(legacy.Redis), flat) + flatten("clickhouse", reflect.ValueOf(legacy.ClickHouse), flat) + flatten("log", reflect.ValueOf(legacy.Log), flat) + flatten("otel", reflect.ValueOf(legacy.Otel), flat) + flatten("worker", reflect.ValueOf(legacy.Worker), flat) + return flat +} + +func TestEngineParityWithLegacyLoader(t *testing.T) { + paths := configCandidates(t) + + scenarios := []struct { + name string + env map[string]string + }{ + {name: "file only", env: nil}, + { + name: "implicit enable from hosts", + env: map[string]string{ + "DB_HOST": "postgres", "REDIS_ADDR": "redis:6379", "CLICKHOUSE_HOST": "ch:9000", + }, + }, + { + name: "explicit flags win over implicit enable", + env: map[string]string{ + "DB_HOST": "postgres", "DB_ENABLED": "false", + "REDIS_ADDR": "redis:6379", "REDIS_ENABLED": "false", + "CLICKHOUSE_HOST": "ch:9000", "CLICKHOUSE_ENABLED": "false", + }, + }, + { + name: "scalar overrides", + env: map[string]string{ + "LOG_LEVEL": "debug", "APP_ADDR": ":9999", "DB_PORT": "6543", + "SQLITE_PATH": "./data/parity.db", "REDIS_KEY_PREFIX": "parity:", + "REDIS_MAINT_NOTIFICATIONS": "true", "OTEL_SAMPLING_RATE": "0.5", + "WORKER_CONCURRENCY": "7", "APP_NODE_ID": "42", + }, + }, + } + + for _, path := range paths { + for _, scenario := range scenarios { + t.Run(filepath.Base(path)+" "+scenario.name, func(t *testing.T) { + for name, value := range scenario.env { + t.Setenv(name, value) + } + + assert.Empty(t, cmp.Diff(legacySections(t, path), bindSections(t, path)), + "engine resolution drifted from the legacy loader") + }) + } + } +} + +func TestEngineHonoursEnvOnlyForDeclaredKeys(t *testing.T) { + // slow_threshold has no environment counterpart in either the legacy loader or this + // mirror declaration, so setting one must leave the file value untouched. + t.Setenv("DB_SLOW_THRESHOLD", "9s") + + for _, path := range configCandidates(t) { + assert.Empty(t, cmp.Diff(legacySections(t, path), bindSections(t, path))) + } +}