- 集中维护 Dockerfile,使用多阶段构建和 Bun 独立可执行文件 - 提供 MCP 浏览器镜像,保留中文与 Emoji 字体并校验下载完整性 - 配置健康检查、非 root 运行、内部网络和持久化数据卷 - 增加容器端到端验证脚本,覆盖模型调用、浏览器启动及数据恢复 - 重组 README,精简部署、配置和开发说明
70 lines
5.0 KiB
TypeScript
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); }
|
|
}
|