Files
threshold/internal/db/schema/manager.go
T
Timothy Messier 79866a287f feat(schema): Add support for migration hooks
This adds the ability to define hooks that will run prior to a schema
migration. The hooks should only be used when a migration would fail due
to data being in an invalid state, likely due to a bug in the previous
schema definition. For example if a column was missing a constraint, and
a new migration is attempting to add the constraint, there might already
be values that violate the new constraint. In such cases, we would want
the user to make a decision to address the data, and need to provide
them with details about what data is problematic in case they want to
manually address the problem. To help the user, if there is a sane way
to address the data, we should provide an option to automatically
"repair" the data, allowing the migration to continue. However, this
still should be an explicit opt-in from the user, and we need to clearly
describe what the repair option would do to the user so they understand
the consequences of opting in.

To accomplish this each migration can be provided a hook that contains a
CheckFunc, RepairFunc, and RepairDescription. These functions must
operate at the point-in-time of the migration immediately preceding the
current migration, as as such they should not use and domain packages or
repositories, instead they should use direct sql with the sql library.
The description is used to explain what the RepairFunc will do.

Hooks should be defined in `internal/db/schema/migrations/oss/oss.go`
and registered with the schema edition.

See the example in `internal/db/schema/manager_example_test.go` for more
details.
2022-08-22 12:15:20 -07:00

273 lines
8.9 KiB
Go

package schema
import (
"bytes"
"context"
"database/sql"
"fmt"
"io"
"github.com/hashicorp/boundary/internal/db/schema/internal/edition"
"github.com/hashicorp/boundary/internal/db/schema/internal/log"
"github.com/hashicorp/boundary/internal/db/schema/internal/postgres"
"github.com/hashicorp/boundary/internal/db/schema/internal/provider"
"github.com/hashicorp/boundary/internal/db/schema/migration"
"github.com/hashicorp/boundary/internal/errors"
)
// driver provides functionality to a database.
type driver interface {
TrySharedLock(context.Context) error
TryLock(context.Context) error
Lock(context.Context) error
Unlock(context.Context) error
UnlockShared(context.Context) error
// StartRun begins a transaction internal to the driver.
StartRun(context.Context) error
// CommitRun commits a transaction, if there is an error it should rollback the transaction.
CommitRun(context.Context) error
// CheckHook is a hook that runs prior to a migration's statements.
// It should run in the same transaction a corresponding Run call.
CheckHook(context.Context, migration.CheckFunc) (migration.Problems, error)
// RepairHook is a hook that runs prior to a migration's statements.
// It should run in the same transaction a corresponding Run call.
RepairHook(context.Context, migration.RepairFunc) (migration.Repairs, error)
// Run will apply a migrations statements. The io.Reader should provide the SQL
// statements to execute, and the int is the version for that set of
// statements. This should always be wrapped by StartRun and CommitRun.
Run(ctx context.Context, statements io.Reader, version int, edition string) error
// CurrentState returns the state of the given edition.
// ver is the current migration version number as recorded in the database.
// A version of -1 indicates no version is set.
// initialized will be true if the schema was previously initialized.
CurrentState(ctx context.Context, edition string) (version int, initialized bool, err error)
// EnsureVersionTable ensures that the table used to record the schema versions for each edition
// exists and is in the correct state.
EnsureVersionTable(ctx context.Context) error
// EnsureMigrationLogTable ensures that the table used to record migration lgos
// exists and is in the correct state.
EnsureMigrationLogTable(ctx context.Context) error
// GetMigrationLog will retrieve the migration logs from the db for the last
// migration.
// The WithDeleteLog option is supported and will remove all log entries,
// after reading the entries, when provided.
GetMigrationLog(ctx context.Context, opt ...log.Option) ([]*log.Entry, error)
}
// Manager provides a way to run operations and retrieve information regarding
// the underlying boundary database schema.
// Manager is not thread safe.
type Manager struct {
db *sql.DB
driver driver
dialect string
editions edition.Editions
selectedRepairs RepairMigrations
}
// NewManager creates a new schema manager. An error is returned
// if the provided dialect is unrecognized or if the passed in db is unreachable.
func NewManager(ctx context.Context, dialect Dialect, db *sql.DB, opt ...Option) (*Manager, error) {
const op = "schema.NewManager"
editions.Lock()
defer editions.Unlock()
opts := getOpts(opt...)
dbM := Manager{
db: db,
dialect: string(dialect),
selectedRepairs: opts.withRepairMigrations,
}
if opts.withEditions != nil {
dbM.editions = opts.withEditions
} else {
dbM.editions = make(edition.Editions, 0, len(editions.m[edition.Dialect(dialect)]))
for _, e := range editions.m[edition.Dialect(dialect)] {
dbM.editions = append(dbM.editions, e)
}
}
switch dialect {
case "postgres":
var err error
dbM.driver, err = postgres.New(ctx, db)
if err != nil {
return nil, errors.Wrap(ctx, err, op)
}
default:
return nil, errors.New(ctx, errors.InvalidParameter, op, fmt.Sprintf("unknown dialect %q", dialect))
}
return &dbM, nil
}
// CurrentState provides the state of the boundary schema contained in the backing database.
func (b *Manager) CurrentState(ctx context.Context) (*State, error) {
const op = "schema.(Manager).CurrentState"
var dbS State
for _, e := range b.editions {
v, initialized, err := b.driver.CurrentState(ctx, e.Name)
if err != nil {
return nil, errors.Wrap(ctx, err, op)
}
dbS.Initialized = initialized || dbS.Initialized
dbS.Editions = append(dbS.Editions, EditionState{
Name: e.Name,
DatabaseSchemaVersion: v,
BinarySchemaVersion: e.LatestVersion,
DatabaseSchemaState: compareVersions(v, e.LatestVersion),
})
}
return &dbS, nil
}
// SharedLock attempts to obtain a shared lock on the database. This can fail
// if an exclusive lock is already held. If the lock can't be obtained an
// error is returned.
func (b *Manager) SharedLock(ctx context.Context) error {
const op = "schema.(Manager).SharedLock"
if err := b.driver.TrySharedLock(ctx); err != nil {
return errors.Wrap(ctx, err, op)
}
return nil
}
// SharedUnlock releases a shared lock on the database. If this
// fails for whatever reason an error is returned. Unlocking a lock
// that is not held is not an error.
func (b *Manager) SharedUnlock(ctx context.Context) error {
const op = "schema.(Manager).SharedUnlock"
if err := b.driver.UnlockShared(ctx); err != nil {
return errors.Wrap(ctx, err, op)
}
return nil
}
// ExclusiveLock attempts to obtain an exclusive lock on the database.
// An error is returned if a lock was unable to be obtained.
func (b *Manager) ExclusiveLock(ctx context.Context) error {
const op = "schema.(Manager).ExclusiveLock"
if err := b.driver.TryLock(ctx); err != nil {
return errors.Wrap(ctx, err, op)
}
return nil
}
// ExclusiveUnlock releases a shared lock on the database. If this
// fails for whatever reason an error is returned. Unlocking a lock
// that is not held is not an error.
func (b *Manager) ExclusiveUnlock(ctx context.Context) error {
const op = "schema.(Manager).ExclusiveUnlock"
if err := b.driver.Unlock(ctx); err != nil {
return errors.Wrap(ctx, err, op)
}
return nil
}
// ApplyMigrations updates the database schema to match the latest version known by
// the boundary binary. An error is not returned if the database is already at
// the most recent version.
func (b *Manager) ApplyMigrations(ctx context.Context) ([]RepairLog, error) {
const op = "schema.(Manager).ApplyMigrations"
// Capturing a lock that this session to the db already possesses is okay.
if err := b.driver.Lock(ctx); err != nil {
return nil, errors.Wrap(ctx, err, op)
}
defer func() {
if err := b.driver.Unlock(ctx); err != nil {
// I'm not sure this is ideal, but we have to rollback the current
// transaction if we're unable to release the lock
panic(errors.Wrap(ctx, err, op))
}
}()
state, err := b.CurrentState(ctx)
if err != nil {
return nil, errors.Wrap(ctx, err, op)
}
logs, err := b.runMigrations(ctx, provider.New(state.databaseState(), b.editions))
if err != nil {
return nil, err
}
return logs, nil
}
// runMigrations passes migration queries to a database driver and manages
// the version and dirty bit. Cancellation or deadline/timeout is managed
// through the passed in context.
func (b *Manager) runMigrations(ctx context.Context, p *provider.Provider) ([]RepairLog, error) {
const op = "schema.(Manager).runMigrations"
var logEntries []RepairLog
var err error
if startErr := b.driver.StartRun(ctx); startErr != nil {
err = errors.Wrap(ctx, startErr, op)
return nil, err
}
defer func() {
if commitErr := b.driver.CommitRun(ctx); commitErr != nil {
err = errors.Wrap(ctx, commitErr, op)
}
}()
if ensureErr := b.driver.EnsureVersionTable(ctx); ensureErr != nil {
err = errors.Wrap(ctx, ensureErr, op)
return nil, err
}
if ensureErr := b.driver.EnsureMigrationLogTable(ctx); ensureErr != nil {
err = errors.Wrap(ctx, ensureErr, op)
return nil, err
}
for p.Next() {
select {
case <-ctx.Done():
err = errors.Wrap(ctx, ctx.Err(), op)
return nil, err
default:
// context is not done yet. Continue on to the next query to execute.
}
if h := p.PreHook(); h != nil {
problems, err := b.driver.CheckHook(ctx, h.CheckFunc)
if err != nil {
return nil, errors.Wrap(ctx, err, op)
}
if len(problems) > 0 {
if !b.selectedRepairs.IsSet(p.Edition(), p.Version()) {
return nil, MigrationCheckError{
Version: p.Version(),
Edition: p.Edition(),
Problems: problems,
RepairDescription: h.RepairDescription,
}
}
repairs, err := b.driver.RepairHook(ctx, h.RepairFunc)
if err != nil {
return nil, errors.Wrap(ctx, err, op)
}
logEntries = append(logEntries, RepairLog{
Version: p.Version(),
Edition: p.Edition(),
Entry: repairs,
})
}
}
if runErr := b.driver.Run(ctx, bytes.NewReader(p.Statements()), p.Version(), p.Edition()); runErr != nil {
err = errors.Wrap(ctx, runErr, op)
return nil, err
}
}
return logEntries, nil
}