// 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/slog"
"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"
"go.bigb.es/auxilia/scribe"
"sourcecraft.dev/bigbes/sr-ht-core/config"
"sourcecraft.dev/bigbes/sr-ht-ecore/logging"
)
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()
log := installLogger(conf)
if a.Auto && !config.GetBool(conf, serviceName, "migrate-on-upgrade", false) {
log.Info(fmt.Sprintf("[%s]migrate-on-upgrade disabled, exiting", serviceName))
return
}
dsn, err := resolveDSN(conf, a.DataSourceName)
if err != nil {
fatal(log, 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 {
fatal(log, err)
}
log.Info("loading migrations", "dir", a.Directory)
if a.Init != nil {
log.Info("initializing schema", "schema", a.Init.Schema)
if err := initDatabase(a); err != nil {
fatal(log, fmt.Errorf("init failed: %w", err))
}
return
}
cli.RunWithArgs(&a.Args)
}
// installLogger installs the process-wide slog handler, and it is the daemon's:
// cmd/specsrht's installLogger builds the same scribe handler over the same
// sr-ht-ecore policy, so a migration and the daemon that starts after it are
// read the same way in the same journal.
//
// Installing it at all is the point rather than the tidiness. brant reports a
// migration directory it cannot open with
// `slog.Error("failed to create provider", "datasource", a.DataSourceName, ...)`
// — the connection string, password and all, at ERR level, on the ordinary path
// an installed package takes before its first migration ships. Without this call
// that record goes to Go's built-in stderr handler, which knows no mask list, so
// the redaction sr-ht-ecore maintains never gets a chance to run. It was the
// install that was missing here, not the list.
//
// One thing no mask list can fix: brant's own cli.initLogger runs inside
// ProviderFromArgs, ahead of that record, and under --json it calls
// slog.SetDefault with a plain JSON handler of its own — which throws this one
// away and prints the DSN again. Nothing here passes --json and nothing should
// start; the default branch only redirects the std log package and leaves the
// installed handler alone, which is why the masking holds.
//
// The verbosity comes from DefaultsWithoutDebugFlag rather than the daemon's
// Defaults, and that is the one place the two differ: ecore reads a standalone
// -d out of os.Args as SourceHut's debug flag, while here -d is brant's
// --dialect and takes a value, so `specsrht-migrate -d postgres up` would
// silently ask for debug logging as well. $LOG_LEVEL and [spec.sr.ht]log-level
// still resolve, which is what an operator debugging an upgrade hook reaches
// for.
func installLogger(conf ini.File) *slog.Logger {
opts := logging.DefaultsWithoutDebugFlag(conf, serviceName)
return logging.Install(scribe.NewTintHandler(
scribe.WithWriter(os.Stderr),
scribe.WithLevel(opts.Level),
scribe.WithSource(opts.AddSource),
scribe.WithTimeFormat(opts.TimeFormat),
scribe.WithNoColor(!opts.Color),
scribe.WithMaskKeys(opts.MaskKeys...),
scribe.WithMask(opts.MaskPattern, opts.MaskReplacement),
))
}
// fatal reports why this binary is stopping and stops it. It replaces
// log.Fatalf, which slog has no equivalent of on purpose: os.Exit runs no
// deferred function, so "say why and die" is a decision only main may take.
//
// The error is the record's message rather than an attribute, and the progName
// prefix every one of these lines used to carry is gone. Both follow from the
// reader: these are whole sentences written for the operator watching an upgrade
// — "no [spec.sr.ht]connection-string configured" is the entire report — and an
// attribute would quote and escape it behind a key, while the unit name and the
// source position the handler already stamps say what the prefix used to.
func fatal(log *slog.Logger, err error) {
log.Error(err.Error())
os.Exit(1)
}
// 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() {
slog.Info("found ./migrations, using it")
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
}