UNPKG

16 kBPlain TextView Raw
1/*
2 * Copyright © 2019 Atomist, Inc.
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17import {
18 guid,
19 logger,
20} from "@atomist/automation-client";
21import {
22 ExecuteGoal,
23 ImplementationRegistration,
24 minimalClone,
25 spawnLog,
26 SpawnLogOptions,
27 SpawnLogResult,
28} from "@atomist/sdm";
29import * as fs from "fs-extra";
30import * as stringify from "json-stringify-safe";
31import * as _ from "lodash";
32import * as os from "os";
33import * as path from "path";
34import {
35 Container,
36 ContainerInput,
37 ContainerOutput,
38 ContainerProjectHome,
39 ContainerRegistration,
40 ContainerScheduler,
41 GoalContainer,
42 GoalContainerSpec,
43} from "./container";
44import { prepareSecrets } from "./provider";
45import {
46 containerEnvVars,
47 prepareInputAndOutput,
48 processResult,
49} from "./util";
50
51/**
52 * Extension to GoalContainer to specify additional docker options
53 */
54export type DockerGoalContainer = GoalContainer & { dockerOptions?: string[] };
55
56/**
57 * Additional options for Docker CLI implementation of container goals.
58 */
59export interface DockerContainerRegistration extends ContainerRegistration {
60 /**
61 * Containers to run for this goal. The goal result is based on
62 * the exit status of the first element of the `containers` array.
63 * The other containers are considered "sidecar" containers
64 * provided functionality that the main container needs to
65 * function. The working directory of the first container is set
66 * to [[ContainerProjectHome]], which contains the project upon
67 * which the goal should operate.
68 *
69 * This extends the base containers property to be able to pass
70 * additional dockerOptions to a single container, eg.
71 * '--link=mongo:mongo'.
72 */
73 containers: DockerGoalContainer[];
74
75 /**
76 * Additional Docker CLI command-line options. Command-line
77 * options provided here will be appended to the default set of
78 * options used when executing `docker run`. For example, if your
79 * main container must run in its default working directory, you
80 * can include `"--workdir="` in the `dockerOptions` array.
81 */
82 dockerOptions?: string[];
83}
84
85export const dockerContainerScheduler: ContainerScheduler = (goal, registration: DockerContainerRegistration) => {
86 goal.addFulfillment({
87 goalExecutor: executeDockerJob(goal, registration),
88 ...registration as ImplementationRegistration,
89 });
90};
91
92interface SpawnedContainer {
93 name: string;
94 promise: Promise<SpawnLogResult>;
95}
96
97/**
98 * Execute container goal using Docker CLI. Wait on completion of
99 * first container, then kill all the rest.
100 */
101export function executeDockerJob(goal: Container, registration: DockerContainerRegistration): ExecuteGoal {
102 // tslint:disable-next-line:cyclomatic-complexity
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 // tslint:disable-next-line:cyclomatic-complexity
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 * Generate container specific Docker command-line options.
299 *
300 * @param container Goal container spec
301 * @param registration Container goal registration object
302 * @return Docker command-line entrypoint, env, p, and volume options
303 */
304export function containerDockerOptions(container: GoalContainer, registration: ContainerRegistration): string[] {
305 const entryPoint: string[] = [];
306 if (container.command && container.command.length > 0) {
307 // Docker CLI entrypoint must be a binary...
308 entryPoint.push(`--entrypoint=${container.command[0]}`);
309 // ...so prepend any other command elements to args array
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 * Use a temporary under the home directory so Docker can use it as a
339 * volume mount.
340 */
341export function dockerTmpDir(): string {
342 return path.join(os.homedir(), ".atomist", "tmp");
343}
344
345/**
346 * Docker elements to cleanup after execution.
347 */
348interface CleanupOptions {
349 /**
350 * Options to use when calling spawnLog. Also provides the
351 * progress log.
352 */
353 spawnOpts: SpawnLogOptions;
354 /** Containers to kill by name, if provided. */
355 containers?: SpawnedContainer[];
356 /**
357 * Name of Docker network created for this goal execution. If
358 * provided, it will be removed.
359 */
360 network?: string;
361}
362
363/**
364 * Kill running Docker containers, then delete network, and
365 * remove directory container directory. If the copy fails, it throws
366 * an error. Other errors are logged and ignored.
367 *
368 * @param opts See [[CleanupOptions]]
369 */
370async 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 * Kill Docker containers. Any errors are caught and logged, but not
386 * re-thrown.
387 *
388 * @param containers Containers to kill, they will be killed by name
389 * @param opts Options to use when calling spawnLog
390 */
391async 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}