Skip to content

Commit

Permalink
Automatic pre-migration DB backups
Browse files Browse the repository at this point in the history
  • Loading branch information
evan-goode committed Dec 21, 2024
1 parent 7d510dd commit 9b79be9
Show file tree
Hide file tree
Showing 6 changed files with 82 additions and 17 deletions.
2 changes: 1 addition & 1 deletion common.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ type UserError struct {
Err error
}

func (e *UserError) Error() string {
func (e UserError) Error() string {
return e.Err.Error()
}

Expand Down
2 changes: 2 additions & 0 deletions config.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ type Config struct {
ListenAddress string
LogRequests bool
MinPasswordLength int
PreMigrationBackups bool
RateLimit rateLimitConfig
RegistrationExistingPlayer registrationExistingPlayerConfig
RegistrationNewPlayer registrationNewPlayerConfig
Expand Down Expand Up @@ -128,6 +129,7 @@ func DefaultConfig() Config {
LogRequests: true,
MinPasswordLength: 8,
OfflineSkins: true,
PreMigrationBackups: true,
RateLimit: defaultRateLimitConfig,
RegistrationExistingPlayer: registrationExistingPlayerConfig{
Allow: false,
Expand Down
39 changes: 34 additions & 5 deletions db.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@ import (
"errors"
"fmt"
mapset "github.com/deckarep/golang-set/v2"
"github.com/samber/mo"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"log"
"os"
"path"
"path/filepath"
"time"
)

Expand Down Expand Up @@ -47,6 +49,14 @@ func IsErrorPlayerNameTakenByUsername(err error) bool {
return err.Error() == PLAYER_NAME_TAKEN_BY_USERNAME_ERROR
}

type BackwardsMigrationError struct {
Err error
}

func (e BackwardsMigrationError) Error() string {
return e.Err.Error()
}

type V1User struct {
IsAdmin bool
IsLocked bool
Expand Down Expand Up @@ -137,7 +147,7 @@ func OpenDB(config *Config) (*gorm.DB, error) {
db := Unwrap(gorm.Open(sqlite.Open(dbPath), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
}))
err = Migrate(config, db, alreadyExisted, CURRENT_USER_VERSION)
err = Migrate(config, mo.Some(dbPath), db, alreadyExisted, CURRENT_USER_VERSION)
if err != nil {
return nil, fmt.Errorf("Error migrating database: %w", err)
}
Expand All @@ -150,7 +160,7 @@ func setUserVersion(tx *gorm.DB, userVersion uint) error {
return tx.Exec(fmt.Sprintf("PRAGMA user_version = %d", userVersion)).Error
}

func Migrate(config *Config, db *gorm.DB, alreadyExisted bool, targetUserVersion uint) error {
func Migrate(config *Config, dbPath mo.Option[string], db *gorm.DB, alreadyExisted bool, targetUserVersion uint) error {
var userVersion uint

if alreadyExisted {
Expand All @@ -162,10 +172,29 @@ func Migrate(config *Config, db *gorm.DB, alreadyExisted bool, targetUserVersion
}

initialUserVersion := userVersion
if initialUserVersion > targetUserVersion {
return BackwardsMigrationError{
Err: fmt.Errorf("Database is version %d, migration target version is %d, cannot continue. Are you trying to run an older version of %s with a newer database?", userVersion, targetUserVersion, config.ApplicationName),
}
}

if initialUserVersion < targetUserVersion {
log.Printf("Started migration of database version %d to %d", userVersion, targetUserVersion)
} else if initialUserVersion > targetUserVersion {
return fmt.Errorf("Database is version %d, migration target version is %d, cannot continue. Are you trying to run an older version of %s with a newer database?", userVersion, targetUserVersion, config.ApplicationName)
log.Printf("Started migration of database version %d to %d.", userVersion, targetUserVersion)
if !config.PreMigrationBackups {
log.Printf("PreMigrationBackups disabled, skipping backup.")
} else if p, ok := dbPath.Get(); ok {
dbDir := filepath.Dir(p)
datetime := time.Now().UTC().Format("2006-01-02T15-04-05Z")
backupPath := path.Join(dbDir, fmt.Sprintf("drasl.%d.%s.db", userVersion, datetime))
log.Printf("Backing up old database to %s", backupPath)
_, err := CopyPath(p, backupPath)
if err != nil {
return fmt.Errorf("Error backing up database: %w", err)
}
log.Printf("Database backed up, proceeding.")
} else {
log.Printf("Database path not specified, skipping backup.")
}
}

err := db.Transaction(func(tx *gorm.DB) error {
Expand Down
33 changes: 22 additions & 11 deletions db_test.go
Original file line number Diff line number Diff line change
@@ -1,23 +1,20 @@
package main

import (
"errors"
"github.com/samber/mo"
"github.com/stretchr/testify/assert"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"io"
"log"
"os"
"path"
"testing"
)

func (ts *TestSuite) getFreshDatabase(t *testing.T) *gorm.DB {
dbPath := path.Join(ts.Config.StateDirectory, "drasl.db")
if err := os.Remove(dbPath); err != nil {
assert.True(t, os.IsNotExist(err))
}
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
assert.Nil(t, err)
Expand Down Expand Up @@ -45,11 +42,12 @@ func TestDB(t *testing.T) {
t.Run("Test 2->3 migration", ts.testMigrate2To3)
t.Run("Test 3->4 migration", ts.testMigrate3To4)
t.Run("Test 3->4 migration, username/player name collision", ts.testMigrate3To4Collision)
t.Run("Test backwards migration", ts.testMigrateBackwards)
}

func (ts *TestSuite) testFreshDatabase(t *testing.T) {
db := ts.getFreshDatabase(t)
err := Migrate(ts.Config, db, false, CURRENT_USER_VERSION)
err := Migrate(ts.Config, mo.None[string](), db, false, CURRENT_USER_VERSION)
assert.Nil(t, err)
}

Expand All @@ -63,7 +61,7 @@ func (ts *TestSuite) testMigrate1To2(t *testing.T) {
var v1Client V1Client
assert.Nil(t, db.First(&v1Client).Error)

err = Migrate(ts.Config, db, true, 2)
err = Migrate(ts.Config, mo.None[string](), db, true, 2)
assert.Nil(t, err)

var v2Client V2Client
Expand All @@ -83,7 +81,7 @@ func (ts *TestSuite) testMigrate2To3(t *testing.T) {
var v2User V2User
assert.Nil(t, db.First(&v2User).Error)

err = Migrate(ts.Config, db, true, 3)
err = Migrate(ts.Config, mo.None[string](), db, true, 3)
assert.Nil(t, err)

var v3User V3User
Expand All @@ -101,7 +99,7 @@ func (ts *TestSuite) testMigrate3To4(t *testing.T) {
var v3User V3User
assert.Nil(t, db.First(&v3User).Error)

err = Migrate(ts.Config, db, true, 4)
err = Migrate(ts.Config, mo.None[string](), db, true, 4)
assert.Nil(t, err)

var v4User V4User
Expand Down Expand Up @@ -133,7 +131,7 @@ func (ts *TestSuite) testMigrate3To4Collision(t *testing.T) {
assert.Nil(t, db.First(&v3qux, "username = ?", "qux").Error)
assert.Equal(t, "foo", v3qux.PlayerName)

err = Migrate(ts.Config, db, true, 4)
err = Migrate(ts.Config, mo.None[string](), db, true, 4)
assert.Nil(t, err)

var v4foo V4User
Expand All @@ -146,3 +144,16 @@ func (ts *TestSuite) testMigrate3To4Collision(t *testing.T) {
assert.Equal(t, 1, len(v4qux.Players))
assert.Equal(t, "qux", v4qux.Players[0].Name)
}

func (ts *TestSuite) testMigrateBackwards(t *testing.T) {
db := ts.getFreshDatabase(t)

query, err := os.ReadFile("sql/1.sql")
assert.Nil(t, err)
assert.Nil(t, db.Exec(string(query)).Error)
setUserVersion(db, CURRENT_USER_VERSION+1)

err = Migrate(ts.Config, mo.None[string](), db, true, CURRENT_USER_VERSION)
var backwardsMigrationError BackwardsMigrationError
assert.True(t, errors.As(err, &backwardsMigrationError))
}
1 change: 1 addition & 0 deletions doc/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ Other available options:
- `ListenAddress`: IP address and port to listen on. Depending on how you configure your reverse proxy and whether you run Drasl in a container, you should consider setting the listen address to `"127.0.0.1:25585"` to ensure Drasl is only accessible through the reverse proxy. If your reverse proxy is unable to connect to Drasl, try setting this back to the default value. String. Default value: `"0.0.0.0:25585"`.
- `DefaultAdmins`: Usernames of the instance's permanent admins. Admin rights can be granted to other accounts using the web UI, but admins defined via `DefaultAdmins` cannot be demoted unless they are removed from the config file. Array of strings. Default value: `[]`.
- `DefaultMaxPlayerCount`: Number of players each user is allowed to own by default. Admins can increase or decrease each user's individual limit. Use `-1` to allow creating an unlimited number of players. Integer. Default value: `1`.
- `PreMigrationBackups`: Back up the database to `/path/to/StateDirectory/drasl.X.YYYY-mm-ddTHH-MM-SSZ.db` (where `X` is the old database version) before migrating to a new database version. Boolean. Default value: `true`.
- `EnableBackgroundEffect`: Whether to enable the 3D background animation in the web UI. Boolean. Default value: `true`.
- `EnableFooter`: Whether to enable the page footer in the web UI. Boolean. Default value: `true`.
- `[RateLimit]`: Rate-limit requests per IP address to limit abuse. Only applies to certain web UI routes, not any Yggdrasil routes. Requests for skins, capes, and web pages are also unaffected. Uses [Echo](https://echo.labstack.com)'s [rate limiter middleware](https://echo.labstack.com/middleware/rate-limiter/).
Expand Down
22 changes: 22 additions & 0 deletions util.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ import (
"crypto/sha256"
"encoding/hex"
"github.com/jxskiss/base62"
"io"
"log"
"os"
"strings"
"sync"
)
Expand Down Expand Up @@ -141,3 +143,23 @@ func (m *KeyedMutex) Lock(key string) func() {

return func() { mtx.Unlock() }
}

func CopyPath(sourcePath string, destinationPath string) (int64, error) {
source, err := os.Open(sourcePath)
if err != nil {
return 0, err
}
defer source.Close()

destination, err := os.Create(destinationPath)
if err != nil {
return 0, err
}
defer destination.Close()

bytesWritten, err := io.Copy(destination, source)
if err != nil {
return 0, err
}
return bytesWritten, nil
}

0 comments on commit 9b79be9

Please sign in to comment.