package lagoon import ( "context" "encoding/json" "errors" "fmt" "reflect" "strconv" "strings" "time" "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/bonfire" "git.golem15.com/golem15/summercms/modules/lagoon/attach" "git.golem15.com/golem15/summercms/modules/pact" "git.golem15.com/golem15/summercms/modules/party" "gocloud.dev/blob" "gorm.io/gorm" ) // Flags shared by the migration commands; each destructive command declares // its own --force. var ( pretendFlag = bonfire.Flag{Name: "pretend", Description: "Show what would change without changing anything", Bare: true} jsonFlag = bonfire.Flag{Name: "json", Description: "Print the result as JSON", Bare: true} ) // forceDescription describes the --force flag of the destructive commands. const forceDescription = "Skip the confirmation asked in production on a terminal" // flagOn reports whether the bare flag name is set; a nil Input has none. func flagOn(in bonfire.Input, name string) bool { if in == nil { return false } v, ok := in.Flag(name) if !ok { return false } on, _ := strconv.ParseBool(v) return on } // confirmProduction asks question before a destructive step when the // environment is production and out is an interactive terminal, unless // --force is set (D-28). Off a terminal, under -n or -q and in // bonfire.Call it never asks, so deploys over SSH proceed as before. A // "no" prints cancelled and returns a Reported error (exit 1). func confirmProduction(app *backpack.App, in bonfire.Input, out bonfire.Output, question, cancelled string) error { if flagOn(in, "force") || app == nil || app.Config == nil || app.Config.Environment() != "production" { return nil } ok, err := bonfire.ConfirmProceed(out, question) if err != nil { return err } if !ok { out.Warning(cancelled) return bonfire.Reported(errors.New(cancelled)) } return nil } // lastApplied is the last migration of pluginID, in declaration order, that // its history table holds; "" when none is applied. func lastApplied(gdb *gorm.DB, plugins []party.Plugin, pluginID string) (string, error) { var target party.Plugin for _, p := range plugins { if p.ID() == pluginID { target = p break } } if target == nil { return "", fmt.Errorf("%w: %q", ErrUnknownPlugin, pluginID) } hm, ok := target.(pact.HasMigrations) if !ok || len(hm.Migrations()) == 0 { return "", fmt.Errorf("%w: %q", ErrNoMigrations, pluginID) } table, err := HistoryTableName(pluginID) if err != nil { return "", err } if !gdb.Migrator().HasTable(table) { return "", nil } var ids []string if err := gdb.Table(table).Pluck("id", &ids).Error; err != nil { return "", fmt.Errorf("lagoon: status %s: %w", pluginID, err) } applied := map[string]bool{} for _, id := range ids { applied[id] = true } ms := hm.Migrations() for i := len(ms) - 1; i >= 0; i-- { if ms[i] != nil && applied[ms[i].ID] { return ms[i].ID, nil } } return "", nil } // statusJSON is one migration of migrate:status --json. type statusJSON struct { Plugin string `json:"plugin"` Table string `json:"table"` ID string `json:"id"` Applied bool `json:"applied"` } // RuntimeCommands returns migrate, migrate:rollback, migrate:status, // deferred:purge and key:generate. Serve is registered separately via // surf.ServeCommand. func RuntimeCommands(app *backpack.App, plugins []party.Plugin) []bonfire.Command { return []bonfire.Command{ { Name: "migrate", Description: "Run plugin migrations in dependency order", Long: `Applies the pending migrations: the framework's own sets (uploads, backend admin, deferred bindings, job queue) first, then each plugin's in dependency order. Each plugin keeps its history in its own summer_migrations_ table, so a migration runs once. --pretend lists the pending migrations and applies nothing. In production, on an interactive terminal, migrate asks before applying; --force skips the question. Off a terminal (a deploy over SSH, a container entrypoint) it never asks.`, Flags: []bonfire.Flag{pretendFlag, {Name: "force", Description: forceDescription, Bare: true}}, Examples: []bonfire.Example{ {Command: "migrate", Description: "Apply every pending migration"}, {Command: "migrate --pretend", Description: "List the pending migrations without applying them"}, {Command: "migrate --force", Description: "In production, apply without the confirmation question"}, }, Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error { return withDB(ctx, app, func(gdb *gorm.DB) error { pending, err := PendingMigrations(gdb, plugins) if err != nil { return err } if flagOn(in, "pretend") { for _, ref := range pending { out.Println(ref.Plugin + ": " + ref.ID) } out.Info(fmt.Sprintf("%d migrations would run", len(pending))) return nil } if len(pending) == 0 { out.Success("Nothing to migrate") return nil } if err := confirmProduction(app, in, out, fmt.Sprintf("Apply %d migrations to the production database?", len(pending)), "Migration cancelled."); err != nil { return err } if err := Migrate(gdb, plugins); err != nil { return err } counts := map[string]int{} var order []string for _, ref := range pending { if counts[ref.Plugin] == 0 { order = append(order, ref.Plugin) } counts[ref.Plugin]++ } for _, plugin := range order { out.Info(fmt.Sprintf("%s: %d applied", plugin, counts[plugin])) } out.Success(fmt.Sprintf("Applied %d migrations", len(pending))) return nil }) }, }, { Name: "migrate:rollback", Description: "Roll back the last migration of a plugin", Long: `Rolls back the last applied migration of one plugin (--plugin), or of the last plugin in activation order that has migrations. Run it again to roll back further. --pretend names the migration that would roll back. In production, on an interactive terminal, it asks first; --force skips the question.`, Flags: []bonfire.Flag{{ Name: "plugin", Description: "Plugin ID whose last migration to roll back", }, pretendFlag, {Name: "force", Description: forceDescription, Bare: true}}, Examples: []bonfire.Example{ {Command: "migrate:rollback --plugin acme.blog", Description: "Roll back the last migration of acme.blog"}, {Command: "migrate:rollback --plugin acme.blog --pretend", Description: "Name the migration that would roll back"}, }, Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error { plugin := "" if in != nil { plugin, _ = in.Flag("plugin") } plugin = strings.TrimSpace(plugin) if plugin == "" { plugin = lastMigrationPlugin(plugins) } if plugin == "" { return ErrNoMigrations } return withDB(ctx, app, func(gdb *gorm.DB) error { id, err := lastApplied(gdb, plugins, plugin) if err != nil { return err } if flagOn(in, "pretend") { if id == "" { out.Info(fmt.Sprintf("%s has no applied migration to roll back", plugin)) return nil } out.Info(fmt.Sprintf("Would roll back %s of %s", id, plugin)) return nil } if err := confirmProduction(app, in, out, fmt.Sprintf("Roll back %s of %s on the production database?", id, plugin), "Rollback cancelled."); err != nil { return err } if err := RollbackLast(gdb, plugins, plugin); err != nil { return err } if id == "" { out.Success(fmt.Sprintf("Rolled back the last migration of %s", plugin)) return nil } out.Success(fmt.Sprintf("Rolled back %s of %s", id, plugin)) return nil }) }, }, { Name: "migrate:status", Description: "Show per-plugin migration history", Long: `Lists each plugin with migrations, its history table and the migration IDs applied. --json prints every declared migration of those plugins with its table and whether it is applied, for scripts.`, Flags: []bonfire.Flag{jsonFlag}, Examples: []bonfire.Example{ {Command: "migrate:status", Description: "Show the applied migrations per plugin"}, {Command: "migrate:status --json", Description: "Machine-readable status, pending migrations included"}, }, Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error { return withDB(ctx, app, func(gdb *gorm.DB) error { rows, err := Status(gdb, plugins) if err != nil { return err } if flagOn(in, "json") { pending, err := PendingMigrations(gdb, plugins) if err != nil { return err } list := []statusJSON{} for _, row := range rows { for _, id := range row.IDs { list = append(list, statusJSON{Plugin: row.Plugin, Table: row.Table, ID: id, Applied: true}) } for _, ref := range pending { if ref.Plugin == row.Plugin { list = append(list, statusJSON{Plugin: row.Plugin, Table: row.Table, ID: ref.ID}) } } } return printJSON(out, list) } tableRows := make([][]string, 0, len(rows)) for _, row := range rows { ids := "(none)" if len(row.IDs) > 0 { ids = strings.Join(row.IDs, ", ") } tableRows = append(tableRows, []string{row.Plugin, row.Table, ids}) } out.Table([]string{"plugin", "table", "applied"}, tableRows) return nil }) }, }, { Name: "deferred:purge", Description: "Delete expired deferred bindings with their pending uploads and created records", Long: `Deletes deferred bindings older than --days (default database.deferred_bindings.purge_days, else 5): uploads attached to a form that was never saved, with their files, and records created under deferral. Run it daily from the scheduler or cron. --pretend counts the bindings that would be purged and deletes nothing. In production, on an interactive terminal, it asks first; --force skips the question.`, Flags: []bonfire.Flag{{ Name: "days", Description: "Purge bindings older than this many days", }, pretendFlag, {Name: "force", Description: forceDescription, Bare: true}}, Examples: []bonfire.Example{ {Command: "deferred:purge", Description: "Purge bindings older than the configured number of days"}, {Command: "deferred:purge --days 2 --pretend", Description: "Count the bindings older than two days"}, }, Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error { days, err := purgeDays(app, in) if err != nil { return err } before := time.Now().Add(-time.Duration(days) * 24 * time.Hour) return withDB(ctx, app, func(gdb *gorm.DB) error { if flagOn(in, "pretend") { var n int64 if err := gdb.WithContext(ctx).Model(&DeferredBinding{}).Where("created_at < ?", before).Count(&n).Error; err != nil { return fmt.Errorf("lagoon: count deferred bindings: %w", err) } out.Info(fmt.Sprintf("%d deferred bindings older than %d days would be purged", n, days)) return nil } if err := confirmProduction(app, in, out, fmt.Sprintf("Purge deferred bindings older than %d days from the production database?", days), "Purge cancelled."); err != nil { return err } models, err := deferredModels(gdb, plugins) if err != nil { return err } bucket, closeBucket, err := purgeBucket(ctx, app) if err != nil { return err } defer closeBucket() res, err := PurgeDeferred(ctx, gdb, bucket, PurgeOptions{ Before: before, Models: func(slaveType string) (any, bool) { m, ok := models[slaveType] return m, ok }, }) if err != nil { return err } out.Success(fmt.Sprintf("purged %d deferred bindings older than %d days (%d files, %d children, %d skipped)", res.Bindings, days, res.Files, res.Children, res.Skipped)) return nil }) }, }, KeyGenerateCommand(), } } // printJSON writes v as indented JSON and a newline on out (data: printed // under -q too). func printJSON(out bonfire.Output, v any) error { b, err := json.MarshalIndent(v, "", " ") if err != nil { return err } out.Println(string(b)) return nil } // purgeDays is the --days flag, else database.deferred_bindings.purge_days, // else WinterCMS's five days. A negative or non-integer value is an error. func purgeDays(app *backpack.App, in bonfire.Input) (int, error) { if in != nil { if raw, ok := in.Flag("days"); ok { n, err := strconv.Atoi(strings.TrimSpace(raw)) if err != nil || n < 0 { return 0, fmt.Errorf("lagoon: deferred:purge --days %q must be a whole number of days, 0 or more", raw) } return n, nil } } if app == nil || app.Config == nil { return defaultPurgeDays, nil } raw, ok := app.Config.Lookup(purgeDaysKey) if !ok || raw == nil { return defaultPurgeDays, nil } var n int switch v := raw.(type) { case int: n = v case int64: n = int(v) case uint64: n = int(v) case float64: if v != float64(int(v)) { return 0, fmt.Errorf("lagoon: %s %v must be a whole number of days", purgeDaysKey, raw) } n = int(v) case string: parsed, err := strconv.Atoi(strings.TrimSpace(v)) if err != nil { return 0, fmt.Errorf("lagoon: %s %q must be a whole number of days", purgeDaysKey, v) } n = parsed default: return 0, fmt.Errorf("lagoon: %s %v must be a whole number of days", purgeDaysKey, raw) } if n < 0 { return 0, fmt.Errorf("lagoon: %s %d must not be negative", purgeDaysKey, n) } return n, nil } // deferredModels maps the MorphType of every plugin model to the model, so // the purge can delete records created under deferral. Two different model // types with the same morph type are an error. func deferredModels(gdb *gorm.DB, plugins []party.Plugin) (map[string]any, error) { out := map[string]any{} for _, p := range plugins { hm, ok := p.(pact.HasModels) if !ok { continue } for _, m := range hm.Models() { if m == nil { continue } key, err := MorphType(gdb, m) if err != nil { return nil, fmt.Errorf("lagoon: deferred:purge: plugin %s: %w", p.ID(), err) } if prev, dup := out[key]; dup { if modelType(prev) != modelType(m) { return nil, fmt.Errorf("lagoon: deferred:purge: models %s and %s share the morph type %q", modelType(prev), modelType(m), key) } continue } out[key] = m } } return out, nil } func modelType(m any) reflect.Type { t := reflect.TypeOf(m) for t != nil && t.Kind() == reflect.Pointer { t = t.Elem() } return t } // purgeBucket is the published uploads bucket, else one opened from // storage.uploads.bucket_url and closed by the returned func. Without a // configured bucket it returns nil; PurgeDeferred then refuses only when an // expired binding points at a file. func purgeBucket(ctx context.Context, app *backpack.App) (*blob.Bucket, func(), error) { noop := func() {} if app == nil { return nil, noop, nil } if b, ok := app.Lookup[*blob.Bucket](); ok && b != nil { return b, noop, nil } if app.Config == nil || strings.TrimSpace(app.Config.String("storage.uploads.bucket_url")) == "" { return nil, noop, nil } b, err := attach.OpenBucket(ctx, app.Config) if err != nil { return nil, noop, err } return b, func() { _ = b.Close() }, nil } func withDB(ctx context.Context, app *backpack.App, fn func(*gorm.DB) error) error { sqlDB, gdb, err := OpenFromApp(ctx, app) if err != nil { return err } defer sqlDB.Close() if err := Publish(app, sqlDB, gdb); err != nil { return err } return fn(gdb) }