Test Failed
Push — test ( a53f3f...e32fe9 )
by Tom
02:31
created

Runner::deployDockerClient()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 13
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 7
CRAP Score 2

Importance

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