- queue:work and schedule:run print a startup summary and a shutdown report on a terminal, log them as records otherwise, and return nil after a clean stop - schedule:run --once ends with 'Ran N due entries (M failed)' - summer dev runs bin/<app> serve with the forwarded global options, reports through Output, stops the old server before starting the new one and prints a dev report on Ctrl+C (exit 0) - lagoon.DatabaseLabel describes a DSN without its secrets - Long help and examples for queue:work, schedule:run, dev, docs:serve
172 lines
5.3 KiB
Go
172 lines
5.3 KiB
Go
package lagoon
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
|
"git.golem15.com/golem15/summercms/modules/compass"
|
|
"git.golem15.com/golem15/summercms/modules/diary"
|
|
"github.com/jackc/pgx/v5/pgconn"
|
|
_ "github.com/jackc/pgx/v5/stdlib"
|
|
"gorm.io/driver/postgres"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/logger"
|
|
)
|
|
|
|
// moduleLog is lagoon's logger: records carry component=lagoon and follow
|
|
// log.components.lagoon (diary).
|
|
var moduleLog = diary.For("lagoon")
|
|
|
|
// Open pings dsn through pgx stdlib and returns that exact *sql.DB plus a
|
|
// GORM handle opened on it.
|
|
//
|
|
// The job worker in the conga module opens its own single-connection
|
|
// pgxpool.Pool for River LISTEN/NOTIFY. Do not create that listener pool
|
|
// here; application queries, including River's, 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)
|
|
}
|
|
gdb, err := gormFromSQL(sqlDB)
|
|
if err != nil {
|
|
_ = sqlDB.Close()
|
|
return nil, nil, err
|
|
}
|
|
return sqlDB, gdb, nil
|
|
}
|
|
|
|
// Use pings an existing pool 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)
|
|
}
|
|
return gormFromSQL(sqlDB)
|
|
}
|
|
|
|
// gormLogger routes GORM's lines through diary as component db instead of
|
|
// GORM's default stdout logger: slow queries (over 200ms) and errors at warn,
|
|
// record-not-found ignored, and SQL traces only at verbosity 3 (-vvv), with
|
|
// parameterized queries so bound values never reach a log line.
|
|
func gormLogger() logger.Interface {
|
|
level := logger.Warn
|
|
if diary.Verbosity() >= 3 {
|
|
level = logger.Info
|
|
}
|
|
return logger.NewSlogLogger(diary.For("db"), logger.Config{
|
|
SlowThreshold: 200 * time.Millisecond,
|
|
LogLevel: level,
|
|
IgnoreRecordNotFoundError: true,
|
|
// Log SQL with placeholders, never with the bound values.
|
|
ParameterizedQueries: true,
|
|
})
|
|
}
|
|
|
|
func gormFromSQL(sqlDB *sql.DB) (*gorm.DB, error) {
|
|
gdb, err := gorm.Open(postgres.New(postgres.Config{Conn: sqlDB}), &gorm.Config{Logger: gormLogger()})
|
|
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")
|
|
}
|
|
if err := registerAfterCommit(gdb); err != nil {
|
|
return nil, fmt.Errorf("lagoon: register %s: %w", AfterCommitCallback, err)
|
|
}
|
|
return gdb, nil
|
|
}
|
|
|
|
// OpenFromApp reads database.dsn from app config and opens the shared pool.
|
|
// It also loads app.key via LoadAppKey and PublishEncryptionKeys so Encrypted
|
|
// columns do not re-read config on every row.
|
|
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")
|
|
}
|
|
sqlDB, gdb, err := Open(ctx, DSN(app.Config))
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if err := loadEncryptionKeysFromApp(app); err != nil {
|
|
_ = sqlDB.Close()
|
|
return nil, nil, err
|
|
}
|
|
return sqlDB, gdb, nil
|
|
}
|
|
|
|
// 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"))
|
|
}
|
|
|
|
// DatabaseLabel describes dsn for a person without its secrets: host:port/name
|
|
// parsed with pgconn.ParseConfig (a Unix socket directory reads "socket
|
|
// <dir>"), never the user or password. An empty DSN is "not configured", one
|
|
// that does not parse "configured". serve and queue:work print it in their
|
|
// startup summaries.
|
|
func DatabaseLabel(dsn string) string {
|
|
dsn = strings.TrimSpace(dsn)
|
|
if dsn == "" {
|
|
return "not configured"
|
|
}
|
|
cfg, err := pgconn.ParseConfig(dsn)
|
|
if err != nil || cfg.Host == "" {
|
|
return "configured"
|
|
}
|
|
host := cfg.Host
|
|
if strings.HasPrefix(host, "/") {
|
|
host = "socket " + host
|
|
} else if strings.Contains(host, ":") {
|
|
host = "[" + host + "]"
|
|
}
|
|
label := host + ":" + strconv.Itoa(int(cfg.Port))
|
|
if cfg.Database != "" {
|
|
label += "/" + cfg.Database
|
|
}
|
|
return label
|
|
}
|
|
|
|
// Publish stores the shared SQL pool and GORM handle on the app. Both
|
|
// handles refer to the same *sql.DB. It then runs the callbacks queued by
|
|
// OnDatabase and returns the first error.
|
|
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
|
|
}
|
|
if err := app.Publish(gdb); err != nil {
|
|
return err
|
|
}
|
|
return runDatabaseHooks(app, sqlDB, gdb)
|
|
}
|