Passed
Push — test ( d6edfc...f7394f )
by Tom
02:30
created

Runner::shutdownStepContainer()   A

Complexity

Conditions 6
Paths 9

Size

Total Lines 27
Code Lines 14

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 14
CRAP Score 6

Importance

Changes 0
Metric Value
cc 6
eloc 14
nc 9
nop 4
dl 0
loc 27
ccs 14
cts 14
cp 1
crap 6
rs 9.2222
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 int
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;
0 ignored issues
show
Documentation Bug introduced by
It seems like null === $flags ? new Kt...Runner\Flags() : $flags of type Ktomk\Pipelines\Runner\Flags is incompatible with the declared type integer of property $flags.

Our type inference engine has found an assignment to a property that is incompatible with the declared type of that property.

Either this assignment is in error or the assigned type should be added to the documentation/type hint for that property..

Loading history...
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
        $flags = $this->flags;
135
136 14
        $name = $this->generateContainerName($step);
137
138 14
        $reuseContainer = $flags->reuseContainer();
0 ignored issues
show
Bug introduced by
The method reuseContainer() does not exist on integer. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

138
        /** @scrutinizer ignore-call */ 
139
        $reuseContainer = $flags->reuseContainer();

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
139
140 14
        if (false === $reuseContainer) {
141 11
            $this->zapContainerByName($name);
142
        }
143 14
        $image = $step->getImage();
144 14
        $env->setContainerName($name);
145
146
        # launch container
147 14
        $streams->out(sprintf(
148 14
            "\x1D+++ step #%d\n\n    name...........: %s\n    effective-image: %s\n    container......: %s\n",
149 14
            $step->getIndex() + 1,
150 14
            $step->getName() ? '"' . $step->getName() . '"' : '(unnamed)',
151 14
            $image->getName(),
152 14
            $name
153
        ));
154
155 14
        $copy = $flags->deployCopy();
156
157 14
        $id = null;
158 14
        if ($reuseContainer) {
159 3
            $id = $this->dockerGetContainerIdByName($name);
160
        }
161
162 14
        if (null === $id) {
163 12
            list($id, $status) = $this->runNewContainer($name, $dir, $copy, $step);
164 12
            if (null === $id) {
165 1
                return $status;
166
            }
167
        }
168
169 13
        $streams->out(sprintf("    container-id...: %s\n\n", substr($id, 0, 12)));
170
171
        # TODO: different deployments, mount (default), mount-ro, copy
172 13
        if (null !== $result = $this->deployCopy($copy, $id, $dir)) {
173 2
            return $result;
174
        }
175
176 11
        $this->deploydockerClient($step, $id);
177
178 11
        $status = $this->runStepScript($step, $streams, $exec, $name);
179
180 11
        $this->captureStepArtifacts($step, $copy && 0 === $status, $id, $dir);
181
182 11
        $this->shutdownStepContainer($status, $id, $exec, $name);
183
184 11
        return $status;
185
    }
186
187
    /**
188
     * method to wrap new to have a test-point
189
     *
190
     * @return Repository
191
     */
192 1
    public function getDockerBinaryRepository()
193
    {
194 1
        return new Repository($this->exec, $this->directories);
195
    }
196
197
    /**
198
     * if there is the docker service in the step, deploy the
199
     * docker client
200
     *
201
     * @param Step $step
202
     * @param string $id
203
     */
204 11
    private function deployDockerClient(Step $step, $id)
205
    {
206 11
        if (!$step->getServices()->has('docker')) {
207 10
            return;
208
        }
209
210 1
        $this->streams->out(' +++ docker client install...: ');
211
212 1
        $this->getDockerBinaryRepository()->inject($id);
213
214 1
        $this->streams->out("\n");
215 1
    }
216
217
    /**
218
     * @param string $name
219
     * @param string $dir
220
     * @param bool $copy
221
     * @param Step $step
222
     * @return array
223
     */
224 12
    private function runNewContainer($name, $dir, $copy, Step $step)
225
    {
226 12
        $env = $this->env;
227 12
        $exec = $this->exec;
228 12
        $flags = $this->flags;
229 12
        $streams = $this->streams;
230
231 12
        $image = $step->getImage();
232 12
        $docker = new Docker($exec);
233
234
        # process docker login if image demands so, but continue on failure
235 12
        $this->imageLogin($image);
236
237
        // enable docker client inside docker by mounting docker socket
238
        // FIXME give controlling options, this is serious /!\
239 12
        $socket = $flags->useDockerSocket();
240 12
        $mountDockerSock = array();
241 12
        if ($socket && file_exists('/var/run/docker.sock')) {
242
            $mountDockerSock = array(
243
                '-v', '/var/run/docker.sock:/var/run/docker.sock',
244
            );
245
        }
246
247 12
        $parentName = $env->getValue('PIPELINES_PARENT_CONTAINER_NAME');
248 12
        $checkMount = $mountDockerSock && null !== $parentName;
249 12
        $deviceDir = $checkMount ? $docker->hostDevice($parentName, $dir) : $dir;
250
251 12
        $mountWorkingDirectory = $copy
252 7
            ? array()
253 12
            : array('--volume', "${deviceDir}:/app"); // FIXME(tk): hard encoded /app
254
255
        $status = $exec->capture('docker', array(
256
            'run', '-i', '--name', $name,
257
            $env->getArgs('-e'),
258
            $mountWorkingDirectory, '-e', 'BITBUCKET_CLONE_DIR=/app',
259
            $mountDockerSock,
260
            '--workdir', '/app', '--detach', '--entrypoint=/bin/sh', $image->getName()
261
        ), $out, $err);
262
        if (0 !== $status) {
263
            $streams->out("    container-id...: *failure*\n\n");
264
            $streams->err("pipelines: setting up the container failed\n");
265
            $streams->err("${err}\n");
266
            $streams->out("${out}\n");
267 1
            $streams->out(sprintf("exit status: %d\n", $status));
268
269 1
            return array(null, $status);
270
        }
271 11
        $id = rtrim($out) ?: '*dry-run*'; # side-effect: internal exploit of no output with true exit status
272
273 11
        return array($id, 0);
274
    }
275
276
    /**
277
     * @param string $name
278
     */
279
    private function zapContainerByName($name)
280
    {
281 11
        $ids = null;
282
283 11
        $status = $this->exec->capture(
284 11
            'docker',
285
            array(
286 11
                'ps', '-qa', '--filter',
287 11
                "name=^/${name}$"
288
            ),
289 11
            $result
290
        );
291
292 11
        $status || $ids = Lib::lines($result);
293
294 11
        if ($status || !(is_array($ids) && 1 === count($ids))) {
295 10
            return;
296
        }
297
298 1
        $this->exec->capture('docker', Lib::merge('kill', $ids));
299 1
        $this->exec->capture('docker', Lib::merge('rm', $ids));
300 1
    }
301
302
    /**
303
     * @param string $name
304
     * @return null|string
305
     */
306
    private function dockerGetContainerIdByName($name)
307
    {
308 3
        $ids = null;
309
310 3
        $status = $this->exec->capture(
311 3
            'docker',
312
            array(
313 3
                'ps', '-qa', '--filter',
314 3
                "name=^/${name}$"
315
            ),
316 3
            $result
317
        );
318
319 3
        $status || $ids = Lib::lines($result);
320
321 3
        if ($status || !(is_array($ids) && 1 === count($ids))) {
322 1
            return null;
323
        }
324
325 2
        return $ids[0];
326
    }
327
328
    /**
329
     * @param Image $image
330
     * @throws \RuntimeException
331
     * @throws \InvalidArgumentException
332
     */
333
    private function imageLogin(Image $image)
334
    {
335 12
        $login = new DockerLogin($this->exec, $this->env->getResolver());
336 12
        $login->byImage($image);
337 12
    }
338
339
    /**
340
     * @param bool $copy
341
     * @param string $id container id
342
     * @param string $dir directory to copy contents into container
343
     * @throws \RuntimeException
344
     * @return null|int null if all clear, integer for exit status
345
     */
346
    private function deployCopy($copy, $id, $dir)
347
    {
348 13
        if (!$copy) {
349 6
            return null;
350
        }
351
352 7
        $streams = $this->streams;
353 7
        $exec = $this->exec;
354
355 7
        $streams->out("\x1D+++ copying files into container...\n");
356
357 7
        $tmpDir = LibTmp::tmpDir('pipelines-cp.');
358 7
        $this->temporaryDirectories[] = DestructibleString::rmDir($tmpDir);
359 7
        LibFs::symlink($dir, $tmpDir . '/app');
360 7
        $cd = Lib::cmd('cd', array($tmpDir . '/.'));
361 7
        $tar = Lib::cmd('tar', array('c', '-h', '-f', '-', '--no-recursion', 'app'));
362 7
        $dockerCp = Lib::cmd('docker ', array('cp', '-', $id . ':/.'));
363 7
        $status = $exec->pass("${cd} && echo 'app' | ${tar} | ${dockerCp}", array());
364 7
        LibFs::unlink($tmpDir . '/app');
365 7
        if (0 !== $status) {
366 1
            $streams->err('pipelines: deploy copy failure\n');
367
368 1
            return $status;
369
        }
370
371 6
        $cd = Lib::cmd('cd', array($dir . '/.'));
372 6
        $tar = Lib::cmd('tar', array('c', '-f', '-', '.'));
373 6
        $dockerCp = Lib::cmd('docker ', array('cp', '-', $id . ':/app'));
374 6
        $status = $exec->pass("${cd} && ${tar} | ${dockerCp}", array());
375 6
        if (0 !== $status) {
376 1
            $streams->err('pipelines: deploy copy failure\n');
377
378 1
            return $status;
379
        }
380
381 5
        $streams('');
382
383 5
        return null;
384
    }
385
386
    /**
387
     * @param Step $step
388
     * @param Streams $streams
389
     * @param Exec $exec
390
     * @param string $name container name
391
     * @return null|int should never be null, status, non-zero if a command failed
392
     */
393
    private function runStepScript(Step $step, Streams $streams, Exec $exec, $name)
394
    {
395 11
        $script = $step->getScript();
396
397 11
        $buffer = Lib::cmd("<<'SCRIPT' docker", array(
398 11
            'exec', '-i', $name, '/bin/sh'
399
        ));
400 11
        $buffer .= "\n# this /bin/sh script is generated from a pipelines pipeline:\n";
401 11
        foreach ($script as $line => $command) {
402 11
            $buffer .= 'printf \'\\035+ %s\\n\' ' . Lib::quoteArg($command) . "\n";
403 11
            $buffer .= $command . "\n";
404 11
            $buffer .= 'ret=$?' . "\n";
405 11
            $buffer .= 'printf \'\\n\'' . "\n";
406 11
            $buffer .= 'if [ $ret -ne 0 ]; then exit $ret; fi' . "\n";
407
        }
408 11
        $buffer .= "SCRIPT\n";
409
410 11
        $status = $exec->pass($buffer, array());
411
412 11
        if (0 !== $status) {
413 2
            $streams->err(sprintf("script non-zero exit status: %d\n", $status));
414
        }
415
416 11
        return $status;
417
    }
418
419
    /**
420
     * @param Step $step
421
     * @param bool $copy
422
     * @param string $id container id
423
     * @param string $dir to put artifacts in (project directory)
424
     * @throws \RuntimeException
425
     */
426
    private function captureStepArtifacts(Step $step, $copy, $id, $dir)
427
    {
428
        # capturing artifacts is only supported for deploy copy
429 11
        if (!$copy) {
430 6
            return;
431
        }
432
433 5
        $artifacts = $step->getArtifacts();
434
435 5
        if (null === $artifacts) {
436 2
            return;
437
        }
438
439 3
        $exec = $this->exec;
440 3
        $streams = $this->streams;
441
442 3
        $streams->out("\x1D+++ copying artifacts from container...\n");
443
444 3
        $source = new ArtifactSource($exec, $id, $dir);
445
446 3
        $patterns = $artifacts->getPatterns();
447 3
        foreach ($patterns as $pattern) {
448 3
            $this->captureArtifactPattern($source, $pattern, $dir);
449
        }
450
451 3
        $streams('');
452 3
    }
453
454
    /**
455
     * @see Runner::captureStepArtifacts()
456
     *
457
     * @param ArtifactSource $source
458
     * @param string $pattern
459
     * @param string $dir
460
     * @throws \RuntimeException
461
     */
462
    private function captureArtifactPattern(ArtifactSource $source, $pattern, $dir)
463
    {
464 3
        $exec = $this->exec;
465 3
        $streams = $this->streams;
466
467 3
        $id = $source->getId();
468 3
        $paths = $source->findByPattern($pattern);
469 3
        if (empty($paths)) {
470 1
            return;
471
        }
472
473 2
        $chunks = Lib::arrayChunkByStringLength($paths, 131072, 4);
474
475 2
        foreach ($chunks as $paths) {
476 2
            $docker = Lib::cmd('docker', array('exec', '-w', '/app', $id));
477 2
            $tar = Lib::cmd('tar', array('c', '-f', '-', $paths));
478 2
            $unTar = Lib::cmd('tar', array('x', '-f', '-', '-C', $dir));
479
480 2
            $command = $docker . ' ' . $tar . ' | ' . $unTar;
481 2
            $status = $exec->pass($command, array());
482
483 2
            if (0 !== $status) {
484 1
                $streams->err(sprintf(
485 1
                    "pipelines: Artifact failure: '%s' (%d, %d paths, %d bytes)\n",
486 1
                    $pattern,
487 1
                    $status,
488 1
                    count($paths),
489 1
                    strlen($command)
490
                ));
491
            }
492
        }
493 2
    }
494
495
    /**
496
     * @param int $status
497
     * @param string $id container id
498
     * @param Exec $exec
499
     * @param string $name container name
500
     * @throws \RuntimeException
501
     */
502
    private function shutdownStepContainer($status, $id, Exec $exec, $name)
503
    {
504 11
        $flags = $this->flags;
505
506
        # keep container on error
507 11
        if (0 !== $status && $flags->keepOnError()) {
508 2
            $this->streams->err(sprintf(
509 2
                "error, keeping container id %s\n",
510 2
                substr($id, 0, 12)
511
            ));
512
513 2
            return;
514
        }
515
516
        # keep or remove container
517 9
        if ($flags->killContainer()) {
518 8
            $exec->capture('docker', array('kill', $name));
519
        }
520
521 9
        if ($flags->removeContainer()) {
522 8
            $exec->capture('docker', array('rm', $name));
523
        }
524
525 9
        if ($flags->keep()) {
526 1
            $this->streams->out(sprintf(
527 1
                "keeping container id %s\n",
528 1
                substr($id, 0, 12)
529
            ));
530
        }
531 9
    }
532
533
    /**
534
     * @param Step $step
535
     * @return string
536
     */
537
    private function generateContainerName(Step $step)
538
    {
539 14
        $project = $this->directories->getName();
540 14
        $idContainerSlug = preg_replace('([^a-zA-Z0-9_.-]+)', '-', $step->getPipeline()->getId());
541 14
        if ('' === $idContainerSlug) {
542 14
            $idContainerSlug = 'null';
543
        }
544 14
        $nameSlug = preg_replace(array('( )', '([^a-zA-Z0-9_.-]+)'), array('-', ''), $step->getName());
545 14
        if ('' === $nameSlug) {
546 14
            $nameSlug = 'no-name';
547
        }
548
549 14
        return $this->prefix . '-' . implode(
550 14
            '.',
551 14
            array_reverse(
552
                array(
553 14
                    $project,
554 14
                    trim($idContainerSlug, '-'),
555 14
                    $nameSlug,
556 14
                    $step->getIndex() + 1,
557
                )
558
            )
559
        );
560
    }
561
}
562