Passed
Push — test ( ccd17d...b5a64e )
by Tom
02:39
created

StepRunner::runStepScript()   A

Complexity

Conditions 4
Paths 6

Size

Total Lines 23
Code Lines 14

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 14
CRAP Score 4

Importance

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