mirror of
https://github.com/go-admin-team/go-admin.git
synced 2026-09-21 02:04:09 +00:00
The process started, both probes passed, and the first sign that the schema did not match was a login failing with a driver-level encoding error - go-admin#919, where an operator upgraded the binary and restarted the API without running migrate. Nothing between those two events had an opinion about the schema. Readiness is where this belongs. Liveness asks "restart me", and a process whose database is on the wrong schema comes back to the same schema. Readiness asks "send me requests", and the answer is no. A rolling update then stalls at the deploy - new instances never become ready, the old ones keep serving - rather than at somebody's login, and running migrate clears it without a restart because the check is evaluated per request. Any tenant database being behind fails the check, not only the one being served: migrations are applied to every database in one run, so one behind means that run did not finish, and serving the rest would let a half-applied deploy look like a partial success. A missing sys_migration table is nothing applied rather than an error. That is a first deploy, where every migration is pending and the operator can act on being told so. The one test that matters is the one that cannot be written normally. The registry is filled by init() in packages cmd/api does not import, so a test that imported them to look at it would pass whatever the real binary links - and a binary that links none of them gives a check that reports every database current, forever, with every other test here still green. TestTheServingBinaryLinksTheMigrationRegistry asks the build instead, with a negative control so that a query matching everything fails rather than passes. Closes #920.
118 lines
4.0 KiB
Go
118 lines
4.0 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/go-admin-team/go-admin-core/v2/sdk"
|
|
"gorm.io/gorm"
|
|
|
|
"go-admin/cmd/migrate/migration"
|
|
"go-admin/common/health"
|
|
commonmodels "go-admin/common/models"
|
|
)
|
|
|
|
// schemaCheckName is what a failing schema reports itself as in /ready's body.
|
|
const schemaCheckName = "schema"
|
|
|
|
// registerSchemaCheck adds the pending-migration check to readiness.
|
|
//
|
|
// Readiness rather than a refusal to start, and rather than a log line alone.
|
|
// The two probes answer different questions: liveness is "restart me", and a
|
|
// process whose database is on the wrong schema comes back to the same schema,
|
|
// so restarting is not the answer. Readiness is "send me requests", and with a
|
|
// schema the binary does not match the answer is no.
|
|
//
|
|
// Issue #919 is what the absence of this looked like: the process started,
|
|
// both probes passed, and the first sign of trouble was a login failing with a
|
|
// driver-level encoding error. Refusing to start would have been the wrong fix
|
|
// - a process that exits tells an operator less than one that runs and says
|
|
// why, and under an orchestrator it crash-loops - while a log line alone is
|
|
// not something an orchestrator can act on.
|
|
func registerSchemaCheck() {
|
|
health.Register(schemaCheckName, schemaCheck)
|
|
}
|
|
|
|
// schemaCheck fails while any tenant database is behind the migrations this
|
|
// binary registers.
|
|
//
|
|
// Any one of them, rather than only the tenant being served: migrations are
|
|
// applied to every database in one run, so one database behind means that run
|
|
// did not finish. Serving the rest would let a half-applied deploy look like a
|
|
// partial success.
|
|
//
|
|
// Evaluated per request rather than decided at start-up, so that running
|
|
// migrate clears it without a restart.
|
|
func schemaCheck(ctx context.Context) error {
|
|
registered := migration.RegisteredVersions()
|
|
if len(registered) == 0 {
|
|
// Nothing registered means nothing can be pending, which is the honest
|
|
// answer for a tree with no migrations. It is also what a broken build
|
|
// would produce - the registry is filled by init() in packages the
|
|
// binary has to link - so cmd/api's dependency test asserts the real
|
|
// binary links them.
|
|
return nil
|
|
}
|
|
|
|
behind := make([]string, 0, 2)
|
|
for name, db := range sdk.Runtime.GetAllDb() {
|
|
applied, err := appliedVersions(ctx, db)
|
|
if err != nil {
|
|
return fmt.Errorf("reading applied migrations for %q: %w", name, err)
|
|
}
|
|
if pending := pendingVersions(registered, applied); len(pending) > 0 {
|
|
behind = append(behind, fmt.Sprintf("%s is %d behind, first pending %s",
|
|
name, len(pending), pending[0]))
|
|
}
|
|
}
|
|
if len(behind) == 0 {
|
|
return nil
|
|
}
|
|
sort.Strings(behind)
|
|
return fmt.Errorf("%s; run `go-admin migrate -c <config>` and see `go-admin migrate status`",
|
|
strings.Join(behind, "; "))
|
|
}
|
|
|
|
// appliedVersions reads what sys_migration records for one database.
|
|
//
|
|
// A missing table is not an error: a database that has never been migrated has
|
|
// applied nothing, which is exactly what the caller needs to hear, and is the
|
|
// state a first deploy is in.
|
|
func appliedVersions(ctx context.Context, db *gorm.DB) (map[string]bool, error) {
|
|
if db == nil {
|
|
return nil, fmt.Errorf("no database")
|
|
}
|
|
db = db.WithContext(ctx)
|
|
if !db.Migrator().HasTable(&commonmodels.Migration{}) {
|
|
return map[string]bool{}, nil
|
|
}
|
|
var rows []commonmodels.Migration
|
|
if err := db.Select("version").Find(&rows).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
out := make(map[string]bool, len(rows))
|
|
for _, r := range rows {
|
|
out[r.Version] = true
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// pendingVersions returns the registered versions applied does not contain.
|
|
//
|
|
// Split out and taking both sides as arguments because the registry is
|
|
// process-wide and filled by init() in packages cmd/api does not import: a
|
|
// test in this package cannot arrange it, so the arranging part is the part
|
|
// that is not tested here.
|
|
func pendingVersions(registered []string, applied map[string]bool) []string {
|
|
out := make([]string, 0)
|
|
for _, v := range registered {
|
|
if !applied[v] {
|
|
out = append(out, v)
|
|
}
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|