664 lines
28 KiB
Python
664 lines
28 KiB
Python
# ============================================================
|
||
# 审批文件后端 -> database 后端存量迁移(moduleId: approval-migrate)
|
||
# Round 67: fail-closed validation, stable source snapshot, one DB transaction.
|
||
# ============================================================
|
||
from __future__ import annotations
|
||
|
||
import copy
|
||
import hashlib
|
||
import json
|
||
import math
|
||
import os
|
||
from collections.abc import Iterator
|
||
from contextlib import contextmanager
|
||
from datetime import UTC, datetime
|
||
from typing import Any
|
||
|
||
from sqlalchemy import select
|
||
from sqlalchemy.exc import IntegrityError, OperationalError
|
||
|
||
from server.agent_core.approval_db_store import DatabaseApprovalStore
|
||
from server.agent_core.approval_store import default_approval_path, token_digest
|
||
from server.db.database import get_session
|
||
from server.db.models import (
|
||
ApprovalEventRecord,
|
||
ApprovalGrantRecord,
|
||
ApprovalRequestRecord,
|
||
)
|
||
|
||
_SCHEMA_VERSION = 1
|
||
_EMPTY_SOURCE = {"schemaVersion": 1, "pending": {}, "grants": {}, "history": []}
|
||
_REQUEST_STATUS_FORWARD = {
|
||
"PENDING": frozenset({"PENDING", "APPROVED", "REJECTED", "EXPIRED"}),
|
||
"APPROVED": frozenset({"APPROVED"}),
|
||
"REJECTED": frozenset({"REJECTED"}),
|
||
"EXPIRED": frozenset({"EXPIRED"}),
|
||
}
|
||
_GRANT_STATUS_FORWARD = {
|
||
"ACTIVE": frozenset({"ACTIVE", "CONSUMED", "EXPIRED"}),
|
||
"CONSUMED": frozenset({"CONSUMED"}),
|
||
"EXPIRED": frozenset({"EXPIRED"}),
|
||
}
|
||
_EVENT_STATUSES = frozenset({
|
||
"APPROVED", "CONSUMED", "EXECUTED", "EXPIRED", "GRANT_EXPIRED",
|
||
"PARTIALLY_APPROVED", "PENDING", "REJECTED", "SOD_DENIED", "TRANSFERRED",
|
||
})
|
||
|
||
|
||
class ApprovalMigrationError(RuntimeError):
|
||
"""The source or target cannot be migrated without risking approval data."""
|
||
|
||
|
||
def _optional_int(value: Any) -> int | None:
|
||
try:
|
||
return int(value)
|
||
except (TypeError, ValueError):
|
||
return 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 _canonical_json(value: Any) -> str:
|
||
try:
|
||
return json.dumps(
|
||
value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False
|
||
)
|
||
except (TypeError, ValueError) as exc:
|
||
raise ApprovalMigrationError("approval migration record is not canonical JSON") from exc
|
||
|
||
|
||
def _canonical_digest(value: Any) -> str:
|
||
return hashlib.sha256(_canonical_json(value).encode("utf-8")).hexdigest()
|
||
|
||
|
||
def _reject_json_constant(value: str) -> Any:
|
||
raise ApprovalMigrationError(f"approval source contains invalid JSON constant: {value}")
|
||
|
||
|
||
def _unique_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
|
||
result: dict[str, Any] = {}
|
||
for key, value in pairs:
|
||
if key in result:
|
||
raise ApprovalMigrationError("approval source contains duplicate JSON object keys")
|
||
result[key] = value
|
||
return result
|
||
|
||
|
||
def _required_string(record: dict[str, Any], key: str, context: str) -> str:
|
||
value = record.get(key)
|
||
if not isinstance(value, str) or not value.strip():
|
||
raise ApprovalMigrationError(f"{context}.{key} must be a non-empty string")
|
||
return value
|
||
|
||
|
||
def _required_epoch(record: dict[str, Any], key: str, context: str) -> float:
|
||
value = record.get(key)
|
||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||
raise ApprovalMigrationError(f"{context}.{key} must be a finite epoch number")
|
||
parsed = float(value)
|
||
if not math.isfinite(parsed) or parsed <= 0:
|
||
raise ApprovalMigrationError(f"{context}.{key} must be a positive finite epoch")
|
||
return parsed
|
||
|
||
|
||
def _nonnegative_int(record: dict[str, Any], key: str, default: int, context: str) -> int:
|
||
value = record.get(key, default)
|
||
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
|
||
raise ApprovalMigrationError(f"{context}.{key} must be a non-negative integer")
|
||
return value
|
||
|
||
|
||
def _validate_owner(record: dict[str, Any], context: str) -> None:
|
||
owner = record.get("ownerUserId")
|
||
if owner is not None and (isinstance(owner, bool) or not isinstance(owner, int)):
|
||
raise ApprovalMigrationError(f"{context}.ownerUserId must be an integer or null")
|
||
|
||
|
||
def _validate_identity(record: dict[str, Any], context: str) -> None:
|
||
for key in ("confirmId", "action", "power", "tenantUuid", "projectId", "paramsHash"):
|
||
_required_string(record, key, context)
|
||
if record["power"] not in {"P2", "P3"}:
|
||
raise ApprovalMigrationError(f"{context}.power must be P2 or P3")
|
||
_validate_owner(record, context)
|
||
|
||
|
||
def _validate_pending(confirm_id: str, record: Any) -> None:
|
||
context = f"pending[{confirm_id!r}]"
|
||
if not isinstance(record, dict):
|
||
raise ApprovalMigrationError(f"{context} must be an object")
|
||
_validate_identity(record, context)
|
||
if record["confirmId"] != confirm_id:
|
||
raise ApprovalMigrationError(f"{context}.confirmId does not match its key")
|
||
created = _required_epoch(record, "createdAtEpoch", context)
|
||
expires = _required_epoch(record, "expiresAtEpoch", context)
|
||
if expires < created:
|
||
raise ApprovalMigrationError(f"{context}.expiresAtEpoch precedes createdAtEpoch")
|
||
approvals = record.get("approvals", [])
|
||
if not isinstance(approvals, list) or any(not isinstance(item, dict) for item in approvals):
|
||
raise ApprovalMigrationError(f"{context}.approvals must be an array of objects")
|
||
required = _nonnegative_int(
|
||
record, "requiredApprovals", 2 if record["power"] == "P3" else 1, context
|
||
)
|
||
if required < 1:
|
||
raise ApprovalMigrationError(f"{context}.requiredApprovals must be positive")
|
||
step = _nonnegative_int(record, "approvalStep", len(approvals), context)
|
||
if step > required or len(approvals) < step:
|
||
raise ApprovalMigrationError(f"{context} approval progress is inconsistent")
|
||
status = record.get("status", "PENDING")
|
||
if status != "PENDING":
|
||
raise ApprovalMigrationError(f"{context}.status must be PENDING")
|
||
_nonnegative_int(record, "revision", 0, context)
|
||
_canonical_json(record)
|
||
|
||
|
||
def _validate_grant(raw_token: str, record: Any) -> None:
|
||
context = "grant"
|
||
if not isinstance(raw_token, str) or not raw_token:
|
||
raise ApprovalMigrationError("grant token key must be a non-empty string")
|
||
if not isinstance(record, dict):
|
||
raise ApprovalMigrationError(f"{context} must be an object")
|
||
_validate_identity(record, context)
|
||
created = _required_epoch(record, "createdAtEpoch", context)
|
||
expires = _required_epoch(record, "expiresAtEpoch", context)
|
||
if expires < created:
|
||
raise ApprovalMigrationError(f"{context}.expiresAtEpoch precedes createdAtEpoch")
|
||
status = str(record.get("status") or "ACTIVE")
|
||
if status not in _GRANT_STATUS_FORWARD:
|
||
raise ApprovalMigrationError(f"{context}.status is invalid")
|
||
consumed = record.get("consumedAtEpoch")
|
||
if status == "CONSUMED":
|
||
consumed_epoch = _required_epoch(record, "consumedAtEpoch", context)
|
||
if consumed_epoch < created:
|
||
raise ApprovalMigrationError(f"{context}.consumedAtEpoch precedes createdAtEpoch")
|
||
elif consumed is not None:
|
||
raise ApprovalMigrationError(f"{context}.consumedAtEpoch requires CONSUMED status")
|
||
_nonnegative_int(record, "revision", 0, context)
|
||
_canonical_json(record)
|
||
|
||
|
||
def _validate_event(event: Any, index: int) -> None:
|
||
context = f"history[{index}]"
|
||
if not isinstance(event, dict):
|
||
raise ApprovalMigrationError(f"{context} must be an object")
|
||
for key in ("confirmId", "action", "power", "status", "tenantUuid", "projectId"):
|
||
_required_string(event, key, context)
|
||
if event["power"] not in {"P2", "P3"}:
|
||
raise ApprovalMigrationError(f"{context}.power must be P2 or P3")
|
||
if event["status"] not in _EVENT_STATUSES:
|
||
raise ApprovalMigrationError(f"{context}.status is invalid")
|
||
_validate_owner(event, context)
|
||
if "decidedAtEpoch" in event:
|
||
_required_epoch(event, "decidedAtEpoch", context)
|
||
else:
|
||
decided_at = event.get("decidedAt")
|
||
if not isinstance(decided_at, str) or not decided_at.strip():
|
||
raise ApprovalMigrationError(
|
||
f"{context} requires decidedAtEpoch or decidedAt"
|
||
)
|
||
try:
|
||
normalized = decided_at.strip().replace("Z", "+00:00")
|
||
parsed = datetime.fromisoformat(normalized)
|
||
if parsed.tzinfo is None:
|
||
parsed = parsed.replace(tzinfo=UTC)
|
||
epoch = parsed.timestamp()
|
||
except (TypeError, ValueError, OverflowError) as exc:
|
||
raise ApprovalMigrationError(f"{context}.decidedAt is invalid") from exc
|
||
if not math.isfinite(epoch) or epoch <= 0:
|
||
raise ApprovalMigrationError(f"{context}.decidedAt is invalid")
|
||
event["decidedAtEpoch"] = epoch
|
||
_canonical_json(event)
|
||
|
||
|
||
def _validate_source(data: Any) -> dict[str, Any]:
|
||
if not isinstance(data, dict):
|
||
raise ApprovalMigrationError("approval source root must be an object")
|
||
expected = {"schemaVersion", "pending", "grants", "history"}
|
||
if set(data) != expected or data.get("schemaVersion") != _SCHEMA_VERSION:
|
||
raise ApprovalMigrationError("approval source schema is invalid")
|
||
pending, grants, history = data["pending"], data["grants"], data["history"]
|
||
if not isinstance(pending, dict) or not isinstance(grants, dict) or not isinstance(history, list):
|
||
raise ApprovalMigrationError("approval source collections have invalid shapes")
|
||
for confirm_id, record in pending.items():
|
||
if not isinstance(confirm_id, str) or not confirm_id:
|
||
raise ApprovalMigrationError("pending key must be a non-empty string")
|
||
_validate_pending(confirm_id, record)
|
||
seen_confirm_ids: set[str] = set()
|
||
seen_digests: set[str] = set()
|
||
for raw_token, record in grants.items():
|
||
_validate_grant(raw_token, record)
|
||
confirm_id = str(record["confirmId"])
|
||
digest = token_digest(raw_token)
|
||
if confirm_id in seen_confirm_ids or digest in seen_digests:
|
||
raise ApprovalMigrationError("approval source grants are not one-to-one")
|
||
seen_confirm_ids.add(confirm_id)
|
||
seen_digests.add(digest)
|
||
for index, event in enumerate(history):
|
||
_validate_event(event, index)
|
||
_validate_cross_references(pending, grants, history)
|
||
return copy.deepcopy(data)
|
||
|
||
|
||
def _identity_values(record: dict[str, Any]) -> tuple[Any, ...]:
|
||
return tuple(record.get(key) for key in ("confirmId", "action", "power", "tenantUuid", "projectId", "paramsHash"))
|
||
|
||
|
||
def _validate_cross_references(
|
||
pending: dict[str, dict[str, Any]],
|
||
grants: dict[str, dict[str, Any]],
|
||
history: list[dict[str, Any]],
|
||
) -> None:
|
||
identities: dict[str, dict[str, Any]] = dict(pending)
|
||
history_by_confirm: dict[str, list[dict[str, Any]]] = {}
|
||
for event in history:
|
||
history_by_confirm.setdefault(str(event["confirmId"]), []).append(event)
|
||
terminal_evidence = {
|
||
"APPROVED",
|
||
"CONSUMED",
|
||
"EXECUTED",
|
||
"EXPIRED",
|
||
"GRANT_EXPIRED",
|
||
}
|
||
for record in grants.values():
|
||
confirm_id = str(record["confirmId"])
|
||
existing = identities.get(confirm_id)
|
||
if existing is not None and _identity_values(existing) != _identity_values(record):
|
||
raise ApprovalMigrationError(f"approval source identity conflict for {confirm_id}")
|
||
if existing is None:
|
||
evidence = history_by_confirm.get(confirm_id, [])
|
||
if not evidence or not any(
|
||
event.get("status") in terminal_evidence for event in evidence
|
||
):
|
||
raise ApprovalMigrationError(
|
||
f"approval grant {confirm_id} lacks terminal request history"
|
||
)
|
||
identities.setdefault(confirm_id, record)
|
||
for event in history:
|
||
confirm_id = str(event["confirmId"])
|
||
existing = identities.get(confirm_id)
|
||
if existing is None:
|
||
continue
|
||
for key in ("action", "power", "tenantUuid", "projectId", "paramsHash"):
|
||
if key in event and event.get(key) != existing.get(key):
|
||
raise ApprovalMigrationError(f"approval history identity conflict for {confirm_id}")
|
||
|
||
|
||
def _parse_source_bytes(raw: bytes, source: str) -> dict[str, Any]:
|
||
try:
|
||
text = raw.decode("utf-8")
|
||
parsed = json.loads(
|
||
text, object_pairs_hook=_unique_object, parse_constant=_reject_json_constant
|
||
)
|
||
except ApprovalMigrationError:
|
||
raise
|
||
except (UnicodeDecodeError, json.JSONDecodeError, TypeError) as exc:
|
||
raise ApprovalMigrationError(f"approval source is invalid JSON: {source}") from exc
|
||
return _validate_source(parsed)
|
||
|
||
|
||
def _read_source_snapshot(source: str) -> tuple[dict[str, Any], str | None]:
|
||
try:
|
||
with open(source, "rb") as handle:
|
||
raw = handle.read()
|
||
except FileNotFoundError:
|
||
return copy.deepcopy(_EMPTY_SOURCE), None
|
||
except OSError as exc:
|
||
raise ApprovalMigrationError(f"approval source cannot be read: {source}") from exc
|
||
return _parse_source_bytes(raw, source), hashlib.sha256(raw).hexdigest()
|
||
|
||
|
||
def load_file_store(file_path: str | None = None) -> dict[str, Any]:
|
||
"""Read and strictly validate a file approval store; only a missing file is empty."""
|
||
source = os.fspath(file_path or default_approval_path())
|
||
data, _digest = _read_source_snapshot(source)
|
||
return data
|
||
|
||
|
||
@contextmanager
|
||
def _source_lock(source: str) -> Iterator[None]:
|
||
lock_path = f"{source}.lock"
|
||
os.makedirs(os.path.dirname(lock_path) or ".", 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 _required_approvals(record: dict[str, Any]) -> int:
|
||
return int(record.get("requiredApprovals", 2 if record.get("power") == "P3" else 1))
|
||
|
||
|
||
def _approval_step(record: dict[str, Any]) -> int:
|
||
return int(record.get("approvalStep", len(record.get("approvals") or [])))
|
||
|
||
|
||
def _request_status(record: dict[str, Any], *, reconstructed: bool) -> str:
|
||
return "APPROVED" if reconstructed else str(record.get("status") or "PENDING")
|
||
|
||
|
||
def _reconstruct_request(
|
||
grant: dict[str, Any], history: list[dict[str, Any]]
|
||
) -> dict[str, Any]:
|
||
confirm_id = str(grant["confirmId"])
|
||
approved = [
|
||
event for event in history
|
||
if event.get("confirmId") == confirm_id and event.get("status") == "APPROVED"
|
||
]
|
||
evidence = max(approved, key=lambda item: float(item["decidedAtEpoch"])) if approved else {}
|
||
record = copy.deepcopy(grant)
|
||
for key in ("sessionId", "worldKey", "requester", "approvals", "createdAt", "expiresAt"):
|
||
if key not in record and key in evidence:
|
||
record[key] = copy.deepcopy(evidence[key])
|
||
record.pop("status", None)
|
||
record.pop("consumedAtEpoch", None)
|
||
record["confirmId"] = confirm_id
|
||
required = max(2 if record.get("power") == "P3" else 1, len(record.get("approvals") or []))
|
||
record["requiredApprovals"] = required
|
||
record["approvalStep"] = required
|
||
record["needsSecondConfirm"] = False
|
||
return record
|
||
|
||
|
||
def _prepare_requests(data: dict[str, Any]) -> dict[str, tuple[dict[str, Any], bool]]:
|
||
requests = {key: (copy.deepcopy(value), False) for key, value in data["pending"].items()}
|
||
for grant in data["grants"].values():
|
||
confirm_id = str(grant["confirmId"])
|
||
if confirm_id not in requests:
|
||
requests[confirm_id] = (_reconstruct_request(grant, data["history"]), True)
|
||
return requests
|
||
|
||
|
||
def _same_number(left: Any, right: Any) -> bool:
|
||
try:
|
||
return math.isclose(float(left), float(right), rel_tol=0.0, abs_tol=1e-9)
|
||
except (TypeError, ValueError):
|
||
return False
|
||
|
||
|
||
def _check_payload_identity(
|
||
payload: Any,
|
||
record: dict[str, Any],
|
||
*,
|
||
context: str,
|
||
) -> None:
|
||
if not isinstance(payload, dict):
|
||
raise ApprovalMigrationError(f"{context} payload must be an object")
|
||
for key in ("confirmId", "tenantUuid", "projectId", "action", "power", "paramsHash"):
|
||
if payload.get(key) != record.get(key):
|
||
raise ApprovalMigrationError(f"{context} payload immutable conflict")
|
||
if _optional_int(payload.get("ownerUserId")) != _optional_int(record.get("ownerUserId")):
|
||
raise ApprovalMigrationError(f"{context} payload owner conflict")
|
||
|
||
|
||
def _check_request_row(
|
||
row: ApprovalRequestRecord, record: dict[str, Any], source_status: str
|
||
) -> None:
|
||
expected = {
|
||
"confirm_id": record["confirmId"],
|
||
"tenant_uuid": record["tenantUuid"],
|
||
"project_id": record["projectId"],
|
||
"action": record["action"],
|
||
"power": record["power"],
|
||
"params_hash": record["paramsHash"],
|
||
"required_approvals": _required_approvals(record),
|
||
}
|
||
for field, value in expected.items():
|
||
if getattr(row, field) != value:
|
||
raise ApprovalMigrationError(f"approval request immutable conflict for {record['confirmId']}")
|
||
_check_payload_identity(
|
||
row.payload,
|
||
record,
|
||
context=f"approval request {record['confirmId']}",
|
||
)
|
||
if not _same_number(row.created_at_epoch, record["createdAtEpoch"]) or not _same_number(
|
||
row.expires_at_epoch, record["expiresAtEpoch"]
|
||
):
|
||
raise ApprovalMigrationError(f"approval request timestamp conflict for {record['confirmId']}")
|
||
if row.status not in _REQUEST_STATUS_FORWARD[source_status]:
|
||
raise ApprovalMigrationError(f"approval request state regression for {record['confirmId']}")
|
||
if row.approval_step < _approval_step(record) or row.revision < int(record.get("revision") or 0):
|
||
raise ApprovalMigrationError(f"approval request progress regression for {record['confirmId']}")
|
||
|
||
|
||
def _request_row(record: dict[str, Any], status: str) -> ApprovalRequestRecord:
|
||
return ApprovalRequestRecord(
|
||
confirm_id=str(record["confirmId"]),
|
||
tenant_uuid=str(record["tenantUuid"]),
|
||
project_id=str(record["projectId"]),
|
||
owner_user_id=_optional_int(record.get("ownerUserId")),
|
||
action=str(record["action"]),
|
||
power=str(record["power"]),
|
||
params_hash=str(record["paramsHash"]),
|
||
status=status,
|
||
approval_step=_approval_step(record),
|
||
required_approvals=_required_approvals(record),
|
||
created_at_epoch=float(record["createdAtEpoch"]),
|
||
expires_at_epoch=float(record["expiresAtEpoch"]),
|
||
revision=int(record.get("revision") or 0),
|
||
payload=copy.deepcopy(record),
|
||
)
|
||
|
||
|
||
def _check_grant_row(row: ApprovalGrantRecord, record: dict[str, Any]) -> None:
|
||
expected = {
|
||
"confirm_id": record["confirmId"],
|
||
"tenant_uuid": record["tenantUuid"],
|
||
"project_id": record["projectId"],
|
||
"owner_user_id": _optional_int(record.get("ownerUserId")),
|
||
"action": record["action"],
|
||
"params_hash": record["paramsHash"],
|
||
}
|
||
for field, value in expected.items():
|
||
if getattr(row, field) != value:
|
||
raise ApprovalMigrationError(f"approval grant immutable conflict for {record['confirmId']}")
|
||
_check_payload_identity(
|
||
row.payload,
|
||
record,
|
||
context=f"approval grant {record['confirmId']}",
|
||
)
|
||
if not _same_number(row.created_at_epoch, record["createdAtEpoch"]) or not _same_number(
|
||
row.expires_at_epoch, record["expiresAtEpoch"]
|
||
):
|
||
raise ApprovalMigrationError(f"approval grant timestamp conflict for {record['confirmId']}")
|
||
source_status = str(record.get("status") or "ACTIVE")
|
||
if row.status not in _GRANT_STATUS_FORWARD[source_status]:
|
||
raise ApprovalMigrationError(f"approval grant state regression for {record['confirmId']}")
|
||
if row.revision < int(record.get("revision") or 0):
|
||
raise ApprovalMigrationError(f"approval grant revision regression for {record['confirmId']}")
|
||
if row.status == "CONSUMED" and row.consumed_at_epoch is None:
|
||
raise ApprovalMigrationError(f"approval grant consumed state is incomplete for {record['confirmId']}")
|
||
|
||
|
||
def _grant_row(token_hash: str, record: dict[str, Any]) -> ApprovalGrantRecord:
|
||
return ApprovalGrantRecord(
|
||
token_hash=token_hash,
|
||
confirm_id=str(record["confirmId"]),
|
||
tenant_uuid=str(record["tenantUuid"]),
|
||
project_id=str(record["projectId"]),
|
||
owner_user_id=_optional_int(record.get("ownerUserId")),
|
||
action=str(record["action"]),
|
||
params_hash=str(record["paramsHash"]),
|
||
status=str(record.get("status") or "ACTIVE"),
|
||
created_at_epoch=float(record["createdAtEpoch"]),
|
||
expires_at_epoch=float(record["expiresAtEpoch"]),
|
||
consumed_at_epoch=_optional_float(record.get("consumedAtEpoch")),
|
||
revision=int(record.get("revision") or 0),
|
||
payload=copy.deepcopy(record),
|
||
)
|
||
|
||
|
||
def _event_row(event: dict[str, Any]) -> ApprovalEventRecord:
|
||
return ApprovalEventRecord(
|
||
confirm_id=_optional_str(event.get("confirmId")),
|
||
tenant_uuid=str(event["tenantUuid"]),
|
||
project_id=str(event["projectId"]),
|
||
owner_user_id=_optional_int(event.get("ownerUserId")),
|
||
action=str(event["action"]),
|
||
power=str(event["power"]),
|
||
status=str(event["status"]),
|
||
decided_at_epoch=float(event["decidedAtEpoch"]),
|
||
payload=copy.deepcopy(event),
|
||
)
|
||
|
||
|
||
def _assert_source_stable(source: str, expected_digest: str | None) -> None:
|
||
try:
|
||
with open(source, "rb") as handle:
|
||
current_digest = hashlib.sha256(handle.read()).hexdigest()
|
||
except FileNotFoundError:
|
||
current_digest = None
|
||
except OSError as exc:
|
||
raise ApprovalMigrationError("approval source cannot be revalidated") from exc
|
||
if current_digest != expected_digest:
|
||
raise ApprovalMigrationError("approval source changed while migration lock was held")
|
||
|
||
|
||
def migrate_file_to_db(file_path: str | None = None, *, dry_run: bool = False) -> dict[str, Any]:
|
||
"""Atomically migrate a quiesced file approval store to the database backend."""
|
||
backend = (os.environ.get("APS_APPROVAL_BACKEND") or "file").strip().lower()
|
||
if backend != "database":
|
||
raise RuntimeError(
|
||
"approval migration requires APS_APPROVAL_BACKEND=database(请先配置 database 后端)"
|
||
)
|
||
if os.environ.get("APS_APPROVAL_MIGRATION_QUIESCED") != "1":
|
||
raise ApprovalMigrationError("approval migration requires APS_APPROVAL_MIGRATION_QUIESCED=1")
|
||
|
||
source = os.fspath(file_path or default_approval_path())
|
||
DatabaseApprovalStore() # Fail before touching source/target when DB/schema is unavailable.
|
||
result: dict[str, Any] = {
|
||
"pending": {"migrated": 0, "skipped": 0},
|
||
"grants": {"migrated": 0, "skipped": 0},
|
||
"events": {"migrated": 0, "skipped": 0},
|
||
"dryRun": bool(dry_run),
|
||
"source": str(source),
|
||
}
|
||
|
||
with _source_lock(source):
|
||
data, source_digest = _read_source_snapshot(source)
|
||
result["sourceDigest"] = source_digest
|
||
requests = _prepare_requests(data)
|
||
session = get_session()
|
||
try:
|
||
request_ids = sorted(requests)
|
||
existing_requests = {
|
||
row.confirm_id: row
|
||
for row in session.execute(
|
||
select(ApprovalRequestRecord).where(
|
||
ApprovalRequestRecord.confirm_id.in_(request_ids)
|
||
) if request_ids else select(ApprovalRequestRecord).where(False)
|
||
).scalars()
|
||
}
|
||
for confirm_id in request_ids:
|
||
record, reconstructed = requests[confirm_id]
|
||
status = _request_status(record, reconstructed=reconstructed)
|
||
row = existing_requests.get(confirm_id)
|
||
if row is None:
|
||
if not dry_run:
|
||
row = _request_row(record, status)
|
||
session.add(row)
|
||
result["pending"]["migrated"] += 1
|
||
else:
|
||
_check_request_row(row, record, status)
|
||
result["pending"]["skipped"] += 1
|
||
|
||
existing_grants = list(session.execute(select(ApprovalGrantRecord)).scalars())
|
||
grants_by_hash = {row.token_hash: row for row in existing_grants}
|
||
grants_by_confirm = {row.confirm_id: row for row in existing_grants}
|
||
for raw_token, record in sorted(data["grants"].items()):
|
||
expected_hash = token_digest(raw_token)
|
||
confirm_id = str(record["confirmId"])
|
||
digest_row = grants_by_hash.get(expected_hash)
|
||
raw_row = grants_by_hash.get(raw_token) if raw_token != expected_hash else None
|
||
confirm_row = grants_by_confirm.get(confirm_id)
|
||
if digest_row is not None:
|
||
if raw_row is not None or (confirm_row is not None and confirm_row is not digest_row):
|
||
raise ApprovalMigrationError(f"approval grant duplicate identity for {confirm_id}")
|
||
_check_grant_row(digest_row, record)
|
||
result["grants"]["skipped"] += 1
|
||
continue
|
||
if raw_row is not None:
|
||
if confirm_row is not None and confirm_row is not raw_row:
|
||
raise ApprovalMigrationError(f"approval grant legacy identity conflict for {confirm_id}")
|
||
_check_grant_row(raw_row, record)
|
||
if not dry_run:
|
||
del grants_by_hash[raw_token]
|
||
raw_row.token_hash = expected_hash
|
||
grants_by_hash[expected_hash] = raw_row
|
||
result["grants"]["migrated"] += 1
|
||
continue
|
||
if confirm_row is not None:
|
||
raise ApprovalMigrationError(f"approval grant token conflict for {confirm_id}")
|
||
if not dry_run:
|
||
row = _grant_row(expected_hash, record)
|
||
session.add(row)
|
||
grants_by_hash[expected_hash] = row
|
||
grants_by_confirm[confirm_id] = row
|
||
result["grants"]["migrated"] += 1
|
||
|
||
existing_event_payloads: dict[str, str] = {}
|
||
for payload in session.execute(select(ApprovalEventRecord.payload)).scalars():
|
||
canonical = _canonical_json(payload)
|
||
digest = hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
||
previous = existing_event_payloads.setdefault(digest, canonical)
|
||
if previous != canonical:
|
||
raise ApprovalMigrationError("approval event digest collision in target database")
|
||
for event in data["history"]:
|
||
canonical = _canonical_json(event)
|
||
digest = hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
||
previous = existing_event_payloads.get(digest)
|
||
if previous is not None:
|
||
if previous != canonical:
|
||
raise ApprovalMigrationError("approval event digest collision")
|
||
result["events"]["skipped"] += 1
|
||
continue
|
||
existing_event_payloads[digest] = canonical
|
||
if not dry_run:
|
||
session.add(_event_row(event))
|
||
result["events"]["migrated"] += 1
|
||
|
||
if not dry_run:
|
||
session.flush()
|
||
_assert_source_stable(source, source_digest)
|
||
if dry_run:
|
||
session.rollback()
|
||
else:
|
||
session.commit()
|
||
except ApprovalMigrationError:
|
||
session.rollback()
|
||
raise
|
||
except (IntegrityError, OperationalError) as exc:
|
||
session.rollback()
|
||
raise ApprovalMigrationError("approval database migration failed atomically") from exc
|
||
except BaseException:
|
||
session.rollback()
|
||
raise
|
||
finally:
|
||
session.close()
|
||
return result |