coverage~bigbes/sr-ht-spec64cae3afcmd/specsrht-migrate/main.go

Coverage
52.3% 34/65 statements
Δ
+0.0
Blob
bad16af
1 // Command specsrht-migrate runs the spec.sr.ht Postgres migrations. It is a
2 // thin single-service wrapper around git.sr.ht/~bitfehler/brant, a sibling of
3 // doltsrht-migrate: the brant subcommands (up, down, current, list, stamp,
4 // validate, ping) apply the files under the migrations directory, and an extra
5 // `init` subcommand loads the full schema.sql in one shot and stamps the
6 // database to head (used for a fresh install instead of replaying every
7 // migration).
8 //
9 // The connection string comes from [spec.sr.ht]connection-string unless
10 // overridden with --dsn. Migrations are read from ./migrations in a dev checkout
11 // or from the installed assets path (/usr/share/sourcehut/migrations/spec.sr.ht)
12 // otherwise. The lib/pq "postgres" driver this module already links is used in
13 // preference to brant's default pgx driver.
14 //
15 // Typical use:
16 //
17 // createdb spec.sr.ht
18 // specsrht-migrate init # apply schema.sql wholesale, stamp to head
19 // specsrht-migrate up # apply pending migrations/*.sql (upgrades)
20 // specsrht-migrate current # print the current schema version
21 // specsrht-migrate -a up # honor migrate-on-upgrade; no-op when disabled
22 package main
23
24 import (
25 "context"
26 "errors"
27 "fmt"
28 "log"
29 "os"
30 "path/filepath"
31
32 "git.sr.ht/~bitfehler/brant"
33 "git.sr.ht/~bitfehler/brant/cli"
34 "github.com/alexflint/go-arg"
35 _ "github.com/lib/pq" // registers the "postgres" database/sql driver
36 "github.com/vaughan0/go-ini"
37
38 "sourcecraft.dev/bigbes/sr-ht-core/config"
39 )
40
41 const (
42 // progName is the binary name used in usage and log lines.
43 progName = "specsrht-migrate"
44
45 // serviceName is the SourceHut service identifier and config section name.
46 serviceName = "spec.sr.ht"
47
48 // driverName is the database/sql driver this binary links (lib/pq). It
49 // overrides brant's postgres-dialect default of "pgx", which this module
50 // does not import.
51 driverName = "postgres"
52
53 // defaultDirectory is brant's own default for --dir. Seeing it unchanged is
54 // how we know the user did not override the migrations directory.
55 defaultDirectory = "./migrations"
56
57 // defaultSchema is the default for `init --schema`, resolved against the
58 // working tree in a checkout and against the assets dir otherwise.
59 defaultSchema = "schema.sql"
60
61 // defaultAssets is the fallback for [sr.ht]assets.
62 defaultAssets = "/usr/share/sourcehut"
63 )
64
65 // InitArgs configures the `init` subcommand: load the full DDL and stamp to head.
66 type InitArgs struct {
67 Schema string `arg:"--schema" default:"schema.sql" placeholder:"FILE" help:"schema file to initialize the database with"`
68 }
69
70 // Args embeds brant's CLI arguments (the up/down/... subcommands and shared
71 // flags such as --dir and --dsn) and adds the init subcommand and the -a
72 // migrate-on-upgrade gate.
73 type Args struct {
74 cli.Args
75 Init *InitArgs `arg:"subcommand:init" help:"initialize the database from the schema file and stamp to head"`
76 Auto bool `arg:"-a" help:"honor [spec.sr.ht]migrate-on-upgrade; exit early when it is disabled"`
77 }
78
79 11 func (Args) Epilogue() string {
80 11 return "Use `<cmd> --help` for help with individual commands"
81 11 }
82
83 // newParser builds the argument parser. Tests reuse it with IgnoreEnv set so
84 // that a developer's BRANT_* environment cannot skew the expected defaults.
85 11 func newParser(conf arg.Config) (*arg.Parser, *Args) {
86 11 conf.Program = progName
87 11 a := &Args{}
88 11 p, err := arg.NewParser(conf, a)
89 11 if err != nil {
90 0 panic(err) // only happens when the Args struct itself is malformed
91 }
92 11 return p, a
93 }
94
95 0 func main() {
96 0 p, a := newParser(arg.Config{})
97 0 p.MustParse(os.Args[1:])
98 0 if p.Subcommand() == nil {
99 0 p.WriteHelp(os.Stderr)
100 0 os.Exit(1)
101 0 }
102
103 0 conf := config.LoadConfig()
104 0
105 0 if a.Auto && !config.GetBool(conf, serviceName, "migrate-on-upgrade", false) {
106 0 log.Printf("%s: [%s]migrate-on-upgrade disabled, exiting", progName, serviceName)
107 0 return
108 0 }
109
110 0 dsn, err := resolveDSN(conf, a.DataSourceName)
111 0 if err != nil {
112 0 log.Fatalf("%s: %v", progName, err)
113 0 }
114 0 a.DataSourceName = dsn
115 0
116 0 // Use lib/pq's "postgres" driver rather than brant's default "pgx".
117 0 drv := driverName
118 0 a.Driver = &drv
119 0
120 0 if err := resolvePaths(conf, a); err != nil {
121 0 log.Fatalf("%s: %v", progName, err)
122 0 }
123 0 log.Printf("%s: loading migrations from %s", progName, a.Directory)
124 0
125 0 if a.Init != nil {
126 0 log.Printf("%s: initializing schema from %s", progName, a.Init.Schema)
127 0 if err := initDatabase(a); err != nil {
128 0 log.Fatalf("%s: init failed: %v", progName, err)
129 0 }
130 0 return
131 }
132
133 0 cli.RunWithArgs(&a.Args)
134 }
135
136 // resolveDSN picks the connection string: an explicit --dsn (or BRANT_DSN) wins,
137 // otherwise [spec.sr.ht]connection-string. A missing or empty configured value
138 // is an error rather than a silent connection attempt against a default DSN.
139 4 func resolveDSN(conf ini.File, override string) (string, error) {
140 4 if override != "" {
141 1 return override, nil
142 1 }
143 3 dsn, ok := conf.Get(serviceName, "connection-string")
144 3 if !ok || dsn == "" {
145 2 return "", fmt.Errorf("no [%s]connection-string configured", serviceName)
146 2 }
147 1 return dsn, nil
148 }
149
150 // resolvePaths picks the migrations directory and schema file when the user did
151 // not override --dir: a ./migrations directory in the working tree (dev
152 // checkout) wins, otherwise the installed assets path is used, mirroring
153 // sourcehut-migrate.
154 6 func resolvePaths(conf ini.File, a *Args) error {
155 6 if a.Directory != defaultDirectory {
156 1 return nil // user overrode --dir; respect it verbatim
157 1 }
158
159 5 info, err := os.Stat("migrations")
160 5 if err != nil && !errors.Is(err, os.ErrNotExist) {
161 0 return fmt.Errorf("checking ./migrations: %w", err)
162 0 }
163 5 if err == nil && info.IsDir() {
164 1 log.Printf("%s: found ./migrations, using it", progName)
165 1 return nil
166 1 }
167
168 4 assetsDir := config.GetString(conf, "sr.ht", "assets", defaultAssets)
169 4 a.Directory = filepath.Join(assetsDir, "migrations", serviceName)
170 4 if a.Init != nil && a.Init.Schema == defaultSchema {
171 2 a.Init.Schema = filepath.Join(assetsDir, serviceName+".sql")
172 2 }
173 4 return nil
174 }
175
176 // initDatabase applies the schema file wholesale and stamps the version table to
177 // head, so a fresh install skips replaying the incremental migrations.
178 1 func initDatabase(a *Args) error {
179 1 p, err := cli.ProviderFromArgs(&a.Args)
180 1 if err != nil {
181 0 return fmt.Errorf("creating provider: %w", err)
182 0 }
183 1 defer p.Close()
184 1
185 1 statements, err := os.ReadFile(a.Init.Schema)
186 1 if err != nil {
187 0 return fmt.Errorf("reading schema %s: %w", a.Init.Schema, err)
188 0 }
189
190 1 db, err := p.DB()
191 1 if err != nil {
192 0 return fmt.Errorf("connecting to database: %w", err)
193 0 }
194 1 if _, err := db.Exec(string(statements)); err != nil {
195 0 return fmt.Errorf("executing %s: %w", a.Init.Schema, err)
196 0 }
197
198 1 if err := p.Stamp(context.Background(), brant.VERSION_HEAD, false); err != nil {
199 0 return fmt.Errorf("stamping database: %w", err)
200 0 }
201 1 return nil
202 }