From 0b6c0043b6c3b58648645aeee03bf80801e6aa3f Mon Sep 17 00:00:00 2001 From: k2safe Date: Tue, 29 Sep 2026 23:58:20 +0800 Subject: [PATCH] feat: release v0.28 generic Go project scaffolding and lifecycle entry --- PROGRAM.md | 81 ++++ PROJECT.md | 8 +- README.md | 31 +- RELEASE.md | 22 ++ cmd/go-omni/README.md | 12 +- cmd/go-omni/apigen/generate.go | 23 +- cmd/go-omni/apigen/write.go | 89 +---- cmd/go-omni/internal/genfs/files.go | 133 +++++++ cmd/go-omni/internal/genfs/files_test.go | 47 +++ cmd/go-omni/main.go | 9 + cmd/go-omni/project.go | 102 +++++ cmd/go-omni/project_test.go | 27 ++ cmd/go-omni/projectgen/consumer_test.go | 190 +++++++++ cmd/go-omni/projectgen/generate.go | 359 ++++++++++++++++++ cmd/go-omni/projectgen/generate_test.go | 215 +++++++++++ .../projectgen/templates/.gitignore.tmpl | 6 + .../projectgen/templates/README.md.tmpl | 55 +++ .../projectgen/templates/etc_config.yaml.tmpl | 9 + .../templates/internal_config_config.go.tmpl | 22 ++ .../templates/internal_config_loadGen.go.tmpl | 6 + .../templates/internal_config_loader.go.tmpl | 4 + .../templates/internal_run_run.go.tmpl | 13 + .../templates/internal_svc_runtimeGen.go.tmpl | 23 ++ .../internal_svc_serviceContext.go.tmpl | 21 + cmd/go-omni/projectgen/templates/main.go.tmpl | 60 +++ .../projectgen/testdata/consumer_test.go | 283 ++++++++++++++ cmd/go-omni/scaffold/version.go | 2 +- examples/program/.gitignore | 6 + examples/program/.goOmni/projectManifest.json | 11 + examples/program/README.md | 18 + examples/program/etc/config.yaml | 12 + examples/program/go.mod | 5 + examples/program/internal/config/config.go | 29 ++ examples/program/internal/config/loadGen.go | 12 + examples/program/internal/config/loader.go | 6 + examples/program/internal/run/run.go | 19 + examples/program/internal/svc/runtimeGen.go | 44 +++ .../program/internal/svc/serviceContext.go | 24 ++ examples/program/main.go | 89 +++++ lifecycle/README.md | 4 + reports/programAcceptance.md | 35 ++ scripts/verifyProgramRelease.py | 85 +++++ 42 files changed, 2134 insertions(+), 117 deletions(-) create mode 100644 PROGRAM.md create mode 100644 cmd/go-omni/internal/genfs/files.go create mode 100644 cmd/go-omni/internal/genfs/files_test.go create mode 100644 cmd/go-omni/project.go create mode 100644 cmd/go-omni/project_test.go create mode 100644 cmd/go-omni/projectgen/consumer_test.go create mode 100644 cmd/go-omni/projectgen/generate.go create mode 100644 cmd/go-omni/projectgen/generate_test.go create mode 100644 cmd/go-omni/projectgen/templates/.gitignore.tmpl create mode 100644 cmd/go-omni/projectgen/templates/README.md.tmpl create mode 100644 cmd/go-omni/projectgen/templates/etc_config.yaml.tmpl create mode 100644 cmd/go-omni/projectgen/templates/internal_config_config.go.tmpl create mode 100644 cmd/go-omni/projectgen/templates/internal_config_loadGen.go.tmpl create mode 100644 cmd/go-omni/projectgen/templates/internal_config_loader.go.tmpl create mode 100644 cmd/go-omni/projectgen/templates/internal_run_run.go.tmpl create mode 100644 cmd/go-omni/projectgen/templates/internal_svc_runtimeGen.go.tmpl create mode 100644 cmd/go-omni/projectgen/templates/internal_svc_serviceContext.go.tmpl create mode 100644 cmd/go-omni/projectgen/templates/main.go.tmpl create mode 100644 cmd/go-omni/projectgen/testdata/consumer_test.go create mode 100644 examples/program/.gitignore create mode 100644 examples/program/.goOmni/projectManifest.json create mode 100644 examples/program/README.md create mode 100644 examples/program/etc/config.yaml create mode 100644 examples/program/go.mod create mode 100644 examples/program/internal/config/config.go create mode 100644 examples/program/internal/config/loadGen.go create mode 100644 examples/program/internal/config/loader.go create mode 100644 examples/program/internal/run/run.go create mode 100644 examples/program/internal/svc/runtimeGen.go create mode 100644 examples/program/internal/svc/serviceContext.go create mode 100644 examples/program/main.go create mode 100644 reports/programAcceptance.md create mode 100644 scripts/verifyProgramRelease.py diff --git a/PROGRAM.md b/PROGRAM.md new file mode 100644 index 0000000..c058a8d --- /dev/null +++ b/PROGRAM.md @@ -0,0 +1,81 @@ +# 通用 Go 项目模板 + +从 v0.28.0-preview 起,`project new/generate/check` 提供不绑定协议与业务的工程骨架。默认只有配置、日志、依赖装配、生命周期和普通 Go 运行入口;不生成 `.api`、HTTP Handler、Swagger、业务任务,也不监听端口、连接数据库或队列。不增加第三方依赖。 + +## 创建与运行 + +```sh +go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.28.0-preview +go-omni project new demo --module example.com/demo +cd demo +go mod tidy +go run . -check-config +go run . # 默认等待 SIGINT / SIGTERM +go build -o program . +``` + +目标目录必须为空;未指定 module 时使用目录名。远程安装的 CLI 将自己的固定版本写入 go.mod,无本地替换。当前通用模板要求框架 v0.28.0-preview 或更新版本。源码开发可显式 `--framework-dir /path/to/go-omni`,不会从当前工作目录隐式选择本地框架。`--framework-version` 可指定兼容版本。框架版本、依赖更新由项目修改 go.mod,重新生成不会代为升级依赖。 + +常驻程序、一次性程序用同一份骨架:项目自己的 `Run` 决定何时完成。完整生成项目见 [examples/program](examples/program/README.md),不需要外部服务。 + +## 文件职责 + +| 路径 | 维护方与职责 | +| --- | --- | +| `main.go` | 生成器:信号、启动、运行入口监督、退出状态与日志收尾 | +| `internal/config/loadGen.go` | 生成器:统一配置加载 | +| `internal/svc/runtimeGen.go` | 生成器:依赖初始化及失败收尾 | +| `internal/config/config.go` | 项目:Config、Default、Validate;初始只含 log/lifecycle | +| `internal/config/loader.go` | 项目:按需注册额外文件格式 | +| `etc/config.yaml` | 项目:配置与显式环境引用 | +| `internal/svc/serviceContext.go` | 项目:依赖字段、Init、资源与后台工作登记 | +| `internal/run/run.go` | 项目:Run 的实际实现 | +| `go.mod`、README、.gitignore、其他 Go 文件 | 项目:生成器不覆盖 | +| `.goOmni/projectManifest.json` | 生成器:工程身份、模板版本、生成文件哈希;应提交 Git | + +配置遵守 [CONFIG.md](CONFIG.md):默认值 → 文件显式 `${NAME}` / `${NAME:-fallback}` → 类型绑定 → 校验,没有隐式环境覆盖。JSON/YAML/XML 内置;`-config ""` 只用默认值。`-check-config` 不打开日志文件、不初始化依赖、不调用 Run。日志通过独立实例传给 svc,并注入 Context/WorkContext;不替换进程级默认日志实例。 + +## 运行与退出 + +```go +func Run(ctx context.Context, deps *svc.ServiceContext) error { + // 一次性程序可完成处理后直接 return nil。 + // 常驻程序收到 ctx.Done() 后停止自己的主流程。 + <-ctx.Done() + return nil +} +``` + +`nil` 表示正常完成,返回 error 表示运行失败。后台组件用 `RequestShutdown(err)` 报告致命故障,`RequestShutdown(nil)` 请求正常停止。框架不替项目重试、重新调度或重启任务。 + +依赖在 `svc.Init` 中装配:创建后立即用 `OnClose` 登记清理,后台组件同时登记同名 `OnStop` 和 `OnDrain`。所有登记应在 Init 返回前完成;Run 不得并发追加生命周期登记。组件何时接收工作由项目决定,停止后不得重新开始领取或启动未登记的工作。 + +- `Context()` 是提前停止通知,用来停止主流程或接收新工作。 +- `WorkContext()` 在正常排空期间仍有效,用于已接受的工作。 +- `OnStop` 停止接收新工作,不关闭处理中的工作仍依赖的资源。 +- `OnDrain` 返回 nil,必须意味着组件处理、确认和 defer 收尾均已结束。仅返回 `ctx.Err()` 不能证明结束。 +- 全部停止钩子先于全部排空钩子;同阶段并行。有先后依赖的组件在一个钩子内显式排序。 +- 全部工作确认结束后,资源按 `OnClose` 登记的反序释放,最后关闭入口拥有的日志。 + +生成入口自动监督 Run 本身,以 `go-omni/project/run` 保留名称登记完成屏障。收到信号、Run 返回或报告故障后开始收尾,同时等待 Run 及其他已登记工作。Run 不应等待资源关闭:资源关闭发生在 Run 完成之后。 + +初始化失败同样收尾已创建的资源和已启动的工作。Init/Run 内 panic 被转为失败,安全收尾后进程以非零状态退出;任意自建 goroutine 的 panic 仍由项目负责处理。Run 不响应取消或其他工作无法确认结束时,`lifecycle.stopTimeout/drainTimeout/cancelTimeout` 限定等待窗口,返回 `ErrResourcesRetained`,跳过共享资源和日志关闭,以失败状态退出。取消后的工作不能被 Go 强制终止,保留资源不代表工作已经完成。SIGKILL 和进程崩溃不会执行退出钩子。 + +详见 [生命周期契约](lifecycle/README.md)。现成组件必须按其真实停止/完成语义适配,不能把某个 `Run/Close` 返回直接视为业务结束。 + +## 生成检查与兼容 + +```sh +go-omni project generate --dry-run +go-omni project generate +go-omni project check # 等价于 project generate --check +go test ./... +``` + +生成器不执行项目 Go 代码、不下载依赖。check/预览只读,不创建锁文件;生成时使用独立项目锁、路径和哈希预检。生成文件被手工修改会报冲突,不提供强制覆盖。删除的生成文件可补回;缺失的项目所有文件必须显式恢复,避免把删除的业务文件当成空白骨架重建。 + +每个文件通过原子替换写入,普通写入失败回滚此前变更;整个目录不具备崩溃事务性。生成期间不要同时手工编辑。发现遗留 project.lock 时,先确认没有生成进程再手工移除。 + +check 检查模板漂移和必要文件,不检查业务正确性,也不能替代 Go 编译与测试。新的模板版本会重新生成受管文件;不自动合并项目所有文件或更新 go.mod。未知的新清单版本会要求使用兼容 CLI。 + +原有 `new` / `api new` 及 API 工程行为保留。通用工程使用自己的清单和命令,不能交给 `api generate` 或 `add` 转换;新增业务包直接写普通 Go。现有 `workspace` 命令仍面向 API 契约工程;多个通用工程可分别执行 `project check --dir <目录>`,本版不引入混合工作区配置。 diff --git a/PROJECT.md b/PROJECT.md index a3f6b72..8665884 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -1,6 +1,8 @@ -# 生成项目与文件维护边界 +# API 生成项目与文件维护边界 -v0.27.0-preview 提供受管理的生成项目。安装同版远程 CLI,生成的项目直接引用该版本,无需本地 replace。根目录入口支持 `go run .` 和 `go build .`;框架本身仍然是可直接引用的库。安装和私有仓库访问见 [README](README.md#准备环境与安装)。 +通用 Go 工程使用独立的 `project new/generate/check`,见 [PROGRAM.md](PROGRAM.md)。本文说明 API 契约工程。 + +v0.28.0-preview 提供受管理的生成项目。安装同版远程 CLI,生成的项目直接引用该版本,无需本地 replace。根目录入口支持 `go run .` 和 `go build .`;框架本身仍然是可直接引用的库。安装和私有仓库访问见 [README](README.md#准备环境与安装)。 ## 创建和日常开发 @@ -215,7 +217,7 @@ get /download (DownloadReq) ## 已有服务接入步骤 -每个独立服务生成一个项目,module 名称、上层目录和共享业务包由项目选择。接入时固定 CLI 与框架版本为 v0.27.0-preview: +每个独立服务生成一个项目,module 名称、上层目录和共享业务包由项目选择。接入时固定 CLI 与框架版本为 v0.28.0-preview: 1. 先保留旧服务,整理实际路由及现有响应、鉴权和超时要求。在新目录执行 `go-omni new --api /path/to/service.api --module example.com/service ./service`;不对旧入口强制覆盖。 2. 在 `.api` 中选择 `@app(defaultResponse: ...)`,显式保留原项目的字段、成功码、错误码和 `dataMode`。需要独立回执的路由单独声明 raw/produces。普通路由、404/405 和提前返回的中间件一起验收。 diff --git a/README.md b/README.md index 5dff0c6..8e39510 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,14 @@ # GoOmni -GoOmni 是面向多个业务项目复用的 Go 后端框架:用 `.api` 定义接口、请求和返回类型,生成项目目录、HTTP 接入代码及 OpenAPI,业务代码负责 Logic 和 Repository。也可以在已有项目中直接使用 `omni.New()`,或单独引入日志、存储等模块。 +GoOmni 是面向多个项目复用的 Go 基础框架。通用工程使用 `project new` 生成配置、日志、依赖装配、生命周期和普通 Go 运行入口;需要 HTTP 接口时可选择 `.api` 工程生成,也可以在已有项目中按需引入独立模块。运行方式、业务类型和外部依赖由项目决定。 + +**v0.28 改动**:新增独立的 `project new/generate/check`,默认无 API、监听端口、业务任务或外部服务配置;保护手写代码,并等待主运行入口和登记的后台工作结束后释放依赖。见 [通用工程指南](PROGRAM.md)和[可运行示例](examples/program/README.md)。 **v0.27 改动**:统一三种内置服务实现的默认/路由/硬请求体限额,配置冲突在启动前报错;停机以处理函数实际退出为资源清理前提,超时仍有请求时保留共享依赖。见 [HTTP 策略](transport/README.md#统一请求体限额)、[停机规则](lifecycle/README.md#http-请求与依赖的关闭屏障)和[可运行示例](examples/httpSafety/README.md)。 -**v0.26 改动**:通用 [i18n](i18n/README.md),提供独立词典、语言匹配、回退、命名参数/条件/复数、可选 HTTP 与错误适配、检查/导出 CLI 及前端运行时。文案由项目维护;[完整示例](examples/i18n/README.md)可直接运行。CLI 与框架使用同版 v0.27.0-preview。同时修复 Go 1.22 下请求体限额错误的识别。 +**v0.26 改动**:通用 [i18n](i18n/README.md),提供独立词典、语言匹配、回退、命名参数/条件/复数、可选 HTTP 与错误适配、检查/导出 CLI 及前端运行时。文案由项目维护;[完整示例](examples/i18n/README.md)可直接运行。CLI 与框架使用同版 v0.28.0-preview。同时修复 Go 1.22 下请求体限额错误的识别。 -**从这份 README 开始接入。** 当前版本 **`v0.27.0-preview`**,新生成项目采用根目录入口和 `config / svc / logic / handler`。可以固定版本开展业务接入;文件维护边界和已有服务迁移步骤见 [项目指南](PROJECT.md),版本变化与验收见 [发布说明](RELEASE.md)。 +**从这份 README 开始接入。** 当前版本 **`v0.28.0-preview`**,通用工程采用根目录入口和 `config / svc / run`;API 工程使用 `config / svc / logic / handler`。可以固定版本开展业务接入;文件维护边界和已有服务迁移步骤见 [项目指南](PROJECT.md),版本变化与验收见 [发布说明](RELEASE.md)。 **v0.25 改动**:`database.InTx` 返回可识别的事务阶段与结果,保留业务/驱动及回滚错误链;提交报错保守标记为结果不确定,不等同于已回滚。[使用规则](database/README.md#事务阶段与结果)、[可运行故障示例](examples/transactions/README.md)。 @@ -38,6 +40,7 @@ Go **1.22+**。框架不依赖 Gin,默认使用标准库网络层,提供可 | --- | --- | --- | | 项目、接口与文档生成 | `go-omni new`;改 `.api` 后 `api generate`;`--check` 检查漂移 | [下方快速开始](#新项目生成并运行)、[CLI](cmd/go-omni/README.md)、[完整生成项目](examples/managedService/README.md) | | 公共字段与跨服务共享契约 | `.api` 组合对象字段;`omni.imports.json` 声明共享根;workspace 批量生成 | [公共字段示例](GENERATION.md#公共字段组合)、[共享契约与 workspace](DEVELOPMENT.md) | +| 通用 Go 工程 | `go-omni project new/generate/check`;配置、日志、依赖、运行与退出 | [通用工程指南](PROGRAM.md)、[完整可运行示例](examples/program/README.md) | | 中间件依赖工厂、可选 Repository | `omni.gen.json` 选择 factories / none;在 `New(deps)` 写一次实现 | [配置与完整工厂示例](GENERATION.md) | | 路由、分组、请求上下文 | `omni.New()`、`app.GET` / `Group` / `Use`;业务使用 `context.Context` | [基本服务](examples/basic/main.go)、[已有项目接入](#已有项目按模块接入) | | HTTP 网络与扩展适配 | 配置 transport 为 nethttp / native / http2;自定义 Router/Runner | [网络层配置与契约](transport/README.md)、[性能与限制](PERFORMANCE.md) | @@ -66,6 +69,22 @@ Go **1.22+**。框架不依赖 Gin,默认使用标准库网络层,提供可 | 可选结构转换与深拷贝 | `copyx.Copy` / `Clone` / `New`;`Check[S,T]` 预检类型,Go converter 处理差异 | [下方示例](#可选结构转换与深拷贝)、[完整 copyx 指南](COPY.md)、[可运行代码](examples/copying) | | 自定义适配器验收 | `transporttest.Run`、`storagetest.Run` / `RunUploads` 验证扩展契约 | [网络契约测试入口](transport/transporttest/router.go)、[存储契约测试入口](storage/storagetest/contract.go) | +## 通用工程快速开始 + +```sh +go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.28.0-preview +go-omni project new demo --module example.com/demo +cd demo +go mod tidy +go run . -check-config +go run . +# Ctrl+C / SIGTERM 退出;项目自己的 Run 也可以完成后直接返回。 +go-omni project check +go test ./... +``` + +项目只修改配置、svc 依赖初始化和 Run;默认没有 HTTP 端口、数据库、队列或自动重试。`main.go`、加载和装配代码由生成器维护;手改受管文件会报冲突,手写文件保持。完整维护边界、异常退出和超时行为见 [PROGRAM.md](PROGRAM.md)。 + ## 浏览器跨域 CORS 同源部署保持默认关闭;需要跨域时,在生成项目的配置文件中加入: @@ -158,7 +177,7 @@ module 地址是 `git.yuntuo.hk/k2safe/go-omni`。先确保本机 Go 和 Git 可 ```sh export GOPRIVATE="$(go env GOPRIVATE),git.yuntuo.hk" -go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.27.0-preview +go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.28.0-preview export PATH="$(go env GOPATH)/bin:$PATH" go-omni --help ``` @@ -315,7 +334,7 @@ go-omni new --api catalog.api \ 在业务项目的 **go.mod 所在目录**执行: ```sh -go get git.yuntuo.hk/k2safe/go-omni@v0.27.0-preview +go get git.yuntuo.hk/k2safe/go-omni@v0.28.0-preview ``` 已有项目可直接复用现有目录、Service、Repository 和数据库连接,先选择一个接口接入。也可以只用 `logx`、`storage` 等包,暂时保留原来的 HTTP 框架。希望入口和 HTTP 接入都交给生成器的项目,按 [已有服务接入步骤](PROJECT.md#已有服务接入步骤) 在新目录生成后迁入业务代码。 @@ -646,7 +665,7 @@ go test ./... `main.go`、`Gen.go`、`docs/openapi.json` 和 manifest 由生成器管理,手动修改会报告冲突。Logic、Repository、中间件实现、配置和依赖装配属于项目,重复生成保留手写内容。契约变化会提示受影响的 Logic 文件和 DTO 字段,明确的签名错误会给出原签名与期望签名;生成后 `--check` 仍能检出未修复的问题。将 `.api`、`omni.gen.json`、生成文件和 `.goOmni/apiManifest.json` 一起提交,CI 同时运行 `generate --check` 与 `go test ./...`。详见 [契约变更提示与检查边界](PROJECT.md#契约变更提示)。 -CLI 与业务 module 固定使用 `v0.27.0-preview`。v0.21 及更早项目先按 [配置迁移](CONFIG.md#v022-迁移旧模式已移除) 整理旧标签、文件与 Load,再升级依赖与 CLI 并重新生成,框架维护的 main/runtimeGen 自动更新;v0.13 及更早项目按 [升级预览](DEVELOPMENT.md#v013-项目升级) 处理,不直接覆盖手写入口或业务文件。使用过旧 mapping 的项目必须先完成 [移除迁移](COPY.md#从旧-mapping-迁移),旧配置或旧生成文件会阻止生成。升级后执行生成检查与业务回归。版本变化、迁移边界和验收记录见 [发布说明](RELEASE.md)。 +CLI 与业务 module 固定使用 `v0.28.0-preview`。v0.21 及更早项目先按 [配置迁移](CONFIG.md#v022-迁移旧模式已移除) 整理旧标签、文件与 Load,再升级依赖与 CLI 并重新生成,框架维护的 main/runtimeGen 自动更新;v0.13 及更早项目按 [升级预览](DEVELOPMENT.md#v013-项目升级) 处理,不直接覆盖手写入口或业务文件。使用过旧 mapping 的项目必须先完成 [移除迁移](COPY.md#从旧-mapping-迁移),旧配置或旧生成文件会阻止生成。升级后执行生成检查与业务回归。版本变化、迁移边界和验收记录见 [发布说明](RELEASE.md)。 ## 按需深入 diff --git a/RELEASE.md b/RELEASE.md index acee1a5..9945e08 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,3 +1,25 @@ +# v0.28.0-preview + +新增通用 Go 工程生成命令 `project new/generate/check`。默认只有配置、日志、svc 依赖装配、生命周期与项目维护的 Run 入口,不生成 API、HTTP Handler、Swagger、业务任务或网络监听,也不自动连接数据库/队列或重试任务。常驻和一次性程序由普通 Go 代码决定。 + +入口监督 Run 的真实完成并复用现有 Runtime:信号、正常结束、运行错误或初始化失败都会进入收尾;先停止接收工作、等待已登记工作及 Run 结束,再反序释放依赖并关闭日志。Run/Init panic 转为失败;超时仍有工作则传播 ErrResourcesRetained,保留共享资源及日志,以非零状态退出。组件登记在 svc.Init 完成,业务语义和调度方式由项目负责。 + +配置遵守现有显式文件引用约定;支持 -check-config,在初始化日志和依赖前校验。生成器只管理 main/loadGen/runtimeGen,手写文件保留,生成文件被修改时拒绝覆盖;check/预览只读,独立 manifest/锁和共享文件事务保障生成。新模板要求框架 v0.28.0-preview;已有 new/api/workspace 行为保留,无强制迁移和新增第三方依赖。 + +```sh +go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.28.0-preview +go-omni project new demo --module example.com/demo +cd demo +go mod tidy +go run . -check-config +go run . +go-omni project check +``` + +完整 [通用工程指南](PROGRAM.md)、[可运行示例](examples/program/README.md)、[回归记录](reports/programAcceptance.md)。56 个测试包全量 race 与 Go 1.22 回归、生成消费者、vet/build 和真实进程退出验证通过。 + +--- + # v0.27.0-preview 新增统一请求体策略与请求完成屏障,无业务字段、数据库依赖或新第三方包。`server.http.defaultMaxBodyBytes` 控制普通路由默认值(零为 8 MiB),`maxBodyBytes` 为不可由路由突破的服务硬上限(零仅保留 1 TiB 框架上界)。已有显式路由和生成契约限额保持,冲突会在启动前报错;旧 nethttp 手写大请求需显式设置路由或默认上限。multipart 单文件、中间件缓存及代理限额仍各自配置。 diff --git a/cmd/go-omni/README.md b/cmd/go-omni/README.md index 999b8d6..b92a96f 100644 --- a/cmd/go-omni/README.md +++ b/cmd/go-omni/README.md @@ -1,5 +1,7 @@ # go-omni CLI +v0.28 新增 `project new/generate/check`:生成无 API、无监听端口和无外部服务依赖的通用 Go 程序,配置/日志/生命周期自动装配,项目维护 svc 与 Run。`project generate --dry-run` 预览,`project check` 只读检查;详见 [通用工程指南](../../PROGRAM.md)。 + v0.26 新增 `i18n check|export|types|extract`:检查独立语言资源、提取 Go 静态消息引用或已导出的错误目录,导出指定命名空间的前端包和 TypeScript 类型。输入只读,结果写 stdout;动态引用明确报告。完整命令见 [国际化工具链](../../i18n/README.md#cli-提取检查与导出)。`.api errorCatalog` 可声明 translation 引用,旧 runtime 在生成前拒绝,接入时使用当前同版 CLI 与框架。 v0.19 移除结构映射生成:`omni.gen.json` 只保留 Repository 和中间件选项。普通接口直接使用生成 DTO;需要类型转换时用普通 Go 或可选 [copyx](../../COPY.md)。旧 `mappings` / `mappingFiles` 必须按迁移步骤清理,CLI 不会静默删除仍在调用的旧生成文件。 @@ -10,13 +12,13 @@ v0.16 增加基础类型/动态 JSON 数组返回、OpenAPI 公共引用和 work 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)。 +API 新项目采用根目录 `main.go` 与 `internal/config、svc、logic、handler、types`。完整职责、启动扩展、流式接口和生成文件边界见 [项目指南](../../PROJECT.md)。 -## 创建项目 +## 创建 API 项目 ```sh # 仓库读取权限与 PATH 配置见根目录 README -go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.27.0-preview +go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.28.0-preview go-omni new --module example.com/hello ./hello # 从自己的契约生成;service.api 及其相对 import 文件需要在本地存在 @@ -51,14 +53,14 @@ main、Gen.go、OpenAPI 和 manifest 由生成器维护;业务文件按字节 ## 版本与迁移 -CLI 与框架依赖固定使用 v0.27.0-preview。远程安装的 CLI 默认把自身版本写入 go.mod,不添加本地 replace;`--framework-version` 可显式指定已发布的兼容版本。仅在开发框架源码时使用本地替换: +CLI 与框架依赖固定使用 v0.28.0-preview。远程安装的 CLI 默认把自身版本写入 go.mod,不添加本地 replace;`--framework-version` 可显式指定已发布的兼容版本。仅在开发框架源码时使用本地替换: ```sh # 在 go-omni 源码仓库中执行 go run ./cmd/go-omni new --module example.com/hello --framework-dir . ../hello ``` -当前模板与依赖以 v0.27.0-preview 为基线,继续使用 manifest v2;GORM 可选适配器从 v0.14.1-preview 提供。已有项目使用 [定点升级与预览](../../DEVELOPMENT.md#v013-项目升级):原始模板可自动替换,含业务修改的基础文件按预览合并;通用清理由 Runtime 负责。独立工具命令不会被删除。受管理项目通过 `.api` 模块导入增加业务,不使用旧 `add` 命令的手动路由装配。 +当前模板与依赖以 v0.28.0-preview 为基线,继续使用 manifest v2;GORM 可选适配器从 v0.14.1-preview 提供。已有项目使用 [定点升级与预览](../../DEVELOPMENT.md#v013-项目升级):原始模板可自动替换,含业务修改的基础文件按预览合并;通用清理由 Runtime 负责。独立工具命令不会被删除。受管理项目通过 `.api` 模块导入增加业务,不使用旧 `add` 命令的手动路由装配。 ## 数据库迁移命令 diff --git a/cmd/go-omni/apigen/generate.go b/cmd/go-omni/apigen/generate.go index 6d5ac75..15362ce 100644 --- a/cmd/go-omni/apigen/generate.go +++ b/cmd/go-omni/apigen/generate.go @@ -12,6 +12,7 @@ import ( "strconv" "strings" + "git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/internal/genfs" "git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/scaffold" "golang.org/x/mod/modfile" "golang.org/x/mod/module" @@ -22,7 +23,7 @@ const frameworkModule = "git.yuntuo.hk/k2safe/go-omni" // Runtime-only releases need not raise the managed template compatibility floor. const minimumVersion = "v0.22.0-preview" -const currentVersion = "v0.27.0-preview" +const currentVersion = "v0.28.0-preview" const generatedHeader = "// Code generated by go-omni api. DO NOT EDIT.\n" type Options struct { @@ -345,25 +346,7 @@ func moduleFile(o Options) ([]byte, error) { } return []byte(text), nil } -func canonical(path string) string { - path, _ = filepath.Abs(path) - candidate := path - var tail []string - for { - if resolved, err := filepath.EvalSymlinks(candidate); err == nil { - for i := len(tail) - 1; i >= 0; i-- { - resolved = filepath.Join(resolved, tail[i]) - } - return resolved - } - parent := filepath.Dir(candidate) - if parent == candidate { - return path - } - tail = append(tail, filepath.Base(candidate)) - candidate = parent - } -} +func canonical(path string) string { return genfs.Canonical(path) } func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map[string][]byte, error) { generated, seeds := map[string][]byte{}, map[string][]byte{} diff --git a/cmd/go-omni/apigen/write.go b/cmd/go-omni/apigen/write.go index 0ecc9bb..e81c44a 100644 --- a/cmd/go-omni/apigen/write.go +++ b/cmd/go-omni/apigen/write.go @@ -7,6 +7,7 @@ import ( "encoding/json" "errors" "fmt" + "git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/internal/genfs" "os" "path/filepath" "sort" @@ -244,44 +245,13 @@ func apply(root, module, entry string, generated, seeds map[string][]byte, check } return result, checkErr } - // A rollback restores bytes/modes if an ordinary filesystem write fails. - // File replacement is atomic; the whole directory is not crash-atomic. - var applied []change + transaction := make([]genfs.Change, 0, len(changes)) for _, c := range changes { - if err = safePath(root, c.name); err == nil { - current, e := os.ReadFile(filepath.Join(root, filepath.FromSlash(c.name))) - if c.existed { - if e != nil || !bytes.Equal(current, c.before) { - err = fmt.Errorf("file changed during generation: %s", c.name) - } - } else if !errors.Is(e, os.ErrNotExist) { - err = fmt.Errorf("file appeared during generation: %s", c.name) - } - } - if err == nil { - if c.remove { - err = os.Remove(filepath.Join(root, filepath.FromSlash(c.name))) - } else { - err = atomicWrite(root, c.name, c.after, c.mode) - } - } - if err != nil { - for i := len(applied) - 1; i >= 0; i-- { - previous := applied[i] - var rollback error - if previous.existed { - rollback = atomicWrite(root, previous.name, previous.before, previous.mode) - } else { - rollback = os.Remove(filepath.Join(root, filepath.FromSlash(previous.name))) - } - err = errors.Join(err, rollback) - } - return result, err - } - applied = append(applied, c) + transaction = append(transaction, genfs.Change{Name: c.name, Before: c.before, After: c.after, Existed: c.existed, Remove: c.remove, Mode: c.mode}) } - return result, nil + return result, genfs.Apply(root, transaction) } + func digest(data []byte) string { sum := sha256.Sum256(data); return hex.EncodeToString(sum[:]) } func ownedPath(name string) bool { if name == "internal/config/loadGen.go" { @@ -298,52 +268,7 @@ func ownedPath(name string) bool { } return strings.HasPrefix(name, "internal/handler/") && strings.HasSuffix(name, "/handlersGen.go") } -func safePath(root, name string) error { - if filepath.IsAbs(name) || strings.Contains(name, "\\") || filepath.ToSlash(filepath.Clean(name)) != name || name == "." || strings.HasPrefix(name, "../") { - return fmt.Errorf("api: unsafe output path %q", name) - } - path := root - parts := strings.Split(name, "/") - for i, part := range parts { - path = filepath.Join(path, part) - info, err := os.Lstat(path) - if errors.Is(err, os.ErrNotExist) { - continue - } - if err != nil { - return err - } - if info.Mode()&os.ModeSymlink != 0 { - return fmt.Errorf("api: refusing symlink %s", path) - } - if i < len(parts)-1 && !info.IsDir() { - return fmt.Errorf("api: expected directory %s", path) - } - if i == len(parts)-1 && !info.Mode().IsRegular() { - return fmt.Errorf("api: expected regular file %s", path) - } - } - return nil -} +func safePath(root, name string) error { return genfs.SafePath(root, name) } func atomicWrite(root, name string, data []byte, mode os.FileMode) error { - target := filepath.Join(root, filepath.FromSlash(name)) - if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil { - return err - } - f, err := os.CreateTemp(filepath.Dir(target), ".apiGen-*") - if err != nil { - return err - } - defer os.Remove(f.Name()) - if err = f.Chmod(mode); err == nil { - _, err = f.Write(data) - } - if err == nil { - err = f.Sync() - } - err = errors.Join(err, f.Close()) - if err != nil { - return err - } - return os.Rename(f.Name(), target) + return genfs.AtomicWrite(root, name, data, mode) } diff --git a/cmd/go-omni/internal/genfs/files.go b/cmd/go-omni/internal/genfs/files.go new file mode 100644 index 0000000..15ed7e2 --- /dev/null +++ b/cmd/go-omni/internal/genfs/files.go @@ -0,0 +1,133 @@ +// Package genfs shares guarded filesystem operations between project generators. +package genfs + +import ( + "bytes" + "errors" + "fmt" + "os" + "path/filepath" + "strings" +) + +func SafePath(root, name string) error { + if filepath.IsAbs(name) || strings.Contains(name, "\\") || filepath.ToSlash(filepath.Clean(name)) != name || name == "." || strings.HasPrefix(name, "../") { + return fmt.Errorf("generation: unsafe output path %q", name) + } + path := root + parts := strings.Split(name, "/") + for i, part := range parts { + path = filepath.Join(path, part) + info, err := os.Lstat(path) + if errors.Is(err, os.ErrNotExist) { + continue + } + if err != nil { + return err + } + if info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("generation: refusing symlink %s", path) + } + if i < len(parts)-1 && !info.IsDir() { + return fmt.Errorf("generation: expected directory %s", path) + } + if i == len(parts)-1 && !info.Mode().IsRegular() { + return fmt.Errorf("generation: expected regular file %s", path) + } + } + return nil +} +func AtomicWrite(root, name string, data []byte, mode os.FileMode) error { + target := filepath.Join(root, filepath.FromSlash(name)) + if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil { + return err + } + f, err := os.CreateTemp(filepath.Dir(target), ".omniGen-*") + if err != nil { + return err + } + defer os.Remove(f.Name()) + if err = f.Chmod(mode); err == nil { + _, err = f.Write(data) + } + if err == nil { + err = f.Sync() + } + err = errors.Join(err, f.Close()) + if err != nil { + return err + } + return os.Rename(f.Name(), target) +} + +// Change records the preflight snapshot used for conflict detection and rollback. +type Change struct { + Name string + Before, After []byte + Existed, Remove bool + Mode os.FileMode +} + +// Apply replaces individual files atomically and rolls back ordinary write failures. +// Callers serialize generators with a project lock. This is not crash-atomic across files. +func Apply(root string, changes []Change) error { + var applied []Change + for _, c := range changes { + err := SafePath(root, c.Name) + if err == nil { + current, e := os.ReadFile(filepath.Join(root, filepath.FromSlash(c.Name))) + if c.Existed { + if e != nil || !bytes.Equal(current, c.Before) { + err = fmt.Errorf("file changed during generation: %s", c.Name) + } + } else if !errors.Is(e, os.ErrNotExist) { + err = fmt.Errorf("file appeared during generation: %s", c.Name) + } + } + if err == nil { + if c.Remove { + err = os.Remove(filepath.Join(root, filepath.FromSlash(c.Name))) + } else { + err = AtomicWrite(root, c.Name, c.After, c.Mode) + } + } + if err != nil { + for i := len(applied) - 1; i >= 0; i-- { + previous := applied[i] + if e := SafePath(root, previous.Name); e != nil { + err = errors.Join(err, e) + continue + } + if previous.Existed { + err = errors.Join(err, AtomicWrite(root, previous.Name, previous.Before, previous.Mode)) + } else { + err = errors.Join(err, os.Remove(filepath.Join(root, filepath.FromSlash(previous.Name)))) + } + } + return err + } + applied = append(applied, c) + } + return nil +} + +// Canonical resolves existing parent symlinks even for an output not yet created. +func Canonical(path string) string { + path, _ = filepath.Abs(path) + candidate := path + var tail []string + for { + if resolved, err := filepath.EvalSymlinks(candidate); err == nil { + for i := len(tail) - 1; i >= 0; i-- { + resolved = filepath.Join(resolved, tail[i]) + } + return resolved + } + parent := filepath.Dir(candidate) + if parent == candidate { + return path + } + tail = append(tail, filepath.Base(candidate)) + candidate = parent + } +} diff --git a/cmd/go-omni/internal/genfs/files_test.go b/cmd/go-omni/internal/genfs/files_test.go new file mode 100644 index 0000000..24c6c14 --- /dev/null +++ b/cmd/go-omni/internal/genfs/files_test.go @@ -0,0 +1,47 @@ +package genfs + +import ( + "os" + "path/filepath" + "testing" +) + +func TestConflictRollsBackEarlierWrites(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "one"), []byte("before"), 0600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "two"), []byte("concurrent"), 0644); err != nil { + t.Fatal(err) + } + err := Apply(root, []Change{{Name: "one", Before: []byte("before"), After: []byte("after"), Existed: true, Mode: 0600}, {Name: "new", After: []byte("created"), Mode: 0644}, {Name: "two", Before: []byte("stale"), After: []byte("after"), Existed: true, Mode: 0644}}) + if err == nil { + t.Fatal("conflict accepted") + } + data, _ := os.ReadFile(filepath.Join(root, "one")) + info, _ := os.Stat(filepath.Join(root, "one")) + if string(data) != "before" || info.Mode().Perm() != 0600 { + t.Fatal("rollback did not restore bytes/mode") + } + if _, err = os.Stat(filepath.Join(root, "new")); !os.IsNotExist(err) { + t.Fatal("new output survived rollback") + } + data, _ = os.ReadFile(filepath.Join(root, "two")) + if string(data) != "concurrent" { + t.Fatal("concurrent edit overwritten") + } +} +func TestUnsafePaths(t *testing.T) { + root := t.TempDir() + for _, name := range []string{"../escape", "a/../../escape", "/outside", "a\\b", ".", "a/../b"} { + if SafePath(root, name) == nil { + t.Fatal(name) + } + } + if err := os.Symlink(t.TempDir(), filepath.Join(root, "link")); err != nil { + t.Fatal(err) + } + if SafePath(root, "link/file") == nil { + t.Fatal("followed symlink") + } +} diff --git a/cmd/go-omni/main.go b/cmd/go-omni/main.go index 18d39da..b1d8419 100644 --- a/cmd/go-omni/main.go +++ b/cmd/go-omni/main.go @@ -18,6 +18,11 @@ func main() { } switch os.Args[1] { + case "project": + if err := runProject(os.Args[2:]); err != nil { + fmt.Fprintln(os.Stderr, "go-omni project:", err) + os.Exit(1) + } case "i18n": if err := runI18n(os.Args[2:]); err != nil { fmt.Fprintln(os.Stderr, "go-omni i18n:", err) @@ -175,6 +180,7 @@ func usage() { fmt.Fprintln(os.Stderr, "GoOmni project tool") fmt.Fprintln(os.Stderr, "") fmt.Fprintln(os.Stderr, "Usage:") + fmt.Fprintln(os.Stderr, " go-omni project Generate a transport-independent Go program") fmt.Fprintln(os.Stderr, " go-omni i18n Validate and export independent language resources") fmt.Fprintln(os.Stderr, " go-omni migrate [--dir migrations] [--apply] [--dsn-env GO_OMNI_DB_DSN]") fmt.Fprintln(os.Stderr, " go-omni new [--module ] ") @@ -217,6 +223,9 @@ func runAdd(args []string) error { return fmt.Errorf("unexpected argument %q", arg) } } + if _, err := os.Lstat(filepath.Join(directory, ".goOmni", "projectManifest.json")); !errors.Is(err, os.ErrNotExist) { + return errors.New("plain Go projects add packages as ordinary Go code; use project generate for infrastructure") + } if _, err := os.Stat(filepath.Join(directory, ".goOmni", "apiManifest.json")); err == nil { return errors.New("managed API projects add modules through .api imports and api generate") } diff --git a/cmd/go-omni/project.go b/cmd/go-omni/project.go new file mode 100644 index 0000000..0a2d840 --- /dev/null +++ b/cmd/go-omni/project.go @@ -0,0 +1,102 @@ +package main + +import ( + "errors" + "fmt" + "strings" + + "git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/projectgen" +) + +func runProject(args []string) error { + if len(args) == 0 || args[0] == "help" || args[0] == "--help" || args[0] == "-h" { + projectUsage() + return nil + } + command := args[0] + if command != "new" && command != "generate" && command != "check" { + return fmt.Errorf("unknown project command %q", command) + } + o := projectgen.Options{Check: command == "check"} + for i := 1; i < len(args); i++ { + arg := args[i] + if arg == "--help" || arg == "-h" { + projectUsage() + return nil + } + if arg == "--check" || arg == "--dry-run" { + if command != "generate" { + return fmt.Errorf("%s belongs to project generate", arg) + } + if arg == "--check" { + o.Check = true + } else { + o.DryRun = true + } + continue + } + if !strings.HasPrefix(arg, "-") { + if command != "new" || o.Dir != "" { + return fmt.Errorf("unexpected argument %q", arg) + } + o.Dir = arg + continue + } + name, value, inline := strings.Cut(arg, "=") + switch name { + case "--dir", "--module", "--framework-dir", "--framework-version": + default: + return fmt.Errorf("unknown project option %q", name) + } + if command != "new" && name != "--dir" { + return fmt.Errorf("%s belongs to project new", name) + } + if !inline { + if i+1 >= len(args) || strings.HasPrefix(args[i+1], "-") { + return fmt.Errorf("%s requires a value", name) + } + i++ + value = args[i] + } + if strings.TrimSpace(value) == "" { + return fmt.Errorf("%s requires a value", name) + } + switch name { + case "--dir": + if o.Dir != "" { + return errors.New("project directory specified more than once") + } + o.Dir = value + case "--module": + o.Module = value + case "--framework-dir": + o.FrameworkDir = value + case "--framework-version": + o.FrameworkVersion = value + } + } + var out projectgen.Output + var err error + if command == "new" { + out, err = projectgen.New(o) + } else { + out, err = projectgen.Generate(o) + } + if err != nil { + return err + } + if o.DryRun { + for _, change := range out.Changes { + fmt.Printf("%-7s %s\n", change.Action, change.Path) + } + } else { + fmt.Printf("project %s: %d file changes\n", command, len(out.Changes)) + } + return nil +} +func projectUsage() { + fmt.Println("go-omni project new [--module ] [--framework-version |--framework-dir ]") + fmt.Println("go-omni project generate [--dir ] [--check|--dry-run]") + fmt.Println("go-omni project check [--dir ]") + fmt.Println("Creates a plain Go program with config, logging, dependency lifecycle and an editable Run entry.") +} diff --git a/cmd/go-omni/project_test.go b/cmd/go-omni/project_test.go new file mode 100644 index 0000000..8b0b551 --- /dev/null +++ b/cmd/go-omni/project_test.go @@ -0,0 +1,27 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +func TestProjectCLI(t *testing.T) { + root := filepath.Join(t.TempDir(), "program") + for _, args := range [][]string{{"new", root, "--module", "example.com/program"}, {"generate", "--dir", root, "--dry-run"}, {"generate", "--dir", root}, {"check", "--dir", root}, {"generate", "--dir", root, "--check"}} { + if err := runProject(args); err != nil { + t.Fatal(args, err) + } + } + if err := runAdd([]string{"item", "--dir", root}); err == nil { + t.Fatal("add adopted plain project") + } + if _, err := os.Stat(filepath.Join(root, "internal/item")); !os.IsNotExist(err) { + t.Fatal("business module generated") + } + for _, args := range [][]string{{"new"}, {"new", "a", "b"}, {"new", "--api", "x"}, {"check", "--dry-run"}, {"generate", "--check", "--dry-run"}, {"generate", "--module", "x"}, {"new", "--module"}, {"new", "--dir="}, {"new", "--dir", "x", "y"}, {"unknown"}} { + if err := runProject(args); err == nil { + t.Fatal("accepted", args) + } + } +} diff --git a/cmd/go-omni/projectgen/consumer_test.go b/cmd/go-omni/projectgen/consumer_test.go new file mode 100644 index 0000000..6a99a0d --- /dev/null +++ b/cmd/go-omni/projectgen/consumer_test.go @@ -0,0 +1,190 @@ +package projectgen + +import ( + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "syscall" + "testing" + "time" +) + +func TestGeneratedProgramConsumer(t *testing.T) { + if os.Getenv("GO_OMNI_SCAFFOLD_INTEGRATION") != "1" { + t.Skip("set GO_OMNI_SCAFFOLD_INTEGRATION=1") + } + root := filepath.Join(t.TempDir(), "program") + cli := os.Getenv("GO_OMNI_RELEASE_CLI") + if cli != "" { + runCommand(t, "", cli, "project", "new", root, "--module", "example.com/plain") + } else { + framework, err := filepath.Abs("../../..") + if err != nil { + t.Fatal(err) + } + if _, err = New(Options{Dir: root, Module: "example.com/plain", FrameworkDir: framework}); err != nil { + t.Fatal(err) + } + } + // Only project-owned files are customized. All entry/loading/lifecycle code + // remains exactly what the local or downloaded CLI generated. + svc := get(t, root, "internal/svc/serviceContext.go") + svc = strings.Replace(svc, "return nil", "return Initialize(ctx, c)", 1) + svc += "\nvar Initialize = func(context.Context, *ServiceContext) error { return nil }\n" + put(t, root, "internal/svc/serviceContext.go", svc) + put(t, root, "internal/run/run.go", `package run + import("context";"errors";"os";"example.com/plain/internal/svc") + var Execute=func(ctx context.Context,deps *svc.ServiceContext)error{ + switch os.Getenv("GO_OMNI_PROGRAM_TEST_MODE") {case "error":return errors.New("process failure marker");case "panic":panic("process panic marker");case "unresponsive":select{}} + <-ctx.Done();return nil +} + func Run(ctx context.Context,deps *svc.ServiceContext)error{return Execute(ctx,deps)} + `) + put(t, root, "consumer_test.go", get(t, "testdata", "consumer_test.go")) + runCommand(t, root, "go", "mod", "tidy") + runCommand(t, root, "go", "test", "-race", "-count=1", "./...") + runCommand(t, root, "go", "vet", "./...") + binary := filepath.Join(root, "program") + runCommand(t, root, "go", "build", "-race", "-o", binary, ".") + before := snapshot(t, root) + for _, mode := range []string{"generate", "check", "dry-run"} { + if cli != "" { + args := []string{"project", mode, "--dir", root} + if mode == "dry-run" { + args = []string{"project", "generate", "--dir", root, "--dry-run"} + } + runCommand(t, root, cli, args...) + } else { + if _, err := Generate(Options{Dir: root, Check: mode == "check", DryRun: mode == "dry-run"}); err != nil { + t.Fatal(err) + } + } + } + after := snapshot(t, root) + for name, data := range before { + if after[name] != data { + t.Fatal("consumer changed during regeneration", name) + } + } + // Validation must not open even the configured log file. + logfile := filepath.Join(root, "check-must-not-open.log") + cfg := filepath.Join(root, "check.json") + raw, _ := json.Marshal(map[string]any{"log": map[string]any{"file": logfile}}) + put(t, root, "check.json", string(raw)) + runCommand(t, root, binary, "-config", cfg, "-check-config") + if _, err := os.Stat(logfile); !os.IsNotExist(err) { + t.Fatal("check-config opened logger", err) + } + put(t, root, "check.json", `{"log":{"level":"invalid"}}`) + cmd := exec.Command(binary, "-config", cfg, "-check-config") + cmd.Dir = root + if data, err := cmd.CombinedOutput(); err == nil || !strings.Contains(string(data), "log.level") { + t.Fatal("invalid config accepted", string(data), err) + } + for _, sig := range []os.Signal{os.Interrupt, syscall.SIGTERM} { + testProcess(t, root, binary, sig, "") + } + for _, mode := range []string{"error", "panic", "unresponsive"} { + var sig os.Signal + if mode == "unresponsive" { + sig = syscall.SIGTERM + } + testProcess(t, root, binary, sig, mode) + } + if cli != "" { + data := runCommand(t, root, "go", "list", "-m", "-json", frameworkModule) + var dep struct { + Version, Sum string + Replace any + } + if err := json.Unmarshal([]byte(data), &dep); err != nil { + t.Fatal(err) + } + if dep.Version != os.Getenv("GO_OMNI_RELEASE_VERSION") || dep.Replace != nil || dep.Sum == "" { + t.Fatal("not a fixed remote consumer", dep) + } + t.Logf("remote framework %s checksum %s; no local replace", dep.Version, dep.Sum) + } +} +func runCommand(t *testing.T, dir, name string, args ...string) string { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + cmd := exec.CommandContext(ctx, name, args...) + if dir != "" { + cmd.Dir = dir + } + cmd.Env = append(os.Environ(), "GOWORK=off") + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("%s %v: %v\n%s", name, args, err, output) + } + t.Logf("%s %v\n%s", name, args, output) + return string(output) +} +func testProcess(t *testing.T, root, binary string, sig os.Signal, mode string) { + t.Helper() + logfile, err := os.CreateTemp(root, "signal-*.log") + if err != nil { + t.Fatal(err) + } + defer logfile.Close() + cfg := filepath.Join(root, "process.json") + put(t, root, "process.json", `{"lifecycle":{"stopTimeout":"100ms","drainTimeout":"30ms","cancelTimeout":"100ms"}}`) + cmd := exec.Command(binary, "-config", cfg) + cmd.Env = append(os.Environ(), "GO_OMNI_PROGRAM_TEST_MODE="+mode) + cmd.Dir = root + cmd.Stdout = logfile + cmd.Stderr = logfile + if err = cmd.Start(); err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { done <- cmd.Wait() }() + defer func() { _ = cmd.Process.Kill() }() + deadline := time.Now().Add(10 * time.Second) + for { + data, _ := os.ReadFile(logfile.Name()) + if strings.Contains(string(data), "program.starting") { + break + } + if time.Now().After(deadline) { + t.Fatal("program did not start", string(data)) + } + time.Sleep(10 * time.Millisecond) + } + if path, err := exec.LookPath("lsof"); err == nil { + output, _ := exec.Command(path, "-nP", "-a", "-p", strconv.Itoa(cmd.Process.Pid), "-i").CombinedOutput() + if strings.Contains(string(output), "TCP") || strings.Contains(string(output), "UDP") { + t.Fatal("starter opened network socket", string(output)) + } + } + if sig != nil { + if err = cmd.Process.Signal(sig); err != nil { + t.Fatal(err) + } + } + select { + case err = <-done: + data, _ := os.ReadFile(logfile.Name()) + if mode == "" && err != nil { + t.Fatal("signal exit failed", err, string(data)) + } + if mode != "" { + exit, ok := err.(*exec.ExitError) + if !ok || exit.ExitCode() != 1 { + t.Fatal("abnormal process exit status", err, string(data)) + } + } + if mode == "unresponsive" && !strings.Contains(string(data), "shared resources retained") { + t.Fatal("timeout did not retain resources", string(data)) + } + t.Logf("process mode=%q signal=%v passed", mode, sig) + case <-time.After(10 * time.Second): + t.Fatal("signal exit timed out") + } +} diff --git a/cmd/go-omni/projectgen/generate.go b/cmd/go-omni/projectgen/generate.go new file mode 100644 index 0000000..67135f1 --- /dev/null +++ b/cmd/go-omni/projectgen/generate.go @@ -0,0 +1,359 @@ +// Package projectgen generates transport-independent Go programs. +package projectgen + +import ( + "bytes" + "crypto/sha256" + "embed" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "go/format" + "io" + "os" + "path/filepath" + "runtime/debug" + "sort" + "strconv" + "strings" + + "git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/internal/genfs" + "golang.org/x/mod/modfile" + "golang.org/x/mod/module" + "golang.org/x/mod/semver" +) + +const frameworkModule = "git.yuntuo.hk/k2safe/go-omni" +const currentVersion = "v0.28.0-preview" +const minimumVersion = "v0.28.0-preview" +const manifestPath = ".goOmni/projectManifest.json" +const lockPath = ".goOmni/project.lock" +const header = "// Code generated by go-omni project. DO NOT EDIT.\n" + +//go:embed templates/* +var templates embed.FS + +var managed = map[string]bool{"main.go": true, "internal/config/loadGen.go": true, "internal/svc/runtimeGen.go": true} +var seeds = []string{"go.mod", ".gitignore", "README.md", "etc/config.yaml", "internal/config/config.go", "internal/config/loader.go", "internal/svc/serviceContext.go", "internal/run/run.go"} + +type Options struct { + Dir, Module, FrameworkDir, FrameworkVersion string + Check, DryRun bool +} +type FileChange struct{ Path, Action string } +type Output struct{ Changes []FileChange } +type manifest struct { + Version int `json:"version"` + Kind string `json:"kind"` + TemplateVersion string `json:"templateVersion"` + Module string `json:"module"` + Generated map[string]string `json:"generated"` +} + +// New only accepts an empty directory. FrameworkDir is an explicit development opt-in. +func New(o Options) (Output, error) { + if o.Dir == "" || o.Check || o.DryRun { + return Output{}, errors.New("project new requires a directory and does not accept --check/--dry-run") + } + if entries, err := os.ReadDir(o.Dir); err == nil && len(entries) > 0 { + return Output{}, errors.New("project new requires an empty directory") + } else if err != nil && !errors.Is(err, os.ErrNotExist) { + return Output{}, err + } + if o.Module == "" { + o.Module = filepath.Base(genfs.Canonical(o.Dir)) + } + if err := module.CheckImportPath(o.Module); err != nil { + return Output{}, err + } + if _, err := moduleFile(o, genfs.Canonical(o.Dir), true); err != nil { + return Output{}, err + } + return generate(o, true) +} +func Generate(o Options) (Output, error) { return generate(o, false) } + +func generate(o Options, create bool) (out Output, err error) { + if o.Check && o.DryRun { + return out, errors.New("--check and --dry-run cannot be combined") + } + if o.Dir == "" { + o.Dir = "." + } + root, err := filepath.Abs(o.Dir) + if err != nil { + return out, err + } + if info, e := os.Lstat(root); e == nil { + if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return out, errors.New("project directory must be a real directory") + } + } else if !create || !errors.Is(e, os.ErrNotExist) { + return out, e + } + root = genfs.Canonical(root) + for _, name := range append(append([]string{}, seeds...), manifestPath, lockPath) { + if err = genfs.SafePath(root, name); err != nil { + return out, err + } + } + for name := range managed { + if err = genfs.SafePath(root, name); err != nil { + return out, err + } + } + for _, other := range []string{".goOmni/apiManifest.json", "api/service.api"} { + if _, e := os.Lstat(filepath.Join(root, other)); !errors.Is(e, os.ErrNotExist) { + return out, errors.New("project generator requires its own project; existing API layout is not adopted") + } + } + if !o.Check && !o.DryRun { + if err = os.MkdirAll(filepath.Join(root, ".goOmni"), 0755); err != nil { + return out, err + } + lock, e := os.OpenFile(filepath.Join(root, lockPath), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) + if e != nil { + return out, fmt.Errorf("acquire project.lock (check for a running generator before removing a stale lock): %w", e) + } + _, err = fmt.Fprintf(lock, "pid=%d\n", os.Getpid()) + err = errors.Join(err, lock.Close()) + defer os.Remove(filepath.Join(root, lockPath)) + if err != nil { + return out, err + } + } + var old manifest + oldData, readErr := os.ReadFile(filepath.Join(root, manifestPath)) + if create { + if !errors.Is(readErr, os.ErrNotExist) { + return out, errors.New("project manifest appeared during creation") + } + // Recheck after taking the lock; never adopt files created concurrently. + entries, e := os.ReadDir(root) + if e != nil { + return out, e + } + for _, entry := range entries { + if entry.Name() != ".goOmni" { + return out, errors.New("project directory changed during creation") + } + } + entries, e = os.ReadDir(filepath.Join(root, ".goOmni")) + if e != nil { + return out, e + } + for _, entry := range entries { + if entry.Name() != "project.lock" { + return out, errors.New("project metadata appeared during creation") + } + } + } else { + if readErr != nil { + return out, fmt.Errorf("read project manifest (create with project new first): %w", readErr) + } + decoder := json.NewDecoder(bytes.NewReader(oldData)) + decoder.DisallowUnknownFields() + if err = decoder.Decode(&old); err != nil { + return out, fmt.Errorf("invalid project manifest: %w", err) + } + var extra any + if decoder.Decode(&extra) != io.EOF { + return out, errors.New("invalid trailing project manifest data") + } + if old.Version != 1 || old.Kind != "project" || old.Module == "" || len(old.Generated) != len(managed) || !semver.IsValid(old.TemplateVersion) || semver.Compare(old.TemplateVersion, minimumVersion) > 0 { + return out, errors.New("unsupported project manifest; use a compatible CLI") + } + if o.Module != "" && o.Module != old.Module { + return out, errors.New("module differs from project manifest") + } + o.Module = old.Module + for name := range managed { + if len(old.Generated[name]) != 64 { + return out, fmt.Errorf("invalid manifest ownership for %s", name) + } + } + } + if o.Module == "" { + o.Module = filepath.Base(root) + } + if err = module.CheckImportPath(o.Module); err != nil { + return out, err + } + moduleData, err := moduleFile(o, root, create) + if err != nil { + return out, err + } + rendered, err := render(o.Module, moduleData) + if err != nil { + return out, err + } + next := manifest{Version: 1, Kind: "project", TemplateVersion: minimumVersion, Module: o.Module, Generated: map[string]string{}} + var changes []genfs.Change + names := make([]string, 0, len(rendered)) + for name := range rendered { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + target := filepath.Join(root, filepath.FromSlash(name)) + before, e := os.ReadFile(target) + exists := e == nil + if e != nil && !errors.Is(e, os.ErrNotExist) { + return out, e + } + generated := managed[name] + if !generated && !create { + if !exists { + return out, fmt.Errorf("project-owned file %s is missing; restore it explicitly", name) + } + continue + } + if generated { + next.Generated[name] = digest(rendered[name]) + if exists && (create || digest(before) != old.Generated[name]) { + return out, fmt.Errorf("generated file %s was edited; move custom code to project-owned files", name) + } + } else if exists { + return out, fmt.Errorf("refusing to adopt %s", name) + } + if exists && bytes.Equal(before, rendered[name]) { + continue + } + mode := os.FileMode(0644) + if exists { + info, e := os.Stat(target) + if e != nil { + return out, e + } + mode = info.Mode().Perm() + } + changes = append(changes, genfs.Change{Name: name, Before: before, After: rendered[name], Existed: exists, Mode: mode}) + } + data, err := json.MarshalIndent(next, "", " ") + if err != nil { + return out, err + } + data = append(data, '\n') + if !bytes.Equal(oldData, data) { + changes = append(changes, genfs.Change{Name: manifestPath, Before: oldData, After: data, Existed: oldData != nil, Mode: 0644}) + } + for _, c := range changes { + action := "create" + if c.Existed { + action = "update" + } + out.Changes = append(out.Changes, FileChange{c.Name, action}) + } + sort.Slice(out.Changes, func(i, j int) bool { return out.Changes[i].Path < out.Changes[j].Path }) + if o.DryRun { + return out, nil + } + if o.Check { + if len(changes) > 0 { + return out, fmt.Errorf("project generation drift: %v", out.Changes) + } + return out, nil + } + return out, genfs.Apply(root, changes) +} + +func moduleFile(o Options, root string, create bool) ([]byte, error) { + if !create { + data, err := os.ReadFile(filepath.Join(root, "go.mod")) + if err != nil { + return nil, err + } + mf, err := modfile.Parse("go.mod", data, nil) + if err != nil { + return nil, err + } + if mf.Module == nil || mf.Module.Mod.Path != o.Module { + return nil, errors.New("go.mod module differs from project manifest") + } + if o.FrameworkDir != "" || o.FrameworkVersion != "" { + return nil, errors.New("framework options belong to project new; update go.mod explicitly") + } + for _, r := range mf.Require { + if r.Mod.Path == frameworkModule && semver.Compare(r.Mod.Version, minimumVersion) >= 0 { + return data, nil + } + } + return nil, fmt.Errorf("go.mod must require go-omni %s or newer", minimumVersion) + } + version := o.FrameworkVersion + if version == "" { + version = currentVersion + if info, ok := debug.ReadBuildInfo(); ok && info.Main.Path == frameworkModule && info.Main.Sum != "" && semver.IsValid(info.Main.Version) { + version = info.Main.Version + } + } + if !semver.IsValid(version) || semver.Major(version) != "v0" && semver.Major(version) != "v1" || semver.Compare(version, minimumVersion) < 0 { + return nil, fmt.Errorf("framework version must be %s or newer", minimumVersion) + } + mf := new(modfile.File) + if err := mf.AddModuleStmt(o.Module); err != nil { + return nil, err + } + if err := mf.AddGoStmt("1.22.0"); err != nil { + return nil, err + } + if err := mf.AddRequire(frameworkModule, version); err != nil { + return nil, err + } + if o.FrameworkDir != "" { + local, err := filepath.Abs(o.FrameworkDir) + if err != nil { + return nil, err + } + data, err := os.ReadFile(filepath.Join(local, "go.mod")) + if err != nil { + return nil, err + } + source, err := modfile.Parse("go.mod", data, nil) + if err != nil || source.Module == nil || source.Module.Mod.Path != frameworkModule { + return nil, errors.New("--framework-dir must contain go-omni") + } + local = genfs.Canonical(local) + rel, err := filepath.Rel(root, local) + if err != nil { + return nil, err + } + rel = filepath.ToSlash(rel) + if !strings.HasPrefix(rel, ".") { + rel = "./" + rel + } + if err = mf.AddReplace(frameworkModule, "", rel, ""); err != nil { + return nil, err + } + } + return mf.Format() +} +func render(moduleName string, moduleData []byte) (map[string][]byte, error) { + files := map[string][]byte{"go.mod": moduleData} + paths := append([]string{}, seeds[1:]...) + for name := range managed { + paths = append(paths, name) + } + for _, name := range paths { + raw, err := templates.ReadFile("templates/" + strings.ReplaceAll(name, "/", "_") + ".tmpl") + if err != nil { + return nil, err + } + s := strings.ReplaceAll(string(raw), "\"PROJECT_MODULE\"", strconv.Quote(moduleName)) + s = strings.ReplaceAll(s, "PROJECT_MODULE/", moduleName+"/") + if managed[name] { + s = header + s + } + data := []byte(s) + if strings.HasSuffix(name, ".go") { + data, err = format.Source(data) + if err != nil { + return nil, fmt.Errorf("format %s: %w", name, err) + } + } + files[name] = data + } + return files, nil +} +func digest(data []byte) string { sum := sha256.Sum256(data); return hex.EncodeToString(sum[:]) } diff --git a/cmd/go-omni/projectgen/generate_test.go b/cmd/go-omni/projectgen/generate_test.go new file mode 100644 index 0000000..acb7b97 --- /dev/null +++ b/cmd/go-omni/projectgen/generate_test.go @@ -0,0 +1,215 @@ +package projectgen + +import ( + "encoding/json" + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +func put(t *testing.T, root, name, content string) { + t.Helper() + p := filepath.Join(root, name) + if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(content), 0644); err != nil { + t.Fatal(err) + } +} +func get(t *testing.T, root, name string) string { + t.Helper() + data, err := os.ReadFile(filepath.Join(root, name)) + if err != nil { + t.Fatal(err) + } + return string(data) +} +func snapshot(t *testing.T, root string) map[string]string { + t.Helper() + files := map[string]string{} + err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if !d.IsDir() { + data, e := os.ReadFile(path) + if e != nil { + return e + } + rel, _ := filepath.Rel(root, path) + files[rel] = string(data) + } + return nil + }) + if err != nil { + t.Fatal(err) + } + return files +} +func fresh(t *testing.T) string { + t.Helper() + root := filepath.Join(t.TempDir(), "program") + if _, err := New(Options{Dir: root, Module: "example.com/program"}); err != nil { + t.Fatal(err) + } + return root +} + +func TestOwnershipAndDrift(t *testing.T) { + root := fresh(t) + files := snapshot(t, root) + for name, source := range files { + if strings.HasSuffix(name, ".api") || strings.Contains(name, "handler") || strings.Contains(name, "openapi") { + t.Fatal("unexpected API output", name) + } + if strings.HasSuffix(name, ".go") { + for _, forbidden := range []string{"net/http", "httpx", "middleware", "ListenAndServe", "database/", "queue/", "task.New"} { + if strings.Contains(source, forbidden) { + t.Fatal(name, forbidden) + } + } + } + } + if strings.Contains(files["go.mod"], "replace") || !strings.Contains(files["go.mod"], currentVersion) { + t.Fatal("default dependency must be remote and fixed") + } + for _, name := range seeds { + if name != "go.mod" { + put(t, root, name, get(t, root, name)+"\n// project edit\n") + } + } + before := snapshot(t, root) + for _, o := range []Options{{Dir: root}, {Dir: root, Check: true}, {Dir: root, DryRun: true}} { + out, err := Generate(o) + if err != nil || len(out.Changes) != 0 { + t.Fatal(out, err) + } + } + if !reflect.DeepEqual(before, snapshot(t, root)) { + t.Fatal("project files changed") + } + original := get(t, root, "main.go") + put(t, root, "main.go", original+"\n// manual edit\n") + for _, o := range []Options{{Dir: root}, {Dir: root, Check: true}, {Dir: root, DryRun: true}} { + if _, err := Generate(o); err == nil || !strings.Contains(err.Error(), "was edited") { + t.Fatal(err) + } + } + put(t, root, "main.go", original) + if err := os.Remove(filepath.Join(root, "main.go")); err != nil { + t.Fatal(err) + } + before = snapshot(t, root) + if _, err := Generate(Options{Dir: root, Check: true}); err == nil { + t.Fatal("missing output accepted") + } + if !reflect.DeepEqual(before, snapshot(t, root)) { + t.Fatal("check wrote files") + } + out, err := Generate(Options{Dir: root, DryRun: true}) + if err != nil || len(out.Changes) != 1 { + t.Fatal(out, err) + } + if !reflect.DeepEqual(before, snapshot(t, root)) { + t.Fatal("dry-run wrote files") + } + if _, err = Generate(Options{Dir: root}); err != nil { + t.Fatal(err) + } + if get(t, root, "main.go") != original { + t.Fatal("missing output not restored") + } + if err = os.Remove(filepath.Join(root, "internal/run/run.go")); err != nil { + t.Fatal(err) + } + if _, err = Generate(Options{Dir: root}); err == nil || !strings.Contains(err.Error(), "project-owned") { + t.Fatal("silently recreated handwritten file", err) + } +} + +func TestRecordedTemplateUpgrade(t *testing.T) { + root := fresh(t) + var m manifest + if err := json.Unmarshal([]byte(get(t, root, manifestPath)), &m); err != nil { + t.Fatal(err) + } + original := get(t, root, "main.go") + older := original + "\n// previous generator baseline\n" + put(t, root, "main.go", older) + m.Generated["main.go"] = digest([]byte(older)) + data, _ := json.Marshal(m) + put(t, root, manifestPath, string(data)) + before := snapshot(t, root) + if _, err := Generate(Options{Dir: root, Check: true}); err == nil { + t.Fatal("stale baseline accepted") + } + if !reflect.DeepEqual(before, snapshot(t, root)) { + t.Fatal("check mutated baseline") + } + if _, err := Generate(Options{Dir: root}); err != nil { + t.Fatal(err) + } + if get(t, root, "main.go") != original { + t.Fatal("template was not updated") + } +} +func TestProjectGuards(t *testing.T) { + for _, tc := range []struct { + name, needle string + mutate func(*testing.T, string) + }{ + {"module", "module differs", func(t *testing.T, r string) { + put(t, r, "go.mod", strings.Replace(get(t, r, "go.mod"), "example.com/program", "example.com/other", 1)) + }}, + {"oldFramework", "must require", func(t *testing.T, r string) { + put(t, r, "go.mod", strings.ReplaceAll(get(t, r, "go.mod"), currentVersion, "v0.22.0-preview")) + }}, + {"manifest", "unsupported", func(t *testing.T, r string) { put(t, r, manifestPath, `{"version":1,"kind":"api"}`) }}, + {"lock", "project.lock", func(t *testing.T, r string) { put(t, r, lockPath, "occupied") }}, + {"otherGenerator", "existing API", func(t *testing.T, r string) { put(t, r, ".goOmni/apiManifest.json", "{}") }}, + {"symlink", "symlink", func(t *testing.T, r string) { + if e := os.Remove(filepath.Join(r, "main.go")); e != nil { + t.Fatal(e) + } + target := filepath.Join(t.TempDir(), "outside.go") + put(t, filepath.Dir(target), "outside.go", "outside") + if e := os.Symlink(target, filepath.Join(r, "main.go")); e != nil { + t.Fatal(e) + } + }}, + } { + t.Run(tc.name, func(t *testing.T) { + root := fresh(t) + tc.mutate(t, root) + before := snapshot(t, root) + if _, err := Generate(Options{Dir: root}); err == nil || !strings.Contains(err.Error(), tc.needle) { + t.Fatal(err) + } + if !reflect.DeepEqual(before, snapshot(t, root)) { + t.Fatal("failed generation changed files") + } + }) + } + root := fresh(t) + before := snapshot(t, root) + if _, err := New(Options{Dir: root}); err == nil { + t.Fatal("new overwrote project") + } + if !reflect.DeepEqual(before, snapshot(t, root)) { + t.Fatal("new mutated project") + } + if _, err := Generate(Options{Dir: t.TempDir(), Check: true}); err == nil { + t.Fatal("adopted unmanaged directory") + } + for _, version := range []string{"v0.27.0-preview", "latest", "v2.0.0", "v0.28.0-preview\nreplace bad"} { + if _, err := New(Options{Dir: filepath.Join(t.TempDir(), "app"), FrameworkVersion: version}); err == nil { + t.Fatal("invalid version", version) + } + } + if _, err := New(Options{Dir: filepath.Join(t.TempDir(), "app"), Module: "example.com/a\nreplace bad"}); err == nil { + t.Fatal("invalid module") + } +} diff --git a/cmd/go-omni/projectgen/templates/.gitignore.tmpl b/cmd/go-omni/projectgen/templates/.gitignore.tmpl new file mode 100644 index 0000000..bb5192a --- /dev/null +++ b/cmd/go-omni/projectgen/templates/.gitignore.tmpl @@ -0,0 +1,6 @@ +/program +/var/ +.env +.env.* +!.env.example +/.goOmni/project.lock diff --git a/cmd/go-omni/projectgen/templates/README.md.tmpl b/cmd/go-omni/projectgen/templates/README.md.tmpl new file mode 100644 index 0000000..5b669a8 --- /dev/null +++ b/cmd/go-omni/projectgen/templates/README.md.tmpl @@ -0,0 +1,55 @@ +# Go program + +This project has no HTTP listener, routes, tasks or external dependencies configured. + +```sh +go mod tidy +go run . -check-config +go run . +# SIGINT/SIGTERM stops the program. +go build -o program . +go-omni project generate --dry-run +go-omni project generate +go-omni project check +go test ./... +``` + +Use the same fixed CLI and framework version. Local --framework-dir is only for +explicit source development; normal creation pins the installed CLI version. + +Edit internal/config/config.go (Config/Default/Validate), etc/config.yaml and +internal/config/loader.go for optional decoders. JSON/YAML/XML resolve only +explicit ${NAME} / ${NAME:-fallback}; fixed values are never implicitly overridden. +-check-config validates without opening the logger or dependencies. + +Add dependencies in internal/svc/serviceContext.go, registering OnClose immediately. +Register all OnStop/OnDrain hooks in Init, before starting work. Paired hooks use the +same name. OnStop stops acquisition; OnDrain returns nil only after all processing, +acknowledgements and defers finish. Runtime executes stop, then drain, then resource +cleanup in reverse registration order. Callbacks within a phase run concurrently; +combine components with ordering requirements in a single hook. Accepted work uses +WorkContext; Context is the early stop notification. No automatic retry or restart +policy is installed. Adapt external components according to their actual semantics. + +Write program behavior in internal/run/run.go. Return nil when a one-shot run finishes; +return an error for fatal failure. The default Run waits for shutdown. Components may +call RequestShutdown(err) to report fatal failure. Signal or Run completion starts +shutdown; the entry waits for Run itself and registered background work. Do not make +Run wait for resource cleanup: cleanup follows Run completion. Do not register hooks +concurrently from Run. The drain name go-omni/project/run is reserved. + +Run/Init panics are converted to startup/run failures and registered resources are +cleaned safely. Panics in arbitrary project goroutines must be handled by the project. +Unresponsive work is bounded by lifecycle timeouts: unfinished work causes +ErrResourcesRetained, skips shared resource/logger cleanup and exits unsuccessfully. +Go cannot forcibly stop a goroutine. SIGKILL/process crashes cannot run cleanup. + +main.go, internal/config/loadGen.go and internal/svc/runtimeGen.go are generator-owned. +Manual edits cause a conflict. Other starter files are project-owned and preserved; +missing project-owned files require explicit restoration. Check is read-only and +reports missing or stale generated files. It does not compile or execute project code. +Commit .goOmni/projectManifest.json with the project. Generation uses a project lock, +preflight checks and per-file atomic replacement with rollback for ordinary write errors; +it is not crash-atomic across files. Avoid editing files during generation. + +Full guide: https://git.yuntuo.hk/k2safe/go-omni/src/branch/main/PROGRAM.md diff --git a/cmd/go-omni/projectgen/templates/etc_config.yaml.tmpl b/cmd/go-omni/projectgen/templates/etc_config.yaml.tmpl new file mode 100644 index 0000000..fa63f91 --- /dev/null +++ b/cmd/go-omni/projectgen/templates/etc_config.yaml.tmpl @@ -0,0 +1,9 @@ +# Project-owned. Environment references are explicit: ${NAME} or ${NAME:-fallback}. +log: + level: info + format: json + output: stderr +lifecycle: + stopTimeout: 5s + drainTimeout: 10s + cancelTimeout: 5s diff --git a/cmd/go-omni/projectgen/templates/internal_config_config.go.tmpl b/cmd/go-omni/projectgen/templates/internal_config_config.go.tmpl new file mode 100644 index 0000000..5562793 --- /dev/null +++ b/cmd/go-omni/projectgen/templates/internal_config_config.go.tmpl @@ -0,0 +1,22 @@ +package config + +import ( + omniConfig "git.yuntuo.hk/k2safe/go-omni/config" + "git.yuntuo.hk/k2safe/go-omni/lifecycle" + "git.yuntuo.hk/k2safe/go-omni/logx" +) + +// Config, Default and Validate are project-owned. Add project settings here. +type Config struct { + Log logx.Config `json:"log"` + Lifecycle lifecycle.ShutdownConfig `json:"lifecycle"` +} +func Default() Config { + log:=logx.Default();log.Service="PROJECT_MODULE" + return Config{Log:log,Lifecycle:lifecycle.DefaultShutdownConfig()} +} +func(c Config)Validate()error { + if err:=c.Log.Validate();err!=nil{return omniConfig.AtField("log",err)} + if err:=c.Lifecycle.Validate();err!=nil{return omniConfig.AtField("lifecycle",err)} + return nil +} diff --git a/cmd/go-omni/projectgen/templates/internal_config_loadGen.go.tmpl b/cmd/go-omni/projectgen/templates/internal_config_loadGen.go.tmpl new file mode 100644 index 0000000..9d85e96 --- /dev/null +++ b/cmd/go-omni/projectgen/templates/internal_config_loadGen.go.tmpl @@ -0,0 +1,6 @@ +package config +import omniConfig "git.yuntuo.hk/k2safe/go-omni/config" +func Load(path string)(Config,error){ + loader,err:=NewLoader();if err!=nil{return Config{},err} + return omniConfig.LoadProject(path,Default(),loader) +} diff --git a/cmd/go-omni/projectgen/templates/internal_config_loader.go.tmpl b/cmd/go-omni/projectgen/templates/internal_config_loader.go.tmpl new file mode 100644 index 0000000..486f980 --- /dev/null +++ b/cmd/go-omni/projectgen/templates/internal_config_loader.go.tmpl @@ -0,0 +1,4 @@ +package config +import omniConfig "git.yuntuo.hk/k2safe/go-omni/config" +// NewLoader is project-owned. Register additional decoders here if needed. +func NewLoader()(*omniConfig.Loader,error){return omniConfig.NewLoader()} diff --git a/cmd/go-omni/projectgen/templates/internal_run_run.go.tmpl b/cmd/go-omni/projectgen/templates/internal_run_run.go.tmpl new file mode 100644 index 0000000..f3251c5 --- /dev/null +++ b/cmd/go-omni/projectgen/templates/internal_run_run.go.tmpl @@ -0,0 +1,13 @@ +package run +import ( + "context" + "PROJECT_MODULE/internal/svc" +) +// Run is project-owned. Return nil for normal completion, or an error to fail. +// The starter waits for shutdown; a one-shot program can simply return when done. +// Register background stop/drain hooks in svc.Init, not concurrently here. +// Accepted background work uses deps.WorkContext(), not the early-stop ctx. +func Run(ctx context.Context,deps *svc.ServiceContext)error { + <-ctx.Done() + return nil +} diff --git a/cmd/go-omni/projectgen/templates/internal_svc_runtimeGen.go.tmpl b/cmd/go-omni/projectgen/templates/internal_svc_runtimeGen.go.tmpl new file mode 100644 index 0000000..1ef5157 --- /dev/null +++ b/cmd/go-omni/projectgen/templates/internal_svc_runtimeGen.go.tmpl @@ -0,0 +1,23 @@ +package svc +import ( + "context" + "errors" + "fmt" + "git.yuntuo.hk/k2safe/go-omni/lifecycle" + "git.yuntuo.hk/k2safe/go-omni/logx" + "PROJECT_MODULE/internal/config" +) +func New(ctx context.Context,logger *logx.Logger,cfg config.Config)(result *ServiceContext,err error){ + if ctx==nil{ctx=context.Background()} + if err=ctx.Err();err!=nil{return nil,errors.Join(err,lifecycle.Cause(ctx))} + if logger==nil{return nil,errors.New("service context requires a logger")} + if err=cfg.Validate();err!=nil{return nil,err} + runtime,err:=lifecycle.NewRuntimeWithConfig(logx.WithLogger(ctx,logger),cfg.Lifecycle);if err!=nil{return nil,err} + c:=&ServiceContext{Runtime:runtime,Config:cfg,Logger:logger} + defer func(){ + if p:=recover();p!=nil{err=fmt.Errorf("dependency initialization panic: %v",p)} + if err!=nil{result=nil;err=c.Finish(err)} + }() + if err=c.Init(c.Context());err!=nil{return nil,err} + return c,nil +} diff --git a/cmd/go-omni/projectgen/templates/internal_svc_serviceContext.go.tmpl b/cmd/go-omni/projectgen/templates/internal_svc_serviceContext.go.tmpl new file mode 100644 index 0000000..d11bcca --- /dev/null +++ b/cmd/go-omni/projectgen/templates/internal_svc_serviceContext.go.tmpl @@ -0,0 +1,21 @@ +package svc +import ( + "context" + "git.yuntuo.hk/k2safe/go-omni/lifecycle" + "git.yuntuo.hk/k2safe/go-omni/logx" + "PROJECT_MODULE/internal/config" +) +// ServiceContext is project-owned. Add dependencies without global variables. +// Runtime owns registered resources; Logger is borrowed from the entry point. +type ServiceContext struct { + *lifecycle.Runtime + Config config.Config + Logger *logx.Logger +} +// Init runs before Run. Register resources immediately with OnClose. +// Register paired OnStop/OnDrain hooks before exposing background work. +// Use WorkContext for accepted work; Context is the early stop notification. +// OnDrain returns nil only after processing and final cleanup have completed. +// Complete all registrations here, before Run can race with shutdown. +// The drain name "go-omni/project/run" is reserved for the generated entry. +func(c *ServiceContext)Init(ctx context.Context)error{return nil} diff --git a/cmd/go-omni/projectgen/templates/main.go.tmpl b/cmd/go-omni/projectgen/templates/main.go.tmpl new file mode 100644 index 0000000..7efd731 --- /dev/null +++ b/cmd/go-omni/projectgen/templates/main.go.tmpl @@ -0,0 +1,60 @@ +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "log" + "os" + "os/signal" + "syscall" + "git.yuntuo.hk/k2safe/go-omni/lifecycle" + "git.yuntuo.hk/k2safe/go-omni/logx" + "PROJECT_MODULE/internal/config" + program "PROJECT_MODULE/internal/run" + "PROJECT_MODULE/internal/svc" +) + +func main() { + path:=flag.String("config","etc/config.yaml","configuration file; empty uses Default()") + check:=flag.Bool("check-config",false,"validate configuration without initializing dependencies") + flag.Parse() + if *check {if _,err:=config.Load(*path);err!=nil{log.Fatal(err)};return} + ctx,stop:=signal.NotifyContext(context.Background(),os.Interrupt,syscall.SIGTERM) + defer stop() + if err:=run(ctx,*path);err!=nil{log.Fatal(err)} +} + +func run(ctx context.Context,path string)(runErr error) { + cfg,err:=config.Load(path);if err!=nil{return err} + logger,err:=logx.New(cfg.Log);if err!=nil{return err} + var deps *svc.ServiceContext + defer func(){ + if p:=recover();p!=nil{runErr=fmt.Errorf("program initialization panic: %v",p)} + if deps!=nil{runErr=deps.Finish(runErr)} + // Unjoined work may still use its dependencies and logger. The process exits + // unsuccessfully; resource retention never means the work completed. + if !errors.Is(runErr,lifecycle.ErrResourcesRetained){runErr=errors.Join(runErr,logger.Close())} + }() + deps,err=svc.New(ctx,logger,cfg);if err!=nil{return err} + if deps.Context().Err()!=nil{return nil} + done:=make(chan struct{}) + // This reserved drain proves Run has actually returned, including its defers. + // Runtime bounds the wait even when Run ignores cancellation. + deps.OnDrain("go-omni/project/run",func(context.Context)error{<-done;return nil}) + logger.InfoContext(deps.Context(),"program.starting") + go func(){ + var result error + returned:=false + defer func(){ + if p:=recover();p!=nil{result=fmt.Errorf("program run panic: %v",p)} else if !returned{result=errors.New("program run exited without returning")} + deps.RequestShutdown(result) + close(done) + }() + result=program.Run(deps.Context(),deps) + returned=true + }() + <-deps.Context().Done() + return nil +} diff --git a/cmd/go-omni/projectgen/testdata/consumer_test.go b/cmd/go-omni/projectgen/testdata/consumer_test.go new file mode 100644 index 0000000..1e49870 --- /dev/null +++ b/cmd/go-omni/projectgen/testdata/consumer_test.go @@ -0,0 +1,283 @@ +package main + +import ( + "context" + "errors" + "os" + "path/filepath" + "reflect" + "runtime" + "strings" + "sync/atomic" + "testing" + "time" + + "example.com/plain/internal/config" + program "example.com/plain/internal/run" + "example.com/plain/internal/svc" + "git.yuntuo.hk/k2safe/go-omni/lifecycle" +) + +func setup(t *testing.T) { + t.Helper() + svc.Initialize = func(context.Context, *svc.ServiceContext) error { return nil } + program.Execute = func(context.Context, *svc.ServiceContext) error { return nil } + t.Cleanup(func() { + svc.Initialize = func(context.Context, *svc.ServiceContext) error { return nil } + program.Execute = func(context.Context, *svc.ServiceContext) error { return nil } + }) +} +func shortConfig(t *testing.T, logfile string) string { + t.Helper() + p := filepath.Join(t.TempDir(), "config.yaml") + text := "log:\n output: discard\nlifecycle:\n stopTimeout: 100ms\n drainTimeout: 30ms\n cancelTimeout: 100ms\n" + if logfile != "" { + text = "log:\n file: " + logfile + "\nlifecycle:\n stopTimeout: 100ms\n drainTimeout: 30ms\n cancelTimeout: 100ms\n" + } + if err := os.WriteFile(p, []byte(text), 0600); err != nil { + t.Fatal(err) + } + return p +} + +func TestFiniteRunAndCleanupOrder(t *testing.T) { + setup(t) + var events []string + svc.Initialize = func(ctx context.Context, c *svc.ServiceContext) error { + events = append(events, "init") + c.OnClose(func() error { events = append(events, "first"); return nil }) + c.OnClose(func() error { events = append(events, "second"); return nil }) + return nil + } + program.Execute = func(context.Context, *svc.ServiceContext) error { + defer func() { events = append(events, "run defer") }() + events = append(events, "run") + return nil + } + if err := run(context.Background(), ""); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(events, []string{"init", "run", "run defer", "second", "first"}) { + t.Fatal(events) + } +} +func TestInitializationAndRunFailures(t *testing.T) { + for _, stage := range []string{"init error", "init panic", "run error", "run panic", "run goexit", "component failure"} { + t.Run(stage, func(t *testing.T) { + setup(t) + boom := errors.New("failure marker") + var closed atomic.Int32 + var started atomic.Bool + svc.Initialize = func(ctx context.Context, c *svc.ServiceContext) error { + c.OnClose(func() error { closed.Add(1); return nil }) + if stage == "init error" { + return boom + } + if stage == "init panic" { + panic(boom) + } + return nil + } + program.Execute = func(ctx context.Context, c *svc.ServiceContext) error { + started.Store(true) + switch stage { + case "run error": + return boom + case "run panic": + panic(boom) + case "run goexit": + runtime.Goexit() + case "component failure": + c.RequestShutdown(boom) + <-ctx.Done() + } + return nil + } + err := run(context.Background(), "") + if err == nil || closed.Load() != 1 { + t.Fatal(stage, err, closed.Load()) + } + if strings.HasPrefix(stage, "init") && started.Load() { + t.Fatal("run started after failed initialization") + } + if strings.Contains(stage, "error") || stage == "component failure" { + if !errors.Is(err, boom) { + t.Fatal("failure cause lost", err) + } + } + }) + } +} +func TestGracefulStopDrainsAcknowledgementBeforeClose(t *testing.T) { + setup(t) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + ready := make(chan struct{}) + stop := make(chan struct{}) + done := make(chan struct{}) + var closed, acked atomic.Bool + var canceledEarly atomic.Bool + svc.Initialize = func(ctx context.Context, c *svc.ServiceContext) error { + c.OnClose(func() error { + if !acked.Load() { + return errors.New("closed before acknowledgement") + } + closed.Store(true) + return nil + }) + c.OnStop("component", func(context.Context) error { close(stop); return nil }) + c.OnDrain("component", func(context.Context) error { <-done; return nil }) + go func() { + defer close(done) + <-stop + if c.WorkContext().Err() != nil { + canceledEarly.Store(true) + } + time.Sleep(20 * time.Millisecond) + acked.Store(true) + }() + return nil + } + program.Execute = func(ctx context.Context, c *svc.ServiceContext) error { close(ready); <-ctx.Done(); return nil } + result := make(chan error, 1) + go func() { result <- run(ctx, "") }() + <-ready + cancel() + if err := <-result; err != nil || !closed.Load() || canceledEarly.Load() { + t.Fatal(err, closed.Load(), canceledEarly.Load()) + } +} +func TestInitializationFailureDrainsStartedWork(t *testing.T) { + setup(t) + done := make(chan struct{}) + stop := make(chan struct{}) + var closed atomic.Bool + svc.Initialize = func(ctx context.Context, c *svc.ServiceContext) error { + c.OnClose(func() error { + select { + case <-done: + closed.Store(true) + return nil + default: + return errors.New("resource closed while starting work still active") + } + }) + c.OnStop("partial", func(context.Context) error { close(stop); return nil }) + c.OnDrain("partial", func(context.Context) error { <-done; return nil }) + go func() { defer close(done); <-stop; time.Sleep(10 * time.Millisecond) }() + return errors.New("later dependency failed") + } + if err := run(context.Background(), ""); err == nil || !closed.Load() { + t.Fatal(err, closed.Load()) + } +} +func TestCancellationGraceJoinsWork(t *testing.T) { + setup(t) + done := make(chan struct{}) + var closed atomic.Bool + svc.Initialize = func(ctx context.Context, c *svc.ServiceContext) error { + c.OnClose(func() error { closed.Store(true); return nil }) + c.OnStop("cooperative", func(context.Context) error { return nil }) + c.OnDrain("cooperative", func(context.Context) error { <-done; return nil }) + go func() { <-c.WorkContext().Done(); close(done) }() + return nil + } + err := run(context.Background(), shortConfig(t, "")) + if !errors.Is(err, context.DeadlineExceeded) || errors.Is(err, lifecycle.ErrResourcesRetained) || !closed.Load() { + t.Fatal(err, closed.Load()) + } +} +func TestUnjoinedWorkRetainsDependenciesAndLogger(t *testing.T) { + for _, which := range []string{"run", "background", "init failure"} { + t.Run(which, func(t *testing.T) { + setup(t) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + release := make(chan struct{}) + joined := make(chan struct{}) + ready := make(chan struct{}) + var closed atomic.Bool + var held *svc.ServiceContext + logfile := filepath.Join(t.TempDir(), "retained.log") + svc.Initialize = func(ctx context.Context, c *svc.ServiceContext) error { + held = c + c.OnClose(func() error { closed.Store(true); return nil }) + if which != "run" { + c.OnStop("stuck", func(context.Context) error { return nil }) + c.OnDrain("stuck", func(context.Context) error { <-joined; return nil }) + go func() { defer close(joined); <-release; c.Logger.Info("after shutdown deadline") }() + } + if which == "init failure" { + return errors.New("initialization failed") + } + return nil + } + program.Execute = func(ctx context.Context, c *svc.ServiceContext) error { + close(ready) + if which == "run" { + defer close(joined) + <-release + c.Logger.Info("after shutdown deadline") + } else { + <-ctx.Done() + } + return nil + } + result := make(chan error, 1) + go func() { result <- run(ctx, shortConfig(t, logfile)) }() + if which != "init failure" { + <-ready + cancel() + } + var err error + select { + case err = <-result: + case <-time.After(3 * time.Second): + t.Fatal("shutdown hung") + } + if !errors.Is(err, lifecycle.ErrResourcesRetained) || closed.Load() { + t.Fatal(err, closed.Load()) + } + close(release) + <-joined + // In the run case the entry's completion notification follows the user defer. + // Close is idempotent and must retain resources even after late completion. + if err = held.Close(); !errors.Is(err, lifecycle.ErrResourcesRetained) || closed.Load() { + t.Fatal("retention was lost", err) + } + data, _ := os.ReadFile(logfile) + if !strings.Contains(string(data), "after shutdown deadline") { + t.Fatal("logger closed while work still running", string(data)) + } + _ = held.Logger.Close() + }) + } +} +func TestExplicitConfigurationAndPreflight(t *testing.T) { + setup(t) + t.Setenv("PLAIN_LEVEL", "debug") + t.Setenv("LOG_LEVEL", "error") + p := filepath.Join(t.TempDir(), "config.yaml") + if err := os.WriteFile(p, []byte("log:\n level: ${PLAIN_LEVEL}\n"), 0600); err != nil { + t.Fatal(err) + } + cfg, err := config.Load(p) + if err != nil || cfg.Log.Level != "debug" { + t.Fatal(cfg.Log.Level, err) + } + if err = os.WriteFile(p, []byte("log:\n level: info\n"), 0600); err != nil { + t.Fatal(err) + } + cfg, err = config.Load(p) + if err != nil || cfg.Log.Level != "info" { + t.Fatal("implicit environment override", err) + } + var initCalled atomic.Bool + svc.Initialize = func(context.Context, *svc.ServiceContext) error { initCalled.Store(true); return nil } + if err = os.WriteFile(p, []byte("lifecycle:\n drainTimeout: -1s\n"), 0600); err != nil { + t.Fatal(err) + } + if err = run(context.Background(), p); err == nil || initCalled.Load() { + t.Fatal("invalid configuration initialized dependencies", err) + } +} diff --git a/cmd/go-omni/scaffold/version.go b/cmd/go-omni/scaffold/version.go index 912c6d0..177ae75 100644 --- a/cmd/go-omni/scaffold/version.go +++ b/cmd/go-omni/scaffold/version.go @@ -10,7 +10,7 @@ const frameworkModule = "git.yuntuo.hk/k2safe/go-omni" // Source builds can carry an unpublished VCS version (including +dirty). // Keep this fallback in sync with the next release; installed CLIs use their // downloaded module version, which also has a module checksum. -const defaultFrameworkVersion = "v0.27.0-preview" +const defaultFrameworkVersion = "v0.28.0-preview" // This target is not a claim that the release already exists. Source users // select a local framework checkout until the module templates are published. diff --git a/examples/program/.gitignore b/examples/program/.gitignore new file mode 100644 index 0000000..bb5192a --- /dev/null +++ b/examples/program/.gitignore @@ -0,0 +1,6 @@ +/program +/var/ +.env +.env.* +!.env.example +/.goOmni/project.lock diff --git a/examples/program/.goOmni/projectManifest.json b/examples/program/.goOmni/projectManifest.json new file mode 100644 index 0000000..062e82d --- /dev/null +++ b/examples/program/.goOmni/projectManifest.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "kind": "project", + "templateVersion": "v0.28.0-preview", + "module": "example.com/program", + "generated": { + "internal/config/loadGen.go": "8a1a2746f46596a7893832ca411bd7e2d23978661943d322ee732d2c095cbe47", + "internal/svc/runtimeGen.go": "0eb681169112302add633cc7355fbd19844b01fa0b38b94625ec18127e5fb937", + "main.go": "36168d3e3fe822f74cede8eac3fffe5502174899e242c41526f6ad5cab7af4fd" + } +} diff --git a/examples/program/README.md b/examples/program/README.md new file mode 100644 index 0000000..066836a --- /dev/null +++ b/examples/program/README.md @@ -0,0 +1,18 @@ +# 通用程序示例 + +由 `go-omni project new` 生成,入口、加载和装配文件未经修改。项目仅在配置添加 `once`,在 `internal/run/run.go` 决定完成即退出或等待信号;无端口、数据库、队列和业务任务。 + +```sh +cd examples/program +go mod tidy +go run . -check-config +go run . # once: true,完成即退出 +# 编辑 etc/config.yaml 为 once: false 后运行,用 Ctrl+C / SIGTERM 停止。 +go build -o program . +go-omni project check +go test ./... +``` + +使用同版远程 v0.28.0-preview CLI 与框架,无本地 replace。源码发布前开发可临时 `go mod edit -replace git.yuntuo.hk/k2safe/go-omni=../..`,验证后移除此本地替换。 + +配置、svc、Run 归项目维护;main/Gen.go 和清单由生成器维护。背景组件在 Init 登记停止与排空,已接受工作使用 WorkContext;未结束时不会提前释放共享依赖。详细规则见 [PROGRAM.md](../../PROGRAM.md)。 diff --git a/examples/program/etc/config.yaml b/examples/program/etc/config.yaml new file mode 100644 index 0000000..4e83725 --- /dev/null +++ b/examples/program/etc/config.yaml @@ -0,0 +1,12 @@ +# Project-owned. Environment references are explicit: ${NAME} or ${NAME:-fallback}. +log: + level: info + format: json + output: stderr +lifecycle: + stopTimeout: 5s + drainTimeout: 10s + cancelTimeout: 5s + +# true: finite run; false: wait for SIGINT/SIGTERM. +once: true diff --git a/examples/program/go.mod b/examples/program/go.mod new file mode 100644 index 0000000..0e3eb39 --- /dev/null +++ b/examples/program/go.mod @@ -0,0 +1,5 @@ +module example.com/program + +go 1.22.0 + +require git.yuntuo.hk/k2safe/go-omni v0.28.0-preview diff --git a/examples/program/internal/config/config.go b/examples/program/internal/config/config.go new file mode 100644 index 0000000..34a41ad --- /dev/null +++ b/examples/program/internal/config/config.go @@ -0,0 +1,29 @@ +package config + +import ( + omniConfig "git.yuntuo.hk/k2safe/go-omni/config" + "git.yuntuo.hk/k2safe/go-omni/lifecycle" + "git.yuntuo.hk/k2safe/go-omni/logx" +) + +// Config, Default and Validate are project-owned. Add project settings here. +type Config struct { + Once bool `json:"once"` + Log logx.Config `json:"log"` + Lifecycle lifecycle.ShutdownConfig `json:"lifecycle"` +} + +func Default() Config { + log := logx.Default() + log.Service = "example.com/program" + return Config{Once: true, Log: log, Lifecycle: lifecycle.DefaultShutdownConfig()} +} +func (c Config) Validate() error { + if err := c.Log.Validate(); err != nil { + return omniConfig.AtField("log", err) + } + if err := c.Lifecycle.Validate(); err != nil { + return omniConfig.AtField("lifecycle", err) + } + return nil +} diff --git a/examples/program/internal/config/loadGen.go b/examples/program/internal/config/loadGen.go new file mode 100644 index 0000000..cc708ae --- /dev/null +++ b/examples/program/internal/config/loadGen.go @@ -0,0 +1,12 @@ +// Code generated by go-omni project. DO NOT EDIT. +package config + +import omniConfig "git.yuntuo.hk/k2safe/go-omni/config" + +func Load(path string) (Config, error) { + loader, err := NewLoader() + if err != nil { + return Config{}, err + } + return omniConfig.LoadProject(path, Default(), loader) +} diff --git a/examples/program/internal/config/loader.go b/examples/program/internal/config/loader.go new file mode 100644 index 0000000..0057e99 --- /dev/null +++ b/examples/program/internal/config/loader.go @@ -0,0 +1,6 @@ +package config + +import omniConfig "git.yuntuo.hk/k2safe/go-omni/config" + +// NewLoader is project-owned. Register additional decoders here if needed. +func NewLoader() (*omniConfig.Loader, error) { return omniConfig.NewLoader() } diff --git a/examples/program/internal/run/run.go b/examples/program/internal/run/run.go new file mode 100644 index 0000000..b8c5892 --- /dev/null +++ b/examples/program/internal/run/run.go @@ -0,0 +1,19 @@ +package run + +import ( + "context" + "example.com/program/internal/svc" +) + +// Run is project-owned. Return nil for normal completion, or an error to fail. +// The starter waits for shutdown; a one-shot program can simply return when done. +// Register background stop/drain hooks in svc.Init, not concurrently here. +// Accepted background work uses deps.WorkContext(), not the early-stop ctx. +func Run(ctx context.Context, deps *svc.ServiceContext) error { + if deps.Config.Once { + deps.Logger.InfoContext(ctx, "program.completed") + return nil + } + <-ctx.Done() + return nil +} diff --git a/examples/program/internal/svc/runtimeGen.go b/examples/program/internal/svc/runtimeGen.go new file mode 100644 index 0000000..b435a0e --- /dev/null +++ b/examples/program/internal/svc/runtimeGen.go @@ -0,0 +1,44 @@ +// Code generated by go-omni project. DO NOT EDIT. +package svc + +import ( + "context" + "errors" + "example.com/program/internal/config" + "fmt" + "git.yuntuo.hk/k2safe/go-omni/lifecycle" + "git.yuntuo.hk/k2safe/go-omni/logx" +) + +func New(ctx context.Context, logger *logx.Logger, cfg config.Config) (result *ServiceContext, err error) { + if ctx == nil { + ctx = context.Background() + } + if err = ctx.Err(); err != nil { + return nil, errors.Join(err, lifecycle.Cause(ctx)) + } + if logger == nil { + return nil, errors.New("service context requires a logger") + } + if err = cfg.Validate(); err != nil { + return nil, err + } + runtime, err := lifecycle.NewRuntimeWithConfig(logx.WithLogger(ctx, logger), cfg.Lifecycle) + if err != nil { + return nil, err + } + c := &ServiceContext{Runtime: runtime, Config: cfg, Logger: logger} + defer func() { + if p := recover(); p != nil { + err = fmt.Errorf("dependency initialization panic: %v", p) + } + if err != nil { + result = nil + err = c.Finish(err) + } + }() + if err = c.Init(c.Context()); err != nil { + return nil, err + } + return c, nil +} diff --git a/examples/program/internal/svc/serviceContext.go b/examples/program/internal/svc/serviceContext.go new file mode 100644 index 0000000..84f2932 --- /dev/null +++ b/examples/program/internal/svc/serviceContext.go @@ -0,0 +1,24 @@ +package svc + +import ( + "context" + "example.com/program/internal/config" + "git.yuntuo.hk/k2safe/go-omni/lifecycle" + "git.yuntuo.hk/k2safe/go-omni/logx" +) + +// ServiceContext is project-owned. Add dependencies without global variables. +// Runtime owns registered resources; Logger is borrowed from the entry point. +type ServiceContext struct { + *lifecycle.Runtime + Config config.Config + Logger *logx.Logger +} + +// Init runs before Run. Register resources immediately with OnClose. +// Register paired OnStop/OnDrain hooks before exposing background work. +// Use WorkContext for accepted work; Context is the early stop notification. +// OnDrain returns nil only after processing and final cleanup have completed. +// Complete all registrations here, before Run can race with shutdown. +// The drain name "go-omni/project/run" is reserved for the generated entry. +func (c *ServiceContext) Init(ctx context.Context) error { return nil } diff --git a/examples/program/main.go b/examples/program/main.go new file mode 100644 index 0000000..4ab3a10 --- /dev/null +++ b/examples/program/main.go @@ -0,0 +1,89 @@ +// Code generated by go-omni project. DO NOT EDIT. +package main + +import ( + "context" + "errors" + "example.com/program/internal/config" + program "example.com/program/internal/run" + "example.com/program/internal/svc" + "flag" + "fmt" + "git.yuntuo.hk/k2safe/go-omni/lifecycle" + "git.yuntuo.hk/k2safe/go-omni/logx" + "log" + "os" + "os/signal" + "syscall" +) + +func main() { + path := flag.String("config", "etc/config.yaml", "configuration file; empty uses Default()") + check := flag.Bool("check-config", false, "validate configuration without initializing dependencies") + flag.Parse() + if *check { + if _, err := config.Load(*path); err != nil { + log.Fatal(err) + } + return + } + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + if err := run(ctx, *path); err != nil { + log.Fatal(err) + } +} + +func run(ctx context.Context, path string) (runErr error) { + cfg, err := config.Load(path) + if err != nil { + return err + } + logger, err := logx.New(cfg.Log) + if err != nil { + return err + } + var deps *svc.ServiceContext + defer func() { + if p := recover(); p != nil { + runErr = fmt.Errorf("program initialization panic: %v", p) + } + if deps != nil { + runErr = deps.Finish(runErr) + } + // Unjoined work may still use its dependencies and logger. The process exits + // unsuccessfully; resource retention never means the work completed. + if !errors.Is(runErr, lifecycle.ErrResourcesRetained) { + runErr = errors.Join(runErr, logger.Close()) + } + }() + deps, err = svc.New(ctx, logger, cfg) + if err != nil { + return err + } + if deps.Context().Err() != nil { + return nil + } + done := make(chan struct{}) + // This reserved drain proves Run has actually returned, including its defers. + // Runtime bounds the wait even when Run ignores cancellation. + deps.OnDrain("go-omni/project/run", func(context.Context) error { <-done; return nil }) + logger.InfoContext(deps.Context(), "program.starting") + go func() { + var result error + returned := false + defer func() { + if p := recover(); p != nil { + result = fmt.Errorf("program run panic: %v", p) + } else if !returned { + result = errors.New("program run exited without returning") + } + deps.RequestShutdown(result) + close(done) + }() + result = program.Run(deps.Context(), deps) + returned = true + }() + <-deps.Context().Done() + return nil +} diff --git a/lifecycle/README.md b/lifecycle/README.md index aca72d0..a64abd6 100644 --- a/lifecycle/README.md +++ b/lifecycle/README.md @@ -96,3 +96,7 @@ err := app.RunContext(control.Context()) 实际使用时 App 和依赖通常先初始化,再由并发监控回调请求退出。资源在 HTTP 排空完成后由调用方关闭,并读取 `control.Err()` 合并清理期间报告的首个故障。通知本身不会关闭资源、执行 os.Exit 或发送操作系统信号。 `RequestShutdown(nil)` 表示正常停止,非空 error 表示故障。Controller 保留首个非空故障,重复调用和并发调用安全。`Cause(ctx)` 将符合 errors.Is(err, context.Canceled) 的取消原因视为正常停止(包含新版 Go 的信号原因),保留其他取消原因和 DeadlineExceeded。显式 RequestShutdown(err) 上报的非空错误始终由 Controller.Err 保留,包括包装 context.Canceled 的错误。App.RunContext、ServeContext、RunWith 均保留这些异常原因,与网络适配器无关。 + +## 通用工程入口 + +`go-omni project new` 可生成不监听端口的普通 Go 程序,直接复用 Runtime。入口监督项目 Run 的完成,并与 Init 登记的后台工作一起排空;Run/Init 异常和资源保留信号会传递到退出状态。完整运行契约与配置见 [通用工程指南](../PROGRAM.md)。 diff --git a/reports/programAcceptance.md b/reports/programAcceptance.md new file mode 100644 index 0000000..eed6338 --- /dev/null +++ b/reports/programAcceptance.md @@ -0,0 +1,35 @@ +# v0.28 通用工程验收 + +本轮新增 `project new/generate/check`,默认生成普通 Go 程序,不包含 API、网络监听、外部服务初始化或业务任务。项目维护配置、svc 和 Run;模板维护入口、加载与生命周期装配。 + +## 发布前回归 + +2026-09-29,macOS arm64,Go 1.27.1 / Go 1.22.12: + +- `GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race -count=1 ./...`:56 个测试包通过,含现有 API 生成消费者与新通用工程消费者。 +- `GOTOOLCHAIN=go1.22.12 GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -count=1 ./...`:56 个测试包通过。 +- `go vet ./...`、`go build ./...`、`git diff --check`:通过。 +- 最终补充真实进程异常退出断言后,新生成器再次通过 race 集成回归与 Go 1.22 集成回归。 +- `examples/program` 在临时副本中使用显式本地替换,完成 tidy、race、配置检查、实际一次性运行、生成漂移检查;仓库示例不含本地 replace。 + +## 验证边界 + +生成保护覆盖:手写文件逐字节保留、修改生成文件报冲突、缺失生成文件补回、缺失手写文件显式报错、只读 check/预览、模板升级漂移、工程与模块身份、最低依赖版本、路径/符号链接和锁保护、写入冲突后的事务回滚。 + +实际生成的消费者保留全部入口/加载/装配代码,只修改项目所有文件,覆盖: + +- 一次性正常完成,主流程 defer 结束后反序关闭资源。 +- Init 返回错误或 panic,Run 返回错误、panic、Goexit,以及组件报告致命故障。 +- 初始化到一半失败后停止并等待已经启动的工作。 +- 停止接收后等待处理与确认完成,期间 WorkContext 仍可用。 +- 排空超时后请求取消,宽限期内完成则可清理;仍未结束则保留共享资源和日志。 +- 主运行入口、后台工作、失败初始化后的工作不响应取消,均不会提前关闭资源。 +- 显式环境引用、固定值不被隐式覆盖、非法配置阻止初始化,check-config 不打开日志文件。 +- 真实构建的进程:SIGINT/SIGTERM 正常退出码 0;Run 错误/panic、主流程不响应停止导致超时,退出码 1。 +- 默认进程通过本机 lsof 检查,无 TCP/UDP 端口;生成目录和源码无 API/Handler/Swagger/数据库/队列启动。 + +文件替换只对单文件原子,普通失败会回滚,整个工程不具备崩溃事务性。未登记的 goroutine、不遵守完成契约的项目组件、SIGKILL 和进程崩溃无法由模板保证收尾。新模板不改变既有调度器或队列的重试/投递语义。 + +## 远程发布验收 + +发布后运行 `scripts/verifyProgramRelease.py` 与原有 `scripts/verifyManagedRelease.py`,使用空模块缓存安装固定远程 CLI,禁止本地 replace。结果在完成后补入本文件及 JSON 报告。 diff --git a/scripts/verifyProgramRelease.py b/scripts/verifyProgramRelease.py new file mode 100644 index 0000000..84020c8 --- /dev/null +++ b/scripts/verifyProgramRelease.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +"""Verify the generic project CLI and its consumer from a fresh remote module cache.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import re +import tempfile +import time + +from verifyErrorInheritanceRelease import run + +ROOT = Path(__file__).resolve().parents[1] +MODULE = "git.yuntuo.hk/k2safe/go-omni" + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--version", required=True) + parser.add_argument("--report", type=Path, default=ROOT / "reports/programRemote.json") + args = parser.parse_args() + if not re.fullmatch(r"v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?", args.version): + parser.error("a fixed release version is required") + env = {k: v for k, v in os.environ.items() if not k.startswith("GO_OMNI_")} + env.update(GOWORK="off", GOFLAGS="", GOPRIVATE="git.yuntuo.hk", GONOSUMDB="git.yuntuo.hk") + started = time.monotonic() + with tempfile.TemporaryDirectory(prefix="goOmniProgramRelease-") as directory: + temp = Path(directory) + (temp / "bin").mkdir() + env.update(GOBIN=str(temp / "bin"), GOMODCACHE=str(temp / "modcache")) + print(f"Installing remote CLI {args.version} with an empty module cache", flush=True) + run(["go", "install", MODULE + "/cmd/go-omni@" + args.version], temp, env) + cli = temp / "bin/go-omni" + # No version argument and no local replacement: installed CLI owns the default. + starter = temp / "program" + run([cli, "project", "new", starter, "--module", "example.com/released"], temp, env) + run(["go", "mod", "tidy"], starter, env) + dependency = json.loads(run(["go", "list", "-m", "-json", MODULE], starter, env)) + assert dependency["Version"] == args.version and dependency.get("Sum") and not dependency.get("Replace") + downloaded = Path(dependency["Dir"]) + env.update(GO_OMNI_SCAFFOLD_INTEGRATION="1", GO_OMNI_RELEASE_CLI=str(cli), GO_OMNI_RELEASE_VERSION=args.version) + print("Testing downloaded generator, shared file transaction and generated program lifecycle", flush=True) + output = run(["go", "test", "-race", "-count=1", "-json", "-timeout=240s", + "./cmd/go-omni/projectgen", "./cmd/go-omni/internal/genfs"], downloaded, env) + events = [json.loads(line) for line in output.splitlines() if line.startswith("{")] + passed = [e["Test"] for e in events if e.get("Action") == "pass" and "Test" in e] + assert "TestGeneratedProgramConsumer" in passed and "TestConflictRollsBackEarlierWrites" in passed, output + assert not any(e.get("Action") == "fail" for e in events), output + evidence = "".join(e.get("Output", "") for e in events) + assert "no local replace" in evidence, evidence + # Check the committed standalone example with only project-owned additions. + for name in ("internal/config/config.go", "internal/run/run.go", "etc/config.yaml"): + text = (ROOT / "examples/program" / name).read_text().replace("example.com/program", "example.com/released") + (starter / name).write_text(text) + run([cli, "project", "check"], starter, env) + run(["go", "run", ".", "-check-config"], starter, env) + example_output = run(["go", "run", "."], starter, env) + assert "program.completed" in example_output, example_output + run(["go", "test", "./..."], starter, env) + sources = list((ROOT / "cmd/go-omni/projectgen").rglob("*.go")) + sources += list((ROOT / "cmd/go-omni/projectgen/templates").glob("*.tmpl")) + sources += list((ROOT / "cmd/go-omni/internal/genfs").glob("*.go")) + sources += [ROOT / "cmd/go-omni/project.go", ROOT / "scripts/verifyProgramRelease.py"] + report = { + "passed": True, "frameworkVersion": args.version, "frameworkSum": dependency["Sum"], + "freshModuleCache": True, "noLocalReplace": True, + "durationSeconds": round(time.monotonic() - started, 2), "tests": passed, + "checks": ["remoteCLIInstall", "installedVersionDefault", "generatedConsumerBuildRaceVet", + "defaultNoAPIFiles", "defaultNoNetworkListener", "finiteProgram", "SIGINT", "SIGTERM", + "initializationFailureAndPanic", "runErrorPanicGoexit", "fatalComponentFailure", + "nonzeroProcessFailureAndTimeoutExits", "partialStartupDraining", "acknowledgementBeforeResourceClose", "workContextSurvivesGracefulStop", + "cancellationGrace", "unresponsiveRunAndBackgroundWork", "retainedResourcesAndLogger", + "explicitConfigurationOnly", "checkConfigWithoutDependencies", "handwrittenFilesPreserved", + "generatedFileConflict", "missingGeneratedFileRestored", "missingHandwrittenFileNotRecreated", + "readOnlyCheckAndDryRun", "templateDriftUpgrade", "manifestIdentityAndVersionGuards", + "symlinkAndLockProtection", "fileTransactionRollback", "publishedExampleFiniteRun"], + "sourceHashes": {str(p.relative_to(ROOT)): hashlib.sha256(p.read_bytes()).hexdigest() for p in sorted(sources)}, + } + args.report.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n") + print(f"PASS remote generic program release {args.version}: {len(report['checks'])} checks", flush=True) + + +if __name__ == "__main__": + main()