fix(config,server): merge defaults on Load and fix monitoring registration
Two related fixes that must land together: 1. config.Load() now starts from DefaultConfig() before unmarshaling YAML. This ensures missing top-level fields (Performance, Monitoring, Resolver) use their documented defaults instead of zero values. Most importantly, file_cache is no longer silently disabled when users omit the performance: section. 2. startSingleMode() now checks Monitoring.Status.Enabled instead of Path/Allow to decide whether to register the status endpoint. Without this change, fix #1 would have caused a regression where the status handler is registered even when monitoring is disabled, because DefaultConfig() sets Path and Allow defaults. Also replace remaining log.Printf in status.go and lua/api_timer.go with zerolog to follow project logging conventions. Added tests: - config/load_test.go: verifies defaults are applied, explicit values override defaults, and monitoring stays disabled by default. - server/monitoring_registration_test.go: verifies /_status is only registered when enabled and remains reachable with static handler on path: /.
This commit is contained in:
parent
7cc76f0d5b
commit
e8fbbf368c
@ -134,8 +134,12 @@ func Load(path string) (*Config, error) {
|
||||
return nil, fmt.Errorf("读取配置文件失败: %w", err)
|
||||
}
|
||||
|
||||
var cfg Config
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
// 从默认值开始,YAML 只覆盖显式配置的字段。
|
||||
// 注意:yaml.v3 对 slice 会整体替换,因此用户显式配置的 Servers[]
|
||||
// 元素不会继承 server-level 默认值;但顶层 struct 字段(Performance、
|
||||
// Monitoring、Resolver)的默认值会被保留。
|
||||
cfg := DefaultConfig()
|
||||
if err := yaml.Unmarshal(data, cfg); err != nil {
|
||||
return nil, fmt.Errorf("解析配置文件失败: %w", err)
|
||||
}
|
||||
|
||||
@ -145,16 +149,16 @@ func Load(path string) (*Config, error) {
|
||||
return nil, fmt.Errorf("获取配置文件绝对路径失败: %w", err)
|
||||
}
|
||||
visited := map[string]bool{absPath: true}
|
||||
if err := processIncludes(&cfg, filepath.Dir(path), 0, visited); err != nil {
|
||||
if err := processIncludes(cfg, filepath.Dir(path), 0, visited); err != nil {
|
||||
return nil, fmt.Errorf("处理配置引入失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := Validate(&cfg); err != nil {
|
||||
if err := Validate(cfg); err != nil {
|
||||
return nil, fmt.Errorf("配置验证失败: %w", err)
|
||||
}
|
||||
|
||||
return &cfg, nil
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
const maxIncludeDepth = 10
|
||||
|
||||
104
internal/config/load_test.go
Normal file
104
internal/config/load_test.go
Normal file
@ -0,0 +1,104 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLoad_MergesDefaults(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
path := filepath.Join(tmpDir, "minimal.yaml")
|
||||
content := `
|
||||
servers:
|
||||
- listen: ":8080"
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load failed: %v", err)
|
||||
}
|
||||
|
||||
if cfg.Performance.FileCache.MaxEntries == 0 {
|
||||
t.Error("Performance.FileCache.MaxEntries should have default value")
|
||||
}
|
||||
if cfg.Performance.FileCache.MaxSize == 0 {
|
||||
t.Error("Performance.FileCache.MaxSize should have default value")
|
||||
}
|
||||
if cfg.Performance.FileCache.Inactive == 0 {
|
||||
t.Error("Performance.FileCache.Inactive should have default value")
|
||||
}
|
||||
if cfg.Monitoring.Status.Path != "/_status" {
|
||||
t.Errorf("Monitoring.Status.Path = %q, want %q", cfg.Monitoring.Status.Path, "/_status")
|
||||
}
|
||||
if cfg.Monitoring.Pprof.Path != "/debug/pprof" {
|
||||
t.Errorf("Monitoring.Pprof.Path = %q, want %q", cfg.Monitoring.Pprof.Path, "/debug/pprof")
|
||||
}
|
||||
if cfg.Resolver.Valid == 0 {
|
||||
t.Error("Resolver.Valid should have default value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_ExplicitOverridesDefault(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
path := filepath.Join(tmpDir, "explicit.yaml")
|
||||
content := `
|
||||
performance:
|
||||
file_cache:
|
||||
max_entries: 500
|
||||
max_size: 52428800
|
||||
inactive: 120s
|
||||
servers:
|
||||
- listen: ":8080"
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load failed: %v", err)
|
||||
}
|
||||
|
||||
if cfg.Performance.FileCache.MaxEntries != 500 {
|
||||
t.Errorf("MaxEntries = %d, want 500", cfg.Performance.FileCache.MaxEntries)
|
||||
}
|
||||
if cfg.Performance.FileCache.MaxSize != 52428800 {
|
||||
t.Errorf("MaxSize = %d, want 52428800", cfg.Performance.FileCache.MaxSize)
|
||||
}
|
||||
if cfg.Performance.FileCache.Inactive != 120*time.Second {
|
||||
t.Errorf("Inactive = %v, want 120s", cfg.Performance.FileCache.Inactive)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_MonitoringDisabledByDefault(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
path := filepath.Join(tmpDir, "minimal.yaml")
|
||||
content := `
|
||||
servers:
|
||||
- listen: ":8080"
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load failed: %v", err)
|
||||
}
|
||||
|
||||
// 默认值 Path 存在,但 Enabled 应为 false
|
||||
if cfg.Monitoring.Status.Enabled {
|
||||
t.Error("Monitoring.Status.Enabled should be false by default")
|
||||
}
|
||||
if cfg.Monitoring.Pprof.Enabled {
|
||||
t.Error("Monitoring.Pprof.Enabled should be false by default")
|
||||
}
|
||||
if cfg.Monitoring.Status.Path != "/_status" {
|
||||
t.Errorf("Monitoring.Status.Path = %q, want %q", cfg.Monitoring.Status.Path, "/_status")
|
||||
}
|
||||
}
|
||||
@ -21,12 +21,12 @@ package lua
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
glua "github.com/yuin/gopher-lua"
|
||||
"rua.plus/lolly/internal/logging"
|
||||
)
|
||||
|
||||
// CallbackEntry 回调队列条目,封装定时器触发的 Lua 回调。
|
||||
@ -261,7 +261,7 @@ func (m *TimerManager) executeTimer(entry *TimerEntry) {
|
||||
m.queueMu.Unlock()
|
||||
default:
|
||||
m.queueMu.Unlock()
|
||||
log.Printf("[lua] timer callback dropped: queue full")
|
||||
logging.Warn().Msg("[lua] timer callback dropped: queue full")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -277,7 +277,7 @@ func (m *TimerManager) schedulerLoop() {
|
||||
// 从字节码重建函数并执行
|
||||
fn := m.schedulerL.NewFunctionFromProto(entry.proto)
|
||||
if fn == nil {
|
||||
log.Printf("[lua] timer callback: failed to create function from proto")
|
||||
logging.Error().Msg("[lua] timer callback: failed to create function from proto")
|
||||
continue
|
||||
}
|
||||
|
||||
@ -286,7 +286,7 @@ func (m *TimerManager) schedulerLoop() {
|
||||
Fn: fn,
|
||||
NRet: 0,
|
||||
}, entry.args...); err != nil {
|
||||
log.Printf("[lua] timer callback error: %v", err)
|
||||
logging.Error().Err(err).Msg("[lua] timer callback error")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -405,7 +405,7 @@ func (m *TimerManager) gracefulShutdown(timeout time.Duration) {
|
||||
case <-time.After(timeout):
|
||||
abandoned := len(m.callbackQueue)
|
||||
if abandoned > 0 {
|
||||
log.Printf("[lua] shutdown timeout: %d callbacks abandoned", abandoned)
|
||||
logging.Warn().Int("abandoned", abandoned).Msg("[lua] shutdown timeout: callbacks abandoned")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
129
internal/server/monitoring_registration_test.go
Normal file
129
internal/server/monitoring_registration_test.go
Normal file
@ -0,0 +1,129 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/valyala/fasthttp"
|
||||
"rua.plus/lolly/internal/config"
|
||||
"rua.plus/lolly/internal/matcher"
|
||||
)
|
||||
|
||||
func TestMonitoringEndpoints_OnlyRegisteredWhenEnabled(t *testing.T) {
|
||||
// Case 1: monitoring 未启用时,/_status 不应注册
|
||||
cfg := &config.Config{
|
||||
Servers: []config.ServerConfig{{
|
||||
Listen: "127.0.0.1:0",
|
||||
Static: []config.StaticConfig{{
|
||||
Path: "/",
|
||||
Root: t.TempDir(),
|
||||
}},
|
||||
}},
|
||||
}
|
||||
|
||||
srv := New(cfg)
|
||||
go srv.Start()
|
||||
defer srv.StopWithTimeout(5 * time.Second)
|
||||
|
||||
var addr string
|
||||
for start := time.Now(); time.Since(start) < 2*time.Second; time.Sleep(10 * time.Millisecond) {
|
||||
listeners := srv.GetListeners()
|
||||
if len(listeners) > 0 {
|
||||
addr = listeners[0].Addr().String()
|
||||
break
|
||||
}
|
||||
}
|
||||
if addr == "" {
|
||||
t.Fatal("server has no listeners")
|
||||
}
|
||||
|
||||
client := &fasthttp.Client{}
|
||||
req := fasthttp.AcquireRequest()
|
||||
resp := fasthttp.AcquireResponse()
|
||||
defer fasthttp.ReleaseRequest(req)
|
||||
defer fasthttp.ReleaseResponse(resp)
|
||||
|
||||
req.SetRequestURI("http://" + addr + "/_status")
|
||||
req.Header.SetMethod("GET")
|
||||
|
||||
if err := client.Do(req, resp); err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
|
||||
// 未启用时应返回 404(被 static handler 处理,找不到文件)
|
||||
assert.Equal(t, fasthttp.StatusNotFound, resp.StatusCode(),
|
||||
"status endpoint should NOT be registered when monitoring is disabled")
|
||||
}
|
||||
|
||||
func TestMonitoringEndpoints_ReachableWhenEnabled(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Monitoring: config.MonitoringConfig{
|
||||
Status: config.StatusConfig{
|
||||
Enabled: true,
|
||||
Path: "/_status",
|
||||
Allow: []string{"127.0.0.1"},
|
||||
},
|
||||
},
|
||||
Servers: []config.ServerConfig{{
|
||||
Listen: "127.0.0.1:0",
|
||||
Static: []config.StaticConfig{{
|
||||
Path: "/",
|
||||
Root: t.TempDir(),
|
||||
}},
|
||||
}},
|
||||
}
|
||||
|
||||
srv := New(cfg)
|
||||
go srv.Start()
|
||||
defer srv.StopWithTimeout(5 * time.Second)
|
||||
|
||||
var addr string
|
||||
for start := time.Now(); time.Since(start) < 2*time.Second; time.Sleep(10 * time.Millisecond) {
|
||||
listeners := srv.GetListeners()
|
||||
if len(listeners) > 0 {
|
||||
addr = listeners[0].Addr().String()
|
||||
break
|
||||
}
|
||||
}
|
||||
if addr == "" {
|
||||
t.Fatal("server has no listeners")
|
||||
}
|
||||
|
||||
client := &fasthttp.Client{}
|
||||
req := fasthttp.AcquireRequest()
|
||||
resp := fasthttp.AcquireResponse()
|
||||
defer fasthttp.ReleaseRequest(req)
|
||||
defer fasthttp.ReleaseResponse(resp)
|
||||
|
||||
req.SetRequestURI("http://" + addr + "/_status")
|
||||
req.Header.SetMethod("GET")
|
||||
|
||||
if err := client.Do(req, resp); err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
|
||||
assert.Equal(t, fasthttp.StatusOK, resp.StatusCode(),
|
||||
"status endpoint should be reachable when enabled, even with static handler on /")
|
||||
}
|
||||
|
||||
func TestLocationEngine_StatusExactBeatsStaticPrefix(t *testing.T) {
|
||||
// 独立验证 location engine 的优先级:exact match 应该 beat prefix /
|
||||
engine := matcher.NewLocationEngine()
|
||||
|
||||
engine.AddExact("/_status", func(ctx *fasthttp.RequestCtx) {
|
||||
ctx.SetStatusCode(fasthttp.StatusOK)
|
||||
}, false)
|
||||
engine.AddPrefix("/", func(ctx *fasthttp.RequestCtx) {
|
||||
ctx.SetStatusCode(fasthttp.StatusNotFound)
|
||||
}, false)
|
||||
engine.MarkInitialized()
|
||||
|
||||
result := engine.Match([]byte("/_status"))
|
||||
if result == nil {
|
||||
t.Fatal("expected match")
|
||||
}
|
||||
if result.LocationType != matcher.LocationTypeExact {
|
||||
t.Errorf("expected exact match, got %s", result.LocationType)
|
||||
}
|
||||
}
|
||||
@ -476,7 +476,7 @@ func (s *Server) startSingleMode() error {
|
||||
s.locationEngine = matcher.NewLocationEngine()
|
||||
|
||||
// 注册状态监控端点(如果配置)
|
||||
if s.config.Monitoring.Status.Path != "" || len(s.config.Monitoring.Status.Allow) > 0 {
|
||||
if s.config.Monitoring.Status.Enabled {
|
||||
statusHandler, err := NewStatusHandler(s, &s.config.Monitoring.Status)
|
||||
if err != nil {
|
||||
logging.Error().Msg("Failed to create status handler: " + err.Error())
|
||||
|
||||
@ -14,13 +14,13 @@ package server
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/valyala/fasthttp"
|
||||
"rua.plus/lolly/internal/config"
|
||||
"rua.plus/lolly/internal/logging"
|
||||
"rua.plus/lolly/internal/utils"
|
||||
"rua.plus/lolly/internal/version"
|
||||
)
|
||||
@ -299,7 +299,7 @@ func (h *StatusHandler) servePrometheus(ctx *fasthttp.RequestCtx, status *Status
|
||||
}
|
||||
|
||||
if _, err := ctx.WriteString(buf.String()); err != nil {
|
||||
log.Printf("failed to write metrics response: %v", err)
|
||||
logging.Error().Err(err).Msg("failed to write metrics response")
|
||||
}
|
||||
}
|
||||
|
||||
@ -315,7 +315,7 @@ func (h *StatusHandler) serveJSON(ctx *fasthttp.RequestCtx, status *Status) {
|
||||
}
|
||||
|
||||
if _, err := ctx.Write(data); err != nil {
|
||||
log.Printf("failed to write status response: %v", err)
|
||||
logging.Error().Err(err).Msg("failed to write status response")
|
||||
}
|
||||
}
|
||||
|
||||
@ -372,7 +372,7 @@ func (h *StatusHandler) serveText(ctx *fasthttp.RequestCtx, status *Status) {
|
||||
}
|
||||
|
||||
if _, err := ctx.WriteString(buf.String()); err != nil {
|
||||
log.Printf("failed to write text response: %v", err)
|
||||
logging.Error().Err(err).Msg("failed to write text response")
|
||||
}
|
||||
}
|
||||
|
||||
@ -477,7 +477,7 @@ func (h *StatusHandler) serveHTML(ctx *fasthttp.RequestCtx, status *Status) {
|
||||
buf.WriteString("</html>\n")
|
||||
|
||||
if _, err := ctx.WriteString(buf.String()); err != nil {
|
||||
log.Printf("failed to write html response: %v", err)
|
||||
logging.Error().Err(err).Msg("failed to write html response")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user