|
| 1 | +import { createHash } from 'node:crypto'; |
| 2 | +import { tmpdir } from 'node:os'; |
| 3 | +import path from 'node:path'; |
| 4 | + |
| 5 | +import type { |
| 6 | + DockerEnvironmentRecipe, |
| 7 | + EnvironmentSetupConfig, |
| 8 | +} from '../loaders/environment-recipe.js'; |
| 9 | +import type { TargetRuntimeConfig } from '../providers/sandbox-runner.js'; |
| 10 | +import type { JsonObject } from '../types.js'; |
| 11 | +import { |
| 12 | + type CommandExecutor, |
| 13 | + DefaultCommandExecutor, |
| 14 | + DockerWorkspaceProvider, |
| 15 | +} from '../workspace/docker-workspace.js'; |
| 16 | + |
| 17 | +export type DockerEnvironmentSetupStatus = 'skipped' | 'success' | 'failed'; |
| 18 | + |
| 19 | +export interface DockerEnvironmentSetupResult { |
| 20 | + readonly type: 'docker'; |
| 21 | + readonly image: string; |
| 22 | + readonly workdir: string; |
| 23 | + readonly status: DockerEnvironmentSetupStatus; |
| 24 | + readonly command?: readonly string[] | string; |
| 25 | + readonly cwd?: string; |
| 26 | + readonly args?: JsonObject; |
| 27 | + readonly stdout?: string; |
| 28 | + readonly stderr?: string; |
| 29 | + readonly exitCode?: number; |
| 30 | + readonly targetRuntime: TargetRuntimeConfig; |
| 31 | +} |
| 32 | + |
| 33 | +export class DockerEnvironmentSetupError extends Error { |
| 34 | + readonly result: DockerEnvironmentSetupResult; |
| 35 | + |
| 36 | + constructor(message: string, result: DockerEnvironmentSetupResult) { |
| 37 | + super(message); |
| 38 | + this.name = 'DockerEnvironmentSetupError'; |
| 39 | + this.result = result; |
| 40 | + } |
| 41 | +} |
| 42 | + |
| 43 | +function dockerImageTag(recipe: DockerEnvironmentRecipe): string { |
| 44 | + const hash = createHash('sha256') |
| 45 | + .update(recipe.context ?? '') |
| 46 | + .update('\0') |
| 47 | + .update(recipe.dockerfile ?? '') |
| 48 | + .digest('hex') |
| 49 | + .slice(0, 16); |
| 50 | + return `agentv-environment:${hash}`; |
| 51 | +} |
| 52 | + |
| 53 | +function shellQuote(value: string): string { |
| 54 | + return `'${value.replaceAll("'", "'\\''")}'`; |
| 55 | +} |
| 56 | + |
| 57 | +function setupPayload(recipe: DockerEnvironmentRecipe): JsonObject { |
| 58 | + return { |
| 59 | + args: recipe.setup?.args ?? {}, |
| 60 | + environment: { |
| 61 | + type: 'docker', |
| 62 | + workdir: recipe.workdir, |
| 63 | + ...(recipe.image ? { image: recipe.image } : {}), |
| 64 | + ...(recipe.context ? { context: recipe.context } : {}), |
| 65 | + ...(recipe.dockerfile ? { dockerfile: recipe.dockerfile } : {}), |
| 66 | + }, |
| 67 | + ...(recipe.setup?.env ? { env: recipe.setup.env } : {}), |
| 68 | + }; |
| 69 | +} |
| 70 | + |
| 71 | +function setupCommandLine(setup: EnvironmentSetupConfig, payload: string): string { |
| 72 | + const envPrefix = Object.entries(setup.env ?? {}) |
| 73 | + .map(([key, value]) => `${key}=${shellQuote(value)}`) |
| 74 | + .join(' '); |
| 75 | + const command = |
| 76 | + typeof setup.command === 'string' |
| 77 | + ? setup.command |
| 78 | + : setup.command.map((entry) => shellQuote(entry)).join(' '); |
| 79 | + const setupCommand = envPrefix ? `${envPrefix} ${command}` : command; |
| 80 | + return `printf %s ${shellQuote(payload)} | (${setupCommand})`; |
| 81 | +} |
| 82 | + |
| 83 | +function setupTimeoutSeconds(setup: EnvironmentSetupConfig | undefined): number | undefined { |
| 84 | + return setup?.timeout_seconds; |
| 85 | +} |
| 86 | + |
| 87 | +function dockerRuntimeForRecipe( |
| 88 | + recipe: DockerEnvironmentRecipe, |
| 89 | + image: string, |
| 90 | +): TargetRuntimeConfig { |
| 91 | + const setup = recipe.setup; |
| 92 | + const payload = JSON.stringify(setupPayload({ ...recipe, image }), null, 2); |
| 93 | + const tempDir = path.resolve(tmpdir()); |
| 94 | + const recipeMounts = (recipe.mounts ?? []).map((mount) => ({ |
| 95 | + source: mount.source, |
| 96 | + target: mount.target, |
| 97 | + access: mount.read_only === true ? 'ro' : (mount.access ?? 'rw'), |
| 98 | + })); |
| 99 | + const mounts = recipeMounts.some((mount) => mount.target === tempDir) |
| 100 | + ? recipeMounts |
| 101 | + : [...recipeMounts, { source: tempDir, target: tempDir, access: 'rw' }]; |
| 102 | + return { |
| 103 | + mode: 'sandbox', |
| 104 | + engine: 'docker', |
| 105 | + image, |
| 106 | + workdir: recipe.workdir, |
| 107 | + host_cwd: recipe.sourceDir, |
| 108 | + env: { |
| 109 | + ...(recipe.env ?? {}), |
| 110 | + AGENTV_ENVIRONMENT_WORKDIR: recipe.workdir, |
| 111 | + }, |
| 112 | + ...(recipe.secrets !== undefined ? { secrets: recipe.secrets } : {}), |
| 113 | + mounts, |
| 114 | + ...(recipe.resources?.memory !== undefined ? { memory: recipe.resources.memory } : {}), |
| 115 | + ...(recipe.resources?.cpus !== undefined ? { cpus: recipe.resources.cpus } : {}), |
| 116 | + ...(setup !== undefined ? { setup: [setupCommandLine(setup, payload)] } : {}), |
| 117 | + ...(setupTimeoutSeconds(setup) !== undefined |
| 118 | + ? { setup_timeout: setupTimeoutSeconds(setup) } |
| 119 | + : {}), |
| 120 | + }; |
| 121 | +} |
| 122 | + |
| 123 | +function formatDockerFailure( |
| 124 | + action: string, |
| 125 | + result: { stderr: string; stdout: string; exitCode: number }, |
| 126 | +) { |
| 127 | + const details = result.stderr.trim() || result.stdout.trim(); |
| 128 | + return details |
| 129 | + ? `docker ${action} failed (exit ${result.exitCode}): ${details}` |
| 130 | + : `docker ${action} failed (exit ${result.exitCode})`; |
| 131 | +} |
| 132 | + |
| 133 | +export async function prepareDockerEnvironment( |
| 134 | + recipe: DockerEnvironmentRecipe, |
| 135 | + executor: CommandExecutor = new DefaultCommandExecutor(), |
| 136 | +): Promise<DockerEnvironmentSetupResult> { |
| 137 | + const image = recipe.context ? (recipe.image ?? dockerImageTag(recipe)) : recipe.image; |
| 138 | + if (!image) { |
| 139 | + throw new Error('Docker environment requires image when context is not set.'); |
| 140 | + } |
| 141 | + |
| 142 | + const docker = new DockerWorkspaceProvider( |
| 143 | + { |
| 144 | + image, |
| 145 | + ...(recipe.resources?.memory !== undefined ? { memory: recipe.resources.memory } : {}), |
| 146 | + ...(recipe.resources?.cpus !== undefined ? { cpus: recipe.resources.cpus } : {}), |
| 147 | + }, |
| 148 | + executor, |
| 149 | + ); |
| 150 | + |
| 151 | + if (!(await docker.isDockerAvailable())) { |
| 152 | + throw new Error( |
| 153 | + 'Docker environment configured but Docker CLI is not available. Install Docker and ensure it is running.', |
| 154 | + ); |
| 155 | + } |
| 156 | + |
| 157 | + if (recipe.context) { |
| 158 | + const argv = ['docker', 'build', '-t', image]; |
| 159 | + if (recipe.dockerfile) { |
| 160 | + argv.push('-f', recipe.dockerfile); |
| 161 | + } |
| 162 | + argv.push(recipe.context); |
| 163 | + const result = await executor.exec(argv, { timeoutMs: 30 * 60 * 1000 }); |
| 164 | + if (result.exitCode !== 0) { |
| 165 | + const failure: DockerEnvironmentSetupResult = { |
| 166 | + type: 'docker', |
| 167 | + image, |
| 168 | + workdir: recipe.workdir, |
| 169 | + status: 'failed', |
| 170 | + stderr: result.stderr, |
| 171 | + stdout: result.stdout, |
| 172 | + exitCode: result.exitCode, |
| 173 | + targetRuntime: dockerRuntimeForRecipe(recipe, image), |
| 174 | + }; |
| 175 | + throw new DockerEnvironmentSetupError(formatDockerFailure('build', result), failure); |
| 176 | + } |
| 177 | + } else { |
| 178 | + await docker.pullImage(); |
| 179 | + } |
| 180 | + |
| 181 | + const runtime = dockerRuntimeForRecipe(recipe, image); |
| 182 | + return { |
| 183 | + type: 'docker', |
| 184 | + image, |
| 185 | + workdir: recipe.workdir, |
| 186 | + status: recipe.setup ? 'success' : 'skipped', |
| 187 | + ...(recipe.setup?.command !== undefined ? { command: recipe.setup.command } : {}), |
| 188 | + cwd: recipe.sourceDir, |
| 189 | + ...(recipe.setup?.args !== undefined ? { args: recipe.setup.args } : {}), |
| 190 | + targetRuntime: runtime, |
| 191 | + }; |
| 192 | +} |
0 commit comments