192 lines
5.0 KiB
Go
192 lines
5.0 KiB
Go
// Package store owns RVBox server persistence and migrations.
|
|
package store
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"sync"
|
|
"time"
|
|
|
|
_ "modernc.org/sqlite"
|
|
)
|
|
|
|
var (
|
|
ErrUnsafeDataDirectory = errors.New("unsafe server data directory")
|
|
ErrAlreadyOpen = errors.New("server data directory is already locked")
|
|
)
|
|
|
|
type Options struct {
|
|
DataDir string
|
|
BusyTimeout time.Duration
|
|
SegmentTargetSize uint64
|
|
QuotaLimits QuotaLimits
|
|
FreeSpaceProbe FreeSpaceProbe
|
|
FaultInjector FaultInjector
|
|
}
|
|
|
|
type Store struct {
|
|
db *sql.DB
|
|
unlock func() error
|
|
dataDir string
|
|
segmentTarget uint64
|
|
quotaLimits QuotaLimits
|
|
freeSpaceProbe FreeSpaceProbe
|
|
faultInjector FaultInjector
|
|
activeSegments map[[16]byte]*activeSegment
|
|
mu sync.Mutex
|
|
writeMu sync.Mutex
|
|
}
|
|
|
|
func Open(ctx context.Context, options Options) (*Store, error) {
|
|
if !filepath.IsAbs(options.DataDir) || filepath.Clean(options.DataDir) == string(filepath.Separator) {
|
|
return nil, ErrUnsafeDataDirectory
|
|
}
|
|
if options.BusyTimeout <= 0 {
|
|
return nil, fmt.Errorf("busy timeout must be positive")
|
|
}
|
|
if options.SegmentTargetSize == 0 {
|
|
options.SegmentTargetSize = 256 << 10
|
|
}
|
|
if options.SegmentTargetSize > DefaultSegmentLimit {
|
|
return nil, fmt.Errorf("segment target exceeds hard record limit")
|
|
}
|
|
if options.QuotaLimits == (QuotaLimits{}) {
|
|
options.QuotaLimits = DefaultQuotaLimits()
|
|
}
|
|
if err := options.QuotaLimits.Validate(); err != nil {
|
|
return nil, err
|
|
}
|
|
if options.FreeSpaceProbe == nil {
|
|
options.FreeSpaceProbe = FreeSpaceProbeFunc(filesystemFreeBytes)
|
|
}
|
|
if err := ensurePrivateDirectory(options.DataDir); err != nil {
|
|
return nil, err
|
|
}
|
|
for _, child := range []string{"segments", "audit", "deleting"} {
|
|
if err := ensurePrivateDirectory(filepath.Join(options.DataDir, child)); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
unlock, err := acquireInstanceLock(filepath.Join(options.DataDir, "server.lock"))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
databasePath := filepath.Join(options.DataDir, "rvbox.db")
|
|
if err := ensurePrivateFile(databasePath); err != nil {
|
|
_ = unlock()
|
|
return nil, err
|
|
}
|
|
query := url.Values{}
|
|
query.Add("_defensive", "1")
|
|
query.Add("_pragma", "journal_mode(WAL)")
|
|
query.Add("_pragma", "foreign_keys(ON)")
|
|
query.Add("_pragma", "synchronous(FULL)")
|
|
query.Add("_pragma", "busy_timeout("+strconv.FormatInt(options.BusyTimeout.Milliseconds(), 10)+")")
|
|
databaseURL := &url.URL{Scheme: "file", Path: filepath.ToSlash(databasePath)}
|
|
databaseURL.RawQuery = query.Encode()
|
|
dsn := databaseURL.String()
|
|
db, err := sql.Open("sqlite", dsn)
|
|
if err != nil {
|
|
_ = unlock()
|
|
return nil, err
|
|
}
|
|
db.SetMaxOpenConns(1)
|
|
db.SetMaxIdleConns(1)
|
|
store := &Store{
|
|
db: db, unlock: unlock, dataDir: options.DataDir, segmentTarget: options.SegmentTargetSize,
|
|
quotaLimits: options.QuotaLimits, freeSpaceProbe: options.FreeSpaceProbe,
|
|
faultInjector: options.FaultInjector, activeSegments: make(map[[16]byte]*activeSegment),
|
|
}
|
|
if err := db.PingContext(ctx); err != nil {
|
|
_ = store.Close()
|
|
return nil, fmt.Errorf("open SQLite: %w", err)
|
|
}
|
|
if err := applyMigrations(ctx, db); err != nil {
|
|
_ = store.Close()
|
|
return nil, err
|
|
}
|
|
return store, nil
|
|
}
|
|
|
|
func (store *Store) DB() *sql.DB {
|
|
store.mu.Lock()
|
|
defer store.mu.Unlock()
|
|
return store.db
|
|
}
|
|
|
|
func (store *Store) Close() error {
|
|
if store == nil {
|
|
return nil
|
|
}
|
|
store.writeMu.Lock()
|
|
defer store.writeMu.Unlock()
|
|
store.mu.Lock()
|
|
defer store.mu.Unlock()
|
|
var result error
|
|
for owner, active := range store.activeSegments {
|
|
if err := active.segment.Close(); result == nil && err != nil {
|
|
result = err
|
|
}
|
|
delete(store.activeSegments, owner)
|
|
}
|
|
if store.db != nil {
|
|
result = store.db.Close()
|
|
store.db = nil
|
|
}
|
|
if store.unlock != nil {
|
|
if err := store.unlock(); result == nil {
|
|
result = err
|
|
}
|
|
store.unlock = nil
|
|
}
|
|
return result
|
|
}
|
|
|
|
func ensurePrivateFile(path string) error {
|
|
file, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_RDWR, 0o600)
|
|
if err == nil {
|
|
return file.Close()
|
|
}
|
|
if !errors.Is(err, os.ErrExist) {
|
|
return err
|
|
}
|
|
info, err := os.Lstat(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
|
|
return fmt.Errorf("%w: %s is not a regular file", ErrUnsafeDataDirectory, path)
|
|
}
|
|
if info.Mode().Perm()&0o077 != 0 {
|
|
return fmt.Errorf("%w: %s permissions %04o expose private state", ErrUnsafeDataDirectory, path, info.Mode().Perm())
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func ensurePrivateDirectory(path string) error {
|
|
info, err := os.Lstat(path)
|
|
if os.IsNotExist(err) {
|
|
if err := os.MkdirAll(path, 0o700); err != nil {
|
|
return err
|
|
}
|
|
info, err = os.Lstat(path)
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
|
|
return fmt.Errorf("%w: %s is not a real directory", ErrUnsafeDataDirectory, path)
|
|
}
|
|
if info.Mode().Perm()&0o077 != 0 {
|
|
return fmt.Errorf("%w: %s permissions %04o expose private state", ErrUnsafeDataDirectory, path, info.Mode().Perm())
|
|
}
|
|
return nil
|
|
}
|