Passed
Push — test ( 2ca121...28607d )
by Tom
03:10
created

StepRunner   C

Complexity

Total Complexity 56

Size/Duplication

Total Lines 483
Duplicated Lines 0 %

Test Coverage

Coverage 100%

Importance

Changes 2
Bugs 0 Features 0
Metric Value
eloc 213
c 2
b 0
f 0
dl 0
loc 483
ccs 209
cts 209
cp 1
rs 5.5199
wmc 56

12 Methods

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