Passed
Push — test ( 9a8848...0c4d14 )
by Tom
02:23
created

Runner::__construct()   A

Complexity

Conditions 4
Paths 8

Size

Total Lines 15
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 7
CRAP Score 4

Importance

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