package lagoon import ( "context" "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" ) // 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", Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error { return withDB(ctx, app, func(gdb *gorm.DB) error { if err := Migrate(gdb, plugins); err != nil { return err } out.Success("migrations applied") return nil }) }, }, { Name: "migrate:rollback", Description: "Roll back the last migration of a plugin", Flags: []bonfire.Flag{{ Name: "plugin", Description: "Plugin ID whose last migration to roll back", }}, Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error { plugin, _ := in.Flag("plugin") return withDB(ctx, app, func(gdb *gorm.DB) error { if err := RollbackLast(gdb, plugins, plugin); err != nil { return err } if plugin == "" { plugin = lastMigrationPlugin(plugins) } out.Success(fmt.Sprintf("rolled back last migration of %s", plugin)) return nil }) }, }, { Name: "migrate:status", Description: "Show per-plugin migration history", 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 } 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", Flags: []bonfire.Flag{{ Name: "days", Description: "Purge bindings older than this many days", }}, Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error { days, err := purgeDays(app, in) if err != nil { return err } return withDB(ctx, app, func(gdb *gorm.DB) error { 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: time.Now().Add(-time.Duration(days) * 24 * time.Hour), 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(), } } // 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) }