Files
upup/scripts/docker-smoke.ts
chuan b9ddb5e21e feat: 支持精简 Docker Compose 部署
- 集中维护 Dockerfile,使用多阶段构建和 Bun 独立可执行文件
- 提供 MCP 浏览器镜像,保留中文与 Emoji 字体并校验下载完整性
- 配置健康检查、非 root 运行、内部网络和持久化数据卷
- 增加容器端到端验证脚本,覆盖模型调用、浏览器启动及数据恢复
- 重组 README,精简部署、配置和开发说明
2026-10-25 01:13:47 +08:00

70 lines
5.0 KiB
TypeScript

import { mkdirSync, unlinkSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
// Uses a unique Compose project and disposable volumes, never the developer's data.
const root = resolve(import.meta.dirname, "..");
const project = `upup-smoke-${crypto.randomUUID().slice(0, 8)}`;
const envFile = resolve(root, ".cache", `${project}.env`);
mkdirSync(resolve(root, ".cache"), { recursive: true });
const key = crypto.randomUUID();
const smokeEnv = { ADMIN_KEY: key, MCP_AUTH_TOKEN: crypto.randomUUID(), UPUP_PORT: "0", UPUP_BIND: "127.0.0.1", PUBLIC_ORIGIN: "", COOKIE_SECURE: "false" };
writeFileSync(envFile, Object.entries(smokeEnv).map(([name, value]) => `${name}=${value}`).join("\n") + "\n", { mode: 0o600 });
const compose = ["docker", "compose", "--file", resolve(root, "compose.yaml"), "--env-file", envFile, "--project-name", project];
async function docker(args: string[], capture = false) {
const child = Bun.spawn([...compose, ...args], { cwd: root, env: { ...process.env, ...smokeEnv }, stdout: capture ? "pipe" : "inherit", stderr: "inherit" });
const output = capture ? await new Response(child.stdout).text() : "";
if (await child.exited !== 0) throw new Error(`Docker command failed: ${args[0]}`);
return output.trim();
}
function check(value: unknown, message: string): asserts value { if (!value) throw new Error(message); }
let modelRequests = 0;
let toolsAvailable = false;
const upstream = Bun.serve({ hostname: "0.0.0.0", port: 0, async fetch(request) {
const input = await request.json() as Record<string, any>;
modelRequests++;
toolsAvailable ||= Array.isArray(input.tools) && input.tools.length > 0;
const events = [
{ id: "smoke", object: "chat.completion.chunk", created: 1, model: "smoke-model", choices: [{ index: 0, delta: { content: "容器部署验证通过" }, finish_reason: null }] },
{ id: "smoke", object: "chat.completion.chunk", created: 1, model: "smoke-model", choices: [{ index: 0, delta: {}, finish_reason: "stop" }], usage: { prompt_tokens: 10, completion_tokens: 3, total_tokens: 13 } },
];
return new Response(events.map(event => `data: ${JSON.stringify(event)}\n\n`).join(""), { headers: { "Content-Type": "text/event-stream" } });
} });
try {
await docker(["up", "-d", "--no-build", "--wait", "--wait-timeout", "120"]);
const address = await docker(["port", "app", "3000"], true);
const base = `http://${address}`;
let cookie = "";
async function api(path: string, method = "GET", input?: unknown) {
const response = await fetch(`${base}/api${path}`, { method, headers: { Cookie: cookie, ...(input ? { "Content-Type": "application/json" } : {}) }, body: input ? JSON.stringify(input) : undefined });
if (response.headers.get("set-cookie")) cookie = response.headers.get("set-cookie")!.split(";")[0]!;
check(response.ok, `${path}: HTTP ${response.status}`);
return response;
}
const html = await (await fetch(base)).text();
check(html.includes("upup · 工作台"), "Built frontend was not served");
const asset = html.match(/src="([^"]+\.js)"/)?.[1];
check(asset && (await fetch(base + asset)).ok, "Frontend asset is missing");
await api("/auth/login", "POST", { key });
const mcp = await (await api("/mcp")).json();
check(mcp.connected && mcp.tools.length > 0, "MCP tools did not connect");
await api("/provider", "PUT", { name: "Docker smoke", protocol: "openai-completions", baseUrl: `http://host.docker.internal:${upstream.port}/v1`, model: "smoke-model", apiKey: "smoke-only", contextWindow: 128000, maxTokens: null });
const output = await (await api("/agent/run", "POST", { prompt: "验证容器内 Pi 模型调用" })).text();
check(output.includes('"type":"done"') && !output.includes('"type":"error"'), "Agent stream failed");
check(modelRequests > 0 && toolsAvailable, "Compiled Pi runtime did not send MCP tools to the model");
const before = await (await api("/conversations")).json();
check(before.active.messages.length >= 2, "Agent messages were not saved");
await docker(["up", "-d", "--no-build", "--force-recreate", "--wait", "--wait-timeout", "120", "app"]);
// The published random port may change after recreation.
const newBase = `http://${await docker(["port", "app", "3000"], true)}`;
const restored = await fetch(`${newBase}/api/conversations`, { headers: { Cookie: cookie } });
check(restored.ok && (await restored.json()).active.id === before.active.id, "Session or conversation did not survive recreation");
const chrome = await docker(["exec", "-T", "mcp", "sh", "-c", 'browser="$(find /opt/browser -type f -name chrome | head -n 1)"; "$browser" --headless --no-sandbox --disable-gpu --disable-dev-shm-usage --dump-dom about:blank 2>/dev/null'], true);
check(chrome.includes("<html"), "Bundled browser could not launch");
console.log("Docker smoke passed: frontend, authentication, MCP tools, compiled Pi model call, persistence, browser launch.");
} finally {
await upstream.stop(true);
try { await docker(["down", "--volumes", "--remove-orphans"]); }
finally { unlinkSync(envFile); }
}