feat: Artisan checklist for application commands, hints and coverage gate
- 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
This commit is contained in:
@@ -256,11 +256,13 @@ storage:
|
||||
|
||||
| Command | Flags | Description |
|
||||
|---------|-------|-------------|
|
||||
| `migrate` | none | Runs the framework migrations (`system_files`, the backend admin tables, `deferred_bindings` and the job queue), then each plugin's migrations in dependency order. |
|
||||
| `migrate:rollback` | `--plugin <id>` | Rolls back the last migration of the given plugin; without the flag, of the last activated plugin that has migrations. |
|
||||
| `migrate:status` | none | Prints a table of plugin, history table and applied migration IDs. |
|
||||
| `deferred:purge` | `--days <n>` | Deletes deferred bindings older than `n` days (default `database.deferred_bindings.purge_days`, else 5), with the unattached uploads they hold (blobs after the commit) and the records created under deferral, and prints the counts. Records that were only linked are kept. Model types come from every plugin's `pact.HasModels`; the uploads bucket from the published one or `storage.uploads.bucket_url`. |
|
||||
| `key:generate` | none | Prints a fresh base64 32-byte key for `app.key`; writes nothing. |
|
||||
| `migrate` | `--pretend`, `--force` | Runs the framework migrations (`system_files`, the backend admin tables, `deferred_bindings` and the job queue), then each plugin's migrations in dependency order; prints `<set>: N applied` per set and `Applied N migrations`, or `Nothing to migrate`. `--pretend` prints each pending migration as `<plugin>: <id>` and `N migrations would run`, and applies nothing. In production on an interactive terminal it asks first (`--force` skips the question); off a terminal it never asks. |
|
||||
| `migrate:rollback` | `--plugin <id>`, `--pretend`, `--force` | Rolls back the last applied migration of the given plugin; without the flag, of the last activated plugin that has migrations. Prints `Rolled back <id> of <plugin>`; `--pretend` names the migration and changes nothing. Production confirmation as for `migrate`. |
|
||||
| `migrate:status` | `--json` | Prints a table of plugin, history table and applied migration IDs. `--json` prints a JSON array of every declared migration of those plugins: `plugin`, `table`, `id`, `applied`. |
|
||||
| `deferred:purge` | `--days <n>`, `--pretend`, `--force` | Deletes deferred bindings older than `n` days (default `database.deferred_bindings.purge_days`, else 5), with the unattached uploads they hold (blobs after the commit) and the records created under deferral, and prints the counts. Records that were only linked are kept. Model types come from every plugin's `pact.HasModels`; the uploads bucket from the published one or `storage.uploads.bucket_url`. `--pretend` counts the bindings past the cut-off and deletes nothing; production confirmation as for `migrate`. |
|
||||
| `key:generate` | `--json` | Prints a fresh base64 32-byte key for `app.key`; writes nothing. Under `-q` it prints only the key; `--json` prints `{"key": "..."}`. |
|
||||
|
||||
An empty `database.dsn` and an unreachable database fail with a `bonfire.WithHint` explanation and a Try line (`cp .env.example .env`, `docker compose up -d --wait`); an empty or invalid `app.key` suggests `{bin} key:generate`.
|
||||
|
||||
## Dependencies
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ package lagoon
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strconv"
|
||||
@@ -17,6 +19,98 @@ import (
|
||||
"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.
|
||||
@@ -25,12 +119,56 @@ func RuntimeCommands(app *backpack.App, plugins []party.Plugin) []bonfire.Comman
|
||||
{
|
||||
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
|
||||
}
|
||||
out.Success("migrations applied")
|
||||
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
|
||||
})
|
||||
},
|
||||
@@ -38,20 +176,55 @@ func RuntimeCommands(app *backpack.App, plugins []party.Plugin) []bonfire.Comman
|
||||
{
|
||||
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, _ := in.Flag("plugin")
|
||||
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 plugin == "" {
|
||||
plugin = lastMigrationPlugin(plugins)
|
||||
if id == "" {
|
||||
out.Success(fmt.Sprintf("Rolled back the last migration of %s", plugin))
|
||||
return nil
|
||||
}
|
||||
out.Success(fmt.Sprintf("rolled back last migration of %s", plugin))
|
||||
out.Success(fmt.Sprintf("Rolled back %s of %s", id, plugin))
|
||||
return nil
|
||||
})
|
||||
},
|
||||
@@ -59,12 +232,38 @@ func RuntimeCommands(app *backpack.App, plugins []party.Plugin) []bonfire.Comman
|
||||
{
|
||||
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)"
|
||||
@@ -81,16 +280,38 @@ func RuntimeCommands(app *backpack.App, plugins []party.Plugin) []bonfire.Comman
|
||||
{
|
||||
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
|
||||
@@ -101,7 +322,7 @@ func RuntimeCommands(app *backpack.App, plugins []party.Plugin) []bonfire.Comman
|
||||
}
|
||||
defer closeBucket()
|
||||
res, err := PurgeDeferred(ctx, gdb, bucket, PurgeOptions{
|
||||
Before: time.Now().Add(-time.Duration(days) * 24 * time.Hour),
|
||||
Before: before,
|
||||
Models: func(slaveType string) (any, bool) {
|
||||
m, ok := models[slaveType]
|
||||
return m, ok
|
||||
@@ -120,6 +341,17 @@ func RuntimeCommands(app *backpack.App, plugins []party.Plugin) []bonfire.Comman
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||
"git.golem15.com/golem15/summercms/modules/compass"
|
||||
"git.golem15.com/golem15/summercms/modules/diary"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
@@ -31,7 +32,9 @@ var moduleLog = diary.For("lagoon")
|
||||
func Open(ctx context.Context, dsn string) (*sql.DB, *gorm.DB, error) {
|
||||
dsn = strings.TrimSpace(dsn)
|
||||
if dsn == "" {
|
||||
return nil, nil, fmt.Errorf("lagoon: database.dsn is empty (set SUMMER_DATABASE__DSN)")
|
||||
return nil, nil, bonfire.WithHint(fmt.Errorf("lagoon: database.dsn is empty (set SUMMER_DATABASE__DSN)"),
|
||||
"The application reads database.dsn from config/database.yaml or SUMMER_DATABASE__DSN.",
|
||||
"cp .env.example .env")
|
||||
}
|
||||
sqlDB, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
@@ -39,7 +42,9 @@ func Open(ctx context.Context, dsn string) (*sql.DB, *gorm.DB, error) {
|
||||
}
|
||||
if err := sqlDB.PingContext(ctx); err != nil {
|
||||
_ = sqlDB.Close()
|
||||
return nil, nil, fmt.Errorf("lagoon: ping postgres: %w", err)
|
||||
return nil, nil, bonfire.WithHint(fmt.Errorf("lagoon: ping postgres: %w", err),
|
||||
"PostgreSQL is not reachable at the configured host.",
|
||||
"docker compose up -d --wait")
|
||||
}
|
||||
gdb, err := gormFromSQL(sqlDB)
|
||||
if err != nil {
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"sync"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||
"git.golem15.com/golem15/summercms/modules/compass"
|
||||
)
|
||||
|
||||
@@ -343,7 +344,9 @@ func loadEncryptionKeysFromApp(app *backpack.App) error {
|
||||
}
|
||||
primary, previous, err := LoadAppKey(app.Config)
|
||||
if err != nil {
|
||||
return err
|
||||
return bonfire.WithHint(err,
|
||||
"app.key encrypts Encrypted columns; set it in SUMMER_APP__KEY or config/app.yaml.",
|
||||
"{bin} key:generate")
|
||||
}
|
||||
return PublishEncryptionKeys(app, primary, previous)
|
||||
}
|
||||
|
||||
@@ -20,17 +20,36 @@ func GenerateAppKey() (string, error) {
|
||||
}
|
||||
|
||||
// KeyGenerateCommand prints a fresh 32-byte base64 app.key and performs no
|
||||
// other side effect (D-11).
|
||||
// other side effect (D-11). --json prints {"key": "..."}; under -q the bare
|
||||
// key is printed, because the key is the data the command exists for (D-27).
|
||||
func KeyGenerateCommand() bonfire.Command {
|
||||
return bonfire.Command{
|
||||
Name: "key:generate",
|
||||
Description: "Print a fresh 32-byte base64 app.key",
|
||||
Long: `Prints a new random app.key (32 bytes, base64) and changes nothing: put it in
|
||||
SUMMER_APP__KEY or config/app.yaml yourself. app.key encrypts Encrypted
|
||||
columns and signs sessions, so changing it on a running application makes
|
||||
existing encrypted values unreadable; move the old key to app.previous_keys
|
||||
first. With -q only the key is printed; --json prints {"key": "..."}.`,
|
||||
Flags: []bonfire.Flag{jsonFlag},
|
||||
Examples: []bonfire.Example{
|
||||
{Command: "key:generate", Description: "Print a new key"},
|
||||
{Command: "key:generate -q", Description: "Print only the key, for scripts: KEY=$(bin/acme key:generate -q)"},
|
||||
{Command: "key:generate --json", Description: "Print the key as JSON"},
|
||||
},
|
||||
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||
key, err := GenerateAppKey()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out.Success(key)
|
||||
switch {
|
||||
case flagOn(in, "json"):
|
||||
return printJSON(out, map[string]string{"key": key})
|
||||
case bonfire.IsQuiet(out):
|
||||
out.Println(key)
|
||||
default:
|
||||
out.Success(key)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user