~bigbes/sr-ht-spec

ref: c74776077006e81af82c2fd53cb186271b42bee9 sr-ht-spec/cmd/specsrht-migrate/main.go -rw-r--r-- 6.6 KiB
c7477607 — Eugene Blikh authn: accept tokens.sr.ht working tokens beside the agent token 10 days ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
// 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
}