verify_channel_worker.py 4.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100
  1. import asyncio
  2. import json
  3. import os
  4. import subprocess
  5. import time
  6. from pathlib import Path
  7. from uuid import uuid4
  8. from redis.asyncio import Redis
  9. from sqlalchemy import func, select
  10. from sqlalchemy.ext.asyncio import async_sessionmaker
  11. from zhifu.core.config import Settings
  12. from zhifu.core.database import create_engine
  13. from zhifu.modules.merchant.models import ChannelApplication
  14. from zhifu.modules.platform.models import ChannelDelivery
  15. ROOT = Path(__file__).resolve().parents[1]
  16. MAX_SECONDS = 25
  17. def docker(*args):
  18. return subprocess.run(
  19. ["docker", *args], check=True, capture_output=True, text=True, timeout=15,
  20. ).stdout.strip()
  21. async def inspect_environment():
  22. settings = Settings()
  23. engine = create_engine(settings)
  24. redis = Redis.from_url(settings.state_url.get_secret_value(), socket_timeout=3)
  25. try:
  26. sessions = async_sessionmaker(engine)
  27. async with sessions() as session:
  28. enabled = await session.scalar(select(func.count()).select_from(
  29. ChannelApplication,
  30. ).where(ChannelApplication.enabled.is_(True)))
  31. pending = await session.scalar(select(func.count()).select_from(
  32. ChannelDelivery,
  33. ).where(ChannelDelivery.status.in_(["QUEUED", "SENDING"])))
  34. keys = {key async for key in redis.scan_iter(match="monitor:worker:channel-delivery:*")}
  35. return enabled, pending, keys
  36. finally:
  37. await redis.aclose()
  38. await engine.dispose()
  39. def main():
  40. enabled, pending, initial = asyncio.run(inspect_environment())
  41. if enabled or pending:
  42. raise RuntimeError("生命周期验收要求当前没有启用渠道和等待投递记录")
  43. name = "zhifu-channel-lifecycle-" + uuid4().hex
  44. environment = Path(os.environ.get(
  45. "ZHIFU_RUNTIME_ENV", str(ROOT / "ops/runtime.local.env.example"),
  46. )).resolve(strict=True)
  47. started = time.monotonic()
  48. created = False
  49. try:
  50. # 只运行渠道进程,源码只读挂载;检查前提确保本次不发送外部业务通知。
  51. docker(
  52. "run", "--detach", "--name", name,
  53. "--network", os.environ.get("ZHIFU_DEPENDENCY_NETWORK", "zhifu-platform_default"),
  54. "--env-file", str(environment), "--env", "PYTHONPATH=/workspace/src",
  55. "--mount", f"type=bind,source={ROOT / 'src'},target=/workspace/src,readonly",
  56. "--read-only", "--cap-drop", "ALL", "--security-opt", "no-new-privileges:true",
  57. os.environ.get("ZHIFU_IMAGE", "zhifu-platform:local"),
  58. "python", "-m", "zhifu.operations.worker", "channel-delivery",
  59. )
  60. created = True
  61. while time.monotonic() - started < MAX_SECONDS:
  62. _, _, current = asyncio.run(inspect_environment())
  63. if current - initial:
  64. break
  65. time.sleep(0.25)
  66. else:
  67. raise RuntimeError("渠道工作进程未在 25 秒期限内登记心跳")
  68. assert len(current - initial) == 1, "渠道工作进程应只登记一份心跳"
  69. docker("stop", "--time", "10", name)
  70. exit_code = int(docker("inspect", "--format", "{{.State.ExitCode}}", name))
  71. assert exit_code == 0, f"渠道进程停止状态异常:{exit_code}"
  72. _, _, after = asyncio.run(inspect_environment())
  73. assert after == initial, "正常停止后应立即清除本进程心跳"
  74. assert "worker=channel-delivery stopped" in docker("logs", name)
  75. report = {
  76. "role": "channel-delivery", "heartbeat_count": 1, "signal": "SIGTERM",
  77. "exit_code": exit_code, "heartbeat_removed": True,
  78. "seconds": round(time.monotonic() - started, 2),
  79. "generated_model_requests": 0, "external_delivery_requests": 0,
  80. }
  81. target = ROOT / ".work/channel-worker-lifecycle.json"
  82. target.parent.mkdir(exist_ok=True)
  83. target.write_text(json.dumps(report, indent=2), encoding="utf-8")
  84. print(json.dumps(report))
  85. finally:
  86. if created:
  87. docker("rm", "--force", name)
  88. if __name__ == "__main__":
  89. main()