From 696809899eb863ab10fb8eb1257681dfb16df3a0 Mon Sep 17 00:00:00 2001 From: ryan Date: Sat, 29 Aug 2026 10:06:46 +0800 Subject: [PATCH] feat(infra): add viper backed configuration source adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实现 core.ConfigSource:按 CONFIG_PATH 或向上查找定位 config.yaml,缺文件 降级为纯环境变量来源,坏文件返回错误而非 log.Fatalf,并把 key 命中与"设为 零值"区分开来。viper 依赖被隔离在此包,内核保持零具体运行时依赖。 --- backend/plugins/infra/config/source.go | 126 ++++++++++++++++++++ backend/plugins/infra/config/source_test.go | 93 +++++++++++++++ 2 files changed, 219 insertions(+) create mode 100644 backend/plugins/infra/config/source.go create mode 100644 backend/plugins/infra/config/source_test.go diff --git a/backend/plugins/infra/config/source.go b/backend/plugins/infra/config/source.go new file mode 100644 index 00000000..fd6c711f --- /dev/null +++ b/backend/plugins/infra/config/source.go @@ -0,0 +1,126 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package config adapts viper to the kernel configuration source contract. It is a +// runtime adapter rather than a core.Plugin: it owns no routes, services or tasks and +// therefore never appears in app.Use. Keeping viper here preserves the micro-kernel +// rule against importing concrete runtime dependencies. +package config + +import ( + "errors" + "fmt" + "io/fs" + "os" + + "github.com/spf13/viper" +) + +// DefaultFileName is the configuration file looked up when CONFIG_PATH is unset. +const DefaultFileName = "config.yaml" + +// EnvOnlyOrigin is reported by Describe when no configuration file was loaded. +const EnvOnlyOrigin = "" + +// maxSearchDepth bounds the upward directory walk so a misconfigured working directory +// cannot make the loader scan the whole filesystem. +const maxSearchDepth = 5 + +// Option configures a Source. +type Option func(*Source) + +// WithPath pins the configuration file, bypassing CONFIG_PATH and the upward search. +func WithPath(path string) Option { + return func(s *Source) { + s.path = path + } +} + +// Source implements core.ConfigSource over a configuration file plus the process environment. +type Source struct { + v *viper.Viper + path string + found bool +} + +// NewSource loads the configuration file. A missing file is not an error: the source +// then serves environment values only, matching the behaviour the previous pkg/config +// loader had for deployments that configure everything through the environment. +func NewSource(opts ...Option) (*Source, error) { + s := &Source{} + for _, opt := range opts { + opt(s) + } + + if s.path == "" { + s.path = os.Getenv("CONFIG_PATH") + } + if s.path == "" { + s.path = findConfigPath(DefaultFileName) + } + + v := viper.New() + v.SetConfigFile(s.path) + + err := v.ReadInConfig() + switch { + case err == nil: + s.found = true + case isNotFound(err): + // No file: fall through to environment-only lookups. + default: + if _, statErr := os.Stat(s.path); statErr == nil { //nolint:gosec // s.path comes from CONFIG_PATH or a bounded upward search + return nil, fmt.Errorf("infra/config: read %s: %w", s.path, err) + } + } + + s.v = v + return s, nil +} + +// isNotFound reports whether the loader failed only because the file is absent. +func isNotFound(err error) bool { + var notFound viper.ConfigFileNotFoundError + return errors.As(err, ¬Found) || errors.Is(err, fs.ErrNotExist) +} + +// Lookup returns the raw value stored at a dotted path, or false when the file was not +// loaded or the path is absent. Declared defaults therefore stay distinguishable from +// values explicitly set to a zero. +func (s *Source) Lookup(path string) (any, bool) { + if !s.found || !s.v.IsSet(path) { + return nil, false + } + return s.v.Get(path), true +} + +// LookupEnv reads a process environment variable. +func (s *Source) LookupEnv(name string) (string, bool) { + return os.LookupEnv(name) +} + +// Describe returns the loaded file path, or EnvOnlyOrigin when running on environment values. +func (s *Source) Describe() string { + if !s.found { + return EnvOnlyOrigin + } + return s.path +} + +// findConfigPath searches upward from the working directory so tests and binaries run +// from backend/ still find the repository-root configuration file. +func findConfigPath(configPath string) string { + if _, err := os.Stat(configPath); err == nil { + return configPath + } + + dir := "." + for range maxSearchDepth { + dir += "/.." + path := dir + "/" + configPath + if _, err := os.Stat(path); err == nil { + return path + } + } + return configPath +} diff --git a/backend/plugins/infra/config/source_test.go b/backend/plugins/infra/config/source_test.go new file mode 100644 index 00000000..8372e156 --- /dev/null +++ b/backend/plugins/infra/config/source_test.go @@ -0,0 +1,93 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package config_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "Wavelet/plugins/infra/config" +) + +const sampleYAML = "" + + "app:\n addr: \":8000\"\n node_id: 1\n" + + "database:\n enabled: false\n port: 5432\n slow_threshold: 200ms\n" + + "redis:\n addrs:\n - \"127.0.0.1:6379\"\n" + +func writeConfig(t *testing.T, body string) string { + t.Helper() + + path := filepath.Join(t.TempDir(), "config.yaml") + require.NoError(t, os.WriteFile(path, []byte(body), 0o600)) + return path +} + +func TestSourceLooksUpNestedPaths(t *testing.T) { + src, err := config.NewSource(config.WithPath(writeConfig(t, sampleYAML))) + require.NoError(t, err) + + value, ok := src.Lookup("database.port") + require.True(t, ok) + assert.Equal(t, 5432, value) + + _, ok = src.Lookup("database.missing") + assert.False(t, ok) +} + +func TestSourceKeepsZeroValuedKeysDistinctFromMissing(t *testing.T) { + src, err := config.NewSource(config.WithPath(writeConfig(t, sampleYAML))) + require.NoError(t, err) + + value, ok := src.Lookup("database.enabled") + require.True(t, ok, "an explicitly set false must not look like a missing key") + assert.Equal(t, false, value) +} + +func TestSourceTreatsUnsetFileAsEnvOnly(t *testing.T) { + missing := filepath.Join(t.TempDir(), "absent.yaml") + + src, err := config.NewSource(config.WithPath(missing)) + require.NoError(t, err, "a missing configuration file must fall back to environment values") + + _, ok := src.Lookup("app.addr") + assert.False(t, ok) + assert.Equal(t, config.EnvOnlyOrigin, src.Describe()) +} + +func TestSourceRejectsMalformedFile(t *testing.T) { + src, err := config.NewSource(config.WithPath(writeConfig(t, "app: [unclosed\n"))) + + assert.Nil(t, src) + require.Error(t, err) + assert.Contains(t, err.Error(), "infra/config") +} + +func TestSourceLookupEnvReadsProcessEnvironment(t *testing.T) { + t.Setenv("WAVELET_SOURCE_PROBE", "present") + + src, err := config.NewSource(config.WithPath(writeConfig(t, sampleYAML))) + require.NoError(t, err) + + value, ok := src.LookupEnv("WAVELET_SOURCE_PROBE") + require.True(t, ok) + assert.Equal(t, "present", value) + + _, ok = src.LookupEnv("WAVELET_SOURCE_ABSENT") + assert.False(t, ok) +} + +func TestSourcePrefersConfigPathEnvironmentVariable(t *testing.T) { + t.Setenv("CONFIG_PATH", writeConfig(t, "app:\n addr: \":9100\"\n")) + + src, err := config.NewSource() + require.NoError(t, err) + + value, ok := src.Lookup("app.addr") + require.True(t, ok) + assert.Equal(t, ":9100", value) +}