diff --git a/API.md b/API.md index 7bd405d..e925285 100644 --- a/API.md +++ b/API.md @@ -19,7 +19,7 @@ | CI 检查生成产物是否过期 | `api generate --api user.api --dir . --check` | | 业务逻辑目录 | `internal/logic/`,使用标准 context.Context | | 依赖装配 | `internal/svc` | -| 接口文档 | 自动生成 `docs/openapi.json`,同时提供 `/openapi.json` | +| 接口文档 | 自动生成 `docs/openapi.json`,HTTP 端点由 `server.endpoints.openapi` 配置 | 这是明确限定的 go-zero 风格方言,**不是完整 goctl 兼容实现**。不支持的声明和校验标签会在生成前报错。 @@ -262,7 +262,7 @@ go test ./... - `@produces "text/plain"` 等声明无 DTO 成功输出;自动模式的 Logic 返回 `*httpx.Stream`,Handler 负责流式发送和关闭。默认状态 200。manual 模式保留项目自己的输出实现。 - 自动 multipart Handler 检查未读部分和结束边界;损坏/截断默认返回 400,超限为 413。公共状态、码和文案仍可用项目错误目录配置,见 [流式接入与错误处理](PROJECT.md#自动上传回调与下载)。 -迁移扩展起始于 v0.9.0,原有手写扩展说明见 [迁移扩展指南](MIGRATION.md)。当前根目录入口和自动流式 Handler 要求 CLI 与框架依赖一起使用 v0.12.0-preview;版本说明和新目录维护规则见 [项目指南](PROJECT.md)。不兼容全部 goctl 语法。 +迁移扩展起始于 v0.9.0,原有手写扩展说明见 [迁移扩展指南](MIGRATION.md)。当前根目录入口和自动流式 Handler 要求 CLI 与框架依赖一起使用 v0.13.0-preview;版本说明和新目录维护规则见 [项目指南](PROJECT.md)。不兼容全部 goctl 语法。 ## 参数语义 @@ -327,3 +327,5 @@ python3 scripts/verifyManagedProject.py ``` Go 测试覆盖契约拒绝规则、多源绑定、零值/缺失、默认/范围/嵌套集合、文档约束、幂等更新、只读检查、业务保留、删除路由、路径边界及生成项目编译。Python 验收会在临时目录生成项目,分别启动 nethttp/native,验证真实 HTTP 请求、中间件、日志 requestId 和优雅退出;结束后清理自己创建的进程和临时文件。结果记录在 `performance/managedProjectLocal.json`;完整记录见 [第一版验收](performance/managedProjectAcceptance.md)。 + +内置健康与 OpenAPI 路由可通过配置禁用或修改路径,`.api` 不再固定保留 `/health/live`、`/health/ready`、`/openapi.json`。启用的内置路径与业务 GET 路由重叠时,生成的 NewApp 在监听前返回配置错误;禁用后可以由 `.api` 自己维护这些路径。见 [内置接口配置](PROJECT.md#配置内置接口)。 diff --git a/CHANGELOG.md b/CHANGELOG.md index 0332948..6ef56c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## v0.13.0-preview(2026-09-24) + +- 新增通用 lifecycle.Controller,svc.RequestShutdown 支持依赖主动通知正常停止或致命故障,通知不阻塞,首个故障原因保留;生成入口排空 HTTP 后关闭依赖与日志,故障退出码为 1。 +- App.RunContext、ServeContext、RunWith 保留自定义 context 取消原因;正常信号取消仍正常返回。 +- 健康检查默认使用 App 响应模板和错误目录,支持 raw JSON;HTTP 状态保持 200/503,避免业务 HTTP 200 策略掩盖未就绪。内部探针原因仅供日志,失败状态继续记录到日志/指标。 +- 生成项目通过 server.endpoints 配置 liveness/readiness/openapi 的开关与路径;全局中间件继续生效,OpenAPI 保持原始规范。禁用端点不保留路径,启用路径与业务 GET/参数/通配路由冲突时在监听前报错。 +- 生成器最低框架版本更新到 v0.13.0-preview;manifest v2 和业务文件保持。v0.12 项目需要显式升级其 svc 扩展,main/Handler 由重新生成更新。 +- 增加故障退出、排空/清理顺序、初始化期间通知、子进程退出码、健康格式、损坏编码器、端点禁用/改名/冲突的回归验收。 + ## v0.12.0-preview(2026-09-24) - 生成项目统一采用根目录 main.go 和 config/svc/logic/handler;new 与 api new 共用受管理生成器,manifest v2 不兼容旧目录。 diff --git a/PROJECT.md b/PROJECT.md index 6be2a1d..ee4ed81 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -1,6 +1,6 @@ # 生成项目与文件维护边界 -v0.12.0-preview 提供受管理的生成项目。安装同版远程 CLI,生成的项目直接引用该版本,无需本地 replace。根目录入口支持 `go run .` 和 `go build .`;框架本身仍然是可直接引用的库。安装和私有仓库访问见 [README](README.md#准备环境与安装)。 +v0.13.0-preview 提供受管理的生成项目。安装同版远程 CLI,生成的项目直接引用该版本,无需本地 replace。根目录入口支持 `go run .` 和 `go build .`;框架本身仍然是可直接引用的库。安装和私有仓库访问见 [README](README.md#准备环境与安装)。 ## 创建和日常开发 @@ -95,6 +95,62 @@ func (c *ServiceContext) AppOptions() []omni.Option { 日志在 AppOptions 前初始化,其配置应放在 Config 中。退出信号先触发 HTTP 排空;达到 shutdownTimeout 后按框架规则强制关闭连接。请求仍依赖的后台任务/客户端不能直接绑定信号 context 提前关闭,应持有独立生命周期,在 OnClose 中取消。业务必须响应请求 context 取消;忽略取消的 goroutine 无法保证在超时前结束。 +## 依赖主动申请退出 + +从 v0.13.0-preview 起,生成的 svc 提供 `RequestShutdown(err)`。数据库客户端、节点租约、后台任务等发现不可恢复故障时,由项目决定是否退出: + +```go +// 在 svc 的依赖监控回调中调用;err 是实际故障原因。 +c.RequestShutdown(err) +``` + +这只是通知,立即返回,不等待 HTTP 排空,也不直接关闭数据库。生成入口停止接收新请求,等待在途请求完成;超过 `shutdownTimeout` 后取消请求并关闭连接,再执行 svc.Close,最后关闭日志。非空故障原因保留在返回错误链中,进程退出码为 1;`RequestShutdown(nil)` 或正常 SIGINT/SIGTERM 在没有其他故障时退出码为 0。 + +多个依赖可并发通知,关闭流程仅执行一次,首个非空故障原因保留。正常信号已开始排空时收到的故障也不会被清理动作覆盖。初始化期间的通知同样有效,不依赖 HTTP 监听已经启动。业务应在 Close/OnClose 中停止并等待自己启动的监控任务,再结束资源清理;关闭完成以后才报告的故障无法参与已经结束的进程结果。 + +`Lifecycle *lifecycle.Controller` 是 svc 持有的通用控制器,初始化时创建,生成入口使用它的 Context。该 Context 表示服务正在停止,不代表数据库已经关闭。不能把它当作在途请求的 Context,也不要在它取消时立刻关闭请求仍依赖的资源。发号器失去租约后拒绝发号等业务安全规则仍由项目实现。 + +## 配置内置接口 + +生成项目在 `etc/config.yaml` 明确列出以下默认配置;可以独立关闭、修改路径,或让健康接口返回原始 JSON: + +```yaml +server: + endpoints: + liveness: + enabled: true + path: /health/live + responseMode: app + readiness: + enabled: true + path: /health/ready + responseMode: app + openapi: + enabled: true + path: /openapi.json +``` + +`responseMode: app` 使用项目 App 的响应模板/自定义 Encoder。健康成功使用项目成功码和成功提示;未就绪的 `NOT_READY` 可在项目 Go 错误目录配置业务码和公开提示,无需在 `.api` 再声明。 + +健康探针的 HTTP 状态固定遵循探针语义:存活/就绪为 200,未就绪为 503;项目错误目录或自定义编码器不能把未就绪改为 HTTP 200。响应模板 `${status}` 同样使用探针状态。失败继续进入业务失败日志/指标,探针底层原因保留在错误链供日志使用,不直接返回给调用方。 + +`responseMode: raw` 返回 `{"status":"ready"}` 等健康数据,有依赖时包含检查名称、结果与耗时;不包装公共体。存活检查不执行依赖探针;服务开始退出后,就绪检查返回 503,存活仍可返回 200,直到监听关闭。OpenAPI 始终输出规范文档本身,不使用业务响应包装;关闭 HTTP 文档端点不影响生成 `docs/openapi.json`。 + +环境变量覆盖配置文件,例如 `GO_OMNI_ENDPOINTS_READINESS_PATH=/readyz`、`GO_OMNI_ENDPOINTS_READINESS_RESPONSE_MODE=raw`、`GO_OMNI_ENDPOINTS_OPENAPI_ENABLED=false`;liveness/readiness/openapi 都支持对应的 ENABLED 和 PATH。Config/AppOptions 的最终有效配置决定是否注册。 + +关闭或改名后,旧路径不再由框架保留,项目可以在 `.api` 自己声明。启用的内置 GET 路由与业务路由相同或被业务参数/通配路径覆盖时,在创建 App、开始监听之前报错;不会覆盖业务路由。内置接口仍经过全局中间件;项目在鉴权中间件中明确决定这些路径的访问规则,框架不自动绕过鉴权。 + +## 从 v0.12 生成项目升级 + +manifest v2 和现有目录保留。升级同版 CLI 与框架依赖后执行 `api generate`,生成器自动更新 main 和 Handler;`svc`、配置与业务文件仍按字节保留,所以需要在项目拥有的 svc 文件补入以下扩展: + +1. `serviceContext.go` 导入 `git.yuntuo.hk/k2safe/go-omni/lifecycle`,给 ServiceContext 增加 `Lifecycle *lifecycle.Controller`;创建 c 时用 `lifecycle.New(ctx)` 初始化,并在后续依赖初始化前设置 `ctx = c.Lifecycle.Context()`。 +2. 增加 `func (c *ServiceContext) RequestShutdown(err error) { c.Lifecycle.RequestShutdown(err) }`。 +3. 构造失败的 defer 在 `c.Close()` 后保留 `c.Lifecycle.Err()`;Close 的 `closeOnce.Do` 开头调用 `c.Lifecycle.RequestShutdown(nil)`,重复关闭仍由已有 once 保护。 +4. 配置默认值若来自 `config.Default()`,自动具有默认端点配置;如需禁用或改路径,在 YAML 或项目 Config 中显式设置。手动拼 Config 的项目需要自行填写要启用的端点。 + +精确实现可对照 [示例 svc](examples/managedService/internal/svc/serviceContext.go) 和 [关闭逻辑](examples/managedService/internal/svc/close.go),也可在临时目录用新版 CLI 生成参考。不要覆盖原有数据库/客户端初始化;不需要手改 main、Handler 或引入另一套启动器。最后执行 `api generate --check`、`go test ./...`、`go build .`,并验证故障退出。 + ## App 默认响应格式 在 **入口** `.api` 声明一次: @@ -157,7 +213,7 @@ get /download (DownloadReq) ## 已有服务接入步骤 -每个独立服务生成一个项目,module 名称、上层目录和共享业务包由项目选择。接入时固定 CLI 与框架版本为 v0.12.0-preview: +每个独立服务生成一个项目,module 名称、上层目录和共享业务包由项目选择。接入时固定 CLI 与框架版本为 v0.13.0-preview: 1. 先保留旧服务,整理实际路由及现有响应、鉴权和超时要求。在新目录执行 `go-omni new --api /path/to/service.api --module example.com/service ./service`;不对旧入口强制覆盖。 2. 在 `.api` 中选择 `@app(defaultResponse: ...)`,显式保留原项目的字段、成功码、错误码和 `dataMode`。需要独立回执的路由单独声明 raw/produces。普通路由、404/405 和提前返回的中间件一起验收。 diff --git a/README.md b/README.md index 235b43f..b5045b4 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ GoOmni 是面向多个业务项目复用的 Go 后端框架:用 `.api` 定义接口、请求和返回类型,生成项目目录、HTTP 接入代码及 OpenAPI,业务代码负责 Logic 和 Repository。也可以在已有项目中直接使用 `omni.New()`,或单独引入日志、存储等模块。 -**从这份 README 开始接入。** 当前版本 **`v0.12.0-preview`**,新生成项目采用根目录入口和 `config / svc / logic / handler`。可以固定版本开展业务接入;文件维护边界和已有服务迁移步骤见 [项目指南](PROJECT.md),版本变化与验收见 [发布说明](RELEASE.md)。 +**从这份 README 开始接入。** 当前版本 **`v0.13.0-preview`**,新生成项目采用根目录入口和 `config / svc / logic / handler`。可以固定版本开展业务接入;文件维护边界和已有服务迁移步骤见 [项目指南](PROJECT.md),版本变化与验收见 [发布说明](RELEASE.md)。 Go **1.22+**。框架不依赖 Gin,默认使用标准库网络层,提供可切换的 native HTTP/1.1 和实验性 HTTP/2 适配器。 @@ -28,7 +28,7 @@ module 地址是 `git.yuntuo.hk/k2safe/go-omni`。先确保本机 Go 和 Git 可 ```sh export GOPRIVATE="$(go env GOPRIVATE),git.yuntuo.hk" -go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.12.0-preview +go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.13.0-preview export PATH="$(go env GOPATH)/bin:$PATH" go-omni --help ``` @@ -184,7 +184,7 @@ go-omni new --api catalog.api \ 在业务项目的 **go.mod 所在目录**执行: ```sh -go get git.yuntuo.hk/k2safe/go-omni@v0.12.0-preview +go get git.yuntuo.hk/k2safe/go-omni@v0.13.0-preview ``` 已有项目可直接复用现有目录、Service、Repository 和数据库连接,先选择一个接口接入。也可以只用 `logx`、`storage` 等包,暂时保留原来的 HTTP 框架。希望入口和 HTTP 接入都交给生成器的项目,按 [已有服务接入步骤](PROJECT.md#已有服务接入步骤) 在新目录生成后迁入业务代码。 @@ -435,6 +435,8 @@ server: 自研网络层通过 `RunWith(ctx, runner)` 接入监听与连接生命周期,通过 `NewWithRouter(router)` 替换路由;业务 Handler/Service 不需要导入具体适配器。细节与验收契约见 [网络适配层](transport/README.md)。 +生成项目的依赖可调用 `svcCtx.RequestShutdown(err)` 主动申请退出,框架排空请求、清理依赖后保留故障原因并以非零状态退出。健康检查与 OpenAPI 的开关、路径通过 `server.endpoints` 配置,健康响应默认沿用项目公共体;未就绪保持 HTTP 503。完整配置和 v0.12 项目的升级步骤见 [项目指南](PROJECT.md#依赖主动申请退出)。 + `Run` 处理 SIGINT/SIGTERM;`RunContext` 接收调用方控制的退出信号。服务先停止接收新请求,等待活跃请求完成,再清理资源;超时会取消请求并关闭连接,业务代码仍需响应 context 取消。 ## 日常开发与升级 @@ -452,7 +454,7 @@ go test ./... `main.go`、`Gen.go`、`docs/openapi.json` 和 manifest 由生成器管理,手动修改会报告冲突。Logic、Repository、中间件实现、配置和依赖装配属于项目,重复生成保留手写内容。契约变化会提示受影响的 Logic 文件和 DTO 字段,明确的签名错误会给出原签名与期望签名;生成后 `--check` 仍能检出未修复的问题。将 `.api`、生成文件和 `.goOmni/apiManifest.json` 一起提交,CI 同时运行 `generate --check` 与 `go test ./...`。详见 [契约变更提示与检查边界](PROJECT.md#契约变更提示)。 -CLI 与业务 module 固定使用 `v0.12.0-preview`。新布局不兼容旧 manifest;旧试验项目需在新目录生成并搬入业务文件,不能直接覆盖手写入口。新布局内部升级再执行上述生成与测试步骤,手写框架项目只需依赖升级及业务回归。版本变化、迁移边界和验收记录见 [发布说明](RELEASE.md)。 +CLI 与业务 module 固定使用 `v0.13.0-preview`。新布局不兼容旧 manifest;旧试验项目需在新目录生成并搬入业务文件,不能直接覆盖手写入口。新布局内部升级再执行上述生成与测试步骤,手写框架项目只需依赖升级及业务回归。版本变化、迁移边界和验收记录见 [发布说明](RELEASE.md)。 ## 按需深入 diff --git a/RELEASE.md b/RELEASE.md index 4083921..291a51c 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,4 +1,43 @@ -# v0.12.0-preview +# v0.13.0-preview + +本版补齐项目依赖主动退出与内置接口配置。ID 租约、数据库或后台任务等故障由项目判断,框架通过统一生命周期完成退出;不内置业务规则。 + +## 使用与升级 + +```sh +go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.13.0-preview +# 在业务 module 中执行 +go get git.yuntuo.hk/k2safe/go-omni@v0.13.0-preview +go-omni api generate +``` + +新项目自动获得全部扩展。v0.12 的 config/svc 属于项目文件,重新生成会保留它们;需按 [升级步骤](PROJECT.md#从-v012-生成项目升级) 给 svc 补入 Lifecycle、RequestShutdown 与清理衔接,main 和 Handler 由生成器更新。旧 v0.11 目录仍按原迁移规则在新目录生成。 + +## 行为 + +- `svc.RequestShutdown(err)` 非阻塞通知:停止接收请求、排空、关闭依赖和日志。首个非空故障保留,故障退出码为 1;正常停止在没有其他故障时退出码为 0。初始化期间通知、并发通知和排空期间报告故障均可处理。 +- App 生命周期入口保留异常 context 取消原因;符合 context.Canceled 的信号取消保持正常停止。生成入口还读取 Controller.Err,保留显式依赖故障,即使该故障包装了 context.Canceled。 +- `/health/ready` 修复绕过响应配置的问题,默认与 liveness 一起使用项目 App 的响应格式。就绪成功使用项目成功码与提示;未就绪使用 `NOT_READY` 的项目错误定义,同时固定 HTTP 503。raw 模式只返回健康数据;内部探针原因仅供日志。 +- `server.endpoints` 控制三个内置 GET 接口的开关和路径,健康端点另有 responseMode。支持环境变量覆盖。OpenAPI 保持原始规范,不包业务公共体;关闭 HTTP 端点不影响生成文档。 +- `.api` 不再固定保留三个默认路径;启用的内置路径与业务 GET/参数/通配路由重叠时,启动前返回明确错误并清理已创建的依赖。关闭或改名后允许项目接管旧路径。全局中间件继续执行。 + +## 验收 + +源码测试和实际生成项目验证记录见 [生命周期与端点验收](performance/lifecycleEndpointsAcceptance.md)。复验命令: + +```sh +GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race -p=1 -count=1 -timeout=300s ./... +go vet ./... +go build ./... +python3 scripts/verifyManagedProject.py --report performance/lifecycleEndpointsLocal.json +python3 scripts/verifyManagedRelease.py --version v0.13.0-preview --report performance/lifecycleEndpointsRemote.json +``` + +远程验收在发布后执行,报告单独提交,不改写发布标签。此版仍为 preview;具体业务的租约失效保护、鉴权与事务回归由接入项目验证。资源清理顺序不代表能强行结束忽略 context 取消的业务 goroutine;项目 Close 必须停止并等待自己启动的后台任务。 + +--- + +# v0.12.0-preview(历史版本) 本版用于固定版本开展业务接入。生成器统一维护服务根目录 main.go、路由、HTTP Handler、DTO 和 OpenAPI,业务方维护 `.api`、config、svc、logic、repository 与项目中间件。目录不再生成 cmd/server、settings、servicecontext 或 service。 diff --git a/app/app.go b/app/app.go index 2b64c1c..86ddb36 100644 --- a/app/app.go +++ b/app/app.go @@ -14,6 +14,7 @@ import ( "git.yuntuo.hk/k2safe/go-omni/config" apierrors "git.yuntuo.hk/k2safe/go-omni/errors" "git.yuntuo.hk/k2safe/go-omni/httpx" + "git.yuntuo.hk/k2safe/go-omni/lifecycle" "git.yuntuo.hk/k2safe/go-omni/middleware" "git.yuntuo.hk/k2safe/go-omni/response" netrouter "git.yuntuo.hk/k2safe/go-omni/transport/nethttp" @@ -183,7 +184,8 @@ func (a *App) NewServer(address string) *http.Server { } // RunContext serves until the context is canceled, then performs a graceful shutdown. -func (a *App) RunContext(ctx context.Context, address ...string) error { +func (a *App) RunContext(ctx context.Context, address ...string) (runErr error) { + defer func() { runErr = shutdownResult(ctx, runErr) }() if ctx == nil { ctx = context.Background() } @@ -205,7 +207,8 @@ func (a *App) RunContext(ctx context.Context, address ...string) error { // RunWith starts a custom server runner while keeping App-owned cleanup in the // same lifecycle. The runner receives the transport-neutral App handler and // owns transport/TLS settings; built-in transport selection is bypassed. -func (a *App) RunWith(ctx context.Context, runner Runner, address ...string) error { +func (a *App) RunWith(ctx context.Context, runner Runner, address ...string) (runErr error) { + defer func() { runErr = shutdownResult(ctx, runErr) }() if runner == nil { return errors.Join(errors.New("go-omni: nil runner"), a.Close()) } @@ -218,6 +221,13 @@ func (a *App) RunWith(ctx context.Context, runner Runner, address ...string) err return errors.Join(runner.Run(ctx, a, a.listenAddress(address...)), a.Close()) } +func shutdownResult(ctx context.Context, err error) error { + if cause := lifecycle.Cause(ctx); cause != nil && !errors.Is(err, cause) { + return errors.Join(err, cause) + } + return err +} + func (a *App) listenAddress(address ...string) string { listenOn := a.config.Address if listenOn == "" { diff --git a/app/fatalShutdown_test.go b/app/fatalShutdown_test.go new file mode 100644 index 0000000..ce8a1f2 --- /dev/null +++ b/app/fatalShutdown_test.go @@ -0,0 +1,119 @@ +package app + +import ( + "context" + "errors" + "io" + "net" + "net/http" + "sync" + "sync/atomic" + "testing" + "time" + + "git.yuntuo.hk/k2safe/go-omni/config" + "git.yuntuo.hk/k2safe/go-omni/httpx" + "git.yuntuo.hk/k2safe/go-omni/lifecycle" +) + +func TestFatalShutdownDrainsAndPreservesCause(t *testing.T) { + for _, transport := range []string{config.TransportNetHTTP, config.TransportNative} { + t.Run(transport, func(t *testing.T) { + app := New(config.WithTransport(transport), config.WithShutdownTimeout(2*time.Second)) + control := lifecycle.New(context.Background()) + defer control.RequestShutdown(nil) + entered, release := make(chan struct{}), make(chan struct{}) + var releaseOnce sync.Once + unblock := func() { releaseOnce.Do(func() { close(release) }) } + defer unblock() + var closed atomic.Int32 + closeErr := errors.New("cleanup diagnostic") + app.OnShutdown(func() error { closed.Add(1); return closeErr }) + app.GET("/work", func(c *httpx.Context) error { + close(entered) + <-release + if closed.Load() != 0 { + return errors.New("closed during request") + } + return c.OK("done") + }) + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { done <- app.ServeContext(control.Context(), listener) }() + requestDone := make(chan error, 1) + client := http.Client{Timeout: 3 * time.Second} + go func() { + res, err := client.Get("http://" + listener.Addr().String() + "/work") + if err == nil { + _, err = io.Copy(io.Discard, res.Body) + _ = res.Body.Close() + if res.StatusCode != 200 { + err = errors.New("request failed") + } + } + requestDone <- err + }() + select { + case <-entered: + case <-time.After(3 * time.Second): + t.Fatal("request never started") + } + fatal := errors.New("lease lost") + control.RequestShutdown(fatal) + select { + case err := <-done: + t.Fatal("did not drain", err) + case <-time.After(30 * time.Millisecond): + } + if closed.Load() != 0 { + t.Fatal("closed dependency before drain") + } + unblock() + select { + case err := <-requestDone: + if err != nil { + t.Fatal(err) + } + case <-time.After(3 * time.Second): + t.Fatal("request stuck") + } + select { + case err := <-done: + if !errors.Is(err, fatal) || !errors.Is(err, closeErr) { + t.Fatal("lost cause", err) + } + case <-time.After(3 * time.Second): + t.Fatal("shutdown stuck") + } + _ = app.Close() + if closed.Load() != 1 { + t.Fatal("cleanup repeated") + } + }) + } +} + +func TestCanceledStartupPreservesFault(t *testing.T) { + fatal := errors.New("dependency failed during startup") + ctx, cancel := context.WithCancelCause(context.Background()) + cancel(fatal) + app := New() + if err := app.RunContext(ctx); !errors.Is(err, fatal) { + t.Fatal(err) + } + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + if err = New().ServeContext(ctx, listener); !errors.Is(err, fatal) { + t.Fatal(err) + } + normal, stop := context.WithCancel(context.Background()) + stop() + if err = New().RunContext(normal); err != nil { + t.Fatal("normal stop failed", err) + } +} diff --git a/app/transport.go b/app/transport.go index abe1e84..064adda 100644 --- a/app/transport.go +++ b/app/transport.go @@ -17,7 +17,8 @@ import ( // ServeContext serves a caller-created listener using the configured transport // and TLS. App takes ownership of the listener, including on startup failure, // and runs resource cleanup after the server has stopped. -func (a *App) ServeContext(ctx context.Context, listener net.Listener) error { +func (a *App) ServeContext(ctx context.Context, listener net.Listener) (runErr error) { + defer func() { runErr = shutdownResult(ctx, runErr) }() if listener == nil { return errors.Join(errors.New("go-omni: nil listener"), a.Close()) } diff --git a/cmd/go-omni/README.md b/cmd/go-omni/README.md index 4531b8a..b7784d4 100644 --- a/cmd/go-omni/README.md +++ b/cmd/go-omni/README.md @@ -6,7 +6,7 @@ ```sh # 仓库读取权限与 PATH 配置见根目录 README -go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.12.0-preview +go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.13.0-preview go-omni new --module example.com/hello ./hello # 从自己的契约生成;service.api 及其相对 import 文件需要在本地存在 @@ -41,14 +41,14 @@ main、Gen.go、OpenAPI 和 manifest 由生成器维护;业务文件按字节 ## 版本与迁移 -CLI 与框架依赖固定使用 v0.12.0-preview。远程安装的 CLI 默认把自身版本写入 go.mod,不添加本地 replace;`--framework-version` 可显式指定已发布的兼容版本。仅在开发框架源码时使用本地替换: +CLI 与框架依赖固定使用 v0.13.0-preview。远程安装的 CLI 默认把自身版本写入 go.mod,不添加本地 replace;`--framework-version` 可显式指定已发布的兼容版本。仅在开发框架源码时使用本地替换: ```sh # 在 go-omni 源码仓库中执行 go run ./cmd/go-omni new --module example.com/hello --framework-dir . ../hello ``` -新模板需要 v0.12.0-preview 的流式能力与 manifest v2。不兼容旧目录,旧生成项目请在新目录重新生成后移入业务代码,不覆盖手写 main。独立工具命令不会被删除。受管理项目通过 `.api` 模块导入增加业务,不使用旧 `add` 命令的手动路由装配。 +新模板需要 v0.13.0-preview 的依赖退出与端点配置能力,继续使用 manifest v2。v0.12 项目需按 [升级步骤](../../PROJECT.md#从-v012-生成项目升级) 补入 svc 生命周期字段和方法,生成器保留这些项目文件。不兼容旧目录,旧生成项目请在新目录重新生成后移入业务代码,不覆盖手写 main。独立工具命令不会被删除。受管理项目通过 `.api` 模块导入增加业务,不使用旧 `add` 命令的手动路由装配。 ## 数据库迁移命令 diff --git a/cmd/go-omni/apigen/apigen_test.go b/cmd/go-omni/apigen/apigen_test.go index 535f449..8825294 100644 --- a/cmd/go-omni/apigen/apigen_test.go +++ b/cmd/go-omni/apigen/apigen_test.go @@ -87,7 +87,6 @@ func TestInvalidDefinitions(t *testing.T) { "service user { @handler go\n @status 201\n post /go\n}", "type A { Name string `header:\"Name\"` }\nservice user { @handler go\n get /go returns (A)\n}", "@server (group: ../../bad)\nservice user { @handler go\n get /go\n}", - "service user { @handler go\n get /health/ready\n}", "type A { Name string `json:\"name\"`\n Name int `json:\"other\"` }\nservice user { @handler go\n post /go (A)\n}", "service user { @handler go\n get /go\n @handler Go\n get /other\n}", "type A { Value []byte `json:\"value\"` }\nservice user { @handler go\n post /go (A)\n}", diff --git a/cmd/go-omni/apigen/compile.go b/cmd/go-omni/apigen/compile.go index 6547ed4..a62b584 100644 --- a/cmd/go-omni/apigen/compile.go +++ b/cmd/go-omni/apigen/compile.go @@ -218,9 +218,6 @@ func Compile(spec *Spec) (*Compiled, error) { return nil, fmt.Errorf("duplicate route %s", pathKey) } paths[pathKey] = true - if r.Path == "/health/live" || r.Path == "/health/ready" || r.Path == "/openapi.json" { - return nil, fmt.Errorf("route %s is reserved by the generated server", r.Path) - } if r.Method != strings.ToLower(r.Method) { return nil, errors.New("HTTP methods must be lowercase") } diff --git a/cmd/go-omni/apigen/endpoints_test.go b/cmd/go-omni/apigen/endpoints_test.go new file mode 100644 index 0000000..be5f060 --- /dev/null +++ b/cmd/go-omni/apigen/endpoints_test.go @@ -0,0 +1,60 @@ +package apigen + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "testing" + "time" +) + +func TestConfigurableBuiltinRoutesConsumer(t *testing.T) { + dir := filepath.Join(t.TempDir(), "endpoints") + api := filepath.Join(t.TempDir(), "service.api") + writeFile(t, api, "service healthApi {\n @handler customReady\n get /health/ready\n @handler customDoc\n get /openapi.json\n}\n") + _, err := Generate(Options{Dir: dir, API: api, Module: "example.com/endpoints", FrameworkDir: frameworkRoot(t)}) + if err != nil { + t.Fatal("built-in paths must be permitted in API contracts", err) + } + writeFile(t, filepath.Join(dir, "endpoints_test.go"), `package main +import ( + "context" + "io" + "net/http/httptest" + "testing" + "strings" + "example.com/endpoints/internal/config" + "example.com/endpoints/internal/svc" + "example.com/endpoints/internal/handler" + "git.yuntuo.hk/k2safe/go-omni/logx" +) +func TestProjectOwnsDisabledBuiltinPaths(t *testing.T){ + cfg:=config.Default();cfg.Server.Log.Writer=io.Discard + logger,_:=logx.New(cfg.Server.Log);defer logger.Close() + deps,err:=svc.New(context.Background(),logger,cfg);if err!=nil{t.Fatal(err)};defer deps.Close() + if _,err=handler.NewApp(deps);err==nil || !strings.Contains(err.Error(),"overlaps business"){t.Fatal("expected preflight collision",err)} + deps.Config.Server.Endpoints.Readiness.Enabled=false + deps.Config.Server.Endpoints.OpenAPI.Enabled=false + app,err:=handler.NewApp(deps);if err!=nil{t.Fatal(err)};defer app.Close() + for _,path:=range []string{"/health/ready","/openapi.json"}{ + out:=httptest.NewRecorder();app.ServeHTTP(out,httptest.NewRequest("GET",path,nil)) + if out.Code!=501{t.Fatal("expected generated business Logic stub",path,out.Code,out.Body.String())} + } + deps.Config.Server.Endpoints.Readiness.Enabled=true + deps.Config.Server.Endpoints.Readiness.Path="/readyz" + deps.Config.Server.Endpoints.OpenAPI.Enabled=true + deps.Config.Server.Endpoints.OpenAPI.Path="/spec.json" + app2,err:=handler.NewApp(deps);if err!=nil{t.Fatal(err)};defer app2.Close() + for _,path:=range []string{"/readyz","/spec.json"}{out:=httptest.NewRecorder();app2.ServeHTTP(out,httptest.NewRequest("GET",path,nil));if out.Code!=200{t.Fatal(path,out.Code)}} +} +`) + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, "go", "test", "-race", "-mod=mod", "-count=1", "./...") + cmd.Dir = dir + cmd.Env = append(os.Environ(), "GOWORK=off") + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatal(err, string(output)) + } +} diff --git a/cmd/go-omni/apigen/generate.go b/cmd/go-omni/apigen/generate.go index 28430e5..d377776 100644 --- a/cmd/go-omni/apigen/generate.go +++ b/cmd/go-omni/apigen/generate.go @@ -19,7 +19,7 @@ import ( ) const frameworkModule = "git.yuntuo.hk/k2safe/go-omni" -const minimumVersion = "v0.12.0-preview" +const minimumVersion = "v0.13.0-preview" const generatedHeader = "// Code generated by go-omni api. DO NOT EDIT.\n" type Options struct { @@ -141,13 +141,13 @@ func generate(o Options, c *Compiled, starter bool) (Output, error) { if !filepath.IsAbs(path) { path = filepath.Join(directory, path) } - if _, err := os.Stat(filepath.Join(path, "httpx", "transfer.go")); err == nil { + if _, err := os.Stat(filepath.Join(path, "lifecycle", "controller.go")); err == nil { localCurrent = true } } } if !localCurrent { - return Output{}, fmt.Errorf("existing framework dependency predates managed startup and streaming support; use %s or a current local replacement", minimumVersion) + return Output{}, fmt.Errorf("existing framework dependency predates dependency shutdown and configurable endpoint support; use %s or a current local replacement", minimumVersion) } } if o.FrameworkDir != "" { diff --git a/cmd/go-omni/apigen/managed_test.go b/cmd/go-omni/apigen/managed_test.go index 783f24d..a1ba2fa 100644 --- a/cmd/go-omni/apigen/managed_test.go +++ b/cmd/go-omni/apigen/managed_test.go @@ -70,6 +70,7 @@ func TestManagedGeneratedProject(t *testing.T) { mainBefore := readFile(t, filepath.Join(dir, "main.go")) files := map[string]string{ "consumer_test.go": "consumer_test.go", "internal/svc/options.go": "options.go", "internal/svc/client.go": "client.go", "internal/logic/demo/check.go": "operations.go", + "shutdown_test.go": "shutdown_test.go", } for path, fixture := range files { writeFile(t, filepath.Join(dir, path), readFile(t, filepath.Join("testdata/managed", fixture))) @@ -78,7 +79,7 @@ func TestManagedGeneratedProject(t *testing.T) { writeFile(t, filepath.Join(dir, "internal/logic/demo", name+".go"), "package demo\n") } configPath := filepath.Join(dir, "internal/config/config.go") - writeFile(t, configPath, strings.Replace(readFile(t, configPath), "type Config struct {", "type Config struct {\n FailInit bool `json:\"failInit\"`", 1)) + writeFile(t, configPath, strings.Replace(readFile(t, configPath), "type Config struct {", "type Config struct {\n FailInit bool `json:\"failInit\"`\n ShutdownInit bool `json:\"shutdownInit\"`", 1)) svcPath := filepath.Join(dir, "internal/svc/serviceContext.go") source := strings.Replace(readFile(t, svcPath), "type ServiceContext struct {", "type ServiceContext struct {\n Client *Client", 1) source = strings.Replace(source, "return c, nil", "if err=initClient(ctx,c);err!=nil{return nil,err}\n return c,nil", 1) diff --git a/cmd/go-omni/apigen/startup.go b/cmd/go-omni/apigen/startup.go index 6d5df9c..008cbb4 100644 --- a/cmd/go-omni/apigen/startup.go +++ b/cmd/go-omni/apigen/startup.go @@ -13,13 +13,15 @@ import ( omni %q %q %q + %q + %q ) // NewApp is generated HTTP composition. The caller owns deps and its logger. func NewApp(deps *svc.ServiceContext)(*omni.App,error){ - if deps==nil || deps.Health==nil{return nil,fmt.Errorf("service context and health checker required")} + if deps==nil || deps.Lifecycle==nil{return nil,fmt.Errorf("service context and lifecycle controller required; see PROJECT.md upgrade instructions")} cfg:=deps.Config.Server for _,option:=range deps.AppOptions(){if option!=nil{option(&cfg)}} -`, frameworkModule, frameworkModule+"/httpx", o.Module+"/internal/svc") +`, frameworkModule, frameworkModule+"/httpx", frameworkModule+"/config", frameworkModule+"/health", o.Module+"/internal/svc") if spec.DefaultResponse != "" { for _, r := range spec.Responses { if r.Name == spec.DefaultResponse { @@ -28,13 +30,30 @@ func NewApp(deps *svc.ServiceContext)(*omni.App,error){ } } } - source += `if err:=cfg.Validate();err!=nil{return nil,err} + source += "if err:=cfg.Validate();err!=nil{return nil,err}\n" + source += "if err:=cfg.Endpoints.ValidateRoutes([]config.EndpointRoute{\n" + for _, route := range spec.Routes { + source += fmt.Sprintf("{Method:%q,Path:%q},\n", route.Method, route.Path) + } + if o.Storage == "local" { + source += "{Method: \"GET\",Path: \"/storage/object\"},\n" + } + source += `}...);err!=nil{return nil,err} + if (cfg.Endpoints.Liveness.Enabled || cfg.Endpoints.Readiness.Enabled) && deps.Health==nil{return nil,fmt.Errorf("enabled health endpoints require a checker")} app:=omni.New(omni.WithConfig(cfg)) app.Use(deps.GlobalMiddleware()...) - if err:=RegisterRoutes(app,deps);err!=nil{return nil,err} - app.GET("/openapi.json",OpenAPI) - app.GET("/health/live",deps.Health.LivenessHandler()) - app.GET("/health/ready",deps.Health.ReadinessHandler()) + if err:=RegisterRoutes(app,deps);err!=nil{_ = app.Close();return nil,err} + if cfg.Endpoints.OpenAPI.Enabled{app.GET(cfg.Endpoints.OpenAPI.Path,OpenAPI)} + if cfg.Endpoints.Liveness.Enabled{ + var options []health.HandlerOption + if cfg.Endpoints.Liveness.ResponseMode=="raw"{options=append(options,health.WithRawResponse())} + app.GET(cfg.Endpoints.Liveness.Path,deps.Health.LivenessHandler(options...)) + } + if cfg.Endpoints.Readiness.Enabled{ + options:=[]health.HandlerOption{health.WithShutdownContext(deps.Lifecycle.Context())} + if cfg.Endpoints.Readiness.ResponseMode=="raw"{options=append(options,health.WithRawResponse())} + app.GET(cfg.Endpoints.Readiness.Path,deps.Health.ReadinessHandler(options...)) + } ` if o.Storage == "local" { source += `if deps.FileHTTP==nil{return nil,fmt.Errorf("local storage handler required")} diff --git a/cmd/go-omni/apigen/templates.go b/cmd/go-omni/apigen/templates.go index 033be81..cd9d673 100644 --- a/cmd/go-omni/apigen/templates.go +++ b/cmd/go-omni/apigen/templates.go @@ -32,11 +32,14 @@ func run(ctx context.Context,path string)(runErr error){ deps,err:=svc.New(ctx,logger,cfg);if err!=nil{return err} // RunContext drains HTTP requests before returning. This scope owns dependencies // and closes them exactly once, before closing the borrowed logger. - defer func(){runErr=errors.Join(runErr,deps.Close())}() + defer func(){ + runErr=errors.Join(runErr,deps.Close()) + if cause:=deps.Lifecycle.Err();cause!=nil && !errors.Is(runErr,cause){runErr=errors.Join(runErr,cause)} + }() app,err:=handler.NewApp(deps);if err!=nil{return err} defer func(){runErr=errors.Join(runErr,app.Close())}() logger.InfoContext(ctx,"service.starting","address",app.Config().Address) - return app.RunContext(ctx) + return app.RunContext(deps.Lifecycle.Context()) } `, ".gitignore": "/server\n/var/\n.env\n.env.*\n!.env.example\n/.goOmni/api.lock\n", @@ -58,6 +61,10 @@ go run . GET /health/live and /health/ready check the running service. GET /openapi.json serves the same document as docs/openapi.json. Bind address defaults to localhost. +Configure server.endpoints to disable or move each built-in route. Health +responseMode is app by default (project format), or raw for plain health data. +Probe HTTP statuses remain 200/503 even when business errors use HTTP 200. +OpenAPI remains an unwrapped document; disabled endpoints reserve no paths. Set GO_OMNI_TRANSPORT=native to select the native transport without changing services. The greeting starter works immediately; other new operation stubs return 501 until their business logic is implemented. Authentication hooks also fail closed with 501. @@ -174,6 +181,16 @@ Do not close borrowed resources or attach duplicate cleanup hooks to App. The st context cancels on signals; long-lived clients/workers that requests depend on need their own lifetime, canceled by OnClose after HTTP draining. +svc.RequestShutdown(err) lets a dependency report a fatal service failure. +It returns immediately. Generated main stops accepting requests, drains them, +closes dependencies and logs, then exits nonzero with the original cause. +nil requests a normal stop. Concurrent calls keep the first non-nil failure; +notifications during initialization or draining are retained. Close must stop +and join project-owned monitors before the final shutdown result is returned. +The svc lifecycle context signals draining, not permission to close resources +still used by active requests. Liveness and readiness are different: shutdown +makes readiness 503; it does not turn a running process's liveness into failure. + Named middleware is composed in declaration order (first is outermost). Implement the generated hooks or replace entries in svc; never bypass an auth hook merely to make the server start. Middleware names do not imply a JWT policy. diff --git a/cmd/go-omni/apigen/testdata/managed/client.go b/cmd/go-omni/apigen/testdata/managed/client.go index ae1fba5..2492a0f 100644 --- a/cmd/go-omni/apigen/testdata/managed/client.go +++ b/cmd/go-omni/apigen/testdata/managed/client.go @@ -9,16 +9,23 @@ import ( var Opened, Closed, DownloadClosed atomic.Int32 var Started = make(chan struct{}, 1) var Release = make(chan struct{}) +var Current atomic.Pointer[ServiceContext] +var FatalDependency = errors.New("test dependency cannot continue") // This fake client exercises the same lifetime contract as a database/client. type Client struct{} func initClient(ctx context.Context, c *ServiceContext) error { + Current.Store(c) Opened.Add(1) c.Client = &Client{} - c.OnClose(func() error { Closed.Add(1); return nil }) + c.OnClose(func() error { Closed.Add(1); c.Logger.Info("test.client.closed"); return nil }) if c.Config.FailInit { return errors.New("client initialization failed") } + if c.Config.ShutdownInit { + c.RequestShutdown(FatalDependency) + return nil + } return ctx.Err() } diff --git a/cmd/go-omni/apigen/testdata/managed/operations.go b/cmd/go-omni/apigen/testdata/managed/operations.go index eead7c3..9d97873 100644 --- a/cmd/go-omni/apigen/testdata/managed/operations.go +++ b/cmd/go-omni/apigen/testdata/managed/operations.go @@ -71,9 +71,10 @@ type countedReader struct{ io.Reader } func (c *countedReader) Close() error { svc.DownloadClosed.Add(1); return nil } func (l *Logic) Slow(context.Context) (*types.Receipt, error) { + before := svc.Closed.Load() svc.Started <- struct{}{} <-svc.Release - if svc.Closed.Load() != 0 { + if svc.Closed.Load() != before { return nil, errors.New("dependency closed before drain") } return &types.Receipt{Bytes: 1}, nil diff --git a/cmd/go-omni/apigen/testdata/managed/shutdown_test.go b/cmd/go-omni/apigen/testdata/managed/shutdown_test.go new file mode 100644 index 0000000..df2eda9 --- /dev/null +++ b/cmd/go-omni/apigen/testdata/managed/shutdown_test.go @@ -0,0 +1,231 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "io" + "net" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "example.com/managed/internal/config" + "example.com/managed/internal/handler" + "example.com/managed/internal/svc" + "git.yuntuo.hk/k2safe/go-omni/health" + "git.yuntuo.hk/k2safe/go-omni/logx" +) + +func TestGeneratedEndpointSettings(t *testing.T) { + cfg := config.Default() + cfg.Server.Log.Writer = io.Discard + cfg.Server.Endpoints.Liveness.Path = "/livez" + cfg.Server.Endpoints.Readiness.Path = "/readyz" + cfg.Server.Endpoints.OpenAPI.Enabled = false + logger, err := logx.New(cfg.Server.Log) + if err != nil { + t.Fatal(err) + } + defer logger.Close() + deps, err := svc.New(context.Background(), logger, cfg) + if err != nil { + t.Fatal(err) + } + defer deps.Close() + app, err := handler.NewApp(deps) + if err != nil { + t.Fatal(err) + } + defer app.Close() + for _, path := range []string{"/health/live", "/health/ready", "/openapi.json"} { + if out := response(t, app, "GET", path, nil, nil); out.Code != 404 { + t.Fatal(path, out.Code) + } + } + for _, path := range []string{"/readyz", "/livez"} { + out := response(t, app, "GET", path, nil, nil) + var data map[string]any + _ = json.Unmarshal(out.Body.Bytes(), &data) + if out.Code != 200 || data["code"] != float64(0) || data["msg"] != "success" || data["message"] != nil || data["requestId"] != nil { + t.Fatal(path, out.Code, out.Body.String()) + } + } + deps.RequestShutdown(errors.New("private shutdown cause")) + out := response(t, app, "GET", "/readyz", nil, nil) + if out.Code != 503 || strings.Contains(out.Body.String(), "private") { + t.Fatal(out.Code, out.Body.String()) + } + if out = response(t, app, "GET", "/livez", nil, nil); out.Code != 200 { + t.Fatal("liveness should still work") + } + // NewApp uses the project's latest configuration, including endpoint switches. + deps.Config.Server.Endpoints.Readiness.ResponseMode = "raw" + deps.Config.Server.Endpoints.Liveness.Enabled = false + deps.Config.Server.Endpoints.OpenAPI.Enabled = true + deps.Config.Server.Endpoints.OpenAPI.Path = "/spec.json" + app2, err := handler.NewApp(deps) + if err != nil { + t.Fatal(err) + } + defer app2.Close() + out = response(t, app2, "GET", "/readyz", nil, nil) + if out.Code != 503 || strings.Contains(out.Body.String(), `"code"`) || !strings.Contains(out.Body.String(), `"status":"not_ready"`) { + t.Fatal(out.Body.String()) + } + out = response(t, app2, "GET", "/spec.json", nil, nil) + if out.Code != 200 || !strings.Contains(out.Body.String(), `"openapi":`) || strings.Contains(out.Body.String(), `"msg":"操作成功"`) { + t.Fatal("OpenAPI was wrapped") + } + deps.Config.Server.Endpoints.Readiness.Path = "/check" + if _, err = handler.NewApp(deps); err == nil || !strings.Contains(err.Error(), "overlaps business") { + t.Fatal("collision accepted", err) + } + deps.Config.Server.Endpoints.Readiness.Enabled = false + if a, err := handler.NewApp(deps); err != nil { + t.Fatal("disabled endpoint reserves path", err) + } else { + _ = a.Close() + } +} + +func TestGeneratedFailedProbe(t *testing.T) { + cfg := config.Default() + cfg.Server.Log.Writer = io.Discard + logger, _ := logx.New(cfg.Server.Log) + defer logger.Close() + deps, err := svc.New(context.Background(), logger, cfg) + if err != nil { + t.Fatal(err) + } + defer deps.Close() + if err = deps.Health.Add(health.Probe{Name: "database", Check: func(context.Context) error { return errors.New("private connection details") }}); err != nil { + t.Fatal(err) + } + app, err := handler.NewApp(deps) + if err != nil { + t.Fatal(err) + } + defer app.Close() + out := response(t, app, "GET", "/health/ready", nil, nil) + if out.Code != 503 || strings.Contains(out.Body.String(), "private") || !strings.Contains(out.Body.String(), `"msg":`) { + t.Fatal(out.Code, out.Body.String()) + } +} + +func TestGeneratedDependencyShutdown(t *testing.T) { + for _, transport := range []string{"nethttp", "native"} { + t.Run(transport, func(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + address := listener.Addr().String() + _ = listener.Close() + t.Setenv("GO_OMNI_TRANSPORT", transport) + svc.Started = make(chan struct{}, 1) + svc.Release = make(chan struct{}) + before := svc.Closed.Load() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + done := make(chan error, 1) + path := configFile(t, address, false) + go func() { done <- run(ctx, path) }() + base := "http://" + address + waitReady(t, base, done) + deps := svc.Current.Load() + requestDone := make(chan error, 1) + go func() { + client := http.Client{Timeout: 3 * time.Second} + res, err := client.Get(base + "/slow") + if err == nil { + _, _ = io.Copy(io.Discard, res.Body) + _ = res.Body.Close() + if res.StatusCode != 200 { + err = io.ErrUnexpectedEOF + } + } + requestDone <- err + }() + select { + case <-svc.Started: + case <-time.After(3 * time.Second): + t.Fatal("request did not start") + } + deps.RequestShutdown(svc.FatalDependency) + // Inspect the same generated readiness handler during draining, even if + // the network listener has already stopped accepting new connections. + probe, _ := handler.NewApp(deps) + out := httptest.NewRecorder() + probe.ServeHTTP(out, httptest.NewRequest("GET", "/health/ready", nil)) + _ = probe.Close() + if out.Code != 503 { + t.Fatal("draining remained ready", out.Body.String()) + } + if svc.Closed.Load() != before { + t.Fatal("dependency closed before drain") + } + close(svc.Release) + select { + case err = <-requestDone: + if err != nil { + t.Fatal(err) + } + case <-time.After(3 * time.Second): + t.Fatal("request stuck") + } + select { + case err = <-done: + if !errors.Is(err, svc.FatalDependency) { + t.Fatal("fatal cause lost", err) + } + case <-time.After(3 * time.Second): + t.Fatal("shutdown stuck") + } + if svc.Closed.Load() != before+1 { + t.Fatal("cleanup did not run once") + } + }) + } +} + +func TestGeneratedFatalProcessExit(t *testing.T) { + if path := os.Getenv("OMNI_TEST_FATAL_CONFIG"); path != "" { + os.Args = []string{os.Args[0], "-config", path} + main() + return + } + path := filepath.Join(t.TempDir(), "config.json") + if err := os.WriteFile(path, []byte(`{"shutdownInit":true,"server":{"address":"127.0.0.1:0"}}`), 0600); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestGeneratedFatalProcessExit$") + cmd.Env = append(os.Environ(), "OMNI_TEST_FATAL_CONFIG="+path) + output, err := cmd.CombinedOutput() + var exit *exec.ExitError + if !errors.As(err, &exit) || exit.ExitCode() != 1 { + t.Fatal("fatal shutdown must exit 1", err, string(output)) + } + if !strings.Contains(string(output), svc.FatalDependency.Error()) || strings.Count(string(output), "test.client.closed") != 1 { + t.Fatal("missing failure or cleanup", string(output)) + } +} + +func TestGeneratedEndpointConflictCleansUp(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.json") + if err := os.WriteFile(path, []byte(`{"server":{"address":"127.0.0.1:0","endpoints":{"readiness":{"path":"/check"}}}}`), 0600); err != nil { + t.Fatal(err) + } + before := svc.Closed.Load() + err := run(context.Background(), path) + if err == nil || !strings.Contains(err.Error(), "overlaps business") || svc.Closed.Load() != before+1 { + t.Fatal("collision leaked resources or started serving", err) + } +} diff --git a/cmd/go-omni/main_test.go b/cmd/go-omni/main_test.go index 67932cc..1d360b0 100644 --- a/cmd/go-omni/main_test.go +++ b/cmd/go-omni/main_test.go @@ -8,7 +8,7 @@ import ( ) func TestNewWithRemoteFrameworkVersion(t *testing.T) { - for _, flag := range [][]string{{"--framework-version", "v0.12.0-preview"}, {"--framework-version=v0.12.0-preview"}} { + for _, flag := range [][]string{{"--framework-version", "v0.13.0-preview"}, {"--framework-version=v0.13.0-preview"}} { project := filepath.Join(t.TempDir(), "billing") args := append([]string{"--module", "example.com/billing"}, flag...) if err := runNew(append(args, project)); err != nil { @@ -18,7 +18,7 @@ func TestNewWithRemoteFrameworkVersion(t *testing.T) { if err != nil { t.Fatal(err) } - if !strings.Contains(string(content), "go-omni v0.12.0-preview") || strings.Contains(string(content), "replace ") { + if !strings.Contains(string(content), "go-omni v0.13.0-preview") || strings.Contains(string(content), "replace ") { t.Fatalf("CLI did not pin remote dependency: %s", content) } } diff --git a/cmd/go-omni/scaffold/templates/api-close.go.tmpl b/cmd/go-omni/scaffold/templates/api-close.go.tmpl index 9f15f39..542b96e 100644 --- a/cmd/go-omni/scaffold/templates/api-close.go.tmpl +++ b/cmd/go-omni/scaffold/templates/api-close.go.tmpl @@ -4,6 +4,7 @@ import "errors" func(c *Context)Close()error{ if c==nil{return nil} c.closeOnce.Do(func(){ + if c.Lifecycle!=nil{c.Lifecycle.RequestShutdown(nil)} c.closeMu.Lock();c.closed=true;cleanups:=c.cleanups;c.cleanups=nil;c.closeMu.Unlock() for i:=len(cleanups)-1;i>=0;i--{c.closeErr=errors.Join(c.closeErr,cleanups[i]())} {{if ne .Storage "none"}}if c.storageClose!=nil{c.closeErr=errors.Join(c.closeErr,c.storageClose())}{{end}} diff --git a/cmd/go-omni/scaffold/templates/api-context.go.tmpl b/cmd/go-omni/scaffold/templates/api-context.go.tmpl index 8f5d754..8a3b6b7 100644 --- a/cmd/go-omni/scaffold/templates/api-context.go.tmpl +++ b/cmd/go-omni/scaffold/templates/api-context.go.tmpl @@ -11,12 +11,14 @@ import ( "git.yuntuo.hk/k2safe/go-omni/health" "git.yuntuo.hk/k2safe/go-omni/httpx" "git.yuntuo.hk/k2safe/go-omni/logx" + "git.yuntuo.hk/k2safe/go-omni/lifecycle" "{{.Module}}/internal/settings" "{{.Module}}/internal/middleware" ) // Context owns infrastructure, while Logger is borrowed from main. // Add business repositories here; HTTP contracts do not define database tables. type Context struct { + Lifecycle *lifecycle.Controller Config settings.Config cleanups []func()error Logger *logx.Logger @@ -40,13 +42,14 @@ type Context struct { // tests without infrastructure; selected adapters still require valid settings. func New(ctx context.Context,logger *logx.Logger,configs ...settings.Config)(result *Context,err error){ if ctx==nil{ctx=context.Background()} - if err=ctx.Err();err!=nil{return nil,err} + if err=ctx.Err();err!=nil{return nil,errors.Join(err,lifecycle.Cause(ctx))} if len(configs)>1{return nil,errors.New("expected at most one configuration")} cfg:=settings.Default();if len(configs)==1{cfg=configs[0]} _=cfg checker,err:=health.New();if err!=nil{return nil,err} - c:=&Context{Config:cfg,Logger:logger,Health:checker,Middlewares:middleware.Defaults()} - defer func(){if err!=nil{err=errors.Join(err,c.Close())}}() + c:=&Context{Config:cfg,Logger:logger,Health:checker,Middlewares:middleware.Defaults(),Lifecycle:lifecycle.New(ctx)} + ctx=c.Lifecycle.Context() + defer func(){if err!=nil{err=errors.Join(err,c.Close());if cause:=c.Lifecycle.Err();cause!=nil && !errors.Is(err,cause){err=errors.Join(err,cause)}}}() {{if eq .Database "mysql"}} if err=cfg.Validate();err!=nil{return nil,err} dsn,parseErr:=mysql.ParseDSN(cfg.Database.DSN) @@ -59,3 +62,7 @@ func New(ctx context.Context,logger *logx.Logger,configs ...settings.Config)(res {{if ne .Storage "none"}}if err=c.InitStorage(ctx,cfg.Storage);err!=nil{return nil,err}{{end}} return c,nil } + +// RequestShutdown reports a fatal dependency failure, or a normal stop when +// err is nil. It returns immediately; generated main drains and cleans up. +func(c *Context)RequestShutdown(err error){c.Lifecycle.RequestShutdown(err)} diff --git a/cmd/go-omni/scaffold/templates/modules-config.yaml.tmpl b/cmd/go-omni/scaffold/templates/modules-config.yaml.tmpl index b50baf7..3ab9bf8 100644 --- a/cmd/go-omni/scaffold/templates/modules-config.yaml.tmpl +++ b/cmd/go-omni/scaffold/templates/modules-config.yaml.tmpl @@ -4,6 +4,19 @@ server: enableLogger: true slowRequestThreshold: 500ms shutdownTimeout: 10s +{{if .API}} endpoints: + liveness: + enabled: true + path: /health/live + responseMode: app + readiness: + enabled: true + path: /health/ready + responseMode: app + openapi: + enabled: true + path: /openapi.json +{{end}} log: service: {{printf "%q" .Module}} level: info diff --git a/cmd/go-omni/scaffold/version.go b/cmd/go-omni/scaffold/version.go index 32c1da8..33c32bd 100644 --- a/cmd/go-omni/scaffold/version.go +++ b/cmd/go-omni/scaffold/version.go @@ -10,7 +10,7 @@ const frameworkModule = "git.yuntuo.hk/k2safe/go-omni" // Source builds can carry an unpublished VCS version (including +dirty). // Keep this fallback in sync with the next release; installed CLIs use their // downloaded module version, which also has a module checksum. -const defaultFrameworkVersion = "v0.12.0-preview" +const defaultFrameworkVersion = "v0.13.0-preview" // This target is not a claim that the release already exists. Source users // select a local framework checkout until the module templates are published. diff --git a/config/config.go b/config/config.go index 40d2822..cf2a845 100644 --- a/config/config.go +++ b/config/config.go @@ -30,6 +30,7 @@ type Config struct { Transport string `json:"transport"` TLS TLSConfig `json:"tls"` HTTP HTTPConfig `json:"http"` + Endpoints EndpointsConfig `json:"endpoints"` RequestIDHeader string `json:"requestIdHeader"` EnableLogger bool `json:"enableLogger"` SlowRequestThreshold time.Duration `json:"slowRequestThreshold"` @@ -47,12 +48,16 @@ func Default() Config { Transport: TransportNetHTTP, RequestIDHeader: defaultRequestIDHeader, ShutdownTimeout: 10 * time.Second, + Endpoints: DefaultEndpoints(), Log: logx.Default(), } } // Validate checks configuration values before an application starts. func (c Config) Validate() error { + if err := c.Endpoints.Validate(); err != nil { + return err + } if err := c.Errors.Validate(); err != nil { return err } diff --git a/config/endpoints.go b/config/endpoints.go new file mode 100644 index 0000000..33490af --- /dev/null +++ b/config/endpoints.go @@ -0,0 +1,119 @@ +package config + +import ( + "fmt" + "strings" + "unicode" +) + +// EndpointsConfig controls routes installed by a generated service. A manually +// assembled App still registers its own routes explicitly. +type EndpointsConfig struct { + Liveness HealthEndpointConfig `json:"liveness"` + Readiness HealthEndpointConfig `json:"readiness"` + OpenAPI EndpointConfig `json:"openapi"` +} + +type EndpointConfig struct { + Enabled bool `json:"enabled"` + Path string `json:"path"` +} + +type HealthEndpointConfig struct { + Enabled bool `json:"enabled"` + Path string `json:"path"` + ResponseMode string `json:"responseMode"` // app (default) or raw +} + +func DefaultEndpoints() EndpointsConfig { + return EndpointsConfig{ + Liveness: HealthEndpointConfig{Enabled: true, Path: "/health/live", ResponseMode: "app"}, + Readiness: HealthEndpointConfig{Enabled: true, Path: "/health/ready", ResponseMode: "app"}, + OpenAPI: EndpointConfig{Enabled: true, Path: "/openapi.json"}, + } +} + +func (c EndpointsConfig) enabled() []struct{ name, path string } { + var result []struct{ name, path string } + for _, endpoint := range []struct { + name string + enabled bool + path string + }{ + {"liveness", c.Liveness.Enabled, c.Liveness.Path}, + {"readiness", c.Readiness.Enabled, c.Readiness.Path}, + {"openapi", c.OpenAPI.Enabled, c.OpenAPI.Path}, + } { + if endpoint.enabled { + result = append(result, struct{ name, path string }{endpoint.name, endpoint.path}) + } + } + return result +} + +func (c EndpointsConfig) Validate() error { + for _, mode := range []string{c.Liveness.ResponseMode, c.Readiness.ResponseMode} { + if mode != "" && mode != "app" && mode != "raw" { + return fmt.Errorf("endpoints: responseMode must be app or raw") + } + } + paths := map[string]string{} + for _, endpoint := range c.enabled() { + p := endpoint.path + if !strings.HasPrefix(p, "/") || strings.ContainsAny(p, ":*?#%\\{}") || + strings.IndexFunc(p, func(r rune) bool { return unicode.IsSpace(r) || unicode.IsControl(r) }) >= 0 { + return fmt.Errorf("endpoints.%s.path must be an absolute literal URL path", endpoint.name) + } + parts := strings.Split(p[1:], "/") + for i, part := range parts { + if part == "." || part == ".." || (part == "" && i < len(parts)-1) { + return fmt.Errorf("endpoints.%s.path contains an invalid segment", endpoint.name) + } + } + if previous := paths[p]; previous != "" { + return fmt.Errorf("endpoints.%s and endpoints.%s share path %s", previous, endpoint.name, p) + } + paths[p] = endpoint.name + } + return nil +} + +// EndpointRoute describes an already validated business route. Only GET +// patterns can overlap these built-in GET endpoints. +type EndpointRoute struct{ Method, Path string } + +// ValidateRoutes rejects a built-in route that would shadow a business route +// or conflict with its catch-all. Disabled endpoints reserve no paths. +func (c EndpointsConfig) ValidateRoutes(routes ...EndpointRoute) error { + if err := c.Validate(); err != nil { + return err + } + for _, endpoint := range c.enabled() { + for _, route := range routes { + if strings.EqualFold(route.Method, "GET") && endpointOverlap(route.Path, endpoint.path) { + return fmt.Errorf("endpoints.%s path %s overlaps business route GET %s; change its path or disable it", endpoint.name, endpoint.path, route.Path) + } + } + } + return nil +} + +func endpointOverlap(pattern, path string) bool { + a, b := strings.Split(strings.TrimPrefix(pattern, "/"), "/"), strings.Split(path[1:], "/") + for i, part := range a { + if i >= len(b) { + return false + } + if strings.HasPrefix(part, "*") { + return true + } + if strings.HasPrefix(part, ":") { + if b[i] == "" { + return false + } + } else if part != b[i] { + return false + } + } + return len(a) == len(b) +} diff --git a/config/endpoints_test.go b/config/endpoints_test.go new file mode 100644 index 0000000..bbd4faa --- /dev/null +++ b/config/endpoints_test.go @@ -0,0 +1,66 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestEndpointConfiguration(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yaml") + if err := os.WriteFile(path, []byte("endpoints:\n liveness:\n enabled: false\n readiness:\n path: /readyz\n responseMode: raw\n openapi:\n enabled: false\n"), 0600); err != nil { + t.Fatal(err) + } + cfg, err := DecodeFile(path, Default()) + if err != nil { + t.Fatal(err) + } + if cfg.Endpoints.Liveness.Enabled || cfg.Endpoints.OpenAPI.Enabled || !cfg.Endpoints.Readiness.Enabled || cfg.Endpoints.Readiness.ResponseMode != "raw" { + t.Fatal(cfg.Endpoints) + } + t.Setenv("TEST_ENDPOINTS_READINESS_PATH", "/internal/ready") + t.Setenv("TEST_ENDPOINTS_OPENAPI_ENABLED", "true") + cfg, err = ApplyEnv(cfg, "TEST_") + if err != nil || cfg.Endpoints.Readiness.Path != "/internal/ready" || !cfg.Endpoints.OpenAPI.Enabled { + t.Fatal(cfg.Endpoints, err) + } + t.Setenv("TEST_ENDPOINTS_OPENAPI_ENABLED", "invalid") + if _, err = ApplyEnv(cfg, "TEST_"); err == nil { + t.Fatal("invalid bool accepted") + } +} + +func TestEndpointRouteConflicts(t *testing.T) { + for _, route := range []EndpointRoute{{"GET", "/health/ready"}, {"get", "/health/:name"}, {"GET", "/health/*rest"}, {"GET", "/*rest"}} { + cfg := DefaultEndpoints() + if err := cfg.ValidateRoutes(route); err == nil { + t.Fatal("overlap accepted", route) + } + cfg.Readiness.Enabled = false + cfg.Liveness.Enabled = false + cfg.OpenAPI.Enabled = false + if err := cfg.ValidateRoutes(route); err != nil { + t.Fatal("disabled endpoint reserves path", err) + } + } + if err := DefaultEndpoints().ValidateRoutes(EndpointRoute{"POST", "/health/ready"}, EndpointRoute{"GET", "/other/*rest"}); err != nil { + t.Fatal(err) + } + for _, path := range []string{"", "ready", "//ready", "/a/../ready", "/:ready", "/ready?x=1", "/a%2fb", "/ready\n"} { + cfg := DefaultEndpoints() + cfg.Readiness.Path = path + if err := cfg.Validate(); err == nil { + t.Fatal("invalid path", path) + } + } + cfg := DefaultEndpoints() + cfg.Readiness.Path = cfg.Liveness.Path + if err := cfg.Validate(); err == nil { + t.Fatal("duplicate endpoints accepted") + } + cfg = DefaultEndpoints() + cfg.Readiness.ResponseMode = "unknown" + if err := cfg.Validate(); err == nil { + t.Fatal("invalid mode accepted") + } +} diff --git a/config/env.go b/config/env.go index c67900e..0c1b84b 100644 --- a/config/env.go +++ b/config/env.go @@ -49,6 +49,26 @@ func applyEnv(cfg Config, prefix string) (Config, error) { cfg.Log.Environment = envString(prefix+"ENVIRONMENT", cfg.Log.Environment) var err error + for _, endpoint := range []struct { + name string + enabled *bool + path *string + mode *string + }{ + {"LIVENESS", &cfg.Endpoints.Liveness.Enabled, &cfg.Endpoints.Liveness.Path, &cfg.Endpoints.Liveness.ResponseMode}, + {"READINESS", &cfg.Endpoints.Readiness.Enabled, &cfg.Endpoints.Readiness.Path, &cfg.Endpoints.Readiness.ResponseMode}, + {"OPENAPI", &cfg.Endpoints.OpenAPI.Enabled, &cfg.Endpoints.OpenAPI.Path, nil}, + } { + key := prefix + "ENDPOINTS_" + endpoint.name + *endpoint.enabled, err = envBool(key+"_ENABLED", *endpoint.enabled) + if err != nil { + return Config{}, err + } + *endpoint.path = envString(key+"_PATH", *endpoint.path) + if endpoint.mode != nil { + *endpoint.mode = envString(key+"_RESPONSE_MODE", *endpoint.mode) + } + } for _, field := range []struct { name string value *time.Duration diff --git a/examples/managedService/.goOmni/apiManifest.json b/examples/managedService/.goOmni/apiManifest.json index 05215c3..ecc30e9 100644 --- a/examples/managedService/.goOmni/apiManifest.json +++ b/examples/managedService/.goOmni/apiManifest.json @@ -10,14 +10,14 @@ "entry": "api/managed.api", "generated": { "docs/openapi.json": "9eb707aefeefd3f4881c774534e2d143949c0edb1d8a4aabd762f978f6203df1", - "internal/handler/appGen.go": "dbadfb73165e0e6ef10e8277dd1b8a0edc2992dac3690d634808ac92829c2654", + "internal/handler/appGen.go": "c6f078932e1a598a46faebba397f4e753db9d8f4f29a02f9b6077a0557d6c14e", "internal/handler/demo/handlersGen.go": "7a6f9d5e105e44b07196cc540cee3f0562d5f3d1c5f0bc094434db1064d0339c", "internal/handler/openapiGen.go": "612920ed25e5dc7a4f673be56041d47b70aa3216707ff282e52dfe503c2d705a", "internal/handler/responseGen.go": "84397a502bba4777ebfa6fae3d3bc418297acab4f26b04b42e3c31b36c735399", "internal/handler/routesGen.go": "76c1a5b2644e75e56553dc6e9e862a63128d902dd94807ce7c8aee5491cd1e3e", "internal/middleware/registryGen.go": "e5f47efa57a1432765ef3419bae59dbf3b4d55b3aac0a6c935ce7a52ba6b47f4", "internal/types/typesGen.go": "7586e86fa5d67051283178cc578c890f6c02fd2d5c383170a71a39c2e77ce344", - "main.go": "09ae81857b806f3f7dca608399b1881d94bc9c4b2258e774e77d694fcadbab65" + "main.go": "a12db5b7df0f2fdca4939a7382daee04c85ddb63ef0bccd1a2ce26b718f42579" }, "contract": { "routes": { diff --git a/examples/managedService/README.md b/examples/managedService/README.md index 65d7fb6..0f90c59 100644 --- a/examples/managedService/README.md +++ b/examples/managedService/README.md @@ -3,7 +3,14 @@ This checked-in example uses a relative local framework replacement (`../..`). Run `go mod tidy && go run .` here. No external database or cloud account is required. The Go-only error catalog is `internal/apierrors/catalog.go`, registered by svc. -The generated main and handlers are unmodified. Try: +The generated main and handlers are unmodified. Built-in endpoint switches and +paths are configured in etc/config.yaml under server.endpoints. Health replies +use the project envelope by default; responseMode: raw opts out. Readiness +failures stay HTTP 503, independently of business error status policies. + +Dependencies can call svc.RequestShutdown(err); the generated entry drains +requests, closes resources and retains the failure for a nonzero exit. +Try: ```sh curl -i 'http://127.0.0.1:8080/check?kind=fixed' diff --git a/examples/managedService/etc/config.yaml b/examples/managedService/etc/config.yaml index bb3fba0..42f523b 100644 --- a/examples/managedService/etc/config.yaml +++ b/examples/managedService/etc/config.yaml @@ -4,6 +4,10 @@ server: enableLogger: true slowRequestThreshold: 500ms shutdownTimeout: 10s + endpoints: + liveness: {enabled: true, path: /health/live, responseMode: app} + readiness: {enabled: true, path: /health/ready, responseMode: app} + openapi: {enabled: true, path: /openapi.json} log: service: "example.com/managed" level: info diff --git a/examples/managedService/go.mod b/examples/managedService/go.mod index 58e4002..10bb65a 100644 --- a/examples/managedService/go.mod +++ b/examples/managedService/go.mod @@ -2,7 +2,7 @@ module example.com/managed go 1.22.0 -require git.yuntuo.hk/k2safe/go-omni v0.12.0-preview +require git.yuntuo.hk/k2safe/go-omni v0.13.0-preview require ( github.com/gabriel-vasile/mimetype v1.4.3 // indirect diff --git a/examples/managedService/internal/handler/appGen.go b/examples/managedService/internal/handler/appGen.go index 7cf1f92..21aaef3 100644 --- a/examples/managedService/internal/handler/appGen.go +++ b/examples/managedService/internal/handler/appGen.go @@ -6,12 +6,14 @@ import ( omni "git.yuntuo.hk/k2safe/go-omni" "example.com/managed/internal/svc" + "git.yuntuo.hk/k2safe/go-omni/config" + "git.yuntuo.hk/k2safe/go-omni/health" ) // NewApp is generated HTTP composition. The caller owns deps and its logger. func NewApp(deps *svc.ServiceContext) (*omni.App, error) { - if deps == nil || deps.Health == nil { - return nil, fmt.Errorf("service context and health checker required") + if deps == nil || deps.Lifecycle == nil { + return nil, fmt.Errorf("service context and lifecycle controller required; see PROJECT.md upgrade instructions") } cfg := deps.Config.Server for _, option := range deps.AppOptions() { @@ -24,13 +26,42 @@ func NewApp(deps *svc.ServiceContext) (*omni.App, error) { if err := cfg.Validate(); err != nil { return nil, err } + if err := cfg.Endpoints.ValidateRoutes([]config.EndpointRoute{ + {Method: "get", Path: "/check"}, + {Method: "get", Path: "/other"}, + {Method: "get", Path: "/raw"}, + {Method: "post", Path: "/upload"}, + {Method: "post", Path: "/callback"}, + {Method: "get", Path: "/download"}, + {Method: "get", Path: "/slow"}, + }...); err != nil { + return nil, err + } + if (cfg.Endpoints.Liveness.Enabled || cfg.Endpoints.Readiness.Enabled) && deps.Health == nil { + return nil, fmt.Errorf("enabled health endpoints require a checker") + } app := omni.New(omni.WithConfig(cfg)) app.Use(deps.GlobalMiddleware()...) if err := RegisterRoutes(app, deps); err != nil { + _ = app.Close() return nil, err } - app.GET("/openapi.json", OpenAPI) - app.GET("/health/live", deps.Health.LivenessHandler()) - app.GET("/health/ready", deps.Health.ReadinessHandler()) + if cfg.Endpoints.OpenAPI.Enabled { + app.GET(cfg.Endpoints.OpenAPI.Path, OpenAPI) + } + if cfg.Endpoints.Liveness.Enabled { + var options []health.HandlerOption + if cfg.Endpoints.Liveness.ResponseMode == "raw" { + options = append(options, health.WithRawResponse()) + } + app.GET(cfg.Endpoints.Liveness.Path, deps.Health.LivenessHandler(options...)) + } + if cfg.Endpoints.Readiness.Enabled { + options := []health.HandlerOption{health.WithShutdownContext(deps.Lifecycle.Context())} + if cfg.Endpoints.Readiness.ResponseMode == "raw" { + options = append(options, health.WithRawResponse()) + } + app.GET(cfg.Endpoints.Readiness.Path, deps.Health.ReadinessHandler(options...)) + } return app, nil } diff --git a/examples/managedService/internal/svc/close.go b/examples/managedService/internal/svc/close.go index 145ce66..f030e55 100644 --- a/examples/managedService/internal/svc/close.go +++ b/examples/managedService/internal/svc/close.go @@ -8,6 +8,9 @@ func (c *ServiceContext) Close() error { return nil } c.closeOnce.Do(func() { + if c.Lifecycle != nil { + c.Lifecycle.RequestShutdown(nil) + } c.closeMu.Lock() c.closed = true cleanups := c.cleanups diff --git a/examples/managedService/internal/svc/serviceContext.go b/examples/managedService/internal/svc/serviceContext.go index 2716452..9c21796 100644 --- a/examples/managedService/internal/svc/serviceContext.go +++ b/examples/managedService/internal/svc/serviceContext.go @@ -9,12 +9,14 @@ import ( "example.com/managed/internal/middleware" "git.yuntuo.hk/k2safe/go-omni/health" "git.yuntuo.hk/k2safe/go-omni/httpx" + "git.yuntuo.hk/k2safe/go-omni/lifecycle" "git.yuntuo.hk/k2safe/go-omni/logx" ) // Context owns infrastructure, while Logger is borrowed from main. // Add business repositories here; HTTP contracts do not define database tables. type ServiceContext struct { + Lifecycle *lifecycle.Controller Config config.Config cleanups []func() error Logger *logx.Logger @@ -34,7 +36,7 @@ func New(ctx context.Context, logger *logx.Logger, configs ...config.Config) (re ctx = context.Background() } if err = ctx.Err(); err != nil { - return nil, err + return nil, errors.Join(err, lifecycle.Cause(ctx)) } if len(configs) > 1 { return nil, errors.New("expected at most one configuration") @@ -48,12 +50,18 @@ func New(ctx context.Context, logger *logx.Logger, configs ...config.Config) (re if err != nil { return nil, err } - c := &ServiceContext{Config: cfg, Logger: logger, Health: checker, Middlewares: middleware.Defaults()} + c := &ServiceContext{Config: cfg, Logger: logger, Health: checker, Middlewares: middleware.Defaults(), Lifecycle: lifecycle.New(ctx)} + ctx = c.Lifecycle.Context() defer func() { if err != nil { err = errors.Join(err, c.Close()) + if cause := c.Lifecycle.Err(); cause != nil && !errors.Is(err, cause) { + err = errors.Join(err, cause) + } } }() return c, nil } + +func (c *ServiceContext) RequestShutdown(err error) { c.Lifecycle.RequestShutdown(err) } diff --git a/examples/managedService/main.go b/examples/managedService/main.go index 1a61113..e8a62b6 100644 --- a/examples/managedService/main.go +++ b/examples/managedService/main.go @@ -41,12 +41,17 @@ func run(ctx context.Context, path string) (runErr error) { } // RunContext drains HTTP requests before returning. This scope owns dependencies // and closes them exactly once, before closing the borrowed logger. - defer func() { runErr = errors.Join(runErr, deps.Close()) }() + defer func() { + runErr = errors.Join(runErr, deps.Close()) + if cause := deps.Lifecycle.Err(); cause != nil && !errors.Is(runErr, cause) { + runErr = errors.Join(runErr, cause) + } + }() app, err := handler.NewApp(deps) if err != nil { return err } defer func() { runErr = errors.Join(runErr, app.Close()) }() logger.InfoContext(ctx, "service.starting", "address", app.Config().Address) - return app.RunContext(ctx) + return app.RunContext(deps.Lifecycle.Context()) } diff --git a/health/README.md b/health/README.md index a2f04fc..53c3a68 100644 --- a/health/README.md +++ b/health/README.md @@ -14,4 +14,8 @@ app.GET("/health/live", checker.LivenessHandler()) app.GET("/health/ready", checker.ReadinessHandler()) ``` -就绪探针会保留每项检查结果,并在至少一项失败时返回 `503` 和 `NOT_READY`。 +从 v0.13.0-preview 起,两个 Handler 默认都使用 App 响应模板/自定义 Encoder。就绪成功使用项目成功码和提示;失败以 `NOT_READY` 查询项目错误目录,返回项目字段、业务码和公开提示。HTTP 状态保持 200/503,不受业务错误 HTTP 200 策略影响。底层探针原因不会直接放入 HTTP 响应,保留到失败错误链供日志使用;内部 `Checker.Check` 仍提供检查错误。 + +`ReadinessHandler(health.WithRawResponse())` 只返回健康数据,不包公共体。`health.WithShutdownContext(ctx)` 让服务开始退出时立即标记为未就绪;存活检查不受影响。超时依赖 Probe.Check 自己响应 context 取消,框架不会遗留 goroutine 去强行打断不合作的检查。 + +生成项目的开关、路径和响应模式统一配置在 `server.endpoints`,不需要修改 Handler。完整配置、依赖主动退出和升级方法见 [项目指南](../PROJECT.md)。 diff --git a/health/health.go b/health/health.go index 5cb7916..2a0db39 100644 --- a/health/health.go +++ b/health/health.go @@ -3,14 +3,15 @@ package health import ( "context" + "errors" "fmt" "net/http" "strings" "sync" "time" + apierrors "git.yuntuo.hk/k2safe/go-omni/errors" "git.yuntuo.hk/k2safe/go-omni/httpx" - "git.yuntuo.hk/k2safe/go-omni/response" ) const ( @@ -38,6 +39,7 @@ type CheckResult struct { type Result struct { Status string `json:"status"` Checks []CheckResult `json:"checks,omitempty"` + cause error } func (r Result) Ready() bool { return r.Status == StatusReady } @@ -118,6 +120,7 @@ func (c *Checker) Check(ctx context.Context) Result { result.Status = StatusNotReady checkResult.Status = StatusNotReady checkResult.Error = err.Error() + result.cause = errors.Join(result.cause, fmt.Errorf("%s: %w", probe.Name, err)) } result.Checks = append(result.Checks, checkResult) } @@ -126,28 +129,78 @@ func (c *Checker) Check(ctx context.Context) Result { // LivenessHandler reports that the process is running. It does not execute // dependency probes and is safe to use for orchestrator liveness checks. -func (c *Checker) LivenessHandler() httpx.Handler { +func (c *Checker) LivenessHandler(options ...HandlerOption) httpx.Handler { + settings := handlerSettings(options) return func(ctx *httpx.Context) error { - return ctx.OK(map[string]string{"status": "ok"}) + data := map[string]string{"status": "ok"} + if settings.raw { + return ctx.JSON(http.StatusOK, data) + } + return ctx.RespondStatus(http.StatusOK, data, nil) } } // ReadinessHandler reports whether all registered dependencies are ready. -// Failure uses HTTP 503 while preserving GoOmni's response envelope. -func (c *Checker) ReadinessHandler() httpx.Handler { +// Failure always uses HTTP 503. App response configuration is used by default; +// WithRawResponse returns just health data. Internal probe errors remain in the +// recorded failure for logs, not in the HTTP response. +func (c *Checker) ReadinessHandler(options ...HandlerOption) httpx.Handler { + settings := handlerSettings(options) return func(ctx *httpx.Context) error { - result := c.Check(ctx.Context()) + var result Result + if settings.shutdown != nil && settings.shutdown.Err() != nil { + result = Result{Status: StatusNotReady, cause: context.Cause(settings.shutdown)} + } else { + result = c.Check(ctx.Context()) + // A stop may arrive while the probes are running. + if settings.shutdown != nil && settings.shutdown.Err() != nil { + result.Status = StatusNotReady + result.cause = errors.Join(result.cause, context.Cause(settings.shutdown)) + } + } + for i := range result.Checks { + result.Checks[i].Error = "" + } status := http.StatusOK - code, message := "READY", "ready" + var failure error if !result.Ready() { status = http.StatusServiceUnavailable - code, message = "NOT_READY", "service is not ready" + failure = apierrors.New(status, "NOT_READY", "service is not ready").WithCause(result.cause) } - return ctx.JSON(status, response.Response{ - Code: code, - Message: message, - Data: result, - RequestID: ctx.RequestID(), - }) + if settings.raw { + if failure != nil { + resolved := ctx.ResolveError(failure) + resolved.Status = status + ctx.RecordFailure(resolved) + } + return ctx.JSON(status, result) + } + return ctx.RespondStatus(status, result, failure) } } + +type handlerConfig struct { + raw bool + shutdown context.Context +} + +type HandlerOption func(*handlerConfig) + +// WithRawResponse bypasses the App envelope for this probe's health data. +func WithRawResponse() HandlerOption { return func(c *handlerConfig) { c.raw = true } } + +// WithShutdownContext marks readiness false as soon as service shutdown begins. +// It never changes liveness or closes any project resource. +func WithShutdownContext(ctx context.Context) HandlerOption { + return func(c *handlerConfig) { c.shutdown = ctx } +} + +func handlerSettings(options []HandlerOption) handlerConfig { + var cfg handlerConfig + for _, option := range options { + if option != nil { + option(&cfg) + } + } + return cfg +} diff --git a/health/health_test.go b/health/health_test.go index a9e815b..40ae848 100644 --- a/health/health_test.go +++ b/health/health_test.go @@ -1,6 +1,7 @@ package health import ( + "bytes" "context" "encoding/json" "errors" @@ -10,6 +11,9 @@ import ( "time" omni "git.yuntuo.hk/k2safe/go-omni" + apierrors "git.yuntuo.hk/k2safe/go-omni/errors" + "git.yuntuo.hk/k2safe/go-omni/httpx" + "git.yuntuo.hk/k2safe/go-omni/response" "git.yuntuo.hk/k2safe/go-omni/transport/nethttp" ) @@ -43,11 +47,119 @@ func TestCheckerReadinessReportsAllProbes(t *testing.T) { if err := json.Unmarshal(ready.Body.Bytes(), &body); err != nil { t.Fatal(err) } - if body.Code != "NOT_READY" || body.Data.Status != StatusNotReady || len(body.Data.Checks) != 2 || body.Data.Checks[1].Error != "connection refused" { + if body.Code != "NOT_READY" || body.Data.Status != StatusNotReady || len(body.Data.Checks) != 2 || body.Data.Checks[1].Error != "" { t.Fatalf("unexpected readiness response: %+v", body) } } +func TestProbeResponsePolicies(t *testing.T) { + for _, raw := range []bool{false, true} { + t.Run(map[bool]string{true: "raw", false: "app"}[raw], func(t *testing.T) { + probeErr := errors.New("private database connection details") + checker, _ := New(Probe{Name: "database", Check: func(context.Context) error { return probeErr }}) + app := omni.New(omni.WithResponse(response.Config{Body: map[string]any{"code": "${code}", "msg": "${message}", "data": "${data}", "httpStatus": "${status}"}, CodeType: "int32", SuccessCode: 200, ErrorCode: 9999, SuccessMessage: "操作成功", DataMode: "always"}), omni.WithErrors(apierrors.Catalog{DefaultStatus: 200, Definitions: map[string]apierrors.Definition{"NOT_READY": {Code: 503001, Message: "暂未就绪"}}})) + var failure *apierrors.APIError + app.Use(func(next httpx.Handler) httpx.Handler { + return func(c *httpx.Context) error { err := next(c); failure = c.Failure(); return err } + }) + var opts []HandlerOption + if raw { + opts = append(opts, WithRawResponse()) + } + app.GET("/ready", checker.ReadinessHandler(opts...)) + app.GET("/live", checker.LivenessHandler(opts...)) + for _, path := range []string{"/ready", "/live"} { + out := httptest.NewRecorder() + app.ServeHTTP(out, httptest.NewRequest("GET", path, nil)) + want := 200 + if path == "/ready" { + want = 503 + } + if out.Code != want { + t.Fatal(out.Code, out.Body.String()) + } + var body map[string]any + if err := json.Unmarshal(out.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if raw { + if _, ok := body["code"]; ok || body["status"] == nil { + t.Fatal(body) + } + } else { + if body["httpStatus"] != float64(want) { + t.Fatal(body) + } + if want == 503 && (body["code"] != float64(503001) || body["msg"] != "暂未就绪") { + t.Fatal(body) + } + if want == 200 && (body["code"] != float64(200) || body["msg"] != "操作成功") { + t.Fatal(body) + } + } + if want == 503 && (failure == nil || failure.Status != 503 || !errors.Is(failure, probeErr)) { + t.Fatal("failure/log cause lost", failure) + } + if bytes.Contains(out.Body.Bytes(), []byte(probeErr.Error())) { + t.Fatal("probe cause leaked") + } + } + }) + } +} + +func TestShutdownReadinessAndCustomEncoderStatus(t *testing.T) { + ctx, stop := context.WithCancel(context.Background()) + defer stop() + checker, _ := New() + app := omni.New(omni.WithResponseEncoder(response.EncoderFunc(func(r response.Result) (response.Payload, error) { + return response.JSON(200, map[string]any{"status": r.Status, "data": r.Data}) + }))) + app.GET("/ready", checker.ReadinessHandler(WithShutdownContext(ctx))) + app.GET("/live", checker.LivenessHandler()) + stop() + out := httptest.NewRecorder() + app.ServeHTTP(out, httptest.NewRequest("GET", "/ready", nil)) + if out.Code != 503 { + t.Fatal("encoder masked failed readiness", out.Code) + } + out = httptest.NewRecorder() + app.ServeHTTP(out, httptest.NewRequest("GET", "/live", nil)) + if out.Code != 200 { + t.Fatal("shutdown changed liveness", out.Code) + } +} + +func TestBrokenProbeEncoderCannotReportHealthy(t *testing.T) { + for _, panicEncoder := range []bool{false, true} { + for _, unhealthy := range []bool{false, true} { + checker, _ := New() + if unhealthy { + _ = checker.Add(Probe{Name: "dependency", Check: func(context.Context) error { return errors.New("offline") }}) + } + app := omni.New(omni.WithResponseEncoder(response.EncoderFunc(func(r response.Result) (response.Payload, error) { + if r.Data != nil { + if panicEncoder { + panic("private encoder detail") + } + return response.Payload{}, errors.New("private encoder detail") + } + return response.JSON(200, map[string]bool{"acknowledged": true}) + }))) + app.GET("/ready", checker.ReadinessHandler()) + out := httptest.NewRecorder() + app.ServeHTTP(out, httptest.NewRequest("GET", "/ready", nil)) + want := 500 + if unhealthy { + want = 503 + } + if out.Code != want || out.Body.Len() != 0 { + t.Fatal(out.Code, out.Body.String()) + } + } + } +} + func TestCheckerTimeoutAndRegistrationValidation(t *testing.T) { checker, err := New(Probe{ Name: "slow", diff --git a/httpx/response.go b/httpx/response.go index 84c7d7d..ad073f5 100644 --- a/httpx/response.go +++ b/httpx/response.go @@ -57,6 +57,9 @@ func (c *Context) writeResult(result response.Result) (err error) { if err != nil { return apierrors.Internal(err) } + if result.PreserveStatus { + payload.Status = result.Status + } if payload.Status < 200 || payload.Status > 599 { return apierrors.Internal(fmt.Errorf("response encoder returned invalid status %d", payload.Status)) } @@ -66,6 +69,41 @@ func (c *Context) writeResult(result response.Result) (err error) { return c.WriteResponse(payload.Status, payload.Header, payload.Body) } +// RespondStatus uses the current response format and error catalog, but keeps +// the explicit HTTP status even if a catalog/custom encoder selects another. +// This is useful for protocol endpoints such as health probes. Ordinary +// business handlers should use OK/Created or return their error as usual. +func (c *Context) RespondStatus(status int, data any, err error) error { + if c.Responded() { + return nil + } + if status < 200 || status > 599 || status == 204 || status == 205 || status == 304 { + return apierrors.Internal(fmt.Errorf("response requires a body-bearing HTTP status")) + } + result := response.Result{Status: status, Code: "OK", Message: "success", Data: data, + RequestID: c.RequestID(), PreserveStatus: true} + if err != nil { + failure := c.ResolveError(err) + failure.Status = status + c.failure = failure + result.Code, result.Message, result.BusinessCode = failure.Code, failure.Message, failure.BusinessCode + result.Err, result.Failed = err, true + } + writeErr := c.writeResult(result) + if writeErr != nil && !c.Responded() { + // A broken encoder must not fall through to a business error policy + // that acknowledges failures with HTTP 200. Commit a bodyless failure. + failureStatus := status + if failureStatus < 400 { + failureStatus = http.StatusInternalServerError + } + c.failure = c.ResolveError(writeErr) + c.failure.Status = failureStatus + _ = c.WriteResponse(failureStatus, nil, nil) + } + return writeErr +} + // Bytes, Text and XML are explicit wire responses; they bypass any envelope. func (c *Context) Bytes(status int, contentType string, body []byte) error { return c.WriteResponse(status, http.Header{"Content-Type": {contentType}}, body) diff --git a/lifecycle/README.md b/lifecycle/README.md new file mode 100644 index 0000000..e1ff560 --- /dev/null +++ b/lifecycle/README.md @@ -0,0 +1,20 @@ +# lifecycle + +Controller 是服务退出通知入口,不管理具体数据库、租约或后台任务。生成项目已经将它接入 svc 与 main,业务在 svc 调用 `c.RequestShutdown(err)` 即可,见 [项目指南](../PROJECT.md#依赖主动申请退出)。 + +手写服务也可组合现有 App: + +```go +control := lifecycle.New(ctx) +defer control.RequestShutdown(nil) + +// 在依赖的故障回调中调用,不阻塞等待排空。 +control.RequestShutdown(cause) + +// RunContext 会观察到已发生的退出通知,不必等监听成功才能通知。 +err := app.RunContext(control.Context()) +``` + +实际使用时 App 和依赖通常先初始化,再由并发监控回调请求退出。资源在 HTTP 排空完成后由调用方关闭,并读取 `control.Err()` 合并清理期间报告的首个故障。通知本身不会关闭资源、执行 os.Exit 或发送操作系统信号。 + +`RequestShutdown(nil)` 表示正常停止,非空 error 表示故障。Controller 保留首个非空故障,重复调用和并发调用安全。`Cause(ctx)` 将符合 errors.Is(err, context.Canceled) 的取消原因视为正常停止(包含新版 Go 的信号原因),保留其他取消原因和 DeadlineExceeded。显式 RequestShutdown(err) 上报的非空错误始终由 Controller.Err 保留,包括包装 context.Canceled 的错误。App.RunContext、ServeContext、RunWith 均保留这些异常原因,与网络适配器无关。 diff --git a/lifecycle/controller.go b/lifecycle/controller.go new file mode 100644 index 0000000..2a7c66e --- /dev/null +++ b/lifecycle/controller.go @@ -0,0 +1,67 @@ +// Package lifecycle coordinates service shutdown without owning project resources. +package lifecycle + +import ( + "context" + "errors" + "sync" +) + +// Controller connects dependency failures to the server's graceful shutdown. +// Construct it with New. RequestShutdown never waits for draining or cleanup. +type Controller struct { + ctx context.Context + cancel context.CancelCauseFunc + mu sync.Mutex + cause error +} + +func New(parent context.Context) *Controller { + if parent == nil { + parent = context.Background() + } + ctx, cancel := context.WithCancelCause(parent) + return &Controller{ctx: ctx, cancel: cancel} +} + +func (c *Controller) Context() context.Context { return c.ctx } + +// RequestShutdown asks the service to stop. nil is a normal stop; a non-nil +// error is fatal. The first fatal error is retained even if a normal signal +// already began draining. Concurrent and repeated calls are safe. +func (c *Controller) RequestShutdown(err error) { + c.mu.Lock() + defer c.mu.Unlock() + if c.cause == nil && err != nil { + c.cause = err + } + c.cancel(err) +} + +// Err is read after resources have stopped reporting failures. It returns the +// first dependency failure, or an abnormal parent cancellation cause. +func (c *Controller) Err() error { + if c == nil { + return nil + } + c.mu.Lock() + defer c.mu.Unlock() + if c.cause != nil { + return c.cause + } + return Cause(c.ctx) +} + +// Cause treats cancellation-compatible errors (including signal causes in +// newer Go versions) as normal stops. Deadlines and other causes are failures. +// Controller.Err additionally retains every explicitly reported non-nil error. +func Cause(ctx context.Context) error { + if ctx == nil { + return nil + } + cause := context.Cause(ctx) + if errors.Is(cause, context.Canceled) { + return nil + } + return cause +} diff --git a/lifecycle/controller_test.go b/lifecycle/controller_test.go new file mode 100644 index 0000000..e936363 --- /dev/null +++ b/lifecycle/controller_test.go @@ -0,0 +1,67 @@ +package lifecycle + +import ( + "context" + "errors" + "fmt" + "sync" + "testing" +) + +func TestShutdownRetainsFirstFailure(t *testing.T) { + parent, signal := context.WithCancel(context.Background()) + c := New(parent) + signal() + if c.Err() != nil { + t.Fatal("signal treated as failure") + } + first := errors.New("dependency stopped") + c.RequestShutdown(first) + var wg sync.WaitGroup + for i := 0; i < 100; i++ { + wg.Add(1) + go func() { defer wg.Done(); c.RequestShutdown(errors.New("later")); c.RequestShutdown(nil); _ = c.Err() }() + } + wg.Wait() + if !errors.Is(c.Err(), first) || c.Context().Err() == nil { + t.Fatal(c.Err()) + } +} + +func TestShutdownBeforeServingAndParentCauses(t *testing.T) { + fatal := errors.New("initialization fault") + c := New(nil) + c.RequestShutdown(fatal) + if c.Context().Err() == nil || !errors.Is(Cause(c.Context()), fatal) || !errors.Is(c.Err(), fatal) { + t.Fatal("lost early shutdown") + } + c.RequestShutdown(nil) + if !errors.Is(c.Err(), fatal) { + t.Fatal("cleanup erased failure") + } + parent, cancel := context.WithCancelCause(context.Background()) + child := New(parent) + cancel(fatal) + if !errors.Is(child.Err(), fatal) { + t.Fatal("lost parent failure") + } + normal := New(nil) + normal.RequestShutdown(nil) + if normal.Err() != nil { + t.Fatal(normal.Err()) + } +} + +func TestCancellationCompatibleCauseAndExplicitFailure(t *testing.T) { + cause := fmt.Errorf("stop: %w", context.Canceled) + ctx, cancel := context.WithCancelCause(context.Background()) + cancel(cause) + if Cause(ctx) != nil { + t.Fatal("cancellation-compatible stop treated as a fault") + } + c := New(nil) + c.RequestShutdown(cause) + if !errors.Is(c.Err(), cause) { + t.Fatal("explicit dependency failure suppressed") + } +} diff --git a/omni.go b/omni.go index 74922cb..5ff185b 100644 --- a/omni.go +++ b/omni.go @@ -27,6 +27,9 @@ type APIError = apierrors.APIError type Config = config.Config type TLSConfig = config.TLSConfig type HTTPConfig = config.HTTPConfig +type EndpointsConfig = config.EndpointsConfig +type EndpointConfig = config.EndpointConfig +type HealthEndpointConfig = config.HealthEndpointConfig type Option = config.Option type Response = response.Response type LogConfig = logx.Config diff --git a/performance/lifecycleEndpointsAcceptance.md b/performance/lifecycleEndpointsAcceptance.md new file mode 100644 index 0000000..eb62dc1 --- /dev/null +++ b/performance/lifecycleEndpointsAcceptance.md @@ -0,0 +1,49 @@ +# 依赖主动退出与内置接口配置验收 + +日期:2026-09-24;环境:Go 1.27.1,darwin/arm64;目标版本:v0.13.0-preview。 + +## 验收范围 + +- 通用生命周期控制器连接项目依赖与生成入口。项目决定故障是否需要退出;框架不判断 ID 租约或其他业务规则。 +- `svc.RequestShutdown(err)` 立即通知并保留首个非空原因,生成入口排空 HTTP 后关闭依赖与日志;重复或并发通知不会重复清理。 +- 生成项目通过 `server.endpoints` 控制 liveness、readiness、OpenAPI 的开关与路径。健康检查默认使用项目响应模板和错误目录,可显式选择 raw 数据。 +- 就绪失败固定 HTTP 503,即使项目业务错误统一使用 HTTP 200;原始探针错误保留给日志,不进入公开响应。OpenAPI 不包装公共体。 + +## 源码与实际生成项目 + +回归命令: + +```sh +GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race -p=1 -count=1 -timeout=300s ./... +go vet ./... +go build ./... +python3 scripts/verifyManagedProject.py --report performance/lifecycleEndpointsLocal.json +``` + +最终全量 race 测试(包括生成器集成测试)、go vet、go build 全部通过;修改文件的 gofmt、Python 语法、文档本地链接及 git diff --check 通过,本地报告的源码哈希与发布源码一致。 + +- `lifecycle` 测试覆盖并发通知、正常停止后报告故障、初始化前通知、父 context 原因,以及包装 context.Canceled 的显式故障。 +- `TestFatalShutdownDrainsAndPreservesCause` 在 nethttp/native 上验证:在途请求完成前不关闭依赖,关闭仅一次,故障和清理错误都保留;提前取消的启动同样保留故障。 +- `TestManagedGeneratedProject` 使用实际 CLI 生成的 main/Handler/DTO,在项目文件中加入模拟客户端和配置,子项目执行 race 测试与根目录构建。新增子测试验证依赖通知后的排空顺序、初始化期间通知、端点配置、冲突后的资源释放。 +- `TestGeneratedFatalProcessExit` 启动真实子进程执行生成的 main:故障退出码为 1,输出保留实际原因,模拟客户端只关闭一次。 +- 健康测试覆盖数字 code、项目 msg、成功模板、NOT_READY 目录映射、raw 数据、关闭中的 readiness、失败日志状态与内部原因。目录的 HTTP 200 和自定义 Encoder 都不能把未就绪改成 HTTP 200;编码器出错或 panic 时也不会误报健康。 +- 配置测试覆盖默认值合并、显式 false、环境变量、非法路径/模式、重复端点及业务参数/通配路径冲突。`TestConfigurableBuiltinRoutesConsumer` 用真实生成项目验证:默认冲突明确报错;禁用或改名后项目可接管原路径。 +- 原生成项目回归继续覆盖 Go 错误目录继承、模板参数、404/405、中间件错误、raw body、multipart 上传、流式下载、初始化/监听失败清理、业务文件保留和生成漂移检查。 +- [本地真实进程报告](lifecycleEndpointsLocal.json) 最近一轮通过,耗时 4.33 秒,记录验收源码哈希;根目录 go run/go build、nethttp/native 的 SIGINT/SIGTERM、端口释放及上传下载请求均通过。二进制正常信号退出码为 0;go run 包装进程允许被信号终止,子服务必须退出并释放端口。 +- 源码示例 `examples/managedService` 编译、race 测试与 `api generate --check` 通过;示例保留相对 replace 以运行本地源码。远程验收会重新生成无 replace 的项目。 + +## 验证过程中修正的问题 + +真实进程验收发现 Go 1.27 的 signal.NotifyContext 使用可匹配 context.Canceled 的信号原因;精确相等判断会把正常信号误报为故障。生命周期现在使用 errors.Is 识别正常取消,同时 Controller 单独保留项目显式报告的非空错误。修正后两种网络层的 SIGINT/SIGTERM 退出验证通过。 + +一次全量测试中,S3 协议夹具的故意损坏请求触发 SDK 随机重试,耗尽共享测试期限。仅将测试自建客户端设置为一次尝试,生产客户端配置保持不变,并单独检查 Open 创建的客户端可访问夹具;连续三次 race 回归通过。此调整不改变存储运行时行为。 + +## 升级与边界 + +v0.12 项目的 svc/config 是开发者文件,重新生成仍会保留。升级需按 [PROJECT.md](../PROJECT.md#从-v012-生成项目升级) 在 svc 增加 Lifecycle、RequestShutdown 和清理衔接;main/Handler 由生成器自动更新。新项目直接获得这些扩展。 + +请求排空使用已有 shutdownTimeout;忽略 context 的业务 goroutine 无法被框架强制结束,项目 Close 必须停止并等待自建监控任务。内置接口继续经过全局中间件,访问规则由项目设置。 + +没有修改 eTax/ePay,也未连接业务服务器、真实数据库或云存储。所有服务与子进程均在本地临时环境验收并回收;本轮不是压力测试或业务迁移验收。 + +远程安装验收在不可变标签发布后执行,报告单独提交,不改写标签。 diff --git a/performance/lifecycleEndpointsLocal.json b/performance/lifecycleEndpointsLocal.json new file mode 100644 index 0000000..2574b18 --- /dev/null +++ b/performance/lifecycleEndpointsLocal.json @@ -0,0 +1,54 @@ +{ + "passed": true, + "mode": "localSource", + "durationSeconds": 4.33, + "checks": [ + "rootGoRun", + "rootGoBuild", + "stableRegeneration", + "checkMainOwnership", + "contractChangePreview", + "logicSignatureCheckPersists", + "businessSourcePreserved", + "fixedConsumerBuild", + "nethttpSIGINT", + "nethttpSIGTERM", + "nativeSIGINT", + "nativeSIGTERM", + "listenerReleased", + "temporaryProcessesRemoved", + "nethttpGeneratedTransfers", + "nativeGeneratedTransfers", + "malformedMultipart400", + "goOwnedErrors", + "default404And405" + ], + "sourceHashes": { + "cmd/go-omni/apigen/generate.go": "c1808f5aff5c0475859f808e52ae29d7ac4234ecf2f784073c93bfee5bb7a2b7", + "cmd/go-omni/apigen/templates.go": "ec73080836763bbe60721351295df8471ed93691c62766172238ea1df390215e", + "cmd/go-omni/apigen/startup.go": "ea6d671a2e580911cdb954b96356e401f0f98bc41fb9e7b0c30239222fce923f", + "cmd/go-omni/apigen/managed_test.go": "ff9ce6e9fa6cff63c8b52f5e0c43879f9b08b5750ab72b617d32bec114d955da", + "cmd/go-omni/apigen/contractChanges.go": "ea190447e4260e0b3f3d28554ae61eac73f289aab601a16c7521dabc59fd736f", + "cmd/go-omni/apigen/logicCheck.go": "9fa1aa8cfef3a7b54dc5afb9b4320ec4e86ea5b46fc03705505216921279fa04", + "cmd/go-omni/apigen/write.go": "e415bb1e2db4674c5de7c81681b26a71925ddadbd647903b72674555e54911f1", + "cmd/go-omni/api.go": "eac8ed809000a939e3dfa7e3c01af67a26c705306143654a6b5a8e8733f2ab13", + "config/config.go": "9933e6ac581d75907afa1285440b61b7d5729d0ac0ce761cd8255473fd7e6028", + "httpx/transfer.go": "c9fe4e98c0e599d367eaf19ebecf367db914edfca9ad3a65527496d1dea80a3d", + "httpx/multipart.go": "b942ede7a810b268cd43b395c3bddcfaaacad92812bbb60e653466b8d86bc875", + "scripts/verifyManagedProject.py": "b0e4b1aea4f44d61470c35c41f971e0882ae36b0eb5df2be190400d62fc60a18", + "lifecycle/controller.go": "1fb9393df1908afed93d87a1bf3ecea8d79d78e437a656b56bb7f1e5f6e51d31", + "health/health.go": "41f07f27a7fdcfa7973c2c554a22b73129ebc5d44e945e70e054e1cff24ab866", + "config/endpoints.go": "9f5123f962d52b71e5b73d5942316170875e895129a94e8b669ce782969ef27d", + "httpx/response.go": "2cf78b8f0eee8049ee612bb409a6eecc1d39eab9ab8f3b533ea21e3cac1611cb", + "app/app.go": "e5255ffdd36cba1c155037afc42f1c921b1f91bc79db75798a7feb0e9d51b1c0", + "app/transport.go": "88212d67d375e4c48c250a2e4c62fbff4e7873a67f91f2deb113a60634149f73", + "cmd/go-omni/scaffold/templates/api-close.go.tmpl": "3f22d272e92f3b64b62123b3d80ec1a6ce8834257d64752fd75facf12623902b", + "cmd/go-omni/scaffold/templates/api-options.go.tmpl": "11fe34ce45e8c98f7b5c9c0a2c512eb0b3abf86f95b4b576e083bcb6f5d0f824", + "cmd/go-omni/scaffold/templates/api-context.go.tmpl": "c68f553e2500a395d77aa20c7b962fa257c47ef71b3ad36324413821b36ca0ec", + "cmd/go-omni/apigen/testdata/managed/options.go": "30d48e4b5aab476c887ac53cc89b48b2b7e250c8cd25e55fecb49bb2a3589247", + "cmd/go-omni/apigen/testdata/managed/client.go": "db886c2d4dd27e67f7446265c20147335d4898d2d2091518e67403806ccd9b69", + "cmd/go-omni/apigen/testdata/managed/shutdown_test.go": "79fbf8dd4508b8217af888a31aaf2f2a01c1fa3869b2e80420605af697c66319", + "cmd/go-omni/apigen/testdata/managed/consumer_test.go": "9e103c9de7a1a906903bc09787e1b38905dc5bb4d891c88938028da5dee98f6f", + "cmd/go-omni/apigen/testdata/managed/operations.go": "44cea63e6023458c5818c9fdae223631cd1721f0841e671457398bfde8bc48f1" + } +} diff --git a/response/encoder.go b/response/encoder.go index 414393e..569f387 100644 --- a/response/encoder.go +++ b/response/encoder.go @@ -16,6 +16,9 @@ type Result struct { Err error BusinessCode any Failed bool + // PreserveStatus keeps an explicit protocol status through catalog mapping + // and HTTP output (for example, readiness must remain 503 when unhealthy). + PreserveStatus bool } // IsError preserves failure semantics independently of the HTTP status. diff --git a/response/errors.go b/response/errors.go index 6aa1eb8..bff74d7 100644 --- a/response/errors.go +++ b/response/errors.go @@ -17,10 +17,14 @@ type errorEncoder struct { func (e *errorEncoder) ErrorRegistry() *apierrors.Registry { return e.registry } func (e *errorEncoder) Encode(r Result) (Payload, error) { + status := r.Status if r.Err != nil { err := e.registry.Resolve(r.Err) r.Status, r.Code, r.Message, r.BusinessCode, r.Failed = err.Status, err.Code, err.Message, err.BusinessCode, true } + if r.PreserveStatus { + r.Status = status + } return e.next.Encode(r) } diff --git a/scripts/verifyManagedProject.py b/scripts/verifyManagedProject.py index e9d6714..07828bd 100644 --- a/scripts/verifyManagedProject.py +++ b/scripts/verifyManagedProject.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 """Verify source-generated root entrypoints with real local processes only.""" +import argparse import hashlib import json import os @@ -85,6 +86,8 @@ def serve(args, project, env, transport, sig, strict_exit, contract=False, logs= else: raise RuntimeError('server readiness timed out') if contract: + status, body = request(base + '/health/ready') + assert status == 200 and json.loads(body) == {'code': 0, 'msg': 'success', 'data': {'status': 'ready'}} status, body = request(base + '/check?kind=fixed') assert status == 400 and json.loads(body) == {'code': 400002, 'msg': '用户名格式不正确', 'data': None} assert json.loads(request(base + '/missing')[1])['msg'] == '接口不存在' @@ -128,6 +131,9 @@ def serve(args, project, env, transport, sig, strict_exit, contract=False, logs= def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--report", type=Path, default=ROOT / "performance/managedProjectLocal.json") + args = parser.parse_args() started = time.monotonic() env = dict(os.environ, GOWORK='off', GOFLAGS='') # Prevent inherited project configuration from binding a real server or loading secrets. @@ -156,10 +162,11 @@ def main(): for transport in ('nethttp', 'native'): serve([str(work/'managedExample')], example, env, transport, signal.SIGTERM, True, contract=True, logs=work) sources = ['cmd/go-omni/apigen/generate.go', 'cmd/go-omni/apigen/templates.go', 'cmd/go-omni/apigen/startup.go', 'cmd/go-omni/apigen/managed_test.go', 'cmd/go-omni/apigen/contractChanges.go', 'cmd/go-omni/apigen/logicCheck.go', 'cmd/go-omni/apigen/write.go', 'cmd/go-omni/api.go', 'config/config.go', 'httpx/transfer.go', 'httpx/multipart.go', 'scripts/verifyManagedProject.py'] + sources += ['lifecycle/controller.go', 'health/health.go', 'config/endpoints.go', 'httpx/response.go', 'app/app.go', 'app/transport.go'] sources += [str(p.relative_to(ROOT)) for p in (ROOT/'cmd/go-omni/scaffold/templates').glob('api-*.tmpl')] sources += [str(p.relative_to(ROOT)) for p in (ROOT/'cmd/go-omni/apigen/testdata/managed').glob('*.go')] report = {'passed': True, 'mode': 'localSource', 'durationSeconds': round(time.monotonic()-started, 2), 'checks': ['rootGoRun', 'rootGoBuild', 'stableRegeneration', 'checkMainOwnership', 'contractChangePreview', 'logicSignatureCheckPersists', 'businessSourcePreserved', 'fixedConsumerBuild', 'nethttpSIGINT', 'nethttpSIGTERM', 'nativeSIGINT', 'nativeSIGTERM', 'listenerReleased', 'temporaryProcessesRemoved', 'nethttpGeneratedTransfers', 'nativeGeneratedTransfers', 'malformedMultipart400', 'goOwnedErrors', 'default404And405'], 'sourceHashes': {p: hashlib.sha256((ROOT/p).read_bytes()).hexdigest() for p in sources}} - (ROOT/'performance/managedProjectLocal.json').write_text(json.dumps(report, indent=2)+'\n') + args.report.write_text(json.dumps(report, indent=2)+'\n') print('PASS generated root project: go run/build, regeneration, nethttp/native signals; temporary files removed') diff --git a/scripts/verifyManagedRelease.py b/scripts/verifyManagedRelease.py index b05e2f1..589fa46 100644 --- a/scripts/verifyManagedRelease.py +++ b/scripts/verifyManagedRelease.py @@ -81,9 +81,9 @@ def main(): evidence = "".join(e.get("Output", "") for e in events) sums = re.findall(r"checksum (h1:\S+); no local replace", evidence) assert len(sums) == len(tests) and all(s == dependency["Sum"] for s in sums), "remote consumer checksum mismatch" - checks += ["initializationAndListenFailureCleanup", "drainBeforeDependencyClose", "generatedConsumerRaceTests", "errorParamsAndInheritance", "manualHooksAndScalars"] + checks += ["initializationAndListenFailureCleanup", "drainBeforeDependencyClose", "generatedConsumerRaceTests", "errorParamsAndInheritance", "manualHooksAndScalars", "dependencyFatalShutdown", "fatalProcessExitCode", "initializationShutdownNotification", "configuredBuiltinEndpoints", "projectHealthEnvelope", "endpointConflictCleanup"] - sources = ["scripts/verifyManagedRelease.py", "scripts/verifyManagedProject.py", "cmd/go-omni/apigen/managed_test.go"] + sources = ["lifecycle/controller.go", "config/endpoints.go", "health/health.go", "httpx/response.go", "app/app.go", "app/transport.go", "scripts/verifyManagedRelease.py", "scripts/verifyManagedProject.py", "cmd/go-omni/apigen/managed_test.go"] sources += [str(p.relative_to(ROOT)) for p in (ROOT / "cmd/go-omni/apigen/testdata/managed").glob("*.go")] report = {"passed": True, "frameworkVersion": args.version, "frameworkSum": dependency["Sum"], "freshModuleCache": True, "noLocalReplace": True, diff --git a/storage/s3/s3_test.go b/storage/s3/s3_test.go index 6c8a84b..a896682 100644 --- a/storage/s3/s3_test.go +++ b/storage/s3/s3_test.go @@ -2,8 +2,10 @@ package s3 import ( "context" + "errors" "fmt" "git.yuntuo.hk/k2safe/go-omni/internal/storagefixture" + "git.yuntuo.hk/k2safe/go-omni/storage" "git.yuntuo.hk/k2safe/go-omni/storage/storagetest" "github.com/minio/minio-go/v7" "github.com/minio/minio-go/v7/pkg/credentials" @@ -24,7 +26,25 @@ func TestS3ProtocolAndR2Config(t *testing.T) { if err != nil { t.Fatal(err) } - storagetest.Run(t, s) + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + if _, err = s.Get(ctx, "open-config-missing"); !errors.Is(err, storage.ErrNotFound) { + t.Fatal("configured client did not reach protocol fixture", err) + } + // Malformed-body cases intentionally cause SDK errors. Disable retries on + // this test-owned client so randomized backoff cannot exhaust the shared + // contract deadline. Open above still checks the production client config. + client, err := minio.New(strings.TrimPrefix(server.URL, "https://"), &minio.Options{ + Creds: creds, Region: "us-east-1", Secure: true, Transport: server.Client().Transport, MaxRetries: 1, + }) + if err != nil { + t.Fatal(err) + } + contractStore, err := New(client, "testbucket", 64) + if err != nil { + t.Fatal(err) + } + storagetest.Run(t, contractStore) cfg, err := R2Config(strings.Repeat("a", 32), "bucket", creds) if err != nil { t.Fatal(err)