mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-03 08:03:39 +00:00
195 lines
5.2 KiB
TypeScript
195 lines
5.2 KiB
TypeScript
// Qa Lab plugin module implements docker up behavior.
|
|
import path from "node:path";
|
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
|
import { writeQaDockerHarnessFiles } from "./docker-harness.js";
|
|
import {
|
|
execCommand,
|
|
fetchHealthUrl,
|
|
resolveComposeServiceUrl,
|
|
resolveHostPort,
|
|
waitForDockerServiceHealth,
|
|
waitForHealth,
|
|
type FetchLike,
|
|
type RunCommand,
|
|
} from "./docker-runtime.js";
|
|
import { shellQuote } from "./shell-quote.js";
|
|
|
|
type QaDockerUpResult = {
|
|
outputDir: string;
|
|
composeFile: string;
|
|
qaLabUrl: string;
|
|
gatewayUrl: string;
|
|
stopCommand: string;
|
|
};
|
|
|
|
function resolveDefaultQaDockerDir(repoRoot: string) {
|
|
return path.resolve(repoRoot, ".artifacts/qa-docker");
|
|
}
|
|
|
|
async function isQaLabDockerHealthReachable(url: string, fetchImpl: FetchLike) {
|
|
let response: Awaited<ReturnType<FetchLike>> | undefined;
|
|
try {
|
|
response = await fetchImpl(url);
|
|
return response.ok;
|
|
} catch {
|
|
return false;
|
|
} finally {
|
|
try {
|
|
await response?.body?.cancel?.();
|
|
} catch {}
|
|
}
|
|
}
|
|
|
|
function isMissingCommandError(
|
|
error: unknown,
|
|
command: string,
|
|
seen = new Set<unknown>(),
|
|
): boolean {
|
|
if (!error || seen.has(error)) {
|
|
return false;
|
|
}
|
|
seen.add(error);
|
|
if (typeof error !== "object") {
|
|
return formatErrorMessage(error).includes(`spawn ${command} ENOENT`);
|
|
}
|
|
const candidate = error as { cause?: unknown; code?: unknown; message?: unknown };
|
|
const message = typeof candidate.message === "string" ? candidate.message : "";
|
|
if (
|
|
candidate.code === "ENOENT" ||
|
|
message.includes(`spawn ${command} ENOENT`) ||
|
|
message.includes(`${command}: command not found`)
|
|
) {
|
|
return true;
|
|
}
|
|
return isMissingCommandError(candidate.cause, command, seen);
|
|
}
|
|
|
|
async function runQaLabBuild(repoRoot: string, runCommand: RunCommand) {
|
|
try {
|
|
await runCommand("pnpm", ["qa:lab:build"], repoRoot);
|
|
} catch (error) {
|
|
if (!isMissingCommandError(error, "pnpm")) {
|
|
throw error;
|
|
}
|
|
await runCommand("corepack", ["pnpm", "qa:lab:build"], repoRoot);
|
|
}
|
|
}
|
|
|
|
export async function runQaDockerUp(
|
|
params: {
|
|
repoRoot?: string;
|
|
outputDir?: string;
|
|
gatewayPort?: number;
|
|
qaLabPort?: number;
|
|
providerBaseUrl?: string;
|
|
image?: string;
|
|
usePrebuiltImage?: boolean;
|
|
bindUiDist?: boolean;
|
|
skipUiBuild?: boolean;
|
|
},
|
|
deps?: {
|
|
runCommand?: RunCommand;
|
|
fetchImpl?: FetchLike;
|
|
sleepImpl?: (ms: number) => Promise<unknown>;
|
|
resolveHostPortImpl?: typeof resolveHostPort;
|
|
},
|
|
): Promise<QaDockerUpResult> {
|
|
const repoRoot = path.resolve(params.repoRoot ?? process.cwd());
|
|
const resolveHostPortImpl = deps?.resolveHostPortImpl ?? resolveHostPort;
|
|
const outputDir = path.resolve(params.outputDir ?? resolveDefaultQaDockerDir(repoRoot));
|
|
const gatewayPort = await resolveHostPortImpl(
|
|
params.gatewayPort ?? 18789,
|
|
params.gatewayPort != null,
|
|
);
|
|
const qaLabPort = await resolveHostPortImpl(params.qaLabPort ?? 43124, params.qaLabPort != null);
|
|
if (gatewayPort === qaLabPort) {
|
|
throw new Error(
|
|
`QA Lab gateway and UI host ports must be different. Both resolved to ${gatewayPort}.`,
|
|
);
|
|
}
|
|
const runCommand = deps?.runCommand ?? execCommand;
|
|
const fetchImpl = deps?.fetchImpl ?? fetchHealthUrl;
|
|
const sleepImpl = deps?.sleepImpl ?? sleep;
|
|
|
|
if (!params.skipUiBuild) {
|
|
await runQaLabBuild(repoRoot, runCommand);
|
|
}
|
|
|
|
await writeQaDockerHarnessFiles({
|
|
outputDir,
|
|
repoRoot,
|
|
gatewayPort,
|
|
qaLabPort,
|
|
providerBaseUrl: params.providerBaseUrl,
|
|
imageName: params.image,
|
|
usePrebuiltImage: params.usePrebuiltImage,
|
|
bindUiDist: params.bindUiDist,
|
|
includeQaLabUi: true,
|
|
});
|
|
|
|
const composeFile = path.join(outputDir, "docker-compose.qa.yml");
|
|
|
|
// Tear down any previous stack from this compose file so ports are freed
|
|
// and we get a clean restart every time.
|
|
try {
|
|
await runCommand(
|
|
"docker",
|
|
["compose", "-f", composeFile, "down", "--remove-orphans"],
|
|
repoRoot,
|
|
);
|
|
} catch {
|
|
// First run or already stopped — ignore.
|
|
}
|
|
|
|
const composeArgs = ["compose", "-f", composeFile, "up"];
|
|
if (!params.usePrebuiltImage) {
|
|
composeArgs.push("--build");
|
|
}
|
|
composeArgs.push("-d");
|
|
|
|
await runCommand("docker", composeArgs, repoRoot);
|
|
|
|
// Brief settle delay so Docker Desktop finishes port-forwarding setup.
|
|
await sleepImpl(3_000);
|
|
|
|
const qaLabUrl = `http://127.0.0.1:${qaLabPort}`;
|
|
const hostGatewayUrl = `http://127.0.0.1:${gatewayPort}/`;
|
|
|
|
await waitForHealth(`${qaLabUrl}/healthz`, {
|
|
label: "QA Lab",
|
|
fetchImpl,
|
|
sleepImpl,
|
|
composeFile,
|
|
});
|
|
await waitForDockerServiceHealth(
|
|
"openclaw-qa-gateway",
|
|
composeFile,
|
|
repoRoot,
|
|
runCommand,
|
|
sleepImpl,
|
|
);
|
|
let gatewayUrl = hostGatewayUrl;
|
|
if (!(await isQaLabDockerHealthReachable(`${hostGatewayUrl}healthz`, fetchImpl))) {
|
|
const containerGatewayUrl = await resolveComposeServiceUrl(
|
|
"openclaw-qa-gateway",
|
|
18789,
|
|
composeFile,
|
|
repoRoot,
|
|
runCommand,
|
|
fetchImpl,
|
|
);
|
|
if (containerGatewayUrl) {
|
|
gatewayUrl = containerGatewayUrl;
|
|
}
|
|
}
|
|
|
|
return {
|
|
outputDir,
|
|
composeFile,
|
|
qaLabUrl,
|
|
gatewayUrl,
|
|
stopCommand: `docker compose -f ${shellQuote(composeFile)} down`,
|
|
};
|
|
}
|