From d0d845052bb5de883646c699eeeb0d18aad23708 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Thu, 17 Sep 2026 19:55:33 +0200 Subject: [PATCH] feat(03-01): add shared postgres pool and plugin migrations Open one pgx stdlib *sql.DB, hand it to GORM, and run per-plugin gormigrate sets with isolated history tables after an ICU pl-PL check. Co-authored-by: Cursor --- bonfire/command.go | 2 +- bonfire/command_test.go | 14 ++++ cmd/summer/main.go | 3 + cmd/summer/main_test.go | 4 +- cmd/summer/runtime.go | 96 ++++++++++++++++++++++ examples/hello/hello_test.go | 8 ++ examples/hello/main.go | 3 +- go.mod | 12 +++ go.sum | 45 ++++++++-- internal/build/build.go | 3 +- internal/build/build_test.go | 3 + internal/build/manifest.go | 5 ++ lagoon/commands.go | 86 +++++++++++++++++++ lagoon/connection.go | 136 ++++++++++++++++++++++++++++++ lagoon/migrations.go | 155 +++++++++++++++++++++++++++++++++++ lagoon/migrations_test.go | 97 ++++++++++++++++++++++ pact/capabilities.go | 20 +++-- pact/capabilities_test.go | 30 ++++++- 18 files changed, 700 insertions(+), 22 deletions(-) create mode 100644 bonfire/command_test.go create mode 100644 cmd/summer/runtime.go create mode 100644 lagoon/commands.go create mode 100644 lagoon/connection.go create mode 100644 lagoon/migrations.go create mode 100644 lagoon/migrations_test.go diff --git a/bonfire/command.go b/bonfire/command.go index 737d267..c25da56 100644 --- a/bonfire/command.go +++ b/bonfire/command.go @@ -82,7 +82,7 @@ func (in cobraInput) Flag(name string) (string, bool) { func validCommandName(name string) bool { switch name { - case "build", "dev": + case "build", "dev", "serve", "migrate": return true } ns, verb, ok := strings.Cut(name, ":") diff --git a/bonfire/command_test.go b/bonfire/command_test.go new file mode 100644 index 0000000..ca08969 --- /dev/null +++ b/bonfire/command_test.go @@ -0,0 +1,14 @@ +package bonfire + +import ( + "io" + "testing" +) + +func TestValidBareRuntimeCommandNames(t *testing.T) { + for _, name := range []string{"serve", "migrate", "migrate:rollback", "migrate:status", "build", "dev"} { + if _, err := NewRoot("app", []Command{{Name: name}}, io.Discard); err != nil { + t.Fatalf("%s: %v", name, err) + } + } +} diff --git a/cmd/summer/main.go b/cmd/summer/main.go index 3ccea61..db44eca 100644 --- a/cmd/summer/main.go +++ b/cmd/summer/main.go @@ -32,6 +32,9 @@ func toolCommands() []bonfire.Command { parityProxyCommand(), parityRecordCommand(), parityReplayCommand(), + delegateCommand("migrate", "Run plugin migrations in the app binary"), + delegateRollbackCommand(), + delegateCommand("migrate:status", "Show per-plugin migration history in the app binary"), } } diff --git a/cmd/summer/main_test.go b/cmd/summer/main_test.go index fedefd8..4452e9b 100644 --- a/cmd/summer/main_test.go +++ b/cmd/summer/main_test.go @@ -19,7 +19,7 @@ func TestToolCommandNames(t *testing.T) { for _, c := range toolCommands() { names = append(names, c.Name) } - for _, want := range []string{"build", "make:plugin", "plugin:add", "dev"} { + for _, want := range []string{"build", "make:plugin", "plugin:add", "dev", "migrate", "migrate:rollback", "migrate:status"} { if !slices.Contains(names, want) { t.Fatalf("missing %s in %v", want, names) } @@ -61,7 +61,7 @@ func TestToolDoesNotImportExamplePlugins(t *testing.T) { if err != nil { t.Fatal(err) } - if strings.Contains(path, "examples/hello") { + if strings.Contains(path, "examples/hello") || strings.Contains(path, "golem15/fonoteka") { t.Fatalf("%s imports %s", name, path) } } diff --git a/cmd/summer/runtime.go b/cmd/summer/runtime.go new file mode 100644 index 0000000..c0b24a3 --- /dev/null +++ b/cmd/summer/runtime.go @@ -0,0 +1,96 @@ +package main + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "git.golem15.com/golem15/summercms/bonfire" + "git.golem15.com/golem15/summercms/internal/build" +) + +func delegateCommand(name, description string) bonfire.Command { + return bonfire.Command{ + Name: name, + Description: description, + Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error { + argv := []string{name} + argv = append(argv, in.Args()...) + return runAppBinary(ctx, argv, out) + }, + } +} + +func delegateRollbackCommand() bonfire.Command { + return bonfire.Command{ + Name: "migrate:rollback", + Description: "Roll back the last migration of a plugin in the app binary", + 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 { + argv := []string{"migrate:rollback"} + if plugin, ok := in.Flag("plugin"); ok && plugin != "" { + argv = append(argv, "--plugin", plugin) + } + argv = append(argv, in.Args()...) + return runAppBinary(ctx, argv, out) + }, + } +} + +func runAppBinary(ctx context.Context, argv []string, out bonfire.Output) error { + if len(argv) == 0 { + return fmt.Errorf("summer: missing app command") + } + dir, err := os.Getwd() + if err != nil { + return err + } + appDir, err := build.FindAppDir(dir) + if err != nil { + return err + } + m, err := build.LoadManifest(filepath.Join(appDir, "summer.yaml")) + if err != nil { + return err + } + bin := filepath.Join(appDir, "bin", m.Binary) + if _, err := os.Stat(bin); err != nil { + if err := build.App(ctx, appDir, out); err != nil { + return err + } + } + self, err := os.Executable() + if err == nil { + if same, _ := sameFile(self, bin); same { + return fmt.Errorf("summer: refusing to recurse into %s", bin) + } + } + cmd := exec.CommandContext(ctx, bin, argv...) + cmd.Dir = appDir + cmd.Stdout = out + cmd.Stderr = out + cmd.Stdin = os.Stdin + cmd.Env = os.Environ() + if err := cmd.Run(); err != nil { + return fmt.Errorf("summer: %s: %w", strings.Join(argv, " "), err) + } + return nil +} + +func sameFile(a, b string) (bool, error) { + ai, err := os.Stat(a) + if err != nil { + return false, err + } + bi, err := os.Stat(b) + if err != nil { + return false, err + } + return os.SameFile(ai, bi), nil +} diff --git a/examples/hello/hello_test.go b/examples/hello/hello_test.go index 007ff0e..77b2664 100644 --- a/examples/hello/hello_test.go +++ b/examples/hello/hello_test.go @@ -104,6 +104,14 @@ func TestBuiltBinaryGreeterHello(t *testing.T) { assertGreeting(t, got, want) } +func TestMigrateWithoutDSNFailsLoudly(t *testing.T) { + var buf bytes.Buffer + err := run([]string{"migrate"}, &buf) + if err == nil || !strings.Contains(err.Error(), "database.dsn") { + t.Fatalf("want empty DSN error, got %v\n%s", err, buf.String()) + } +} + func TestBuiltBinaryUnknownCommandExitsNonZero(t *testing.T) { runSummerBuild(t) cmd := exec.Command("./bin/hello", "does:not-exist") diff --git a/examples/hello/main.go b/examples/hello/main.go index e4200cf..9e00808 100644 --- a/examples/hello/main.go +++ b/examples/hello/main.go @@ -10,6 +10,7 @@ import ( "git.golem15.com/golem15/summercms/backpack" "git.golem15.com/golem15/summercms/bonfire" "git.golem15.com/golem15/summercms/compass" + "git.golem15.com/golem15/summercms/lagoon" "git.golem15.com/golem15/summercms/pact" "git.golem15.com/golem15/summercms/party" ) @@ -31,7 +32,7 @@ func run(args []string, out io.Writer) error { if err != nil { return err } - var commands []bonfire.Command + commands := lagoon.RuntimeCommands(app, plugins) for _, plugin := range plugins { if hasCommands, ok := plugin.(pact.HasCommands); ok { commands = append(commands, hasCommands.Commands()...) diff --git a/go.mod b/go.mod index ad9445e..5d04d7d 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,9 @@ toolchain go1.27.0 require ( github.com/fsnotify/fsnotify v1.10.1 + github.com/go-gormigrate/gormigrate/v2 v2.1.7 github.com/goccy/go-yaml v1.19.2 + github.com/jackc/pgx/v5 v5.10.0 github.com/knadh/koanf/parsers/yaml v1.1.1 github.com/knadh/koanf/providers/confmap v1.0.1 github.com/knadh/koanf/providers/env/v2 v2.0.1 @@ -14,15 +16,25 @@ require ( github.com/knadh/koanf/v2 v2.3.6 github.com/spf13/cobra v1.10.2 golang.org/x/term v0.46.0 + gorm.io/driver/postgres v1.6.3 + gorm.io/gorm v1.31.2 ) require ( github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect github.com/knadh/koanf/maps v0.1.2 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect + github.com/rogpeppe/go-internal v1.16.0 // indirect github.com/spf13/pflag v1.0.9 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/sync v0.17.0 // indirect golang.org/x/sys v0.48.0 // indirect + golang.org/x/text v0.29.0 // indirect ) diff --git a/go.sum b/go.sum index 5c30426..48b85a0 100644 --- a/go.sum +++ b/go.sum @@ -1,14 +1,29 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= +github.com/go-gormigrate/gormigrate/v2 v2.1.7 h1:PdT4jVPbRb4R+0Ey2R0yJOdctVf4Whiq1Qi4necaZdg= +github.com/go-gormigrate/gormigrate/v2 v2.1.7/go.mod h1:3ouXglTuPrKF5+7cQyVGfvAXTU4vLMaYh9+EPl03uog= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= +github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo= github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI= github.com/knadh/koanf/parsers/yaml v1.1.1 h1:u70vV5IyaM0HvONh8HoqBC97oTgO33KcpZbTLiKVinU= @@ -21,31 +36,49 @@ github.com/knadh/koanf/providers/file v1.2.1 h1:bEWbtQwYrA+W2DtdBrQWyXqJaJSG3KrP github.com/knadh/koanf/providers/file v1.2.1/go.mod h1:bp1PM5f83Q+TOUu10J/0ApLBd9uIzg+n9UgthfY+nRA= github.com/knadh/koanf/v2 v2.3.6 h1:JoQPSJmvS4aP0xNc8xMDr5tcrkSEInL23/Il7pITAKo= github.com/knadh/koanf/v2 v2.3.6/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28= -github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= -github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g= +github.com/rogpeppe/go-internal v1.16.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= +golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/postgres v1.6.3 h1:bAn6O2pUa8LtpWEvL5NFU4+52Tfx8Ut7IVaIacCLcI0= +gorm.io/driver/postgres v1.6.3/go.mod h1:0c4fQA44XhOklXDkgtuKqysHCycTa5i9e3EIpDGCwXk= +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= +gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo= +gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= diff --git a/internal/build/build.go b/internal/build/build.go index 4c6d134..5b1abf6 100644 --- a/internal/build/build.go +++ b/internal/build/build.go @@ -85,6 +85,7 @@ func generateMain(m Manifest) ([]byte, error) { b.WriteString("\t\"git.golem15.com/golem15/summercms/backpack\"\n") b.WriteString("\t\"git.golem15.com/golem15/summercms/bonfire\"\n") b.WriteString("\t\"git.golem15.com/golem15/summercms/compass\"\n") + b.WriteString("\t\"git.golem15.com/golem15/summercms/lagoon\"\n") b.WriteString("\t\"git.golem15.com/golem15/summercms/pact\"\n") b.WriteString("\t\"git.golem15.com/golem15/summercms/party\"\n") b.WriteString(")\n\n") @@ -104,7 +105,7 @@ func generateMain(m Manifest) ([]byte, error) { b.WriteString("\tif err != nil {\n") b.WriteString("\t\treturn err\n") b.WriteString("\t}\n") - b.WriteString("\tvar commands []bonfire.Command\n") + b.WriteString("\tcommands := lagoon.RuntimeCommands(app, plugins)\n") b.WriteString("\tfor _, plugin := range plugins {\n") b.WriteString("\t\tif hasCommands, ok := plugin.(pact.HasCommands); ok {\n") b.WriteString("\t\t\tcommands = append(commands, hasCommands.Commands()...)\n") diff --git a/internal/build/build_test.go b/internal/build/build_test.go index 761800f..c4c1ff9 100644 --- a/internal/build/build_test.go +++ b/internal/build/build_test.go @@ -94,6 +94,9 @@ func TestGenerateStableQuotedImportsInManifestOrder(t *testing.T) { if !bytes.Contains(mainSrc, []byte(`bonfire.NewRoot("hello"`)) { t.Fatalf("main does not use manifest binary:\n%s", mainSrc) } + if !bytes.Contains(mainSrc, []byte("lagoon.RuntimeCommands")) { + t.Fatalf("main does not register lagoon runtime commands:\n%s", mainSrc) + } if bytes.Contains(mainSrc, []byte("examples/hello")) { t.Fatal("generated main hard-codes examples/hello") } diff --git a/internal/build/manifest.go b/internal/build/manifest.go index 32ac0e8..9c6891a 100644 --- a/internal/build/manifest.go +++ b/internal/build/manifest.go @@ -121,6 +121,11 @@ func ValidatePluginID(id string) error { return nil } +// FindAppDir walks up from start looking for summer.yaml. +func FindAppDir(start string) (string, error) { + return findAppDir(start) +} + func findAppDir(start string) (string, error) { if start == "" { return "", fmt.Errorf("build: app directory is empty") diff --git a/lagoon/commands.go b/lagoon/commands.go new file mode 100644 index 0000000..016e7bd --- /dev/null +++ b/lagoon/commands.go @@ -0,0 +1,86 @@ +package lagoon + +import ( + "context" + "fmt" + + "git.golem15.com/golem15/summercms/backpack" + "git.golem15.com/golem15/summercms/bonfire" + "git.golem15.com/golem15/summercms/party" + "gorm.io/gorm" +) + +// RuntimeCommands returns migrate, migrate:rollback and migrate:status. +// Serve is registered once the HTTP layer exists. +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 = fmt.Sprintf("%d: %s", len(row.IDs), row.IDs[len(row.IDs)-1]) + } + tableRows = append(tableRows, []string{row.Plugin, row.Table, ids}) + } + out.Table([]string{"plugin", "table", "applied"}, tableRows) + return 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) +} diff --git a/lagoon/connection.go b/lagoon/connection.go new file mode 100644 index 0000000..f1bb65f --- /dev/null +++ b/lagoon/connection.go @@ -0,0 +1,136 @@ +package lagoon + +import ( + "context" + "database/sql" + "fmt" + "strings" + + "git.golem15.com/golem15/summercms/backpack" + "git.golem15.com/golem15/summercms/compass" + _ "github.com/jackc/pgx/v5/stdlib" + "gorm.io/driver/postgres" + "gorm.io/gorm" +) + +const ( + requiredLocaleProvider = "i" + requiredICULocale = "pl-PL" +) + +// Open pings dsn through pgx stdlib, requires Postgres 16 ICU pl-PL, and +// returns that exact *sql.DB plus a GORM handle opened on it. +// +// Phase 11 owns a separate pgxpool.Pool for River LISTEN/NOTIFY. Do not +// create that listener pool here; application queries share this *sql.DB. +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)") + } + sqlDB, err := sql.Open("pgx", dsn) + if err != nil { + return nil, nil, fmt.Errorf("lagoon: open postgres: %w", err) + } + if err := sqlDB.PingContext(ctx); err != nil { + _ = sqlDB.Close() + return nil, nil, fmt.Errorf("lagoon: ping postgres: %w", err) + } + if err := CheckLocale(ctx, sqlDB); err != nil { + _ = sqlDB.Close() + return nil, nil, err + } + gdb, err := gormFromSQL(sqlDB) + if err != nil { + _ = sqlDB.Close() + return nil, nil, err + } + return sqlDB, gdb, nil +} + +// Use pings an existing pool, requires ICU pl-PL, and returns a GORM handle +// opened on that exact *sql.DB. Callers that already hold a pool (tests, +// the app boot seam) must not open a second connection. +func Use(ctx context.Context, sqlDB *sql.DB) (*gorm.DB, error) { + if sqlDB == nil { + return nil, fmt.Errorf("lagoon: sql db is nil") + } + if err := sqlDB.PingContext(ctx); err != nil { + return nil, fmt.Errorf("lagoon: ping postgres: %w", err) + } + if err := CheckLocale(ctx, sqlDB); err != nil { + return nil, err + } + return gormFromSQL(sqlDB) +} + +func gormFromSQL(sqlDB *sql.DB) (*gorm.DB, error) { + gdb, err := gorm.Open(postgres.New(postgres.Config{Conn: sqlDB}), &gorm.Config{}) + if err != nil { + return nil, fmt.Errorf("lagoon: gorm open: %w", err) + } + got, err := gdb.DB() + if err != nil { + return nil, fmt.Errorf("lagoon: gorm sql handle: %w", err) + } + if got != sqlDB { + return nil, fmt.Errorf("lagoon: GORM is not using the shared *sql.DB") + } + return gdb, nil +} + +// OpenFromApp reads database.dsn from app config and opens the shared pool. +func OpenFromApp(ctx context.Context, app *backpack.App) (*sql.DB, *gorm.DB, error) { + if app == nil || app.Config == nil { + return nil, nil, fmt.Errorf("lagoon: app config is missing") + } + return Open(ctx, DSN(app.Config)) +} + +// DSN returns database.dsn from layered config (env SUMMER_DATABASE__DSN). +func DSN(cfg *compass.Config) string { + if cfg == nil { + return "" + } + return strings.TrimSpace(cfg.String("database.dsn")) +} + +// Publish stores the shared SQL pool and GORM handle on the app. Both +// handles refer to the same *sql.DB. +func Publish(app *backpack.App, sqlDB *sql.DB, gdb *gorm.DB) error { + if app == nil { + return fmt.Errorf("lagoon: app is nil") + } + if sqlDB == nil || gdb == nil { + return fmt.Errorf("lagoon: database handles are nil") + } + if err := app.Publish(sqlDB); err != nil { + return err + } + return app.Publish(gdb) +} + +// CheckLocale fails unless the connected database uses ICU locale pl-PL. +func CheckLocale(ctx context.Context, db *sql.DB) error { + if db == nil { + return fmt.Errorf("lagoon: sql db is nil") + } + var provider, icu string + err := db.QueryRowContext(ctx, ` +SELECT datlocprovider::text, COALESCE(daticulocale, '') +FROM pg_database +WHERE datname = current_database()`).Scan(&provider, &icu) + if err != nil { + return fmt.Errorf("lagoon: read database locale: %w", err) + } + return checkLocale(provider, icu) +} + +func checkLocale(provider, icu string) error { + provider = strings.TrimSpace(provider) + icu = strings.TrimSpace(icu) + if provider == requiredLocaleProvider && icu == requiredICULocale { + return nil + } + return fmt.Errorf("lagoon: database locale must be ICU pl-PL (datlocprovider=%q, daticulocale=%q); got provider %q locale %q. Create the database with: CREATE DATABASE ... TEMPLATE template0 ENCODING 'UTF8' LOCALE_PROVIDER icu ICU_LOCALE 'pl-PL'", requiredLocaleProvider, requiredICULocale, provider, icu) +} diff --git a/lagoon/migrations.go b/lagoon/migrations.go new file mode 100644 index 0000000..2d253c4 --- /dev/null +++ b/lagoon/migrations.go @@ -0,0 +1,155 @@ +package lagoon + +import ( + "fmt" + "strings" + "unicode" + + "git.golem15.com/golem15/summercms/pact" + "git.golem15.com/golem15/summercms/party" + "github.com/go-gormigrate/gormigrate/v2" + "gorm.io/gorm" +) + +const historyTablePrefix = "summer_migrations_" + +// 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 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") + } + 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 fmt.Errorf("lagoon: no plugin migrations to roll back") + } + for _, p := range plugins { + if p.ID() != pluginID { + continue + } + hm, ok := p.(pact.HasMigrations) + if !ok { + return fmt.Errorf("lagoon: plugin %q has no migrations", pluginID) + } + m, err := migrator(gdb, p.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 + } + return fmt.Errorf("lagoon: plugin %q is not activated", pluginID) +} + +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 +} diff --git a/lagoon/migrations_test.go b/lagoon/migrations_test.go new file mode 100644 index 0000000..36466fc --- /dev/null +++ b/lagoon/migrations_test.go @@ -0,0 +1,97 @@ +package lagoon + +import ( + "bytes" + "io" + "os" + "strings" + "testing" + + "git.golem15.com/golem15/summercms/bonfire" +) + +func TestHistoryTableName(t *testing.T) { + got, err := HistoryTableName("golem15.user") + if err != nil { + t.Fatal(err) + } + if got != "summer_migrations_golem15_user" { + t.Fatalf("got %q", got) + } + got, err = HistoryTableName("golem15.fonoteka") + if err != nil { + t.Fatal(err) + } + if got != "summer_migrations_golem15_fonoteka" { + t.Fatalf("got %q", got) + } + if _, err := HistoryTableName("golem15.user;drop"); err == nil { + t.Fatal("want invalid plugin id error") + } + if _, err := HistoryTableName(""); err == nil { + t.Fatal("want empty plugin id error") + } +} + +func TestCheckLocaleMessage(t *testing.T) { + if err := checkLocale("i", "pl-PL"); err != nil { + t.Fatal(err) + } + err := checkLocale("c", "en_US") + if err == nil { + t.Fatal("want locale error") + } + msg := err.Error() + for _, want := range []string{"ICU", "pl-PL", "CREATE DATABASE", "LOCALE_PROVIDER icu", "en_US"} { + if !strings.Contains(msg, want) { + t.Fatalf("missing %q in %s", want, msg) + } + } +} + +func TestRuntimeCommandsRegisterBareAndColonNames(t *testing.T) { + cmds := RuntimeCommands(nil, nil) + names := map[string]bool{} + for _, c := range cmds { + names[c.Name] = true + } + for _, want := range []string{"migrate", "migrate:rollback", "migrate:status"} { + if !names[want] { + t.Fatalf("missing %s", want) + } + } + root, err := bonfire.NewRoot("app", cmds, io.Discard) + if err != nil { + t.Fatal(err) + } + root.SetArgs([]string{"migrate", "--help"}) + if err := root.Execute(); err != nil { + t.Fatal(err) + } +} + +func TestNoAutoMigrate(t *testing.T) { + entries, err := os.ReadDir(".") + if err != nil { + t.Fatal(err) + } + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".go") || strings.HasSuffix(entry.Name(), "_test.go") { + continue + } + body, err := os.ReadFile(entry.Name()) + if err != nil { + t.Fatal(err) + } + if bytes.Contains(body, []byte("AutoMigrate")) { + t.Fatalf("%s must not call AutoMigrate", entry.Name()) + } + } +} + +func TestOpenRequiresDSN(t *testing.T) { + _, _, err := Open(t.Context(), " ") + if err == nil || !strings.Contains(err.Error(), "database.dsn") { + t.Fatalf("got %v", err) + } +} diff --git a/pact/capabilities.go b/pact/capabilities.go index 19ef4c5..e06ff36 100644 --- a/pact/capabilities.go +++ b/pact/capabilities.go @@ -4,6 +4,7 @@ import ( "io/fs" "git.golem15.com/golem15/summercms/bonfire" + "github.com/go-gormigrate/gormigrate/v2" ) // HasCommands is implemented by plugins that register console commands. @@ -18,19 +19,24 @@ type HasConfig interface { ConfigFS() fs.FS } +// HasMigrations is implemented by plugins that ship an ordered gormigrate +// set. The kernel runs each set in party.Activate order with a separate +// history table per plugin ID. +type HasMigrations interface { + Migrations() []*gormigrate.Migration +} + // OptionalMessage is a service an optional plugin may publish so other // plugins can integrate without importing that plugin's package. type OptionalMessage interface { Message() string } -// Future KERN-03 capability families are type-asserted when their first -// consumer packages exist. Method payloads are not declared here so pact -// does not import surf, lagoon, festival, or other packages that Phase 1 -// does not yet ship: +// Future capability families are type-asserted when their first consumer +// packages exist. Method payloads for HTTP/model surfaces are declared in +// the phase that ships surf: // // HasModels -// HasMigrations // HasRoutes // HasMiddleware // HasJobs @@ -42,5 +48,5 @@ type OptionalMessage interface { // HasMailTemplates // HasLang // -// Phase 1 type-asserts only HasConfig (party, before Register) and -// HasCommands (generated app main, after Boot). +// The kernel type-asserts HasConfig (party, before Register), HasCommands +// (generated app main, after Boot), and HasMigrations (lagoon migrate). diff --git a/pact/capabilities_test.go b/pact/capabilities_test.go index 812a2ba..42c9dcc 100644 --- a/pact/capabilities_test.go +++ b/pact/capabilities_test.go @@ -6,6 +6,7 @@ import ( "testing/fstest" "git.golem15.com/golem15/summercms/bonfire" + "github.com/go-gormigrate/gormigrate/v2" ) type configOnly struct{} @@ -27,14 +28,21 @@ type bothCaps struct { type neither struct{} -func TestOptionalCapabilitiesDiscoveredByTypeAssertion(t *testing.T) { - plugins := []any{configOnly{}, commandsOnly{}, bothCaps{}, neither{}, extraMessage{}} +type migrationsOnly struct{} - var configs, commands, messages, none int +func (migrationsOnly) Migrations() []*gormigrate.Migration { + return []*gormigrate.Migration{{ID: "202609170001_create_users"}} +} + +func TestOptionalCapabilitiesDiscoveredByTypeAssertion(t *testing.T) { + plugins := []any{configOnly{}, commandsOnly{}, bothCaps{}, neither{}, extraMessage{}, migrationsOnly{}} + + var configs, commands, messages, migrations, none int for _, p := range plugins { _, hasCfg := p.(HasConfig) _, hasCmd := p.(HasCommands) _, hasMsg := p.(OptionalMessage) + _, hasMig := p.(HasMigrations) if hasCfg { configs++ } @@ -44,7 +52,10 @@ func TestOptionalCapabilitiesDiscoveredByTypeAssertion(t *testing.T) { if hasMsg { messages++ } - if !hasCfg && !hasCmd && !hasMsg { + if hasMig { + migrations++ + } + if !hasCfg && !hasCmd && !hasMsg && !hasMig { none++ } } @@ -57,11 +68,22 @@ func TestOptionalCapabilitiesDiscoveredByTypeAssertion(t *testing.T) { if messages != 1 { t.Fatalf("OptionalMessage count = %d, want 1", messages) } + if migrations != 1 { + t.Fatalf("HasMigrations count = %d, want 1", migrations) + } if none != 1 { t.Fatalf("neither count = %d, want 1", none) } } +func TestHasMigrationsReturnsOrderedSet(t *testing.T) { + var p HasMigrations = migrationsOnly{} + set := p.Migrations() + if len(set) != 1 || set[0].ID != "202609170001_create_users" { + t.Fatalf("Migrations = %+v", set) + } +} + func TestHasConfigReturnsFilesystem(t *testing.T) { var p HasConfig = configOnly{} fsys := p.ConfigFS()