Files
openfsd/cmd/openfsd-migrate-to-sqlite/main.go
Reese Norris e0d7532423 feat: make openfsd SQLite-only with Postgres migration tool
Drop runtime PostgreSQL support (repos, dual migration trees, driver
selection). Reject non-sqlite DATABASE_DRIVER at startup with a pointer
to the cutover path. Add openfsd-migrate-to-sqlite (unit + embedded-
Postgres e2e), wiki docs for operators, and the sweatbox integrated
simulator design doc.
2026-07-17 16:09:55 -04:00

282 lines
7.2 KiB
Go

// Command openfsd-migrate-to-sqlite copies an openfsd PostgreSQL database into
// a new SQLite file. It is a one-shot operator tool for deployments that still
// run on PostgreSQL after openfsd became SQLite-only.
//
// Usage:
//
// openfsd-migrate-to-sqlite -from 'postgres://user:pass@host:5432/openfsd?sslmode=disable' -to ./openfsd.db
//
// The destination file must not already exist. Source data is read only;
// PostgreSQL is never modified.
package main
import (
"database/sql"
"flag"
"fmt"
"io"
"os"
"strings"
"github.com/renorris/openfsd/internal/db"
_ "github.com/lib/pq"
_ "modernc.org/sqlite"
)
// Test seams for otherwise unreachable driver/OS error paths.
var (
sqlOpen = sql.Open
applySQLiteMigrations = db.Migrate
osExit = os.Exit
txCommit = func(tx *sql.Tx) error { return tx.Commit() }
txPrepare = func(tx *sql.Tx, q string) (*sql.Stmt, error) { return tx.Prepare(q) }
rowsErr = func(rows *sql.Rows) error { return rows.Err() }
seqDeleteSQL = `DELETE FROM sqlite_sequence WHERE name = 'users'`
seqInsertSQL = `INSERT INTO sqlite_sequence(name, seq) VALUES ('users', ?)`
scanConfigRow = func(rows *sql.Rows) (key, value string, err error) {
err = rows.Scan(&key, &value)
return
}
)
func main() {
osExit(run(os.Args[1:], os.Stderr, os.Stdout))
}
// run is the testable entrypoint. Returns a process exit code.
func run(args []string, stderr, stdout io.Writer) int {
fs := flag.NewFlagSet("openfsd-migrate-to-sqlite", flag.ContinueOnError)
fs.SetOutput(stderr)
from := fs.String("from", "", "PostgreSQL DSN (e.g. postgres://user:pass@localhost:5432/openfsd?sslmode=disable)")
to := fs.String("to", "", "destination SQLite file path (must not already exist)")
if err := fs.Parse(args); err != nil {
return 2
}
if strings.TrimSpace(*from) == "" || strings.TrimSpace(*to) == "" {
fs.Usage()
fmt.Fprintln(stderr, "\nboth -from and -to are required")
return 2
}
if err := migrate(*from, *to, stdout); err != nil {
fmt.Fprintf(stderr, "migrate: %v\n", err)
return 1
}
fmt.Fprintf(stdout, "OK: migrated PostgreSQL → %s\n", *to)
fmt.Fprintln(stdout, "Next: set DATABASE_SOURCE_NAME to this file (and DATABASE_DRIVER=sqlite or omit it), then start openfsd.")
return 0
}
func migrate(pgDSN, sqlitePath string, stdout io.Writer) error {
if err := ensureDestAbsent(sqlitePath); err != nil {
return err
}
pg, err := openPostgres(pgDSN)
if err != nil {
return err
}
defer pg.Close()
sqliteDB, err := openSQLiteDest(sqlitePath)
if err != nil {
return err
}
defer sqliteDB.Close()
nUsers, err := copyUsers(pg, sqliteDB)
if err != nil {
return err
}
nConfig, err := copyConfig(pg, sqliteDB)
if err != nil {
return err
}
fmt.Fprintf(stdout, "copied %d user(s) and %d config row(s)\n", nUsers, nConfig)
return nil
}
func ensureDestAbsent(sqlitePath string) error {
_, err := os.Stat(sqlitePath)
if err == nil {
return fmt.Errorf("destination %q already exists; refuse to overwrite (move or delete it first)", sqlitePath)
}
if !os.IsNotExist(err) {
return fmt.Errorf("stat destination: %w", err)
}
return nil
}
func openPostgres(pgDSN string) (*sql.DB, error) {
pg, err := sqlOpen("postgres", pgDSN)
if err != nil {
return nil, fmt.Errorf("open postgres: %w", err)
}
if err := pg.Ping(); err != nil {
_ = pg.Close()
return nil, fmt.Errorf("ping postgres: %w", err)
}
return pg, nil
}
func openSQLiteDest(sqlitePath string) (*sql.DB, error) {
sqliteDB, err := sqlOpen("sqlite", sqlitePath)
if err != nil {
return nil, fmt.Errorf("open sqlite: %w", err)
}
sqliteDB.SetMaxOpenConns(1)
if err := sqliteDB.Ping(); err != nil {
_ = sqliteDB.Close()
return nil, fmt.Errorf("ping sqlite: %w", err)
}
if err := applySQLiteMigrations(sqliteDB); err != nil {
_ = sqliteDB.Close()
return nil, fmt.Errorf("sqlite schema migrate: %w", err)
}
return sqliteDB, nil
}
// userSelect statements tried in order. public.users is the historical openfsd
// schema; the unqualified form covers non-public search_path deployments.
var userSelects = []string{
`SELECT cid, password, first_name, last_name, network_rating
FROM public.users
ORDER BY cid`,
`SELECT cid, password, first_name, last_name, network_rating
FROM users
ORDER BY cid`,
}
func queryUsers(pg *sql.DB) (*sql.Rows, error) {
var lastErr error
for _, q := range userSelects {
rows, err := pg.Query(q)
if err == nil {
return rows, nil
}
lastErr = err
}
return nil, fmt.Errorf("select users: %w", lastErr)
}
func copyUsers(pg, sqliteDB *sql.DB) (int, error) {
rows, err := queryUsers(pg)
if err != nil {
return 0, err
}
defer rows.Close()
tx, err := sqliteDB.Begin()
if err != nil {
return 0, fmt.Errorf("begin users tx: %w", err)
}
defer func() { _ = tx.Rollback() }()
stmt, err := txPrepare(tx, `
INSERT INTO users (cid, password, first_name, last_name, network_rating)
VALUES (?, ?, ?, ?, ?)`)
if err != nil {
return 0, fmt.Errorf("prepare user insert: %w", err)
}
defer stmt.Close()
n := 0
var maxCID int
for rows.Next() {
var (
cid int
password string
firstName sql.NullString
lastName sql.NullString
networkRating int
)
if err := rows.Scan(&cid, &password, &firstName, &lastName, &networkRating); err != nil {
return 0, fmt.Errorf("scan user: %w", err)
}
// Postgres char(60) may pad with spaces; trim for bcrypt hashes.
password = strings.TrimSpace(password)
var fn, ln any
if firstName.Valid {
fn = firstName.String
}
if lastName.Valid {
ln = lastName.String
}
if _, err := stmt.Exec(cid, password, fn, ln, networkRating); err != nil {
return 0, fmt.Errorf("insert user cid=%d: %w", cid, err)
}
if cid > maxCID {
maxCID = cid
}
n++
}
if err := rowsErr(rows); err != nil {
return 0, fmt.Errorf("iterate users: %w", err)
}
if err := setUsersSequence(tx, maxCID); err != nil {
return 0, err
}
if err := txCommit(tx); err != nil {
return 0, fmt.Errorf("commit users: %w", err)
}
return n, nil
}
func setUsersSequence(tx *sql.Tx, maxCID int) error {
if maxCID <= 0 {
return nil
}
if _, err := tx.Exec(seqDeleteSQL); err != nil {
return fmt.Errorf("reset sqlite_sequence: %w", err)
}
if _, err := tx.Exec(seqInsertSQL, maxCID); err != nil {
return fmt.Errorf("set sqlite_sequence: %w", err)
}
return nil
}
func copyConfig(pg, sqliteDB *sql.DB) (int, error) {
rows, err := pg.Query(`SELECT key, value FROM config`)
if err != nil {
return 0, fmt.Errorf("select config: %w", err)
}
defer rows.Close()
tx, err := sqliteDB.Begin()
if err != nil {
return 0, fmt.Errorf("begin config tx: %w", err)
}
defer func() { _ = tx.Rollback() }()
stmt, err := txPrepare(tx, `INSERT INTO config (key, value) VALUES (?, ?)`)
if err != nil {
return 0, fmt.Errorf("prepare config insert: %w", err)
}
defer stmt.Close()
n := 0
for rows.Next() {
key, value, err := scanConfigRow(rows)
if err != nil {
return 0, fmt.Errorf("scan config: %w", err)
}
if _, err := stmt.Exec(key, value); err != nil {
return 0, fmt.Errorf("insert config key=%q: %w", key, err)
}
n++
}
if err := rowsErr(rows); err != nil {
return 0, fmt.Errorf("iterate config: %w", err)
}
if err := txCommit(tx); err != nil {
return 0, fmt.Errorf("commit config: %w", err)
}
return n, nil
}