feat(12.2-01): add deferred:purge, its daily framework schedule and relation child hooks
- deferred:purge [--days] in lagoon.RuntimeCommands (purge_days, default 5)
- lagoon.FrameworkSchedule entry at purge_at (default 03:00, empty disables)
- conga prepends framework entries as summercms.lagoon[i]:<command>
- pact.Relation{Before,After}{Create,Update,Delete} optional hooks
- lagoon, conga and pact READMEs, scheduling and setup docs
This commit is contained in:
@@ -144,7 +144,9 @@ func (p *Plugin) Migrations() []*gormigrate.Migration {
|
||||
| `lagoon.PurgeDeferred` | Removes expired bindings, their unattached files (blobs after commit) and the children created under deferral. |
|
||||
| `lagoon.PurgeOptions` | Cut-off time and created-child model resolver for `lagoon.PurgeDeferred`. |
|
||||
| `lagoon.PurgeResult` | Counts of deleted bindings, files and children and of skipped bindings. |
|
||||
| `lagoon.RuntimeCommands` | Returns the migrate, migrate:rollback, migrate:status and key:generate commands. |
|
||||
| `lagoon.RuntimeCommands` | Returns the migrate, migrate:rollback, migrate:status, deferred:purge and key:generate commands. |
|
||||
| `lagoon.FrameworkSchedule` | The framework's scheduled commands for conga: the daily `deferred:purge` at `database.deferred_bindings.purge_at`, or none when it is empty. |
|
||||
| `lagoon.FrameworkScheduleID` | Plugin id the framework's schedule entries are listed under, `summercms.lagoon`. |
|
||||
| `lagoon.KeyGenerateCommand` | Returns the key:generate command on its own. |
|
||||
| `lagoon.LoadAppKey` | Decodes `app.key` and `app.previous_keys`. |
|
||||
| `lagoon.PublishEncryptionKeys` | Installs the keys used by `lagoon.Encrypted` columns. |
|
||||
@@ -214,6 +216,8 @@ Keys are read from the compass config; the `SUMMER_` environment overlay maps a
|
||||
| `app.previous_keys` | empty | List of earlier base64 keys still accepted when decrypting, for key rotation. |
|
||||
| `storage.uploads.bucket_url` | none (required by `attach.OpenBucket`) | Uploads bucket URL, `file://` or `mem://`. |
|
||||
| `storage.uploads.public_path_prefix` | `/storage/uploads` | URL prefix used when building public file and thumbnail URLs. |
|
||||
| `database.deferred_bindings.purge_days` | `5` | Age in days after which `deferred:purge` removes a deferred binding, WinterCMS's `cleanUp(5)`; the `--days` flag overrides it. |
|
||||
| `database.deferred_bindings.purge_at` | `03:00` | Daily `HH:MM` (in `app.timezone`) at which the scheduler runs `deferred:purge`; an empty value disables the framework entry. |
|
||||
|
||||
```yaml
|
||||
database:
|
||||
@@ -234,6 +238,7 @@ storage:
|
||||
| `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. |
|
||||
|
||||
## Dependencies
|
||||
|
||||
@@ -3,16 +3,23 @@ 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, and key:generate.
|
||||
// Serve is registered separately via surf.ServeCommand.
|
||||
// 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{
|
||||
{
|
||||
@@ -71,10 +78,155 @@ 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",
|
||||
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 {
|
||||
|
||||
@@ -45,7 +45,7 @@ func TestRuntimeCommandsRegisterBareAndColonNames(t *testing.T) {
|
||||
for _, c := range cmds {
|
||||
names[c.Name] = true
|
||||
}
|
||||
for _, want := range []string{"migrate", "migrate:rollback", "migrate:status", "key:generate"} {
|
||||
for _, want := range []string{"migrate", "migrate:rollback", "migrate:status", "deferred:purge", "key:generate"} {
|
||||
if !names[want] {
|
||||
t.Fatalf("missing %s", want)
|
||||
}
|
||||
|
||||
58
modules/lagoon/schedule.go
Normal file
58
modules/lagoon/schedule.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package lagoon
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
)
|
||||
|
||||
// FrameworkScheduleID is the plugin id under which the scheduler lists the
|
||||
// framework's own schedule entries, so the daily purge has the entry id
|
||||
// summercms.lagoon[0]:deferred:purge.
|
||||
const FrameworkScheduleID = "summercms.lagoon"
|
||||
|
||||
const (
|
||||
purgeAtKey = "database.deferred_bindings.purge_at"
|
||||
purgeDaysKey = "database.deferred_bindings.purge_days"
|
||||
defaultPurgeAt = "03:00"
|
||||
// defaultPurgeDays is WinterCMS's DeferredBinding::cleanUp(5).
|
||||
defaultPurgeDays = 5
|
||||
)
|
||||
|
||||
var purgeAtPattern = regexp.MustCompile(`^([01][0-9]|2[0-3]):([0-5][0-9])$`)
|
||||
|
||||
// FrameworkSchedule returns the scheduled commands the framework itself
|
||||
// runs: deferred:purge daily at database.deferred_bindings.purge_at ("HH:MM"
|
||||
// in app.timezone, default "03:00"). An explicitly empty purge_at disables
|
||||
// the entry; a malformed one is an error naming the key. A nil app or an app
|
||||
// without config returns no entries and no error.
|
||||
func FrameworkSchedule(app *backpack.App) ([]pact.ScheduledCommand, error) {
|
||||
if app == nil || app.Config == nil {
|
||||
return nil, nil
|
||||
}
|
||||
at := defaultPurgeAt
|
||||
if raw, ok := app.Config.Lookup(purgeAtKey); ok {
|
||||
switch v := raw.(type) {
|
||||
case nil:
|
||||
at = ""
|
||||
case string:
|
||||
at = strings.TrimSpace(v)
|
||||
default:
|
||||
return nil, fmt.Errorf("lagoon: %s must be a quoted \"HH:MM\" string, got %v", purgeAtKey, raw)
|
||||
}
|
||||
}
|
||||
if at == "" {
|
||||
return nil, nil
|
||||
}
|
||||
m := purgeAtPattern.FindStringSubmatch(at)
|
||||
if m == nil {
|
||||
return nil, fmt.Errorf("lagoon: %s %q is not HH:MM (00:00 to 23:59)", purgeAtKey, at)
|
||||
}
|
||||
hour, _ := strconv.Atoi(m[1])
|
||||
minute, _ := strconv.Atoi(m[2])
|
||||
return []pact.ScheduledCommand{{Command: "deferred:purge", Cadence: pact.DailyAt(hour, minute)}}, nil
|
||||
}
|
||||
Reference in New Issue
Block a user