From b8ab6b35219d53d6de392c920fe7ecbe9a76dade Mon Sep 17 00:00:00 2001 From: k2safe Date: Thu, 24 Sep 2026 22:25:44 +0800 Subject: [PATCH] feat: add optional GORM pool and SQL logging adapter --- PROJECT.md | 4 +- README.md | 10 +- RELEASE.md | 19 ++- cmd/go-omni/README.md | 6 +- cmd/go-omni/apigen/generate.go | 8 +- cmd/go-omni/scaffold/version.go | 2 +- database/README.md | 2 + database/gormx/README.md | 104 +++++++++++++++ database/gormx/gorm.go | 121 +++++++++++++++++ database/gormx/gorm_test.go | 173 +++++++++++++++++++++++++ database/gormx/integration_test.go | 157 ++++++++++++++++++++++ database/gormx/logger.go | 171 ++++++++++++++++++++++++ database/gormx/logger_test.go | 92 +++++++++++++ database/gormx/pool.go | 52 ++++++++ database/mysql/README.md | 2 + go.mod | 4 + go.sum | 12 ++ reports/gormAcceptance.md | 36 +++++ reports/gormLocal.json | 82 ++++++++++++ scripts/testdata/gormGenerated_test.go | 48 +++++++ scripts/verifyConnector.py | 35 ++++- 21 files changed, 1122 insertions(+), 18 deletions(-) create mode 100644 database/gormx/README.md create mode 100644 database/gormx/gorm.go create mode 100644 database/gormx/gorm_test.go create mode 100644 database/gormx/integration_test.go create mode 100644 database/gormx/logger.go create mode 100644 database/gormx/logger_test.go create mode 100644 database/gormx/pool.go create mode 100644 reports/gormAcceptance.md create mode 100644 reports/gormLocal.json create mode 100644 scripts/testdata/gormGenerated_test.go diff --git a/PROJECT.md b/PROJECT.md index b3b926b..7b003a8 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -1,6 +1,6 @@ # 生成项目与文件维护边界 -v0.14.0-preview 提供受管理的生成项目。安装同版远程 CLI,生成的项目直接引用该版本,无需本地 replace。根目录入口支持 `go run .` 和 `go build .`;框架本身仍然是可直接引用的库。安装和私有仓库访问见 [README](README.md#准备环境与安装)。 +v0.14.1-preview 提供受管理的生成项目。安装同版远程 CLI,生成的项目直接引用该版本,无需本地 replace。根目录入口支持 `go run .` 和 `go build .`;框架本身仍然是可直接引用的库。安装和私有仓库访问见 [README](README.md#准备环境与安装)。 ## 创建和日常开发 @@ -208,7 +208,7 @@ get /download (DownloadReq) ## 已有服务接入步骤 -每个独立服务生成一个项目,module 名称、上层目录和共享业务包由项目选择。接入时固定 CLI 与框架版本为 v0.14.0-preview: +每个独立服务生成一个项目,module 名称、上层目录和共享业务包由项目选择。接入时固定 CLI 与框架版本为 v0.14.1-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 e3a1b64..9e47502 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ GoOmni 是面向多个业务项目复用的 Go 后端框架:用 `.api` 定义接口、请求和返回类型,生成项目目录、HTTP 接入代码及 OpenAPI,业务代码负责 Logic 和 Repository。也可以在已有项目中直接使用 `omni.New()`,或单独引入日志、存储等模块。 -**从这份 README 开始接入。** 当前版本 **`v0.14.0-preview`**,新生成项目采用根目录入口和 `config / svc / logic / handler`。可以固定版本开展业务接入;文件维护边界和已有服务迁移步骤见 [项目指南](PROJECT.md),版本变化与验收见 [发布说明](RELEASE.md)。 +**从这份 README 开始接入。** 当前版本 **`v0.14.1-preview`**,新生成项目采用根目录入口和 `config / svc / logic / handler`。可以固定版本开展业务接入;文件维护边界和已有服务迁移步骤见 [项目指南](PROJECT.md),版本变化与验收见 [发布说明](RELEASE.md)。 Go **1.22+**。框架不依赖 Gin,默认使用标准库网络层,提供可切换的 native HTTP/1.1 和实验性 HTTP/2 适配器。 @@ -10,6 +10,8 @@ Go **1.22+**。框架不依赖 Gin,默认使用标准库网络层,提供可 新增通用开发能力见 [业务开发指南](DEVELOPMENT.md):数组/null、严格请求策略、声明式上传、完整配置环境覆盖、资源生命周期、共享契约和升级工具。MySQL 直接使用 [官方适配器](database/mysql/README.md)。 +使用 GORM 的项目可以选择 [gormx 适配器](database/gormx/README.md),复用数据库初始化和连接池,并将 SQL 日志接入 logx。 + ## 选择接入方式 | 你的项目 | 从哪里开始 | @@ -30,7 +32,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.14.0-preview +go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.14.1-preview export PATH="$(go env GOPATH)/bin:$PATH" go-omni --help ``` @@ -186,7 +188,7 @@ go-omni new --api catalog.api \ 在业务项目的 **go.mod 所在目录**执行: ```sh -go get git.yuntuo.hk/k2safe/go-omni@v0.14.0-preview +go get git.yuntuo.hk/k2safe/go-omni@v0.14.1-preview ``` 已有项目可直接复用现有目录、Service、Repository 和数据库连接,先选择一个接口接入。也可以只用 `logx`、`storage` 等包,暂时保留原来的 HTTP 框架。希望入口和 HTTP 接入都交给生成器的项目,按 [已有服务接入步骤](PROJECT.md#已有服务接入步骤) 在新目录生成后迁入业务代码。 @@ -456,7 +458,7 @@ go test ./... `main.go`、`Gen.go`、`docs/openapi.json` 和 manifest 由生成器管理,手动修改会报告冲突。Logic、Repository、中间件实现、配置和依赖装配属于项目,重复生成保留手写内容。契约变化会提示受影响的 Logic 文件和 DTO 字段,明确的签名错误会给出原签名与期望签名;生成后 `--check` 仍能检出未修复的问题。将 `.api`、生成文件和 `.goOmni/apiManifest.json` 一起提交,CI 同时运行 `generate --check` 与 `go test ./...`。详见 [契约变更提示与检查边界](PROJECT.md#契约变更提示)。 -CLI 与业务 module 固定使用 `v0.14.0-preview`。新布局不兼容旧 manifest;旧试验项目需在新目录生成并搬入业务文件,不能直接覆盖手写入口。新布局内部升级再执行上述生成与测试步骤,手写框架项目只需依赖升级及业务回归。版本变化、迁移边界和验收记录见 [发布说明](RELEASE.md)。 +CLI 与业务 module 固定使用 `v0.14.1-preview`。v0.14.0 项目直接升级依赖与 CLI,GORM 按需导入;v0.13 及更早项目按 [升级预览](DEVELOPMENT.md#v013-项目升级) 处理,不直接覆盖手写入口或业务文件。升级后执行生成检查与业务回归。版本变化、迁移边界和验收记录见 [发布说明](RELEASE.md)。 ## 按需深入 diff --git a/RELEASE.md b/RELEASE.md index c9522f1..ce6af67 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,4 +1,21 @@ -# v0.14.0-preview +# v0.14.1-preview + +增加可选 `database/gormx`:GORM 复用 go-omni 已初始化的标准池、TLS、限额、健康检查和关闭流程,业务继续使用原生 GORM 模型与事务。核心 App/数据库包的编译依赖链不引入 GORM。 + +在 svc.Init 中调用 `gormx.MySQL(ctx, c.DB, gormx.WithLogger(c.Logger))`;通用 Open 工厂保留其他方言入口。适配器保护借用池不被初始化失败路径关闭,并为初始化查询设置时限。查询使用 WithContext,SQL 日志接入 logx,请求标识、慢查询和错误分类贯通;默认不输出绑定参数与驱动完整错误文本。 + +v0.14.0 项目更新 module 与 CLI 即可,不改生成目录或 main。模板最低运行时保持 v0.14.0;修正新版 CLI 把最低兼容版本误当自身版本、导致同版消费者出现版本提示的问题。其他数据库方言和管理额外池的插件未纳入本次实库验收,原生 PrepareStmt 的生命周期限制见 [接入说明](database/gormx/README.md)。 + +```sh +go get git.yuntuo.hk/k2safe/go-omni@v0.14.1-preview +go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.14.1-preview +``` + +全量 race(含生成消费者)、vet、build,以及隔离 MySQL 的事务/取消/日志/生成项目接入通过,详见 [GORM 验收](reports/gormAcceptance.md)。 + +--- + +# v0.14.0-preview(历史版本) 本版补齐契约驱动的业务开发与通用基础设施。项目继续维护接口、配置、依赖装配、Logic、SQL 和业务规则;生成器维护入口、请求绑定、路由、响应与文档。 diff --git a/cmd/go-omni/README.md b/cmd/go-omni/README.md index 921987a..1880881 100644 --- a/cmd/go-omni/README.md +++ b/cmd/go-omni/README.md @@ -8,7 +8,7 @@ v0.14 新增 `api format [--check]`、`api upgrade [--dry-run|--preview <空目 ```sh # 仓库读取权限与 PATH 配置见根目录 README -go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.14.0-preview +go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.14.1-preview go-omni new --module example.com/hello ./hello # 从自己的契约生成;service.api 及其相对 import 文件需要在本地存在 @@ -43,14 +43,14 @@ main、Gen.go、OpenAPI 和 manifest 由生成器维护;业务文件按字节 ## 版本与迁移 -CLI 与框架依赖固定使用 v0.14.0-preview。远程安装的 CLI 默认把自身版本写入 go.mod,不添加本地 replace;`--framework-version` 可显式指定已发布的兼容版本。仅在开发框架源码时使用本地替换: +CLI 与框架依赖固定使用 v0.14.1-preview。远程安装的 CLI 默认把自身版本写入 go.mod,不添加本地 replace;`--framework-version` 可显式指定已发布的兼容版本。仅在开发框架源码时使用本地替换: ```sh # 在 go-omni 源码仓库中执行 go run ./cmd/go-omni new --module example.com/hello --framework-dir . ../hello ``` -新模板需要 v0.14.0-preview 的通用 Runtime、配置和绑定能力,继续使用 manifest v2。已有项目使用 [定点升级与预览](../../DEVELOPMENT.md#v013-项目升级):原始模板可自动替换,含业务修改的基础文件按预览合并;通用清理由 Runtime 负责。独立工具命令不会被删除。受管理项目通过 `.api` 模块导入增加业务,不使用旧 `add` 命令的手动路由装配。 +模板最低需要 v0.14.0-preview 的通用 Runtime、配置和绑定能力,继续使用 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 84b8004..b8f2b03 100644 --- a/cmd/go-omni/apigen/generate.go +++ b/cmd/go-omni/apigen/generate.go @@ -242,8 +242,12 @@ func generate(o Options, c *Compiled, starter bool) (Output, error) { } } out, err := apply(directory, o.Module, entry, generated, seeds, o.Check, o.DryRun, o.profile(), snapshotContract(c.Spec), o.lockHeld) - if requiredVersion != "" && requiredVersion != minimumVersion { - out.Diagnostics = append(out.Diagnostics, Diagnostic{Severity: "review", Code: "frameworkVersion", Path: "go.mod", Before: requiredVersion, After: minimumVersion, Message: "project dependency differs from this CLI template version; use matching CLI/framework versions, or verify the explicit local replacement"}) + expectedVersion := minimumVersion + if info, ok := debug.ReadBuildInfo(); ok && info.Main.Path == frameworkModule && info.Main.Sum != "" && semver.IsValid(info.Main.Version) { + expectedVersion = info.Main.Version + } + if requiredVersion != "" && requiredVersion != expectedVersion { + out.Diagnostics = append(out.Diagnostics, Diagnostic{Severity: "review", Code: "frameworkVersion", Path: "go.mod", Before: requiredVersion, After: expectedVersion, Message: "project dependency differs from this CLI version; use matching CLI/framework versions, or verify the explicit local replacement"}) sortDiagnostics(out.Diagnostics) } return out, err diff --git a/cmd/go-omni/scaffold/version.go b/cmd/go-omni/scaffold/version.go index 0c79a89..48f1279 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.14.0-preview" +const defaultFrameworkVersion = "v0.14.1-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/database/README.md b/database/README.md index 86c6980..db0d5c6 100644 --- a/database/README.md +++ b/database/README.md @@ -2,6 +2,8 @@ 结构化 MySQL 配置、独立 TLS/mTLS、驱动日志和生成项目接入见 [官方 MySQL 适配器](mysql/README.md)。已有 Open/OpenConnector 接口继续支持。 +使用 GORM 的项目可显式接入 [gormx 可选适配器](gormx/README.md),复用本包连接池,并将 SQL 日志接入 logx;普通 database 包不导入 GORM。 + `database` 包提供基于标准库 `database/sql` 的连接池生命周期,不绑定 ORM 或驱动: - `Config` / `ConfigFromEnv`:连接池参数和 `GO_OMNI_DB_*` 环境变量。 diff --git a/database/gormx/README.md b/database/gormx/README.md new file mode 100644 index 0000000..eb1fae4 --- /dev/null +++ b/database/gormx/README.md @@ -0,0 +1,104 @@ +# 可选 GORM 适配器 + +从 `v0.14.1-preview` 提供。`gormx` 把 GORM 接到 go-omni 已初始化的 `*sql.DB`,保留一份连接池和一处资源所有权。使用 GORM v2 API(本版验收版本为 `gorm.io/gorm v1.31.2`、MySQL dialect `v1.6.0`)。 + +核心 `omni`、`database`、`database/mysql` 不导入此包;普通项目的编译依赖链不包含 GORM。仓库同一 Go module 的 go.mod 会记录可选适配器依赖,使用方显式导入 `database/gormx` 才编译它。本版没有新增默认 ORM 或 `--orm` 脚手架开关。 + +## 生成项目接入 + +```sh +go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.14.1-preview +go-omni new --database mysql --module example.com/billing ./billing +``` + +按 [MySQL 配置说明](../mysql/README.md) 设置连接信息、TLS 和池大小。在开发者维护的 `internal/svc/serviceContext.go` 中增加 `ORM *gorm.DB` 字段,并填写现有 Init: + +```go +import ( + "context" + "git.yuntuo.hk/k2safe/go-omni/database/gormx" + "gorm.io/gorm" +) + +// 向现有 ServiceContext 添加 ORM *gorm.DB 字段。 +// 保留 Runtime/Config/Logger/DB 等现有字段。 +func (c *ServiceContext) Init(ctx context.Context) error { + orm, err := gormx.MySQL(ctx, c.DB, gormx.WithLogger(c.Logger)) + if err != nil { + return err + } + c.ORM = orm + return nil +} +``` + +执行 `go mod tidy`。main、runtimeGen、Handler 和路由无需修改;重新生成保留 svc 的字段与 Init。已有项目先 `go get git.yuntuo.hk/k2safe/go-omni@v0.14.1-preview`,同样接入;v0.14.0 生成项目的目录和生命周期契约不变。 + +Repository 接收 `*gorm.DB`,查询使用请求 context: + +```go +err := repo.db.WithContext(ctx).Where("id = ?", id).First(&item).Error + +err = repo.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + // 同一事务内始终使用 tx。 + return tx.Create(&item).Error +}) +``` + +现有模型、Hooks、Scopes、事务、命名策略和错误处理继续由项目维护。共享连接池不会把分别发起的 SQL/GORM 事务自动合并;不要在一个事务内切回根 ORM 或 c.DB 执行本应属于该事务的写入。 + +## 连接池与失败处理 + +- MySQL 配置、TLS、字符集、会话参数、池大小及健康检查由底层 `database/mysql` 管理,适配器不再设置另一套 DSN 或池参数。 +- GORM 方言的版本协商继续执行,自动 Ping 关闭以复用已完成的池初始化。初始化查询使用传入 context,并受池的 PingTimeout 限制;可用 `WithInitTimeout(3*time.Second)` 单独指定。 +- 返回成功后不保留启动 context,每次查询用 `WithContext(ctx)`。查询取消、错误、事务提交/回滚保持 GORM 原生语义。 +- 对方言初始化失败时 GORM 自动关闭连接池的路径提供保护;错误仍保留 errors.Is/As,原池继续归调用方管理。生成项目的 Init 返回错误时,再由 Runtime 统一回滚该项目资源。 +- 服务排空后由 go-omni 关闭原池;`orm.DB()` 返回的仍是那一个池。不要给它重复登记 Close,也不要让 Repository 关闭它。 + +适配器自身不创建后台 worker、不自动建表、不执行 AutoMigrate、不改写默认事务或全表更新保护,也不替业务重试写入。 + +## SQL 日志 + +默认借用当前 context 的 logx,也可显式 `WithLogger(c.Logger)`。每个 ORM 可以使用独立日志实例;不会改动进程全局 Logger。 + +```go +sqlLog := gormx.DefaultLogConfig() +sqlLog.Level = "info" // silent / error / warn / info +sqlLog.SlowThreshold = 300*time.Millisecond + +orm, err := gormx.MySQL(ctx, c.DB, + gormx.WithLogger(c.Logger), + gormx.WithLogConfig(sqlLog), + gormx.WithConfig(gorm.Config{ + TranslateError: true, + // NamingStrategy 等原生选项可按项目添加。 + }), +) +``` + +默认记录错误和超过 200 ms 的慢查询,忽略 ErrRecordNotFound 的错误事件;`SlowThreshold: 0` 关闭慢查询事件。事件为 `gorm.query`、`gorm.slowQuery`、`gorm.queryFailed`,携带 durationMs、rowsAffected、sql 和 context 中的 requestId/traceId。GORM 的 `Debug()`/`LogMode` 使用副本,不改变其他请求的日志级别;最终输出还受 logx 自身级别控制。 + +默认不插入绑定参数、不记录驱动完整错误文案,只记录错误类别;SQL 正文最多保留 4096 字节,截断后附加省略号。明确开启 `IncludeParameters` / `IncludeErrorDetails` 才输出这些内容。日志不会解析和脱敏直接写在原始 SQL 里的字面量,查询仍应使用占位符。仅需要日志适配的既有 GORM 实例,也可使用 `NewLogger(logger, config)` 返回的原生 `logger.Interface`。 + +`WithConfig` 中若显式设置 `gorm.Config.Logger`,则使用项目自己的 Logger,该 Logger 自行负责脱敏和上下文。配置和 Plugins/ClauseBuilders 容器会复制;用户自定义插件、NamingStrategy 等对象仍由用户保证并发安全。 + +## 其他方言与高级配置 + +MySQL 便捷入口封装了下面的通用入口: + +```go +orm, err := gormx.Open(ctx, pool, + func(conn gorm.ConnPool) gorm.Dialector { + return mysql.New(mysql.Config{Conn: conn}) + }, + gormx.WithLogger(logger), +) +``` + +其他方言可由项目提供相同形式的工厂,必须使用传入的 Conn,不能创建或替换连接池。本版真实数据库验收覆盖 MySQL 8.4,不代表其他数据库已经联调。 + +不能通过 WithConfig 的 ConnPool/Dialector 覆盖此约定。初始化期间不暴露裸 sql.DB;需要它的自定义插件应在 Open 成功后显式注册。创建额外池或替换 ConnPool 的路由插件不属于本适配器的一池所有权范围。 + +PrepareStmt 默认关闭;若显式开启,保留 GORM 的缓存行为,容量、TTL 和缓存清理由项目按 GORM 接口管理。当前固定的 GORM 版本,PreparedStmtDB.Close 只清理语句,不提供停止其缓存清理 goroutine 的公开接口;因此应在服务初始化时创建一次 ORM,避免按请求反复创建。适配器不修改第三方缓存实现,也不宣称解决其生命周期限制。 + +参考:[GORM 现有连接池](https://gorm.io/docs/connecting_to_the_database.html#Existing-database-connection)、[GORM Logger](https://gorm.io/docs/logger.html)。本版验收见 [GORM 验收报告](../../reports/gormAcceptance.md)。 diff --git a/database/gormx/gorm.go b/database/gormx/gorm.go new file mode 100644 index 0000000..69be2a7 --- /dev/null +++ b/database/gormx/gorm.go @@ -0,0 +1,121 @@ +// Package gormx optionally attaches GORM to a pool owned by GoOmni. It never +// creates a pool, changes pool limits, migrates tables or closes the owner. +package gormx + +import ( + "context" + "errors" + "maps" + "reflect" + "time" + + "git.yuntuo.hk/k2safe/go-omni/database" + "git.yuntuo.hk/k2safe/go-omni/logx" + gormmysql "gorm.io/driver/mysql" + "gorm.io/gorm" +) + +// DialectorFactory must use the supplied connection pool. It must not create a +// pool or retain/use the ORM before Open succeeds. For example, return +// mysql.New(mysql.Config{Conn: conn}). Other dialects can use the same contract. +type DialectorFactory func(conn gorm.ConnPool) gorm.Dialector + +type options struct { + config gorm.Config + logger *logx.Logger + logConfig LogConfig + timeout time.Duration +} +type Option func(*options) + +// WithConfig preserves native GORM behavior, including naming, hooks, prepared +// statements and transactions. ConnPool/Dialector are supplied by this adapter; +// automatic Ping is disabled because the owning pool already passed startup Ping. +// A custom Config.Logger takes precedence over the logx adapter. +func WithConfig(config gorm.Config) Option { return func(o *options) { o.config = config } } +func WithLogger(logger *logx.Logger) Option { return func(o *options) { o.logger = logger } } +func WithLogConfig(config LogConfig) Option { return func(o *options) { o.logConfig = config } } +func WithInitTimeout(timeout time.Duration) Option { return func(o *options) { o.timeout = timeout } } + +// Error exposes a safe initialization summary while preserving errors.Is/As. +type Error struct{ cause error } + +func (e *Error) Error() string { return "gormx: initialization failed" } +func (e *Error) Unwrap() error { return e.cause } + +// MySQL attaches GORM's MySQL dialect, including version negotiation, to the +// existing pool. Configure TLS, timeouts and connection limits on that pool. +func MySQL(ctx context.Context, pool *database.DB, opts ...Option) (*gorm.DB, error) { + return Open(ctx, pool, func(conn gorm.ConnPool) gorm.Dialector { + return gormmysql.New(gormmysql.Config{Conn: conn}) + }, opts...) +} + +// Open borrows pool. On failure (including dialect initialization failure), +// the original pool remains owned by the caller. No ORM Close is required. +// The returned ORM does not retain the startup context: each request should use +// WithContext(ctx). Never close the sql.DB returned by orm.DB() independently. +func Open(ctx context.Context, pool *database.DB, factory DialectorFactory, opts ...Option) (*gorm.DB, error) { + if ctx == nil { + ctx = context.Background() + } + if err := ctx.Err(); err != nil { + return nil, &Error{cause: err} + } + if pool == nil || pool.DB == nil || factory == nil { + return nil, errors.New("gormx: pool and dialector factory are required") + } + timeout := pool.PoolConfig().PingTimeout + if timeout == 0 { + timeout = 5 * time.Second + } + o := options{timeout: timeout, logConfig: DefaultLogConfig(), logger: logx.FromContext(ctx)} + for _, option := range opts { + if option != nil { + option(&o) + } + } + if o.timeout <= 0 { + return nil, errors.New("gormx: initialization timeout must be positive") + } + if o.config.ConnPool != nil || o.config.Dialector != nil { + return nil, errors.New("gormx: configure the existing pool through the dialector factory") + } + logger, err := NewLogger(o.logger, o.logConfig) + if err != nil { + return nil, err + } + config := o.config + config.Plugins = maps.Clone(config.Plugins) + config.ClauseBuilders = maps.Clone(config.ClauseBuilders) + if config.Logger == nil { + config.Logger = logger + } + config.DisableAutomaticPing = true + startup, cancel := context.WithTimeout(ctx, o.timeout) + defer cancel() + borrowed := &borrowedPool{db: pool.DB} + borrowed.startup.Store(&startupContext{startup}) + config.ConnPool = borrowed + dialect := factory(borrowed) + if dialect == nil || (reflect.ValueOf(dialect).Kind() == reflect.Pointer && reflect.ValueOf(dialect).IsNil()) { + return nil, errors.New("gormx: dialector factory returned nil") + } + orm, err := gorm.Open(dialect, &config) + if err != nil { + return nil, &Error{cause: err} + } + if err = startup.Err(); err != nil { + return nil, &Error{cause: err} + } + // Prepared statements are GORM's own wrapper over the very same pool. + conn := orm.ConnPool + if prepared, ok := conn.(*gorm.PreparedStmtDB); ok { + conn = prepared.ConnPool + } + if actual, ok := conn.(*borrowedPool); !ok || actual != borrowed { + return nil, errors.New("gormx: dialector or plugin replaced the supplied pool") + } + borrowed.startup.Store(nil) + return orm, nil +} diff --git a/database/gormx/gorm_test.go b/database/gormx/gorm_test.go new file mode 100644 index 0000000..0d12d4d --- /dev/null +++ b/database/gormx/gorm_test.go @@ -0,0 +1,173 @@ +package gormx + +import ( + "context" + "database/sql/driver" + "errors" + "io" + "sync/atomic" + "testing" + "time" + + "git.yuntuo.hk/k2safe/go-omni/database" + "git.yuntuo.hk/k2safe/go-omni/logx" + "gorm.io/gorm" + "gorm.io/gorm/clause" + "gorm.io/gorm/schema" +) + +type testConnector struct { + closed atomic.Int32 + query func(context.Context) error +} + +func (c *testConnector) Connect(context.Context) (driver.Conn, error) { + return &testConn{owner: c}, nil +} +func (c *testConnector) Driver() driver.Driver { return testDriver{} } +func (c *testConnector) Close() error { c.closed.Add(1); return nil } + +type testDriver struct{} + +func (testDriver) Open(string) (driver.Conn, error) { return nil, errors.New("unexpected new pool") } + +type testConn struct{ owner *testConnector } + +func (c *testConn) Prepare(string) (driver.Stmt, error) { return nil, errors.New("unused") } +func (c *testConn) Close() error { return nil } +func (c *testConn) Begin() (driver.Tx, error) { return nil, errors.New("unused") } +func (c *testConn) Ping(context.Context) error { return nil } +func (c *testConn) QueryContext(ctx context.Context, _ string, _ []driver.NamedValue) (driver.Rows, error) { + if c.owner.query != nil { + if err := c.owner.query(ctx); err != nil { + return nil, err + } + } + return &testRows{}, nil +} + +type testRows struct{ done bool } + +func (r *testRows) Columns() []string { return []string{"version"} } +func (r *testRows) Close() error { return nil } +func (r *testRows) Next(values []driver.Value) error { + if r.done { + return io.EOF + } + r.done = true + values[0] = "8.4.0" + return nil +} +func poolFixture(t *testing.T) (*database.DB, *testConnector) { + t.Helper() + connector := &testConnector{} + pool, err := database.OpenConnector(context.Background(), connector, database.DefaultPoolConfig()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = pool.Close() }) + return pool, connector +} + +func TestBorrowedPoolFailureAndStartupDeadline(t *testing.T) { + for _, mode := range []string{"driverError", "deadline", "canceled"} { + t.Run(mode, func(t *testing.T) { + pool, connector := poolFixture(t) + cause := errors.New("private-driver-error") + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + connector.query = func(ctx context.Context) error { + if mode == "driverError" { + return cause + } + <-ctx.Done() + return ctx.Err() + } + if mode == "canceled" { + cancel() + cause = context.Canceled + } else if mode == "deadline" { + cause = context.DeadlineExceeded + } + start := time.Now() + orm, err := MySQL(ctx, pool, WithInitTimeout(20*time.Millisecond)) + if orm != nil || !errors.Is(err, cause) { + t.Fatal("lost cause", err) + } + if time.Since(start) > time.Second { + t.Fatal("startup deadline ignored") + } + if connector.closed.Load() != 0 { + t.Fatal("borrowed pool closed after GORM failure") + } + if healthErr := pool.Health(context.Background()); healthErr != nil { + t.Fatal("owner pool unusable", healthErr) + } + if err.Error() == cause.Error() { + t.Fatal("unsafe initialization error") + } + }) + } +} + +func TestPoolIdentityNativeOptionsAndRequestContext(t *testing.T) { + pool, connector := poolFixture(t) + ctx, cancel := context.WithCancel(context.Background()) + cfg := gorm.Config{NamingStrategy: schema.NamingStrategy{TablePrefix: "business_"}, ClauseBuilders: map[string]clause.ClauseBuilder{}} + logger, _ := logx.New(logx.Config{Output: "discard"}) + orm, err := MySQL(ctx, pool, WithConfig(cfg), WithLogger(logger)) + if err != nil { + t.Fatal(err) + } + cancel() // The startup context must not poison future queries. + got, err := orm.DB() + if err != nil || got != pool.DB { + t.Fatal("pool identity changed", err) + } + if len(cfg.ClauseBuilders) != 0 || cfg.ConnPool != nil || cfg.Logger != nil { + t.Fatal("native input config mutated") + } + if orm.NamingStrategy.TableName("Item") != "business_items" { + t.Fatal("native option lost") + } + if orm.SkipDefaultTransaction || orm.AllowGlobalUpdate { + t.Fatal("transaction safety defaults changed") + } + ctx, cancel = context.WithCancel(context.Background()) + cancel() + var v string + if err = orm.WithContext(ctx).Raw("SELECT VERSION()").Scan(&v).Error; !errors.Is(err, context.Canceled) { + t.Fatal(err) + } + if err = orm.WithContext(context.Background()).Raw("SELECT VERSION()").Scan(&v).Error; err != nil || v != "8.4.0" { + t.Fatal(v, err) + } + if connector.closed.Load() != 0 { + t.Fatal("pool unexpectedly closed") + } + if err = pool.Close(); err != nil { + t.Fatal(err) + } + if err = orm.Raw("SELECT VERSION()").Scan(&v).Error; err == nil { + t.Fatal("owner close did not affect GORM") + } +} + +func TestRejectedOptionsDoNotTouchPool(t *testing.T) { + for _, option := range []Option{WithInitTimeout(0), WithLogConfig(LogConfig{Level: "bad"}), WithConfig(gorm.Config{ConnPool: &borrowedPool{}})} { + pool, connector := poolFixture(t) + if _, err := MySQL(context.Background(), pool, option); err == nil { + t.Fatal("invalid option accepted") + } + if connector.closed.Load() != 0 { + t.Fatal("pool closed") + } + } + if _, err := MySQL(nil, nil); err == nil { + t.Fatal("nil pool accepted") + } + pool, _ := poolFixture(t) + if _, err := Open(nil, pool, func(gorm.ConnPool) gorm.Dialector { return nil }); err == nil { + t.Fatal("nil dialect accepted") + } +} diff --git a/database/gormx/integration_test.go b/database/gormx/integration_test.go new file mode 100644 index 0000000..da5ef10 --- /dev/null +++ b/database/gormx/integration_test.go @@ -0,0 +1,157 @@ +package gormx_test + +import ( + "bytes" + "context" + "errors" + "net" + "os" + "strconv" + "strings" + "testing" + "time" + + "git.yuntuo.hk/k2safe/go-omni/database/gormx" + adapter "git.yuntuo.hk/k2safe/go-omni/database/mysql" + "git.yuntuo.hk/k2safe/go-omni/logx" + "gorm.io/gorm" +) + +type record struct { + ID int64 `gorm:"primaryKey;autoIncrement:false"` + Name string +} + +func (record) TableName() string { return "gorm_adapter_records" } + +func TestGORMMySQLTransactionsCancellationAndLogs(t *testing.T) { + if os.Getenv("GO_OMNI_CONNECTOR_MYSQL_TEST") != "1" { + t.Skip("use scripts/verifyConnector.py --gorm with isolated MySQL") + } + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + cfg := adapter.DefaultConfig() + host, port, err := net.SplitHostPort(os.Getenv("GO_OMNI_CONNECTOR_ADDR")) + if err != nil { + t.Fatal(err) + } + cfg.Host = host + cfg.Port, _ = strconv.Atoi(port) + cfg.User = os.Getenv("GO_OMNI_CONNECTOR_USER") + cfg.Password = os.Getenv("GO_OMNI_CONNECTOR_PASSWORD") + cfg.Database = os.Getenv("GO_OMNI_CONNECTOR_DATABASE") + cfg.TLS.CAFile = os.Getenv("GO_OMNI_CONNECTOR_CA_FILE") + cfg.TLS.ServerName = "localhost" + cfg.Pool.MaxOpenConns = 2 + cfg.Pool.MaxIdleConns = 2 + pool, err := adapter.Open(ctx, cfg) + if err != nil { + t.Fatal(err) + } + defer pool.Close() + var logs bytes.Buffer + logger, err := logx.New(logx.Config{Writer: &logs}) + if err != nil { + t.Fatal(err) + } + defer logger.Close() + logConfig := gormx.DefaultLogConfig() + logConfig.Level = "info" + orm, err := gormx.MySQL(ctx, pool, gormx.WithLogger(logger), gormx.WithLogConfig(logConfig), gormx.WithConfig(gorm.Config{PrepareStmt: true, TranslateError: true})) + if err != nil { + t.Fatal(err) + } + sqlDB, err := orm.DB() + if err != nil || sqlDB != pool.DB { + t.Fatal("pool not shared", err) + } + if sqlDB.Stats().MaxOpenConnections != 2 { + t.Fatal("pool limits changed") + } + var tables int + if err = pool.QueryRowContext(ctx, "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'gorm_adapter_records'").Scan(&tables); err != nil || tables != 0 { + t.Fatal("adapter must not migrate schema", err) + } + if _, err = pool.ExecContext(ctx, "CREATE TABLE gorm_adapter_records (id BIGINT PRIMARY KEY, name VARCHAR(100) UNIQUE NOT NULL)"); err != nil { + t.Fatal(err) + } + defer pool.ExecContext(context.Background(), "DROP TABLE gorm_adapter_records") + requestCtx := logx.WithRequestID(ctx, "gorm-request") + orm = orm.WithContext(requestCtx) + secret := "private-bound-name" + if err = orm.Transaction(func(tx *gorm.DB) error { return tx.Create(&record{ID: 1, Name: secret}).Error }); err != nil { + t.Fatal(err) + } + wanted := errors.New("rollback sentinel") + if err = orm.Transaction(func(tx *gorm.DB) error { + if e := tx.Create(&record{ID: 2, Name: "rollback"}).Error; e != nil { + return e + } + return wanted + }); !errors.Is(err, wanted) { + t.Fatal(err) + } + func() { + defer func() { + if recover() != "rollback panic" { + t.Error("panic changed") + } + }() + _ = orm.Transaction(func(tx *gorm.DB) error { + if e := tx.Create(&record{ID: 3, Name: "panic"}).Error; e != nil { + t.Fatal(e) + } + panic("rollback panic") + }) + }() + var count int64 + if err = orm.Model(&record{}).Count(&count).Error; err != nil || count != 1 { + t.Fatal("commit/rollback differs", count, err) + } + if err = orm.Transaction(func(tx *gorm.DB) error { + if e := tx.Create(&record{ID: 4, Name: "outer"}).Error; e != nil { + return e + } + if e := tx.Transaction(func(inner *gorm.DB) error { + if e := inner.Create(&record{ID: 5, Name: "inner"}).Error; e != nil { + return e + } + return wanted + }); !errors.Is(e, wanted) { + return errors.New("nested rollback failed") + } + return nil + }); err != nil { + t.Fatal(err) + } + if err = orm.Model(&record{}).Count(&count).Error; err != nil || count != 2 { + t.Fatal(count, err) + } + if err = orm.Create(&record{ID: 6, Name: secret}).Error; !errors.Is(err, gorm.ErrDuplicatedKey) { + t.Fatal("native error translation lost", err) + } + var missing record + if err = orm.First(&missing, "id = ?", 999).Error; !errors.Is(err, gorm.ErrRecordNotFound) { + t.Fatal("not-found semantics changed", err) + } + deadline, stop := context.WithTimeout(requestCtx, 30*time.Millisecond) + start := time.Now() + err = orm.WithContext(deadline).Exec("SELECT SLEEP(3)").Error + stop() + if !errors.Is(err, context.DeadlineExceeded) || time.Since(start) > time.Second { + t.Fatal("query cancellation lost", err) + } + if err = pool.Health(ctx); err != nil { + t.Fatal("pool did not recover", err) + } + if strings.Contains(logs.String(), secret) || !strings.Contains(logs.String(), `"requestId":"gorm-request"`) || !strings.Contains(logs.String(), "gorm.queryFailed") { + t.Fatal("SQL log privacy/correlation differs") + } + var cipher struct { + VariableName string + Value string + } + if err = orm.Raw("SHOW SESSION STATUS LIKE 'Ssl_cipher'").Scan(&cipher).Error; err != nil || cipher.Value == "" { + t.Fatal("GORM bypassed TLS pool", err) + } +} diff --git a/database/gormx/logger.go b/database/gormx/logger.go new file mode 100644 index 0000000..a840e6c --- /dev/null +++ b/database/gormx/logger.go @@ -0,0 +1,171 @@ +package gormx + +import ( + "context" + "errors" + "fmt" + "time" + "unicode/utf8" + + "git.yuntuo.hk/k2safe/go-omni/logx" + "gorm.io/gorm" + gormlog "gorm.io/gorm/logger" +) + +type LogConfig struct { + Level string `json:"level"` // silent, error, warn (default), info + SlowThreshold time.Duration `json:"slowThreshold"` // zero disables slow-query events + IgnoreRecordNotFound bool `json:"ignoreRecordNotFound"` + IncludeParameters bool `json:"includeParameters"` + IncludeErrorDetails bool `json:"includeErrorDetails"` + MaxSQLBytes int `json:"maxSqlBytes"` // zero uses 4096; bounds emitted SQL text +} + +func DefaultLogConfig() LogConfig { + return LogConfig{Level: "warn", SlowThreshold: 200 * time.Millisecond, IgnoreRecordNotFound: true, MaxSQLBytes: 4096} +} +func (c LogConfig) Validate() error { + switch c.Level { + case "", "silent", "error", "warn", "info": + default: + return errors.New("gormx: invalid SQL log level") + } + if c.SlowThreshold < 0 || c.MaxSQLBytes < 0 { + return errors.New("gormx: SQL log limits cannot be negative") + } + return nil +} + +type sqlLogger struct { + logger *logx.Logger + config LogConfig + level gormlog.LogLevel +} + +// NewLogger adapts logx without changing its level, globals or ownership. +// Bound SQL arguments and raw driver error text are omitted by default. SQL +// literals written directly into a query are not parsed or redacted. +func NewLogger(logger *logx.Logger, config LogConfig) (gormlog.Interface, error) { + if err := config.Validate(); err != nil { + return nil, err + } + if config.MaxSQLBytes == 0 { + config.MaxSQLBytes = 4096 + } + level := gormlog.Warn + switch config.Level { + case "silent": + level = gormlog.Silent + case "error": + level = gormlog.Error + case "info": + level = gormlog.Info + } + return &sqlLogger{logger: logger, config: config, level: level}, nil +} +func (l *sqlLogger) LogMode(level gormlog.LogLevel) gormlog.Interface { + copy := *l + if level < gormlog.Silent || level > gormlog.Info { + level = gormlog.Silent + } + copy.level = level + return © +} +func (l *sqlLogger) output(ctx context.Context) *logx.Logger { + if l.logger != nil { + return l.logger + } + return logx.FromContext(ctx) +} +func (l *sqlLogger) message(msg string, args []any) string { + if !l.config.IncludeErrorDetails { + copy := append([]any(nil), args...) + for i, v := range copy { + if e, ok := v.(error); ok { + copy[i] = errorKind(e) + } + } + args = copy + } + return clip(fmt.Sprintf(msg, args...), l.config.MaxSQLBytes) +} +func (l *sqlLogger) Info(ctx context.Context, msg string, args ...any) { + if l.level >= gormlog.Info { + l.output(ctx).InfoContext(ctx, "gorm.message", "message", l.message(msg, args)) + } +} +func (l *sqlLogger) Warn(ctx context.Context, msg string, args ...any) { + if l.level >= gormlog.Warn { + l.output(ctx).WarnContext(ctx, "gorm.message", "message", l.message(msg, args)) + } +} +func (l *sqlLogger) Error(ctx context.Context, msg string, args ...any) { + if l.level >= gormlog.Error { + l.output(ctx).ErrorContext(ctx, "gorm.message", "message", l.message(msg, args)) + } +} + +func (l *sqlLogger) ParamsFilter(_ context.Context, query string, args ...any) (string, []any) { + if !l.config.IncludeParameters { + return query, nil + } + return query, args +} +func (l *sqlLogger) Trace(ctx context.Context, begin time.Time, fc func() (string, int64), err error) { + if l.level == gormlog.Silent { + return + } + elapsed := time.Since(begin) + failure := err != nil && !(l.config.IgnoreRecordNotFound && errors.Is(err, gorm.ErrRecordNotFound)) + slow := l.config.SlowThreshold > 0 && elapsed > l.config.SlowThreshold + level := gormlog.Info + if failure && l.level >= gormlog.Error { + level = gormlog.Error + } else if slow && l.level >= gormlog.Warn { + level = gormlog.Warn + } else if l.level < gormlog.Info { + return + } + query, rows := fc() + attrs := []any{"durationMs", float64(elapsed) / float64(time.Millisecond), "rowsAffected", rows, "sql", clip(query, l.config.MaxSQLBytes)} + if level == gormlog.Error { + attrs = append(attrs, "errorKind", errorKind(err)) + if l.config.IncludeErrorDetails { + attrs = append(attrs, "error", clip(err.Error(), l.config.MaxSQLBytes)) + } + } + logger := l.output(ctx) + switch level { + case gormlog.Error: + logger.ErrorContext(ctx, "gorm.queryFailed", attrs...) + case gormlog.Warn: + logger.WarnContext(ctx, "gorm.slowQuery", attrs...) + default: + logger.InfoContext(ctx, "gorm.query", attrs...) + } +} +func errorKind(err error) string { + switch { + case errors.Is(err, context.Canceled): + return "canceled" + case errors.Is(err, context.DeadlineExceeded): + return "deadlineExceeded" + case errors.Is(err, gorm.ErrRecordNotFound): + return "recordNotFound" + default: + return fmt.Sprintf("%T", err) + } +} +func clip(value string, limit int) string { + if len(value) <= limit { + return value + } + value = value[:limit] + for !utf8.ValidString(value) && len(value) > 0 { + value = value[:len(value)-1] + } + return value + "…" +} + +var _ gormlog.Interface = (*sqlLogger)(nil) +var _ gorm.ParamsFilter = (*sqlLogger)(nil) diff --git a/database/gormx/logger_test.go b/database/gormx/logger_test.go new file mode 100644 index 0000000..e387eac --- /dev/null +++ b/database/gormx/logger_test.go @@ -0,0 +1,92 @@ +package gormx + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "sync" + "testing" + "time" + + "git.yuntuo.hk/k2safe/go-omni/logx" + "gorm.io/gorm" + gormlog "gorm.io/gorm/logger" +) + +func TestSQLLoggerLevelsPrivacyAndCorrelation(t *testing.T) { + var out bytes.Buffer + base, _ := logx.New(logx.Config{Writer: &out}) + l, _ := NewLogger(base, DefaultLogConfig()) + ctx := logx.WithRequestID(logx.WithTraceID(context.Background(), "trace-1"), "request-1") + count := 0 + fc := func() (string, int64) { count++; return "SELECT * FROM records WHERE token = ?", 1 } + l.Trace(ctx, time.Now(), fc, nil) + l.Trace(ctx, time.Now(), fc, fmt.Errorf("wrapped: %w", gorm.ErrRecordNotFound)) + l.LogMode(gormlog.Silent).Trace(ctx, time.Now(), fc, errors.New("password-value")) + if count != 0 { + t.Fatal("ignored event evaluated SQL") + } + l.Trace(ctx, time.Now().Add(-time.Second), fc, nil) + l.Trace(ctx, time.Now(), fc, errors.New("password-value")) + l.Error(ctx, "failed: %v", errors.New("password-value")) + l.LogMode(gormlog.Info).Trace(ctx, time.Now(), fc, nil) + if count != 3 { + t.Fatal(count) + } + if strings.Contains(out.String(), "password-value") { + t.Fatal("driver detail leaked", out.String()) + } + for _, text := range []string{"gorm.slowQuery", "gorm.queryFailed", "gorm.query", `"requestId":"request-1"`, `"traceId":"trace-1"`} { + if !strings.Contains(out.String(), text) { + t.Fatal(text, out.String()) + } + } + q, args := l.(gorm.ParamsFilter).ParamsFilter(ctx, "SELECT ?", "private") + if q != "SELECT ?" || len(args) != 0 { + t.Fatal("default SQL arguments exposed") + } + // LogMode clones rather than mutating the shared instance. + l.Trace(ctx, time.Now(), fc, nil) + if count != 3 { + t.Fatal("session mode changed root logger") + } +} + +func TestSQLLoggerExplicitDetailsAndConcurrentModes(t *testing.T) { + var out bytes.Buffer + base, _ := logx.New(logx.Config{Writer: &out}) + cfg := DefaultLogConfig() + cfg.IncludeParameters = true + cfg.IncludeErrorDetails = true + cfg.Level = "info" + cfg.MaxSQLBytes = 8 + l, err := NewLogger(base, cfg) + if err != nil { + t.Fatal(err) + } + _, args := l.(gorm.ParamsFilter).ParamsFilter(context.Background(), "SELECT ?", "value") + if len(args) != 1 { + t.Fatal("explicit arguments dropped") + } + var wg sync.WaitGroup + for i := 0; i < 20; i++ { + wg.Add(1) + go func() { + defer wg.Done() + l.LogMode(gormlog.Error).Trace(context.Background(), time.Now(), func() (string, int64) { return "SELECT 中文标识符", -1 }, errors.New("failure")) + }() + } + wg.Wait() + for _, line := range strings.Split(strings.TrimSpace(out.String()), "\n") { + var event map[string]any + if err = json.Unmarshal([]byte(line), &event); err != nil { + t.Fatal(err) + } + if event["error"] != "failure" || strings.Contains(event["sql"].(string), "�") { + t.Fatal(event) + } + } +} diff --git a/database/gormx/pool.go b/database/gormx/pool.go new file mode 100644 index 0000000..763c583 --- /dev/null +++ b/database/gormx/pool.go @@ -0,0 +1,52 @@ +package gormx + +import ( + "context" + "database/sql" + "sync/atomic" + + "gorm.io/gorm" +) + +// GORM can call DB().Close on failed initialization. Do not expose the owner's +// sql.DB until initialization has succeeded; SQL methods remain fully usable. +// Dialects using context.Background during version probing are bounded by the +// startup context, which stops affecting queries as soon as Open succeeds. +type borrowedPool struct { + db *sql.DB + startup atomic.Pointer[startupContext] +} + +type startupContext struct{ context.Context } + +func (p *borrowedPool) operationContext(ctx context.Context) context.Context { + if startup := p.startup.Load(); startup != nil { + return startup.Context + } + return ctx +} +func (p *borrowedPool) GetDBConn() (*sql.DB, error) { + if p.startup.Load() != nil { + return nil, gorm.ErrInvalidDB + } + return p.db, nil +} +func (p *borrowedPool) PrepareContext(ctx context.Context, query string) (*sql.Stmt, error) { + return p.db.PrepareContext(p.operationContext(ctx), query) +} +func (p *borrowedPool) ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) { + return p.db.ExecContext(p.operationContext(ctx), query, args...) +} +func (p *borrowedPool) QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) { + return p.db.QueryContext(p.operationContext(ctx), query, args...) +} +func (p *borrowedPool) QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row { + return p.db.QueryRowContext(p.operationContext(ctx), query, args...) +} +func (p *borrowedPool) BeginTx(ctx context.Context, opts *sql.TxOptions) (*sql.Tx, error) { + return p.db.BeginTx(p.operationContext(ctx), opts) +} + +var _ gorm.ConnPool = (*borrowedPool)(nil) +var _ gorm.TxBeginner = (*borrowedPool)(nil) +var _ gorm.GetDBConnector = (*borrowedPool)(nil) diff --git a/database/mysql/README.md b/database/mysql/README.md index 6b45af0..c0fbd1f 100644 --- a/database/mysql/README.md +++ b/database/mysql/README.md @@ -2,6 +2,8 @@ 本包可选,使用 `database/sql` 和 `go-sql-driver/mysql`,复用框架的唯一连接池、Ping、事务与健康检查。不自动建库/迁移、不重试写操作,也不强制某个 MySQL 版本或业务表。 +项目使用 GORM 时,在 svc.Init 中调用 [gormx.MySQL](../gormx/README.md) 即可复用这个池、TLS 和清理流程。 + 生成项目选择 `--database mysql` 即接入: ```sh diff --git a/go.mod b/go.mod index 087d1fd..c92bc7c 100644 --- a/go.mod +++ b/go.mod @@ -19,6 +19,8 @@ require ( golang.org/x/net v0.33.0 gopkg.in/natefinch/lumberjack.v2 v2.2.1 gopkg.in/yaml.v3 v3.0.1 + gorm.io/driver/mysql v1.6.0 + gorm.io/gorm v1.31.2 ) require ( @@ -36,6 +38,8 @@ require ( github.com/google/uuid v1.6.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect github.com/klauspost/compress v1.17.11 // indirect github.com/klauspost/cpuid/v2 v2.2.9 // indirect github.com/leodido/go-urn v1.4.0 // indirect diff --git a/go.sum b/go.sum index 871ab2d..e758aca 100644 --- a/go.sum +++ b/go.sum @@ -67,6 +67,10 @@ github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= @@ -76,6 +80,8 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= github.com/minio/minio-go/v7 v7.0.83 h1:W4Kokksvlz3OKf3OqIlzDNKd4MERlC2oN8YptwJ0+GA= @@ -134,3 +140,9 @@ gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg= +gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo= +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= +gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo= +gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= diff --git a/reports/gormAcceptance.md b/reports/gormAcceptance.md new file mode 100644 index 0000000..92d1336 --- /dev/null +++ b/reports/gormAcceptance.md @@ -0,0 +1,36 @@ +# v0.14.1-preview GORM 可选适配验收 + +日期:2026-09-24。GORM v2 API,固定 `gorm.io/gorm v1.31.2`、`gorm.io/driver/mysql v1.6.0`。没有修改接入项目业务,也未使用业务数据库。 + +## 已通过的检查 + +- 原池身份与限额保持;初始化错误、已取消 context、版本查询超时都保留原因,且不会关闭借用池。 +- 原生命名策略、默认事务保护、错误翻译、PreparedStmt 查询和嵌套事务保持可用。成功后启动 context 取消不会影响新请求。 +- SQL 日志级别、慢查询、ErrRecordNotFound 忽略、占位符过滤、错误详情显式开关、requestId/traceId、并发 LogMode 副本与 UTF-8 截断。 +- 真实 MySQL 8.4:TLS、同池连接、事务提交/错误回滚/panic 回滚、嵌套 savepoint 回滚、唯一约束、查询取消和后续恢复;参数与重复键错误中的值不进入默认日志。 +- 实际生成 MySQL 项目,只扩展项目拥有的 svc 字段与 Init,main/运行期不改;生成检查通过,ORM 随项目池关闭。既有 MySQL TLS/mTLS 与 HTTP 限额回归一并执行。 +- `go list -deps` 确认核心 App、database、database/mysql 的编译依赖不包含 GORM。 + +本地命令: + +```sh +GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race ./... -count=1 -timeout=300s +go vet ./... +go build ./... +python3 scripts/verifyConnector.py --local --gorm --report reports/gormLocal.json +``` + +[本地数据库报告](gormLocal.json) 的 MySQL 限制为 1 CPU、768 MiB、128 PIDs,只绑定 localhost;测试使用临时凭据/证书,结束后清理容器和消费者。实库用例通过脚本显式启用,不能把普通 go test 下的 skip 当作实库通过。 + +发布后的远程验收命令: + +```sh +python3 scripts/verifyConnector.py --version v0.14.1-preview --gorm --report reports/gormRemote.json +python3 scripts/verifyManagedRelease.py --version v0.14.1-preview --report reports/gormManagedRemote.json +``` + +远程脚本从空缓存读取固定版本,并检查 CLI/消费者版本、module 校验值和没有本地 replace;结果发布后单独归档,代码标签保持不变。 + +## 边界 + +本版真实数据库只验收 MySQL。自定义方言工厂必须使用给定连接池;额外连接池由其所有者管理,不自动纳入当前池生命周期。GORM 默认不启用 PrepareStmt;显式启用时保留第三方缓存语义,其清理接口及后台 goroutine 的限制已在 [适配文档](../database/gormx/README.md) 说明。默认 SQL 参数隐藏不等于自动脱敏原始 SQL 字面量。 diff --git a/reports/gormLocal.json b/reports/gormLocal.json new file mode 100644 index 0000000..e8fcb24 --- /dev/null +++ b/reports/gormLocal.json @@ -0,0 +1,82 @@ +{ + "passed": true, + "frameworkVersion": "v0.14.1-preview", + "localSource": true, + "gormAdapter": true, + "frameworkSum": null, + "freshModuleCache": false, + "noLocalReplace": false, + "mysqlImage": "sha256:1afd25fe334a1be64a3866495dd43a6bdba3c55dd1d6190ea12933163fe2843c", + "mysqlLimits": { + "cpus": 1, + "memoryMiB": 768, + "pids": 128 + }, + "durationSeconds": 15.01, + "tests": [ + "TestConnectorIndependentPoolsAndContext", + "TestConnectorRejectsInvalidInputWithoutTakingOwnership", + "TestConnectorStartupFailuresReleaseResources/connect", + "TestConnectorStartupFailuresReleaseResources/ping", + "TestConnectorStartupFailuresReleaseResources", + "TestConnectorTimeoutBoundsConnectAndPing/connect", + "TestConnectorTimeoutBoundsConnectAndPing/ping", + "TestConnectorTimeoutBoundsConnectAndPing", + "TestConnectorHealthBoundsPoolWaitAndRecovers", + "TestConnectorIdleZeroAndLifetime/noIdle", + "TestConnectorIdleZeroAndLifetime/expired", + "TestConnectorIdleZeroAndLifetime", + "TestConnectorTransactionsKeepCommitAndRollbackSemantics", + "TestMySQLConnectorDriverLoggerIsolation", + "TestMySQLConnectorTLSAndPoolIsolation/untrustedCA", + "TestMySQLConnectorTLSAndPoolIsolation/wrongHostname", + "TestMySQLConnectorTLSAndPoolIsolation", + "TestHTTPFileAndEnvironment/server.json", + "TestHTTPFileAndEnvironment/server.yaml", + "TestHTTPFileAndEnvironment", + "TestHTTPInvalidAndUnsupportedSettings/READ_HEADER_TIMEOUTinvalid", + "TestHTTPInvalidAndUnsupportedSettings/READ_HEADER_TIMEOUT-1", + "TestHTTPInvalidAndUnsupportedSettings/READ_TIMEOUTinvalid", + "TestHTTPInvalidAndUnsupportedSettings/READ_TIMEOUT-1", + "TestHTTPInvalidAndUnsupportedSettings/WRITE_TIMEOUTinvalid", + "TestHTTPInvalidAndUnsupportedSettings/WRITE_TIMEOUT-1", + "TestHTTPInvalidAndUnsupportedSettings/IDLE_TIMEOUTinvalid", + "TestHTTPInvalidAndUnsupportedSettings/IDLE_TIMEOUT-1", + "TestHTTPInvalidAndUnsupportedSettings/MAX_HEADER_BYTESinvalid", + "TestHTTPInvalidAndUnsupportedSettings/MAX_HEADER_BYTES-1", + "TestHTTPInvalidAndUnsupportedSettings", + "TestHTTPConfiguredStandardServer", + "TestHTTPInvalidStartupReleasesResources", + "TestHTTPConfiguredNetworkLimits/nethttp/slowHeaders", + "TestHTTPConfiguredNetworkLimits/nethttp/slowBody", + "TestHTTPConfiguredNetworkLimits/nethttp/idle", + "TestHTTPConfiguredNetworkLimits/nethttp/writeBackpressure", + "TestGORMMySQLTransactionsCancellationAndLogs", + "TestStructuredMySQLAdapterTLS", + "TestHTTPConfiguredNetworkLimits/nethttp/headerLimit", + "TestHTTPConfiguredNetworkLimits/nethttp", + "TestHTTPConfiguredNetworkLimits/native/slowHeaders", + "TestHTTPConfiguredNetworkLimits/native/slowBody", + "TestHTTPConfiguredNetworkLimits/native/idle", + "TestHTTPConfiguredNetworkLimits/native/writeBackpressure", + "TestHTTPConfiguredNetworkLimits/native/headerLimit", + "TestHTTPConfiguredNetworkLimits/native", + "TestHTTPConfiguredNetworkLimits", + "TestGeneratedOptionalGORM", + "TestGeneratedMySQLConfigurationAndLifecycle" + ], + "sourceHashes": { + "database/connector_test.go": "0663dfc9794ba24130225fa3b6418a6d5f75d57d0f01686151f70c7ea3f592c0", + "database/mysqlConnector_test.go": "e1cdd11ef76b09f1332ba92bfdde5b1917d84c85cc42fac48f65a8b23f5e9720", + "app/httpConfig_test.go": "b060dc0b76fb8e5feebae7e3b12cb7bca845693fcc097e2a0496a5ba937c837a", + "config/http_test.go": "0cae3dcf213369cfaff5487bf630c2bc3ec097739ca1d40794124e69c3880db8", + "database/mysql/integration_test.go": "05b0a73d50363eef760e8a3324a7e9a68c598f7519c6360ad530503178b56f43", + "database/gormx/integration_test.go": "3a5ac66d5972efc9127c7f1cc473337f899a521b967d4ec1ce00a0dbe06188bb", + "scripts/verifyConnector.py": "58d94e869e6faab1e49bd31ccaebab6d46b28e40058cf7805ad906057229149e", + "scripts/testdata/mysqlGenerated_test.go": "5630123add5ed17f261c1a1bf63ba773f7fa6ea613fd573d5c5cb72da3e42645", + "scripts/testdata/gormGenerated_test.go": "6fc476ec8c089c63b032c8cc707a83c88e685ce8599a59ab63006bad05cbf251", + "database/gormx/gorm.go": "512134d2c9aa7aa14b42071e837900ccf6b2fd8ed5550b686339c7b1605f14a2", + "database/gormx/pool.go": "227fac41c2a2ef64f2e9fc2b8a7202ba6fc22c61fd231e015fe08f5b1d97001d", + "database/gormx/logger.go": "e90a3d411e1b267d4abdf0ab795ef3d60ca4a443f7f9b1d51ccfe4cdae8aae4d" + } +} diff --git a/scripts/testdata/gormGenerated_test.go b/scripts/testdata/gormGenerated_test.go new file mode 100644 index 0000000..5a33e30 --- /dev/null +++ b/scripts/testdata/gormGenerated_test.go @@ -0,0 +1,48 @@ +package main + +import ( + "context" + "io" + "testing" + "time" + + "example.com/mysqlAcceptance/internal/config" + "example.com/mysqlAcceptance/internal/svc" + "git.yuntuo.hk/k2safe/go-omni/logx" +) + +func TestGeneratedOptionalGORM(t *testing.T) { + cfg, err := config.Load("etc/config.yaml") + if err != nil { + t.Fatal(err) + } + logger, err := logx.New(logx.Config{Writer: io.Discard}) + if err != nil { + t.Fatal(err) + } + defer logger.Close() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + c, err := svc.New(ctx, logger, cfg) + if err != nil { + t.Fatal(err) + } + defer c.Close() + if c.ORM == nil { + t.Fatal("svc.Init did not provide GORM") + } + pool, err := c.ORM.DB() + if err != nil || pool != c.DB.DB { + t.Fatal("GORM created another pool", err) + } + var value int + if err = c.ORM.WithContext(ctx).Raw("SELECT ?", 42).Scan(&value).Error; err != nil || value != 42 { + t.Fatal(value, err) + } + if err = c.Finish(nil); err != nil { + t.Fatal(err) + } + if err = c.ORM.WithContext(ctx).Raw("SELECT 1").Scan(&value).Error; err == nil { + t.Fatal("ORM did not follow owner shutdown") + } +} diff --git a/scripts/verifyConnector.py b/scripts/verifyConnector.py index fc7e1c6..bbebba3 100644 --- a/scripts/verifyConnector.py +++ b/scripts/verifyConnector.py @@ -43,6 +43,7 @@ def main(): source.add_argument("--version") parser.add_argument("--report", type=Path, required=True) parser.add_argument("--image", default="mysql:8.4") + parser.add_argument("--gorm", action="store_true", help="also verify the optional GORM adapter and generated svc extension") args = parser.parse_args() if args.version and not re.fullmatch(r"v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?", args.version): parser.error("a fixed release version is required") @@ -70,13 +71,16 @@ def main(): path.chmod(0o644) # Disposable fixtures, readable by the container's mysql uid. project = temp / "consumer" project.mkdir() - version = args.version or "v0.14.0-preview" + version = args.version or "v0.14.1-preview" (project / "go.mod").write_text(f"module example.com/connectorAcceptance\n\ngo 1.22.0\n\nrequire {MODULE} {version}\n") if args.local: run(["go", "mod", "edit", f"-replace={MODULE}={ROOT}"], project, env) else: env["GOMODCACHE"] = str(temp / "modcache") - for name in SOURCES: + sources = list(SOURCES) + if args.gorm: + sources.append("database/gormx/integration_test.go") + for name in sources: target = project / name target.parent.mkdir(exist_ok=True, parents=True) shutil.copyfile(ROOT / name, target) @@ -98,6 +102,18 @@ def main(): command += ["--framework-dir", ROOT] run([*command, generated], temp, env) shutil.copyfile(ROOT / "scripts/testdata/mysqlGenerated_test.go", generated / "mysql_test.go") + if args.gorm: + # Only the documented project-owned extension is customized. + service = generated / "internal/svc/serviceContext.go" + code = service.read_text() + assert '"context"' in code and 'type ServiceContext struct {' in code + code = code.replace('"context"', '"context"\n "gorm.io/gorm"\n "' + MODULE + '/database/gormx"', 1) + code = code.replace('type ServiceContext struct {', 'type ServiceContext struct {\n ORM *gorm.DB', 1) + assert 'return nil' in code + code = code.replace('return nil', 'var err error\n c.ORM, err = gormx.MySQL(ctx, c.DB, gormx.WithLogger(c.Logger))\n return err', 1) + service.write_text(code) + run(["gofmt", "-w", service], generated, env) + shutil.copyfile(ROOT / "scripts/testdata/gormGenerated_test.go", generated / "gorm_test.go") run(["go", "mod", "tidy"], generated, env) generated_module = json.loads(run(["go", "list", "-m", "-json", MODULE], generated, env)) if not args.local: @@ -137,6 +153,8 @@ def main(): passed = [e["Test"] for e in events if e.get("Action") == "pass" and "Test" in e] assert "TestMySQLConnectorTLSAndPoolIsolation" in passed assert "TestStructuredMySQLAdapterTLS" in passed + if args.gorm: + assert "TestGORMMySQLTransactionsCancellationAndLogs" in passed assert not any(e.get("Action") in ("fail", "skip") for e in events), output env.update(GO_OMNI_DB_HOST="127.0.0.1", GO_OMNI_DB_PORT=addr.rsplit(":", 1)[1], GO_OMNI_DB_USER="omni_test", GO_OMNI_DB_PASSWORD=env["MYSQL_PASSWORD"], @@ -144,23 +162,30 @@ def main(): GO_OMNI_DB_TLS_SERVER_NAME="localhost", GO_OMNI_DB_POOL_MAX_OPEN_CONNS="2", GO_OMNI_DB_POOL_MAX_IDLE_CONNS="2") print("Testing generated MySQL service config, independent pools and cleanup", flush=True) - run([cli, "api", "generate", "--check"], generated, env) + check_output = run([cli, "api", "generate", "--check"], generated, env) + if not args.local: + assert "frameworkVersion" not in check_output, check_output output = run(["go", "test", "-race", "-count=1", "-json", "-timeout=60s", "./..."], generated, env) generated_events = [json.loads(line) for line in output.splitlines() if line.startswith("{")] generated_passed = [e["Test"] for e in generated_events if e.get("Action") == "pass" and "Test" in e] assert "TestGeneratedMySQLConfigurationAndLifecycle" in generated_passed, output + if args.gorm: + assert "TestGeneratedOptionalGORM" in generated_passed, output assert not any(e.get("Action") == "fail" or (e.get("Action") == "skip" and "Test" in e) for e in generated_events), output passed += generated_passed image = run(["docker", "inspect", "--format", "{{.Image}}", name], temp, env).strip() finally: if created: run(["docker", "rm", "--force", "--volumes", name], temp, env) - report = {"passed": True, "frameworkVersion": version, "localSource": args.local, + report_sources = sources + ["scripts/verifyConnector.py", "scripts/testdata/mysqlGenerated_test.go"] + if args.gorm: + report_sources += ["scripts/testdata/gormGenerated_test.go", "database/gormx/gorm.go", "database/gormx/pool.go", "database/gormx/logger.go"] + report = {"passed": True, "frameworkVersion": version, "localSource": args.local, "gormAdapter": args.gorm, "frameworkSum": module.get("Sum"), "freshModuleCache": not args.local, "noLocalReplace": not bool(module.get("Replace")), "mysqlImage": image, "mysqlLimits": {"cpus": 1, "memoryMiB": 768, "pids": 128}, "durationSeconds": round(time.monotonic() - began, 2), "tests": passed, - "sourceHashes": {name: hashlib.sha256((ROOT / name).read_bytes()).hexdigest() for name in (*SOURCES, "scripts/verifyConnector.py", "scripts/testdata/mysqlGenerated_test.go")}} + "sourceHashes": {name: hashlib.sha256((ROOT / name).read_bytes()).hexdigest() for name in report_sources}} args.report.parent.mkdir(parents=True, exist_ok=True) args.report.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n") print("PASS; isolated container, credentials, certificates and consumer removed", flush=True)