diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index 796c6330..e5f0708e 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -26,7 +26,7 @@ env: LICENSE README.md README_zh.md - config.example.yaml + manifest/config/config.default.yaml DEPLOYMENT_zh.md permissions: diff --git a/README.md b/README.md index cd24b851..54c99356 100644 --- a/README.md +++ b/README.md @@ -211,9 +211,8 @@ pnpm format ``` wavelet/ ├── main.go # Entry point (delegates to internal/cmd) -├── config.example.yaml # Configuration template ├── 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 ├── frontend/ # Next.js frontend application │ ├── app/ # App Router pages diff --git a/README_zh.md b/README_zh.md index a5cdb71c..5e24f48a 100644 --- a/README_zh.md +++ b/README_zh.md @@ -211,9 +211,8 @@ pnpm format ``` wavelet/ ├── main.go # 程序入口(委托给 internal/cmd) -├── config.example.yaml # 配置模板 ├── Makefile # 常用命令(swagger、tidy、license、cross-build) -├── manifest/ # 项目清单与编排:docker 镜像构建/compose、deploy (k8s)、config 配置 +├── manifest/ # 项目清单与编排:docker 镜像构建、deploy (k8s)、config 配置(默认/覆盖) ├── docs/ # Swagger 自动生成文档 ├── frontend/ # Next.js 前端应用 │ ├── app/ # App Router 页面 diff --git a/backend/plugins/infra/config/source.go b/backend/plugins/infra/config/source.go index 1b3525fd..7da2de08 100644 --- a/backend/plugins/infra/config/source.go +++ b/backend/plugins/infra/config/source.go @@ -16,15 +16,14 @@ import ( "github.com/spf13/viper" ) -// DefaultFileName is the configuration file looked up when CONFIG_PATH is unset. -const DefaultFileName = "config.yaml" +// DefaultBaseFileName is the default base configuration file path looked up relative to the workspace. +const DefaultBaseFileName = "manifest/config/config.default.yaml" -// DefaultCandidates defines the prioritized list of configuration file paths looked up -// when CONFIG_PATH is unset. -var DefaultCandidates = []string{ - "manifest/config/config.yaml", - DefaultFileName, -} +// DefaultOverrideFileName is the default user override configuration file path looked up relative to the workspace. +const DefaultOverrideFileName = "manifest/config/config.yaml" + +// DefaultFileName is kept for backwards compatibility. +const DefaultFileName = DefaultOverrideFileName // EnvOnlyOrigin is reported by Describe when no configuration file was loaded. const EnvOnlyOrigin = "" @@ -40,47 +39,94 @@ type Option func(*Source) func WithPath(path string) Option { return func(s *Source) { 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. type Source struct { - v *viper.Viper - path string - found bool + v *viper.Viper + path string + defaultPath string + pinned bool + defaultFound bool + overrideFound bool + 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. +// NewSource loads configuration files. By default, it loads manifest/config/config.default.yaml +func (s *Source) resolvePaths() { + 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) { s := &Source{} for _, opt := range opts { opt(s) } - - if s.path == "" { - s.path = os.Getenv("CONFIG_PATH") - } - if s.path == "" { - s.path = findConfigPath(DefaultCandidates...) - } + s.resolvePaths() 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) - } + var err error + s.defaultFound, err = readConfigFile(v, s.defaultPath, false) + if err != nil { + return nil, 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 return s, nil } @@ -111,31 +157,25 @@ func (s *Source) Describe() string { if !s.found { 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 -// tests and binaries run from subdirectories still find configuration files in manifest/config/ -// or the repository root. -func findConfigPath(candidates ...string) string { - if len(candidates) == 0 { - return DefaultFileName - } - for _, candidate := range candidates { - if _, err := os.Stat(candidate); err == nil { - return candidate - } +// findExistingUpward searches upward from the working directory for a relative file path. +func findExistingUpward(relativeFilePath string) (string, bool) { + if _, err := os.Stat(relativeFilePath); err == nil { + return relativeFilePath, true } dir := "." for range maxSearchDepth { dir += "/.." - for _, candidate := range candidates { - path := dir + "/" + candidate - if _, err := os.Stat(path); err == nil { - return path - } + path := dir + "/" + relativeFilePath + if _, err := os.Stat(path); err == nil { + return path, true } } - return candidates[0] + return "", false } diff --git a/backend/plugins/infra/config/source_test.go b/backend/plugins/infra/config/source_test.go index f8fff81a..39481735 100644 --- a/backend/plugins/infra/config/source_test.go +++ b/backend/plugins/infra/config/source_test.go @@ -120,3 +120,88 @@ func TestSourceFindsManifestConfigInParentDirectory(t *testing.T) { require.True(t, ok) 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) +} diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index ff2ee328..235c53a4 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -23,8 +23,8 @@ ## 二、 部署配置准备 -系统在启动前会从当前目录加载 `config.yaml` 配置文件。 -生产环境部署前,请复制 `config.example.yaml` 为 `config.yaml`,并至少确认以下关键参数的配置: +系统在启动前会默认加载 `manifest/config/config.default.yaml`,并自动读取 `manifest/config/config.yaml`(或 `CONFIG_PATH` 环境变量指定的文件)进行覆盖。 +生产环境部署前,可在 `manifest/config/config.yaml` 中按需覆盖以下关键参数: ```yaml app: diff --git a/config.example.yaml b/manifest/config/config.default.yaml similarity index 100% rename from config.example.yaml rename to manifest/config/config.default.yaml diff --git a/manifest/config/config.example.yaml b/manifest/config/config.example.yaml deleted file mode 100644 index 91df9420..00000000 --- a/manifest/config/config.example.yaml +++ /dev/null @@ -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: "" # 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