mirror of
https://github.com/go-admin-team/go-admin.git
synced 2026-09-24 19:17:43 +00:00
Compare commits
43
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
211ae85a4e | ||
|
|
3c3d94ca76 | ||
|
|
6138d2d74c | ||
|
|
d5de79f75b | ||
|
|
46e793972c | ||
|
|
46f4092b43 | ||
|
|
196195357b | ||
|
|
c579c5f84c | ||
|
|
241c27358b | ||
|
|
aa3c9866cb | ||
|
|
2ac01ea584 | ||
|
|
7f9cc1e435 | ||
|
|
4fb0529d2d | ||
|
|
8ee4141af6 | ||
|
|
36f2549172 | ||
|
|
dff0e64f51 | ||
|
|
0b78bc1e2e | ||
|
|
94163f9afb | ||
|
|
4510b06959 | ||
|
|
750c7c744e | ||
|
|
d52dca1cb6 | ||
|
|
71413a4248 | ||
|
|
4fede43254 | ||
|
|
0a629e2f3f | ||
|
|
37065fb089 | ||
|
|
6966f14dd4 | ||
|
|
22716e90c1 | ||
|
|
73cce7fc2f | ||
|
|
b59c7f0d46 | ||
|
|
d3a44a2a6b | ||
|
|
5c3c3907d5 | ||
|
|
f2215e132e | ||
|
|
a69afab34f | ||
|
|
e0132db1b9 | ||
|
|
7c3f55a873 | ||
|
|
f7c0247394 | ||
|
|
ac23556029 | ||
|
|
f64115e03a | ||
|
|
a2524c31bf | ||
|
|
71d6211c61 | ||
|
|
c67760bc39 | ||
|
|
d3e7f46a46 | ||
|
|
55866682ae |
@@ -15,6 +15,27 @@ jobs:
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
# The queue's ordering rule - consumers registered before the queue is
|
||||
# started - is invisible on the memory backend, which is the default and
|
||||
# therefore what every other test runs on: queue.Memory's Register starts a
|
||||
# consumer goroutine whatever the state. Only redis refuses a late
|
||||
# registration, so without a server here the tests that cover it would skip
|
||||
# and the suite would report success for a queue that accepts no consumers.
|
||||
services:
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
ports:
|
||||
- 6379:6379
|
||||
options: >-
|
||||
--health-cmd "redis-cli ping"
|
||||
--health-interval 5s
|
||||
--health-timeout 3s
|
||||
--health-retries 10
|
||||
|
||||
env:
|
||||
GO_ADMIN_TEST_REDIS_ADDR: 127.0.0.1:6379
|
||||
|
||||
steps:
|
||||
|
||||
- name: Set up Go 1.26
|
||||
|
||||
@@ -125,9 +125,12 @@ func (SysPost) TableName() string { return "sys_post" }
|
||||
两条与主仓贡献者直接相关的:
|
||||
|
||||
- **`common/`、`core/` 不得 import `app/`** —— `make checksilent` 在 CI 里守着,违反即红。
|
||||
- **从 core 契约包声明出来的类型必须写成别名**(`type X = pkg.Y`,不是 `type X pkg.Y`)
|
||||
—— `contract-shim-alias` 检查守着。defined type 会丢掉整个方法集,
|
||||
而且**不一定在本仓编译失败**,理由见 `docs/contract.md` 末节。
|
||||
- **注册类 API(`AppRouters` / `sdk.Runtime.SetAppRouters` / `migration.ForApp`)
|
||||
只允许在 `init()` 中调用** —— 注册期靠 Go 的包初始化顺序保证无并发写,
|
||||
core 侧的 setter 没有加锁。
|
||||
必须在 `runStartupHooks()` 之前调用完** —— `init()` 是最省事的位置,
|
||||
但约束的是**顺序**,不是写在哪个函数里;晚到的注册会被丢弃并只记一条 ERROR。
|
||||
|
||||
## 路由注册
|
||||
|
||||
@@ -191,7 +194,8 @@ go run -tags sqlite3 . server -c config/settings.sqlite.yml
|
||||
|
||||
## 数据库迁移
|
||||
|
||||
文件名前 13 位为时间戳版本号。**已执行过的迁移文件不可修改** ——
|
||||
文件名前 13 位为毫秒时间戳版本号,不合规的名字会在启动时 panic 并报出该文件名。
|
||||
**已执行过的迁移文件不可修改** ——
|
||||
`sys_migration` 表按版本号去重,改动不会重跑,只能新增一个迁移来修正。
|
||||
|
||||
放哪个目录取决于身份:
|
||||
@@ -223,7 +227,7 @@ go run -tags sqlite3 . server -c config/settings.sqlite.yml
|
||||
|
||||
## 静默失败校验
|
||||
|
||||
`make checksilent` 检查六类**不报错、不记日志、行为悄悄变得不对**的问题,
|
||||
`make checksilent` 检查七类**不报错、不记日志、行为悄悄变得不对**的问题,
|
||||
CI 会跑,命中 ERROR 即失败:
|
||||
|
||||
| 检查 | 级别 | 静默后果 |
|
||||
@@ -233,6 +237,7 @@ CI 会跑,命中 ERROR 即失败:
|
||||
| `config-value-truncation` | ERROR | `sys_config.config_value` 超 255 字符被静默截断 |
|
||||
| `menu-id-collision` | ERROR | 两个模块硬编码同一菜单 ID,互相覆盖 |
|
||||
| `contract-import-boundary` | ERROR | 契约包 import `app/`,应用无法独立编译 |
|
||||
| `contract-shim-alias` | ERROR | 契约薄壳写成 defined type 而非别名,方法集丢失,本仓可能照常编译、第三方应用编译不过 |
|
||||
| `menu-name-mismatch` | WARN | 菜单名与前端组件 `name` 不一致,keep-alive 缓存静默失效 |
|
||||
|
||||
最后一条要跨仓库比对,只能做正则启发式,因此是 WARN,**不影响退出码**,
|
||||
|
||||
@@ -444,7 +444,6 @@ func (e SysUser) GetInfo(c *gin.Context) {
|
||||
e.Error(500, err, err.Error())
|
||||
return
|
||||
}
|
||||
p := actions.GetPermissionFromContext(c)
|
||||
var roles = make([]string, 1)
|
||||
roles[0] = user.GetRoleName(c)
|
||||
var permissions = make([]string, 1)
|
||||
@@ -464,7 +463,14 @@ func (e SysUser) GetInfo(c *gin.Context) {
|
||||
}
|
||||
sysUser := models.SysUser{}
|
||||
req.Id = user.GetUserId(c)
|
||||
err = s.Get(&req, p, &sysUser)
|
||||
// Unscoped on purpose: the id is the caller's own, taken from the token.
|
||||
// This used to go through Get with whatever GetPermissionFromContext
|
||||
// returned - and this route installs no PermissionAction, so that was the
|
||||
// zero value. An unset scope is not a recognised one, so once unknown
|
||||
// scopes started failing closed rather than silently matching everything,
|
||||
// every login on a deployment with enabledp: true ended here with a 401
|
||||
// and the browser went straight back to the login page.
|
||||
err = s.GetSelf(&req, &sysUser)
|
||||
if err != nil {
|
||||
e.Error(http.StatusUnauthorized, err, "登录失败")
|
||||
return
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
jwt "github.com/go-admin-team/go-admin-core/v2/jwtauth"
|
||||
|
||||
"go-admin/app/admin/apis"
|
||||
"go-admin/common/actions"
|
||||
"go-admin/common/middleware"
|
||||
)
|
||||
|
||||
@@ -15,7 +16,10 @@ func init() {
|
||||
// registerSysApiRouter
|
||||
func registerSysApiRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddleware) {
|
||||
api := apis.SysApi{}
|
||||
r := v1.Group("/sys-api").Use(authMiddleware.MiddlewareFunc()).Use(middleware.AuthCheckRole())
|
||||
// PermissionAction is not optional here: all three handlers below read the
|
||||
// data permission out of the context, and without it they read the zero
|
||||
// value - an unset scope, which Permission now fails closed on.
|
||||
r := v1.Group("/sys-api").Use(authMiddleware.MiddlewareFunc()).Use(middleware.AuthCheckRole()).Use(actions.PermissionAction())
|
||||
{
|
||||
r.GET("", api.GetPage)
|
||||
r.GET("/:id", api.Get)
|
||||
|
||||
@@ -38,6 +38,30 @@ func (e *SysUser) GetPage(c *dto.SysUserGetPageReq, p *actions.DataPermission, l
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSelf 获取调用者自己的 SysUser 对象,不套数据权限
|
||||
//
|
||||
// The data scope answers "whose rows may this user see"; the caller here is
|
||||
// reading their own, and the id comes from the token, so there is nothing left
|
||||
// for a scope to restrict. Applying one is not a stricter version of this
|
||||
// query - it is a broken one. DataScopeSelf matches on create_by, and a user
|
||||
// account is created by whoever added it, so a scoped self-read would fail for
|
||||
// every user who did not create their own account.
|
||||
//
|
||||
// GetProfile has always read the same row this way, with no scope at all.
|
||||
func (e *SysUser) GetSelf(d *dto.SysUserById, model *models.SysUser) error {
|
||||
err := e.Orm.First(model, d.GetId()).Error
|
||||
if err != nil && errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
err = errors.New("查看对象不存在或无权查看")
|
||||
e.Log.Errorf("db error: %s", err)
|
||||
return err
|
||||
}
|
||||
if err != nil {
|
||||
e.Log.Errorf("db error: %s", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get 获取SysUser对象
|
||||
func (e *SysUser) Get(d *dto.SysUserById, p *actions.DataPermission, model *models.SysUser) error {
|
||||
var data models.SysUser
|
||||
|
||||
+29
-3
@@ -1,6 +1,7 @@
|
||||
package jobs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
log "github.com/go-admin-team/go-admin-core/v2/logger"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk"
|
||||
@@ -145,11 +146,36 @@ func setup(key string, db *gorm.DB) {
|
||||
}
|
||||
|
||||
// 其中任务
|
||||
crontab.Start()
|
||||
startCrontab(crontab)
|
||||
}
|
||||
|
||||
// startCrontab starts c and arranges for it to be stopped on the way out.
|
||||
//
|
||||
// The stop used to be `defer crontab.Stop()` followed by `select {}`. The
|
||||
// select never returned, so the defer never ran and the scheduler was never
|
||||
// stopped; and because setup never returned, the loop in Setup never reached
|
||||
// the second tenant - only whichever database came first out of the map ever
|
||||
// got a scheduler at all. cron.Start is itself `go c.run()`, so the select was
|
||||
// blocking for nothing.
|
||||
//
|
||||
// cron.Stop returns a context that closes once the jobs already running have
|
||||
// finished. That is the wait the shutdown budget exists to bound: giving up on
|
||||
// it leaves those jobs running until the process exits, which is better than
|
||||
// holding the whole shutdown open for one job that will not end.
|
||||
func startCrontab(c *cron.Cron) {
|
||||
c.Start()
|
||||
fmt.Println(time.Now().Format(timeFormat), " [INFO] JobCore start success.")
|
||||
|
||||
// 关闭任务
|
||||
defer crontab.Stop()
|
||||
select {}
|
||||
sdk.Runtime.SetShutdown(func(ctx context.Context) {
|
||||
stopped := c.Stop()
|
||||
select {
|
||||
case <-stopped.Done():
|
||||
fmt.Println(time.Now().Format(timeFormat), " [INFO] JobCore stopped.")
|
||||
case <-ctx.Done():
|
||||
fmt.Println(time.Now().Format(timeFormat), " [WARN] JobCore stop gave up waiting for running jobs")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// AddJob 添加任务 AddJob(invokeTarget string, jobId int, jobName string, cronExpression string)
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package jobs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg/cronjob"
|
||||
)
|
||||
|
||||
// The scheduler had never been stopped. `defer crontab.Stop()` sat directly
|
||||
// above a `select {}` that never returned, so the deferred call was
|
||||
// unreachable for the life of the process.
|
||||
//
|
||||
// There is one test rather than several because BeforeExit closes to further
|
||||
// registration once it has run: a second RunShutdown in this binary would find
|
||||
// an empty registry and pass while proving nothing.
|
||||
func TestTheSchedulerIsStoppedOnTheWayOut(t *testing.T) {
|
||||
var ticks atomic.Int64
|
||||
|
||||
c := cronjob.NewWithSeconds()
|
||||
if _, err := c.AddFunc("* * * * * *", func() { ticks.Add(1) }); err != nil {
|
||||
t.Fatalf("AddFunc: %v", err)
|
||||
}
|
||||
|
||||
startCrontab(c)
|
||||
|
||||
// It has to be running before stopping it can mean anything.
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for ticks.Load() == 0 && time.Now().Before(deadline) {
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
if ticks.Load() == 0 {
|
||||
t.Fatal("the scheduler never ran the job, so this test cannot show it was stopped")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
if err := sdk.Runtime.RunShutdown(ctx); err != nil {
|
||||
t.Fatalf("RunShutdown: %v", err)
|
||||
}
|
||||
|
||||
// Two and a half seconds is two more firings of a job that runs every
|
||||
// second, so silence here is the assertion.
|
||||
at := ticks.Load()
|
||||
time.Sleep(2500 * time.Millisecond)
|
||||
if n := ticks.Load() - at; n > 0 {
|
||||
t.Errorf("the job fired %d more times after shutdown: the scheduler is still running", n)
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,63 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-admin-team/go-admin-core/v2/tools/transfer"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
|
||||
"go-admin/common/health"
|
||||
)
|
||||
|
||||
func init() {
|
||||
routerNoCheckRole = append(routerNoCheckRole, registerMonitorRouter)
|
||||
}
|
||||
|
||||
// 需认证的路由代码
|
||||
// readyTimeout bounds the whole probe. It has to stay under whatever period
|
||||
// the orchestrator polls on, or a slow dependency turns a readiness check into
|
||||
// a queue of readiness checks.
|
||||
const readyTimeout = 2 * time.Second
|
||||
|
||||
// 无需认证的路由代码
|
||||
func registerMonitorRouter(v1 *gin.RouterGroup) {
|
||||
v1.GET("/metrics", transfer.Handler(promhttp.Handler()))
|
||||
//健康检查
|
||||
|
||||
// 健康检查(存活)
|
||||
//
|
||||
// Stays a bare 200 on purpose. This is the answer to "should I restart
|
||||
// you", and a process whose database is unreachable does not want
|
||||
// restarting - that turns one outage into a crash loop and throws away the
|
||||
// connection pool, the cache and every in-flight request along the way.
|
||||
v1.GET("/health", func(c *gin.Context) {
|
||||
c.Status(http.StatusOK)
|
||||
})
|
||||
|
||||
}
|
||||
// 就绪检查
|
||||
//
|
||||
// The answer to "should I send you requests". It fails while a dependency
|
||||
// is unreachable, and from the moment shutdown begins - which is before
|
||||
// the server stops accepting, so a load balancer can take this instance
|
||||
// out of the pool while it can still finish what it has.
|
||||
v1.GET("/ready", func(c *gin.Context) {
|
||||
if health.Draining() {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||
"status": "draining",
|
||||
"checks": []health.Check{},
|
||||
})
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), readyTimeout)
|
||||
defer cancel()
|
||||
|
||||
checks := health.Ready(ctx)
|
||||
status := http.StatusOK
|
||||
if !health.Healthy(checks) {
|
||||
status = http.StatusServiceUnavailable
|
||||
}
|
||||
c.JSON(status, gin.H{"status": http.StatusText(status), "checks": checks})
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/runtime"
|
||||
)
|
||||
|
||||
// freePort returns a port nothing is listening on. It is inherently a guess -
|
||||
// the port is free when it is handed back and could be taken a moment later -
|
||||
// but every alternative needs the caller to hold the listener, which is the one
|
||||
// thing these tests cannot do.
|
||||
func freePort(t *testing.T) int {
|
||||
t.Helper()
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("probe listen: %v", err)
|
||||
}
|
||||
port := ln.Addr().(*net.TCPAddr).Port
|
||||
_ = ln.Close()
|
||||
return port
|
||||
}
|
||||
|
||||
// AfterListen promises a hook that the port is reachable. Both halves of that
|
||||
// are asserted here, and in one test rather than two, because the phase seals
|
||||
// itself once it has run: a second test calling RunPhase again would find a
|
||||
// closed registry and pass while proving nothing.
|
||||
//
|
||||
// The failing bind comes first for the same reason. It must leave the phase
|
||||
// unsealed, which is only visible if nothing has sealed it yet.
|
||||
func TestAfterListenIsAnnouncedOnlyOnceThePortIsBound(t *testing.T) {
|
||||
// The pause makes the "announced synchronously" claim testable: if the
|
||||
// announcement were moved onto a goroutine, startServing would return
|
||||
// while the hook was still sleeping and the count below would be zero.
|
||||
var ran int
|
||||
sdk.Runtime.SetPhase(runtime.AfterListen, func() {
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
ran++
|
||||
})
|
||||
|
||||
// Somebody else already has the port. Under ListenAndServe this surfaced
|
||||
// on the serving goroutine, far too late to stop the announcement.
|
||||
taken, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("occupy: %v", err)
|
||||
}
|
||||
defer func() { _ = taken.Close() }()
|
||||
|
||||
blocked := &http.Server{Addr: taken.Addr().String(), Handler: http.NewServeMux()}
|
||||
if err := startServing(blocked, false, "", ""); err == nil {
|
||||
t.Fatal("startServing returned no error for a port that was already taken")
|
||||
}
|
||||
if ran != 0 {
|
||||
t.Errorf("AfterListen ran %d times after a failed bind; a hook there is told the port is reachable", ran)
|
||||
}
|
||||
if sdk.Runtime.PhaseSealed(runtime.AfterListen) {
|
||||
t.Error("a failed bind sealed AfterListen, so the phase could never run for a server that did start")
|
||||
}
|
||||
|
||||
// A certificate that cannot be read is the other way to fail before there
|
||||
// is anything to announce. ServeTLS reads it on the serving goroutine, so
|
||||
// without the check in startServing this would be a hook told the port was
|
||||
// reachable while the server was already on its way down.
|
||||
if err := startServing(&http.Server{Addr: "127.0.0.1:0"}, true, "no-such.pem", "no-such.key"); err == nil {
|
||||
t.Fatal("startServing returned no error for a certificate that does not exist")
|
||||
}
|
||||
if ran != 0 {
|
||||
t.Errorf("AfterListen ran %d times after a certificate failure", ran)
|
||||
}
|
||||
if sdk.Runtime.PhaseSealed(runtime.AfterListen) {
|
||||
t.Error("a certificate failure sealed AfterListen")
|
||||
}
|
||||
|
||||
// And now a bind that works.
|
||||
port := freePort(t)
|
||||
srv := &http.Server{Addr: fmt.Sprintf("127.0.0.1:%d", port), Handler: http.NewServeMux()}
|
||||
if err := startServing(srv, false, "", ""); err != nil {
|
||||
t.Fatalf("startServing on a free port: %v", err)
|
||||
}
|
||||
defer func() { _ = srv.Close() }()
|
||||
|
||||
// Checked the instant startServing returns, so this is also the assertion
|
||||
// that it did not return early: an asynchronous announcement would still
|
||||
// be inside the sleep. Synchrony matters because an announcement that
|
||||
// overlaps the wait below could, on a fast SIGTERM, have the shutdown
|
||||
// callbacks finish before the startup ones.
|
||||
if ran != 1 {
|
||||
t.Fatalf("AfterListen ran %d times, want 1", ran)
|
||||
}
|
||||
|
||||
// The claim is not "Serve was called" but "the port answers". Dial it.
|
||||
c, err := net.DialTimeout("tcp", srv.Addr, 5*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("AfterListen ran but the port does not answer: %v", err)
|
||||
}
|
||||
_ = c.Close()
|
||||
}
|
||||
|
||||
// BeforeRouter is the last point at which a module can still affect how routes
|
||||
// are built, so it has to run while there is no engine yet. The before registry
|
||||
// is a different moment despite the name: those callbacks run after initRouter
|
||||
// has built the engine.
|
||||
//
|
||||
// The two are two lines apart in buildRouter, and calling them equivalent is a
|
||||
// mistake this repository has already made in writing. Until this test the
|
||||
// ordering was checked by reading - which is how the stop signals came to be
|
||||
// armed after the readiness banner in the same file.
|
||||
func TestBeforeRouterRunsWhileThereIsNoEngine(t *testing.T) {
|
||||
freshRuntime(t)
|
||||
|
||||
// AuthInit reads these two package-level values and nothing else. No
|
||||
// database is involved in building a router: the handlers are registered,
|
||||
// not called.
|
||||
config.ApplicationConfig.Mode = "dev"
|
||||
config.JwtConfig.Secret = "test-secret-for-the-router-build"
|
||||
|
||||
type observation struct {
|
||||
ran int
|
||||
engineWas interface{}
|
||||
engineSeen bool
|
||||
}
|
||||
var phase, before observation
|
||||
|
||||
sdk.Runtime.SetPhase(runtime.BeforeRouter, func() {
|
||||
phase.ran++
|
||||
phase.engineWas = sdk.Runtime.GetEngine()
|
||||
phase.engineSeen = true
|
||||
})
|
||||
sdk.Runtime.SetBefore(func() {
|
||||
before.ran++
|
||||
before.engineWas = sdk.Runtime.GetEngine()
|
||||
before.engineSeen = true
|
||||
})
|
||||
|
||||
buildRouter()
|
||||
|
||||
if phase.ran != 1 {
|
||||
t.Fatalf("BeforeRouter ran %d times, want 1", phase.ran)
|
||||
}
|
||||
if !phase.engineSeen || phase.engineWas != nil {
|
||||
t.Errorf("BeforeRouter saw engine %v, want nil: it is meant to run before initRouter builds one", phase.engineWas)
|
||||
}
|
||||
|
||||
if before.ran != 1 {
|
||||
t.Fatalf("the before registry ran %d times, want 1", before.ran)
|
||||
}
|
||||
if before.engineWas == nil {
|
||||
t.Error("a before callback saw no engine; that registry is meant to run after initRouter, and describing it as equivalent to BeforeRouter is the error this asserts against")
|
||||
}
|
||||
|
||||
if sdk.Runtime.GetEngine() == nil {
|
||||
t.Error("buildRouter returned with no engine built")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
corestorage "github.com/go-admin-team/go-admin-core/v2/storage"
|
||||
)
|
||||
|
||||
// recordingQueue records what was done to it, in order. Register and Run are
|
||||
// the two calls whose order is the point of this file; Append and Shutdown are
|
||||
// here to satisfy the interface.
|
||||
type recordingQueue struct {
|
||||
mu sync.Mutex
|
||||
events []string
|
||||
ran chan struct{}
|
||||
}
|
||||
|
||||
func newRecordingQueue() *recordingQueue {
|
||||
return &recordingQueue{ran: make(chan struct{}, 4)}
|
||||
}
|
||||
|
||||
func (q *recordingQueue) record(e string) {
|
||||
q.mu.Lock()
|
||||
q.events = append(q.events, e)
|
||||
q.mu.Unlock()
|
||||
}
|
||||
|
||||
func (q *recordingQueue) seen() []string {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
return append([]string(nil), q.events...)
|
||||
}
|
||||
|
||||
func (q *recordingQueue) String() string { return "recording" }
|
||||
func (q *recordingQueue) Append(corestorage.Messager) error { return nil }
|
||||
func (q *recordingQueue) Register(name string, _ corestorage.ConsumerFunc) {
|
||||
q.record("register:" + name)
|
||||
}
|
||||
func (q *recordingQueue) Shutdown() {}
|
||||
|
||||
func (q *recordingQueue) Run() {
|
||||
q.record("run")
|
||||
select {
|
||||
case q.ran <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// waitForRun waits for Run, which is started on a goroutine.
|
||||
func (q *recordingQueue) waitForRun(t *testing.T) {
|
||||
t.Helper()
|
||||
select {
|
||||
case <-q.ran:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatalf("Run was never called; saw: %v", q.seen())
|
||||
}
|
||||
}
|
||||
|
||||
// The consumers must be registered before the queue is started. A queue that
|
||||
// is already running refuses further registration - the contract
|
||||
// implementations answer storage.ErrQueueAlreadyStarted - and the legacy
|
||||
// adapter this path goes through drops that error, so the wrong order loses
|
||||
// consumers with nothing said about it. The memory backend does not care,
|
||||
// which is exactly why this cannot be left to be noticed in use.
|
||||
func TestConsumersAreRegisteredBeforeTheQueueIsStarted(t *testing.T) {
|
||||
attachedQueue.Store(0)
|
||||
t.Cleanup(func() { attachedQueue.Store(0) })
|
||||
|
||||
q := newRecordingQueue()
|
||||
attachConsumersOnce(1, q)
|
||||
q.waitForRun(t)
|
||||
|
||||
seen := q.seen()
|
||||
runAt := -1
|
||||
registers := 0
|
||||
for i, e := range seen {
|
||||
switch {
|
||||
case e == "run":
|
||||
if runAt < 0 {
|
||||
runAt = i
|
||||
}
|
||||
case strings.HasPrefix(e, "register:"):
|
||||
registers++
|
||||
if runAt >= 0 {
|
||||
t.Errorf("%q came after Run; a running queue refuses registration", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
if registers != 3 {
|
||||
t.Errorf("registered %d consumers, want 3; saw %v", registers, seen)
|
||||
}
|
||||
if runAt < 0 {
|
||||
t.Errorf("the queue was never started; saw %v", seen)
|
||||
}
|
||||
}
|
||||
|
||||
// AfterResource runs again on every configuration reload, so the hook has to
|
||||
// be idempotent with respect to a given queue - not "does nothing the second
|
||||
// time". Registering twice on the same queue would give every message two
|
||||
// consumers and write every log row twice.
|
||||
func TestTheSameQueueIsNotGivenConsumersTwice(t *testing.T) {
|
||||
attachedQueue.Store(0)
|
||||
t.Cleanup(func() { attachedQueue.Store(0) })
|
||||
|
||||
q := newRecordingQueue()
|
||||
attachConsumersOnce(1, q)
|
||||
q.waitForRun(t)
|
||||
attachConsumersOnce(1, q)
|
||||
|
||||
// Nothing to wait for on the second call, so give a wrong implementation
|
||||
// the time it would need to show up.
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
if n := len(q.seen()); n != 4 {
|
||||
t.Errorf("%d calls after attaching twice to the same queue, want 4 (3 registers + 1 run); saw %v", n, q.seen())
|
||||
}
|
||||
}
|
||||
|
||||
// The other half of the same rule: a reload builds a new adapter, and the
|
||||
// consumers on the old one are attached to a queue nobody publishes to any
|
||||
// more. A new generation must get its own set.
|
||||
func TestANewQueueGetsItsOwnConsumers(t *testing.T) {
|
||||
attachedQueue.Store(0)
|
||||
t.Cleanup(func() { attachedQueue.Store(0) })
|
||||
|
||||
first := newRecordingQueue()
|
||||
attachConsumersOnce(1, first)
|
||||
first.waitForRun(t)
|
||||
|
||||
second := newRecordingQueue()
|
||||
attachConsumersOnce(2, second)
|
||||
second.waitForRun(t)
|
||||
|
||||
if n := len(second.seen()); n != 4 {
|
||||
t.Errorf("the queue from the second generation saw %d calls, want 4; saw %v", n, second.seen())
|
||||
}
|
||||
if n := len(first.seen()); n != 4 {
|
||||
t.Errorf("the queue from the first generation saw %d calls, want 4 - it should not have been touched again; saw %v", n, first.seen())
|
||||
}
|
||||
}
|
||||
|
||||
// Generation 0 means the configuration has no queue section at all, so nothing
|
||||
// was installed and the runtime hands back its own memory queue. That case
|
||||
// still has to get consumers - the registration it replaces was unconditional,
|
||||
// and dropping it would stop the login and operation logs for anyone who
|
||||
// commented the section out.
|
||||
func TestAnUnconfiguredQueueStillGetsConsumers(t *testing.T) {
|
||||
attachedQueue.Store(0)
|
||||
t.Cleanup(func() { attachedQueue.Store(0) })
|
||||
|
||||
q := newRecordingQueue()
|
||||
attachConsumersOnce(0, q)
|
||||
q.waitForRun(t)
|
||||
|
||||
if n := len(q.seen()); n != 4 {
|
||||
t.Errorf("an unconfigured queue saw %d calls, want 4; saw %v", n, q.seen())
|
||||
}
|
||||
|
||||
// And still only once.
|
||||
attachConsumersOnce(0, q)
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
if n := len(q.seen()); n != 4 {
|
||||
t.Errorf("generation 0 was attached to twice: %d calls, want 4; saw %v", n, q.seen())
|
||||
}
|
||||
}
|
||||
+258
-37
@@ -2,10 +2,14 @@ package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -13,8 +17,11 @@ import (
|
||||
log "github.com/go-admin-team/go-admin-core/v2/logger"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/api"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/bootstrap"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/runtime"
|
||||
corestorage "github.com/go-admin-team/go-admin-core/v2/storage"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
@@ -23,6 +30,7 @@ import (
|
||||
"go-admin/app/jobs"
|
||||
"go-admin/common/database"
|
||||
"go-admin/common/global"
|
||||
"go-admin/common/health"
|
||||
common "go-admin/common/middleware"
|
||||
"go-admin/common/middleware/handler"
|
||||
"go-admin/common/storage"
|
||||
@@ -59,44 +67,109 @@ func init() {
|
||||
func setup() {
|
||||
// 注入配置扩展项
|
||||
config.ExtendConfig = &ext.ExtConfig
|
||||
|
||||
// Registered before the configuration is read. SetupConfig announces
|
||||
// AfterResource as soon as the callbacks that build the resources have
|
||||
// run, so a hook added after that call would miss the first round and the
|
||||
// queue would have no consumers until somebody edited the config file.
|
||||
sdk.Runtime.SetPhase(runtime.AfterResource, attachQueueConsumers)
|
||||
|
||||
// On AfterListen rather than on a bare goroutine from run(). Two reasons:
|
||||
// the phase runs behind core's panic guard, which does not reach across a
|
||||
// goroutine boundary - a panic while loading jobs used to take the whole
|
||||
// process down with a stack that named this file - and the jobs it starts
|
||||
// can call the API, which is only true once the socket is accepting.
|
||||
sdk.Runtime.SetPhase(runtime.AfterListen, startCronJobs)
|
||||
|
||||
//1. 读取配置
|
||||
config.Setup(
|
||||
bootstrap.SetupConfig(
|
||||
file.NewSource(file.WithPath(configYml)),
|
||||
database.Setup,
|
||||
storage.Setup,
|
||||
)
|
||||
//注册监听函数
|
||||
queue := sdk.Runtime.GetQueuePrefix("")
|
||||
queue.Register(global.LoginLog, models.SaveLoginLog)
|
||||
queue.Register(global.OperateLog, models.SaveOperaLog)
|
||||
queue.Register(global.ApiCheck, models.SaveSysApi)
|
||||
go queue.Run()
|
||||
|
||||
usageStr := `starting api server...`
|
||||
log.Info(usageStr)
|
||||
}
|
||||
|
||||
// startCronJobs registers the job implementations and starts a scheduler for
|
||||
// every tenant database.
|
||||
//
|
||||
// It is synchronous, like the phase that runs it. jobs.Setup returns now that
|
||||
// the `select {}` at the end of its per-tenant setup is gone, which is what
|
||||
// makes that possible; while it was there this could only be a goroutine, and
|
||||
// a goroutine is outside the panic guard.
|
||||
func startCronJobs() {
|
||||
jobs.InitJob()
|
||||
jobs.Setup(sdk.Runtime.GetAllDb())
|
||||
}
|
||||
|
||||
// attachedQueue is the queue generation the consumers are attached to, plus
|
||||
// one, so that the zero value means "attached to nothing yet". Written from
|
||||
// the goroutine running the phase, read from the next one - rounds never
|
||||
// overlap, but they are not the same goroutine.
|
||||
var attachedQueue atomic.Uint64
|
||||
|
||||
// attachQueueConsumers registers the log consumers against the queue that is
|
||||
// current, and starts it.
|
||||
//
|
||||
// It runs on AfterResource, so it runs again after every configuration reload
|
||||
// - and it has to. A reload rebuilds the queue adapter, and consumers
|
||||
// registered against the one that existed at start-up are attached to an
|
||||
// adapter nobody publishes to any more, so the login and operation logs stop
|
||||
// being written with nothing said about it.
|
||||
//
|
||||
// It is therefore idempotent with respect to a given queue rather than "does
|
||||
// nothing the second time": a new adapter gets a fresh set of consumers, the
|
||||
// same one gets none. Registering twice on the same queue would give every
|
||||
// message two consumers and write every log row twice.
|
||||
//
|
||||
// Generation 0 means the configuration has no queue section, so nothing was
|
||||
// installed and GetQueuePrefix hands back the runtime's own memory queue.
|
||||
// That case still gets consumers - it is what the previous unconditional
|
||||
// registration did, and dropping it would silently stop logging for anyone who
|
||||
// commented the section out - it just never gets them twice.
|
||||
func attachQueueConsumers() {
|
||||
attachConsumersOnce(storage.QueueGeneration(), sdk.Runtime.GetQueuePrefix(""))
|
||||
}
|
||||
|
||||
// attachConsumersOnce puts the log consumers on q and starts it, unless gen
|
||||
// says this queue already has them.
|
||||
//
|
||||
// Split out from attachQueueConsumers so that the order and the once-ness can
|
||||
// be checked against a queue the test controls: the sequence that matters here
|
||||
// cannot be read back out of a real adapter.
|
||||
func attachConsumersOnce(gen uint64, q corestorage.AdapterQueue) {
|
||||
if attachedQueue.Load() == gen+1 {
|
||||
return
|
||||
}
|
||||
attachedQueue.Store(gen + 1)
|
||||
|
||||
//注册监听函数
|
||||
q.Register(global.LoginLog, models.SaveLoginLog)
|
||||
q.Register(global.OperateLog, models.SaveOperaLog)
|
||||
q.Register(global.ApiCheck, models.SaveSysApi)
|
||||
|
||||
// Started only now, and by whoever registered. setupQueue deliberately
|
||||
// leaves it stopped: a queue that is already running refuses further
|
||||
// registration, and the adapter in this path drops that error on the
|
||||
// floor, so starting first loses consumers without a word.
|
||||
go q.Run()
|
||||
}
|
||||
|
||||
func run() error {
|
||||
if config.ApplicationConfig.Mode == pkg.ModeProd.String() {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
}
|
||||
initRouter()
|
||||
|
||||
runStartupHooks()
|
||||
buildRouter()
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: fmt.Sprintf("%s:%d", config.ApplicationConfig.Host, config.ApplicationConfig.Port),
|
||||
Handler: sdk.Runtime.GetEngine(),
|
||||
Addr: fmt.Sprintf("%s:%d", config.ApplicationConfig.Host, config.ApplicationConfig.Port),
|
||||
Handler: sdk.Runtime.GetEngine(),
|
||||
ReadTimeout: time.Duration(config.ApplicationConfig.ReadTimeout) * time.Second,
|
||||
WriteTimeout: time.Duration(config.ApplicationConfig.WriterTimeout) * time.Second,
|
||||
}
|
||||
|
||||
go func() {
|
||||
jobs.InitJob()
|
||||
jobs.Setup(sdk.Runtime.GetAllDb())
|
||||
|
||||
}()
|
||||
|
||||
if apiCheck {
|
||||
var routers = sdk.Runtime.GetRouter()
|
||||
q := sdk.Runtime.GetQueuePrefix("")
|
||||
@@ -114,18 +187,17 @@ func run() error {
|
||||
}
|
||||
}
|
||||
|
||||
go func() {
|
||||
// 服务连接
|
||||
if config.SslConfig.Enable {
|
||||
if err := srv.ListenAndServeTLS(config.SslConfig.Pem, config.SslConfig.KeyStr); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Fatal("listen: ", err)
|
||||
}
|
||||
} else {
|
||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Fatal("listen: ", err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
// Armed before the server starts serving, and well before the readiness
|
||||
// banner: a signal arriving between "the process is up" and "the process
|
||||
// is listening for signals" reaches the default handler and kills it
|
||||
// without any of the shutdown below. That window is the whole reason
|
||||
// arming is separate from waiting.
|
||||
quit, disarmStopSignals := armStopSignals()
|
||||
|
||||
if err := startServing(srv, config.SslConfig.Enable, config.SslConfig.Pem, config.SslConfig.KeyStr); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println(pkg.Red(string(global.LogoContent)))
|
||||
tip()
|
||||
fmt.Println(pkg.Green("Server run at:"))
|
||||
@@ -135,23 +207,172 @@ func run() error {
|
||||
fmt.Printf("- Local: http://localhost:%d/swagger/admin/index.html \r\n", config.ApplicationConfig.Port)
|
||||
fmt.Printf("- Network: %s://%s:%d/swagger/admin/index.html \r\n", "http", pkg.GetLocalHost(), config.ApplicationConfig.Port)
|
||||
fmt.Printf("%s Enter Control + C Shutdown Server \r\n", pkg.GetCurrentTimeStr())
|
||||
// 等待中断信号以优雅地关闭服务器(设置 5 秒的超时时间)
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, os.Interrupt)
|
||||
|
||||
<-quit
|
||||
// Restored here, not deferred: from this point a second signal must reach
|
||||
// the default handler, so a shutdown that hangs can still be interrupted.
|
||||
disarmStopSignals()
|
||||
|
||||
// Said before anything is taken apart. A configuration reload arriving in
|
||||
// this window would otherwise re-run AfterResource - rebuilding the pool
|
||||
// and the queue adapter, and re-registering consumers - on top of cleanup
|
||||
// that has already run.
|
||||
sdk.Runtime.BeginShutdown()
|
||||
// Readiness fails from here, which is before the server stops accepting.
|
||||
// The order is the whole point: a load balancer that is told "not ready"
|
||||
// while this instance can still finish what it has in flight takes it out
|
||||
// of the pool without dropping anything. Reversed, the connections are cut
|
||||
// first and the health check reports it afterwards.
|
||||
health.BeginDraining()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
log.Info("Shutdown Server ... ")
|
||||
if err := shutdownServer(srv, shutdownTimeout); err != nil {
|
||||
// Not log.Fatal: that is an unconditional os.Exit(1), and Shutdown
|
||||
// reports an error exactly when connections were still in flight -
|
||||
// which is when the cleanup that follows matters most.
|
||||
log.Error("Server Shutdown: ", err)
|
||||
}
|
||||
|
||||
if err := srv.Shutdown(ctx); err != nil {
|
||||
log.Fatal("Server Shutdown:", err)
|
||||
// Runs whether or not the line above reported an error, for that reason.
|
||||
if err := runShutdownHooks(cleanupTimeout); err != nil {
|
||||
log.Error("Cleanup: ", err)
|
||||
}
|
||||
log.Info("Server exiting")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// shutdownTimeout is how long Shutdown waits for in-flight requests, and
|
||||
// cleanupTimeout how long the BeforeExit callbacks get after it.
|
||||
//
|
||||
// They are consumed one after the other, so the two together are what has to
|
||||
// stay inside the orchestrator's grace period: `docker stop` allows 10s by
|
||||
// default before it sends SIGKILL, and 5+3 leaves room for the process to
|
||||
// finish returning. Raising either without lowering the other buys nothing -
|
||||
// the budget that runs out is the orchestrator's.
|
||||
const (
|
||||
shutdownTimeout = 5 * time.Second
|
||||
cleanupTimeout = 3 * time.Second
|
||||
)
|
||||
|
||||
// armStopSignals registers for the stop signals and returns the channel they
|
||||
// arrive on together with the function that restores the default disposition.
|
||||
//
|
||||
// SIGTERM is what actually arrives in production: `docker stop`, a Kubernetes
|
||||
// pod deletion and `systemctl stop` all send it, and Go terminates the process
|
||||
// immediately for a signal nobody listens for. Registering only os.Interrupt
|
||||
// meant every graceful shutdown below the wait was dead code outside a
|
||||
// terminal.
|
||||
//
|
||||
// Registering is separate from waiting so a caller can arm before it announces
|
||||
// that it is ready: a signal that arrives between the two is delivered to the
|
||||
// default handler, which for both of these means the process dies without
|
||||
// running any of this.
|
||||
func armStopSignals() (<-chan os.Signal, func()) {
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
|
||||
return quit, func() { signal.Stop(quit) }
|
||||
}
|
||||
|
||||
// startServing binds srv.Addr, hands the listener to srv on its own goroutine,
|
||||
// and announces AfterListen.
|
||||
//
|
||||
// The bind is done here rather than left to ListenAndServe, which binds on the
|
||||
// goroutine that serves. That put the failure every deployment actually hits -
|
||||
// "address already in use" - on a goroutine nobody was reading, so the banner
|
||||
// went on to claim the server was up, and there would be no way to keep
|
||||
// AfterListen from announcing a socket that does not exist. A hook there is
|
||||
// promised a reachable port; the only way to keep that promise is for the bind
|
||||
// to have already happened on this goroutine.
|
||||
//
|
||||
// AfterListen is announced synchronously. Running it in a goroutine to save the
|
||||
// few milliseconds would let it overlap the shutdown: on a fast SIGTERM the
|
||||
// cleanup callbacks could finish before the startup ones had.
|
||||
//
|
||||
// Both ways of failing to start are therefore checked before the announcement:
|
||||
// the bind, and - with ssl enabled - the certificate.
|
||||
func startServing(srv *http.Server, useTLS bool, pem, key string) error {
|
||||
if useTLS {
|
||||
// Read the certificate before anything is announced. ServeTLS reads
|
||||
// these files itself, but on the serving goroutine - so a bad
|
||||
// certificate used to surface after AfterListen had already promised a
|
||||
// reachable port. Loading it here costs one extra read and moves the
|
||||
// failure onto this goroutine, where run() can return it.
|
||||
//
|
||||
// ServeTLS still does the real work below rather than this handing it a
|
||||
// tls.Listener: that is what sets up HTTP/2 negotiation, and taking it
|
||||
// over here would quietly drop h2 for every TLS deployment.
|
||||
if _, err := tls.LoadX509KeyPair(pem, key); err != nil {
|
||||
return errors.Wrap(err, "tls certificate")
|
||||
}
|
||||
}
|
||||
|
||||
ln, err := net.Listen("tcp", srv.Addr)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "listen")
|
||||
}
|
||||
|
||||
go func() {
|
||||
// 服务连接
|
||||
var err error
|
||||
if useTLS {
|
||||
err = srv.ServeTLS(ln, pem, key)
|
||||
} else {
|
||||
err = srv.Serve(ln)
|
||||
}
|
||||
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
// Still fatal, as it was. Neither the bind nor the certificate is
|
||||
// among the errors that reach here any more - both are checked
|
||||
// above, on the caller's goroutine. What is left is a serve that
|
||||
// failed after the port was taken, and carrying on would park the
|
||||
// process on <-quit with nothing serving.
|
||||
log.Fatal("serve: ", err)
|
||||
}
|
||||
}()
|
||||
|
||||
sdk.Runtime.RunPhase(runtime.AfterListen)
|
||||
return nil
|
||||
}
|
||||
|
||||
// shutdownServer stops srv, giving in-flight requests up to timeout to finish.
|
||||
//
|
||||
// It returns the error instead of exiting on it. A caller that exits here skips
|
||||
// its own cleanup, and Shutdown fails precisely when there was something left
|
||||
// to clean up after.
|
||||
func shutdownServer(srv *http.Server, timeout time.Duration) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
return srv.Shutdown(ctx)
|
||||
}
|
||||
|
||||
// runShutdownHooks runs the BeforeExit callbacks with timeout to share.
|
||||
//
|
||||
// What the budget bounds is the wait, not the work. When it is gone RunShutdown
|
||||
// stops waiting and returns; a callback that never looks at its context carries
|
||||
// on until the process exits, and may leave a partial write behind. Go cannot
|
||||
// cancel a function that does not check for cancellation, which is why the
|
||||
// callbacks are handed a context at all.
|
||||
func runShutdownHooks(timeout time.Duration) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
return sdk.Runtime.RunShutdown(ctx)
|
||||
}
|
||||
|
||||
// buildRouter announces BeforeRouter, builds the engine, and then drains the
|
||||
// startup registries.
|
||||
//
|
||||
// The order is the contract. BeforeRouter is the last point at which a module
|
||||
// can still affect how routes are built, so it has to run while there is no
|
||||
// engine yet. The before registry runStartupHooks drains is a different moment
|
||||
// despite the name: those callbacks run after initRouter has built the engine.
|
||||
// Two lines apart, and describing them as equivalent is a mistake this
|
||||
// repository has already made once in writing.
|
||||
func buildRouter() {
|
||||
sdk.Runtime.RunPhase(runtime.BeforeRouter)
|
||||
initRouter()
|
||||
runStartupHooks()
|
||||
}
|
||||
|
||||
// runStartupHooks runs the router registries and then the before callbacks.
|
||||
//
|
||||
// The package-level slice runs first and in its existing order, so a fork that
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk"
|
||||
)
|
||||
|
||||
// The signal path cannot be exercised in-process: delivering a signal to the
|
||||
// test binary would race with the test framework, and the disposition changes
|
||||
// are global. So the test re-executes itself as a child, and the child runs the
|
||||
// same armStopSignals / shutdownServer the server does.
|
||||
//
|
||||
// The child deliberately serves an empty http.Server rather than the real one:
|
||||
// this repository's CI has no database (.github/workflows/go.yml runs neither
|
||||
// MySQL nor a sqlite-tagged build), and none of what is under test needs one.
|
||||
const (
|
||||
childEnv = "GO_ADMIN_SIGNAL_CHILD"
|
||||
childStuckEnv = "GO_ADMIN_SIGNAL_CHILD_STUCK"
|
||||
childHangConn = "GO_ADMIN_SIGNAL_CHILD_HANGCONN"
|
||||
childSlowCleanup = "GO_ADMIN_SIGNAL_CHILD_SLOWCLEANUP"
|
||||
markerReady = "CHILD-READY"
|
||||
markerSignal = "CHILD-SIGNAL"
|
||||
markerShutdown = "CHILD-SHUTDOWN-OK"
|
||||
markerCleanup = "CHILD-CLEANUP-RAN"
|
||||
markerExiting = "CHILD-EXITING"
|
||||
)
|
||||
|
||||
// TestSignalChild is the child process. It is skipped in a normal run.
|
||||
func TestSignalChild(t *testing.T) {
|
||||
if os.Getenv(childEnv) != "1" {
|
||||
t.Skip("child process entry point")
|
||||
}
|
||||
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
fmt.Println("listen:", err)
|
||||
os.Exit(3)
|
||||
}
|
||||
// accepted fires once the server has taken a connection off the listener.
|
||||
// Dialling is not enough: Shutdown only waits for connections the server
|
||||
// has already accepted, so calling it between the dial and the accept
|
||||
// finds nothing to wait for and returns immediately.
|
||||
accepted := make(chan struct{}, 1)
|
||||
srv := &http.Server{
|
||||
Handler: http.NewServeMux(),
|
||||
ConnState: func(_ net.Conn, state http.ConnState) {
|
||||
if state == http.StateNew {
|
||||
select {
|
||||
case accepted <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
go func() { _ = srv.Serve(ln) }()
|
||||
|
||||
// A BeforeExit callback, registered the way a module would. What the tests
|
||||
// below care about is whether it runs at all - after a Shutdown that
|
||||
// failed, and after its own budget has been spent.
|
||||
cleanupBudget := cleanupTimeout
|
||||
sdk.Runtime.SetShutdown(func(ctx context.Context) {
|
||||
if os.Getenv(childSlowCleanup) == "1" {
|
||||
// Outlasts the budget on purpose, and does not consult ctx -
|
||||
// which is the case the contract is explicit about: what the
|
||||
// context bounds is the wait, not the work.
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
fmt.Println(markerCleanup)
|
||||
os.Stdout.Sync()
|
||||
})
|
||||
if os.Getenv(childSlowCleanup) == "1" {
|
||||
cleanupBudget = 300 * time.Millisecond
|
||||
}
|
||||
|
||||
// Arm before announcing readiness. Doing it the other way round leaves a
|
||||
// window in which the parent's signal reaches the default handler and
|
||||
// kills the child before any of this runs - which is exactly the failure
|
||||
// this whole change is about, so the test must not reproduce it by
|
||||
// accident.
|
||||
quit, disarm := armStopSignals()
|
||||
|
||||
fmt.Println(markerReady)
|
||||
os.Stdout.Sync()
|
||||
|
||||
sig := <-quit
|
||||
disarm()
|
||||
fmt.Println(markerSignal, sig)
|
||||
os.Stdout.Sync()
|
||||
|
||||
if os.Getenv(childStuckEnv) == "1" {
|
||||
// Stand in for a cleanup hook that never finishes. The point of
|
||||
// restoring the signal disposition is that a second signal still
|
||||
// reaches the default handler and kills this.
|
||||
time.Sleep(2 * time.Minute)
|
||||
}
|
||||
|
||||
timeout := shutdownTimeout
|
||||
if os.Getenv(childHangConn) == "1" {
|
||||
// Dialled here, not at start-up. net/http stops counting a StateNew
|
||||
// connection against Shutdown once it is more than five seconds old,
|
||||
// so a connection opened before the wait would age out on a slow CI
|
||||
// run and Shutdown would succeed - leaving the test asserting nothing.
|
||||
c, err := net.Dial("tcp", ln.Addr().String())
|
||||
if err != nil {
|
||||
fmt.Println("dial:", err)
|
||||
os.Exit(5)
|
||||
}
|
||||
defer func() { _ = c.Close() }()
|
||||
|
||||
// And wait for the accept, for the opposite reason: an unaccepted
|
||||
// connection is not one Shutdown waits for either.
|
||||
select {
|
||||
case <-accepted:
|
||||
case <-time.After(10 * time.Second):
|
||||
fmt.Println("the server never accepted the stalling connection")
|
||||
os.Exit(6)
|
||||
}
|
||||
|
||||
// A connection that has sent nothing keeps Shutdown busy: net/http
|
||||
// only treats a StateNew connection as idle once it is more than five
|
||||
// seconds old. A short budget makes the timeout deterministic without
|
||||
// waiting out the real one.
|
||||
timeout = 300 * time.Millisecond
|
||||
}
|
||||
|
||||
sdk.Runtime.BeginShutdown()
|
||||
|
||||
if err := shutdownServer(srv, timeout); err != nil {
|
||||
// Deliberately not fatal, and deliberately not a bare return: the
|
||||
// point is that whatever follows still runs.
|
||||
fmt.Println("shutdown error:", err)
|
||||
} else {
|
||||
fmt.Println(markerShutdown)
|
||||
}
|
||||
|
||||
if err := runShutdownHooks(cleanupBudget); err != nil {
|
||||
fmt.Println("cleanup error:", err)
|
||||
}
|
||||
fmt.Println(markerExiting)
|
||||
os.Stdout.Sync()
|
||||
}
|
||||
|
||||
func startChild(t *testing.T, stuck bool, extraEnv ...string) (*exec.Cmd, *os.File, chan string) {
|
||||
t.Helper()
|
||||
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatalf("pipe: %v", err)
|
||||
}
|
||||
cmd := exec.Command(os.Args[0], "-test.run=TestSignalChild", "-test.v")
|
||||
cmd.Env = append(os.Environ(), childEnv+"=1")
|
||||
if stuck {
|
||||
cmd.Env = append(cmd.Env, childStuckEnv+"=1")
|
||||
}
|
||||
cmd.Env = append(cmd.Env, extraEnv...)
|
||||
cmd.Stdout = w
|
||||
cmd.Stderr = w
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatalf("start child: %v", err)
|
||||
}
|
||||
_ = w.Close()
|
||||
|
||||
lines := make(chan string, 64)
|
||||
go func() {
|
||||
defer close(lines)
|
||||
buf := make([]byte, 4096)
|
||||
var acc strings.Builder
|
||||
for {
|
||||
n, err := r.Read(buf)
|
||||
if n > 0 {
|
||||
acc.Write(buf[:n])
|
||||
for {
|
||||
s := acc.String()
|
||||
i := strings.IndexByte(s, '\n')
|
||||
if i < 0 {
|
||||
break
|
||||
}
|
||||
lines <- s[:i]
|
||||
acc.Reset()
|
||||
acc.WriteString(s[i+1:])
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
if acc.Len() > 0 {
|
||||
lines <- acc.String()
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
t.Cleanup(func() {
|
||||
_ = cmd.Process.Kill()
|
||||
_, _ = cmd.Process.Wait()
|
||||
_ = r.Close()
|
||||
})
|
||||
return cmd, r, lines
|
||||
}
|
||||
|
||||
// await drains lines until one contains want, or the deadline passes. It
|
||||
// returns everything it saw, so a failure says what the child actually did.
|
||||
func await(t *testing.T, lines chan string, want string, d time.Duration) []string {
|
||||
t.Helper()
|
||||
var seen []string
|
||||
deadline := time.After(d)
|
||||
for {
|
||||
select {
|
||||
case l, ok := <-lines:
|
||||
if !ok {
|
||||
t.Fatalf("child output ended before %q; saw:\n%s", want, strings.Join(seen, "\n"))
|
||||
}
|
||||
seen = append(seen, l)
|
||||
if strings.Contains(l, want) {
|
||||
return seen
|
||||
}
|
||||
case <-deadline:
|
||||
t.Fatalf("timed out waiting for %q; saw:\n%s", want, strings.Join(seen, "\n"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Acceptance 19. Registering only os.Interrupt meant SIGTERM - the signal
|
||||
// `docker stop`, Kubernetes and systemd all send - terminated the process
|
||||
// before any of the shutdown path ran. Both must now reach it.
|
||||
func TestBothSignalsRunTheShutdownPath(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
sig syscall.Signal
|
||||
}{
|
||||
{"SIGINT", syscall.SIGINT},
|
||||
{"SIGTERM", syscall.SIGTERM},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cmd, _, lines := startChild(t, false)
|
||||
await(t, lines, markerReady, 30*time.Second)
|
||||
|
||||
if err := cmd.Process.Signal(tc.sig); err != nil {
|
||||
t.Fatalf("signal: %v", err)
|
||||
}
|
||||
|
||||
await(t, lines, markerSignal, 10*time.Second)
|
||||
await(t, lines, markerShutdown, 10*time.Second)
|
||||
await(t, lines, markerExiting, 10*time.Second)
|
||||
|
||||
if err := cmd.Wait(); err != nil {
|
||||
t.Fatalf("child exited with %v, want a clean exit", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Acceptance 20. quit is a buffered channel and signal.Notify stays armed, so
|
||||
// without restoring the disposition a second signal only refills the buffer:
|
||||
// once SIGTERM is registered, a shutdown that hangs could not be interrupted by
|
||||
// anything short of SIGKILL.
|
||||
func TestASecondSignalStillKillsAStuckShutdown(t *testing.T) {
|
||||
cmd, _, lines := startChild(t, true)
|
||||
await(t, lines, markerReady, 30*time.Second)
|
||||
|
||||
if err := cmd.Process.Signal(syscall.SIGTERM); err != nil {
|
||||
t.Fatalf("first signal: %v", err)
|
||||
}
|
||||
await(t, lines, markerSignal, 10*time.Second)
|
||||
|
||||
// The child is now inside a cleanup that will not finish on its own.
|
||||
if err := cmd.Process.Signal(syscall.SIGTERM); err != nil {
|
||||
t.Fatalf("second signal: %v", err)
|
||||
}
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- cmd.Wait() }()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
if err == nil {
|
||||
t.Fatal("child exited cleanly; it was supposed to be killed by the second signal")
|
||||
}
|
||||
case <-time.After(15 * time.Second):
|
||||
t.Fatal("the second signal did not kill a stuck shutdown - the escape hatch is gone")
|
||||
}
|
||||
}
|
||||
|
||||
// Acceptance 21. srv.Shutdown reports an error exactly when connections were
|
||||
// still in flight, and the old code answered that with log.Fatal - an
|
||||
// unconditional os.Exit(1). Everything after it, which is where the cleanup
|
||||
// hooks will hang, never ran. A failed Shutdown must not end the process.
|
||||
func TestShutdownTimeoutDoesNotStopWhatFollows(t *testing.T) {
|
||||
cmd, _, lines := startChild(t, false, childHangConn+"=1")
|
||||
await(t, lines, markerReady, 30*time.Second)
|
||||
|
||||
if err := cmd.Process.Signal(syscall.SIGTERM); err != nil {
|
||||
t.Fatalf("signal: %v", err)
|
||||
}
|
||||
await(t, lines, markerSignal, 10*time.Second)
|
||||
|
||||
seen := await(t, lines, markerExiting, 20*time.Second)
|
||||
|
||||
var timedOut bool
|
||||
for _, l := range seen {
|
||||
if strings.Contains(l, "shutdown error:") {
|
||||
timedOut = true
|
||||
}
|
||||
}
|
||||
if !timedOut {
|
||||
t.Fatalf("Shutdown did not time out, so this test proves nothing; saw:\n%s",
|
||||
strings.Join(seen, "\n"))
|
||||
}
|
||||
var cleaned bool
|
||||
for _, l := range seen {
|
||||
if strings.Contains(l, markerCleanup) {
|
||||
cleaned = true
|
||||
}
|
||||
}
|
||||
if !cleaned {
|
||||
t.Fatalf("the BeforeExit callback did not run after a failed Shutdown; saw:\n%s",
|
||||
strings.Join(seen, "\n"))
|
||||
}
|
||||
if err := cmd.Wait(); err != nil {
|
||||
t.Fatalf("child exited with %v after a failed Shutdown, want a clean exit", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A callback that outlasts its budget must not take the process with it, and
|
||||
// must not be waited for: RunShutdown reports the deadline and returns, the
|
||||
// callback carries on, and the process still exits cleanly. This is the half of
|
||||
// the contract that is easy to get backwards - the context bounds the wait, not
|
||||
// the work, because Go cannot cancel a function that does not check for it.
|
||||
func TestACleanupThatOutlastsItsBudgetIsAbandonedNotAwaited(t *testing.T) {
|
||||
cmd, _, lines := startChild(t, false, childSlowCleanup+"=1")
|
||||
await(t, lines, markerReady, 30*time.Second)
|
||||
|
||||
if err := cmd.Process.Signal(syscall.SIGTERM); err != nil {
|
||||
t.Fatalf("signal: %v", err)
|
||||
}
|
||||
await(t, lines, markerSignal, 10*time.Second)
|
||||
|
||||
// The budget is 300ms and the callback sleeps two seconds. If RunShutdown
|
||||
// waited for it, this marker would not arrive for two seconds; the one
|
||||
// second here is what makes "abandoned, not awaited" the thing asserted.
|
||||
seen := await(t, lines, markerExiting, 1*time.Second)
|
||||
|
||||
var reported bool
|
||||
for _, l := range seen {
|
||||
if strings.Contains(l, "cleanup error:") {
|
||||
reported = true
|
||||
}
|
||||
if strings.Contains(l, markerCleanup) {
|
||||
t.Fatalf("the slow callback finished before the process moved on, so nothing was abandoned; saw:\n%s",
|
||||
strings.Join(seen, "\n"))
|
||||
}
|
||||
}
|
||||
if !reported {
|
||||
t.Fatalf("RunShutdown returned no error for a callback that outlasted the budget; saw:\n%s",
|
||||
strings.Join(seen, "\n"))
|
||||
}
|
||||
if err := cmd.Wait(); err != nil {
|
||||
t.Fatalf("child exited with %v, want a clean exit despite the abandoned callback", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
// Package health answers whether this process should be sent traffic.
|
||||
//
|
||||
// The two questions an orchestrator asks are not the same one, and go-admin
|
||||
// answers them at two endpoints:
|
||||
//
|
||||
// - /health is liveness: is the process there at all. It stays a bare 200,
|
||||
// because the honest answer to "should I restart you" is almost always no.
|
||||
// Restarting a process because its database is unreachable turns one
|
||||
// outage into a crash loop that also loses the connection pool, the cache
|
||||
// and every in-flight request.
|
||||
// - /ready is readiness: should this instance receive requests now. It fails
|
||||
// while the dependencies are unreachable, and - the part that only exists
|
||||
// because of the life-cycle phases - it fails as soon as shutdown begins,
|
||||
// before the server stops accepting, so a load balancer has a chance to
|
||||
// take the instance out before connections are cut.
|
||||
package health
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk"
|
||||
)
|
||||
|
||||
// draining is set when the process starts shutting down.
|
||||
//
|
||||
// It is kept here rather than read back from core: BeginShutdown sets a flag on
|
||||
// the Application, but nothing exports it, and one host wanting to know is not
|
||||
// yet a reason to widen that interface.
|
||||
var draining atomic.Bool
|
||||
|
||||
// BeginDraining records that shutdown has started, so readiness fails from now
|
||||
// on. It is called with BeginShutdown, before anything is taken apart.
|
||||
func BeginDraining() { draining.Store(true) }
|
||||
|
||||
// Draining reports whether shutdown has begun.
|
||||
func Draining() bool { return draining.Load() }
|
||||
|
||||
// Check is one dependency and what asking it produced.
|
||||
type Check struct {
|
||||
Name string `json:"name"`
|
||||
OK bool `json:"ok"`
|
||||
Err string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// Ready asks every dependency this process cannot serve a request without.
|
||||
//
|
||||
// The queue is deliberately absent. Nothing on AdapterQueue answers "are you
|
||||
// reachable" without publishing something, the memory backend cannot fail, and
|
||||
// a queue that is down degrades logging rather than stopping requests - which
|
||||
// is a reason to alert, not a reason to leave the load balancer pool.
|
||||
func Ready(ctx context.Context) []Check {
|
||||
return []Check{
|
||||
safely("database", func() error { return pingDB(ctx) }),
|
||||
safely("cache", probeCache),
|
||||
}
|
||||
}
|
||||
|
||||
// safely turns a panic into a failed check.
|
||||
//
|
||||
// Not defensive habit: the accessors hand back wrappers, not the resources.
|
||||
// sdk.Runtime.GetCacheAdapter builds a runtime.Cache around whatever is
|
||||
// configured and returns it even when nothing is - so the value is not nil, the
|
||||
// cache inside it is, and the first call dereferences it. A nil check cannot
|
||||
// see that, and the same is true of GetQueueAdapter.
|
||||
//
|
||||
// Whatever the reason, a probe is the last thing that should be able to take
|
||||
// the process down: the caller is asking whether this instance is well, and
|
||||
// killing it to answer is the wrong reply.
|
||||
func safely(name string, fn func() error) (c Check) {
|
||||
c = Check{Name: name}
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
c.OK, c.Err = false, fmt.Sprintf("the check panicked: %v", r)
|
||||
}
|
||||
}()
|
||||
if err := fn(); err != nil {
|
||||
c.Err = err.Error()
|
||||
return c
|
||||
}
|
||||
c.OK = true
|
||||
return c
|
||||
}
|
||||
|
||||
// Healthy reports whether every check passed.
|
||||
func Healthy(checks []Check) bool {
|
||||
for _, c := range checks {
|
||||
if !c.OK {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func pingDB(ctx context.Context) error {
|
||||
db := sdk.Runtime.GetDb()
|
||||
if db == nil {
|
||||
return errors.New("no database configured")
|
||||
}
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return sqlDB.PingContext(ctx)
|
||||
}
|
||||
|
||||
// cacheProbePrefix names the probe's keys. The key itself is per probe, not
|
||||
// fixed: two /ready requests arriving together - or two instances sharing one
|
||||
// redis, which is the normal deployment - would otherwise overwrite each
|
||||
// other's value between the write and the read and each conclude the cache was
|
||||
// broken. A readiness probe that reports false negatives under load takes
|
||||
// healthy instances out of the pool, which is worse than not probing.
|
||||
const cacheProbePrefix = "go-admin:health:"
|
||||
|
||||
// cacheProbeTTL is short because these keys are write-once and never read
|
||||
// again by anyone else; it only has to outlive the read that follows.
|
||||
const cacheProbeTTL = 30
|
||||
|
||||
func probeCache() error {
|
||||
adapter := sdk.Runtime.GetCacheAdapter()
|
||||
if adapter == nil {
|
||||
return errors.New("no cache configured")
|
||||
}
|
||||
|
||||
suffix := make([]byte, 8)
|
||||
if _, err := rand.Read(suffix); err != nil {
|
||||
return fmt.Errorf("could not build a probe key: %w", err)
|
||||
}
|
||||
key := cacheProbePrefix + hex.EncodeToString(suffix)
|
||||
|
||||
// Written and read back rather than only read: a cache that answers "miss"
|
||||
// for every key - a client pointed at the wrong server - is
|
||||
// indistinguishable from a healthy one on a read alone.
|
||||
want := time.Now().Format(time.RFC3339Nano)
|
||||
if err := adapter.Set(key, want, cacheProbeTTL); err != nil {
|
||||
return err
|
||||
}
|
||||
// Best effort, and its error is deliberately dropped: the verdict is
|
||||
// already decided by the read below, and a cache that cannot delete a key
|
||||
// it just wrote is not a reason to refuse traffic. The TTL is the real
|
||||
// cleanup.
|
||||
defer func() { _ = adapter.Del(key) }()
|
||||
|
||||
got, err := adapter.Get(key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if got != want {
|
||||
return errors.New("the cache returned a different value than was written")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
package health
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/runtime"
|
||||
corestorage "github.com/go-admin-team/go-admin-core/v2/storage"
|
||||
)
|
||||
|
||||
func freshRuntime(t *testing.T) {
|
||||
t.Helper()
|
||||
previous := sdk.Runtime
|
||||
t.Cleanup(func() { sdk.Runtime = previous })
|
||||
sdk.Runtime = runtime.NewConfig()
|
||||
}
|
||||
|
||||
// fakeCache answers whatever the test needs it to.
|
||||
type fakeCache struct {
|
||||
mu sync.Mutex
|
||||
setErr error
|
||||
getErr error
|
||||
getBack string // returned instead of what was written, when non-empty
|
||||
stored map[string]string
|
||||
|
||||
// oneSlot makes the cache keep a single value however many keys are
|
||||
// written, which is what a shared probe key turns any cache into.
|
||||
oneSlot bool
|
||||
slot string
|
||||
|
||||
// setBarrier, when set, holds every writer until all of them have written.
|
||||
// Without it the probes are short enough that the scheduler usually runs
|
||||
// them one after another, and a shared key survives by luck rather than by
|
||||
// design - which would leave the test below asserting nothing.
|
||||
setBarrier *barrier
|
||||
}
|
||||
|
||||
// barrier releases every waiter once n of them have arrived.
|
||||
type barrier struct {
|
||||
n int
|
||||
mu sync.Mutex
|
||||
got int
|
||||
ch chan struct{}
|
||||
}
|
||||
|
||||
func newBarrier(n int) *barrier { return &barrier{n: n, ch: make(chan struct{})} }
|
||||
|
||||
func (b *barrier) wait() {
|
||||
b.mu.Lock()
|
||||
b.got++
|
||||
if b.got == b.n {
|
||||
close(b.ch)
|
||||
}
|
||||
b.mu.Unlock()
|
||||
<-b.ch
|
||||
}
|
||||
|
||||
func (c *fakeCache) String() string { return "fake" }
|
||||
|
||||
func (c *fakeCache) Set(key string, val interface{}, _ int) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.setErr != nil {
|
||||
return c.setErr
|
||||
}
|
||||
v, _ := val.(string)
|
||||
if c.oneSlot {
|
||||
c.slot = v
|
||||
return nil
|
||||
}
|
||||
if c.stored == nil {
|
||||
c.stored = map[string]string{}
|
||||
}
|
||||
c.stored[key] = v
|
||||
c.mu.Unlock()
|
||||
if c.setBarrier != nil {
|
||||
// Outside the lock on purpose: waiting while holding it would deadlock
|
||||
// every other writer before the barrier could fill.
|
||||
c.setBarrier.wait()
|
||||
}
|
||||
c.mu.Lock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *fakeCache) Get(key string) (string, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.getErr != nil {
|
||||
return "", c.getErr
|
||||
}
|
||||
if c.getBack != "" {
|
||||
return c.getBack, nil
|
||||
}
|
||||
if c.oneSlot {
|
||||
return c.slot, nil
|
||||
}
|
||||
return c.stored[key], nil
|
||||
}
|
||||
|
||||
func (c *fakeCache) Del(key string) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
delete(c.stored, key)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *fakeCache) HashGet(_, _ string) (string, error) { return "", nil }
|
||||
func (c *fakeCache) HashDel(_, _ string) error { return nil }
|
||||
func (c *fakeCache) Increase(string) error { return nil }
|
||||
func (c *fakeCache) Decrease(string) error { return nil }
|
||||
func (c *fakeCache) Expire(string, time.Duration) error { return nil }
|
||||
|
||||
var _ corestorage.AdapterCache = (*fakeCache)(nil)
|
||||
|
||||
func named(checks []Check, name string) Check {
|
||||
for _, c := range checks {
|
||||
if c.Name == name {
|
||||
return c
|
||||
}
|
||||
}
|
||||
return Check{Name: name, Err: "check not reported at all"}
|
||||
}
|
||||
|
||||
// A cache that accepts writes and answers every read with a different value is
|
||||
// the failure this probe exists for - a client pointed at the wrong server, or
|
||||
// one that silently drops everything. A read alone cannot tell that apart from
|
||||
// a healthy cache with a cold key, which is why the probe writes first.
|
||||
func TestCacheProbeFailsWhenTheValueDoesNotComeBack(t *testing.T) {
|
||||
freshRuntime(t)
|
||||
sdk.Runtime.SetCacheAdapter(&fakeCache{getBack: "something else"})
|
||||
|
||||
got := named(Ready(context.Background()), "cache")
|
||||
if got.OK {
|
||||
t.Error("the cache check passed although the value written was not the value read back")
|
||||
}
|
||||
if got.Err == "" {
|
||||
t.Error("the failing check reported no reason")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheProbePassesWhenTheValueComesBack(t *testing.T) {
|
||||
freshRuntime(t)
|
||||
sdk.Runtime.SetCacheAdapter(&fakeCache{})
|
||||
|
||||
if got := named(Ready(context.Background()), "cache"); !got.OK {
|
||||
t.Errorf("the cache check failed for a cache that works: %s", got.Err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheProbeReportsAWriteFailure(t *testing.T) {
|
||||
freshRuntime(t)
|
||||
sdk.Runtime.SetCacheAdapter(&fakeCache{setErr: errors.New("connection refused")})
|
||||
|
||||
got := named(Ready(context.Background()), "cache")
|
||||
if got.OK {
|
||||
t.Error("the cache check passed although the write failed")
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing configured is the case that used to take the process down rather
|
||||
// than answer. GetCacheAdapter builds a wrapper around whatever is configured
|
||||
// and returns it even when nothing is, so the value is not nil, the cache
|
||||
// inside it is, and Set dereferences it - a probe that panics is the worst
|
||||
// possible answer to "are you well".
|
||||
//
|
||||
// Every check has to be reported, passing or not. A probe that omits what it
|
||||
// could not reach reads as a shorter list of healthy things.
|
||||
func TestEveryDependencyIsReportedEvenWithNothingConfigured(t *testing.T) {
|
||||
freshRuntime(t)
|
||||
|
||||
checks := Ready(context.Background())
|
||||
for _, name := range []string{"database", "cache"} {
|
||||
c := named(checks, name)
|
||||
if c.Err == "check not reported at all" {
|
||||
t.Errorf("%s was not reported", name)
|
||||
}
|
||||
if c.OK {
|
||||
t.Errorf("%s passed with nothing configured", name)
|
||||
}
|
||||
}
|
||||
if Healthy(checks) {
|
||||
t.Error("Healthy said yes for a process with no database and no cache")
|
||||
}
|
||||
}
|
||||
|
||||
// Draining is what makes the shutdown graceful from the outside: it has to be
|
||||
// observable before the server stops accepting, or the load balancer learns
|
||||
// about the shutdown by having its connections cut.
|
||||
func TestDrainingIsObservableOnceItBegins(t *testing.T) {
|
||||
previous := draining.Load()
|
||||
t.Cleanup(func() { draining.Store(previous) })
|
||||
|
||||
draining.Store(false)
|
||||
if Draining() {
|
||||
t.Fatal("Draining reported true before shutdown began")
|
||||
}
|
||||
BeginDraining()
|
||||
if !Draining() {
|
||||
t.Error("Draining still reported false after BeginDraining")
|
||||
}
|
||||
}
|
||||
|
||||
// Two probes at once must both pass. With one fixed key they overwrite each
|
||||
// other's value between the write and the read, and a readiness probe that
|
||||
// reports false negatives under load takes healthy instances out of the pool -
|
||||
// which is worse than not probing at all.
|
||||
func TestConcurrentProbesDoNotOverwriteEachOther(t *testing.T) {
|
||||
freshRuntime(t)
|
||||
const probes = 16
|
||||
sdk.Runtime.SetCacheAdapter(&fakeCache{setBarrier: newBarrier(probes)})
|
||||
|
||||
var wg sync.WaitGroup
|
||||
failures := make(chan string, probes)
|
||||
for i := 0; i < probes; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if c := named(Ready(context.Background()), "cache"); !c.OK {
|
||||
failures <- c.Err
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
close(failures)
|
||||
|
||||
var n int
|
||||
var first string
|
||||
for err := range failures {
|
||||
if n == 0 {
|
||||
first = err
|
||||
}
|
||||
n++
|
||||
}
|
||||
if n > 0 {
|
||||
t.Errorf("%d of %d concurrent probes called a healthy cache broken; first: %s", n, probes, first)
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ var CasbinExclude = []UrlInfo{
|
||||
{Url: "/api/v1/user/pwd", Method: "PUT"},
|
||||
{Url: "/api/v1/metrics", Method: "GET"},
|
||||
{Url: "/api/v1/health", Method: "GET"},
|
||||
{Url: "/api/v1/ready", Method: "GET"},
|
||||
{Url: "/", Method: "GET"},
|
||||
{Url: "/api/v1/server-monitor", Method: "GET"},
|
||||
{Url: "/api/v1/public/uploadFile", Method: "POST"},
|
||||
|
||||
@@ -9,10 +9,11 @@ package storage
|
||||
|
||||
import (
|
||||
"log"
|
||||
"sync"
|
||||
|
||||
"github.com/go-admin-team/go-admin-core/v2/captcha"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
|
||||
"github.com/go-admin-team/go-admin-core/v2/captcha"
|
||||
)
|
||||
|
||||
// Setup 配置storage组件
|
||||
@@ -34,17 +35,75 @@ func setupCaptcha() {
|
||||
captcha.SetStore(captcha.NewCacheStore(sdk.Runtime.GetCacheAdapter(), 600))
|
||||
}
|
||||
|
||||
var (
|
||||
queueMu sync.Mutex
|
||||
// installed is the adapter setupQueue built, kept so the next reload can
|
||||
// shut it down, and counted so a consumer can tell one from the next.
|
||||
installed interface{ Shutdown() }
|
||||
installedGen uint64
|
||||
)
|
||||
|
||||
// QueueGeneration reports how many times this package has installed a queue
|
||||
// adapter. It changes every time setupQueue builds a new one, which is on
|
||||
// every configuration reload, and stays 0 for as long as the configuration has
|
||||
// no queue section at all - in which case nothing is installed and callers are
|
||||
// working with the runtime's own fallback queue.
|
||||
//
|
||||
// It exists because there is no way to ask for the adapter's identity from the
|
||||
// outside. sdk.Runtime.GetQueueAdapter and GetQueuePrefix build a fresh
|
||||
// runtime.Queue wrapper on every call, so comparing what two calls return
|
||||
// compares two wrappers and never matches, however many times the underlying
|
||||
// adapter has been replaced. This package creates the adapter, so this is the
|
||||
// only place that knows. A counter rather than the adapter itself keeps the
|
||||
// comparison on a uint64: an adapter type that is not comparable would panic
|
||||
// an `==` between two interface values.
|
||||
func QueueGeneration() uint64 {
|
||||
queueMu.Lock()
|
||||
defer queueMu.Unlock()
|
||||
return installedGen
|
||||
}
|
||||
|
||||
func setupQueue() {
|
||||
if config.QueueConfig.Empty() {
|
||||
return
|
||||
}
|
||||
if q := sdk.Runtime.GetQueueAdapter(); q != nil {
|
||||
q.Shutdown()
|
||||
}
|
||||
|
||||
queueMu.Lock()
|
||||
defer queueMu.Unlock()
|
||||
|
||||
queueAdapter, err := config.QueueConfig.Setup()
|
||||
if err != nil {
|
||||
log.Fatalf("queue setup error, %s\n", err.Error())
|
||||
}
|
||||
|
||||
previous := installed
|
||||
sdk.Runtime.SetQueueAdapter(queueAdapter)
|
||||
go queueAdapter.Run()
|
||||
installed = queueAdapter
|
||||
installedGen++
|
||||
|
||||
// The previous adapter goes down after the new one is installed, not
|
||||
// before. Shutdown waits for its consumers to deliver what it still holds,
|
||||
// and for that whole wait the runtime would otherwise be handing producers
|
||||
// a queue that has stopped accepting: every Append in the window comes back
|
||||
// ErrQueueClosed, and both call sites in common/middleware log it. Swapping
|
||||
// first leaves no such window - a producer gets the new queue or the old
|
||||
// one, and both work.
|
||||
//
|
||||
// Only an adapter this package installed. GetQueueAdapter never returns
|
||||
// nil - with nothing configured the runtime falls back to its own memory
|
||||
// queue and wraps that - so the `if q := GetQueueAdapter(); q != nil` this
|
||||
// replaces was always true, and shut down the fallback queue on the very
|
||||
// first start, before anything had used it.
|
||||
if previous != nil {
|
||||
previous.Shutdown()
|
||||
}
|
||||
|
||||
// Deliberately not started here. Run has to come after the consumers have
|
||||
// registered: the contract implementations refuse a registration once the
|
||||
// queue is running (storage.ErrQueueAlreadyStarted), and the legacy
|
||||
// adapter this repository still goes through swallows that error rather
|
||||
// than reporting it - its own comment says the interface gives it no way
|
||||
// to tell the caller. Starting here and registering afterwards is
|
||||
// therefore a race that loses consumers in silence. Whoever registers is
|
||||
// the one that starts it.
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
|
||||
corestorage "github.com/go-admin-team/go-admin-core/v2/storage"
|
||||
"github.com/go-admin-team/go-admin-core/v2/storage/queue"
|
||||
)
|
||||
|
||||
// redisAddrEnv points these tests at a server. They are skipped without it, so
|
||||
// a developer with no redis running still gets a green run - and CI sets it,
|
||||
// which is the point: the ordering rule they cover is invisible on the memory
|
||||
// backend, and memory is the default. A suite that only ever exercised the
|
||||
// default would report success for a queue that silently drops every consumer.
|
||||
const redisAddrEnv = "GO_ADMIN_TEST_REDIS_ADDR"
|
||||
|
||||
func redisAddr(t *testing.T) string {
|
||||
t.Helper()
|
||||
addr := os.Getenv(redisAddrEnv)
|
||||
if addr != "" {
|
||||
return addr
|
||||
}
|
||||
// Skipping locally is the point; skipping in CI is the failure this whole
|
||||
// file exists to prevent. A workflow that renamed the variable, or dropped
|
||||
// the service, would otherwise go green while these two tests quietly did
|
||||
// nothing - which is the same shape as the defect they cover.
|
||||
if os.Getenv("CI") != "" {
|
||||
t.Fatalf("%s is not set while CI is: the redis-backed queue tests must not skip here", redisAddrEnv)
|
||||
}
|
||||
t.Skipf("%s is not set; skipping the redis-backed queue tests", redisAddrEnv)
|
||||
return ""
|
||||
}
|
||||
|
||||
// newRedisQueue builds the queue the same way setupQueue does - through
|
||||
// config.QueueConfig.Setup - so that what is under test is the adapter this
|
||||
// repository actually gets, LegacyQueueAdapter and all, rather than a redis
|
||||
// client wired up by the test.
|
||||
func newRedisQueue(t *testing.T, prefix string) corestorage.AdapterQueue {
|
||||
t.Helper()
|
||||
previous := config.QueueConfig
|
||||
t.Cleanup(func() { config.QueueConfig = previous })
|
||||
|
||||
config.QueueConfig = &config.Queue{
|
||||
Redis: &config.RedisQueue{
|
||||
RedisOptions: config.RedisOptions{Addr: redisAddr(t)},
|
||||
Group: prefix,
|
||||
KeyPrefix: prefix,
|
||||
},
|
||||
}
|
||||
q, err := config.QueueConfig.Setup()
|
||||
if err != nil {
|
||||
t.Fatalf("queue setup: %v", err)
|
||||
}
|
||||
t.Cleanup(q.Shutdown)
|
||||
return q
|
||||
}
|
||||
|
||||
func message(t *testing.T, stream string) corestorage.Messager {
|
||||
t.Helper()
|
||||
m := &queue.Message{}
|
||||
m.SetStream(stream)
|
||||
m.SetValues(map[string]interface{}{"hello": "world"})
|
||||
return m
|
||||
}
|
||||
|
||||
// Registered first, then started: the consumer gets the message. This is the
|
||||
// order setupQueue and attachQueueConsumers now produce between them.
|
||||
func TestRedisQueueDeliversToAConsumerRegisteredBeforeTheStart(t *testing.T) {
|
||||
stream := "t-ordered"
|
||||
q := newRedisQueue(t, "gotest-ordered")
|
||||
|
||||
got := make(chan struct{}, 1)
|
||||
q.Register(stream, func(corestorage.Messager) error {
|
||||
select {
|
||||
case got <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
return nil
|
||||
})
|
||||
go q.Run()
|
||||
|
||||
// Give Start a moment to reach its read loop before publishing.
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
if err := q.Append(message(t, stream)); err != nil {
|
||||
t.Fatalf("append: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-got:
|
||||
case <-time.After(15 * time.Second):
|
||||
t.Fatal("the consumer never received the message")
|
||||
}
|
||||
}
|
||||
|
||||
// Started first, then registered: the registration is refused and every
|
||||
// publish afterwards fails.
|
||||
//
|
||||
// Subscribe answers ErrQueueAlreadyStarted, and LegacyQueueAdapter.Register
|
||||
// returns nothing, so the caller cannot know - that part is silent. What is not
|
||||
// silent is the consequence: no consumer group was created, so Publish refuses
|
||||
// the topic with ErrNoHandler on every single request, and go-admin's call
|
||||
// sites log that at error level while the login and operation log rows are
|
||||
// never written.
|
||||
//
|
||||
// This is the test the memory backend cannot provide. queue.Memory's Register
|
||||
// starts another consumer goroutine whatever the state, so the same code passes
|
||||
// there - which is how the defect survived, memory being the default.
|
||||
func TestRedisQueueRefusesAConsumerRegisteredAfterTheStart(t *testing.T) {
|
||||
stream := "t-late"
|
||||
q := newRedisQueue(t, "gotest-late")
|
||||
|
||||
go q.Run()
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
q.Register(stream, func(corestorage.Messager) error { return nil })
|
||||
|
||||
err := q.Append(message(t, stream))
|
||||
if err == nil {
|
||||
t.Fatal("a message was accepted for a topic whose registration came after Start; " +
|
||||
"if the backend now accepts late registration, the ordering rule in setupQueue can be revisited")
|
||||
}
|
||||
if !errors.Is(err, corestorage.ErrNoHandler) {
|
||||
t.Fatalf("append failed with %v, want %v - the test is meant to pin the "+
|
||||
"missing-consumer path, not any error at all", err, corestorage.ErrNoHandler)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/runtime"
|
||||
corestorage "github.com/go-admin-team/go-admin-core/v2/storage"
|
||||
"github.com/go-admin-team/go-admin-core/v2/storage/queue"
|
||||
)
|
||||
|
||||
// sampleSize is how many publishes have to land inside the reload before the
|
||||
// measurement is taken. Waiting on the count rather than on wall clock keeps
|
||||
// the window the test covers the same on a loaded runner as on an idle one.
|
||||
const sampleSize = 200
|
||||
|
||||
func swapMsg() corestorage.Messager {
|
||||
m := new(queue.Message)
|
||||
m.SetStream("t")
|
||||
m.SetValues(map[string]interface{}{"a": "b"})
|
||||
return m
|
||||
}
|
||||
|
||||
// A reload must never leave producers holding a queue that has stopped
|
||||
// accepting.
|
||||
//
|
||||
// Shutdown waits for its consumers to deliver what the queue still holds. Taking
|
||||
// the old adapter down before installing the new one meant the runtime pointed
|
||||
// at a closed queue for that entire wait: every Append in the window came back
|
||||
// ErrQueueClosed, and both call sites in common/middleware log it at error
|
||||
// level. Installing first leaves no window - a producer gets the new queue or
|
||||
// the old one, and both accept.
|
||||
//
|
||||
// The difference is only visible during that wait, which is why the test holds
|
||||
// a consumer rather than checking the state after Setup has returned: by then
|
||||
// the two orders look identical.
|
||||
//
|
||||
// One refusal survives the fix and is not something this ordering can reach.
|
||||
// GetQueuePrefix hands back a wrapper that captured the adapter, so a producer
|
||||
// that fetched before the swap and appends after Shutdown has begun is still
|
||||
// holding the old one. That window is one call wide and closing it means
|
||||
// resolving the adapter inside Append, which is core's to change. What the
|
||||
// ordering removes is the sustained window: every producer that fetches during
|
||||
// the wait. The test publishes from a single goroutine, so at most one of its
|
||||
// calls can straddle the swap - which is what makes "more than one" the line
|
||||
// between the two orders rather than a tolerance.
|
||||
func TestAReloadNeverPointsProducersAtAClosedQueue(t *testing.T) {
|
||||
prevQ, prevC := config.QueueConfig, config.CacheConfig
|
||||
prevRuntime := sdk.Runtime
|
||||
prevInstalled, prevGen := installed, installedGen
|
||||
t.Cleanup(func() {
|
||||
config.QueueConfig, config.CacheConfig = prevQ, prevC
|
||||
sdk.Runtime = prevRuntime
|
||||
queueMu.Lock()
|
||||
installed, installedGen = prevInstalled, prevGen
|
||||
queueMu.Unlock()
|
||||
})
|
||||
sdk.Runtime = runtime.NewConfig()
|
||||
config.CacheConfig = &config.Cache{Memory: struct{}{}}
|
||||
// Sized so the buffer cannot fill while the consumer is held: a full queue
|
||||
// returns an error of its own, and this test needs every error other than
|
||||
// ErrQueueClosed to mean something it does not model has happened.
|
||||
config.QueueConfig = &config.Queue{Memory: &config.QueueMemory{PoolSize: 4096}}
|
||||
|
||||
Setup()
|
||||
|
||||
// A consumer that will not finish until this test lets it, so the reload's
|
||||
// Shutdown has something to wait for.
|
||||
release := make(chan struct{})
|
||||
consuming := make(chan struct{})
|
||||
var picked sync.Once
|
||||
first := sdk.Runtime.GetQueuePrefix("")
|
||||
first.Register("t", func(corestorage.Messager) error {
|
||||
picked.Do(func() { close(consuming) })
|
||||
<-release
|
||||
return nil
|
||||
})
|
||||
go first.Run()
|
||||
for i := 0; i < 4; i++ {
|
||||
if err := first.Append(swapMsg()); err != nil {
|
||||
t.Fatalf("seed append %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
select {
|
||||
case <-consuming:
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("the consumer never picked a message up, so the reload has nothing to wait for")
|
||||
}
|
||||
|
||||
reloaded := make(chan struct{})
|
||||
go func() { Setup(); close(reloaded) }()
|
||||
|
||||
// Publish continuously while the reload is in progress.
|
||||
var refused atomic.Int64
|
||||
var attempts atomic.Int64
|
||||
unexpected := make(chan error, 1)
|
||||
stop := make(chan struct{})
|
||||
// publishing is closed by the producer on its way out. The test joins on it
|
||||
// before returning: t.Cleanup restores sdk.Runtime, and a producer still in
|
||||
// flight would be reading the variable that restore writes.
|
||||
publishing := make(chan struct{})
|
||||
go func() {
|
||||
defer close(publishing)
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
attempts.Add(1)
|
||||
err := sdk.Runtime.GetQueuePrefix("").Append(swapMsg())
|
||||
switch {
|
||||
case err == nil:
|
||||
case errors.Is(err, corestorage.ErrQueueClosed):
|
||||
refused.Add(1)
|
||||
default:
|
||||
// Kept rather than counted: an Append refused for some other
|
||||
// reason would otherwise leave refused at zero and the test
|
||||
// green while nothing was reaching a queue at all.
|
||||
select {
|
||||
case unexpected <- err:
|
||||
default:
|
||||
}
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
}()
|
||||
|
||||
deadline := time.After(30 * time.Second)
|
||||
for attempts.Load() < sampleSize {
|
||||
select {
|
||||
case <-deadline:
|
||||
t.Fatalf("only %d publishes landed inside the reload; the window was never sampled", attempts.Load())
|
||||
case <-time.After(time.Millisecond):
|
||||
}
|
||||
}
|
||||
close(release)
|
||||
|
||||
select {
|
||||
case <-reloaded:
|
||||
case <-time.After(30 * time.Second):
|
||||
t.Fatal("the reload never finished")
|
||||
}
|
||||
close(stop)
|
||||
<-publishing
|
||||
|
||||
select {
|
||||
case err := <-unexpected:
|
||||
t.Fatalf("a publish failed for a reason this test does not model: %v", err)
|
||||
default:
|
||||
}
|
||||
if n := refused.Load(); n > 1 {
|
||||
t.Errorf("%d of %d publishes during the reload were refused: producers were pointed at the closed queue",
|
||||
n, attempts.Load())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/runtime"
|
||||
)
|
||||
|
||||
// Issue #892: a configuration reload replaces the queue adapter and the
|
||||
// consumers registered against the previous one are attached to a queue nobody
|
||||
// publishes to any more.
|
||||
//
|
||||
// The fix has two halves. attachQueueConsumers gives a new queue its own
|
||||
// consumers and the same queue none, which cmd/api covers against a queue the
|
||||
// test controls. This is the other half: that a reload actually produces a new
|
||||
// queue for it to notice. Setup is what config re-runs on every change, so
|
||||
// calling it twice is what a reload does to this package.
|
||||
func TestSetupBumpsTheQueueGenerationOnEveryReload(t *testing.T) {
|
||||
// Setup writes the process-wide sdk.Runtime - the cache and queue adapters -
|
||||
// and this package's own record of what it installed. Restoring all of it
|
||||
// keeps the test from deciding what a later test in this binary sees,
|
||||
// which is the same isolation cmd/api's freshRuntime provides.
|
||||
prevQ, prevC := config.QueueConfig, config.CacheConfig
|
||||
prevRuntime := sdk.Runtime
|
||||
prevInstalled, prevGen := installed, installedGen
|
||||
t.Cleanup(func() {
|
||||
config.QueueConfig, config.CacheConfig = prevQ, prevC
|
||||
sdk.Runtime = prevRuntime
|
||||
queueMu.Lock()
|
||||
installed, installedGen = prevInstalled, prevGen
|
||||
queueMu.Unlock()
|
||||
})
|
||||
sdk.Runtime = runtime.NewConfig()
|
||||
|
||||
config.CacheConfig = &config.Cache{Memory: struct{}{}}
|
||||
config.QueueConfig = &config.Queue{Memory: &config.QueueMemory{PoolSize: 10}}
|
||||
|
||||
before := QueueGeneration()
|
||||
Setup()
|
||||
first := QueueGeneration()
|
||||
Setup()
|
||||
second := QueueGeneration()
|
||||
|
||||
t.Logf("before=%d first=%d second=%d", before, first, second)
|
||||
if first == before {
|
||||
t.Fatal("the first Setup did not install a queue")
|
||||
}
|
||||
if second == first {
|
||||
t.Fatal("a second Setup - which is what a configuration reload does - did not install a new one")
|
||||
}
|
||||
}
|
||||
+684
-76
@@ -1,64 +1,453 @@
|
||||
# 公共契约面
|
||||
|
||||
> 本文写给**第三方应用作者**:你写一个装进 go-admin 的业务模块,可以依赖什么、
|
||||
> 怎么注册进来、哪些东西随时可能变。
|
||||
> 怎么接进来、哪些约定不遵守会**不报错地出错**。
|
||||
>
|
||||
> 主仓贡献者的编码约定见根目录 `AGENTS.md`,设计取舍见 `docs/architecture.md`。
|
||||
|
||||
---
|
||||
|
||||
## 承诺稳定的包
|
||||
## 契约面在 core,不在 go-admin
|
||||
|
||||
| 包 | 用途 |
|
||||
|---|---|
|
||||
| `common/actions` | 通用 CRUD Action(Index / View / Create / Update / Delete / Permission) |
|
||||
| `common/dto` | 分页、`search` tag 解析、`Control` / `Index` 接口 |
|
||||
| `common/models` | `ActiveRecord`、`ControlBy`、`ModelTime`、`Model` |
|
||||
| `common/middleware` | `AuthCheckRole`、`InitMiddleware` 等 |
|
||||
这份文档以前列的是 go-admin 自己的四个包(`common/actions` 等),依据写的是
|
||||
「把 `app/demo` 的 import 去重之后恰好就是这四个」。
|
||||
|
||||
**依据不是拍脑袋列的**:`app/demo` 是一个可编译、有测试、CI 会跑的标准 CRUD 模块,
|
||||
把它的 `go-admin/` 前缀 import 全部去重之后,恰好就是这四个包 —— 它代表
|
||||
"写一个标准模块所需要的最小依赖面"。你的模块如果需要第五个包,先在 issue 里说一声,
|
||||
那多半意味着契约面缺了什么。
|
||||
**那个依据是错的,而且错的方向是把人引向依赖宿主。**
|
||||
|
||||
"稳定"的含义:**在 `2.x` 内不做破坏性变更**。新增导出符号不算破坏;改签名、
|
||||
改语义、删除导出符号算,会走 major 版本并在 release note 里单列。
|
||||
go-admin 的使用方式是 clone / fork:每个使用者拿到的是一整份代码,然后**改它**。
|
||||
应用如果依赖 `go-admin/common/actions`,它依赖的是一个**每个使用者都不一样、
|
||||
而且随时在变**的东西——你没有办法测试自己的应用在别人改过的 fork 上能不能编译。
|
||||
|
||||
### 没有已知例外
|
||||
还有一条更硬的:`go-admin` 这个 module path 没有点号,
|
||||
按 Go 的规则**不是合法的可解析模块路径**:
|
||||
|
||||
这四个包**不 import `app/` 下的任何东西**,2026-08-31 起由 CI 强制
|
||||
(见下方「边界由 CI 守着」)。在此之前有两处反向依赖,都已根治:
|
||||
```
|
||||
$ go get go-admin/common/models
|
||||
go: malformed module path "go-admin/common/models": missing dot in first path element
|
||||
```
|
||||
|
||||
| 原位置 | 反向依赖 | 处理 |
|
||||
|---|---|---|
|
||||
| `common/middleware/logger.go` | `app/admin/service/dto` 的两个操作日志状态常量 | 常量下沉到 `common/global`,`dto` 侧保留同名常量作为 deprecated 别名,fork 不受影响 |
|
||||
| `common/middleware/handler/auth.go` | `app/admin/models` 的 `SysUser` / `SysRole` | 该段断言恒失败、设的是零值且开源版无人读取,属死代码,已删除 |
|
||||
想 import 它就必须写 `replace`,而**非主模块的 `replace` 会被忽略**——
|
||||
你在自己应用里写的 replace 对使用者不生效。所以「应用 require go-admin」
|
||||
这条路不是不优雅,是走不通。
|
||||
|
||||
之所以不把它们记成"已知例外":这份文档的作用就是告诉你哪些包可以依赖,
|
||||
如果第一条下面就挂着例外脚注,后来人会照着例外抄,边界从第一天起就是脏的。
|
||||
契约面因此落在 **go-admin-core**:那是唯一一个大家都一样、有版本号、
|
||||
不会被使用者随手改的东西。
|
||||
|
||||
---
|
||||
|
||||
## 其余包不保证稳定
|
||||
## 承诺稳定的包
|
||||
|
||||
`common/` 下没有出现在上表里的包(`common/global`、`common/storage`、
|
||||
`common/database`、`common/file_store`、`common/response`、`common/service`、
|
||||
`common/apis`、`common/middleware/handler`、根 `common` 包……)以及
|
||||
`app/admin` 的内部实现,**均不承诺稳定**。
|
||||
全部在 `github.com/go-admin-team/go-admin-core/v2` 下:
|
||||
|
||||
其中 `common/global`、`common/middleware/handler`、根 `common` 包是
|
||||
`common/middleware` 的编译期依赖 —— 它们会被一起拉进你的依赖图,但这不代表
|
||||
它们的 API 稳定。**不要因为"都在 `common/` 目录下"就认为是契约面。**
|
||||
| 包 | 用途 |
|
||||
|---|---|
|
||||
| `sdk/contract/models` | `Model` / `ControlBy` / `ModelTime` / `ActiveRecord` / `BaseUser` / `Migration`、`sys_menu.menu_type` 的三个枚举值 |
|
||||
| `sdk/contract/dto` | `Pagination` / `MakeCondition` / `Paginate` / `OrderDest` / `ObjectById`、`Index` 与 `Control` 接口 |
|
||||
| `sdk/contract/actions` | 数据权限设施:`DataPermission` / `Permission` / `PermissionAction` / `GetPermissionFromContext`、五个 `DataScope*` 常量与 `IsValidDataScope` |
|
||||
| `sdk/contract/migration` | `Registry` / `AppRegistrar` / `ForApp` / `SetVersion` / `GetFilename` |
|
||||
| `sdk/contract/seed` | `MenuSpec` / `ApiSpec` / `Seeder` / `SeedMenus`——往侧边栏和接口表里登记自己 |
|
||||
| `sdk/pkg` | `GetOrm(c)`:从请求上下文取本租户的数据库连接 |
|
||||
| `sdk/api`、`sdk/service` | 可选的 Api / Service 基类 |
|
||||
| `response` | `OK` / `Error` / `PageOK`:响应格式 |
|
||||
| `jwtauth/user` | 从 token 取当前用户身份 |
|
||||
| `sdk/runtime` | 中间件 key 常量与 `GetHandlerFunc`:复用宿主已注册的鉴权链 |
|
||||
|
||||
规划中的 001(模块路径改名)会把非契约包移进 `internal/`,由编译器强制这条边界。
|
||||
届时上表之外的包对外部模块直接不可见 —— 现在就照上表写,那次改动对你零成本。
|
||||
`sdk/contract/` 这个前缀的含义就是「**承诺对应用稳定**的那一面」。core 里
|
||||
`sdk/` 下的其他包是框架基础设施,语义不同——上表逐个列了名字,
|
||||
**不要因为「都在 core 里」就认为是契约面**。
|
||||
|
||||
"稳定"的含义:**在 core 的 `v2.x` 内不做破坏性变更**。新增导出符号不算破坏;
|
||||
改签名、改语义、删除导出符号算,会走 major 版本并在 release note 里单列。
|
||||
|
||||
准确的语义以 core 那份文档为准:
|
||||
[go-admin-core `docs/contract.md`](https://github.com/go-admin-team/go-admin-core/blob/main/docs/contract.md)。
|
||||
本文写的是宿主这一侧——它管不着的那些。
|
||||
|
||||
### go-admin 自己的包
|
||||
|
||||
`go-admin/common/models`、`common/dto`、`common/actions` 里的契约类型现在是
|
||||
**指向 core 的类型别名**(`type X = corepkg.X`),主仓和所有 fork 的存量代码
|
||||
一行不用改。别名在编译期就是同一个类型,不是"兼容层"。
|
||||
|
||||
但**新写的应用不要 import 它们**——那样就又依赖上宿主了。
|
||||
|
||||
---
|
||||
|
||||
## 契约面是三层,不是一层
|
||||
|
||||
划分依据不是"应用会 import 哪些包",而是**"哪一条不遵守会静默出错"**:
|
||||
|
||||
| 层 | 内容 | 判据 |
|
||||
|---|---|---|
|
||||
| **一 · 必须遵守** | 路由注册、从 context 取库、响应 shape、`ControlBy`/`ModelTime`、鉴权、数据权限、事务范式 | 不遵守 → **不报错,行为悄悄不对** |
|
||||
| **二 · 可选便利** | `api.Api`、`service.Service`、CRUD Action、`MakeCondition` | 用不用都对 |
|
||||
| **三 · 今天空白** | 应用间调用、领域事件、缓存租户隔离 | **没有。别自己发明** |
|
||||
|
||||
**框架不强制任何一层抽象。** 一个不用任何便利层的 handler 完全合法:
|
||||
|
||||
```go
|
||||
func handler(c *gin.Context) {
|
||||
db, err := pkg.GetOrm(c)
|
||||
if err != nil {
|
||||
response.Error(c, 500, err, "")
|
||||
return
|
||||
}
|
||||
var list []MyModel
|
||||
if err := db.Find(&list).Error; err != nil {
|
||||
response.Error(c, 500, err, "")
|
||||
return
|
||||
}
|
||||
response.OK(c, list, "")
|
||||
}
|
||||
```
|
||||
|
||||
第一层则是不管你用不用便利层都要遵守的,逐条写在下面,每条都附**不遵守会怎样**。
|
||||
|
||||
---
|
||||
|
||||
## 第一层:不遵守就静默出错
|
||||
|
||||
### 1. 路由注册
|
||||
|
||||
见下方「注册路由」一节。
|
||||
|
||||
**不遵守会怎样**:注册表在 `RunAppRouters()` 之后就封闭了,晚到的注册被丢弃,
|
||||
只记一条 ERROR 日志。包级 `AppRouters` 连这个都没有——它就是一个普通 slice,
|
||||
什么时候 append 都"成功",启动钩子之后 append 的那些永远不会执行,**且不出声**。
|
||||
|
||||
### 2. 数据库连接从 context 取,不用全局变量
|
||||
|
||||
```go
|
||||
db, err := pkg.GetOrm(c) // 唯一正确的取法
|
||||
```
|
||||
|
||||
`common/middleware/db.go` 在每个请求上按 `c.Request.Host` 挑出本租户的连接
|
||||
放进 context:
|
||||
|
||||
```go
|
||||
c.Set("db", sdk.Runtime.GetDbByTenant(c.Request.Host).WithContext(c))
|
||||
```
|
||||
|
||||
**不遵守会怎样**:连接是**按租户注册**的(`SetDbByTenant(host, db)`),
|
||||
`GetOrm(c)` 按 `c.Request.Host` 挑。你要是在启动时把某个连接存进包级变量再一直用,
|
||||
多租户部署下所有租户的读写就都落到那一个库上——不报错、不告警,数据串了才发现。
|
||||
|
||||
这个坑在本仓库真踩过:`common/global.Driver` 取的是启动循环
|
||||
**迭代到的第一个**库的驱动(`common/database/initialize.go`),
|
||||
而 Go 的 map 迭代顺序是随机的——两个库用不同驱动时,那个值每次启动都可能不一样。
|
||||
所以「一个进程一个库」这个假设不要写进任何一行代码。
|
||||
|
||||
### 3. 响应 shape
|
||||
|
||||
一律用 `response.OK` / `response.Error` / `response.PageOK`,不要自己
|
||||
`c.JSON`。它们发出去的形状是:
|
||||
|
||||
```jsonc
|
||||
// 成功
|
||||
{"requestId": "...", "code": 200, "data": {...}}
|
||||
// 分页:data 里再套一层
|
||||
{"requestId": "...", "code": 200, "data": {"count": 42, "pageIndex": 1, "pageSize": 10, "list": [...]}}
|
||||
// 失败
|
||||
{"requestId": "...", "code": 500, "msg": "...", "status": "error"}
|
||||
```
|
||||
|
||||
**HTTP 状态码永远是 200**,业务码在 body 的 `code` 里——这是既定行为,
|
||||
`response.Error` 走的是 `c.AbortWithStatusJSON(http.StatusOK, res)`。
|
||||
|
||||
**不遵守会怎样**:前端 `src/utils/request.ts` 的响应拦截器只读 body 的 `code`,
|
||||
`code !== 200` 就弹一条 `msg` 内容的 error toast 并 reject。你自己
|
||||
`c.JSON(200, myThing)` 的话 `code` 是 `undefined`,界面上弹出来的是**一条空的
|
||||
错误提示**,数据到不了页面。列表更安静:`useTable.ts` 读的是
|
||||
`page?.list ?? []` 和 `page?.count ?? 0`,形状对不上就是**一张空表,零报错**。
|
||||
|
||||
### 4. `ControlBy` 与 `ModelTime`
|
||||
|
||||
每张业务表的 model 都嵌这三个:
|
||||
|
||||
```go
|
||||
type Order struct {
|
||||
models.Model // Id
|
||||
// ... 你的字段 ...
|
||||
models.ControlBy // CreateBy / UpdateBy
|
||||
models.ModelTime // CreatedAt / UpdatedAt / DeletedAt
|
||||
}
|
||||
|
||||
func (Order) TableName() string { return "app_order" } // 必须显式声明
|
||||
```
|
||||
|
||||
`ControlBy` 提供 `create_by` 列,**数据权限的每一条 SQL 都 join 在它上面**。
|
||||
`ModelTime` 的 `DeletedAt` 是 `soft_delete.DeletedAt`(毫秒时间戳,活行为 0,
|
||||
永不为 NULL),不是 `gorm.DeletedAt`。
|
||||
|
||||
**不遵守会怎样**:
|
||||
|
||||
- 嵌了 `ControlBy` 但写入时忘了 `SetCreateBy(user.GetUserId(c))`,
|
||||
`create_by` 就是 0。除「全部数据权限」外的每一档都**查不到任何数据**,
|
||||
而且不报错——看起来像"这个用户还没建过数据"。
|
||||
- 用错 `ModelTime` 版本(可空的 `gorm.DeletedAt`):gorm 按
|
||||
`deleted_at IS NULL` 过滤,而活行里存的是 0,于是**整张表一行都查不出来**。
|
||||
主仓的 `sys_columns` / `sys_tables` 真在这个状态下待过——代码生成器
|
||||
一张表都列不出来,没有任何报错。`make checksilent` 的 `modeltime-mix`
|
||||
就是为这条加的。
|
||||
- `TableName()` 忘了写:GORM 配了 `SingularTable`,不会推导复数,表名会是
|
||||
你没预料的那个。
|
||||
|
||||
### 5. 鉴权:用宿主已注册的中间件,不要自己造
|
||||
|
||||
```go
|
||||
jwtCheck, ok := sdk.Runtime.GetHandlerFunc(runtime.JwtTokenCheck)
|
||||
if !ok {
|
||||
log.Fatal("JwtTokenCheck is not registered; is the host started via cmd/api?")
|
||||
}
|
||||
roleCheck, _ := sdk.Runtime.GetHandlerFunc(runtime.RoleCheck)
|
||||
permCheck, _ := sdk.Runtime.GetHandlerFunc(runtime.PermissionCheck)
|
||||
|
||||
g := v1.Group("/order").Use(jwtCheck).Use(roleCheck).Use(permCheck)
|
||||
```
|
||||
|
||||
三个 key 的常量在 `sdk/runtime`,宿主启动时把三个中间件注册进去。
|
||||
|
||||
**不遵守会怎样**:`GetHandlerFunc` 在"没注册"和"注册成了别的类型"两种情况下
|
||||
都返回 `ok=false` 而不是 panic——**因为路由注册跑在 core 的 panic 护栏里面,
|
||||
裸类型断言 panic 之后日志报的是"这个模块一条路由都没注册上",跟真实原因对不上**。
|
||||
所以 `ok` 必须自己判,判出来要**大声失败**:一个跳过鉴权继续注册的路由,
|
||||
就是一条静默的匿名可访问接口。
|
||||
|
||||
**宿主必须注册绑定过的闭包。** 三个 key 存的都得是 `gin.HandlerFunc`——
|
||||
比如 `authMiddleware.MiddlewareFunc()`,**不是** `(*jwt.GinJWTMiddleware).MiddlewareFunc`。
|
||||
后者是方法表达式,没有接收者绑在上面,取回来断言不成 `gin.HandlerFunc`,
|
||||
怎么断言都做不成一个能用的 handler。
|
||||
|
||||
> **当前状态**:`common/middleware/init.go` 里 `RoleCheck` 与 `PermissionCheck`
|
||||
> 注册的是 `AuthCheckRole()` 和 `actions.PermissionAction()`,都是绑定过的闭包,
|
||||
> 取回来就能用;**`JwtTokenCheck` 注册的还是那个方法表达式**,所以今天对它
|
||||
> `GetHandlerFunc` 拿到的是 `ok=false`。上面那段 `log.Fatal` 会在启动时打出来——
|
||||
> 这是有意的,宁可起不来也不要一条没鉴权的路由。主仓这一处的修复见 F10,
|
||||
> 修完之后本段可以删掉。
|
||||
|
||||
还有一条**不影响行为但影响理解**的:主仓今天四个模块各自调一次 `AuthInit()`
|
||||
(`app/admin`、`app/jobs`、`app/other`、`app/demo`),也就是有四个 JWT 实例。
|
||||
这不产生行为差异——配置同源(`config.JwtConfig`),JWT 校验是无状态的,
|
||||
不看实例身份。但它意味着 `GetHandlerFunc(runtime.JwtTokenCheck)` 取回来的是
|
||||
**最后注册进去的那一个**。要让应用拿到一个有意义的共享实例,宿主应当在注册路由
|
||||
之前构造一次,而不是每个模块构造一次。
|
||||
|
||||
**测的时候别用 `admin` 账号。** `AuthCheckRole` 里 `rolekey == "admin"` 直接
|
||||
`c.Next()`,**完全跳过 Casbin**。拿 admin 压任何鉴权路径都测不到东西。
|
||||
|
||||
### 6. 数据权限
|
||||
|
||||
两件事都要做:
|
||||
|
||||
```go
|
||||
// 路由上挂中间件(上一节的 permCheck 就是它)
|
||||
g := v1.Group("/order").Use(permCheck)
|
||||
|
||||
// 查询里组合 scope
|
||||
p := actions.GetPermissionFromContext(c)
|
||||
db.Scopes(actions.Permission(Order{}.TableName(), p)).Find(&list)
|
||||
```
|
||||
|
||||
`sys_role.data_scope` 有五档,`Permission()` 按它拼 WHERE 条件:
|
||||
|
||||
| 值 | 常量 | 含义 | 条件 |
|
||||
|---|---|---|---|
|
||||
| `1` | `DataScopeAll` | 全部数据权限 | 不加条件 |
|
||||
| `2` | `DataScopeCustom` | 自定义数据权限 | `create_by` 属于 `sys_role_dept` 关联到的部门 |
|
||||
| `3` | `DataScopeDept` | 本部门 | `create_by` 属于本部门 |
|
||||
| `4` | `DataScopeDeptTree` | 本部门及以下 | `create_by` 属于 `dept_path` 匹配的子树 |
|
||||
| `5` | `DataScopeSelf` | 仅本人 | `create_by = 当前用户` |
|
||||
|
||||
自己往 `sys_role.data_scope` 写值的话先过一遍 `IsValidDataScope`——
|
||||
写进去的非法值不会在写入时报错,只会在**每一次查询**里静默地什么都查不到。
|
||||
|
||||
**不遵守会怎样**,两种漏法的方向相反,值得分清:
|
||||
|
||||
- **查询里忘了组合 `Permission()`** —— 就是**全量可见**,每个角色都看得到所有人
|
||||
的数据,不报错、不记日志。**这是本框架里最贵的一类静默失败**,所以那一行
|
||||
`db.Scopes(...)` 不是"最佳实践",是契约。
|
||||
- **组合了 `Permission()` 但路由上漏挂中间件** —— 上下文里没有 `PermissionKey`,
|
||||
拿到的是零值,`DataScope` 是空串,落进下面那个 fail-closed 的 default,
|
||||
结果是**一行都查不到**。方向反了,至少还看得见。
|
||||
|
||||
五档之外的值(空串、拼错的、还没迁移的老数据)落到 `default` 分支,
|
||||
那里是 **fail closed**:加一条 `1 = 0`,什么都不返回。注意 `1`(全部数据权限)
|
||||
是**显式列出的一个 case**,不是"落到 default"——两者曾经是同一条路,
|
||||
于是"没配置"和"配置成看全部"产出的 SQL 一个字都不差。
|
||||
|
||||
`3` / `4` 两档在 `DeptId <= 0` 时同样 fail closed。原因是
|
||||
`sys_dept.dept_path` 一律以 `/0/` 开头,`dept_id=0` 会把 LIKE 模式变成
|
||||
`'%/0/%'`,**命中全表**——本来想表达"没有部门",实际表达的是"全部部门"。
|
||||
|
||||
数据权限还有一个**全局开关** `application.enabledp`,默认是 `false`。
|
||||
关掉时 `Permission()` 原样返回查询、`PermissionAction()` 直接放行——
|
||||
**你的应用在默认配置下测不出数据权限的任何行为**,要验证得先把它打开。
|
||||
|
||||
**不要自己重写这段 SQL。** 那 20 行里埋着 8 项内部知识:JWT claims 的私有键名
|
||||
(`datascope` / `deptid`)、`sys_user`↔`sys_role` 的 join、`sys_role_dept`
|
||||
关联表、`sys_dept.dept_path` 的 `/0/1/2/` 编码、`create_by` 的归属约定、
|
||||
`enabledp` 开关、老 token 的回落逻辑。**而且写错的方向是越权。**
|
||||
仓库里有过一份第二实现,`dept_path` 的匹配写成 `"%"+id+"%"` 少了两个斜杠,
|
||||
`dept_id=1` 会匹配上 `/11/`、`/21/`、`/100/`——写它的人比第三方更懂这套约定,
|
||||
仍然写错了。那份实现已经删掉了。
|
||||
|
||||
### 7. 事务范式
|
||||
|
||||
**业务层的事务一律用 `Transaction()` 闭包形式**:
|
||||
|
||||
```go
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(&order).Error; err != nil {
|
||||
return err // rolled back
|
||||
}
|
||||
return tx.Model(&stock).Where("qty >= ?", n).
|
||||
UpdateColumn("qty", gorm.Expr("qty - ?", n)).Error
|
||||
})
|
||||
```
|
||||
|
||||
GORM 自己处理提交、回滚,以及 **panic 时的回滚**。
|
||||
|
||||
**不要照抄 `app/admin/service/sys_role.go`。** 那里有 5 处手写的
|
||||
`Begin` / `defer` 写法,三个缺陷都是静默的:
|
||||
|
||||
```go
|
||||
tx := e.Orm
|
||||
if config.DatabaseConfig.Driver != "sqlite3" { // 缺陷 2
|
||||
tx = e.Orm.Begin()
|
||||
defer func() {
|
||||
if err != nil { tx.Rollback() } else { tx.Commit() } // 缺陷 1
|
||||
}()
|
||||
}
|
||||
```
|
||||
|
||||
1. **panic 时提交半截事务**——defer 只看 `err`,panic 时 `err` 仍是 nil,走的是
|
||||
`Commit()`
|
||||
2. **sqlite 下根本不开事务**——那一整个特判让 `tx` 就是 `e.Orm` 本身,
|
||||
写一半失败留一半
|
||||
3. **读 `config.DatabaseConfig.Driver`**——那是全局单库配置,多租户下不是
|
||||
当前租户的驱动
|
||||
|
||||
缺陷 1 不止那一处:`app/admin/service/sys_dept.go`、`sys_menu.go`、
|
||||
`app/other/models/tools/sys_tables.go` 用的是同一个 `defer` 写法
|
||||
(没有 sqlite 特判,所以只有缺陷 1)。**整个 `Begin`/`defer` 家族都别照抄。**
|
||||
|
||||
同一个仓库里就有正确的参照:`cmd/migrate/migration/version/` 下 7 个迁移里
|
||||
5 个用的是闭包形式(另外两个是纯 DDL 标记,DDL 在 MySQL 下本来就不进事务),
|
||||
且这条路在 sqlite 下实测跑得通(`make build-sqlite`)。
|
||||
主仓那些写法本批次不改,单独跟。
|
||||
|
||||
**并发保护用条件更新 + `RowsAffected`**,不要"先查后改":
|
||||
|
||||
```go
|
||||
res := tx.Model(&Order{}).Where("id = ? AND status = ?", id, StatusPending).
|
||||
Update("status", StatusPaid)
|
||||
if res.Error != nil { return res.Error }
|
||||
if res.RowsAffected == 0 { return ErrAlreadyPaid } // 别人先改了
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 第二层:可选便利
|
||||
|
||||
用不用都对,**不用不会出任何问题**:
|
||||
|
||||
| 东西 | 在哪 | 是什么 |
|
||||
|---|---|---|
|
||||
| `api.Api` | core `sdk/api` | 一条链式糖:`MakeContext` / `Bind` / `MakeOrm` / `OK` / `PageOK` / `Error` |
|
||||
| `service.Service` | core `sdk/service` | 一个装 `Orm` / `Log` / `Cache` / `Error` 的结构体加一个 `AddError` |
|
||||
| `MakeCondition` / `search` tag | core `sdk/contract/dto` | 把 DTO 上的 `search:"type:exact;column:name;table:xx"` 翻成 WHERE |
|
||||
| 通用 CRUD Action | go-admin `common/actions` | `IndexAction` 等五个。**留在 go-admin,没有下沉** |
|
||||
|
||||
最后一行是有意的:CRUD Action 是最需要演进的一类东西(分页参数、批量操作、
|
||||
软删语义、字段级权限),而 core 的每一个导出都是永久承诺——放进去容易,
|
||||
拿出来不可能。想用就把那 294 行抄走,抄走的那份还能按你自己的需要改。
|
||||
主仓唯一的真实业务模块 `app/admin` **一个 CRUD Action 都没用**,全是手写 Service。
|
||||
|
||||
`MakeCondition` 返回的是 `func(db *gorm.DB) *gorm.DB` 闭包,方言从闭包里那个
|
||||
`db.Dialector.Name()` 读,**必然是本租户那个库的驱动**,不需要你设置任何东西。
|
||||
|
||||
---
|
||||
|
||||
## 第三层:今天没有的
|
||||
|
||||
**明说没有,别自己发明**:
|
||||
|
||||
| 能力 | 现状 |
|
||||
|---|---|
|
||||
| 应用间调用 | 零定义。A 应用要调 B 应用只能直接 import 对方的包,循环依赖就回来了 |
|
||||
| 领域事件 / EventBus | 无 |
|
||||
| 缓存的租户隔离 | `service.Service` 有 `Cache` 字段,**是否按租户隔离未验证**。当作没隔离来写 |
|
||||
| 生命周期钩子之外的时点 | 只有下面那四个。没有「路由装好之后、开始监听之前」这一档 |
|
||||
|
||||
这几条留给后续批次,按真实需求补——现在凭空设计只会设计错。
|
||||
如果你的应用卡在这里,在 issue 里说一声,那正是我们要的输入。
|
||||
|
||||
---
|
||||
|
||||
## 装一个应用要接两处线
|
||||
|
||||
后端**两处**,漏掉第二处是**静默失败**:
|
||||
|
||||
```go
|
||||
// 1. 路由:cmd/api/<name>.go
|
||||
import _ "github.com/acme/go-admin-app-order/router"
|
||||
|
||||
// 2. 迁移:cmd/migrate/server.go 的 import 块里
|
||||
import _ "github.com/acme/go-admin-app-order/migration"
|
||||
```
|
||||
|
||||
两个都是空导入,作用只是让那个包的 `init()` 跑起来。
|
||||
|
||||
**漏了第二处会怎样**:不报错。`migrate` 命令照常跑完、照常打印成功,
|
||||
你的建表和种子数据**就是不执行**。等到第一个请求打过来才会看到
|
||||
"表不存在",而那时排查方向已经跑偏了。
|
||||
|
||||
`migrate --dry-run` 是确认接线成功的最快方式——它只读,可以直接对生产库跑:
|
||||
|
||||
```bash
|
||||
go-admin migrate --dry-run -c config/settings.yml # 你的迁移应该出现在列表里
|
||||
```
|
||||
|
||||
带界面的应用还有第三处,在前端仓库,见下一节。
|
||||
|
||||
---
|
||||
|
||||
## 前端:菜单 `component` 必须以 `apps/` 开头
|
||||
|
||||
前端那一处接线是 `go-admin-ui` 的 `apps.config.mjs`——加一条
|
||||
`{ code: 'order', source: '...' }`,`source` 指到你的页面目录
|
||||
(兄弟目录的相对路径,或 `./node_modules/@scope/app-order/views/order`)。
|
||||
`scripts/sync-apps.mjs` 会在 `pnpm dev` 与 `pnpm build` 之前把它复制进
|
||||
`src/apps/<code>/`,不需要手工跑。
|
||||
|
||||
`src/stores/permission.ts` 的 `appPath()` **只认路径第一段是 `apps`**,
|
||||
其余一律当成主仓内置视图去 `src/views/` 下找。
|
||||
|
||||
所以你的菜单种子里 `Component` 必须写成:
|
||||
|
||||
```
|
||||
apps/<code>/<该应用内的相对路径>/index
|
||||
```
|
||||
|
||||
比如 `code` 是 `order` 的应用写 `apps/order/index`(开头带不带 `/` 都行,
|
||||
只看第一段)。**不能**写成 `/order/index`。
|
||||
|
||||
**写错会怎样**:第一段是 `order` 而不是 `apps`,前端会去找一个不存在的
|
||||
`src/views/order/index.vue`,页面摔到 `AppNotInstalled` 占位组件。
|
||||
但控制台打印的是 `no component at src/views/order/index.vue`——
|
||||
**跟真实原因(漏了 `apps/` 前缀)对不上**,排查时很容易被这条日志带偏。
|
||||
|
||||
对应的前端约定写在 go-admin-ui 的 `AGENTS.md`。另外一条:`source` 目录的内容
|
||||
**原样**搬进 `src/apps/<code>/`,不会在 `code` 之外再自动插一层——想要
|
||||
`apps/order/index` 这种最短形式,`source` 就要直接指到该应用**这一个页面模块**
|
||||
的目录,而不是应用仓库的 `views` 根目录。
|
||||
|
||||
---
|
||||
|
||||
## 注册路由
|
||||
|
||||
一个应用模块要注册自己的路由,写一个 `func()` 签名的 `InitRouter`
|
||||
(照抄 `app/demo/router/router.go`),然后二选一接进来:
|
||||
写一个 `func()` 签名的 `InitRouter`(照抄 `app/demo/router/router.go`),
|
||||
然后二选一接进来:
|
||||
|
||||
```go
|
||||
// 方式一(历史写法,仍然有效):在主仓 cmd/api/<name>.go 里
|
||||
@@ -68,36 +457,30 @@ AppRouters = append(AppRouters, router.InitRouter)
|
||||
sdk.Runtime.SetAppRouters(router.InitRouter)
|
||||
```
|
||||
|
||||
方式二是本次新接上的。差别只有一个但很关键:方式一要求你的模块
|
||||
`import "go-admin/cmd/api"` —— 那是主程序的命令包,让业务模块依赖它很别扭,
|
||||
也正是"主仓要为每个模块加一个七行文件"的根源。
|
||||
**第三方应用只能走方式二**——方式一要求 `import "go-admin/cmd/api"`,
|
||||
那就又依赖上宿主了。
|
||||
|
||||
**执行顺序**:先跑完包级 `AppRouters`,再由 core 的 `sdk.Runtime.RunAppRouters()`
|
||||
跑它自己的注册表,各自内部保持注册顺序。别依赖跨来源的相对顺序,各模块的
|
||||
`RouterGroup` 前缀互不相同,本来就不该有顺序依赖。
|
||||
走方式二还多拿到两样东西,都在 core 那边实现:
|
||||
|
||||
走方式二还多拿到两样东西,都在 core 那边实现(见
|
||||
[core 的 `docs/contract.md`](https://github.com/go-admin-team/go-admin-core/blob/main/docs/contract.md)):
|
||||
**panic 护栏**——你的 `InitRouter` panic 了,其余模块照常注册、进程不退出,日志里会写明
|
||||
是哪一行注册的;**失败分级**——`sdk.Runtime.SetAppRoutersWith(f, runtime.WithFatal())`
|
||||
声明「我起不来就别启动」。方式一(包级 `AppRouters`)没有护栏,panic 直接掀桌。
|
||||
- **panic 护栏**——你的 `InitRouter` panic 了,其余模块照常注册、进程不退出,
|
||||
日志里会写明是哪一行注册的
|
||||
- **失败分级**——`sdk.Runtime.SetAppRoutersWith(f, runtime.WithFatal())`
|
||||
声明「我起不来就别启动」
|
||||
|
||||
`InitRouter()` 内部的约定:自己拿 `sdk.Runtime.GetEngine()`,按需建
|
||||
方式一(包级 `AppRouters`)没有护栏,panic 直接掀桌。
|
||||
|
||||
**执行顺序**:先跑完包级 `AppRouters`,再由 `sdk.Runtime.RunAppRouters()`
|
||||
跑 core 自己的注册表,各自内部保持注册顺序。别依赖跨来源的相对顺序。
|
||||
|
||||
`InitRouter()` 内部:自己拿 `sdk.Runtime.GetEngine()`,按需建
|
||||
`gin.RouterGroup`,通过 `init()` 自注册到你自己包内的
|
||||
`routerCheckRole` / `routerNoCheckRole` 列表,不在任何中心文件手工列举
|
||||
(与 `AGENTS.md`「路由注册」一节一致)。
|
||||
`routerCheckRole` / `routerNoCheckRole` 列表,不在任何中心文件手工列举。
|
||||
|
||||
---
|
||||
|
||||
## 注册数据库迁移
|
||||
|
||||
框架自身的迁移不变:
|
||||
|
||||
```go
|
||||
migration.Migrate.SetVersion(migration.GetFilename(fileName), _1786700001000DemoMenu)
|
||||
```
|
||||
|
||||
应用的迁移走 `ForApp`:
|
||||
框架自身的迁移用 `SetVersion`;应用的迁移走 `ForApp`:
|
||||
|
||||
```go
|
||||
func init() {
|
||||
@@ -108,12 +491,20 @@ func init() {
|
||||
func initCrmTables(db *gorm.DB, version, appCode string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
// ... schema / data changes ...
|
||||
return tx.Create(&common.Migration{Version: version, AppCode: appCode}).Error
|
||||
return tx.Create(&models.Migration{Version: version, AppCode: appCode}).Error
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
四条必须知道的规则:
|
||||
注册面(`ForApp` / `SetVersion` / `GetFilename`)在 core 的
|
||||
`sdk/contract/migration`,是一个**进程级的包级注册表**——`ForApp` 直接当包级函数
|
||||
调,不需要从宿主手里接过什么句柄。**执行面**——读 `sys_migration`、排序、跑事务、
|
||||
`migrate` 与 `migrate status` 两个命令——留在宿主,它通过 `Snapshot()` 读那张表。
|
||||
|
||||
仓库内的模块继续经 `go-admin/cmd/migrate/migration` 走,那个包现在是薄壳,
|
||||
导入路径不变;外置应用直接 import core 的那个包,**两边写法一模一样**。
|
||||
|
||||
五条必须知道的规则:
|
||||
|
||||
1. **完成记录由迁移函数自己写**,而且要写在自己的事务里。框架的调度循环只做
|
||||
"这个 version 在 `sys_migration` 里有没有" 的判断,从不代你插入 —— 这样
|
||||
@@ -122,12 +513,17 @@ func initCrmTables(db *gorm.DB, version, appCode string) error {
|
||||
schema 上那一列等于白加,你的迁移会被记成框架的。
|
||||
3. **落库的 `version` 是加了前缀的**。`ForApp("crm")` 注册 `1786800001000`,
|
||||
实际写进 `sys_migration.version` 的是 `crm-1786800001000`,函数收到的
|
||||
`version` 参数已经是这个带前缀的值,照抄进 `common.Migration{Version: version}`
|
||||
`version` 参数已经是这个带前缀的值,照抄进 `models.Migration{Version: version}`
|
||||
即可。前缀的意义是:两个来源不同的应用哪怕碰巧生成同一个毫秒时间戳,也不会撞主键、
|
||||
不会有一方被误判为"已应用"。
|
||||
4. **应用 code 一律小写**,`ForApp` 会自己 `strings.ToLower` 一遍。`core` 是保留字
|
||||
(`migrate status` 用它表示框架自身,`--app core` 选中框架),`ForApp("core")`
|
||||
会 panic。
|
||||
5. **文件名前 13 位必须是毫秒时间戳**,`GetFilename` 就是从这里取版本号的。
|
||||
不合规的名字会 panic,并把违规文件名报出来 —— 这是**故意的**:调用点全在
|
||||
`init()` 里,没有 error 可返回,而另一条路是把文件名本身注册成"版本号"
|
||||
(`add_orders.go` 恰好 13 个字符,只查长度是拦不住的),那样这条迁移
|
||||
永远不会被执行,且不会有任何提示。宁可启动失败。
|
||||
|
||||
顺序保证:**同一应用内按版本号严格有序**。跨应用顺序不做承诺 —— 由于前缀的存在,
|
||||
今天的实际顺序是"先跑完全部框架迁移,再按 appCode 字母序逐个应用跑完",
|
||||
@@ -146,6 +542,98 @@ go-admin migrate --app crm -c config/settings.yml # 只跑 crm 的迁移
|
||||
|
||||
---
|
||||
|
||||
## 菜单与接口种子
|
||||
|
||||
一个带界面的应用要在侧边栏里出现,需要往四类数据里写东西:`sys_api`、
|
||||
`sys_menu`、`sys_menu_api_rule`(菜单与接口的关联)、以及角色授权与 Casbin
|
||||
策略(`sys_role_menu` / `casbin_rule`)。
|
||||
|
||||
**你不需要知道这些表长什么样。** `sdk/contract/seed` 让你只描述"我要什么",
|
||||
由宿主决定"怎么写进它自己的表":
|
||||
|
||||
```go
|
||||
// 在你自己的迁移里,用它自己的那个事务
|
||||
err := seed.SeedMenus(tx, "order", []seed.MenuSpec{
|
||||
{Code: "root", Kind: models.Directory, Title: "订单"},
|
||||
{Code: "list", Parent: "root", Kind: models.Menu, Title: "订单列表",
|
||||
Path: "/order", Component: "apps/order/index", ApiCodes: []string{"list"}},
|
||||
}, []seed.ApiSpec{
|
||||
{Code: "list", Title: "订单列表", Path: "/api/v1/order", Method: "GET"},
|
||||
})
|
||||
```
|
||||
|
||||
`Kind` 用的就是 `sdk/contract/models` 里 `sys_menu.menu_type` 的那三个值
|
||||
(`Directory` / `Menu` / `Button`),不是另一套同值的常量。
|
||||
|
||||
`Component` 的写法见上面「前端」一节——**这里是最容易写错的一个字段**。
|
||||
|
||||
core 里**没有** `SysMenu`、没有 `SysApi`、没有任何表名。这是刻意划的边界:
|
||||
这个框架的宿主里本来就已经有两份 `SysMenu`(一份冻结在迁移期、一份运行期),
|
||||
两者在软删语义上不一致,害过人,为此专门建了一个仓库内的工具来守。
|
||||
往 core 里再放第三份表结构,就等于在**唯一没有工具守着**的地方重造同一类 bug。
|
||||
|
||||
### `Sort` 有上界,越界会中断整场迁移
|
||||
|
||||
`sys_menu.sort` 声明为 `gorm:"size:4"`,MySQL 据此建成 **tinyint,取值 -128..127**。
|
||||
sqlite 忽略宽度,所以越界值在本地测试里一路绿灯,到真实安装时是 Error 1264 ——
|
||||
而且发生在一次迁移的**中途**,后面的迁移全部不再执行。
|
||||
|
||||
`make checksilent` 的 `menu-sort-overflow` 会扫出仓库树里的越界字面量,
|
||||
**但它扫不到 module cache 里的应用**。外置应用只有宿主 Seeder 的运行期校验兜底。
|
||||
|
||||
### `MenuSpec` 没有菜单名字段,名字由宿主合成
|
||||
|
||||
前端用菜单名做 keep-alive 的缓存键。两个应用如果都取 `Code: "list"`,
|
||||
缓存键就会撞在一起 —— 后打开的那个页面会拿到前一个的缓存实例。
|
||||
|
||||
所以宿主的 Seeder 不直接用 `Code` 当菜单名,而是用
|
||||
**PascalCase(appCode) + PascalCase(Code)** 合成(`order` + `list` → `OrderList`)。
|
||||
你不需要做什么,但要知道两件事:
|
||||
|
||||
- 菜单名不是你能指定的,也不必与 `Title` 一致 —— `Title` 才是界面上显示的文字
|
||||
- 前端组件的 `name` 若要与菜单名对齐(`checksilent` 的 `menu-name-mismatch` 会比对),
|
||||
按合成后的名字写,不是按 `Code`
|
||||
|
||||
---
|
||||
|
||||
## 应用配置节
|
||||
|
||||
不要改宿主的源码去加配置。`sdk/config.RegisterExtend` 让你认领
|
||||
`extend:` 下自己那一节:
|
||||
|
||||
```go
|
||||
type orderConfig struct {
|
||||
PaymentEndpoint string
|
||||
Timeout int
|
||||
}
|
||||
|
||||
// 在 init() 里调,与 SetAppRouters / ForApp 同一约定
|
||||
var getOrderConfig = config.RegisterExtend[orderConfig]("order")
|
||||
|
||||
func handler(c *gin.Context) {
|
||||
cfg := getOrderConfig()
|
||||
_ = cfg.PaymentEndpoint
|
||||
}
|
||||
```
|
||||
|
||||
```yaml
|
||||
extend:
|
||||
order:
|
||||
PaymentEndpoint: https://payment.internal
|
||||
Timeout: 30
|
||||
```
|
||||
|
||||
每个 key 各自解码,互不覆盖。**同一个 key 注册两次会立刻 panic**——
|
||||
注册期没有"封闭时刻"可以用来拒绝迟到的注册,所以重复只能在注册的那一刻
|
||||
大声报出来,而不是让第二个人静默顶掉第一个人的配置节。
|
||||
|
||||
配置文件是被监听的,改动会触发重载。`RegisterExtend` 每次重载解码进一个全新的
|
||||
`T` 再原子换指针,所以访问器拿到的永远是一个自洽的快照,请求路径上读它不需要加锁。
|
||||
唯一要注意的:**不要跨两次调用拼一个视图**——从同一个返回值上读两个字段是一致的,
|
||||
调两次访问器各读一个字段,中间夹一次重载就不是了。
|
||||
|
||||
---
|
||||
|
||||
## 硬约束:注册要赶在启动钩子之前
|
||||
|
||||
三个注册入口——`AppRouters`、`sdk.Runtime.SetAppRouters`、`migration.ForApp`——
|
||||
@@ -153,18 +641,16 @@ go-admin migrate --app crm -c config/settings.yml # 只跑 crm 的迁移
|
||||
|
||||
`init()` 是最省事的位置:Go 规范保证包级变量初始化与 `init()` 在 `main()` 之前
|
||||
**单 goroutine 顺序执行**,注册期天然没有并发写。但它不是唯一合法位置——
|
||||
在 `run()` 之类早于启动钩子的地方注册同样成立。这条规则约束的是**顺序**,
|
||||
不是你写在哪个函数里。
|
||||
在 `run()` 之类早于启动钩子的地方注册同样成立。**这条规则约束的是顺序,
|
||||
不是你写在哪个函数里。**
|
||||
|
||||
`sdk.Runtime.SetAppRouters` 的准确语义以 core 为准:
|
||||
想在代码里判断注册窗口是否还开着:
|
||||
|
||||
> [go-admin-core `docs/contract.md`](https://github.com/go-admin-team/go-admin-core/blob/main/docs/contract.md)
|
||||
```go
|
||||
if sdk.Runtime.AppRoutersSealed() { /* RunAppRouters 已经跑过了 */ }
|
||||
```
|
||||
|
||||
那份文档写明了注册类与资源类的划分、封闭时刻、护栏边界(**只覆盖同步 panic,
|
||||
你自己 `go func()` 出去的 panic 框架够不着**)、以及配置热更新会在运行期
|
||||
重新执行 setup 回调这件事。
|
||||
|
||||
主仓这边只补三条它管不着的:
|
||||
主仓这边补三条 core 那份文档管不着的:
|
||||
|
||||
1. **`RunAppRouters()` 跑过之后,core 的注册表就封闭了**,再调
|
||||
`sdk.Runtime.SetAppRouters` 会被丢弃并记一条 ERROR 日志。包级 `AppRouters`
|
||||
@@ -181,19 +667,141 @@ go-admin migrate --app crm -c config/settings.yml # 只跑 crm 的迁移
|
||||
```
|
||||
|
||||
`cmd/api/server_test.go` 里的 `freshRuntime` 就是这个。
|
||||
3. **`migration.ForApp` 是主仓的东西**,core 不认识它,上面那份文档不覆盖它。
|
||||
它的约束仍然是"注册要在迁移调度循环跑起来之前",实践上就是 `init()`。
|
||||
3. **迁移的调度循环是主仓的东西**,core 只有注册面。迁移注册的约束仍然是
|
||||
"赶在调度循环跑起来之前",实践上就是 `init()`。
|
||||
|
||||
---
|
||||
|
||||
## 生命周期挂载点
|
||||
|
||||
除了注册路由和迁移,应用还可以把工作挂在进程生命的四个时点上,不必等宿主按名字来调自己。
|
||||
契约本身在 core,见
|
||||
[go-admin-core `docs/contract.md`](https://github.com/go-admin-team/go-admin-core/blob/main/docs/contract.md)
|
||||
的「Life-cycle phases」一节。这里只写**在本仓里它们分别落在哪一行**。
|
||||
|
||||
| 阶段 | 在 `cmd/api/server.go` 的位置 | 此时可用 |
|
||||
|---|---|---|
|
||||
| `AfterResource` | `bootstrap.SetupConfig` 跑完 `database.Setup` / `storage.Setup` 之后 | 配置、库、缓存、队列、casbin |
|
||||
| `BeforeRouter` | `initRouter()` **之前** | 以上,加引擎尚未构建这一事实 |
|
||||
| `AfterListen` | `startServing()` 里,`net.Listen` 返回之后 | 全部,端口**已绑定**、连接进得来 |
|
||||
| `BeforeExit` | `srv.Shutdown` 返回之后(无论它是否报错) | 全部,正在被拆掉 |
|
||||
|
||||
`AfterListen` 承诺的是**端口已绑定**,不是「`Serve` 已经在 accept 循环里」——
|
||||
`srv.Serve` 在另一个 goroutine 上。这个区别是真实的:绑定成功之后内核就会把连接排进
|
||||
backlog,所以钩子里去连自己的端口不会被拒;但此刻 `Serve` 可能还没跑到第一次 `Accept`。
|
||||
绑定失败则**根本不会有这个阶段**:`net.Listen` 的错误直接从 `run()` 返回,
|
||||
横幅不打印,进程非零退出。
|
||||
|
||||
```go
|
||||
sdk.Runtime.SetPhase(runtime.AfterResource, func() { /* ... */ })
|
||||
sdk.Runtime.SetShutdown(func(ctx context.Context) { /* ... */ })
|
||||
```
|
||||
|
||||
### `BeforeRouter` 不等于 `before` 注册表
|
||||
|
||||
**这两个不是同一个时点,文档里别混着写。** `SetBefore` 的回调由
|
||||
`runStartupHooks()` 执行,而那是在 `initRouter()` **之后**——引擎已经建好了。
|
||||
`BeforeRouter` 在它之前。
|
||||
|
||||
顺带:`BeforeRouter` 是「硬约束:注册要赶在启动钩子之前」那一节所说的合法注册窗口之一。
|
||||
它早于 `runStartupHooks()`,所以在这里调 `sdk.Runtime.SetAppRouters` 仍然来得及。
|
||||
|
||||
### `AfterResource` 会跑很多次,回调必须扛得住
|
||||
|
||||
它在**每次配置热更新之后**都会再跑一遍,因为热更新会重建它所命名的那些资源。
|
||||
所以这里的回调要求是**「对同一个资源幂等」,不是「第二次什么都不做」**。
|
||||
|
||||
本仓自己的队列消费者就是这条规则的样板,也是它存在的理由
|
||||
(`cmd/api/server.go` 的 `attachQueueConsumers`):
|
||||
|
||||
- 热更新重建了队列适配器,挂在旧适配器上的消费者连着一个**再没人往里发消息**的队列,
|
||||
登录日志和操作日志就此停写且不出声。所以新适配器**必须**重新注册。
|
||||
- 但同一个适配器不能注册两次,否则每条消息有两个消费者,每行日志写两遍。
|
||||
|
||||
**身份不能从访问器取。** `sdk.Runtime.GetQueueAdapter()` 与 `GetQueuePrefix()`
|
||||
每次调用都新造一个 `runtime.Queue` 包装,比较两次返回等于比较两个包装,
|
||||
**底层适配器换过多少次都不相等**。要在**创建资源的地方**记身份——
|
||||
本仓是 `common/storage.QueueGeneration()`。
|
||||
|
||||
### 注册消费者要赶在队列启动之前
|
||||
|
||||
走哪条实现,取决于配置里有没有 `redis:` 段(`config.QueueConfig.Setup()`):
|
||||
|
||||
| 配置 | 实际类型 | 启动后还能注册吗 |
|
||||
|---|---|---|
|
||||
| `queue: memory:` | `queue.NewMemory` | **能**。它的 `Register` 每次起一个消费 goroutine,不看是否已 `Run` |
|
||||
| `queue: redis:` | `storage.LegacyQueueAdapter` 包着新契约实现 | **不能**。`Register` 内部调 `Subscribe`,启动后返回 `storage.ErrQueueAlreadyStarted` |
|
||||
|
||||
而 `LegacyQueueAdapter.Register` **没有返回值**——它只能把这个错误写进 slog,
|
||||
core 里那行注释自己写着「The interface has no way to report this to the caller」。
|
||||
**静默的是注册这一步,不是之后。** 没有建立消费组,redis 会用
|
||||
`storage.ErrNoHandler` 拒绝**之后的每一次投递**,而本仓两个调用点
|
||||
(`common/middleware/logger.go`、`common/middleware/handler/auth.go`)
|
||||
都把它记为 error——于是日志行一条都不落库,同时每个请求刷一条错误日志。
|
||||
|
||||
所以顺序是硬的:**先 `Register` 完,再由注册方 `Run()`。**
|
||||
`common/storage` 的 `setupQueue` 有意不启动队列。
|
||||
|
||||
默认配置选的是 memory 后端,它不在乎顺序——**这个缺陷在默认部署里看不见,
|
||||
只在配了 redis 的部署上发作**,而丢掉的正是登录日志、操作日志和 api 检查。
|
||||
|
||||
### `BeforeExit` 反序执行,预算约束的是等待
|
||||
|
||||
清理按**注册的逆序**执行。`SetShutdown` 拿到宿主剩余的预算,
|
||||
但**它约束的是等待,不是工作**:预算用尽时 `RunShutdown` 停止等待并返回,
|
||||
而不检查 context 的回调会一直跑到进程退出。Go 没法取消一个不检查取消的函数。
|
||||
|
||||
本仓的样板是 cron(`app/jobs/jobbase.go` 的 `startCrontab`):
|
||||
`cron.Stop()` 返回一个在**已经在跑的任务结束时**关闭的 context,
|
||||
钩子在它和预算之间二选一。
|
||||
|
||||
---
|
||||
|
||||
## 安全边界:装一个应用等于信任它
|
||||
|
||||
**这一层划不出安全边界,本文不假装划得出。**
|
||||
|
||||
第三方应用的代码在**宿主进程内**运行,与宿主**同权限**。它持有的是裸的
|
||||
`*gorm.DB`——`seed.SeedMenus` 用的就是你自己迁移里那个 `tx`,绕开 `Seeder`
|
||||
直写 `sys_menu`、`sys_api`、甚至 `casbin_rule` 一直都做得到,Go 的类型系统
|
||||
拦不住,本框架的任何一层也拦不住。
|
||||
|
||||
还有一条**不碰 `casbin_rule` 也能走通**的间接路径:把自己的菜单通过
|
||||
`ApiCodes` 关联到别人的接口,然后等管理员在后台把这个菜单授权给某个角色——
|
||||
策略是后台自己生成的,记在管理员头上。
|
||||
|
||||
所以:
|
||||
|
||||
> **装一个应用,等于信任它。** 这和 `import _` 一个 Go 库是同一量级的信任。
|
||||
> `Seeder` 这类设计的目的是让**守规矩的应用不必知道宿主的表结构**,
|
||||
> 不是把不守规矩的应用关起来。
|
||||
|
||||
给使用者的实际建议只有一条:**按信任 Go 依赖的标准来审应用**——看源码、
|
||||
钉版本、认作者。不要因为它叫"应用"就以为它跑在沙箱里。
|
||||
|
||||
---
|
||||
|
||||
## 边界由 CI 守着
|
||||
|
||||
`common/`、`core/` 不得 import `app/`,这条由 `tools/checksilent` 的
|
||||
`contract-import-boundary` 检查固化,`make checksilent` 在 CI 里跑,违反即失败
|
||||
(测试文件同样算 —— 一个删掉 `app/admin` 的 fork 也应该能跑 `go test ./...`)。
|
||||
`tools/checksilent` 里有两条盯契约面的检查,`make checksilent` 在 CI 里跑,
|
||||
命中 ERROR 即失败:
|
||||
|
||||
靠人工评审列契约面会漏。上面那两处反向依赖里,第二处就是评审没发现、
|
||||
靠机器全量扫描才找出来的。
|
||||
| 检查 | 盯的是 |
|
||||
|---|---|
|
||||
| `contract-import-boundary` | `common/`、`core/` 不得 import `app/`——否则一个删掉 `app/admin` 的 fork 就编译不了它被告知可以依赖的那一面 |
|
||||
| `contract-shim-alias` | 从 core 契约包声明出来的类型必须是**别名**(`type X = pkg.Y`),不能是 defined type。判据是右手边,不是一份包名清单,所以谁在哪加的都算 |
|
||||
|
||||
`tools/checksilent` 还检查另外五类"不出声的失败",写模块时值得先看一眼
|
||||
第二条守的是一条一个字符的差别。`type X = pkg.Y` 和 `type X pkg.Y`
|
||||
看着几乎一样,但后者只拿走底层结构、**丢掉整个方法集**,于是嵌了它的 model
|
||||
不再满足 `ActiveRecord`。麻烦在于这**不一定在本仓编译失败**——本仓只用接口
|
||||
使唤其中一部分类型,没被使唤到的那些在这里编译得好好的,
|
||||
**到第三方应用或某个 fork 里才炸**,而那里没人看着。
|
||||
|
||||
测试文件同样算——一个删掉 `app/admin` 的 fork 也应该能跑 `go test ./...`。
|
||||
|
||||
**这两条工具都只扫仓库树。** 装在 module cache 里的第三方应用,
|
||||
`checksilent` 一个文件都看不到。所以它保的是**这个仓库和它的 fork**,
|
||||
不是你的应用——你的应用要自己跑自己的检查。
|
||||
|
||||
`checksilent` 还检查另外五类"不出声的失败",写模块时值得先看一眼
|
||||
`go run ./tools/checksilent -h`。
|
||||
|
||||
@@ -11,7 +11,7 @@ require (
|
||||
github.com/casbin/casbin/v3 v3.8.1
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
github.com/glebarez/sqlite v1.11.0
|
||||
github.com/go-admin-team/go-admin-core/v2 v2.5.0
|
||||
github.com/go-admin-team/go-admin-core/v2 v2.7.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/huaweicloud/huaweicloud-sdk-go-obs v3.26.6+incompatible
|
||||
github.com/mssola/user_agent v0.6.0
|
||||
|
||||
@@ -147,6 +147,10 @@ github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GM
|
||||
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
||||
github.com/go-admin-team/go-admin-core/v2 v2.5.0 h1:aD1SALklBxizGB9u8cOgm4OT8z656FM83F4fD6dMz9g=
|
||||
github.com/go-admin-team/go-admin-core/v2 v2.5.0/go.mod h1:LG/XvEfOplbuadKrPTPm0Nu5pN06aQUNZZC3ao4B4gs=
|
||||
github.com/go-admin-team/go-admin-core/v2 v2.6.0 h1:sRoZaxniTpbe287uR/uWpA14Jl1GTAcfGXLKoBLph2w=
|
||||
github.com/go-admin-team/go-admin-core/v2 v2.6.0/go.mod h1:LG/XvEfOplbuadKrPTPm0Nu5pN06aQUNZZC3ao4B4gs=
|
||||
github.com/go-admin-team/go-admin-core/v2 v2.7.0 h1:1qV0/5iFBvkE3BRtm4ip0v0QYG9Fgx4UtOTd8zkQT9c=
|
||||
github.com/go-admin-team/go-admin-core/v2 v2.7.0/go.mod h1:LG/XvEfOplbuadKrPTPm0Nu5pN06aQUNZZC3ao4B4gs=
|
||||
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||
github.com/go-kit/kit v0.10.0/go.mod h1:xUsJbQ/Fp4kEt7AFgCuvyX4a71u8h9jB8tj/ORgOZ7o=
|
||||
|
||||
+162
-2
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"go/ast"
|
||||
"go/token"
|
||||
"path"
|
||||
"sort"
|
||||
@@ -18,6 +19,8 @@ const (
|
||||
checkConfigValue = "config-value-truncation"
|
||||
checkMenuIDConflict = "menu-id-collision"
|
||||
checkImportBoundary = "contract-import-boundary"
|
||||
checkShimAlias = "contract-shim-alias"
|
||||
checkDataScopeRoute = "datascope-route-unguarded"
|
||||
)
|
||||
|
||||
// Package paths, relative to the module. Spelled once so a module rename
|
||||
@@ -44,6 +47,8 @@ func runChecks(s *snapshot, opt options) ([]Finding, error) {
|
||||
out = append(out, checkConfigValueLength(s)...)
|
||||
out = append(out, checkMenuIDCollisions(s)...)
|
||||
out = append(out, checkContractImportBoundary(s)...)
|
||||
out = append(out, checkContractShimAlias(s)...)
|
||||
out = append(out, checkDataScopeRoutes(s)...)
|
||||
|
||||
if opt.UIDir != "" {
|
||||
fs, err := checkMenuNames(s, opt.UIDir)
|
||||
@@ -109,6 +114,9 @@ func checkModelTimeMixing(s *snapshot) []Finding {
|
||||
frozen := s.pkg(pkgFrozenModels)
|
||||
|
||||
for _, sf := range s.Files {
|
||||
if sf.isTest() {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(sf.Path, "app/") && sf.Imports(frozen) {
|
||||
tables := tableNames(sf)
|
||||
for name, st := range structTypes(sf) {
|
||||
@@ -166,6 +174,9 @@ func checkMenuSortOverflow(s *snapshot) []Finding {
|
||||
)
|
||||
var out []Finding
|
||||
for _, sf := range s.Files {
|
||||
if sf.isTest() {
|
||||
continue
|
||||
}
|
||||
forEachStructLiteral(sf, func(lit structLiteral) {
|
||||
if !s.isMenuModel(lit) {
|
||||
return
|
||||
@@ -204,6 +215,9 @@ func checkConfigValueLength(s *snapshot) []Finding {
|
||||
const limit = 255
|
||||
var out []Finding
|
||||
for _, sf := range s.Files {
|
||||
if sf.isTest() {
|
||||
continue
|
||||
}
|
||||
forEachStructLiteral(sf, func(lit structLiteral) {
|
||||
if lit.Name != "SysConfig" || !s.isModelPackage(lit.PkgPath) {
|
||||
return
|
||||
@@ -251,6 +265,9 @@ func checkMenuIDCollisions(s *snapshot) []Finding {
|
||||
sites := map[int64][]site{}
|
||||
|
||||
for _, sf := range s.Files {
|
||||
if sf.isTest() {
|
||||
continue
|
||||
}
|
||||
forEachStructLiteral(sf, func(lit structLiteral) {
|
||||
if !s.isMenuModel(lit) {
|
||||
return
|
||||
@@ -379,6 +396,135 @@ func checkContractImportBoundary(s *snapshot) []Finding {
|
||||
return out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// check 7: a shim of a core contract type must be an alias
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// coreModulePrefix and coreContractSegment together identify a package under
|
||||
// core's contract namespace. Matched as prefix plus segment rather than as one
|
||||
// literal path so that a major-version bump of core - which rewrites the
|
||||
// /v2 in every import - does not quietly turn this check off.
|
||||
const (
|
||||
coreModulePrefix = "github.com/go-admin-team/go-admin-core/"
|
||||
coreContractSegment = "/sdk/contract/"
|
||||
)
|
||||
|
||||
// isCoreContractPkg reports whether an import path names one of core's
|
||||
// contract packages.
|
||||
func isCoreContractPkg(path string) bool {
|
||||
return strings.HasPrefix(path, coreModulePrefix) && strings.Contains(path, coreContractSegment)
|
||||
}
|
||||
|
||||
// checkContractShimAlias reports a shim of a core contract type that was
|
||||
// written as a defined type instead of an alias.
|
||||
//
|
||||
// type ControlBy = models.ControlBy // alias: same type, same method set
|
||||
// type ControlBy models.ControlBy // defined type: methods are gone
|
||||
//
|
||||
// The two lines differ by one character and by everything else. A defined type
|
||||
// takes the underlying struct and none of the methods declared on it, so a
|
||||
// model embedding the second one no longer has SetCreateBy or SetUpdateBy and
|
||||
// no longer satisfies ActiveRecord - which is not a warning, it is a compile
|
||||
// error, but only in code that actually uses the method set.
|
||||
//
|
||||
// That is why the compiler is not enough on its own. This repository exercises
|
||||
// some of the contract types through interfaces and some not at all; the ones
|
||||
// it does not exercise compile perfectly well as defined types here and break
|
||||
// in a third-party application, or in a fork's own module, which is where
|
||||
// nobody is looking. The check costs one field of the AST - a type alias
|
||||
// records the position of its '=' - and covers the surface uniformly rather
|
||||
// than covering whatever app/demo happens to touch this month.
|
||||
//
|
||||
// The trigger is the right-hand side, not a list of names: any type declared
|
||||
// from a core contract package is one of these, whoever wrote it and whenever
|
||||
// it was added. A type declared from a local struct literal is not caught by
|
||||
// this - see ScannedShimAliases, which is what stops a run over a tree with no
|
||||
// shims in it from reading as a clean bill of health.
|
||||
func checkContractShimAlias(s *snapshot) []Finding {
|
||||
var out []Finding
|
||||
for _, sf := range s.Files {
|
||||
forEachTypeSpec(sf, func(ts *ast.TypeSpec) {
|
||||
qualifier, pkg, name, ok := qualifiedType(sf, ts.Type)
|
||||
if !ok || !isCoreContractPkg(pkg) {
|
||||
return
|
||||
}
|
||||
if ts.Assign.IsValid() {
|
||||
return // "type X = pkg.Y", which is what it must be
|
||||
}
|
||||
out = append(out, s.finding(Error, checkShimAlias, sf, ts,
|
||||
"%s is declared from %s.%s as a defined type, not an alias;\n"+
|
||||
" a defined type keeps the fields and drops the method set, so anything embedding it stops satisfying\n"+
|
||||
" the interfaces it satisfied before - here it may still compile, in a fork or a third-party app it does not.\n"+
|
||||
" Write it as: type %s = %s.%s",
|
||||
ts.Name.Name, qualifier, name, ts.Name.Name, qualifier, name))
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ScannedShimAliases counts the type aliases into core's contract packages the
|
||||
// snapshot holds, so the summary can say whether checkContractShimAlias found
|
||||
// anything to guard at all.
|
||||
//
|
||||
// Reported for the same reason ScannedContractRoots is: before the contract
|
||||
// packages are lowered into core there are no shims here, the check has
|
||||
// nothing to look at, and a run that printed nothing would look exactly like a
|
||||
// run over a tree that passed.
|
||||
func ScannedShimAliases(s *snapshot) int {
|
||||
n := 0
|
||||
for _, sf := range s.Files {
|
||||
forEachTypeSpec(sf, func(ts *ast.TypeSpec) {
|
||||
_, pkg, _, ok := qualifiedType(sf, ts.Type)
|
||||
if ok && isCoreContractPkg(pkg) && ts.Assign.IsValid() {
|
||||
n++
|
||||
}
|
||||
})
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// forEachTypeSpec visits every type declaration in the file, including the
|
||||
// ones inside a parenthesised type block.
|
||||
func forEachTypeSpec(sf *sourceFile, fn func(*ast.TypeSpec)) {
|
||||
for _, decl := range sf.Syntax.Decls {
|
||||
gen, ok := decl.(*ast.GenDecl)
|
||||
if !ok || gen.Tok != token.TYPE {
|
||||
continue
|
||||
}
|
||||
for _, spec := range gen.Specs {
|
||||
if ts, ok := spec.(*ast.TypeSpec); ok {
|
||||
fn(ts)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// qualifiedType resolves a type expression that names a type in another
|
||||
// package, returning that package's import path and the type name. A bare
|
||||
// identifier, a struct literal or anything else reports false: this asks
|
||||
// specifically "is the right-hand side pkg.Name", which is the shape both a
|
||||
// correct shim and the mistake it guards against are written in.
|
||||
// The qualifier returned is the one written in this file, which is not
|
||||
// path.Base of the import path whenever the import is aliased - and the shim
|
||||
// files alias every one of them (contractmodels, contractdto). A message that
|
||||
// suggests a fix has to spell it the way the file already does, or the line it
|
||||
// tells the author to write does not compile.
|
||||
func qualifiedType(sf *sourceFile, typ ast.Expr) (qualifier, pkgPath, name string, ok bool) {
|
||||
sel, isSel := typ.(*ast.SelectorExpr)
|
||||
if !isSel {
|
||||
return "", "", "", false
|
||||
}
|
||||
ident, isIdent := sel.X.(*ast.Ident)
|
||||
if !isIdent {
|
||||
return "", "", "", false
|
||||
}
|
||||
p, found := sf.imports[ident.Name]
|
||||
if !found {
|
||||
return "", "", "", false
|
||||
}
|
||||
return ident.Name, p, sel.Sel.Name, true
|
||||
}
|
||||
|
||||
// migrationVersion reads the 13-digit timestamp a migration file name starts
|
||||
// with. Files outside the two migration directories are not migrations, however
|
||||
// they are named.
|
||||
@@ -398,9 +544,23 @@ func migrationVersion(rel string) (int64, bool) {
|
||||
return v, true
|
||||
}
|
||||
|
||||
// isMenuModel reports whether a literal is one of the SysMenu models rather
|
||||
// than, say, the SysMenu service struct that shares the name.
|
||||
// isMenuModel reports whether a literal describes a menu row, whichever of
|
||||
// the two shapes it is written in.
|
||||
//
|
||||
// A host module seeds a menu by building the SysMenu model directly. An
|
||||
// application installed from outside this repository cannot reach that type,
|
||||
// so it describes the same row as a seed.MenuSpec and hands it to the host's
|
||||
// Seeder. Both end up in sys_menu and both are subject to its column widths,
|
||||
// so a check that knew only the first shape would go quiet exactly when the
|
||||
// author is furthest from the schema it protects.
|
||||
//
|
||||
// That is not hypothetical: this repository's own reference application was
|
||||
// written with a Sort of 200 - past the tinyint sys_menu.sort is built as -
|
||||
// and this check passed it, because a MenuSpec is not a SysMenu.
|
||||
func (s *snapshot) isMenuModel(lit structLiteral) bool {
|
||||
if lit.Name == "MenuSpec" && isCoreContractPkg(lit.PkgPath) {
|
||||
return true
|
||||
}
|
||||
return lit.Name == "SysMenu" && s.isModelPackage(lit.PkgPath)
|
||||
}
|
||||
|
||||
|
||||
@@ -452,3 +452,219 @@ func TestComponentNameParsesBothVueStyles(t *testing.T) {
|
||||
t.Error("a component with no declared name must not be compared")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const coreContractModels = "github.com/go-admin-team/go-admin-core/v2/sdk/contract/models"
|
||||
|
||||
// shimFixture writes one shim file declaring ControlBy from core's contract
|
||||
// package, in whichever of the two forms the caller asks for.
|
||||
func shimFixture(t *testing.T, decl string) string {
|
||||
t.Helper()
|
||||
return fixture(t, map[string]string{
|
||||
"common/models/by.go": "package models\n\nimport \"" + coreContractModels + "\"\n\n" + decl + "\n",
|
||||
})
|
||||
}
|
||||
|
||||
func TestShimAliasDetectsADefinedType(t *testing.T) {
|
||||
root := shimFixture(t, "type ControlBy models.ControlBy")
|
||||
|
||||
f := requireOne(t, check(t, root, options{}), checkShimAlias)
|
||||
if f.Severity != "ERROR" {
|
||||
t.Errorf("severity = %s", f.Severity)
|
||||
}
|
||||
if !strings.Contains(f.Message, "type ControlBy = models.ControlBy") {
|
||||
t.Errorf("the message must spell out the fix; got %s", f.Message)
|
||||
}
|
||||
if f.File != "common/models/by.go" || f.Line != 5 {
|
||||
t.Errorf("position = %s:%d", f.File, f.Line)
|
||||
}
|
||||
}
|
||||
|
||||
// The counterproof for the check above: the same fixture with the one
|
||||
// character that makes it correct must produce nothing. Without this the check
|
||||
// could be reporting every type declaration it sees and the test above would
|
||||
// still pass.
|
||||
func TestShimAliasAcceptsAnAlias(t *testing.T) {
|
||||
root := shimFixture(t, "type ControlBy = models.ControlBy")
|
||||
if got := only(t, check(t, root, options{}), checkShimAlias); len(got) != 0 {
|
||||
t.Errorf("reported %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A parenthesised type block is how a shim package with more than one type
|
||||
// tends to get written, and a walker that only looked at single-spec
|
||||
// declarations would skip all but the first.
|
||||
func TestShimAliasReadsAParenthesisedBlock(t *testing.T) {
|
||||
root := shimFixture(t, `type (
|
||||
Model = models.Model
|
||||
ControlBy models.ControlBy
|
||||
ModelTime = models.ModelTime
|
||||
)`)
|
||||
f := requireOne(t, check(t, root, options{}), checkShimAlias)
|
||||
if !strings.Contains(f.Message, "ControlBy") {
|
||||
t.Errorf("message = %s", f.Message)
|
||||
}
|
||||
}
|
||||
|
||||
// A defined type over a package that is not core's contract namespace is
|
||||
// somebody's ordinary code. The check exists for the surface core promises to
|
||||
// keep stable, and reporting anything else would make it a style rule.
|
||||
func TestShimAliasIgnoresOtherPackages(t *testing.T) {
|
||||
root := fixture(t, map[string]string{
|
||||
"app/demo/models/product.go": `package models
|
||||
|
||||
import "go-admin/common/models"
|
||||
|
||||
type Product models.Model
|
||||
`,
|
||||
})
|
||||
if got := only(t, check(t, root, options{}), checkShimAlias); len(got) != 0 {
|
||||
t.Errorf("reported %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The version is part of core's import path and changes on every major bump.
|
||||
// Matching the whole path literally would turn the check off on that day and
|
||||
// say nothing about it.
|
||||
func TestShimAliasSurvivesACoreMajorVersionBump(t *testing.T) {
|
||||
root := fixture(t, map[string]string{
|
||||
"common/models/by.go": `package models
|
||||
|
||||
import "github.com/go-admin-team/go-admin-core/v9/sdk/contract/models"
|
||||
|
||||
type ControlBy models.ControlBy
|
||||
`,
|
||||
})
|
||||
if got := only(t, check(t, root, options{}), checkShimAlias); len(got) != 1 {
|
||||
t.Errorf("findings = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A tree with no shims in it is the state of this repository until the
|
||||
// contract packages are lowered, and the check saying nothing there must not
|
||||
// be reported as a boundary being guarded.
|
||||
func TestShimAliasCoverageIsReportedAsZeroWhenThereAreNoShims(t *testing.T) {
|
||||
root := fixture(t, map[string]string{
|
||||
"common/models/by.go": "package models\n\ntype ControlBy struct{}\n",
|
||||
})
|
||||
s, err := load(root)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if n := ScannedShimAliases(s); n != 0 {
|
||||
t.Errorf("ScannedShimAliases = %d, want 0", n)
|
||||
}
|
||||
|
||||
var buf strings.Builder
|
||||
if _, err := run(&buf, root, options{}, false); err != nil {
|
||||
t.Fatalf("run: %v", err)
|
||||
}
|
||||
if !strings.Contains(buf.String(), "guarded nothing") {
|
||||
t.Errorf("the summary must say the check covered nothing; got:\n%s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestShimAliasCoverageCountsTheAliasesItGuards(t *testing.T) {
|
||||
root := shimFixture(t, `type (
|
||||
Model = models.Model
|
||||
ControlBy = models.ControlBy
|
||||
)`)
|
||||
s, err := load(root)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if n := ScannedShimAliases(s); n != 2 {
|
||||
t.Errorf("ScannedShimAliases = %d, want 2", n)
|
||||
}
|
||||
}
|
||||
|
||||
// A menu written as a seed.MenuSpec lands in the same sys_menu.sort column as
|
||||
// one written as a SysMenu, so the same tinyint bound applies. Until this was
|
||||
// covered, an application - the one author furthest from the schema - was the
|
||||
// one the check went quiet for.
|
||||
func TestMenuSortOverflowIsDetectedInAContractMenuSpec(t *testing.T) {
|
||||
root := fixture(t, map[string]string{
|
||||
"example/app-order/migration/migration.go": `package migration
|
||||
|
||||
import "github.com/go-admin-team/go-admin-core/v2/sdk/contract/seed"
|
||||
|
||||
func menus() []seed.MenuSpec {
|
||||
return []seed.MenuSpec{
|
||||
{Code: "dir", Sort: 200},
|
||||
{Code: "ok", Sort: 20},
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
|
||||
f := requireOne(t, check(t, root, options{}), checkMenuSort)
|
||||
if !strings.Contains(f.Message, "200") {
|
||||
t.Errorf("finding should name the offending value, got: %s", f.Message)
|
||||
}
|
||||
}
|
||||
|
||||
// Every guard against a bad seeded value needs a test that writes the very
|
||||
// value it rejects. Scanning _test.go made each of those guards report its
|
||||
// own test - the check firing on the proof that it works.
|
||||
func TestSeededValueChecksSkipTestFiles(t *testing.T) {
|
||||
root := fixture(t, map[string]string{
|
||||
"cmd/migrate/migration/models/models.go": frozenModelsPkg,
|
||||
"app/admin/service/seed_test.go": `package service
|
||||
|
||||
import "go-admin/cmd/migrate/migration/models"
|
||||
|
||||
func fixtureMenus() []models.SysMenu {
|
||||
return []models.SysMenu{
|
||||
{MenuId: 9000, Sort: 900},
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
|
||||
if got := only(t, check(t, root, options{}), checkMenuSort); len(got) != 0 {
|
||||
t.Fatalf("%s fired on a test fixture: %v", checkMenuSort, got)
|
||||
}
|
||||
}
|
||||
|
||||
// The other direction: the exemption must not turn the check off. A real
|
||||
// seed - the thing that actually reaches MySQL - is still reported.
|
||||
func TestSeededValueChecksStillCoverNonTestFiles(t *testing.T) {
|
||||
root := fixture(t, map[string]string{
|
||||
"cmd/migrate/migration/models/models.go": frozenModelsPkg,
|
||||
"cmd/migrate/migration/version/1786700001000_seed.go": `package version
|
||||
|
||||
import "go-admin/cmd/migrate/migration/models"
|
||||
|
||||
func seed() []models.SysMenu {
|
||||
return []models.SysMenu{
|
||||
{MenuId: 9000, Sort: 900},
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
|
||||
f := requireOne(t, check(t, root, options{}), checkMenuSort)
|
||||
if !strings.Contains(f.Message, "900") {
|
||||
t.Errorf("finding = %+v", f)
|
||||
}
|
||||
}
|
||||
|
||||
// The suggested fix has to use the qualifier the file actually writes. Every
|
||||
// shim in this repository aliases its import (contractmodels, contractdto),
|
||||
// so building the message from path.Base of the import path told the author
|
||||
// to write a line that does not compile.
|
||||
func TestShimAliasSuggestionUsesTheInSourceQualifier(t *testing.T) {
|
||||
root := fixture(t, map[string]string{
|
||||
"common/models/by.go": "package models\n\nimport contractmodels \"" + coreContractModels +
|
||||
"\"\n\ntype ControlBy contractmodels.ControlBy\n",
|
||||
})
|
||||
|
||||
f := requireOne(t, check(t, root, options{}), checkShimAlias)
|
||||
if !strings.Contains(f.Message, "type ControlBy = contractmodels.ControlBy") {
|
||||
t.Errorf("the fix must name the import as this file spells it; got %s", f.Message)
|
||||
}
|
||||
if strings.Contains(f.Message, "= models.ControlBy") {
|
||||
t.Errorf("the fix names a qualifier this file does not bind; got %s", f.Message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,390 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// check 8: a handler that reads the data permission, on a route that never
|
||||
// installs the middleware which puts one there
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// permissionGetter is the function a handler calls to obtain the caller's data
|
||||
// scope, and permissionMiddleware is the middleware that puts one in the
|
||||
// context. Matched by name rather than by resolved symbol: the tool parses
|
||||
// without type checking, and both names are distinctive enough that a
|
||||
// same-named function from somewhere else would still be worth a look.
|
||||
const (
|
||||
permissionGetter = "GetPermissionFromContext"
|
||||
permissionMiddleware = "PermissionAction"
|
||||
)
|
||||
|
||||
// actionsPkgSuffix identifies the package the two names above live in - this
|
||||
// repository's common/actions shim and core's sdk/contract/actions both end
|
||||
// this way, and a module rename changes neither.
|
||||
const actionsPkgSuffix = "/actions"
|
||||
|
||||
// handlerKey identifies one handler method uniquely across packages, so that
|
||||
// two types named SysUser in different packages are not confused.
|
||||
type handlerKey struct {
|
||||
Pkg string
|
||||
Type string
|
||||
Func string
|
||||
}
|
||||
|
||||
// checkDataScopeRoutes reports a route whose handler asks for the caller's data
|
||||
// permission while the group it is registered on never installs the middleware
|
||||
// that supplies one.
|
||||
//
|
||||
// GetPermissionFromContext cannot fail. When nothing put a *DataPermission in
|
||||
// the context it hands back a zero value, whose DataScope is the empty string -
|
||||
// and the empty string is not one of the five scopes Permission recognises, so
|
||||
// it takes the default branch. That branch fails closed: the query is given
|
||||
// `1 = 0` and matches nothing.
|
||||
//
|
||||
// The result is an endpoint that answers "not found" or "no permission" for
|
||||
// rows that plainly exist, and only on deployments that set enabledp: true -
|
||||
// with data permissions off, Permission returns the query untouched and the
|
||||
// missing middleware costs nothing. That is the shape this check exists for: a
|
||||
// default configuration where the mistake is invisible, and a test suite that
|
||||
// runs on it.
|
||||
//
|
||||
// It happened. /api/v1/getinfo read the permission on a group carrying only the
|
||||
// JWT middleware, so every login on a deployment with data permissions enabled
|
||||
// ended in a 401 from the endpoint the browser calls immediately after signing
|
||||
// in - and went back to the login page.
|
||||
//
|
||||
// Either half is a fix, and which one depends on the route. A handler that
|
||||
// reads somebody else's rows wants the middleware. A handler reading the
|
||||
// caller's own row - where the id comes from the token - wants no scope at all,
|
||||
// because a scope has nothing left to restrict there and DataScopeSelf, which
|
||||
// matches on create_by, would reject every user who did not create their own
|
||||
// account. The check reports the mismatch and leaves the choice.
|
||||
func checkDataScopeRoutes(s *snapshot) []Finding {
|
||||
handlers := permissionReadingHandlers(s)
|
||||
if len(handlers) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var out []Finding
|
||||
for _, sf := range s.Files {
|
||||
if sf.isTest() {
|
||||
continue
|
||||
}
|
||||
for _, decl := range sf.Syntax.Decls {
|
||||
fn, ok := decl.(*ast.FuncDecl)
|
||||
if !ok || fn.Body == nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, s.routeFindings(sf, fn, handlers)...)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// permissionReadingHandlers collects every method whose body calls the getter.
|
||||
//
|
||||
// Test files are included deliberately: a handler is a handler wherever it is
|
||||
// declared, and skipping them would let a route registered from a test fixture
|
||||
// go unchecked while the fixture is exactly where a new one gets written first.
|
||||
func permissionReadingHandlers(s *snapshot) map[handlerKey]bool {
|
||||
out := map[handlerKey]bool{}
|
||||
for _, sf := range s.Files {
|
||||
for _, decl := range sf.Syntax.Decls {
|
||||
fn, ok := decl.(*ast.FuncDecl)
|
||||
if !ok || fn.Body == nil || fn.Recv == nil || len(fn.Recv.List) == 0 {
|
||||
continue
|
||||
}
|
||||
recv := receiverTypeName(fn.Recv.List[0].Type)
|
||||
if recv == "" {
|
||||
continue
|
||||
}
|
||||
if callsPackageFunc(sf, fn.Body, permissionGetter) {
|
||||
out[handlerKey{Pkg: sf.Pkg, Type: recv, Func: fn.Name.Name}] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// routeFindings walks one function looking for group definitions and the routes
|
||||
// registered on them.
|
||||
func (s *snapshot) routeFindings(sf *sourceFile, fn *ast.FuncDecl, handlers map[handlerKey]bool) []Finding {
|
||||
// Local variable bindings for the whole function. The first pass below
|
||||
// fills these and the second reads them, so a registration sees every
|
||||
// binding in the function rather than only the ones written above it -
|
||||
// deliberately, because a `.Use` can be written below a route and still be
|
||||
// part of the chain. The cost is that a name reused for two different
|
||||
// things in one function resolves to whichever assignment came last.
|
||||
apiVars := map[string]handlerKey{} // var -> the type it holds
|
||||
guarded := map[string]bool{} // group var -> middleware installed
|
||||
known := map[string]bool{} // group var -> is a router group at all
|
||||
prefix := map[string]string{} // group var -> the path it was declared with
|
||||
|
||||
var out []Finding
|
||||
ast.Inspect(fn.Body, func(n ast.Node) bool {
|
||||
switch stmt := n.(type) {
|
||||
case *ast.AssignStmt:
|
||||
for i, lhs := range stmt.Lhs {
|
||||
id, ok := lhs.(*ast.Ident)
|
||||
if !ok || i >= len(stmt.Rhs) {
|
||||
continue
|
||||
}
|
||||
rhs := stmt.Rhs[i]
|
||||
if key, ok := apiTypeOf(sf, rhs); ok {
|
||||
apiVars[id.Name] = key
|
||||
continue
|
||||
}
|
||||
if parent, isGroup := groupSource(rhs); isGroup {
|
||||
known[id.Name] = true
|
||||
prefix[id.Name] = prefix[parent] + groupPath(rhs)
|
||||
// A subgroup inherits whatever its parent already had:
|
||||
// gin copies the parent's handler chain into the child.
|
||||
guarded[id.Name] = guarded[parent] || containsCallNamed(rhs, permissionMiddleware)
|
||||
}
|
||||
}
|
||||
case *ast.ExprStmt:
|
||||
// A separate `g.Use(...)` after the group was defined.
|
||||
call, ok := stmt.X.(*ast.CallExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
if target, ok := receiverIdentOf(call, "Use"); ok && known[target] {
|
||||
if containsCallNamed(call, permissionMiddleware) {
|
||||
guarded[target] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
// Second pass for the registrations, so that a `.Use` written below a route
|
||||
// still counts - the middleware chain is assembled before any request is
|
||||
// served, not in source order.
|
||||
ast.Inspect(fn.Body, func(n ast.Node) bool {
|
||||
call, ok := n.(*ast.CallExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
gvar, method, ok := routeRegistration(call)
|
||||
if !ok || !known[gvar] || guarded[gvar] {
|
||||
return true
|
||||
}
|
||||
route, handlerVar, handlerName, ok := routeArgs(call)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
key, ok := apiVars[handlerVar]
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
key.Func = handlerName
|
||||
if !handlers[key] {
|
||||
return true
|
||||
}
|
||||
out = append(out, s.finding(Error, checkDataScopeRoute, sf, call,
|
||||
"%s %q is handled by %s.%s, which reads the caller's data permission,\n"+
|
||||
" but the group it is registered on never installs %s.\n"+
|
||||
" GetPermissionFromContext then returns the zero value, whose empty DataScope is not a\n"+
|
||||
" recognised scope, so Permission fails closed and the query matches nothing - on any\n"+
|
||||
" deployment with enabledp: true. With data permissions off the route works, which is\n"+
|
||||
" why this does not show up in the default configuration or in CI.\n"+
|
||||
" Add %s() to the group, or stop scoping a query that is already limited to the caller.",
|
||||
method, prefix[gvar]+route, key.Type, handlerName, permissionMiddleware, permissionMiddleware))
|
||||
return true
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// receiverTypeName returns the bare type name of a method receiver, for both
|
||||
// `(e SysUser)` and `(e *SysUser)`.
|
||||
func receiverTypeName(expr ast.Expr) string {
|
||||
if star, ok := expr.(*ast.StarExpr); ok {
|
||||
expr = star.X
|
||||
}
|
||||
if id, ok := expr.(*ast.Ident); ok {
|
||||
return id.Name
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// callsPackageFunc reports whether body calls name on a package whose import
|
||||
// path ends in actionsPkgSuffix.
|
||||
func callsPackageFunc(sf *sourceFile, body ast.Node, name string) bool {
|
||||
found := false
|
||||
ast.Inspect(body, func(n ast.Node) bool {
|
||||
if found {
|
||||
return false
|
||||
}
|
||||
call, ok := n.(*ast.CallExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
sel, ok := call.Fun.(*ast.SelectorExpr)
|
||||
if !ok || sel.Sel.Name != name {
|
||||
return true
|
||||
}
|
||||
pkg, ok := sel.X.(*ast.Ident)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
if path, ok := sf.imports[pkg.Name]; ok && strings.HasSuffix(path, actionsPkgSuffix) {
|
||||
found = true
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
return found
|
||||
}
|
||||
|
||||
// apiTypeOf recognises `apis.SysUser{}` and returns the package path and type.
|
||||
func apiTypeOf(sf *sourceFile, expr ast.Expr) (handlerKey, bool) {
|
||||
lit, ok := expr.(*ast.CompositeLit)
|
||||
if !ok {
|
||||
return handlerKey{}, false
|
||||
}
|
||||
sel, ok := lit.Type.(*ast.SelectorExpr)
|
||||
if !ok {
|
||||
return handlerKey{}, false
|
||||
}
|
||||
pkg, ok := sel.X.(*ast.Ident)
|
||||
if !ok {
|
||||
return handlerKey{}, false
|
||||
}
|
||||
path, ok := sf.imports[pkg.Name]
|
||||
if !ok {
|
||||
return handlerKey{}, false
|
||||
}
|
||||
return handlerKey{Pkg: path, Type: sel.Sel.Name}, true
|
||||
}
|
||||
|
||||
// groupSource reports whether expr builds a router group, and names the
|
||||
// variable it was built from when there is one.
|
||||
func groupSource(expr ast.Expr) (string, bool) {
|
||||
parent := ""
|
||||
isGroup := false
|
||||
ast.Inspect(expr, func(n ast.Node) bool {
|
||||
call, ok := n.(*ast.CallExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
sel, ok := call.Fun.(*ast.SelectorExpr)
|
||||
if !ok || sel.Sel.Name != "Group" {
|
||||
return true
|
||||
}
|
||||
isGroup = true
|
||||
if id, ok := sel.X.(*ast.Ident); ok {
|
||||
parent = id.Name
|
||||
}
|
||||
return true
|
||||
})
|
||||
return parent, isGroup
|
||||
}
|
||||
|
||||
// groupPath returns the literal path a group was declared with, or "" when it
|
||||
// is not a literal - a computed prefix is left out of the message rather than
|
||||
// printed as something it is not.
|
||||
func groupPath(expr ast.Expr) string {
|
||||
out := ""
|
||||
ast.Inspect(expr, func(n ast.Node) bool {
|
||||
call, ok := n.(*ast.CallExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
sel, ok := call.Fun.(*ast.SelectorExpr)
|
||||
if !ok || sel.Sel.Name != "Group" || len(call.Args) == 0 {
|
||||
return true
|
||||
}
|
||||
if lit, ok := call.Args[0].(*ast.BasicLit); ok {
|
||||
out = strings.Trim(lit.Value, `"`)
|
||||
}
|
||||
return true
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// containsCallNamed reports whether expr contains a call to a function with
|
||||
// this name, at any depth of a method chain or argument list.
|
||||
func containsCallNamed(expr ast.Node, name string) bool {
|
||||
found := false
|
||||
ast.Inspect(expr, func(n ast.Node) bool {
|
||||
if found {
|
||||
return false
|
||||
}
|
||||
call, ok := n.(*ast.CallExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
switch fun := call.Fun.(type) {
|
||||
case *ast.SelectorExpr:
|
||||
if fun.Sel.Name == name {
|
||||
found = true
|
||||
return false
|
||||
}
|
||||
case *ast.Ident:
|
||||
if fun.Name == name {
|
||||
found = true
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
return found
|
||||
}
|
||||
|
||||
// receiverIdentOf returns the variable a `x.method(...)` call was made on.
|
||||
func receiverIdentOf(call *ast.CallExpr, method string) (string, bool) {
|
||||
sel, ok := call.Fun.(*ast.SelectorExpr)
|
||||
if !ok || sel.Sel.Name != method {
|
||||
return "", false
|
||||
}
|
||||
id, ok := sel.X.(*ast.Ident)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
return id.Name, true
|
||||
}
|
||||
|
||||
// httpMethods are the registration calls this check understands. Any and Match
|
||||
// are absent on purpose: they take the method as data, and a check that half
|
||||
// understands a registration is worse than one that says nothing about it.
|
||||
var httpMethods = map[string]bool{
|
||||
"GET": true, "POST": true, "PUT": true, "DELETE": true, "PATCH": true, "HEAD": true, "OPTIONS": true,
|
||||
}
|
||||
|
||||
// routeRegistration recognises `g.GET(...)` and names the group and method.
|
||||
func routeRegistration(call *ast.CallExpr) (string, string, bool) {
|
||||
sel, ok := call.Fun.(*ast.SelectorExpr)
|
||||
if !ok || !httpMethods[sel.Sel.Name] {
|
||||
return "", "", false
|
||||
}
|
||||
id, ok := sel.X.(*ast.Ident)
|
||||
if !ok {
|
||||
return "", "", false
|
||||
}
|
||||
return id.Name, sel.Sel.Name, true
|
||||
}
|
||||
|
||||
// routeArgs pulls the path and the `api.Handler` argument out of a
|
||||
// registration, ignoring any middleware written between them.
|
||||
func routeArgs(call *ast.CallExpr) (route, handlerVar, handlerName string, ok bool) {
|
||||
if len(call.Args) < 2 {
|
||||
return "", "", "", false
|
||||
}
|
||||
lit, isLit := call.Args[0].(*ast.BasicLit)
|
||||
if !isLit {
|
||||
return "", "", "", false
|
||||
}
|
||||
route = strings.Trim(lit.Value, `"`)
|
||||
// The handler is the last argument; anything before it is middleware.
|
||||
sel, isSel := call.Args[len(call.Args)-1].(*ast.SelectorExpr)
|
||||
if !isSel {
|
||||
return "", "", "", false
|
||||
}
|
||||
id, isIdent := sel.X.(*ast.Ident)
|
||||
if !isIdent {
|
||||
return "", "", "", false
|
||||
}
|
||||
return route, id.Name, sel.Sel.Name, true
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// apisFile is a handler package with two methods: one that reads the caller's
|
||||
// data permission and one that does not.
|
||||
const apisFile = `package apis
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"go-admin/common/actions"
|
||||
)
|
||||
|
||||
type SysUser struct{}
|
||||
|
||||
func (e SysUser) Scoped(c *gin.Context) {
|
||||
p := actions.GetPermissionFromContext(c)
|
||||
_ = p
|
||||
}
|
||||
|
||||
func (e SysUser) Unscoped(c *gin.Context) {}
|
||||
`
|
||||
|
||||
func routerFile(uses string) string {
|
||||
return `package router
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"go-admin/app/admin/apis"
|
||||
"go-admin/common/actions"
|
||||
)
|
||||
|
||||
var _ = actions.PermissionAction
|
||||
|
||||
func register(v1 *gin.RouterGroup) {
|
||||
api := apis.SysUser{}
|
||||
r := v1.Group("/sys-user")` + uses + `
|
||||
{
|
||||
r.GET("/:id", api.Scoped)
|
||||
}
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
// The mistake itself: a handler that reads the permission, on a group that
|
||||
// never installs the middleware which puts one there.
|
||||
func TestDataScopeRouteWithoutTheMiddlewareIsReported(t *testing.T) {
|
||||
root := fixture(t, map[string]string{
|
||||
"app/admin/apis/sys_user.go": apisFile,
|
||||
"app/admin/router/sys_user.go": routerFile(`.Use(gin.Logger())`),
|
||||
})
|
||||
f := requireOne(t, check(t, root, options{}), checkDataScopeRoute)
|
||||
for _, want := range []string{`GET "/sys-user/:id"`, "SysUser.Scoped", "PermissionAction"} {
|
||||
if !strings.Contains(f.Message, want) {
|
||||
t.Errorf("message does not mention %q:\n%s", want, f.Message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The middleware installed in the chain is the fix, and must silence it.
|
||||
func TestDataScopeRouteWithTheMiddlewareIsQuiet(t *testing.T) {
|
||||
root := fixture(t, map[string]string{
|
||||
"app/admin/apis/sys_user.go": apisFile,
|
||||
"app/admin/router/sys_user.go": routerFile(`.Use(gin.Logger()).Use(actions.PermissionAction())`),
|
||||
})
|
||||
if got := only(t, check(t, root, options{}), checkDataScopeRoute); len(got) != 0 {
|
||||
t.Errorf("reported %d findings for a guarded group:\n%v", len(got), got)
|
||||
}
|
||||
}
|
||||
|
||||
// The other fix - the handler stops reading the permission - must silence it
|
||||
// too. Reporting a route whose handler needs no scope would push people to
|
||||
// install middleware they do not want, which is how /getinfo would have been
|
||||
// "fixed" into rejecting every user who did not create their own account.
|
||||
func TestARouteWhoseHandlerReadsNoPermissionIsQuiet(t *testing.T) {
|
||||
root := fixture(t, map[string]string{
|
||||
"app/admin/apis/sys_user.go": apisFile,
|
||||
"app/admin/router/sys_user.go": `package router
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"go-admin/app/admin/apis"
|
||||
)
|
||||
|
||||
func register(v1 *gin.RouterGroup) {
|
||||
api := apis.SysUser{}
|
||||
r := v1.Group("")
|
||||
{
|
||||
r.GET("/getinfo", api.Unscoped)
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
if got := only(t, check(t, root, options{}), checkDataScopeRoute); len(got) != 0 {
|
||||
t.Errorf("reported %d findings for a handler that reads no permission:\n%v", len(got), got)
|
||||
}
|
||||
}
|
||||
|
||||
// gin copies the parent's handler chain into a subgroup, so a group carved out
|
||||
// of a guarded one is guarded. Reporting it would be a false positive, and a
|
||||
// check that cries wolf is one people switch off.
|
||||
func TestASubgroupInheritsTheMiddleware(t *testing.T) {
|
||||
root := fixture(t, map[string]string{
|
||||
"app/admin/apis/sys_user.go": apisFile,
|
||||
"app/admin/router/sys_user.go": `package router
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"go-admin/app/admin/apis"
|
||||
"go-admin/common/actions"
|
||||
)
|
||||
|
||||
func register(v1 *gin.RouterGroup) {
|
||||
api := apis.SysUser{}
|
||||
parent := v1.Group("/sys").Use(actions.PermissionAction())
|
||||
child := parent.Group("/user")
|
||||
{
|
||||
child.GET("/:id", api.Scoped)
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
if got := only(t, check(t, root, options{}), checkDataScopeRoute); len(got) != 0 {
|
||||
t.Errorf("reported %d findings for a subgroup of a guarded group:\n%v", len(got), got)
|
||||
}
|
||||
}
|
||||
|
||||
// Two packages can both declare a SysUser. Only the one whose method reads the
|
||||
// permission may be reported, or the check becomes a name search.
|
||||
func TestAHandlerIsMatchedByPackageNotJustName(t *testing.T) {
|
||||
root := fixture(t, map[string]string{
|
||||
"app/admin/apis/sys_user.go": apisFile,
|
||||
"app/other/apis/sys_user.go": `package apis
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
type SysUser struct{}
|
||||
|
||||
func (e SysUser) Scoped(c *gin.Context) {}
|
||||
`,
|
||||
"app/other/router/sys_user.go": `package router
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"go-admin/app/other/apis"
|
||||
)
|
||||
|
||||
func register(v1 *gin.RouterGroup) {
|
||||
api := apis.SysUser{}
|
||||
r := v1.Group("/other")
|
||||
{
|
||||
r.GET("/:id", api.Scoped)
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
if got := only(t, check(t, root, options{}), checkDataScopeRoute); len(got) != 0 {
|
||||
t.Errorf("reported %d findings for a same-named handler in another package:\n%v", len(got), got)
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
// Command checksilent reports the failures in this repository that do not
|
||||
// announce themselves: no error, no log line, behaviour quietly wrong.
|
||||
//
|
||||
// Six checks, five of them ERROR and one WARN. An ERROR fails the run; a WARN
|
||||
// Seven checks, six of them ERROR and one WARN. An ERROR fails the run; a WARN
|
||||
// prints and does not. The split is not about how bad the consequence is - all
|
||||
// six are bad - but about how certain the detection is. Everything reported as
|
||||
// seven are bad - but about how certain the detection is. Everything reported as
|
||||
// an ERROR is decided from this repository's own syntax. The one WARN compares
|
||||
// against a second repository through a regular expression, and a check that
|
||||
// can be wrong must not be able to stop a build, or the first response to it
|
||||
@@ -102,4 +102,16 @@ func printSummary(w io.Writer, findings []Finding, opt options, s *snapshot) {
|
||||
fmt.Fprintf(w, "The %s check covered %s; %s does not exist here and was not scanned.\n",
|
||||
checkImportBoundary, strings.Join(scanned, ", "), strings.Join(absent, ", "))
|
||||
}
|
||||
// Same reason: a tree with no alias into core's contract packages gives
|
||||
// this check nothing to look at, and its silence must not be read as a
|
||||
// pass. That is now the interesting case rather than the expected one -
|
||||
// the shims exist, so a count of zero means they stopped being aliases,
|
||||
// or stopped being here.
|
||||
if n := ScannedShimAliases(s); n == 0 {
|
||||
fmt.Fprintf(w, "The %s check found no type alias into core's contract packages and guarded nothing.\n",
|
||||
checkShimAlias)
|
||||
} else {
|
||||
fmt.Fprintf(w, "The %s check covered %d type alias(es) into core's contract packages.\n",
|
||||
checkShimAlias, n)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,19 @@ type sourceFile struct {
|
||||
consts map[string]int64 // package-level integer constants, filled per package
|
||||
}
|
||||
|
||||
// isTest reports whether this file is a _test.go.
|
||||
//
|
||||
// The checks about a seeded value - a menu sort, a config value, a menu id, a
|
||||
// soft-delete shape - are all about what reaches a real database through a
|
||||
// migration, and a test fixture reaches none. Worse, each of those guards
|
||||
// needs a test that writes the very value it rejects, so scanning test files
|
||||
// makes every such guard report its own test. The import and alias checks do
|
||||
// not skip tests: those are about the dependency graph, where a test file's
|
||||
// import is as real as any other.
|
||||
func (f *sourceFile) isTest() bool {
|
||||
return strings.HasSuffix(f.Path, "_test.go")
|
||||
}
|
||||
|
||||
// snapshot is every Go file under the root, parsed once and shared by all the
|
||||
// checks.
|
||||
type snapshot struct {
|
||||
|
||||
Reference in New Issue
Block a user