Files
summercms/modules/lagoon/migrations.go
Jakub Zych c38dc1fe48 feat(surf): serve startup summary, shutdown report and http request log
- serve prints a startup summary on a terminal (version, environment, URL,
  admin, database host and name, pending migrations, plugins, drivers) and
  logs it as a 'startup summary' record plus 'listening on <addr>'
- on SIGINT/SIGTERM serve drains, prints and logs a shutdown report and
  returns nil (exit 0), or the drain errors (exit 1)
- the request log is on by default under diary component http with
  request_id; the route pattern, never the path, is logged
- lagoon.PendingMigrations, conga.Worker.Stats, diary.HideOnTTY,
  bonfire.IsTerminal and bonfire.Details back the summary and report
2026-10-21 12:59:07 +02:00

280 lines
7.5 KiB
Go

package lagoon
import (
"errors"
"fmt"
"strings"
"unicode"
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
const historyTablePrefix = "summer_migrations_"
var (
// ErrUnknownPlugin is returned when migrate:rollback names a plugin that is not activated.
ErrUnknownPlugin = errors.New("lagoon: plugin is not activated")
// ErrNoMigrations is returned when there is no plugin migration set to roll back.
ErrNoMigrations = errors.New("lagoon: no plugin migrations to roll back")
)
// HistoryTableName returns the isolated gormigrate table for pluginID.
func HistoryTableName(pluginID string) (string, error) {
if err := validatePluginID(pluginID); err != nil {
return "", err
}
return historyTablePrefix + strings.ReplaceAll(pluginID, ".", "_"), nil
}
func validatePluginID(id string) error {
if id == "" {
return fmt.Errorf("lagoon: plugin id is empty")
}
for _, r := range id {
if r == '.' || unicode.IsLower(r) || unicode.IsDigit(r) {
continue
}
return fmt.Errorf("lagoon: plugin id %q is not a valid history table name", id)
}
if strings.Contains(id, "..") || strings.HasPrefix(id, ".") || strings.HasSuffix(id, ".") {
return fmt.Errorf("lagoon: plugin id %q is not a valid history table name", id)
}
return nil
}
func migrator(gdb *gorm.DB, pluginID string, migrations []*gormigrate.Migration) (*gormigrate.Gormigrate, error) {
table, err := HistoryTableName(pluginID)
if err != nil {
return nil, err
}
return gormigrate.New(gdb, &gormigrate.Options{
TableName: table,
UseTransaction: true,
}, migrations), nil
}
// Migrate runs the framework-owned system_files, backend-admin,
// deferred-binding (DeferredBindingMigrations, history DeferredHistoryID) and
// job-queue (QueueMigrations) sets first, then each plugin's HasMigrations set
// in party.Activate order.
func Migrate(gdb *gorm.DB, plugins []party.Plugin) error {
if gdb == nil {
return fmt.Errorf("lagoon: gorm db is nil")
}
m, err := migrator(gdb, "summercms.attach", attach.Migrations)
if err != nil {
return err
}
if err := m.Migrate(); err != nil {
return fmt.Errorf("lagoon: migrate system_files: %w", err)
}
admin, err := migrator(gdb, "summercms.cabana", BackendAdminMigrations)
if err != nil {
return err
}
if err := admin.Migrate(); err != nil {
return fmt.Errorf("lagoon: migrate backend admin: %w", err)
}
deferred, err := migrator(gdb, DeferredHistoryID, DeferredBindingMigrations)
if err != nil {
return err
}
if err := deferred.Migrate(); err != nil {
return fmt.Errorf("lagoon: migrate deferred bindings: %w", err)
}
sqlDB, err := gdb.DB()
if err != nil {
return fmt.Errorf("lagoon: migrate queue: %w", err)
}
queue, err := migrator(gdb, QueueHistoryID, QueueMigrations(sqlDB))
if err != nil {
return err
}
if err := queue.Migrate(); err != nil {
return fmt.Errorf("lagoon: migrate queue: %w", err)
}
for _, p := range plugins {
hm, ok := p.(pact.HasMigrations)
if !ok {
continue
}
migrations := hm.Migrations()
if len(migrations) == 0 {
continue
}
m, err := migrator(gdb, p.ID(), migrations)
if err != nil {
return err
}
if err := m.Migrate(); err != nil {
return fmt.Errorf("lagoon: migrate %s: %w", p.ID(), err)
}
}
return nil
}
// RollbackLast rolls back the last migration of pluginID.
func RollbackLast(gdb *gorm.DB, plugins []party.Plugin, pluginID string) error {
if gdb == nil {
return fmt.Errorf("lagoon: gorm db is nil")
}
pluginID = strings.TrimSpace(pluginID)
if pluginID == "" {
pluginID = lastMigrationPlugin(plugins)
}
if pluginID == "" {
return ErrNoMigrations
}
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)
}
m, err := migrator(gdb, target.ID(), hm.Migrations())
if err != nil {
return err
}
if err := m.RollbackLast(); err != nil {
return fmt.Errorf("lagoon: rollback %s: %w", pluginID, err)
}
return nil
}
func lastMigrationPlugin(plugins []party.Plugin) string {
for i := len(plugins) - 1; i >= 0; i-- {
p := plugins[i]
hm, ok := p.(pact.HasMigrations)
if !ok || len(hm.Migrations()) == 0 {
continue
}
return p.ID()
}
return ""
}
// StatusRow is one plugin's recorded migration ids.
type StatusRow struct {
Plugin string
Table string
IDs []string
}
// Status reads each plugin history table.
func Status(gdb *gorm.DB, plugins []party.Plugin) ([]StatusRow, error) {
if gdb == nil {
return nil, fmt.Errorf("lagoon: gorm db is nil")
}
var rows []StatusRow
for _, p := range plugins {
if _, ok := p.(pact.HasMigrations); !ok {
continue
}
table, err := HistoryTableName(p.ID())
if err != nil {
return nil, err
}
row := StatusRow{Plugin: p.ID(), Table: table}
if !gdb.Migrator().HasTable(table) {
rows = append(rows, row)
continue
}
if err := gdb.Table(table).Order("id").Pluck("id", &row.IDs).Error; err != nil {
return nil, fmt.Errorf("lagoon: status %s: %w", p.ID(), err)
}
if row.IDs == nil {
row.IDs = []string{}
}
rows = append(rows, row)
}
return rows, nil
}
// MigrationRef names one migration: the plugin (or framework set) that
// declares it and its ID.
type MigrationRef struct {
Plugin string
ID string
}
// migrationSet is one history table's declared migrations.
type migrationSet struct {
id string
migrations []*gormigrate.Migration
}
// migrationSets lists the sets Migrate applies, in its order: the framework
// sets (system files, backend admin, deferred bindings, job queue), then each
// plugin's HasMigrations set in plugin order.
func migrationSets(gdb *gorm.DB, plugins []party.Plugin) ([]migrationSet, error) {
sqlDB, err := gdb.DB()
if err != nil {
return nil, fmt.Errorf("lagoon: migration sets: %w", err)
}
sets := []migrationSet{
{"summercms.attach", attach.Migrations},
{"summercms.cabana", BackendAdminMigrations},
{DeferredHistoryID, DeferredBindingMigrations},
{QueueHistoryID, QueueMigrations(sqlDB)},
}
for _, p := range plugins {
hm, ok := p.(pact.HasMigrations)
if !ok {
continue
}
if ms := hm.Migrations(); len(ms) > 0 {
sets = append(sets, migrationSet{p.ID(), ms})
}
}
return sets, nil
}
// PendingMigrations returns the migrations Migrate would apply: each set's
// declared IDs that its history table does not hold, framework sets first,
// then plugins in order, each in declaration order. A missing history table
// means every migration of that set is pending. It only reads.
func PendingMigrations(gdb *gorm.DB, plugins []party.Plugin) ([]MigrationRef, error) {
if gdb == nil {
return nil, fmt.Errorf("lagoon: gorm db is nil")
}
sets, err := migrationSets(gdb, plugins)
if err != nil {
return nil, err
}
var pending []MigrationRef
for _, s := range sets {
table, err := HistoryTableName(s.id)
if err != nil {
return nil, err
}
applied := map[string]bool{}
if gdb.Migrator().HasTable(table) {
var ids []string
if err := gdb.Table(table).Pluck("id", &ids).Error; err != nil {
return nil, fmt.Errorf("lagoon: pending %s: %w", s.id, err)
}
for _, id := range ids {
applied[id] = true
}
}
for _, m := range s.migrations {
if m != nil && !applied[m.ID] {
pending = append(pending, MigrationRef{Plugin: s.id, ID: m.ID})
}
}
}
return pending, nil
}