Files
gocryptotrader/config/versions/versions.go
Gareth Kirwan 9fcaa9130b Config: Tighten config version handling as uint16 (#1825)
* Config: Tighten config version handling as uint16

This constrains the versions to uint16 and improves error handling.
LatestVersion becomes literally that.
Fixes handling for negative or overflowing versions in config

* Config: Rename LatestVersion to UseLatestVersion
2025-03-06 12:12:57 +11:00

247 lines
7.8 KiB
Go

// Package versions handles config upgrades and downgrades
/*
- Versions must be stateful, and not rely upon type definitions in the config pkg
- Instead versions should localise types into vN/types.go to avoid issues with subsequent changes
- Versions must upgrade to the next version. Do not retrospectively change versions to match new type changes. Create a new version
- Versions must implement ExchangeVersion or ConfigVersion, and may implement both
*/
package versions
import (
"bytes"
"context"
"encoding/json" //nolint:depguard // Used instead of gct encoding/json so that we can ensure consistent library functionality between versions
"errors"
"fmt"
"log"
"math"
"os"
"slices"
"strconv"
"sync"
"github.com/buger/jsonparser"
"github.com/thrasher-corp/gocryptotrader/common"
)
// UseLatestVersion used as version param to Deploy to automatically use the latest version
const UseLatestVersion = math.MaxUint16
var (
errMissingVersion = errors.New("missing version")
errVersionIncompatible = errors.New("version does not implement ConfigVersion or ExchangeVersion")
errModifyingExchange = errors.New("error modifying exchange config")
errNoVersions = errors.New("error retrieving latest config version: No config versions are registered")
errApplyingVersion = errors.New("error applying version")
errTargetVersion = errors.New("target downgrade version is higher than the latest available version")
errConfigVersion = errors.New("invalid version in config")
errConfigVersionUnavail = errors.New("version is higher than the latest available version")
errConfigVersionNegative = errors.New("version is negative")
errConfigVersionMax = errors.New("version is above max versions")
)
// ConfigVersion is a version that affects the general configuration
type ConfigVersion interface {
UpgradeConfig(context.Context, []byte) ([]byte, error)
DowngradeConfig(context.Context, []byte) ([]byte, error)
}
// ExchangeVersion is a version that affects specific exchange configurations
type ExchangeVersion interface {
Exchanges() []string // Use `*` for all exchanges
UpgradeExchange(context.Context, []byte) ([]byte, error)
DowngradeExchange(context.Context, []byte) ([]byte, error)
}
// manager contains versions registerVersioned during import init
type manager struct {
m sync.RWMutex
versions []any
}
// Manager is a public instance of the config version manager
var Manager = &manager{}
// Deploy upgrades or downgrades the config between versions
// Pass UseLatestVersion for version to use the latest version automatically
// Prints an error an exits if the config file version or version param is not registered
func (m *manager) Deploy(ctx context.Context, j []byte, version uint16) ([]byte, error) {
if err := m.checkVersions(); err != nil {
return j, err
}
latest, err := m.latest()
if err != nil {
return j, err
}
target := latest
if version != UseLatestVersion {
target = version
}
m.m.RLock()
defer m.m.RUnlock()
current64, err := jsonparser.GetInt(j, "version")
switch {
case errors.Is(err, jsonparser.KeyPathNotFoundError):
// With no version first upgrade is to Version1; current64 is already 0
case err != nil:
return j, fmt.Errorf("%w: %w `version`: %w", errConfigVersion, common.ErrGettingField, err)
case current64 < 0:
return j, fmt.Errorf("%w: %w `version`: `%d`", errConfigVersion, errConfigVersionNegative, current64)
case current64 >= UseLatestVersion:
return j, fmt.Errorf("%w: %w `version`: `%d`", errConfigVersion, errConfigVersionMax, current64)
}
current := uint16(current64)
switch {
case target == current:
return j, nil
case latest < current:
err := fmt.Errorf("%w: %w", errConfigVersion, errConfigVersionUnavail)
warnVersionNotRegistered(current, latest, err)
return j, err
case target > latest:
warnVersionNotRegistered(target, latest, errTargetVersion)
return j, errTargetVersion
}
for current != target {
patchVersion := current + 1
action := "upgrade to"
configMethod := ConfigVersion.UpgradeConfig
exchMethod := ExchangeVersion.UpgradeExchange
if target < current {
patchVersion = current
action = "downgrade from"
configMethod = ConfigVersion.DowngradeConfig
exchMethod = ExchangeVersion.DowngradeExchange
}
log.Printf("Running %s config version %v\n", action, patchVersion)
patch := m.versions[patchVersion]
if cPatch, ok := patch.(ConfigVersion); ok {
if j, err = configMethod(cPatch, ctx, j); err != nil {
return j, fmt.Errorf("%w %s %v: %w", errApplyingVersion, action, patchVersion, err)
}
}
if ePatch, ok := patch.(ExchangeVersion); ok {
if j, err = exchangeDeploy(ctx, ePatch, exchMethod, j); err != nil {
return j, fmt.Errorf("%w %s %v: %w", errApplyingVersion, action, patchVersion, err)
}
}
current = patchVersion
if target < current {
current = patchVersion - 1
}
if j, err = jsonparser.Set(j, []byte(strconv.FormatUint(uint64(current), 10)), "version"); err != nil {
return j, fmt.Errorf("%w `version` during %s %v: %w", common.ErrSettingField, action, patchVersion, err)
}
}
var out bytes.Buffer
if err = json.Indent(&out, j, "", " "); err != nil {
return j, fmt.Errorf("error formatting json: %w", err)
}
log.Println("Version management finished")
return out.Bytes(), nil
}
func exchangeDeploy(ctx context.Context, patch ExchangeVersion, method func(ExchangeVersion, context.Context, []byte) ([]byte, error), j []byte) ([]byte, error) {
var errs error
wanted := patch.Exchanges()
var i int
eFunc := func(exchOrig []byte, _ jsonparser.ValueType, _ int, _ error) {
defer func() { i++ }()
name, err := jsonparser.GetString(exchOrig, "name")
if err != nil {
errs = common.AppendError(errs, fmt.Errorf("%w: %w `name`: %w", errModifyingExchange, common.ErrGettingField, err))
return
}
for _, want := range wanted {
if want != "*" && want != name {
continue
}
exchNew, err := method(patch, ctx, exchOrig)
if err != nil {
errs = common.AppendError(errs, fmt.Errorf("%w: %w", errModifyingExchange, err))
continue
}
if !bytes.Equal(exchNew, exchOrig) {
if j, err = jsonparser.Set(j, exchNew, "exchanges", "["+strconv.Itoa(i)+"]"); err != nil {
errs = common.AppendError(errs, fmt.Errorf("%w: %w `exchanges.[%d]`: %w", errModifyingExchange, common.ErrSettingField, i, err))
}
}
}
}
v, dataType, _, err := jsonparser.Get(j, "exchanges")
switch {
case errors.Is(err, jsonparser.KeyPathNotFoundError), dataType != jsonparser.Array:
return j, nil
case err != nil:
return j, fmt.Errorf("%w: %w `exchanges`: %w", errModifyingExchange, common.ErrGettingField, err)
}
if _, err := jsonparser.ArrayEach(bytes.Clone(v), eFunc); err != nil {
return j, err
}
return j, errs
}
// registerVersion takes instances of config versions and adds them to the registry
func (m *manager) registerVersion(ver int, v any) {
m.m.Lock()
defer m.m.Unlock()
if ver >= len(m.versions) {
m.versions = slices.Grow(m.versions, ver+1)[:ver+1]
}
m.versions[ver] = v
}
// latest returns the highest version number
func (m *manager) latest() (uint16, error) {
m.m.RLock()
defer m.m.RUnlock()
if len(m.versions) == 0 {
return 0, errNoVersions
}
return uint16(len(m.versions)) - 1, nil
}
// checkVersions ensures that registered versions are consistent
func (m *manager) checkVersions() error {
m.m.RLock()
defer m.m.RUnlock()
for ver, v := range m.versions {
switch v.(type) {
case ExchangeVersion, ConfigVersion:
default:
return fmt.Errorf("%w: %v", errVersionIncompatible, ver)
}
if v == nil {
return fmt.Errorf("%w: v%v", errMissingVersion, ver)
}
}
return nil
}
func warnVersionNotRegistered(current, latest uint16, msg error) {
fmt.Fprintf(os.Stderr, `
%s ('%d' > '%d')
Switch back to the version of GoCryptoTrader containing config version '%d' and run:
$ ./cmd/config downgrade %d
`, msg, current, latest, current, latest)
}