Files
cma-management/backend/scripts/db_migrate.py
T
wecom-fullstack b8493f8d04 feat(db): 新表授权面收敛机制落地(白名单驱动 · 增量锚定 · fail-closed · 只增不减)
依据:A 侧(wecom-yanxue)裁定 编号 2「准予但需调整」+ R1(裁定件
yanxue-20260928-newtable-grant-surface-impl-plan-ruling.md,md5 272d1a8b4db0db0e9f35f5e62695ae50)
三条开口 (a) 空锚=合法零集 / (b) --anchor-file 载体等价 / (c) drill WARN 不阻断(两条独立分支)

改动面(3 件,全部开发工作树):
- 新增 backend/scripts/grant_surface_sync.py:项目侧原型逐字复制 + Δ1/Δ2/Δ3/Δ4 + R1
  Δ1 锚语义(未给=缺锚 rc=2 / 空集=合法零集)、Δ2 连接身份与 db_migrate 同口径并拒绝 cma_api、
  Δ3 --anchor-file(读 db-migrate-journal 指针件)、Δ4 anchor_source 入 JSON
  R1-a 同源自证(journal 在册 + status=applied + finished_at 一致 + 目录内最新 -applied)
  R1-b 未收敛锚累积由 db_migrate.write_anchor_pointer 实现、R1-c 收敛回写 converged_at
- db_migrate.py 加性 +2 处:新增 write_anchor_pointer()(原子写 journal/last-anchor.json);
  cmd_apply 成功/失败两路径各调一次;零变更面未动(列级差异只告警 / create_all 幂等 /
  --check 不写指针 / --rollback 与 resolve_journal 语义 / JSON 键集)
- deploy.sh 加性 1 步 [4b-2/5] 授权面收敛(插在「✓ 结构迁移完成」与「# 5. 重启后端服务」之间):
  apply 跑 --apply --anchor-file ... --drift-policy warn,rc!=0 ⇒ 打印缺裁定清单 + exit 1
  (后端不重启、旧码继续服务);drill 跑 --check,读数原样打印、WARN 不阻断

证据(全部零生产面):
- py_compile x3 通过;bash -n deploy.sh 通过(PYTHONPYCACHEPREFIX 隔离,仓内零新增 pyc)
- 纯函数用例 11/11(零连库):语句均为 GRANT / 无 REVOKE / 语句表 ⊆ write_tables / 域外权限 SystemExit
- 沙箱 S1–S9 回归 9/9 一致(SUMMARY.txt 与项目侧基线逐字节相同 md5 1a697e002a2cba8ae9b5ad9cc71b022d)
- 锚/R1 新例 18/18:缺锚/缺件/status=failed/R1-a 四负例均 rc=2 零写入;空锚 rc=0 executed=[]
- R1 验收链:发布1 建表 X(∉白名单) ⇒ rc=3 零写入 + 片段 exit 1 ⇒ 不修白名单重跑 ⇒ 仍 rc=3 ⇒
  补白名单 ⇒ rc=0 + X 获授权 + converged_at 落定 ⇒ 三次重跑 executed=[](幂等)
- 既有 backend/tests:1509 passed / 0 failed,改前(HEAD 导出树)与改后失败集零差异

边界:本地 commit,零 push / 零部署 / 零重启 / 零连生产库 / 零 DDL-DML-GRANT-REVOKE;
上线(push + 部署 ⇒ 授权面生效)另单另铸 approval ID 且必附 R2 预演 diff(本单不为任何动作铸 ID)
2026-09-28 17:01:39 +08:00

522 lines
23 KiB
Python
Raw 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
"""CMA 数据库结构迁移(特权 DDL 步)—— 与应用启动 create_all 剥离
任务:cma-create-all-minpriv-rootfix-20260916(P2)
背景(2026-09-16 01:08 发布中断事故)
--------------------------------------
应用启动时 `app/database.py::init_db()` 调 `Base.metadata.create_all()`(需要 CREATE 权限),
而运行库账号 `cma_api` 只有 SELECT + 表级 DML 白名单(**无 DDL**)。新代码引入新模型
(`tax_filing_log` / `treasury_*`)→ 启动即 `CREATE command denied` → 崩溃重启循环。
本脚本 = 部署链里的**独立、幂等、可回滚**的特权迁移步,在应用重启**之前**执行:
deploy.sh [4b] → backend/scripts/db_migrate.py --apply → systemctl restart cma-backend
于是:DDL 不再随应用 boot 发生;运行库账号可保持最小权限(无需 CREATE)。
用法
----
python3 scripts/db_migrate.py --check # 只读预检(默认动作,零写入)
python3 scripts/db_migrate.py --apply # 幂等建表(缺什么建什么)+ 写 journal
python3 scripts/db_migrate.py --status # 列出历史 journal
python3 scripts/db_migrate.py --rollback latest # 回滚最近一次 --apply 新建的表
python3 scripts/db_migrate.py --rollback <journal.json>
选项
----
--json 机器可读输出(部署链/CI 断言用)
--force 回滚时允许删除**有数据**的表(默认拒绝,exit 3)
--no-dump 回滚时不 mysqldump(默认先备份到 journal 目录,dump 失败即拒绝回滚)
--socket PATH unix socket 路径(默认 /var/run/mysqld/mysqld.sock)
--db-url URL 显式连接串(覆盖下方全部推导;不含口令时应配合环境变量)
--db-name NAME 目标库名(默认 env CMA_MIGRATE_DB_NAME → CMA_DB_NAME → cma)
连接凭据(按优先级;口令只从环境读,禁止硬编码/回显)
----------------------------------------------------
1) CMA_MIGRATE_DB_USER + CMA_MIGRATE_DB_PASS(**专用迁移账号**,TCP)——推荐用于非本机场景
2) 未设 CMA_MIGRATE_DB_USER → 本机 unix socket + root(socket 认证,无需口令)
⚠️ 应用运行账号(backend/.env 的 CMA_DB_USER,= cma_api)**不得**被本脚本使用:
迁移必须走在有 DDL 权限的账号上,本脚本会显式拒绝。
幂等契约
--------
首跑 --apply:created_new = 缺失表数(本次实测:0 —— 库已由应急 create_all 补齐)
二跑 --apply:created_new = 0,零副作用
已存在的表**不** ALTER、**不** DROP、**不**动数据行(列级差异只报告,见下)
退出码
------
0 成功 | 1 迁移失败 / 迁移后自检不一致 / 连接失败(fail-closed,部署链据此中止)
2 用法错误 | 3 回滚被拒(目标表有数据且未 --force)
"""
import argparse
import fcntl
import json
import os
import random
import socket
import string
import subprocess
import sys
import tempfile
from datetime import datetime
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from sqlalchemy import create_engine, inspect, text # noqa: E402
from sqlalchemy.engine.url import make_url # noqa: E402
HERE = os.path.dirname(os.path.abspath(__file__))
BACKEND_DIR = os.path.dirname(HERE)
DEFAULT_JOURNAL_DIR = os.path.join(HERE, "backups", "db-migrate-journal")
DEFAULT_SOCKET = "/var/run/mysqld/mysqld.sock"
DEFAULT_DB = "cma"
# 应用运行账号:绝不允许用于本迁移步(无 DDL 权限 = 事故根因)
FORBIDDEN_RUN_ACCOUNTS = {"cma_api"}
# ────────────────────────────────────────────────────────────────
# 连接(特权通道)
# ────────────────────────────────────────────────────────────────
def _resolve_db_name(args) -> str:
return (
args.db_name
or os.getenv("CMA_MIGRATE_DB_NAME")
or os.getenv("CMA_DB_NAME")
or DEFAULT_DB
)
def build_engine(args):
"""构造特权连接。返回 (engine, 描述串)。描述串不含口令。"""
db_name = _resolve_db_name(args)
if args.db_url:
url = make_url(args.db_url)
if url.database is None:
url = url.set(database=db_name)
return create_engine(url, pool_pre_ping=True), f"db-url({url.drivername}@{url.host or 'socket'}/{url.database})"
user = os.getenv("CMA_MIGRATE_DB_USER", "").strip()
if user:
if user in FORBIDDEN_RUN_ACCOUNTS:
raise SystemExit(
f"[FAIL] CMA_MIGRATE_DB_USER={user} 是**应用运行账号**(无 DDL 权限)——\n"
f" 迁移步必须使用有 DDL 权限的专用账号(或本机 root socket)。已拒绝执行。"
)
password = os.getenv("CMA_MIGRATE_DB_PASS", "")
host = os.getenv("CMA_MIGRATE_DB_HOST", "127.0.0.1")
port = int(os.getenv("CMA_MIGRATE_DB_PORT", "3306"))
url = (
f"mysql+pymysql://{user}:{password}@{host}:{port}/{db_name}"
f"?charset=utf8mb4"
)
return create_engine(url, pool_pre_ping=True), f"tcp({user}@{host}:{port}/{db_name})"
# 默认:本机 unix socket + root(socket 认证,无口令落盘)
sock = args.socket or os.getenv("CMA_MIGRATE_DB_SOCKET") or DEFAULT_SOCKET
if not os.path.exists(sock):
raise SystemExit(
f"[FAIL] 未设 CMA_MIGRATE_DB_USER,且 unix socket 不存在:{sock}\n"
f" 请提供专用迁移账号(CMA_MIGRATE_DB_USER/PASS)或用 --socket 指定路径。"
)
url = f"mysql+pymysql://root@localhost/{db_name}?unix_socket={sock}&charset=utf8mb4"
return create_engine(url, pool_pre_ping=True), f"socket(root@{sock}/{db_name})"
# ────────────────────────────────────────────────────────────────
# schema 比对(ORM 期望 vs 库内实况)
# ────────────────────────────────────────────────────────────────
def _import_models():
import app.models # noqa: F401 触发全部模型注册
from app.database import Base
return Base
def schema_diff(engine):
"""返回 (missing_tables, missing_columns{表:[列]})。只读。"""
Base = _import_models()
insp = inspect(engine)
existing = set(insp.get_table_names())
missing_tables, missing_columns = [], {}
for table in Base.metadata.sorted_tables:
if table.name not in existing:
missing_tables.append(table.name)
continue
have = {c["name"] for c in insp.get_columns(table.name)}
miss = [c.name for c in table.columns if c.name not in have]
if miss:
missing_columns[table.name] = miss
return missing_tables, missing_columns
# ────────────────────────────────────────────────────────────────
# journal(迁移留痕:谁/何时/建了什么 → 回滚依据)
# ────────────────────────────────────────────────────────────────
def journal_dir() -> str:
d = os.getenv("CMA_MIGRATE_JOURNAL_DIR") or DEFAULT_JOURNAL_DIR
os.makedirs(d, exist_ok=True)
return d
def _git_head() -> str:
try:
return subprocess.run(
["git", "-C", os.path.dirname(BACKEND_DIR), "rev-parse", "--short", "HEAD"],
capture_output=True, text=True, timeout=10,
).stdout.strip() or "unknown"
except Exception:
return "unknown"
def write_journal(payload: dict) -> str:
path = os.path.join(
journal_dir(), f"{datetime.now().strftime('%Y%m%dT%H%M%S')}-{payload.get('status', 'na')}.json"
)
with open(path, "w", encoding="utf-8") as fh:
json.dump(payload, fh, ensure_ascii=False, indent=2)
return path
def write_anchor_pointer(payload: dict, journal_path: str) -> str:
"""原子写 journal/last-anchor.json —— 授权面收敛步(grant_surface_sync.py)消费的锚(R1)
键:journal(本次 journal 文件名,供 R1-a 同源自证)/ status / created_tables / created_new /
anchor_csv / finished_at / git_head / db_name / actor / carried_over / carried_from /
written_at / converged_at
R1-b:已存在指针且其 status=applied 而未收敛(无 converged_at)时,本次 created_tables =
旧集合 ∪ 本次 created,并记 carried_over / carried_from;无旧指针或旧指针已收敛时 = 本次
加性写:不改 journal、不改任何既有键语义;失败由调用方按告警处理(收敛步会据此 rc=2 中止)
"""
d = journal_dir()
path = os.path.join(d, "last-anchor.json")
prev = None
try:
with open(path, encoding="utf-8") as fh:
prev = json.load(fh)
except Exception:
prev = None
created = [t for t in (payload.get("created_tables") or [])]
carried_over, carried_from = False, None
if (isinstance(prev, dict) and prev.get("status") == "applied"
and not prev.get("converged_at") and (prev.get("created_tables") or [])):
old = list(prev.get("created_tables") or [])
created = sorted(set(old) | set(created))
carried_from = os.path.basename(str(prev.get("journal") or "")) or None
carried_over = True
doc = {
"pointer": "last-anchor.json",
"journal": os.path.basename(journal_path),
"status": payload.get("status"),
"created_tables": created,
"created_new": payload.get("created_new"),
"anchor_csv": ",".join(created),
"carried_over": carried_over,
"carried_from": carried_from,
"finished_at": payload.get("finished_at"),
"git_head": payload.get("git_head"),
"db_name": payload.get("db_name"),
"actor": payload.get("actor"),
"written_at": datetime.now().isoformat(timespec="seconds"),
"converged_at": None,
}
tmp = "%s.tmp.%d" % (path, os.getpid())
with open(tmp, "w", encoding="utf-8") as fh:
json.dump(doc, fh, ensure_ascii=False, indent=2)
fh.write("\n")
try:
ref = path if os.path.exists(path) else journal_path
if os.path.exists(ref):
st = os.stat(ref)
os.chmod(tmp, st.st_mode & 0o777)
os.chown(tmp, st.st_uid, st.st_gid)
except Exception:
pass
os.replace(tmp, path)
return path
def list_journals():
d = journal_dir()
return sorted(
(os.path.join(d, f) for f in os.listdir(d) if f.endswith(".json")),
reverse=True,
)
def resolve_journal(ref: str) -> str:
if ref in ("latest", ""):
items = [p for p in list_journals() if "-applied" in os.path.basename(p)]
if not items:
raise SystemExit("[FAIL] 没有可回滚的 journal(无 --apply 记录)")
return items[0]
if os.path.exists(ref):
return ref
cand = os.path.join(journal_dir(), os.path.basename(ref))
if os.path.exists(cand):
return cand
raise SystemExit(f"[FAIL] journal 不存在:{ref}")
# ────────────────────────────────────────────────────────────────
# 动作
# ────────────────────────────────────────────────────────────────
def cmd_check(args, engine, conn_desc) -> int:
missing_tables, missing_cols = schema_diff(engine)
out = {
"action": "check",
"connection": conn_desc,
"missing_tables": missing_tables,
"missing_table_count": len(missing_tables),
"missing_columns": missing_cols,
}
if args.json:
print(json.dumps(out, ensure_ascii=False))
else:
print(f"[check] 连接: {conn_desc}")
print(f"[check] 缺失表 {len(missing_tables)} 张: {missing_tables if missing_tables else '(无)'}")
if missing_cols:
print(f"[check] 列级差异 {len(missing_cols)} 张表: {missing_cols}")
print(" ⚠️ create_all 不会补列 → 需要独立的 ALTER 迁移(见 journal/回报)")
else:
print("[check] 列级差异:无")
# 列级差异属"存在但结构过期":部署链上以告警呈现,不阻断(阻断判据 = 缺表未补齐)
return 0
def run_structural() -> dict:
"""结构增量(补列/索引/种子/授权)——create_all 覆盖不到。
复用 `treasury_migrate.py --apply`(幂等;其自身走特权连接)。失败不中断部署,只告警+留痕。"""
script = os.path.join(HERE, "treasury_migrate.py")
if not os.path.exists(script):
return {"status": "skipped", "reason": "treasury_migrate.py 不存在"}
try:
proc = subprocess.run([sys.executable, script, "--apply"],
capture_output=True, text=True, timeout=300)
except Exception as exc:
return {"status": "error", "reason": f"{type(exc).__name__}: {exc}"}
return {
"status": "ok" if proc.returncode == 0 else "failed",
"rc": proc.returncode,
"stdout_tail": (proc.stdout or "").strip()[-600:],
"stderr_tail": (proc.stderr or "").strip()[-400:],
}
def cmd_apply(args, engine, conn_desc) -> int:
Base = _import_models()
before_missing, before_cols = schema_diff(engine)
started = datetime.now().isoformat(timespec="seconds")
payload = {
"action": "apply",
"status": "running",
"started_at": started,
"finished_at": None,
"connection": conn_desc,
"git_head": _git_head(),
"actor": f"{os.getenv('USER', 'unknown')}@{socket.gethostname()}",
"db_name": _resolve_db_name(args),
"missing_tables_before": before_missing,
"missing_columns_before": before_cols,
"created_tables": [],
"created_new": 0,
"error": None,
}
print(f"[apply] 连接: {conn_desc}")
print(f"[apply] 迁移前缺失表 {len(before_missing)} 张: {before_missing if before_missing else '(无)'}")
try:
if before_missing:
Base.metadata.create_all(bind=engine) # checkfirst=True → 已存在的表零动作
# 结构增量(补列/索引/种子/授权):create_all 不补列 → 复用 treasury_migrate(幂等,特权连接)
structural = run_structural()
after_missing, after_cols = schema_diff(engine)
created = [t for t in before_missing if t not in after_missing]
payload.update(
status="applied",
finished_at=datetime.now().isoformat(timespec="seconds"),
created_tables=created,
created_new=len(created),
missing_tables_after=after_missing,
structural=structural,
)
path = write_journal(payload)
try:
write_anchor_pointer(payload, path)
except Exception as exc: # 加性:指针写失败不阻断迁移,由收敛步 fail-closed 兜住
print(f"[apply] [warn] 锚指针写入失败(收敛步会据此 rc=2 中止部署):"
f"{type(exc).__name__}: {exc}")
except Exception as exc: # DDL 无事务:失败时记录已建对象,供 --rollback 恢复
try:
after_missing, _ = schema_diff(engine)
except Exception:
after_missing = before_missing
created = [t for t in before_missing if t not in after_missing]
payload.update(
status="failed",
finished_at=datetime.now().isoformat(timespec="seconds"),
created_tables=created,
created_new=len(created),
error=f"{type(exc).__name__}: {exc}",
)
path = write_journal(payload)
try:
write_anchor_pointer(payload, path)
except Exception as exc: # 加性:失败路径同样留锚(status=failed),收敛步只消费 applied
print(f"[apply] [warn] 锚指针写入失败(失败路径):{type(exc).__name__}: {exc}")
print(f"[FAIL] 迁移失败:{payload['error']}")
print(f" 已建对象 {created}(journal: {path})")
print(f" 回滚:python3 scripts/db_migrate.py --rollback {os.path.basename(path)}")
return 1
ok = not payload["missing_tables_after"]
print(f"[apply] 新建 {payload['created_new']} 张表: {created if created else '(无 → 幂等空跑)'}")
print(f"[apply] 迁移后缺失表 {len(payload['missing_tables_after'])} 张: "
f"{payload['missing_tables_after'] if payload['missing_tables_after'] else '(无)'}")
print(f"[apply] 结构增量(treasury_migrate) status={structural.get('status')} rc={structural.get('rc')}")
if after_cols:
print(f"[apply] ⚠️ 列级差异仍存(结构步未覆盖): {after_cols}")
print(f"[apply] journal: {path}")
if not ok:
print("[FAIL] 迁移后自检不一致(仍有缺失表)→ 部署链应中止")
return 1
return 0
def _dump_tables(tables, db_name) -> str:
"""mysqldump 备份(凭据走 600 临时 defaults 文件,不进 argv/日志)。返回文件路径。"""
fd, cfg = tempfile.mkstemp(prefix="cma-migrate-dump-", suffix=".cnf")
os.close(fd)
os.chmod(cfg, 0o600)
try:
lines = ["[client]"]
if os.getenv("CMA_MIGRATE_DB_USER"):
lines += [
f"user={os.getenv('CMA_MIGRATE_DB_USER')}",
f"password={os.getenv('CMA_MIGRATE_DB_PASS', '')}",
f"host={os.getenv('CMA_MIGRATE_DB_HOST', '127.0.0.1')}",
f"port={os.getenv('CMA_MIGRATE_DB_PORT', '3306')}",
]
else:
lines += ["user=root", f"socket={os.getenv('CMA_MIGRATE_DB_SOCKET', DEFAULT_SOCKET)}"]
with open(cfg, "w", encoding="utf-8") as fh:
fh.write("\n".join(lines) + "\n")
out = os.path.join(
journal_dir(), f"dump-{datetime.now().strftime('%Y%m%dT%H%M%S')}.sql"
)
cmd = ["mysqldump", f"--defaults-extra-file={cfg}", "--no-tablespaces", "--skip-add-locks", db_name, *tables]
with open(out, "wb") as fh:
subprocess.run(cmd, stdout=fh, stderr=subprocess.PIPE, check=True)
return out
finally:
os.unlink(cfg)
def cmd_rollback(args, engine, conn_desc) -> int:
path = resolve_journal(args.rollback)
with open(path, encoding="utf-8") as fh:
payload = json.load(fh)
tables = payload.get("created_tables") or []
print(f"[rollback] journal: {path}")
print(f"[rollback] 该次迁移新建的表: {tables if tables else '(无)'}")
if not tables:
print("[rollback] 无新建对象 → 无需回滚(空操作)")
return 0
db_name = payload.get("db_name") or _resolve_db_name(args)
if not args.no_dump:
try:
dump_path = _dump_tables(tables, db_name)
print(f"[rollback] 已备份: {dump_path}")
except Exception as exc:
print(f"[FAIL] 备份失败 → 拒绝回滚(fail-closed):{type(exc).__name__}: {exc}")
return 1
with engine.connect() as conn:
rows = {}
for t in tables:
try:
rows[t] = conn.execute(text(f"SELECT COUNT(*) FROM `{t}`")).scalar()
except Exception:
rows[t] = 0
non_empty = {t: n for t, n in rows.items() if n}
if non_empty and not args.force:
print(f"[REFUSED] 以下表已有数据,未加 --force 拒绝删除:{non_empty}")
return 3
for t in tables:
conn.execute(text(f"DROP TABLE IF EXISTS `{t}`"))
conn.commit()
print(f"[rollback] 已删除 {len(tables)} 张表: {tables}")
payload["rolled_back_at"] = datetime.now().isoformat(timespec="seconds")
payload["rolled_back_by"] = f"{os.getenv('USER', 'unknown')}@{socket.gethostname()}"
payload["rollback_dump"] = None if args.no_dump else "见同目录 dump-*.sql"
base = os.path.basename(path)
archived = os.path.join(journal_dir(), base.replace("-applied.json", "-rolled_back.json"))
with open(archived, "w", encoding="utf-8") as fh:
json.dump(payload, fh, ensure_ascii=False, indent=2)
print(f"[rollback] 记录: {archived}")
return 0
def cmd_status(args) -> int:
items = list_journals()
if not items:
print("[status] 无 journal 记录")
return 0
for p in items[:20]:
try:
with open(p, encoding="utf-8") as fh:
j = json.load(fh)
print(
f"{os.path.basename(p)} status={j.get('status')} created_new={j.get('created_new')} "
f"tables={len(j.get('created_tables') or [])} git={j.get('git_head')} by={j.get('actor')}"
)
except Exception as exc:
print(f"{os.path.basename(p)} [unreadable] {exc}")
return 0
# ────────────────────────────────────────────────────────────────
def main() -> int:
ap = argparse.ArgumentParser(
description="CMA 特权数据库迁移步(与应用启动 create_all 剥离)",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
g = ap.add_mutually_exclusive_group()
g.add_argument("--check", action="store_true", help="只读预检(默认)")
g.add_argument("--apply", action="store_true", help="幂等建表 + journal")
g.add_argument("--rollback", metavar="JOURNAL", help="回滚某次 --apply 新建的表(latest 或文件名)")
g.add_argument("--status", action="store_true", help="列出 journal 历史")
ap.add_argument("--json", action="store_true", help="机器可读输出")
ap.add_argument("--force", action="store_true", help="回滚允许删有数据的表")
ap.add_argument("--no-dump", action="store_true", help="回滚前不 dump")
ap.add_argument("--socket", default=None, help=f"unix socket 路径(默认 {DEFAULT_SOCKET})")
ap.add_argument("--db-url", default=None, help="显式连接串(覆盖推导)")
ap.add_argument("--db-name", default=None, help="目标库名")
args = ap.parse_args()
if not (args.apply or args.rollback or args.status):
args.check = True # 默认只读预检
if args.status:
return cmd_status(args)
engine, conn_desc = build_engine(args)
# 迁移互斥:防并发会话/手工操作同时跑 DDL
lock_path = os.path.join(journal_dir(), ".db-migrate.lock")
lock_fh = open(lock_path, "a+")
try:
fcntl.flock(lock_fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
print("[FAIL] 另一个迁移正在运行(锁被占用)→ 本次中止")
return 1
if args.rollback:
return cmd_rollback(args, engine, conn_desc)
if args.apply:
return cmd_apply(args, engine, conn_desc)
return cmd_check(args, engine, conn_desc)
if __name__ == "__main__":
sys.exit(main())