Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ff95692edb | |||
| 0bd71b17b4 |
14
CHANGELOG.md
Normal file
14
CHANGELOG.md
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## v0.1.1
|
||||||
|
|
||||||
|
- 修复脚手架生成项目固定依赖不存在的 `v0.0.0`;远程安装的 CLI 默认使用自身版本,支持 `--framework-version` 指定完整版本号。
|
||||||
|
- 显式远程版本禁用自动本地替换;本地替换路径支持空格,并按精确 module path 识别框架目录。
|
||||||
|
- 生成项目通过 `signal.NotifyContext` 接入优雅退出,启动过程中的错误会先执行 defer 清理再报告。
|
||||||
|
- `App.Run` 自动处理 `SIGINT` / `SIGTERM`;`RunContext` 仍由调用方控制,退出超时会关闭活跃连接。
|
||||||
|
- 增加请求排空、超时取消、启动失败清理测试,以及远程脚手架和真实进程信号验收脚本。
|
||||||
|
|
||||||
|
## v0.1.0
|
||||||
|
|
||||||
|
- 首个可试用版本:应用生命周期、HTTP 抽象、Gin 与标准库路由适配器、统一响应与错误。
|
||||||
|
- 提供配置、日志、校验、认证接口、数据库基础能力、健康探针和项目脚手架。
|
||||||
24
README.md
24
README.md
@@ -5,9 +5,22 @@ GoOmni 是一套面向业务复用的 Go 后端基础框架。当前默认用 Gi
|
|||||||
远程 module 地址是 `git.yuntuo.hk/k2safe/go-omni`。业务项目可以直接引用指定版本:
|
远程 module 地址是 `git.yuntuo.hk/k2safe/go-omni`。业务项目可以直接引用指定版本:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
go get git.yuntuo.hk/k2safe/go-omni@v0.1.0
|
go get git.yuntuo.hk/k2safe/go-omni@v0.1.1
|
||||||
```
|
```
|
||||||
|
|
||||||
|
创建新业务项目:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.1.1
|
||||||
|
go-omni new --module example.com/billing ./billing
|
||||||
|
cd billing
|
||||||
|
go mod tidy
|
||||||
|
go run ./cmd/server
|
||||||
|
```
|
||||||
|
|
||||||
|
远程安装的 CLI 默认引用自身版本,生成的项目不需要本地 `replace`。
|
||||||
|
具体版本和本地开发选项见 [CLI 文档](cmd/go-omni/README.md)。
|
||||||
|
|
||||||
## 目录结构
|
## 目录结构
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -25,7 +38,7 @@ go-omni/
|
|||||||
├── database/ # database/sql 生命周期和事务
|
├── database/ # database/sql 生命周期和事务
|
||||||
├── transport/gin/ # Gin 网络适配器,可替换
|
├── transport/gin/ # Gin 网络适配器,可替换
|
||||||
├── examples/ # 示例项目
|
├── examples/ # 示例项目
|
||||||
└── cmd/ # 后续 CLI
|
└── cmd/ # go-omni 项目脚手架
|
||||||
```
|
```
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
@@ -104,7 +117,8 @@ application.OnShutdown(logger.Close)
|
|||||||
`GO_OMNI_ENVIRONMENT`、`GO_OMNI_HTTP_ADDR` 和 `GO_OMNI_SHUTDOWN_TIMEOUT`。数据库连接仍由
|
`GO_OMNI_ENVIRONMENT`、`GO_OMNI_HTTP_ADDR` 和 `GO_OMNI_SHUTDOWN_TIMEOUT`。数据库连接仍由
|
||||||
`database.ConfigFromEnv` 从 `GO_OMNI_DB_*` 加载。
|
`database.ConfigFromEnv` 从 `GO_OMNI_DB_*` 加载。
|
||||||
|
|
||||||
`App` 同时实现标准 `http.Handler`,并提供 `RunContext`:收到 `SIGINT` / `SIGTERM` 后可以先停止接收新请求,再在超时时间内等待活跃请求完成。数据库连接池由 `database.Open` 创建,在服务退出时调用 `Close`。
|
`App` 同时实现标准 `http.Handler`。`Run` 监听 `SIGINT` / `SIGTERM`;`RunContext` 则由调用方的 context 控制退出,生成项目已使用 `signal.NotifyContext` 接好信号。
|
||||||
|
退出时先停止接收新请求,等待活跃请求完成,再执行资源清理。超过 `ShutdownTimeout` 时强制关闭连接、取消请求 context,并返回超时错误。业务代码应响应 context 取消;框架无法强制结束忽略取消的业务 goroutine。数据库连接池由 `database.Open` 创建,在服务退出时调用 `Close`。
|
||||||
|
|
||||||
应用资源可以直接挂到 `omni.New` 返回的生命周期上:
|
应用资源可以直接挂到 `omni.New` 返回的生命周期上:
|
||||||
|
|
||||||
@@ -154,4 +168,6 @@ go run ./examples/reference
|
|||||||
|
|
||||||
## 当前边界
|
## 当前边界
|
||||||
|
|
||||||
缓存、消息队列、定时任务和 CLI 暂时作为后续可选模块。认证层只定义 `Authenticator`、`Principal` 和路由保护中间件,具体 JWT、Session 或 OAuth 由项目实现。核心数据库层只依赖 `database/sql`,具体驱动、迁移和表结构由项目选择。
|
缓存、消息队列和定时任务暂时作为后续可选模块。认证层只定义 `Authenticator`、`Principal` 和路由保护中间件,具体 JWT、Session 或 OAuth 由项目实现。核心数据库层只依赖 `database/sql`,具体驱动、迁移和表结构由项目选择。
|
||||||
|
|
||||||
|
当前成熟度与后续验收标准见 [ROADMAP.md](ROADMAP.md),版本变更见 [CHANGELOG.md](CHANGELOG.md)。
|
||||||
|
|||||||
28
ROADMAP.md
Normal file
28
ROADMAP.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# GoOmni 成熟度计划
|
||||||
|
|
||||||
|
## 1. 新项目启动与发布流程(已完成,v0.1.1)
|
||||||
|
|
||||||
|
- 修正脚手架固定版本引用,保留显式本地开发选项。
|
||||||
|
- 生成服务处理退出信号,等待请求完成后清理资源。
|
||||||
|
- 验收:源码目录外安装远程 CLI → 生成无 replace 的项目 → 编译 → 请求健康接口和业务接口 → SIGINT/SIGTERM 正常退出。
|
||||||
|
- 自动验收入口:`python3 scripts/verifyScaffold.py --version v0.1.1`。
|
||||||
|
- 已通过全量 race 测试、vet、build,以及从远程提交安装 CLI 后的无 replace 端到端验收;覆盖生成服务和 `App.Run` 的两种退出信号。
|
||||||
|
|
||||||
|
## 2. 业务数据接入(待完成)
|
||||||
|
|
||||||
|
- 数据库初始化、Repository 和 ServiceContext 组合成完整业务启动流程。
|
||||||
|
- Readiness 注册数据库探针,启动失败与服务退出时清理连接池。
|
||||||
|
- 验收:真实数据库 CRUD、事务回滚、数据库不可用时 readiness 返回 503。
|
||||||
|
|
||||||
|
## 3. 网络适配一致性(待完成)
|
||||||
|
|
||||||
|
- Gin 与标准库适配器运行同一套契约测试。
|
||||||
|
- 覆盖路由参数、分组、中间件、错误响应和取消传播。
|
||||||
|
- 验收:切换适配器后,业务 Handler 和 Service 无需修改,约定的 HTTP 行为一致。
|
||||||
|
|
||||||
|
## 4. 实际业务试用(待完成)
|
||||||
|
|
||||||
|
- 在一个实际业务模块中使用远程固定版本,验证配置、日志、错误处理和升级流程。
|
||||||
|
- 根据真实接入问题收敛公共 API,再评估稳定版范围。
|
||||||
|
|
||||||
|
以上阶段以验收结果判断完成。当前仍是基础框架试用阶段,数据库生产验收、自研网络内核与实际业务接入尚未完成。
|
||||||
29
app/app.go
29
app/app.go
@@ -3,8 +3,12 @@ package app
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
"sync"
|
"sync"
|
||||||
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.yuntuo.hk/k2safe/go-omni/config"
|
"git.yuntuo.hk/k2safe/go-omni/config"
|
||||||
@@ -104,9 +108,12 @@ func (a *App) Close() error {
|
|||||||
return a.closeErr
|
return a.closeErr
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run starts the HTTP server. The default address is :8080.
|
// Run starts the HTTP server and handles SIGINT/SIGTERM. Use RunContext when
|
||||||
|
// the caller owns cancellation. The default address is :8080.
|
||||||
func (a *App) Run(address ...string) error {
|
func (a *App) Run(address ...string) error {
|
||||||
return a.RunContext(context.Background(), address...)
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
|
defer stop()
|
||||||
|
return a.RunContext(ctx, address...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewServer creates a standard HTTP server around the application.
|
// NewServer creates a standard HTTP server around the application.
|
||||||
@@ -125,6 +132,9 @@ func (a *App) RunContext(ctx context.Context, address ...string) error {
|
|||||||
if ctx == nil {
|
if ctx == nil {
|
||||||
ctx = context.Background()
|
ctx = context.Background()
|
||||||
}
|
}
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return a.Close()
|
||||||
|
}
|
||||||
listenOn := a.config.Address
|
listenOn := a.config.Address
|
||||||
if listenOn == "" {
|
if listenOn == "" {
|
||||||
listenOn = ":8080"
|
listenOn = ":8080"
|
||||||
@@ -133,8 +143,16 @@ func (a *App) RunContext(ctx context.Context, address ...string) error {
|
|||||||
listenOn = address[0]
|
listenOn = address[0]
|
||||||
}
|
}
|
||||||
server := a.NewServer(listenOn)
|
server := a.NewServer(listenOn)
|
||||||
|
listener, err := net.Listen("tcp", server.Addr)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Join(err, a.Close())
|
||||||
|
}
|
||||||
|
return a.serveContext(ctx, server, listener)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) serveContext(ctx context.Context, server *http.Server, listener net.Listener) error {
|
||||||
serveErr := make(chan error, 1)
|
serveErr := make(chan error, 1)
|
||||||
go func() { serveErr <- server.ListenAndServe() }()
|
go func() { serveErr <- server.Serve(listener) }()
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case err := <-serveErr:
|
case err := <-serveErr:
|
||||||
@@ -151,6 +169,11 @@ func (a *App) RunContext(ctx context.Context, address ...string) error {
|
|||||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
shutdownErr := server.Shutdown(shutdownCtx)
|
shutdownErr := server.Shutdown(shutdownCtx)
|
||||||
|
if shutdownErr != nil {
|
||||||
|
// Shutdown leaves active connections open on timeout. Close them so
|
||||||
|
// request contexts are canceled before releasing shared resources.
|
||||||
|
shutdownErr = errors.Join(shutdownErr, server.Close())
|
||||||
|
}
|
||||||
if err := <-serveErr; err != nil && err != http.ErrServerClosed {
|
if err := <-serveErr; err != nil && err != http.ErrServerClosed {
|
||||||
shutdownErr = errors.Join(shutdownErr, err)
|
shutdownErr = errors.Join(shutdownErr, err)
|
||||||
}
|
}
|
||||||
|
|||||||
163
app/lifecycle_test.go
Normal file
163
app/lifecycle_test.go
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/config"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/transport/nethttp"
|
||||||
|
)
|
||||||
|
|
||||||
|
type closingListener struct {
|
||||||
|
net.Listener
|
||||||
|
closed chan struct{}
|
||||||
|
once sync.Once
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *closingListener) Close() error {
|
||||||
|
err := l.Listener.Close()
|
||||||
|
l.once.Do(func() { close(l.closed) })
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func startTestServer(t *testing.T, application *App) (*closingListener, context.CancelFunc, <-chan error) {
|
||||||
|
t.Helper()
|
||||||
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
wrapped := &closingListener{Listener: listener, closed: make(chan struct{})}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
t.Cleanup(cancel)
|
||||||
|
t.Cleanup(func() { _ = listener.Close() })
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() { done <- application.serveContext(ctx, application.NewServer(""), wrapped) }()
|
||||||
|
return wrapped, cancel, done
|
||||||
|
}
|
||||||
|
|
||||||
|
func await(t *testing.T, event <-chan struct{}) {
|
||||||
|
t.Helper()
|
||||||
|
select {
|
||||||
|
case <-event:
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("timed out waiting for server event")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShutdownDrainsRequestBeforeCleanup(t *testing.T) {
|
||||||
|
application := NewWithRouter(nethttp.New(), config.WithShutdownTimeout(3*time.Second))
|
||||||
|
started, release, cleaned := make(chan struct{}), make(chan struct{}), make(chan struct{})
|
||||||
|
var releaseOnce sync.Once
|
||||||
|
unblock := func() { releaseOnce.Do(func() { close(release) }) }
|
||||||
|
t.Cleanup(unblock)
|
||||||
|
application.GET("/slow", func(ctx *httpx.Context) error {
|
||||||
|
close(started)
|
||||||
|
<-release
|
||||||
|
return ctx.OK("finished")
|
||||||
|
})
|
||||||
|
application.OnShutdown(func() error { close(cleaned); return nil })
|
||||||
|
listener, cancel, done := startTestServer(t, application)
|
||||||
|
requestDone := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
client := &http.Client{Timeout: 5 * time.Second}
|
||||||
|
response, err := client.Get("http://" + listener.Addr().String() + "/slow")
|
||||||
|
if err == nil {
|
||||||
|
defer response.Body.Close()
|
||||||
|
_, err = io.ReadAll(response.Body)
|
||||||
|
if response.StatusCode != http.StatusOK {
|
||||||
|
err = errors.New("request was not completed successfully")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
requestDone <- err
|
||||||
|
}()
|
||||||
|
await(t, started)
|
||||||
|
cancel()
|
||||||
|
await(t, listener.closed)
|
||||||
|
select {
|
||||||
|
case <-cleaned:
|
||||||
|
t.Fatal("resources closed while a request was still running")
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
unblock()
|
||||||
|
if err := <-requestDone; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("server did not finish shutdown")
|
||||||
|
}
|
||||||
|
await(t, cleaned)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShutdownTimeoutClosesActiveConnection(t *testing.T) {
|
||||||
|
application := NewWithRouter(nethttp.New(), config.WithShutdownTimeout(25*time.Millisecond))
|
||||||
|
started, canceled := make(chan struct{}), make(chan struct{})
|
||||||
|
application.GET("/slow", func(ctx *httpx.Context) error {
|
||||||
|
close(started)
|
||||||
|
<-ctx.Context().Done()
|
||||||
|
close(canceled)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
listener, cancel, done := startTestServer(t, application)
|
||||||
|
requestDone := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(requestDone)
|
||||||
|
client := &http.Client{Timeout: 5 * time.Second}
|
||||||
|
response, err := client.Get("http://" + listener.Addr().String() + "/slow")
|
||||||
|
if err == nil {
|
||||||
|
response.Body.Close()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
await(t, started)
|
||||||
|
cancel()
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
if !errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
t.Fatalf("expected shutdown deadline error, got %v", err)
|
||||||
|
}
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("shutdown ignored timeout")
|
||||||
|
}
|
||||||
|
await(t, canceled)
|
||||||
|
await(t, requestDone)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunContextStartupFailureAndCanceledContextCloseResources(t *testing.T) {
|
||||||
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer listener.Close()
|
||||||
|
for _, canceled := range []bool{false, true} {
|
||||||
|
application := NewWithRouter(nethttp.New())
|
||||||
|
closeCount := 0
|
||||||
|
application.OnShutdown(func() error { closeCount++; return nil })
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
if canceled {
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
err := application.RunContext(ctx, listener.Addr().String())
|
||||||
|
cancel()
|
||||||
|
if !canceled && err == nil {
|
||||||
|
t.Fatal("expected occupied address error")
|
||||||
|
}
|
||||||
|
if canceled && err != nil {
|
||||||
|
t.Fatalf("canceled start: %v", err)
|
||||||
|
}
|
||||||
|
_ = application.Close()
|
||||||
|
if closeCount != 1 {
|
||||||
|
t.Fatalf("cleanup ran %d times", closeCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,53 @@
|
|||||||
# go-omni CLI
|
# go-omni CLI
|
||||||
|
|
||||||
生成一个带 Handler / Service / Repository / Model 分层的服务骨架:
|
从远程安装,生成一个带 Handler / Service / Repository / Model 分层的服务骨架:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
go run ./cmd/go-omni new --module example.com/billing ./billing
|
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.1.1
|
||||||
|
go-omni new --module example.com/billing ./billing
|
||||||
|
cd billing
|
||||||
|
go mod tidy
|
||||||
|
go run ./cmd/server
|
||||||
```
|
```
|
||||||
|
|
||||||
|
通过 `go install ...@版本` 安装的 CLI 从构建信息中读取版本,并把它固定到生成项目的
|
||||||
|
`go.mod`。不会使用不存在的 `v0.0.0`,也不会在普通业务目录中添加本地 `replace`。
|
||||||
|
|
||||||
|
需要选择其他已发布版本时,使用完整版本号:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go-omni new --framework-version v0.1.1 --module example.com/billing ./billing
|
||||||
|
```
|
||||||
|
|
||||||
|
`--framework-version` 禁用自动本地替换;`main`、`latest` 等浮动引用不接受为固定版本。
|
||||||
|
源码构建的 CLI 使用代码中声明的目标发布版本;该版本未发布时请显式选择已发布版本,或用 `--framework-dir` 验证本地源码。
|
||||||
|
|
||||||
在 GoOmni 源码目录执行时,命令会自动在生成项目的 `go.mod` 中加入本地
|
在 GoOmni 源码目录执行时,命令会自动在生成项目的 `go.mod` 中加入本地
|
||||||
`replace git.yuntuo.hk/k2safe/go-omni => ...`,便于立即运行和测试。目标目录非空时命令会停止,明确传入
|
`replace git.yuntuo.hk/k2safe/go-omni => ...`,便于立即运行和测试。目标目录非空时命令会停止,明确传入
|
||||||
`--force` 才会覆盖脚手架文件。
|
`--force` 才会覆盖脚手架文件。
|
||||||
|
|
||||||
|
也可以在任意目录显式指定源码路径:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go-omni new --framework-dir /path/to/go-omni --module example.com/billing ./billing
|
||||||
|
```
|
||||||
|
|
||||||
|
生成服务默认包含 `/health/live`、`/health/ready` 和示例 `POST /users`。
|
||||||
|
退出信号通过 `signal.NotifyContext` 传入 `RunContext`,Logger 的关闭函数注册到应用生命周期。
|
||||||
|
|
||||||
|
发布验收使用 Python 3 标准库脚本,不需要额外 Python 依赖:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 scripts/verifyScaffold.py --version v0.1.1
|
||||||
|
```
|
||||||
|
|
||||||
|
脚本在源码目录外安装远程 CLI、生成项目,确认固定版本且没有 `replace`,再下载依赖、
|
||||||
|
编译、请求接口,并对真实二进制分别发送 `SIGINT` 和 `SIGTERM`。同时验证框架 `Run` 的
|
||||||
|
信号处理和清理回调。每个进程都必须退出码为 0、释放监听端口;临时项目和测试进程会被清理。
|
||||||
|
|
||||||
|
发布前验证当前源码:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go build -o /tmp/go-omni ./cmd/go-omni
|
||||||
|
python3 scripts/verifyScaffold.py --cli /tmp/go-omni --framework-dir .
|
||||||
|
```
|
||||||
|
|||||||
@@ -55,6 +55,17 @@ func runNew(args []string) error {
|
|||||||
options.FrameworkDir = args[index]
|
options.FrameworkDir = args[index]
|
||||||
case strings.HasPrefix(arg, "--framework-dir="):
|
case strings.HasPrefix(arg, "--framework-dir="):
|
||||||
options.FrameworkDir = strings.TrimPrefix(arg, "--framework-dir=")
|
options.FrameworkDir = strings.TrimPrefix(arg, "--framework-dir=")
|
||||||
|
case arg == "--framework-version":
|
||||||
|
if index+1 >= len(args) || strings.TrimSpace(args[index+1]) == "" {
|
||||||
|
return errors.New("--framework-version requires a value")
|
||||||
|
}
|
||||||
|
index++
|
||||||
|
options.FrameworkVersion = args[index]
|
||||||
|
case strings.HasPrefix(arg, "--framework-version="):
|
||||||
|
options.FrameworkVersion = strings.TrimPrefix(arg, "--framework-version=")
|
||||||
|
if strings.TrimSpace(options.FrameworkVersion) == "" {
|
||||||
|
return errors.New("--framework-version requires a value")
|
||||||
|
}
|
||||||
case strings.HasPrefix(arg, "-"):
|
case strings.HasPrefix(arg, "-"):
|
||||||
return fmt.Errorf("unknown option %q", arg)
|
return fmt.Errorf("unknown option %q", arg)
|
||||||
case target == "":
|
case target == "":
|
||||||
@@ -82,4 +93,6 @@ func usage() {
|
|||||||
fmt.Fprintln(os.Stderr, "")
|
fmt.Fprintln(os.Stderr, "")
|
||||||
fmt.Fprintln(os.Stderr, "Usage:")
|
fmt.Fprintln(os.Stderr, "Usage:")
|
||||||
fmt.Fprintln(os.Stderr, " go-omni new [--module <module>] [--force] <directory>")
|
fmt.Fprintln(os.Stderr, " go-omni new [--module <module>] [--force] <directory>")
|
||||||
|
fmt.Fprintln(os.Stderr, " --framework-version <version> Pin a remote framework version (defaults to CLI version)")
|
||||||
|
fmt.Fprintln(os.Stderr, " --framework-dir <directory> Use a local framework checkout")
|
||||||
}
|
}
|
||||||
|
|||||||
33
cmd/go-omni/main_test.go
Normal file
33
cmd/go-omni/main_test.go
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewWithRemoteFrameworkVersion(t *testing.T) {
|
||||||
|
for _, flag := range [][]string{{"--framework-version", "v0.1.0"}, {"--framework-version=v0.1.0"}} {
|
||||||
|
project := filepath.Join(t.TempDir(), "billing")
|
||||||
|
args := append([]string{"--module", "example.com/billing"}, flag...)
|
||||||
|
if err := runNew(append(args, project)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
content, err := os.ReadFile(filepath.Join(project, "go.mod"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(content), "go-omni v0.1.0") || strings.Contains(string(content), "replace ") {
|
||||||
|
t.Fatalf("CLI did not pin remote dependency: %s", content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewRejectsEmptyFrameworkVersion(t *testing.T) {
|
||||||
|
for _, args := range [][]string{{"--framework-version"}, {"--framework-version="}, {"--framework-version", " "}} {
|
||||||
|
if err := runNew(args); err == nil {
|
||||||
|
t.Fatalf("accepted missing framework version: %v", args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"text/template"
|
"text/template"
|
||||||
)
|
)
|
||||||
@@ -17,10 +18,11 @@ var templateFiles embed.FS
|
|||||||
|
|
||||||
// Options controls project generation.
|
// Options controls project generation.
|
||||||
type Options struct {
|
type Options struct {
|
||||||
Dir string
|
Dir string
|
||||||
Module string
|
Module string
|
||||||
FrameworkDir string
|
FrameworkDir string
|
||||||
Force bool
|
FrameworkVersion string
|
||||||
|
Force bool
|
||||||
}
|
}
|
||||||
|
|
||||||
var files = map[string]string{
|
var files = map[string]string{
|
||||||
@@ -42,6 +44,13 @@ func Generate(options Options) error {
|
|||||||
if strings.TrimSpace(options.Module) == "" {
|
if strings.TrimSpace(options.Module) == "" {
|
||||||
return errors.New("module path is required")
|
return errors.New("module path is required")
|
||||||
}
|
}
|
||||||
|
version := strings.TrimSpace(options.FrameworkVersion)
|
||||||
|
if version == "" {
|
||||||
|
version = currentFrameworkVersion()
|
||||||
|
}
|
||||||
|
if !validFrameworkVersion(version) {
|
||||||
|
return fmt.Errorf("framework version %q must be a complete v0 or v1 semantic version", version)
|
||||||
|
}
|
||||||
|
|
||||||
directory, err := filepath.Abs(options.Dir)
|
directory, err := filepath.Abs(options.Dir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -54,9 +63,15 @@ func Generate(options Options) error {
|
|||||||
return fmt.Errorf("create project directory: %w", err)
|
return fmt.Errorf("create project directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An explicit remote version opts out of automatic local development mode.
|
||||||
|
replace := ""
|
||||||
|
if options.FrameworkDir != "" || options.FrameworkVersion == "" {
|
||||||
|
replace = frameworkReplace(directory, options.FrameworkDir)
|
||||||
|
}
|
||||||
data := templateData{
|
data := templateData{
|
||||||
Module: options.Module,
|
Module: options.Module,
|
||||||
FrameworkReplace: frameworkReplace(directory, options.FrameworkDir),
|
FrameworkVersion: version,
|
||||||
|
FrameworkReplace: replace,
|
||||||
}
|
}
|
||||||
paths := make([]string, 0, len(files))
|
paths := make([]string, 0, len(files))
|
||||||
for path := range files {
|
for path := range files {
|
||||||
@@ -88,6 +103,7 @@ func Generate(options Options) error {
|
|||||||
|
|
||||||
type templateData struct {
|
type templateData struct {
|
||||||
Module string
|
Module string
|
||||||
|
FrameworkVersion string
|
||||||
FrameworkReplace string
|
FrameworkReplace string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,7 +165,7 @@ func frameworkReplace(projectDir, configured string) string {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return filepath.ToSlash(relative)
|
return strconv.Quote(filepath.ToSlash(relative))
|
||||||
}
|
}
|
||||||
|
|
||||||
func canonicalPath(path string) string {
|
func canonicalPath(path string) string {
|
||||||
@@ -172,8 +188,14 @@ func findFrameworkRoot() string {
|
|||||||
for {
|
for {
|
||||||
moduleFile := filepath.Join(directory, "go.mod")
|
moduleFile := filepath.Join(directory, "go.mod")
|
||||||
content, readErr := os.ReadFile(moduleFile)
|
content, readErr := os.ReadFile(moduleFile)
|
||||||
if readErr == nil && strings.Contains(string(content), "module git.yuntuo.hk/k2safe/go-omni") {
|
if readErr == nil {
|
||||||
return directory
|
for _, line := range strings.Split(string(content), "\n") {
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
if len(fields) >= 2 && fields[0] == "module" && strings.Trim(fields[1], "\"") == frameworkModule {
|
||||||
|
return directory
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
}
|
}
|
||||||
parent := filepath.Dir(directory)
|
parent := filepath.Dir(directory)
|
||||||
if parent == directory {
|
if parent == directory {
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package scaffold
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime/debug"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
@@ -37,7 +39,7 @@ func TestGenerateCreatesServiceLayout(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("read generated server: %v", err)
|
t.Fatalf("read generated server: %v", err)
|
||||||
}
|
}
|
||||||
for _, expected := range []string{"config.LoadFromEnv(\"\")", "logx.SetDefault(logger)", "omni.WithConfig(settings)", "servicecontext.New(logger, users)", "health.New()", "application.Run(settings.Address)"} {
|
for _, expected := range []string{"config.LoadFromEnv(\"\")", "logx.SetDefault(logger)", "omni.WithConfig(settings)", "servicecontext.New(logger, users)", "health.New()", "application.RunContext(ctx, settings.Address)"} {
|
||||||
if !strings.Contains(string(mainContent), expected) {
|
if !strings.Contains(string(mainContent), expected) {
|
||||||
t.Fatalf("generated server missing %q: %s", expected, mainContent)
|
t.Fatalf("generated server missing %q: %s", expected, mainContent)
|
||||||
}
|
}
|
||||||
@@ -73,11 +75,59 @@ func TestFrameworkReplaceUsesCanonicalPaths(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if replace != filepath.ToSlash(expected) {
|
if replace != strconv.Quote(filepath.ToSlash(expected)) {
|
||||||
t.Fatalf("expected canonical replace %q, got %q", filepath.ToSlash(expected), replace)
|
t.Fatalf("expected canonical replace %q, got %q", filepath.ToSlash(expected), replace)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGenerateRemoteProjectPinsVersion(t *testing.T) {
|
||||||
|
for _, version := range []string{"v0.1.0", "v1.2.3-rc.1", "v0.0.0-20260920140624-8b19aa79eea0"} {
|
||||||
|
t.Run(version, func(t *testing.T) {
|
||||||
|
project := filepath.Join(t.TempDir(), "billing")
|
||||||
|
if err := Generate(Options{Dir: project, Module: "example.com/billing", FrameworkVersion: version}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
content, err := os.ReadFile(filepath.Join(project, "go.mod"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(content), "require "+frameworkModule+" "+version+"\n") || strings.Contains(string(content), "replace ") {
|
||||||
|
t.Fatalf("expected pinned remote dependency without replace: %s", content)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateRejectsInvalidVersionBeforeWriting(t *testing.T) {
|
||||||
|
for _, version := range []string{"main", "latest", "v1.2", "v01.2.3", "v2.0.0", "v0.1.0-01", "v0.1.0\nreplace bad => ./bad"} {
|
||||||
|
project := filepath.Join(t.TempDir(), "billing")
|
||||||
|
if err := Generate(Options{Dir: project, Module: "example.com/billing", FrameworkVersion: version}); err == nil {
|
||||||
|
t.Fatalf("accepted invalid version %q", version)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(project); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("invalid version %q created a project: %v", version, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFrameworkVersionUsesInstalledModuleMetadata(t *testing.T) {
|
||||||
|
for _, test := range []struct {
|
||||||
|
info *debug.BuildInfo
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{nil, defaultFrameworkVersion},
|
||||||
|
{&debug.BuildInfo{Main: debug.Module{Path: frameworkModule, Version: "(devel)"}}, defaultFrameworkVersion},
|
||||||
|
{&debug.BuildInfo{Main: debug.Module{Path: frameworkModule, Version: "v0.1.0+dirty"}}, defaultFrameworkVersion},
|
||||||
|
{&debug.BuildInfo{Main: debug.Module{Path: "example.com/unrelated", Version: "v1.2.3"}}, defaultFrameworkVersion},
|
||||||
|
{&debug.BuildInfo{Main: debug.Module{Path: frameworkModule, Version: "v0.9.2", Sum: "h1:test"}}, "v0.9.2"},
|
||||||
|
{&debug.BuildInfo{Main: debug.Module{Path: frameworkModule, Version: "v0.0.0-20260920140624-8b19aa79eea0", Sum: "h1:test"}}, "v0.0.0-20260920140624-8b19aa79eea0"},
|
||||||
|
} {
|
||||||
|
if got := frameworkVersion(test.info); got != test.want {
|
||||||
|
t.Fatalf("version = %q, want %q", got, test.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func frameworkRoot(t *testing.T) string {
|
func frameworkRoot(t *testing.T) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
directory, err := os.Getwd()
|
directory, err := os.Getwd()
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
This service was generated with `go-omni new`.
|
This service was generated with `go-omni new`.
|
||||||
|
|
||||||
|
Framework dependency: `git.yuntuo.hk/k2safe/go-omni {{.FrameworkVersion}}`.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
go mod tidy
|
go mod tidy
|
||||||
go run ./cmd/server
|
go run ./cmd/server
|
||||||
@@ -16,6 +18,17 @@ GO_OMNI_ENABLE_LOGGER=true GO_OMNI_LOG_LEVEL=debug go run ./cmd/server
|
|||||||
`GO_OMNI_HTTP_ADDR` 可以修改监听地址。生成项目默认提供 `/health/live` 和
|
`GO_OMNI_HTTP_ADDR` 可以修改监听地址。生成项目默认提供 `/health/live` 和
|
||||||
`/health/ready`,日志、请求 ID 和优雅退出等基础能力由 GoOmni 统一初始化;业务依赖集中放在项目自己的 `internal/servicecontext`。
|
`/health/ready`,日志、请求 ID 和优雅退出等基础能力由 GoOmni 统一初始化;业务依赖集中放在项目自己的 `internal/servicecontext`。
|
||||||
|
|
||||||
|
服务使用 `signal.NotifyContext` 处理 `SIGINT` / `SIGTERM`。部署时构建并运行服务二进制,
|
||||||
|
使信号直接到达服务进程:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go build -o server ./cmd/server
|
||||||
|
./server
|
||||||
|
```
|
||||||
|
|
||||||
|
`GO_OMNI_SHUTDOWN_TIMEOUT` 控制等待请求完成的时间,默认 `10s`。健康探针目前没有
|
||||||
|
数据库依赖检查;接入数据库后需要向 Checker 注册探针。
|
||||||
|
|
||||||
The generated layout keeps HTTP handlers, business services, repositories,
|
The generated layout keeps HTTP handlers, business services, repositories,
|
||||||
models and a project-owned ServiceContext separate. Replace the memory
|
models and a project-owned ServiceContext separate. Replace the memory
|
||||||
repository with a database implementation when the service is ready for
|
repository with a database implementation when the service is ready for
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"log"
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
"strings"
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
omni "git.yuntuo.hk/k2safe/go-omni"
|
omni "git.yuntuo.hk/k2safe/go-omni"
|
||||||
"git.yuntuo.hk/k2safe/go-omni/config"
|
"git.yuntuo.hk/k2safe/go-omni/config"
|
||||||
@@ -15,17 +19,27 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
if err := run(); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func run() error {
|
||||||
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
settings, err := config.LoadFromEnv("")
|
settings, err := config.LoadFromEnv("")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
return err
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(settings.Log.Service) == "" {
|
if strings.TrimSpace(settings.Log.Service) == "" {
|
||||||
settings.Log.Service = "{{.Module}}"
|
settings.Log.Service = "{{.Module}}"
|
||||||
}
|
}
|
||||||
logger, err := logx.New(settings.Log)
|
logger, err := logx.New(settings.Log)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
return err
|
||||||
}
|
}
|
||||||
|
defer logger.Close()
|
||||||
// Access logs and business logs share the same configured sink.
|
// Access logs and business logs share the same configured sink.
|
||||||
logx.SetDefault(logger)
|
logx.SetDefault(logger)
|
||||||
|
|
||||||
@@ -35,7 +49,7 @@ func main() {
|
|||||||
userHandler := handler.NewUsers(userService)
|
userHandler := handler.NewUsers(userService)
|
||||||
checker, err := health.New()
|
checker, err := health.New()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
application := omni.New(omni.WithConfig(settings))
|
application := omni.New(omni.WithConfig(settings))
|
||||||
@@ -43,7 +57,5 @@ func main() {
|
|||||||
application.GET("/health/live", checker.LivenessHandler())
|
application.GET("/health/live", checker.LivenessHandler())
|
||||||
application.GET("/health/ready", checker.ReadinessHandler())
|
application.GET("/health/ready", checker.ReadinessHandler())
|
||||||
userHandler.RegisterRoutes(application)
|
userHandler.RegisterRoutes(application)
|
||||||
if err := application.Run(settings.Address); err != nil {
|
return application.RunContext(ctx, settings.Address)
|
||||||
log.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ module {{.Module}}
|
|||||||
|
|
||||||
go 1.22
|
go 1.22
|
||||||
|
|
||||||
require git.yuntuo.hk/k2safe/go-omni v0.0.0
|
require git.yuntuo.hk/k2safe/go-omni {{.FrameworkVersion}}
|
||||||
{{- if .FrameworkReplace}}
|
{{- if .FrameworkReplace}}
|
||||||
replace git.yuntuo.hk/k2safe/go-omni => {{.FrameworkReplace}}
|
replace git.yuntuo.hk/k2safe/go-omni => {{.FrameworkReplace}}
|
||||||
{{- end}}
|
{{- end}}
|
||||||
|
|||||||
31
cmd/go-omni/scaffold/version.go
Normal file
31
cmd/go-omni/scaffold/version.go
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
package scaffold
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"runtime/debug"
|
||||||
|
)
|
||||||
|
|
||||||
|
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.1.1"
|
||||||
|
|
||||||
|
var frameworkVersionPattern = regexp.MustCompile(`^v[01]\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$`)
|
||||||
|
|
||||||
|
func validFrameworkVersion(version string) bool {
|
||||||
|
return frameworkVersionPattern.MatchString(version)
|
||||||
|
}
|
||||||
|
|
||||||
|
func currentFrameworkVersion() string {
|
||||||
|
info, _ := debug.ReadBuildInfo()
|
||||||
|
return frameworkVersion(info)
|
||||||
|
}
|
||||||
|
|
||||||
|
func frameworkVersion(info *debug.BuildInfo) string {
|
||||||
|
if info != nil && info.Main.Path == frameworkModule && info.Main.Sum != "" && validFrameworkVersion(info.Main.Version) {
|
||||||
|
return info.Main.Version
|
||||||
|
}
|
||||||
|
return defaultFrameworkVersion
|
||||||
|
}
|
||||||
158
scripts/verifyScaffold.py
Normal file
158
scripts/verifyScaffold.py
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Verify a generated service outside the source tree using a real server process."""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import signal
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
MODULE = "git.yuntuo.hk/k2safe/go-omni"
|
||||||
|
|
||||||
|
|
||||||
|
def run(args, cwd, env):
|
||||||
|
result = subprocess.run(args, cwd=cwd, env=env, text=True,
|
||||||
|
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=180)
|
||||||
|
if result.returncode:
|
||||||
|
raise RuntimeError(f"{args[0]} {args[1:]} failed:\n{result.stdout}")
|
||||||
|
return result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def exerciseServer(binary, directory, env, stopSignal, cleanupMarker=None):
|
||||||
|
with socket.socket() as listener:
|
||||||
|
listener.bind(("127.0.0.1", 0))
|
||||||
|
address = f"127.0.0.1:{listener.getsockname()[1]}"
|
||||||
|
serverEnv = {key: value for key, value in env.items() if not key.startswith("GO_OMNI_")}
|
||||||
|
serverEnv.update(GO_OMNI_HTTP_ADDR=address, GO_OMNI_MODE="release",
|
||||||
|
GO_OMNI_ENABLE_LOGGER="true", GO_OMNI_SHUTDOWN_TIMEOUT="2s")
|
||||||
|
if cleanupMarker:
|
||||||
|
serverEnv["GO_OMNI_CLEANUP_MARKER"] = str(cleanupMarker)
|
||||||
|
logFile = directory / f"{binary.name}-{stopSignal.name}.log"
|
||||||
|
client = urllib.request.build_opener(urllib.request.ProxyHandler({}))
|
||||||
|
|
||||||
|
def request(path, data=None):
|
||||||
|
req = urllib.request.Request(f"http://{address}{path}", data=data,
|
||||||
|
headers={"Content-Type": "application/json", "X-Request-ID": "smokeRequest"})
|
||||||
|
with client.open(req, timeout=2) as response:
|
||||||
|
if response.headers.get("X-Request-ID") != "smokeRequest":
|
||||||
|
raise AssertionError("request ID was not preserved")
|
||||||
|
return response.status, json.load(response)
|
||||||
|
|
||||||
|
with logFile.open("w") as logs:
|
||||||
|
process = subprocess.Popen([str(binary)], cwd=directory, env=serverEnv,
|
||||||
|
stdout=logs, stderr=subprocess.STDOUT)
|
||||||
|
try:
|
||||||
|
deadline = time.monotonic() + 15
|
||||||
|
while True:
|
||||||
|
if process.poll() is not None:
|
||||||
|
raise RuntimeError(f"server exited during startup: {logFile.read_text()}")
|
||||||
|
try:
|
||||||
|
status, body = request("/health/live")
|
||||||
|
break
|
||||||
|
except (OSError, urllib.error.URLError):
|
||||||
|
if time.monotonic() >= deadline:
|
||||||
|
raise RuntimeError(f"server did not start: {logFile.read_text()}")
|
||||||
|
time.sleep(0.05)
|
||||||
|
if status != 200 or body["data"]["status"] != "ok":
|
||||||
|
raise AssertionError(f"unexpected liveness: {body}")
|
||||||
|
if not cleanupMarker:
|
||||||
|
status, body = request("/health/ready")
|
||||||
|
if status != 200 or body["code"] != "READY":
|
||||||
|
raise AssertionError(f"unexpected readiness: {body}")
|
||||||
|
status, user = request("/users", json.dumps({"name": "Smoke", "email": "smoke@example.com"}).encode())
|
||||||
|
if status != 201 or user["name"] != "Smoke" or "createdAt" not in user:
|
||||||
|
raise AssertionError(f"unexpected created user: {user}")
|
||||||
|
process.send_signal(stopSignal)
|
||||||
|
exitCode = process.wait(timeout=8)
|
||||||
|
if exitCode != 0:
|
||||||
|
raise AssertionError(f"{stopSignal.name} exit code {exitCode}: {logFile.read_text()}")
|
||||||
|
if cleanupMarker and cleanupMarker.read_text() != "closed":
|
||||||
|
raise AssertionError("shutdown callback was not executed")
|
||||||
|
# A built binary, rather than `go run`, lets us verify the actual
|
||||||
|
# service exited without leaving its child listening in the background.
|
||||||
|
with socket.socket() as connection:
|
||||||
|
connection.settimeout(1)
|
||||||
|
if connection.connect_ex(("127.0.0.1", int(address.rsplit(":", 1)[1]))) == 0:
|
||||||
|
raise AssertionError("server still accepts connections after exit")
|
||||||
|
finally:
|
||||||
|
if process.poll() is None:
|
||||||
|
process.kill()
|
||||||
|
process.wait(timeout=5)
|
||||||
|
print(f"PASS {binary.name}: HTTP checks, {stopSignal.name}, exit 0, listener closed", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--go", default="go", help="Go executable")
|
||||||
|
parser.add_argument("--version", default="v0.1.1", help="Released CLI version to install")
|
||||||
|
parser.add_argument("--cli", help="Use a locally built CLI instead of installing")
|
||||||
|
parser.add_argument("--framework-version", help="Explicit version passed to the CLI")
|
||||||
|
parser.add_argument("--framework-dir", help="Local checkout for pre-release verification")
|
||||||
|
parser.add_argument("--skip-run-check", action="store_true", help="Skip core Run signal checks when testing an older framework")
|
||||||
|
args = parser.parse_args()
|
||||||
|
env = dict(os.environ, GOWORK="off")
|
||||||
|
env["GOPRIVATE"] = ",".join(filter(None, [env.get("GOPRIVATE", ""), "git.yuntuo.hk"]))
|
||||||
|
with tempfile.TemporaryDirectory(prefix="go omni smoke ") as temporary:
|
||||||
|
directory = Path(temporary).resolve()
|
||||||
|
if args.cli:
|
||||||
|
cli = Path(args.cli).resolve()
|
||||||
|
else:
|
||||||
|
env["GOBIN"] = str(directory / "bin")
|
||||||
|
print(f"Installing remote CLI {args.version}", flush=True)
|
||||||
|
run([args.go, "install", f"{MODULE}/cmd/go-omni@{args.version}"], directory, env)
|
||||||
|
cli = directory / "bin" / "go-omni"
|
||||||
|
command = [str(cli), "new", "--module", "example.com/scaffoldSmoke"]
|
||||||
|
if args.framework_version:
|
||||||
|
command += ["--framework-version", args.framework_version]
|
||||||
|
if args.framework_dir:
|
||||||
|
command += ["--framework-dir", str(Path(args.framework_dir).resolve())]
|
||||||
|
project = directory / "billing"
|
||||||
|
run(command + [str(project)], directory, env)
|
||||||
|
module = json.loads(run([args.go, "mod", "edit", "-json"], project, env))
|
||||||
|
requirements = {entry["Path"]: entry["Version"] for entry in module["Require"]}
|
||||||
|
expectedVersion = args.framework_version or args.version
|
||||||
|
if requirements.get(MODULE) != expectedVersion:
|
||||||
|
raise AssertionError(f"expected framework {expectedVersion}, got {requirements}")
|
||||||
|
if not args.framework_dir and module.get("Replace"):
|
||||||
|
raise AssertionError("remote generation must not contain local replace directives")
|
||||||
|
print(f"PASS generated module pins {expectedVersion}; local replace: {bool(module.get('Replace'))}", flush=True)
|
||||||
|
run([args.go, "mod", "tidy"], project, env)
|
||||||
|
run([args.go, "test", "./..."], project, env)
|
||||||
|
binary = directory / "server"
|
||||||
|
run([args.go, "build", "-o", str(binary), "./cmd/server"], project, env)
|
||||||
|
for stopSignal in (signal.SIGINT, signal.SIGTERM):
|
||||||
|
exerciseServer(binary, project, env, stopSignal)
|
||||||
|
|
||||||
|
if not args.skip_run_check:
|
||||||
|
# Verify the framework's convenient Run API independently from the
|
||||||
|
# generated main's caller-owned signal context.
|
||||||
|
helper = project / "cmd" / "runCheck"
|
||||||
|
helper.mkdir()
|
||||||
|
helper.joinpath("main.go").write_text('''package main
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
omni "git.yuntuo.hk/k2safe/go-omni"
|
||||||
|
)
|
||||||
|
func main() {
|
||||||
|
app := omni.New(omni.WithMode("release"))
|
||||||
|
app.GET("/health/live", func(ctx *omni.Context) error { return ctx.OK(map[string]string{"status": "ok"}) })
|
||||||
|
app.OnShutdown(func() error { return os.WriteFile(os.Getenv("GO_OMNI_CLEANUP_MARKER"), []byte("closed"), 0600) })
|
||||||
|
if err := app.Run(os.Getenv("GO_OMNI_HTTP_ADDR")); err != nil { log.Fatal(err) }
|
||||||
|
}
|
||||||
|
''')
|
||||||
|
runBinary = directory / "runCheck"
|
||||||
|
run([args.go, "build", "-o", str(runBinary), "./cmd/runCheck"], project, env)
|
||||||
|
for stopSignal in (signal.SIGINT, signal.SIGTERM):
|
||||||
|
marker = project / f"cleanup-{stopSignal.name}"
|
||||||
|
exerciseServer(runBinary, project, env, stopSignal, marker)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user