1 |
|
2 |
|
3 |
|
4 |
|
5 |
|
6 |
|
7 |
|
8 |
|
9 |
|
10 |
|
11 |
|
12 |
|
13 |
|
14 |
|
15 |
|
16 |
|
17 | import {
|
18 | guid,
|
19 | logger,
|
20 | } from "@atomist/automation-client";
|
21 | import {
|
22 | ExecuteGoal,
|
23 | ImplementationRegistration,
|
24 | minimalClone,
|
25 | spawnLog,
|
26 | SpawnLogOptions,
|
27 | SpawnLogResult,
|
28 | } from "@atomist/sdm";
|
29 | import * as fs from "fs-extra";
|
30 | import * as stringify from "json-stringify-safe";
|
31 | import * as _ from "lodash";
|
32 | import * as os from "os";
|
33 | import * as path from "path";
|
34 | import {
|
35 | Container,
|
36 | ContainerInput,
|
37 | ContainerOutput,
|
38 | ContainerProjectHome,
|
39 | ContainerRegistration,
|
40 | ContainerScheduler,
|
41 | GoalContainer,
|
42 | GoalContainerSpec,
|
43 | } from "./container";
|
44 | import { prepareSecrets } from "./provider";
|
45 | import {
|
46 | containerEnvVars,
|
47 | prepareInputAndOutput,
|
48 | processResult,
|
49 | } from "./util";
|
50 |
|
51 |
|
52 |
|
53 |
|
54 | export type DockerGoalContainer = GoalContainer & { dockerOptions?: string[] };
|
55 |
|
56 |
|
57 |
|
58 |
|
59 | export interface DockerContainerRegistration extends ContainerRegistration {
|
60 | |
61 |
|
62 |
|
63 |
|
64 |
|
65 |
|
66 |
|
67 |
|
68 |
|
69 |
|
70 |
|
71 |
|
72 |
|
73 | containers: DockerGoalContainer[];
|
74 |
|
75 | |
76 |
|
77 |
|
78 |
|
79 |
|
80 |
|
81 |
|
82 | dockerOptions?: string[];
|
83 | }
|
84 |
|
85 | export const dockerContainerScheduler: ContainerScheduler = (goal, registration: DockerContainerRegistration) => {
|
86 | goal.addFulfillment({
|
87 | goalExecutor: executeDockerJob(goal, registration),
|
88 | ...registration as ImplementationRegistration,
|
89 | });
|
90 | };
|
91 |
|
92 | interface SpawnedContainer {
|
93 | name: string;
|
94 | promise: Promise<SpawnLogResult>;
|
95 | }
|
96 |
|
97 |
|
98 |
|
99 |
|
100 |
|
101 | export function executeDockerJob(goal: Container, registration: DockerContainerRegistration): ExecuteGoal {
|
102 |
|
103 | return async gi => {
|
104 |
|
105 | const { goalEvent, progressLog, configuration } = gi;
|
106 | const goalName = goalEvent.uniqueName.split("#")[0].toLowerCase();
|
107 | const namePrefix = "sdm-";
|
108 | const nameSuffix = `-${goalEvent.goalSetId.slice(0, 7)}-${goalName}`;
|
109 |
|
110 | const tmpDir = path.join(dockerTmpDir(), goalEvent.repo.owner, goalEvent.repo.name, goalEvent.goalSetId);
|
111 | const containerDir = path.join(tmpDir, `${namePrefix}tmp-${guid()}${nameSuffix}`);
|
112 |
|
113 | return configuration.sdm.projectLoader.doWithProject({
|
114 | ...gi,
|
115 | readOnly: false,
|
116 | cloneDir: containerDir,
|
117 | cloneOptions: minimalClone(goalEvent.push, { detachHead: true }),
|
118 | },
|
119 |
|
120 | async project => {
|
121 | const spec: GoalContainerSpec = {
|
122 | ...registration,
|
123 | ...(!!registration.callback ? await registration.callback(_.cloneDeep(registration), project, goal, goalEvent, gi) : {}),
|
124 | };
|
125 |
|
126 | if (!spec.containers || spec.containers.length < 1) {
|
127 | throw new Error("No containers defined in GoalContainerSpec");
|
128 | }
|
129 |
|
130 | const inputDir = path.join(tmpDir, `${namePrefix}tmp-${guid()}${nameSuffix}`);
|
131 | const outputDir = path.join(tmpDir, `${namePrefix}tmp-${guid()}${nameSuffix}`);
|
132 | try {
|
133 | await prepareInputAndOutput(inputDir, outputDir, gi);
|
134 | } catch (e) {
|
135 | const message = `Failed to prepare input and output for goal ${goalName}: ${e.message}`;
|
136 | progressLog.write(message);
|
137 | return { code: 1, message };
|
138 | }
|
139 |
|
140 | const spawnOpts = {
|
141 | log: progressLog,
|
142 | cwd: containerDir,
|
143 | };
|
144 |
|
145 | const network = `${namePrefix}network-${guid()}${nameSuffix}`;
|
146 | let networkCreateRes: SpawnLogResult;
|
147 | try {
|
148 | networkCreateRes = await spawnLog("docker", ["network", "create", network], spawnOpts);
|
149 | } catch (e) {
|
150 | networkCreateRes = {
|
151 | cmdString: `'docker' 'network' 'create' '${network}'`,
|
152 | code: 128,
|
153 | error: e,
|
154 | output: [undefined, "", e.message],
|
155 | pid: -1,
|
156 | signal: undefined,
|
157 | status: 128,
|
158 | stdout: "",
|
159 | stderr: e.message,
|
160 | };
|
161 | }
|
162 | if (networkCreateRes.code) {
|
163 | let message = `Failed to create Docker network '${network}'` +
|
164 | ((networkCreateRes.error) ? `: ${networkCreateRes.error.message}` : "");
|
165 | progressLog.write(message);
|
166 | try {
|
167 | await dockerCleanup({ spawnOpts });
|
168 | } catch (e) {
|
169 | networkCreateRes.code++;
|
170 | message += `; ${e.message}`;
|
171 | }
|
172 | return { code: networkCreateRes.code, message };
|
173 | }
|
174 |
|
175 | const atomistEnvs = (await containerEnvVars(gi.goalEvent, gi)).map(env => `--env=${env.name}=${env.value}`);
|
176 |
|
177 | const spawnedContainers: SpawnedContainer[] = [];
|
178 | const failures: string[] = [];
|
179 | for (const container of spec.containers) {
|
180 | let secrets = {
|
181 | env: [],
|
182 | files: [],
|
183 | };
|
184 | try {
|
185 | secrets = await prepareSecrets(container, gi);
|
186 | if (!!secrets?.files) {
|
187 | const secretPath = path.join(inputDir, ".secrets");
|
188 | await fs.ensureDir(secretPath);
|
189 | for (const file of secrets.files) {
|
190 | const secretFile = path.join(secretPath, guid());
|
191 | file.hostPath = secretFile;
|
192 | await fs.writeFile(secretFile, file.value);
|
193 | }
|
194 | }
|
195 | } catch (e) {
|
196 | failures.push(e.message);
|
197 | }
|
198 | const containerName = `${namePrefix}${container.name}${nameSuffix}`;
|
199 | let containerArgs: string[];
|
200 | try {
|
201 | containerArgs = containerDockerOptions(container, registration);
|
202 | } catch (e) {
|
203 | progressLog.write(e.message);
|
204 | failures.push(e.message);
|
205 | break;
|
206 | }
|
207 | const dockerArgs = [
|
208 | "run",
|
209 | "--tty",
|
210 | "--rm",
|
211 | `--name=${containerName}`,
|
212 | `--volume=${containerDir}:${ContainerProjectHome}`,
|
213 | `--volume=${inputDir}:${ContainerInput}`,
|
214 | `--volume=${outputDir}:${ContainerOutput}`,
|
215 | ...secrets.files.map(f => `--volume=${f.hostPath}:${f.mountPath}`),
|
216 | `--network=${network}`,
|
217 | `--network-alias=${container.name}`,
|
218 | ...containerArgs,
|
219 | ...(registration.dockerOptions || []),
|
220 | ...((container as DockerGoalContainer).dockerOptions || []),
|
221 | ...atomistEnvs,
|
222 | ...secrets.env.map(e => `--env=${e.name}=${e.value}`),
|
223 | container.image,
|
224 | ...(container.args || []),
|
225 | ];
|
226 | if (spawnedContainers.length < 1) {
|
227 | dockerArgs.splice(5, 0, `--workdir=${ContainerProjectHome}`);
|
228 | }
|
229 | const promise = spawnLog("docker", dockerArgs, spawnOpts);
|
230 | spawnedContainers.push({ name: containerName, promise });
|
231 | }
|
232 | if (failures.length > 0) {
|
233 | try {
|
234 | await dockerCleanup({
|
235 | network,
|
236 | spawnOpts,
|
237 | containers: spawnedContainers,
|
238 | });
|
239 | } catch (e) {
|
240 | failures.push(e.message);
|
241 | }
|
242 | return {
|
243 | code: failures.length,
|
244 | message: `Failed to spawn Docker containers: ${failures.join("; ")}`,
|
245 | };
|
246 | }
|
247 |
|
248 | const main = spawnedContainers[0];
|
249 | try {
|
250 | const result = await main.promise;
|
251 | if (result.code) {
|
252 | const msg = `Docker container '${main.name}' failed` + ((result.error) ? `: ${result.error.message}` : "");
|
253 | progressLog.write(msg);
|
254 | failures.push(msg);
|
255 | }
|
256 | } catch (e) {
|
257 | const message = `Failed to execute main Docker container '${main.name}': ${e.message}`;
|
258 | progressLog.write(message);
|
259 | failures.push(message);
|
260 | }
|
261 |
|
262 | const outputFile = path.join(outputDir, "result.json");
|
263 | let outputResult;
|
264 | if ((await fs.pathExists(outputFile)) && failures.length === 0) {
|
265 | try {
|
266 | outputResult = await processResult(await fs.readJson(outputFile), gi);
|
267 | } catch (e) {
|
268 | const message = `Failed to read output from Docker container '${main.name}': ${e.message}`;
|
269 | progressLog.write(message);
|
270 | failures.push(message);
|
271 | }
|
272 | }
|
273 |
|
274 | const sidecars = spawnedContainers.slice(1);
|
275 | try {
|
276 | await dockerCleanup({
|
277 | network,
|
278 | spawnOpts,
|
279 | containers: sidecars,
|
280 | });
|
281 | } catch (e) {
|
282 | failures.push(e.message);
|
283 | }
|
284 |
|
285 | if (failures.length === 0 && !!outputResult) {
|
286 | return outputResult;
|
287 | } else {
|
288 | return {
|
289 | code: failures.length,
|
290 | message: (failures.length > 0) ? failures.join("; ") : "Successfully completed container job",
|
291 | };
|
292 | }
|
293 | });
|
294 | };
|
295 | }
|
296 |
|
297 |
|
298 |
|
299 |
|
300 |
|
301 |
|
302 |
|
303 |
|
304 | export function containerDockerOptions(container: GoalContainer, registration: ContainerRegistration): string[] {
|
305 | const entryPoint: string[] = [];
|
306 | if (container.command && container.command.length > 0) {
|
307 |
|
308 | entryPoint.push(`--entrypoint=${container.command[0]}`);
|
309 |
|
310 | if (container.args) {
|
311 | container.args.splice(0, 0, ...container.command.slice(1));
|
312 | } else {
|
313 | container.args = container.command.slice(1);
|
314 | }
|
315 | }
|
316 | const envs = (container.env || []).map(env => `--env=${env.name}=${env.value}`);
|
317 | const ports = (container.ports || []).map(port => `-p=${port.containerPort}`);
|
318 | const volumes: string[] = [];
|
319 | for (const vm of (container.volumeMounts || [])) {
|
320 | const volume = (registration.volumes || []).find(v => v.name === vm.name);
|
321 | if (!volume) {
|
322 | const msg = `Container '${container.name}' references volume '${vm.name}' which not provided in goal registration ` +
|
323 | `volumes: ${stringify(registration.volumes)}`;
|
324 | logger.error(msg);
|
325 | throw new Error(msg);
|
326 | }
|
327 | volumes.push(`--volume=${volume.hostPath.path}:${vm.mountPath}`);
|
328 | }
|
329 | return [
|
330 | ...entryPoint,
|
331 | ...envs,
|
332 | ...ports,
|
333 | ...volumes,
|
334 | ];
|
335 | }
|
336 |
|
337 |
|
338 |
|
339 |
|
340 |
|
341 | export function dockerTmpDir(): string {
|
342 | return path.join(os.homedir(), ".atomist", "tmp");
|
343 | }
|
344 |
|
345 |
|
346 |
|
347 |
|
348 | interface CleanupOptions {
|
349 | |
350 |
|
351 |
|
352 |
|
353 | spawnOpts: SpawnLogOptions;
|
354 |
|
355 | containers?: SpawnedContainer[];
|
356 | |
357 |
|
358 |
|
359 |
|
360 | network?: string;
|
361 | }
|
362 |
|
363 |
|
364 |
|
365 |
|
366 |
|
367 |
|
368 |
|
369 |
|
370 | async function dockerCleanup(opts: CleanupOptions): Promise<void> {
|
371 | if (opts.containers) {
|
372 | await dockerKill(opts.containers, opts.spawnOpts);
|
373 | }
|
374 | if (opts.network) {
|
375 | const networkDeleteRes = await spawnLog("docker", ["network", "rm", opts.network], opts.spawnOpts);
|
376 | if (networkDeleteRes.code) {
|
377 | const msg = `Failed to delete Docker network '${opts.network}'` +
|
378 | ((networkDeleteRes.error) ? `: ${networkDeleteRes.error.message}` : "");
|
379 | opts.spawnOpts.log.write(msg);
|
380 | }
|
381 | }
|
382 | }
|
383 |
|
384 |
|
385 |
|
386 |
|
387 |
|
388 |
|
389 |
|
390 |
|
391 | async function dockerKill(containers: SpawnedContainer[], opts: SpawnLogOptions): Promise<void> {
|
392 | try {
|
393 | const killPromises: Array<Promise<SpawnLogResult>> = [];
|
394 | for (const container of containers) {
|
395 | killPromises.push(spawnLog("docker", ["kill", container.name], opts));
|
396 | }
|
397 | await Promise.all(killPromises);
|
398 | } catch (e) {
|
399 | const message = `Failed to kill Docker containers: ${e.message}`;
|
400 | opts.log.write(message);
|
401 | }
|
402 | }
|