onesvm-browser-server/scripts/smoke-local.sh
chii eb972dfa93 feat: 落地 browser-server 控制面并打通 mgr1 海外订阅
单二进制三角色 + Dock 适配器 + Swarm stack 达到可部署态;mgr1 实测订阅经 central-proxy bootstrap,探活 alive=41/52。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 15:05:12 +08:00

207 lines
No EOL
12 KiB
Bash
Executable file
Raw 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 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"