165 lines
4.6 KiB
Python
165 lines
4.6 KiB
Python
"""配置版本迁移 — schema_version + 迁移规则 + 自动执行 + 回滚"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import copy
|
|
import logging
|
|
from collections.abc import Callable
|
|
from dataclasses import dataclass
|
|
from typing import Any
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
CURRENT_SCHEMA_VERSION = 1
|
|
|
|
ConfigDict = dict[str, Any]
|
|
MigrationFn = Callable[[ConfigDict], ConfigDict]
|
|
|
|
|
|
@dataclass
|
|
class MigrationRule:
|
|
from_version: int
|
|
to_version: int
|
|
description: str
|
|
migrate: MigrationFn
|
|
|
|
|
|
_migration_registry: dict[int, MigrationRule] = {}
|
|
|
|
|
|
def register_migration(from_version: int, to_version: int, description: str):
|
|
"""装饰器:注册配置迁移规则"""
|
|
|
|
if to_version != from_version + 1:
|
|
raise ValueError(
|
|
f"迁移规则版本不连续: v{from_version} → v{to_version}, "
|
|
f"期望 to_version={from_version + 1}"
|
|
)
|
|
|
|
def decorator(fn: MigrationFn) -> MigrationFn:
|
|
existing = _migration_registry.get(from_version)
|
|
if existing is not None:
|
|
logger.warning(
|
|
"重复注册迁移规则 v%d → v%d: 旧规则 (%s) 将被覆盖为新规则 (%s)",
|
|
from_version,
|
|
to_version,
|
|
existing.description,
|
|
description,
|
|
)
|
|
_migration_registry[from_version] = MigrationRule(
|
|
from_version=from_version,
|
|
to_version=to_version,
|
|
description=description,
|
|
migrate=fn,
|
|
)
|
|
logger.info(
|
|
"注册配置迁移规则: v%d → v%d (%s)",
|
|
from_version,
|
|
to_version,
|
|
description,
|
|
)
|
|
return fn
|
|
|
|
return decorator
|
|
|
|
|
|
def get_migration_path(current_version: int) -> list[MigrationRule]:
|
|
if current_version >= CURRENT_SCHEMA_VERSION:
|
|
return []
|
|
path: list[MigrationRule] = []
|
|
version = current_version
|
|
while version < CURRENT_SCHEMA_VERSION:
|
|
rule = _migration_registry.get(version)
|
|
if rule is None:
|
|
logger.warning("缺少迁移规则: v%d → v%d", version, version + 1)
|
|
break
|
|
path.append(rule)
|
|
version = rule.to_version
|
|
return path
|
|
|
|
|
|
def migrate_config(
|
|
config: ConfigDict,
|
|
current_version: int | None = None,
|
|
*,
|
|
dry_run: bool = False,
|
|
) -> tuple[ConfigDict, int, list[MigrationRule]]:
|
|
"""执行配置迁移,返回 (新配置, 新版本号, 已应用的迁移规则列表)。
|
|
|
|
失败时自动恢复到原始配置(回滚)。
|
|
|
|
dry_run=True 时只返回迁移后的配置预览,不实际修改任何数据。
|
|
"""
|
|
if current_version is None:
|
|
version = config.get("schema_version", 0)
|
|
else:
|
|
version = current_version
|
|
|
|
path = get_migration_path(version)
|
|
if not path:
|
|
return config, version, []
|
|
|
|
original = copy.deepcopy(config)
|
|
working = copy.deepcopy(config)
|
|
applied: list[MigrationRule] = []
|
|
last_version = version
|
|
|
|
for rule in path:
|
|
try:
|
|
working = rule.migrate(working)
|
|
working["schema_version"] = rule.to_version
|
|
last_version = rule.to_version
|
|
applied.append(rule)
|
|
logger.info(
|
|
"配置迁移成功: v%d → v%d (%s)%s",
|
|
rule.from_version,
|
|
rule.to_version,
|
|
rule.description,
|
|
" [dry-run]" if dry_run else "",
|
|
)
|
|
except Exception:
|
|
logger.exception(
|
|
"配置迁移失败: v%d → v%d (%s), 回滚到 v%d",
|
|
rule.from_version,
|
|
rule.to_version,
|
|
rule.description,
|
|
version,
|
|
)
|
|
return original, version, applied
|
|
|
|
if dry_run:
|
|
logger.info(
|
|
"迁移 dry-run 完成: v%d → v%d, 共 %d 步",
|
|
version,
|
|
last_version,
|
|
len(applied),
|
|
)
|
|
return working, last_version, applied
|
|
|
|
return working, last_version, applied
|
|
|
|
|
|
def backup_config(config: ConfigDict, reason: str = "迁移前备份") -> ConfigDict:
|
|
logger.debug("备份配置: %s", reason)
|
|
return copy.deepcopy(config)
|
|
|
|
|
|
# ── 内置迁移规则 ────────────────────────────────────────
|
|
|
|
|
|
@register_migration(
|
|
from_version=0,
|
|
to_version=1,
|
|
description="初始化 schema_version 字段",
|
|
)
|
|
def migration_v0_to_v1(config: ConfigDict) -> ConfigDict:
|
|
result = dict(config)
|
|
|
|
if "approval" not in result:
|
|
result["approval"] = {
|
|
"enabled": False,
|
|
"approvers": [],
|
|
"auto_approve_internal": True,
|
|
}
|
|
return result
|