Passed
Push — test ( 1ff34a...5f8c8d )
by Tom
02:50
created

StepRunner   C

Complexity

Total Complexity 54

Size/Duplication

Total Lines 491
Duplicated Lines 0 %

Test Coverage

Coverage 100%

Importance

Changes 3
Bugs 0 Features 0
Metric Value
eloc 208
c 3
b 0
f 0
dl 0
loc 491
ccs 206
cts 206
cp 1
rs 6.4799
wmc 54

13 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 deployDockerClient() 0 19 3
A deployCopy() 0 38 4
A runStepScript() 0 23 4
B runStep() 0 66 9
B runNewContainer() 0 64 11
A dockerGetContainerIdByName() 0 20 5
A getDockerBinaryRepository() 0 6 1
A zapContainerByName() 0 4 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\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
        $name = PipelineContainer::createName(
101 17
            $step,
102 17
            $this->runOpts->getPrefix(),
103 17
            $this->directories->getName()
104
        );
105
106 17
        $reuseContainer = $this->flags->reuseContainer();
107 17
        $deployCopy = $this->flags->deployCopy();
108
109 17
        if (false === $reuseContainer) {
110 14
            $this->zapContainerByName($name);
111
        }
112 17
        $image = $step->getImage();
113 17
        $env->setContainerName($name);
114
115
        # launch container
116 17
        $streams->out(sprintf(
117 17
            "\x1D+++ step #%d\n\n    name...........: %s\n    effective-image: %s\n    container......: %s\n",
118 17
            $step->getIndex() + 1,
119 17
            $step->getName() ? '"' . $step->getName() . '"' : '(unnamed)',
120 17
            $image->getName(),
121 17
            $name
122
        ));
123
124 17
        $id = null;
125 17
        if ($reuseContainer) {
126 3
            $id = $this->dockerGetContainerIdByName($name);
127
        }
128
129 17
        if (null === $id) {
130 15
            list($id, $status) = $this->runNewContainer($name, $dir, $deployCopy, $step);
131 15
            if (null === $id) {
132 2
                return $status;
133
            }
134
        }
135
136 15
        $streams->out(sprintf("    container-id...: %s\n\n", substr($id, 0, 12)));
137
138
        # TODO: different deployments, mount (default), mount-ro, copy
139 15
        if (null !== $result = $this->deployCopy($deployCopy, $id, $dir)) {
140 2
            return $result;
141
        }
142
143 13
        list($status, $message) = $this->deployDockerClient($step, $id);
144 12
        if (0 !== $status) {
145 1
            $this->streams->err(rtrim($message, "\n") . "\n");
146
147 1
            return $status;
148
        }
149
150 11
        $status = $this->runStepScript($step, $streams, $exec, $name);
151
152 11
        $this->captureStepArtifacts($step, $deployCopy && 0 === $status, $id, $dir);
153
154 11
        $this->shutdownStepContainer($status, $id, $exec, $name);
155
156 11
        return $status;
157
    }
158
159
    /**
160
     * method to wrap new to have a test-point
161
     *
162
     * @return Repository
163
     */
164 2
    public function getDockerBinaryRepository()
165
    {
166 2
        $repo = Repository::create($this->exec, $this->directories);
167 2
        $repo->resolve($this->runOpts->getBinaryPackage());
168
169 1
        return $repo;
170
    }
171
172
    /**
173
     * @param Step $step
174
     * @param bool $copy
175
     * @param string $id container id
176
     * @param string $dir to put artifacts in (project directory)
177
     * @throws \RuntimeException
178
     */
179 11
    private function captureStepArtifacts(Step $step, $copy, $id, $dir)
180
    {
181
        # capturing artifacts is only supported for deploy copy
182 11
        if (!$copy) {
183 6
            return;
184
        }
185
186 5
        $artifacts = $step->getArtifacts();
187
188 5
        if (null === $artifacts) {
189 2
            return;
190
        }
191
192 3
        $exec = $this->exec;
193 3
        $streams = $this->streams;
194
195 3
        $streams->out("\x1D+++ copying artifacts from container...\n");
196
197 3
        $source = new ArtifactSource($exec, $id, $dir);
198
199 3
        $patterns = $artifacts->getPatterns();
200 3
        foreach ($patterns as $pattern) {
201 3
            $this->captureArtifactPattern($source, $pattern, $dir);
202
        }
203
204 3
        $streams('');
205 3
    }
206
207
    /**
208
     * @see Runner::captureStepArtifacts()
209
     *
210
     * @param ArtifactSource $source
211
     * @param string $pattern
212
     * @param string $dir
213
     * @throws \RuntimeException
214
     */
215 3
    private function captureArtifactPattern(ArtifactSource $source, $pattern, $dir)
216
    {
217 3
        $exec = $this->exec;
218 3
        $streams = $this->streams;
219
220 3
        $id = $source->getId();
221 3
        $paths = $source->findByPattern($pattern);
222 3
        if (empty($paths)) {
223 1
            return;
224
        }
225
226 2
        $chunks = Lib::arrayChunkByStringLength($paths, 131072, 4);
227
228 2
        foreach ($chunks as $paths) {
229 2
            $docker = Lib::cmd('docker', array('exec', '-w', '/app', $id));
230 2
            $tar = Lib::cmd('tar', array('c', '-f', '-', $paths));
231 2
            $unTar = Lib::cmd('tar', array('x', '-f', '-', '-C', $dir));
232
233 2
            $command = $docker . ' ' . $tar . ' | ' . $unTar;
234 2
            $status = $exec->pass($command, array());
235
236 2
            if (0 !== $status) {
237 1
                $streams->err(sprintf(
238 1
                    "pipelines: Artifact failure: '%s' (%d, %d paths, %d bytes)\n",
239 1
                    $pattern,
240 1
                    $status,
241 1
                    count($paths),
242 1
                    strlen($command)
243
                ));
244
            }
245
        }
246 2
    }
247
248
    /**
249
     * @param bool $copy
250
     * @param string $id container id
251
     * @param string $dir directory to copy contents into container
252
     * @throws \RuntimeException
253
     * @return null|int null if all clear, integer for exit status
254
     */
255 15
    private function deployCopy($copy, $id, $dir)
256
    {
257 15
        if (!$copy) {
258 8
            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
     * @throws
302
     * @return array array(int $status, string $message)
303
     */
304
    private function deployDockerClient(Step $step, $id)
305
    {
306 13
        if (!$step->getServices()->has('docker')) {
307 10
            return array(0, '');
308
        }
309
310 3
        $this->streams->out(' +++ docker client install...: ');
311
312
        try {
313 3
            list($status, $message) = $this->getDockerBinaryRepository()->inject($id);
314 1
        } catch (\Exception $e) {
315 1
            $this->streams->out("pipelines internal failure.\n");
316
317 1
            throw new \InvalidArgumentException('inject docker client failed: ' . $e->getMessage(), 1, $e);
318
        }
319
320 2
        $this->streams->out("${message}\n");
321
322 2
        return array($status, $message);
323
    }
324
325
    /**
326
     * @param string $name
327
     * @return null|string
328
     */
329
    private function dockerGetContainerIdByName($name)
330
    {
331 3
        $ids = null;
332
333 3
        $status = $this->exec->capture(
334 3
            'docker',
335
            array(
336 3
                'ps', '-qa', '--filter',
337 3
                "name=^/${name}$"
338
            ),
339 3
            $result
340
        );
341
342 3
        $status || $ids = Lib::lines($result);
343
344 3
        if ($status || !(is_array($ids) && 1 === count($ids))) {
345 1
            return null;
346
        }
347
348 2
        return $ids[0];
349
    }
350
351
    /**
352
     * @param Image $image
353
     * @throws \RuntimeException
354
     * @throws \InvalidArgumentException
355
     */
356
    private function imageLogin(Image $image)
357
    {
358 15
        $login = new DockerLogin($this->exec, $this->env->getResolver());
359 15
        $login->byImage($image);
360 15
    }
361
362
    /**
363
     * @param string $name
364
     * @param string $dir
365
     * @param bool $copy
366
     * @param Step $step
367
     * @return array array(string|null $id, int $status)
368
     */
369
    private function runNewContainer($name, $dir, $copy, Step $step)
370
    {
371 15
        $env = $this->env;
372 15
        $exec = $this->exec;
373 15
        $streams = $this->streams;
374
375 15
        $image = $step->getImage();
376
377
        # process docker login if image demands so, but continue on failure
378 15
        $this->imageLogin($image);
379
380
        // enable docker client inside docker by mounting docker socket
381
        // FIXME give controlling options, this is serious /!\
382 15
        $mountDockerSock = array();
383 15
        $pathDockerSock = $this->runOpts->getOption('docker.socket.path');
384 15
        if ($this->flags->useDockerSocket() && file_exists($pathDockerSock)) {
385
            $mountDockerSock = array(
386 12
                '-v', sprintf('%s:%s', $pathDockerSock, $pathDockerSock),
387
            );
388
        }
389
390 15
        $parentName = $env->getValue('PIPELINES_PARENT_CONTAINER_NAME');
391 15
        $checkMount = $mountDockerSock && null !== $parentName;
392 15
        $deviceDir = $dir;
393 15
        if ($checkMount && '/app' === $dir) { // FIXME(tk): hard encoded /app
394 1
            $docker = new Docker($exec);
395 1
            $deviceDir = $docker->hostDevice($parentName, $dir);
396 1
            unset($docker);
397 1
            if ($deviceDir === $dir) {
398 1
                $deviceDir = $env->getPipelinesProjectPath($deviceDir);
399
            }
400 1
            if ($deviceDir === $dir) {
401 1
                $streams->err("pipelines: fatal: can not detect ${dir} mount point. preventing new container.\n");
402
403 1
                return array(null, 1);
404
            }
405
        }
406
407 14
        $mountWorkingDirectory = $copy
408 7
            ? array()
409
            // FIXME(tk): Never mount anything not matching /home/[a-zA-Z][a-zA-Z0-9]*/[^.].*/...
410
            //   + do realpath checking
411
            //   + prevent dot path injections (logical fix first)
412 14
            : array('--volume', "${deviceDir}:/app"); // FIXME(tk): hard encoded /app
413
414 14
        $status = $exec->capture('docker', array(
415 14
            'run', '-i', '--name', $name,
416 14
            $env->getArgs('-e'),
417 14
            $mountWorkingDirectory, '-e', 'BITBUCKET_CLONE_DIR=/app',
418 14
            $mountDockerSock,
419 14
            '--workdir', '/app', '--detach', '--entrypoint=/bin/sh', $image->getName()
420 14
        ), $out, $err);
421 14
        if (0 !== $status) {
422 1
            $streams->out("    container-id...: *failure*\n\n");
423 1
            $streams->err("pipelines: setting up the container failed\n");
424 1
            $streams->err("${err}\n");
425 1
            $streams->out("${out}\n");
426 1
            $streams->out(sprintf("exit status: %d\n", $status));
427
428 1
            return array(null, $status);
429
        }
430 13
        $id = rtrim($out) ?: '*dry-run*'; # side-effect: internal exploit of no output with true exit status
431
432 13
        return array($id, 0);
433
    }
434
435
    /**
436
     * @param Step $step
437
     * @param Streams $streams
438
     * @param Exec $exec
439
     * @param string $name container name
440
     * @return null|int should never be null, status, non-zero if a command failed
441
     */
442
    private function runStepScript(Step $step, Streams $streams, Exec $exec, $name)
443
    {
444 11
        $script = $step->getScript();
445
446 11
        $buffer = Lib::cmd("<<'SCRIPT' docker", array(
447 11
            'exec', '-i', $name, '/bin/sh'
448
        ));
449 11
        $buffer .= "\n# this /bin/sh script is generated from a pipelines pipeline:\n";
450 11
        $buffer .= "set -e\n";
451 11
        foreach ($script as $line => $command) {
452 11
            $line && $buffer .= 'printf \'\\n\'' . "\n";
453 11
            $buffer .= 'printf \'\\035+ %s\\n\' ' . Lib::quoteArg($command) . "\n";
454 11
            $buffer .= $command . "\n";
455
        }
456 11
        $buffer .= "SCRIPT\n";
457
458 11
        $status = $exec->pass($buffer, array());
459
460 11
        if (0 !== $status) {
461 2
            $streams->err(sprintf("script non-zero exit status: %d\n", $status));
462
        }
463
464 11
        return $status;
465
    }
466
467
    /**
468
     * @param int $status
469
     * @param string $id container id
470
     * @param Exec $exec
471
     * @param string $name container name
472
     * @throws \RuntimeException
473
     */
474
    private function shutdownStepContainer($status, $id, Exec $exec, $name)
475
    {
476 11
        $flags = $this->flags;
477
478
        # keep container on error
479 11
        if (0 !== $status && $flags->keepOnError()) {
480 2
            $this->streams->err(sprintf(
481 2
                "error, keeping container id %s\n",
482 2
                substr($id, 0, 12)
483
            ));
484
485 2
            return;
486
        }
487
488
        # keep or remove container
489 9
        if ($flags->killContainer()) {
490 8
            $exec->capture('docker', array('kill', $name));
491
        }
492
493 9
        if ($flags->removeContainer()) {
494 8
            $exec->capture('docker', array('rm', $name));
495
        }
496
497 9
        if ($flags->keep()) {
498 1
            $this->streams->out(sprintf(
499 1
                "keeping container id %s\n",
500 1
                substr($id, 0, 12)
501
            ));
502
        }
503 9
    }
504
505
    /**
506
     * @param string $name
507
     */
508
    private function zapContainerByName($name)
509
    {
510 14
        Docker::create($this->exec)->getProcessManager()
511 14
            ->zapContainersByName($name);
512 14
    }
513
}
514