diff --git a/app/admin/models/sys_app.go b/app/admin/models/sys_app.go index 72008266..885d5215 100644 --- a/app/admin/models/sys_app.go +++ b/app/admin/models/sys_app.go @@ -6,6 +6,22 @@ import ( "go-admin/common/models" ) +// The values sys_app.status takes. +// +// Three states rather than a single "installed", because an install that +// stopped partway has to be an observable row rather than the absence of one: +// the versions an app installs are separate migration files, and on MySQL a +// DDL statement commits the transaction around it, so they cannot be wrapped +// in one. See docs-prd/008-应用清单与安装器/数据库变更.md §1.5. +// +// AppInstalling is also what a row reads as after the process was killed +// mid-install, which is why it is not treated as "installed" by anything. +const ( + AppInstalling = 1 + AppInstalled = 2 + AppFailed = 3 +) + // SysApp is the sys_app row model: one row per installed application (PRD // 008 F2). It deliberately does not embed models.ModelTime - see the design // doc (docs-prd/008-应用清单与安装器/数据库变更.md) §1.1 for why an diff --git a/cmd/migrate/install.go b/cmd/migrate/install.go new file mode 100644 index 00000000..d8aeb25d --- /dev/null +++ b/cmd/migrate/install.go @@ -0,0 +1,308 @@ +package migrate + +import ( + "errors" + "fmt" + "io" + "sort" + "strings" + "time" + + "github.com/go-admin-team/go-admin-core/v2/sdk/contract/app" + "gorm.io/gorm" + + adminmodels "go-admin/app/admin/models" + "go-admin/cmd/migrate/migration" +) + +// engine is the part of the migration engine the installer drives. +// +// An interface rather than *migration.Migration because the concrete type is +// a package-level singleton with no exported constructor, so a test that took +// it would be sharing one registry with every other test in the process. +type engine interface { + SetDb(*gorm.DB) + Status() ([]migration.StatusEntry, error) + MigrateApp(string) error +} + +// installReport is what an install did, for the command to print. +type installReport struct { + Code string + // Version is the manifest version this run recorded. + Version string + // Previous is the version sys_app held before this run, empty when this + // is the first install. + Previous string + // Applied lists the versions this run brought in, in the order they were + // applied. Empty on a no-op, and also empty on a run that only corrected + // sys_app - the difference is NoOp. + Applied []string + // NoOp says nothing was left to do: the app is recorded as installed, at + // this same version, with no migration outstanding. + NoOp bool +} + +// install brings one application up to the version its manifest declares. +// +// Three phases, each committing on its own. They are not one transaction and +// cannot be: an application's versions are separate migration files, and a +// DDL statement inside any of them commits the transaction around it on +// MySQL, which destroys an outer transaction and every savepoint taken from +// it (docs-prd/008-应用清单与安装器/数据库变更.md §1.5). So this does not +// promise that a half-installed application cannot happen. It promises that +// one is visible when it does: phase A writes "installing" before anything +// that can fail, and phase C turns that into "installed" or "failed". +// +// What is left to apply comes from sys_migration, never from sys_app. +// sys_app is a derived view - a summary for a human, and the answer to "which +// version does this app think it is at". If it were the authority, then an +// operator who deleted sys_migration rows by hand would be told an app is +// installed while its schema is not, which is worse than not knowing. +func install(db *gorm.DB, eng engine, m app.Manifest) (installReport, error) { + code := migration.NormalizeAppCode(m.Code) + rep := installReport{Code: code, Version: m.Version} + if code == "" { + return rep, errors.New("the manifest declares no app code") + } + if code == migration.FrameworkAppCode { + // Installing the framework is what `migrate` is, and the framework + // has no manifest and no sys_app row. Saying so beats writing a row + // that nothing else in this batch expects to exist. + return rep, fmt.Errorf("%q is the framework's own migrations, not an application; run `migrate` for those", code) + } + if !db.Migrator().HasTable(&adminmodels.SysApp{}) { + return rep, errors.New("sys_app does not exist; run `migrate` first to bring the framework's own tables up to date") + } + + eng.SetDb(db) + + row, found, err := loadApp(db, code) + if err != nil { + return rep, err + } + // sameVersion is only meaningful when found; it stays false otherwise. + // The comparison happens here, before phase A, so an unparseable + // recorded version is refused while it is still readable rather than + // after being overwritten. + sameVersion := false + if found { + rep.Previous = row.Version + cmp, err := app.Compare(m.Version, row.Version) + if err != nil { + return rep, fmt.Errorf("comparing %s against the recorded %s: %w", m.Version, row.Version, err) + } + if cmp < 0 { + return rep, fmt.Errorf("%s is recorded at %s; installing %s would be a downgrade, which is not supported", + code, row.Version, m.Version) + } + sameVersion = cmp == 0 + } + + pending, err := pendingFor(eng, code) + if err != nil { + return rep, err + } + + // Nothing outstanding, recorded as installed, at this same version. All + // three, and the first one comes from sys_migration: a row that says + // installed while a migration of its has never run is exactly the case + // sys_app must not be believed about. AppInstalling is not installed - + // it is what a row reads as after the process was killed partway. + if found && sameVersion && row.Status == adminmodels.AppInstalled && len(pending) == 0 { + rep.NoOp = true + return rep, nil + } + + // Phase A: the attempt is on disk before anything that can fail. + now := time.Now() + if err := beginInstall(db, &row, m, code, found, now); err != nil { + return rep, err + } + + // Phase B: no atomicity across these, by the nature of the thing. + runErr := eng.MigrateApp(code) + + // Phase C. + if runErr != nil { + failed := "" + var vf *migration.VersionFailure + if errors.As(runErr, &vf) { + failed = vf.Version + } + if err := markFailed(db, code, failed, runErr, time.Now()); err != nil { + return rep, errors.Join(runErr, fmt.Errorf("recording the failure on sys_app: %w", err)) + } + return rep, runErr + } + if err := markInstalled(db, code, row.InstalledAt, time.Now()); err != nil { + return rep, err + } + rep.Applied = pending + return rep, nil +} + +// loadApp reads the sys_app row for code. A missing row is not an error: it +// is what a first install looks like. +func loadApp(db *gorm.DB, code string) (adminmodels.SysApp, bool, error) { + var row adminmodels.SysApp + err := db.Where("app_code = ?", code).First(&row).Error + if err == nil { + return row, true, nil + } + if errors.Is(err, gorm.ErrRecordNotFound) { + return adminmodels.SysApp{}, false, nil + } + return adminmodels.SysApp{}, false, fmt.Errorf("reading sys_app for %q: %w", code, err) +} + +// pendingFor is the authoritative answer to "what is left to apply", and it +// is recomputed every time rather than stored: what is registered in this +// process, minus what sys_migration says has run. sys_app.failed_version is a +// snapshot of what this returned once and may be stale by now; nothing may +// read it to decide this. +func pendingFor(eng engine, code string) ([]string, error) { + entries, err := eng.Status() + if err != nil { + return nil, err + } + var out []string + for _, e := range entries { + if e.AppCode == code && e.Registered && !e.Applied { + out = append(out, e.Version) + } + } + sort.Strings(out) + return out, nil +} + +// beginInstall is phase A. It refreshes every descriptive column from the +// manifest, because those are the manifest's to say and the row is only a +// copy, and it clears the two diagnostic columns so a stale failure from a +// previous attempt cannot be read as this one's. +func beginInstall(db *gorm.DB, row *adminmodels.SysApp, m app.Manifest, code string, found bool, now time.Time) error { + row.AppCode = code + row.Name = m.Name + row.Version = m.Version + row.Description = m.Description + row.Author = m.Author + row.Requires = strings.Join(m.Requires, ",") + row.Pricing = m.Pricing + row.License = m.License + row.Status = adminmodels.AppInstalling + row.FailedVersion = "" + row.LastError = "" + row.UpdatedAt = now + if !found { + if err := db.Create(row).Error; err != nil { + return fmt.Errorf("recording the install attempt for %q: %w", code, err) + } + return nil + } + if err := db.Save(row).Error; err != nil { + return fmt.Errorf("recording the install attempt for %q: %w", code, err) + } + return nil +} + +// markInstalled is the success half of phase C. installed_at is set once and +// never moved: an upgrade keeps the time of the first install, which is what +// the column is for. +// +// Computed here rather than with COALESCE so the statement is the same on all +// four drivers this repository supports. +func markInstalled(db *gorm.DB, code string, installedAt *time.Time, now time.Time) error { + updates := map[string]any{ + "status": adminmodels.AppInstalled, + "updated_at": now, + } + if installedAt == nil { + updates["installed_at"] = now + } + err := db.Model(&adminmodels.SysApp{}).Where("app_code = ?", code).Updates(updates).Error + if err != nil { + return fmt.Errorf("recording %q as installed: %w", code, err) + } + return nil +} + +// markFailed is the other half. Both columns it writes are diagnostic text +// for whoever reads the row; no code may branch on either one. +func markFailed(db *gorm.DB, code, failedVersion string, cause error, now time.Time) error { + updates := map[string]any{ + "status": adminmodels.AppFailed, + "failed_version": truncate(failedVersion, 64), + "last_error": truncate(cause.Error(), 255), + "updated_at": now, + } + return db.Model(&adminmodels.SysApp{}).Where("app_code = ?", code).Updates(updates).Error +} + +// truncate cuts s to at most n runes, not bytes: these columns are declared in +// characters, and a message that is partly Chinese would otherwise be cut in +// the middle of one and stored as an invalid sequence. +func truncate(s string, n int) string { + r := []rune(s) + if len(r) <= n { + return s + } + return string(r[:n]) +} + +// reportInstall prints what happened, and says that the data is in place but +// the code is not. +// +// That last sentence is not a pleasantry. Go links its applications 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. An operator who is not told that sees the menus +// appear and reasonably concludes the thing is live. +func reportInstall(w io.Writer, rep installReport) { + if rep.NoOp { + fmt.Fprintf(w, "%s %s is already installed; nothing to do\n", rep.Code, rep.Version) + return + } + switch { + case rep.Previous == "": + fmt.Fprintf(w, "installed %s %s\n", rep.Code, rep.Version) + case rep.Previous == rep.Version: + fmt.Fprintf(w, "brought %s %s the rest of the way\n", rep.Code, rep.Version) + default: + fmt.Fprintf(w, "upgraded %s from %s to %s\n", rep.Code, rep.Previous, rep.Version) + } + if len(rep.Applied) > 0 { + fmt.Fprintf(w, "applied %d migration(s): %s\n", len(rep.Applied), strings.Join(rep.Applied, ", ")) + } else { + fmt.Fprintln(w, "no migration was outstanding; only sys_app was brought up to date") + } + fmt.Fprintln(w, "the database is up to date, but the application's code is not running yet:") + fmt.Fprintln(w, "rebuild and restart the server before expecting its routes to answer.") +} + +// manifestFor finds the manifest an application registered for code. +// +// A code nothing registered is an error naming what is registered, for the +// same reason exitUnlessAppRegistered exists: the alternative is telling an +// operator who typed `install ordr` that there was nothing to do. +func manifestFor(code string) (app.Manifest, error) { + want := migration.NormalizeAppCode(code) + all := app.Snapshot() + if m, ok := all[want]; ok { + return m, nil + } + codes := make([]string, 0, len(all)) + for c := range all { + codes = append(codes, c) + } + sort.Strings(codes) + if len(codes) == 0 { + // Worth its own sentence: no application is compiled into this + // binary at all, which is a different thing from having typed the + // wrong one of several. + return app.Manifest{}, fmt.Errorf( + "no application registers a manifest in this binary, so %q cannot be installed; "+ + "an application has to be compiled in before it can be installed", want) + } + return app.Manifest{}, fmt.Errorf("no application registers the code %q; registered: %s", + want, strings.Join(codes, ", ")) +} diff --git a/cmd/migrate/install_test.go b/cmd/migrate/install_test.go new file mode 100644 index 00000000..af7c64d8 --- /dev/null +++ b/cmd/migrate/install_test.go @@ -0,0 +1,468 @@ +package migrate + +import ( + "errors" + "strings" + "testing" + + "github.com/glebarez/sqlite" + "github.com/go-admin-team/go-admin-core/v2/sdk/contract/app" + "gorm.io/gorm" + "gorm.io/gorm/logger" + + adminmodels "go-admin/app/admin/models" + "go-admin/cmd/migrate/migration" +) + +func newInstallDB(t *testing.T) *gorm.DB { + t.Helper() + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + }) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if err := db.AutoMigrate(&adminmodels.SysApp{}); err != nil { + t.Fatalf("automigrate sys_app: %v", err) + } + return db +} + +// fakeEngine stands in for the migration engine. The real one is a +// package-level singleton with no exported constructor, so a test taking it +// would share one registry with every other test in this process. +type fakeEngine struct { + entries []migration.StatusEntry + // failWith, when set, is what MigrateApp returns instead of applying. + failWith error + calls []string +} + +func (f *fakeEngine) SetDb(*gorm.DB) {} + +func (f *fakeEngine) Status() ([]migration.StatusEntry, error) { + out := make([]migration.StatusEntry, len(f.entries)) + copy(out, f.entries) + return out, nil +} + +func (f *fakeEngine) MigrateApp(code string) error { + f.calls = append(f.calls, code) + if f.failWith != nil { + return f.failWith + } + for i := range f.entries { + if f.entries[i].AppCode == code && f.entries[i].Registered { + f.entries[i].Applied = true + } + } + return nil +} + +func orderManifest(version string) app.Manifest { + return app.Manifest{ + Code: "order", + Name: "Orders", + Version: version, + Description: "order management", + Author: "go-admin", + Requires: []string{"crm"}, + Pricing: "free", + License: "MIT", + } +} + +func loadRow(t *testing.T, db *gorm.DB, code string) adminmodels.SysApp { + t.Helper() + var row adminmodels.SysApp + if err := db.Where("app_code = ?", code).First(&row).Error; err != nil { + t.Fatalf("sys_app has no row for %q: %v", code, err) + } + return row +} + +// A1: a first install records the app, at the version the manifest declares, +// with every descriptive column copied from it. +func TestInstallRecordsAFirstInstall(t *testing.T) { + db := newInstallDB(t) + eng := &fakeEngine{entries: []migration.StatusEntry{ + {Version: "order-1786800001000", AppCode: "order", Registered: true}, + {Version: "order-1786800002000", AppCode: "order", Registered: true}, + {Version: "crm-1786800001000", AppCode: "crm", Registered: true}, + }} + + rep, err := install(db, eng, orderManifest("1.0.0")) + if err != nil { + t.Fatalf("install: %v", err) + } + if rep.NoOp { + t.Error("a first install reported nothing to do") + } + if got, want := len(rep.Applied), 2; got != want { + t.Errorf("applied %v, want %d versions", rep.Applied, want) + } + // Only this app's migrations, not every pending one in the process. + if len(eng.calls) != 1 || eng.calls[0] != "order" { + t.Errorf("MigrateApp calls = %v", eng.calls) + } + + row := loadRow(t, db, "order") + if row.Status != adminmodels.AppInstalled { + t.Errorf("status = %d, want installed", row.Status) + } + if row.Version != "1.0.0" { + t.Errorf("version = %q", row.Version) + } + if row.InstalledAt == nil { + t.Error("installed_at was not set") + } + if row.Name != "Orders" || row.Author != "go-admin" || row.Description != "order management" { + t.Errorf("descriptive columns not copied from the manifest: %+v", row) + } + if row.Requires != "crm" { + t.Errorf("requires = %q, want the manifest's list as CSV", row.Requires) + } + if row.Pricing != "free" || row.License != "MIT" { + t.Errorf("the reserved fields were not carried through: %+v", row) + } +} + +// A2: installing the same version again is a no-op, and says so. +func TestInstallIsANoOpAtTheSameVersion(t *testing.T) { + db := newInstallDB(t) + eng := &fakeEngine{entries: []migration.StatusEntry{ + {Version: "order-1786800001000", AppCode: "order", Registered: true}, + }} + if _, err := install(db, eng, orderManifest("1.0.0")); err != nil { + t.Fatalf("first install: %v", err) + } + before := loadRow(t, db, "order") + + rep, err := install(db, eng, orderManifest("1.0.0")) + if err != nil { + t.Fatalf("second install: %v", err) + } + if !rep.NoOp { + t.Error("installing the same version again was not reported as a no-op") + } + if len(eng.calls) != 1 { + t.Errorf("the engine was driven again: %v", eng.calls) + } + after := loadRow(t, db, "order") + if !after.UpdatedAt.Equal(before.UpdatedAt) { + t.Error("a no-op rewrote the row") + } + var n int64 + db.Model(&adminmodels.SysApp{}).Count(&n) + if n != 1 { + t.Errorf("sys_app has %d rows, want 1", n) + } +} + +// A no-op is only a no-op when nothing is outstanding. A row that says +// installed while a migration of its has never run is the case sys_app must +// not be believed over sys_migration. +func TestInstallRunsWhenTheRowSaysInstalledButAMigrationIsPending(t *testing.T) { + db := newInstallDB(t) + eng := &fakeEngine{entries: []migration.StatusEntry{ + {Version: "order-1786800001000", AppCode: "order", Registered: true, Applied: true}, + }} + if _, err := install(db, eng, orderManifest("1.0.0")); err != nil { + t.Fatalf("first install: %v", err) + } + + // A second version of the same app appears - the app was rebuilt with + // one more migration file, without its version changing. + eng.entries = append(eng.entries, migration.StatusEntry{ + Version: "order-1786800002000", AppCode: "order", Registered: true, + }) + + rep, err := install(db, eng, orderManifest("1.0.0")) + if err != nil { + t.Fatalf("install: %v", err) + } + if rep.NoOp { + t.Fatal("an outstanding migration was reported as nothing to do") + } + if len(rep.Applied) != 1 || rep.Applied[0] != "order-1786800002000" { + t.Errorf("applied = %v", rep.Applied) + } +} + +// A9: an upgrade is in place. installed_at is the first install's, not this +// one's. +func TestInstallUpgradesInPlaceAndKeepsTheFirstInstallTime(t *testing.T) { + db := newInstallDB(t) + eng := &fakeEngine{entries: []migration.StatusEntry{ + {Version: "order-1786800001000", AppCode: "order", Registered: true}, + }} + if _, err := install(db, eng, orderManifest("1.0.0")); err != nil { + t.Fatalf("first install: %v", err) + } + first := loadRow(t, db, "order") + if first.InstalledAt == nil { + t.Fatal("installed_at was not set by the first install") + } + + eng.entries = append(eng.entries, migration.StatusEntry{ + Version: "order-1786800002000", AppCode: "order", Registered: true, + }) + rep, err := install(db, eng, orderManifest("2.0.0")) + if err != nil { + t.Fatalf("upgrade: %v", err) + } + if rep.Previous != "1.0.0" { + t.Errorf("previous = %q, want 1.0.0", rep.Previous) + } + + row := loadRow(t, db, "order") + if row.Version != "2.0.0" { + t.Errorf("version = %q, want 2.0.0", row.Version) + } + if row.Status != adminmodels.AppInstalled { + t.Errorf("status = %d, want installed", row.Status) + } + if !row.InstalledAt.Equal(*first.InstalledAt) { + t.Errorf("installed_at moved from %v to %v; an upgrade keeps the first install's time", + first.InstalledAt, row.InstalledAt) + } +} + +// A10: a downgrade is refused, and refused before anything is written. +func TestInstallRefusesADowngrade(t *testing.T) { + db := newInstallDB(t) + eng := &fakeEngine{entries: []migration.StatusEntry{ + {Version: "order-1786800001000", AppCode: "order", Registered: true}, + }} + if _, err := install(db, eng, orderManifest("2.0.0")); err != nil { + t.Fatalf("first install: %v", err) + } + before := loadRow(t, db, "order") + + _, err := install(db, eng, orderManifest("1.0.0")) + if err == nil { + t.Fatal("a downgrade was accepted") + } + if !strings.Contains(err.Error(), "downgrade") { + t.Errorf("error = %q, it has to say what it refused", err) + } + after := loadRow(t, db, "order") + if after.Version != before.Version || after.Status != before.Status { + t.Errorf("the refused downgrade still wrote to the row: %+v -> %+v", before, after) + } +} + +// A5: a failing migration leaves a row that says so, and says where. +func TestInstallRecordsAFailure(t *testing.T) { + db := newInstallDB(t) + boom := errors.New("the seed hit a duplicate") + eng := &fakeEngine{ + entries: []migration.StatusEntry{ + {Version: "order-1786800001000", AppCode: "order", Registered: true}, + }, + failWith: &migration.VersionFailure{Version: "order-1786800001000", Err: boom}, + } + + _, err := install(db, eng, orderManifest("1.0.0")) + if err == nil { + t.Fatal("a failed install reported success") + } + if !errors.Is(err, boom) { + t.Errorf("the cause is not reachable: %v", err) + } + + row := loadRow(t, db, "order") + if row.Status != adminmodels.AppFailed { + t.Errorf("status = %d, want failed", row.Status) + } + if row.FailedVersion != "order-1786800001000" { + t.Errorf("failed_version = %q", row.FailedVersion) + } + if !strings.Contains(row.LastError, "duplicate") { + t.Errorf("last_error = %q", row.LastError) + } + if row.InstalledAt != nil { + t.Error("installed_at was set by an install that failed") + } +} + +// A failed install is retried by running it again - not by any special +// command, and without the previous attempt's diagnostics surviving into a +// row that now says installed. +func TestInstallResumesAfterAFailure(t *testing.T) { + db := newInstallDB(t) + eng := &fakeEngine{ + entries: []migration.StatusEntry{ + {Version: "order-1786800001000", AppCode: "order", Registered: true}, + }, + failWith: &migration.VersionFailure{Version: "order-1786800001000", Err: errors.New("boom")}, + } + if _, err := install(db, eng, orderManifest("1.0.0")); err == nil { + t.Fatal("the first attempt did not fail") + } + + eng.failWith = nil + rep, err := install(db, eng, orderManifest("1.0.0")) + if err != nil { + t.Fatalf("retry: %v", err) + } + if rep.NoOp { + t.Error("a failed row was treated as installed") + } + + row := loadRow(t, db, "order") + if row.Status != adminmodels.AppInstalled { + t.Errorf("status = %d, want installed", row.Status) + } + if row.FailedVersion != "" || row.LastError != "" { + t.Errorf("the previous failure survived onto a row that now says installed: %q / %q", + row.FailedVersion, row.LastError) + } + if row.InstalledAt == nil { + t.Error("installed_at was not set by the attempt that succeeded") + } +} + +// A row stuck at installing - the process was killed partway - is not +// installed, and must not be mistaken for it. +func TestInstallRetriesARowStuckAtInstalling(t *testing.T) { + db := newInstallDB(t) + if err := db.Create(&adminmodels.SysApp{ + AppCode: "order", Name: "Orders", Version: "1.0.0", + Status: adminmodels.AppInstalling, + }).Error; err != nil { + t.Fatalf("seed: %v", err) + } + eng := &fakeEngine{entries: []migration.StatusEntry{ + {Version: "order-1786800001000", AppCode: "order", Registered: true, Applied: true}, + }} + + rep, err := install(db, eng, orderManifest("1.0.0")) + if err != nil { + t.Fatalf("install: %v", err) + } + if rep.NoOp { + t.Fatal("a row stuck at installing was reported as already installed") + } + if row := loadRow(t, db, "order"); row.Status != adminmodels.AppInstalled { + t.Errorf("status = %d, want installed", row.Status) + } +} + +func TestInstallRejectsTheFrameworkCode(t *testing.T) { + db := newInstallDB(t) + m := orderManifest("1.0.0") + m.Code = migration.FrameworkAppCode + _, err := install(db, &fakeEngine{}, m) + if err == nil { + t.Fatal("the framework was installed as an application") + } + if !strings.Contains(err.Error(), "migrate") { + t.Errorf("error = %q, it should point at the command that does this", err) + } +} + +func TestInstallRefusesAnUnparseableRecordedVersion(t *testing.T) { + db := newInstallDB(t) + if err := db.Create(&adminmodels.SysApp{ + AppCode: "order", Name: "Orders", Version: "v1.0", Status: adminmodels.AppInstalled, + }).Error; err != nil { + t.Fatalf("seed: %v", err) + } + _, err := install(db, &fakeEngine{}, orderManifest("1.0.0")) + if err == nil { + t.Fatal("an unparseable recorded version was compared anyway") + } + row := loadRow(t, db, "order") + if row.Status != adminmodels.AppInstalled || row.Version != "v1.0" { + t.Errorf("the row was overwritten before the comparison failed: %+v", row) + } +} + +// A7: the report has to say the code is not running yet. Menus appearing is +// exactly what makes an operator think it is. +func TestReportInstallSaysTheCodeIsNotRunningYet(t *testing.T) { + var out strings.Builder + reportInstall(&out, installReport{Code: "order", Version: "1.0.0", Applied: []string{"order-1786800001000"}}) + got := out.String() + if !strings.Contains(got, "rebuild") || !strings.Contains(got, "restart") { + t.Errorf("the report does not say the binary has to be rebuilt: %q", got) + } + if !strings.Contains(got, "order-1786800001000") { + t.Errorf("the report does not name what it applied: %q", got) + } +} + +func TestReportInstallOnANoOp(t *testing.T) { + var out strings.Builder + reportInstall(&out, installReport{Code: "order", Version: "1.0.0", NoOp: true}) + if !strings.Contains(out.String(), "already installed") { + t.Errorf("output = %q", out.String()) + } +} + +// last_error is a varchar(255) declared in characters. A message that is +// partly Chinese would be cut mid-rune by a byte-wise truncation and stored +// as an invalid sequence. +func TestTruncateCutsRunesNotBytes(t *testing.T) { + s := strings.Repeat("迁", 300) + got := truncate(s, 255) + if n := len([]rune(got)); n != 255 { + t.Errorf("kept %d runes, want 255", n) + } + if !strings.HasPrefix(s, got) { + t.Error("truncation did not cut at a rune boundary") + } + if short := truncate("ok", 255); short != "ok" { + t.Errorf("a short message was altered: %q", short) + } +} + +func TestInstallNeedsSysApp(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + }) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + _, err = install(db, &fakeEngine{}, orderManifest("1.0.0")) + if err == nil { + t.Fatal("install ran against a database with no sys_app") + } + if !strings.Contains(err.Error(), "migrate") { + t.Errorf("error = %q, it should say what to run first", err) + } +} + +// The code written to sys_app and handed to the engine is the normalized one. +// A manifest whose Code was typed with different case or stray spaces has to +// land on the same identity migration.ForApp and seed.SeedMenus already use, +// or the row and the migrations it stands for are filed under two names. +func TestInstallNormalizesTheAppCode(t *testing.T) { + db := newInstallDB(t) + eng := &fakeEngine{entries: []migration.StatusEntry{ + {Version: "order-1786800001000", AppCode: "order", Registered: true}, + }} + m := orderManifest("1.0.0") + m.Code = " Order " + + rep, err := install(db, eng, m) + if err != nil { + t.Fatalf("install: %v", err) + } + if rep.Code != "order" { + t.Errorf("reported code = %q, want order", rep.Code) + } + if len(eng.calls) != 1 || eng.calls[0] != "order" { + t.Errorf("the engine was asked for %v, want [order]", eng.calls) + } + // The row has to be findable by the normalized code, which is what every + // other table in this batch is keyed by. + row := loadRow(t, db, "order") + if row.AppCode != "order" { + t.Errorf("app_code = %q", row.AppCode) + } + if len(rep.Applied) != 1 { + t.Errorf("applied = %v; the normalized code has to match what Status reports", rep.Applied) + } +} diff --git a/cmd/migrate/server.go b/cmd/migrate/server.go index d845b0c7..337ecc5d 100644 --- a/cmd/migrate/server.go +++ b/cmd/migrate/server.go @@ -48,6 +48,22 @@ var ( 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 ", + 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 在您看不见代码的时候运行迁移,我觉得是不安全的,所以编译后最好不要去执行迁移 @@ -65,6 +81,7 @@ func init() { 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() { @@ -255,6 +272,31 @@ func runStatus() { ) } +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 {