mirror of
https://github.com/go-admin-team/go-admin.git
synced 2026-09-23 18:58:09 +00:00
run() called log.Fatalf on the first migration that failed, which ended the process from inside the migration engine. Nothing above it could record what happened - an installer needs to write down which version an attempt stopped on - and no test could exercise a failing migration at all without taking the test binary with it, which is why the one test that covers a failed migration drove the registered function directly and left the scheduler uncovered. run(), Migrate() and MigrateApp() now return an error, and the exit moved to the command layer where the exit code is the command's business. Two of those errors say more than "it failed". A migration that fails comes back as a *VersionFailure naming the version, because an installer records that as a diagnostic snapshot - the authoritative answer to where a retry resumes is always recomputed from sys_migration, never read back, and asking the database what is still pending answers a different question that merely has the same answer most of the time. An app code nothing registered under is now an error rather than a log line, so an installer asking for one app by name cannot be told that installing an app that does not exist succeeded; the command layer still rejects a typo before any database work. exitOnError is what makes the command exit non-zero, and it covers more than it replaces. Every path out of migrateModel used to return without an exit code: an unreachable tenant database or a failed AutoMigrate printed a line and exited 0, so a caller that migrates before starting a server - the deploy workflow does exactly that - carried on onto a schema that had not been brought forward. A failing migration function was the only failure reported, and only as a side effect of the log.Fatalf this commit removes. Each of these was checked by degrading it and watching the named assertion go red: returning nil instead of the failure, naming the first version rather than the one that failed, accepting an unregistered app code, and not exiting. One gap is left open deliberately. Go allows a call whose only result is an error to stand as a statement, so `migration.Migrate.Migrate()` still compiles while dropping what it returns - `go build` passed while migrateModel was doing exactly that during this change. Both call sites now return the value, which the compiler does check, but nothing guards against the statement form coming back. A checksilent rule was considered and dropped: that tool parses without type information, so it could only match the method name, and a guard that fires on any type with a Migrate method is noise.
428 lines
15 KiB
Go
428 lines
15 KiB
Go
package migration
|
|
|
|
import (
|
|
"fmt"
|
|
"log"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"gorm.io/gorm"
|
|
|
|
contractmigration "github.com/go-admin-team/go-admin-core/v2/sdk/contract/migration"
|
|
|
|
common "go-admin/common/models"
|
|
)
|
|
|
|
var Migrate = newMigration()
|
|
|
|
// contractSnapshot is contractmigration.Snapshot, indirected through a
|
|
// package-level variable so tests can substitute an isolated
|
|
// *contractmigration.Registry's Snapshot instead of reaching into
|
|
// go-admin-core's single process-wide registry, which every *Migration in
|
|
// this process - test-local or the package-level Migrate - reads through the
|
|
// same call. See mergedEntries.
|
|
var contractSnapshot = contractmigration.Snapshot
|
|
|
|
func newMigration() *Migration {
|
|
return &Migration{version: make(map[string]versionEntry)}
|
|
}
|
|
|
|
// versionEntry is one registered migration plus the app it belongs to. The
|
|
// empty app code means the framework itself, which is also what the
|
|
// sys_migration.app_code column defaults to, so history written before this
|
|
// field existed reads back correctly with no backfill.
|
|
type versionEntry struct {
|
|
appCode string
|
|
fn func(db *gorm.DB, version string) error
|
|
}
|
|
|
|
type Migration struct {
|
|
db *gorm.DB
|
|
version map[string]versionEntry
|
|
mutex sync.Mutex
|
|
}
|
|
|
|
func (e *Migration) GetDb() *gorm.DB {
|
|
return e.db
|
|
}
|
|
|
|
func (e *Migration) SetDb(db *gorm.DB) {
|
|
e.db = db
|
|
}
|
|
|
|
// SetVersion registers a migration owned by the framework. Signature and
|
|
// behaviour are unchanged: every existing call site in version/*.go keeps
|
|
// compiling and keeps writing common.Migration{Version: version} with no app
|
|
// code, which is the correct meaning of "framework".
|
|
func (e *Migration) SetVersion(k string, f func(db *gorm.DB, version string) error) {
|
|
e.setVersion(k, "", f)
|
|
}
|
|
|
|
func (e *Migration) setVersion(k, appCode string, f func(db *gorm.DB, version string) error) {
|
|
e.mutex.Lock()
|
|
defer e.mutex.Unlock()
|
|
e.version[k] = versionEntry{appCode: appCode, fn: f}
|
|
}
|
|
|
|
// AppMigrationFunc is the signature of a migration registered through ForApp.
|
|
//
|
|
// It receives appCode explicitly because the migration - not the framework -
|
|
// writes its own completion row, normally as the last statement inside its own
|
|
// transaction. That is what makes "the schema change and the record of it
|
|
// commit together" true, and the framework cannot insert the row on the
|
|
// migration's behalf without giving that up. Handing the code to the function
|
|
// is what stops an app's migrations from silently recording themselves as the
|
|
// framework's.
|
|
type AppMigrationFunc func(db *gorm.DB, version, appCode string) error
|
|
|
|
// AppRegistrar is a per-app view over a registry.
|
|
type AppRegistrar struct {
|
|
m *Migration
|
|
appCode string
|
|
}
|
|
|
|
// FrameworkAppCode is the name migrate status prints for migrations that belong
|
|
// to the framework rather than to an app, and the name --app accepts to select
|
|
// them. The stored app code for those is the empty string; this is only the
|
|
// spelling humans use. It is reserved - ForApp rejects it - so that every group
|
|
// heading status prints is also a value --app understands.
|
|
const FrameworkAppCode = "core"
|
|
|
|
// ForApp returns a registrar that records migrations under code.
|
|
//
|
|
// The code is lower-cased: sys_migration.version sorts as ASCII, so mixed case
|
|
// would order MyApp before crm for no reason a reader could guess, and the two
|
|
// spellings would group as two different apps in migrate status.
|
|
//
|
|
// An empty or reserved code panics rather than falling back to the framework.
|
|
// Registration happens in init(), so this fires the first time the binary runs
|
|
// anywhere, which is the point: an app whose migrations quietly file themselves
|
|
// under the framework is exactly the class of silent failure this work is meant
|
|
// to remove. Framework migrations call Migrate.SetVersion directly.
|
|
func ForApp(code string) *AppRegistrar { return Migrate.ForApp(code) }
|
|
|
|
// ForApp is the same on an explicit registry, which is what tests use.
|
|
func (e *Migration) ForApp(code string) *AppRegistrar {
|
|
code = NormalizeAppCode(code)
|
|
switch code {
|
|
case "":
|
|
panic("migration.ForApp: empty app code; framework migrations use Migrate.SetVersion")
|
|
case FrameworkAppCode:
|
|
panic("migration.ForApp: app code " + FrameworkAppCode + " is reserved for the framework")
|
|
}
|
|
return &AppRegistrar{m: e, appCode: code}
|
|
}
|
|
|
|
// AppCode reports the code this registrar files migrations under, after
|
|
// normalisation.
|
|
func (r *AppRegistrar) AppCode() string { return r.appCode }
|
|
|
|
// SetVersion registers an app-owned migration under k, which is the bare
|
|
// timestamp taken from the file name exactly as framework migrations do.
|
|
//
|
|
// What reaches sys_migration.version is the namespaced form; the version string
|
|
// handed to f is that same namespaced string, so a migration that writes
|
|
// common.Migration{Version: version, AppCode: appCode} records the key the
|
|
// registry will look for next time.
|
|
func (r *AppRegistrar) SetVersion(k string, f AppMigrationFunc) {
|
|
key := namespacedKey(r.appCode, k)
|
|
r.m.setVersion(key, r.appCode, func(db *gorm.DB, version string) error {
|
|
return f(db, version, r.appCode)
|
|
})
|
|
}
|
|
|
|
// namespacedKey scopes k to appCode so two apps cannot collide on the
|
|
// sys_migration.version primary key by minting the same millisecond timestamp.
|
|
// Framework migrations (appCode == "") stay bare, matching every version string
|
|
// already in production.
|
|
func namespacedKey(appCode, k string) string {
|
|
if appCode == "" {
|
|
return k
|
|
}
|
|
return appCode + "-" + k
|
|
}
|
|
|
|
// mergedEntries returns every migration this process knows about: the
|
|
// host's own registry (e.version, filled by version/*.go and
|
|
// version-local/*.go through SetVersion/ForApp) plus whatever a third-party
|
|
// application registered through go-admin-core's sdk/contract/migration
|
|
// package (PRD 006, F9's host wiring).
|
|
//
|
|
// That package keeps its own process-wide registry, entirely separate from
|
|
// e.version, because a third-party application cannot reach into this
|
|
// process to call an unexported method on *Migration - contract/migration's
|
|
// package-level ForApp/Snapshot are the only door open to it. Without this
|
|
// merge, migrate/status/--dry-run would only ever see the host's own
|
|
// migrations: an application's ForApp("crm").SetVersion(...) would compile,
|
|
// register successfully into contract/migration's registry, and then never
|
|
// run, with no error anywhere - the exact silent gap this method closes.
|
|
//
|
|
// Entry and versionEntry are structurally identical (an app code plus a
|
|
// func(db, version) error); the conversion below exists only because they
|
|
// are two distinct named types, one per package, not because the data
|
|
// differs.
|
|
func (e *Migration) mergedEntries() map[string]versionEntry {
|
|
e.mutex.Lock()
|
|
out := make(map[string]versionEntry, len(e.version))
|
|
for k, v := range e.version {
|
|
out[k] = v
|
|
}
|
|
e.mutex.Unlock()
|
|
|
|
for k, entry := range contractSnapshot() {
|
|
if _, exists := out[k]; exists {
|
|
// contract/migration.ForApp namespaces every app-owned key as
|
|
// appCode + "-" + k, and appCode is reserved from ""/"core", so
|
|
// this should never collide with a host-registered key. If it
|
|
// somehow does, the host's own registration wins rather than
|
|
// silently overwriting it.
|
|
continue
|
|
}
|
|
out[k] = versionEntry{appCode: entry.AppCode, fn: entry.Fn}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// RegisteredVersions returns every migration version this binary registers,
|
|
// sorted, without touching a database.
|
|
//
|
|
// Status answers a richer question - what is registered, what is applied, and
|
|
// what is applied while nothing registers it - and needs a database to do it.
|
|
// This is the half that can be asked of the process alone, which is what a
|
|
// readiness check needs: the check holds the databases it is asking about, and
|
|
// reusing Status would mean calling SetDb from a request handler, writing this
|
|
// package's shared state from a request path.
|
|
func (e *Migration) RegisteredVersions() []string {
|
|
all := e.mergedEntries()
|
|
out := make([]string, 0, len(all))
|
|
for k := range all {
|
|
out = append(out, k)
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
// RegisteredVersions reports what the process-wide registry holds.
|
|
func RegisteredVersions() []string { return Migrate.RegisteredVersions() }
|
|
|
|
// StatusEntry is one row of migrate status.
|
|
type StatusEntry struct {
|
|
AppCode string
|
|
Version string
|
|
Registered bool
|
|
Applied bool
|
|
ApplyTime *time.Time
|
|
}
|
|
|
|
// Status merges the in-process registry with sys_migration, so it reports all
|
|
// three shapes at once: registered but not applied, registered and applied, and
|
|
// applied while nothing registers it any more - a row left behind by a
|
|
// migration file that was deleted, or by an app that was uninstalled.
|
|
//
|
|
// It only reads. Nothing here creates or alters a table, which is what lets
|
|
// both `status` and `--dry-run` run against a database without touching it.
|
|
func (e *Migration) Status() ([]StatusEntry, error) {
|
|
if e.db == nil {
|
|
return nil, fmt.Errorf("migration: no database configured")
|
|
}
|
|
|
|
all := e.mergedEntries()
|
|
registered := make(map[string]string, len(all))
|
|
for k, v := range all {
|
|
registered[k] = v.appCode
|
|
}
|
|
|
|
applied := make(map[string]common.Migration)
|
|
// A database that has never been migrated has no sys_migration table.
|
|
// Reporting everything as pending is the honest answer there; erroring out
|
|
// would make status useless in exactly the case it is most wanted.
|
|
if e.db.Migrator().HasTable(&common.Migration{}) {
|
|
var rows []common.Migration
|
|
if err := e.db.Find(&rows).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
for _, r := range rows {
|
|
applied[r.Version] = r
|
|
}
|
|
}
|
|
|
|
versions := make(map[string]struct{}, len(registered)+len(applied))
|
|
for k := range registered {
|
|
versions[k] = struct{}{}
|
|
}
|
|
for k := range applied {
|
|
versions[k] = struct{}{}
|
|
}
|
|
list := make([]string, 0, len(versions))
|
|
for k := range versions {
|
|
list = append(list, k)
|
|
}
|
|
sort.Strings(list)
|
|
|
|
out := make([]StatusEntry, 0, len(list))
|
|
for _, v := range list {
|
|
entry := StatusEntry{Version: v}
|
|
if code, ok := registered[v]; ok {
|
|
entry.Registered = true
|
|
entry.AppCode = code
|
|
}
|
|
if row, ok := applied[v]; ok {
|
|
entry.Applied = true
|
|
t := row.ApplyTime
|
|
entry.ApplyTime = &t
|
|
if !entry.Registered {
|
|
// Nothing registers this version any more, so the database is
|
|
// the only source left for what it belonged to.
|
|
entry.AppCode = row.AppCode
|
|
}
|
|
}
|
|
out = append(out, entry)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// Migrate applies every registered migration that has not been applied yet,
|
|
// across all apps.
|
|
func (e *Migration) Migrate() error { return e.run(allApps) }
|
|
|
|
// MigrateApp applies only the migrations registered under appCode. Pass
|
|
// FrameworkAppCode for the framework's own migrations.
|
|
func (e *Migration) MigrateApp(appCode string) error { return e.run(AppFilter(appCode)) }
|
|
|
|
// VersionFailure names the migration that failed.
|
|
//
|
|
// The caller that needs this is an installer recording which version an
|
|
// install got stuck on. That is a diagnostic snapshot and nothing more: the
|
|
// authoritative answer to "where does a retry resume" is always recomputed
|
|
// by subtracting sys_migration's applied rows from what is registered, never
|
|
// read back from anywhere it was stored. Which is exactly why this carries
|
|
// the version rather than leaving the caller to infer it - inferring it
|
|
// would produce "what is pending now", a different question that happens to
|
|
// have the same answer most of the time.
|
|
type VersionFailure struct {
|
|
Version string
|
|
Err error
|
|
}
|
|
|
|
func (e *VersionFailure) Error() string {
|
|
return fmt.Sprintf("migration %s failed: %v", e.Version, e.Err)
|
|
}
|
|
|
|
func (e *VersionFailure) Unwrap() error { return e.Err }
|
|
|
|
// NormalizeAppCode applies the same rule ForApp does, so a code typed on the
|
|
// command line matches one written in an init().
|
|
func NormalizeAppCode(code string) string {
|
|
return strings.ToLower(strings.TrimSpace(code))
|
|
}
|
|
|
|
// AppFilter turns a code as typed into the code stored in the registry, so
|
|
// "core" selects the framework's migrations, whose stored code is empty.
|
|
func AppFilter(code string) string {
|
|
code = NormalizeAppCode(code)
|
|
if code == FrameworkAppCode {
|
|
return ""
|
|
}
|
|
return code
|
|
}
|
|
|
|
// DisplayAppCode is the inverse: what to print for a stored code.
|
|
func DisplayAppCode(code string) string {
|
|
if code == "" {
|
|
return FrameworkAppCode
|
|
}
|
|
return code
|
|
}
|
|
|
|
// AppCodes lists the app codes with at least one registered migration, framework
|
|
// included under its display name, sorted.
|
|
func (e *Migration) AppCodes() []string {
|
|
all := e.mergedEntries()
|
|
seen := map[string]struct{}{}
|
|
for _, v := range all {
|
|
seen[DisplayAppCode(v.appCode)] = struct{}{}
|
|
}
|
|
|
|
out := make([]string, 0, len(seen))
|
|
for code := range seen {
|
|
out = append(out, code)
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
// run applies the pending migrations selected by appCode.
|
|
//
|
|
// It reports failure instead of ending the process. It used to call
|
|
// log.Fatalf, which took the whole process down at the first failing
|
|
// migration - so a caller had nowhere to record what happened, and a test
|
|
// could not exercise a failing migration at all without killing the test
|
|
// binary. The exit now lives at the command layer, where the exit code is
|
|
// the command's business (see initDB in cmd/migrate/server.go).
|
|
func (e *Migration) run(appCode string) error {
|
|
all := e.mergedEntries()
|
|
versions := make([]string, 0, len(all))
|
|
entries := make(map[string]versionEntry, len(all))
|
|
for k, v := range all {
|
|
if appCode != allApps && v.appCode != appCode {
|
|
continue
|
|
}
|
|
versions = append(versions, k)
|
|
entries[k] = v
|
|
}
|
|
sort.Strings(versions)
|
|
|
|
// A mistyped --app would otherwise select nothing and report "no
|
|
// migrations to apply", which reads exactly like "already up to date".
|
|
//
|
|
// The command layer rejects an unregistered code before any database
|
|
// work (exitUnlessAppRegistered), so on that path this is unreachable.
|
|
// It is reachable from an installer, which asks for one app by name and
|
|
// must not be told that installing an app nothing registered succeeded.
|
|
if appCode != allApps && len(versions) == 0 {
|
|
return fmt.Errorf("no migrations are registered for app %q; registered: %s",
|
|
DisplayAppCode(appCode), strings.Join(e.AppCodes(), ", "))
|
|
}
|
|
|
|
var err error
|
|
var count int64
|
|
applied := 0
|
|
for _, v := range versions {
|
|
err = e.db.Table("sys_migration").Where("version = ?", v).Count(&count).Error
|
|
if err != nil {
|
|
return fmt.Errorf("checking whether migration %s was applied: %w", v, err)
|
|
}
|
|
if count > 0 {
|
|
// Already applied. This used to print the bare count, so a mature
|
|
// database wrote a screen of "1" at every start.
|
|
count = 0
|
|
continue
|
|
}
|
|
log.Printf("applying migration %s", v)
|
|
if err = entries[v].fn(e.db.Debug(), v); err != nil {
|
|
return &VersionFailure{Version: v, Err: err}
|
|
}
|
|
applied++
|
|
}
|
|
if applied == 0 {
|
|
log.Println("no migrations to apply")
|
|
} else {
|
|
log.Printf("applied %d migration(s)", applied)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// allApps is the sentinel run() takes to mean "do not filter". It is distinct
|
|
// from the empty app code, which selects the framework's own migrations.
|
|
const allApps = "\x00all"
|
|
|
|
// GetFilename derives a migration's version from its file name. The rule
|
|
// lives in contract/migration, because an application registering through
|
|
// that package names its files by the same convention and must land on the
|
|
// same version string; a second copy here is a second thing to keep in step.
|
|
func GetFilename(s string) string {
|
|
return contractmigration.GetFilename(s)
|
|
}
|