rotate_runtime.py 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232
  1. """Runtime reconciliation helpers for rotate."""
  2. from __future__ import annotations
  3. import json
  4. from pathlib import Path
  5. from platforms.chatgpt.pool import (
  6. is_warmup_pending_record,
  7. load_token_record,
  8. now_iso,
  9. update_token_record,
  10. write_token_record,
  11. )
  12. from .common import CpaClient, DEFAULT_MANAGEMENT_BASE_URL, now
  13. def _reg_entry_names(entries: list[dict] | None) -> set[str]:
  14. if not isinstance(entries, list):
  15. return set()
  16. return {
  17. str(entry.get("name", "")).strip()
  18. for entry in entries
  19. if isinstance(entry, dict) and "@" in str(entry.get("name", "")) and str(entry.get("name", "")).strip()
  20. }
  21. def _local_pool_names(pool_dir: Path, *, sync_candidates_only: bool = False) -> set[str]:
  22. if not pool_dir.exists():
  23. return set()
  24. names: set[str] = set()
  25. for path in pool_dir.glob("*.json"):
  26. if not path.is_file() or "@" not in path.name:
  27. continue
  28. if sync_candidates_only:
  29. try:
  30. payload = load_token_record(path)
  31. except Exception:
  32. continue
  33. if is_warmup_pending_record(payload):
  34. continue
  35. names.add(path.name)
  36. return names
  37. def _restore_cpa_from_pool_backups(
  38. *,
  39. names: list[str],
  40. pool_dir: Path,
  41. backend_client: object,
  42. ) -> tuple[int, int]:
  43. if not hasattr(backend_client, "upload_auth_file"):
  44. return 0, len(names)
  45. restored = 0
  46. failed = 0
  47. sync_at = now_iso()
  48. for name in names:
  49. pool_path = pool_dir / name
  50. if not pool_path.is_file():
  51. failed += 1
  52. continue
  53. try:
  54. payload = load_token_record(pool_path)
  55. except Exception:
  56. failed += 1
  57. continue
  58. if is_warmup_pending_record(payload):
  59. continue
  60. if not bool(getattr(backend_client, "upload_auth_file")(name, payload)):
  61. failed += 1
  62. update_token_record(
  63. pool_path,
  64. backup_written=True,
  65. cpa_sync_status="failed",
  66. last_cpa_sync_at=sync_at,
  67. last_cpa_sync_error="runtime reconcile upload failed",
  68. )
  69. continue
  70. restored += 1
  71. update_token_record(
  72. pool_path,
  73. backup_written=True,
  74. cpa_sync_status="synced",
  75. last_cpa_sync_at=sync_at,
  76. last_cpa_sync_error="",
  77. )
  78. return restored, failed
  79. def _restore_pool_backups_from_cpa(
  80. *,
  81. names: list[str],
  82. pool_dir: Path,
  83. backend_client: object,
  84. ) -> tuple[int, int]:
  85. if not hasattr(backend_client, "get_auth_file"):
  86. return 0, len(names)
  87. restored = 0
  88. failed = 0
  89. sync_at = now_iso()
  90. for name in names:
  91. payload = getattr(backend_client, "get_auth_file")(name)
  92. if not isinstance(payload, dict):
  93. failed += 1
  94. continue
  95. pool_path = write_token_record(payload, pool_dir, filename=name)
  96. update_token_record(
  97. pool_path,
  98. backup_written=True,
  99. cpa_sync_status="synced",
  100. last_cpa_sync_at=sync_at,
  101. last_cpa_sync_error="",
  102. )
  103. restored += 1
  104. return restored, failed
  105. def _load_runtime_reconcile_state(path: Path) -> dict[str, object]:
  106. try:
  107. raw = json.loads(path.read_text(encoding="utf-8"))
  108. except Exception:
  109. return {}
  110. return raw if isinstance(raw, dict) else {}
  111. def _write_runtime_reconcile_state(path: Path, payload: dict[str, object]) -> None:
  112. try:
  113. path.parent.mkdir(parents=True, exist_ok=True)
  114. tmp = path.with_name(f"{path.name}.tmp")
  115. tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
  116. tmp.replace(path)
  117. except Exception:
  118. return
  119. def _fetch_main_pool_entries(
  120. management_base_url: str = DEFAULT_MANAGEMENT_BASE_URL,
  121. *,
  122. client: object | None = None,
  123. management_key: str | None = None,
  124. ) -> list[dict] | None:
  125. backend_client = client or CpaClient(management_base_url, management_key=management_key)
  126. if not getattr(backend_client, "health_check")():
  127. print(f"[{now()}] [rotate] CPA management API 不可达")
  128. return None
  129. files = getattr(backend_client, "list_auth_files")()
  130. return [f for f in files if isinstance(f, dict)]
  131. def _maybe_reconcile_cpa_runtime(
  132. *,
  133. pool_dir: Path,
  134. management_base_url: str,
  135. enabled: bool,
  136. cooldown_seconds: int,
  137. state_file: Path,
  138. restart_enabled: bool = False,
  139. client: object | None = None,
  140. management_key: str | None = None,
  141. ) -> None:
  142. if not enabled:
  143. return
  144. backend_client = client or CpaClient(management_base_url, management_key=management_key)
  145. entries = _fetch_main_pool_entries(
  146. management_base_url,
  147. client=backend_client,
  148. management_key=management_key,
  149. )
  150. if entries is None:
  151. return
  152. management_names = _reg_entry_names(entries)
  153. local_names = _local_pool_names(pool_dir)
  154. local_sync_names = _local_pool_names(pool_dir, sync_candidates_only=True)
  155. if management_names == local_sync_names:
  156. return
  157. management_only = sorted(management_names - local_names)
  158. local_only = sorted(local_sync_names - management_names)
  159. sample_management_only = ", ".join(management_only[:5]) or "-"
  160. sample_local_only = ", ".join(local_only[:5]) or "-"
  161. print(
  162. f"[{now()}] [rotate] ⚠️ CPA runtime drift detected"
  163. f" | management={len(management_names)}"
  164. f" | local_pool={len(local_names)}"
  165. f" | management_only={len(management_only)} [{sample_management_only}]"
  166. f" | local_only={len(local_only)} [{sample_local_only}]"
  167. )
  168. restored_to_cpa, failed_to_cpa = _restore_cpa_from_pool_backups(
  169. names=local_only,
  170. pool_dir=pool_dir,
  171. backend_client=backend_client,
  172. )
  173. restored_to_pool, failed_to_pool = _restore_pool_backups_from_cpa(
  174. names=management_only,
  175. pool_dir=pool_dir,
  176. backend_client=backend_client,
  177. )
  178. if restored_to_cpa or restored_to_pool or failed_to_cpa or failed_to_pool:
  179. print(
  180. f"[{now()}] [rotate] ↺ runtime reconcile"
  181. f" | restored_to_cpa={restored_to_cpa}"
  182. f" | restored_to_pool={restored_to_pool}"
  183. f" | failed_to_cpa={failed_to_cpa}"
  184. f" | failed_to_pool={failed_to_pool}"
  185. )
  186. _write_runtime_reconcile_state(
  187. state_file,
  188. {
  189. "last_drift_at": now_iso(),
  190. "management_count": len(management_names),
  191. "local_pool_count": len(local_names),
  192. "local_sync_candidate_count": len(local_sync_names),
  193. "management_only_sample": sample_management_only,
  194. "local_only_sample": sample_local_only,
  195. "restored_to_cpa": restored_to_cpa,
  196. "restored_to_pool": restored_to_pool,
  197. "failed_to_cpa": failed_to_cpa,
  198. "failed_to_pool": failed_to_pool,
  199. "restart_attempted": False,
  200. "restart_reason": "api_inventory_local_pool_drift",
  201. "restart_enabled": bool(restart_enabled),
  202. "cooldown_seconds": max(0, int(cooldown_seconds)),
  203. },
  204. )
  205. if restart_enabled:
  206. print(f"[{now()}] [rotate] ⏭️ API-only mode: drift detected but automatic restart has been disabled")