Adopt golib/pg for migrations and pool construction
forgebot applied its schema by executing an inline DDL string on every boot, with no version tracking and no lock, so two API replicas starting together raced and the SQL had nowhere to grow. golib/pg already owns that mechanic for the estate; take it and keep owning the SQL. - Move the schema into migrations/0001_init.sql, embedded via migrations.FS. The DDL is verbatim. - The four legacy-status UPDATEs move into 0001 unchanged. Each reads only retired statuses (pending/failed/running/succeeded/cancelled) and writes only current ones, and no current status is a source, so replaying 0001 once against the live database is a no-op. A test pins that property. - Build the pool with pg.NewMigrated, LockName "forgebot-migrations": the advisory lock serializes replicas, schema_migrations records what ran, and a migration failure fails startup instead of half-migrating. database.New and apiserver.New take a context and logger for it. - Render the DSN with pg.DSN, which percent-escapes the credentials the fmt.Sprintf builder pasted in raw. LoadConfig still reads the environment itself: pg.DSNFromEnv has no defaults for user and database name, where forgebot defaults both to "forgebot", and would newly honour DATABASE_URL and PG*. The deployed DBHOST/DBPORT/DBUSER/DBPASS/DBNAME/DBSSL contract and its defaults are unchanged, and pinned by a test. - Plumb GOPRIVATE=git.unkin.net for the first cross-repo Go dependency: exported by the Makefile, set in both Dockerfiles and the woodpecker Go steps, documented in the README. - gofmt the four files that were already unformatted on main, so the pre-commit step can pass.
This commit is contained in:
@@ -4,6 +4,8 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"git.unkin.net/unkin/golib/pg"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
@@ -19,9 +21,16 @@ type Config struct {
|
||||
GiteaToken string
|
||||
}
|
||||
|
||||
// DatabaseDSN renders the connection string with golib's builder, which
|
||||
// percent-escapes the credentials — byte-identical to the fmt.Sprintf form it
|
||||
// replaces for values without reserved characters.
|
||||
//
|
||||
// The environment is still read by LoadConfig rather than pg.DSNFromEnv: the
|
||||
// library has no defaults for the user and database name, where forgebot
|
||||
// defaults both to "forgebot", and it would newly honour DATABASE_URL and the
|
||||
// PG* variables. Deployments set the DB* names below and nothing else.
|
||||
func (c *Config) DatabaseDSN() string {
|
||||
return fmt.Sprintf("postgres://%s:%s@%s:%d/%s?sslmode=%s",
|
||||
c.DBUser, c.DBPass, c.DBHost, c.DBPort, c.DBName, c.DBSSL)
|
||||
return pg.DSN(c.DBHost, c.DBPort, c.DBUser, c.DBPass, c.DBName, c.DBSSL)
|
||||
}
|
||||
|
||||
func LoadConfig() (*Config, error) {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package apiserver
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The deployed contract is these six variables and these defaults; nothing else
|
||||
// is consulted for the database connection.
|
||||
func TestLoadConfig_DatabaseEnvContract(t *testing.T) {
|
||||
// Set by a deployment that would confuse a DATABASE_URL/PG*-aware loader.
|
||||
t.Setenv("DATABASE_URL", "postgres://someone@elsewhere:5432/other")
|
||||
t.Setenv("PGHOST", "elsewhere")
|
||||
|
||||
cfg, err := LoadConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig: %v", err)
|
||||
}
|
||||
if got, want := cfg.DatabaseDSN(), "postgres://forgebot:@localhost:5432/forgebot?sslmode=disable"; got != want {
|
||||
t.Fatalf("default DSN = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
t.Setenv("DBHOST", "db.example")
|
||||
t.Setenv("DBPORT", "6432")
|
||||
t.Setenv("DBUSER", "bot")
|
||||
t.Setenv("DBPASS", "hunter2")
|
||||
t.Setenv("DBNAME", "tasks")
|
||||
t.Setenv("DBSSL", "require")
|
||||
|
||||
cfg, err = LoadConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig: %v", err)
|
||||
}
|
||||
if got, want := cfg.DatabaseDSN(), "postgres://bot:hunter2@db.example:6432/tasks?sslmode=require"; got != want {
|
||||
t.Fatalf("DSN = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfig_RejectsBadPort(t *testing.T) {
|
||||
t.Setenv("DBPORT", "not-a-port")
|
||||
if _, err := LoadConfig(); err == nil {
|
||||
t.Fatal("expected an error for a non-numeric DBPORT")
|
||||
}
|
||||
}
|
||||
|
||||
// A password with reserved characters used to truncate the DSN; the builder
|
||||
// percent-escapes the credentials so it round-trips through pgx intact.
|
||||
func TestDatabaseDSN_EscapesCredentials(t *testing.T) {
|
||||
cfg := &Config{
|
||||
DBHost: "db.example", DBPort: 5432, DBUser: "bo/t",
|
||||
DBPass: "p@ss/word", DBName: "tasks", DBSSL: "disable",
|
||||
}
|
||||
if got, want := cfg.DatabaseDSN(), "postgres://bo%2Ft:p%40ss%2Fword@db.example:5432/tasks?sslmode=disable"; got != want {
|
||||
t.Fatalf("DSN = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -22,8 +22,10 @@ type Server struct {
|
||||
provider *gitea.Client
|
||||
}
|
||||
|
||||
func New(cfg *Config) (*Server, error) {
|
||||
db, err := database.New(cfg.DatabaseDSN())
|
||||
// New connects to Postgres and migrates the schema before wiring the routes,
|
||||
// so a failed migration is a failed startup rather than a broken server.
|
||||
func New(ctx context.Context, cfg *Config) (*Server, error) {
|
||||
db, err := database.New(ctx, cfg.DatabaseDSN(), slog.Default())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user