Passed
Push — test ( 52bb81...4c4f16 )
by Tom
02:50
created

StepRunner::obtainDockerSocketMount()   B

Complexity

Conditions 7
Paths 10

Size

Total Lines 39
Code Lines 20

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 18
CRAP Score 7

Importance

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