blocky/config/migration/migration.go

180 lines
4.5 KiB
Go

// Package migration helps with migrating deprecated config options.
//
// `panic` is only used for programmer errors, meaning they will only trigger during development.
package migration
import (
"fmt"
"reflect"
"strings"
"github.com/creasty/defaults"
"github.com/sirupsen/logrus"
"golang.org/x/exp/maps"
)
// Migrate checks each field of `deprecated` to see if a migration can and should be run.
//
// Each field must be a pointer: this allows knowing if the user has set a value in the config.
func Migrate(logger *logrus.Entry, optPrefix string, deprecated any, newOptions map[string]Migrator) bool {
deprecatedVal := reflect.ValueOf(deprecated)
deprecatedTyp := deprecatedVal.Type()
usesDepredOpts := false
for i := 0; i < deprecatedTyp.NumField(); i++ {
field := deprecatedTyp.Field(i)
fieldTag := field.Tag.Get("yaml")
oldName := fullname(optPrefix, fieldTag)
migrator, ok := newOptions[fieldTag]
if !ok {
panic(fmt.Errorf("deprecated option %s has no matching %T", oldName, migrator))
}
delete(newOptions, fieldTag) // so we know it's been checked
migrator.dest.prefix = optPrefix
val := deprecatedVal.Field(i)
if val.Type().Kind() != reflect.Pointer {
panic(fmt.Errorf("deprecated option %s must be a pointer", oldName))
}
if field.Tag.Get("default") != "" {
panic(fmt.Errorf("deprecated option %s must not have a default", oldName))
}
if val.IsNil() {
// Deprecated option is not defined in the user's config
continue
}
usesDepredOpts = true
val = val.Elem() // deref the pointer
if !migrator.dest.IsDefault() {
logger.
WithFields(logrus.Fields{
migrator.dest.Name(): migrator.dest.Value.Interface(),
oldName: val.Interface(),
}).
Errorf(
"config options %q (new) and %q (deprecated) are both set, ignoring the deprecated one",
migrator.dest, oldName,
)
continue
}
logger.Warnf("config option %q is deprecated, please use %q instead", oldName, migrator.dest)
migrator.apply(oldName, val)
}
if len(newOptions) != 0 {
panic(fmt.Errorf("%q has unused migrations: %v", optPrefix, maps.Keys(newOptions)))
}
return usesDepredOpts
}
type applyFunc func(oldName string, oldValue reflect.Value)
type Migrator struct {
dest *Dest
apply applyFunc
}
func newMigrator(dest *Dest, apply applyFunc) Migrator {
return Migrator{dest, apply}
}
// Move copies the deprecated option's value to `dest`.
func Move(dest *Dest) Migrator {
return newMigrator(dest, func(oldName string, oldValue reflect.Value) {
dest.Value.Set(oldValue)
})
}
// Apply calls `apply` with the deprecated value casted to `T`.
func Apply[T any](dest *Dest, apply func(oldValue T)) Migrator {
return newMigrator(dest, func(oldName string, oldValue reflect.Value) {
valItf := oldValue.Interface()
valTyped, ok := valItf.(T)
if !ok {
panic(fmt.Errorf("%q migration types don't match: cannot convert %v to %T", oldName, valItf, valTyped))
}
apply(valTyped)
})
}
type Dest struct {
prefix string
name string
Value reflect.Value
Default any
}
// To creates a new `Dest` from an option name (relative to the `Migrate` prefix) and the struct containing that option.
func To[T any](newName string, newContainerStruct *T) *Dest {
stVal := reflect.ValueOf(newContainerStruct).Elem()
if stVal.Type().Kind() == reflect.Pointer {
panic(fmt.Errorf("newContainerStruct for %s is a double pointer: %T", newName, newContainerStruct))
}
// Find the field matching `newName` in `newContainerStruct`
fieldIdx, newVal := func() (int, reflect.Value) {
parts := strings.Split(newName, ".")
tag := parts[len(parts)-1]
for i := 0; i < stVal.NumField(); i++ {
field := stVal.Type().Field(i)
if field.Tag.Get("yaml") == tag {
return i, stVal.Field(i)
}
}
panic(fmt.Errorf("migrated option %q not found in %T", newName, *newContainerStruct))
}()
// Get the default value of the new option
newDefaultVal := func() reflect.Value {
defaultVals := new(T)
defaults.MustSet(defaultVals)
return reflect.ValueOf(defaultVals).Elem().Field(fieldIdx)
}()
return &Dest{
prefix: "", // set by Run
name: newName,
Value: newVal,
Default: newDefaultVal.Interface(),
}
}
func (d *Dest) Name() string {
return fullname(d.prefix, d.name)
}
func (d *Dest) IsDefault() bool {
return reflect.DeepEqual(d.Value.Interface(), d.Default)
}
func (d *Dest) String() string {
return d.Name()
}
func fullname(prefix, name string) string {
if len(prefix) == 0 {
return name
}
return fmt.Sprintf("%s.%s", prefix, name)
}