Phase 0: bootstrap the Warp homeserver
CI / Test (ubuntu-latest) (push) Canceled after 0s
CI / Test (windows-latest) (push) Canceled after 0s
CI / Storage tests on PostgreSQL (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
CI / Smoke test the binary (push) Canceled after 0s

Scaffolds the server module and everything Phase 0 of PLAN.md calls for:
a binary that boots, serves its discovery endpoints and nothing else yet.

- config: one YAML file plus per-field WARP_* environment overrides,
  unknown fields rejected, validation that reports every problem at once
- logging and metrics: slog, a Prometheus registry, per-route HTTP
  instrumentation labelled by pattern rather than path
- httputil: the Matrix error envelope, error-returning handlers, CORS,
  request logging, panic recovery, and a router over http.ServeMux
- endpoints: /_matrix/client/versions, /.well-known/matrix/client,
  /.well-known/matrix/server (gated), /healthz, /metrics, and
  M_UNRECOGNIZED for everything else
- storage: driver registry, shared database/sql base, checksummed
  migration runner, sqlite and postgres drivers, pocketbase stub
- settings: server_settings table behind a cached service, so the
  federation and registration switches change without a restart
- federation: the gate that makes federation a runtime mode
- cmd/warp: serve, migrate, generate-keys, version
- Makefile, golangci-lint v2, GitHub Actions CI (Linux and Windows tests,
  race on Linux, storage tests against PostgreSQL, lint, boot smoke test),
  Dockerfile, example config and docs

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tajniak81andClaude Opus 5 committed 2026-10-15 17:01:13 +02:00
commit b4fb60a8ab
50 files changed
+5672

No files matched your search

@@ -0,0 +1,14 @@
// Package drivers links every storage driver into the binary. Importing it
// for its side effects is what makes storage.Open able to find them:
//
// import _ "github.com/dariuszjaniak/warp/server/internal/storage/drivers"
//
// It exists so that commands and tests have one import to remember, and so
// that the storage package itself never imports a database engine.
package drivers
import (
_ "github.com/dariuszjaniak/warp/server/internal/storage/pocketbase"
_ "github.com/dariuszjaniak/warp/server/internal/storage/postgres"
_ "github.com/dariuszjaniak/warp/server/internal/storage/sqlite"
)
@@ -0,0 +1,236 @@
// Package migrations runs embedded SQL migrations against any
// database/sql driver.
//
// Migrations live next to their driver as files named NNNN_description.sql
// and are applied in version order inside a transaction each, recorded in a
// schema_migrations table. A migration that has already run and whose file
// has since changed is an error, not a silent no-op.
package migrations
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"io/fs"
"log/slog"
"path"
"slices"
"strconv"
"strings"
"time"
)
// Migration is one SQL file.
type Migration struct {
Version int
Name string
SQL string
Checksum string
}
// Applied is a row of schema_migrations.
type Applied struct {
Version int
Name string
Checksum string
AppliedAt time.Time
}
// Dialect carries the handful of statements that differ per database. Each
// driver package supplies one; the runner itself stays dialect-agnostic.
type Dialect struct {
// Name is used in log lines and errors, e.g. "sqlite".
Name string
// CreateTable creates schema_migrations if it does not exist, with
// columns (version, name, checksum, applied_at).
CreateTable string
// SelectApplied reads every row, ordered by version.
SelectApplied string
// InsertApplied records one migration. Its placeholders bind
// (version, name, checksum, applied_at) in that order.
InsertApplied string
}
// Load reads every NNNN_*.sql file in dir of fsys.
func Load(fsys fs.FS, dir string) ([]Migration, error) {
entries, err := fs.ReadDir(fsys, dir)
if err != nil {
return nil, fmt.Errorf("read migrations directory %s: %w", dir, err)
}
var out []Migration
seen := map[int]string{}
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".sql") {
continue
}
version, name, err := parseFilename(e.Name())
if err != nil {
return nil, err
}
if prev, dup := seen[version]; dup {
return nil, fmt.Errorf("duplicate migration version %d: %s and %s", version, prev, e.Name())
}
seen[version] = e.Name()
body, err := fs.ReadFile(fsys, path.Join(dir, e.Name()))
if err != nil {
return nil, fmt.Errorf("read migration %s: %w", e.Name(), err)
}
sum := sha256.Sum256(body)
out = append(out, Migration{
Version: version,
Name: name,
SQL: string(body),
Checksum: hex.EncodeToString(sum[:]),
})
}
slices.SortFunc(out, func(a, b Migration) int { return a.Version - b.Version })
return out, nil
}
// parseFilename splits "0001_server_settings.sql" into 1 and
// "server_settings".
func parseFilename(filename string) (int, string, error) {
base := strings.TrimSuffix(filename, ".sql")
prefix, name, ok := strings.Cut(base, "_")
if !ok {
return 0, "", fmt.Errorf("migration %s: expected NNNN_description.sql", filename)
}
version, err := strconv.Atoi(prefix)
if err != nil {
return 0, "", fmt.Errorf("migration %s: %q is not a version number", filename, prefix)
}
if version <= 0 {
return 0, "", fmt.Errorf("migration %s: version must be positive", filename)
}
return version, name, nil
}
// Status lists the migrations already applied.
func Status(ctx context.Context, db *sql.DB, d Dialect) ([]Applied, error) {
if _, err := db.ExecContext(ctx, d.CreateTable); err != nil {
return nil, fmt.Errorf("create schema_migrations: %w", err)
}
rows, err := db.QueryContext(ctx, d.SelectApplied)
if err != nil {
return nil, fmt.Errorf("read schema_migrations: %w", err)
}
defer rows.Close()
var out []Applied
for rows.Next() {
var a Applied
if err := rows.Scan(&a.Version, &a.Name, &a.Checksum, &a.AppliedAt); err != nil {
return nil, fmt.Errorf("scan schema_migrations: %w", err)
}
out = append(out, a)
}
return out, rows.Err()
}
// Run applies every migration that has not run yet and returns how many it
// applied. It is safe to call on every boot.
func Run(ctx context.Context, db *sql.DB, d Dialect, ms []Migration, log *slog.Logger) (int, error) {
if log == nil {
log = slog.Default()
}
applied, err := Status(ctx, db, d)
if err != nil {
return 0, err
}
known := make(map[int]Applied, len(applied))
for _, a := range applied {
known[a.Version] = a
}
count := 0
for _, m := range ms {
if prev, ok := known[m.Version]; ok {
if prev.Checksum != m.Checksum {
return count, fmt.Errorf(
"migration %04d_%s has changed since it was applied (recorded %s, file %s); "+
"write a new migration instead of editing an applied one",
m.Version, m.Name, short(prev.Checksum), short(m.Checksum))
}
continue
}
if err := apply(ctx, db, d, m); err != nil {
return count, err
}
log.Info("applied migration", "version", m.Version, "name", m.Name)
count++
}
return count, nil
}
func apply(ctx context.Context, db *sql.DB, d Dialect, m Migration) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin migration %04d_%s: %w", m.Version, m.Name, err)
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.ExecContext(ctx, m.SQL); err != nil {
return fmt.Errorf("migration %04d_%s: %w", m.Version, m.Name, err)
}
if _, err := tx.ExecContext(ctx, d.InsertApplied, m.Version, m.Name, m.Checksum, time.Now().UTC()); err != nil {
return fmt.Errorf("record migration %04d_%s: %w", m.Version, m.Name, err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit migration %04d_%s: %w", m.Version, m.Name, err)
}
return nil
}
// Pending reports the migrations in ms that have not been applied yet.
func Pending(ctx context.Context, db *sql.DB, d Dialect, ms []Migration) ([]Migration, error) {
applied, err := Status(ctx, db, d)
if err != nil {
return nil, err
}
done := make(map[int]struct{}, len(applied))
for _, a := range applied {
done[a.Version] = struct{}{}
}
var out []Migration
for _, m := range ms {
if _, ok := done[m.Version]; !ok {
out = append(out, m)
}
}
return out, nil
}
// ErrDirtySchema is returned when the recorded schema is ahead of the
// binary: an older warp must not run against a newer database.
var ErrDirtySchema = errors.New("database schema is newer than this binary")
// CheckNotAhead fails when the database records a migration this binary does
// not know about.
func CheckNotAhead(applied []Applied, ms []Migration) error {
highest := 0
for _, m := range ms {
highest = max(highest, m.Version)
}
for _, a := range applied {
if a.Version > highest {
return fmt.Errorf("%w: database is at %04d_%s, this binary knows up to %04d",
ErrDirtySchema, a.Version, a.Name, highest)
}
}
return nil
}
func short(checksum string) string {
if len(checksum) > 8 {
return checksum[:8]
}
return checksum
}
@@ -0,0 +1,175 @@
package migrations_test
import (
"database/sql"
"path/filepath"
"strings"
"testing"
"testing/fstest"
_ "modernc.org/sqlite"
"github.com/dariuszjaniak/warp/server/internal/storage/migrations"
)
var dialect = migrations.Dialect{
Name: "sqlite",
CreateTable: `CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY, name TEXT NOT NULL,
checksum TEXT NOT NULL, applied_at TIMESTAMP NOT NULL)`,
SelectApplied: `SELECT version, name, checksum, applied_at FROM schema_migrations ORDER BY version`,
InsertApplied: `INSERT INTO schema_migrations (version, name, checksum, applied_at) VALUES (?, ?, ?, ?)`,
}
func openDB(t *testing.T) *sql.DB {
t.Helper()
path := filepath.ToSlash(filepath.Join(t.TempDir(), "test.db"))
db, err := sql.Open("sqlite", "file:"+path)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Close() })
return db
}
func fsWith(files map[string]string) fstest.MapFS {
out := fstest.MapFS{}
for name, body := range files {
out["migrations/"+name] = &fstest.MapFile{Data: []byte(body)}
}
return out
}
func TestLoadOrdersByVersionAndParsesNames(t *testing.T) {
ms, err := migrations.Load(fsWith(map[string]string{
"0002_devices.sql": "CREATE TABLE devices (id TEXT);",
"0001_server_settings.sql": "CREATE TABLE server_settings (k TEXT);",
"notes.txt": "ignored",
}), "migrations")
if err != nil {
t.Fatalf("Load: %v", err)
}
if len(ms) != 2 {
t.Fatalf("loaded %d migrations, want 2", len(ms))
}
if ms[0].Version != 1 || ms[0].Name != "server_settings" {
t.Errorf("first migration = %d/%s", ms[0].Version, ms[0].Name)
}
if ms[1].Version != 2 || ms[1].Name != "devices" {
t.Errorf("second migration = %d/%s", ms[1].Version, ms[1].Name)
}
if ms[0].Checksum == "" || ms[0].Checksum == ms[1].Checksum {
t.Error("each migration needs its own checksum")
}
}
func TestLoadRejectsBadFilenames(t *testing.T) {
for _, name := range []string{"nope.sql", "abc_x.sql", "0000_zero.sql"} {
if _, err := migrations.Load(fsWith(map[string]string{name: "SELECT 1;"}), "migrations"); err == nil {
t.Errorf("Load(%s) = nil error, want a complaint", name)
}
}
}
func TestRunIsIdempotent(t *testing.T) {
db := openDB(t)
fsys := fsWith(map[string]string{
"0001_server_settings.sql": "CREATE TABLE server_settings (k TEXT PRIMARY KEY);",
})
ms, err := migrations.Load(fsys, "migrations")
if err != nil {
t.Fatal(err)
}
n, err := migrations.Run(t.Context(), db, dialect, ms, nil)
if err != nil {
t.Fatalf("Run: %v", err)
}
if n != 1 {
t.Errorf("applied %d migrations, want 1", n)
}
n, err = migrations.Run(t.Context(), db, dialect, ms, nil)
if err != nil {
t.Fatalf("second Run: %v", err)
}
if n != 0 {
t.Errorf("second Run applied %d migrations, want 0", n)
}
}
func TestRunDetectsEditedMigration(t *testing.T) {
db := openDB(t)
before, err := migrations.Load(fsWith(map[string]string{
"0001_server_settings.sql": "CREATE TABLE server_settings (k TEXT PRIMARY KEY);",
}), "migrations")
if err != nil {
t.Fatal(err)
}
if _, err := migrations.Run(t.Context(), db, dialect, before, nil); err != nil {
t.Fatal(err)
}
after, err := migrations.Load(fsWith(map[string]string{
"0001_server_settings.sql": "CREATE TABLE server_settings (k TEXT PRIMARY KEY, v TEXT);",
}), "migrations")
if err != nil {
t.Fatal(err)
}
_, err = migrations.Run(t.Context(), db, dialect, after, nil)
if err == nil {
t.Fatal("expected an error for an edited migration")
}
if !strings.Contains(err.Error(), "has changed since it was applied") {
t.Errorf("unhelpful error: %v", err)
}
}
func TestRunRollsBackFailedMigration(t *testing.T) {
db := openDB(t)
ms, err := migrations.Load(fsWith(map[string]string{
"0001_ok.sql": "CREATE TABLE a (k TEXT);",
"0002_broken.sql": "CREATE TABLE b (k TEXT); THIS IS NOT SQL;",
}), "migrations")
if err != nil {
t.Fatal(err)
}
n, err := migrations.Run(t.Context(), db, dialect, ms, nil)
if err == nil {
t.Fatal("expected the broken migration to fail")
}
if n != 1 {
t.Errorf("applied %d migrations before failing, want 1", n)
}
// The failed migration must leave nothing behind.
var count int
if err := db.QueryRowContext(t.Context(),
`SELECT count(*) FROM sqlite_master WHERE type = 'table' AND name = 'b'`).Scan(&count); err != nil {
t.Fatal(err)
}
if count != 0 {
t.Error("table from the failed migration was not rolled back")
}
pending, err := migrations.Pending(t.Context(), db, dialect, ms)
if err != nil {
t.Fatal(err)
}
if len(pending) != 1 || pending[0].Version != 2 {
t.Errorf("pending = %v, want just migration 2", pending)
}
}
func TestCheckNotAhead(t *testing.T) {
applied := []migrations.Applied{{Version: 7, Name: "future"}}
ms := []migrations.Migration{{Version: 1, Name: "server_settings"}}
if err := migrations.CheckNotAhead(applied, ms); err == nil {
t.Fatal("expected a dirty-schema error")
}
if err := migrations.CheckNotAhead(nil, ms); err != nil {
t.Errorf("fresh database must be fine, got %v", err)
}
}
@@ -0,0 +1,79 @@
// Package pocketbase is the placeholder for the PocketBase storage driver.
//
// The plan (PLAN.md section 4) is an appliance deployment: one binary that
// also brings admin auth, a filesystem abstraction for media (local or S3)
// and realtime for the admin panel. The event DAG is a poor fit for
// PocketBase's record model, so the roomserver and syncapi tables will use
// its underlying dbx connection directly rather than its collection API.
//
// Until that work starts (Phase 10) the driver is compiled in, registered
// and fails loudly, so that `database.driver: pocketbase` is a clear error
// instead of an unknown-driver one, and so the interface stays honest:
// every method the other drivers implement exists here too.
package pocketbase
import (
"context"
"fmt"
"log/slog"
"github.com/dariuszjaniak/warp/server/internal/storage"
)
func init() {
storage.Register(storage.DriverPocketBase, Open)
}
// Database is the not-yet-implemented PocketBase driver.
type Database struct{}
// Open always fails with storage.ErrNotImplemented.
func Open(context.Context, storage.Config, *slog.Logger) (storage.Database, error) {
return nil, fmt.Errorf(
"pocketbase driver is not implemented yet (planned for Phase 10); use %q or %q: %w",
storage.DriverSQLite, storage.DriverPostgres, storage.ErrNotImplemented)
}
// Driver implements storage.Database.
func (*Database) Driver() string { return storage.DriverPocketBase }
// Ping implements storage.Database.
func (*Database) Ping(context.Context) error { return storage.ErrNotImplemented }
// Migrate implements storage.Database.
func (*Database) Migrate(context.Context) error { return storage.ErrNotImplemented }
// PendingMigrations implements storage.Database.
func (*Database) PendingMigrations(context.Context) ([]string, error) {
return nil, storage.ErrNotImplemented
}
// Settings implements storage.Database.
func (*Database) Settings() storage.SettingsStore { return notImplementedSettings{} }
// Close implements storage.Database.
func (*Database) Close() error { return nil }
// notImplementedSettings satisfies storage.SettingsStore by refusing every
// call, so a partially wired-up PocketBase deployment cannot silently lose
// an admin's federation switch.
type notImplementedSettings struct{}
func (notImplementedSettings) Get(context.Context, string) (string, bool, error) {
return "", false, storage.ErrNotImplemented
}
func (notImplementedSettings) GetAll(context.Context) (map[string]string, error) {
return nil, storage.ErrNotImplemented
}
func (notImplementedSettings) Set(context.Context, string, string) error {
return storage.ErrNotImplemented
}
func (notImplementedSettings) Delete(context.Context, string) error {
return storage.ErrNotImplemented
}
// Compile-time proof that the stub keeps up with the interface.
var _ storage.Database = (*Database)(nil)
@@ -0,0 +1,7 @@
-- Runtime-mutable server settings (federation and registration switches).
-- Values are stored as text; the settings service parses and caches them.
CREATE TABLE IF NOT EXISTS server_settings (
setting_key TEXT NOT NULL PRIMARY KEY,
setting_value TEXT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
@@ -0,0 +1,85 @@
// Package postgres implements the storage interfaces on PostgreSQL 15+ via
// pgx's database/sql driver. This is the production backend and the one
// Complement runs against.
package postgres
import (
"context"
"database/sql"
"embed"
"fmt"
"log/slog"
_ "github.com/jackc/pgx/v5/stdlib" // registers the "pgx" database/sql driver
"github.com/dariuszjaniak/warp/server/internal/storage"
"github.com/dariuszjaniak/warp/server/internal/storage/migrations"
"github.com/dariuszjaniak/warp/server/internal/storage/sqlstore"
)
//go:embed migrations/*.sql
var migrationFiles embed.FS
func init() {
storage.Register(storage.DriverPostgres, Open)
}
var dialect = migrations.Dialect{
Name: storage.DriverPostgres,
CreateTable: `CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
name TEXT NOT NULL,
checksum TEXT NOT NULL,
applied_at TIMESTAMPTZ NOT NULL
)`,
SelectApplied: `SELECT version, name, checksum, applied_at FROM schema_migrations ORDER BY version`,
InsertApplied: `INSERT INTO schema_migrations (version, name, checksum, applied_at) VALUES ($1, $2, $3, $4)`,
}
var settingsQueries = sqlstore.SettingsQueries{
Get: `SELECT setting_value FROM server_settings WHERE setting_key = $1`,
GetAll: `SELECT setting_key, setting_value FROM server_settings`,
Upsert: `INSERT INTO server_settings (setting_key, setting_value, updated_at) VALUES ($1, $2, $3)
ON CONFLICT (setting_key) DO UPDATE SET setting_value = excluded.setting_value, updated_at = excluded.updated_at`,
Delete: `DELETE FROM server_settings WHERE setting_key = $1`,
}
// Database is the PostgreSQL implementation of storage.Database.
type Database struct {
*sqlstore.Store
}
// Open connects to the DSN in cfg.ConnectionString, e.g.
// "postgres://warp:secret@localhost/warp?sslmode=disable".
func Open(ctx context.Context, cfg storage.Config, log *slog.Logger) (storage.Database, error) {
if cfg.ConnectionString == "" {
return nil, fmt.Errorf("postgres: connection_string must not be empty")
}
db, err := sql.Open("pgx", cfg.ConnectionString)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(cfg.MaxOpenConns)
db.SetMaxIdleConns(cfg.MaxIdleConns)
db.SetConnMaxLifetime(cfg.ConnMaxLifetime)
if err := db.PingContext(ctx); err != nil {
_ = db.Close()
return nil, fmt.Errorf("postgres: connect: %w", err)
}
store, err := sqlstore.New(sqlstore.Options{
DriverName: storage.DriverPostgres,
DB: db,
Dialect: dialect,
MigrationsFS: migrationFiles,
MigrationsDir: "migrations",
Settings: settingsQueries,
Logger: log,
})
if err != nil {
_ = db.Close()
return nil, err
}
return &Database{Store: store}, nil
}
@@ -0,0 +1,7 @@
-- Runtime-mutable server settings (federation and registration switches).
-- Values are stored as text; the settings service parses and caches them.
CREATE TABLE IF NOT EXISTS server_settings (
setting_key TEXT NOT NULL PRIMARY KEY,
setting_value TEXT NOT NULL,
updated_at TIMESTAMP NOT NULL
);
+136
View File
@@ -0,0 +1,136 @@
// Package sqlite implements the storage interfaces on modernc.org/sqlite,
// a pure-Go SQLite with no cgo. It is the development and single-user
// driver; PostgreSQL is the production one.
package sqlite
import (
"context"
"database/sql"
"embed"
"fmt"
"log/slog"
"net/url"
"os"
"path/filepath"
"strings"
_ "modernc.org/sqlite" // registers the "sqlite" database/sql driver
"github.com/dariuszjaniak/warp/server/internal/storage"
"github.com/dariuszjaniak/warp/server/internal/storage/migrations"
"github.com/dariuszjaniak/warp/server/internal/storage/sqlstore"
)
//go:embed migrations/*.sql
var migrationFiles embed.FS
func init() {
storage.Register(storage.DriverSQLite, Open)
}
// dialect is the migration bookkeeping for SQLite.
var dialect = migrations.Dialect{
Name: storage.DriverSQLite,
CreateTable: `CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
name TEXT NOT NULL,
checksum TEXT NOT NULL,
applied_at TIMESTAMP NOT NULL
)`,
SelectApplied: `SELECT version, name, checksum, applied_at FROM schema_migrations ORDER BY version`,
InsertApplied: `INSERT INTO schema_migrations (version, name, checksum, applied_at) VALUES (?, ?, ?, ?)`,
}
var settingsQueries = sqlstore.SettingsQueries{
Get: `SELECT setting_value FROM server_settings WHERE setting_key = ?`,
GetAll: `SELECT setting_key, setting_value FROM server_settings`,
Upsert: `INSERT INTO server_settings (setting_key, setting_value, updated_at) VALUES (?, ?, ?)
ON CONFLICT (setting_key) DO UPDATE SET setting_value = excluded.setting_value, updated_at = excluded.updated_at`,
Delete: `DELETE FROM server_settings WHERE setting_key = ?`,
}
// Database is the SQLite implementation of storage.Database.
type Database struct {
*sqlstore.Store
}
// Open connects to the SQLite file named by cfg.ConnectionString.
//
// The connection string is a modernc.org/sqlite DSN: "file:warp.db" with
// optional "_pragma=..." parameters. The parent directory is created if it
// does not exist, because a missing directory is the single most common
// first-run failure.
//
// Writes are serialised onto one connection: SQLite allows a single writer,
// and letting database/sql open several write connections turns that into
// SQLITE_BUSY under concurrency rather than a queue.
func Open(ctx context.Context, cfg storage.Config, log *slog.Logger) (storage.Database, error) {
dsn := cfg.ConnectionString
if dsn == "" {
return nil, fmt.Errorf("sqlite: connection_string must not be empty")
}
if err := ensureParentDir(dsn); err != nil {
return nil, err
}
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(1)
db.SetMaxIdleConns(1)
db.SetConnMaxLifetime(cfg.ConnMaxLifetime)
if err := db.PingContext(ctx); err != nil {
_ = db.Close()
return nil, fmt.Errorf("sqlite: connect: %w", err)
}
store, err := sqlstore.New(sqlstore.Options{
DriverName: storage.DriverSQLite,
DB: db,
Dialect: dialect,
MigrationsFS: migrationFiles,
MigrationsDir: "migrations",
Settings: settingsQueries,
Logger: log,
})
if err != nil {
_ = db.Close()
return nil, err
}
return &Database{Store: store}, nil
}
// ensureParentDir creates the directory holding the database file. In-memory
// databases (":memory:" or "mode=memory") have no directory.
func ensureParentDir(dsn string) error {
path := filePathFromDSN(dsn)
if path == "" {
return nil
}
dir := filepath.Dir(path)
if dir == "" || dir == "." {
return nil
}
if err := os.MkdirAll(dir, 0o750); err != nil {
return fmt.Errorf("sqlite: create database directory %s: %w", dir, err)
}
return nil
}
// filePathFromDSN extracts the filesystem path from a SQLite DSN, or "" for
// an in-memory database.
func filePathFromDSN(dsn string) string {
path := strings.TrimPrefix(dsn, "file:")
if i := strings.IndexAny(path, "?"); i >= 0 {
path = path[:i]
}
if unescaped, err := url.PathUnescape(path); err == nil {
path = unescaped
}
if path == "" || path == ":memory:" || strings.Contains(dsn, "mode=memory") {
return ""
}
return path
}
@@ -0,0 +1,89 @@
package sqlstore
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
)
// SettingsQueries are the four statements the settings store needs. Each
// driver writes them in its own placeholder style and upsert syntax.
type SettingsQueries struct {
// Get binds (key) and selects setting_value.
Get string
// GetAll selects (setting_key, setting_value).
GetAll string
// Upsert binds (key, value, updated_at) and overwrites on conflict.
Upsert string
// Delete binds (key).
Delete string
}
// Settings is the server_settings key/value store. It is the persistence
// behind the runtime federation and registration switches; the in-memory
// cache lives one layer up, in the settings service.
type Settings struct {
db *sql.DB
q SettingsQueries
}
// NewSettings builds a settings store.
func NewSettings(db *sql.DB, q SettingsQueries) *Settings {
return &Settings{db: db, q: q}
}
// Get returns the stored value for key; the boolean reports presence.
func (s *Settings) Get(ctx context.Context, key string) (string, bool, error) {
var value string
err := s.db.QueryRowContext(ctx, s.q.Get, key).Scan(&value)
switch {
case errors.Is(err, sql.ErrNoRows):
return "", false, nil
case err != nil:
return "", false, fmt.Errorf("get setting %q: %w", key, err)
}
return value, true, nil
}
// GetAll returns every stored setting.
func (s *Settings) GetAll(ctx context.Context) (map[string]string, error) {
rows, err := s.db.QueryContext(ctx, s.q.GetAll)
if err != nil {
return nil, fmt.Errorf("list settings: %w", err)
}
defer rows.Close()
out := map[string]string{}
for rows.Next() {
var k, v string
if err := rows.Scan(&k, &v); err != nil {
return nil, fmt.Errorf("scan setting: %w", err)
}
out[k] = v
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("list settings: %w", err)
}
return out, nil
}
// Set writes key, overwriting any previous value.
func (s *Settings) Set(ctx context.Context, key, value string) error {
if _, err := s.db.ExecContext(ctx, s.q.Upsert, key, value, time.Now().UTC()); err != nil {
return fmt.Errorf("set setting %q: %w", key, err)
}
return nil
}
// Delete removes key. Removing an absent key is not an error.
func (s *Settings) Delete(ctx context.Context, key string) error {
if _, err := s.db.ExecContext(ctx, s.q.Delete, key); err != nil {
return fmt.Errorf("delete setting %q: %w", key, err)
}
return nil
}
@@ -0,0 +1,124 @@
// Package sqlstore holds the parts of a storage driver that are identical
// for every database/sql backend: the migration plumbing and the
// server_settings table. The sqlite and postgres drivers differ only in
// their DSN handling, placeholder style and upsert syntax, all of which
// they pass in here.
package sqlstore
import (
"context"
"database/sql"
"errors"
"fmt"
"io/fs"
"log/slog"
"github.com/dariuszjaniak/warp/server/internal/storage"
"github.com/dariuszjaniak/warp/server/internal/storage/migrations"
)
// Options describe one database/sql backend.
type Options struct {
// DriverName is the Warp driver name, e.g. "sqlite".
DriverName string
// DB is the already-opened pool.
DB *sql.DB
// Dialect supplies the migration bookkeeping statements.
Dialect migrations.Dialect
// MigrationsFS and MigrationsDir locate the embedded .sql files.
MigrationsFS fs.FS
MigrationsDir string
// Settings holds the statements for the server_settings table.
Settings SettingsQueries
// Logger is used for migration progress.
Logger *slog.Logger
}
// Store is the shared implementation of storage.Database. Drivers embed it
// and add their own component stores as those are built.
type Store struct {
opts Options
db *sql.DB
log *slog.Logger
settings *Settings
}
// New builds a Store. It does not touch the database.
func New(opts Options) (*Store, error) {
if opts.DB == nil {
return nil, errors.New("sqlstore: DB must not be nil")
}
if opts.Logger == nil {
opts.Logger = slog.Default()
}
return &Store{
opts: opts,
db: opts.DB,
log: opts.Logger,
settings: NewSettings(opts.DB, opts.Settings),
}, nil
}
// DB exposes the pool for driver-specific code and tests.
func (s *Store) DB() *sql.DB { return s.db }
// Driver implements storage.Database.
func (s *Store) Driver() string { return s.opts.DriverName }
// Ping implements storage.Database.
func (s *Store) Ping(ctx context.Context) error { return s.db.PingContext(ctx) }
// Close implements storage.Database.
func (s *Store) Close() error { return s.db.Close() }
// Settings implements storage.Database.
func (s *Store) Settings() storage.SettingsStore { return s.settings }
// Migrate implements storage.Database.
func (s *Store) Migrate(ctx context.Context) error {
ms, err := s.migrations()
if err != nil {
return err
}
applied, err := migrations.Status(ctx, s.db, s.opts.Dialect)
if err != nil {
return err
}
if err := migrations.CheckNotAhead(applied, ms); err != nil {
return err
}
n, err := migrations.Run(ctx, s.db, s.opts.Dialect, ms, s.log)
if err != nil {
return err
}
if n == 0 {
s.log.Debug("database schema is up to date", "migrations", len(ms))
}
return nil
}
// PendingMigrations implements storage.Database.
func (s *Store) PendingMigrations(ctx context.Context) ([]string, error) {
ms, err := s.migrations()
if err != nil {
return nil, err
}
pending, err := migrations.Pending(ctx, s.db, s.opts.Dialect, ms)
if err != nil {
return nil, err
}
out := make([]string, 0, len(pending))
for _, m := range pending {
out = append(out, fmt.Sprintf("%04d_%s", m.Version, m.Name))
}
return out, nil
}
func (s *Store) migrations() ([]migrations.Migration, error) {
return migrations.Load(s.opts.MigrationsFS, s.opts.MigrationsDir)
}
+127
View File
@@ -0,0 +1,127 @@
// Package storage defines the interfaces every Warp storage driver
// implements, plus the registry that turns a config into a Database.
//
// Drivers live in sub-packages (sqlite, postgres, pocketbase) and register
// themselves from an init function, the database/sql way. Nothing in this
// package imports a driver, so components can depend on the interfaces
// without pulling in every database engine.
package storage
import (
"context"
"errors"
"fmt"
"log/slog"
"maps"
"slices"
"sync"
"time"
)
// Driver names. These are the values accepted by database.driver in the
// config file.
const (
DriverSQLite = "sqlite"
DriverPostgres = "postgres"
DriverPocketBase = "pocketbase"
)
// ErrNotImplemented is returned by a driver that is compiled in but not
// finished — currently PocketBase.
var ErrNotImplemented = errors.New("not implemented by this storage driver")
// ErrNoSuchSetting is returned by SettingsStore.Get for an unknown key.
var ErrNoSuchSetting = errors.New("no such setting")
// Config is the subset of the file configuration a driver needs.
type Config struct {
Driver string
ConnectionString string
MaxOpenConns int
MaxIdleConns int
ConnMaxLifetime time.Duration
}
// Database is the handle every component gets. Component-specific stores
// (userapi, roomserver, syncapi...) hang off it as they are implemented.
type Database interface {
// Driver reports which driver this is, for logs and admin output.
Driver() string
// Ping checks the connection is alive; used by /healthz.
Ping(ctx context.Context) error
// Migrate applies pending migrations.
Migrate(ctx context.Context) error
// PendingMigrations lists migrations that Migrate would apply.
PendingMigrations(ctx context.Context) ([]string, error)
// Settings is the server_settings key/value store backing the runtime
// settings service (federation and registration switches).
Settings() SettingsStore
// Close releases the connection pool.
Close() error
}
// SettingsStore persists the handful of settings that are mutable at
// runtime through the admin API.
type SettingsStore interface {
// Get returns the raw value for key. The boolean reports presence.
Get(ctx context.Context, key string) (string, bool, error)
// GetAll returns every setting.
GetAll(ctx context.Context) (map[string]string, error)
// Set writes key, overwriting any previous value.
Set(ctx context.Context, key, value string) error
// Delete removes key, reverting it to its configured default. Deleting
// a key that is not there is not an error.
Delete(ctx context.Context, key string) error
}
// OpenFunc opens a database for a driver.
type OpenFunc func(ctx context.Context, cfg Config, log *slog.Logger) (Database, error)
var (
registryMu sync.RWMutex
registry = map[string]OpenFunc{}
)
// Register makes a driver available to Open. It panics on a duplicate
// registration, which can only be a programming error.
func Register(name string, open OpenFunc) {
registryMu.Lock()
defer registryMu.Unlock()
if _, dup := registry[name]; dup {
panic("storage: driver " + name + " registered twice")
}
registry[name] = open
}
// Drivers lists the registered driver names, sorted.
func Drivers() []string {
registryMu.RLock()
defer registryMu.RUnlock()
return slices.Sorted(maps.Keys(registry))
}
// Open connects using the driver named in cfg.
func Open(ctx context.Context, cfg Config, log *slog.Logger) (Database, error) {
registryMu.RLock()
open, ok := registry[cfg.Driver]
registryMu.RUnlock()
if !ok {
return nil, fmt.Errorf("storage: unknown driver %q (compiled in: %v)", cfg.Driver, Drivers())
}
if log == nil {
log = slog.Default()
}
db, err := open(ctx, cfg, log.With("driver", cfg.Driver))
if err != nil {
return nil, fmt.Errorf("storage: open %s: %w", cfg.Driver, err)
}
return db, nil
}
+149
View File
@@ -0,0 +1,149 @@
package storage_test
import (
"errors"
"os"
"path/filepath"
"slices"
"testing"
"github.com/dariuszjaniak/warp/server/internal/storage"
_ "github.com/dariuszjaniak/warp/server/internal/storage/drivers"
)
// openTestDB opens each driver that can run without external services. The
// postgres driver joins in when WARP_TEST_POSTGRES_DSN is set, which is how
// CI runs the same tests against both engines.
func openTestDB(t *testing.T, driver string) storage.Database {
t.Helper()
cfg := storage.Config{Driver: driver, MaxOpenConns: 4, MaxIdleConns: 2}
switch driver {
case storage.DriverSQLite:
path := filepath.ToSlash(filepath.Join(t.TempDir(), "warp.db"))
cfg.ConnectionString = "file:" + path + "?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)"
case storage.DriverPostgres:
dsn := os.Getenv("WARP_TEST_POSTGRES_DSN")
if dsn == "" {
t.Skip("set WARP_TEST_POSTGRES_DSN to test the postgres driver")
}
cfg.ConnectionString = dsn
default:
t.Fatalf("no test setup for driver %q", driver)
}
db, err := storage.Open(t.Context(), cfg, nil)
if err != nil {
t.Fatalf("Open(%s): %v", driver, err)
}
t.Cleanup(func() { _ = db.Close() })
return db
}
// testDrivers are run by every storage test.
var testDrivers = []string{storage.DriverSQLite, storage.DriverPostgres}
func TestDriversRegistered(t *testing.T) {
got := storage.Drivers()
for _, want := range []string{storage.DriverSQLite, storage.DriverPostgres, storage.DriverPocketBase} {
if !slices.Contains(got, want) {
t.Errorf("driver %q is not registered (have %v)", want, got)
}
}
}
func TestOpenUnknownDriver(t *testing.T) {
_, err := storage.Open(t.Context(), storage.Config{Driver: "mysql"}, nil)
if err == nil {
t.Fatal("expected an error for an unknown driver")
}
}
func TestPocketBaseIsNotImplemented(t *testing.T) {
_, err := storage.Open(t.Context(), storage.Config{
Driver: storage.DriverPocketBase,
ConnectionString: "./pb_data",
}, nil)
if !errors.Is(err, storage.ErrNotImplemented) {
t.Fatalf("expected ErrNotImplemented, got %v", err)
}
}
func TestMigrateAndSettings(t *testing.T) {
for _, driver := range testDrivers {
t.Run(driver, func(t *testing.T) {
ctx := t.Context()
db := openTestDB(t, driver)
pending, err := db.PendingMigrations(ctx)
if err != nil {
t.Fatalf("PendingMigrations: %v", err)
}
if len(pending) == 0 {
t.Skip("database already migrated by a previous run")
}
if err := db.Migrate(ctx); err != nil {
t.Fatalf("Migrate: %v", err)
}
// Migrating twice must be a no-op, not an error: it runs on
// every boot.
if err := db.Migrate(ctx); err != nil {
t.Fatalf("second Migrate: %v", err)
}
pending, err = db.PendingMigrations(ctx)
if err != nil {
t.Fatalf("PendingMigrations: %v", err)
}
if len(pending) != 0 {
t.Errorf("after Migrate, still pending: %v", pending)
}
settings := db.Settings()
if _, found, err := settings.Get(ctx, "federation.enabled"); err != nil {
t.Fatalf("Get: %v", err)
} else if found {
t.Fatal("expected no stored value on a fresh database")
}
if err := settings.Set(ctx, "federation.enabled", "true"); err != nil {
t.Fatalf("Set: %v", err)
}
if err := settings.Set(ctx, "federation.enabled", "false"); err != nil {
t.Fatalf("Set (overwrite): %v", err)
}
value, found, err := settings.Get(ctx, "federation.enabled")
if err != nil || !found {
t.Fatalf("Get after Set: value=%q found=%v err=%v", value, found, err)
}
if value != "false" {
t.Errorf("value = %q, want %q (the upsert must overwrite)", value, "false")
}
all, err := settings.GetAll(ctx)
if err != nil {
t.Fatalf("GetAll: %v", err)
}
if all["federation.enabled"] != "false" {
t.Errorf("GetAll = %v", all)
}
if err := settings.Delete(ctx, "federation.enabled"); err != nil {
t.Fatalf("Delete: %v", err)
}
if err := settings.Delete(ctx, "federation.enabled"); err != nil {
t.Errorf("deleting an absent key must not fail: %v", err)
}
if _, found, _ := settings.Get(ctx, "federation.enabled"); found {
t.Error("value survived Delete")
}
if err := db.Ping(ctx); err != nil {
t.Errorf("Ping: %v", err)
}
if db.Driver() != driver {
t.Errorf("Driver() = %q, want %q", db.Driver(), driver)
}
})
}
}