Compare commits

...
Author SHA1 Message Date
zhangwenjian 047b23872c fix🐛: let a drain that finishes on the deadline count as finished
The wait was one select over done and ctx.Done(). Both can be ready when
it runs, select picks at random among ready cases, and so a queue that
drained in the same instant the budget expired was reported as an
overrun about half the times it landed there - often enough to be read
as noise, and pointing at the wrong thing when it was not. core's own
RunShutdown re-checks for this reason; this did not.

The tie-break is now a function taking channels rather than a queue,
which is what lets a test hand it a closed done and an expired ctx
together. That state is the whole of the bug and cannot be arrived at
reliably from the outside; over 1000 iterations the single-select
version fails, and the second look does not.

The test for giving up on the deadline read the call counter straight
after shutdownQueue returned, while Shutdown runs on a goroutine nobody
joins. It passed because the goroutine is scheduled promptly, not
because anything ordered the two. The fake now signals that Shutdown has
been entered and the test waits for it.

Both raised by Copilot on #918.

The first attempt at the tie-break test was wrong and is not what
landed: it asserted that an immediately-returning Shutdown always counts
as drained under an already-expired context, which is not true and
should not be - if the goroutine has not run, nothing has drained. That
test failed, correctly. What is being claimed is narrower: when both are
ready, done wins.
2026-09-07 17:21:03 +08:00
zhangwenjian 84bd87dcc9 fix🐛: drain the queue on the way out instead of abandoning it
Nothing stopped the queue when the process exited. core v2.7.0 made the
drain work - Memory.Shutdown closes the queue and waits for every
consumer to finish what it holds, and the legacy adapter cancels its
context and closes the underlying queue - but no call site ever reached
it. The only Shutdown() in this repository applies to the previous
adapter during a reload, so the installed one was simply left. The login
log, the operation log and the API sync all publish through it, so a
rolling restart dropped whatever had not been consumed, on the path
where the process exits 0 and reports "Server exiting".

Setup now registers a BeforeExit callback that shuts down the adapter
this package installed.

Three things it has to get right, each with a test.

It reads `installed` when it runs, not when it registers. A reload
replaces the adapter, and the one from start-up is a queue nobody has
published to since.

It never goes through sdk.Runtime.GetQueueAdapter. That accessor never
returns nil - with no queue section configured it wraps the runtime's
own fallback - so it would look like it worked while closing a queue
this package neither built nor started. That is the same trap setupQueue
already had to drop an `if q != nil` for.

It registers once. Setup is re-run on every configuration change, and a
callback per reload would leave the shutdown phase holding a row of
identical entries, each eligible to be named as the one that overran the
budget.

That last one needed a seam. shutdownQueue takes the adapter on its
first run, so the second and third callbacks find nothing and return -
three registrations produce exactly the same observable result as one,
and a test going through the effect passes either way. It did: the
counter-proof for "register on every reload" came back green until the
registration was counted at the seam instead.

The wait is bounded here rather than left to the phase. Shutdown takes
no context, so a consumer that never finishes would hold the process
until SIGKILL; the callback gives up and says what is being lost, which
the phase's generic overrun message cannot.

Ordering falls out of the phase rather than being arranged: callbacks
run in reverse registration order, this one registers during setup and
the job scheduler's registers on AfterListen, so the schedulers stop
before the queue drains. Verified against core v2.7.0 rather than read
off the source.

Closes #911.
2026-09-07 17:10:13 +08:00
wenjianzhang 0e7a13aeba Merge pull request #917 from go-admin-team/fix/914-gen-write-guard
Register the generator's writing endpoints only in a development mode
2026-09-07 15:32:13 +08:00
zhangwenjian 85d50da494 fix🐛: tell the start-up warning's reader to restart, not only to reconfigure
The warning said to set application.mode and stopped there. Following
that on a running process does not close anything: buildRouter has one
call site, in run(), and route registration is on no phase and no reload
callback, so a configuration reload moves the mode and leaves the routes
exactly where they were.

The reader is then worse off than before they acted. The mode now says
prod, GenWriteRoutesEnabled agrees, and the endpoints are still served -
so the one thing they could check to confirm the fix reports success
while the exposure is untouched, until something restarts the process.

A test pins the gap rather than the prose: build under dev, move the
mode to prod, and the routes are still in the engine. It fails if
registration ever becomes dynamic, which is the change that would make
the new sentence wrong.

That test degrades differently from the others - making it fail means
rewriting registration, not weakening it - so what was checked instead
is that it cannot go vacuous. Both of its premises are guarded: with the
gate always refusing it reports building under dev without the writing
routes, and with the gate always allowing it reports the predicate still
allowing prod. Neither failure can be mistaken for the assertion passing.

Raised by Copilot on #917.
2026-09-07 15:20:36 +08:00
zhangwenjian 1d9def4314 test✅: restore the mode before the route helper returns
registeredRoutes set config.ApplicationConfig.Mode and gave it back with
t.Cleanup, which runs at the end of the test rather than at the end of
the helper. Everything the caller did after the call therefore ran under
the mode the helper had been asked about, not one the caller chose.

Nothing was wrong yet: the one caller that reads the mode afterwards
sets it itself, and the two cleanups happen to unwind in an order that
leaves the right value. Both of those are accidents, and neither is
visible at the call site.

A defer inside the helper makes the borrowing end where it starts. The
doc comment said the mode was put back before returning while the code
did not, so that is now true rather than aspirational.

The counter-proof is the reason this has a test of its own: with
t.Cleanup back in place TestRegisteredRoutesRestoresTheModeBeforeReturning
fails and nothing else does, which is what a leak this quiet looks like
when something is actually watching for it.

Raised by Copilot on #917.
2026-09-07 15:14:39 +08:00
zhangwenjian ed9bbd01e2 feat✨: warn at start-up when the generator can write to this host
The gate in the previous commit is decided by application.mode, and the
shipped configuration says dev. So the deployment most likely to be
serving the writing endpoints is the one that changed nothing, and that
is also the one least likely to go looking for them. A gate whose
default is open needs to say so.

Nothing is said in demo mode. The routes are registered there, but
DemoEvn refuses all three by name, so a warning would describe an
exposure that is not present.

The decision is split from the logging so it can be tested. Three
counter-proofs: warning in demo as well fails mode=demo; a warning that
never fires fails mode=dev, which is what shows the line can be reached
at all; and one that always fires fails every mode but dev.
2026-09-07 15:07:07 +08:00
zhangwenjian 523d6a3649 fix🐛: register the generator's writing endpoints only in a development mode
Three of the code generator's endpoints do not read. /gen/toproject
writes seven Go and Vue source files onto the host, one of them under
the path gen.frontpath names; /gen/apitofile writes a migration;
/gen/todb inserts menus and APIs. All three are GET, and all three are
listed in CasbinExclude - which AuthCheckRole skips - so Enforce never
runs for them. Any account that can log in could call them, on every
deployment.

They are now registered only where application.mode is dev or demo. dev
is the shipped default and is where the generator is meant to be used.
demo keeps them because demo mode already has a better answer than a
404: DemoEvn refuses these three by name and explains itself, which is
what the demo is for. test and prod get nothing, and so does a process
whose mode was never set.

This does not make the endpoints safe where they exist; it stops them
existing where nobody should be calling them. A host left on the shipped
dev is still open, which is why the next commit says so at start-up.

CasbinExclude is left alone on purpose. Taking the three off that list
would make them require a permission no existing deployment has granted,
so every non-admin user would start getting 403 from a tool that worked
yesterday. That is a migration, not a guard, and it belongs with a
release that can carry one.

Four counter-proofs, each red on the test that names the behaviour and
green everywhere else: a gate that always allows fails test/prod/unset
only; a gate that always refuses fails dev/demo and takes
TestEveryRouteDemoModeRefusesStillExists with it; moving a read-only
route inside the gate fails the reading test; and spelling the condition
at the registration site instead of calling the predicate fails the
agreement test, which is what keeps that test from being a tautology.
2026-09-07 15:05:24 +08:00
zhangwenjian 6326962862 style🎨: gofmt gen_router.go
Two pre-existing deviations: a space before the comma in
sysNoCheckRoleRouter's parameter list, and no newline at end of file.
Separated from the change that follows so its diff is only the change.
2026-09-07 15:02:33 +08:00
wenjianzhang cd7c8375c0 Merge pull request #916 from go-admin-team/docs/replicas-constraint
Name the job scheduler as the other reason for one replica, and stop the manifests redeploying the demo
2026-09-07 14:52:53 +08:00
zhangwenjian 63bcc912ef ci👷: stop the k8s manifests from redeploying the demo
The comment at the top of this workflow says documentation-only changes
skip it, because a push to master pushes an image, runs the migrations
and restarts the demo container. The ignore list did not cover
scripts/k8s, so editing a manifest that the deploy never reads bought
the site an outage.

The pattern is scripts/k8s/** rather than scripts/** because
scripts/Dockerfile is a build input - go.yml builds the release image
from it on a tag.

This workflow file stays outside the list on purpose. paths-ignore skips
only when every changed path matches, so a change that edits the deploy
still runs it, which is the point.
2026-09-07 14:14:39 +08:00
zhangwenjian adcdd2edcd docs📝: name the job scheduler as the other reason for one replica
The comment on replicas gave one obstacle to raising it, the shared log
volume, which reads as the only one. Someone who moves the log path off
that volume would conclude the way is clear.

The scheduler in app/jobs is the second, and it is the one that does not
announce itself. Its handle on a job lives in sys_job.entry_id, one
column shared by every process, and startup zeroes the whole column
before writing its own ids. A second pod therefore erases the first
pod's, and both pods run the full enabled list. Stopping a job from the
UI then removes an entry from whichever process is asked, by an id that
belongs to another one, and answers 200.

See #915.
2026-09-07 13:32:43 +08:00
wenjianzhang 29406f839e Merge pull request #913 from go-admin-team/fix/demo-mode-guard
fix🐛: demo 模式放行了注册成 GET 的写接口
2026-09-07 08:11:14 +08:00
zhangwenjian a43133ab7b fix🐛: stop demo mode serving the writes that are registered as GET
DemoEvn decided by HTTP method: GET and OPTIONS through, everything else
refused. Three of the code generator's routes are registered as GET and write
anyway - two emit Go source files onto the server's filesystem, and the third
inserts menus, APIs and casbin rules into the database. They sit in a group
whose own name says it does no role check, and a demo deployment lets anybody
log in. So on the demo host any visitor could write to the machine and to the
database, and the one that writes menus had in fact been used: three generated
SysCasbinRule entries is how this was noticed.

The guard now also looks at the matched route. The method cannot answer the
question - whether a request changes anything is not something the verb reports
truthfully here - so the three are named, as gin route patterns, which is what
Context.FullPath returns and how CasbinExclude already spells them.

Changing them to POST would be the better shape and is not this change. sys_api
records an endpoint by method and path and the casbin policy follows it, so
flipping the verb needs a migration and a policy resync; until both land, every
existing deployment would start answering 403 to a role that could use the
generator the day before.

The read-only half stays reachable: preview, the table tree, and the two
database listings. A demo host that cannot demonstrate the generator is as
broken as one that lets visitors write to it - refusing too much is the same
defect facing the other way, and there is a test for that direction too.

Half of the general hole is closed and the other half is written down. The
closed half is a test beside the route registrations: it builds the generator's
routes, enumerates them, and fails if any entry in the guard has stopped being
a real route, so renaming one turns the list red instead of quietly making it
match nothing. It lives there because common/ may not import app/ - which is
also why the guard cannot check its own list from where it is. The open half is
that no static check can tell a handler that writes from one that reads, so the
next GET that writes has to be added by hand. The comment says that rather than
leaving the impression the class is covered.

application.demomsg was configuration nothing read. The message was hard-coded
in the middleware, and the demo host's configured string happened to be
identical, so the setting looked like it worked and never had. It is read now,
with the old string kept verbatim as the fallback, so a deployment that never
set it is answered exactly as before.

This covers demo mode only. On a deployment that is not a demo those three
routes remain in CasbinExclude and stay reachable by any authenticated user
whatever their role; that is a separate decision and is not touched here.
2026-09-07 08:03:40 +08:00
wenjianzhang 7002cd4065 Merge pull request #912 from go-admin-team/feat/007-drain-window
feat✨: 关闭时先排空再停止接收——让 /ready 的 503 真的能被采到
2026-09-07 08:01:59 +08:00
11 changed files with 1071 additions and 27 deletions
+16 -1
View File
@@ -1,12 +1,25 @@
name: Build
# Documentation-only changes skip this workflow entirely.
# Documentation-only changes, and changes confined to the Kubernetes
# manifests, skip this workflow entirely.
#
# A push to master here does not just build - it pushes an image, runs the
# migrations and restarts the demo container, so the site takes a short outage.
# Paying that for a README edit is waste at best; at worst a deploy fails for a
# reason unrelated to anything in the change. Code coverage is unaffected,
# because go.yml still builds every push and pull request.
#
# scripts/k8s holds deploy.yml, storage.yml and prerun.sh, and the deploy below
# reads none of them - it is an ssh into one host that runs docker, building the
# Dockerfile at the repository root. Those manifests are for people deploying to
# a cluster of their own. The pattern is scripts/k8s/** rather than scripts/**
# because scripts/Dockerfile is a build input: go.yml builds the release image
# from it on a tag.
#
# A file outside these patterns still runs the workflow even when the rest of
# the change is ignorable: paths-ignore skips only when every changed path
# matches. Editing this file is one such case, on purpose - a deploy script
# that is never exercised by the change that broke it is worse than an outage.
on:
push:
branches: [ master ]
@@ -15,6 +28,7 @@ on:
- 'docs/**'
- 'LICENSE*'
- '.github/ISSUE_TEMPLATE/**'
- 'scripts/k8s/**'
pull_request:
branches: [ master ]
paths-ignore:
@@ -22,6 +36,7 @@ on:
- 'docs/**'
- 'LICENSE*'
- '.github/ISSUE_TEMPLATE/**'
- 'scripts/k8s/**'
# One deploy at a time. Two merges seconds apart raced here: both runs did
# docker rm -f then docker run, the second removed the container the first had
+91
View File
@@ -0,0 +1,91 @@
package router
import (
"testing"
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/v2/jwtauth"
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
"go-admin/common/middleware"
)
// registeredRoutes builds the generator's routes on an engine of its own and
// reports the patterns they were registered under.
//
// The mode has to be given rather than inherited, because it now decides what
// gets registered: a test that leaves it at the zero value would be asking
// about a mode no deployment runs in, and would pass whether or not the gate
// works.
//
// It is put back before this returns, not at the end of the test. t.Cleanup
// would leave the mode set for everything the caller does afterwards, so a
// caller that went on to assert something mode-dependent would be reading a
// value this helper left behind rather than one it chose. A caller that does
// want the mode set has to set it, which is visible where it happens.
//
// The JWT middleware is a zero value. MiddlewareFunc only closes over the
// receiver and is never called here - no request is served, the engine is
// asked what it has - so nothing dereferences it.
func registeredRoutes(t *testing.T, mode string) map[string]bool {
t.Helper()
gin.SetMode(gin.TestMode)
previous := config.ApplicationConfig.Mode
defer func() { config.ApplicationConfig.Mode = previous }()
config.ApplicationConfig.Mode = mode
r := gin.New()
v1 := r.Group("/api/v1")
sysNoCheckRoleRouter(v1, &jwt.GinJWTMiddleware{})
registerDBRouter(v1, &jwt.GinJWTMiddleware{})
out := map[string]bool{}
for _, route := range r.Routes() {
out[route.Path] = true
}
return out
}
// The list of routes demo mode refuses lives in common/middleware, which may
// not import app/ and therefore cannot see whether any of them is still a
// route. This is the half that can be checked, and it is checked here because
// this is where the routes are declared: rename one, and the entry over there
// stops matching anything, demo mode silently starts serving it again, and
// nothing else would say so.
func TestEveryRouteDemoModeRefusesStillExists(t *testing.T) {
routes := registeredRoutes(t, "demo")
for _, guarded := range middleware.DemoWriteRoutes() {
if !routes[guarded] {
t.Errorf("demo mode refuses %q, but no route is registered under that pattern - "+
"either it was renamed, or it moved to another file; the guard now matches nothing",
guarded)
}
}
}
// The other direction, and the one the demo host cares about: the generator's
// read-only routes have to stay reachable, or a demo deployment cannot show
// the feature at all. Refusing too much is as much of a defect as refusing too
// little.
func TestTheGeneratorsReadOnlyRoutesAreNotRefused(t *testing.T) {
refused := map[string]bool{}
for _, guarded := range middleware.DemoWriteRoutes() {
refused[guarded] = true
}
for _, readOnly := range []string{
"/api/v1/gen/preview/:tableId",
"/api/v1/gen/tabletree",
"/api/v1/db/tables/page",
"/api/v1/db/columns/page",
} {
if !registeredRoutes(t, "demo")[readOnly] {
t.Fatalf("%s is not registered, so this test is asserting against nothing", readOnly)
}
if refused[readOnly] {
t.Errorf("demo mode refuses %s, which only reads - the demo host needs it to "+
"demonstrate the generator", readOnly)
}
}
}
+41 -5
View File
@@ -3,15 +3,49 @@ package router
import (
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/v2/jwtauth"
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
"go-admin/app/admin/apis"
"go-admin/app/other/apis/tools"
)
// GenWriteRoutesEnabled reports whether the code generator's writing endpoints
// are registered in this process.
//
// Three of the generator's endpoints do not read. /gen/toproject writes seven
// Go and Vue source files onto this host, one of them under the path
// gen.frontpath names; /gen/apitofile writes a migration; /gen/todb inserts
// menus and APIs. All three are GET, all three are listed in CasbinExclude,
// and AuthCheckRole skips what is on that list - so Enforce never runs for
// them and any account that can log in may call them. That is a bargain a
// workstation can make and a deployment cannot.
//
// dev is the shipped default and is where the generator is meant to be used.
// demo keeps them because demo mode already has a better answer than a 404:
// DemoEvn refuses these three by name and explains itself, which is the thing
// the demo exists to show. test and prod get nothing.
//
// The mode is read once, while the routes are being built. Changing
// application.mode in a running process adds and removes nothing - a
// configuration reload rebuilds neither the engine nor its routes.
//
// core has constants for dev, test and prod but none for demo, which this
// repository spells as a literal in common/middleware/demo.go. Both are
// literals here so that the two read as one set rather than two conventions.
func GenWriteRoutesEnabled() bool {
switch config.ApplicationConfig.Mode {
case "dev", "demo":
return true
default:
return false
}
}
func init() {
routerCheckRole = append(routerCheckRole, sysNoCheckRoleRouter, registerDBRouter, registerSysTableRouter)
}
func sysNoCheckRoleRouter(v1 *gin.RouterGroup ,authMiddleware *jwt.GinJWTMiddleware) {
func sysNoCheckRoleRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddleware) {
r1 := v1.Group("")
{
sys := apis.System{}
@@ -22,9 +56,11 @@ func sysNoCheckRoleRouter(v1 *gin.RouterGroup ,authMiddleware *jwt.GinJWTMiddlew
{
gen := tools.Gen{}
r.GET("/gen/preview/:tableId", gen.Preview)
r.GET("/gen/toproject/:tableId", gen.GenCode)
r.GET("/gen/apitofile/:tableId", gen.GenApiToFile)
r.GET("/gen/todb/:tableId", gen.GenMenuAndApi)
if GenWriteRoutesEnabled() {
r.GET("/gen/toproject/:tableId", gen.GenCode)
r.GET("/gen/apitofile/:tableId", gen.GenApiToFile)
r.GET("/gen/todb/:tableId", gen.GenMenuAndApi)
}
sysTable := tools.SysTable{}
r.GET("/gen/tabletree", sysTable.GetSysTablesTree)
}
@@ -53,4 +89,4 @@ func registerSysTableRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddl
tablesInfo.GET("", sysTable.GetSysTablesInfo)
}
}
}
}
+151
View File
@@ -0,0 +1,151 @@
package router
import (
"testing"
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
)
// genWritingRoutes are the three that do not read. They write Go and Vue
// source onto the host, a migration, and rows in sys_menu.
var genWritingRoutes = []string{
"/api/v1/gen/toproject/:tableId",
"/api/v1/gen/apitofile/:tableId",
"/api/v1/gen/todb/:tableId",
}
// genReadingRoutes are the rest of the generator's surface. Gating the three
// above must not cost any of these: a deployment that cannot list its tables
// or preview a template has lost the feature, not secured it.
var genReadingRoutes = []string{
"/api/v1/gen/preview/:tableId",
"/api/v1/gen/tabletree",
"/api/v1/db/tables/page",
"/api/v1/db/columns/page",
}
// The endpoints that write are registered where the mode says development and
// nowhere else.
//
// They are in CasbinExclude, so Enforce never runs for them and any account
// that can log in may call them. dev is the shipped default and is where the
// generator is meant to be used; demo keeps them because DemoEvn refuses these
// three by name and saying so is the thing the demo is for. Everything else,
// including the empty mode a process gets when nothing set one, is refused by
// not existing.
func TestGeneratorWritingRoutesExistOnlyWhereTheModeAllowsIt(t *testing.T) {
for _, tc := range []struct {
mode string
expected bool
why string
}{
{"dev", true, "the shipped default, and where the generator is used"},
{"demo", true, "registered so demo mode can refuse them by name"},
{"test", false, "a deployment, however much it is called a test"},
{"prod", false, "a deployment"},
{"", false, "no mode configured is not a reason to trust the caller"},
} {
t.Run("mode="+tc.mode, func(t *testing.T) {
routes := registeredRoutes(t, tc.mode)
for _, writing := range genWritingRoutes {
if got := routes[writing]; got != tc.expected {
t.Errorf("mode %q: %s registered = %v, want %v (%s)",
tc.mode, writing, got, tc.expected, tc.why)
}
}
})
}
}
// The other direction. Refusing too much is as much of a defect as refusing
// too little, and the read-only half of the generator is what a demo shows.
func TestGeneratorReadingRoutesExistInEveryMode(t *testing.T) {
for _, mode := range []string{"dev", "demo", "test", "prod", ""} {
t.Run("mode="+mode, func(t *testing.T) {
routes := registeredRoutes(t, mode)
for _, reading := range genReadingRoutes {
if !routes[reading] {
t.Errorf("mode %q: %s is not registered - the gate took a route that only reads",
mode, reading)
}
}
})
}
}
// GenWriteRoutesEnabled is what cmd/api reads to decide whether to warn at
// start-up. If it and the registration ever disagree, the log says one thing
// and the engine does another, so they are checked against each other rather
// than each against a list.
func TestGenWriteRoutesEnabledAgreesWithWhatWasRegistered(t *testing.T) {
for _, mode := range []string{"dev", "demo", "test", "prod", ""} {
t.Run("mode="+mode, func(t *testing.T) {
routes := registeredRoutes(t, mode)
// registeredRoutes puts the mode back before it returns, so ask
// the predicate under a mode set here - about the same value the
// engine was just built under.
previous := config.ApplicationConfig.Mode
t.Cleanup(func() { config.ApplicationConfig.Mode = previous })
config.ApplicationConfig.Mode = mode
claimed := GenWriteRoutesEnabled()
actual := routes["/api/v1/gen/todb/:tableId"]
if claimed != actual {
t.Errorf("mode %q: GenWriteRoutesEnabled() = %v but the route was registered = %v",
mode, claimed, actual)
}
})
}
}
// The helper restores the mode before it returns, so nothing it was asked
// about leaks into what the caller does next.
//
// Worth a test of its own because the failure is silent: a helper that left
// the mode set would make every assertion after the call read a value the
// caller did not choose, and each of those assertions would still pass for as
// long as the leaked value happened to be the right one.
func TestRegisteredRoutesRestoresTheModeBeforeReturning(t *testing.T) {
const sentinel = "not-a-mode"
previous := config.ApplicationConfig.Mode
t.Cleanup(func() { config.ApplicationConfig.Mode = previous })
config.ApplicationConfig.Mode = sentinel
registeredRoutes(t, "prod")
if got := config.ApplicationConfig.Mode; got != sentinel {
t.Errorf("mode after the helper returned = %q, want %q - it was left set to what "+
"the helper was asked about", got, sentinel)
}
}
// Changing the mode after the routes were built unregisters nothing.
//
// buildRouter has one call site, in run(), and route registration is not on
// any phase or reload callback - so a configuration reload moves
// config.ApplicationConfig.Mode without moving the routes. From that moment
// GenWriteRoutesEnabled answers about a mode the engine was not built under.
//
// That gap is why the start-up warning tells the reader to restart rather than
// only to change the mode. This pins it: if registration ever becomes dynamic,
// this test fails and the message it justifies has to be revisited.
func TestChangingTheModeDoesNotUnregisterWhatWasAlreadyBuilt(t *testing.T) {
built := registeredRoutes(t, "dev")
if !built["/api/v1/gen/todb/:tableId"] {
t.Fatal("built under dev without the writing routes, so this test asserts nothing")
}
previous := config.ApplicationConfig.Mode
t.Cleanup(func() { config.ApplicationConfig.Mode = previous })
config.ApplicationConfig.Mode = "prod"
if GenWriteRoutesEnabled() {
t.Fatal("the predicate still allows prod, so the disagreement below is not the one meant")
}
if !built["/api/v1/gen/todb/:tableId"] {
t.Error("the route left the engine when the mode changed - registration has become " +
"dynamic, and the start-up warning's advice to restart is now wrong")
}
}
+38
View File
@@ -0,0 +1,38 @@
package api
import (
"testing"
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
)
// The warning fires exactly where the generator's writing endpoints are served
// and nothing else refuses them.
//
// dev is the case the warning exists for: it is the shipped default, so it is
// the mode a deployment that changed nothing is running in. demo serves the
// routes too, but DemoEvn refuses all three by name, so warning there would
// describe an exposure that is not there.
func TestGeneratorWriteRoutesWarningFiresWhereTheExposureIs(t *testing.T) {
for _, tc := range []struct {
mode string
want bool
why string
}{
{"dev", true, "shipped default, endpoints served and not refused"},
{"demo", false, "served, but DemoEvn refuses all three"},
{"test", false, "not served"},
{"prod", false, "not served"},
{"", false, "not served"},
} {
t.Run("mode="+tc.mode, func(t *testing.T) {
previous := config.ApplicationConfig.Mode
t.Cleanup(func() { config.ApplicationConfig.Mode = previous })
config.ApplicationConfig.Mode = tc.mode
if got := generatorWriteRoutesNeedWarning(); got != tc.want {
t.Errorf("mode %q: warning = %v, want %v (%s)", tc.mode, got, tc.want, tc.why)
}
})
}
}
+34
View File
@@ -177,6 +177,7 @@ func run() error {
gin.SetMode(gin.ReleaseMode)
}
buildRouter()
reportGeneratorWriteRoutes()
srv := &http.Server{
Addr: fmt.Sprintf("%s:%d", config.ApplicationConfig.Host, config.ApplicationConfig.Port),
@@ -356,6 +357,39 @@ const (
kubernetesGraceSeconds = 30
)
// reportGeneratorWriteRoutes says whether this process serves the code
// generator's writing endpoints, and to whom.
//
// The endpoints are gated on the mode, and the shipped configuration says dev -
// so the deployment most likely to be exposed is the one that changed nothing,
// and the one least likely to go looking. Silence there would leave the gate
// technically correct and practically useless.
//
// Nothing is said in demo mode. The routes are registered, but DemoEvn refuses
// all three by name, so a warning would describe an exposure that is not there.
func reportGeneratorWriteRoutes() {
if !generatorWriteRoutesNeedWarning() {
return
}
log.Warnf("the code generator's writing endpoints are served in mode %q: "+
"/api/v1/gen/{toproject,apitofile,todb} write Go and Vue source onto this host and rows "+
"into this database, and they are in CasbinExclude, so any account that can log in may "+
"call them. Set application.mode to prod or test on anything that is not a workstation, "+
"then restart: these routes were registered at start-up and a configuration reload does "+
"not rebuild them.",
config.ApplicationConfig.Mode)
}
// generatorWriteRoutesNeedWarning reports whether there is an exposure to warn
// about: the endpoints are served, and nothing else is refusing them.
//
// Split from the logging so the decision can be tested. A warning nobody can
// make fire is indistinguishable from no warning at all, and this one exists
// precisely for the case nobody is looking at.
func generatorWriteRoutesNeedWarning() bool {
return otherrouter.GenWriteRoutesEnabled() && config.ApplicationConfig.Mode != "demo"
}
// reportShutdownBudget states what a shutdown will spend and whether it fits.
//
// The sum is taken from the resolved values, not from the configuration file:
+91 -17
View File
@@ -1,29 +1,103 @@
package middleware
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
"net/http"
)
// defaultDemoMsg is what a refused request is told when nothing is configured.
//
// It is the string this middleware used to carry hard-coded, kept verbatim so
// that a deployment which never set application.demomsg is answered exactly as
// it was before.
const defaultDemoMsg = "谢谢您的参与,但为了大家更好的体验,所以本次提交就算了吧!\U0001F600\U0001F600\U0001F600"
// demoWriteRoutes are routes that change something despite being registered as
// GET, so the method alone does not say whether they are safe to serve.
//
// All three belong to the code generator: two write Go source files onto the
// server's filesystem and the third inserts menus, APIs and casbin rules into
// the database. They are registered under a group whose own name says it does
// no role check, and a demo deployment lets anybody log in - so on a demo host
// they were reachable by any visitor, and the menus one had in fact been used.
//
// Spelled as gin route patterns, which is what Context.FullPath returns, so a
// path parameter matches whatever value it is given.
//
// This list cannot be checked from here: common/ may not import app/, so this
// package cannot see which routes exist. What keeps it honest is a test beside
// the routes themselves - see app/other/router - which registers them and
// fails if any entry here has stopped being a real route.
//
// It also does not close the general hole. Nothing stops the next GET handler
// that writes something from being added without an entry here, and no static
// check can tell a handler that writes from one that reads. Demo mode refuses
// the routes it has been told about; that is the whole of the guarantee.
var demoWriteRoutes = map[string]bool{
"/api/v1/gen/toproject/:tableId": true,
"/api/v1/gen/apitofile/:tableId": true,
"/api/v1/gen/todb/:tableId": true,
}
// DemoWriteRoutes returns the routes demo mode refuses despite their method.
//
// Exported only so the test that lives beside the route registrations can
// check every one of them still exists; nothing else should need it.
func DemoWriteRoutes() []string {
out := make([]string, 0, len(demoWriteRoutes))
for route := range demoWriteRoutes {
out = append(out, route)
}
return out
}
// demoAllows reports whether demo mode lets a request through.
//
// route is the matched gin route pattern and uri the raw request target; the
// two are different things and both are needed. The route is what identifies a
// handler regardless of the values in its path parameters, and it is empty for
// a request that matched nothing - which is why the login and logout checks
// still read the raw target, as they always did.
func demoAllows(method, route, uri string) bool {
if demoWriteRoutes[route] {
return false
}
return method == http.MethodGet ||
method == http.MethodOptions ||
uri == "/api/v1/login" ||
uri == "/api/v1/logout"
}
// demoMessage is the answer a refused request gets.
//
// application.demomsg has been in the configuration all along and nothing read
// it: the message was hard-coded here, and the demo host's configured string
// happened to be identical, so the setting looked like it worked. An empty
// value falls back rather than answering with nothing.
func demoMessage() string {
if msg := config.ApplicationConfig.DemoMsg; msg != "" {
return msg
}
return defaultDemoMsg
}
// DemoEvn refuses anything that would change state while mode is demo.
func DemoEvn() gin.HandlerFunc {
return func(c *gin.Context) {
method := c.Request.Method
if config.ApplicationConfig.Mode == "demo" {
if method == "GET" ||
method == "OPTIONS" ||
c.Request.RequestURI == "/api/v1/login" ||
c.Request.RequestURI == "/api/v1/logout" {
c.Next()
} else {
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": "谢谢您的参与,但为了大家更好的体验,所以本次提交就算了吧!\U0001F600\U0001F600\U0001F600",
})
c.Abort()
return
}
if config.ApplicationConfig.Mode != "demo" {
c.Next()
return
}
c.Next()
if demoAllows(c.Request.Method, c.FullPath(), c.Request.RequestURI) {
c.Next()
return
}
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": demoMessage(),
})
c.Abort()
}
}
+144
View File
@@ -0,0 +1,144 @@
package middleware
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
)
// demoMode puts the process in demo mode for one test and puts it back.
func demoMode(t *testing.T, mode, msg string) {
t.Helper()
previousMode, previousMsg := config.ApplicationConfig.Mode, config.ApplicationConfig.DemoMsg
t.Cleanup(func() {
config.ApplicationConfig.Mode = previousMode
config.ApplicationConfig.DemoMsg = previousMsg
})
config.ApplicationConfig.Mode, config.ApplicationConfig.DemoMsg = mode, msg
}
// The method is not enough on its own. Three of the generator's routes are
// registered as GET and write anyway - two of them onto the server's
// filesystem, one into the database - so a guard that reads only the method
// serves them to anybody who can log in, which on a demo host is everybody.
func TestDemoRefusesTheWritesThatAreServedOverGET(t *testing.T) {
const login = "/api/v1/login"
for _, tc := range []struct {
name string
method string
route, uri string
wantThrough bool
}{
{"a plain read", http.MethodGet, "/api/v1/dept", "/api/v1/dept", true},
{"a write, by method", http.MethodPost, "/api/v1/dept", "/api/v1/dept", false},
{"login is how a visitor gets in", http.MethodPost, login, login, true},
{"logout", http.MethodPost, "/api/v1/logout", "/api/v1/logout", true},
{"preflight", http.MethodOptions, "/api/v1/dept", "/api/v1/dept", true},
// A request that matched no route has an empty pattern, and the guard
// still has to refuse it by method - this is what a POST to a path
// that does not exist looks like from in here.
{"a write to nothing at all", http.MethodPost, "", "/api/v1/__probe__", false},
// The three this change is about.
{"generator writes the database", http.MethodGet,
"/api/v1/gen/todb/:tableId", "/api/v1/gen/todb/3", false},
{"generator writes source files", http.MethodGet,
"/api/v1/gen/toproject/:tableId", "/api/v1/gen/toproject/3", false},
{"generator writes an api file", http.MethodGet,
"/api/v1/gen/apitofile/:tableId", "/api/v1/gen/apitofile/3", false},
// The read-only half of the generator has to keep working, or the demo
// host cannot demonstrate the feature at all. Refusing too much is as
// much of a defect as refusing too little.
{"generator preview stays available", http.MethodGet,
"/api/v1/gen/preview/:tableId", "/api/v1/gen/preview/3", true},
{"generator table tree stays available", http.MethodGet,
"/api/v1/gen/tabletree", "/api/v1/gen/tabletree", true},
{"table list stays available", http.MethodGet,
"/api/v1/db/tables/page", "/api/v1/db/tables/page", true},
{"column list stays available", http.MethodGet,
"/api/v1/db/columns/page", "/api/v1/db/columns/page", true},
} {
t.Run(tc.name, func(t *testing.T) {
if got := demoAllows(tc.method, tc.route, tc.uri); got != tc.wantThrough {
t.Errorf("demoAllows(%s %s) = %v, want %v", tc.method, tc.route, got, tc.wantThrough)
}
})
}
}
// Everything above is about demo mode only. A deployment that is not a demo
// runs the generator for real, and a guard that reached it there would have
// taken the feature away from every production install.
func TestOutsideDemoModeNothingIsRefused(t *testing.T) {
demoMode(t, "prod", "")
gin.SetMode(gin.TestMode)
for _, route := range append(DemoWriteRoutes(), "/api/v1/dept") {
t.Run(route, func(t *testing.T) {
r := gin.New()
r.Use(DemoEvn())
r.GET(route, func(c *gin.Context) { c.String(http.StatusOK, "served") })
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, requestFor(route), nil))
if w.Body.String() != "served" {
t.Errorf("answered %q; outside demo mode the handler must run", w.Body.String())
}
})
}
}
// The refusal has to come back as the demo message rather than a 403 or a 404:
// the front end shows it to the visitor, and the point of a demo host is that
// being turned away is explained.
func TestDemoRefusalCarriesTheConfiguredMessage(t *testing.T) {
gin.SetMode(gin.TestMode)
const route = "/api/v1/gen/todb/:tableId"
for _, tc := range []struct {
name, configured, want string
}{
{"configured", "come back tomorrow", "come back tomorrow"},
// A deployment that never set application.demomsg keeps the answer it
// already had; an empty setting must not become an empty message.
{"not configured", "", defaultDemoMsg},
} {
t.Run(tc.name, func(t *testing.T) {
demoMode(t, "demo", tc.configured)
r := gin.New()
r.Use(DemoEvn())
r.GET(route, func(c *gin.Context) { c.String(http.StatusOK, "served") })
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, requestFor(route), nil))
if w.Code != http.StatusOK {
t.Errorf("answered %d, want 200 so the front end reads the body", w.Code)
}
if body := w.Body.String(); !strings.Contains(body, tc.want) {
t.Errorf("body %q does not carry %q", body, tc.want)
}
if strings.Contains(w.Body.String(), "served") {
t.Error("the handler ran; the request was supposed to be refused")
}
})
}
}
// requestFor turns a route pattern into a request target by giving every path
// parameter a value.
func requestFor(route string) string {
segments := strings.Split(route, "/")
for i, segment := range segments {
if strings.HasPrefix(segment, ":") {
segments[i] = "1"
}
}
return strings.Join(segments, "/")
}
+118
View File
@@ -8,10 +8,12 @@
package storage
import (
"context"
"log"
"sync"
"github.com/go-admin-team/go-admin-core/v2/captcha"
corelog "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/config"
)
@@ -21,6 +23,7 @@ func Setup() {
setupCache()
setupCaptcha()
setupQueue()
registerQueueDrain()
}
func setupCache() {
@@ -41,8 +44,123 @@ var (
// shut it down, and counted so a consumer can tell one from the next.
installed interface{ Shutdown() }
installedGen uint64
// drainRegistered records that the BeforeExit callback is on the runtime,
// so that a reload does not add another one.
drainRegistered bool
)
// setShutdown is sdk.Runtime.SetShutdown, indirected so that registering can
// be observed.
//
// It has to be: the runtime does not report how many callbacks a phase holds,
// and shutdownQueue takes the adapter on its first run, so every registration
// after the first returns immediately and changes nothing anybody can see. A
// reload adding one callback per round would therefore be invisible from the
// outside - which is exactly how it would survive.
var setShutdown = func(f func(context.Context)) { sdk.Runtime.SetShutdown(f) }
// registerQueueDrain puts shutdownQueue on the BeforeExit phase, once.
//
// Setup is one of the callbacks bootstrap.SetupConfig re-runs on every
// configuration change, so registering from it without a guard would leave one
// callback per reload - each shutting down the same adapter, each reported
// separately when the budget runs out.
//
// A flag under the existing mutex rather than a sync.Once: the tests in this
// package already save and restore installed and installedGen to keep one test
// from deciding what the next one sees, and a sync.Once cannot be put back.
func registerQueueDrain() {
queueMu.Lock()
first := !drainRegistered
drainRegistered = true
queueMu.Unlock()
if first {
setShutdown(shutdownQueue)
}
}
// drainedInTime shuts q down and reports whether it finished before ctx expired.
func drainedInTime(ctx context.Context, q interface{ Shutdown() }) bool {
done := make(chan struct{})
go func() {
defer close(done)
q.Shutdown()
}()
return finishedBeforeDeadline(ctx, done)
}
// finishedBeforeDeadline waits for done or for ctx, and resolves a tie in
// favour of done.
//
// The tie is the reason this is a function of its own rather than one select
// inline. Both channels can be ready when the select runs, select picks at
// random among ready cases, and so a single look reports an overrun for a
// drain that completed - about half the times it lands there, which is exactly
// often enough to be dismissed as noise. core's own RunShutdown re-checks for
// this reason.
//
// Taking channels rather than a queue is what makes it testable: a closed done
// and an expired ctx can be handed in together, which is the state a race
// would otherwise have to be caught in.
func finishedBeforeDeadline(ctx context.Context, done <-chan struct{}) bool {
select {
case <-done:
return true
case <-ctx.Done():
}
select {
case <-done:
return true
default:
return false
}
}
// shutdownQueue drains the queue this package installed, on the way out.
//
// Nothing used to. core's Memory.Shutdown closes the queue and waits for every
// consumer to finish what it is holding, and the legacy adapter cancels its
// context and closes the underlying queue - but neither ran at exit, so the
// process left with the login log, the operation log and the API sync still
// buffered, and left reporting success.
//
// The adapter is read here rather than captured at registration because a
// reload replaces it. Registration happens once per process; this runs against
// whatever is current when the signal arrives.
//
// Only an adapter this package installed. sdk.Runtime.GetQueueAdapter never
// returns nil - with no queue section configured the runtime wraps its own
// fallback queue - so going through that accessor would shut down a queue this
// package neither built nor started.
//
// The adapter is taken, not read: after this the package owns nothing, so a
// reload arriving mid-shutdown builds a new one instead of being handed a
// closed one to shut down again. Both implementations tolerate a second
// Shutdown, so this is about who owns it rather than about a crash.
func shutdownQueue(ctx context.Context) {
queueMu.Lock()
q := installed
installed = nil
queueMu.Unlock()
if q == nil {
return
}
if drainedInTime(ctx, q) {
corelog.Info("queue: drained")
return
}
// The wait is what the budget bounds, not the work: Shutdown takes no
// context and is still running in that goroutine. Saying so here names what
// is being lost, which the generic overrun message cannot.
corelog.Warnf("queue: the shutdown budget ran out while the queue was still draining - " +
"whatever it had not delivered goes with the process. Raise extend.shutdown.cleanup " +
"if this recurs.")
}
// 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
+333
View File
@@ -0,0 +1,333 @@
package storage
import (
"context"
"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"
)
// countingQueue stands in for an installed adapter. Only Shutdown is exercised
// - the drain callback never publishes or consumes - so the rest of
// AdapterQueue is deliberately absent: `installed` is typed on Shutdown alone,
// and widening the fake would only invite it to be used for something else.
type countingQueue struct {
calls atomic.Int32
block chan struct{}
// started is closed on the way into Shutdown, so a test can wait for the
// call rather than assume the goroutine that makes it was scheduled. The
// caller returns on its own deadline while Shutdown is still running, so
// reading calls straight after that return is a race with the increment.
startOnce sync.Once
started chan struct{}
}
func newCountingQueue() *countingQueue {
return &countingQueue{started: make(chan struct{})}
}
func (q *countingQueue) Shutdown() {
q.startOnce.Do(func() { close(q.started) })
q.calls.Add(1)
if q.block != nil {
<-q.block
}
}
// waitStarted blocks until Shutdown has been entered, or fails the test.
func (q *countingQueue) waitStarted(t *testing.T) {
t.Helper()
select {
case <-q.started:
case <-time.After(5 * time.Second):
t.Fatal("Shutdown was never called")
}
}
// isolate gives the test its own runtime and its own view of what this package
// has installed, and puts the process-wide state back afterwards.
//
// Same isolation as TestSetupBumpsTheQueueGenerationOnEveryReload, plus
// drainRegistered: it is what stops a reload registering a second callback, so
// leaving it set would make every later test in this binary see a package that
// has already registered.
func isolate(t *testing.T) {
t.Helper()
prevQ, prevC := config.QueueConfig, config.CacheConfig
prevRuntime := sdk.Runtime
queueMu.Lock()
prevInstalled, prevGen, prevRegistered := installed, installedGen, drainRegistered
queueMu.Unlock()
t.Cleanup(func() {
config.QueueConfig, config.CacheConfig = prevQ, prevC
sdk.Runtime = prevRuntime
queueMu.Lock()
installed, installedGen, drainRegistered = prevInstalled, prevGen, prevRegistered
queueMu.Unlock()
})
sdk.Runtime = runtime.NewConfig()
queueMu.Lock()
installed, drainRegistered = nil, false
queueMu.Unlock()
}
// setInstalled puts a fake where setupQueue would have left the real adapter.
//
// Legitimate because the callback reads `installed` when it runs rather than
// capturing it at registration - that is the property that lets a reload
// replace the adapter and still have the right one drained.
func setInstalled(q interface{ Shutdown() }) {
queueMu.Lock()
installed = q
queueMu.Unlock()
}
func currentInstalled() interface{ Shutdown() } {
queueMu.Lock()
defer queueMu.Unlock()
return installed
}
// Issue #911: nothing shut the queue down at exit, so whatever was buffered
// went with the process.
//
// This is the half that matters most - a callback is on BeforeExit and it
// reaches the adapter this package installed. It says nothing about how many
// times the callback was registered; see the test below for that.
func TestSetupPutsTheQueueDrainOnBeforeExit(t *testing.T) {
isolate(t)
config.CacheConfig = &config.Cache{Memory: struct{}{}}
config.QueueConfig = &config.Queue{Memory: &config.QueueMemory{PoolSize: 10}}
Setup()
Setup()
Setup()
q := newCountingQueue()
setInstalled(q)
if err := sdk.Runtime.RunShutdown(context.Background()); err != nil {
t.Fatalf("RunShutdown: %v", err)
}
if got := q.calls.Load(); got != 1 {
t.Errorf("Shutdown called %d times, want 1 - 0 means nothing registered the drain", got)
}
}
// Setup is re-run on every configuration change, so registering from it has to
// be guarded: a callback per reload would leave the shutdown phase holding a
// row of identical entries, each timed and each eligible to be named as the one
// that overran the budget.
//
// Counted at the seam rather than through the effect. The test above cannot see
// this - shutdownQueue takes the adapter on its first run, so the second and
// third callbacks find nothing and return, and three registrations produce
// exactly the same observable result as one. That is a good property of the
// callback and a blind spot for any test that goes through it.
func TestSetupRegistersTheDrainOncePerProcessHoweverManyReloads(t *testing.T) {
isolate(t)
previous := setShutdown
t.Cleanup(func() { setShutdown = previous })
registrations := 0
setShutdown = func(func(context.Context)) { registrations++ }
config.CacheConfig = &config.Cache{Memory: struct{}{}}
config.QueueConfig = &config.Queue{Memory: &config.QueueMemory{PoolSize: 10}}
Setup()
Setup()
Setup()
if registrations != 1 {
t.Errorf("three reloads registered the drain %d times, want 1", registrations)
}
}
// The drain reaches the adapter that is current when the signal arrives, not
// one captured while wiring up. A reload replaces the adapter, and draining the
// one that was installed at start-up would drain something nobody has published
// to since.
func TestTheDrainRunsAgainstTheAdapterInstalledLast(t *testing.T) {
isolate(t)
config.CacheConfig = &config.Cache{Memory: struct{}{}}
config.QueueConfig = &config.Queue{Memory: &config.QueueMemory{PoolSize: 10}}
Setup()
first, second := newCountingQueue(), newCountingQueue()
setInstalled(first)
setInstalled(second)
if err := sdk.Runtime.RunShutdown(context.Background()); err != nil {
t.Fatalf("RunShutdown: %v", err)
}
if first.calls.Load() != 0 {
t.Error("the adapter that was replaced was shut down; the callback captured it instead of " +
"reading it when it ran")
}
if second.calls.Load() != 1 {
t.Errorf("the current adapter was shut down %d times, want 1", second.calls.Load())
}
}
// Nothing installed is the shipped default: settings.yml has no queue section,
// so setupQueue returns early and the runtime's own fallback queue is what
// callers get. Shutting that down would close a queue this package neither
// built nor started.
func TestTheDrainDoesNothingWhenThisPackageInstalledNothing(t *testing.T) {
isolate(t)
config.CacheConfig = &config.Cache{Memory: struct{}{}}
config.QueueConfig = &config.Queue{}
Setup()
if currentInstalled() != nil {
t.Fatal("an empty queue configuration installed an adapter, so this test asserts nothing")
}
if err := sdk.Runtime.RunShutdown(context.Background()); err != nil {
t.Fatalf("RunShutdown: %v", err)
}
}
// The budget bounds the wait, not the work. A consumer that never finishes must
// not hold the process past its grace period - SIGKILL would arrive mid-write
// instead of at a point of the process's choosing.
func TestTheDrainStopsWaitingWhenTheBudgetIsGone(t *testing.T) {
isolate(t)
blocked := newCountingQueue()
blocked.block = make(chan struct{})
t.Cleanup(func() { close(blocked.block) })
setInstalled(blocked)
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
returned := make(chan struct{})
go func() {
defer close(returned)
shutdownQueue(ctx)
}()
select {
case <-returned:
case <-time.After(5 * time.Second):
t.Fatal("shutdownQueue did not return after its context expired - it waits on a Shutdown " +
"that takes no context, so the wait has to be bounded here")
}
// Waited for rather than read straight after the return: Shutdown runs on a
// goroutine that the caller does not join, so the increment is not ordered
// against the caller giving up on its deadline.
blocked.waitStarted(t)
if blocked.calls.Load() != 1 {
t.Errorf("Shutdown called %d times, want 1 - the drain has to be attempted even when it "+
"cannot be waited out", blocked.calls.Load())
}
}
// After the drain this package owns nothing. A reload arriving mid-shutdown
// then builds a new adapter rather than being handed a closed one as its
// `previous` to shut down again.
func TestTheDrainGivesUpOwnershipOfTheAdapter(t *testing.T) {
isolate(t)
setInstalled(newCountingQueue())
shutdownQueue(context.Background())
if got := currentInstalled(); got != nil {
t.Errorf("installed is %T after the drain, want nil", got)
}
}
// runtimeQueue is a full AdapterQueue, so it can be handed to the runtime
// rather than only to this package's own record of what it installed.
type runtimeQueue struct {
countingQueue
}
func (q *runtimeQueue) String() string { return "runtime-fake" }
func (q *runtimeQueue) Append(corestorage.Messager) error { return nil }
func (q *runtimeQueue) Register(string, corestorage.ConsumerFunc) {}
func (q *runtimeQueue) Run() {}
// The drain must not reach a queue this package did not install.
//
// sdk.Runtime.GetQueueAdapter never returns nil: with no queue section
// configured it wraps the runtime's own fallback, and the wrapper's Shutdown
// forwards. Reaching for the accessor would therefore look like it worked and
// would close a queue this package neither built nor started - the same shape
// as the `if q := GetQueueAdapter(); q != nil` that setupQueue already had to
// drop.
//
// The previous test's empty-configuration case cannot see this: it only checks
// that RunShutdown returns, which it would either way.
func TestTheDrainNeverReachesTheRuntimesOwnQueue(t *testing.T) {
isolate(t)
onTheRuntime := &runtimeQueue{countingQueue: *newCountingQueue()}
sdk.Runtime.SetQueueAdapter(onTheRuntime)
if currentInstalled() != nil {
t.Fatal("this package installed something, so the distinction under test is not set up")
}
if sdk.Runtime.GetQueueAdapter() == nil {
t.Fatal("the accessor returned nil, so it is no longer the trap this guards")
}
shutdownQueue(context.Background())
if got := onTheRuntime.calls.Load(); got != 0 {
t.Errorf("the runtime's queue was shut down %d times - the drain went through "+
"GetQueueAdapter instead of the adapter this package installed", got)
}
}
// A drain that finishes in the same instant the budget expires counts as
// finished.
//
// Both channels are ready when the select runs, and select picks at random
// among ready cases, so a single look reports an overrun for a drain that
// completed - roughly half the times it lands here. The repetition is what
// makes that visible: one iteration passes either way.
func TestATieBetweenTheDeadlineAndTheDrainGoesToTheDrain(t *testing.T) {
expired, cancel := context.WithCancel(context.Background())
cancel()
<-expired.Done()
done := make(chan struct{})
close(done)
for i := 0; i < 1000; i++ {
if !finishedBeforeDeadline(expired, done) {
t.Fatalf("iteration %d of 1000: both the deadline and the drain were ready and the "+
"deadline won - a drain that completed is being reported as an overrun", i)
}
}
}
// The other side of it. A drain that really has not finished has to be
// reported, or the warning never fires and the tie-break above has quietly
// turned into "always say it drained".
func TestADrainThatHasNotFinishedIsReportedAsAnOverrun(t *testing.T) {
expired, cancel := context.WithCancel(context.Background())
cancel()
<-expired.Done()
stillRunning := make(chan struct{}) // never closed
if finishedBeforeDeadline(expired, stillRunning) {
t.Error("an unfinished drain was reported as having finished in time")
}
}
+14 -4
View File
@@ -23,10 +23,20 @@ metadata:
version: v1
spec:
# One replica, and the drain window below buys nothing at one replica: there
# is nowhere to send the traffic this pod stops taking. Raising it needs one
# more change than the number - the volume below is shared by every replica,
# and the log path in settings.yml lives on it, so a second pod would append
# to the same rotating file.
# is nowhere to send the traffic this pod stops taking. Raising it needs two
# changes that are not this number:
#
# The volume below is shared by every replica, and the log path in
# settings.yml lives on it, so a second pod would append to the same
# rotating file.
#
# The job scheduler is per process while its handle on a job is one shared
# column. Startup runs `UPDATE sys_job SET entry_id = 0 WHERE entry_id > 0`
# across the whole table (app/jobs/jobbase.go), so a second pod erases the
# first pod's ids and writes its own, and every pod registers the whole
# enabled list in its own scheduler. Neither symptom logs anything: an
# enabled job fires once per pod, and stopping one from the UI removes an
# entry from the wrong process and still answers 200. See #915.
replicas: 1
selector:
matchLabels: