mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-06 15:46:37 +08:00
feat(config): load manifest config.default.yaml with config.yaml override and clean root configs
This commit is contained in:
@@ -26,7 +26,7 @@ env:
|
|||||||
LICENSE
|
LICENSE
|
||||||
README.md
|
README.md
|
||||||
README_zh.md
|
README_zh.md
|
||||||
config.example.yaml
|
manifest/config/config.default.yaml
|
||||||
DEPLOYMENT_zh.md
|
DEPLOYMENT_zh.md
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
|
|||||||
@@ -211,9 +211,8 @@ pnpm format
|
|||||||
```
|
```
|
||||||
wavelet/
|
wavelet/
|
||||||
├── main.go # Entry point (delegates to internal/cmd)
|
├── main.go # Entry point (delegates to internal/cmd)
|
||||||
├── config.example.yaml # Configuration template
|
|
||||||
├── Makefile # Common commands (swagger, tidy, license, cross-build)
|
├── Makefile # Common commands (swagger, tidy, license, cross-build)
|
||||||
├── manifest/ # Manifests: docker (compose/Dockerfiles), deploy (k8s), config
|
├── manifest/ # Manifests: docker (Dockerfiles), deploy (k8s), config (default/override)
|
||||||
├── docs/ # Swagger auto-generated docs
|
├── docs/ # Swagger auto-generated docs
|
||||||
├── frontend/ # Next.js frontend application
|
├── frontend/ # Next.js frontend application
|
||||||
│ ├── app/ # App Router pages
|
│ ├── app/ # App Router pages
|
||||||
|
|||||||
+1
-2
@@ -211,9 +211,8 @@ pnpm format
|
|||||||
```
|
```
|
||||||
wavelet/
|
wavelet/
|
||||||
├── main.go # 程序入口(委托给 internal/cmd)
|
├── main.go # 程序入口(委托给 internal/cmd)
|
||||||
├── config.example.yaml # 配置模板
|
|
||||||
├── Makefile # 常用命令(swagger、tidy、license、cross-build)
|
├── Makefile # 常用命令(swagger、tidy、license、cross-build)
|
||||||
├── manifest/ # 项目清单与编排:docker 镜像构建/compose、deploy (k8s)、config 配置
|
├── manifest/ # 项目清单与编排:docker 镜像构建、deploy (k8s)、config 配置(默认/覆盖)
|
||||||
├── docs/ # Swagger 自动生成文档
|
├── docs/ # Swagger 自动生成文档
|
||||||
├── frontend/ # Next.js 前端应用
|
├── frontend/ # Next.js 前端应用
|
||||||
│ ├── app/ # App Router 页面
|
│ ├── app/ # App Router 页面
|
||||||
|
|||||||
@@ -16,15 +16,14 @@ import (
|
|||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DefaultFileName is the configuration file looked up when CONFIG_PATH is unset.
|
// DefaultBaseFileName is the default base configuration file path looked up relative to the workspace.
|
||||||
const DefaultFileName = "config.yaml"
|
const DefaultBaseFileName = "manifest/config/config.default.yaml"
|
||||||
|
|
||||||
// DefaultCandidates defines the prioritized list of configuration file paths looked up
|
// DefaultOverrideFileName is the default user override configuration file path looked up relative to the workspace.
|
||||||
// when CONFIG_PATH is unset.
|
const DefaultOverrideFileName = "manifest/config/config.yaml"
|
||||||
var DefaultCandidates = []string{
|
|
||||||
"manifest/config/config.yaml",
|
// DefaultFileName is kept for backwards compatibility.
|
||||||
DefaultFileName,
|
const DefaultFileName = DefaultOverrideFileName
|
||||||
}
|
|
||||||
|
|
||||||
// EnvOnlyOrigin is reported by Describe when no configuration file was loaded.
|
// EnvOnlyOrigin is reported by Describe when no configuration file was loaded.
|
||||||
const EnvOnlyOrigin = "<env only>"
|
const EnvOnlyOrigin = "<env only>"
|
||||||
@@ -40,47 +39,94 @@ type Option func(*Source)
|
|||||||
func WithPath(path string) Option {
|
func WithPath(path string) Option {
|
||||||
return func(s *Source) {
|
return func(s *Source) {
|
||||||
s.path = path
|
s.path = path
|
||||||
|
s.pinned = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithDefaultPath pins the default base configuration file path.
|
||||||
|
func WithDefaultPath(path string) Option {
|
||||||
|
return func(s *Source) {
|
||||||
|
s.defaultPath = path
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Source implements core.ConfigSource over a configuration file plus the process environment.
|
// Source implements core.ConfigSource over a configuration file plus the process environment.
|
||||||
type Source struct {
|
type Source struct {
|
||||||
v *viper.Viper
|
v *viper.Viper
|
||||||
path string
|
path string
|
||||||
found bool
|
defaultPath string
|
||||||
|
pinned bool
|
||||||
|
defaultFound bool
|
||||||
|
overrideFound bool
|
||||||
|
found bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSource loads the configuration file. A missing file is not an error: the source
|
// NewSource loads configuration files. By default, it loads manifest/config/config.default.yaml
|
||||||
// then serves environment values only, matching the behaviour the previous pkg/config
|
func (s *Source) resolvePaths() {
|
||||||
// loader had for deployments that configure everything through the environment.
|
if s.pinned {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.defaultPath == "" {
|
||||||
|
s.defaultPath, _ = findExistingUpward(DefaultBaseFileName)
|
||||||
|
}
|
||||||
|
if s.path == "" {
|
||||||
|
s.path = os.Getenv("CONFIG_PATH")
|
||||||
|
}
|
||||||
|
if s.path == "" {
|
||||||
|
s.path, _ = findExistingUpward(DefaultOverrideFileName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func readConfigFile(v *viper.Viper, path string, merge bool) (bool, error) {
|
||||||
|
if path == "" {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
v.SetConfigFile(path)
|
||||||
|
var err error
|
||||||
|
if merge {
|
||||||
|
err = v.MergeInConfig()
|
||||||
|
} else {
|
||||||
|
err = v.ReadInConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
return true, nil
|
||||||
|
case isNotFound(err):
|
||||||
|
return false, nil
|
||||||
|
default:
|
||||||
|
if _, statErr := os.Stat(path); statErr == nil { //nolint:gosec // path is vetted by bounded upward search or config options
|
||||||
|
return false, fmt.Errorf("infra/config: read %s: %w", path, err)
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSource loads configuration files. By default, it loads manifest/config/config.default.yaml
|
||||||
|
// and merges manifest/config/config.yaml (or CONFIG_PATH) on top if present. A missing file is
|
||||||
|
// not an error: the source then serves environment values only.
|
||||||
func NewSource(opts ...Option) (*Source, error) {
|
func NewSource(opts ...Option) (*Source, error) {
|
||||||
s := &Source{}
|
s := &Source{}
|
||||||
for _, opt := range opts {
|
for _, opt := range opts {
|
||||||
opt(s)
|
opt(s)
|
||||||
}
|
}
|
||||||
|
s.resolvePaths()
|
||||||
if s.path == "" {
|
|
||||||
s.path = os.Getenv("CONFIG_PATH")
|
|
||||||
}
|
|
||||||
if s.path == "" {
|
|
||||||
s.path = findConfigPath(DefaultCandidates...)
|
|
||||||
}
|
|
||||||
|
|
||||||
v := viper.New()
|
v := viper.New()
|
||||||
v.SetConfigFile(s.path)
|
|
||||||
|
|
||||||
err := v.ReadInConfig()
|
var err error
|
||||||
switch {
|
s.defaultFound, err = readConfigFile(v, s.defaultPath, false)
|
||||||
case err == nil:
|
if err != nil {
|
||||||
s.found = true
|
return nil, err
|
||||||
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.overrideFound, err = readConfigFile(v, s.path, s.defaultFound)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.found = s.defaultFound || s.overrideFound
|
||||||
s.v = v
|
s.v = v
|
||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
@@ -111,31 +157,25 @@ func (s *Source) Describe() string {
|
|||||||
if !s.found {
|
if !s.found {
|
||||||
return EnvOnlyOrigin
|
return EnvOnlyOrigin
|
||||||
}
|
}
|
||||||
return s.path
|
if s.overrideFound {
|
||||||
|
return s.path
|
||||||
|
}
|
||||||
|
return s.defaultPath
|
||||||
}
|
}
|
||||||
|
|
||||||
// findConfigPath searches upward from the working directory through candidate paths so
|
// findExistingUpward searches upward from the working directory for a relative file path.
|
||||||
// tests and binaries run from subdirectories still find configuration files in manifest/config/
|
func findExistingUpward(relativeFilePath string) (string, bool) {
|
||||||
// or the repository root.
|
if _, err := os.Stat(relativeFilePath); err == nil {
|
||||||
func findConfigPath(candidates ...string) string {
|
return relativeFilePath, true
|
||||||
if len(candidates) == 0 {
|
|
||||||
return DefaultFileName
|
|
||||||
}
|
|
||||||
for _, candidate := range candidates {
|
|
||||||
if _, err := os.Stat(candidate); err == nil {
|
|
||||||
return candidate
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
dir := "."
|
dir := "."
|
||||||
for range maxSearchDepth {
|
for range maxSearchDepth {
|
||||||
dir += "/.."
|
dir += "/.."
|
||||||
for _, candidate := range candidates {
|
path := dir + "/" + relativeFilePath
|
||||||
path := dir + "/" + candidate
|
if _, err := os.Stat(path); err == nil {
|
||||||
if _, err := os.Stat(path); err == nil {
|
return path, true
|
||||||
return path
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return candidates[0]
|
return "", false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,3 +120,88 @@ func TestSourceFindsManifestConfigInParentDirectory(t *testing.T) {
|
|||||||
require.True(t, ok)
|
require.True(t, ok)
|
||||||
assert.Equal(t, ":9200", value)
|
assert.Equal(t, ":9200", value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSourceDefaultConfigMergedWithOverride(t *testing.T) {
|
||||||
|
tempRoot := t.TempDir()
|
||||||
|
manifestConfigDir := filepath.Join(tempRoot, "manifest", "config")
|
||||||
|
require.NoError(t, os.MkdirAll(manifestConfigDir, 0o755))
|
||||||
|
|
||||||
|
// 1. Write default configuration
|
||||||
|
require.NoError(t, os.WriteFile(
|
||||||
|
filepath.Join(manifestConfigDir, "config.default.yaml"),
|
||||||
|
[]byte("app:\n addr: \":8000\"\n node_id: 1\n env: \"development\"\n"),
|
||||||
|
0o600,
|
||||||
|
))
|
||||||
|
|
||||||
|
// 2. Write override configuration (only overrides addr)
|
||||||
|
require.NoError(t, os.WriteFile(
|
||||||
|
filepath.Join(manifestConfigDir, "config.yaml"),
|
||||||
|
[]byte("app:\n addr: \":9500\"\n"),
|
||||||
|
0o600,
|
||||||
|
))
|
||||||
|
|
||||||
|
subDir := filepath.Join(tempRoot, "backend")
|
||||||
|
require.NoError(t, os.MkdirAll(subDir, 0o755))
|
||||||
|
|
||||||
|
origWd, err := os.Getwd()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, os.Chdir(subDir))
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = os.Chdir(origWd)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Setenv("CONFIG_PATH", "")
|
||||||
|
|
||||||
|
src, err := config.NewSource()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Overridden by config.yaml
|
||||||
|
addr, ok := src.Lookup("app.addr")
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, ":9500", addr)
|
||||||
|
|
||||||
|
// Retained from config.default.yaml
|
||||||
|
nodeID, ok := src.Lookup("app.node_id")
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, 1, nodeID)
|
||||||
|
|
||||||
|
envVal, ok := src.Lookup("app.env")
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, "development", envVal)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSourceDefaultConfigUsedWhenOverrideAbsent(t *testing.T) {
|
||||||
|
tempRoot := t.TempDir()
|
||||||
|
manifestConfigDir := filepath.Join(tempRoot, "manifest", "config")
|
||||||
|
require.NoError(t, os.MkdirAll(manifestConfigDir, 0o755))
|
||||||
|
|
||||||
|
// Write only default configuration
|
||||||
|
require.NoError(t, os.WriteFile(
|
||||||
|
filepath.Join(manifestConfigDir, "config.default.yaml"),
|
||||||
|
[]byte("app:\n addr: \":8080\"\n env: \"testing\"\n"),
|
||||||
|
0o600,
|
||||||
|
))
|
||||||
|
|
||||||
|
subDir := filepath.Join(tempRoot, "backend")
|
||||||
|
require.NoError(t, os.MkdirAll(subDir, 0o755))
|
||||||
|
|
||||||
|
origWd, err := os.Getwd()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, os.Chdir(subDir))
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = os.Chdir(origWd)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Setenv("CONFIG_PATH", "")
|
||||||
|
|
||||||
|
src, err := config.NewSource()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
addr, ok := src.Lookup("app.addr")
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, ":8080", addr)
|
||||||
|
|
||||||
|
envVal, ok := src.Lookup("app.env")
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, "testing", envVal)
|
||||||
|
}
|
||||||
|
|||||||
+2
-2
@@ -23,8 +23,8 @@
|
|||||||
|
|
||||||
## 二、 部署配置准备
|
## 二、 部署配置准备
|
||||||
|
|
||||||
系统在启动前会从当前目录加载 `config.yaml` 配置文件。
|
系统在启动前会默认加载 `manifest/config/config.default.yaml`,并自动读取 `manifest/config/config.yaml`(或 `CONFIG_PATH` 环境变量指定的文件)进行覆盖。
|
||||||
生产环境部署前,请复制 `config.example.yaml` 为 `config.yaml`,并至少确认以下关键参数的配置:
|
生产环境部署前,可在 `manifest/config/config.yaml` 中按需覆盖以下关键参数:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
app:
|
app:
|
||||||
|
|||||||
@@ -1,114 +0,0 @@
|
|||||||
# wavelet — Full-Stack Boilerplate Config
|
|
||||||
# Copy this file to config.yaml and fill in your values.
|
|
||||||
# Fields marked with <...> are required; others have sensible defaults.
|
|
||||||
|
|
||||||
# ─── Application ────────────────────────────────────────────────────────────────
|
|
||||||
app:
|
|
||||||
app_name: "wavelet"
|
|
||||||
env: "development" # development | testing | production
|
|
||||||
addr: ":8000"
|
|
||||||
node_id: 1 # Snowflake node ID (0-1023). Must be unique per instance.
|
|
||||||
graceful_shutdown_timeout: 30
|
|
||||||
session_cookie_name: "wavelet_session_id" # Change to something unique before deploy
|
|
||||||
session_secret: "<uniq-random-string>" # Cannot be changed after first start
|
|
||||||
session_domain: "" # e.g. ".yourdomain.com"
|
|
||||||
session_age: 86400 # Session lifetime in seconds (default: 24h)
|
|
||||||
session_secure: false
|
|
||||||
session_http_only: false
|
|
||||||
api_prefix: "/api"
|
|
||||||
|
|
||||||
# ─── PostgreSQL ─────────────────────────────────────────────────────────────────
|
|
||||||
# Supports Standalone and Primary-Replica (read/write split) modes.
|
|
||||||
database:
|
|
||||||
enabled: true
|
|
||||||
sqlite_path: "wavelet.db" # PostgreSQL 禁用时使用此 SQLite 文件路径
|
|
||||||
host: "127.0.0.1"
|
|
||||||
port: 5432
|
|
||||||
username: "postgres"
|
|
||||||
password: "postgres"
|
|
||||||
database: "wavelet"
|
|
||||||
max_idle_conn: 16
|
|
||||||
max_open_conn: 128
|
|
||||||
conn_max_lifetime: 1800
|
|
||||||
conn_max_idle_time: 600
|
|
||||||
log_level: "info" # error | warn | info | debug | silent;SQL 语句仅在 log.level=debug 时输出
|
|
||||||
ssl_mode: "disable"
|
|
||||||
time_zone: "UTC"
|
|
||||||
application_name: "wavelet-server"
|
|
||||||
prefer_simple_protocol: false
|
|
||||||
search_path: "public"
|
|
||||||
statement_cache_capacity: 256
|
|
||||||
default_query_exec_mode: "cache_statement"
|
|
||||||
slow_threshold: 200ms
|
|
||||||
# Optional read replicas:
|
|
||||||
# replicas:
|
|
||||||
# - host: "replica1.db.internal"
|
|
||||||
# port: 5432
|
|
||||||
# - host: "replica2.db.internal"
|
|
||||||
# port: 5432
|
|
||||||
|
|
||||||
# ─── Redis ──────────────────────────────────────────────────────────────────────
|
|
||||||
# Supports Standalone, Sentinel (HA), and Cluster modes.
|
|
||||||
redis:
|
|
||||||
enabled: true
|
|
||||||
addrs:
|
|
||||||
- "127.0.0.1:6379"
|
|
||||||
username: ""
|
|
||||||
password: ""
|
|
||||||
db: 0 # Ignored in Cluster mode
|
|
||||||
cluster_mode: false # Set true to enable Cluster mode
|
|
||||||
master_name: "" # Set non-empty to enable Sentinel mode
|
|
||||||
key_prefix: "wavelet:"
|
|
||||||
pool_size: 100
|
|
||||||
min_idle_conn: 10
|
|
||||||
dial_timeout: 5
|
|
||||||
read_timeout: 3
|
|
||||||
write_timeout: 3
|
|
||||||
max_retries: 3
|
|
||||||
pool_timeout: 4
|
|
||||||
conn_max_idle_time: 300
|
|
||||||
maint_notifications: false # 启动时开关;启用 Redis maintenance notifications 自动协商,修改后需重启
|
|
||||||
|
|
||||||
# ─── Logging ────────────────────────────────────────────────────────────────────
|
|
||||||
log:
|
|
||||||
level: "info" # debug | info | warn | error | fatal | panic
|
|
||||||
format: "console" # console | json
|
|
||||||
output: "stdout" # stdout | file
|
|
||||||
file_path: "./logs/app.log"
|
|
||||||
max_size: 100 # MB per log file
|
|
||||||
max_age: 30 # Days to retain old log files
|
|
||||||
max_backups: 10
|
|
||||||
compress: true
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Async Task Worker ──────────────────────────────────────────────────────────
|
|
||||||
worker:
|
|
||||||
concurrency: 20
|
|
||||||
strict_priority: false
|
|
||||||
queues:
|
|
||||||
- name: webhook
|
|
||||||
priority: 10
|
|
||||||
- name: whitelist_only
|
|
||||||
priority: 5
|
|
||||||
- name: default
|
|
||||||
priority: 3
|
|
||||||
|
|
||||||
# ─── OpenTelemetry Tracing ──────────────────────────────────────────────────────
|
|
||||||
otel:
|
|
||||||
sampling_rate: 0.0 # Trace sampling rate (0.0 – 1.0)
|
|
||||||
tracer_name: "github.com/Rain-kl/Wavelet" # Global tracer instrumentation name
|
|
||||||
|
|
||||||
|
|
||||||
# ─── ClickHouse (optional) ──────────────────────────────────────────────────────
|
|
||||||
clickhouse:
|
|
||||||
enabled: false
|
|
||||||
hosts:
|
|
||||||
- "127.0.0.1:9000"
|
|
||||||
username: "default"
|
|
||||||
password: ""
|
|
||||||
database: "wavelet"
|
|
||||||
max_idle_conn: 10
|
|
||||||
max_open_conn: 100
|
|
||||||
conn_max_lifetime: 3600
|
|
||||||
dial_timeout: 5
|
|
||||||
block_buffer_size: 10
|
|
||||||
Reference in New Issue
Block a user