~bigbes/sr-ht-spec

ref: a53c6d7ddd2d3718183838ae0354aa250ab4b892 sr-ht-spec/cmd/specsrht-migrate/main.go -rw-r--r-- 9.5 KiB
a53c6d7d — Eugene Blikh migrate: install the instance's masking before brant can log a DSN 11 hours 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
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
// 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
}