Files
go-omni/cmd/go-omni/apigen/testdata/managed/shutdown_test.go

310 lines
10 KiB
Go

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/lifecycle"
"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)
}
}
func TestGeneratedIncompleteHTTPDrainRetainsDependencies(t *testing.T) {
for _, transport := range []string{"nethttp", "native"} {
t.Run(transport, func(t *testing.T) {
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
address := l.Addr().String()
l.Close()
path := filepath.Join(t.TempDir(), "config.json")
logFile := filepath.Join(t.TempDir(), "service.log")
data, _ := json.Marshal(map[string]any{"server": map[string]any{"address": address, "transport": transport, "shutdownTimeout": "30ms", "http": map[string]string{"shutdownCancelTimeout": "30ms"}, "log": map[string]any{"file": logFile, "rotation": map[string]any{"enabled": true}}}})
if err = os.WriteFile(path, data, 0600); err != nil {
t.Fatal(err)
}
svc.Started = make(chan struct{}, 1)
svc.Release = make(chan struct{})
before := svc.Closed.Load()
returned := svc.SlowReturned.Load()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
done := make(chan error, 1)
go func() { done <- run(ctx, path) }()
waitReady(t, "http://"+address, done)
client := &http.Client{Timeout: 3 * time.Second}
defer client.CloseIdleConnections()
requestDone := make(chan struct{})
go func() {
defer close(requestDone)
res, err := client.Get("http://" + address + "/slow")
if err == nil {
io.Copy(io.Discard, res.Body)
res.Body.Close()
}
}()
await(t, svc.Started)
// Capture the channel, because a later test installs its own fixture.
release := svc.Release
deps := svc.Current.Load()
defer func() {
close(release)
deadline := time.Now().Add(3 * time.Second)
for svc.SlowReturned.Load() == returned {
if time.Now().After(deadline) {
t.Error("handler did not exit after test release")
break
}
time.Sleep(time.Millisecond)
}
// The test owns the controlled handler and has now joined it.
_ = deps.Logger.Close()
}()
cancel()
select {
case err = <-done:
if !errors.Is(err, lifecycle.ErrResourcesRetained) || !errors.Is(err, context.DeadlineExceeded) {
t.Fatal(err)
}
case <-time.After(3 * time.Second):
t.Fatal("unbounded HTTP shutdown")
}
if err = deps.Close(); !errors.Is(err, lifecycle.ErrResourcesRetained) {
t.Fatal("deferred Close bypassed retention", err)
}
if svc.Closed.Load() != before {
t.Fatal("generated main closed client during active request")
}
deps.Logger.Info("retained.http.logger")
logs, err := os.ReadFile(logFile)
if err != nil || !strings.Contains(string(logs), "retained.http.logger") {
t.Fatal("generated main closed logger during active request", err, string(logs))
}
await(t, requestDone)
})
}
}