Passed
Push — test ( ae188d...baca72 )
by Tom
02:55
created

StepRunner   C

Complexity

Total Complexity 56

Size/Duplication

Total Lines 515
Duplicated Lines 0 %

Test Coverage

Coverage 100%

Importance

Changes 6
Bugs 0 Features 0
Metric Value
eloc 205
c 6
b 0
f 0
dl 0
loc 515
ccs 201
cts 201
cp 1
rs 5.5199
wmc 56

14 Methods

Rating   Name   Duplication   Size   Complexity  
A deployCopy() 0 38 4
A __construct() 0 15 1
A imageLogin() 0 4 1
A obtainDockerSocketMount() 0 34 6
A captureStepArtifacts() 0 26 4
A shutdownStepContainer() 0 22 4
A deployDockerClient() 0 19 3
A pipHostConfigBind() 0 12 3
A runStepScript() 0 23 4
B runStep() 0 60 8
A runNewContainer() 0 38 4
B obtainWorkingDirMount() 0 23 9
A getDockerBinaryRepository() 0 6 1
A captureArtifactPattern() 0 28 4

How to fix   Complexity   

Complex Class

Complex classes like StepRunner often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use StepRunner, and based on these observations, apply Extract Interface, too.

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 23
    public function __construct(
73
        RunOpts $runOpts,
74
        Directories $directories,
75
        Exec $exec,
76
        Flags $flags,
77
        Env $env,
78
        Streams $streams
79
    )
80
    {
81 23
        $this->runOpts = $runOpts;
82 23
        $this->directories = $directories;
83 23
        $this->exec = $exec;
84 23
        $this->flags = $flags;
85 23
        $this->env = $env;
86 23
        $this->streams = $streams;
87 23
    }
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 22
    public function runStep(Step $step)
95
    {
96 22
        $dir = $this->directories->getProjectDirectory();
97 22
        $env = $this->env;
98 22
        $exec = $this->exec;
99 22
        $streams = $this->streams;
100
101 22
        $env->setPipelinesProjectPath($dir);
102
103 22
        $container = StepContainer::create($step, $exec);
104
105 22
        $name = $container->generateName(
106 22
            $this->runOpts->getPrefix(),
107 22
            $this->env->getValue('BITBUCKET_REPO_SLUG') ?: $this->directories->getName()
108
        );
109 22
        $env->setContainerName($name);
110
111 22
        $image = $step->getImage();
112
113
        # launch container
114 22
        $streams->out(sprintf(
115 22
            "\x1D+++ step #%d\n\n    name...........: %s\n    effective-image: %s\n    container......: %s\n",
116 22
            $step->getIndex() + 1,
117 22
            $step->getName() ? '"' . $step->getName() . '"' : '(unnamed)',
118 22
            $image->getName(),
119 22
            $name
120
        ));
121
122 22
        $id = $container->keepOrKill($this->flags->reuseContainer());
123
124 22
        $deployCopy = $this->flags->deployCopy();
125
126 22
        if (null === $id) {
127 20
            list($id, $status) = $this->runNewContainer($container, $dir, $deployCopy, $step);
128 20
            if (null === $id) {
129 3
                return $status;
130
            }
131
        }
132
133 19
        $streams->out(sprintf("    container-id...: %s\n\n", substr($id, 0, 12)));
134
135
        # TODO: different deployments, mount (default), mount-ro, copy
136 19
        if (null !== $result = $this->deployCopy($deployCopy, $id, $dir)) {
137 2
            return $result;
138
        }
139
140 17
        list($status, $message) = $this->deployDockerClient($step, $id);
141 16
        if (0 !== $status) {
142 1
            $this->streams->err(rtrim($message, "\n") . "\n");
143
144 1
            return $status;
145
        }
146
147 15
        $status = $this->runStepScript($step, $streams, $exec, $name);
148
149 15
        $this->captureStepArtifacts($step, $deployCopy && 0 === $status, $id, $dir);
150
151 15
        $this->shutdownStepContainer($container, $status);
152
153 15
        return $status;
154
    }
155
156
    /**
157
     * method to wrap new to have a test-point
158
     *
159
     * @return Repository
160
     */
161 2
    public function getDockerBinaryRepository()
162
    {
163 2
        $repo = Repository::create($this->exec, $this->directories);
164 2
        $repo->resolve($this->runOpts->getBinaryPackage());
165
166 1
        return $repo;
167
    }
168
169
    /**
170
     * @param Step $step
171
     * @param bool $copy
172
     * @param string $id container id
173
     * @param string $dir to put artifacts in (project directory)
174
     *
175
     * @throws \RuntimeException
176
     */
177 15
    private function captureStepArtifacts(Step $step, $copy, $id, $dir)
178
    {
179
        # capturing artifacts is only supported for deploy copy
180 15
        if (!$copy) {
181 10
            return;
182
        }
183
184 5
        $artifacts = $step->getArtifacts();
185
186 5
        if (null === $artifacts) {
187 2
            return;
188
        }
189
190 3
        $exec = $this->exec;
191 3
        $streams = $this->streams;
192
193 3
        $streams->out("\x1D+++ copying artifacts from container...\n");
194
195 3
        $source = new ArtifactSource($exec, $id, $dir);
196
197 3
        $patterns = $artifacts->getPatterns();
198 3
        foreach ($patterns as $pattern) {
199 3
            $this->captureArtifactPattern($source, $pattern, $dir);
200
        }
201
202 3
        $streams('');
203 3
    }
204
205
    /**
206
     * @param ArtifactSource $source
207
     * @param string $pattern
208
     * @param string $dir
209
     *
210
     * @throws \RuntimeException
211
     * @see Runner::captureStepArtifacts()
212
     *
213
     */
214 3
    private function captureArtifactPattern(ArtifactSource $source, $pattern, $dir)
215
    {
216 3
        $exec = $this->exec;
217 3
        $streams = $this->streams;
218
219 3
        $id = $source->getId();
220 3
        $paths = $source->findByPattern($pattern);
221 3
        if (empty($paths)) {
222 1
            return;
223
        }
224
225 2
        $chunks = Lib::arrayChunkByStringLength($paths, 131072, 4);
226
227 2
        foreach ($chunks as $paths) {
228 2
            $docker = Lib::cmd('docker', array('exec', '-w', '/app', $id));
229 2
            $tar = Lib::cmd('tar', array('c', '-f', '-', $paths));
230 2
            $unTar = Lib::cmd('tar', array('x', '-f', '-', '-C', $dir));
231
232 2
            $command = $docker . ' ' . $tar . ' | ' . $unTar;
233 2
            $status = $exec->pass($command, array());
234
235 2
            if (0 !== $status) {
236 1
                $streams->err(sprintf(
237 1
                    "pipelines: Artifact failure: '%s' (%d, %d paths, %d bytes)\n",
238 1
                    $pattern,
239 1
                    $status,
240 1
                    count($paths),
241 1
                    strlen($command)
242
                ));
243
            }
244
        }
245 2
    }
246
247
    /**
248
     * @param bool $copy
249
     * @param string $id container id
250
     * @param string $dir directory to copy contents into container
251
     *
252
     * @throws \RuntimeException
253
     * @return null|int null if all clear, integer for exit status
254
     */
255 19
    private function deployCopy($copy, $id, $dir)
256
    {
257 19
        if (!$copy) {
258 12
            return null;
259
        }
260
261 7
        $streams = $this->streams;
262 7
        $exec = $this->exec;
263
264 7
        $streams->out("\x1D+++ copying files into container...\n");
265
266 7
        $tmpDir = LibTmp::tmpDir('pipelines-cp.');
267 7
        $this->temporaryDirectories[] = DestructibleString::rmDir($tmpDir);
268 7
        LibFs::symlink($dir, $tmpDir . '/app');
269 7
        $cd = Lib::cmd('cd', array($tmpDir . '/.'));
270 7
        $tar = Lib::cmd('tar', array('c', '-h', '-f', '-', '--no-recursion', 'app'));
271 7
        $dockerCp = Lib::cmd('docker ', array('cp', '-', $id . ':/.'));
272
        $status = $exec->pass("${cd} && echo 'app' | ${tar} | ${dockerCp}", array());
273
        LibFs::unlink($tmpDir . '/app');
274 7
        if (0 !== $status) {
275 1
            $streams->err('pipelines: deploy copy failure\n');
276
277 1
            return $status;
278
        }
279
280 6
        $cd = Lib::cmd('cd', array($dir . '/.'));
281 6
        $tar = Lib::cmd('tar', array('c', '-f', '-', '.'));
282 6
        $dockerCp = Lib::cmd('docker ', array('cp', '-', $id . ':/app'));
283 6
        $status = $exec->pass("${cd} && ${tar} | ${dockerCp}", array());
284 6
        if (0 !== $status) {
285 1
            $streams->err('pipelines: deploy copy failure\n');
286
287 1
            return $status;
288
        }
289
290 5
        $streams('');
291
292 5
        return null;
293
    }
294
295
    /**
296
     * if there is the docker service in the step, deploy the
297
     * docker client
298
     *
299
     * @param Step $step
300
     * @param string $id
301
     *
302
     * @throws
303
     * @return array array(int $status, string $message)
304
     */
305
    private function deployDockerClient(Step $step, $id)
306
    {
307 17
        if (!$step->getServices()->has('docker')) {
308 14
            return array(0, '');
309
        }
310
311 3
        $this->streams->out(' +++ docker client install...: ');
312
313
        try {
314 3
            list($status, $message) = $this->getDockerBinaryRepository()->inject($id);
315 1
        } catch (\Exception $e) {
316 1
            $this->streams->out("pipelines internal failure.\n");
317
318 1
            throw new \InvalidArgumentException('inject docker client failed: ' . $e->getMessage(), 1, $e);
319
        }
320
321 2
        $this->streams->out("${message}\n");
322
323 2
        return array($status, $message);
324
    }
325
326
    /**
327
     * @param Image $image
328
     *
329
     * @throws \RuntimeException
330
     * @throws \InvalidArgumentException
331
     */
332
    private function imageLogin(Image $image)
333
    {
334 20
        $login = new ImageLogin($this->exec, $this->env->getResolver());
335 20
        $login->byImage($image);
336 20
    }
337
338
    /**
339
     * @param StepContainer $container
340
     * @param string $dir
341
     * @param bool $copy
342
     * @param Step $step
343
     *
344
     * @return array array(string|null $id, int $status)
345
     */
346
    private function runNewContainer(StepContainer $container, $dir, $copy, Step $step)
347
    {
348 20
        $env = $this->env;
349 20
        $streams = $this->streams;
350
351 20
        $image = $step->getImage();
352
353
        # process docker login if image demands so, but continue on failure
354 20
        $this->imageLogin($image);
355
356 20
        $mountDockerSock = $this->obtainDockerSocketMount();
357
358 20
        $mountWorkingDirectory = $this->obtainWorkingDirMount($copy, $dir, $mountDockerSock);
359 20
        if ($mountWorkingDirectory && is_int($mountWorkingDirectory[1])) {
360 2
            return $mountWorkingDirectory;
361
        }
362
363 18
        list($status, $out, $err) = $container->run(
364
            array(
365 18
                '-i', '--name', $container->getName(),
366 18
                $env->getArgs('-e'),
367 18
                $mountWorkingDirectory, '-e', 'BITBUCKET_CLONE_DIR=/app',
368 18
                $mountDockerSock,
369 18
                '--workdir', '/app', '--detach', '--entrypoint=/bin/sh', $image->getName(),
370
            )
371
        );
372 18
        if (0 !== $status) {
373 1
            $streams->out("    container-id...: *failure*\n\n");
374 1
            $streams->err("pipelines: setting up the container failed\n");
375 1
            $streams->err("${err}\n");
376 1
            $streams->out("${out}\n");
377 1
            $streams->out(sprintf("exit status: %d\n", $status));
378
379 1
            return array(null, $status);
380
        }
381 17
        $id = $container->getDisplayId();
382
383 17
        return array($id, $status);
384
    }
385
386
    /**
387
     * enable docker client inside docker by mounting docker socket
388
     *
389
     * @return array docker socket volume args for docker run, empty if not mounting
390
     */
391
    private function obtainDockerSocketMount()
392
    {
393 20
        $args = array();
394
395
        // FIXME give more controlling options, this is serious /!\
396 20
        if (!$this->flags->useDockerSocket()) {
397 1
            return $args;
398
        }
399
400 19
        $defaultSocketPath = $this->runOpts->getOption('docker.socket.path');
401 19
        $hostPathDockerSocket = $defaultSocketPath;
402
403
        // pipelines inside pipelines
404 19
        $hostPath = $this->pipHostConfigBind($defaultSocketPath);
405 19
        if (null !== $hostPath) {
406
            return array(
407 1
                '-v', sprintf('%s:%s', $hostPath, $defaultSocketPath),
408
            );
409
        }
410
411 18
        $dockerHost = $this->env->getInheritValue('DOCKER_HOST');
412 18
        if (null !== $dockerHost && 0 === strpos($dockerHost, 'unix://')) {
413 1
            $hostPathDockerSocket = LibFs::normalizePath(substr($dockerHost, 7));
414
        }
415
416 18
        $pathDockerSock = $defaultSocketPath;
417
418 18
        if (file_exists($hostPathDockerSocket)) {
419
            $args = array(
420 14
                '-v', sprintf('%s:%s', $hostPathDockerSocket, $pathDockerSock),
421
            );
422
        }
423
424 18
        return $args;
425
    }
426
427
    /**
428
     * @param bool $copy
429
     * @param string $dir
430
     * @param array $mountDockerSock
431
     *
432
     * @return array mount options or array(null, int $status) for error handling
433
     */
434
    private function obtainWorkingDirMount($copy, $dir, array $mountDockerSock)
435
    {
436 20
        if ($copy) {
437 7
            return array();
438
        }
439
440 13
        $parentName = $this->env->getValue('PIPELINES_PARENT_CONTAINER_NAME');
441 13
        $hostDeviceDir = $this->pipHostConfigBind($dir);
442 13
        $checkMount = $mountDockerSock && null !== $parentName;
1 ignored issue
show
Bug Best Practice introduced by
The expression $mountDockerSock of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
443 13
        $deviceDir = $hostDeviceDir ?: $dir;
444 13
        if ($checkMount && '/app' === $dir && null === $hostDeviceDir) { // FIXME(tk): hard encoded /app
445 2
            $deviceDir = $this->env->getPipelinesProjectPath($deviceDir);
446 2
            if ($deviceDir === $dir || null === $deviceDir) {
447 2
                $this->streams->err("pipelines: fatal: can not detect ${dir} mount point. preventing new container.\n");
448
449 2
                return array(null, 1);
450
            }
451
        }
452
453
        // FIXME(tk): Never mount anything not matching /home/[a-zA-Z][a-zA-Z0-9]*/[^.].*/...
454
        //   + do realpath checking
455
        //   + prevent dot path injections (logical fix first)
456 11
        return array('-v', "${deviceDir}:/app"); // FIXME(tk): hard encoded /app
457
    }
458
459
    /**
460
     * get host path from mount point if in pip level 2+
461
     *
462
     * @param mixed $mountPoint
463
     * @return null|string
464
     */
465
    private function pipHostConfigBind($mountPoint)
466
    {
467
        // if there is a parent name, this is level 2+
468 20
        if (null === $this->env->getValue('PIPELINES_PARENT_CONTAINER_NAME')) {
469 16
            return null;
470
        }
471
472 4
        if (null === $pipName = $this->env->getValue('PIPELINES_PIP_CONTAINER_NAME')) {
473 2
            return null;
474
        }
475
476 2
        return Docker::create($this->exec)->hostConfigBind($pipName, $mountPoint);
477
    }
478
479
    /**
480
     * @param Step $step
481
     * @param Streams $streams
482
     * @param Exec $exec
483
     * @param string $name container name
484
     *
485
     * @return null|int should never be null, status, non-zero if a command failed
486
     */
487
    private function runStepScript(Step $step, Streams $streams, Exec $exec, $name)
488
    {
489 15
        $script = $step->getScript();
490
491 15
        $buffer = Lib::cmd("<<'SCRIPT' docker", array(
492 15
            'exec', '-i', $name, '/bin/sh',
493
        ));
494 15
        $buffer .= "\n# this /bin/sh script is generated from a pipelines pipeline:\n";
495 15
        $buffer .= "set -e\n";
496 15
        foreach ($script as $line => $command) {
497 15
            $line && $buffer .= 'printf \'\\n\'' . "\n";
498 15
            $buffer .= 'printf \'\\035+ %s\\n\' ' . Lib::quoteArg($command) . "\n";
499 15
            $buffer .= $command . "\n";
500
        }
501 15
        $buffer .= "SCRIPT\n";
502
503 15
        $status = $exec->pass($buffer, array());
504
505 15
        if (0 !== $status) {
506 2
            $streams->err(sprintf("script non-zero exit status: %d\n", $status));
507
        }
508
509 15
        return $status;
510
    }
511
512
    /**
513
     * @param StepContainer $container
514
     * @param int $status
515
     */
516
    private function shutdownStepContainer(StepContainer $container, $status)
517
    {
518 15
        $flags = $this->flags;
519 15
        $id = $container->getDisplayId();
520
521
        # keep container on error
522 15
        if (0 !== $status && $flags->keepOnError()) {
523 2
            $this->streams->err(sprintf(
524 2
                "error, keeping container id %s\n",
525 2
                substr($id, 0, 12)
526
            ));
527
528 2
            return;
529
        }
530
531
        # keep or kill/remove container
532 13
        $container->killAndRemove($flags->killContainer(), $flags->removeContainer());
533
534 13
        if ($flags->keep()) {
535 1
            $this->streams->out(sprintf(
536 1
                "keeping container id %s\n",
537 1
                substr($id, 0, 12)
538
            ));
539
        }
540 13
    }
541
}
542