mirror of
https://github.com/go-admin-team/go-admin.git
synced 2026-09-25 11:31:47 +00:00
`migrate uninstall <code>` removes what an application's install wrote and leaves the application's own tables alone. Removing an order module is not the same decision as destroying the orders, and nothing here can tell an operator who is done with it from one who will reinstall tomorrow. One transaction, and this one really is one: every statement is DML or a SELECT, so unlike an install there is no DDL to commit it out from under itself. Child rows go first, while the ids that identify them can still be read from their parents, and the api paths are read before the rows carrying them are deleted. The two join tables need no ledger and get none. menu_id is a surrogate key, so a sys_role_menu or sys_menu_api_rule row can only have come from a menu this application wrote - there is no "looks like it but is not". A column on sys_role_menu would have been worse than unnecessary: SysRole.Update deletes a role's rows and writes them back through GORM's many2many, which does not carry extra columns, so the column would be blanked the first time anybody edited a role, silently. There is a test that performs that edit and then uninstalls. casbin_rule is the opposite case, because its key is business text somebody else may have written for their own reasons. Policies are removed one at a time, by exact tuple, and only the ones the ledger says this install created. A tuple the ledger names that is no longer there is reported, not treated as a failure - the uninstall wanted it gone and it is gone. Then, with everything the ledger could speak for already dealt with, a read-only pass lists the policies still naming this application's paths: those are grants somebody made by hand, they are about to point at APIs that no longer exist, and they are not this command's to delete. The two lists stay separate because they mean different things - one is something of ours that had already gone, the other is somebody else's grant now pointing at nothing - and merged into one "could not remove" list neither would be actionable. sys_migration's rows for the application go too. Without that a reinstall finds every version applied, runs no migration, seeds nothing, and reports success. It is the easiest step to leave out, because a migration record does not look like the application's data. A sys_app row is not required. `migrate` with no subcommand applies every registered migration, an application's included, so an application can have all of its rows without ever having gone through the installer - and that is the case where nothing else can clean up after it. Eleven degradations were applied one at a time, each red on the assertion it was aimed at: skipping either join table, deleting sys_role_menu without its filter, skipping sys_migration, deleting sys_migration without its filter, matching policies by path instead of by ledger tuple, dropping the orphan pass, treating a missing policy as a failure, leaving the ledger behind, soft deleting sys_menu instead of removing it, and running the whole thing outside a transaction.
353 lines
10 KiB
Go
353 lines
10 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])
|
|
},
|
|
}
|
|
uninstallCmd = &cobra.Command{
|
|
Use: "uninstall <code>",
|
|
Short: "Remove one application's menus, apis and permission grants; its own tables are left alone",
|
|
Example: "go-admin migrate uninstall order -c config/settings.yml",
|
|
Args: cobra.ExactArgs(1),
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
runUninstall(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)
|
|
StartCmd.AddCommand(uninstallCmd)
|
|
}
|
|
|
|
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 runUninstall(code string) {
|
|
config.Setup(
|
|
file.NewSource(file.WithPath(configYml)),
|
|
func() {
|
|
database.Setup()
|
|
db, err := resolveDB()
|
|
if err != nil {
|
|
exitOnError(os.Stderr, err)
|
|
return
|
|
}
|
|
// No manifest lookup. An application whose code has already been
|
|
// taken out of the binary registers nothing, and that is exactly
|
|
// when somebody needs to clear its rows out of the database.
|
|
rep, err := uninstall(db, code)
|
|
if err != nil {
|
|
exitOnError(os.Stderr, err)
|
|
return
|
|
}
|
|
reportUninstall(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
|
|
}
|