mirror of
https://github.com/go-admin-team/go-admin.git
synced 2026-09-29 04:27:17 +00:00
`migrate install <code>` brings one application up to the version its manifest declares: it runs that application's outstanding migrations and records what it did in sys_app. It goes under migrate rather than under the existing `app` command, which already means "generate the skeleton of a new application" - a directory that does not exist yet, not an application already compiled into this binary. Installing one is running its migrations, which is what this command is, so --domain, resolveDB and the guard that refuses a mistyped code instead of reporting a successful no-op are all already here. Three phases, each committing on its own, and they are not one transaction. An application's versions are separate migration files, and on MySQL a DDL statement commits the transaction around it - destroying an outer transaction and every savepoint taken from it. So this does not promise that a half-installed application cannot happen. It promises one is visible when it does: phase A writes "installing" before anything that can fail, phase B runs the migrations, phase C turns that into "installed" or into "failed" with the version it stopped on. What is left to apply comes from sys_migration, never from sys_app. sys_app is a derived view - a summary, and the answer to "which version does this app think it is at". If it were the authority, an operator who deleted sys_migration rows by hand would be told an application is installed while its schema is not, which is worse than not knowing. So "already installed, nothing to do" needs all three: nothing outstanding, recorded as installed, and the same version. A row stuck at "installing" - what it reads as after the process was killed partway - is not installed, and retrying is just running the command again. An upgrade is in place and keeps the first install's time; a downgrade is refused, and refused before phase A writes anything, so a refusal cannot cost the operator the row that told them what they had. An unparseable recorded version is refused the same way, while it is still readable. The report ends by saying the code is not running yet. That is not a pleasantry: Go links at build time and Vite resolves its import globs at build time, so installing an application writes its menus, its APIs and its permissions and cannot make one line of its code run - and the menus appearing is exactly what makes an operator believe otherwise. Ten degradations were applied one at a time to check the tests name the behaviour rather than the shape: deciding the no-op from sys_app alone, always writing installed_at, allowing the downgrade, keeping the previous attempt's diagnostics on a row that now says installed, treating "installing" as installed, truncating last_error by bytes so a Chinese message is cut mid-rune, not recording the failure at all, skipping code normalization, and writing phase A before either the downgrade or the version-parse refusal. Each went red on the assertion it was aimed at. An eleventh was discarded rather than counted: it failed in the first install's setup, not on the claim.
320 lines
9.4 KiB
Go
320 lines
9.4 KiB
Go
package migrate
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"text/template"
|
|
"time"
|
|
|
|
"github.com/go-admin-team/go-admin-core/v2/sdk"
|
|
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg"
|
|
"gorm.io/gorm"
|
|
|
|
"github.com/go-admin-team/go-admin-core/v2/config/source/file"
|
|
"github.com/spf13/cobra"
|
|
|
|
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
|
|
"go-admin/cmd/migrate/migration"
|
|
_ "go-admin/cmd/migrate/migration/version"
|
|
_ "go-admin/cmd/migrate/migration/version-local"
|
|
"go-admin/common/database"
|
|
"go-admin/common/models"
|
|
)
|
|
|
|
var (
|
|
configYml string
|
|
generate bool
|
|
goAdmin bool
|
|
host string
|
|
appCode string
|
|
dryRun bool
|
|
StartCmd = &cobra.Command{
|
|
Use: "migrate",
|
|
Short: "Initialize the database",
|
|
Example: "go-admin migrate -c config/settings.yml",
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
run()
|
|
},
|
|
}
|
|
statusCmd = &cobra.Command{
|
|
Use: "status",
|
|
Short: "List applied and pending migrations, grouped by app",
|
|
Example: "go-admin migrate status -c config/settings.yml",
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
runStatus()
|
|
},
|
|
}
|
|
// Under migrate rather than under the existing `app` command, which
|
|
// already means "generate the skeleton of a new app" - a directory that
|
|
// does not exist yet, not an application already compiled into this
|
|
// binary. Installing an application is running its migrations, which is
|
|
// what this command is; --app, --domain and resolveDB are all already
|
|
// here, including the guard that refuses a mistyped code instead of
|
|
// reporting a successful no-op.
|
|
installCmd = &cobra.Command{
|
|
Use: "install <code>",
|
|
Short: "Install one application: run its migrations and record it in sys_app",
|
|
Example: "go-admin migrate install order -c config/settings.yml",
|
|
Args: cobra.ExactArgs(1),
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
runInstall(args[0])
|
|
},
|
|
}
|
|
)
|
|
|
|
// fixme 在您看不见代码的时候运行迁移,我觉得是不安全的,所以编译后最好不要去执行迁移
|
|
func init() {
|
|
StartCmd.PersistentFlags().StringVarP(&configYml, "config", "c", "config/settings.yml", "Start server with provided configuration file")
|
|
StartCmd.PersistentFlags().BoolVarP(&generate, "generate", "g", false, "generate migration file")
|
|
StartCmd.PersistentFlags().BoolVarP(&goAdmin, "goAdmin", "a", false, "with -g, write the generated file to version/ instead of version-local/ (does not affect which migrations run)")
|
|
StartCmd.PersistentFlags().StringVarP(&host, "domain", "d", "*", "select tenant host")
|
|
|
|
// --app is deliberately long-only. -a already means "generate into
|
|
// version/ rather than version-local/", which is about writing a template
|
|
// file, not about which migrations run; giving the two the same letter
|
|
// would be a trap.
|
|
StartCmd.PersistentFlags().StringVar(&appCode, "app", "", "limit to the migrations of one app (\""+migration.FrameworkAppCode+"\" for the framework's own)")
|
|
StartCmd.Flags().BoolVar(&dryRun, "dry-run", false, "list what would be applied, in order, and write nothing")
|
|
|
|
StartCmd.AddCommand(statusCmd)
|
|
StartCmd.AddCommand(installCmd)
|
|
}
|
|
|
|
func run() {
|
|
|
|
if !generate {
|
|
fmt.Println(`start init`)
|
|
//1. 读取配置
|
|
config.Setup(
|
|
file.NewSource(file.WithPath(configYml)),
|
|
initDB,
|
|
)
|
|
} else {
|
|
fmt.Println(`generate migration file`)
|
|
_ = genFile()
|
|
}
|
|
}
|
|
|
|
// resolveDB picks the tenant database and hands it to the registry.
|
|
//
|
|
// It creates and alters nothing, which is what lets status and --dry-run share
|
|
// it: those two must be able to run against a production database without
|
|
// leaving a trace.
|
|
func resolveDB() (*gorm.DB, error) {
|
|
if host == "" {
|
|
host = "*"
|
|
}
|
|
db := sdk.Runtime.GetDbByTenant(host)
|
|
if db == nil {
|
|
if len(sdk.Runtime.GetAllDb()) == 1 && host == "*" {
|
|
for k, v := range sdk.Runtime.GetAllDb() {
|
|
db = v
|
|
host = k
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if db == nil {
|
|
return nil, fmt.Errorf("未找到数据库配置")
|
|
}
|
|
if config.DatabasesConfig[host].Driver == "mysql" {
|
|
//初始化数据库时候用
|
|
db.Set("gorm:table_options", "ENGINE=InnoDB CHARSET=utf8mb4")
|
|
}
|
|
return db, nil
|
|
}
|
|
|
|
// exitUnlessAppRegistered ends the command when --app names something no
|
|
// migration was registered under.
|
|
//
|
|
// Every path took a typo as "nothing matched" and reported success: `migrate`
|
|
// printed that the app was unknown and still exited 0, while `--dry-run` and
|
|
// `status` said "nothing to apply" and "none recorded" - which is what an
|
|
// up-to-date database says too, so the output does not even hint at the typo.
|
|
// An operator running `go-admin migrate --app crmm && deploy` gets the deploy.
|
|
//
|
|
// Checked against the registry, which init() has already filled, so this runs
|
|
// before any database work and costs nothing. It lives in the command layer
|
|
// because the exit code does: the migration package stays callable from a test
|
|
// without taking the process down with it.
|
|
func exitUnlessAppRegistered() {
|
|
if err := appRegistrationError(); err != nil {
|
|
fmt.Fprintln(os.Stderr, err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
// appRegistrationError carries the decision on its own so it can be tested;
|
|
// exitUnlessAppRegistered is only the os.Exit around it. Nil means --app was
|
|
// either empty or names a registered app.
|
|
func appRegistrationError() error {
|
|
if appCode == "" {
|
|
return nil
|
|
}
|
|
want := migration.DisplayAppCode(migration.AppFilter(appCode))
|
|
registered := migration.Migrate.AppCodes()
|
|
for _, c := range registered {
|
|
if c == want {
|
|
return nil
|
|
}
|
|
}
|
|
return fmt.Errorf("no migrations are registered for app %q; registered: %s",
|
|
want, strings.Join(registered, ", "))
|
|
}
|
|
|
|
func migrateModel() error {
|
|
db, err := resolveDB()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// sys_migration is the one table that never goes through a versioned
|
|
// migration - it is the table that records them. AutoMigrate realigns it
|
|
// on every run, which is how the app_code column reaches an existing
|
|
// database without anyone writing a migration for it.
|
|
if err = db.Debug().AutoMigrate(&models.Migration{}); err != nil {
|
|
return err
|
|
}
|
|
migration.Migrate.SetDb(db.Debug())
|
|
if appCode != "" {
|
|
return migration.Migrate.MigrateApp(appCode)
|
|
}
|
|
return migration.Migrate.Migrate()
|
|
}
|
|
|
|
func initDB() {
|
|
// Before the database is touched, so a typo cannot get as far as looking
|
|
// like a successful no-op on either path below.
|
|
exitUnlessAppRegistered()
|
|
|
|
//3. 初始化数据库链接
|
|
database.Setup()
|
|
|
|
if dryRun {
|
|
db, err := resolveDB()
|
|
if err != nil {
|
|
fmt.Println(err)
|
|
return
|
|
}
|
|
migration.Migrate.SetDb(db)
|
|
entries, err := migration.Migrate.Status()
|
|
if err != nil {
|
|
fmt.Println(err)
|
|
return
|
|
}
|
|
if err = printPending(os.Stdout, entries, appCode); err != nil {
|
|
fmt.Println(err)
|
|
}
|
|
return
|
|
}
|
|
|
|
//4. 数据库迁移
|
|
fmt.Println("数据库迁移开始")
|
|
exitOnError(os.Stderr, migrateModel())
|
|
fmt.Println(`数据库基础数据初始化成功`)
|
|
}
|
|
|
|
// exitOnError ends the command non-zero when the migration did not go through.
|
|
//
|
|
// A caller that migrates before starting a server decides whether to go ahead
|
|
// on the exit code alone - the deploy workflow does exactly that. Every path
|
|
// out of migrateModel used to return without one: an unreachable tenant
|
|
// database or a failed AutoMigrate printed a line and exited 0, so a
|
|
// deployment carried on onto a schema that had not been brought forward. A
|
|
// failing migration function was the only one reported, and only because it
|
|
// ended the process from inside the migration engine - which is the call this
|
|
// batch moved out here, so without this the last reported failure would have
|
|
// stopped being reported too.
|
|
//
|
|
// Split from the exit itself, the way appRegistrationError is split from
|
|
// exitUnlessAppRegistered, so what it decides can be tested without a
|
|
// subprocess. osExit is a variable for the same reason.
|
|
func exitOnError(w io.Writer, err error) {
|
|
if err == nil {
|
|
return
|
|
}
|
|
fmt.Fprintln(w, err)
|
|
osExit(1)
|
|
}
|
|
|
|
// osExit is a variable so a test can watch the decision without ending the
|
|
// test binary; origExit is what it is put back to.
|
|
var (
|
|
osExit = os.Exit
|
|
origExit = os.Exit
|
|
)
|
|
|
|
func runStatus() {
|
|
config.Setup(
|
|
file.NewSource(file.WithPath(configYml)),
|
|
func() {
|
|
exitUnlessAppRegistered()
|
|
|
|
database.Setup()
|
|
db, err := resolveDB()
|
|
if err != nil {
|
|
fmt.Println(err)
|
|
return
|
|
}
|
|
migration.Migrate.SetDb(db)
|
|
entries, err := migration.Migrate.Status()
|
|
if err != nil {
|
|
fmt.Println(err)
|
|
return
|
|
}
|
|
if err = printStatus(os.Stdout, entries, appCode); err != nil {
|
|
fmt.Println(err)
|
|
}
|
|
},
|
|
)
|
|
}
|
|
|
|
func runInstall(code string) {
|
|
config.Setup(
|
|
file.NewSource(file.WithPath(configYml)),
|
|
func() {
|
|
database.Setup()
|
|
db, err := resolveDB()
|
|
if err != nil {
|
|
exitOnError(os.Stderr, err)
|
|
return
|
|
}
|
|
m, err := manifestFor(code)
|
|
if err != nil {
|
|
exitOnError(os.Stderr, err)
|
|
return
|
|
}
|
|
rep, err := install(db, migration.Migrate, m)
|
|
if err != nil {
|
|
exitOnError(os.Stderr, err)
|
|
return
|
|
}
|
|
reportInstall(os.Stdout, rep)
|
|
},
|
|
)
|
|
}
|
|
|
|
func genFile() error {
|
|
t1, err := template.ParseFiles("template/migrate.template")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
m := map[string]string{}
|
|
m["GenerateTime"] = strconv.FormatInt(time.Now().UnixNano()/1e6, 10)
|
|
m["Package"] = "version_local"
|
|
if goAdmin {
|
|
m["Package"] = "version"
|
|
}
|
|
var b1 bytes.Buffer
|
|
err = t1.Execute(&b1, m)
|
|
if goAdmin {
|
|
pkg.FileCreate(b1, "./cmd/migrate/migration/version/"+m["GenerateTime"]+"_migrate.go")
|
|
} else {
|
|
pkg.FileCreate(b1, "./cmd/migrate/migration/version-local/"+m["GenerateTime"]+"_migrate.go")
|
|
}
|
|
return nil
|
|
}
|