Files
xiaoxia-saas/scripts/check_migration_safety.py
XiaoXia Bot e8e4928062
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Staging API Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Build Production API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (push) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Production API Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (pull_request) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m14s
CI/CD Pipeline / Frontend Lint (push) Successful in 1m16s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 3m18s
CI/CD Pipeline / Unit Tests (push) Successful in 3m48s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 14m42s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 14m42s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m47s
CI/CD Pipeline / Integration Tests (push) Successful in 1m51s
ci: fix migration safety check for tar.gz checkout environment
2026-07-16 18:25:28 +08:00

396 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
数据库迁移破坏性变更安全检查
只检查 Alembic 迁移文件的 upgrade 函数中是否包含破坏性操作:
- DROP TABLE
- ALTER TABLE ... DROP COLUMN
- 列类型变更(可能导致数据丢失)
- NOT NULL 约束新增(无默认值时)
- RENAME TABLE / RENAME COLUMN
忽略 downgrade 函数中的操作(那是回滚逻辑,正常的)。
使用方式:
# 检查所有迁移(不推荐,会扫历史已执行的迁移)
python3 scripts/check_migration_safety.py
# 只检查与目标分支相比新增的迁移(推荐用于CI)
python3 scripts/check_migration_safety.py --diff-against origin/main
# 只检查指定版本之后的迁移
python3 scripts/check_migration_safety.py --since 030_xxx
退出码:
0 - 安全 / 只有非破坏性变更
1 - 检测到高风险破坏性变更
2 - 检测到中风险变更,需人工确认
"""
from __future__ import annotations
import argparse
import json
import os
import re
import subprocess
import sys
import time
import urllib.error
import urllib.request
from pathlib import Path
from typing import List, Tuple
REPO_ROOT = Path(__file__).resolve().parents[1]
ALEMBIC_VERSIONS_DIR = REPO_ROOT / "alembic" / "versions"
# 高风险模式:直接导致数据丢失(只在 upgrade 中检查)
HIGH_RISK_PATTERNS = [
(r"\bop\.drop_table\(", "op.drop_table() - 删除表,数据永久丢失"),
(r"\bop\.drop_column\(", "op.drop_column() - 删除列,数据永久丢失"),
]
# 中风险模式:可能导致数据丢失或兼容性问题
MEDIUM_RISK_PATTERNS = [
(
r"op\.alter_column\([^)]*nullable\s*=\s*False",
"新增 NOT NULL 约束 - 旧数据可能为空导致迁移失败",
),
(r"op\.alter_column\([^)]*type_\s*=", "列类型变更 - 可能导致数据截断或转换失败"),
(
r"\bop\.rename_table\(",
"op.rename_table() - 重命名表,可能导致依赖该表的代码报错",
),
(
r"\bop\.rename_column\(",
"op.rename_column() - 重命名列,可能导致依赖该列的代码报错",
),
(r"\bop\.drop_index\(", "op.drop_index() - 删除索引,可能影响查询性能"),
(r"\bop\.drop_constraint\(", "op.drop_constraint() - 删除约束,可能影响数据完整性"),
]
# 安全模式:这些是安全的新增操作
SAFE_PATTERNS = [
(r"\bop\.create_table\(", "新建表"),
(r"\bop\.add_column\(", "新增列"),
(r"\bop\.create_index\(", "新建索引"),
(r"\bop\.create_unique_constraint\(", "新建唯一约束"),
(r"\bop\.create_foreign_key\(", "新建外键约束"),
]
def _get_env(*names: str, default: str = "") -> str:
"""按优先级尝试多个环境变量名,返回第一个非空值。"""
for name in names:
val = os.environ.get(name, "")
if val:
return val
return default
def extract_upgrade_content(content: str) -> str:
"""
从迁移文件中提取 upgrade 函数的内容。
只检查 upgrade 中的操作,忽略 downgrade。
"""
upgrade_match = re.search(r"def upgrade\b[^:]*:", content)
if not upgrade_match:
return ""
upgrade_start = upgrade_match.end()
# 找到下一个顶层 def(通常是 def downgrade)作为结束位置
rest = content[upgrade_start:]
downgrade_match = re.search(r"\n\ndef\s+\w+\b", rest)
if downgrade_match:
upgrade_end = upgrade_start + downgrade_match.start()
else:
upgrade_end = len(content)
return content[upgrade_start:upgrade_end]
def _api_get_with_retry(url: str, token: str, max_retries: int = 3) -> dict | list:
"""
带重试的 API 调用。
指数退避:1s, 2s, 4s
"""
last_error = None
for attempt in range(max_retries):
try:
req = urllib.request.Request(url, headers={"Authorization": f"token {token}"})
with urllib.request.urlopen(req, timeout=15) as resp:
return json.loads(resp.read().decode())
except urllib.error.HTTPError as e:
# 404 说明目录不存在或分支不存在,直接抛
if e.code == 404:
raise
last_error = e
if attempt < max_retries - 1:
wait = 2**attempt
print(f" API 请求失败,{wait}s 后重试 {attempt + 1}/{max_retries}{e}")
time.sleep(wait)
except Exception as e:
last_error = e
if attempt < max_retries - 1:
wait = 2**attempt
print(f" API 请求失败,{wait}s 后重试 {attempt + 1}/{max_retries}{e}")
time.sleep(wait)
raise last_error # type: ignore[misc]
def get_new_migrations_via_api(diff_target: str) -> List[Path] | None:
"""
通过 Gitea/GitHub Contents API 对比目标分支,找出 alembic/versions/ 下新增的迁移文件。
返回 None 表示 API 方式不可用,调用方应尝试其他方式。
"""
# 同时支持 Gitea 和 GitHub 的环境变量命名
api_url = _get_env("GITEA_API_URL", "GITHUB_API_URL", "CI_API_V4_URL")
repo = _get_env("GITEA_REPOSITORY", "GITHUB_REPOSITORY", "CI_PROJECT_PATH")
token = _get_env("GITEA_TOKEN", "GITHUB_TOKEN", "CI_JOB_TOKEN")
branch = diff_target.replace("origin/", "")
if not api_url or not repo or not token:
print(
f" API 环境变量不完整:api_url={'✓' if api_url else '✗'} repo={'✓' if repo else '✗'} token={'✓' if token else '✗'}"
)
return None
try:
url = f"{api_url}/repos/{repo}/contents/alembic/versions?ref={branch}"
data = _api_get_with_retry(url, token)
if isinstance(data, dict):
# Gitea 目录不存在时返回 404,不会到这里;如果返回 dict 可能是错误信息
print(f" API 返回异常:{str(data)[:100]}")
return None
remote_files = {item["name"] for item in data if item["name"].endswith(".py")}
local_files = {f.name for f in ALEMBIC_VERSIONS_DIR.glob("*.py")}
new_file_names = sorted(local_files - remote_files)
if new_file_names:
result = [ALEMBIC_VERSIONS_DIR / f for f in new_file_names]
print(f" API 对比 {branch} 分支,发现 {len(result)} 个新增迁移)")
return result
else:
print(f" API 对比 {branch} 分支,无新增迁移)")
return []
except Exception as e:
print(f" (API 获取迁移列表失败:{e}")
return None
def get_new_migrations_via_git(diff_target: str) -> List[Path] | None:
"""
Fallback:通过本地 git diff 找出新增的迁移文件。
CI 环境中 git 可用时作为 API 失败后的兜底方案。
"""
try:
# 确保目标分支存在
subprocess.run(
["git", "fetch", "origin", diff_target.replace("origin/", ""), "--depth=50"],
capture_output=True,
cwd=str(REPO_ROOT),
timeout=30,
)
# 优先使用三点diff(找合并基线),失败时回退到两点diff(兼容tar.gz checkout + git init的CI环境)
diff_args = ["git", "diff", "--name-only", "--diff-filter=A", f"{diff_target}...HEAD"]
result = subprocess.run(
diff_args,
capture_output=True,
text=True,
cwd=str(REPO_ROOT),
timeout=10,
)
if result.returncode != 0:
# fallback: 两点diff(无需共同祖先)
diff_args_2 = ["git", "diff", "--name-only", "--diff-filter=A", diff_target, "HEAD"]
result = subprocess.run(
diff_args_2,
capture_output=True,
text=True,
cwd=str(REPO_ROOT),
timeout=10,
)
if result.returncode != 0:
print(f" git diff 失败:{result.stderr.strip()}")
return None
new_migrations = []
for line in result.stdout.strip().split("\n"):
line = line.strip()
if line.startswith("alembic/versions/") and line.endswith(".py"):
new_migrations.append(REPO_ROOT / line)
new_migrations.sort()
print(f" git diff 对比 {diff_target},发现 {len(new_migrations)} 个新增迁移)")
return new_migrations
except Exception as e:
print(f" git diff 方式失败:{e}")
return None
def get_new_migrations_via_diff(diff_target: str) -> List[Path]:
"""
找出相对目标分支新增的迁移文件,按优先级尝试多种方式:
1. Gitea/GitHub Contents API(最可靠,不受本地 checkout 深度影响)
2. git diffAPI 失败时的兜底)
3. 全量扫描(以上都失败时的最后兜底,会输出警告)
"""
print("🔍 尝试通过 API 获取新增迁移列表...")
result = get_new_migrations_via_api(diff_target)
if result is not None:
return result
print("🔍 API 不可用,尝试 git diff 方式...")
result = get_new_migrations_via_git(diff_target)
if result is not None:
return result
print("⚠️ 所有增量方式均失败,降级为检查所有迁移文件")
print(" 这可能导致历史迁移中的破坏性操作被误报")
print(" 建议检查 CI 环境变量配置(GITHUB_API_URL / GITHUB_REPOSITORY / GITHUB_TOKEN")
return sorted(ALEMBIC_VERSIONS_DIR.glob("*.py"))
def find_new_migrations(since_revision: str | None = None, diff_against: str | None = None) -> List[Path]:
"""
找出需要检查的迁移文件。
优先级:diff_against > since_revision > 全部
"""
if diff_against:
return get_new_migrations_via_diff(diff_against)
all_migrations = sorted(ALEMBIC_VERSIONS_DIR.glob("*.py"))
if not since_revision:
return all_migrations
result = []
found = False
for m in all_migrations:
if since_revision in m.name or since_revision in m.stem:
found = True
continue
if found:
result.append(m)
return result if found else all_migrations
def analyze_migration(file_path: Path) -> Tuple[List[str], List[str], List[str]]:
"""分析单个迁移文件 upgrade 部分的风险等级"""
content = file_path.read_text()
upgrade_content = extract_upgrade_content(content)
if not upgrade_content:
return [], [], [f"{file_path.name}: 未找到 upgrade 函数"]
high_risks = []
medium_risks = []
safes = []
for pattern, desc in HIGH_RISK_PATTERNS:
if re.search(pattern, upgrade_content):
high_risks.append(f"{file_path.name}: {desc}")
for pattern, desc in MEDIUM_RISK_PATTERNS:
if re.search(pattern, upgrade_content):
medium_risks.append(f"{file_path.name}: {desc}")
for pattern, desc in SAFE_PATTERNS:
if re.search(pattern, upgrade_content):
safes.append(f"{file_path.name}: {desc}")
return high_risks, medium_risks, safes
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument(
"--since",
default=os.getenv("MIGRATION_SINCE_REVISION"),
help="只检查指定版本之后的迁移(如:030_xxx),不传则检查所有迁移",
)
parser.add_argument(
"--diff-against",
default=os.getenv("MIGRATION_DIFF_AGAINST"),
help="对比指定分支/commit,只检查新增的迁移文件(推荐用于CI,如 origin/main",
)
parser.add_argument(
"--warn-only",
action="store_true",
help="只警告不失败(用于非强制门禁场景)",
)
parser.add_argument(
"--allow-medium-risk",
action="store_true",
help="允许中风险变更(只拦截高风险)",
)
args = parser.parse_args()
migrations = find_new_migrations(args.since, args.diff_against)
if not migrations:
print("✅ 未找到需要检查的新增迁移文件,跳过")
return 0
print(f"🔍 正在检查 {len(migrations)} 个迁移文件的 upgrade 操作...")
if args.diff_against:
print(f" (对比基准:{args.diff_against},仅检查新增迁移)")
print()
all_high = []
all_medium = []
all_safe = []
for m in migrations:
high, medium, safe = analyze_migration(m)
all_high.extend(high)
all_medium.extend(medium)
all_safe.extend(safe)
if all_safe:
print("✅ 安全变更:")
for s in all_safe:
print(f" - {s}")
print()
if all_medium:
print("⚠️ 中风险变更(需人工确认):")
for m_item in all_medium:
print(f" - {m_item}")
print()
if all_high:
print("❌ 高风险破坏性变更(禁止自动部署):")
for h in all_high:
print(f" - {h}")
print()
print("=" * 60)
print(f"检查结果:{len(all_safe)} 项安全 / {len(all_medium)} 项中风险 / {len(all_high)} 项高风险")
print()
if all_high:
print("❌ 检测到高风险破坏性变更,CI 检查失败!")
print(" 如果确认这是预期操作,请在 MR/PR 中说明原因并获得审批。")
if args.warn_only:
return 0
return 1
if all_medium and not args.allow_medium_risk:
print("⚠️ 检测到中风险变更,请人工确认后再部署。")
if args.warn_only:
return 0
print("(如需仅拦截高风险,可使用 --allow-medium-risk 参数)")
return 2
print("✅ 未检测到破坏性变更")
return 0
if __name__ == "__main__":
sys.exit(main())