Compare commits
4 Commits
v0.14.1-pr
...
v0.16.0-pr
| Author | SHA1 | Date | |
|---|---|---|---|
| 64e3bd6cfc | |||
| 1f2d8b6a6a | |||
| 16deead70e | |||
| adcb6f60b6 |
@@ -1,4 +1,4 @@
|
|||||||
# 通用业务开发指南(v0.14.0-preview)
|
# 通用业务开发指南(v0.16.0-preview)
|
||||||
|
|
||||||
从 [README](README.md) 创建服务后,业务主要维护 `.api`、配置、`svc.Init`、Logic、Repository 和项目中间件。本页说明通用接入能力;字段、错误码、权限、表结构和事务边界都由项目定义。
|
从 [README](README.md) 创建服务后,业务主要维护 `.api`、配置、`svc.Init`、Logic、Repository 和项目中间件。本页说明通用接入能力;字段、错误码、权限、表结构和事务边界都由项目定义。
|
||||||
|
|
||||||
@@ -21,12 +21,17 @@ delete /item
|
|||||||
| 契约 | Logic 返回 | 成功数据 |
|
| 契约 | Logic 返回 | 成功数据 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `returns (Item)` | `(*types.Item, error)` | 对象;nil 被视为实现错误 |
|
| `returns (Item)` | `(*types.Item, error)` | 对象;nil 被视为实现错误 |
|
||||||
| `returns ([]Item)` | `([]types.Item, error)` | 数组;nil 统一输出 `[]` |
|
| `returns ([]Item)` | `([]types.Item, error)` | 对象数组;nil 统一输出 `[]` |
|
||||||
|
| `returns ([]string)` | `([]string, error)` | 字符串数组 |
|
||||||
|
| `returns ([]int64)` / `([]bool)` | `([]int64, error)` / `([]bool, error)` | 数值 / 布尔数组 |
|
||||||
|
| `returns ([]any)` | `([]any, error)` | 动态 JSON 数组,元素允许对象、数组、标量和 null |
|
||||||
| `returns (*Item)` | `(*types.Item, error)` | 对象或 `null` |
|
| `returns (*Item)` | `(*types.Item, error)` | 对象或 `null` |
|
||||||
| `returns (null)` | `error` | HTTP 200 和空业务数据 |
|
| `returns (null)` | `error` | HTTP 200 和空业务数据 |
|
||||||
| 不写 returns | `error` | HTTP 204,无响应体 |
|
| 不写 returns | `error` | HTTP 204,无响应体 |
|
||||||
|
|
||||||
统一响应中的空数据是否显示,仍遵循项目模板的 `dataMode`;需要 `data:null` 就设置 `dataMode: always`。`@response raw` 输出业务数据本身,包括 `[]`、对象或 `null`。可用 `@status 201/202`;204 不能声明响应类型。根类型当前支持以上四种形式,嵌套字段继续支持既有 slice/map/pointer/scalar/any。
|
统一响应中的空数据是否显示,仍遵循项目模板的 `dataMode`;需要 `data:null` 就设置 `dataMode: always`。`@response raw` 输出业务数据本身,包括 `[]`、对象或 `null`。可用 `@status 201/202`;204 不能声明响应类型。嵌套字段继续支持既有 slice/map/pointer/scalar/any。
|
||||||
|
|
||||||
|
基础类型数组还支持 int/int8/int16/int32、uint/uint16/uint32/uint64、float32/float64。nil 根数组都输出 `[]`;`[]any` 在 OpenAPI 中是 `type: array, items: {}`,不限制元素形状,但元素必须可被标准 JSON 编码器序列化。`[]uint8`(Go 的 byte slice)会编码为 base64,故明确拒绝;数字数组可用 `[]int32`,二进制用下载接口。当前不支持根嵌套数组或根自定义 scalar 数组。
|
||||||
|
|
||||||
## 契约驱动的请求策略
|
## 契约驱动的请求策略
|
||||||
|
|
||||||
@@ -113,6 +118,23 @@ duration 使用 `250ms`、`3s`;slice/map 使用 JSON,如 `[1,3,5]`、`{"retr
|
|||||||
|
|
||||||
手写项目可使用 `config.LoadProject(path, defaults, config.EnvOptions{Prefix:"MY_APP_"})`,或分别调用 `ApplyDefaults/DecodeFile/OverlayEnv`。读取失败不修改输入默认值;诊断不会打印输入值。不要主动打印含密钥的整个配置,或把底层解析错误展开到公开响应。Validate 不打开日志文件、数据库或存储连接;证书的可读性和握手信任在依赖初始化阶段验证。
|
手写项目可使用 `config.LoadProject(path, defaults, config.EnvOptions{Prefix:"MY_APP_"})`,或分别调用 `ApplyDefaults/DecodeFile/OverlayEnv`。读取失败不修改输入默认值;诊断不会打印输入值。不要主动打印含密钥的整个配置,或把底层解析错误展开到公开响应。Validate 不打开日志文件、数据库或存储连接;证书的可读性和握手信任在依赖初始化阶段验证。
|
||||||
|
|
||||||
|
### 一次覆盖完整配置与 HTTP/日志短名称
|
||||||
|
|
||||||
|
```go
|
||||||
|
cfg, err = config.OverlayEnv(cfg, config.EnvOptions{
|
||||||
|
Prefix: "MY_APP_",
|
||||||
|
Empty: "reject", // allow / ignore / reject,项目自行选择
|
||||||
|
Aliases: config.ServerEnvAliases("MY_APP_", "server"),
|
||||||
|
// Lookup 可注入测试或项目配置源;默认 os.LookupEnv。
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
不要再紧接着调用 `ApplyEnv(cfg.Server, ...)`。上面的调用同时处理完整配置和 HTTP_ADDR、SERVICE、LOG_LEVEL 等框架短名称。例如 `MY_APP_SERVER_ADDRESS` 与 `MY_APP_HTTP_ADDR` 指向同一字段,**完整名称优先**;未设置时才读取短名称。完整名称显式设空仍优先:allow 交给该字段解析(string 可清空;数字等仍要求合法值),ignore 保留文件/默认值,reject 报错。不会因 ignore 再退回别名。字段 `env` 标签的 empty 规则优先于全局规则。
|
||||||
|
|
||||||
|
自定义别名用 `EnvOptions.Aliases`,键是完整环境名、值是按优先级排列的别名数组。两种名称都使用同一 Lookup、空值策略和类型转换;布尔 false、数值 0 与未设置不同。集合始终使用 JSON,包括 `MY_APP_TRUSTED_PROXIES='["127.0.0.1"]'`。
|
||||||
|
|
||||||
|
新项目模板一次调用 OverlayEnv,默认 `Empty: "ignore"`。旧 `ApplyEnv` 保留原有忽略空值、修剪空白及 TRUSTED_PROXIES 逗号列表行为。已有项目的 `internal/config` 属于开发者文件,升级不覆盖它;按上例合并原来的两次覆盖调用后,保留项目自己的 Validate。日志输出的显式完整名称也优先于短名称。
|
||||||
|
|
||||||
## 依赖、日志与中间件
|
## 依赖、日志与中间件
|
||||||
|
|
||||||
`internal/svc/runtimeGen.go` 持续由生成器维护。项目的 ServiceContext 嵌入 `*lifecycle.Runtime`,只在 `Init(ctx)` 中装配额外依赖:
|
`internal/svc/runtimeGen.go` 持续由生成器维护。项目的 ServiceContext 嵌入 `*lifecycle.Runtime`,只在 `Init(ctx)` 中装配额外依赖:
|
||||||
@@ -128,7 +150,7 @@ func (c *ServiceContext) Init(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
每个资源初始化成功就登记清理;后续初始化失败自动逆序回滚。正常退出先排空 HTTP 请求,再逆序关闭依赖,最后关闭日志。关闭幂等,单个清理函数 panic/返回错误不会跳过其他清理;初始化 panic 清理后继续传播。项目清理函数仍须自行停止、等待后台任务并保证能返回,框架不能强制杀死任意 goroutine。
|
每个资源初始化成功就登记清理;后续初始化失败自动逆序回滚。正常退出先排空 HTTP 请求,再执行后台组件的 Stop/Drain 阶段,确认收尾后逆序关闭依赖,最后关闭日志。关闭幂等,单个清理函数 panic/返回错误不会跳过其他清理;初始化 panic 清理后继续传播。后台组件按 [分阶段关闭](lifecycle/README.md#后台组件分阶段关闭) 登记;无法确认收尾完成时保留共享依赖并异常退出,不能把取消等同于任务已经结束。
|
||||||
|
|
||||||
中间件用业务所需的小接口接收依赖,避免反向导入 svc:
|
中间件用业务所需的小接口接收依赖,避免反向导入 svc:
|
||||||
|
|
||||||
@@ -185,21 +207,9 @@ go-omni api generate --check
|
|||||||
|
|
||||||
格式化保留注释、token 和多行字面量,规范四空格缩进、空行、行首尾空白及相邻单行字段的类型/标签/行尾注释对齐;结果幂等。先校验完整契约,check 不写文件;导入的共享目录不被某个服务的格式化命令修改。DTO 按源 `.api` 输出 `dto<Source><摘要>Gen.go`,都属于 `types` 包,跨文件引用无需改包名。
|
格式化保留注释、token 和多行字面量,规范四空格缩进、空行、行首尾空白及相邻单行字段的类型/标签/行尾注释对齐;结果幂等。先校验完整契约,check 不写文件;导入的共享目录不被某个服务的格式化命令修改。DTO 按源 `.api` 输出 `dto<Source><摘要>Gen.go`,都属于 `types` 包,跨文件引用无需改包名。
|
||||||
|
|
||||||
Go Catalog 是错误的唯一来源时,显式导出文档元数据:
|
Go Catalog 是错误的唯一来源时,优先使用 workspace 的显式导出配置,不再维护导出/复制脚本。AppOptions 仍通过 `omni.WithErrors(apierrors.Catalog())` 注册同一个 Go 函数,运行期只注册一次。单个项目也可继续使用 `errors.ExportCatalog` 导出入口目录的 `api/omni.errors.json`;此文件和下面的共享 source 二选一。
|
||||||
|
|
||||||
```go
|
OpenAPI 自动把有收益的重复响应、schema 和示例移到 `components.responses/schemas/examples`,通过标准 `$ref` 引用。HTTP 200 同时含成功和业务失败时保留两种 schema、业务码、模板参数示例和 data:null;业务响应内容不会改变。消费 OpenAPI 的工具需要支持标准本地引用。
|
||||||
// 在项目自己的导出命令中;appErrors.Catalog 与 AppOptions 注册同一函数。
|
|
||||||
if err := omnierrors.ExportCatalog(os.Stdout, appErrors.Catalog()); err != nil {
|
|
||||||
log.Fatal(err)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
```sh
|
|
||||||
go run ./cmd/exportErrors > api/omni.errors.json
|
|
||||||
go-omni api generate
|
|
||||||
```
|
|
||||||
|
|
||||||
文件是派生产物,应纳入版本控制并由业务 CI 检查导出漂移。CLI 自动读取入口目录的 `omni.errors.json`,校验错误目录、合并状态/模板参数/具体示例到 OpenAPI;不会运行项目 Go,不创建另一份运行期注册器。带独立错误目录的路由继续使用自己的目录。没有路由级精确错误列表时,文档描述 App 目录的公共错误集合;特殊自定义 Encoder 的文档由项目显式扩展。
|
|
||||||
|
|
||||||
## 两个服务共享契约
|
## 两个服务共享契约
|
||||||
|
|
||||||
@@ -241,9 +251,57 @@ go-omni workspace check --file omni.workspace.json
|
|||||||
|
|
||||||
各服务保持独立 module;可显式填写各服务的 `api`(相对服务目录)和 `module`。所有服务预检通过后再写;每个服务独立提交文件事务,不承诺跨项目的崩溃原子性。check 汇总格式与生成漂移;业务编译/测试仍在各 module 执行。
|
各服务保持独立 module;可显式填写各服务的 `api`(相对服务目录)和 `module`。所有服务预检通过后再写;每个服务独立提交文件事务,不承诺跨项目的崩溃原子性。check 汇总格式与生成漂移;业务编译/测试仍在各 module 执行。
|
||||||
|
|
||||||
|
### 同一份 Go 错误目录供多个服务生成
|
||||||
|
|
||||||
|
在 workspace 清单显式声明提供目录的 Go 包和函数:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"errorCatalogs": [{
|
||||||
|
"dir": ".",
|
||||||
|
"package": "example.com/project/internal/apierrors",
|
||||||
|
"function": "Catalog",
|
||||||
|
"output": "contracts/common/errors.json"
|
||||||
|
}],
|
||||||
|
"services": [{"dir":"services/api"}, {"dir":"services/id"}]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`dir` 相对 workspace,必须包含 go.mod;函数签名为 `func Catalog() errors.Catalog`。允许 module 内的 internal 包。先准备 output 的父目录。生成器构建一个临时导出程序,使用 Go overlay、临时 go.mod/go.sum 和 `-mod=readonly`,不会往项目增加导出命令或修改 go.mod/go.sum。最多 16 个提供方,每个有 90 秒构建/执行总时限和 1 MiB stdout 限制。错误消息不回显程序输出。
|
||||||
|
|
||||||
|
只有显式运行 **workspace generate/check** 会执行清单指定的 Go 包及其依赖初始化;不要指向不信任的包。提供目录的函数应只构造目录,不连接数据库、启动服务、写 stdout 或修改项目。框架不扫描其他包,也不通过 shell 执行配置字符串。普通 api validate/export/generate 和 workspace format 只读取现有元数据。
|
||||||
|
|
||||||
|
各服务在自己的 `api/omni.imports.json` 引用共享 JSON:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"roots": {"common":{"path":"../../../contracts/common","version":"1.0.0"}},
|
||||||
|
"errors": {
|
||||||
|
"source": "@common/errors.json",
|
||||||
|
"include": ["USERNAME_INVALID", "INTERNAL_ERROR"],
|
||||||
|
"routes": {"GET /permissions":["INTERNAL_ERROR"]}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`include` 省略或 null 表示全部目录,`[]` 表示不列业务错误;routes 按 **大写方法 + 空格 + .api 原路径**(如 `GET /users/:id`)覆盖服务级列表。错误标识、路由拼错或重复标识会明确报错。路由已经通过 `.api @errors` 声明独立运行期目录时,不能再给它设置文档 routes 列表。
|
||||||
|
|
||||||
|
**这些列表只决定文档展示,不修改 App 错误目录、路由继承或运行期可返回的错误。** `.api` 不需要重复定义 Go 错误表。状态、业务码、提示和模板参数仍来自同一 Catalog;特殊自定义 Encoder 的文档由项目显式扩展。
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# 首次生成导出一次共享目录,所有服务预检通过才写入
|
||||||
|
# 共享 JSON 不复制到各服务;已有 api/omni.errors.json 请显式移除后改用 source。
|
||||||
|
go-omni workspace generate --file omni.workspace.json
|
||||||
|
go-omni workspace format --file omni.workspace.json
|
||||||
|
go-omni workspace generate --file omni.workspace.json
|
||||||
|
go-omni workspace check --file omni.workspace.json
|
||||||
|
```
|
||||||
|
|
||||||
|
共享 JSON 应纳入版本控制。check 在内存重新导出并比较 JSON,然后检查所有服务;缺失或过期时失败,不更新项目文件。Go 构建缓存仍可能更新;显式提供方的自有副作用不属于生成器可隔离范围。业务 CI 仍执行各 module 的编译与测试。目录函数本身有变化时重新 generate,受控共享根的固定 SHA256 如需改变仍由项目显式更新。
|
||||||
|
|
||||||
## v0.13 项目升级
|
## v0.13 项目升级
|
||||||
|
|
||||||
先使用与目标框架匹配的 CLI;CLI 会提示与 go.mod 不同的版本。当前生成模板至少需要 v0.14.0-preview(或显式引用当前源码)。
|
先使用与目标框架匹配的 CLI;CLI 会提示与 go.mod 不同的版本。当前生成模板至少需要 v0.16.0-preview(或显式引用当前源码)。
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
go-omni api upgrade --dir . --dry-run
|
go-omni api upgrade --dir . --dry-run
|
||||||
|
|||||||
10
PROJECT.md
10
PROJECT.md
@@ -1,6 +1,6 @@
|
|||||||
# 生成项目与文件维护边界
|
# 生成项目与文件维护边界
|
||||||
|
|
||||||
v0.14.1-preview 提供受管理的生成项目。安装同版远程 CLI,生成的项目直接引用该版本,无需本地 replace。根目录入口支持 `go run .` 和 `go build .`;框架本身仍然是可直接引用的库。安装和私有仓库访问见 [README](README.md#准备环境与安装)。
|
v0.16.0-preview 提供受管理的生成项目。安装同版远程 CLI,生成的项目直接引用该版本,无需本地 replace。根目录入口支持 `go run .` 和 `go build .`;框架本身仍然是可直接引用的库。安装和私有仓库访问见 [README](README.md#准备环境与安装)。
|
||||||
|
|
||||||
## 创建和日常开发
|
## 创建和日常开发
|
||||||
|
|
||||||
@@ -85,7 +85,7 @@ func (c *ServiceContext) AppOptions() []omni.Option {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
业务调用 `omnierrors.ByCode(apierrors.CodeUsernameInvalid)`,可接 `WithParams` / `WithCause`。无需在 `.api` 重复错误目录。未声明独立错误目录的路由继承 App 目录;独立目录不与 App 合并。静态 OpenAPI 不执行 Go;可从同一 Catalog 显式导出 `api/omni.errors.json`,详见 [元数据导出](DEVELOPMENT.md#字段文档格式化go-错误目录导出)。
|
业务调用 `omnierrors.ByCode(apierrors.CodeUsernameInvalid)`,可接 `WithParams` / `WithCause`。无需在 `.api` 重复错误目录。未声明独立错误目录的路由继承 App 目录;独立目录不与 App 合并。普通 `.api` 编译不执行 Go;可在 workspace 显式配置 Catalog 函数,统一导出共享文档元数据,也可沿用 `api/omni.errors.json`,详见 [元数据导出](DEVELOPMENT.md#字段文档格式化go-错误目录导出)。
|
||||||
|
|
||||||
`GlobalMiddleware()` 提供全局中间件;`Middlewares` 映射提供 `.api` 声明的具名中间件。全部显式调用,不依赖项目 init 注册,也不要求 svc 导入 handler。框架请求链按原有次序运行。
|
`GlobalMiddleware()` 提供全局中间件;`Middlewares` 映射提供 `.api` 声明的具名中间件。全部显式调用,不依赖项目 init 注册,也不要求 svc 导入 handler。框架请求链按原有次序运行。
|
||||||
|
|
||||||
@@ -93,7 +93,7 @@ func (c *ServiceContext) AppOptions() []omni.Option {
|
|||||||
|
|
||||||
最终配置校验包含自定义响应编码器与错误目录的组合。码类型/映射冲突、编码器已绑定另一份目录等问题在创建 App 前返回错误,进入入口的清理流程,不等到构造 App 时才 panic。
|
最终配置校验包含自定义响应编码器与错误目录的组合。码类型/映射冲突、编码器已绑定另一份目录等问题在创建 App 前返回错误,进入入口的清理流程,不等到构造 App 时才 panic。
|
||||||
|
|
||||||
日志在 AppOptions 前初始化,其配置应放在 Config 中。退出信号先触发 HTTP 排空;达到 shutdownTimeout 后按框架规则强制关闭连接。请求仍依赖的后台任务/客户端不能直接绑定信号 context 提前关闭,应持有独立生命周期,在 OnClose 中取消。业务必须响应请求 context 取消;忽略取消的 goroutine 无法保证在超时前结束。
|
日志在 AppOptions 前初始化,其配置应放在 Config 中。退出信号先触发 HTTP 排空;达到 shutdownTimeout 后按框架规则强制关闭连接。请求仍依赖的后台任务/客户端不能直接绑定信号 context 提前关闭,应持有独立生命周期,使用 WorkContext 并登记成对的 OnStop/OnDrain,详见 [分阶段关闭](lifecycle/README.md#后台组件分阶段关闭)。业务必须响应请求 context 取消;忽略取消的 goroutine 无法保证在超时前结束。
|
||||||
|
|
||||||
## 依赖主动申请退出
|
## 依赖主动申请退出
|
||||||
|
|
||||||
@@ -106,7 +106,7 @@ c.RequestShutdown(err)
|
|||||||
|
|
||||||
这只是通知,立即返回,不等待 HTTP 排空,也不直接关闭数据库。生成入口停止接收新请求,等待在途请求完成;超过 `shutdownTimeout` 后取消请求并关闭连接,再执行 svc.Close,最后关闭日志。非空故障原因保留在返回错误链中,进程退出码为 1;`RequestShutdown(nil)` 或正常 SIGINT/SIGTERM 在没有其他故障时退出码为 0。
|
这只是通知,立即返回,不等待 HTTP 排空,也不直接关闭数据库。生成入口停止接收新请求,等待在途请求完成;超过 `shutdownTimeout` 后取消请求并关闭连接,再执行 svc.Close,最后关闭日志。非空故障原因保留在返回错误链中,进程退出码为 1;`RequestShutdown(nil)` 或正常 SIGINT/SIGTERM 在没有其他故障时退出码为 0。
|
||||||
|
|
||||||
多个依赖可并发通知,关闭流程仅执行一次,首个非空故障原因保留。正常信号已开始排空时收到的故障也不会被清理动作覆盖。初始化期间的通知同样有效,不依赖 HTTP 监听已经启动。业务应在 Close/OnClose 中停止并等待自己启动的监控任务,再结束资源清理;关闭完成以后才报告的故障无法参与已经结束的进程结果。
|
多个依赖可并发通知,关闭流程仅执行一次,首个非空故障原因保留。正常信号已开始排空时收到的故障也不会被清理动作覆盖。初始化期间的通知同样有效,不依赖 HTTP 监听已经启动。后台监控任务应通过 OnStop/OnDrain 停止并等待,再进入资源清理;关闭完成以后才报告的故障无法参与已经结束的进程结果。
|
||||||
|
|
||||||
`Lifecycle *lifecycle.Controller` 是 svc 持有的通用控制器,初始化时创建,生成入口使用它的 Context。该 Context 表示服务正在停止,不代表数据库已经关闭。不能把它当作在途请求的 Context,也不要在它取消时立刻关闭请求仍依赖的资源。发号器失去租约后拒绝发号等业务安全规则仍由项目实现。
|
`Lifecycle *lifecycle.Controller` 是 svc 持有的通用控制器,初始化时创建,生成入口使用它的 Context。该 Context 表示服务正在停止,不代表数据库已经关闭。不能把它当作在途请求的 Context,也不要在它取消时立刻关闭请求仍依赖的资源。发号器失去租约后拒绝发号等业务安全规则仍由项目实现。
|
||||||
|
|
||||||
@@ -208,7 +208,7 @@ get /download (DownloadReq)
|
|||||||
|
|
||||||
## 已有服务接入步骤
|
## 已有服务接入步骤
|
||||||
|
|
||||||
每个独立服务生成一个项目,module 名称、上层目录和共享业务包由项目选择。接入时固定 CLI 与框架版本为 v0.14.1-preview:
|
每个独立服务生成一个项目,module 名称、上层目录和共享业务包由项目选择。接入时固定 CLI 与框架版本为 v0.16.0-preview:
|
||||||
|
|
||||||
1. 先保留旧服务,整理实际路由及现有响应、鉴权和超时要求。在新目录执行 `go-omni new --api /path/to/service.api --module example.com/service ./service`;不对旧入口强制覆盖。
|
1. 先保留旧服务,整理实际路由及现有响应、鉴权和超时要求。在新目录执行 `go-omni new --api /path/to/service.api --module example.com/service ./service`;不对旧入口强制覆盖。
|
||||||
2. 在 `.api` 中选择 `@app(defaultResponse: ...)`,显式保留原项目的字段、成功码、错误码和 `dataMode`。需要独立回执的路由单独声明 raw/produces。普通路由、404/405 和提前返回的中间件一起验收。
|
2. 在 `.api` 中选择 `@app(defaultResponse: ...)`,显式保留原项目的字段、成功码、错误码和 `dataMode`。需要独立回执的路由单独声明 raw/produces。普通路由、404/405 和提前返回的中间件一起验收。
|
||||||
|
|||||||
14
README.md
14
README.md
@@ -2,13 +2,17 @@
|
|||||||
|
|
||||||
GoOmni 是面向多个业务项目复用的 Go 后端框架:用 `.api` 定义接口、请求和返回类型,生成项目目录、HTTP 接入代码及 OpenAPI,业务代码负责 Logic 和 Repository。也可以在已有项目中直接使用 `omni.New()`,或单独引入日志、存储等模块。
|
GoOmni 是面向多个业务项目复用的 Go 后端框架:用 `.api` 定义接口、请求和返回类型,生成项目目录、HTTP 接入代码及 OpenAPI,业务代码负责 Logic 和 Repository。也可以在已有项目中直接使用 `omni.New()`,或单独引入日志、存储等模块。
|
||||||
|
|
||||||
**从这份 README 开始接入。** 当前版本 **`v0.14.1-preview`**,新生成项目采用根目录入口和 `config / svc / logic / handler`。可以固定版本开展业务接入;文件维护边界和已有服务迁移步骤见 [项目指南](PROJECT.md),版本变化与验收见 [发布说明](RELEASE.md)。
|
**从这份 README 开始接入。** 当前版本 **`v0.16.0-preview`**,新生成项目采用根目录入口和 `config / svc / logic / handler`。可以固定版本开展业务接入;文件维护边界和已有服务迁移步骤见 [项目指南](PROJECT.md),版本变化与验收见 [发布说明](RELEASE.md)。
|
||||||
|
|
||||||
Go **1.22+**。框架不依赖 Gin,默认使用标准库网络层,提供可切换的 native HTTP/1.1 和实验性 HTTP/2 适配器。
|
Go **1.22+**。框架不依赖 Gin,默认使用标准库网络层,提供可切换的 native HTTP/1.1 和实验性 HTTP/2 适配器。
|
||||||
|
|
||||||
响应字段、业务码、登录方式、权限规则和数据库表结构由接入项目决定。文中的 `code/msg/data`、`200/9999` 都是示例配置,可以替换。
|
响应字段、业务码、登录方式、权限规则和数据库表结构由接入项目决定。文中的 `code/msg/data`、`200/9999` 都是示例配置,可以替换。
|
||||||
|
|
||||||
新增通用开发能力见 [业务开发指南](DEVELOPMENT.md):数组/null、严格请求策略、声明式上传、完整配置环境覆盖、资源生命周期、共享契约和升级工具。MySQL 直接使用 [官方适配器](database/mysql/README.md)。
|
本版补齐 `returns([]string/[]int64/[]bool/[]any)`、OpenAPI 公共引用、workspace 一次导出共享 Go 错误目录,以及统一环境变量别名覆盖。详细配置与升级步骤见 [业务开发指南](DEVELOPMENT.md)。
|
||||||
|
|
||||||
|
已有通用开发能力见 [业务开发指南](DEVELOPMENT.md):数组/null、严格请求策略、声明式上传、完整配置环境覆盖、资源生命周期、共享契约和升级工具。MySQL 直接使用 [官方适配器](database/mysql/README.md)。
|
||||||
|
|
||||||
|
后台组件可在 svc 登记 [分阶段关闭](lifecycle/README.md#后台组件分阶段关闭),先停止接收工作并等待收尾,再关闭共享资源;无需绑定队列或 ORM。
|
||||||
|
|
||||||
使用 GORM 的项目可以选择 [gormx 适配器](database/gormx/README.md),复用数据库初始化和连接池,并将 SQL 日志接入 logx。
|
使用 GORM 的项目可以选择 [gormx 适配器](database/gormx/README.md),复用数据库初始化和连接池,并将 SQL 日志接入 logx。
|
||||||
|
|
||||||
@@ -32,7 +36,7 @@ module 地址是 `git.yuntuo.hk/k2safe/go-omni`。先确保本机 Go 和 Git 可
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
export GOPRIVATE="$(go env GOPRIVATE),git.yuntuo.hk"
|
export GOPRIVATE="$(go env GOPRIVATE),git.yuntuo.hk"
|
||||||
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.14.1-preview
|
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.16.0-preview
|
||||||
export PATH="$(go env GOPATH)/bin:$PATH"
|
export PATH="$(go env GOPATH)/bin:$PATH"
|
||||||
go-omni --help
|
go-omni --help
|
||||||
```
|
```
|
||||||
@@ -188,7 +192,7 @@ go-omni new --api catalog.api \
|
|||||||
在业务项目的 **go.mod 所在目录**执行:
|
在业务项目的 **go.mod 所在目录**执行:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
go get git.yuntuo.hk/k2safe/go-omni@v0.14.1-preview
|
go get git.yuntuo.hk/k2safe/go-omni@v0.16.0-preview
|
||||||
```
|
```
|
||||||
|
|
||||||
已有项目可直接复用现有目录、Service、Repository 和数据库连接,先选择一个接口接入。也可以只用 `logx`、`storage` 等包,暂时保留原来的 HTTP 框架。希望入口和 HTTP 接入都交给生成器的项目,按 [已有服务接入步骤](PROJECT.md#已有服务接入步骤) 在新目录生成后迁入业务代码。
|
已有项目可直接复用现有目录、Service、Repository 和数据库连接,先选择一个接口接入。也可以只用 `logx`、`storage` 等包,暂时保留原来的 HTTP 框架。希望入口和 HTTP 接入都交给生成器的项目,按 [已有服务接入步骤](PROJECT.md#已有服务接入步骤) 在新目录生成后迁入业务代码。
|
||||||
@@ -458,7 +462,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#契约变更提示)。
|
`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.14.1-preview`。v0.14.0 项目直接升级依赖与 CLI,GORM 按需导入;v0.13 及更早项目按 [升级预览](DEVELOPMENT.md#v013-项目升级) 处理,不直接覆盖手写入口或业务文件。升级后执行生成检查与业务回归。版本变化、迁移边界和验收记录见 [发布说明](RELEASE.md)。
|
CLI 与业务 module 固定使用 `v0.16.0-preview`。v0.14.x 项目升级依赖与 CLI 后重新生成,框架维护的 main/runtimeGen 自动更新;v0.13 及更早项目按 [升级预览](DEVELOPMENT.md#v013-项目升级) 处理,不直接覆盖手写入口或业务文件。升级后执行生成检查与业务回归。版本变化、迁移边界和验收记录见 [发布说明](RELEASE.md)。
|
||||||
|
|
||||||
## 按需深入
|
## 按需深入
|
||||||
|
|
||||||
|
|||||||
51
RELEASE.md
51
RELEASE.md
@@ -1,4 +1,51 @@
|
|||||||
# v0.14.1-preview
|
# v0.16.0-preview
|
||||||
|
|
||||||
|
补齐通用契约与生成体验,业务响应协议继续由项目定义:
|
||||||
|
|
||||||
|
- `returns([]string/[]int64/[]bool/[]any)` 及其他支持的基础类型数组,贯通 Logic、Handler、OpenAPI 和生成检查。nil 根数组输出 `[]`;DTO 数组、nullable、null、raw 和 204 保持原语义。字节数组明确拒绝 base64/数组歧义。
|
||||||
|
- OpenAPI 以标准 components / `$ref` 共享重复响应、schema、示例;保留同一 HTTP 200 下的成功与失败、模板参数、数字业务码和 data:null。
|
||||||
|
- workspace 显式配置 Go Catalog 提供方,一次导出共享 JSON,多服务通过受控根引用。支持服务/路由的文档错误列表,运行期 App 目录继承不变。check 核对导出漂移并保持项目文件不变。
|
||||||
|
- EnvOptions.Aliases + ServerEnvAliases 统一完整配置与框架短名称的 Lookup、类型转换、空值规则。完整名称优先,显式空值不等于未设置;旧 ApplyEnv 的空值/逗号列表行为保留。
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go get git.yuntuo.hk/k2safe/go-omni@v0.16.0-preview
|
||||||
|
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.16.0-preview
|
||||||
|
go-omni api generate
|
||||||
|
go-omni api generate --check
|
||||||
|
go test ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
旧项目 config/Logic/svc 继续由开发者维护,不会被重新生成覆盖。已有环境配置需按 [一次覆盖完整配置](DEVELOPMENT.md#一次覆盖完整配置与-http日志短名称) 合并 OverlayEnv 与 ApplyEnv;共享错误目录接入见 [workspace 配置](DEVELOPMENT.md#同一份-go-错误目录供多个服务生成)。已有手写权限 Handler 切换自动生成前,先迁出实现并显式移除 Manual 文件,生成器不会静默删除。
|
||||||
|
|
||||||
|
全量 race(含实际生成消费者)、vet、build、根入口/退出/再生成验证通过。eTax 只读契约验收:API 文档 20,751,149 → 847,171 字节,ID 文档 1,008,210 → 392,481 字节;70 个操作展开引用后与原文档完全一致。详见 [验收记录](reports/contractFollowupAcceptance.md)。这不代表已完成业务项目迁移。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# v0.15.0-preview(历史版本)
|
||||||
|
|
||||||
|
增加通用后台分阶段关闭:生成入口先完成 HTTP 排空,再运行全部 OnStop、全部 OnDrain,确认组件收尾后逆序释放 OnClose 资源,最后关闭日志。适用于任意后台组件;不引入 Orbit,不改变业务事务或任务一致性策略。
|
||||||
|
|
||||||
|
- svc 登记成对、同名的 OnStop/OnDrain,Context 用于早期退出通知,WorkContext 供已接受工作在收尾期间使用。同一阶段并发执行,存在内部先后依赖的组件组合登记。
|
||||||
|
- server.lifecycle 配置 stopTimeout、drainTimeout、cancelTimeout。阶段超时取消工作并给未返回钩子收尾窗口;错误与阶段/组件名称可通过错误链追踪。
|
||||||
|
- 不能确认后台已经结束时,返回 ErrResourcesRetained,保留共享资源和借用日志,生成进程非零退出。未返回的 Stop 不会与自己的 Drain 重叠;错误、panic 不阻止其他组件尝试收尾。
|
||||||
|
- task.Runner 增加 StopAccepting/Wait,原 Shutdown/Close 继续可用。核心 lifecycle 不依赖 task 或其他适配器。
|
||||||
|
- 初始化和监听失败复用同一流程;重复关闭只执行一次。生成器继续保留配置、svc 和业务代码。
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go get git.yuntuo.hk/k2safe/go-omni@v0.15.0-preview
|
||||||
|
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.15.0-preview
|
||||||
|
go-omni api generate
|
||||||
|
go-omni api generate --check
|
||||||
|
go test ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
v0.14.x 无需重写 svc/config;生成器更新 main/runtimeGen,未配置时使用默认时限。也可 api upgrade --dry-run/--apply 更新依赖与生成文件,保留已有项目扩展。需要阶段保证的旧后台 OnClose 登记,应显式迁到 Stop/Drain;v0.13 及更早版本仍按原迁移指南处理。
|
||||||
|
|
||||||
|
全量 race(含实际生成项目)、vet、build 和隔离 MySQL/GORM 验收已通过。发布后的空缓存远程生成项目与真实 MySQL/GORM 验收也通过,CLI/框架同版且无本地 replace;标签固定在 `16deead70e860052c5b629511207578c03f9b2cb`。详见 [生命周期接入](lifecycle/README.md#后台组件分阶段关闭) 与 [验收记录](reports/phasedLifecycleAcceptance.md)。Go 无法强制结束任意 goroutine;OnClose 仍须能返回,HTTP 处理器仍须响应取消。此版不会将未知的第三方组件自动改造成优雅关闭组件。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# v0.14.1-preview(历史版本)
|
||||||
|
|
||||||
增加可选 `database/gormx`:GORM 复用 go-omni 已初始化的标准池、TLS、限额、健康检查和关闭流程,业务继续使用原生 GORM 模型与事务。核心 App/数据库包的编译依赖链不引入 GORM。
|
增加可选 `database/gormx`:GORM 复用 go-omni 已初始化的标准池、TLS、限额、健康检查和关闭流程,业务继续使用原生 GORM 模型与事务。核心 App/数据库包的编译依赖链不引入 GORM。
|
||||||
|
|
||||||
@@ -11,7 +58,7 @@ go get git.yuntuo.hk/k2safe/go-omni@v0.14.1-preview
|
|||||||
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.14.1-preview
|
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.14.1-preview
|
||||||
```
|
```
|
||||||
|
|
||||||
全量 race(含生成消费者)、vet、build,以及隔离 MySQL 的事务/取消/日志/生成项目接入通过,详见 [GORM 验收](reports/gormAcceptance.md)。
|
全量 race(含生成消费者)、vet、build,以及隔离 MySQL 的事务/取消/日志/生成项目接入通过。发布后的空缓存远程 MySQL/GORM 和生成项目验收均通过,无本地 replace,CLI 与框架使用同版。标签固定在 `b8ab6b35219d53d6de392c920fe7ecbe9a76dade`,详见 [GORM 验收](reports/gormAcceptance.md)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# go-omni CLI
|
# go-omni CLI
|
||||||
|
|
||||||
|
v0.16 增加基础类型/动态 JSON 数组返回、OpenAPI 公共引用和 workspace Go Catalog 统一导出。见 [接入与筛选规则](../../DEVELOPMENT.md#同一份-go-错误目录供多个服务生成)。
|
||||||
|
|
||||||
v0.14 新增 `api format [--check]`、`api upgrade [--dry-run|--preview <空目录>|--apply]`,以及 `workspace format|generate|check --file omni.workspace.json`。详见 [通用开发指南](../../DEVELOPMENT.md)。
|
v0.14 新增 `api format [--check]`、`api upgrade [--dry-run|--preview <空目录>|--apply]`,以及 `workspace format|generate|check --file omni.workspace.json`。详见 [通用开发指南](../../DEVELOPMENT.md)。
|
||||||
|
|
||||||
新项目采用根目录 `main.go` 与 `internal/config、svc、logic、handler、types`。完整职责、启动扩展、流式接口和生成文件边界见 [项目指南](../../PROJECT.md)。
|
新项目采用根目录 `main.go` 与 `internal/config、svc、logic、handler、types`。完整职责、启动扩展、流式接口和生成文件边界见 [项目指南](../../PROJECT.md)。
|
||||||
@@ -8,7 +10,7 @@ v0.14 新增 `api format [--check]`、`api upgrade [--dry-run|--preview <空目
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
# 仓库读取权限与 PATH 配置见根目录 README
|
# 仓库读取权限与 PATH 配置见根目录 README
|
||||||
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.14.1-preview
|
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.16.0-preview
|
||||||
go-omni new --module example.com/hello ./hello
|
go-omni new --module example.com/hello ./hello
|
||||||
|
|
||||||
# 从自己的契约生成;service.api 及其相对 import 文件需要在本地存在
|
# 从自己的契约生成;service.api 及其相对 import 文件需要在本地存在
|
||||||
@@ -43,14 +45,14 @@ main、Gen.go、OpenAPI 和 manifest 由生成器维护;业务文件按字节
|
|||||||
|
|
||||||
## 版本与迁移
|
## 版本与迁移
|
||||||
|
|
||||||
CLI 与框架依赖固定使用 v0.14.1-preview。远程安装的 CLI 默认把自身版本写入 go.mod,不添加本地 replace;`--framework-version` 可显式指定已发布的兼容版本。仅在开发框架源码时使用本地替换:
|
CLI 与框架依赖固定使用 v0.16.0-preview。远程安装的 CLI 默认把自身版本写入 go.mod,不添加本地 replace;`--framework-version` 可显式指定已发布的兼容版本。仅在开发框架源码时使用本地替换:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
# 在 go-omni 源码仓库中执行
|
# 在 go-omni 源码仓库中执行
|
||||||
go run ./cmd/go-omni new --module example.com/hello --framework-dir . ../hello
|
go run ./cmd/go-omni new --module example.com/hello --framework-dir . ../hello
|
||||||
```
|
```
|
||||||
|
|
||||||
模板最低需要 v0.14.0-preview 的通用 Runtime、配置和绑定能力,继续使用 manifest v2;GORM 可选适配器从 v0.14.1-preview 提供。已有项目使用 [定点升级与预览](../../DEVELOPMENT.md#v013-项目升级):原始模板可自动替换,含业务修改的基础文件按预览合并;通用清理由 Runtime 负责。独立工具命令不会被删除。受管理项目通过 `.api` 模块导入增加业务,不使用旧 `add` 命令的手动路由装配。
|
模板最低需要 v0.16.0-preview 的统一环境变量别名能力,继续使用 manifest v2;GORM 可选适配器从 v0.14.1-preview 提供。已有项目使用 [定点升级与预览](../../DEVELOPMENT.md#v013-项目升级):原始模板可自动替换,含业务修改的基础文件按预览合并;通用清理由 Runtime 负责。独立工具命令不会被删除。受管理项目通过 `.api` 模块导入增加业务,不使用旧 `add` 命令的手动路由装配。
|
||||||
|
|
||||||
## 数据库迁移命令
|
## 数据库迁移命令
|
||||||
|
|
||||||
|
|||||||
104
cmd/go-omni/apigen/arrayResponse_test.go
Normal file
104
cmd/go-omni/apigen/arrayResponse_test.go
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
package apigen
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPrimitiveArrayContracts(t *testing.T) {
|
||||||
|
for _, name := range []string{"string", "bool", "int", "int8", "int16", "int32", "int64", "uint", "uint16", "uint32", "uint64", "float32", "float64", "any"} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
spec, err := parseText(t, fmt.Sprintf("service arrays{\n@handler list\nget /list returns ([]%s)\n}", name))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
c, err := Compile(spec)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
schema := operation(t, document(t, c), "/list", "get")["responses"].(map[string]any)["200"].(map[string]any)["content"].(map[string]any)["application/json"].(map[string]any)["schema"].(map[string]any)
|
||||||
|
data := schema["properties"].(map[string]any)["data"].(map[string]any)
|
||||||
|
if data["type"] != "array" || data["items"] == nil {
|
||||||
|
t.Fatal(data)
|
||||||
|
}
|
||||||
|
if name == "any" && len(data["items"].(map[string]any)) != 0 {
|
||||||
|
t.Fatal("dynamic JSON was constrained")
|
||||||
|
}
|
||||||
|
if responseGoType("[]"+name) != "[]"+name || responseNeedsTypes("[]"+name) {
|
||||||
|
t.Fatal("wrong Go signature/import")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for _, name := range []string{"uint8", "File", "Unknown", "[]string"} {
|
||||||
|
spec, err := parseText(t, fmt.Sprintf("service arrays{\n@handler list\nget /list returns ([]%s)\n}", name))
|
||||||
|
if err == nil {
|
||||||
|
_, err = Compile(spec)
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("unsupported root accepted", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrimitiveArrayConsumer(t *testing.T) {
|
||||||
|
if os.Getenv("GO_OMNI_SCAFFOLD_INTEGRATION") != "1" {
|
||||||
|
t.Skip("set GO_OMNI_SCAFFOLD_INTEGRATION=1")
|
||||||
|
}
|
||||||
|
dir := filepath.Join(t.TempDir(), "arrays")
|
||||||
|
o := Options{Dir: dir, API: filepath.Join(frameworkRoot(t), "examples/contracts/arrays.api"), Module: "example.com/arrays", FrameworkDir: frameworkRoot(t)}
|
||||||
|
if err := generateManagedTestProject(t, o); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, name := range []string{"strings", "integers", "dynamic", "empty", "raw"} {
|
||||||
|
writeFile(t, filepath.Join(dir, "internal/logic/arrays/"+name+".go"), "package arrays\n")
|
||||||
|
}
|
||||||
|
writeFile(t, filepath.Join(dir, "internal/logic/arrays/values.go"), readFile(t, "testdata/arrays/values.go"))
|
||||||
|
writeFile(t, filepath.Join(dir, "consumer_test.go"), readFile(t, "testdata/arrays/consumer_test.go"))
|
||||||
|
o.API = filepath.Join(dir, "api/arrays.api")
|
||||||
|
if err := generateManagedTestProject(t, o); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(readFile(t, filepath.Join(dir, "internal/logic/arrays/values.go")), "企业") {
|
||||||
|
t.Fatal("logic overwritten")
|
||||||
|
}
|
||||||
|
o.Check = true
|
||||||
|
if err := generateManagedTestProject(t, o); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
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 out, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("%v\n%s", err, out)
|
||||||
|
}
|
||||||
|
if os.Getenv("GO_OMNI_RELEASE_CLI") != "" {
|
||||||
|
cmd = exec.CommandContext(ctx, "go", "list", "-m", "-json", frameworkModule)
|
||||||
|
cmd.Dir = dir
|
||||||
|
cmd.Env = append(os.Environ(), "GOWORK=off")
|
||||||
|
raw, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err, string(raw))
|
||||||
|
}
|
||||||
|
var module struct {
|
||||||
|
Version, Sum string
|
||||||
|
Replace any
|
||||||
|
}
|
||||||
|
if err = json.Unmarshal(raw, &module); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if module.Version != os.Getenv("GO_OMNI_RELEASE_VERSION") || module.Replace != nil || module.Sum == "" {
|
||||||
|
t.Fatal("not a fixed remote consumer")
|
||||||
|
}
|
||||||
|
t.Logf("remote framework %s checksum %s; no local replace", module.Version, module.Sum)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -2,41 +2,73 @@ package apigen
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||||
"git.yuntuo.hk/k2safe/go-omni/response"
|
"git.yuntuo.hk/k2safe/go-omni/response"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
const catalogDocumentFile = "omni.errors.json"
|
const catalogDocumentFile = "omni.errors.json"
|
||||||
|
|
||||||
func readDocumentCatalog(root string, spec *Spec) error {
|
// DocumentErrors selects documentation only. Runtime App inheritance and
|
||||||
path := filepath.Join(root, catalogDocumentFile)
|
// route-local @errors declarations are never changed by this metadata.
|
||||||
stat, err := os.Lstat(path)
|
// A nil Include lists all errors; [] lists none. Routes override Include.
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
type DocumentErrors struct {
|
||||||
return nil
|
Source string `json:"source"`
|
||||||
|
Include []string `json:"include"`
|
||||||
|
Routes map[string][]string `json:"routes,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func readDocumentCatalog(root string, spec *Spec, overrides map[string][]byte) error {
|
||||||
|
name := catalogDocumentFile
|
||||||
|
path := filepath.Join(root, name)
|
||||||
|
// Shared metadata uses the same explicitly authorized roots as .api imports.
|
||||||
|
|
||||||
|
if selection := spec.DocumentErrors; selection != nil {
|
||||||
|
if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return errors.New("api: use either local omni.errors.json or shared errors.source, not both")
|
||||||
|
}
|
||||||
|
name = selection.Source
|
||||||
|
alias, sub, ok := strings.Cut(strings.TrimPrefix(name, "@"), "/")
|
||||||
|
declared, found := spec.SharedRoots[alias]
|
||||||
|
if !strings.HasPrefix(name, "@") || !ok || !found || sub == "" || filepath.Ext(sub) != ".json" {
|
||||||
|
return errors.New("api: errors.source must name a JSON file in a declared @root")
|
||||||
|
}
|
||||||
|
if err := safePath(declared.Path, sub); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
path = filepath.Join(declared.Path, filepath.FromSlash(sub))
|
||||||
}
|
}
|
||||||
if err != nil {
|
raw, ok := overrides[path]
|
||||||
return err
|
if !ok {
|
||||||
}
|
stat, err := os.Lstat(path)
|
||||||
if !stat.Mode().IsRegular() {
|
if errors.Is(err, os.ErrNotExist) && spec.DocumentErrors == nil {
|
||||||
return errors.New("api: error metadata must be a regular file")
|
return nil
|
||||||
}
|
}
|
||||||
file, err := os.Open(path)
|
if err != nil {
|
||||||
if err != nil {
|
return err
|
||||||
return err
|
}
|
||||||
}
|
if !stat.Mode().IsRegular() {
|
||||||
defer file.Close()
|
return errors.New("api: error metadata must be a regular file")
|
||||||
raw, err := io.ReadAll(io.LimitReader(file, (1<<20)+1))
|
}
|
||||||
if err != nil {
|
f, err := os.Open(path)
|
||||||
return err
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
raw, err = io.ReadAll(io.LimitReader(f, (1<<20)+1))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if len(raw) > 1<<20 {
|
if len(raw) > 1<<20 {
|
||||||
return errors.New("api: error metadata exceeds 1 MiB")
|
return errors.New("api: error metadata exceeds 1 MiB")
|
||||||
}
|
}
|
||||||
var catalog apierrors.Catalog
|
var catalog apierrors.Catalog
|
||||||
if err = apierrors.DecodeCatalogJSON(raw, &catalog); err != nil {
|
if err := apierrors.DecodeCatalogJSON(raw, &catalog); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
registry, err := apierrors.NewRegistry(catalog)
|
registry, err := apierrors.NewRegistry(catalog)
|
||||||
@@ -45,10 +77,70 @@ func readDocumentCatalog(root string, spec *Spec) error {
|
|||||||
}
|
}
|
||||||
normalized := registry.Catalog()
|
normalized := registry.Catalog()
|
||||||
spec.DocumentCatalog = &normalized
|
spec.DocumentCatalog = &normalized
|
||||||
spec.Sources[catalogDocumentFile] = raw
|
spec.Sources[name] = raw
|
||||||
spec.SourcePaths[catalogDocumentFile] = path
|
spec.SourcePaths[name] = path
|
||||||
|
return validateDocumentSelection(spec)
|
||||||
|
}
|
||||||
|
|
||||||
|
func documentRouteCatalog(spec *Spec, route Route) apierrors.Catalog {
|
||||||
|
catalog := spec.DocumentCatalog.Clone()
|
||||||
|
if spec.DocumentErrors == nil {
|
||||||
|
return catalog
|
||||||
|
}
|
||||||
|
include := spec.DocumentErrors.Include
|
||||||
|
if selected, ok := spec.DocumentErrors.Routes[strings.ToUpper(route.Method)+" "+route.Path]; ok {
|
||||||
|
include = selected
|
||||||
|
}
|
||||||
|
if include != nil {
|
||||||
|
selected := map[string]apierrors.Definition{}
|
||||||
|
for _, key := range include {
|
||||||
|
selected[key] = catalog.Definitions[key]
|
||||||
|
}
|
||||||
|
catalog.Definitions = selected
|
||||||
|
}
|
||||||
|
return catalog
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateDocumentSelection(spec *Spec) error {
|
||||||
|
cfg := spec.DocumentErrors
|
||||||
|
if cfg == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
validate := func(keys []string) error {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, key := range keys {
|
||||||
|
if _, ok := spec.DocumentCatalog.Definitions[key]; !ok {
|
||||||
|
return fmt.Errorf("api: unknown documentation error %q", key)
|
||||||
|
}
|
||||||
|
if seen[key] {
|
||||||
|
return fmt.Errorf("api: duplicate documentation error %q", key)
|
||||||
|
}
|
||||||
|
seen[key] = true
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := validate(cfg.Include); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
routes := map[string]Route{}
|
||||||
|
for _, r := range spec.Routes {
|
||||||
|
routes[strings.ToUpper(r.Method)+" "+r.Path] = r
|
||||||
|
}
|
||||||
|
for key, keys := range cfg.Routes {
|
||||||
|
r, ok := routes[key]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("api: unknown documentation route %q", key)
|
||||||
|
}
|
||||||
|
if len(r.Errors) > 0 {
|
||||||
|
return fmt.Errorf("api: %s already declares route-local errors", key)
|
||||||
|
}
|
||||||
|
if err := validate(keys); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateDocumentCatalog(spec *Spec) error {
|
func validateDocumentCatalog(spec *Spec) error {
|
||||||
if spec.DocumentCatalog == nil {
|
if spec.DocumentCatalog == nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -61,7 +153,7 @@ func validateDocumentCatalog(spec *Spec) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err = policy.ValidateErrors(*spec.DocumentCatalog); err != nil {
|
if err = policy.ValidateErrors(documentRouteCatalog(spec, r)); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ func Compile(spec *Spec) (*Compiled, error) {
|
|||||||
resolving := map[string]bool{}
|
resolving := map[string]bool{}
|
||||||
var resolve func(string, int) (reflect.Type, error)
|
var resolve func(string, int) (reflect.Type, error)
|
||||||
var expression func(ast.Expr, int) (reflect.Type, error)
|
var expression func(ast.Expr, int) (reflect.Type, error)
|
||||||
primitives := map[string]reflect.Type{"File": reflect.TypeOf(contract.File{}), "any": reflect.TypeOf((*any)(nil)).Elem(), "string": reflect.TypeOf(""), "bool": reflect.TypeOf(false), "int": reflect.TypeOf(int(0)), "int8": reflect.TypeOf(int8(0)), "int16": reflect.TypeOf(int16(0)), "int32": reflect.TypeOf(int32(0)), "int64": reflect.TypeOf(int64(0)), "uint": reflect.TypeOf(uint(0)), "uint8": reflect.TypeOf(uint8(0)), "uint16": reflect.TypeOf(uint16(0)), "uint32": reflect.TypeOf(uint32(0)), "uint64": reflect.TypeOf(uint64(0)), "float32": reflect.TypeOf(float32(0)), "float64": reflect.TypeOf(float64(0))}
|
|
||||||
expression = func(e ast.Expr, depth int) (reflect.Type, error) {
|
expression = func(e ast.Expr, depth int) (reflect.Type, error) {
|
||||||
if depth > 32 {
|
if depth > 32 {
|
||||||
return nil, errors.New("type nesting exceeds 32")
|
return nil, errors.New("type nesting exceeds 32")
|
||||||
@@ -80,7 +80,7 @@ func Compile(spec *Spec) (*Compiled, error) {
|
|||||||
if t, ok := scalarTypes[e.Name]; ok {
|
if t, ok := scalarTypes[e.Name]; ok {
|
||||||
return t, nil
|
return t, nil
|
||||||
}
|
}
|
||||||
if t, ok := primitives[e.Name]; ok {
|
if t, ok := primitiveTypes[e.Name]; ok {
|
||||||
return t, nil
|
return t, nil
|
||||||
}
|
}
|
||||||
return resolve(e.Name, depth+1)
|
return resolve(e.Name, depth+1)
|
||||||
@@ -313,6 +313,7 @@ func Compile(spec *Spec) (*Compiled, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
decorateDocument(doc, spec, c.Bindings)
|
decorateDocument(doc, spec, c.Bindings)
|
||||||
|
compactDocument(doc)
|
||||||
c.Document, err = json.MarshalIndent(doc, "", " ")
|
c.Document, err = json.MarshalIndent(doc, "", " ")
|
||||||
return c, err
|
return c, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -213,7 +213,7 @@ func decorateDocument(doc map[string]any, spec *Spec, bindings map[string]*contr
|
|||||||
if len(r.Errors) == 0 && documentRegistry != nil {
|
if len(r.Errors) == 0 && documentRegistry != nil {
|
||||||
registry = documentRegistry
|
registry = documentRegistry
|
||||||
op["x-omni-error-catalog-scope"] = "app"
|
op["x-omni-error-catalog-scope"] = "app"
|
||||||
for key, definition := range registry.Catalog().Definitions {
|
for key, definition := range documentRouteCatalog(spec, r).Definitions {
|
||||||
grouped[definition.Status] = append(grouped[definition.Status], ErrorDefinition{Name: key, Code: key, Status: definition.Status, Message: definition.Message, BusinessCode: definition.Code, Params: definition.Params})
|
grouped[definition.Status] = append(grouped[definition.Status], ErrorDefinition{Name: key, Code: key, Status: definition.Status, Message: definition.Message, BusinessCode: definition.Code, Params: definition.Params})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
86
cmd/go-omni/apigen/documentRefs.go
Normal file
86
cmd/go-omni/apigen/documentRefs.go
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
package apigen
|
||||||
|
|
||||||
|
import "encoding/json"
|
||||||
|
|
||||||
|
// compactDocument only replaces standard OpenAPI Reference Object positions.
|
||||||
|
// Payloads (including examples containing a literal "$ref") are never walked.
|
||||||
|
// Mixed HTTP 200 responses retain both success and error alternatives.
|
||||||
|
func compactDocument(doc map[string]any) {
|
||||||
|
type slot struct {
|
||||||
|
parent map[string]any
|
||||||
|
key string
|
||||||
|
}
|
||||||
|
components := doc["components"].(map[string]any)
|
||||||
|
var responses, schemas, examples []slot
|
||||||
|
for _, path := range doc["paths"].(map[string]any) {
|
||||||
|
for _, value := range path.(map[string]any) {
|
||||||
|
op, ok := value.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rs, _ := op["responses"].(map[string]any)
|
||||||
|
for status := range rs {
|
||||||
|
responses = append(responses, slot{rs, status})
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
factor := func(kind string, slots []slot) {
|
||||||
|
counts := map[string]int{}
|
||||||
|
for _, s := range slots {
|
||||||
|
raw, _ := json.Marshal(s.parent[s.key])
|
||||||
|
counts[string(raw)]++
|
||||||
|
}
|
||||||
|
shared, _ := components[kind].(map[string]any)
|
||||||
|
if shared == nil {
|
||||||
|
shared = map[string]any{}
|
||||||
|
}
|
||||||
|
for _, s := range slots {
|
||||||
|
raw, _ := json.Marshal(s.parent[s.key])
|
||||||
|
count := counts[string(raw)]
|
||||||
|
if count < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := "Omni_" + digest(raw)
|
||||||
|
ref := map[string]any{"$ref": "#/components/" + kind + "/" + name}
|
||||||
|
refJSON, _ := json.Marshal(ref)
|
||||||
|
// Keep small objects inline if components would increase size.
|
||||||
|
if (count-1)*len(raw) <= count*len(refJSON)+len(name)+4 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
shared[name] = s.parent[s.key]
|
||||||
|
s.parent[s.key] = map[string]any{"$ref": "#/components/" + kind + "/" + name}
|
||||||
|
}
|
||||||
|
if len(shared) > 0 {
|
||||||
|
components[kind] = shared
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Share whole responses before counting inner objects. Otherwise a catalog
|
||||||
|
// copied across 100 routes can create hundreds of unnecessary components
|
||||||
|
// that only one shared response ultimately uses.
|
||||||
|
factor("responses", responses)
|
||||||
|
collectMedia := func(value any) {
|
||||||
|
resp := value.(map[string]any)
|
||||||
|
content, _ := resp["content"].(map[string]any)
|
||||||
|
for _, value := range content {
|
||||||
|
media := value.(map[string]any)
|
||||||
|
if media["schema"] != nil {
|
||||||
|
schemas = append(schemas, slot{media, "schema"})
|
||||||
|
}
|
||||||
|
es, _ := media["examples"].(map[string]any)
|
||||||
|
for name := range es {
|
||||||
|
examples = append(examples, slot{es, name})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, s := range responses {
|
||||||
|
collectMedia(s.parent[s.key])
|
||||||
|
}
|
||||||
|
sharedResponses, _ := components["responses"].(map[string]any)
|
||||||
|
for _, resp := range sharedResponses {
|
||||||
|
collectMedia(resp)
|
||||||
|
}
|
||||||
|
factor("examples", examples)
|
||||||
|
factor("schemas", schemas)
|
||||||
|
}
|
||||||
107
cmd/go-omni/apigen/documentRefs_test.go
Normal file
107
cmd/go-omni/apigen/documentRefs_test.go
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
package apigen
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSharedErrorDocumentReferences(t *testing.T) {
|
||||||
|
source := `@app(defaultResponse: Common)
|
||||||
|
response Common(codeType:int32;successCode:0;errorCode:9999;messageField:msg;dataMode:always)
|
||||||
|
service demo {
|
||||||
|
`
|
||||||
|
for i := 0; i < 16; i++ {
|
||||||
|
kind := "string"
|
||||||
|
if i%2 == 0 {
|
||||||
|
kind = "int64"
|
||||||
|
}
|
||||||
|
source += fmt.Sprintf("@handler list%d\nget /list%d returns ([]%s)\n", i, i, kind)
|
||||||
|
}
|
||||||
|
source += "}\n"
|
||||||
|
spec, err := parseText(t, source)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
catalog := apierrors.Catalog{DefaultStatus: 400, Definitions: map[string]apierrors.Definition{}}
|
||||||
|
for i := 0; i < 80; i++ {
|
||||||
|
status := 400
|
||||||
|
if i%2 == 0 {
|
||||||
|
status = 200
|
||||||
|
}
|
||||||
|
catalog.Definitions[fmt.Sprintf("ERROR_%d", i)] = apierrors.Definition{Status: status, Code: 400000 + i, Message: strings.Repeat("说明", 40) + "{label}不能超过{max}个字符", Params: []apierrors.Parameter{{Name: "label", Type: "string", Example: "企业名称"}, {Name: "max", Type: "int", Example: 100}}}
|
||||||
|
}
|
||||||
|
spec.DocumentCatalog = &catalog
|
||||||
|
compiled, err := Compile(spec)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var raw map[string]any
|
||||||
|
if err = json.Unmarshal(compiled.Document, &raw); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
components := raw["components"].(map[string]any)
|
||||||
|
for _, kind := range []string{"responses", "examples"} {
|
||||||
|
if len(components[kind].(map[string]any)) == 0 {
|
||||||
|
t.Fatal("missing shared", kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expanded := document(t, compiled)
|
||||||
|
for i := 0; i < 16; i++ {
|
||||||
|
responses := operation(t, expanded, fmt.Sprintf("/list%d", i), "get")["responses"].(map[string]any)
|
||||||
|
for _, status := range []string{"200", "400"} {
|
||||||
|
media := responses[status].(map[string]any)["content"].(map[string]any)["application/json"].(map[string]any)
|
||||||
|
if len(media["examples"].(map[string]any)) != 40 {
|
||||||
|
t.Fatal("examples lost")
|
||||||
|
}
|
||||||
|
if status == "200" {
|
||||||
|
choices := media["schema"].(map[string]any)["anyOf"].([]any)
|
||||||
|
if len(choices) != 2 || choices[0].(map[string]any)["properties"].(map[string]any)["data"].(map[string]any)["type"] != "array" {
|
||||||
|
t.Fatal("200 success shape lost")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, example := range media["examples"].(map[string]any) {
|
||||||
|
value := example.(map[string]any)["value"].(map[string]any)
|
||||||
|
if _, ok := value["data"]; !ok || value["data"] != nil || !strings.Contains(value["msg"].(string), "企业名称不能超过100个字符") {
|
||||||
|
t.Fatal(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expandedBytes, _ := json.MarshalIndent(expanded, "", " ")
|
||||||
|
if len(compiled.Document)*5 > len(expandedBytes) {
|
||||||
|
t.Fatalf("poor dedup: %d vs %d", len(compiled.Document), len(expandedBytes))
|
||||||
|
}
|
||||||
|
again, err := Compile(spec)
|
||||||
|
if err != nil || string(again.Document) != string(compiled.Document) {
|
||||||
|
t.Fatal("non-deterministic document", err)
|
||||||
|
}
|
||||||
|
before, _ := json.Marshal(raw)
|
||||||
|
compactDocument(raw)
|
||||||
|
data, _ := json.Marshal(raw)
|
||||||
|
if string(data) != string(before) {
|
||||||
|
t.Fatal("compaction not idempotent")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSharedSchemasAndLiteralExampleReference(t *testing.T) {
|
||||||
|
doc := map[string]any{"components": map[string]any{}, "paths": map[string]any{}}
|
||||||
|
paths := doc["paths"].(map[string]any)
|
||||||
|
for _, name := range []string{"first", "second", "third"} {
|
||||||
|
schema := map[string]any{"type": "object", "description": strings.Repeat("schema description ", 40)}
|
||||||
|
example := map[string]any{"summary": strings.Repeat("example ", 50), "value": map[string]any{"$ref": "literal business field"}}
|
||||||
|
paths["/"+name] = map[string]any{"get": map[string]any{"responses": map[string]any{"200": map[string]any{"description": name, "content": map[string]any{"application/json": map[string]any{"schema": schema, "examples": map[string]any{"same": example}}}}}}}
|
||||||
|
}
|
||||||
|
compactDocument(doc)
|
||||||
|
components := doc["components"].(map[string]any)
|
||||||
|
if len(components["schemas"].(map[string]any)) != 1 {
|
||||||
|
t.Fatal("schema not shared")
|
||||||
|
}
|
||||||
|
for _, example := range components["examples"].(map[string]any) {
|
||||||
|
if example.(map[string]any)["value"].(map[string]any)["$ref"] != "literal business field" {
|
||||||
|
t.Fatal("payload reference interpreted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,11 +19,12 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const frameworkModule = "git.yuntuo.hk/k2safe/go-omni"
|
const frameworkModule = "git.yuntuo.hk/k2safe/go-omni"
|
||||||
const minimumVersion = "v0.14.0-preview"
|
const minimumVersion = "v0.16.0-preview"
|
||||||
const generatedHeader = "// Code generated by go-omni api. DO NOT EDIT.\n"
|
const generatedHeader = "// Code generated by go-omni api. DO NOT EDIT.\n"
|
||||||
|
|
||||||
type Options struct {
|
type Options struct {
|
||||||
lockHeld bool
|
lockHeld bool
|
||||||
|
documentCatalogs map[string][]byte
|
||||||
Database, Storage string
|
Database, Storage string
|
||||||
API, Dir, Module, FrameworkDir, FrameworkVersion, LogOutput, LogFormat string
|
API, Dir, Module, FrameworkDir, FrameworkVersion, LogOutput, LogFormat string
|
||||||
Check bool
|
Check bool
|
||||||
@@ -48,7 +49,7 @@ func Generate(o Options) (Output, error) {
|
|||||||
if o.API == "" || o.Dir == "" {
|
if o.API == "" || o.Dir == "" {
|
||||||
return Output{}, errors.New("--api and --dir are required")
|
return Output{}, errors.New("--api and --dir are required")
|
||||||
}
|
}
|
||||||
spec, err := Parse(o.API)
|
spec, err := parseWithCatalogs(o.API, o.documentCatalogs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Output{}, err
|
return Output{}, err
|
||||||
}
|
}
|
||||||
@@ -145,13 +146,13 @@ func generate(o Options, c *Compiled, starter bool) (Output, error) {
|
|||||||
if !filepath.IsAbs(path) {
|
if !filepath.IsAbs(path) {
|
||||||
path = filepath.Join(directory, path)
|
path = filepath.Join(directory, path)
|
||||||
}
|
}
|
||||||
if _, err := os.Stat(filepath.Join(path, "lifecycle", "runtime.go")); err == nil {
|
if source, err := os.ReadFile(filepath.Join(path, "config", "env.go")); err == nil && strings.Contains(string(source), "func ServerEnvAliases(") {
|
||||||
localCurrent = true
|
localCurrent = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !localCurrent {
|
if !localCurrent {
|
||||||
return Output{}, fmt.Errorf("existing framework dependency predates the generated Runtime, request policy and structured MySQL support; use %s or a current local replacement", minimumVersion)
|
return Output{}, fmt.Errorf("existing framework dependency predates the generated configuration helpers; use %s or a current local replacement", minimumVersion)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if o.FrameworkDir != "" {
|
if o.FrameworkDir != "" {
|
||||||
@@ -454,7 +455,7 @@ func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
hasAutomatic = true
|
hasAutomatic = true
|
||||||
needsTypes = needsTypes || r.Request != "" || responseGoType(r.Response) != ""
|
needsTypes = needsTypes || r.Request != "" || responseNeedsTypes(r.Response)
|
||||||
needsBinder = needsBinder || !r.CustomBinding
|
needsBinder = needsBinder || !r.CustomBinding
|
||||||
}
|
}
|
||||||
text += fmt.Sprintf("%q;%q;", frameworkModule+"/httpx", o.Module+"/internal/svc")
|
text += fmt.Sprintf("%q;%q;", frameworkModule+"/httpx", o.Module+"/internal/svc")
|
||||||
@@ -570,7 +571,7 @@ func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
service := fmt.Sprintf("package %s\nimport(\"context\"; apierrors %q;", packageName, frameworkModule+"/errors")
|
service := fmt.Sprintf("package %s\nimport(\"context\"; apierrors %q;", packageName, frameworkModule+"/errors")
|
||||||
if r.Request != "" || responseGoType(r.Response) != "" {
|
if r.Request != "" || responseNeedsTypes(r.Response) {
|
||||||
service += fmt.Sprintf("%q;", o.Module+"/internal/types")
|
service += fmt.Sprintf("%q;", o.Module+"/internal/types")
|
||||||
}
|
}
|
||||||
if streamingBody || r.Produces != "" {
|
if streamingBody || r.Produces != "" {
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ func TestManagedGeneratedProject(t *testing.T) {
|
|||||||
files := map[string]string{
|
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",
|
"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",
|
"shutdown_test.go": "shutdown_test.go",
|
||||||
|
"phases_test.go": "phases_test.go", "internal/svc/background.go": "background.go",
|
||||||
}
|
}
|
||||||
for path, fixture := range files {
|
for path, fixture := range files {
|
||||||
writeFile(t, filepath.Join(dir, path), readFile(t, filepath.Join("testdata/managed", fixture)))
|
writeFile(t, filepath.Join(dir, path), readFile(t, filepath.Join("testdata/managed", fixture)))
|
||||||
@@ -79,7 +80,7 @@ func TestManagedGeneratedProject(t *testing.T) {
|
|||||||
writeFile(t, filepath.Join(dir, "internal/logic/demo", name+".go"), "package demo\n")
|
writeFile(t, filepath.Join(dir, "internal/logic/demo", name+".go"), "package demo\n")
|
||||||
}
|
}
|
||||||
configPath := filepath.Join(dir, "internal/config/config.go")
|
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\"`\n ShutdownInit bool `json:\"shutdownInit\"`", 1))
|
writeFile(t, configPath, strings.Replace(readFile(t, configPath), "type Config struct {", "type Config struct {\n BackgroundMode string `json:\"backgroundMode\"`\n FailInit bool `json:\"failInit\"`\n ShutdownInit bool `json:\"shutdownInit\"`", 1))
|
||||||
svcPath := filepath.Join(dir, "internal/svc/serviceContext.go")
|
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(readFile(t, svcPath), "type ServiceContext struct {", "type ServiceContext struct {\n Client *Client", 1)
|
||||||
source = strings.Replace(source, "return nil", "return initClient(ctx,c)", 1)
|
source = strings.Replace(source, "return nil", "return initClient(ctx,c)", 1)
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ func document(t *testing.T, c *Compiled) map[string]any {
|
|||||||
if err := json.Unmarshal(c.Document, &doc); err != nil {
|
if err := json.Unmarshal(c.Document, &doc); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
return doc
|
return expandDocumentRefs(t, doc, doc).(map[string]any)
|
||||||
}
|
}
|
||||||
func operation(t *testing.T, doc map[string]any, path, method string) map[string]any {
|
func operation(t *testing.T, doc map[string]any, path, method string) map[string]any {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
@@ -303,3 +303,21 @@ func TestAuthMetadataWithParameterNamePrefixes(t *testing.T) {
|
|||||||
t.Fatal("auth metadata lost")
|
t.Fatal("auth metadata lost")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Existing semantic assertions resolve the standard local references emitted
|
||||||
|
// by the compiler. documentRefs_test additionally checks the actual references.
|
||||||
|
func expandDocumentRefs(t *testing.T, doc map[string]any, v any) any {
|
||||||
|
t.Helper()
|
||||||
|
switch v:=v.(type){
|
||||||
|
case map[string]any:
|
||||||
|
if ref,ok:=v["$ref"].(string);ok && strings.HasPrefix(ref,"#/components/") && len(v)==1 {
|
||||||
|
var target any=doc
|
||||||
|
for _,part:=range strings.Split(strings.TrimPrefix(ref,"#/"),"/"){m,ok:=target.(map[string]any);if !ok{t.Fatal("invalid reference",ref)};target=m[part]}
|
||||||
|
if target==nil{t.Fatal("dangling reference",ref)};return expandDocumentRefs(t,doc,target)
|
||||||
|
}
|
||||||
|
out:=map[string]any{};for k,x:=range v{out[k]=expandDocumentRefs(t,doc,x)};return out
|
||||||
|
case []any:
|
||||||
|
out:=make([]any,len(v));for i,x:=range v{out[i]=expandDocumentRefs(t,doc,x)};return out
|
||||||
|
default:return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
|
|
||||||
type Spec struct {
|
type Spec struct {
|
||||||
DocumentCatalog *apierrors.Catalog
|
DocumentCatalog *apierrors.Catalog
|
||||||
|
DocumentErrors *DocumentErrors
|
||||||
SharedRoots map[string]SharedRoot
|
SharedRoots map[string]SharedRoot
|
||||||
SourcePaths map[string]string
|
SourcePaths map[string]string
|
||||||
RequestPolicy contract.RequestPolicy
|
RequestPolicy contract.RequestPolicy
|
||||||
@@ -72,7 +73,9 @@ type parser struct {
|
|||||||
imports []string
|
imports []string
|
||||||
}
|
}
|
||||||
|
|
||||||
func Parse(path string) (*Spec, error) {
|
func Parse(path string) (*Spec, error) { return parseWithCatalogs(path, nil) }
|
||||||
|
|
||||||
|
func parseWithCatalogs(path string, catalogs map[string][]byte) (*Spec, error) {
|
||||||
entry, err := filepath.Abs(path)
|
entry, err := filepath.Abs(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -82,11 +85,12 @@ func Parse(path string) (*Spec, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
root := filepath.Dir(entry)
|
root := filepath.Dir(entry)
|
||||||
roots, err := readSharedRoots(root)
|
shared, err := readSharedConfig(root)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
spec := &Spec{Version: "1.0.0", SharedRoots: roots, SourcePaths: map[string]string{}, Sources: map[string][]byte{}, Entry: filepath.Base(entry)}
|
roots := shared.Roots
|
||||||
|
spec := &Spec{Version: "1.0.0", DocumentErrors: shared.Errors, SharedRoots: roots, SourcePaths: map[string]string{}, Sources: map[string][]byte{}, Entry: filepath.Base(entry)}
|
||||||
visiting, visited := map[string]bool{}, map[string]bool{}
|
visiting, visited := map[string]bool{}, map[string]bool{}
|
||||||
total := 0
|
total := 0
|
||||||
var visit func(string, string, int) error
|
var visit func(string, string, int) error
|
||||||
@@ -169,7 +173,7 @@ func Parse(path string) (*Spec, error) {
|
|||||||
if err = visit(entry, "", 0); err != nil {
|
if err = visit(entry, "", 0); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err = readDocumentCatalog(root, spec); err != nil {
|
if err = readDocumentCatalog(root, spec, catalogs); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err = validateSharedPins(spec); err != nil {
|
if err = validateSharedPins(spec); err != nil {
|
||||||
|
|||||||
@@ -73,6 +73,8 @@ func TestConfiguration(t *testing.T){
|
|||||||
cfg,err:=Load("../../etc/config.yaml");if err!=nil{t.Fatal(err)}
|
cfg,err:=Load("../../etc/config.yaml");if err!=nil{t.Fatal(err)}
|
||||||
if cfg.Server.Log.File==""{t.Fatal("file logger absent")}
|
if cfg.Server.Log.File==""{t.Fatal("file logger absent")}
|
||||||
t.Setenv("GO_OMNI_LOG_OUTPUT","stdout");cfg,err=Load("");if err!=nil||cfg.Server.Log.File!=""{t.Fatalf("override: %v",err)}
|
t.Setenv("GO_OMNI_LOG_OUTPUT","stdout");cfg,err=Load("");if err!=nil||cfg.Server.Log.File!=""{t.Fatalf("override: %v",err)}
|
||||||
|
t.Setenv("GO_OMNI_SERVER_LOG_FILE","./explicit.log");cfg,err=Load("");if err!=nil||cfg.Server.Log.File!="./explicit.log"{t.Fatalf("canonical log file: %v",err)}
|
||||||
|
t.Setenv("GO_OMNI_SERVER_LOG_OUTPUT","stderr");cfg,err=Load("");if err!=nil||cfg.Server.Log.Output!="stderr"{t.Fatalf("canonical output: %v",err)}
|
||||||
_=os.Getenv("unused")
|
_=os.Getenv("unused")
|
||||||
}`
|
}`
|
||||||
endpoint := ""
|
endpoint := ""
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"git.yuntuo.hk/k2safe/go-omni/contract"
|
"git.yuntuo.hk/k2safe/go-omni/contract"
|
||||||
"git.yuntuo.hk/k2safe/go-omni/openapi"
|
"git.yuntuo.hk/k2safe/go-omni/openapi"
|
||||||
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Response roots deliberately allow named DTOs, []DTO, *DTO and null. Keeping
|
// Response roots allow named DTOs, []DTO, []primitive, *DTO and null.
|
||||||
// these four shapes explicit makes nil/empty behavior uniform across encoders.
|
// A nil root slice is always encoded as an empty JSON array.
|
||||||
func responseShape(value string) (kind, name string) {
|
func responseShape(value string) (kind, name string) {
|
||||||
if value == "null" {
|
if value == "null" {
|
||||||
return "null", ""
|
return "null", ""
|
||||||
@@ -27,6 +28,9 @@ func responseGoType(value string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
if kind == "array" {
|
if kind == "array" {
|
||||||
|
if primitiveTypes[name] != nil {
|
||||||
|
return "[]" + name
|
||||||
|
}
|
||||||
return "[]types." + name
|
return "[]types." + name
|
||||||
}
|
}
|
||||||
return "*types." + name
|
return "*types." + name
|
||||||
@@ -36,9 +40,25 @@ func responseDataSchema(value string, bindings map[string]*contract.Binder) (*op
|
|||||||
if kind == "null" {
|
if kind == "null" {
|
||||||
return &openapi.Schema{Type: "object", Nullable: true, Enum: []any{nil}}, nil
|
return &openapi.Schema{Type: "object", Nullable: true, Enum: []any{nil}}, nil
|
||||||
}
|
}
|
||||||
|
if kind == "array" && primitiveTypes[name] != nil {
|
||||||
|
if name == "File" {
|
||||||
|
return nil, fmt.Errorf("File is request-only; use a response DTO")
|
||||||
|
}
|
||||||
|
// Reuse the contract compiler, including integer bounds and byte-slice rules.
|
||||||
|
t := reflect.StructOf([]reflect.StructField{{Name: "Value", Type: reflect.SliceOf(primitiveTypes[name]), Tag: `json:"value"`}})
|
||||||
|
b, err := contract.Describe(t)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s, err := b.ResponseSchema()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.Properties["value"], nil
|
||||||
|
}
|
||||||
b, ok := bindings[name]
|
b, ok := bindings[name]
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, fmt.Errorf("unknown response DTO %q; use Name, []Name, *Name or null", name)
|
return nil, fmt.Errorf("unknown response DTO %q; use Name, []Name, []string/[]int64/[]bool/[]any, *Name or null", name)
|
||||||
}
|
}
|
||||||
s, err := b.ResponseSchema()
|
s, err := b.ResponseSchema()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -52,3 +72,7 @@ func responseDataSchema(value string, bindings map[string]*contract.Binder) (*op
|
|||||||
}
|
}
|
||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var primitiveTypes = map[string]reflect.Type{"File": reflect.TypeOf(contract.File{}), "any": reflect.TypeOf((*any)(nil)).Elem(), "string": reflect.TypeOf(""), "bool": reflect.TypeOf(false), "int": reflect.TypeOf(int(0)), "int8": reflect.TypeOf(int8(0)), "int16": reflect.TypeOf(int16(0)), "int32": reflect.TypeOf(int32(0)), "int64": reflect.TypeOf(int64(0)), "uint": reflect.TypeOf(uint(0)), "uint8": reflect.TypeOf(uint8(0)), "uint16": reflect.TypeOf(uint16(0)), "uint32": reflect.TypeOf(uint32(0)), "uint64": reflect.TypeOf(uint64(0)), "float32": reflect.TypeOf(float32(0)), "float64": reflect.TypeOf(float64(0))}
|
||||||
|
|
||||||
|
func responseNeedsTypes(value string) bool { return strings.Contains(responseGoType(value), "types.") }
|
||||||
|
|||||||
@@ -23,48 +23,49 @@ type SharedRoot struct {
|
|||||||
SHA256 string `json:"sha256,omitempty"`
|
SHA256 string `json:"sha256,omitempty"`
|
||||||
}
|
}
|
||||||
type sharedConfig struct {
|
type sharedConfig struct {
|
||||||
Roots map[string]SharedRoot `json:"roots"`
|
Roots map[string]SharedRoot `json:"roots"`
|
||||||
|
Errors *DocumentErrors `json:"errors,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func readSharedRoots(root string) (map[string]SharedRoot, error) {
|
func readSharedConfig(root string) (sharedConfig, error) {
|
||||||
stat, err := os.Lstat(filepath.Join(root, importsFile))
|
stat, err := os.Lstat(filepath.Join(root, importsFile))
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
return nil, nil
|
return sharedConfig{}, nil
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return sharedConfig{}, err
|
||||||
}
|
}
|
||||||
if !stat.Mode().IsRegular() {
|
if !stat.Mode().IsRegular() {
|
||||||
return nil, errors.New("api: shared root configuration must be a regular file")
|
return sharedConfig{}, errors.New("api: shared root configuration must be a regular file")
|
||||||
}
|
}
|
||||||
f, err := os.Open(filepath.Join(root, importsFile))
|
f, err := os.Open(filepath.Join(root, importsFile))
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
return nil, nil
|
return sharedConfig{}, nil
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return sharedConfig{}, err
|
||||||
}
|
}
|
||||||
defer f.Close()
|
defer f.Close()
|
||||||
raw, err := io.ReadAll(io.LimitReader(f, 65537))
|
raw, err := io.ReadAll(io.LimitReader(f, 65537))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return sharedConfig{}, err
|
||||||
}
|
}
|
||||||
if len(raw) > 65536 {
|
if len(raw) > 65536 {
|
||||||
return nil, errors.New("api: shared root config too large")
|
return sharedConfig{}, errors.New("api: shared root config too large")
|
||||||
}
|
}
|
||||||
var cfg sharedConfig
|
var cfg sharedConfig
|
||||||
if err = apierrors.DecodeCatalogJSON(raw, &cfg); err != nil {
|
if err = apierrors.DecodeCatalogJSON(raw, &cfg); err != nil {
|
||||||
return nil, err
|
return sharedConfig{}, err
|
||||||
}
|
}
|
||||||
if len(cfg.Roots) > 16 {
|
if len(cfg.Roots) > 16 {
|
||||||
return nil, errors.New("api: at most 16 shared roots")
|
return sharedConfig{}, errors.New("api: at most 16 shared roots")
|
||||||
}
|
}
|
||||||
for name, r := range cfg.Roots {
|
for name, r := range cfg.Roots {
|
||||||
if !lowerName.MatchString(name) || r.Path == "" || len(r.Version) > 128 {
|
if !lowerName.MatchString(name) || r.Path == "" || len(r.Version) > 128 {
|
||||||
return nil, errors.New("api: invalid shared root")
|
return sharedConfig{}, errors.New("api: invalid shared root")
|
||||||
}
|
}
|
||||||
if r.SHA256 != "" && (len(r.SHA256) != 64 || strings.Trim(r.SHA256, "0123456789abcdef") != "") {
|
if r.SHA256 != "" && (len(r.SHA256) != 64 || strings.Trim(r.SHA256, "0123456789abcdef") != "") {
|
||||||
return nil, errors.New("api: shared sha256 must be 64 lowercase hex characters")
|
return sharedConfig{}, errors.New("api: shared sha256 must be 64 lowercase hex characters")
|
||||||
}
|
}
|
||||||
path := r.Path
|
path := r.Path
|
||||||
if !filepath.IsAbs(path) {
|
if !filepath.IsAbs(path) {
|
||||||
@@ -72,22 +73,22 @@ func readSharedRoots(root string) (map[string]SharedRoot, error) {
|
|||||||
}
|
}
|
||||||
r.Path, err = filepath.EvalSymlinks(path)
|
r.Path, err = filepath.EvalSymlinks(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return sharedConfig{}, err
|
||||||
}
|
}
|
||||||
r.Path, err = filepath.Abs(r.Path)
|
r.Path, err = filepath.Abs(r.Path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return sharedConfig{}, err
|
||||||
}
|
}
|
||||||
stat, err := os.Stat(r.Path)
|
stat, err := os.Stat(r.Path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return sharedConfig{}, err
|
||||||
}
|
}
|
||||||
if !stat.IsDir() {
|
if !stat.IsDir() {
|
||||||
return nil, errors.New("api: shared root must be a directory")
|
return sharedConfig{}, errors.New("api: shared root must be a directory")
|
||||||
}
|
}
|
||||||
cfg.Roots[name] = r
|
cfg.Roots[name] = r
|
||||||
}
|
}
|
||||||
return cfg.Roots, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
func sourceWithin(root, name string) (string, error) {
|
func sourceWithin(root, name string) (string, error) {
|
||||||
real, err := filepath.EvalSymlinks(name)
|
real, err := filepath.EvalSymlinks(name)
|
||||||
@@ -122,7 +123,7 @@ func sourceMetadata(spec *Spec) map[string]any {
|
|||||||
return map[string]any{"files": hashes, "shared": shared}
|
return map[string]any{"files": hashes, "shared": shared}
|
||||||
}
|
}
|
||||||
func sharedSeeds(spec *Spec, directory string) ([]byte, error) {
|
func sharedSeeds(spec *Spec, directory string) ([]byte, error) {
|
||||||
cfg := sharedConfig{Roots: map[string]SharedRoot{}}
|
cfg := sharedConfig{Roots: map[string]SharedRoot{}, Errors: spec.DocumentErrors}
|
||||||
for name, r := range spec.SharedRoots {
|
for name, r := range spec.SharedRoots {
|
||||||
rel, err := filepath.Rel(filepath.Join(directory, "api"), r.Path)
|
rel, err := filepath.Rel(filepath.Join(directory, "api"), r.Path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"os/signal"
|
"os/signal"
|
||||||
"syscall"
|
"syscall"
|
||||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
|
||||||
"MODULE/internal/config"
|
"MODULE/internal/config"
|
||||||
"MODULE/internal/handler"
|
"MODULE/internal/handler"
|
||||||
"MODULE/internal/svc"
|
"MODULE/internal/svc"
|
||||||
@@ -27,7 +28,9 @@ func main(){
|
|||||||
func run(ctx context.Context,path string)(runErr error){
|
func run(ctx context.Context,path string)(runErr error){
|
||||||
cfg,err:=config.Load(path);if err!=nil{return err}
|
cfg,err:=config.Load(path);if err!=nil{return err}
|
||||||
logger,err:=logx.New(cfg.Server.Log);if err!=nil{return err}
|
logger,err:=logx.New(cfg.Server.Log);if err!=nil{return err}
|
||||||
defer func(){runErr=errors.Join(runErr,logger.Close())}()
|
// Unjoined background work may still log. The owning process exits on this
|
||||||
|
// error; do not close its borrowed logger before that work has finished.
|
||||||
|
defer func(){if !errors.Is(runErr,lifecycle.ErrResourcesRetained){runErr=errors.Join(runErr,logger.Close())}}()
|
||||||
deps,err:=svc.New(ctx,logger,cfg);if err!=nil{return err}
|
deps,err:=svc.New(ctx,logger,cfg);if err!=nil{return err}
|
||||||
// RunContext drains HTTP requests before returning. This scope owns dependencies
|
// RunContext drains HTTP requests before returning. This scope owns dependencies
|
||||||
// and closes them exactly once, before closing the borrowed logger.
|
// and closes them exactly once, before closing the borrowed logger.
|
||||||
@@ -182,7 +185,16 @@ then immediately register each cleanup with OnClose. A later initialization fail
|
|||||||
runs registered cleanups in reverse order. Close is idempotent. GlobalMiddleware
|
runs registered cleanups in reverse order. Close is idempotent. GlobalMiddleware
|
||||||
provides global hooks; Middlewares supplies named API hooks. AppOptions is evaluated
|
provides global hooks; Middlewares supplies named API hooks. AppOptions is evaluated
|
||||||
once before route registration; logging is configured earlier through Config.
|
once before route registration; logging is configured earlier through Config.
|
||||||
The generated main runs HTTP draining before closing svc resources, then the logger.
|
The generated main runs HTTP draining, all OnStop hooks, all OnDrain hooks, resource
|
||||||
|
OnClose callbacks, then logger close. Stop/drain hooks within a phase run concurrently;
|
||||||
|
combine components with ordering dependencies into one pair. Pair OnStop/OnDrain
|
||||||
|
with the same name. OnDrain returns nil only after final work and acknowledgements
|
||||||
|
have finished. Use WorkContext for accepted background work; Context is an early
|
||||||
|
shutdown notification. server.lifecycle configures stopTimeout (5s), drainTimeout
|
||||||
|
(10s), and cancelTimeout (5s grace per timed-out phase). Zero uses defaults.
|
||||||
|
Timeout/failure cancels WorkContext. If hooks cannot prove completion, resources
|
||||||
|
and the borrowed logger are retained and the generated process exits unsuccessfully.
|
||||||
|
Errors retain their causes. No automatic retry of close or force killing of goroutines.
|
||||||
Do not close borrowed resources or attach duplicate cleanup hooks to App. The startup
|
Do not close borrowed resources or attach duplicate cleanup hooks to App. The startup
|
||||||
context cancels on signals; long-lived clients/workers that requests depend on need
|
context cancels on signals; long-lived clients/workers that requests depend on need
|
||||||
their own lifetime, canceled by OnClose after HTTP draining.
|
their own lifetime, canceled by OnClose after HTTP draining.
|
||||||
|
|||||||
54
cmd/go-omni/apigen/testdata/arrays/consumer_test.go
vendored
Normal file
54
cmd/go-omni/apigen/testdata/arrays/consumer_test.go
vendored
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"example.com/arrays/internal/config"
|
||||||
|
"example.com/arrays/internal/handler"
|
||||||
|
"example.com/arrays/internal/svc"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||||
|
"io"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestArrayWire(t *testing.T) {
|
||||||
|
cfg := config.Default()
|
||||||
|
cfg.Server.Log.Writer = io.Discard
|
||||||
|
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, want := range map[string]string{
|
||||||
|
"/strings": `["企业","备注"]`, "/integers": `[9223372036854775807,-3]`, "/dynamic": `["text",7,true,null,{"name":"企业"},[false,2]]`, "/empty": `[]`, "/raw": `[true,false]`,
|
||||||
|
} {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
app.ServeHTTP(w, httptest.NewRequest("GET", path, nil))
|
||||||
|
got := strings.TrimSpace(w.Body.String())
|
||||||
|
if w.Code != 200 {
|
||||||
|
t.Fatal(got)
|
||||||
|
}
|
||||||
|
if path != "/raw" {
|
||||||
|
var obj map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &obj); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got = string(obj["data"])
|
||||||
|
}
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("%s got %s want %s", path, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
13
cmd/go-omni/apigen/testdata/arrays/values.go
vendored
Normal file
13
cmd/go-omni/apigen/testdata/arrays/values.go
vendored
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
package arrays
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
func (s *Logic) Strings(context.Context) ([]string, error) { return []string{"企业", "备注"}, nil }
|
||||||
|
func (s *Logic) Integers(context.Context) ([]int64, error) {
|
||||||
|
return []int64{9223372036854775807, -3}, nil
|
||||||
|
}
|
||||||
|
func (s *Logic) Dynamic(context.Context) ([]any, error) {
|
||||||
|
return []any{"text", 7, true, nil, map[string]any{"name": "企业"}, []any{false, 2}}, nil
|
||||||
|
}
|
||||||
|
func (s *Logic) Empty(context.Context) ([]any, error) { return nil, nil }
|
||||||
|
func (s *Logic) Raw(context.Context) ([]bool, error) { return []bool{true, false}, nil }
|
||||||
49
cmd/go-omni/apigen/testdata/managed/background.go
vendored
Normal file
49
cmd/go-omni/apigen/testdata/managed/background.go
vendored
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
package svc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/task"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type BackgroundState struct {
|
||||||
|
Stop, Drain, Release, Started chan struct{}
|
||||||
|
Wrote atomic.Bool
|
||||||
|
Future *task.Future
|
||||||
|
}
|
||||||
|
|
||||||
|
var Background atomic.Pointer[BackgroundState]
|
||||||
|
|
||||||
|
func initBackground(c *ServiceContext) error {
|
||||||
|
state := &BackgroundState{Stop: make(chan struct{}), Drain: make(chan struct{}), Release: make(chan struct{}), Started: make(chan struct{})}
|
||||||
|
baseline := Closed.Load()
|
||||||
|
runner, err := task.New(task.Config{Workers: 1, QueueSize: 1, TaskTimeout: time.Minute, OnError: func(string, error) {}})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c.OnStop("jobs", func(ctx context.Context) error { close(state.Stop); return runner.StopAccepting(ctx) })
|
||||||
|
c.OnDrain("jobs", func(ctx context.Context) error { close(state.Drain); return runner.Wait(ctx) })
|
||||||
|
state.Future, err = runner.Submit(c.WorkContext(), "finish", func(ctx context.Context) error {
|
||||||
|
close(state.Started)
|
||||||
|
if c.Config.BackgroundMode == "auto" {
|
||||||
|
<-state.Stop
|
||||||
|
} else {
|
||||||
|
<-state.Release
|
||||||
|
}
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
if Closed.Load() != baseline {
|
||||||
|
return errors.New("shared client closed before background write")
|
||||||
|
}
|
||||||
|
state.Wrote.Store(true)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
<-state.Started
|
||||||
|
}
|
||||||
|
Background.Store(state)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -20,6 +20,11 @@ func initClient(ctx context.Context, c *ServiceContext) error {
|
|||||||
Opened.Add(1)
|
Opened.Add(1)
|
||||||
c.Client = &Client{}
|
c.Client = &Client{}
|
||||||
c.OnClose(func() error { Closed.Add(1); c.Logger.Info("test.client.closed"); return nil })
|
c.OnClose(func() error { Closed.Add(1); c.Logger.Info("test.client.closed"); return nil })
|
||||||
|
if c.Config.BackgroundMode != "" {
|
||||||
|
if err := initBackground(c); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
if c.Config.FailInit {
|
if c.Config.FailInit {
|
||||||
return errors.New("client initialization failed")
|
return errors.New("client initialization failed")
|
||||||
}
|
}
|
||||||
|
|||||||
155
cmd/go-omni/apigen/testdata/managed/phases_test.go
vendored
Normal file
155
cmd/go-omni/apigen/testdata/managed/phases_test.go
vendored
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"example.com/managed/internal/svc"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func phasedConfig(t *testing.T, address, mode string, fail bool) string {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join(t.TempDir(), "config.json")
|
||||||
|
drainTimeout := "1s"
|
||||||
|
if mode == "stuck" {
|
||||||
|
drainTimeout = "50ms"
|
||||||
|
}
|
||||||
|
data, _ := json.Marshal(map[string]any{"backgroundMode": mode, "failInit": fail, "server": map[string]any{"address": address, "lifecycle": map[string]string{"stopTimeout": "1s", "drainTimeout": drainTimeout, "cancelTimeout": "50ms"}}})
|
||||||
|
if err := os.WriteFile(path, data, 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
func await(t *testing.T, ch <-chan struct{}) {
|
||||||
|
t.Helper()
|
||||||
|
select {
|
||||||
|
case <-ch:
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
t.Fatal("phase did not arrive")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGeneratedBackgroundPhases(t *testing.T) {
|
||||||
|
for _, transport := range []string{"nethttp", "native"} {
|
||||||
|
t.Run(transport, func(t *testing.T) {
|
||||||
|
t.Setenv("GO_OMNI_TRANSPORT", transport)
|
||||||
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
addr := listener.Addr().String()
|
||||||
|
_ = listener.Close()
|
||||||
|
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 := phasedConfig(t, addr, "hold", false)
|
||||||
|
go func() { done <- run(ctx, path) }()
|
||||||
|
waitReady(t, "http://"+addr, done)
|
||||||
|
state := svc.Background.Load()
|
||||||
|
defer func() {
|
||||||
|
select {
|
||||||
|
case <-state.Release:
|
||||||
|
default:
|
||||||
|
close(state.Release)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
requestDone := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
client := http.Client{Timeout: 3 * time.Second}
|
||||||
|
resp, e := client.Get("http://" + addr + "/slow")
|
||||||
|
if e == nil {
|
||||||
|
_, _ = io.Copy(io.Discard, resp.Body)
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
e = errors.New("HTTP drain failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
requestDone <- e
|
||||||
|
}()
|
||||||
|
await(t, svc.Started)
|
||||||
|
deps := svc.Current.Load()
|
||||||
|
deps.RequestShutdown(svc.FatalDependency)
|
||||||
|
if deps.WorkContext().Err() != nil {
|
||||||
|
t.Fatal("accepted work canceled at signal")
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-state.Stop:
|
||||||
|
t.Fatal("stopped background before HTTP finished")
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
close(svc.Release)
|
||||||
|
if err := <-requestDone; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
await(t, state.Drain)
|
||||||
|
if svc.Closed.Load() != before {
|
||||||
|
t.Fatal("client closed during background drain")
|
||||||
|
}
|
||||||
|
close(state.Release)
|
||||||
|
if err := <-done; !errors.Is(err, svc.FatalDependency) || errors.Is(err, lifecycle.ErrResourcesRetained) {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !state.Wrote.Load() || svc.Closed.Load() != before+1 {
|
||||||
|
t.Fatal("missing final write or cleanup")
|
||||||
|
}
|
||||||
|
if err := state.Future.Wait(context.Background()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGeneratedBackgroundFailureCleanup(t *testing.T) {
|
||||||
|
for _, failInit := range []bool{false, true} {
|
||||||
|
t.Run(map[bool]string{true: "initialization", false: "listen"}[failInit], func(t *testing.T) {
|
||||||
|
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer l.Close()
|
||||||
|
before := svc.Closed.Load()
|
||||||
|
err = run(context.Background(), phasedConfig(t, l.Addr().String(), "auto", failInit))
|
||||||
|
if err == nil || errors.Is(err, lifecycle.ErrResourcesRetained) {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
state := svc.Background.Load()
|
||||||
|
if !state.Wrote.Load() || svc.Closed.Load() != before+1 {
|
||||||
|
t.Fatal("failure cleanup did not join background first")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGeneratedBackgroundTimeoutProcess(t *testing.T) {
|
||||||
|
if path := os.Getenv("OMNI_PHASE_TIMEOUT_CONFIG"); path != "" {
|
||||||
|
os.Args = []string{os.Args[0], "-config", path}
|
||||||
|
main()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
path := phasedConfig(t, "127.0.0.1:0", "stuck", true)
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestGeneratedBackgroundTimeoutProcess$")
|
||||||
|
cmd.Env = append(os.Environ(), "OMNI_PHASE_TIMEOUT_CONFIG="+path)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
var exit *exec.ExitError
|
||||||
|
if !errors.As(err, &exit) || exit.ExitCode() != 1 || ctx.Err() != nil {
|
||||||
|
t.Fatal(err, string(output))
|
||||||
|
}
|
||||||
|
if strings.Contains(string(output), "test.client.closed") || !strings.Contains(string(output), "shared resources retained") || !strings.Contains(string(output), "client initialization failed") {
|
||||||
|
t.Fatal(string(output))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -78,6 +78,12 @@ func PlanUpgrade(dir string) (UpgradePlan, error) {
|
|||||||
names[name] = true
|
names[name] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// v0.14 already owns config/svc extensions. Only bump the module here;
|
||||||
|
// Generate updates generator-owned main/runtime files without replaying the
|
||||||
|
// v0.13 seed migration over developer configuration and dependency code.
|
||||||
|
if semver.IsValid(m.TemplateVersion) && semver.Compare(m.TemplateVersion, "v0.14.0-preview") >= 0 {
|
||||||
|
names = map[string]bool{}
|
||||||
|
}
|
||||||
sorted := make([]string, 0, len(names))
|
sorted := make([]string, 0, len(names))
|
||||||
for name := range names {
|
for name := range names {
|
||||||
sorted = append(sorted, name)
|
sorted = append(sorted, name)
|
||||||
|
|||||||
@@ -60,6 +60,49 @@ func TestUpgradeRejectsEscapingEntry(t *testing.T) {
|
|||||||
t.Fatal("invalid upgrade wrote project")
|
t.Fatal("invalid upgrade wrote project")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUpgradeV014PreservesExtensions(t *testing.T) {
|
||||||
|
dir := filepath.Join(t.TempDir(), "service")
|
||||||
|
if _, err := New(Options{Dir: dir, Module: "example.com/upgrade", FrameworkDir: frameworkRoot(t)}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var m manifest
|
||||||
|
if err := json.Unmarshal([]byte(readFile(t, filepath.Join(dir, manifestPath))), &m); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.TemplateVersion = "v0.14.0-preview"
|
||||||
|
raw, _ := json.Marshal(m)
|
||||||
|
writeFile(t, filepath.Join(dir, manifestPath), string(raw))
|
||||||
|
mod := filepath.Join(dir, "go.mod")
|
||||||
|
writeFile(t, mod, strings.ReplaceAll(readFile(t, mod), minimumVersion, "v0.14.1-preview"))
|
||||||
|
owned := map[string]string{}
|
||||||
|
for _, name := range []string{"internal/svc/serviceContext.go", "internal/config/config.go", "etc/config.yaml"} {
|
||||||
|
path := filepath.Join(dir, name)
|
||||||
|
value := readFile(t, path)
|
||||||
|
if strings.HasSuffix(name, ".go") {
|
||||||
|
value += "\n// project extension kept\n"
|
||||||
|
} else {
|
||||||
|
value += "\n# project configuration kept\n"
|
||||||
|
}
|
||||||
|
writeFile(t, path, value)
|
||||||
|
owned[path] = value
|
||||||
|
}
|
||||||
|
plan, err := PlanUpgrade(dir)
|
||||||
|
if err != nil || len(plan.Changes) != 1 || plan.Changes[0].Path != "go.mod" {
|
||||||
|
t.Fatal(plan, err)
|
||||||
|
}
|
||||||
|
if _, err := ApplyUpgrade(dir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for path, want := range owned {
|
||||||
|
if readFile(t, path) != want {
|
||||||
|
t.Fatal("developer file modified", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := Generate(Options{Dir: dir, API: filepath.Join(dir, "api/service.api"), Check: true}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
func TestSafeInfrastructureUpgrade(t *testing.T) {
|
func TestSafeInfrastructureUpgrade(t *testing.T) {
|
||||||
dir := oldInfrastructureProject(t)
|
dir := oldInfrastructureProject(t)
|
||||||
before := snapshot(t, dir)
|
before := snapshot(t, dir)
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ type WorkspaceService struct {
|
|||||||
Module string `json:"module,omitempty"`
|
Module string `json:"module,omitempty"`
|
||||||
}
|
}
|
||||||
type WorkspaceConfig struct {
|
type WorkspaceConfig struct {
|
||||||
Services []WorkspaceService `json:"services"`
|
Services []WorkspaceService `json:"services"`
|
||||||
|
ErrorCatalogs []WorkspaceCatalog `json:"errorCatalogs,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Workspace operates independent Go modules listed in an explicit local file.
|
// Workspace operates independent Go modules listed in an explicit local file.
|
||||||
@@ -83,6 +84,16 @@ func Workspace(path, command string, w io.Writer) error {
|
|||||||
}
|
}
|
||||||
options = append(options, Options{API: entry, Dir: dir, Module: service.Module, DryRun: true})
|
options = append(options, Options{API: entry, Dir: dir, Module: service.Module, DryRun: true})
|
||||||
}
|
}
|
||||||
|
var catalogs map[string][]byte
|
||||||
|
if command != "format" {
|
||||||
|
catalogs, err = prepareWorkspaceCatalogs(root, cfg.ErrorCatalogs, command == "check")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for i := range options {
|
||||||
|
options[i].documentCatalogs = catalogs
|
||||||
|
}
|
||||||
|
}
|
||||||
var failures error
|
var failures error
|
||||||
for _, o := range options {
|
for _, o := range options {
|
||||||
if command == "format" {
|
if command == "format" {
|
||||||
@@ -101,6 +112,13 @@ func Workspace(path, command string, w io.Writer) error {
|
|||||||
if failures != nil {
|
if failures != nil {
|
||||||
return failures
|
return failures
|
||||||
}
|
}
|
||||||
|
if command == "generate" {
|
||||||
|
for path, data := range catalogs {
|
||||||
|
if err = writeWorkspaceCatalog(path, data); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
for _, o := range options {
|
for _, o := range options {
|
||||||
switch command {
|
switch command {
|
||||||
case "format":
|
case "format":
|
||||||
|
|||||||
217
cmd/go-omni/apigen/workspaceCatalog.go
Normal file
217
cmd/go-omni/apigen/workspaceCatalog.go
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
package apigen
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
gotoken "go/token"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||||
|
"golang.org/x/mod/modfile"
|
||||||
|
"golang.org/x/mod/module"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WorkspaceCatalog is an explicitly trusted Go provider: func() errors.Catalog.
|
||||||
|
// Only workspace generate/check execute providers. Parsing an .api never does.
|
||||||
|
type WorkspaceCatalog struct {
|
||||||
|
Dir string `json:"dir"`
|
||||||
|
Package string `json:"package"`
|
||||||
|
Function string `json:"function"`
|
||||||
|
Output string `json:"output"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type limitedCatalogBuffer struct {
|
||||||
|
bytes.Buffer
|
||||||
|
limit int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *limitedCatalogBuffer) Write(p []byte) (int, error) {
|
||||||
|
if len(p) > b.limit-b.Len() {
|
||||||
|
return 0, errors.New("catalog provider output exceeds limit")
|
||||||
|
}
|
||||||
|
return b.Buffer.Write(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func exportWorkspaceCatalog(root string, cfg WorkspaceCatalog) ([]byte, error) {
|
||||||
|
if cfg.Dir == "" || cfg.Output == "" || !gotoken.IsIdentifier(cfg.Function) || !gotoken.IsExported(cfg.Function) || module.CheckImportPath(cfg.Package) != nil {
|
||||||
|
return nil, errors.New("workspace catalog requires dir, an importable package, an exported function and output")
|
||||||
|
}
|
||||||
|
if err := safePath(root, filepath.ToSlash(filepath.Join(cfg.Dir, ".goOmniExportCheck"))); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := safePath(root, cfg.Output); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if filepath.Ext(cfg.Output) != ".json" {
|
||||||
|
return nil, errors.New("workspace catalog output must be .json")
|
||||||
|
}
|
||||||
|
dir := filepath.Join(root, filepath.FromSlash(cfg.Dir))
|
||||||
|
if stat, err := os.Stat(filepath.Join(dir, "go.mod")); err != nil || !stat.Mode().IsRegular() {
|
||||||
|
return nil, errors.New("workspace catalog dir must contain go.mod")
|
||||||
|
}
|
||||||
|
temp, err := os.MkdirTemp("", "go-omni-catalog-*")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(temp)
|
||||||
|
// Overlay a virtual Go file under the provider module. This allows internal
|
||||||
|
// packages without creating files or changing go.mod/go.sum in that module.
|
||||||
|
virtual := filepath.Join(dir, ".goOmniCatalogExport", "main.go")
|
||||||
|
if _, err := os.Lstat(filepath.Dir(virtual)); !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return nil, errors.New("reserved catalog export path already exists")
|
||||||
|
}
|
||||||
|
source := fmt.Sprintf("package main\nimport (\"os\"; provider %q; omnierrors %q)\nfunc main(){if err:=omnierrors.ExportCatalog(os.Stdout,provider.%s());err!=nil{os.Exit(1)}}\n", cfg.Package, frameworkModule+"/errors", cfg.Function)
|
||||||
|
backing := filepath.Join(temp, "main.go")
|
||||||
|
if err = os.WriteFile(backing, []byte(source), 0600); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
overlay, _ := json.Marshal(map[string]any{"Replace": map[string]string{virtual: backing}})
|
||||||
|
overlayPath := filepath.Join(temp, "overlay.json")
|
||||||
|
if err = os.WriteFile(overlayPath, overlay, 0600); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// Keep all module metadata writes in the temporary directory as well.
|
||||||
|
// -mod=readonly alone must not be relied on to protect every go.sum update.
|
||||||
|
moduleData, err := os.ReadFile(filepath.Join(dir, "go.mod"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
mf, err := modfile.Parse("go.mod", moduleData, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("workspace catalog has invalid go.mod")
|
||||||
|
}
|
||||||
|
replacements := append([]*modfile.Replace(nil), mf.Replace...)
|
||||||
|
for _, r := range replacements {
|
||||||
|
if r.New.Version == "" && !filepath.IsAbs(r.New.Path) {
|
||||||
|
if err = mf.AddReplace(r.Old.Path, r.Old.Version, filepath.Join(dir, r.New.Path), ""); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
moduleData, err = mf.Format()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
modPath := filepath.Join(temp, "go.mod")
|
||||||
|
if err = os.WriteFile(modPath, moduleData, 0600); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if sums, e := os.ReadFile(filepath.Join(dir, "go.sum")); e == nil {
|
||||||
|
if err = os.WriteFile(filepath.Join(temp, "go.sum"), sums, 0600); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
} else if !errors.Is(e, os.ErrNotExist) {
|
||||||
|
return nil, e
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
executable := filepath.Join(temp, "catalogExport")
|
||||||
|
// Build first, then execute the binary directly: cancellation kills the
|
||||||
|
// provider itself instead of only terminating a go-run wrapper process.
|
||||||
|
build := exec.CommandContext(ctx, "go", "build", "-mod=readonly", "-modfile", modPath, "-overlay", overlayPath, "-o", executable, virtual)
|
||||||
|
build.Dir = dir
|
||||||
|
build.Env = append(os.Environ(), "GOWORK=off")
|
||||||
|
build.WaitDelay = 2 * time.Second
|
||||||
|
build.Stdout = &limitedCatalogBuffer{limit: 64 << 10}
|
||||||
|
build.Stderr = &limitedCatalogBuffer{limit: 64 << 10}
|
||||||
|
if err = build.Run(); err != nil {
|
||||||
|
return nil, fmt.Errorf("workspace catalog %s.%s: build failed (%v); check module dependencies and func() errors.Catalog signature", cfg.Package, cfg.Function, err)
|
||||||
|
}
|
||||||
|
cmd := exec.CommandContext(ctx, executable)
|
||||||
|
cmd.Dir = dir
|
||||||
|
cmd.Env = append(os.Environ(), "GOWORK=off")
|
||||||
|
cmd.WaitDelay = 2 * time.Second
|
||||||
|
stdout := &limitedCatalogBuffer{limit: 1 << 20}
|
||||||
|
stderr := &limitedCatalogBuffer{limit: 64 << 10}
|
||||||
|
cmd.Stdout = stdout
|
||||||
|
cmd.Stderr = stderr
|
||||||
|
if err = cmd.Run(); err != nil {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return nil, fmt.Errorf("workspace catalog %s: %w", cfg.Package, ctx.Err())
|
||||||
|
}
|
||||||
|
// Provider output can contain arbitrary application data. Do not echo it.
|
||||||
|
return nil, fmt.Errorf("workspace catalog %s.%s: execution failed (%v); provider must return errors.Catalog without writing stdout", cfg.Package, cfg.Function, err)
|
||||||
|
}
|
||||||
|
var catalog apierrors.Catalog
|
||||||
|
if err = apierrors.DecodeCatalogJSON(stdout.Bytes(), &catalog); err != nil {
|
||||||
|
return nil, errors.New("workspace catalog provider did not emit a valid catalog")
|
||||||
|
}
|
||||||
|
var normalized bytes.Buffer
|
||||||
|
if err = apierrors.ExportCatalog(&normalized, catalog); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return normalized.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func prepareWorkspaceCatalogs(root string, configs []WorkspaceCatalog, check bool) (map[string][]byte, error) {
|
||||||
|
if len(configs) > 16 {
|
||||||
|
return nil, errors.New("workspace supports at most 16 catalog providers")
|
||||||
|
}
|
||||||
|
result := map[string][]byte{}
|
||||||
|
for _, cfg := range configs {
|
||||||
|
if err := safePath(root, cfg.Output); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
path := filepath.Join(root, filepath.FromSlash(cfg.Output))
|
||||||
|
for prior := range result {
|
||||||
|
if strings.EqualFold(prior, path) {
|
||||||
|
return nil, errors.New("workspace catalog outputs overlap")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data, err := exportWorkspaceCatalog(root, cfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if check {
|
||||||
|
old, err := os.ReadFile(path)
|
||||||
|
if err != nil || !bytes.Equal(old, data) {
|
||||||
|
return nil, fmt.Errorf("workspace catalog drift: %s; run workspace generate", cfg.Output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result[path] = data
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeWorkspaceCatalog(path string, data []byte) error {
|
||||||
|
mode := os.FileMode(0644)
|
||||||
|
if stat, err := os.Lstat(path); err == nil {
|
||||||
|
if !stat.Mode().IsRegular() {
|
||||||
|
return errors.New("workspace catalog output must be a regular file")
|
||||||
|
}
|
||||||
|
mode = stat.Mode().Perm()
|
||||||
|
} else if !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
old, err := os.ReadFile(path)
|
||||||
|
if err == nil && bytes.Equal(old, data) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
f, err := os.CreateTemp(filepath.Dir(path), ".omni-catalog-*")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer os.Remove(f.Name())
|
||||||
|
if err = f.Chmod(mode); err != nil {
|
||||||
|
_ = f.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, writeErr := io.Copy(f, bytes.NewReader(data))
|
||||||
|
err = errors.Join(writeErr, f.Close())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Rename(f.Name(), path)
|
||||||
|
}
|
||||||
168
cmd/go-omni/apigen/workspaceCatalog_test.go
Normal file
168
cmd/go-omni/apigen/workspaceCatalog_test.go
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
package apigen
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestWorkspaceSharedGoCatalog(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
writeFile(t, filepath.Join(root, "go.mod"), fmt.Sprintf("module example.com/catalogs\n\ngo 1.24.0\nrequire %s v0.15.0-preview\nreplace %s => %s\n", frameworkModule, frameworkModule, strconvQuote(frameworkRoot(t))))
|
||||||
|
writeFile(t, filepath.Join(root, "internal/apierrors/catalog.go"), `package apierrors
|
||||||
|
import errors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||||
|
func Catalog() errors.Catalog {return errors.Catalog{DefaultStatus:400,Definitions:map[string]errors.Definition{
|
||||||
|
"INVALID":{Code:"INVALID",Message:"invalid input"},"MISSING":{Status:404,Code:"MISSING",Message:"missing"},
|
||||||
|
}}}
|
||||||
|
`)
|
||||||
|
if err := os.MkdirAll(filepath.Join(root, "contracts"), 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, name := range []string{"api", "id"} {
|
||||||
|
dir := filepath.Join(root, "services", name)
|
||||||
|
if _, err := New(Options{Dir: dir, Module: "example.com/" + name, FrameworkDir: frameworkRoot(t)}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
writeFile(t, filepath.Join(dir, "api/omni.imports.json"), `{"roots":{"common":{"path":"../../../contracts"}},"errors":{"source":"@common/errors.json","include":["INVALID"],"routes":{"GET /api/greet/:name":["MISSING"]}}}`)
|
||||||
|
}
|
||||||
|
workspace := filepath.Join(root, "omni.workspace.json")
|
||||||
|
writeFile(t, workspace, `{"errorCatalogs":[{"dir":".","package":"example.com/catalogs/internal/apierrors","function":"Catalog","output":"contracts/errors.json"}],"services":[{"dir":"services/api"},{"dir":"services/id"}]}`)
|
||||||
|
if err := Workspace(workspace, "generate", io.Discard); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := Workspace(workspace, "format", io.Discard); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := Workspace(workspace, "generate", io.Discard); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
before := snapshot(t, root)
|
||||||
|
if err := Workspace(workspace, "check", io.Discard); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !sameFiles(before, snapshot(t, root)) {
|
||||||
|
t.Fatal("check wrote project files")
|
||||||
|
}
|
||||||
|
for _, name := range []string{"api", "id"} {
|
||||||
|
entry := filepath.Join(root, "services", name, "api/service.api")
|
||||||
|
spec, err := Parse(entry)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
c, err := Compile(spec)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
op := operation(t, document(t, c), "/api/greet/{name}", "get")
|
||||||
|
if op["responses"].(map[string]any)["404"] == nil || len(spec.Errors) != 0 || len(spec.Routes[0].Errors) != 0 {
|
||||||
|
t.Fatal("selection changed runtime catalog")
|
||||||
|
}
|
||||||
|
if _, err = os.Stat(filepath.Join(filepath.Dir(entry), catalogDocumentFile)); !os.IsNotExist(err) {
|
||||||
|
t.Fatal("shared catalog was copied")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
shared := filepath.Join(root, "contracts/errors.json")
|
||||||
|
data := readFile(t, shared)
|
||||||
|
writeFile(t, shared, strings.Replace(data, "invalid input", "stale", 1))
|
||||||
|
before = snapshot(t, root)
|
||||||
|
if err := Workspace(workspace, "check", io.Discard); err == nil || !strings.Contains(err.Error(), "catalog drift") {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !sameFiles(before, snapshot(t, root)) {
|
||||||
|
t.Fatal("drift check wrote files")
|
||||||
|
}
|
||||||
|
if err := Workspace(workspace, "generate", io.Discard); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := readFile(t, shared); got != data {
|
||||||
|
t.Fatal("catalog not refreshed")
|
||||||
|
}
|
||||||
|
// Failed provider/preflight may not update even the shared metadata.
|
||||||
|
source := filepath.Join(root, "internal/apierrors/catalog.go")
|
||||||
|
writeFile(t, source, readFile(t, source)+"\ninvalid go source")
|
||||||
|
before = snapshot(t, root)
|
||||||
|
if err := Workspace(workspace, "generate", io.Discard); err == nil {
|
||||||
|
t.Fatal("provider failure ignored")
|
||||||
|
}
|
||||||
|
if !sameFiles(before, snapshot(t, root)) {
|
||||||
|
t.Fatal("provider failure mutated project")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDocumentSelectionBoundaries(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
local := filepath.Join(root, "api")
|
||||||
|
shared := filepath.Join(root, "shared")
|
||||||
|
entry := filepath.Join(local, "service.api")
|
||||||
|
writeFile(t, entry, "service demo {\n@handler list\nget /list returns ([]string)\n}\n")
|
||||||
|
writeFile(t, filepath.Join(shared, "errors.json"), `{"defaultStatus":400,"definitions":{"INVALID":{"code":400002,"message":"invalid"}}}`)
|
||||||
|
for _, cfg := range []string{
|
||||||
|
`{"source":"@common/errors.json","include":["UNKNOWN"]}`,
|
||||||
|
`{"source":"@common/errors.json","routes":{"GET /absent":["INVALID"]}}`,
|
||||||
|
`{"source":"@common/../api/service.api"}`,
|
||||||
|
`{"source":"../shared/errors.json"}`,
|
||||||
|
} {
|
||||||
|
writeFile(t, filepath.Join(local, importsFile), `{"roots":{"common":{"path":"../shared"}},"errors":`+cfg+`}`)
|
||||||
|
if _, err := Parse(entry); err == nil {
|
||||||
|
t.Fatal("bad selection accepted", cfg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeFile(t, filepath.Join(local, importsFile), `{"roots":{"common":{"path":"../shared"}},"errors":{"source":"@common/errors.json","include":[]}}`)
|
||||||
|
spec, err := Parse(entry)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(documentRouteCatalog(spec, spec.Routes[0]).Definitions) != 0 {
|
||||||
|
t.Fatal("empty include ignored")
|
||||||
|
}
|
||||||
|
seeded, err := sharedSeeds(spec, filepath.Join(root, "generated"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var cfg sharedConfig
|
||||||
|
if err = json.Unmarshal(seeded, &cfg); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if cfg.Errors.Include == nil || !bytes.Contains(seeded, []byte(`"include": []`)) {
|
||||||
|
t.Fatal("empty selection lost during relocation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWorkspaceCatalogPathsAndProviderValidation(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
writeFile(t, filepath.Join(root, "go.mod"), "module example.com/test\ngo 1.24.0\n")
|
||||||
|
good := WorkspaceCatalog{Dir: ".", Package: "example.com/test/internal/errors", Function: "Catalog", Output: "errors.json"}
|
||||||
|
cases := []WorkspaceCatalog{}
|
||||||
|
for _, change := range []func(*WorkspaceCatalog){
|
||||||
|
func(c *WorkspaceCatalog) { c.Dir = "../outside" },
|
||||||
|
func(c *WorkspaceCatalog) { c.Output = "../outside.json" },
|
||||||
|
func(c *WorkspaceCatalog) { c.Package = "example.com/pkg;bad" },
|
||||||
|
func(c *WorkspaceCatalog) { c.Function = "Catalog()" },
|
||||||
|
func(c *WorkspaceCatalog) { c.Output = "go.mod" },
|
||||||
|
} {
|
||||||
|
c := good
|
||||||
|
change(&c)
|
||||||
|
cases = append(cases, c)
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if _, err := exportWorkspaceCatalog(root, c); err == nil {
|
||||||
|
t.Fatal("bad provider accepted", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
outside := filepath.Join(t.TempDir(), "errors.json")
|
||||||
|
writeFile(t, outside, `{}`)
|
||||||
|
if err := os.Symlink(outside, filepath.Join(root, "errors.json")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := prepareWorkspaceCatalogs(root, []WorkspaceCatalog{good}, false); err == nil {
|
||||||
|
t.Fatal("symlink output accepted")
|
||||||
|
}
|
||||||
|
if readFile(t, outside) != `{}` {
|
||||||
|
t.Fatal("outside file modified")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestNewWithRemoteFrameworkVersion(t *testing.T) {
|
func TestNewWithRemoteFrameworkVersion(t *testing.T) {
|
||||||
for _, flag := range [][]string{{"--framework-version", "v0.14.0-preview"}, {"--framework-version=v0.14.0-preview"}} {
|
for _, flag := range [][]string{{"--framework-version", "v0.16.0-preview"}, {"--framework-version=v0.16.0-preview"}} {
|
||||||
project := filepath.Join(t.TempDir(), "billing")
|
project := filepath.Join(t.TempDir(), "billing")
|
||||||
args := append([]string{"--module", "example.com/billing"}, flag...)
|
args := append([]string{"--module", "example.com/billing"}, flag...)
|
||||||
if err := runNew(append(args, project)); err != nil {
|
if err := runNew(append(args, project)); err != nil {
|
||||||
@@ -18,7 +18,7 @@ func TestNewWithRemoteFrameworkVersion(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(string(content), "go-omni v0.14.0-preview") || strings.Contains(string(content), "replace ") {
|
if !strings.Contains(string(content), "go-omni v0.16.0-preview") || strings.Contains(string(content), "replace ") {
|
||||||
t.Fatalf("CLI did not pin remote dependency: %s", content)
|
t.Fatalf("CLI did not pin remote dependency: %s", content)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package settings
|
package settings
|
||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
{{if and (not .API) (ne .Storage "none")}}"strings"{{end}}
|
||||||
"time"
|
"time"
|
||||||
"git.yuntuo.hk/k2safe/go-omni/config"
|
"git.yuntuo.hk/k2safe/go-omni/config"
|
||||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||||
@@ -41,10 +41,10 @@ func Load(path string)(Config,error){
|
|||||||
cfg:=Default();var err error
|
cfg:=Default();var err error
|
||||||
cfg,err=config.ApplyDefaults(cfg);if err!=nil{return Config{},err}
|
cfg,err=config.ApplyDefaults(cfg);if err!=nil{return Config{},err}
|
||||||
if path!="" {cfg,err=config.DecodeFile(path,cfg);if err!=nil{return Config{},err}}
|
if path!="" {cfg,err=config.DecodeFile(path,cfg);if err!=nil{return Config{},err}}
|
||||||
cfg,err=config.OverlayEnv(cfg,config.EnvOptions{Prefix:"GO_OMNI_"});if err!=nil{return Config{},err}
|
cfg,err=config.OverlayEnv(cfg,config.EnvOptions{Prefix:"GO_OMNI_",Empty:"ignore",Aliases:config.ServerEnvAliases("GO_OMNI_","server")});if err!=nil{return Config{},err}
|
||||||
// Preserve the documented framework aliases (HTTP_ADDR, LOG_LEVEL, etc).
|
output,outputSet:=os.LookupEnv("GO_OMNI_SERVER_LOG_OUTPUT");if !outputSet {output=os.Getenv("GO_OMNI_LOG_OUTPUT")}
|
||||||
cfg.Server,err=config.ApplyEnv(cfg.Server,"");if err!=nil{return Config{},err}
|
file,fileSet:=os.LookupEnv("GO_OMNI_SERVER_LOG_FILE");if !fileSet {file=os.Getenv("GO_OMNI_LOG_FILE")}
|
||||||
if output:=strings.TrimSpace(os.Getenv("GO_OMNI_LOG_OUTPUT"));output!="" && strings.TrimSpace(os.Getenv("GO_OMNI_LOG_FILE"))=="" {cfg.Server.Log.File="";cfg.Server.Log.Rotation=logx.RotationConfig{}}
|
if output!="" && file=="" {cfg.Server.Log.File="";cfg.Server.Log.Rotation=logx.RotationConfig{}}
|
||||||
return cfg,cfg.Validate()
|
return cfg,cfg.Validate()
|
||||||
}
|
}
|
||||||
func(c Config)Validate()error{
|
func(c Config)Validate()error{
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ type Context struct {
|
|||||||
{{end}}
|
{{end}}
|
||||||
}
|
}
|
||||||
// Init is project-owned. Add dependencies here and immediately register each
|
// Init is project-owned. Add dependencies here and immediately register each
|
||||||
// cleanup with c.OnClose. Returning an error automatically rolls everything back.
|
// cleanup with c.OnClose. Background components use paired OnStop/OnDrain hooks
|
||||||
|
// and WorkContext. Failed initialization stops/drains before releasing resources;
|
||||||
|
// unjoined background work returns ErrResourcesRetained instead of closing its dependencies.
|
||||||
// Generated infrastructure is ready here. Never create another App or logger.
|
// Generated infrastructure is ready here. Never create another App or logger.
|
||||||
func(c *Context)Init(ctx context.Context)error {return nil}
|
func(c *Context)Init(ctx context.Context)error {return nil}
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ func New(ctx context.Context,logger *logx.Logger,configs ...settings.Config)(res
|
|||||||
cfg:=settings.Default();if len(configs)==1{cfg=configs[0]}
|
cfg:=settings.Default();if len(configs)==1{cfg=configs[0]}
|
||||||
if err=cfg.Validate();err!=nil{return nil,err}
|
if err=cfg.Validate();err!=nil{return nil,err}
|
||||||
checker,err:=health.New();if err!=nil{return nil,err}
|
checker,err:=health.New();if err!=nil{return nil,err}
|
||||||
c:=&Context{Config:cfg,Logger:logger,Health:checker,Middlewares:middleware.Defaults(),Runtime:lifecycle.NewRuntime(ctx)}
|
runtime,err:=lifecycle.NewRuntimeWithConfig(logx.WithLogger(ctx,logger),cfg.Server.Lifecycle);if err!=nil{return nil,err}
|
||||||
|
c:=&Context{Config:cfg,Logger:logger,Health:checker,Middlewares:middleware.Defaults(),Runtime:runtime}
|
||||||
ctx=logx.WithLogger(c.Context(),logger)
|
ctx=logx.WithLogger(c.Context(),logger)
|
||||||
defer func(){if p:=recover();p!=nil{_ = c.Close();panic(p)};if err!=nil{err=c.Finish(err)}}()
|
defer func(){if p:=recover();p!=nil{_ = c.Close();panic(p)};if err!=nil{err=c.Finish(err)}}()
|
||||||
{{if eq .Database "mysql"}}
|
{{if eq .Database "mysql"}}
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ server:
|
|||||||
enableLogger: true
|
enableLogger: true
|
||||||
slowRequestThreshold: 500ms
|
slowRequestThreshold: 500ms
|
||||||
shutdownTimeout: 10s
|
shutdownTimeout: 10s
|
||||||
{{if .API}} endpoints:
|
{{if .API}} lifecycle:
|
||||||
|
stopTimeout: 5s
|
||||||
|
drainTimeout: 10s
|
||||||
|
cancelTimeout: 5s
|
||||||
|
endpoints:
|
||||||
liveness:
|
liveness:
|
||||||
enabled: true
|
enabled: true
|
||||||
path: /health/live
|
path: /health/live
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const frameworkModule = "git.yuntuo.hk/k2safe/go-omni"
|
|||||||
// Source builds can carry an unpublished VCS version (including +dirty).
|
// Source builds can carry an unpublished VCS version (including +dirty).
|
||||||
// Keep this fallback in sync with the next release; installed CLIs use their
|
// Keep this fallback in sync with the next release; installed CLIs use their
|
||||||
// downloaded module version, which also has a module checksum.
|
// downloaded module version, which also has a module checksum.
|
||||||
const defaultFrameworkVersion = "v0.14.1-preview"
|
const defaultFrameworkVersion = "v0.16.0-preview"
|
||||||
|
|
||||||
// This target is not a claim that the release already exists. Source users
|
// This target is not a claim that the release already exists. Source users
|
||||||
// select a local framework checkout until the module templates are published.
|
// select a local framework checkout until the module templates are published.
|
||||||
|
|||||||
66
config/aliases_test.go
Normal file
66
config/aliases_test.go
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestUnifiedEnvAliases(t *testing.T) {
|
||||||
|
type project struct {
|
||||||
|
Server Config `json:"server"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
}
|
||||||
|
base := project{Server: Default(), Label: "base"}
|
||||||
|
for _, policy := range []string{"allow", "ignore", "reject"} {
|
||||||
|
t.Run(policy, func(t *testing.T) {
|
||||||
|
env := map[string]string{"TEST_HTTP_ADDR": "127.0.0.1:8099", "TEST_LOG_SERVICE": "", "TEST_SERVICE": "", "TEST_LABEL": ""}
|
||||||
|
opts := EnvOptions{Prefix: "TEST_", Empty: policy, Aliases: ServerEnvAliases("TEST_", "server"), Lookup: func(k string) (string, bool) { v, ok := env[k]; return v, ok }}
|
||||||
|
got, err := OverlayEnv(base, opts)
|
||||||
|
if policy == "reject" {
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "empty") {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.Server.Address != "127.0.0.1:8099" {
|
||||||
|
t.Fatal("alias ignored")
|
||||||
|
}
|
||||||
|
if policy == "allow" && got.Label != "" || policy == "ignore" && got.Label != "base" {
|
||||||
|
t.Fatal("empty policy differs")
|
||||||
|
}
|
||||||
|
env["TEST_SERVER_ADDRESS"] = "127.0.0.1:9001"
|
||||||
|
env["TEST_HTTP_READ_TIMEOUT"] = "3s"
|
||||||
|
env["TEST_ENABLE_LOGGER"] = "false"
|
||||||
|
env["TEST_TRUSTED_PROXIES"] = `["127.0.0.1"]`
|
||||||
|
got, err = OverlayEnv(base, opts)
|
||||||
|
if err != nil || got.Server.Address != "127.0.0.1:9001" || got.Server.HTTP.ReadTimeout != 3*time.Second || got.Server.EnableLogger || len(got.Server.TrustedProxies) != 1 {
|
||||||
|
t.Fatal(got, err)
|
||||||
|
}
|
||||||
|
env["TEST_SERVER_ADDRESS"] = ""
|
||||||
|
got, err = OverlayEnv(base, opts)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
expected := ""
|
||||||
|
if policy == "ignore" {
|
||||||
|
expected = base.Server.Address
|
||||||
|
}
|
||||||
|
if got.Server.Address != expected {
|
||||||
|
t.Fatal("empty canonical unexpectedly fell through to alias")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if base.Server.Address != ":8080" || base.Label != "base" {
|
||||||
|
t.Fatal("defaults mutated")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestAliasTypeErrorsDoNotExposeValue(t *testing.T) {
|
||||||
|
opts := EnvOptions{Prefix: "X_", Empty: "reject", Aliases: ServerEnvAliases("X_", ""), Lookup: func(k string) (string, bool) { return "private-invalid", k == "X_HTTP_READ_TIMEOUT" }}
|
||||||
|
if _, err := OverlayEnv(Default(), opts); err == nil || strings.Contains(err.Error(), "private-invalid") {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"git.yuntuo.hk/k2safe/go-omni/contract"
|
"git.yuntuo.hk/k2safe/go-omni/contract"
|
||||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
|
||||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||||
"git.yuntuo.hk/k2safe/go-omni/response"
|
"git.yuntuo.hk/k2safe/go-omni/response"
|
||||||
)
|
)
|
||||||
@@ -27,22 +28,23 @@ type TLSConfig struct {
|
|||||||
|
|
||||||
// Config controls the default runtime behavior of an App.
|
// Config controls the default runtime behavior of an App.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Request contract.RequestPolicy `json:"request"`
|
Request contract.RequestPolicy `json:"request"`
|
||||||
Address string `json:"address"`
|
Address string `json:"address"`
|
||||||
Transport string `json:"transport"`
|
Transport string `json:"transport"`
|
||||||
TLS TLSConfig `json:"tls"`
|
TLS TLSConfig `json:"tls"`
|
||||||
HTTP HTTPConfig `json:"http"`
|
HTTP HTTPConfig `json:"http"`
|
||||||
Endpoints EndpointsConfig `json:"endpoints"`
|
Endpoints EndpointsConfig `json:"endpoints"`
|
||||||
RequestIDHeader string `json:"requestIdHeader"`
|
RequestIDHeader string `json:"requestIdHeader"`
|
||||||
EnableLogger bool `json:"enableLogger"`
|
EnableLogger bool `json:"enableLogger"`
|
||||||
SlowRequestThreshold time.Duration `json:"slowRequestThreshold"`
|
SlowRequestThreshold time.Duration `json:"slowRequestThreshold"`
|
||||||
TrustedProxies []string `json:"trustedProxies"`
|
TrustedProxies []string `json:"trustedProxies"`
|
||||||
ShutdownTimeout time.Duration `json:"shutdownTimeout"`
|
ShutdownTimeout time.Duration `json:"shutdownTimeout"`
|
||||||
Log logx.Config `json:"log"`
|
Lifecycle lifecycle.ShutdownConfig `json:"lifecycle"`
|
||||||
Errors apierrors.Catalog `json:"errors"`
|
Log logx.Config `json:"log"`
|
||||||
Response response.Config `json:"response"`
|
Errors apierrors.Catalog `json:"errors"`
|
||||||
ResponseEncoder response.Encoder `json:"-"`
|
Response response.Config `json:"response"`
|
||||||
LogInstance *logx.Logger `json:"-"`
|
ResponseEncoder response.Encoder `json:"-"`
|
||||||
|
LogInstance *logx.Logger `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func Default() Config {
|
func Default() Config {
|
||||||
@@ -51,6 +53,7 @@ func Default() Config {
|
|||||||
Transport: TransportNetHTTP,
|
Transport: TransportNetHTTP,
|
||||||
RequestIDHeader: defaultRequestIDHeader,
|
RequestIDHeader: defaultRequestIDHeader,
|
||||||
ShutdownTimeout: 10 * time.Second,
|
ShutdownTimeout: 10 * time.Second,
|
||||||
|
Lifecycle: lifecycle.DefaultShutdownConfig(),
|
||||||
Endpoints: DefaultEndpoints(),
|
Endpoints: DefaultEndpoints(),
|
||||||
Log: logx.Default(),
|
Log: logx.Default(),
|
||||||
}
|
}
|
||||||
@@ -58,6 +61,9 @@ func Default() Config {
|
|||||||
|
|
||||||
// Validate checks configuration values before an application starts.
|
// Validate checks configuration values before an application starts.
|
||||||
func (c Config) Validate() error {
|
func (c Config) Validate() error {
|
||||||
|
if err := c.Lifecycle.Validate(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if err := c.Request.Validate(); err != nil {
|
if err := c.Request.Validate(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
168
config/env.go
168
config/env.go
@@ -1,135 +1,83 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"encoding/json"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const defaultEnvPrefix = "GO_OMNI_"
|
const defaultEnvPrefix = "GO_OMNI_"
|
||||||
|
|
||||||
// LoadFromEnv loads application settings from GO_OMNI_* variables. The
|
// ServerEnvAliases returns the documented short names for Config fields.
|
||||||
// database settings remain owned by database.ConfigFromEnv.
|
// field is the JSON path of Config in a project (usually "server"), or ""
|
||||||
func LoadFromEnv(prefix string) (Config, error) {
|
// for Config itself. Canonical names win over aliases. Both use the same
|
||||||
return applyEnv(Default(), prefix)
|
// EnvOptions.Empty/Lookup and typed parser; collections use JSON syntax.
|
||||||
|
func ServerEnvAliases(prefix, field string) map[string][]string {
|
||||||
|
root := prefix
|
||||||
|
if field != "" {
|
||||||
|
for _, part := range strings.Split(field, ".") {
|
||||||
|
root += envName(part) + "_"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
names := map[string]string{
|
||||||
|
"ADDRESS": "HTTP_ADDR", "TRANSPORT": "TRANSPORT", "TLS_CERT_FILE": "TLS_CERT_FILE", "TLS_KEY_FILE": "TLS_KEY_FILE",
|
||||||
|
"REQUEST_ID_HEADER": "REQUEST_ID_HEADER", "ENABLE_LOGGER": "ENABLE_LOGGER", "SLOW_REQUEST_THRESHOLD": "SLOW_REQUEST_THRESHOLD",
|
||||||
|
"TRUSTED_PROXIES": "TRUSTED_PROXIES", "SHUTDOWN_TIMEOUT": "SHUTDOWN_TIMEOUT",
|
||||||
|
"LOG_LEVEL": "LOG_LEVEL", "LOG_FORMAT": "LOG_FORMAT", "LOG_OUTPUT": "LOG_OUTPUT", "LOG_FILE": "LOG_FILE", "LOG_SERVICE": "SERVICE", "LOG_ENVIRONMENT": "ENVIRONMENT", "LOG_ADD_SOURCE": "LOG_ADD_SOURCE",
|
||||||
|
"HTTP_READ_HEADER_TIMEOUT": "HTTP_READ_HEADER_TIMEOUT", "HTTP_READ_TIMEOUT": "HTTP_READ_TIMEOUT", "HTTP_WRITE_TIMEOUT": "HTTP_WRITE_TIMEOUT", "HTTP_IDLE_TIMEOUT": "HTTP_IDLE_TIMEOUT", "HTTP_MAX_HEADER_BYTES": "HTTP_MAX_HEADER_BYTES",
|
||||||
|
"LIFECYCLE_STOP_TIMEOUT": "LIFECYCLE_STOP_TIMEOUT", "LIFECYCLE_DRAIN_TIMEOUT": "LIFECYCLE_DRAIN_TIMEOUT", "LIFECYCLE_CANCEL_TIMEOUT": "LIFECYCLE_CANCEL_TIMEOUT",
|
||||||
|
}
|
||||||
|
for _, endpoint := range []string{"LIVENESS", "READINESS", "OPENAPI"} {
|
||||||
|
for _, part := range []string{"ENABLED", "PATH", "RESPONSE_MODE"} {
|
||||||
|
if endpoint == "OPENAPI" && part == "RESPONSE_MODE" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := "ENDPOINTS_" + endpoint + "_" + part
|
||||||
|
names[name] = name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
aliases := map[string][]string{}
|
||||||
|
for key, alias := range names {
|
||||||
|
if root+key != prefix+alias {
|
||||||
|
aliases[root+key] = []string{prefix + alias}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return aliases
|
||||||
}
|
}
|
||||||
|
|
||||||
// ApplyEnv overlays nonempty environment values on a caller's defaults or file
|
// LoadFromEnv loads defaults using the legacy short-name conventions.
|
||||||
// settings, then validates the result. Caller-owned slices are not retained.
|
func LoadFromEnv(prefix string) (Config, error) { return ApplyEnv(Default(), prefix) }
|
||||||
|
|
||||||
|
// ApplyEnv retains legacy whitespace trimming, ignored empty variables and
|
||||||
|
// comma-separated TRUSTED_PROXIES. New projects should use OverlayEnv with
|
||||||
|
// ServerEnvAliases for one explicit empty-value policy across the whole config.
|
||||||
func ApplyEnv(defaults Config, prefix string) (Config, error) {
|
func ApplyEnv(defaults Config, prefix string) (Config, error) {
|
||||||
defaults.Errors = defaults.Errors.Clone()
|
|
||||||
defaults.TrustedProxies = append([]string(nil), defaults.TrustedProxies...)
|
|
||||||
defaults.Log.RedactKeys = append([]string(nil), defaults.Log.RedactKeys...)
|
|
||||||
return applyEnv(defaults, prefix)
|
|
||||||
}
|
|
||||||
|
|
||||||
func applyEnv(cfg Config, prefix string) (Config, error) {
|
|
||||||
prefix = strings.TrimSpace(prefix)
|
prefix = strings.TrimSpace(prefix)
|
||||||
if prefix == "" {
|
if prefix == "" {
|
||||||
prefix = defaultEnvPrefix
|
prefix = defaultEnvPrefix
|
||||||
}
|
}
|
||||||
cfg.Address = envString(prefix+"HTTP_ADDR", cfg.Address)
|
lookup := func(key string) (string, bool) {
|
||||||
cfg.Transport = envString(prefix+"TRANSPORT", cfg.Transport)
|
raw, ok := os.LookupEnv(key)
|
||||||
cfg.TLS.CertFile = envString(prefix+"TLS_CERT_FILE", cfg.TLS.CertFile)
|
raw = strings.TrimSpace(raw)
|
||||||
cfg.TLS.KeyFile = envString(prefix+"TLS_KEY_FILE", cfg.TLS.KeyFile)
|
if ok && raw != "" && key == prefix+"TRUSTED_PROXIES" && !strings.HasPrefix(raw, "[") {
|
||||||
cfg.RequestIDHeader = envString(prefix+"REQUEST_ID_HEADER", cfg.RequestIDHeader)
|
values := strings.Split(raw, ",")
|
||||||
if value := strings.TrimSpace(os.Getenv(prefix + "TRUSTED_PROXIES")); value != "" {
|
for i := range values {
|
||||||
cfg.TrustedProxies = strings.Split(value, ",")
|
values[i] = strings.TrimSpace(values[i])
|
||||||
for i := range cfg.TrustedProxies {
|
|
||||||
cfg.TrustedProxies[i] = strings.TrimSpace(cfg.TrustedProxies[i])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cfg.Log.Level = envString(prefix+"LOG_LEVEL", cfg.Log.Level)
|
|
||||||
cfg.Log.Format = envString(prefix+"LOG_FORMAT", cfg.Log.Format)
|
|
||||||
cfg.Log.Output = envString(prefix+"LOG_OUTPUT", cfg.Log.Output)
|
|
||||||
cfg.Log.File = envString(prefix+"LOG_FILE", cfg.Log.File)
|
|
||||||
cfg.Log.Service = envString(prefix+"SERVICE", cfg.Log.Service)
|
|
||||||
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
|
|
||||||
}{
|
|
||||||
{"HTTP_READ_HEADER_TIMEOUT", &cfg.HTTP.ReadHeaderTimeout},
|
|
||||||
{"HTTP_READ_TIMEOUT", &cfg.HTTP.ReadTimeout},
|
|
||||||
{"HTTP_WRITE_TIMEOUT", &cfg.HTTP.WriteTimeout},
|
|
||||||
{"HTTP_IDLE_TIMEOUT", &cfg.HTTP.IdleTimeout},
|
|
||||||
} {
|
|
||||||
if value := strings.TrimSpace(os.Getenv(prefix + field.name)); value != "" {
|
|
||||||
*field.value, err = time.ParseDuration(value)
|
|
||||||
if err != nil {
|
|
||||||
return Config{}, fmt.Errorf("%s%s must be a duration: %w", prefix, field.name, err)
|
|
||||||
}
|
}
|
||||||
|
data, _ := json.Marshal(values)
|
||||||
|
raw = string(data)
|
||||||
}
|
}
|
||||||
|
return raw, ok
|
||||||
}
|
}
|
||||||
if value := strings.TrimSpace(os.Getenv(prefix + "HTTP_MAX_HEADER_BYTES")); value != "" {
|
defaults.Errors = defaults.Errors.Clone()
|
||||||
cfg.HTTP.MaxHeaderBytes, err = strconv.Atoi(value)
|
defaults.TrustedProxies = append([]string(nil), defaults.TrustedProxies...)
|
||||||
if err != nil {
|
defaults.Log.RedactKeys = append([]string(nil), defaults.Log.RedactKeys...)
|
||||||
return Config{}, fmt.Errorf("%sHTTP_MAX_HEADER_BYTES must be an integer: %w", prefix, err)
|
cfg, err := OverlayEnv(defaults, EnvOptions{Prefix: prefix, Empty: "ignore", Lookup: lookup, Aliases: ServerEnvAliases(prefix, "")})
|
||||||
}
|
if err != nil {
|
||||||
}
|
|
||||||
if cfg.EnableLogger, err = envBool(prefix+"ENABLE_LOGGER", cfg.EnableLogger); err != nil {
|
|
||||||
return Config{}, err
|
return Config{}, err
|
||||||
}
|
}
|
||||||
if cfg.Log.AddSource, err = envBool(prefix+"LOG_ADD_SOURCE", cfg.Log.AddSource); err != nil {
|
if err = cfg.Validate(); err != nil {
|
||||||
return Config{}, err
|
|
||||||
}
|
|
||||||
if value := strings.TrimSpace(os.Getenv(prefix + "SLOW_REQUEST_THRESHOLD")); value != "" {
|
|
||||||
cfg.SlowRequestThreshold, err = time.ParseDuration(value)
|
|
||||||
if err != nil {
|
|
||||||
return Config{}, fmt.Errorf("%sSLOW_REQUEST_THRESHOLD must be a duration: %w", prefix, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if value := strings.TrimSpace(os.Getenv(prefix + "SHUTDOWN_TIMEOUT")); value != "" {
|
|
||||||
cfg.ShutdownTimeout, err = time.ParseDuration(value)
|
|
||||||
if err != nil {
|
|
||||||
return Config{}, fmt.Errorf("%sSHUTDOWN_TIMEOUT must be a duration: %w", prefix, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := cfg.Validate(); err != nil {
|
|
||||||
return Config{}, err
|
return Config{}, err
|
||||||
}
|
}
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func envString(key, fallback string) string {
|
|
||||||
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
func envBool(key string, fallback bool) (bool, error) {
|
|
||||||
value := strings.TrimSpace(os.Getenv(key))
|
|
||||||
if value == "" {
|
|
||||||
return fallback, nil
|
|
||||||
}
|
|
||||||
parsed, err := strconv.ParseBool(value)
|
|
||||||
if err != nil {
|
|
||||||
return false, fmt.Errorf("%s must be a boolean: %w", key, err)
|
|
||||||
}
|
|
||||||
return parsed, nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ func LoadFile(path, prefix string) (Config, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return Config{}, err
|
return Config{}, err
|
||||||
}
|
}
|
||||||
return applyEnv(cfg, prefix)
|
return ApplyEnv(cfg, prefix)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reloader only accepts log.level changes. Static changes and invalid files
|
// Reloader only accepts log.level changes. Static changes and invalid files
|
||||||
|
|||||||
37
config/lifecycle_test.go
Normal file
37
config/lifecycle_test.go
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
package config_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/config"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLifecycleConfiguration(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "config.yaml")
|
||||||
|
if err := os.WriteFile(path, []byte("lifecycle:\n stopTimeout: 2s\n drainTimeout: 3s\n cancelTimeout: 4s\n"), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cfg, err := config.LoadFile(path, "PHASE_TEST_")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if cfg.Lifecycle != (lifecycle.ShutdownConfig{StopTimeout: 2 * time.Second, DrainTimeout: 3 * time.Second, CancelTimeout: 4 * time.Second}) {
|
||||||
|
t.Fatal(cfg.Lifecycle)
|
||||||
|
}
|
||||||
|
for _, key := range []string{"STOP_TIMEOUT", "DRAIN_TIMEOUT", "CANCEL_TIMEOUT"} {
|
||||||
|
t.Setenv("PHASE_TEST_LIFECYCLE_"+key, "6s")
|
||||||
|
}
|
||||||
|
cfg, err = config.ApplyEnv(cfg, "PHASE_TEST_")
|
||||||
|
if err != nil || cfg.Lifecycle.StopTimeout != 6*time.Second || cfg.Lifecycle.DrainTimeout != 6*time.Second || cfg.Lifecycle.CancelTimeout != 6*time.Second {
|
||||||
|
t.Fatal(cfg.Lifecycle, err)
|
||||||
|
}
|
||||||
|
for _, v := range []string{"bad", "-1s"} {
|
||||||
|
t.Setenv("PHASE_TEST_LIFECYCLE_DRAIN_TIMEOUT", v)
|
||||||
|
if _, err := config.ApplyEnv(cfg, "PHASE_TEST_"); err == nil {
|
||||||
|
t.Fatal("invalid duration accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,9 @@ type EnvOptions struct {
|
|||||||
Prefix string
|
Prefix string
|
||||||
Empty string
|
Empty string
|
||||||
Lookup func(string) (string, bool)
|
Lookup func(string) (string, bool)
|
||||||
|
// Aliases maps canonical absolute environment names to ordered fallback names.
|
||||||
|
// A present canonical value wins, including an explicit empty value.
|
||||||
|
Aliases map[string][]string
|
||||||
}
|
}
|
||||||
|
|
||||||
// ApplyDefaults applies envDefault tags to zero fields before file overlays.
|
// ApplyDefaults applies envDefault tags to zero fields before file overlays.
|
||||||
@@ -162,13 +165,22 @@ func projectOverlay[T any](base T, options EnvOptions, defaults bool) (T, error)
|
|||||||
}
|
}
|
||||||
seen[key] = true
|
seen[key] = true
|
||||||
raw, present := options.Lookup(key)
|
raw, present := options.Lookup(key)
|
||||||
|
selectedKey := key
|
||||||
|
if !present && !defaults {
|
||||||
|
for _, alias := range options.Aliases[key] {
|
||||||
|
if raw, present = options.Lookup(alias); present {
|
||||||
|
selectedKey = alias
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if defaults {
|
if defaults {
|
||||||
raw, present = field.Tag.Lookup("envDefault")
|
raw, present = field.Tag.Lookup("envDefault")
|
||||||
present = present && dst.IsZero()
|
present = present && dst.IsZero()
|
||||||
}
|
}
|
||||||
if present && raw == "" && !defaults {
|
if present && raw == "" && !defaults {
|
||||||
if empty == "reject" {
|
if empty == "reject" {
|
||||||
return fmt.Errorf("config: %s must not be empty", key)
|
return fmt.Errorf("config: %s must not be empty", selectedKey)
|
||||||
}
|
}
|
||||||
if empty == "ignore" {
|
if empty == "ignore" {
|
||||||
present = false
|
present = false
|
||||||
@@ -177,7 +189,7 @@ func projectOverlay[T any](base T, options EnvOptions, defaults bool) (T, error)
|
|||||||
if present {
|
if present {
|
||||||
parsed := reflect.New(dst.Type()).Elem()
|
parsed := reflect.New(dst.Type()).Elem()
|
||||||
if err := parseEnvValue(parsed, raw); err != nil {
|
if err := parseEnvValue(parsed, raw); err != nil {
|
||||||
return fmt.Errorf("config: %s must be a valid %s", key, dst.Type())
|
return fmt.Errorf("config: %s must be a valid %s", selectedKey, dst.Type())
|
||||||
}
|
}
|
||||||
dst.Set(parsed)
|
dst.Set(parsed)
|
||||||
}
|
}
|
||||||
|
|||||||
23
examples/contracts/arrays.api
Normal file
23
examples/contracts/arrays.api
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
syntax = "v1"
|
||||||
|
@app (defaultResponse: Common)
|
||||||
|
response Common (
|
||||||
|
codeType: int32
|
||||||
|
successCode: 0
|
||||||
|
errorCode: 9999
|
||||||
|
messageField: msg
|
||||||
|
dataMode: always
|
||||||
|
)
|
||||||
|
@server (group: arrays)
|
||||||
|
service arrays {
|
||||||
|
@handler strings
|
||||||
|
get /strings returns ([]string)
|
||||||
|
@handler integers
|
||||||
|
get /integers returns ([]int64)
|
||||||
|
@handler dynamic
|
||||||
|
get /dynamic returns ([]any)
|
||||||
|
@handler empty
|
||||||
|
get /empty returns ([]any)
|
||||||
|
@handler raw
|
||||||
|
@response raw
|
||||||
|
get /raw returns ([]bool)
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"templateVersion": "v0.14.0-preview",
|
"templateVersion": "v0.16.0-preview",
|
||||||
"profile": {
|
"profile": {
|
||||||
"database": "none",
|
"database": "none",
|
||||||
"storage": "none",
|
"storage": "none",
|
||||||
@@ -10,17 +10,17 @@
|
|||||||
"module": "example.com/managed",
|
"module": "example.com/managed",
|
||||||
"entry": "api/managed.api",
|
"entry": "api/managed.api",
|
||||||
"generated": {
|
"generated": {
|
||||||
"docs/openapi.json": "68c7e9e0294bf62937aa953a44732288c51571108c922a36a72faa72c4360b5f",
|
"docs/openapi.json": "0370fd6bd6a387e2ef21f6b42b009ce66749846b8bea3d481009a34f3af039a5",
|
||||||
"internal/handler/appGen.go": "f5251b4dc33e2312b0bdb71ead70d2171977d851265e124f640458d705a2cc9d",
|
"internal/handler/appGen.go": "f5251b4dc33e2312b0bdb71ead70d2171977d851265e124f640458d705a2cc9d",
|
||||||
"internal/handler/demo/handlersGen.go": "3c76d85dbe89eb5127d9d134dc625203ecf9719dd11d174c1f335691b99e1375",
|
"internal/handler/demo/handlersGen.go": "3c76d85dbe89eb5127d9d134dc625203ecf9719dd11d174c1f335691b99e1375",
|
||||||
"internal/handler/openapiGen.go": "ae9b794c63e0f3c8f9d200a5fc6977350139d4a6f00b42a26102c8630d907d03",
|
"internal/handler/openapiGen.go": "f24cab3abbb8557a3fc4dd93fba23cc0b093f3e0c944d3ba1bd5b0eeff2d96f3",
|
||||||
"internal/handler/responseGen.go": "84397a502bba4777ebfa6fae3d3bc418297acab4f26b04b42e3c31b36c735399",
|
"internal/handler/responseGen.go": "84397a502bba4777ebfa6fae3d3bc418297acab4f26b04b42e3c31b36c735399",
|
||||||
"internal/handler/routesGen.go": "76c1a5b2644e75e56553dc6e9e862a63128d902dd94807ce7c8aee5491cd1e3e",
|
"internal/handler/routesGen.go": "76c1a5b2644e75e56553dc6e9e862a63128d902dd94807ce7c8aee5491cd1e3e",
|
||||||
"internal/middleware/registryGen.go": "e5f47efa57a1432765ef3419bae59dbf3b4d55b3aac0a6c935ce7a52ba6b47f4",
|
"internal/middleware/registryGen.go": "e5f47efa57a1432765ef3419bae59dbf3b4d55b3aac0a6c935ce7a52ba6b47f4",
|
||||||
"internal/svc/runtimeGen.go": "78ffd0b2a196fbc29e29473919d1341e8a2f508f6594ad148c0b399fbcc40018",
|
"internal/svc/runtimeGen.go": "29f6afb6614881935099c1845ecb3f7902ee773caa077940f566cb8dfdbcdbb1",
|
||||||
"internal/types/dtoManagedd28645a2af1cGen.go": "7586e86fa5d67051283178cc578c890f6c02fd2d5c383170a71a39c2e77ce344",
|
"internal/types/dtoManagedd28645a2af1cGen.go": "7586e86fa5d67051283178cc578c890f6c02fd2d5c383170a71a39c2e77ce344",
|
||||||
"internal/types/typesGen.go": "372d67f23a313b3e77d2a3b7a236315db9721bc63350bf1f71db1293e57570d1",
|
"internal/types/typesGen.go": "372d67f23a313b3e77d2a3b7a236315db9721bc63350bf1f71db1293e57570d1",
|
||||||
"main.go": "8a4051c0d51fc14ddf22c36d09de706f02499817accc937f0785e3d0fb5e2aa5"
|
"main.go": "64e26d97de7e7c6c17f47186785edc73d43c23d6c20b50bfc7fff6ef2e914f17"
|
||||||
},
|
},
|
||||||
"contract": {
|
"contract": {
|
||||||
"routes": {
|
"routes": {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@ module example.com/managed
|
|||||||
|
|
||||||
go 1.22.0
|
go 1.22.0
|
||||||
|
|
||||||
require git.yuntuo.hk/k2safe/go-omni v0.14.0-preview
|
require git.yuntuo.hk/k2safe/go-omni v0.16.0-preview
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"git.yuntuo.hk/k2safe/go-omni/config"
|
"git.yuntuo.hk/k2safe/go-omni/config"
|
||||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -38,16 +37,19 @@ func Load(path string) (Config, error) {
|
|||||||
return Config{}, err
|
return Config{}, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
cfg, err = config.OverlayEnv(cfg, config.EnvOptions{Prefix: "GO_OMNI_"})
|
cfg, err = config.OverlayEnv(cfg, config.EnvOptions{Prefix: "GO_OMNI_", Empty: "ignore", Aliases: config.ServerEnvAliases("GO_OMNI_", "server")})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Config{}, err
|
return Config{}, err
|
||||||
}
|
}
|
||||||
// Preserve the documented framework aliases (HTTP_ADDR, LOG_LEVEL, etc).
|
output, outputSet := os.LookupEnv("GO_OMNI_SERVER_LOG_OUTPUT")
|
||||||
cfg.Server, err = config.ApplyEnv(cfg.Server, "")
|
if !outputSet {
|
||||||
if err != nil {
|
output = os.Getenv("GO_OMNI_LOG_OUTPUT")
|
||||||
return Config{}, err
|
|
||||||
}
|
}
|
||||||
if output := strings.TrimSpace(os.Getenv("GO_OMNI_LOG_OUTPUT")); output != "" && strings.TrimSpace(os.Getenv("GO_OMNI_LOG_FILE")) == "" {
|
file, fileSet := os.LookupEnv("GO_OMNI_SERVER_LOG_FILE")
|
||||||
|
if !fileSet {
|
||||||
|
file = os.Getenv("GO_OMNI_LOG_FILE")
|
||||||
|
}
|
||||||
|
if output != "" && file == "" {
|
||||||
cfg.Server.Log.File = ""
|
cfg.Server.Log.File = ""
|
||||||
cfg.Server.Log.Rotation = logx.RotationConfig{}
|
cfg.Server.Log.Rotation = logx.RotationConfig{}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -37,7 +37,11 @@ func New(ctx context.Context, logger *logx.Logger, configs ...config.Config) (re
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
c := &ServiceContext{Config: cfg, Logger: logger, Health: checker, Middlewares: middleware.Defaults(), Runtime: lifecycle.NewRuntime(ctx)}
|
runtime, err := lifecycle.NewRuntimeWithConfig(logx.WithLogger(ctx, logger), cfg.Server.Lifecycle)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
c := &ServiceContext{Config: cfg, Logger: logger, Health: checker, Middlewares: middleware.Defaults(), Runtime: runtime}
|
||||||
ctx = logx.WithLogger(c.Context(), logger)
|
ctx = logx.WithLogger(c.Context(), logger)
|
||||||
defer func() {
|
defer func() {
|
||||||
if p := recover(); p != nil {
|
if p := recover(); p != nil {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"example.com/managed/internal/handler"
|
"example.com/managed/internal/handler"
|
||||||
"example.com/managed/internal/svc"
|
"example.com/managed/internal/svc"
|
||||||
"flag"
|
"flag"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
|
||||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
@@ -33,7 +34,13 @@ func run(ctx context.Context, path string) (runErr error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer func() { runErr = errors.Join(runErr, logger.Close()) }()
|
// Unjoined background work may still log. The owning process exits on this
|
||||||
|
// error; do not close its borrowed logger before that work has finished.
|
||||||
|
defer func() {
|
||||||
|
if !errors.Is(runErr, lifecycle.ErrResourcesRetained) {
|
||||||
|
runErr = errors.Join(runErr, logger.Close())
|
||||||
|
}
|
||||||
|
}()
|
||||||
deps, err := svc.New(ctx, logger, cfg)
|
deps, err := svc.New(ctx, logger, cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -1,5 +1,67 @@
|
|||||||
# lifecycle
|
# lifecycle
|
||||||
|
|
||||||
|
## 后台组件分阶段关闭
|
||||||
|
|
||||||
|
从 `v0.15.0-preview` 起,生成项目使用以下顺序:HTTP 停止接收并排空 → 全部 OnStop → 全部 OnDrain → 逆序 OnClose → 关闭日志。初始化、创建 App 或监听失败也经过后台收尾。Runtime 不导入 Orbit、数据库、ORM 或 Redis。
|
||||||
|
|
||||||
|
在项目维护的 svc.Init 中登记即可,main/runtimeGen 由生成器更新。例如使用可选的内存 task.Runner:
|
||||||
|
|
||||||
|
```go
|
||||||
|
jobs, err := task.New(task.Config{Workers: 4, QueueSize: 128})
|
||||||
|
if err != nil { return err }
|
||||||
|
c.OnStop("jobs", jobs.StopAccepting)
|
||||||
|
c.OnDrain("jobs", jobs.Wait)
|
||||||
|
// 把 jobs 保存到项目的 svc 字段,供 Logic 使用。
|
||||||
|
// 已接受任务使用 c.WorkContext(),保留到 HTTP 和后台正常排空结束。
|
||||||
|
```
|
||||||
|
|
||||||
|
数据库、Redis 或第三方客户端仍用已有的 `c.OnClose(client.Close)`;框架创建的 DB 已登记,不要再次登记。这里的 jobs 不再额外登记 Close:StopAccepting 停止定时调度/提交,Wait 等待 worker 与回调全部退出,已完成其生命周期。
|
||||||
|
|
||||||
|
任何自定义组件都可以登记 `func(context.Context) error`,不必实现或依赖框架任务接口:
|
||||||
|
|
||||||
|
- OnStop 只停止接收新工作,不能关闭正在执行的工作所需的资源。每个 OnStop 必须有同名 OnDrain;缺失时拒绝释放共享资源。
|
||||||
|
- OnDrain 返回 nil 表示已经收尾,包含最终写入、确认和监控协程退出。返回错误不作为已完成证明;运行中的业务错误可以先通过 RequestShutdown(err) 上报,再等完成后由 Drain 返回 nil。
|
||||||
|
- 同一阶段的回调并发执行,不按登记顺序执行;框架等待整个阶段结束后才进入下一阶段。有组件之间的先后依赖时,组合为一个 Stop/Drain 登记项,由该项明确组织内部顺序。Stop 不能等待 Drain 才会完成的工作。
|
||||||
|
- OnClose 保留原来的同步逆序执行;普通项目无后台组件时无需登记新钩子。生命周期钩子不能再次调用 Runtime.Close/Finish,否则会等待自身;可以调用 RequestShutdown。
|
||||||
|
- 所有依赖初始化成功后立即登记,再启动使用它们的工作。构造器交付前失败的内部资源仍由构造器释放。
|
||||||
|
|
||||||
|
### 两种 context
|
||||||
|
|
||||||
|
`c.Context()` / `c.Lifecycle.Context()` 是早期退出通知,会随外部信号或后台故障取消。`c.WorkContext()` 保留初始化 context 的值但不继承其取消/截止时间,允许已接受工作在 HTTP 收尾期间继续使用依赖。
|
||||||
|
|
||||||
|
停止接收工作由 OnStop 负责,不能仅等待 WorkContext 取消。正常情况下 WorkContext 在后台排空完成后取消;阶段失败或超时会请求取消工作。框架不假设取消已经意味着 goroutine 结束,仍以 Drain 的完成为准。每个业务任务的执行时限继续由项目/任务组件设置。
|
||||||
|
|
||||||
|
### 时限与失败
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
server:
|
||||||
|
shutdownTimeout: 10s # HTTP 排空时限,保持原语义
|
||||||
|
lifecycle:
|
||||||
|
stopTimeout: 5s
|
||||||
|
drainTimeout: 10s
|
||||||
|
cancelTimeout: 5s
|
||||||
|
```
|
||||||
|
|
||||||
|
这些是各阶段的整体预算,不是每个回调分别等待这么久。零使用默认值,负值在启动前拒绝。可用 `GO_OMNI_LIFECYCLE_STOP_TIMEOUT`、`GO_OMNI_LIFECYCLE_DRAIN_TIMEOUT`、`GO_OMNI_LIFECYCLE_CANCEL_TIMEOUT` 覆盖;生成配置还支持完整的 `GO_OMNI_SERVER_LIFECYCLE_*` 路径,别名优先。配置应在 config 中设置,不放在创建 App 时才执行的 AppOptions 中。
|
||||||
|
|
||||||
|
阶段超时会取消阶段 context 和 WorkContext,再给尚未返回的钩子一个 cancelTimeout 收尾窗口。若所有钩子都返回 nil,可以释放依赖,但仍返回超时错误,进程非零退出。task.Wait 收到取消会取消其任务并返回错误;这不证明全部任务已退出,因此仍按以下保守规则处理。
|
||||||
|
|
||||||
|
Stop 超时且仍未返回的组件,不会再并发调用它同名的 Drain;其他组件仍可继续收尾。此时共享资源始终保留,避免未退出的 Stop 和 Drain 同时操作同一个组件。
|
||||||
|
|
||||||
|
钩子返回错误、panic、缺少匹配 Drain,或窗口结束后仍有钩子未返回时,Runtime 返回可由 `errors.Is(err, lifecycle.ErrResourcesRetained)` 判断的错误,**跳过全部 OnClose**;生成入口也保留借用的日志实例并非零退出,由进程退出释放剩余 OS 资源。不会悄悄重试 Close,也不会让晚到的后台结果触发自动关闭。错误链保留初始化/监听/后台故障及具体 HookError 的阶段、名称和原始原因。
|
||||||
|
|
||||||
|
手写入口必须处理这个错误并终止该服务,不能继续运行半关闭的 Runtime;嵌入式宿主需自行安排隔离/终止策略。Go 不能强杀不响应取消的 goroutine,有限等待与保证它已经结束不能同时无条件成立。OnClose 仍要求自身能返回;本版阶段时限不会强制中断资源 Close。HTTP 超时沿用现有传输层规则,业务处理器仍须响应请求取消。
|
||||||
|
|
||||||
|
手写项目使用 `lifecycle.NewRuntimeWithConfig(ctx, cfg)`;完成 HTTP 排空后调用 `runtime.Finish(runErr)`。库本身不执行 os.Exit。
|
||||||
|
|
||||||
|
### 升级
|
||||||
|
|
||||||
|
v0.14.x 受管理项目将 module 和 CLI 一起升级到 v0.15.0-preview,再运行 `go-omni api generate`、`go-omni api generate --check` 和业务测试。生成器更新 main/runtimeGen,保留 svc、Logic 和配置。已有配置不添加新字段也会使用默认时限,需要调整时再编辑 yaml。
|
||||||
|
|
||||||
|
要获得后台分阶段保证,项目需把原先 `OnClose(worker.Close)` 的后台组件改成对应 Stop/Drain,并使用适合的工作 context;框架不会猜测旧 Close 回调负责数据库还是 worker。仅在 OnClose 中登记的旧任务继续遵循旧的逆序清理规则。
|
||||||
|
|
||||||
|
## 退出通知
|
||||||
|
|
||||||
Controller 是服务退出通知入口,不管理具体数据库、租约或后台任务。生成项目已经将它接入 svc 与 main,业务在 svc 调用 `c.RequestShutdown(err)` 即可,见 [项目指南](../PROJECT.md#依赖主动申请退出)。
|
Controller 是服务退出通知入口,不管理具体数据库、租约或后台任务。生成项目已经将它接入 svc 与 main,业务在 svc 调用 `c.RequestShutdown(err)` 即可,见 [项目指南](../PROJECT.md#依赖主动申请退出)。
|
||||||
|
|
||||||
手写服务也可组合现有 App:
|
手写服务也可组合现有 App:
|
||||||
|
|||||||
184
lifecycle/phases.go
Normal file
184
lifecycle/phases.go
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
package lifecycle
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrResourcesRetained means background quiescence could not be established.
|
||||||
|
// OnClose callbacks were deliberately skipped. Close is not retried; an owning
|
||||||
|
// process should exit unsuccessfully instead of continuing with a stopped Runtime.
|
||||||
|
var ErrResourcesRetained = errors.New("lifecycle: background shutdown incomplete; shared resources retained")
|
||||||
|
|
||||||
|
type ShutdownConfig struct {
|
||||||
|
StopTimeout time.Duration `json:"stopTimeout"`
|
||||||
|
DrainTimeout time.Duration `json:"drainTimeout"`
|
||||||
|
CancelTimeout time.Duration `json:"cancelTimeout"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func DefaultShutdownConfig() ShutdownConfig {
|
||||||
|
return ShutdownConfig{StopTimeout: 5 * time.Second, DrainTimeout: 10 * time.Second, CancelTimeout: 5 * time.Second}
|
||||||
|
}
|
||||||
|
func (c ShutdownConfig) Validate() error {
|
||||||
|
if c.StopTimeout < 0 || c.DrainTimeout < 0 || c.CancelTimeout < 0 {
|
||||||
|
return errors.New("lifecycle: shutdown timeouts must be nonnegative")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (c ShutdownConfig) defaults() ShutdownConfig {
|
||||||
|
d := DefaultShutdownConfig()
|
||||||
|
if c.StopTimeout == 0 {
|
||||||
|
c.StopTimeout = d.StopTimeout
|
||||||
|
}
|
||||||
|
if c.DrainTimeout == 0 {
|
||||||
|
c.DrainTimeout = d.DrainTimeout
|
||||||
|
}
|
||||||
|
if c.CancelTimeout == 0 {
|
||||||
|
c.CancelTimeout = d.CancelTimeout
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// HookError identifies the failed phase/component and preserves the original error.
|
||||||
|
type HookError struct {
|
||||||
|
Phase, Name string
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *HookError) Error() string { return fmt.Sprintf("lifecycle %s %q: %v", e.Phase, e.Name, e.Err) }
|
||||||
|
func (e *HookError) Unwrap() error { return e.Err }
|
||||||
|
|
||||||
|
// phaseError keeps diagnostics separate from proof that every hook succeeded.
|
||||||
|
// A deadline is still reported when all callbacks finish during cancellation grace.
|
||||||
|
type phaseError struct {
|
||||||
|
err error
|
||||||
|
unsafe bool
|
||||||
|
unjoined map[string]bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *phaseError) Error() string { return e.err.Error() }
|
||||||
|
func (e *phaseError) Unwrap() error { return e.err }
|
||||||
|
func unsafePhase(err error) bool { var p *phaseError; return errors.As(err, &p) && p.unsafe }
|
||||||
|
|
||||||
|
type shutdownHook struct {
|
||||||
|
name string
|
||||||
|
fn func(context.Context) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnStop registers a prompt, idempotent stop-admission callback. It must not
|
||||||
|
// close resources used by accepted work. All stop hooks precede all drain hooks.
|
||||||
|
// Hooks within a phase run concurrently: combine dependent components in one hook.
|
||||||
|
// Every OnStop requires a matching OnDrain with the same name.
|
||||||
|
func (r *Runtime) OnStop(name string, fn func(context.Context) error) { r.addHook(&r.stops, name, fn) }
|
||||||
|
|
||||||
|
// OnDrain registers proof of quiescence. Return nil only after the component can
|
||||||
|
// no longer use shared resources, including final acknowledgements/cleanup.
|
||||||
|
// Deadline cancellation asks the component to stop; returning ctx.Err alone does
|
||||||
|
// not prove completion. A callback may finish during the cancellation grace.
|
||||||
|
func (r *Runtime) OnDrain(name string, fn func(context.Context) error) {
|
||||||
|
r.addHook(&r.drains, name, fn)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Runtime) addHook(list *[]shutdownHook, name string, fn func(context.Context) error) {
|
||||||
|
if fn == nil || name == "" || len(name) > 128 || strings.TrimSpace(name) != name || strings.IndexFunc(name, unicode.IsControl) >= 0 {
|
||||||
|
panic("lifecycle: valid hook name and function required")
|
||||||
|
}
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
if r.closed {
|
||||||
|
panic("lifecycle: register hook after Close")
|
||||||
|
}
|
||||||
|
for _, h := range *list {
|
||||||
|
if h.name == name {
|
||||||
|
panic("lifecycle: duplicate phase hook name")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*list = append(*list, shutdownHook{name, fn})
|
||||||
|
}
|
||||||
|
|
||||||
|
func callHook(ctx context.Context, h shutdownHook) (err error) {
|
||||||
|
defer func() {
|
||||||
|
if p := recover(); p != nil {
|
||||||
|
if cause, ok := p.(error); ok {
|
||||||
|
err = fmt.Errorf("hook panic: %w", cause)
|
||||||
|
} else {
|
||||||
|
err = fmt.Errorf("hook panic: %v", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return h.fn(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Runtime) runPhase(phase string, hooks []shutdownHook, timeout time.Duration) error {
|
||||||
|
if len(hooks) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.WithoutCancel(r.work), timeout)
|
||||||
|
defer cancel()
|
||||||
|
type result struct {
|
||||||
|
index int
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
results := make(chan result, len(hooks))
|
||||||
|
pending := make([]bool, len(hooks))
|
||||||
|
for i, h := range hooks {
|
||||||
|
pending[i] = true
|
||||||
|
go func(i int, h shutdownHook) { results <- result{i, callHook(ctx, h)} }(i, h)
|
||||||
|
}
|
||||||
|
remaining := len(hooks)
|
||||||
|
var errs error
|
||||||
|
unsafe := false
|
||||||
|
accept := func(v result) {
|
||||||
|
pending[v.index] = false
|
||||||
|
remaining--
|
||||||
|
if v.err != nil {
|
||||||
|
unsafe = true
|
||||||
|
errs = errors.Join(errs, &HookError{phase, hooks[v.index].name, v.err})
|
||||||
|
r.cancelWork()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for remaining > 0 {
|
||||||
|
select {
|
||||||
|
case v := <-results:
|
||||||
|
accept(v)
|
||||||
|
case <-ctx.Done():
|
||||||
|
r.cancelWork()
|
||||||
|
errs = errors.Join(errs, &HookError{phase, "deadline", ctx.Err()})
|
||||||
|
grace := time.NewTimer(r.shutdown.CancelTimeout)
|
||||||
|
defer grace.Stop()
|
||||||
|
for remaining > 0 {
|
||||||
|
select {
|
||||||
|
case v := <-results:
|
||||||
|
accept(v)
|
||||||
|
case <-grace.C:
|
||||||
|
// Collect callbacks already returned at the deadline before marking pending ones.
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case v := <-results:
|
||||||
|
accept(v)
|
||||||
|
default:
|
||||||
|
goto collected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
collected:
|
||||||
|
unjoined := make(map[string]bool)
|
||||||
|
for i, active := range pending {
|
||||||
|
if active {
|
||||||
|
unjoined[hooks[i].name] = true
|
||||||
|
errs = errors.Join(errs, &HookError{phase, hooks[i].name, context.DeadlineExceeded})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &phaseError{err: errs, unsafe: unsafe || remaining > 0, unjoined: unjoined}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if errs != nil {
|
||||||
|
return &phaseError{err: errs, unsafe: unsafe}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
234
lifecycle/phases_test.go
Normal file
234
lifecycle/phases_test.go
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
package lifecycle
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"reflect"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testRuntime(t *testing.T) *Runtime {
|
||||||
|
t.Helper()
|
||||||
|
r, err := NewRuntimeWithConfig(context.Background(), ShutdownConfig{StopTimeout: time.Second, DrainTimeout: time.Second, CancelTimeout: time.Second})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPhasesBarrierAndWorkLifetime(t *testing.T) {
|
||||||
|
parent, cancel := context.WithCancel(context.WithValue(context.Background(), struct{}{}, "value"))
|
||||||
|
defer cancel()
|
||||||
|
r := NewRuntime(parent)
|
||||||
|
var stops, drains atomic.Int32
|
||||||
|
var mu sync.Mutex
|
||||||
|
var closed []int
|
||||||
|
started := make(chan struct{}, 2)
|
||||||
|
release := make(chan struct{})
|
||||||
|
// Deliberately interleave registration. Phases take precedence over order.
|
||||||
|
r.OnClose(func() error { mu.Lock(); defer mu.Unlock(); closed = append(closed, 1); return nil })
|
||||||
|
for _, name := range []string{"producer", "consumer"} {
|
||||||
|
r.OnStop(name, func(ctx context.Context) error {
|
||||||
|
if r.WorkContext().Err() != nil || ctx.Err() != nil {
|
||||||
|
return errors.New("work canceled before drain")
|
||||||
|
}
|
||||||
|
stops.Add(1)
|
||||||
|
started <- struct{}{}
|
||||||
|
<-release
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
r.OnDrain(name, func(ctx context.Context) error {
|
||||||
|
if stops.Load() != 2 || r.WorkContext().Err() != nil || ctx.Err() != nil {
|
||||||
|
return errors.New("stage barrier failed")
|
||||||
|
}
|
||||||
|
drains.Add(1)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
r.OnClose(func() error {
|
||||||
|
if drains.Load() != 2 {
|
||||||
|
return errors.New("dependency closed early")
|
||||||
|
}
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
closed = append(closed, 2)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
cancel()
|
||||||
|
if r.WorkContext().Err() != nil || r.WorkContext().Value(struct{}{}) != "value" {
|
||||||
|
t.Fatal("work context inherited cancellation or lost values")
|
||||||
|
}
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() { done <- r.Finish(nil) }()
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
select {
|
||||||
|
case <-started:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("stop callbacks did not run concurrently")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if drains.Load() != 0 {
|
||||||
|
t.Fatal("drain ran before stop barrier")
|
||||||
|
}
|
||||||
|
close(release)
|
||||||
|
if err := <-done; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(closed, []int{2, 1}) || r.WorkContext().Err() == nil {
|
||||||
|
t.Fatal(closed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPhasesFailuresRetainDependenciesAndCauses(t *testing.T) {
|
||||||
|
for _, phase := range []string{"stop", "drain"} {
|
||||||
|
for _, panics := range []bool{false, true} {
|
||||||
|
t.Run(phase+map[bool]string{false: "Error", true: "Panic"}[panics], func(t *testing.T) {
|
||||||
|
r := testRuntime(t)
|
||||||
|
cause := errors.New("component cause")
|
||||||
|
fatal := errors.New("fatal monitor")
|
||||||
|
cleanup := atomic.Int32{}
|
||||||
|
other := atomic.Int32{}
|
||||||
|
r.OnClose(func() error { cleanup.Add(1); return nil })
|
||||||
|
hook := func(context.Context) error {
|
||||||
|
r.RequestShutdown(fatal)
|
||||||
|
if panics {
|
||||||
|
panic(cause)
|
||||||
|
}
|
||||||
|
return cause
|
||||||
|
}
|
||||||
|
if phase == "stop" {
|
||||||
|
r.OnStop("broken", hook)
|
||||||
|
} else {
|
||||||
|
r.OnDrain("broken", hook)
|
||||||
|
}
|
||||||
|
r.OnDrain("other", func(context.Context) error { other.Add(1); return nil })
|
||||||
|
err := r.Finish(nil)
|
||||||
|
var detail *HookError
|
||||||
|
if !errors.Is(err, ErrResourcesRetained) || !errors.Is(err, cause) || !errors.Is(err, fatal) || !errors.As(err, &detail) || detail.Phase != phase {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if cleanup.Load() != 0 || other.Load() != 1 || r.WorkContext().Err() == nil {
|
||||||
|
t.Fatal("unsafe cleanup or omitted drain")
|
||||||
|
}
|
||||||
|
if !errors.Is(r.Close(), ErrResourcesRetained) || other.Load() != 1 {
|
||||||
|
t.Fatal("close retried hooks")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPhasesDeadlineCancelsWorkAndJoinsBeforeClose(t *testing.T) {
|
||||||
|
r, err := NewRuntimeWithConfig(context.Background(), ShutdownConfig{DrainTimeout: 20 * time.Millisecond, CancelTimeout: time.Second})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var joined atomic.Bool
|
||||||
|
var closed atomic.Bool
|
||||||
|
r.OnDrain("worker", func(ctx context.Context) error {
|
||||||
|
<-ctx.Done()
|
||||||
|
<-r.WorkContext().Done()
|
||||||
|
// Cleanup can still use shared resources after forced cancellation.
|
||||||
|
if closed.Load() {
|
||||||
|
return errors.New("dependency closed during cancellation")
|
||||||
|
}
|
||||||
|
joined.Store(true)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
r.OnClose(func() error {
|
||||||
|
if !joined.Load() {
|
||||||
|
t.Error("worker not joined")
|
||||||
|
}
|
||||||
|
closed.Store(true)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
err = r.Close()
|
||||||
|
if !errors.Is(err, context.DeadlineExceeded) || errors.Is(err, ErrResourcesRetained) || !closed.Load() {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPhasesUncooperativeHookIsBoundedAndNeverClosesDependencies(t *testing.T) {
|
||||||
|
for _, phase := range []string{"stop", "drain"} {
|
||||||
|
t.Run(phase, func(t *testing.T) {
|
||||||
|
r, err := NewRuntimeWithConfig(context.Background(), ShutdownConfig{StopTimeout: 20 * time.Millisecond, DrainTimeout: 20 * time.Millisecond, CancelTimeout: 20 * time.Millisecond})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
release := make(chan struct{})
|
||||||
|
finished := make(chan struct{})
|
||||||
|
var closed atomic.Int32
|
||||||
|
hook := func(context.Context) error { defer close(finished); <-release; return nil }
|
||||||
|
if phase == "stop" {
|
||||||
|
r.OnStop("stuck", hook)
|
||||||
|
r.OnDrain("stuck", func(context.Context) error { t.Error("Drain overlapped unfinished Stop"); return nil })
|
||||||
|
} else {
|
||||||
|
r.OnDrain("stuck", hook)
|
||||||
|
}
|
||||||
|
r.OnClose(func() error { closed.Add(1); return nil })
|
||||||
|
began := time.Now()
|
||||||
|
err = r.Finish(errors.New("listen failed"))
|
||||||
|
close(release)
|
||||||
|
<-finished
|
||||||
|
if time.Since(began) > time.Second || !errors.Is(err, ErrResourcesRetained) || !errors.Is(err, context.DeadlineExceeded) || closed.Load() != 0 {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_ = r.Close()
|
||||||
|
if closed.Load() != 0 {
|
||||||
|
t.Fatal("late cleanup raced work")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPhasesConcurrentFinishAndValidation(t *testing.T) {
|
||||||
|
if _, err := NewRuntimeWithConfig(nil, ShutdownConfig{DrainTimeout: -1}); err == nil {
|
||||||
|
t.Fatal("negative timeout accepted")
|
||||||
|
}
|
||||||
|
r := testRuntime(t)
|
||||||
|
var called atomic.Int32
|
||||||
|
r.OnStop("worker", func(context.Context) error { called.Add(1); return nil })
|
||||||
|
r.OnDrain("worker", func(context.Context) error { called.Add(1); return nil })
|
||||||
|
r.OnClose(func() error { called.Add(1); return nil })
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i := 0; i < 20; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
if err := r.Finish(nil); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
if called.Load() != 3 {
|
||||||
|
t.Fatal(called.Load())
|
||||||
|
}
|
||||||
|
for _, fn := range []func(){func() { r.OnStop("late", func(context.Context) error { return nil }) }, func() { NewRuntime(nil).OnDrain("bad\nname", func(context.Context) error { return nil }) }, func() {
|
||||||
|
x := NewRuntime(nil)
|
||||||
|
x.OnStop("duplicate", func(context.Context) error { return nil })
|
||||||
|
x.OnStop("duplicate", func(context.Context) error { return nil })
|
||||||
|
}} {
|
||||||
|
func() {
|
||||||
|
defer func() {
|
||||||
|
if recover() == nil {
|
||||||
|
t.Error("invalid registration accepted")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
fn()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMissingDrainRetainsResources(t *testing.T) {
|
||||||
|
r := testRuntime(t)
|
||||||
|
r.OnStop("worker", func(context.Context) error { return nil })
|
||||||
|
closed := false
|
||||||
|
r.OnClose(func() error { closed = true; return nil })
|
||||||
|
if err := r.Close(); !errors.Is(err, ErrResourcesRetained) || closed {
|
||||||
|
t.Fatal("missing completion proof accepted", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,17 +11,42 @@ import (
|
|||||||
// must finish draining before calling Close. Embed *Runtime in the project's svc.
|
// must finish draining before calling Close. Embed *Runtime in the project's svc.
|
||||||
// The logger and any other borrowed resources must not be registered here.
|
// The logger and any other borrowed resources must not be registered here.
|
||||||
type Runtime struct {
|
type Runtime struct {
|
||||||
Lifecycle *Controller
|
Lifecycle *Controller
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
cleanups []func() error
|
cleanups []func() error
|
||||||
closed bool
|
closed bool
|
||||||
once sync.Once
|
once sync.Once
|
||||||
closeErr error
|
closeErr error
|
||||||
|
stops, drains []shutdownHook
|
||||||
|
shutdown ShutdownConfig
|
||||||
|
work context.Context
|
||||||
|
cancelWork context.CancelFunc
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewRuntime(ctx context.Context) *Runtime { return &Runtime{Lifecycle: New(ctx)} }
|
func NewRuntime(ctx context.Context) *Runtime {
|
||||||
func (r *Runtime) Context() context.Context { return r.Lifecycle.Context() }
|
r, _ := NewRuntimeWithConfig(ctx, DefaultShutdownConfig())
|
||||||
func (r *Runtime) RequestShutdown(err error) { r.Lifecycle.RequestShutdown(err) }
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRuntimeWithConfig separates the stop notification from the lifetime of
|
||||||
|
// accepted background work. Zero timeout fields use documented defaults.
|
||||||
|
func NewRuntimeWithConfig(ctx context.Context, cfg ShutdownConfig) (*Runtime, error) {
|
||||||
|
if err := cfg.Validate(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if ctx == nil {
|
||||||
|
ctx = context.Background()
|
||||||
|
}
|
||||||
|
work, cancel := context.WithCancel(context.WithoutCancel(ctx))
|
||||||
|
return &Runtime{Lifecycle: New(ctx), shutdown: cfg.defaults(), work: work, cancelWork: cancel}, nil
|
||||||
|
}
|
||||||
|
func (r *Runtime) Context() context.Context { return r.Lifecycle.Context() }
|
||||||
|
func (r *Runtime) RequestShutdown(err error) { r.Lifecycle.RequestShutdown(err) }
|
||||||
|
|
||||||
|
// WorkContext stays alive during HTTP draining and graceful background draining.
|
||||||
|
// It is canceled on a shutdown failure/deadline, or after successful draining.
|
||||||
|
// Context() remains the early stop notification; do not use it for accepted work.
|
||||||
|
func (r *Runtime) WorkContext() context.Context { return r.work }
|
||||||
|
|
||||||
// OnClose transfers cleanup ownership. Register immediately after creation,
|
// OnClose transfers cleanup ownership. Register immediately after creation,
|
||||||
// before exposing resources to other goroutines. Registration after Close is a
|
// before exposing resources to other goroutines. Registration after Close is a
|
||||||
@@ -38,8 +63,10 @@ func (r *Runtime) OnClose(cleanup func() error) {
|
|||||||
r.cleanups = append(r.cleanups, cleanup)
|
r.cleanups = append(r.cleanups, cleanup)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close releases all resources in reverse order, including after a failed
|
// Close runs stop and drain barriers before releasing resources in reverse
|
||||||
// initialization. All callbacks are attempted even if one fails or panics.
|
// order, including after failed initialization. If quiescence is unproven it
|
||||||
|
// skips resource callbacks and returns ErrResourcesRetained. Once release is
|
||||||
|
// safe, all OnClose callbacks are attempted even if one fails or panics.
|
||||||
func (r *Runtime) Close() error {
|
func (r *Runtime) Close() error {
|
||||||
if r == nil {
|
if r == nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -48,9 +75,43 @@ func (r *Runtime) Close() error {
|
|||||||
r.RequestShutdown(nil)
|
r.RequestShutdown(nil)
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
r.closed = true
|
r.closed = true
|
||||||
cleanups := r.cleanups
|
cleanups, stops, drains := r.cleanups, r.stops, r.drains
|
||||||
r.cleanups = nil
|
r.cleanups = nil
|
||||||
|
r.stops, r.drains = nil, nil
|
||||||
r.mu.Unlock()
|
r.mu.Unlock()
|
||||||
|
stopErr := r.runPhase("stop", stops, r.shutdown.StopTimeout)
|
||||||
|
var stopped *phaseError
|
||||||
|
errors.As(stopErr, &stopped)
|
||||||
|
for _, stop := range stops {
|
||||||
|
found := false
|
||||||
|
for _, drain := range drains {
|
||||||
|
if drain.name == stop.name {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
stopErr = &phaseError{err: errors.Join(stopErr, &HookError{"drain", stop.name, errors.New("matching OnDrain required")}), unsafe: true}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if stopErr != nil {
|
||||||
|
r.cancelWork()
|
||||||
|
}
|
||||||
|
// A timed-out Stop can still be executing. Never overlap its own Drain;
|
||||||
|
// other components can still establish their completion independently.
|
||||||
|
eligible := make([]shutdownHook, 0, len(drains))
|
||||||
|
for _, drain := range drains {
|
||||||
|
if stopped == nil || !stopped.unjoined[drain.name] {
|
||||||
|
eligible = append(eligible, drain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
drainErr := r.runPhase("drain", eligible, r.shutdown.DrainTimeout)
|
||||||
|
r.cancelWork()
|
||||||
|
r.closeErr = errors.Join(stopErr, drainErr)
|
||||||
|
if unsafePhase(stopErr) || unsafePhase(drainErr) {
|
||||||
|
r.closeErr = errors.Join(r.closeErr, ErrResourcesRetained)
|
||||||
|
return
|
||||||
|
}
|
||||||
for i := len(cleanups) - 1; i >= 0; i-- {
|
for i := len(cleanups) - 1; i >= 0; i-- {
|
||||||
r.closeErr = errors.Join(r.closeErr, runCleanup(cleanups[i]))
|
r.closeErr = errors.Join(r.closeErr, runCleanup(cleanups[i]))
|
||||||
}
|
}
|
||||||
|
|||||||
34
reports/contractFollowupAcceptance.md
Normal file
34
reports/contractFollowupAcceptance.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# v0.16.0-preview 契约与生成体验验收
|
||||||
|
|
||||||
|
日期:2026-09-25。范围是通用数组响应、OpenAPI 引用、共享 Go 错误目录与环境覆盖;没有引入业务专属错误或外部服务依赖。
|
||||||
|
|
||||||
|
## 验证结果
|
||||||
|
|
||||||
|
- 字符串、布尔、各支持整数/浮点类型与 any 数组可以校验并生成 Go 类型;实际生成消费者验证中文字符串、int64 精度、混合对象/数组/null/标量、nil 根数组归一和 raw 数组。未知类型、byte/File/根嵌套数组明确拒绝。
|
||||||
|
- 重复响应先共享,再依据体积收益共享 schema/示例,避免组件表本身膨胀。引用全部可解析,编译与压缩可重复;示例业务数据中的字面 `$ref` 不被解释。HTTP 200 同时含成功/业务错误时,两种 schema、所有模板示例和 data:null 保留。
|
||||||
|
- 两个独立生成服务共享一次 Go Catalog 导出;服务/路由文档筛选不生成运行期目录。支持 internal 包,临时 Go overlay、模块文件及导出程序在工作结束后删除。check 完整文件快照不变;过期目录、编译错误、非法路径和 symlink 输出拒绝。
|
||||||
|
- 重新生成保留 Logic;生成 --check、独立消费者 race 与编译通过。根 go run/go build、信号退出、nethttp/native、下载/原始回调/multipart 和生命周期清理继续通过。
|
||||||
|
- 环境覆盖验证同一个 Lookup、allow/ignore/reject、完整名优先、显式空与未设置区别、false/0、JSON 集合、默认值不被修改、错误不带输入值。框架旧短名称入口兼容;存储/MySQL 生成组合验证了新版模板。
|
||||||
|
|
||||||
|
## 实际契约文档
|
||||||
|
|
||||||
|
| 服务 | 原字节数 | 新字节数 | 操作数 | 展开后内容 |
|
||||||
|
| --- | ---: | ---: | ---: | --- |
|
||||||
|
| eTax API | 20,751,149 | 847,171 | 67 | 完全一致 |
|
||||||
|
| eTax ID | 1,008,210 | 392,481 | 3 | 完全一致 |
|
||||||
|
|
||||||
|
[文档报告](contractFollowupDocuments.json) 保存原/新 SHA256 与生成源码摘要。通过只读 api export 写入临时目录,逐项比较完整 paths 操作;业务项目未做迁移或修改。
|
||||||
|
|
||||||
|
[全量回归报告](contractFollowupRegression.json) 与 [进程验收](contractFollowupLocal.json) 保存本次检查结果。
|
||||||
|
|
||||||
|
## 复验
|
||||||
|
|
||||||
|
```sh
|
||||||
|
GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race ./... -count=1 -timeout=300s
|
||||||
|
go vet ./...
|
||||||
|
go build ./...
|
||||||
|
python3 scripts/verifyManagedProject.py --report reports/contractFollowupLocal.json
|
||||||
|
python3 scripts/verifyManagedRelease.py --version v0.16.0-preview --report reports/contractFollowupRemote.json
|
||||||
|
```
|
||||||
|
|
||||||
|
共享 Catalog 提供方是清单显式指定的可信代码。框架隔离自己的文件操作,但不能保证用户提供方自身没有副作用;提供方应保持纯粹。该版本仍为 preview,未承诺支持完整 Go/goctl 类型语法或替业务完成数据库、权限、事务迁移。
|
||||||
40
reports/contractFollowupDocuments.json
Normal file
40
reports/contractFollowupDocuments.json
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"passed": true,
|
||||||
|
"frameworkVersion": "v0.16.0-preview",
|
||||||
|
"date": "2026-09-25",
|
||||||
|
"method": "Read-only api export against existing contracts; expand standard local references and compare every path operation to the existing documents. No business migration performed.",
|
||||||
|
"services": [
|
||||||
|
{
|
||||||
|
"service": "api",
|
||||||
|
"beforeBytes": 20751149,
|
||||||
|
"afterBytes": 847171,
|
||||||
|
"operations": 67,
|
||||||
|
"expandedOperationsIdentical": true,
|
||||||
|
"beforeSHA256": "e9e7fa5de073f1c1747d83e8144dead47080ad720f1c0daf9f0ef6af330eff02",
|
||||||
|
"afterSHA256": "b1276c7cec2ce7bcaba9a73c475dcc3f5b485306cc6b9a66a4ccf5c6077a13cb",
|
||||||
|
"components": {
|
||||||
|
"responses": 25,
|
||||||
|
"schemas": 1,
|
||||||
|
"securitySchemes": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"service": "id",
|
||||||
|
"beforeBytes": 1008210,
|
||||||
|
"afterBytes": 392481,
|
||||||
|
"operations": 3,
|
||||||
|
"expandedOperationsIdentical": true,
|
||||||
|
"beforeSHA256": "0ab7f09e18604e402ef372cfe71720c60d8e744b43c7fcbf83211ce1c1a5b04b",
|
||||||
|
"afterSHA256": "31c1b14754dc863431bf56ef3f6820b019428d8982011503ac91ea87aaecdf9d",
|
||||||
|
"components": {
|
||||||
|
"responses": 14,
|
||||||
|
"schemas": 1,
|
||||||
|
"securitySchemes": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"sourceHashes": {
|
||||||
|
"cmd/go-omni/apigen/documentRefs.go": "7b841a668154d872493104eda0f9049340c67d9323eb99f1621a1ef6ba343891",
|
||||||
|
"cmd/go-omni/apigen/contractMetadata.go": "392fca7c015d17e0799019f62c9ed82904a7531a9779eb28c12ec37f8ab93fd7"
|
||||||
|
}
|
||||||
|
}
|
||||||
59
reports/contractFollowupLocal.json
Normal file
59
reports/contractFollowupLocal.json
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
{
|
||||||
|
"passed": true,
|
||||||
|
"mode": "localSource",
|
||||||
|
"durationSeconds": 4.91,
|
||||||
|
"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": "9d81a3a4cd7905bd1829626e960512d86ac628d981be47c1d0cacc0648c0f33a",
|
||||||
|
"cmd/go-omni/apigen/templates.go": "6a98c26329d4c54d97abc170a324016931baac0e21028f649f1660347f5dd971",
|
||||||
|
"cmd/go-omni/apigen/startup.go": "e3322fa3e2aff9f45d66427f44187535c7c0960edc0f5fdfd5d7b4943b349334",
|
||||||
|
"cmd/go-omni/apigen/managed_test.go": "8b1b86d38151e78a1a18d8ecf001b9635d32c37276abaf2c9e28af10aa983e9d",
|
||||||
|
"cmd/go-omni/apigen/contractChanges.go": "aeb83990e908738faaa3cdfa20c3b8938023db05b6e55f2d24352c44144056a8",
|
||||||
|
"cmd/go-omni/apigen/logicCheck.go": "ca1af4f876a3a0db7b7b9ba9b82c1d6399fd89d29f667dad39cfd3de526eb1cb",
|
||||||
|
"cmd/go-omni/apigen/write.go": "180c5f5daee1441fe936cb376b1645a3d0c802cc78bf4b451dbf7e7b3e4ac0f4",
|
||||||
|
"cmd/go-omni/api.go": "5203a17c5f66977a9eba838d61cf5a7356de53d5f72a1196fd4f2046aa43ac85",
|
||||||
|
"config/config.go": "b4935ebb452c9df34b733842687ba11eec7e006a54ce42ff767273dd74d6e672",
|
||||||
|
"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-config.go.tmpl": "715d29a9be33ba232eac339547cc0a13ae09c8bcb38a65a69a4f32f09fea5346",
|
||||||
|
"cmd/go-omni/scaffold/templates/api-runtime.go.tmpl": "e8575c04b0923d924d96e4334731313076284fd068c4990715b7a07a2724f969",
|
||||||
|
"cmd/go-omni/scaffold/templates/api-close.go.tmpl": "3f22d272e92f3b64b62123b3d80ec1a6ce8834257d64752fd75facf12623902b",
|
||||||
|
"cmd/go-omni/scaffold/templates/api-databaseOptions.go.tmpl": "6e5395b4a787c77a6cabfd85f16049a39578d6ec751a9b78203f3a03baabdf28",
|
||||||
|
"cmd/go-omni/scaffold/templates/api-options.go.tmpl": "11fe34ce45e8c98f7b5c9c0a2c512eb0b3abf86f95b4b576e083bcb6f5d0f824",
|
||||||
|
"cmd/go-omni/scaffold/templates/api-context.go.tmpl": "6cf67d37ef64f388b23a98781c3ee3abb71a2e0b9e71eeb70f1e5492ff7e32b4",
|
||||||
|
"cmd/go-omni/apigen/testdata/managed/options.go": "30d48e4b5aab476c887ac53cc89b48b2b7e250c8cd25e55fecb49bb2a3589247",
|
||||||
|
"cmd/go-omni/apigen/testdata/managed/background.go": "fb357b5deefd5187f2df406be5189fc4d57da857bd55b474f4726bf065c77167",
|
||||||
|
"cmd/go-omni/apigen/testdata/managed/client.go": "cdd7d394ddad4bcdc10224890a884aa513ed2ffd8275ecdad8de9963dd10986a",
|
||||||
|
"cmd/go-omni/apigen/testdata/managed/phases_test.go": "4d826d7133027ac2399f8209f03efa46ed54aa45dcd0a35274a9009314941dca",
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
59
reports/contractFollowupRegression.json
Normal file
59
reports/contractFollowupRegression.json
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
{
|
||||||
|
"passed": true,
|
||||||
|
"frameworkVersion": "v0.16.0-preview",
|
||||||
|
"date": "2026-09-25",
|
||||||
|
"command": "GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race ./... -count=1 -timeout=300s",
|
||||||
|
"testedPackages": [
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni",
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/app",
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/audit",
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/auth",
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/cache",
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/cache/redis",
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/cmd/go-omni",
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/apigen",
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/scaffold",
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/config",
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/contract",
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/database",
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/database/gormx",
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/database/migrate",
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/database/mysql",
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/errors",
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/examples/modules",
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/examples/platform",
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/examples/reference",
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/examples/reference/repository",
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/health",
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/httpc",
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/httpx",
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/internal/perfcheck",
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/lifecycle",
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/logx",
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/metrics",
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/middleware",
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/middleware/redis",
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/openapi",
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/pagination",
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/queue/redis",
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/resilience",
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/response",
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/storage",
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/storage/local",
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/storage/oss",
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/storage/s3",
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/task",
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/telemetry",
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/transport",
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/transport/http2",
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/transport/native",
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/transport/nethttp"
|
||||||
|
],
|
||||||
|
"logSHA256": "ea70707ea4c160e9ed56edab99d040d9959dfa402a3e8f6d090d2b30ccef1513",
|
||||||
|
"additionalChecks": [
|
||||||
|
"go vet ./...",
|
||||||
|
"go build ./...",
|
||||||
|
"example managedService generate --check",
|
||||||
|
"example managedService go test ./..."
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -22,14 +22,20 @@ python3 scripts/verifyConnector.py --local --gorm --report reports/gormLocal.jso
|
|||||||
|
|
||||||
[本地数据库报告](gormLocal.json) 的 MySQL 限制为 1 CPU、768 MiB、128 PIDs,只绑定 localhost;测试使用临时凭据/证书,结束后清理容器和消费者。实库用例通过脚本显式启用,不能把普通 go test 下的 skip 当作实库通过。
|
[本地数据库报告](gormLocal.json) 的 MySQL 限制为 1 CPU、768 MiB、128 PIDs,只绑定 localhost;测试使用临时凭据/证书,结束后清理容器和消费者。实库用例通过脚本显式启用,不能把普通 go test 下的 skip 当作实库通过。
|
||||||
|
|
||||||
发布后的远程验收命令:
|
发布后的远程验收也已通过:
|
||||||
|
|
||||||
|
- [远程 MySQL/GORM 报告](gormRemote.json):25.42 秒,实际下载固定版本,执行 TLS、事务、取消、日志、生成项目接入及已有数据库/HTTP 回归。
|
||||||
|
- [远程生成项目报告](gormManagedRemote.json):30.45 秒,验证根目录运行/构建、重新生成、错误目录与参数、上传下载、内置端点及初始化失败/监听失败/正常退出清理。
|
||||||
|
- 两份报告均使用空 module 缓存,无本地 replace,CLI 和消费者版本均为 `v0.14.1-preview`;module 校验值一致为 `h1:qJ5EPUfSKe8KOHK8aU5f78Fg24HXCgbDfe2z1XXTmf4=`。同版 CLI 的生成检查没有错误版本提示。
|
||||||
|
|
||||||
|
复验命令:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
python3 scripts/verifyConnector.py --version v0.14.1-preview --gorm --report reports/gormRemote.json
|
python3 scripts/verifyConnector.py --version v0.14.1-preview --gorm --report reports/gormRemote.json
|
||||||
python3 scripts/verifyManagedRelease.py --version v0.14.1-preview --report reports/gormManagedRemote.json
|
python3 scripts/verifyManagedRelease.py --version v0.14.1-preview --report reports/gormManagedRemote.json
|
||||||
```
|
```
|
||||||
|
|
||||||
远程脚本从空缓存读取固定版本,并检查 CLI/消费者版本、module 校验值和没有本地 replace;结果发布后单独归档,代码标签保持不变。
|
代码标签固定在 `b8ab6b35219d53d6de392c920fe7ecbe9a76dade`。远程验收结果单独提交,标签保持不变;临时容器、证书、消费者与进程均已清理。
|
||||||
|
|
||||||
## 边界
|
## 边界
|
||||||
|
|
||||||
|
|||||||
68
reports/gormManagedRemote.json
Normal file
68
reports/gormManagedRemote.json
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
{
|
||||||
|
"passed": true,
|
||||||
|
"frameworkVersion": "v0.14.1-preview",
|
||||||
|
"frameworkSum": "h1:qJ5EPUfSKe8KOHK8aU5f78Fg24HXCgbDfe2z1XXTmf4=",
|
||||||
|
"freshModuleCache": true,
|
||||||
|
"noLocalReplace": true,
|
||||||
|
"durationSeconds": 30.45,
|
||||||
|
"checks": [
|
||||||
|
"remoteCLIInstall",
|
||||||
|
"freshModuleCache",
|
||||||
|
"fixedVersionNoReplace",
|
||||||
|
"contractChangePreview",
|
||||||
|
"persistentLogicSignatureFailure",
|
||||||
|
"businessSourcePreserved",
|
||||||
|
"fixedConsumerBuild",
|
||||||
|
"rootGoRun",
|
||||||
|
"nethttpAndNativeSignals",
|
||||||
|
"listenerReleased",
|
||||||
|
"default404And405",
|
||||||
|
"goOwnedErrorCatalog",
|
||||||
|
"unwrappedDownloads",
|
||||||
|
"rawCallback",
|
||||||
|
"multipart400And413",
|
||||||
|
"nethttpAndNativeTransfers",
|
||||||
|
"initializationAndListenFailureCleanup",
|
||||||
|
"drainBeforeDependencyClose",
|
||||||
|
"generatedConsumerRaceTests",
|
||||||
|
"errorParamsAndInheritance",
|
||||||
|
"manualHooksAndScalars",
|
||||||
|
"dependencyFatalShutdown",
|
||||||
|
"fatalProcessExitCode",
|
||||||
|
"initializationShutdownNotification",
|
||||||
|
"configuredBuiltinEndpoints",
|
||||||
|
"projectHealthEnvelope",
|
||||||
|
"endpointConflictCleanup",
|
||||||
|
"responseArrayNullableNull",
|
||||||
|
"strictRequestPolicies",
|
||||||
|
"declarativeMultipartBinding"
|
||||||
|
],
|
||||||
|
"sourceHashes": {
|
||||||
|
"lifecycle/runtime.go": "be9d3a483adc322b9c2633efbc87609f4b4a7aeebc2bee062e89ec32368466a3",
|
||||||
|
"config/project.go": "54b4d6f4d3a2e7a172e7f446af22067735b7c02ba253dd2483f42190616e90d3",
|
||||||
|
"contract/bind.go": "7590c3100a9bf90236aba7fda0a021d065d9dacd8c432c8eed1066f2ed8d37c8",
|
||||||
|
"contract/policy.go": "73a3a55fb88195b32f22b2bc066d9b13dccd7fd520e98c5a1b2979a3fd2447eb",
|
||||||
|
"contract/multipart.go": "db0377599b1b8b39246d41129513e8187109e44c1892971244d581c22ccc8ecd",
|
||||||
|
"contract/file.go": "20dd2de17e4854d452afc3c65f0293d504fb8fc25e91583ef45207fdab30948d",
|
||||||
|
"cmd/go-omni/apigen/business_test.go": "c0af26cd270f1c971dc5886316b144b776fdb886e930f883f0ee0c94b0909226",
|
||||||
|
"cmd/go-omni/apigen/generate.go": "138ea2809df839a5283c1f545d94e9a13d3476d149e7bfb62f73fb9c84ad1d92",
|
||||||
|
"lifecycle/controller.go": "1fb9393df1908afed93d87a1bf3ecea8d79d78e437a656b56bb7f1e5f6e51d31",
|
||||||
|
"config/endpoints.go": "9f5123f962d52b71e5b73d5942316170875e895129a94e8b669ce782969ef27d",
|
||||||
|
"health/health.go": "41f07f27a7fdcfa7973c2c554a22b73129ebc5d44e945e70e054e1cff24ab866",
|
||||||
|
"httpx/response.go": "2cf78b8f0eee8049ee612bb409a6eecc1d39eab9ab8f3b533ea21e3cac1611cb",
|
||||||
|
"app/app.go": "e5255ffdd36cba1c155037afc42f1c921b1f91bc79db75798a7feb0e9d51b1c0",
|
||||||
|
"app/transport.go": "88212d67d375e4c48c250a2e4c62fbff4e7873a67f91f2deb113a60634149f73",
|
||||||
|
"scripts/verifyManagedRelease.py": "ac2ef8f03d4c01fa793bc381c152d5d09f76227659cd6399ef8d43087785295c",
|
||||||
|
"scripts/verifyManagedProject.py": "b0e4b1aea4f44d61470c35c41f971e0882ae36b0eb5df2be190400d62fc60a18",
|
||||||
|
"cmd/go-omni/apigen/managed_test.go": "2b6bdf62a06cf25b85cbf06d60a5284ba24256c6705611fad4c012f63661a03d",
|
||||||
|
"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",
|
||||||
|
"cmd/go-omni/apigen/testdata/business/accessFactory.go": "2f982cf39b5e1a4be98596ce5d0a296af0fbf8c94616543a268e67ec376f31b4",
|
||||||
|
"cmd/go-omni/apigen/testdata/business/client.go": "d4cbaeec18dfb3a0e2821217e8eb0515000e351a745a93ec811dabca44e58c8a",
|
||||||
|
"cmd/go-omni/apigen/testdata/business/consumer_test.go": "e4159862a729ca2c5c9699840cc16a56350fc2914c43841f08946e9a6e9c8091",
|
||||||
|
"cmd/go-omni/apigen/testdata/business/operations.go": "11ded7921a0753f22975c525e334e3d45366143625e41d24fe500504e785c64c"
|
||||||
|
}
|
||||||
|
}
|
||||||
82
reports/gormRemote.json
Normal file
82
reports/gormRemote.json
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
{
|
||||||
|
"passed": true,
|
||||||
|
"frameworkVersion": "v0.14.1-preview",
|
||||||
|
"localSource": false,
|
||||||
|
"gormAdapter": true,
|
||||||
|
"frameworkSum": "h1:qJ5EPUfSKe8KOHK8aU5f78Fg24HXCgbDfe2z1XXTmf4=",
|
||||||
|
"freshModuleCache": true,
|
||||||
|
"noLocalReplace": true,
|
||||||
|
"mysqlImage": "sha256:1afd25fe334a1be64a3866495dd43a6bdba3c55dd1d6190ea12933163fe2843c",
|
||||||
|
"mysqlLimits": {
|
||||||
|
"cpus": 1,
|
||||||
|
"memoryMiB": 768,
|
||||||
|
"pids": 128
|
||||||
|
},
|
||||||
|
"durationSeconds": 25.42,
|
||||||
|
"tests": [
|
||||||
|
"TestHTTPFileAndEnvironment/server.json",
|
||||||
|
"TestHTTPFileAndEnvironment/server.yaml",
|
||||||
|
"TestHTTPFileAndEnvironment",
|
||||||
|
"TestHTTPInvalidAndUnsupportedSettings/READ_HEADER_TIMEOUTinvalid",
|
||||||
|
"TestHTTPInvalidAndUnsupportedSettings/READ_HEADER_TIMEOUT-1",
|
||||||
|
"TestHTTPInvalidAndUnsupportedSettings/READ_TIMEOUTinvalid",
|
||||||
|
"TestHTTPInvalidAndUnsupportedSettings/READ_TIMEOUT-1",
|
||||||
|
"TestHTTPInvalidAndUnsupportedSettings/WRITE_TIMEOUTinvalid",
|
||||||
|
"TestHTTPInvalidAndUnsupportedSettings/WRITE_TIMEOUT-1",
|
||||||
|
"TestHTTPInvalidAndUnsupportedSettings/IDLE_TIMEOUTinvalid",
|
||||||
|
"TestHTTPInvalidAndUnsupportedSettings/IDLE_TIMEOUT-1",
|
||||||
|
"TestHTTPInvalidAndUnsupportedSettings/MAX_HEADER_BYTESinvalid",
|
||||||
|
"TestHTTPInvalidAndUnsupportedSettings/MAX_HEADER_BYTES-1",
|
||||||
|
"TestHTTPInvalidAndUnsupportedSettings",
|
||||||
|
"TestHTTPConfiguredStandardServer",
|
||||||
|
"TestHTTPInvalidStartupReleasesResources",
|
||||||
|
"TestHTTPConfiguredNetworkLimits/nethttp/slowHeaders",
|
||||||
|
"TestHTTPConfiguredNetworkLimits/nethttp/slowBody",
|
||||||
|
"TestHTTPConfiguredNetworkLimits/nethttp/idle",
|
||||||
|
"TestHTTPConfiguredNetworkLimits/nethttp/writeBackpressure",
|
||||||
|
"TestStructuredMySQLAdapterTLS",
|
||||||
|
"TestGORMMySQLTransactionsCancellationAndLogs",
|
||||||
|
"TestHTTPConfiguredNetworkLimits/nethttp/headerLimit",
|
||||||
|
"TestHTTPConfiguredNetworkLimits/nethttp",
|
||||||
|
"TestConnectorIndependentPoolsAndContext",
|
||||||
|
"TestConnectorRejectsInvalidInputWithoutTakingOwnership",
|
||||||
|
"TestConnectorStartupFailuresReleaseResources/connect",
|
||||||
|
"TestConnectorStartupFailuresReleaseResources/ping",
|
||||||
|
"TestConnectorStartupFailuresReleaseResources",
|
||||||
|
"TestConnectorTimeoutBoundsConnectAndPing/connect",
|
||||||
|
"TestHTTPConfiguredNetworkLimits/native/slowHeaders",
|
||||||
|
"TestConnectorTimeoutBoundsConnectAndPing/ping",
|
||||||
|
"TestConnectorTimeoutBoundsConnectAndPing",
|
||||||
|
"TestConnectorHealthBoundsPoolWaitAndRecovers",
|
||||||
|
"TestConnectorIdleZeroAndLifetime/noIdle",
|
||||||
|
"TestConnectorIdleZeroAndLifetime/expired",
|
||||||
|
"TestConnectorIdleZeroAndLifetime",
|
||||||
|
"TestConnectorTransactionsKeepCommitAndRollbackSemantics",
|
||||||
|
"TestMySQLConnectorDriverLoggerIsolation",
|
||||||
|
"TestHTTPConfiguredNetworkLimits/native/slowBody",
|
||||||
|
"TestMySQLConnectorTLSAndPoolIsolation/untrustedCA",
|
||||||
|
"TestMySQLConnectorTLSAndPoolIsolation/wrongHostname",
|
||||||
|
"TestMySQLConnectorTLSAndPoolIsolation",
|
||||||
|
"TestHTTPConfiguredNetworkLimits/native/idle",
|
||||||
|
"TestHTTPConfiguredNetworkLimits/native/writeBackpressure",
|
||||||
|
"TestHTTPConfiguredNetworkLimits/native/headerLimit",
|
||||||
|
"TestHTTPConfiguredNetworkLimits/native",
|
||||||
|
"TestHTTPConfiguredNetworkLimits",
|
||||||
|
"TestGeneratedOptionalGORM",
|
||||||
|
"TestGeneratedMySQLConfigurationAndLifecycle"
|
||||||
|
],
|
||||||
|
"sourceHashes": {
|
||||||
|
"database/connector_test.go": "0663dfc9794ba24130225fa3b6418a6d5f75d57d0f01686151f70c7ea3f592c0",
|
||||||
|
"database/mysqlConnector_test.go": "e1cdd11ef76b09f1332ba92bfdde5b1917d84c85cc42fac48f65a8b23f5e9720",
|
||||||
|
"app/httpConfig_test.go": "b060dc0b76fb8e5feebae7e3b12cb7bca845693fcc097e2a0496a5ba937c837a",
|
||||||
|
"config/http_test.go": "0cae3dcf213369cfaff5487bf630c2bc3ec097739ca1d40794124e69c3880db8",
|
||||||
|
"database/mysql/integration_test.go": "05b0a73d50363eef760e8a3324a7e9a68c598f7519c6360ad530503178b56f43",
|
||||||
|
"database/gormx/integration_test.go": "3a5ac66d5972efc9127c7f1cc473337f899a521b967d4ec1ce00a0dbe06188bb",
|
||||||
|
"scripts/verifyConnector.py": "58d94e869e6faab1e49bd31ccaebab6d46b28e40058cf7805ad906057229149e",
|
||||||
|
"scripts/testdata/mysqlGenerated_test.go": "5630123add5ed17f261c1a1bf63ba773f7fa6ea613fd573d5c5cb72da3e42645",
|
||||||
|
"scripts/testdata/gormGenerated_test.go": "6fc476ec8c089c63b032c8cc707a83c88e685ce8599a59ab63006bad05cbf251",
|
||||||
|
"database/gormx/gorm.go": "512134d2c9aa7aa14b42071e837900ccf6b2fd8ed5550b686339c7b1605f14a2",
|
||||||
|
"database/gormx/pool.go": "227fac41c2a2ef64f2e9fc2b8a7202ba6fc22c61fd231e015fe08f5b1d97001d",
|
||||||
|
"database/gormx/logger.go": "e90a3d411e1b267d4abdf0ab795ef3d60ca4a443f7f9b1d51ccfe4cdae8aae4d"
|
||||||
|
}
|
||||||
|
}
|
||||||
48
reports/phasedLifecycleAcceptance.md
Normal file
48
reports/phasedLifecycleAcceptance.md
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# v0.15.0-preview 后台分阶段关闭验收
|
||||||
|
|
||||||
|
日期:2026-09-25。范围为通用生命周期、task 可选接入、生成项目装配和升级;没有修改业务项目,也没有引入事务任务一致性实现。
|
||||||
|
|
||||||
|
## 已验证行为
|
||||||
|
|
||||||
|
- 早期退出通知不取消 WorkContext;父 context 的值保留,截止时间不传入后台工作生命周期。阶段钩子有独立时限。
|
||||||
|
- 所有 Stop 完成才正常进入 Drain,同一阶段并发;仅确认收尾完成后才逆序释放共享资源。OnStop 要求匹配 OnDrain。
|
||||||
|
- 阶段错误、panic、超时和未响应取消都会保留原因;不能证明结束时跳过资源关闭,返回 ErrResourcesRetained。超时后在取消窗口成功完成的组件允许释放资源,同时仍报告超时。
|
||||||
|
- 未返回的 Stop 不与同名 Drain 并发;其他组件继续尝试收尾。重复/并发 Finish 不重复调用钩子。
|
||||||
|
- 实际生成项目在 nethttp/native 上验证:HTTP 请求仍在处理时后台不提前停止,后台最终操作完成后才关闭客户端;后台致命原因贯穿返回错误。
|
||||||
|
- 初始化失败和监听失败仍等待已创建后台任务收尾。独立子进程验证未结束任务不会导致共享客户端提前关闭,进程退出码为 1,原始初始化错误保留。
|
||||||
|
- 配置 YAML/环境覆盖、非法时限拒绝;重新生成保留业务文件,v0.14 项目的定点升级只更新依赖/生成文件,保留自定义配置与 svc。
|
||||||
|
- 实际 MySQL 8.4 TLS:后台在退出通知后创建临时表、完成 INSERT/SELECT 并释放借用连接,再关闭池;无法证明完成时池仍通过健康检查。GORM 同池接入及既有 Connector/TLS/HTTP 限额回归同时通过。
|
||||||
|
|
||||||
|
本地进程记录:[生成项目](phasedManagedLocal.json)。数据库记录:[MySQL 与 GORM](phasedMySQLLocal.json)。容器限制为 1 CPU / 768 MiB / 128 PIDs,只使用本机临时数据库,已清理容器、凭据、证书和临时消费者。
|
||||||
|
|
||||||
|
全量 race(启用生成消费者)、go vet、go build、示例生成漂移检查和依赖隔离检查均通过。
|
||||||
|
|
||||||
|
## 复验
|
||||||
|
|
||||||
|
```sh
|
||||||
|
GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race ./... -count=1 -timeout=300s
|
||||||
|
go vet ./...
|
||||||
|
go build ./...
|
||||||
|
python3 scripts/verifyManagedProject.py --report reports/phasedManagedLocal.json
|
||||||
|
python3 scripts/verifyConnector.py --local --phases --gorm --report reports/phasedMySQLLocal.json
|
||||||
|
```
|
||||||
|
|
||||||
|
发布后空缓存远程验收也已通过:
|
||||||
|
|
||||||
|
- [生成项目远程报告](phasedManagedRemote.json):31.93 秒,包含后台阶段顺序、初始化/监听失败、超时退出及既有接口功能。
|
||||||
|
- [真实 MySQL/GORM 远程报告](phasedMySQLRemote.json):25.14 秒,包含后台最终写入后关池、收尾失败保留池与 GORM 同池回归。
|
||||||
|
- 两份报告均为固定版本 v0.15.0-preview、空 module 缓存、无本地 replace;module 校验值一致为 `h1:llp5XZcLAsv9tckUQsZlwBDAO2RzoyxeygxZZ4/ZW1c=`。
|
||||||
|
- 发布标签固定在 `16deead70e860052c5b629511207578c03f9b2cb`,报告单独提交,标签不移动。
|
||||||
|
|
||||||
|
远程复验命令:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 scripts/verifyManagedRelease.py --version v0.15.0-preview --report reports/phasedManagedRemote.json
|
||||||
|
python3 scripts/verifyConnector.py --version v0.15.0-preview --phases --gorm --report reports/phasedMySQLRemote.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## 使用边界
|
||||||
|
|
||||||
|
普通 OnClose 保留原有逆序语义;需要阶段保证的后台组件必须迁入对应钩子,框架不会猜测任意 Close 内部行为。钩子负责真实停止与等待,nil 是完成证明。未结束 goroutine 不能被 Go 强杀;资源被保留后由生成入口异常退出,手写宿主须自行终止服务。OnClose 的同步回调仍须能返回;HTTP 超时保留原传输层行为,业务必须响应请求取消。同一阶段有顺序依赖时由一个登记项组合管理。
|
||||||
|
|
||||||
|
lifecycle 本身仅依赖标准库;核心 App/database/task 的编译依赖不包含 Orbit/GORM/Redis。第三方可选适配依赖仍记录于同一 module,不声称已经拆成独立 module。
|
||||||
59
reports/phasedManagedLocal.json
Normal file
59
reports/phasedManagedLocal.json
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
{
|
||||||
|
"passed": true,
|
||||||
|
"mode": "localSource",
|
||||||
|
"durationSeconds": 4.31,
|
||||||
|
"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": "819ce227f1041fa9447d7fbff4bfc2d4ea60dcb73e8667a21a41a090b40f0dd3",
|
||||||
|
"cmd/go-omni/apigen/templates.go": "6a98c26329d4c54d97abc170a324016931baac0e21028f649f1660347f5dd971",
|
||||||
|
"cmd/go-omni/apigen/startup.go": "e3322fa3e2aff9f45d66427f44187535c7c0960edc0f5fdfd5d7b4943b349334",
|
||||||
|
"cmd/go-omni/apigen/managed_test.go": "8b1b86d38151e78a1a18d8ecf001b9635d32c37276abaf2c9e28af10aa983e9d",
|
||||||
|
"cmd/go-omni/apigen/contractChanges.go": "aeb83990e908738faaa3cdfa20c3b8938023db05b6e55f2d24352c44144056a8",
|
||||||
|
"cmd/go-omni/apigen/logicCheck.go": "ca1af4f876a3a0db7b7b9ba9b82c1d6399fd89d29f667dad39cfd3de526eb1cb",
|
||||||
|
"cmd/go-omni/apigen/write.go": "180c5f5daee1441fe936cb376b1645a3d0c802cc78bf4b451dbf7e7b3e4ac0f4",
|
||||||
|
"cmd/go-omni/api.go": "5203a17c5f66977a9eba838d61cf5a7356de53d5f72a1196fd4f2046aa43ac85",
|
||||||
|
"config/config.go": "b4935ebb452c9df34b733842687ba11eec7e006a54ce42ff767273dd74d6e672",
|
||||||
|
"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-config.go.tmpl": "fa37bd0a71587e2f821ec1595eb1f1a371677c90428001ea75f61815243c3b7b",
|
||||||
|
"cmd/go-omni/scaffold/templates/api-runtime.go.tmpl": "e8575c04b0923d924d96e4334731313076284fd068c4990715b7a07a2724f969",
|
||||||
|
"cmd/go-omni/scaffold/templates/api-close.go.tmpl": "3f22d272e92f3b64b62123b3d80ec1a6ce8834257d64752fd75facf12623902b",
|
||||||
|
"cmd/go-omni/scaffold/templates/api-databaseOptions.go.tmpl": "6e5395b4a787c77a6cabfd85f16049a39578d6ec751a9b78203f3a03baabdf28",
|
||||||
|
"cmd/go-omni/scaffold/templates/api-options.go.tmpl": "11fe34ce45e8c98f7b5c9c0a2c512eb0b3abf86f95b4b576e083bcb6f5d0f824",
|
||||||
|
"cmd/go-omni/scaffold/templates/api-context.go.tmpl": "6cf67d37ef64f388b23a98781c3ee3abb71a2e0b9e71eeb70f1e5492ff7e32b4",
|
||||||
|
"cmd/go-omni/apigen/testdata/managed/options.go": "30d48e4b5aab476c887ac53cc89b48b2b7e250c8cd25e55fecb49bb2a3589247",
|
||||||
|
"cmd/go-omni/apigen/testdata/managed/background.go": "fb357b5deefd5187f2df406be5189fc4d57da857bd55b474f4726bf065c77167",
|
||||||
|
"cmd/go-omni/apigen/testdata/managed/client.go": "cdd7d394ddad4bcdc10224890a884aa513ed2ffd8275ecdad8de9963dd10986a",
|
||||||
|
"cmd/go-omni/apigen/testdata/managed/phases_test.go": "4d826d7133027ac2399f8209f03efa46ed54aa45dcd0a35274a9009314941dca",
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
78
reports/phasedManagedRemote.json
Normal file
78
reports/phasedManagedRemote.json
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
{
|
||||||
|
"passed": true,
|
||||||
|
"frameworkVersion": "v0.15.0-preview",
|
||||||
|
"frameworkSum": "h1:llp5XZcLAsv9tckUQsZlwBDAO2RzoyxeygxZZ4/ZW1c=",
|
||||||
|
"freshModuleCache": true,
|
||||||
|
"noLocalReplace": true,
|
||||||
|
"durationSeconds": 31.93,
|
||||||
|
"checks": [
|
||||||
|
"remoteCLIInstall",
|
||||||
|
"freshModuleCache",
|
||||||
|
"fixedVersionNoReplace",
|
||||||
|
"contractChangePreview",
|
||||||
|
"persistentLogicSignatureFailure",
|
||||||
|
"businessSourcePreserved",
|
||||||
|
"fixedConsumerBuild",
|
||||||
|
"rootGoRun",
|
||||||
|
"nethttpAndNativeSignals",
|
||||||
|
"listenerReleased",
|
||||||
|
"default404And405",
|
||||||
|
"goOwnedErrorCatalog",
|
||||||
|
"unwrappedDownloads",
|
||||||
|
"rawCallback",
|
||||||
|
"multipart400And413",
|
||||||
|
"nethttpAndNativeTransfers",
|
||||||
|
"initializationAndListenFailureCleanup",
|
||||||
|
"drainBeforeDependencyClose",
|
||||||
|
"generatedConsumerRaceTests",
|
||||||
|
"errorParamsAndInheritance",
|
||||||
|
"manualHooksAndScalars",
|
||||||
|
"dependencyFatalShutdown",
|
||||||
|
"fatalProcessExitCode",
|
||||||
|
"initializationShutdownNotification",
|
||||||
|
"configuredBuiltinEndpoints",
|
||||||
|
"projectHealthEnvelope",
|
||||||
|
"endpointConflictCleanup",
|
||||||
|
"responseArrayNullableNull",
|
||||||
|
"strictRequestPolicies",
|
||||||
|
"declarativeMultipartBinding",
|
||||||
|
"backgroundStopDrainResourceOrder",
|
||||||
|
"backgroundInitAndListenFailureCleanup",
|
||||||
|
"backgroundTimeoutRetainsResourcesAndExits"
|
||||||
|
],
|
||||||
|
"sourceHashes": {
|
||||||
|
"lifecycle/runtime.go": "7d890150b931cdc6c5d03f3706ea09b1d220a958d91fbd6ddc6715a9ec13a5d2",
|
||||||
|
"lifecycle/phases.go": "1436856c708d004ebb28704a867316bf7a44553de56b3e29152d41475a2f793a",
|
||||||
|
"config/lifecycle_test.go": "8e5fccf0f7432c64d7fa8a52687002307d3ebb1fd039adb6ae09da0c9a2891bf",
|
||||||
|
"task/task.go": "acb1a2cc0e26029331065545582c30e0c547e3b6869f7d8b16c7d25ab0077545",
|
||||||
|
"cmd/go-omni/scaffold/templates/api-runtime.go.tmpl": "e8575c04b0923d924d96e4334731313076284fd068c4990715b7a07a2724f969",
|
||||||
|
"cmd/go-omni/apigen/templates.go": "6a98c26329d4c54d97abc170a324016931baac0e21028f649f1660347f5dd971",
|
||||||
|
"config/project.go": "54b4d6f4d3a2e7a172e7f446af22067735b7c02ba253dd2483f42190616e90d3",
|
||||||
|
"contract/bind.go": "7590c3100a9bf90236aba7fda0a021d065d9dacd8c432c8eed1066f2ed8d37c8",
|
||||||
|
"contract/policy.go": "73a3a55fb88195b32f22b2bc066d9b13dccd7fd520e98c5a1b2979a3fd2447eb",
|
||||||
|
"contract/multipart.go": "db0377599b1b8b39246d41129513e8187109e44c1892971244d581c22ccc8ecd",
|
||||||
|
"contract/file.go": "20dd2de17e4854d452afc3c65f0293d504fb8fc25e91583ef45207fdab30948d",
|
||||||
|
"cmd/go-omni/apigen/business_test.go": "c0af26cd270f1c971dc5886316b144b776fdb886e930f883f0ee0c94b0909226",
|
||||||
|
"cmd/go-omni/apigen/generate.go": "819ce227f1041fa9447d7fbff4bfc2d4ea60dcb73e8667a21a41a090b40f0dd3",
|
||||||
|
"lifecycle/controller.go": "1fb9393df1908afed93d87a1bf3ecea8d79d78e437a656b56bb7f1e5f6e51d31",
|
||||||
|
"config/endpoints.go": "9f5123f962d52b71e5b73d5942316170875e895129a94e8b669ce782969ef27d",
|
||||||
|
"health/health.go": "41f07f27a7fdcfa7973c2c554a22b73129ebc5d44e945e70e054e1cff24ab866",
|
||||||
|
"httpx/response.go": "2cf78b8f0eee8049ee612bb409a6eecc1d39eab9ab8f3b533ea21e3cac1611cb",
|
||||||
|
"app/app.go": "e5255ffdd36cba1c155037afc42f1c921b1f91bc79db75798a7feb0e9d51b1c0",
|
||||||
|
"app/transport.go": "88212d67d375e4c48c250a2e4c62fbff4e7873a67f91f2deb113a60634149f73",
|
||||||
|
"scripts/verifyManagedRelease.py": "8bc3ce425ca93cbea370fa94ff855922bdb60b2213330dec1cddd25d73d6f12b",
|
||||||
|
"scripts/verifyManagedProject.py": "b0e4b1aea4f44d61470c35c41f971e0882ae36b0eb5df2be190400d62fc60a18",
|
||||||
|
"cmd/go-omni/apigen/managed_test.go": "8b1b86d38151e78a1a18d8ecf001b9635d32c37276abaf2c9e28af10aa983e9d",
|
||||||
|
"cmd/go-omni/apigen/testdata/managed/options.go": "30d48e4b5aab476c887ac53cc89b48b2b7e250c8cd25e55fecb49bb2a3589247",
|
||||||
|
"cmd/go-omni/apigen/testdata/managed/background.go": "fb357b5deefd5187f2df406be5189fc4d57da857bd55b474f4726bf065c77167",
|
||||||
|
"cmd/go-omni/apigen/testdata/managed/client.go": "cdd7d394ddad4bcdc10224890a884aa513ed2ffd8275ecdad8de9963dd10986a",
|
||||||
|
"cmd/go-omni/apigen/testdata/managed/phases_test.go": "4d826d7133027ac2399f8209f03efa46ed54aa45dcd0a35274a9009314941dca",
|
||||||
|
"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",
|
||||||
|
"cmd/go-omni/apigen/testdata/business/accessFactory.go": "2f982cf39b5e1a4be98596ce5d0a296af0fbf8c94616543a268e67ec376f31b4",
|
||||||
|
"cmd/go-omni/apigen/testdata/business/client.go": "d4cbaeec18dfb3a0e2821217e8eb0515000e351a745a93ec811dabca44e58c8a",
|
||||||
|
"cmd/go-omni/apigen/testdata/business/consumer_test.go": "e4159862a729ca2c5c9699840cc16a56350fc2914c43841f08946e9a6e9c8091",
|
||||||
|
"cmd/go-omni/apigen/testdata/business/operations.go": "11ded7921a0753f22975c525e334e3d45366143625e41d24fe500504e785c64c"
|
||||||
|
}
|
||||||
|
}
|
||||||
87
reports/phasedMySQLLocal.json
Normal file
87
reports/phasedMySQLLocal.json
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
{
|
||||||
|
"passed": true,
|
||||||
|
"frameworkVersion": "v0.15.0-preview",
|
||||||
|
"localSource": true,
|
||||||
|
"gormAdapter": true,
|
||||||
|
"phasedLifecycle": true,
|
||||||
|
"frameworkSum": null,
|
||||||
|
"freshModuleCache": false,
|
||||||
|
"noLocalReplace": false,
|
||||||
|
"mysqlImage": "sha256:1afd25fe334a1be64a3866495dd43a6bdba3c55dd1d6190ea12933163fe2843c",
|
||||||
|
"mysqlLimits": {
|
||||||
|
"cpus": 1,
|
||||||
|
"memoryMiB": 768,
|
||||||
|
"pids": 128
|
||||||
|
},
|
||||||
|
"durationSeconds": 12.44,
|
||||||
|
"tests": [
|
||||||
|
"TestHTTPConfiguredStandardServer",
|
||||||
|
"TestHTTPInvalidStartupReleasesResources",
|
||||||
|
"TestHTTPConfiguredNetworkLimits/nethttp/slowHeaders",
|
||||||
|
"TestHTTPConfiguredNetworkLimits/nethttp/slowBody",
|
||||||
|
"TestHTTPConfiguredNetworkLimits/nethttp/idle",
|
||||||
|
"TestHTTPConfiguredNetworkLimits/nethttp/writeBackpressure",
|
||||||
|
"TestHTTPFileAndEnvironment/server.json",
|
||||||
|
"TestHTTPFileAndEnvironment/server.yaml",
|
||||||
|
"TestHTTPFileAndEnvironment",
|
||||||
|
"TestHTTPInvalidAndUnsupportedSettings/READ_HEADER_TIMEOUTinvalid",
|
||||||
|
"TestHTTPInvalidAndUnsupportedSettings/READ_HEADER_TIMEOUT-1",
|
||||||
|
"TestHTTPInvalidAndUnsupportedSettings/READ_TIMEOUTinvalid",
|
||||||
|
"TestHTTPInvalidAndUnsupportedSettings/READ_TIMEOUT-1",
|
||||||
|
"TestHTTPInvalidAndUnsupportedSettings/WRITE_TIMEOUTinvalid",
|
||||||
|
"TestHTTPInvalidAndUnsupportedSettings/WRITE_TIMEOUT-1",
|
||||||
|
"TestHTTPInvalidAndUnsupportedSettings/IDLE_TIMEOUTinvalid",
|
||||||
|
"TestHTTPInvalidAndUnsupportedSettings/IDLE_TIMEOUT-1",
|
||||||
|
"TestHTTPInvalidAndUnsupportedSettings/MAX_HEADER_BYTESinvalid",
|
||||||
|
"TestHTTPInvalidAndUnsupportedSettings/MAX_HEADER_BYTES-1",
|
||||||
|
"TestHTTPInvalidAndUnsupportedSettings",
|
||||||
|
"TestConnectorIndependentPoolsAndContext",
|
||||||
|
"TestConnectorRejectsInvalidInputWithoutTakingOwnership",
|
||||||
|
"TestConnectorStartupFailuresReleaseResources/connect",
|
||||||
|
"TestConnectorStartupFailuresReleaseResources/ping",
|
||||||
|
"TestConnectorStartupFailuresReleaseResources",
|
||||||
|
"TestConnectorTimeoutBoundsConnectAndPing/connect",
|
||||||
|
"TestConnectorTimeoutBoundsConnectAndPing/ping",
|
||||||
|
"TestConnectorTimeoutBoundsConnectAndPing",
|
||||||
|
"TestConnectorHealthBoundsPoolWaitAndRecovers",
|
||||||
|
"TestConnectorIdleZeroAndLifetime/noIdle",
|
||||||
|
"TestConnectorIdleZeroAndLifetime/expired",
|
||||||
|
"TestConnectorIdleZeroAndLifetime",
|
||||||
|
"TestConnectorTransactionsKeepCommitAndRollbackSemantics",
|
||||||
|
"TestMySQLConnectorDriverLoggerIsolation",
|
||||||
|
"TestMySQLConnectorTLSAndPoolIsolation/untrustedCA",
|
||||||
|
"TestMySQLConnectorTLSAndPoolIsolation/wrongHostname",
|
||||||
|
"TestMySQLConnectorTLSAndPoolIsolation",
|
||||||
|
"TestHTTPConfiguredNetworkLimits/nethttp/headerLimit",
|
||||||
|
"TestHTTPConfiguredNetworkLimits/nethttp",
|
||||||
|
"TestHTTPConfiguredNetworkLimits/native/slowHeaders",
|
||||||
|
"TestStructuredMySQLAdapterTLS",
|
||||||
|
"TestHTTPConfiguredNetworkLimits/native/slowBody",
|
||||||
|
"TestHTTPConfiguredNetworkLimits/native/idle",
|
||||||
|
"TestHTTPConfiguredNetworkLimits/native/writeBackpressure",
|
||||||
|
"TestHTTPConfiguredNetworkLimits/native/headerLimit",
|
||||||
|
"TestHTTPConfiguredNetworkLimits/native",
|
||||||
|
"TestHTTPConfiguredNetworkLimits",
|
||||||
|
"TestGORMMySQLTransactionsCancellationAndLogs",
|
||||||
|
"TestGeneratedOptionalGORM",
|
||||||
|
"TestGeneratedMySQLConfigurationAndLifecycle",
|
||||||
|
"TestGeneratedMySQLBackgroundDrain"
|
||||||
|
],
|
||||||
|
"sourceHashes": {
|
||||||
|
"database/connector_test.go": "0663dfc9794ba24130225fa3b6418a6d5f75d57d0f01686151f70c7ea3f592c0",
|
||||||
|
"database/mysqlConnector_test.go": "e1cdd11ef76b09f1332ba92bfdde5b1917d84c85cc42fac48f65a8b23f5e9720",
|
||||||
|
"app/httpConfig_test.go": "b060dc0b76fb8e5feebae7e3b12cb7bca845693fcc097e2a0496a5ba937c837a",
|
||||||
|
"config/http_test.go": "0cae3dcf213369cfaff5487bf630c2bc3ec097739ca1d40794124e69c3880db8",
|
||||||
|
"database/mysql/integration_test.go": "05b0a73d50363eef760e8a3324a7e9a68c598f7519c6360ad530503178b56f43",
|
||||||
|
"database/gormx/integration_test.go": "3a5ac66d5972efc9127c7f1cc473337f899a521b967d4ec1ce00a0dbe06188bb",
|
||||||
|
"scripts/verifyConnector.py": "fba3064dc4611eef5a7cc25369d4523b485e633448fa14a7a1fea0fbca3796fe",
|
||||||
|
"scripts/testdata/mysqlGenerated_test.go": "5630123add5ed17f261c1a1bf63ba773f7fa6ea613fd573d5c5cb72da3e42645",
|
||||||
|
"scripts/testdata/phasedMySQL_test.go": "925ca9ce0036ce6d3833cc8be9e72f8a097c689b8a270051fa2b6471f2edb6bb",
|
||||||
|
"lifecycle/runtime.go": "7d890150b931cdc6c5d03f3706ea09b1d220a958d91fbd6ddc6715a9ec13a5d2",
|
||||||
|
"lifecycle/phases.go": "1436856c708d004ebb28704a867316bf7a44553de56b3e29152d41475a2f793a",
|
||||||
|
"scripts/testdata/gormGenerated_test.go": "6fc476ec8c089c63b032c8cc707a83c88e685ce8599a59ab63006bad05cbf251",
|
||||||
|
"database/gormx/gorm.go": "512134d2c9aa7aa14b42071e837900ccf6b2fd8ed5550b686339c7b1605f14a2",
|
||||||
|
"database/gormx/pool.go": "227fac41c2a2ef64f2e9fc2b8a7202ba6fc22c61fd231e015fe08f5b1d97001d",
|
||||||
|
"database/gormx/logger.go": "e90a3d411e1b267d4abdf0ab795ef3d60ca4a443f7f9b1d51ccfe4cdae8aae4d"
|
||||||
|
}
|
||||||
|
}
|
||||||
87
reports/phasedMySQLRemote.json
Normal file
87
reports/phasedMySQLRemote.json
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
{
|
||||||
|
"passed": true,
|
||||||
|
"frameworkVersion": "v0.15.0-preview",
|
||||||
|
"localSource": false,
|
||||||
|
"gormAdapter": true,
|
||||||
|
"phasedLifecycle": true,
|
||||||
|
"frameworkSum": "h1:llp5XZcLAsv9tckUQsZlwBDAO2RzoyxeygxZZ4/ZW1c=",
|
||||||
|
"freshModuleCache": true,
|
||||||
|
"noLocalReplace": true,
|
||||||
|
"mysqlImage": "sha256:1afd25fe334a1be64a3866495dd43a6bdba3c55dd1d6190ea12933163fe2843c",
|
||||||
|
"mysqlLimits": {
|
||||||
|
"cpus": 1,
|
||||||
|
"memoryMiB": 768,
|
||||||
|
"pids": 128
|
||||||
|
},
|
||||||
|
"durationSeconds": 25.14,
|
||||||
|
"tests": [
|
||||||
|
"TestHTTPConfiguredStandardServer",
|
||||||
|
"TestHTTPInvalidStartupReleasesResources",
|
||||||
|
"TestHTTPConfiguredNetworkLimits/nethttp/slowHeaders",
|
||||||
|
"TestHTTPConfiguredNetworkLimits/nethttp/slowBody",
|
||||||
|
"TestHTTPConfiguredNetworkLimits/nethttp/idle",
|
||||||
|
"TestHTTPConfiguredNetworkLimits/nethttp/writeBackpressure",
|
||||||
|
"TestStructuredMySQLAdapterTLS",
|
||||||
|
"TestHTTPFileAndEnvironment/server.json",
|
||||||
|
"TestHTTPFileAndEnvironment/server.yaml",
|
||||||
|
"TestHTTPFileAndEnvironment",
|
||||||
|
"TestHTTPInvalidAndUnsupportedSettings/READ_HEADER_TIMEOUTinvalid",
|
||||||
|
"TestHTTPInvalidAndUnsupportedSettings/READ_HEADER_TIMEOUT-1",
|
||||||
|
"TestHTTPInvalidAndUnsupportedSettings/READ_TIMEOUTinvalid",
|
||||||
|
"TestHTTPInvalidAndUnsupportedSettings/READ_TIMEOUT-1",
|
||||||
|
"TestHTTPInvalidAndUnsupportedSettings/WRITE_TIMEOUTinvalid",
|
||||||
|
"TestHTTPInvalidAndUnsupportedSettings/WRITE_TIMEOUT-1",
|
||||||
|
"TestHTTPInvalidAndUnsupportedSettings/IDLE_TIMEOUTinvalid",
|
||||||
|
"TestHTTPInvalidAndUnsupportedSettings/IDLE_TIMEOUT-1",
|
||||||
|
"TestHTTPInvalidAndUnsupportedSettings/MAX_HEADER_BYTESinvalid",
|
||||||
|
"TestHTTPInvalidAndUnsupportedSettings/MAX_HEADER_BYTES-1",
|
||||||
|
"TestHTTPInvalidAndUnsupportedSettings",
|
||||||
|
"TestHTTPConfiguredNetworkLimits/nethttp/headerLimit",
|
||||||
|
"TestHTTPConfiguredNetworkLimits/nethttp",
|
||||||
|
"TestConnectorIndependentPoolsAndContext",
|
||||||
|
"TestConnectorRejectsInvalidInputWithoutTakingOwnership",
|
||||||
|
"TestConnectorStartupFailuresReleaseResources/connect",
|
||||||
|
"TestConnectorStartupFailuresReleaseResources/ping",
|
||||||
|
"TestConnectorStartupFailuresReleaseResources",
|
||||||
|
"TestConnectorTimeoutBoundsConnectAndPing/connect",
|
||||||
|
"TestConnectorTimeoutBoundsConnectAndPing/ping",
|
||||||
|
"TestConnectorTimeoutBoundsConnectAndPing",
|
||||||
|
"TestHTTPConfiguredNetworkLimits/native/slowHeaders",
|
||||||
|
"TestConnectorHealthBoundsPoolWaitAndRecovers",
|
||||||
|
"TestConnectorIdleZeroAndLifetime/noIdle",
|
||||||
|
"TestConnectorIdleZeroAndLifetime/expired",
|
||||||
|
"TestConnectorIdleZeroAndLifetime",
|
||||||
|
"TestConnectorTransactionsKeepCommitAndRollbackSemantics",
|
||||||
|
"TestMySQLConnectorDriverLoggerIsolation",
|
||||||
|
"TestMySQLConnectorTLSAndPoolIsolation/untrustedCA",
|
||||||
|
"TestMySQLConnectorTLSAndPoolIsolation/wrongHostname",
|
||||||
|
"TestHTTPConfiguredNetworkLimits/native/slowBody",
|
||||||
|
"TestMySQLConnectorTLSAndPoolIsolation",
|
||||||
|
"TestHTTPConfiguredNetworkLimits/native/idle",
|
||||||
|
"TestHTTPConfiguredNetworkLimits/native/writeBackpressure",
|
||||||
|
"TestHTTPConfiguredNetworkLimits/native/headerLimit",
|
||||||
|
"TestHTTPConfiguredNetworkLimits/native",
|
||||||
|
"TestHTTPConfiguredNetworkLimits",
|
||||||
|
"TestGORMMySQLTransactionsCancellationAndLogs",
|
||||||
|
"TestGeneratedOptionalGORM",
|
||||||
|
"TestGeneratedMySQLConfigurationAndLifecycle",
|
||||||
|
"TestGeneratedMySQLBackgroundDrain"
|
||||||
|
],
|
||||||
|
"sourceHashes": {
|
||||||
|
"database/connector_test.go": "0663dfc9794ba24130225fa3b6418a6d5f75d57d0f01686151f70c7ea3f592c0",
|
||||||
|
"database/mysqlConnector_test.go": "e1cdd11ef76b09f1332ba92bfdde5b1917d84c85cc42fac48f65a8b23f5e9720",
|
||||||
|
"app/httpConfig_test.go": "b060dc0b76fb8e5feebae7e3b12cb7bca845693fcc097e2a0496a5ba937c837a",
|
||||||
|
"config/http_test.go": "0cae3dcf213369cfaff5487bf630c2bc3ec097739ca1d40794124e69c3880db8",
|
||||||
|
"database/mysql/integration_test.go": "05b0a73d50363eef760e8a3324a7e9a68c598f7519c6360ad530503178b56f43",
|
||||||
|
"database/gormx/integration_test.go": "3a5ac66d5972efc9127c7f1cc473337f899a521b967d4ec1ce00a0dbe06188bb",
|
||||||
|
"scripts/verifyConnector.py": "fba3064dc4611eef5a7cc25369d4523b485e633448fa14a7a1fea0fbca3796fe",
|
||||||
|
"scripts/testdata/mysqlGenerated_test.go": "5630123add5ed17f261c1a1bf63ba773f7fa6ea613fd573d5c5cb72da3e42645",
|
||||||
|
"scripts/testdata/phasedMySQL_test.go": "925ca9ce0036ce6d3833cc8be9e72f8a097c689b8a270051fa2b6471f2edb6bb",
|
||||||
|
"lifecycle/runtime.go": "7d890150b931cdc6c5d03f3706ea09b1d220a958d91fbd6ddc6715a9ec13a5d2",
|
||||||
|
"lifecycle/phases.go": "1436856c708d004ebb28704a867316bf7a44553de56b3e29152d41475a2f793a",
|
||||||
|
"scripts/testdata/gormGenerated_test.go": "6fc476ec8c089c63b032c8cc707a83c88e685ce8599a59ab63006bad05cbf251",
|
||||||
|
"database/gormx/gorm.go": "512134d2c9aa7aa14b42071e837900ccf6b2fd8ed5550b686339c7b1605f14a2",
|
||||||
|
"database/gormx/pool.go": "227fac41c2a2ef64f2e9fc2b8a7202ba6fc22c61fd231e015fe08f5b1d97001d",
|
||||||
|
"database/gormx/logger.go": "e90a3d411e1b267d4abdf0ab795ef3d60ca4a443f7f9b1d51ccfe4cdae8aae4d"
|
||||||
|
}
|
||||||
|
}
|
||||||
85
scripts/testdata/phasedMySQL_test.go
vendored
Normal file
85
scripts/testdata/phasedMySQL_test.go
vendored
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"example.com/mysqlAcceptance/internal/config"
|
||||||
|
"example.com/mysqlAcceptance/internal/svc"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||||
|
"io"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGeneratedMySQLBackgroundDrain(t *testing.T) {
|
||||||
|
cfg, err := config.Load("etc/config.yaml")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
logger, err := logx.New(logx.Config{Writer: io.Discard})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer logger.Close()
|
||||||
|
parent, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
c, err := svc.New(parent, logger, cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer c.Close()
|
||||||
|
stop := make(chan struct{})
|
||||||
|
finished := make(chan error, 1)
|
||||||
|
c.OnStop("writer", func(context.Context) error { close(stop); return nil })
|
||||||
|
c.OnDrain("writer", func(context.Context) error { return <-finished })
|
||||||
|
go func() {
|
||||||
|
<-stop
|
||||||
|
result := func() (err error) {
|
||||||
|
ctx, end := context.WithTimeout(c.WorkContext(), 3*time.Second)
|
||||||
|
defer end()
|
||||||
|
conn, err := c.DB.Conn(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() { err = errors.Join(err, conn.Close()) }()
|
||||||
|
for _, query := range []string{"CREATE TEMPORARY TABLE omni_phase_records (id INT)", "INSERT INTO omni_phase_records VALUES (42)"} {
|
||||||
|
if _, err = conn.ExecContext(ctx, query); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var n int
|
||||||
|
if err = conn.QueryRowContext(ctx, "SELECT id FROM omni_phase_records").Scan(&n); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if n != 42 {
|
||||||
|
return errors.New("final background write missing")
|
||||||
|
}
|
||||||
|
_, err = conn.ExecContext(ctx, "DROP TEMPORARY TABLE omni_phase_records")
|
||||||
|
return err
|
||||||
|
}()
|
||||||
|
// Completion follows release of the borrowed connection, including failures.
|
||||||
|
finished <- result
|
||||||
|
}()
|
||||||
|
cancel()
|
||||||
|
if err = c.Finish(nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err = c.DB.PingContext(context.Background()); err == nil {
|
||||||
|
t.Fatal("pool not closed after worker finished")
|
||||||
|
}
|
||||||
|
|
||||||
|
held, err := svc.New(context.Background(), logger, cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer held.DB.Close() // This test owns recovery and has no outstanding work.
|
||||||
|
cause := errors.New("worker completion unproven")
|
||||||
|
held.OnDrain("unjoined", func(context.Context) error { return cause })
|
||||||
|
if err = held.Finish(nil); !errors.Is(err, cause) || !errors.Is(err, lifecycle.ErrResourcesRetained) {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err = held.DB.Health(context.Background()); err != nil {
|
||||||
|
t.Fatal("unjoined worker lost shared database", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,6 +44,7 @@ def main():
|
|||||||
parser.add_argument("--report", type=Path, required=True)
|
parser.add_argument("--report", type=Path, required=True)
|
||||||
parser.add_argument("--image", default="mysql:8.4")
|
parser.add_argument("--image", default="mysql:8.4")
|
||||||
parser.add_argument("--gorm", action="store_true", help="also verify the optional GORM adapter and generated svc extension")
|
parser.add_argument("--gorm", action="store_true", help="also verify the optional GORM adapter and generated svc extension")
|
||||||
|
parser.add_argument("--phases", action="store_true", help="verify final background writes before generated pool cleanup (v0.15+)")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
if args.version and not re.fullmatch(r"v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?", args.version):
|
if args.version and not re.fullmatch(r"v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?", args.version):
|
||||||
parser.error("a fixed release version is required")
|
parser.error("a fixed release version is required")
|
||||||
@@ -71,7 +72,7 @@ def main():
|
|||||||
path.chmod(0o644) # Disposable fixtures, readable by the container's mysql uid.
|
path.chmod(0o644) # Disposable fixtures, readable by the container's mysql uid.
|
||||||
project = temp / "consumer"
|
project = temp / "consumer"
|
||||||
project.mkdir()
|
project.mkdir()
|
||||||
version = args.version or "v0.14.1-preview"
|
version = args.version or "v0.15.0-preview"
|
||||||
(project / "go.mod").write_text(f"module example.com/connectorAcceptance\n\ngo 1.22.0\n\nrequire {MODULE} {version}\n")
|
(project / "go.mod").write_text(f"module example.com/connectorAcceptance\n\ngo 1.22.0\n\nrequire {MODULE} {version}\n")
|
||||||
if args.local:
|
if args.local:
|
||||||
run(["go", "mod", "edit", f"-replace={MODULE}={ROOT}"], project, env)
|
run(["go", "mod", "edit", f"-replace={MODULE}={ROOT}"], project, env)
|
||||||
@@ -102,6 +103,8 @@ def main():
|
|||||||
command += ["--framework-dir", ROOT]
|
command += ["--framework-dir", ROOT]
|
||||||
run([*command, generated], temp, env)
|
run([*command, generated], temp, env)
|
||||||
shutil.copyfile(ROOT / "scripts/testdata/mysqlGenerated_test.go", generated / "mysql_test.go")
|
shutil.copyfile(ROOT / "scripts/testdata/mysqlGenerated_test.go", generated / "mysql_test.go")
|
||||||
|
if args.phases:
|
||||||
|
shutil.copyfile(ROOT / "scripts/testdata/phasedMySQL_test.go", generated / "phases_test.go")
|
||||||
if args.gorm:
|
if args.gorm:
|
||||||
# Only the documented project-owned extension is customized.
|
# Only the documented project-owned extension is customized.
|
||||||
service = generated / "internal/svc/serviceContext.go"
|
service = generated / "internal/svc/serviceContext.go"
|
||||||
@@ -169,6 +172,8 @@ def main():
|
|||||||
generated_events = [json.loads(line) for line in output.splitlines() if line.startswith("{")]
|
generated_events = [json.loads(line) for line in output.splitlines() if line.startswith("{")]
|
||||||
generated_passed = [e["Test"] for e in generated_events if e.get("Action") == "pass" and "Test" in e]
|
generated_passed = [e["Test"] for e in generated_events if e.get("Action") == "pass" and "Test" in e]
|
||||||
assert "TestGeneratedMySQLConfigurationAndLifecycle" in generated_passed, output
|
assert "TestGeneratedMySQLConfigurationAndLifecycle" in generated_passed, output
|
||||||
|
if args.phases:
|
||||||
|
assert "TestGeneratedMySQLBackgroundDrain" in generated_passed, output
|
||||||
if args.gorm:
|
if args.gorm:
|
||||||
assert "TestGeneratedOptionalGORM" in generated_passed, output
|
assert "TestGeneratedOptionalGORM" in generated_passed, output
|
||||||
assert not any(e.get("Action") == "fail" or (e.get("Action") == "skip" and "Test" in e) for e in generated_events), output
|
assert not any(e.get("Action") == "fail" or (e.get("Action") == "skip" and "Test" in e) for e in generated_events), output
|
||||||
@@ -178,9 +183,11 @@ def main():
|
|||||||
if created:
|
if created:
|
||||||
run(["docker", "rm", "--force", "--volumes", name], temp, env)
|
run(["docker", "rm", "--force", "--volumes", name], temp, env)
|
||||||
report_sources = sources + ["scripts/verifyConnector.py", "scripts/testdata/mysqlGenerated_test.go"]
|
report_sources = sources + ["scripts/verifyConnector.py", "scripts/testdata/mysqlGenerated_test.go"]
|
||||||
|
if args.phases:
|
||||||
|
report_sources += ["scripts/testdata/phasedMySQL_test.go", "lifecycle/runtime.go", "lifecycle/phases.go"]
|
||||||
if args.gorm:
|
if args.gorm:
|
||||||
report_sources += ["scripts/testdata/gormGenerated_test.go", "database/gormx/gorm.go", "database/gormx/pool.go", "database/gormx/logger.go"]
|
report_sources += ["scripts/testdata/gormGenerated_test.go", "database/gormx/gorm.go", "database/gormx/pool.go", "database/gormx/logger.go"]
|
||||||
report = {"passed": True, "frameworkVersion": version, "localSource": args.local, "gormAdapter": args.gorm,
|
report = {"passed": True, "frameworkVersion": version, "localSource": args.local, "gormAdapter": args.gorm, "phasedLifecycle": args.phases,
|
||||||
"frameworkSum": module.get("Sum"), "freshModuleCache": not args.local,
|
"frameworkSum": module.get("Sum"), "freshModuleCache": not args.local,
|
||||||
"noLocalReplace": not bool(module.get("Replace")), "mysqlImage": image,
|
"noLocalReplace": not bool(module.get("Replace")), "mysqlImage": image,
|
||||||
"mysqlLimits": {"cpus": 1, "memoryMiB": 768, "pids": 128},
|
"mysqlLimits": {"cpus": 1, "memoryMiB": 768, "pids": 128},
|
||||||
|
|||||||
@@ -18,6 +18,55 @@ ROOT = Path(__file__).resolve().parents[1]
|
|||||||
MODULE = "git.yuntuo.hk/k2safe/go-omni"
|
MODULE = "git.yuntuo.hk/k2safe/go-omni"
|
||||||
|
|
||||||
|
|
||||||
|
def verify_shared_catalog(cli, temp, env, version):
|
||||||
|
workspace = temp / "workspace"
|
||||||
|
(workspace / "internal/apierrors").mkdir(parents=True)
|
||||||
|
(workspace / "contracts").mkdir()
|
||||||
|
(workspace / "go.mod").write_text(f"module example.com/releaseWorkspace\n\ngo 1.24.0\nrequire {MODULE} {version}\n")
|
||||||
|
provider = workspace / "internal/apierrors/catalog.go"
|
||||||
|
provider.write_text('package apierrors\nimport errors "' + MODULE + '/errors"\n'
|
||||||
|
'func Catalog() errors.Catalog {return errors.Catalog{DefaultStatus:400,Definitions:map[string]errors.Definition{'
|
||||||
|
'"INVALID":{Code:"INVALID",Message:"invalid"},"MISSING":{Status:404,Code:"MISSING",Message:"missing"}}}}\n')
|
||||||
|
run(["go", "mod", "tidy"], workspace, env)
|
||||||
|
for name in ("api", "id"):
|
||||||
|
service = workspace / "services" / name
|
||||||
|
run([cli, "new", "--module", "example.com/" + name, service], workspace, env)
|
||||||
|
(service / "api/omni.imports.json").write_text(json.dumps({
|
||||||
|
"roots": {"common": {"path": "../../../contracts"}},
|
||||||
|
"errors": {"source": "@common/errors.json", "include": ["INVALID"],
|
||||||
|
"routes": {"GET /api/greet/:name": ["MISSING"]}}}))
|
||||||
|
manifest = workspace / "omni.workspace.json"
|
||||||
|
manifest.write_text(json.dumps({"errorCatalogs": [{"dir": ".", "package": "example.com/releaseWorkspace/internal/apierrors",
|
||||||
|
"function": "Catalog", "output": "contracts/errors.json"}],
|
||||||
|
"services": [{"dir": "services/api"}, {"dir": "services/id"}]}))
|
||||||
|
for command in ("generate", "format", "generate", "check"):
|
||||||
|
run([cli, "workspace", command, "--file", manifest], workspace, env)
|
||||||
|
for name in ("api", "id"):
|
||||||
|
service = workspace / "services" / name
|
||||||
|
assert not (service / "api/omni.errors.json").exists()
|
||||||
|
assert not (service / "internal/apierrors/errorsGen.go").exists()
|
||||||
|
doc = json.loads((service / "docs/openapi.json").read_text())
|
||||||
|
responses = doc["paths"]["/api/greet/{name}"]["get"]["responses"]
|
||||||
|
assert "404" in responses
|
||||||
|
run(["go", "test", "-mod=mod", "./..."], service, env)
|
||||||
|
snapshot = lambda: {str(p.relative_to(workspace)): hashlib.sha256(p.read_bytes()).hexdigest()
|
||||||
|
for p in workspace.rglob("*") if p.is_file()}
|
||||||
|
before = snapshot()
|
||||||
|
run([cli, "workspace", "check", "--file", manifest], workspace, env)
|
||||||
|
assert before == snapshot(), "workspace check changed project files"
|
||||||
|
provider.write_text(provider.read_text().replace('Message:"invalid"', 'Message:"new message"'))
|
||||||
|
before = snapshot()
|
||||||
|
try:
|
||||||
|
run([cli, "workspace", "check", "--file", manifest], workspace, env)
|
||||||
|
except RuntimeError as err:
|
||||||
|
assert "catalog drift" in str(err)
|
||||||
|
else:
|
||||||
|
raise AssertionError("stale catalog accepted")
|
||||||
|
assert before == snapshot(), "drift check changed project files"
|
||||||
|
run([cli, "workspace", "generate", "--file", manifest], workspace, env)
|
||||||
|
run([cli, "workspace", "check", "--file", manifest], workspace, env)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
parser = argparse.ArgumentParser(description=__doc__)
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
parser.add_argument("--version", required=True)
|
parser.add_argument("--version", required=True)
|
||||||
@@ -71,8 +120,11 @@ def main():
|
|||||||
serve([str(temp / "managedServer")], managed, env, transport, signal.SIGTERM, True, contract=True, logs=temp)
|
serve([str(temp / "managedServer")], managed, env, transport, signal.SIGTERM, True, contract=True, logs=temp)
|
||||||
checks += ["default404And405", "goOwnedErrorCatalog", "unwrappedDownloads", "rawCallback", "multipart400And413", "nethttpAndNativeTransfers"]
|
checks += ["default404And405", "goOwnedErrorCatalog", "unwrappedDownloads", "rawCallback", "multipart400And413", "nethttpAndNativeTransfers"]
|
||||||
|
|
||||||
|
verify_shared_catalog(cli, temp, env, args.version)
|
||||||
|
checks += ["sharedGoCatalogExport", "documentOnlyErrorSelection", "workspaceCheckNoWrites", "staleCatalogRejected", "twoIndependentServiceBuilds"]
|
||||||
|
|
||||||
env.update(GO_OMNI_SCAFFOLD_INTEGRATION="1", GO_OMNI_RELEASE_CLI=str(cli), GO_OMNI_RELEASE_VERSION=args.version)
|
env.update(GO_OMNI_SCAFFOLD_INTEGRATION="1", GO_OMNI_RELEASE_CLI=str(cli), GO_OMNI_RELEASE_VERSION=args.version)
|
||||||
tests = ["TestManagedGeneratedProject", "TestErrorInheritanceConsumer", "TestErrorParamsConsumer", "TestCompatibilityConsumer", "TestBusinessGeneratedProject"]
|
tests = ["TestManagedGeneratedProject", "TestErrorInheritanceConsumer", "TestErrorParamsConsumer", "TestCompatibilityConsumer", "TestBusinessGeneratedProject", "TestPrimitiveArrayConsumer"]
|
||||||
print("Running generated consumers against the remote framework, including lifecycle failure cleanup", flush=True)
|
print("Running generated consumers against the remote framework, including lifecycle failure cleanup", flush=True)
|
||||||
output = run(["go", "test", "-race", "-count=1", "-json", "-timeout=240s", "./cmd/go-omni/apigen", "-run", "^(" + "|".join(tests) + ")$"], ROOT, env)
|
output = run(["go", "test", "-race", "-count=1", "-json", "-timeout=240s", "./cmd/go-omni/apigen", "-run", "^(" + "|".join(tests) + ")$"], ROOT, env)
|
||||||
events = [json.loads(line) for line in output.splitlines() if line.startswith("{")]
|
events = [json.loads(line) for line in output.splitlines() if line.startswith("{")]
|
||||||
@@ -81,11 +133,13 @@ def main():
|
|||||||
evidence = "".join(e.get("Output", "") for e in events)
|
evidence = "".join(e.get("Output", "") for e in events)
|
||||||
sums = re.findall(r"checksum (h1:\S+); no local replace", evidence)
|
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"
|
assert len(sums) == len(tests) and all(s == dependency["Sum"] for s in sums), "remote consumer checksum mismatch"
|
||||||
checks += ["initializationAndListenFailureCleanup", "drainBeforeDependencyClose", "generatedConsumerRaceTests", "errorParamsAndInheritance", "manualHooksAndScalars", "dependencyFatalShutdown", "fatalProcessExitCode", "initializationShutdownNotification", "configuredBuiltinEndpoints", "projectHealthEnvelope", "endpointConflictCleanup", "responseArrayNullableNull", "strictRequestPolicies", "declarativeMultipartBinding"]
|
checks += ["initializationAndListenFailureCleanup", "drainBeforeDependencyClose", "generatedConsumerRaceTests", "errorParamsAndInheritance", "manualHooksAndScalars", "dependencyFatalShutdown", "fatalProcessExitCode", "initializationShutdownNotification", "configuredBuiltinEndpoints", "projectHealthEnvelope", "endpointConflictCleanup", "responseArrayNullableNull", "primitiveAndDynamicArrays", "strictRequestPolicies", "declarativeMultipartBinding", "backgroundStopDrainResourceOrder", "backgroundInitAndListenFailureCleanup", "backgroundTimeoutRetainsResourcesAndExits"]
|
||||||
|
|
||||||
sources = ["lifecycle/runtime.go", "config/project.go", "contract/bind.go", "contract/policy.go", "contract/multipart.go", "contract/file.go", "cmd/go-omni/apigen/business_test.go", "cmd/go-omni/apigen/generate.go", "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 = ["lifecycle/runtime.go", "lifecycle/phases.go", "config/lifecycle_test.go", "task/task.go", "cmd/go-omni/scaffold/templates/api-runtime.go.tmpl", "cmd/go-omni/apigen/templates.go", "config/project.go", "contract/bind.go", "contract/policy.go", "contract/multipart.go", "contract/file.go", "cmd/go-omni/apigen/business_test.go", "cmd/go-omni/apigen/generate.go", "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")]
|
sources += [str(p.relative_to(ROOT)) for p in (ROOT / "cmd/go-omni/apigen/testdata/managed").glob("*.go")]
|
||||||
sources += [str(p.relative_to(ROOT)) for p in (ROOT / "cmd/go-omni/apigen/testdata/business").glob("*.go")]
|
sources += [str(p.relative_to(ROOT)) for p in (ROOT / "cmd/go-omni/apigen/testdata/business").glob("*.go")]
|
||||||
|
sources += ["cmd/go-omni/apigen/responseShape.go", "cmd/go-omni/apigen/documentRefs.go", "cmd/go-omni/apigen/catalogDocument.go", "cmd/go-omni/apigen/workspaceCatalog.go", "config/env.go", "cmd/go-omni/apigen/arrayResponse_test.go"]
|
||||||
|
sources += [str(p.relative_to(ROOT)) for p in (ROOT / "cmd/go-omni/apigen/testdata/arrays").glob("*.go")]
|
||||||
report = {"passed": True, "frameworkVersion": args.version, "frameworkSum": dependency["Sum"],
|
report = {"passed": True, "frameworkVersion": args.version, "frameworkSum": dependency["Sum"],
|
||||||
"freshModuleCache": True, "noLocalReplace": True,
|
"freshModuleCache": True, "noLocalReplace": True,
|
||||||
"durationSeconds": round(time.monotonic() - started, 2), "checks": checks,
|
"durationSeconds": round(time.monotonic() - started, 2), "checks": checks,
|
||||||
|
|||||||
60
task/phases_test.go
Normal file
60
task/phases_test.go
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
package task_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/task"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRunnerPhasedShutdown(t *testing.T) {
|
||||||
|
runtime := lifecycle.NewRuntime(context.Background())
|
||||||
|
runner, err := task.New(task.Config{Workers: 1, QueueSize: 1})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var resourceClosed atomic.Bool
|
||||||
|
runtime.OnClose(func() error { resourceClosed.Store(true); return nil })
|
||||||
|
started, release, draining := make(chan struct{}), make(chan struct{}), make(chan struct{})
|
||||||
|
runtime.OnStop("jobs", runner.StopAccepting)
|
||||||
|
runtime.OnDrain("jobs", func(ctx context.Context) error { close(draining); return runner.Wait(ctx) })
|
||||||
|
future, err := runner.Submit(runtime.WorkContext(), "write", func(ctx context.Context) error {
|
||||||
|
close(started)
|
||||||
|
<-release
|
||||||
|
if resourceClosed.Load() {
|
||||||
|
return errors.New("resource already closed")
|
||||||
|
}
|
||||||
|
return ctx.Err()
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
<-started
|
||||||
|
runtime.RequestShutdown(nil)
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() { done <- runtime.Close() }()
|
||||||
|
select {
|
||||||
|
case <-draining:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("did not drain")
|
||||||
|
}
|
||||||
|
if _, err := runner.Submit(context.Background(), "late", func(context.Context) error { return nil }); !errors.Is(err, task.ErrClosed) {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if resourceClosed.Load() {
|
||||||
|
t.Fatal("closed resource before task")
|
||||||
|
}
|
||||||
|
close(release)
|
||||||
|
if err := <-done; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := future.Wait(context.Background()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !resourceClosed.Load() {
|
||||||
|
t.Fatal("resource leaked")
|
||||||
|
}
|
||||||
|
}
|
||||||
29
task/task.go
29
task/task.go
@@ -227,10 +227,9 @@ func run(ctx context.Context, fn Func) (err error) {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shutdown stops schedules and submission, then drains accepted jobs. Deadline
|
// StopAccepting stops schedules and submission without canceling accepted jobs.
|
||||||
// expiry cancels running/queued jobs and returns without waiting for code that
|
// Pair it with Wait to establish completion before releasing dependencies.
|
||||||
// ignores cancellation. Such code cannot be forcibly stopped by Go.
|
func (r *Runner) StopAccepting(ctx context.Context) error {
|
||||||
func (r *Runner) Shutdown(ctx context.Context) error {
|
|
||||||
if ctx == nil {
|
if ctx == nil {
|
||||||
return errors.New("task: nil context")
|
return errors.New("task: nil context")
|
||||||
}
|
}
|
||||||
@@ -242,6 +241,20 @@ func (r *Runner) Shutdown(ctx context.Context) error {
|
|||||||
go func() { r.wg.Wait(); r.cancel(); close(r.done) }()
|
go func() { r.wg.Wait(); r.cancel(); close(r.done) }()
|
||||||
}
|
}
|
||||||
r.mu.Unlock()
|
r.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait joins all workers and schedules after StopAccepting. A canceled context
|
||||||
|
// cancels work, but callers can wait again to establish actual completion.
|
||||||
|
func (r *Runner) Wait(ctx context.Context) error {
|
||||||
|
if ctx == nil {
|
||||||
|
return errors.New("task: nil context")
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-r.done:
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
}
|
||||||
select {
|
select {
|
||||||
case <-r.done:
|
case <-r.done:
|
||||||
return nil
|
return nil
|
||||||
@@ -251,6 +264,14 @@ func (r *Runner) Shutdown(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Shutdown stops admission and waits with the caller's deadline.
|
||||||
|
func (r *Runner) Shutdown(ctx context.Context) error {
|
||||||
|
if err := r.StopAccepting(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return r.Wait(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Runner) Close() error {
|
func (r *Runner) Close() error {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), r.config.ShutdownTimeout)
|
ctx, cancel := context.WithTimeout(context.Background(), r.config.ShutdownTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|||||||
Reference in New Issue
Block a user