feat: add dependency shutdown and configurable service endpoints
This commit is contained in:
@@ -6,7 +6,7 @@
|
||||
|
||||
```sh
|
||||
# 仓库读取权限与 PATH 配置见根目录 README
|
||||
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.12.0-preview
|
||||
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.13.0-preview
|
||||
go-omni new --module example.com/hello ./hello
|
||||
|
||||
# 从自己的契约生成;service.api 及其相对 import 文件需要在本地存在
|
||||
@@ -41,14 +41,14 @@ main、Gen.go、OpenAPI 和 manifest 由生成器维护;业务文件按字节
|
||||
|
||||
## 版本与迁移
|
||||
|
||||
CLI 与框架依赖固定使用 v0.12.0-preview。远程安装的 CLI 默认把自身版本写入 go.mod,不添加本地 replace;`--framework-version` 可显式指定已发布的兼容版本。仅在开发框架源码时使用本地替换:
|
||||
CLI 与框架依赖固定使用 v0.13.0-preview。远程安装的 CLI 默认把自身版本写入 go.mod,不添加本地 replace;`--framework-version` 可显式指定已发布的兼容版本。仅在开发框架源码时使用本地替换:
|
||||
|
||||
```sh
|
||||
# 在 go-omni 源码仓库中执行
|
||||
go run ./cmd/go-omni new --module example.com/hello --framework-dir . ../hello
|
||||
```
|
||||
|
||||
新模板需要 v0.12.0-preview 的流式能力与 manifest v2。不兼容旧目录,旧生成项目请在新目录重新生成后移入业务代码,不覆盖手写 main。独立工具命令不会被删除。受管理项目通过 `.api` 模块导入增加业务,不使用旧 `add` 命令的手动路由装配。
|
||||
新模板需要 v0.13.0-preview 的依赖退出与端点配置能力,继续使用 manifest v2。v0.12 项目需按 [升级步骤](../../PROJECT.md#从-v012-生成项目升级) 补入 svc 生命周期字段和方法,生成器保留这些项目文件。不兼容旧目录,旧生成项目请在新目录重新生成后移入业务代码,不覆盖手写 main。独立工具命令不会被删除。受管理项目通过 `.api` 模块导入增加业务,不使用旧 `add` 命令的手动路由装配。
|
||||
|
||||
## 数据库迁移命令
|
||||
|
||||
|
||||
@@ -87,7 +87,6 @@ func TestInvalidDefinitions(t *testing.T) {
|
||||
"service user { @handler go\n @status 201\n post /go\n}",
|
||||
"type A { Name string `header:\"Name\"` }\nservice user { @handler go\n get /go returns (A)\n}",
|
||||
"@server (group: ../../bad)\nservice user { @handler go\n get /go\n}",
|
||||
"service user { @handler go\n get /health/ready\n}",
|
||||
"type A { Name string `json:\"name\"`\n Name int `json:\"other\"` }\nservice user { @handler go\n post /go (A)\n}",
|
||||
"service user { @handler go\n get /go\n @handler Go\n get /other\n}",
|
||||
"type A { Value []byte `json:\"value\"` }\nservice user { @handler go\n post /go (A)\n}",
|
||||
|
||||
@@ -218,9 +218,6 @@ func Compile(spec *Spec) (*Compiled, error) {
|
||||
return nil, fmt.Errorf("duplicate route %s", pathKey)
|
||||
}
|
||||
paths[pathKey] = true
|
||||
if r.Path == "/health/live" || r.Path == "/health/ready" || r.Path == "/openapi.json" {
|
||||
return nil, fmt.Errorf("route %s is reserved by the generated server", r.Path)
|
||||
}
|
||||
if r.Method != strings.ToLower(r.Method) {
|
||||
return nil, errors.New("HTTP methods must be lowercase")
|
||||
}
|
||||
|
||||
60
cmd/go-omni/apigen/endpoints_test.go
Normal file
60
cmd/go-omni/apigen/endpoints_test.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package apigen
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestConfigurableBuiltinRoutesConsumer(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "endpoints")
|
||||
api := filepath.Join(t.TempDir(), "service.api")
|
||||
writeFile(t, api, "service healthApi {\n @handler customReady\n get /health/ready\n @handler customDoc\n get /openapi.json\n}\n")
|
||||
_, err := Generate(Options{Dir: dir, API: api, Module: "example.com/endpoints", FrameworkDir: frameworkRoot(t)})
|
||||
if err != nil {
|
||||
t.Fatal("built-in paths must be permitted in API contracts", err)
|
||||
}
|
||||
writeFile(t, filepath.Join(dir, "endpoints_test.go"), `package main
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"strings"
|
||||
"example.com/endpoints/internal/config"
|
||||
"example.com/endpoints/internal/svc"
|
||||
"example.com/endpoints/internal/handler"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
)
|
||||
func TestProjectOwnsDisabledBuiltinPaths(t *testing.T){
|
||||
cfg:=config.Default();cfg.Server.Log.Writer=io.Discard
|
||||
logger,_:=logx.New(cfg.Server.Log);defer logger.Close()
|
||||
deps,err:=svc.New(context.Background(),logger,cfg);if err!=nil{t.Fatal(err)};defer deps.Close()
|
||||
if _,err=handler.NewApp(deps);err==nil || !strings.Contains(err.Error(),"overlaps business"){t.Fatal("expected preflight collision",err)}
|
||||
deps.Config.Server.Endpoints.Readiness.Enabled=false
|
||||
deps.Config.Server.Endpoints.OpenAPI.Enabled=false
|
||||
app,err:=handler.NewApp(deps);if err!=nil{t.Fatal(err)};defer app.Close()
|
||||
for _,path:=range []string{"/health/ready","/openapi.json"}{
|
||||
out:=httptest.NewRecorder();app.ServeHTTP(out,httptest.NewRequest("GET",path,nil))
|
||||
if out.Code!=501{t.Fatal("expected generated business Logic stub",path,out.Code,out.Body.String())}
|
||||
}
|
||||
deps.Config.Server.Endpoints.Readiness.Enabled=true
|
||||
deps.Config.Server.Endpoints.Readiness.Path="/readyz"
|
||||
deps.Config.Server.Endpoints.OpenAPI.Enabled=true
|
||||
deps.Config.Server.Endpoints.OpenAPI.Path="/spec.json"
|
||||
app2,err:=handler.NewApp(deps);if err!=nil{t.Fatal(err)};defer app2.Close()
|
||||
for _,path:=range []string{"/readyz","/spec.json"}{out:=httptest.NewRecorder();app2.ServeHTTP(out,httptest.NewRequest("GET",path,nil));if out.Code!=200{t.Fatal(path,out.Code)}}
|
||||
}
|
||||
`)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, "go", "test", "-race", "-mod=mod", "-count=1", "./...")
|
||||
cmd.Dir = dir
|
||||
cmd.Env = append(os.Environ(), "GOWORK=off")
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatal(err, string(output))
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ import (
|
||||
)
|
||||
|
||||
const frameworkModule = "git.yuntuo.hk/k2safe/go-omni"
|
||||
const minimumVersion = "v0.12.0-preview"
|
||||
const minimumVersion = "v0.13.0-preview"
|
||||
const generatedHeader = "// Code generated by go-omni api. DO NOT EDIT.\n"
|
||||
|
||||
type Options struct {
|
||||
@@ -141,13 +141,13 @@ func generate(o Options, c *Compiled, starter bool) (Output, error) {
|
||||
if !filepath.IsAbs(path) {
|
||||
path = filepath.Join(directory, path)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(path, "httpx", "transfer.go")); err == nil {
|
||||
if _, err := os.Stat(filepath.Join(path, "lifecycle", "controller.go")); err == nil {
|
||||
localCurrent = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !localCurrent {
|
||||
return Output{}, fmt.Errorf("existing framework dependency predates managed startup and streaming support; use %s or a current local replacement", minimumVersion)
|
||||
return Output{}, fmt.Errorf("existing framework dependency predates dependency shutdown and configurable endpoint support; use %s or a current local replacement", minimumVersion)
|
||||
}
|
||||
}
|
||||
if o.FrameworkDir != "" {
|
||||
|
||||
@@ -70,6 +70,7 @@ func TestManagedGeneratedProject(t *testing.T) {
|
||||
mainBefore := readFile(t, filepath.Join(dir, "main.go"))
|
||||
files := map[string]string{
|
||||
"consumer_test.go": "consumer_test.go", "internal/svc/options.go": "options.go", "internal/svc/client.go": "client.go", "internal/logic/demo/check.go": "operations.go",
|
||||
"shutdown_test.go": "shutdown_test.go",
|
||||
}
|
||||
for path, fixture := range files {
|
||||
writeFile(t, filepath.Join(dir, path), readFile(t, filepath.Join("testdata/managed", fixture)))
|
||||
@@ -78,7 +79,7 @@ func TestManagedGeneratedProject(t *testing.T) {
|
||||
writeFile(t, filepath.Join(dir, "internal/logic/demo", name+".go"), "package demo\n")
|
||||
}
|
||||
configPath := filepath.Join(dir, "internal/config/config.go")
|
||||
writeFile(t, configPath, strings.Replace(readFile(t, configPath), "type Config struct {", "type Config struct {\n FailInit bool `json:\"failInit\"`", 1))
|
||||
writeFile(t, configPath, strings.Replace(readFile(t, configPath), "type Config struct {", "type Config struct {\n FailInit bool `json:\"failInit\"`\n ShutdownInit bool `json:\"shutdownInit\"`", 1))
|
||||
svcPath := filepath.Join(dir, "internal/svc/serviceContext.go")
|
||||
source := strings.Replace(readFile(t, svcPath), "type ServiceContext struct {", "type ServiceContext struct {\n Client *Client", 1)
|
||||
source = strings.Replace(source, "return c, nil", "if err=initClient(ctx,c);err!=nil{return nil,err}\n return c,nil", 1)
|
||||
|
||||
@@ -13,13 +13,15 @@ import (
|
||||
omni %q
|
||||
%q
|
||||
%q
|
||||
%q
|
||||
%q
|
||||
)
|
||||
// NewApp is generated HTTP composition. The caller owns deps and its logger.
|
||||
func NewApp(deps *svc.ServiceContext)(*omni.App,error){
|
||||
if deps==nil || deps.Health==nil{return nil,fmt.Errorf("service context and health checker required")}
|
||||
if deps==nil || deps.Lifecycle==nil{return nil,fmt.Errorf("service context and lifecycle controller required; see PROJECT.md upgrade instructions")}
|
||||
cfg:=deps.Config.Server
|
||||
for _,option:=range deps.AppOptions(){if option!=nil{option(&cfg)}}
|
||||
`, frameworkModule, frameworkModule+"/httpx", o.Module+"/internal/svc")
|
||||
`, frameworkModule, frameworkModule+"/httpx", frameworkModule+"/config", frameworkModule+"/health", o.Module+"/internal/svc")
|
||||
if spec.DefaultResponse != "" {
|
||||
for _, r := range spec.Responses {
|
||||
if r.Name == spec.DefaultResponse {
|
||||
@@ -28,13 +30,30 @@ func NewApp(deps *svc.ServiceContext)(*omni.App,error){
|
||||
}
|
||||
}
|
||||
}
|
||||
source += `if err:=cfg.Validate();err!=nil{return nil,err}
|
||||
source += "if err:=cfg.Validate();err!=nil{return nil,err}\n"
|
||||
source += "if err:=cfg.Endpoints.ValidateRoutes([]config.EndpointRoute{\n"
|
||||
for _, route := range spec.Routes {
|
||||
source += fmt.Sprintf("{Method:%q,Path:%q},\n", route.Method, route.Path)
|
||||
}
|
||||
if o.Storage == "local" {
|
||||
source += "{Method: \"GET\",Path: \"/storage/object\"},\n"
|
||||
}
|
||||
source += `}...);err!=nil{return nil,err}
|
||||
if (cfg.Endpoints.Liveness.Enabled || cfg.Endpoints.Readiness.Enabled) && deps.Health==nil{return nil,fmt.Errorf("enabled health endpoints require a checker")}
|
||||
app:=omni.New(omni.WithConfig(cfg))
|
||||
app.Use(deps.GlobalMiddleware()...)
|
||||
if err:=RegisterRoutes(app,deps);err!=nil{return nil,err}
|
||||
app.GET("/openapi.json",OpenAPI)
|
||||
app.GET("/health/live",deps.Health.LivenessHandler())
|
||||
app.GET("/health/ready",deps.Health.ReadinessHandler())
|
||||
if err:=RegisterRoutes(app,deps);err!=nil{_ = app.Close();return nil,err}
|
||||
if cfg.Endpoints.OpenAPI.Enabled{app.GET(cfg.Endpoints.OpenAPI.Path,OpenAPI)}
|
||||
if cfg.Endpoints.Liveness.Enabled{
|
||||
var options []health.HandlerOption
|
||||
if cfg.Endpoints.Liveness.ResponseMode=="raw"{options=append(options,health.WithRawResponse())}
|
||||
app.GET(cfg.Endpoints.Liveness.Path,deps.Health.LivenessHandler(options...))
|
||||
}
|
||||
if cfg.Endpoints.Readiness.Enabled{
|
||||
options:=[]health.HandlerOption{health.WithShutdownContext(deps.Lifecycle.Context())}
|
||||
if cfg.Endpoints.Readiness.ResponseMode=="raw"{options=append(options,health.WithRawResponse())}
|
||||
app.GET(cfg.Endpoints.Readiness.Path,deps.Health.ReadinessHandler(options...))
|
||||
}
|
||||
`
|
||||
if o.Storage == "local" {
|
||||
source += `if deps.FileHTTP==nil{return nil,fmt.Errorf("local storage handler required")}
|
||||
|
||||
@@ -32,11 +32,14 @@ func run(ctx context.Context,path string)(runErr error){
|
||||
deps,err:=svc.New(ctx,logger,cfg);if err!=nil{return err}
|
||||
// RunContext drains HTTP requests before returning. This scope owns dependencies
|
||||
// and closes them exactly once, before closing the borrowed logger.
|
||||
defer func(){runErr=errors.Join(runErr,deps.Close())}()
|
||||
defer func(){
|
||||
runErr=errors.Join(runErr,deps.Close())
|
||||
if cause:=deps.Lifecycle.Err();cause!=nil && !errors.Is(runErr,cause){runErr=errors.Join(runErr,cause)}
|
||||
}()
|
||||
app,err:=handler.NewApp(deps);if err!=nil{return err}
|
||||
defer func(){runErr=errors.Join(runErr,app.Close())}()
|
||||
logger.InfoContext(ctx,"service.starting","address",app.Config().Address)
|
||||
return app.RunContext(ctx)
|
||||
return app.RunContext(deps.Lifecycle.Context())
|
||||
}
|
||||
`,
|
||||
".gitignore": "/server\n/var/\n.env\n.env.*\n!.env.example\n/.goOmni/api.lock\n",
|
||||
@@ -58,6 +61,10 @@ go run .
|
||||
|
||||
GET /health/live and /health/ready check the running service. GET /openapi.json
|
||||
serves the same document as docs/openapi.json. Bind address defaults to localhost.
|
||||
Configure server.endpoints to disable or move each built-in route. Health
|
||||
responseMode is app by default (project format), or raw for plain health data.
|
||||
Probe HTTP statuses remain 200/503 even when business errors use HTTP 200.
|
||||
OpenAPI remains an unwrapped document; disabled endpoints reserve no paths.
|
||||
Set GO_OMNI_TRANSPORT=native to select the native transport without changing services.
|
||||
The greeting starter works immediately; other new operation stubs return 501 until
|
||||
their business logic is implemented. Authentication hooks also fail closed with 501.
|
||||
@@ -174,6 +181,16 @@ Do not close borrowed resources or attach duplicate cleanup hooks to App. The st
|
||||
context cancels on signals; long-lived clients/workers that requests depend on need
|
||||
their own lifetime, canceled by OnClose after HTTP draining.
|
||||
|
||||
svc.RequestShutdown(err) lets a dependency report a fatal service failure.
|
||||
It returns immediately. Generated main stops accepting requests, drains them,
|
||||
closes dependencies and logs, then exits nonzero with the original cause.
|
||||
nil requests a normal stop. Concurrent calls keep the first non-nil failure;
|
||||
notifications during initialization or draining are retained. Close must stop
|
||||
and join project-owned monitors before the final shutdown result is returned.
|
||||
The svc lifecycle context signals draining, not permission to close resources
|
||||
still used by active requests. Liveness and readiness are different: shutdown
|
||||
makes readiness 503; it does not turn a running process's liveness into failure.
|
||||
|
||||
Named middleware is composed in declaration order (first is outermost). Implement
|
||||
the generated hooks or replace entries in svc; never bypass an auth
|
||||
hook merely to make the server start. Middleware names do not imply a JWT policy.
|
||||
|
||||
@@ -9,16 +9,23 @@ import (
|
||||
var Opened, Closed, DownloadClosed atomic.Int32
|
||||
var Started = make(chan struct{}, 1)
|
||||
var Release = make(chan struct{})
|
||||
var Current atomic.Pointer[ServiceContext]
|
||||
var FatalDependency = errors.New("test dependency cannot continue")
|
||||
|
||||
// This fake client exercises the same lifetime contract as a database/client.
|
||||
type Client struct{}
|
||||
|
||||
func initClient(ctx context.Context, c *ServiceContext) error {
|
||||
Current.Store(c)
|
||||
Opened.Add(1)
|
||||
c.Client = &Client{}
|
||||
c.OnClose(func() error { Closed.Add(1); return nil })
|
||||
c.OnClose(func() error { Closed.Add(1); c.Logger.Info("test.client.closed"); return nil })
|
||||
if c.Config.FailInit {
|
||||
return errors.New("client initialization failed")
|
||||
}
|
||||
if c.Config.ShutdownInit {
|
||||
c.RequestShutdown(FatalDependency)
|
||||
return nil
|
||||
}
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
@@ -71,9 +71,10 @@ type countedReader struct{ io.Reader }
|
||||
|
||||
func (c *countedReader) Close() error { svc.DownloadClosed.Add(1); return nil }
|
||||
func (l *Logic) Slow(context.Context) (*types.Receipt, error) {
|
||||
before := svc.Closed.Load()
|
||||
svc.Started <- struct{}{}
|
||||
<-svc.Release
|
||||
if svc.Closed.Load() != 0 {
|
||||
if svc.Closed.Load() != before {
|
||||
return nil, errors.New("dependency closed before drain")
|
||||
}
|
||||
return &types.Receipt{Bytes: 1}, nil
|
||||
|
||||
231
cmd/go-omni/apigen/testdata/managed/shutdown_test.go
vendored
Normal file
231
cmd/go-omni/apigen/testdata/managed/shutdown_test.go
vendored
Normal file
@@ -0,0 +1,231 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"example.com/managed/internal/config"
|
||||
"example.com/managed/internal/handler"
|
||||
"example.com/managed/internal/svc"
|
||||
"git.yuntuo.hk/k2safe/go-omni/health"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
)
|
||||
|
||||
func TestGeneratedEndpointSettings(t *testing.T) {
|
||||
cfg := config.Default()
|
||||
cfg.Server.Log.Writer = io.Discard
|
||||
cfg.Server.Endpoints.Liveness.Path = "/livez"
|
||||
cfg.Server.Endpoints.Readiness.Path = "/readyz"
|
||||
cfg.Server.Endpoints.OpenAPI.Enabled = false
|
||||
logger, err := logx.New(cfg.Server.Log)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer logger.Close()
|
||||
deps, err := svc.New(context.Background(), logger, cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer deps.Close()
|
||||
app, err := handler.NewApp(deps)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer app.Close()
|
||||
for _, path := range []string{"/health/live", "/health/ready", "/openapi.json"} {
|
||||
if out := response(t, app, "GET", path, nil, nil); out.Code != 404 {
|
||||
t.Fatal(path, out.Code)
|
||||
}
|
||||
}
|
||||
for _, path := range []string{"/readyz", "/livez"} {
|
||||
out := response(t, app, "GET", path, nil, nil)
|
||||
var data map[string]any
|
||||
_ = json.Unmarshal(out.Body.Bytes(), &data)
|
||||
if out.Code != 200 || data["code"] != float64(0) || data["msg"] != "success" || data["message"] != nil || data["requestId"] != nil {
|
||||
t.Fatal(path, out.Code, out.Body.String())
|
||||
}
|
||||
}
|
||||
deps.RequestShutdown(errors.New("private shutdown cause"))
|
||||
out := response(t, app, "GET", "/readyz", nil, nil)
|
||||
if out.Code != 503 || strings.Contains(out.Body.String(), "private") {
|
||||
t.Fatal(out.Code, out.Body.String())
|
||||
}
|
||||
if out = response(t, app, "GET", "/livez", nil, nil); out.Code != 200 {
|
||||
t.Fatal("liveness should still work")
|
||||
}
|
||||
// NewApp uses the project's latest configuration, including endpoint switches.
|
||||
deps.Config.Server.Endpoints.Readiness.ResponseMode = "raw"
|
||||
deps.Config.Server.Endpoints.Liveness.Enabled = false
|
||||
deps.Config.Server.Endpoints.OpenAPI.Enabled = true
|
||||
deps.Config.Server.Endpoints.OpenAPI.Path = "/spec.json"
|
||||
app2, err := handler.NewApp(deps)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer app2.Close()
|
||||
out = response(t, app2, "GET", "/readyz", nil, nil)
|
||||
if out.Code != 503 || strings.Contains(out.Body.String(), `"code"`) || !strings.Contains(out.Body.String(), `"status":"not_ready"`) {
|
||||
t.Fatal(out.Body.String())
|
||||
}
|
||||
out = response(t, app2, "GET", "/spec.json", nil, nil)
|
||||
if out.Code != 200 || !strings.Contains(out.Body.String(), `"openapi":`) || strings.Contains(out.Body.String(), `"msg":"操作成功"`) {
|
||||
t.Fatal("OpenAPI was wrapped")
|
||||
}
|
||||
deps.Config.Server.Endpoints.Readiness.Path = "/check"
|
||||
if _, err = handler.NewApp(deps); err == nil || !strings.Contains(err.Error(), "overlaps business") {
|
||||
t.Fatal("collision accepted", err)
|
||||
}
|
||||
deps.Config.Server.Endpoints.Readiness.Enabled = false
|
||||
if a, err := handler.NewApp(deps); err != nil {
|
||||
t.Fatal("disabled endpoint reserves path", err)
|
||||
} else {
|
||||
_ = a.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratedFailedProbe(t *testing.T) {
|
||||
cfg := config.Default()
|
||||
cfg.Server.Log.Writer = io.Discard
|
||||
logger, _ := logx.New(cfg.Server.Log)
|
||||
defer logger.Close()
|
||||
deps, err := svc.New(context.Background(), logger, cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer deps.Close()
|
||||
if err = deps.Health.Add(health.Probe{Name: "database", Check: func(context.Context) error { return errors.New("private connection details") }}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app, err := handler.NewApp(deps)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer app.Close()
|
||||
out := response(t, app, "GET", "/health/ready", nil, nil)
|
||||
if out.Code != 503 || strings.Contains(out.Body.String(), "private") || !strings.Contains(out.Body.String(), `"msg":`) {
|
||||
t.Fatal(out.Code, out.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratedDependencyShutdown(t *testing.T) {
|
||||
for _, transport := range []string{"nethttp", "native"} {
|
||||
t.Run(transport, func(t *testing.T) {
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
address := listener.Addr().String()
|
||||
_ = listener.Close()
|
||||
t.Setenv("GO_OMNI_TRANSPORT", transport)
|
||||
svc.Started = make(chan struct{}, 1)
|
||||
svc.Release = make(chan struct{})
|
||||
before := svc.Closed.Load()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
done := make(chan error, 1)
|
||||
path := configFile(t, address, false)
|
||||
go func() { done <- run(ctx, path) }()
|
||||
base := "http://" + address
|
||||
waitReady(t, base, done)
|
||||
deps := svc.Current.Load()
|
||||
requestDone := make(chan error, 1)
|
||||
go func() {
|
||||
client := http.Client{Timeout: 3 * time.Second}
|
||||
res, err := client.Get(base + "/slow")
|
||||
if err == nil {
|
||||
_, _ = io.Copy(io.Discard, res.Body)
|
||||
_ = res.Body.Close()
|
||||
if res.StatusCode != 200 {
|
||||
err = io.ErrUnexpectedEOF
|
||||
}
|
||||
}
|
||||
requestDone <- err
|
||||
}()
|
||||
select {
|
||||
case <-svc.Started:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("request did not start")
|
||||
}
|
||||
deps.RequestShutdown(svc.FatalDependency)
|
||||
// Inspect the same generated readiness handler during draining, even if
|
||||
// the network listener has already stopped accepting new connections.
|
||||
probe, _ := handler.NewApp(deps)
|
||||
out := httptest.NewRecorder()
|
||||
probe.ServeHTTP(out, httptest.NewRequest("GET", "/health/ready", nil))
|
||||
_ = probe.Close()
|
||||
if out.Code != 503 {
|
||||
t.Fatal("draining remained ready", out.Body.String())
|
||||
}
|
||||
if svc.Closed.Load() != before {
|
||||
t.Fatal("dependency closed before drain")
|
||||
}
|
||||
close(svc.Release)
|
||||
select {
|
||||
case err = <-requestDone:
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("request stuck")
|
||||
}
|
||||
select {
|
||||
case err = <-done:
|
||||
if !errors.Is(err, svc.FatalDependency) {
|
||||
t.Fatal("fatal cause lost", err)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("shutdown stuck")
|
||||
}
|
||||
if svc.Closed.Load() != before+1 {
|
||||
t.Fatal("cleanup did not run once")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratedFatalProcessExit(t *testing.T) {
|
||||
if path := os.Getenv("OMNI_TEST_FATAL_CONFIG"); path != "" {
|
||||
os.Args = []string{os.Args[0], "-config", path}
|
||||
main()
|
||||
return
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
if err := os.WriteFile(path, []byte(`{"shutdownInit":true,"server":{"address":"127.0.0.1:0"}}`), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestGeneratedFatalProcessExit$")
|
||||
cmd.Env = append(os.Environ(), "OMNI_TEST_FATAL_CONFIG="+path)
|
||||
output, err := cmd.CombinedOutput()
|
||||
var exit *exec.ExitError
|
||||
if !errors.As(err, &exit) || exit.ExitCode() != 1 {
|
||||
t.Fatal("fatal shutdown must exit 1", err, string(output))
|
||||
}
|
||||
if !strings.Contains(string(output), svc.FatalDependency.Error()) || strings.Count(string(output), "test.client.closed") != 1 {
|
||||
t.Fatal("missing failure or cleanup", string(output))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratedEndpointConflictCleansUp(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
if err := os.WriteFile(path, []byte(`{"server":{"address":"127.0.0.1:0","endpoints":{"readiness":{"path":"/check"}}}}`), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := svc.Closed.Load()
|
||||
err := run(context.Background(), path)
|
||||
if err == nil || !strings.Contains(err.Error(), "overlaps business") || svc.Closed.Load() != before+1 {
|
||||
t.Fatal("collision leaked resources or started serving", err)
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
)
|
||||
|
||||
func TestNewWithRemoteFrameworkVersion(t *testing.T) {
|
||||
for _, flag := range [][]string{{"--framework-version", "v0.12.0-preview"}, {"--framework-version=v0.12.0-preview"}} {
|
||||
for _, flag := range [][]string{{"--framework-version", "v0.13.0-preview"}, {"--framework-version=v0.13.0-preview"}} {
|
||||
project := filepath.Join(t.TempDir(), "billing")
|
||||
args := append([]string{"--module", "example.com/billing"}, flag...)
|
||||
if err := runNew(append(args, project)); err != nil {
|
||||
@@ -18,7 +18,7 @@ func TestNewWithRemoteFrameworkVersion(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(content), "go-omni v0.12.0-preview") || strings.Contains(string(content), "replace ") {
|
||||
if !strings.Contains(string(content), "go-omni v0.13.0-preview") || strings.Contains(string(content), "replace ") {
|
||||
t.Fatalf("CLI did not pin remote dependency: %s", content)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import "errors"
|
||||
func(c *Context)Close()error{
|
||||
if c==nil{return nil}
|
||||
c.closeOnce.Do(func(){
|
||||
if c.Lifecycle!=nil{c.Lifecycle.RequestShutdown(nil)}
|
||||
c.closeMu.Lock();c.closed=true;cleanups:=c.cleanups;c.cleanups=nil;c.closeMu.Unlock()
|
||||
for i:=len(cleanups)-1;i>=0;i--{c.closeErr=errors.Join(c.closeErr,cleanups[i]())}
|
||||
{{if ne .Storage "none"}}if c.storageClose!=nil{c.closeErr=errors.Join(c.closeErr,c.storageClose())}{{end}}
|
||||
|
||||
@@ -11,12 +11,14 @@ import (
|
||||
"git.yuntuo.hk/k2safe/go-omni/health"
|
||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
|
||||
"{{.Module}}/internal/settings"
|
||||
"{{.Module}}/internal/middleware"
|
||||
)
|
||||
// Context owns infrastructure, while Logger is borrowed from main.
|
||||
// Add business repositories here; HTTP contracts do not define database tables.
|
||||
type Context struct {
|
||||
Lifecycle *lifecycle.Controller
|
||||
Config settings.Config
|
||||
cleanups []func()error
|
||||
Logger *logx.Logger
|
||||
@@ -40,13 +42,14 @@ type Context struct {
|
||||
// tests without infrastructure; selected adapters still require valid settings.
|
||||
func New(ctx context.Context,logger *logx.Logger,configs ...settings.Config)(result *Context,err error){
|
||||
if ctx==nil{ctx=context.Background()}
|
||||
if err=ctx.Err();err!=nil{return nil,err}
|
||||
if err=ctx.Err();err!=nil{return nil,errors.Join(err,lifecycle.Cause(ctx))}
|
||||
if len(configs)>1{return nil,errors.New("expected at most one configuration")}
|
||||
cfg:=settings.Default();if len(configs)==1{cfg=configs[0]}
|
||||
_=cfg
|
||||
checker,err:=health.New();if err!=nil{return nil,err}
|
||||
c:=&Context{Config:cfg,Logger:logger,Health:checker,Middlewares:middleware.Defaults()}
|
||||
defer func(){if err!=nil{err=errors.Join(err,c.Close())}}()
|
||||
c:=&Context{Config:cfg,Logger:logger,Health:checker,Middlewares:middleware.Defaults(),Lifecycle:lifecycle.New(ctx)}
|
||||
ctx=c.Lifecycle.Context()
|
||||
defer func(){if err!=nil{err=errors.Join(err,c.Close());if cause:=c.Lifecycle.Err();cause!=nil && !errors.Is(err,cause){err=errors.Join(err,cause)}}}()
|
||||
{{if eq .Database "mysql"}}
|
||||
if err=cfg.Validate();err!=nil{return nil,err}
|
||||
dsn,parseErr:=mysql.ParseDSN(cfg.Database.DSN)
|
||||
@@ -59,3 +62,7 @@ func New(ctx context.Context,logger *logx.Logger,configs ...settings.Config)(res
|
||||
{{if ne .Storage "none"}}if err=c.InitStorage(ctx,cfg.Storage);err!=nil{return nil,err}{{end}}
|
||||
return c,nil
|
||||
}
|
||||
|
||||
// RequestShutdown reports a fatal dependency failure, or a normal stop when
|
||||
// err is nil. It returns immediately; generated main drains and cleans up.
|
||||
func(c *Context)RequestShutdown(err error){c.Lifecycle.RequestShutdown(err)}
|
||||
|
||||
@@ -4,6 +4,19 @@ server:
|
||||
enableLogger: true
|
||||
slowRequestThreshold: 500ms
|
||||
shutdownTimeout: 10s
|
||||
{{if .API}} endpoints:
|
||||
liveness:
|
||||
enabled: true
|
||||
path: /health/live
|
||||
responseMode: app
|
||||
readiness:
|
||||
enabled: true
|
||||
path: /health/ready
|
||||
responseMode: app
|
||||
openapi:
|
||||
enabled: true
|
||||
path: /openapi.json
|
||||
{{end}}
|
||||
log:
|
||||
service: {{printf "%q" .Module}}
|
||||
level: info
|
||||
|
||||
@@ -10,7 +10,7 @@ const frameworkModule = "git.yuntuo.hk/k2safe/go-omni"
|
||||
// Source builds can carry an unpublished VCS version (including +dirty).
|
||||
// Keep this fallback in sync with the next release; installed CLIs use their
|
||||
// downloaded module version, which also has a module checksum.
|
||||
const defaultFrameworkVersion = "v0.12.0-preview"
|
||||
const defaultFrameworkVersion = "v0.13.0-preview"
|
||||
|
||||
// This target is not a claim that the release already exists. Source users
|
||||
// select a local framework checkout until the module templates are published.
|
||||
|
||||
Reference in New Issue
Block a user