From 35d213f339a15709d3df6ea0db697a6a94471564 Mon Sep 17 00:00:00 2001 From: zhangwenjian Date: Wed, 9 Sep 2026 12:22:16 +0800 Subject: [PATCH] =?UTF-8?q?feat=E2=9C=A8:=20install=20one=20application=20?= =?UTF-8?q?from=20its=20manifest?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `migrate install ` 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. --- app/admin/models/sys_app.go | 16 ++ cmd/migrate/install.go | 308 ++++++++++++++++++++++++ cmd/migrate/install_test.go | 468 ++++++++++++++++++++++++++++++++++++ cmd/migrate/server.go | 42 ++++ 4 files changed, 834 insertions(+) create mode 100644 cmd/migrate/install.go create mode 100644 cmd/migrate/install_test.go 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 {