Files
openclaw/extensions/qa-lab/src/docker-up.runtime.ts
2026-06-21 12:23:52 +02:00

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`,
};
}