54f9ab4c4f
vps-production-runbook(VPS运维一站式,已验证状态+拓扑+发布流水线+人类闸口+排障); env-version-matrix(三机Python/deps矩阵+Lock建议); scripts/ops(NAS bridge探针-容器无curl-+VPS→NAS备份脚本); nas-deploy-plan§3(修rsync --exclude语法,原会删万级staging parquet+破坏entrypoint启动).
128 lines
4.8 KiB
Python
Executable File
128 lines
4.8 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
"""NAS 容器内 QMT bridge 健康探针(纯标准库 urllib)。
|
||
|
||
为何用 python 不用 curl:
|
||
sanguo_vnpy_v2 容器镜像(Python 3.10)**没有装 curl/wget**,NAS 上做
|
||
bridge 连通性探测只能用 python urllib(标准库自带,零第三方依赖)。
|
||
|
||
为何走 Mac tunnel(默认 http://192.168.2.101:8765)不走 VPS 公网:
|
||
华为光猫拦截 NAS→VPS 公网 80/443,bridge 只能通过 Mac Mini tunnel
|
||
反向暴露给 NAS。默认探 http://192.168.2.101:8765/health。
|
||
|
||
接口契约(见 sanguo_qmt_bridge/README.md):
|
||
GET /health 免鉴权 → {"status":"ok","miniqmt_connected": true|false}
|
||
GET /account 需 header X-Bridge-Token →
|
||
{"ok":true,"cash":..,"frozen":..,"market_value":..,"total":..}
|
||
|
||
退出码:0=健康(bridge 可达 且 status=ok 且 miniqmt_connected=true)
|
||
1=不健康(bridge 不可达 / status 异常 / miniQMT 断开)
|
||
|
||
容错风格:网络/解析失败不抛异常,记 warning 后视情况降级(与
|
||
sanguo_trader.bridge_client 一致——探针失败不应崩溃,只报状态退出)。
|
||
|
||
用法(容器内执行):
|
||
python /app/scripts/ops/nas_bridge_probe.py
|
||
BRIDGE_TOKEN=xxx python /app/scripts/ops/nas_bridge_probe.py # 额外探 /account
|
||
BRIDGE_URL=http://192.168.2.101:8765 python .../nas_bridge_probe.py # 覆盖 URL
|
||
|
||
环境变量:
|
||
BRIDGE_URL bridge 基址(默认 http://192.168.2.101:8765)
|
||
BRIDGE_TOKEN 鉴权 token;设了才探 /account(绝不硬编码,从环境读)
|
||
BRIDGE_TIMEOUT 单请求超时秒数(默认 8)
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import os
|
||
import sys
|
||
import urllib.error
|
||
import urllib.request
|
||
from typing import Any
|
||
|
||
DEFAULT_BRIDGE_URL = "http://192.168.2.101:8765"
|
||
DEFAULT_TIMEOUT = 8
|
||
|
||
|
||
def _get_json(url: str, token: str | None, timeout: float) -> tuple[dict | None, str | None]:
|
||
"""GET JSON;失败返回 (None, 错误描述),绝不抛异常。
|
||
|
||
与 bridge_client._get 同风格:URLError/Timeout/JSON/OSError 统一吞掉记 warning。
|
||
"""
|
||
headers: dict[str, str] = {"Accept": "application/json"}
|
||
if token:
|
||
headers["X-Bridge-Token"] = token
|
||
req = urllib.request.Request(url, headers=headers, method="GET")
|
||
try:
|
||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||
body = resp.read().decode("utf-8", errors="replace")
|
||
return json.loads(body), None
|
||
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, OSError) as e:
|
||
return None, f"{type(e).__name__}: {e}"
|
||
|
||
|
||
def _fmt_money(val: Any) -> str:
|
||
"""资金字段友好打印;非数字原样 str。"""
|
||
try:
|
||
return f"{float(val):,.2f}"
|
||
except (TypeError, ValueError):
|
||
return str(val)
|
||
|
||
|
||
def main() -> int:
|
||
base = os.environ.get("BRIDGE_URL", DEFAULT_BRIDGE_URL).rstrip("/")
|
||
token = os.environ.get("BRIDGE_TOKEN") or None
|
||
timeout = float(os.environ.get("BRIDGE_TIMEOUT", str(DEFAULT_TIMEOUT)))
|
||
|
||
print(f"bridge_url = {base}")
|
||
print(f"timeout = {timeout}s")
|
||
print(f"token = {'(set, will probe /account)' if token else '(not set, skip /account)'}")
|
||
print("-" * 48)
|
||
|
||
# 1. /health(免鉴权)
|
||
health, err = _get_json(f"{base}/health", token=None, timeout=timeout)
|
||
if health is None:
|
||
print(f"[DOWN] bridge 不可达:{err}")
|
||
print(" 排查:Mac tunnel 是否在线 / 192.168.2.101:8765 是否监听 / 光猫拦截")
|
||
return 1
|
||
|
||
status = str(health.get("status", ""))
|
||
mini_connected = bool(health.get("miniqmt_connected", False))
|
||
print(f"[/health] status={status or '?'} miniqmt_connected={mini_connected}")
|
||
|
||
if status != "ok":
|
||
print(f"[WARN] bridge 可达但 status 异常: {status!r}(可能是降级态)")
|
||
return 1
|
||
|
||
if not mini_connected:
|
||
print("[DOWN] bridge UP 但 miniQMT 未连接(disconnected)——无法下单/查账户")
|
||
return 1
|
||
|
||
print("[UP] bridge 健康,miniQMT 已连接")
|
||
|
||
# 2. /account(需 token;token 缺省则跳过,不报错)
|
||
if not token:
|
||
print("[SKIP] /account(未设 BRIDGE_TOKEN)")
|
||
return 0
|
||
|
||
acct, err = _get_json(f"{base}/account", token=token, timeout=timeout)
|
||
if acct is None:
|
||
print(f"[WARN] /account 探测失败:{err}(bridge 本身健康,鉴权/查询侧异常)")
|
||
# bridge 已确认健康,账户查询失败不改变整体健康判定
|
||
return 0
|
||
if not acct.get("ok"):
|
||
print(f"[/account] ok=false error={acct.get('error', '?')}")
|
||
return 0
|
||
|
||
print(
|
||
f"[/account] ok=true"
|
||
f" cash={_fmt_money(acct.get('cash'))}"
|
||
f" frozen={_fmt_money(acct.get('frozen'))}"
|
||
f" market_value={_fmt_money(acct.get('market_value'))}"
|
||
f" total={_fmt_money(acct.get('total'))}"
|
||
)
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|