Files
knowledge-governance/deploy/cloud-test/gate.py
姚章浩 e6472b0ec6 feat(library): 云测试发布目标接入的角色分离、快照搬运与图谱门禁
为什么改:发布执行链是"与目标同机"假设写死的(本机 docker.sock、本机 drain.json、
本机 Redis/PG),把目标换成云测试不能只改展示 URL;直接搬整套 worker 又会抢走
本机封存/QA/周报队列并按本机配置写错目标。所以先把"谁消费哪段队列"变成显式角色,
再补齐跨主机搬运与目标侧门禁。

角色(LIBRARY_WORKER_ROLE=full/release-executor/capture)不只影响 handler 列表,
还决定是否拥有 DDL(release-executor 只读校验迁移版本,落后即拒绝启动)、
收拾哪些 library_jobs.kind、是否跑 release 接管扫描、是否起 scheduler/QA/周报/探测。
capture 保留本机原有全部业务;release-executor 只挂 sync/restore/recover/baseline。

快照跨主机:新增内容寻址的 bundle(版本清单先落、blob 按 sha256 命名、清单最后
Link 作提交点),pack/unpack 幂等且拒绝覆盖与同名不同内容;执行器使用前仍逐 blob
重算 sha256,因此传输不可变有两道证据。

目标侧门禁:目标知识库 graph_enabled=true 时拒绝发布(发布只写 PG/ES/OSS,不写
Neo4j;未知也拒绝),只读检测、不改目标配置、不做图谱迁移。

云端入口:新增 drain 感知的 ingress 闸门(建连超时与读超时分离,半关闭单向传播,
SIGTERM 排空后写 drain.json 并 exit 0/2)、compose 叠加片段(app 不再发布端口、
frontend 与 adapter 的入口收敛到闸门、指向真实网络 weknora-test-v071-network)、
执行器启动脚本与治理 PG 反向隧道脚本(默认 unix socket,不占 TCP)。

验证:deploy/cloud-test/test_gate.py 13 项全 PASS(含 12 秒静默流、半关闭、2MiB 完整、
排空 exit 0/active_at_close=1、排空超时 exit 2、上游不可达不挂住);go test 相关包全绿;
scripts/ci_checks.py 16 项全部 success(EXIT=0,1241 用例通过/0 失败/191 已声明可选集成跳过);
云测试只读预检 17 项 / 6 FAIL,FAIL 项即当前部署缺口(无闸门、app 仍发布端口、
adapter 与 frontend 仍直连 app、云端归档根未建),未部署、未改云端任何状态。
2026-10-30 18:38:38 +08:00

193 lines
6.8 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""目标入口闸门:唯一去 app 的 HTTP 路径,负责"关入口 + 排空在途请求"。
为什么必须有一个独立闸门,而不是在被代理的 WeKnora 上排空:
v0.7.1 的 app 收到 SIGTERM 会先关监听、随后因 use of closed network connection
直接 exit(1),在途请求被切断。所以"入口已关、在途已归零"这条证据只能在真正的
连接入口上产生,并且必须落在**目标主机自己的**文件上(治理侧的维护协调器会先删除
这个文件、发 SIGTERM、等容器退出、再读回;读到旧文件就是伪造证据)。
连接语义(两条都被真实业务踩过,不是理论问题):
1. 上游读**不设超时**:`create_connection(timeout=...)` 只对 connect 生效,连上后
必须 `settimeout(None)`。否则一条等待模型输出超过这个秒数的空闲流会被闸门自己
掐断 —— 那是"闸门把正常业务掐了",不是"连接已经排空"。
2. 半关闭按 TCP 语义单向传播:客户端写完请求后关自己的写端(shutdown(SHUT_WR))时,
只把 EOF 传到上游,**不能**顺手把上游→客户端方向也拆掉,否则响应会被截断。
只有真正的错误(RST 等)才双向拆除,避免另一侧线程永久挂住。
SIGTERM 语义(顺序是证据的一部分):
1. 立即关闭监听 —— 新连接被拒 = 入口已关;
2. 等在途连接自然结束(上限 GATE_DRAIN_TIMEOUT);
3. 写 drain.json(临时文件 + fsync + rename,0600)后 exit 0(drained)
/ exit 2(drain_timeout,此时绝不能当成排空成功)。
"""
import json
import os
import signal
import socket
import sys
import threading
import time
GATE_PORT = int(os.environ.get("GATE_PORT", "8080"))
UPSTREAM_HOST = os.environ.get("GATE_UPSTREAM_HOST", "app")
UPSTREAM_PORT = int(os.environ.get("GATE_UPSTREAM_PORT", "8080"))
DRAIN_FILE = os.environ.get("GATE_DRAIN_FILE", "/gate-state/drain.json")
DRAIN_TIMEOUT = float(os.environ.get("GATE_DRAIN_TIMEOUT", "45"))
CONNECT_TIMEOUT = float(os.environ.get("GATE_CONNECT_TIMEOUT", "10"))
active = 0
served = 0
lock = threading.Lock()
listener = None
stopping = threading.Event()
def write_state(payload):
if not os.path.isabs(DRAIN_FILE):
print("gate: GATE_DRAIN_FILE must be an absolute path", file=sys.stderr, flush=True)
os._exit(3)
payload = dict(payload, at=time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), pid=os.getpid())
directory = os.path.dirname(DRAIN_FILE)
os.makedirs(directory, exist_ok=True)
tmp = DRAIN_FILE + ".tmp"
fd = os.open(tmp, os.O_CREAT | os.O_WRONLY | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w") as handle:
json.dump(payload, handle)
handle.flush()
os.fsync(handle.fileno())
os.replace(tmp, DRAIN_FILE)
def pump(src, dst):
"""单向搬运并传播半关闭。正常 EOF 只关对端写端;异常则双向拆除。"""
clean = False
try:
while True:
data = src.recv(65536)
if not data:
clean = True
break
dst.sendall(data)
except OSError:
clean = False
finally:
if clean:
try:
dst.shutdown(socket.SHUT_WR)
except OSError:
pass
else:
for sock in (src, dst):
try:
sock.shutdown(socket.SHUT_RDWR)
except OSError:
pass
def handle(client):
global active, served
with lock:
if stopping.is_set():
client.close()
return
active += 1
served += 1
current = active
print(f"conn:open active={current} served={served}", flush=True)
upstream = None
try:
upstream = socket.create_connection((UPSTREAM_HOST, UPSTREAM_PORT), timeout=CONNECT_TIMEOUT)
# 建连超时只对 connect 生效;读超时必须清掉,否则长静默流会被掐断。
upstream.settimeout(None)
client.settimeout(None)
except OSError:
with lock:
active -= 1
remaining = active
print(f"conn:close active={remaining} upstream-unreachable", flush=True)
if upstream is not None:
upstream.close()
client.close()
return
left = threading.Thread(target=pump, args=(client, upstream), daemon=True)
right = threading.Thread(target=pump, args=(upstream, client), daemon=True)
left.start()
right.start()
def finish():
global active
try:
left.join()
right.join()
finally:
# 两个方向都结束才算这条连接离开:只看一个方向会把半关的连接当"已排空"。
for sock in (client, upstream):
try:
sock.close()
except OSError:
pass
with lock:
active -= 1
remaining = active
print(f"conn:close active={remaining}", flush=True)
threading.Thread(target=finish, daemon=True).start()
def on_term(_signum, _frame):
stopping.set()
if listener is not None:
try:
listener.close()
except OSError:
pass
def main():
global listener
signal.signal(signal.SIGTERM, on_term)
signal.signal(signal.SIGINT, on_term)
listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
listener.bind(("0.0.0.0", GATE_PORT))
listener.listen(256)
write_state({"phase": "serving", "active": 0, "served": 0})
started = time.time()
print(f"gate: listening on {GATE_PORT} -> {UPSTREAM_HOST}:{UPSTREAM_PORT} drain={DRAIN_FILE}",
flush=True)
while not stopping.is_set():
try:
client, _ = listener.accept()
except OSError:
break
handle(client)
with lock:
active_at_close = active
drain_started = time.time()
write_state({"phase": "draining", "active": active_at_close, "served": served})
deadline = drain_started + DRAIN_TIMEOUT
while time.time() < deadline:
with lock:
if active == 0:
break
time.sleep(0.05)
with lock:
remaining = active
drain_ms = int((time.time() - drain_started) * 1000)
uptime_ms = int((time.time() - started) * 1000)
if remaining != 0:
write_state({"phase": "drain_timeout", "active": remaining, "served": served,
"active_at_close": active_at_close, "drain_ms": drain_ms,
"uptime_ms": uptime_ms})
os._exit(2)
write_state({"phase": "drained", "active": 0, "served": served,
"active_at_close": active_at_close, "drain_ms": drain_ms,
"uptime_ms": uptime_ms})
os._exit(0)
if __name__ == "__main__":
main()