forgejo/models/forgejo_migrations/migrate.go
2025-10-14 14:51:50 -06:00

198 lines
6.7 KiB
Go

// Copyright 2025 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: GPL-3.0-or-later
package forgejo_migrations
import (
"fmt"
"os"
"regexp"
"runtime"
"slices"
"strings"
"forgejo.org/modules/container"
"forgejo.org/modules/log"
"forgejo.org/modules/setting"
"forgejo.org/modules/timeutil"
"xorm.io/xorm"
"xorm.io/xorm/names"
)
// ForgejoMigration table contains a record of migrations applied to the database. (Note that there are older
// migrations in the forgejo_version table from before this table was introduced, and the `version` table from Gitea
// migrations). Each record in this table represents one successfully completed migration which was completed at the
// `CreatedUnix` time.
type ForgejoMigration struct {
ID string `xorm:"pk"`
CreatedUnix timeutil.TimeStamp `xorm:"created"`
}
type Migration struct {
Description string // short plaintext explanation of the migration
Upgrade func(*xorm.Engine) error // perform the migration
id string // unique migration identifier
}
var (
rawMigrations []*Migration
migrationFilenameRegex = regexp.MustCompile(`/(?P<migration_group>v[0-9]+[a-z])_(?P<migration_id>[^/]+)\.go$`)
)
var getMigrationFilename = func() string {
_, migrationFilename, _, _ := runtime.Caller(2)
return migrationFilename
}
func registerMigration(migration *Migration) {
migrationFilename := getMigrationFilename()
if migrationResolutionComplete {
panic(fmt.Sprintf("attempted to register migration from %s after migration resolution is already complete", migrationFilename))
}
matches := migrationFilenameRegex.FindStringSubmatch(migrationFilename)
if len(matches) == 0 {
panic(fmt.Sprintf("registerMigration must be invoked from a file matching migrationFilenameRegex, but was invoked from %q", migrationFilename))
}
migration.id = fmt.Sprintf("%s_%s", matches[1], matches[2]) // this just rebuilds the filename, but guarantees that the regex applied for consistent naming
rawMigrations = append(rawMigrations, migration)
}
// For testing only
func resetMigrations() {
rawMigrations = nil
orderedMigrations = nil
migrationResolutionComplete = false
inMemoryMigrationIDs = nil
}
var (
migrationResolutionComplete = false
inMemoryMigrationIDs container.Set[string]
orderedMigrations []*Migration
)
func resolveMigrations() {
if migrationResolutionComplete {
return
}
inMemoryMigrationIDs = make(container.Set[string])
for _, m := range rawMigrations {
if inMemoryMigrationIDs.Contains(m.id) {
// With the filename-based migration ID this shouldn't be possible, but a bit of a sanity check..
panic(fmt.Sprintf("migration id is duplicated: %q", m.id))
}
inMemoryMigrationIDs.Add(m.id)
}
orderedMigrations = slices.Clone(rawMigrations)
slices.SortFunc(orderedMigrations, func(m1, m2 *Migration) int {
return strings.Compare(m1.id, m2.id)
})
migrationResolutionComplete = true
}
func inDBMigrationIDs(x *xorm.Engine) (container.Set[string], error) {
var inDBMigrations []ForgejoMigration
err := x.Find(&inDBMigrations)
if err != nil {
return nil, err
}
inDBMigrationIDs := make(container.Set[string], len(inDBMigrations))
for _, inDB := range inDBMigrations {
inDBMigrationIDs.Add(inDB.ID)
}
return inDBMigrationIDs, nil
}
// EnsureUpToDate will check if the Forgejo database is at the correct version.
func EnsureUpToDate(x *xorm.Engine) error {
resolveMigrations()
inDBMigrationIDs, err := inDBMigrationIDs(x)
if err != nil {
return err
}
// invalidMigrations are those that are in the database, but aren't registered.
invalidMigrations := inDBMigrationIDs.Difference(inMemoryMigrationIDs)
if len(invalidMigrations) > 0 {
return fmt.Errorf("current Forgejo database has migration(s) %s applied, which are not registered migrations", strings.Join(invalidMigrations.Slice(), ", "))
}
// unappliedMigrations are those that haven't yet been applied, but seem valid
unappliedMigrations := inMemoryMigrationIDs.Difference(inDBMigrationIDs)
if len(unappliedMigrations) > 0 {
return fmt.Errorf(`current Forgejo database is missing migration(s) %s. Please run "forgejo [--config /path/to/app.ini] migrate" to update the database version`, strings.Join(unappliedMigrations.Slice(), ", "))
}
return nil
}
// Migrate Forgejo database to current version.
func Migrate(x *xorm.Engine) error {
resolveMigrations()
// Set a new clean the default mapper to GonicMapper as that is the default for .
x.SetMapper(names.GonicMapper{})
if err := x.Sync(new(ForgejoMigration)); err != nil {
return fmt.Errorf("sync: %w", err)
}
inDBMigrationIDs, err := inDBMigrationIDs(x)
if err != nil {
return err
}
// invalidMigrations are those that are in the database, but aren't registered.
invalidMigrations := inDBMigrationIDs.Difference(inMemoryMigrationIDs)
if len(invalidMigrations) > 0 {
// Downgrading Forgejo's database version not supported
msg := fmt.Sprintf("Your Forgejo database has %d migration(s) (%s) for a newer version of Forgejo, you cannot use the newer database for this old Forgejo release.", len(invalidMigrations), strings.Join(invalidMigrations.Slice(), ", "))
msg += "\nForgejo will exit to keep your database safe and unchanged. Please use the correct Forgejo release, do not change the migration version manually (incorrect manual operation may cause data loss)."
if !setting.IsProd {
msg += "\nIf you are in development and know what you're doing, you can remove the migration records from the forgejo_migration table. The affect of those migrations will still be present."
quoted := slices.Clone(invalidMigrations.Slice())
for i, s := range quoted {
quoted[i] = "'" + s + "'"
}
msg += fmt.Sprintf("\n DELETE FROM forgejo_migration WHERE id IN (%s)", strings.Join(quoted, ", "))
}
_, _ = fmt.Fprintln(os.Stderr, msg)
log.Fatal(msg)
return nil
}
// unappliedMigrations are those that are registered but haven't been applied.
unappliedMigrations := inMemoryMigrationIDs.Difference(inDBMigrationIDs)
for _, migration := range orderedMigrations {
if !unappliedMigrations.Contains(migration.id) {
continue
}
log.Info("Migration[%s]: %s", migration.id, migration.Description)
// Reset the mapper between each migration - migrations are not supposed to depend on each other
x.SetMapper(names.GonicMapper{})
if err = migration.Upgrade(x); err != nil {
return fmt.Errorf("migration[%s]: %s failed: %w", migration.id, migration.Description, err)
}
affected, err := x.Insert(&ForgejoMigration{ID: migration.id})
if err != nil {
return err
} else if affected != 1 {
return fmt.Errorf("migration[%s]: failed to insert into DB, %d records affected", migration.id, affected)
}
}
return nil
}