Passed
Push — master ( e7e380...72c582 )
by Tom
04:40
created

Runner::runNewContainer()   B

Complexity

Conditions 8
Paths 32

Size

Total Lines 50
Code Lines 34

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 21
CRAP Score 8.006

Importance

Changes 0
Metric Value
cc 8
eloc 34
nc 32
nop 4
dl 0
loc 50
ccs 21
cts 22
cp 0.9545
crap 8.006
rs 8.1315
c 0
b 0
f 0
1
<?php
2
3
/* this file is part of pipelines */
4
5
namespace Ktomk\Pipelines;
6
7
use Ktomk\Pipelines\Cli\Docker;
8
use Ktomk\Pipelines\Cli\Exec;
9
use Ktomk\Pipelines\Cli\Streams;
10
use Ktomk\Pipelines\File\Image;
11
use Ktomk\Pipelines\Runner\ArtifactSource;
12
use Ktomk\Pipelines\Runner\Directories;
13
use Ktomk\Pipelines\Runner\DockerLogin;
14
use Ktomk\Pipelines\Runner\Env;
15
16
/**
17
 * Pipeline runner with docker under the hood
18
 */
19
class Runner
20
{
21
    const FLAGS = 19;
22
    const FLAG_DOCKER_REMOVE = 1;
23
    const FLAG_DOCKER_KILL = 2;
24
    const FLAG_DEPLOY_COPY = 4; # copy working dir into container
25
    const FLAG_KEEP_ON_ERROR = 8;
26
    const FLAG_SOCKET = 16;
27
28
    const STATUS_NO_STEPS = 1;
29
    const STATUS_RECURSION_DETECTED = 127;
30
31
    /**
32
     * @var string
33
     */
34
    private $prefix;
35
36
    /**
37
     * @var Directories
38
     */
39
    private $directories;
40
41
    /**
42
     * @var Exec
43
     */
44
    private $exec;
45
46
    /**
47
     * @var int
48
     */
49
    private $flags;
50
51
    /**
52
     * @var Env
53
     */
54
    private $env;
55
    /**
56
     * @var Streams
57
     */
58
    private $streams;
59
60
    /**
61
     * DockerSession constructor.
62
     *
63
     * @param string $prefix
64
     * @param Directories $directories source repository root directory based directories object
65
     * @param Exec $exec
66
     * @param int $flags [optional]
67
     * @param Env $env [optional]
68
     * @param Streams $streams [optional]
69
     */
70 15
    public function __construct(
71
        $prefix,
72
        Directories $directories,
73
        Exec $exec,
74
        $flags = null,
75
        Env $env = null,
76
        Streams $streams = null
77
    )
78
    {
79 15
        $this->prefix = $prefix;
80 15
        $this->directories = $directories;
81 15
        $this->exec = $exec;
82 15
        $this->flags = null === $flags ? self::FLAGS : $flags;
83 15
        $this->env = null === $env ? Env::create() : $env;
84 15
        $this->streams = null === $streams ? Streams::create() : $streams;
85 15
    }
86
87
    /**
88
     * @param Pipeline $pipeline
89
     * @throws \RuntimeException
90
     * @return int status (as in exit status, 0 OK, !0 NOK)
91
     */
92 15
    public function run(Pipeline $pipeline)
93
    {
94 15
        $hasId = $this->env->setPipelinesId($pipeline->getId()); # TODO give Env an addPipeline() method (compare addReference)
95 15
        if ($hasId) {
96 1
            $this->streams->err(sprintf(
97 1
                "pipelines: won't start pipeline '%s'; pipeline inside pipelines recursion detected\n",
98 1
                $pipeline->getId()
99
            ));
100
101 1
            return self::STATUS_RECURSION_DETECTED;
102
        }
103
104 14
        foreach ($pipeline->getSteps() as $step) {
105 13
            $status = $this->runStep($step);
106 13
            if (0 !== $status) {
107 13
                return $status;
108
            }
109
        }
110
111 9
        if (!isset($status)) {
112 1
            $this->streams->err("pipelines: pipeline with no step to execute\n");
113
114 1
            return self::STATUS_NO_STEPS;
115
        }
116
117 8
        return $status;
118
    }
119
120
    /**
121
     * @param Step $step
122
     * @throws \RuntimeException
123
     * @return int exit status
124
     */
125 13
    public function runStep(Step $step)
126
    {
127 13
        $dir = $this->directories->getProject();
128 13
        $env = $this->env;
129 13
        $exec = $this->exec;
130 13
        $streams = $this->streams;
131 13
        $flags = $this->flags;
132
133 13
        $name = $this->generateContainerName($step);
134
135
        $reuseContainer =
136 13
            ($flags & self::FLAG_KEEP_ON_ERROR)
137 13
            || !($flags & (self::FLAG_DOCKER_KILL | self::FLAG_DOCKER_REMOVE));
138
139 13
        if (false === $reuseContainer) {
140 10
            $this->zapContainerByName($name);
141
        }
142 13
        $image = $step->getImage();
143 13
        $env->setContainerName($name);
144
145
        # launch container
146 13
        $streams->out(sprintf(
147 13
            "\x1D+++ step #%d\n\n    name...........: %s\n    effective-image: %s\n    container......: %s\n",
148 13
            $step->getIndex() + 1,
149 13
            $step->getName() ? '"' . $step->getName() . '"' : '(unnamed)',
150 13
            $image->getName(),
151 13
            $name
152
        ));
153
154 13
        $copy = (bool)($flags & self::FLAG_DEPLOY_COPY);
155
156 13
        $id = null;
157 13
        if ($reuseContainer) {
158 3
            $id = $this->dockerGetContainerIdByName($name);
159
        }
160
161 13
        if (null === $id) {
162 11
            list($id, $status) = $this->runNewContainer($name, $dir, $copy, $step);
163 11
            if (null === $id) {
164 1
                return $status;
165
            }
166
        }
167
168 12
        $streams->out(sprintf("    container-id...: %s\n\n", substr($id, 0, 12)));
169
170
        # TODO: different deployments, mount (default), mount-ro, copy
171 12
        if (null !== $result = $this->deployCopy($copy, $id, $dir)) {
172 2
            return $result;
173
        }
174
175 10
        $status = $this->runStepScript($step, $streams, $exec, $name);
176
177 10
        $this->captureStepArtifacts($step, $copy && 0 === $status, $id, $dir);
178
179 10
        $this->shutdownStepContainer($status, $id, $exec, $name);
180
181 10
        return $status;
182
    }
183
184
    /**
185
     * @param string $name
186
     * @param string $dir
187
     * @param bool $copy
188
     * @param Step $step
189
     * @return array
190
     */
191 11
    private function runNewContainer($name, $dir, $copy, Step $step)
192
    {
193 11
        $env = $this->env;
194 11
        $exec = $this->exec;
195 11
        $flags = $this->flags;
196 11
        $streams = $this->streams;
197
198 11
        $image = $step->getImage();
199 11
        $docker = new Docker($exec);
200
201
        # process docker login if image demands so, but continue on failure
202 11
        $this->imageLogin($image);
203
204
        // enable docker client inside docker by mounting docker socket
205
        // FIXME give controlling options, this is serious /!\
206 11
        $socket = (bool)($flags & self::FLAG_SOCKET);
207 11
        $mountDockerSock = array();
208 11
        if ($socket && file_exists('/var/run/docker.sock')) {
209
            $mountDockerSock = array(
210
                '-v', '/var/run/docker.sock:/var/run/docker.sock',
211
            );
212
        }
213
214 11
        $parentName = $env->getValue('PIPELINES_PARENT_CONTAINER_NAME');
215 11
        $checkMount = $mountDockerSock && null !== $parentName;
216 11
        $deviceDir = $checkMount ? $docker->hostDevice($parentName, $dir) : $dir;
217
218 11
        $mountWorkingDirectory = $copy
219 7
            ? array()
220 11
            : array('--volume', "${deviceDir}:/app"); // FIXME(tk): hard encoded /app
221
222
        $status = $exec->capture('docker', array(
223
            'run', '-i', '--name', $name,
224
            $env->getArgs('-e'),
225
            $mountWorkingDirectory, '-e', 'BITBUCKET_CLONE_DIR=/app',
226
            $mountDockerSock,
227
            '--workdir', '/app', '--detach', $image->getName()
228
        ), $out, $err);
229
        if (0 !== $status) {
230
            $streams->out("    container-id...: *failure*\n\n");
231
            $streams->err("pipelines: setting up the container failed\n");
232
            $streams->err("${err}\n");
233
            $streams->out("${out}\n");
234 1
            $streams->out(sprintf("exit status: %d\n", $status));
235
236 1
            return array(null, $status);
237
        }
238 10
        $id = rtrim($out) ?: '*dry-run*'; # side-effect: internal exploit of no output with true exit status
239
240 10
        return array($id, 0);
241
    }
242
243
    /**
244
     * @param string $name
245
     */
246
    private function zapContainerByName($name)
247
    {
248 10
        $ids = null;
249
250 10
        $status = $this->exec->capture(
251 10
            'docker',
252
            array(
253 10
                'ps', '-qa', '--filter',
254 10
                "name=^/${name}$"
255
            ),
256 10
            $result
257
        );
258
259 10
        $status || $ids = Lib::lines($result);
260
261 10
        if ($status || !(is_array($ids) && 1 === count($ids))) {
262 9
            return;
263
        }
264
265 1
        $this->exec->capture('docker', Lib::merge('kill', $ids));
266 1
        $this->exec->capture('docker', Lib::merge('rm', $ids));
267 1
    }
268
269
    /**
270
     * @param string $name
271
     * @return null|string
272
     */
273
    private function dockerGetContainerIdByName($name)
274
    {
275 3
        $ids = null;
276
277 3
        $status = $this->exec->capture(
278 3
            'docker',
279
            array(
280 3
                'ps', '-qa', '--filter',
281 3
                "name=^/${name}$"
282
            ),
283 3
            $result
284
        );
285
286 3
        $status || $ids = Lib::lines($result);
287
288 3
        if ($status || !(is_array($ids) && 1 === count($ids))) {
289 1
            return null;
290
        }
291
292 2
        return $ids[0];
293
    }
294
295
    /**
296
     * @param Image $image
297
     * @throws \RuntimeException
298
     * @throws \InvalidArgumentException
299
     */
300
    private function imageLogin(Image $image)
301
    {
302 11
        $login = new DockerLogin($this->exec, $this->env->getResolver());
303 11
        $login->byImage($image);
304 11
    }
305
306
    /**
307
     * @param bool $copy
308
     * @param string $id container id
309
     * @param string $dir directory to copy contents into container
310
     * @throws \RuntimeException
311
     * @return null|int null if all clear, integer for exit status
312
     */
313
    private function deployCopy($copy, $id, $dir)
314
    {
315 12
        if (!$copy) {
316 5
            return null;
317
        }
318
319 7
        $streams = $this->streams;
320 7
        $exec = $this->exec;
321
322 7
        $streams->out("\x1D+++ copying files into container...\n");
323
324 7
        $tmpDir = sys_get_temp_dir() . '/pipelines/cp';
325 7
        Lib::fsMkdir($tmpDir);
326 7
        Lib::fsSymlink($dir, $tmpDir . '/app');
327 7
        $cd = Lib::cmd('cd', array($tmpDir . '/.'));
328 7
        $tar = Lib::cmd(
329 7
            'tar',
330
            array(
331 7
                'c', '-h', '-f', '-', '--no-recursion', 'app')
332
        );
333 7
        $dockerCp = Lib::cmd(
334 7
            'docker ',
335
            array(
336 7
                'cp', '-', $id . ':/.')
337
        );
338 7
        $status = $exec->pass("${cd} && echo 'app' | ${tar} | ${dockerCp}", array());
339 7
        Lib::fsUnlink($tmpDir . '/app');
340 7
        if (0 !== $status) {
341 1
            $streams->err('pipelines: deploy copy failure\n');
342
343 1
            return $status;
344
        }
345
346 6
        $cd = Lib::cmd('cd', array($dir . '/.'));
347 6
        $tar = Lib::cmd(
348 6
            'tar',
349
            array(
350 6
                'c', '-f', '-', '.')
351
        );
352 6
        $dockerCp = Lib::cmd(
353 6
            'docker ',
354
            array(
355 6
                'cp', '-', $id . ':/app')
356
        );
357 6
        $status = $exec->pass("${cd} && ${tar} | ${dockerCp}", array());
358 6
        if (0 !== $status) {
359 1
            $streams->err('pipelines: deploy copy failure\n');
360
361 1
            return $status;
362
        }
363
364 5
        $streams('');
365
366 5
        return null;
367
    }
368
369
    /**
370
     * @param Step $step
371
     * @param Streams $streams
372
     * @param Exec $exec
373
     * @param string $name container name
374
     * @return null|int should never be null, status, non-zero if a command failed
375
     */
376
    private function runStepScript(Step $step, Streams $streams, Exec $exec, $name)
377
    {
378 10
        $script = $step->getScript();
379 10
        $status = null;
380
381 10
        foreach ($script as $line => $command) {
382 10
            $streams->out(sprintf("\x1D+ %s\n", $command));
383 10
            $status = $exec->pass('docker', array(
384 10
                'exec', '-i', $name, '/bin/sh', '-c', $command,
385
            ));
386 10
            $streams->out(sprintf("\n"));
387 10
            if (0 !== $status) {
388 2
                $this->streams->err(sprintf("script non-zero exit status: %d\n", $status));
389
390 10
                break;
391
            }
392
        }
393
394 10
        return $status;
395
    }
396
397
    /**
398
     * @param Step $step
399
     * @param bool $copy
400
     * @param string $id container id
401
     * @param string $dir to put artifacts in (project directory)
402
     * @throws \RuntimeException
403
     */
404
    private function captureStepArtifacts(Step $step, $copy, $id, $dir)
405
    {
406
        # capturing artifacts is only supported for deploy copy
407 10
        if (!$copy) {
408 5
            return;
409
        }
410
411 5
        $artifacts = $step->getArtifacts();
412
413 5
        if (null === $artifacts) {
414 2
            return;
415
        }
416
417 3
        $exec = $this->exec;
418 3
        $streams = $this->streams;
419
420 3
        $streams->out("\x1D+++ copying artifacts from container...\n");
421
422 3
        $source = new ArtifactSource($exec, $id, $dir);
423
424 3
        $patterns = $artifacts->getPatterns();
425 3
        foreach ($patterns as $pattern) {
426 3
            $this->captureArtifactPattern($source, $pattern, $dir);
427
        }
428
429 3
        $streams('');
430 3
    }
431
432
    /**
433
     * @see Runner::captureStepArtifacts()
434
     *
435
     * @param ArtifactSource $source
436
     * @param string $pattern
437
     * @param string $dir
438
     * @throws \RuntimeException
439
     */
440
    private function captureArtifactPattern(ArtifactSource $source, $pattern, $dir)
441
    {
442 3
        $exec = $this->exec;
443 3
        $streams = $this->streams;
444
445 3
        $id = $source->getId();
446 3
        $paths = $source->findByPattern($pattern);
447 3
        if (empty($paths)) {
448 1
            return;
449
        }
450
451 2
        $chunks = Lib::arrayChunkByStringLength($paths, 131072, 4);
452
453 2
        foreach ($chunks as $paths) {
454 2
            $docker = Lib::cmd('docker', array('exec', '-w', '/app', $id));
455 2
            $tar = Lib::cmd('tar', array('c', '-f', '-', $paths));
456 2
            $unTar = Lib::cmd('tar', array('x', '-f', '-', '-C', $dir));
457
458 2
            $command = $docker . ' ' . $tar . ' | ' . $unTar;
459 2
            $status = $exec->pass($command, array());
460
461 2
            if (0 !== $status) {
462 1
                $streams->err(sprintf(
463 1
                    "pipelines: Artifact failure: '%s' (%d, %d paths, %d bytes)\n",
464 1
                    $pattern,
465 1
                    $status,
466 1
                    count($paths),
467 2
                    strlen($command)
468
                ));
469
            }
470
        }
471 2
    }
472
473
    /**
474
     * @param int $status
475
     * @param string $id container id
476
     * @param Exec $exec
477
     * @param string $name container name
478
     * @throws \RuntimeException
479
     */
480
    private function shutdownStepContainer($status, $id, Exec $exec, $name)
481
    {
482 10
        $flags = $this->flags;
483
484
        # keep container on error
485 10
        if (0 !== $status && $flags & self::FLAG_KEEP_ON_ERROR) {
486 2
            $this->streams->err(sprintf(
487 2
                "error, keeping container id %s\n",
488 2
                substr($id, 0, 12)
489
            ));
490
491 2
            return;
492
        }
493
494
        # keep or remove container
495 8
        if ($flags & self::FLAG_DOCKER_KILL) {
496 7
            $exec->capture('docker', array('kill', $name));
497
        }
498
499 8
        if ($flags & self::FLAG_DOCKER_REMOVE) {
500 7
            $exec->capture('docker', array('rm', $name));
501
        }
502
503 8
        if (!($flags & (self::FLAG_DOCKER_KILL | self::FLAG_DOCKER_REMOVE))) {
504 1
            $this->streams->out(sprintf(
505 1
                "keeping container id %s\n",
506 1
                substr($id, 0, 12)
507
            ));
508
        }
509 8
    }
510
511
    /**
512
     * @param Step $step
513
     * @return string
514
     */
515
    private function generateContainerName(Step $step)
516
    {
517 13
        $project = $this->directories->getName();
518 13
        $idContainerSlug = preg_replace('([^a-zA-Z0-9_.-]+)', '-', $step->getPipeline()->getId());
519 13
        if ('' === $idContainerSlug) {
520 13
            $idContainerSlug = 'null';
521
        }
522 13
        $nameSlug = preg_replace(array('( )', '([^a-zA-Z0-9_.-]+)'), array('-', ''), $step->getName());
523 13
        if ('' === $nameSlug) {
524 13
            $nameSlug = 'no-name';
525
        }
526
527 13
        return $this->prefix . '-' . implode(
528 13
            '.',
529 13
            array_reverse(
530
                array(
531 13
                    $project,
532 13
                    trim($idContainerSlug, '-'),
533 13
                    $nameSlug,
534 13
                    $step->getIndex() + 1,
535
                )
536
            )
537
        );
538
    }
539
}
540