mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-29 14:06:36 +08:00
feat(infra): add viper backed configuration source adapter
实现 core.ConfigSource:按 CONFIG_PATH 或向上查找定位 config.yaml,缺文件 降级为纯环境变量来源,坏文件返回错误而非 log.Fatalf,并把 key 命中与"设为 零值"区分开来。viper 依赖被隔离在此包,内核保持零具体运行时依赖。
This commit is contained in:
@@ -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 = "<env only>"
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user