448 lines
20 KiB
Python
448 lines
20 KiB
Python
# ============================================================
|
||
# 审批文件后端 → database 后端存量迁移(moduleId: approval-migrate, 可重生 ✅)
|
||
# 矩阵 117 行剩余项:文件→DB 存量迁移可靠性(round-67 R67-B1)。
|
||
# 安全不变量:
|
||
# 1. grant 只持久化 token_digest(raw_token),绝不落 raw token;
|
||
# 2. 文件缺失维持空源语义;文件存在但 JSON/schema/记录损坏一律 fail closed;
|
||
# 3. 全部写入在单个 SQLAlchemy Session 事务内 flush/commit,后段异常整体回滚;
|
||
# 4. 幂等按「不可变身份字段冲突即拒绝 + 单调状态只前进不回退」判定;
|
||
# 5. 迁移要求 APS_APPROVAL_MIGRATION_QUIESCED=1,并持有与文件后端相同的
|
||
# `{source}.lock` 进程锁直到事务提交;flush 后复核源文件 SHA-256,
|
||
# 漂移即回滚。
|
||
# dry_run 只预检与报告,不写库。
|
||
# ============================================================
|
||
from __future__ import annotations
|
||
|
||
import copy
|
||
import hashlib
|
||
import json
|
||
import math
|
||
import os
|
||
import time
|
||
from collections.abc import Iterator
|
||
from contextlib import contextmanager
|
||
from typing import Any
|
||
|
||
from sqlalchemy import select
|
||
from sqlalchemy.exc import SQLAlchemyError
|
||
|
||
from server.agent_core.approval_db_store import DatabaseApprovalStore
|
||
from server.agent_core.approval_store import (
|
||
_SCHEMA_VERSION,
|
||
default_approval_path,
|
||
token_digest,
|
||
)
|
||
from server.db.database import get_session
|
||
from server.db.models import (
|
||
ApprovalEventRecord,
|
||
ApprovalGrantRecord,
|
||
ApprovalRequestRecord,
|
||
)
|
||
|
||
_QUIESCED_ENV = "APS_APPROVAL_MIGRATION_QUIESCED"
|
||
_HEX_DIGEST_LEN = 64
|
||
|
||
_REQUEST_IDENTITY_FIELDS = ("action", "power", "paramsHash", "tenantUuid", "projectId", "ownerUserId")
|
||
_GRANT_IDENTITY_FIELDS = ("confirmId", "action", "paramsHash", "tenantUuid", "projectId", "ownerUserId")
|
||
_GRANT_STATUS_RANK = {"ACTIVE": 0, "CONSUMED": 1, "EXPIRED": 1}
|
||
|
||
|
||
class MigrationError(RuntimeError):
|
||
"""迁移 fail-closed:源损坏、身份冲突、状态回退、未停写或源漂移。"""
|
||
|
||
|
||
def _optional_int(value: Any) -> int | None:
|
||
try:
|
||
parsed = int(value)
|
||
except (TypeError, ValueError):
|
||
return None
|
||
return parsed or None
|
||
|
||
|
||
def _optional_float(value: Any) -> float | None:
|
||
try:
|
||
return float(value)
|
||
except (TypeError, ValueError):
|
||
return None
|
||
|
||
|
||
def _optional_str(value: Any) -> str | None:
|
||
return str(value) if value is not None else None
|
||
|
||
|
||
def _sha256_path(path: str) -> str:
|
||
digest = hashlib.sha256()
|
||
with open(path, "rb") as handle:
|
||
for chunk in iter(lambda: handle.read(65536), b""):
|
||
digest.update(chunk)
|
||
return digest.hexdigest()
|
||
|
||
|
||
@contextmanager
|
||
def _source_lock(path: str) -> Iterator[None]:
|
||
"""与文件后端 ApprovalStore 相同的 `{path}.lock` 跨进程锁(持有到事务提交)。"""
|
||
lock_path = f"{path}.lock"
|
||
parent = os.path.dirname(lock_path) or "."
|
||
os.makedirs(parent, exist_ok=True)
|
||
with open(lock_path, "a+b") as handle:
|
||
handle.seek(0, os.SEEK_END)
|
||
if handle.tell() == 0:
|
||
handle.write(b"\0")
|
||
handle.flush()
|
||
handle.seek(0)
|
||
if os.name == "nt":
|
||
import msvcrt
|
||
|
||
msvcrt.locking(handle.fileno(), msvcrt.LK_LOCK, 1)
|
||
try:
|
||
yield
|
||
finally:
|
||
handle.seek(0)
|
||
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
|
||
else:
|
||
import fcntl
|
||
|
||
fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
|
||
try:
|
||
yield
|
||
finally:
|
||
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
||
|
||
|
||
def _canonical_event_digest(event: dict[str, Any]) -> str:
|
||
"""完整事件 canonical digest:同秒同状态但 approver/note/payload 不同的事件必须区分。"""
|
||
blob = json.dumps(event, sort_keys=True, ensure_ascii=False, separators=(",", ":"), default=str)
|
||
return hashlib.sha256(blob.encode("utf-8")).hexdigest()
|
||
|
||
|
||
def _validate_store_record(kind: str, key: Any, record: Any) -> None:
|
||
if not isinstance(key, str) or not isinstance(record, dict):
|
||
raise MigrationError(f"审批源损坏:{kind} 记录键/值类型非法(key={key!r})")
|
||
try:
|
||
expires_at = float(record.get("expiresAtEpoch"))
|
||
except (TypeError, ValueError):
|
||
raise MigrationError(f"审批源损坏:{kind} 记录 {key} 缺少合法 expiresAtEpoch") from None
|
||
if not math.isfinite(expires_at) or expires_at <= 0:
|
||
raise MigrationError(f"审批源损坏:{kind} 记录 {key} expiresAtEpoch 非法")
|
||
if not isinstance(record.get("action"), str) or not record.get("action"):
|
||
raise MigrationError(f"审批源损坏:{kind} 记录 {key} 缺少 action")
|
||
if not isinstance(record.get("tenantUuid"), str):
|
||
raise MigrationError(f"审批源损坏:{kind} 记录 {key} 缺少 tenantUuid")
|
||
|
||
|
||
def _validate_history(events: Any) -> None:
|
||
if not isinstance(events, list):
|
||
raise MigrationError("审批源损坏:history 不是数组")
|
||
for index, event in enumerate(events):
|
||
if not isinstance(event, dict):
|
||
raise MigrationError(f"审批源损坏:history[{index}] 不是对象")
|
||
if not isinstance(event.get("action"), str) or not isinstance(event.get("status"), str):
|
||
raise MigrationError(f"审批源损坏:history[{index}] 缺少 action/status")
|
||
if _optional_float(event.get("decidedAtEpoch")) is None:
|
||
raise MigrationError(f"审批源损坏:history[{index}] 缺少合法 decidedAtEpoch")
|
||
|
||
|
||
def load_file_store(file_path: str | None = None) -> dict[str, Any]:
|
||
"""读取并校验文件后端审批仓。
|
||
|
||
文件不存在 → 空源语义(不抛错);文件存在但 JSON/schema/记录损坏 → fail closed。
|
||
"""
|
||
path = file_path or default_approval_path()
|
||
try:
|
||
with open(path, "r", encoding="utf-8") as handle:
|
||
raw = json.load(handle)
|
||
except FileNotFoundError:
|
||
return {"schemaVersion": _SCHEMA_VERSION, "pending": {}, "grants": {}, "history": []}
|
||
except (json.JSONDecodeError, OSError, UnicodeDecodeError) as exc:
|
||
raise MigrationError(f"审批源损坏:{path} 无法解析({type(exc).__name__}),已拒绝迁移") from exc
|
||
if not isinstance(raw, dict) or raw.get("schemaVersion") != _SCHEMA_VERSION:
|
||
raise MigrationError(f"审批源损坏:{path} 顶层 schema 非法,已拒绝迁移")
|
||
pending, grants, history = raw.get("pending"), raw.get("grants"), raw.get("history")
|
||
if not isinstance(pending, dict) or not isinstance(grants, dict):
|
||
raise MigrationError(f"审批源损坏:{path} pending/grants 结构非法,已拒绝迁移")
|
||
for confirm_id, record in pending.items():
|
||
_validate_store_record("pending", confirm_id, record)
|
||
for key, record in grants.items():
|
||
_validate_store_record("grants", key, record)
|
||
_validate_history(history)
|
||
return {"schemaVersion": _SCHEMA_VERSION, "pending": pending, "grants": grants, "history": history}
|
||
|
||
|
||
def _identity_of(record: dict[str, Any], fields: tuple[str, ...]) -> tuple[Any, ...]:
|
||
normalized: list[Any] = []
|
||
for field in fields:
|
||
value = record.get(field)
|
||
normalized.append(_optional_int(value) if field == "ownerUserId" else str(value or ""))
|
||
return tuple(normalized)
|
||
|
||
|
||
def _request_identity_matches(row: ApprovalRequestRecord, record: dict[str, Any]) -> bool:
|
||
db_identity = (
|
||
row.action,
|
||
row.power,
|
||
row.params_hash,
|
||
row.tenant_uuid,
|
||
row.project_id,
|
||
row.owner_user_id,
|
||
)
|
||
return db_identity == _identity_of(record, _REQUEST_IDENTITY_FIELDS)
|
||
|
||
|
||
def _grant_identity_matches(row: ApprovalGrantRecord, record: dict[str, Any], confirm_id: str) -> bool:
|
||
db_identity = (
|
||
row.confirm_id,
|
||
row.action,
|
||
row.params_hash,
|
||
row.tenant_uuid,
|
||
row.project_id,
|
||
row.owner_user_id,
|
||
)
|
||
expected = list(_identity_of(record, _GRANT_IDENTITY_FIELDS))
|
||
expected[0] = confirm_id
|
||
return db_identity == tuple(expected)
|
||
|
||
|
||
def _grant_row(digest: str, confirm_id: str, record: dict[str, Any], now_epoch: float) -> ApprovalGrantRecord:
|
||
return ApprovalGrantRecord(
|
||
token_hash=digest,
|
||
confirm_id=confirm_id,
|
||
tenant_uuid=str(record.get("tenantUuid") or "platform"),
|
||
project_id=str(record.get("projectId") or "default"),
|
||
owner_user_id=_optional_int(record.get("ownerUserId")),
|
||
action=str(record.get("action") or ""),
|
||
params_hash=str(record.get("paramsHash") or ""),
|
||
status=str(record.get("status") or "ACTIVE"),
|
||
created_at_epoch=float(record.get("createdAtEpoch") or now_epoch),
|
||
expires_at_epoch=float(record.get("expiresAtEpoch") or now_epoch),
|
||
consumed_at_epoch=_optional_float(record.get("consumedAtEpoch")),
|
||
revision=int(record.get("revision") or 0),
|
||
payload=copy.deepcopy(record),
|
||
)
|
||
|
||
|
||
def _terminal_request_row(confirm_id: str, grant: dict[str, Any], now_epoch: float) -> ApprovalRequestRecord:
|
||
"""grant 缺 pending request 时,依据 grant 重建终态 APPROVED request 以满足外键。"""
|
||
approvals = list(grant.get("approvals") or [])
|
||
power = str(grant.get("power") or "")
|
||
payload = copy.deepcopy(grant)
|
||
payload["confirmId"] = confirm_id
|
||
payload["status"] = "APPROVED"
|
||
payload["migratedTerminalRequest"] = True # 诚实标注:迁移重建,非现场决策
|
||
return ApprovalRequestRecord(
|
||
confirm_id=confirm_id,
|
||
tenant_uuid=str(grant.get("tenantUuid") or "platform"),
|
||
project_id=str(grant.get("projectId") or "default"),
|
||
owner_user_id=_optional_int(grant.get("ownerUserId")),
|
||
action=str(grant.get("action") or ""),
|
||
power=power,
|
||
params_hash=str(grant.get("paramsHash") or ""),
|
||
status="APPROVED",
|
||
approval_step=len(approvals),
|
||
required_approvals=max(len(approvals), 2 if power == "P3" else 1),
|
||
created_at_epoch=float(grant.get("createdAtEpoch") or now_epoch),
|
||
expires_at_epoch=float(grant.get("expiresAtEpoch") or now_epoch),
|
||
revision=0,
|
||
payload=payload,
|
||
)
|
||
|
||
|
||
def migrate_file_to_db(file_path: str | None = None, *, dry_run: bool = False) -> dict[str, Any]:
|
||
"""把文件后端审批存量迁移到 database 后端(幂等、单事务、fail-closed)。
|
||
|
||
前置:APS_APPROVAL_BACKEND=database、DB schema 就绪、
|
||
APS_APPROVAL_MIGRATION_QUIESCED=1(确认文件后端已停写)。
|
||
Returns: {"pending": {migrated, skipped, rebuilt}, "grants": {migrated, skipped, repaired},
|
||
"events": {migrated, skipped}, "dryRun": bool, "source": str,
|
||
"sourceDigest": str}
|
||
"""
|
||
backend = (os.environ.get("APS_APPROVAL_BACKEND") or "file").strip().lower()
|
||
if backend != "database":
|
||
raise MigrationError(
|
||
"approval migration requires APS_APPROVAL_BACKEND=database(请先配置 database 后端)")
|
||
if (os.environ.get(_QUIESCED_ENV) or "").strip() != "1":
|
||
raise MigrationError(
|
||
f"approval migration requires {_QUIESCED_ENV}=1"
|
||
"(请先停止文件后端写入并显式确认停写)")
|
||
source = file_path or default_approval_path()
|
||
result: dict[str, Any] = {
|
||
"pending": {"migrated": 0, "skipped": 0, "rebuilt": 0},
|
||
"grants": {"migrated": 0, "skipped": 0, "repaired": 0},
|
||
"events": {"migrated": 0, "skipped": 0},
|
||
"dryRun": bool(dry_run),
|
||
"source": str(source),
|
||
}
|
||
if not os.path.exists(source):
|
||
result["sourceDigest"] = None # 文件缺失维持空源语义
|
||
return result
|
||
DatabaseApprovalStore() # DB 可用性/schema 门禁
|
||
with _source_lock(str(source)):
|
||
source_digest = _sha256_path(str(source))
|
||
result["sourceDigest"] = source_digest
|
||
data = load_file_store(str(source))
|
||
now_epoch = time.time()
|
||
try:
|
||
with get_session() as session:
|
||
_migrate_pending(session, data, result, dry_run=dry_run)
|
||
_migrate_grants(session, data, result, now_epoch=now_epoch, dry_run=dry_run)
|
||
_migrate_history(session, data, result, dry_run=dry_run)
|
||
if dry_run:
|
||
session.rollback()
|
||
return result
|
||
session.flush()
|
||
if _sha256_path(str(source)) != source_digest:
|
||
session.rollback()
|
||
raise MigrationError("迁移期间审批源文件发生变化,已整体回滚(请在停写后重试)")
|
||
session.commit()
|
||
except MigrationError:
|
||
raise
|
||
except SQLAlchemyError as exc:
|
||
raise MigrationError(f"approval database write failed(已整体回滚):{type(exc).__name__}") from exc
|
||
return result
|
||
|
||
|
||
def _migrate_pending(
|
||
session: Any, data: dict[str, Any], result: dict[str, Any], *, dry_run: bool
|
||
) -> None:
|
||
"""pending 请求:不可变身份字段冲突即拒绝;DB 端已合法前进到终态则不覆盖。"""
|
||
pending = data.get("pending") or {}
|
||
for confirm_id, record in pending.items():
|
||
existing = session.get(ApprovalRequestRecord, confirm_id)
|
||
if existing is not None:
|
||
if not _request_identity_matches(existing, record):
|
||
raise MigrationError(
|
||
f"审批迁移冲突:请求 {confirm_id} 与数据库已有记录的身份字段不一致,已拒绝迁移")
|
||
# 已迁移过,或 DB 端已合法前进到终态(APPROVED/REJECTED/EXPIRED):不覆盖、不回退
|
||
result["pending"]["skipped"] += 1
|
||
continue
|
||
result["pending"]["migrated"] += 1
|
||
if dry_run:
|
||
continue
|
||
payload = copy.deepcopy(record)
|
||
payload.setdefault("confirmId", confirm_id)
|
||
session.add(ApprovalRequestRecord(
|
||
confirm_id=confirm_id,
|
||
tenant_uuid=str(record.get("tenantUuid") or "platform"),
|
||
project_id=str(record.get("projectId") or "default"),
|
||
owner_user_id=_optional_int(record.get("ownerUserId")),
|
||
action=str(record.get("action") or ""),
|
||
power=str(record.get("power") or ""),
|
||
params_hash=str(record.get("paramsHash") or ""),
|
||
status="PENDING",
|
||
approval_step=int(record.get("approvalStep") or 0),
|
||
required_approvals=int(record.get("requiredApprovals") or 1),
|
||
created_at_epoch=float(record.get("createdAtEpoch") or 0),
|
||
expires_at_epoch=float(record.get("expiresAtEpoch") or 0),
|
||
revision=0,
|
||
payload=payload,
|
||
))
|
||
|
||
|
||
def _migrate_grants(
|
||
session: Any, data: dict[str, Any], result: dict[str, Any], *, now_epoch: float, dry_run: bool
|
||
) -> None:
|
||
"""grants:只落 digest;legacy raw-token 行一致时原子修复,冲突 fail-closed。"""
|
||
grants = data.get("grants") or {}
|
||
pending = data.get("pending") or {}
|
||
planned_confirm_ids: set[str] = set()
|
||
for raw_key, record in grants.items():
|
||
confirm_id = str(record.get("confirmId") or "")
|
||
if not confirm_id:
|
||
raise MigrationError(f"审批源损坏:grant {raw_key} 缺少 confirmId,已拒绝迁移")
|
||
# 守卫:文件键本身已是 DB 已知 digest 时按已迁移处理,禁止再哈希
|
||
key_is_digest = len(raw_key) == _HEX_DIGEST_LEN and all(
|
||
ch in "0123456789abcdef" for ch in raw_key.lower())
|
||
existing_by_key = session.get(ApprovalGrantRecord, raw_key)
|
||
if key_is_digest and existing_by_key is not None:
|
||
if not _grant_identity_matches(existing_by_key, record, confirm_id):
|
||
raise MigrationError(
|
||
f"审批迁移冲突:grant {raw_key[:12]}… 与数据库已有记录的身份字段不一致,已拒绝迁移")
|
||
result["grants"]["skipped"] += 1
|
||
continue
|
||
digest = token_digest(raw_key)
|
||
existing_digest = session.get(ApprovalGrantRecord, digest)
|
||
if existing_digest is not None and existing_by_key is not None:
|
||
raise MigrationError(
|
||
f"审批迁移冲突:grant {raw_key[:12]}… 在数据库中同时存在 raw/digest 两行,已拒绝迁移")
|
||
if existing_digest is not None:
|
||
if not _grant_identity_matches(existing_digest, record, confirm_id):
|
||
raise MigrationError(
|
||
f"审批迁移冲突:grant {raw_key[:12]}… 与数据库已有记录的身份字段不一致,已拒绝迁移")
|
||
db_rank = _GRANT_STATUS_RANK.get(existing_digest.status, 0)
|
||
src_rank = _GRANT_STATUS_RANK.get(str(record.get("status") or "ACTIVE"), 0)
|
||
if db_rank >= src_rank:
|
||
result["grants"]["skipped"] += 1 # 合法前进或一致:不覆盖、不回退
|
||
continue
|
||
result["grants"]["migrated"] += 1 # 源更新:单调前进 DB 状态
|
||
if not dry_run:
|
||
existing_digest.status = str(record.get("status") or "ACTIVE")
|
||
existing_digest.consumed_at_epoch = _optional_float(record.get("consumedAtEpoch"))
|
||
existing_digest.revision = int(existing_digest.revision) + 1
|
||
merged = copy.deepcopy(existing_digest.payload)
|
||
merged.update(copy.deepcopy(record))
|
||
existing_digest.payload = merged
|
||
continue
|
||
if existing_by_key is not None:
|
||
# legacy raw-token 主键行:一致 → 事务内原子修复为 digest;不一致 → fail-closed
|
||
if not _grant_identity_matches(existing_by_key, record, confirm_id):
|
||
raise MigrationError(
|
||
f"审批迁移冲突:legacy grant {raw_key[:12]}… 与源记录身份字段不一致,已拒绝迁移")
|
||
result["grants"]["repaired"] += 1
|
||
if not dry_run:
|
||
repaired = _grant_row(digest, confirm_id, record, now_epoch)
|
||
repaired.status = existing_by_key.status # 保留 DB 端合法前进后的状态
|
||
repaired.consumed_at_epoch = existing_by_key.consumed_at_epoch
|
||
repaired.revision = int(existing_by_key.revision)
|
||
session.delete(existing_by_key)
|
||
session.flush()
|
||
session.add(repaired)
|
||
continue
|
||
result["grants"]["migrated"] += 1
|
||
if confirm_id in planned_confirm_ids:
|
||
raise MigrationError(
|
||
f"审批迁移冲突:confirmId {confirm_id} 对应多个待插入 grant(唯一约束),已拒绝迁移")
|
||
planned_confirm_ids.add(confirm_id)
|
||
# grant 缺 pending request 且 DB 无该 request → 重建终态 APPROVED request 满足外键
|
||
needs_rebuild = (
|
||
confirm_id not in pending
|
||
and session.get(ApprovalRequestRecord, confirm_id) is None
|
||
)
|
||
if needs_rebuild:
|
||
result["pending"]["rebuilt"] += 1
|
||
if dry_run:
|
||
continue
|
||
if needs_rebuild:
|
||
session.add(_terminal_request_row(confirm_id, record, now_epoch))
|
||
session.add(_grant_row(digest, confirm_id, record, now_epoch))
|
||
|
||
|
||
def _migrate_history(
|
||
session: Any, data: dict[str, Any], result: dict[str, Any], *, dry_run: bool
|
||
) -> None:
|
||
"""history:完整 canonical event digest 去重;近似但不相同的事件全部保留。"""
|
||
events = data.get("history") or []
|
||
existing_digests = {
|
||
_canonical_event_digest(payload)
|
||
for payload in session.execute(select(ApprovalEventRecord.payload)).scalars()
|
||
}
|
||
batch_digests: set[str] = set()
|
||
for event in events:
|
||
digest = _canonical_event_digest(event)
|
||
if digest in existing_digests or digest in batch_digests:
|
||
result["events"]["skipped"] += 1
|
||
continue
|
||
batch_digests.add(digest)
|
||
result["events"]["migrated"] += 1
|
||
if dry_run:
|
||
continue
|
||
session.add(ApprovalEventRecord(
|
||
confirm_id=_optional_str(event.get("confirmId")),
|
||
tenant_uuid=str(event.get("tenantUuid") or "platform"),
|
||
project_id=str(event.get("projectId") or "default"),
|
||
owner_user_id=_optional_int(event.get("ownerUserId")),
|
||
action=str(event.get("action") or ""),
|
||
power=str(event.get("power") or ""),
|
||
status=str(event.get("status") or ""),
|
||
decided_at_epoch=float(event.get("decidedAtEpoch") or 0),
|
||
payload=copy.deepcopy(event),
|
||
))
|