// Command specsrht-migrate runs the spec.sr.ht Postgres migrations. It is a
// thin single-service wrapper around git.sr.ht/~bitfehler/brant, a sibling of
// doltsrht-migrate: the brant subcommands (up, down, current, list, stamp,
// validate, ping) apply the files under the migrations directory, and an extra
// `init` subcommand loads the full schema.sql in one shot and stamps the
// database to head (used for a fresh install instead of replaying every
// migration).
//
// The connection string comes from [spec.sr.ht]connection-string unless
// overridden with --dsn. Migrations are read from ./migrations in a dev checkout
// or from the installed assets path (/usr/share/sourcehut/migrations/spec.sr.ht)
// otherwise. The lib/pq "postgres" driver this module already links is used in
// preference to brant's default pgx driver.
//
// Typical use:
//
// createdb spec.sr.ht
// specsrht-migrate init # apply schema.sql wholesale, stamp to head
// specsrht-migrate up # apply pending migrations/*.sql (upgrades)
// specsrht-migrate current # print the current schema version
// specsrht-migrate -a up # honor migrate-on-upgrade; no-op when disabled
package main
import (
"context"
"errors"
"fmt"
"log"
"os"
"path/filepath"
"git.sr.ht/~bitfehler/brant"
"git.sr.ht/~bitfehler/brant/cli"
"github.com/alexflint/go-arg"
_ "github.com/lib/pq" // registers the "postgres" database/sql driver
"github.com/vaughan0/go-ini"
"sourcecraft.dev/bigbes/sr-ht-core/config"
)
const (
// progName is the binary name used in usage and log lines.
progName = "specsrht-migrate"
// serviceName is the SourceHut service identifier and config section name.
serviceName = "spec.sr.ht"
// driverName is the database/sql driver this binary links (lib/pq). It
// overrides brant's postgres-dialect default of "pgx", which this module
// does not import.
driverName = "postgres"
// defaultDirectory is brant's own default for --dir. Seeing it unchanged is
// how we know the user did not override the migrations directory.
defaultDirectory = "./migrations"
// defaultSchema is the default for `init --schema`, resolved against the
// working tree in a checkout and against the assets dir otherwise.
defaultSchema = "schema.sql"
// defaultAssets is the fallback for [sr.ht]assets.
defaultAssets = "/usr/share/sourcehut"
)
// InitArgs configures the `init` subcommand: load the full DDL and stamp to head.
type InitArgs struct {
Schema string `arg:"--schema" default:"schema.sql" placeholder:"FILE" help:"schema file to initialize the database with"`
}
// Args embeds brant's CLI arguments (the up/down/... subcommands and shared
// flags such as --dir and --dsn) and adds the init subcommand and the -a
// migrate-on-upgrade gate.
type Args struct {
cli.Args
Init *InitArgs `arg:"subcommand:init" help:"initialize the database from the schema file and stamp to head"`
Auto bool `arg:"-a" help:"honor [spec.sr.ht]migrate-on-upgrade; exit early when it is disabled"`
}
func (Args) Epilogue() string {
return "Use `<cmd> --help` for help with individual commands"
}
// newParser builds the argument parser. Tests reuse it with IgnoreEnv set so
// that a developer's BRANT_* environment cannot skew the expected defaults.
func newParser(conf arg.Config) (*arg.Parser, *Args) {
conf.Program = progName
a := &Args{}
p, err := arg.NewParser(conf, a)
if err != nil {
panic(err) // only happens when the Args struct itself is malformed
}
return p, a
}
func main() {
p, a := newParser(arg.Config{})
p.MustParse(os.Args[1:])
if p.Subcommand() == nil {
p.WriteHelp(os.Stderr)
os.Exit(1)
}
conf := config.LoadConfig()
if a.Auto && !config.GetBool(conf, serviceName, "migrate-on-upgrade", false) {
log.Printf("%s: [%s]migrate-on-upgrade disabled, exiting", progName, serviceName)
return
}
dsn, err := resolveDSN(conf, a.DataSourceName)
if err != nil {
log.Fatalf("%s: %v", progName, err)
}
a.DataSourceName = dsn
// Use lib/pq's "postgres" driver rather than brant's default "pgx".
drv := driverName
a.Driver = &drv
if err := resolvePaths(conf, a); err != nil {
log.Fatalf("%s: %v", progName, err)
}
log.Printf("%s: loading migrations from %s", progName, a.Directory)
if a.Init != nil {
log.Printf("%s: initializing schema from %s", progName, a.Init.Schema)
if err := initDatabase(a); err != nil {
log.Fatalf("%s: init failed: %v", progName, err)
}
return
}
cli.RunWithArgs(&a.Args)
}
// resolveDSN picks the connection string: an explicit --dsn (or BRANT_DSN) wins,
// otherwise [spec.sr.ht]connection-string. A missing or empty configured value
// is an error rather than a silent connection attempt against a default DSN.
func resolveDSN(conf ini.File, override string) (string, error) {
if override != "" {
return override, nil
}
dsn, ok := conf.Get(serviceName, "connection-string")
if !ok || dsn == "" {
return "", fmt.Errorf("no [%s]connection-string configured", serviceName)
}
return dsn, nil
}
// resolvePaths picks the migrations directory and schema file when the user did
// not override --dir: a ./migrations directory in the working tree (dev
// checkout) wins, otherwise the installed assets path is used, mirroring
// sourcehut-migrate.
func resolvePaths(conf ini.File, a *Args) error {
if a.Directory != defaultDirectory {
return nil // user overrode --dir; respect it verbatim
}
info, err := os.Stat("migrations")
if err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("checking ./migrations: %w", err)
}
if err == nil && info.IsDir() {
log.Printf("%s: found ./migrations, using it", progName)
return nil
}
assetsDir := config.GetString(conf, "sr.ht", "assets", defaultAssets)
a.Directory = filepath.Join(assetsDir, "migrations", serviceName)
if a.Init != nil && a.Init.Schema == defaultSchema {
a.Init.Schema = filepath.Join(assetsDir, serviceName+".sql")
}
return nil
}
// initDatabase applies the schema file wholesale and stamps the version table to
// head, so a fresh install skips replaying the incremental migrations.
func initDatabase(a *Args) error {
p, err := cli.ProviderFromArgs(&a.Args)
if err != nil {
return fmt.Errorf("creating provider: %w", err)
}
defer p.Close()
statements, err := os.ReadFile(a.Init.Schema)
if err != nil {
return fmt.Errorf("reading schema %s: %w", a.Init.Schema, err)
}
db, err := p.DB()
if err != nil {
return fmt.Errorf("connecting to database: %w", err)
}
if _, err := db.Exec(string(statements)); err != nil {
return fmt.Errorf("executing %s: %w", a.Init.Schema, err)
}
if err := p.Stamp(context.Background(), brant.VERSION_HEAD, false); err != nil {
return fmt.Errorf("stamping database: %w", err)
}
return nil
}