Smail/server/services/dbmigration/service.go
smail d060a36d57
Some checks are pending
Security Checks / security (push) Waiting to run
完善审计与数据库迁移取消边界
2026-08-30 22:11:11 +08:00

363 lines
11 KiB
Go

package dbmigration
import (
"context"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"errors"
"regexp"
"strings"
"sync"
"time"
"git.pcux.cn/xuans/Smail/config"
)
type Status string
const (
StatusPreviewed Status = "previewed"
StatusCreated Status = "created"
StatusCopying Status = "copying"
StatusVerifying Status = "verifying"
StatusSwitching Status = "switching"
StatusSucceeded Status = "succeeded"
StatusFailed Status = "failed"
)
var (
ErrInvalidRequest = errors.New("database migration request is invalid")
ErrInvalidTargetName = errors.New("database migration target name is invalid")
ErrImmutableSetting = errors.New("database migration may change only the database name")
ErrMigrationNotFound = errors.New("database migration not found")
ErrPreviewInvalid = errors.New("database migration preview is invalid")
ErrPreviewExpired = errors.New("database migration preview expired")
ErrMigrationState = errors.New("database migration state is invalid")
ErrExecutorUnavailable = errors.New("database migration executor unavailable")
ErrSourceDatabase = errors.New("source database configuration is invalid")
ErrTargetExists = errors.New("database migration target already exists")
ErrSwitchUnavailable = errors.New("database migration configuration switch unavailable")
ErrUnsupportedDatabase = errors.New("database migration database type is unsupported")
)
const previewLifetime = 10 * time.Minute
var targetNamePattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_-]{0,62}$`)
var sqliteTargetNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$`)
type DatabaseIdentity struct {
Type config.DatabaseType `json:"type"`
Host string `json:"host,omitempty"`
Port uint16 `json:"port,omitempty"`
Username string `json:"username,omitempty"`
Name string `json:"name,omitempty"`
LocalRef string `json:"localRef,omitempty"`
}
type Plan struct {
Source DatabaseIdentity
Target DatabaseIdentity
sourceConfig config.DatabaseConfig
targetConfig config.DatabaseConfig
expectedGeneration uint64
actor config.AuditActor
}
type Record struct {
ID string `json:"id"`
Status Status `json:"status"`
Source DatabaseIdentity `json:"source"`
Target DatabaseIdentity `json:"target"`
CurrentGeneration uint64 `json:"currentGeneration"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
ErrorCode string `json:"errorCode,omitempty"`
}
type Runner interface {
Run(context.Context, Plan, func(Status) error) error
}
type Service struct {
mu sync.RWMutex
records map[string]*record
runner Runner
now func() time.Time
}
type record struct {
Record
tokenHash [32]byte
expiresAt time.Time
sourceConfig config.DatabaseConfig
targetConfig config.DatabaseConfig
}
var current struct {
sync.RWMutex
value *Service
}
func NewService(runner Runner) *Service {
return &Service{records: make(map[string]*record), runner: runner, now: func() time.Time { return time.Now().UTC() }}
}
func SetCurrent(service *Service) {
current.Lock()
current.value = service
current.Unlock()
}
func Current() *Service {
current.RLock()
defer current.RUnlock()
return current.value
}
func (s *Service) Preview(ctx context.Context, source config.DatabaseConfig, currentGeneration uint64, targetName string) (Record, string, error) {
if err := contextError(ctx); err != nil {
return Record{}, "", err
}
if s == nil || currentGeneration == 0 {
return Record{}, "", ErrInvalidRequest
}
if err := config.ValidateDatabaseConfig(source); err != nil {
return Record{}, "", ErrSourceDatabase
}
targetName = strings.TrimSpace(targetName)
if (source.Type == config.DatabaseTypeSQLite && !sqliteTargetNamePattern.MatchString(targetName)) || (source.Type != config.DatabaseTypeSQLite && !targetNamePattern.MatchString(targetName)) {
return Record{}, "", ErrInvalidTargetName
}
plan, err := buildPlan(source, targetName)
if err != nil {
return Record{}, "", err
}
id, err := randomID("dbmig-")
if err != nil {
return Record{}, "", ErrInvalidRequest
}
token, err := randomID("preview-")
if err != nil {
return Record{}, "", ErrInvalidRequest
}
if err := contextError(ctx); err != nil {
return Record{}, "", err
}
now := s.currentTime()
item := &record{Record: Record{ID: id, Status: StatusPreviewed, Source: plan.Source, Target: plan.Target, CurrentGeneration: currentGeneration, CreatedAt: now, UpdatedAt: now}, tokenHash: sha256.Sum256([]byte(token)), expiresAt: now.Add(previewLifetime), sourceConfig: plan.sourceConfig, targetConfig: plan.targetConfig}
s.mu.Lock()
s.records[id] = item
s.mu.Unlock()
return cloneRecord(item.Record), token, nil
}
func (s *Service) Execute(ctx context.Context, id, token string) (Record, error) {
return s.execute(ctx, id, token, config.AuditActor{})
}
// ExecuteAuthorized executes a migration while preserving the authenticated
// operator for the final configuration switch.
func (s *Service) ExecuteAuthorized(ctx context.Context, id, token string, actor config.AuditActor) (Record, error) {
if actor.UserID == "" || actor.SessionID == "" {
return Record{}, config.ErrAuthorizationRequired
}
return s.execute(ctx, id, token, actor)
}
func (s *Service) execute(ctx context.Context, id, token string, actor config.AuditActor) (Record, error) {
if s == nil || strings.TrimSpace(id) == "" || strings.TrimSpace(token) == "" {
return Record{}, ErrInvalidRequest
}
if ctx == nil {
ctx = context.Background()
}
now := s.currentTime()
s.mu.Lock()
item, ok := s.records[id]
if !ok {
s.mu.Unlock()
return Record{}, ErrMigrationNotFound
}
if item.Status != StatusPreviewed {
snapshot := cloneRecord(item.Record)
s.mu.Unlock()
return snapshot, ErrMigrationState
}
if now.After(item.expiresAt) {
item.Status = StatusFailed
item.ErrorCode = "PREVIEW_EXPIRED"
item.UpdatedAt = now
s.mu.Unlock()
return cloneRecord(item.Record), ErrPreviewExpired
}
hash := sha256.Sum256([]byte(token))
if subtle.ConstantTimeCompare(hash[:], item.tokenHash[:]) != 1 {
s.mu.Unlock()
return Record{}, ErrPreviewInvalid
}
item.tokenHash = [32]byte{}
item.Status = StatusCreated
item.UpdatedAt = now
plan := Plan{Source: item.Source, Target: item.Target, sourceConfig: item.sourceConfig, targetConfig: item.targetConfig, expectedGeneration: item.CurrentGeneration, actor: actor}
s.mu.Unlock()
if err := s.setStatus(id, StatusCopying); err != nil {
return s.fail(id, err)
}
if s.runner == nil {
return s.fail(id, ErrExecutorUnavailable)
}
if err := s.runner.Run(ctx, plan, func(status Status) error { return s.setStatus(id, status) }); err != nil {
return s.fail(id, err)
}
if err := s.setStatus(id, StatusSucceeded); err != nil {
return s.fail(id, err)
}
return s.Get(id)
}
func (s *Service) Get(id string) (Record, error) {
if s == nil || strings.TrimSpace(id) == "" {
return Record{}, ErrInvalidRequest
}
s.mu.RLock()
item, ok := s.records[id]
if !ok {
s.mu.RUnlock()
return Record{}, ErrMigrationNotFound
}
value := cloneRecord(item.Record)
s.mu.RUnlock()
return value, nil
}
func (s *Service) setStatus(id string, status Status) error {
s.mu.Lock()
defer s.mu.Unlock()
item, ok := s.records[id]
if !ok {
return ErrMigrationNotFound
}
if !validStatusTransition(item.Status, status) {
return ErrMigrationState
}
item.Status = status
item.UpdatedAt = s.currentTime()
return nil
}
func validStatusTransition(current, next Status) bool {
if current == StatusFailed || current == StatusSucceeded || next == StatusPreviewed || next == StatusFailed {
return false
}
return statusRank(next) >= statusRank(current)
}
func statusRank(status Status) int {
switch status {
case StatusCreated:
return 1
case StatusCopying:
return 2
case StatusVerifying:
return 3
case StatusSwitching:
return 4
case StatusSucceeded:
return 5
default:
return 0
}
}
func (s *Service) fail(id string, err error) (Record, error) {
s.mu.Lock()
defer s.mu.Unlock()
item, ok := s.records[id]
if !ok {
return Record{}, ErrMigrationNotFound
}
item.Status = StatusFailed
item.ErrorCode = migrationErrorCode(err)
item.UpdatedAt = s.currentTime()
return cloneRecord(item.Record), err
}
func (s *Service) currentTime() time.Time {
if s.now == nil {
return time.Now().UTC()
}
return s.now().UTC()
}
func buildPlan(source config.DatabaseConfig, targetName string) (Plan, error) {
source = config.NormalizeDatabaseConfig(source)
identity := DatabaseIdentity{Type: source.Type, Host: source.Host, Port: source.Port, Username: source.Username, Name: source.Name, LocalRef: source.LocalRef}
target := identity
if source.Type == config.DatabaseTypeSQLite {
target.LocalRef = targetName
} else {
target.Name = targetName
}
if target.Type != source.Type || target.Host != source.Host || target.Port != source.Port || target.Username != source.Username {
return Plan{}, ErrImmutableSetting
}
if target.Name == source.Name && target.LocalRef == source.LocalRef {
return Plan{}, ErrInvalidTargetName
}
return Plan{Source: identity, Target: target, sourceConfig: source, targetConfig: func() config.DatabaseConfig {
candidate := source
if source.Type == config.DatabaseTypeSQLite {
candidate.LocalRef = targetName
} else {
candidate.Name = targetName
}
return candidate
}()}, nil
}
func randomID(prefix string) (string, error) {
value := make([]byte, 16)
if _, err := rand.Read(value); err != nil {
return "", err
}
return prefix + hex.EncodeToString(value), nil
}
func cloneRecord(value Record) Record {
return value
}
func migrationErrorCode(err error) string {
switch {
case errors.Is(err, ErrExecutorUnavailable):
return "EXECUTOR_UNAVAILABLE"
case errors.Is(err, ErrPreviewExpired):
return "PREVIEW_EXPIRED"
case errors.Is(err, ErrTargetExists):
return "TARGET_EXISTS"
case errors.Is(err, ErrSQLTargetExists):
return "TARGET_EXISTS"
case errors.Is(err, ErrSQLCopyUnavailable):
return "COPIER_UNAVAILABLE"
case errors.Is(err, ErrSQLConnectionUnavailable):
return "CONNECTION_UNAVAILABLE"
case errors.Is(err, ErrSQLUnsupportedObject):
return "UNSUPPORTED_OBJECT"
case errors.Is(err, ErrSQLDataVerification):
return "DATA_VERIFICATION_FAILED"
case errors.Is(err, ErrSQLSourceBusy):
return "SOURCE_BUSY"
case errors.Is(err, ErrSwitchUnavailable):
return "SWITCH_UNAVAILABLE"
case errors.Is(err, ErrSwitchOutcomeUnknown):
return "SWITCH_OUTCOME_UNKNOWN"
case errors.Is(err, ErrUnsupportedDatabase):
return "UNSUPPORTED_DATABASE"
default:
return "MIGRATION_FAILED"
}
}