diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 59ad11b0b8f7..316c97f6a7f8 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -790,7 +790,6 @@ src/daemon/schtasks.startup-fallback.test.ts src/daemon/schtasks.ts src/daemon/systemd.test.ts src/daemon/systemd.ts -src/docker-setup.e2e.test.ts src/fleet/backup.runtime.ts src/fleet/containers.runtime.ts src/fleet/service.runtime.ts diff --git a/src/docker-setup.e2e.test-support.ts b/src/docker-setup.e2e.test-support.ts new file mode 100644 index 000000000000..f020c3f5308c --- /dev/null +++ b/src/docker-setup.e2e.test-support.ts @@ -0,0 +1,296 @@ +import { spawnSync } from "node:child_process"; +import { chmod, copyFile, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { createServer } from "node:net"; +import { join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { expect } from "vitest"; +import { createSuiteTempRootTracker } from "./test-helpers/temp-dir.js"; + +export const repoRoot = resolve(fileURLToPath(new URL(".", import.meta.url)), ".."); + +export type DockerSetupSandbox = { + rootDir: string; + scriptPath: string; + logPath: string; + binDir: string; +}; + +const sandboxRootTracker = createSuiteTempRootTracker({ prefix: "openclaw-docker-setup-" }); + +export async function setupDockerSetupSandboxRoot(): Promise { + await sandboxRootTracker.setup(); +} + +export async function cleanupDockerSetupSandboxRoot(): Promise { + await sandboxRootTracker.cleanup(); +} + +async function writeDockerStub(binDir: string, logPath: string) { + const stub = `#!/usr/bin/env bash +set -euo pipefail +log="$DOCKER_STUB_LOG" +fail_match="\${DOCKER_STUB_FAIL_MATCH:-}" +docker_host="" +if [[ "\${1:-}" == "--host" ]]; then + docker_host="\${2:-}" + shift 2 +fi +if [[ "\${1:-}" == "compose" && "\${2:-}" == "version" ]]; then + exit 0 +fi +if [[ "\${1:-}" == "image" && "\${2:-}" == "inspect" ]]; then + format="" + if [[ "\${3:-}" == "-f" || "\${3:-}" == "--format" ]]; then + format="\${4:-}" + image="\${5:-}" + else + image="\${3:-}" + fi + echo "image inspect $image host=$docker_host" >>"$log" + missing_images=",\${DOCKER_STUB_MISSING_IMAGES:-}," + if [[ "$missing_images" == *",$image,"* ]]; then + exit 1 + fi + if [[ -n "$format" ]]; then + printf '%s\n' "\${DOCKER_STUB_BROWSER_CONTRACT:-}" + fi + exit 0 +fi +if [[ "\${1:-}" == "pull" ]]; then + echo "pull $*" >>"$log" + exit 0 +fi +if [[ "\${1:-}" == "build" ]]; then + if [[ -n "$fail_match" && "$*" == *"$fail_match"* ]]; then + echo "build-fail $*" >>"$log" + exit 1 + fi + echo "build DOCKER_BUILDKIT=\${DOCKER_BUILDKIT:-} $*" >>"$log" + exit 0 +fi +if [[ "\${1:-}" == "compose" ]]; then + if [[ -n "$fail_match" && "$*" == *"$fail_match"* ]]; then + echo "compose-fail $*" >>"$log" + exit 1 + fi + echo "compose $*" >>"$log" + if [[ "$*" == *"config get tools.sandbox.tools --json"* ]]; then + if [[ -n "\${DOCKER_STUB_SANDBOX_TOOLS_JSON:-}" ]]; then + printf '%s\n' "$DOCKER_STUB_SANDBOX_TOOLS_JSON" + else + printf '{}\n' + fi + exit 0 + fi + if [[ "$*" == *"config get agents --json"* ]]; then + if [[ -n "\${DOCKER_STUB_AGENTS_JSON:-}" ]]; then + printf '%s\n' "$DOCKER_STUB_AGENTS_JSON" + else + printf '{}\n' + fi + exit 0 + fi + args=("$@") + for ((i = 0; i + 4 < \${#args[@]}; i++)); do + if [[ "\${args[$i]}" == "--entrypoint" && + "\${args[$((i + 1))]}" == "node" && + "\${args[$((i + 2))]}" == "openclaw-gateway" && + "\${args[$((i + 3))]}" == "-e" ]]; then + node -e "\${args[$((i + 4))]}" "\${args[@]:$((i + 5))}" + exit $? + fi + done + exit 0 +fi +echo "unknown $*" >>"$log" +exit 0 +`; + + const timeoutStub = `#!/usr/bin/env bash +set -euo pipefail +if [[ "\${1:-}" == --kill-after=* ]]; then + shift +elif [[ "\${1:-}" == "--kill-after" ]]; then + shift 2 +fi +if [[ $# -gt 0 ]]; then + shift +fi +exec "$@" +`; + + await mkdir(binDir, { recursive: true }); + await writeFile(join(binDir, "docker"), stub, { mode: 0o755 }); + await writeFile(join(binDir, "timeout"), timeoutStub, { mode: 0o755 }); + await writeFile(logPath, ""); +} + +export async function expectMissingPath(path: string): Promise { + try { + await stat(path); + } catch (error) { + expect((error as NodeJS.ErrnoException).code).toBe("ENOENT"); + return; + } + throw new Error(`Expected missing path: ${path}`); +} + +export async function createDockerSetupSandbox(): Promise { + const rootDir = await sandboxRootTracker.make("suite"); + const scriptPath = join(rootDir, "scripts", "docker", "setup.sh"); + const dockerfilePath = join(rootDir, "Dockerfile"); + const composePath = join(rootDir, "docker-compose.yml"); + const binDir = join(rootDir, "bin"); + const logPath = join(rootDir, "docker-stub.log"); + + await mkdir(join(rootDir, "scripts", "docker"), { recursive: true }); + await mkdir(join(rootDir, "scripts", "lib"), { recursive: true }); + await copyFile(join(repoRoot, "scripts", "docker", "setup.sh"), scriptPath); + await copyFile( + join(repoRoot, "scripts", "lib", "docker-build.sh"), + join(rootDir, "scripts", "lib", "docker-build.sh"), + ); + await copyFile( + join(repoRoot, "scripts", "lib", "build-metadata.sh"), + join(rootDir, "scripts", "lib", "build-metadata.sh"), + ); + await copyFile( + join(repoRoot, "scripts", "lib", "docker-e2e-logs.sh"), + join(rootDir, "scripts", "lib", "docker-e2e-logs.sh"), + ); + await copyFile( + join(repoRoot, "scripts", "lib", "docker-e2e-container.sh"), + join(rootDir, "scripts", "lib", "docker-e2e-container.sh"), + ); + await copyFile( + join(repoRoot, "scripts", "lib", "host-timeout.sh"), + join(rootDir, "scripts", "lib", "host-timeout.sh"), + ); + await chmod(scriptPath, 0o755); + await writeFile(dockerfilePath, "FROM scratch\n"); + await writeFile( + composePath, + "services:\n openclaw-gateway:\n image: noop\n openclaw-cli:\n image: noop\n", + ); + await writeDockerStub(binDir, logPath); + + return { rootDir, scriptPath, logPath, binDir }; +} + +export const prestartContainerEnvFlags = [ + "-e HOME=/home/node", + "-e OPENCLAW_HOME=/home/node", + "-e OPENCLAW_STATE_DIR=/home/node/.openclaw", + "-e OPENCLAW_CONFIG_PATH=/home/node/.openclaw/openclaw.json", + "-e OPENCLAW_CONFIG_DIR=/home/node/.openclaw", + "-e OPENCLAW_WORKSPACE_DIR=/home/node/.openclaw/workspace", +].join(" "); + +export const noFollowOwnershipRepair = (root: string) => + `/usr/bin/find -P ${root} -xdev -execdir /usr/bin/chown -h node:node {} +`; +export const prestartSafePath = "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; + +export function requireSandbox(sandbox: DockerSetupSandbox | null): DockerSetupSandbox { + if (!sandbox) { + throw new Error("sandbox missing"); + } + return sandbox; +} + +export async function resetDockerLog(sandbox: DockerSetupSandbox) { + await writeFile(sandbox.logPath, ""); +} + +export async function readDockerLog(sandbox: DockerSetupSandbox) { + return readFile(sandbox.logPath, "utf8"); +} + +export async function readDockerLogLines(sandbox: DockerSetupSandbox) { + const lines: string[] = []; + for (const line of (await readDockerLog(sandbox)).split("\n")) { + if (line) { + lines.push(line); + } + } + return lines; +} + +export function collectMatchingLines( + lines: string[], + predicate: (line: string) => boolean, +): string[] { + const matches: string[] = []; + for (const line of lines) { + if (predicate(line)) { + matches.push(line); + } + } + return matches; +} + +export function isGatewayStartLine(line: string) { + return line.includes("compose") && line.includes(" up -d") && line.includes("openclaw-gateway"); +} + +export function findGatewayStartLineIndex(lines: string[]) { + return lines.findIndex((line) => isGatewayStartLine(line)); +} + +export function expectOfflineComposePolicy( + lines: string[], + options: { gatewayStarts?: boolean } = {}, +) { + const composeLines = collectMatchingLines(lines, (line) => line.startsWith("compose ")); + expect(composeLines.length).toBeGreaterThan(0); + for (const line of composeLines) { + if (line.includes(" run ")) { + expect(line).toContain(" run --pull never "); + } + } + const gatewayStarts = collectMatchingLines(composeLines, (line) => isGatewayStartLine(line)); + if (options.gatewayStarts === false) { + expect(gatewayStarts).toHaveLength(0); + return; + } + expect(gatewayStarts.length).toBeGreaterThan(0); + for (const line of gatewayStarts) { + expect(line).toContain(" up -d --pull never --no-build"); + } +} + +export async function withUnixSocket(socketPath: string, run: () => Promise): Promise { + const server = createServer(); + await new Promise((resolveValue, reject) => { + const onError = (error: Error) => { + server.off("listening", onListening); + reject(error); + }; + const onListening = () => { + server.off("error", onError); + resolveValue(); + }; + server.once("error", onError); + server.once("listening", onListening); + server.listen(socketPath); + }); + + try { + return await run(); + } finally { + await new Promise((resolveLocal) => { + server.close(() => resolveLocal()); + }); + await rm(socketPath, { force: true }); + } +} + +export function resolveBashForCompatCheck(): string | null { + for (const candidate of ["/bin/bash", "bash"]) { + const probe = spawnSync(candidate, ["-c", "exit 0"], { encoding: "utf8" }); + if (!probe.error && probe.status === 0) { + return candidate; + } + } + + return null; +} diff --git a/src/docker-setup.e2e.test.ts b/src/docker-setup.e2e.test.ts index 20d66ac652e3..32154fde7561 100644 --- a/src/docker-setup.e2e.test.ts +++ b/src/docker-setup.e2e.test.ts @@ -1,187 +1,29 @@ // E2E tests for Docker setup script behavior and generated commands. import { spawnSync } from "node:child_process"; -import { chmod, copyFile, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises"; -import { createServer } from "node:net"; -import { join, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; +import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { join } from "node:path"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; -import { createSuiteTempRootTracker } from "./test-helpers/temp-dir.js"; - -const repoRoot = resolve(fileURLToPath(new URL(".", import.meta.url)), ".."); - -type DockerSetupSandbox = { - rootDir: string; - scriptPath: string; - logPath: string; - binDir: string; -}; - -async function writeDockerStub(binDir: string, logPath: string) { - const stub = `#!/usr/bin/env bash -set -euo pipefail -log="$DOCKER_STUB_LOG" -fail_match="\${DOCKER_STUB_FAIL_MATCH:-}" -docker_host="" -if [[ "\${1:-}" == "--host" ]]; then - docker_host="\${2:-}" - shift 2 -fi -if [[ "\${1:-}" == "compose" && "\${2:-}" == "version" ]]; then - exit 0 -fi -if [[ "\${1:-}" == "image" && "\${2:-}" == "inspect" ]]; then - format="" - if [[ "\${3:-}" == "-f" || "\${3:-}" == "--format" ]]; then - format="\${4:-}" - image="\${5:-}" - else - image="\${3:-}" - fi - echo "image inspect $image host=$docker_host" >>"$log" - missing_images=",\${DOCKER_STUB_MISSING_IMAGES:-}," - if [[ "$missing_images" == *",$image,"* ]]; then - exit 1 - fi - if [[ -n "$format" ]]; then - printf '%s\n' "\${DOCKER_STUB_BROWSER_CONTRACT:-}" - fi - exit 0 -fi -if [[ "\${1:-}" == "pull" ]]; then - echo "pull $*" >>"$log" - exit 0 -fi -if [[ "\${1:-}" == "build" ]]; then - if [[ -n "$fail_match" && "$*" == *"$fail_match"* ]]; then - echo "build-fail $*" >>"$log" - exit 1 - fi - echo "build DOCKER_BUILDKIT=\${DOCKER_BUILDKIT:-} $*" >>"$log" - exit 0 -fi -if [[ "\${1:-}" == "compose" ]]; then - if [[ -n "$fail_match" && "$*" == *"$fail_match"* ]]; then - echo "compose-fail $*" >>"$log" - exit 1 - fi - echo "compose $*" >>"$log" - if [[ "$*" == *"config get tools.sandbox.tools --json"* ]]; then - if [[ -n "\${DOCKER_STUB_SANDBOX_TOOLS_JSON:-}" ]]; then - printf '%s\n' "$DOCKER_STUB_SANDBOX_TOOLS_JSON" - else - printf '{}\n' - fi - exit 0 - fi - if [[ "$*" == *"config get agents --json"* ]]; then - if [[ -n "\${DOCKER_STUB_AGENTS_JSON:-}" ]]; then - printf '%s\n' "$DOCKER_STUB_AGENTS_JSON" - else - printf '{}\n' - fi - exit 0 - fi - args=("$@") - for ((i = 0; i + 4 < \${#args[@]}; i++)); do - if [[ "\${args[$i]}" == "--entrypoint" && - "\${args[$((i + 1))]}" == "node" && - "\${args[$((i + 2))]}" == "openclaw-gateway" && - "\${args[$((i + 3))]}" == "-e" ]]; then - node -e "\${args[$((i + 4))]}" "\${args[@]:$((i + 5))}" - exit $? - fi - done - exit 0 -fi -echo "unknown $*" >>"$log" -exit 0 -`; - - const timeoutStub = `#!/usr/bin/env bash -set -euo pipefail -if [[ "\${1:-}" == --kill-after=* ]]; then - shift -elif [[ "\${1:-}" == "--kill-after" ]]; then - shift 2 -fi -if [[ $# -gt 0 ]]; then - shift -fi -exec "$@" -`; - - await mkdir(binDir, { recursive: true }); - await writeFile(join(binDir, "docker"), stub, { mode: 0o755 }); - await writeFile(join(binDir, "timeout"), timeoutStub, { mode: 0o755 }); - await writeFile(logPath, ""); -} - -async function expectMissingPath(path: string): Promise { - try { - await stat(path); - } catch (error) { - expect((error as NodeJS.ErrnoException).code).toBe("ENOENT"); - return; - } - throw new Error(`Expected missing path: ${path}`); -} - -async function createDockerSetupSandbox(): Promise { - const rootDir = await sandboxRootTracker.make("suite"); - const scriptPath = join(rootDir, "scripts", "docker", "setup.sh"); - const dockerfilePath = join(rootDir, "Dockerfile"); - const composePath = join(rootDir, "docker-compose.yml"); - const binDir = join(rootDir, "bin"); - const logPath = join(rootDir, "docker-stub.log"); - - await mkdir(join(rootDir, "scripts", "docker"), { recursive: true }); - await mkdir(join(rootDir, "scripts", "lib"), { recursive: true }); - await copyFile(join(repoRoot, "scripts", "docker", "setup.sh"), scriptPath); - await copyFile( - join(repoRoot, "scripts", "lib", "docker-build.sh"), - join(rootDir, "scripts", "lib", "docker-build.sh"), - ); - await copyFile( - join(repoRoot, "scripts", "lib", "build-metadata.sh"), - join(rootDir, "scripts", "lib", "build-metadata.sh"), - ); - await copyFile( - join(repoRoot, "scripts", "lib", "docker-e2e-logs.sh"), - join(rootDir, "scripts", "lib", "docker-e2e-logs.sh"), - ); - await copyFile( - join(repoRoot, "scripts", "lib", "docker-e2e-container.sh"), - join(rootDir, "scripts", "lib", "docker-e2e-container.sh"), - ); - await copyFile( - join(repoRoot, "scripts", "lib", "host-timeout.sh"), - join(rootDir, "scripts", "lib", "host-timeout.sh"), - ); - await chmod(scriptPath, 0o755); - await writeFile(dockerfilePath, "FROM scratch\n"); - await writeFile( - composePath, - "services:\n openclaw-gateway:\n image: noop\n openclaw-cli:\n image: noop\n", - ); - await writeDockerStub(binDir, logPath); - - return { rootDir, scriptPath, logPath, binDir }; -} - -const sandboxRootTracker = createSuiteTempRootTracker({ prefix: "openclaw-docker-setup-" }); - -const prestartContainerEnvFlags = [ - "-e HOME=/home/node", - "-e OPENCLAW_HOME=/home/node", - "-e OPENCLAW_STATE_DIR=/home/node/.openclaw", - "-e OPENCLAW_CONFIG_PATH=/home/node/.openclaw/openclaw.json", - "-e OPENCLAW_CONFIG_DIR=/home/node/.openclaw", - "-e OPENCLAW_WORKSPACE_DIR=/home/node/.openclaw/workspace", -].join(" "); - -const noFollowOwnershipRepair = (root: string) => - `/usr/bin/find -P ${root} -xdev -execdir /usr/bin/chown -h node:node {} +`; -const prestartSafePath = "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; +import { + cleanupDockerSetupSandboxRoot, + collectMatchingLines, + createDockerSetupSandbox, + expectMissingPath, + expectOfflineComposePolicy, + findGatewayStartLineIndex, + isGatewayStartLine, + noFollowOwnershipRepair, + prestartContainerEnvFlags, + prestartSafePath, + readDockerLog, + readDockerLogLines, + repoRoot, + requireSandbox, + resetDockerLog, + resolveBashForCompatCheck, + setupDockerSetupSandboxRoot, + withUnixSocket, + type DockerSetupSandbox, +} from "./docker-setup.e2e.test-support.js"; function createEnv( sandbox: DockerSetupSandbox, @@ -210,13 +52,6 @@ function createEnv( return env; } -function requireSandbox(sandbox: DockerSetupSandbox | null): DockerSetupSandbox { - if (!sandbox) { - throw new Error("sandbox missing"); - } - return sandbox; -} - function runDockerSetup( sandbox: DockerSetupSandbox, overrides: Record = {}, @@ -231,61 +66,6 @@ function runDockerSetup( }); } -async function resetDockerLog(sandbox: DockerSetupSandbox) { - await writeFile(sandbox.logPath, ""); -} - -async function readDockerLog(sandbox: DockerSetupSandbox) { - return readFile(sandbox.logPath, "utf8"); -} - -async function readDockerLogLines(sandbox: DockerSetupSandbox) { - const lines: string[] = []; - for (const line of (await readDockerLog(sandbox)).split("\n")) { - if (line) { - lines.push(line); - } - } - return lines; -} - -function collectMatchingLines(lines: string[], predicate: (line: string) => boolean): string[] { - const matches: string[] = []; - for (const line of lines) { - if (predicate(line)) { - matches.push(line); - } - } - return matches; -} - -function isGatewayStartLine(line: string) { - return line.includes("compose") && line.includes(" up -d") && line.includes("openclaw-gateway"); -} - -function findGatewayStartLineIndex(lines: string[]) { - return lines.findIndex((line) => isGatewayStartLine(line)); -} - -function expectOfflineComposePolicy(lines: string[], options: { gatewayStarts?: boolean } = {}) { - const composeLines = collectMatchingLines(lines, (line) => line.startsWith("compose ")); - expect(composeLines.length).toBeGreaterThan(0); - for (const line of composeLines) { - if (line.includes(" run ")) { - expect(line).toContain(" run --pull never "); - } - } - const gatewayStarts = collectMatchingLines(composeLines, (line) => isGatewayStartLine(line)); - if (options.gatewayStarts === false) { - expect(gatewayStarts).toHaveLength(0); - return; - } - expect(gatewayStarts.length).toBeGreaterThan(0); - for (const line of gatewayStarts) { - expect(line).toContain(" up -d --pull never --no-build"); - } -} - async function runDockerSetupWithUnsetGatewayToken( sandbox: DockerSetupSandbox, suffix: string, @@ -306,58 +86,21 @@ async function runDockerSetupWithUnsetGatewayToken( return { result, envFile }; } -async function withUnixSocket(socketPath: string, run: () => Promise): Promise { - const server = createServer(); - await new Promise((resolveValue, reject) => { - const onError = (error: Error) => { - server.off("listening", onListening); - reject(error); - }; - const onListening = () => { - server.off("error", onError); - resolveValue(); - }; - server.once("error", onError); - server.once("listening", onListening); - server.listen(socketPath); - }); - - try { - return await run(); - } finally { - await new Promise((resolveLocal) => { - server.close(() => resolveLocal()); - }); - await rm(socketPath, { force: true }); - } -} - -function resolveBashForCompatCheck(): string | null { - for (const candidate of ["/bin/bash", "bash"]) { - const probe = spawnSync(candidate, ["-c", "exit 0"], { encoding: "utf8" }); - if (!probe.error && probe.status === 0) { - return candidate; - } - } - - return null; -} - describe("scripts/docker/setup.sh", () => { let sandbox: DockerSetupSandbox | null = null; beforeAll(async () => { - await sandboxRootTracker.setup(); + await setupDockerSetupSandboxRoot(); sandbox = await createDockerSetupSandbox(); }); afterAll(async () => { if (!sandbox) { - await sandboxRootTracker.cleanup(); + await cleanupDockerSetupSandboxRoot(); return; } await rm(sandbox.rootDir, { recursive: true, force: true }); - await sandboxRootTracker.cleanup(); + await cleanupDockerSetupSandboxRoot(); sandbox = null; }); @@ -1217,4 +960,3 @@ describe("scripts/docker/setup.sh", () => { expect(argLine).toBe("ARG OPENCLAW_IMAGE_APT_PACKAGES"); }); }); -/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */