- migrate, migrate:rollback and deferred:purge gain --pretend and --force; they and queue:clear confirm only in production on an interactive terminal, so deploys over SSH proceed as before - result lines: Applied N migrations, Rolled back <id> of <plugin>, Cleared N jobs from <queue> - --json for migrate:status, key:generate, route:list, admin:token:create, admin:token:list, admin:token:scopes and websockets:health; -q keeps the key and the token - hints for an empty DSN, an unreachable database, an invalid app.key, a missing uploads bucket and an application binary run below its root - flare and websockets:health return bonfire.Reported after printing - long help and examples for every application command, greeter:hello and blog:publish; cmd/summer TestCommandsDocumented fails on any gap
473 lines
15 KiB
Go
473 lines
15 KiB
Go
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_<plugin>
|
|
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)
|
|
}
|