Passed
Push — test ( 303a4d...d6edfc )
by Tom
02:34
created

Runner::captureStepArtifacts()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 26
Code Lines 13

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 13
CRAP Score 4

Importance

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