单二进制三角色 + Dock 适配器 + Swarm stack 达到可部署态;mgr1 实测订阅经 central-proxy bootstrap,探活 alive=41/52。 Co-authored-by: Cursor <cursoragent@cursor.com>
207 lines
No EOL
12 KiB
Bash
Executable file
207 lines
No EOL
12 KiB
Bash
Executable file
#!/usr/bin/env bash
|
||
# scripts/smoke-local.sh — 本机端到端冒烟(docker compose;Contract §5-A6.4 / brief W5-5)。
|
||
#
|
||
# 拓扑:stub 引擎(searxng/trafilatura 形状二合一)+ gateway/scheduler/proxymanager 三角色。
|
||
# 纪律:stub only,不访问外网真实引擎;订阅 env 用假 URL;
|
||
# proxymanager 因假订阅/mihomo 缺位允许 unhealthy——断言「不 panic + /api/exit fail-closed 形状」。
|
||
# 证据:全部请求/响应 tee 到 .dsh/artifacts/run-20260901-browser-impl/smoke/smoke-run.log;
|
||
# 断言失败即 exit 1(逐条 || fail,非 set -e 陷阱)。
|
||
# 清理:trap 保证 docker compose down -v。
|
||
|
||
set -uo pipefail
|
||
# 注意:故意不用 set -e——断言逐条 || fail,避免静默中断。
|
||
|
||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||
SMOKE_DIR="$ROOT/.dsh/artifacts/run-20260901-browser-impl/smoke"
|
||
LOG="$SMOKE_DIR/smoke-run.log"
|
||
COMPOSE_FILE="$ROOT/stacks/compose-smoke.yml"
|
||
GW="http://localhost:18640"
|
||
SCH="http://localhost:18641"
|
||
ADMIN_TOKEN="bs_smoke_admin_token_0123456789abcdef"
|
||
|
||
mkdir -p "$SMOKE_DIR"
|
||
: > "$LOG"
|
||
|
||
PASS=0; FAIL=0
|
||
declare -a FAILURES=()
|
||
|
||
ok() { PASS=$((PASS+1)); echo "PASS: $1" | tee -a "$LOG"; }
|
||
fail(){ FAIL=$((FAIL+1)); FAILURES+=("$1"); echo "FAIL: $1" | tee -a "$LOG"; }
|
||
|
||
cleanup() {
|
||
echo "---- cleanup: docker compose down -v ----" | tee -a "$LOG"
|
||
(cd "$ROOT/stacks" && docker compose -f compose-smoke.yml down -v --remove-orphans) | tee -a "$LOG"
|
||
}
|
||
trap cleanup EXIT
|
||
|
||
log() { echo "$*" | tee -a "$LOG"; }
|
||
|
||
# 请求 helper:把 HTTP 状态与 body 都记入 log。
|
||
req() { # req <method> <url> <body-or-"-"?> <extra-header...>
|
||
local method="$1" url="$2" body="${3:--}"
|
||
shift 3 || true
|
||
local args=(-s -w $'\n%{http_code}' -X "$method" "$url" "$@")
|
||
if [ "$body" != "-" ]; then args+=(-H "Content-Type: application/json" -d "$body"); fi
|
||
# 剥离占位符:body='-' 表示无请求体,绝不能把 '-' 传给 curl(会被当作 URL/读 stdin)。
|
||
local clean=()
|
||
for a in "${args[@]}"; do [ "$a" = "-" ] || clean+=("$a"); done
|
||
curl "${clean[@]}" 2>>"$LOG" | tee -a "$LOG"
|
||
}
|
||
# split_last:curl -w 把状态码附在最后一行;body=去掉末行,code=末行。
|
||
# 注意:body 为空时 ${out%$'\n'*} 会把末行误当 body,故先给空响应补一个换行。
|
||
split_last() { # 输出 "code<tab>body"
|
||
local out="$1"
|
||
case "$out" in *$'\n'*) ;; *) out="$out"$'\n';; esac
|
||
local code="${out##*$'\n'}"
|
||
local body="${out%$'\n'*}"
|
||
[ -z "$body" ] && body="${out%%$'\n'*}"
|
||
printf '%s\t%s' "$code" "$body"
|
||
}
|
||
|
||
jqget() { python3 -c "import json,sys;d=json.loads(sys.argv[1]);print(eval(sys.argv[2]))" "$1" "$2" 2>>"$LOG"; }
|
||
|
||
# ============ 0. 构建与启动 ============
|
||
log "== 构建冒烟镜像(arm64)=="
|
||
docker build -q -t onesvm/browser-server:smoke -f "$ROOT/server/Dockerfile" --platform linux/arm64 "$ROOT" | tee -a "$LOG" || { fail "镜像构建"; exit 1; }
|
||
ok "镜像构建 onesvm/browser-server:smoke"
|
||
|
||
log "== compose up =="
|
||
# 注:不用 --wait——proxymanager 因「假订阅 + 无 mihomo」必现 /healthz 重复挂载 panic
|
||
# 退出(缺陷清单 W5-D1),--wait 会因此整体等待失败;改为 up -d 后按端口轮询就绪。
|
||
(cd "$ROOT/stacks" && docker compose -f compose-smoke.yml up -d) 2>&1 | tee -a "$LOG"
|
||
|
||
# 等三角色 healthz 就绪(最多 30s)。
|
||
for i in $(seq 1 30); do
|
||
code=$(curl -s -o /dev/null -w '%{http_code}' "$GW/healthz" || true)
|
||
[ "$code" = "200" ] && break
|
||
sleep 1
|
||
done
|
||
# 等适配器健康位收敛:scheduler InitAll 启动即探活一次 + 冷启动 10×1s 短重探
|
||
# (ITER-2);此处轮询 /metrics 直到 searxng-cn adapter_health=1,窗口 60s
|
||
# (防御性冗余:冷启动窗口 10×1s+5s 探活超时已覆盖,60s 为脚本侧防御性冗余)。
|
||
# 超时不中断(由步骤5断言兜底)。
|
||
for i in $(seq 1 60); do
|
||
h=$(curl -s "http://localhost:18641/metrics" 2>/dev/null | grep 'adapter_health{adapter="searxng-cn"}' | awk '{print $2}')
|
||
[ "$h" = "1" ] && break
|
||
sleep 1
|
||
done
|
||
|
||
# ============ 断言 1:GET gateway /healthz → 200 ============
|
||
out=$(req GET "$GW/healthz" -); code=$(split_last "$out" | cut -f1)
|
||
[ "$code" = "200" ] && ok "步骤1 healthz=200" || fail "步骤1 healthz 期望200 实得$code"
|
||
|
||
# ============ 断言 2:POST /admin/keys 签发 → bs_ 前缀明文 ============
|
||
out=$(req POST "$GW/admin/keys" '{"consumer_name":"smoke-consumer","scopes":["search","read"],"rpm":60,"daily":100}' -H "X-Service-Token: $ADMIN_TOKEN")
|
||
code=$(split_last "$out" | cut -f1)
|
||
body=$(split_last "$out" | cut -f2)
|
||
if [ "$code" = "200" ] || [ "$code" = "201" ]; then ok "步骤2 admin/keys=${code}(201 Created 语义)"; else fail "步骤2 admin/keys 期望200/201 实得${code} body=${body}"; fi
|
||
PLAIN_KEY=$(jqget "$body" "d.get('key','')" 2>/dev/null || true)
|
||
case "$PLAIN_KEY" in bs_*) ok "步骤2 key 前缀 bs_(${PLAIN_KEY})";; *) fail "步骤2 key 非 bs_ 前缀:${PLAIN_KEY}";; esac
|
||
[ -n "$PLAIN_KEY" ] || { fail "步骤2 未取得明文 key,中止后续"; exit 1; }
|
||
KEY_ID=$(jqget "$body" "str(d.get('key_id',''))" 2>/dev/null || true)
|
||
log "KEY_ID=$KEY_ID"
|
||
|
||
# ============ 断言 3:无 key POST /v1/search → 401 ============
|
||
out=$(req POST "$GW/v1/search" '{"query":"测试","region":"domestic","max_results":3}')
|
||
code=$(split_last "$out" | cut -f1)
|
||
[ "$code" = "401" ] && ok "步骤3 无key 401" || fail "步骤3 期望401 实得$code"
|
||
|
||
# ============ 断言 4:错误 key → 401 ============
|
||
out=$(req POST "$GW/v1/search" '{"query":"测试","region":"domestic"}' -H "X-Service-Token: bs_wrong_key")
|
||
code=$(split_last "$out" | cut -f1)
|
||
[ "$code" = "401" ] && ok "步骤4 错key 401" || fail "步骤4 期望401 实得$code"
|
||
|
||
# ============ 断言 5:带 key 搜索 → 200 + 信封断言 ============
|
||
out=$(req POST "$GW/v1/search" '{"query":"测试","region":"domestic","max_results":3}' -H "X-Service-Token: $PLAIN_KEY")
|
||
code=$(split_last "$out" | cut -f1); body=$(split_last "$out" | cut -f2)
|
||
[ "$code" = "200" ] && ok "步骤5 搜索 HTTP 200" || fail "步骤5 期望200 实得$code body=$body"
|
||
|
||
env_ok=$(jqget "$body" "d.get('ok')" 2>/dev/null)
|
||
[ "$env_ok" = "True" ] && ok "步骤5 信封 ok=true" || fail "步骤5 ok 期望true 实得$env_ok"
|
||
kind=$(jqget "$body" "d.get('kind')" 2>/dev/null)
|
||
[ "$kind" = "search" ] && ok "步骤5 kind=search" || fail "步骤5 kind 实得$kind"
|
||
nres=$(jqget "$body" "len(d.get('results') or [])" 2>/dev/null)
|
||
[ "${nres:-0}" -ge 1 ] 2>/dev/null && ok "步骤5 results≥1($nres 条)" || fail "步骤5 results 实得$nres"
|
||
r0=$(jqget "$body" "json.dumps((d.get('results') or [{}])[0], ensure_ascii=False)" 2>/dev/null)
|
||
for f in title url content score engine; do
|
||
v=$(jqget "$r0" "str(f'$f' in d).lower()" 2>/dev/null)
|
||
[ "$v" = "true" ] && ok "步骤5 results[0].$f 在" || fail "步骤5 results[0].$f 缺失:$r0"
|
||
done
|
||
score_ok=$(jqget "$body" "0 <= ((d.get('results') or [{}])[0]).get('score', -1) <= 1" 2>/dev/null)
|
||
[ "$score_ok" = "True" ] && ok "步骤5 score∈[0,1]" || fail "步骤5 score 越界:$(jqget "$r0" "d.get('score')" 2>/dev/null)"
|
||
ret_at=$(jqget "$body" "(d.get('provenance') or {}).get('retrieved_at','')" 2>/dev/null)
|
||
case "$ret_at" in *"+08:00") ok "步骤5 provenance.retrieved_at 以 +08:00 结尾(${ret_at})";; *) fail "步骤5 retrieved_at 非 +08:00:${ret_at}";; esac
|
||
err=$(jqget "$body" "d.get('error')" 2>/dev/null)
|
||
[ "$err" = "None" ] && ok "步骤5 error=null" || fail "步骤5 error 实得$err"
|
||
|
||
# ============ 断言 6:read → 200 kind=read markdown 非空 truncated bool ============
|
||
out=$(req POST "$GW/v1/read" '{"url":"http://stub-engines/whatever","max_chars":100}' -H "X-Service-Token: $PLAIN_KEY")
|
||
code=$(split_last "$out" | cut -f1); body=$(split_last "$out" | cut -f2)
|
||
[ "$code" = "200" ] && ok "步骤6 read HTTP 200" || fail "步骤6 期望200 实得$code body=$body"
|
||
kind=$(jqget "$body" "d.get('kind')" 2>/dev/null)
|
||
[ "$kind" = "read" ] && ok "步骤6 kind=read" || fail "步骤6 kind 实得$kind"
|
||
mdlen=$(jqget "$body" "len(d.get('markdown') or '')" 2>/dev/null)
|
||
[ "${mdlen:-0}" -gt 0 ] 2>/dev/null && ok "步骤6 markdown 非空(${mdlen} 字符)" || fail "步骤6 markdown 空"
|
||
trunc=$(jqget "$body" "str(isinstance(d.get('truncated'), bool))" 2>/dev/null)
|
||
[ "$trunc" = "True" ] && ok "步骤6 truncated 为 bool" || fail "步骤6 truncated 非 bool:$trunc"
|
||
|
||
# ============ 断言 7:MCP initialize ============
|
||
MCP_HDR=(-H "Content-Type: application/json" -H "Accept: application/json, text/event-stream")
|
||
out=$(req POST "$GW/mcp" '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2026-07-28","capabilities":{},"clientInfo":{"name":"smoke","version":"0"}}}' - "${MCP_HDR[@]}")
|
||
code=$(split_last "$out" | cut -f1); body=$(split_last "$out" | cut -f2)
|
||
[ "$code" = "200" ] && ok "步骤7 initialize HTTP 200" || fail "步骤7 期望200 实得$code body=$body"
|
||
err=$(jqget "$body" "d.get('error')" 2>/dev/null)
|
||
[ "$err" = "None" ] && ok "步骤7 JSON-RPC 无 error" || fail "步骤7 error=$err"
|
||
svr=$(jqget "$body" "((d.get('result') or {}).get('serverInfo') or {}).get('name','')" 2>/dev/null)
|
||
[ -n "$svr" ] && ok "步骤7 serverInfo.name=$svr" || fail "步骤7 serverInfo.name 空"
|
||
|
||
# ============ 断言 8:tools/list 两个工具 ============
|
||
out=$(req POST "$GW/mcp" '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' - "${MCP_HDR[@]}")
|
||
code=$(split_last "$out" | cut -f1); body=$(split_last "$out" | cut -f2)
|
||
ntools=$(jqget "$body" "len(((d.get('result') or {}).get('tools')) or [])" 2>/dev/null)
|
||
[ "${ntools:-0}" -eq 2 ] 2>/dev/null && ok "步骤8 tools/list 2 个工具" || fail "步骤8 工具数 实得${ntools} body=${body}"
|
||
|
||
# ============ 断言 9:tools/call search → content[0].text 信封 ok ============
|
||
out=$(req POST "$GW/mcp" '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"search","arguments":{"query":"测试","region":"domestic","max_results":3}}}' - "${MCP_HDR[@]}" -H "X-Service-Token: $PLAIN_KEY")
|
||
code=$(split_last "$out" | cut -f1); body=$(split_last "$out" | cut -f2)
|
||
[ "$code" = "200" ] && ok "步骤9 tools/call HTTP 200" || fail "步骤9 期望200 实得$code"
|
||
text=$(jqget "$body" "((((d.get('result') or {}).get('content')) or [{}])[0]).get('text','')" 2>/dev/null)
|
||
env_ok=$(jqget "$text" "json.loads(text_s if False else d_s).get('ok')" 2>/dev/null)
|
||
# text 是 JSON 字符串,直接解析:
|
||
env_ok=$(python3 -c "import json,sys;e=json.loads(sys.argv[1]);print(e.get('ok'))" "$text" 2>>"$LOG" || true)
|
||
[ "$env_ok" = "True" ] && ok "步骤9 content[0].text 信封 ok=true" || fail "步骤9 text 信封 ok 实得$env_ok text=$text"
|
||
|
||
# ============ 断言 10:SSRF 内网域 → 403 denied ============
|
||
out=$(req POST "$GW/v1/read" '{"url":"http://192.168.0.1/"}' -H "X-Service-Token: $PLAIN_KEY")
|
||
code=$(split_last "$out" | cut -f1); body=$(split_last "$out" | cut -f2)
|
||
[ "$code" = "403" ] && ok "步骤10 SSRF 403" || fail "步骤10 期望403 实得$code body=$body"
|
||
ecode=$(jqget "$body" "(d.get('error') or {}).get('code','')" 2>/dev/null)
|
||
[ "$ecode" = "denied" ] && ok "步骤10 error.code=denied" || fail "步骤10 error.code 实得${ecode}"
|
||
|
||
# ============ 断言 11:吊销 key → 原 key 401 ============
|
||
out=$(req DELETE "$GW/admin/keys/$KEY_ID" - -H "X-Service-Token: $ADMIN_TOKEN")
|
||
code=$(split_last "$out" | cut -f1)
|
||
[ "$code" = "200" ] && ok "步骤11 吊销 200" || fail "步骤11 吊销 期望200 实得$code"
|
||
out=$(req POST "$GW/v1/search" '{"query":"测试","region":"domestic"}' -H "X-Service-Token: $PLAIN_KEY")
|
||
code=$(split_last "$out" | cut -f1)
|
||
[ "$code" = "401" ] && ok "步骤11 吊销后 401(即时生效)" || fail "步骤11 期望401 实得$code"
|
||
|
||
# ============ 断言 12:scheduler /pressure is_available ============
|
||
out=$(req GET "$SCH/pressure" -)
|
||
code=$(split_last "$out" | cut -f1); body=$(split_last "$out" | cut -f2)
|
||
[ "$code" = "200" ] && ok "步骤12 /pressure 200" || fail "步骤12 期望200 实得$code"
|
||
avail=$(jqget "$body" "str('is_available' in d).lower()" 2>/dev/null)
|
||
[ "$avail" = "true" ] && ok "步骤12 is_available 字段在" || fail "步骤12 is_available 缺失:$body"
|
||
|
||
# ============ 附加观察(不断言 200):proxymanager fail-closed 形状 ============
|
||
pm_code=$(curl -s -o /tmp/pm_exit.json -w '%{http_code}' "http://localhost:18643/api/exit?domain=example.com" 2>>"$LOG" || true)
|
||
log "PM /api/exit HTTP=$pm_code body=$(cat /tmp/pm_exit.json 2>/dev/null || echo '(无)')"
|
||
log "PM unhealthy 属预期(假订阅 + 无 mihomo);关键断言:三角色进程存活(gateway 步骤1-11 全程 200/401/403 正常响应即未 panic)。"
|
||
|
||
# ============ 汇总 ============
|
||
log "==== 冒烟汇总:PASS=$PASS FAIL=$FAIL ===="
|
||
if [ "$FAIL" -gt 0 ]; then
|
||
for f in "${FAILURES[@]}"; do log " 失败项: $f"; done
|
||
exit 1
|
||
fi
|
||
log "全部 12 步 PASS;证据:$LOG" |