Test Failed
Branch test (ad29d6)
by Tom
02:27
created

Runner::runStepScript()   A

Complexity

Conditions 3
Paths 4

Size

Total Lines 24
Code Lines 14

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 9
CRAP Score 3

Importance

Changes 2
Bugs 1 Features 0
Metric Value
cc 3
eloc 14
c 2
b 1
f 0
nc 4
nop 4
dl 0
loc 24
rs 9.7998
ccs 9
cts 9
cp 1
crap 3
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\Runner\Docker\Binary\Repository;
17
18
/**
19
 * Pipeline runner with docker under the hood
20
 */
21
class Runner
22
{
23
    const FLAGS = 19;
24
    const FLAG_DOCKER_REMOVE = 1;
25
    const FLAG_DOCKER_KILL = 2;
26
    const FLAG_DEPLOY_COPY = 4; # copy working dir into container
27
    const FLAG_KEEP_ON_ERROR = 8;
28
    const FLAG_SOCKET = 16;
29
30
    const STATUS_NO_STEPS = 1;
31
    const STATUS_RECURSION_DETECTED = 127;
32
33
    /**
34
     * @var string
35
     */
36
    private $prefix;
37
38
    /**
39
     * @var Directories
40
     */
41
    private $directories;
42
43
    /**
44
     * @var Exec
45
     */
46
    private $exec;
47
48
    /**
49
     * @var int
50
     */
51
    private $flags;
52
53
    /**
54
     * @var Env
55
     */
56
    private $env;
57
    /**
58
     * @var Streams
59
     */
60
    private $streams;
61
62
    /**
63
     * list of temporary directory destructible markers
64
     *
65
     * @var array
66
     */
67
    private $temporaryDirectories = array();
68
69
    /**
70
     * DockerSession constructor.
71
     *
72
     * @param string $prefix
73
     * @param Directories $directories source repository root directory based directories object
74
     * @param Exec $exec
75
     * @param int $flags [optional]
76
     * @param Env $env [optional]
77
     * @param Streams $streams [optional]
78 15
     */
79
    public function __construct(
80
        $prefix,
81
        Directories $directories,
82
        Exec $exec,
83
        $flags = null,
84
        Env $env = null,
85
        Streams $streams = null
86
    )
87 15
    {
88 15
        $this->prefix = $prefix;
89 15
        $this->directories = $directories;
90 15
        $this->exec = $exec;
91 15
        $this->flags = null === $flags ? self::FLAGS : $flags;
92 15
        $this->env = null === $env ? Env::create() : $env;
93 15
        $this->streams = null === $streams ? Streams::create() : $streams;
94
    }
95
96
    /**
97
     * @param Pipeline $pipeline
98
     * @throws \RuntimeException
99
     * @return int status (as in exit status, 0 OK, !0 NOK)
100 15
     */
101
    public function run(Pipeline $pipeline)
102 15
    {
103 15
        $hasId = $this->env->setPipelinesId($pipeline->getId()); # TODO give Env an addPipeline() method (compare addReference)
104 1
        if ($hasId) {
105 1
            $this->streams->err(sprintf(
106 1
                "pipelines: won't start pipeline '%s'; pipeline inside pipelines recursion detected\n",
107
                $pipeline->getId()
108
            ));
109 1
110
            return self::STATUS_RECURSION_DETECTED;
111
        }
112 14
113 13
        foreach ($pipeline->getSteps() as $step) {
114 13
            $status = $this->runStep($step);
115 13
            if (0 !== $status) {
116
                return $status;
117
            }
118
        }
119 9
120 1
        if (!isset($status)) {
121
            $this->streams->err("pipelines: pipeline with no step to execute\n");
122 1
123
            return self::STATUS_NO_STEPS;
124
        }
125 8
126
        return $status;
127
    }
128
129
    /**
130
     * @param Step $step
131
     * @throws \RuntimeException
132
     * @return int exit status
133 13
     */
134
    public function runStep(Step $step)
135 13
    {
136 13
        $dir = $this->directories->getProjectDirectory();
137 13
        $env = $this->env;
138 13
        $exec = $this->exec;
139 13
        $streams = $this->streams;
140
        $flags = $this->flags;
141 13
142
        $name = $this->generateContainerName($step);
143
144 13
        $reuseContainer =
145 13
            ($flags & self::FLAG_KEEP_ON_ERROR)
146
            || !($flags & (self::FLAG_DOCKER_KILL | self::FLAG_DOCKER_REMOVE));
147 13
148 10
        if (false === $reuseContainer) {
149
            $this->zapContainerByName($name);
150 13
        }
151 13
        $image = $step->getImage();
152
        $env->setContainerName($name);
153
154 13
        # launch container
155 13
        $streams->out(sprintf(
156 13
            "\x1D+++ step #%d\n\n    name...........: %s\n    effective-image: %s\n    container......: %s\n",
157 13
            $step->getIndex() + 1,
158 13
            $step->getName() ? '"' . $step->getName() . '"' : '(unnamed)',
159 13
            $image->getName(),
160
            $name
161
        ));
162 13
163
        $copy = (bool)($flags & self::FLAG_DEPLOY_COPY);
164 13
165 13
        $id = null;
166 3
        if ($reuseContainer) {
167
            $id = $this->dockerGetContainerIdByName($name);
168
        }
169 13
170 11
        if (null === $id) {
171 11
            list($id, $status) = $this->runNewContainer($name, $dir, $copy, $step);
172 1
            if (null === $id) {
173
                return $status;
174
            }
175
        }
176 12
177
        $streams->out(sprintf("    container-id...: %s\n\n", substr($id, 0, 12)));
178
179 12
        # TODO: different deployments, mount (default), mount-ro, copy
180 2
        if (null !== $result = $this->deployCopy($copy, $id, $dir)) {
181
            return $result;
182
        }
183 10
184
        $this->deploydockerClient($step, $id);
185 10
186
        $status = $this->runStepScript($step, $streams, $exec, $name);
187 10
188
        $this->captureStepArtifacts($step, $copy && 0 === $status, $id, $dir);
189 10
190
        $this->shutdownStepContainer($status, $id, $exec, $name);
191
192
        return $status;
193
    }
194
195
    /**
196
     * method to wrap new to have a test-point
197
     *
198
     * @return Repository
199 11
     */
200
    public function getDockerBinaryRepository()
201 11
    {
202 11
        return new Repository($this->exec, $this->directories);
203 11
    }
204 11
205
    /**
206 11
     * if there is the docker service in the step, deploy the
207 11
     * docker client
208
     *
209
     * @param Step $step
210 11
     * @param string $id
211
     */
212
    private function deployDockerClient(Step $step, $id)
213
    {
214 11
        if (!$step->getServices()->has('docker')) {
215 11
            return;
216 11
        }
217
218
        $this->streams->out('    docker client install... ');
219
220
        $this->getDockerBinaryRepository()->inject($id);
221
    }
222 11
223 11
    /**
224 11
     * @param string $name
225
     * @param string $dir
226 11
     * @param bool $copy
227 7
     * @param Step $step
228 11
     * @return array
229
     */
230
    private function runNewContainer($name, $dir, $copy, Step $step)
231
    {
232
        $env = $this->env;
233
        $exec = $this->exec;
234
        $flags = $this->flags;
235
        $streams = $this->streams;
236
237
        $image = $step->getImage();
238
        $docker = new Docker($exec);
239
240
        # process docker login if image demands so, but continue on failure
241
        $this->imageLogin($image);
242 1
243
        // enable docker client inside docker by mounting docker socket
244 1
        // FIXME give controlling options, this is serious /!\
245
        $socket = (bool)($flags & self::FLAG_SOCKET);
246 10
        $mountDockerSock = array();
247
        if ($socket && file_exists('/var/run/docker.sock')) {
248 10
            $mountDockerSock = array(
249
                '-v', '/var/run/docker.sock:/var/run/docker.sock',
250
            );
251
        }
252
253
        $parentName = $env->getValue('PIPELINES_PARENT_CONTAINER_NAME');
254
        $checkMount = $mountDockerSock && null !== $parentName;
255
        $deviceDir = $checkMount ? $docker->hostDevice($parentName, $dir) : $dir;
256 10
257
        $mountWorkingDirectory = $copy
258 10
            ? array()
259 10
            : array('--volume', "${deviceDir}:/app"); // FIXME(tk): hard encoded /app
260
261 10
        $status = $exec->capture('docker', array(
262 10
            'run', '-i', '--name', $name,
263
            $env->getArgs('-e'),
264 10
            $mountWorkingDirectory, '-e', 'BITBUCKET_CLONE_DIR=/app',
265
            $mountDockerSock,
266
            '--workdir', '/app', '--detach', '--entrypoint=/bin/sh', $image->getName()
267 10
        ), $out, $err);
268
        if (0 !== $status) {
269 10
            $streams->out("    container-id...: *failure*\n\n");
270 9
            $streams->err("pipelines: setting up the container failed\n");
271
            $streams->err("${err}\n");
272
            $streams->out("${out}\n");
273 1
            $streams->out(sprintf("exit status: %d\n", $status));
274 1
275 1
            return array(null, $status);
276
        }
277
        $id = rtrim($out) ?: '*dry-run*'; # side-effect: internal exploit of no output with true exit status
278
279
        return array($id, 0);
280
    }
281
282
    /**
283 3
     * @param string $name
284
     */
285 3
    private function zapContainerByName($name)
286 3
    {
287
        $ids = null;
288 3
289 3
        $status = $this->exec->capture(
290
            'docker',
291 3
            array(
292
                'ps', '-qa', '--filter',
293
                "name=^/${name}$"
294 3
            ),
295
            $result
296 3
        );
297 1
298
        $status || $ids = Lib::lines($result);
299
300 2
        if ($status || !(is_array($ids) && 1 === count($ids))) {
301
            return;
302
        }
303
304
        $this->exec->capture('docker', Lib::merge('kill', $ids));
305
        $this->exec->capture('docker', Lib::merge('rm', $ids));
306
    }
307
308
    /**
309
     * @param string $name
310 11
     * @return null|string
311 11
     */
312 11
    private function dockerGetContainerIdByName($name)
313
    {
314
        $ids = null;
315
316
        $status = $this->exec->capture(
317
            'docker',
318
            array(
319
                'ps', '-qa', '--filter',
320
                "name=^/${name}$"
321
            ),
322
            $result
323 12
        );
324 5
325
        $status || $ids = Lib::lines($result);
326
327 7
        if ($status || !(is_array($ids) && 1 === count($ids))) {
328 7
            return null;
329
        }
330 7
331
        return $ids[0];
332 7
    }
333 7
334 7
    /**
335 7
     * @param Image $image
336 7
     * @throws \RuntimeException
337 7
     * @throws \InvalidArgumentException
338 7
     */
339
    private function imageLogin(Image $image)
340 7
    {
341 7
        $login = new DockerLogin($this->exec, $this->env->getResolver());
342 7
        $login->byImage($image);
343
    }
344 7
345 7
    /**
346 7
     * @param bool $copy
347 1
     * @param string $id container id
348
     * @param string $dir directory to copy contents into container
349 1
     * @throws \RuntimeException
350
     * @return null|int null if all clear, integer for exit status
351
     */
352 6
    private function deployCopy($copy, $id, $dir)
353 6
    {
354 6
        if (!$copy) {
355
            return null;
356 6
        }
357
358 6
        $streams = $this->streams;
359 6
        $exec = $this->exec;
360
361 6
        $streams->out("\x1D+++ copying files into container...\n");
362
363 6
        $tmpDir = LibFs::tmpDir('pipelines-cp.');
364 6
        $this->temporaryDirectories[] = DestructibleString::rmDir($tmpDir);
365 1
        LibFs::symlink($dir, $tmpDir . '/app');
366
        $cd = Lib::cmd('cd', array($tmpDir . '/.'));
367 1
        $tar = Lib::cmd(
368
            'tar',
369
            array('c', '-h', '-f', '-', '--no-recursion', 'app')
370 5
        );
371
        $dockerCp = Lib::cmd(
372 5
            'docker ',
373
            array('cp', '-', $id . ':/.')
374
        );
375
        $status = $exec->pass("${cd} && echo 'app' | ${tar} | ${dockerCp}", array());
376
        LibFs::unlink($tmpDir . '/app');
377
        if (0 !== $status) {
378
            $streams->err('pipelines: deploy copy failure\n');
379
380
            return $status;
381
        }
382
383
        $cd = Lib::cmd('cd', array($dir . '/.'));
384 10
        $tar = Lib::cmd(
385
            'tar',
386 10
            array(
387 10
                'c', '-f', '-', '.')
388 10
        );
389 10
        $dockerCp = Lib::cmd(
390 10
            'docker ',
391 10
            array(
392 10
                'cp', '-', $id . ':/app')
393
        );
394
        $status = $exec->pass("${cd} && ${tar} | ${dockerCp}", array());
395 10
        if (0 !== $status) {
396
            $streams->err('pipelines: deploy copy failure\n');
397 10
398 10
            return $status;
399
        }
400
401 10
        $streams('');
402 2
403
        return null;
404
    }
405 10
406
    /**
407 10
     * @param Step $step
408
     * @param Streams $streams
409
     * @param Exec $exec
410
     * @param string $name container name
411
     * @return null|int should never be null, status, non-zero if a command failed
412
     */
413
    private function runStepScript(Step $step, Streams $streams, Exec $exec, $name)
0 ignored issues
show
Unused Code introduced by
The parameter $streams is not used and could be removed. ( Ignorable by Annotation )

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

413
    private function runStepScript(Step $step, /** @scrutinizer ignore-unused */ Streams $streams, Exec $exec, $name)

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
414
    {
415
        $script = $step->getScript();
416
417
        $buffer = '';
418
        foreach ($script as $line => $command) {
419
            $buffer .= 'printf \'\\035+ %s\\n\' ' . Lib::quoteArg($command) . "\n";
420 10
            $buffer .= $command . "\n";
421 5
            $buffer .= 'ret=$?' . "\n";
422
            $buffer .= 'printf \'\\n\'' . "\n";
423
            $buffer .= 'if [ $ret -ne 0 ]; then exit $ret; fi' . "\n";
424 5
        }
425
426 5
        $file = LibFs::tmpFilePut($buffer);
427 2
428
        $status = $exec->pass(sprintf('< %s docker', Lib::quoteArg($file)), array(
429
            'exec', '-i', $name, '/bin/sh'
430 3
        ));
431 3
432
        if (0 !== $status) {
433 3
            $this->streams->err(sprintf("script non-zero exit status: %d\n", $status));
434
        }
435 3
436
        return $status;
437 3
    }
438 3
439 3
    /**
440
     * @param Step $step
441
     * @param bool $copy
442 3
     * @param string $id container id
443 3
     * @param string $dir to put artifacts in (project directory)
444
     * @throws \RuntimeException
445
     */
446
    private function captureStepArtifacts(Step $step, $copy, $id, $dir)
447
    {
448
        # capturing artifacts is only supported for deploy copy
449
        if (!$copy) {
450
            return;
451
        }
452
453
        $artifacts = $step->getArtifacts();
454
455 3
        if (null === $artifacts) {
456 3
            return;
457
        }
458 3
459 3
        $exec = $this->exec;
460 3
        $streams = $this->streams;
461 1
462
        $streams->out("\x1D+++ copying artifacts from container...\n");
463
464 2
        $source = new ArtifactSource($exec, $id, $dir);
465
466 2
        $patterns = $artifacts->getPatterns();
467 2
        foreach ($patterns as $pattern) {
468 2
            $this->captureArtifactPattern($source, $pattern, $dir);
469 2
        }
470
471 2
        $streams('');
472 2
    }
473
474 2
    /**
475 1
     * @see Runner::captureStepArtifacts()
476 1
     *
477 1
     * @param ArtifactSource $source
478 1
     * @param string $pattern
479 1
     * @param string $dir
480 2
     * @throws \RuntimeException
481
     */
482
    private function captureArtifactPattern(ArtifactSource $source, $pattern, $dir)
483
    {
484 2
        $exec = $this->exec;
485
        $streams = $this->streams;
486
487
        $id = $source->getId();
488
        $paths = $source->findByPattern($pattern);
489
        if (empty($paths)) {
490
            return;
491
        }
492
493
        $chunks = Lib::arrayChunkByStringLength($paths, 131072, 4);
494
495 10
        foreach ($chunks as $paths) {
496
            $docker = Lib::cmd('docker', array('exec', '-w', '/app', $id));
497
            $tar = Lib::cmd('tar', array('c', '-f', '-', $paths));
498 10
            $unTar = Lib::cmd('tar', array('x', '-f', '-', '-C', $dir));
499 2
500 2
            $command = $docker . ' ' . $tar . ' | ' . $unTar;
501 2
            $status = $exec->pass($command, array());
502
503
            if (0 !== $status) {
504 2
                $streams->err(sprintf(
505
                    "pipelines: Artifact failure: '%s' (%d, %d paths, %d bytes)\n",
506
                    $pattern,
507
                    $status,
508 8
                    count($paths),
509 7
                    strlen($command)
510
                ));
511
            }
512 8
        }
513 7
    }
514
515
    /**
516 8
     * @param int $status
517 1
     * @param string $id container id
518 1
     * @param Exec $exec
519 1
     * @param string $name container name
520
     * @throws \RuntimeException
521
     */
522 8
    private function shutdownStepContainer($status, $id, Exec $exec, $name)
523
    {
524
        $flags = $this->flags;
525
526
        # keep container on error
527
        if (0 !== $status && $flags & self::FLAG_KEEP_ON_ERROR) {
528
            $this->streams->err(sprintf(
529
                "error, keeping container id %s\n",
530 13
                substr($id, 0, 12)
531 13
            ));
532 13
533 13
            return;
534
        }
535 13
536 13
        # keep or remove container
537 13
        if ($flags & self::FLAG_DOCKER_KILL) {
538
            $exec->capture('docker', array('kill', $name));
539
        }
540 13
541 13
        if ($flags & self::FLAG_DOCKER_REMOVE) {
542 13
            $exec->capture('docker', array('rm', $name));
543
        }
544 13
545 13
        if (!($flags & (self::FLAG_DOCKER_KILL | self::FLAG_DOCKER_REMOVE))) {
546 13
            $this->streams->out(sprintf(
547 13
                "keeping container id %s\n",
548
                substr($id, 0, 12)
549
            ));
550
        }
551
    }
552
553
    /**
554
     * @param Step $step
555
     * @return string
556
     */
557
    private function generateContainerName(Step $step)
558
    {
559
        $project = $this->directories->getName();
560
        $idContainerSlug = preg_replace('([^a-zA-Z0-9_.-]+)', '-', $step->getPipeline()->getId());
561
        if ('' === $idContainerSlug) {
562
            $idContainerSlug = 'null';
563
        }
564
        $nameSlug = preg_replace(array('( )', '([^a-zA-Z0-9_.-]+)'), array('-', ''), $step->getName());
565
        if ('' === $nameSlug) {
566
            $nameSlug = 'no-name';
567
        }
568
569
        return $this->prefix . '-' . implode(
570
            '.',
571
            array_reverse(
572
                array(
573
                    $project,
574
                    trim($idContainerSlug, '-'),
575
                    $nameSlug,
576
                    $step->getIndex() + 1,
577
                )
578
            )
579
        );
580
    }
581
}
582