Passed
Push — test ( f7394f...e7f3db )
by Tom
02:41 queued 22s
created

Runner::runNewContainer()   B

Complexity

Conditions 8
Paths 32

Size

Total Lines 48
Code Lines 32

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 19
CRAP Score 8.0079

Importance

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