288 lines
7.8 KiB
Go
288 lines
7.8 KiB
Go
package storage
|
|
|
|
import (
|
|
"fmt"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
|
|
"github.com/glebarez/sqlite"
|
|
"gorm.io/gorm"
|
|
|
|
"gpt-load/internal/platform/config"
|
|
"gpt-load/internal/platform/securefile"
|
|
)
|
|
|
|
const sqliteBusyTimeoutMS = 5000
|
|
|
|
type sqliteTarget struct {
|
|
fileBacked bool
|
|
databasePath string
|
|
directory string
|
|
}
|
|
|
|
var hardenManagedFileIfExists = securefile.HardenManagedFileIfExists
|
|
|
|
func openSQLite(
|
|
dsn string,
|
|
source config.DatabaseSource,
|
|
pool config.DatabasePoolConfig,
|
|
) (*gorm.DB, error) {
|
|
target, err := parseSQLiteTarget(dsn)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := rejectExistingDatabaseWithoutMigrationLedger(target); err != nil {
|
|
return nil, err
|
|
}
|
|
switch source {
|
|
case config.DatabaseSourceManaged:
|
|
if target.fileBacked {
|
|
if err := hardenSQLiteRecoverySet(target); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
case config.DatabaseSourceExternal:
|
|
default:
|
|
return nil, fmt.Errorf("open SQLite database: unsupported database source")
|
|
}
|
|
|
|
runtimeDSN, err := withSQLiteRuntimeOptions(dsn, target.fileBacked)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
dialector, err := newDatabaseDialector(config.DatabaseConfig{
|
|
Driver: config.DatabaseDriverSQLite,
|
|
DSN: runtimeDSN,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
db, err := openDatabase(config.DatabaseDriverSQLite, dialector, pool)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
sqlDB, err := db.DB()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get SQLite connection pool: %w", err)
|
|
}
|
|
if err := verifySQLiteRuntime(db, target.fileBacked); err != nil {
|
|
_ = sqlDB.Close()
|
|
return nil, err
|
|
}
|
|
if source == config.DatabaseSourceManaged && target.fileBacked {
|
|
if err := hardenSQLiteRecoverySet(target); err != nil {
|
|
_ = sqlDB.Close()
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
return db, nil
|
|
}
|
|
|
|
// rejectExistingDatabaseWithoutMigrationLedger inspects the first-install
|
|
// sentinel with SQLite read-only mode before runtime pragmas can change
|
|
// journal state. External tables are intentionally not enumerated.
|
|
func rejectExistingDatabaseWithoutMigrationLedger(target sqliteTarget) error {
|
|
if !target.fileBacked {
|
|
return nil
|
|
}
|
|
if _, err := os.Lstat(target.databasePath); os.IsNotExist(err) {
|
|
return nil
|
|
} else if err != nil {
|
|
return fmt.Errorf("inspect SQLite database before migration: %w", err)
|
|
}
|
|
absolutePath, err := filepath.Abs(target.databasePath)
|
|
if err != nil {
|
|
return fmt.Errorf("resolve SQLite database path before migration: %w", err)
|
|
}
|
|
uriPath := filepath.ToSlash(absolutePath)
|
|
if runtime.GOOS == "windows" && len(uriPath) >= 2 && uriPath[1] == ':' {
|
|
// A Windows drive path must be an absolute file-URI path. Without
|
|
// the leading slash, net/url treats the drive letter as the URI host.
|
|
uriPath = "/" + uriPath
|
|
}
|
|
readOnlyDSN := (&url.URL{
|
|
Scheme: "file",
|
|
Path: uriPath,
|
|
RawQuery: "mode=ro&immutable=1",
|
|
}).String()
|
|
db, err := gorm.Open(sqlite.Open(readOnlyDSN), &gorm.Config{Logger: databaseLogger})
|
|
if err != nil {
|
|
return fmt.Errorf("inspect existing SQLite database before migration: %w", err)
|
|
}
|
|
sqlDB, err := db.DB()
|
|
if err != nil {
|
|
return fmt.Errorf("inspect existing SQLite database connection: %w", err)
|
|
}
|
|
defer func() { _ = sqlDB.Close() }()
|
|
|
|
if db.Migrator().HasTable(migrationLedgerTable) {
|
|
return nil
|
|
}
|
|
if db.Migrator().HasTable(initialSchemaSentinelTable) {
|
|
return fmt.Errorf(
|
|
"open SQLite database: %s table already exists",
|
|
initialSchemaSentinelTable,
|
|
)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func withSQLiteRuntimeOptions(dsn string, fileBacked bool) (string, error) {
|
|
base, rawQuery, _ := strings.Cut(dsn, "?")
|
|
query, err := url.ParseQuery(rawQuery)
|
|
if err != nil {
|
|
return "", fmt.Errorf("open SQLite database: invalid DSN query: %w", err)
|
|
}
|
|
query.Set("_txlock", "immediate")
|
|
|
|
pragmas := make([]string, 0, len(query["_pragma"])+3)
|
|
for _, pragma := range query["_pragma"] {
|
|
name := strings.ToLower(strings.TrimSpace(pragma))
|
|
if index := strings.IndexAny(name, "(="); index >= 0 {
|
|
name = strings.TrimSpace(name[:index])
|
|
}
|
|
switch name {
|
|
case "foreign_keys", "busy_timeout", "journal_mode":
|
|
continue
|
|
default:
|
|
pragmas = append(pragmas, pragma)
|
|
}
|
|
}
|
|
pragmas = append(pragmas,
|
|
"foreign_keys(1)",
|
|
fmt.Sprintf("busy_timeout(%d)", sqliteBusyTimeoutMS),
|
|
)
|
|
if fileBacked {
|
|
pragmas = append(pragmas, "journal_mode(WAL)")
|
|
}
|
|
query["_pragma"] = pragmas
|
|
return base + "?" + query.Encode(), nil
|
|
}
|
|
|
|
func isSQLiteMemoryDSN(dsn string) bool {
|
|
base, _, _ := strings.Cut(dsn, "?")
|
|
if base == ":memory:" {
|
|
return true
|
|
}
|
|
if !strings.HasPrefix(dsn, "file:") {
|
|
return false
|
|
}
|
|
parsed, err := url.Parse(dsn)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
if strings.EqualFold(parsed.Query().Get("mode"), "memory") {
|
|
return true
|
|
}
|
|
return parsed.Path == ":memory:" || parsed.Opaque == ":memory:"
|
|
}
|
|
|
|
func verifySQLiteRuntime(db *gorm.DB, fileBacked bool) error {
|
|
var foreignKeys, busyTimeout int
|
|
if err := db.Raw("PRAGMA foreign_keys").Scan(&foreignKeys).Error; err != nil {
|
|
return fmt.Errorf("verify SQLite foreign_keys: %w", err)
|
|
}
|
|
if foreignKeys != 1 {
|
|
return fmt.Errorf("verify SQLite foreign_keys: got %d, want 1", foreignKeys)
|
|
}
|
|
if err := db.Raw("PRAGMA busy_timeout").Scan(&busyTimeout).Error; err != nil {
|
|
return fmt.Errorf("verify SQLite busy_timeout: %w", err)
|
|
}
|
|
if busyTimeout != sqliteBusyTimeoutMS {
|
|
return fmt.Errorf("verify SQLite busy_timeout: got %d, want %d", busyTimeout, sqliteBusyTimeoutMS)
|
|
}
|
|
if fileBacked {
|
|
var journalMode string
|
|
if err := db.Raw("PRAGMA journal_mode").Scan(&journalMode).Error; err != nil {
|
|
return fmt.Errorf("verify SQLite journal_mode: %w", err)
|
|
}
|
|
if !strings.EqualFold(journalMode, "wal") {
|
|
return fmt.Errorf("verify SQLite journal_mode: got %q, want wal", journalMode)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func parseSQLiteTarget(dsn string) (sqliteTarget, error) {
|
|
if isSQLiteMemoryDSN(dsn) {
|
|
return sqliteTarget{}, nil
|
|
}
|
|
if err := validateSQLiteDSN(dsn); err != nil {
|
|
return sqliteTarget{}, err
|
|
}
|
|
|
|
databasePath, _, _ := strings.Cut(dsn, "?")
|
|
parsed, err := url.Parse(dsn)
|
|
if err != nil {
|
|
return sqliteTarget{}, fmt.Errorf("open SQLite database: invalid DSN: %w", err)
|
|
}
|
|
if strings.HasPrefix(dsn, "file:") {
|
|
databasePath = parsed.Path
|
|
if databasePath == "" {
|
|
databasePath = parsed.Opaque
|
|
databasePath, err = url.PathUnescape(databasePath)
|
|
if err != nil {
|
|
return sqliteTarget{}, fmt.Errorf("open SQLite database: invalid file URI: %w", err)
|
|
}
|
|
}
|
|
if runtime.GOOS == "windows" &&
|
|
len(databasePath) >= 3 &&
|
|
databasePath[0] == '/' &&
|
|
databasePath[2] == ':' &&
|
|
(databasePath[1] >= 'A' && databasePath[1] <= 'Z' ||
|
|
databasePath[1] >= 'a' && databasePath[1] <= 'z') {
|
|
databasePath = databasePath[1:]
|
|
}
|
|
databasePath = filepath.FromSlash(databasePath)
|
|
}
|
|
if databasePath == "" {
|
|
return sqliteTarget{}, fmt.Errorf("open SQLite database: file path is empty")
|
|
}
|
|
|
|
directory := filepath.Dir(databasePath)
|
|
return sqliteTarget{
|
|
fileBacked: true,
|
|
databasePath: databasePath,
|
|
directory: directory,
|
|
}, nil
|
|
}
|
|
|
|
func hardenSQLiteRecoverySet(target sqliteTarget) error {
|
|
for _, path := range []string{
|
|
target.databasePath,
|
|
target.databasePath + "-wal",
|
|
target.databasePath + "-shm",
|
|
} {
|
|
if err := hardenManagedFileIfExists(path); err != nil {
|
|
return fmt.Errorf("secure managed SQLite recovery set: %w", err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateSQLiteDSN(dsn string) error {
|
|
if dsn == "" {
|
|
return fmt.Errorf("open SQLite database: DSN is empty")
|
|
}
|
|
if dsn == ":memory:" || filepath.VolumeName(dsn) != "" {
|
|
return nil
|
|
}
|
|
|
|
parsed, err := url.Parse(dsn)
|
|
if err != nil {
|
|
return fmt.Errorf("open SQLite database: invalid DSN: %w", err)
|
|
}
|
|
if parsed.Scheme == "file" && !strings.HasPrefix(dsn, "file:") {
|
|
return fmt.Errorf("open SQLite database: unsupported non-canonical DSN scheme")
|
|
}
|
|
if parsed.Scheme != "" && parsed.Scheme != "file" {
|
|
return fmt.Errorf("open SQLite database: unsupported DSN scheme %q", parsed.Scheme)
|
|
}
|
|
return nil
|
|
}
|