mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-16 15:43:13 +08:00
Caddy 式进程内模块系统(详见 docs/plugin-architecture/MODULE-AUTHOR-GUIDE.md): - internal/plugin:命名空间注册表(init 注册/重复 panic)、Provision→Validate→Start→Stop 生命周期(Start 半途逆序回滚、Stop 逆序+ctx deadline)、Host 四项 ports 能力面、 modules: 配置子树(enabled 三态;viper 含点 key 手工提取)、Runtime 状态机 - internal/plugin/plugintest:模块测试夹具(NewHost Options + RunLifecycle) - internal/modules:standard/imports.go 唯一插装清单 + job.hello 示例模块(默认 disabled) - Wire/main 接入:Build+Start 失败 fail-fast(显式 Cleanup 防 leader 锁泄漏)、 runtime.Stop 在 cleanup 并行组首位(先于 Redis/Ent) - admin 可观测:GET /api/v1/admin/modules(只读,错误文本脱敏) - 默认配置零行为变更(Phase-0 安全网 + bench allocs 零漂移验证) 含两轮对抗式审计修复(1×P1 启动失败泄漏 leader 锁 + 11×P2)与回归测试。
390 lines
13 KiB
Go
390 lines
13 KiB
Go
package plugin
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"sync"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/stretchr/testify/require"
|
||
)
|
||
|
||
// lifecycleLog 记录全部 fake 模块的生命周期调用序列("模块ID:阶段")。
|
||
type lifecycleLog struct {
|
||
mu sync.Mutex
|
||
events []string
|
||
}
|
||
|
||
func (l *lifecycleLog) add(event string) {
|
||
l.mu.Lock()
|
||
defer l.mu.Unlock()
|
||
l.events = append(l.events, event)
|
||
}
|
||
|
||
func (l *lifecycleLog) snapshot() []string {
|
||
l.mu.Lock()
|
||
defer l.mu.Unlock()
|
||
return append([]string(nil), l.events...)
|
||
}
|
||
|
||
// fakeModule 是实现全部可选生命周期接口的测试模块,按配置注入各阶段错误。
|
||
type fakeModule struct {
|
||
id ModuleID
|
||
enabledByDefault bool
|
||
log *lifecycleLog
|
||
|
||
provisionErr error
|
||
validateErr error
|
||
startErr error
|
||
stopErr error
|
||
stopFn func(ctx context.Context) error // 非 nil 时覆盖默认 Stop 行为
|
||
|
||
host *Host // Provision 时记录,供断言
|
||
}
|
||
|
||
func (m *fakeModule) ModuleInfo() ModuleInfo {
|
||
return ModuleInfo{
|
||
ID: m.id,
|
||
EnabledByDefault: m.enabledByDefault,
|
||
New: func() Module { return m },
|
||
}
|
||
}
|
||
|
||
func (m *fakeModule) Provision(host *Host) error {
|
||
m.host = host
|
||
m.log.add(string(m.id) + ":provision")
|
||
return m.provisionErr
|
||
}
|
||
|
||
func (m *fakeModule) Validate() error {
|
||
m.log.add(string(m.id) + ":validate")
|
||
return m.validateErr
|
||
}
|
||
|
||
func (m *fakeModule) Start(ctx context.Context) error {
|
||
m.log.add(string(m.id) + ":start")
|
||
return m.startErr
|
||
}
|
||
|
||
func (m *fakeModule) Stop(ctx context.Context) error {
|
||
m.log.add(string(m.id) + ":stop")
|
||
if m.stopFn != nil {
|
||
return m.stopFn(ctx)
|
||
}
|
||
return m.stopErr
|
||
}
|
||
|
||
// plainModule 只实现 Module(无任何生命周期接口)。
|
||
type plainModule struct {
|
||
id ModuleID
|
||
}
|
||
|
||
func (m *plainModule) ModuleInfo() ModuleInfo {
|
||
return ModuleInfo{
|
||
ID: m.id,
|
||
EnabledByDefault: true,
|
||
New: func() Module { return m },
|
||
}
|
||
}
|
||
|
||
// newTestRuntime 构造隔离注册表 + Runtime。
|
||
func newTestRuntime(t *testing.T, cfg Config, mods ...Module) *Runtime {
|
||
t.Helper()
|
||
reg := NewRegistry()
|
||
for _, m := range mods {
|
||
reg.RegisterModule(m)
|
||
}
|
||
return NewRuntimeWithRegistry(&Host{}, cfg, reg)
|
||
}
|
||
|
||
func statusByID(t *testing.T, statuses []ModuleStatus, id ModuleID) ModuleStatus {
|
||
t.Helper()
|
||
for _, s := range statuses {
|
||
if s.ID == id {
|
||
return s
|
||
}
|
||
}
|
||
t.Fatalf("module %q not found in snapshot", id)
|
||
return ModuleStatus{}
|
||
}
|
||
|
||
func TestRuntimeBuildLifecycleOrderAndEnabledTriState(t *testing.T) {
|
||
log := &lifecycleLog{}
|
||
on, off := true, false
|
||
cfg := Config{
|
||
"job.c-default-off-enabled": {Enabled: &on},
|
||
"job.d-default-on-disabled": {Enabled: &off},
|
||
}
|
||
rt := newTestRuntime(t, cfg,
|
||
// 故意乱序注册,验证 Build 按 ID 字典序执行。
|
||
&fakeModule{id: "job.b-default-on", enabledByDefault: true, log: log},
|
||
&fakeModule{id: "job.a-default-on", enabledByDefault: true, log: log},
|
||
&fakeModule{id: "job.c-default-off-enabled", enabledByDefault: false, log: log},
|
||
&fakeModule{id: "job.d-default-on-disabled", enabledByDefault: true, log: log},
|
||
&fakeModule{id: "job.e-default-off", enabledByDefault: false, log: log},
|
||
)
|
||
|
||
require.NoError(t, rt.Build())
|
||
|
||
// enabled 三态:默认 on(a、b)、显式 enable 默认 off 的 c、显式 disable 默认 on 的 d、默认 off 的 e。
|
||
require.Equal(t, []string{
|
||
"job.a-default-on:provision", "job.a-default-on:validate",
|
||
"job.b-default-on:provision", "job.b-default-on:validate",
|
||
"job.c-default-off-enabled:provision", "job.c-default-off-enabled:validate",
|
||
}, log.snapshot())
|
||
|
||
statuses := rt.Snapshot()
|
||
require.Equal(t, StateProvisioned, statusByID(t, statuses, "job.a-default-on").State)
|
||
require.Equal(t, StateProvisioned, statusByID(t, statuses, "job.c-default-off-enabled").State)
|
||
require.False(t, statusByID(t, statuses, "job.d-default-on-disabled").Enabled)
|
||
require.Equal(t, StateRegistered, statusByID(t, statuses, "job.d-default-on-disabled").State)
|
||
require.False(t, statusByID(t, statuses, "job.e-default-off").Enabled)
|
||
require.Equal(t, StateRegistered, statusByID(t, statuses, "job.e-default-off").State)
|
||
}
|
||
|
||
func TestRuntimeBuildProvisionFailureAborts(t *testing.T) {
|
||
log := &lifecycleLog{}
|
||
provisionErr := errors.New("provision boom")
|
||
rt := newTestRuntime(t, Config{},
|
||
&fakeModule{id: "job.a", enabledByDefault: true, log: log},
|
||
&fakeModule{id: "job.b", enabledByDefault: true, log: log, provisionErr: provisionErr},
|
||
&fakeModule{id: "job.c", enabledByDefault: true, log: log},
|
||
)
|
||
|
||
err := rt.Build()
|
||
require.Error(t, err)
|
||
require.Contains(t, err.Error(), "job.b", "Build 错误必须包含失败模块 ID")
|
||
require.ErrorIs(t, err, provisionErr)
|
||
|
||
// 失败即中止:job.c 不会被 Provision;job.b 的 Validate 不会执行。
|
||
require.Equal(t, []string{
|
||
"job.a:provision", "job.a:validate",
|
||
"job.b:provision",
|
||
}, log.snapshot())
|
||
|
||
statuses := rt.Snapshot()
|
||
require.Equal(t, StateProvisioned, statusByID(t, statuses, "job.a").State)
|
||
bStatus := statusByID(t, statuses, "job.b")
|
||
require.Equal(t, StateErrored, bStatus.State)
|
||
require.Contains(t, bStatus.Err, "provision boom")
|
||
require.Equal(t, StateRegistered, statusByID(t, statuses, "job.c").State)
|
||
|
||
// Build 失败后 Start 必须被拒绝。
|
||
require.Error(t, rt.Start(context.Background()))
|
||
}
|
||
|
||
func TestRuntimeBuildValidateFailureAborts(t *testing.T) {
|
||
log := &lifecycleLog{}
|
||
rt := newTestRuntime(t, Config{},
|
||
&fakeModule{id: "job.a", enabledByDefault: true, log: log, validateErr: errors.New("validate boom")},
|
||
&fakeModule{id: "job.b", enabledByDefault: true, log: log},
|
||
)
|
||
|
||
err := rt.Build()
|
||
require.Error(t, err)
|
||
require.Contains(t, err.Error(), "job.a")
|
||
require.Equal(t, []string{"job.a:provision", "job.a:validate"}, log.snapshot())
|
||
require.Equal(t, StateErrored, statusByID(t, rt.Snapshot(), "job.a").State)
|
||
}
|
||
|
||
func TestRuntimeStartOrderAndStopReverseOrder(t *testing.T) {
|
||
log := &lifecycleLog{}
|
||
rt := newTestRuntime(t, Config{},
|
||
&fakeModule{id: "job.b", enabledByDefault: true, log: log},
|
||
&fakeModule{id: "job.a", enabledByDefault: true, log: log},
|
||
&fakeModule{id: "job.c", enabledByDefault: true, log: log},
|
||
)
|
||
require.NoError(t, rt.Build())
|
||
require.NoError(t, rt.Start(context.Background()))
|
||
|
||
for _, s := range rt.Snapshot() {
|
||
require.Equal(t, StateRunning, s.State)
|
||
}
|
||
|
||
require.NoError(t, rt.Stop(context.Background()))
|
||
|
||
require.Equal(t, []string{
|
||
"job.a:provision", "job.a:validate",
|
||
"job.b:provision", "job.b:validate",
|
||
"job.c:provision", "job.c:validate",
|
||
"job.a:start", "job.b:start", "job.c:start",
|
||
"job.c:stop", "job.b:stop", "job.a:stop", // 逆序关闭
|
||
}, log.snapshot())
|
||
|
||
for _, s := range rt.Snapshot() {
|
||
require.Equal(t, StateStopped, s.State)
|
||
}
|
||
}
|
||
|
||
func TestRuntimeStartHalfwayFailureRollsBackInReverse(t *testing.T) {
|
||
log := &lifecycleLog{}
|
||
startErr := errors.New("start boom")
|
||
rt := newTestRuntime(t, Config{},
|
||
&fakeModule{id: "job.a", enabledByDefault: true, log: log},
|
||
&fakeModule{id: "job.b", enabledByDefault: true, log: log},
|
||
&fakeModule{id: "job.c", enabledByDefault: true, log: log, startErr: startErr},
|
||
&fakeModule{id: "job.d", enabledByDefault: true, log: log},
|
||
)
|
||
require.NoError(t, rt.Build())
|
||
|
||
err := rt.Start(context.Background())
|
||
require.Error(t, err)
|
||
require.Contains(t, err.Error(), "job.c", "Start 错误必须包含失败模块 ID")
|
||
require.ErrorIs(t, err, startErr)
|
||
|
||
// a、b 已启动;c 启动失败;回滚必须按 b → a 逆序 Stop;d 不得被 Start。
|
||
require.Equal(t, []string{
|
||
"job.a:provision", "job.a:validate",
|
||
"job.b:provision", "job.b:validate",
|
||
"job.c:provision", "job.c:validate",
|
||
"job.d:provision", "job.d:validate",
|
||
"job.a:start", "job.b:start", "job.c:start",
|
||
"job.b:stop", "job.a:stop",
|
||
}, log.snapshot())
|
||
|
||
statuses := rt.Snapshot()
|
||
require.Equal(t, StateStopped, statusByID(t, statuses, "job.a").State)
|
||
require.Equal(t, StateStopped, statusByID(t, statuses, "job.b").State)
|
||
cStatus := statusByID(t, statuses, "job.c")
|
||
require.Equal(t, StateErrored, cStatus.State)
|
||
require.Contains(t, cStatus.Err, "start boom")
|
||
require.Equal(t, StateProvisioned, statusByID(t, statuses, "job.d").State)
|
||
}
|
||
|
||
func TestRuntimeStopToleratesSingleFailure(t *testing.T) {
|
||
log := &lifecycleLog{}
|
||
stopErr := errors.New("stop boom")
|
||
rt := newTestRuntime(t, Config{},
|
||
&fakeModule{id: "job.a", enabledByDefault: true, log: log},
|
||
&fakeModule{id: "job.b", enabledByDefault: true, log: log, stopErr: stopErr},
|
||
&fakeModule{id: "job.c", enabledByDefault: true, log: log},
|
||
)
|
||
require.NoError(t, rt.Build())
|
||
require.NoError(t, rt.Start(context.Background()))
|
||
|
||
err := rt.Stop(context.Background())
|
||
require.Error(t, err)
|
||
require.ErrorIs(t, err, stopErr)
|
||
require.Contains(t, err.Error(), "job.b")
|
||
|
||
// 单个失败不中断其余模块关闭:c → b(失败)→ a 全部被调用。
|
||
stops := log.snapshot()[9:]
|
||
require.Equal(t, []string{"job.c:stop", "job.b:stop", "job.a:stop"}, stops)
|
||
|
||
statuses := rt.Snapshot()
|
||
require.Equal(t, StateStopped, statusByID(t, statuses, "job.a").State)
|
||
require.Equal(t, StateErrored, statusByID(t, statuses, "job.b").State)
|
||
require.Contains(t, statusByID(t, statuses, "job.b").Err, "stop boom")
|
||
require.Equal(t, StateStopped, statusByID(t, statuses, "job.c").State)
|
||
}
|
||
|
||
func TestRuntimeStopRespectsContextDeadline(t *testing.T) {
|
||
log := &lifecycleLog{}
|
||
blocking := &fakeModule{
|
||
id: "job.blocking", enabledByDefault: true, log: log,
|
||
stopFn: func(ctx context.Context) error {
|
||
<-ctx.Done() // 模拟尊重 ctx deadline 的慢关闭
|
||
return ctx.Err()
|
||
},
|
||
}
|
||
rt := newTestRuntime(t, Config{}, blocking,
|
||
&fakeModule{id: "job.a", enabledByDefault: true, log: log},
|
||
)
|
||
require.NoError(t, rt.Build())
|
||
require.NoError(t, rt.Start(context.Background()))
|
||
|
||
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||
defer cancel()
|
||
|
||
begin := time.Now()
|
||
err := rt.Stop(ctx)
|
||
require.Less(t, time.Since(begin), 2*time.Second, "Stop 必须随 ctx deadline 返回,不得无限阻塞")
|
||
require.Error(t, err)
|
||
require.ErrorIs(t, err, context.DeadlineExceeded)
|
||
|
||
// 即使 blocking 超时失败,job.a 仍被关闭(容错继续)。
|
||
require.Equal(t, StateStopped, statusByID(t, rt.Snapshot(), "job.a").State)
|
||
}
|
||
|
||
func TestRuntimeModuleWithoutLifecycleInterfaces(t *testing.T) {
|
||
rt := newTestRuntime(t, Config{}, &plainModule{id: "job.plain"})
|
||
require.NoError(t, rt.Build())
|
||
require.NoError(t, rt.Start(context.Background()))
|
||
require.Equal(t, StateRunning, statusByID(t, rt.Snapshot(), "job.plain").State,
|
||
"未实现 Starter 的 enabled 模块在 Start 后视为 running")
|
||
require.NoError(t, rt.Stop(context.Background()))
|
||
require.Equal(t, StateStopped, statusByID(t, rt.Snapshot(), "job.plain").State)
|
||
}
|
||
|
||
func TestRuntimeGuards(t *testing.T) {
|
||
rt := newTestRuntime(t, Config{})
|
||
require.Error(t, rt.Start(context.Background()), "未 Build 不得 Start")
|
||
require.NoError(t, rt.Build())
|
||
require.Error(t, rt.Build(), "Build 只能调用一次")
|
||
require.NoError(t, rt.Start(context.Background()))
|
||
require.Error(t, rt.Start(context.Background()), "Start 只能调用一次")
|
||
}
|
||
|
||
func TestRuntimeSnapshotBeforeBuild(t *testing.T) {
|
||
log := &lifecycleLog{}
|
||
off := false
|
||
rt := newTestRuntime(t, Config{"job.b": {Enabled: &off}},
|
||
&fakeModule{id: "job.b", enabledByDefault: true, log: log},
|
||
&fakeModule{id: "job.a", enabledByDefault: true, log: log},
|
||
)
|
||
|
||
statuses := rt.Snapshot()
|
||
require.Len(t, statuses, 2)
|
||
require.Equal(t, ModuleID("job.a"), statuses[0].ID, "Snapshot 按 ID 字典序")
|
||
require.Equal(t, ModuleID("job.b"), statuses[1].ID)
|
||
for _, s := range statuses {
|
||
require.Equal(t, StateRegistered, s.State)
|
||
require.Empty(t, s.Err)
|
||
}
|
||
require.True(t, statuses[0].Enabled)
|
||
require.False(t, statuses[1].Enabled, "Build 前 Snapshot 也要正确解析 enabled 三态")
|
||
}
|
||
|
||
func TestRuntimeHostConfigOfDefaultBinding(t *testing.T) {
|
||
log := &lifecycleLog{}
|
||
cfg, err := ParseConfig(map[string]map[string]any{
|
||
"job.hello": {"enabled": true, "greeting": "from-config"},
|
||
})
|
||
require.NoError(t, err)
|
||
|
||
mod := &fakeModule{id: "job.hello", log: log}
|
||
rt := newTestRuntime(t, cfg, mod)
|
||
require.NoError(t, rt.Build())
|
||
|
||
// NewRuntime 未显式装配 ConfigOf 时默认绑定到自身 Config.Of。
|
||
require.NotNil(t, mod.host)
|
||
require.NotNil(t, mod.host.ConfigOf)
|
||
var out helloModuleConfig
|
||
require.NoError(t, mod.host.ConfigOf("job.hello", &out))
|
||
require.Equal(t, "from-config", out.Greeting)
|
||
}
|
||
|
||
// 审计 B2 修复的回归测试:Start 半途失败触发回滚时,若已启动模块的回滚 Stop
|
||
// 也失败,该失败必须并入 Start 的返回错误(而不是只记日志被吞掉)。
|
||
func TestRuntimeStartRollbackStopFailureJoinedIntoError(t *testing.T) {
|
||
log := &lifecycleLog{}
|
||
stopBoom := errors.New("stop-a-boom")
|
||
rt := newTestRuntime(t, Config{},
|
||
&fakeModule{id: "job.a", enabledByDefault: true, log: log, stopErr: stopBoom},
|
||
&fakeModule{id: "job.b", enabledByDefault: true, log: log, startErr: errors.New("start-b-boom")},
|
||
)
|
||
require.NoError(t, rt.Build())
|
||
|
||
err := rt.Start(context.Background())
|
||
require.Error(t, err)
|
||
// 启动失败与回滚失败都必须在返回错误中可见(errors.Join 可穿透)。
|
||
require.ErrorIs(t, err, stopBoom)
|
||
require.Contains(t, err.Error(), `start module "job.b"`)
|
||
require.Contains(t, err.Error(), `stop module "job.a"`)
|
||
|
||
statuses := rt.Snapshot()
|
||
require.Equal(t, StateErrored, statusByID(t, statuses, "job.a").State, "回滚失败的模块应为 errored")
|
||
require.Equal(t, StateErrored, statusByID(t, statuses, "job.b").State)
|
||
}
|