Passed
Push — master ( a18808...143631 )
by Tom
04:16
created

StepRunner::generateScript()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 11
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 7
CRAP Score 3

Importance

Changes 0
Metric Value
cc 3
eloc 7
c 0
b 0
f 0
nc 3
nop 1
dl 0
loc 11
ccs 7
cts 7
cp 1
crap 3
rs 10
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\Pipeline\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 25
    public function __construct(
73
        RunOpts $runOpts,
74
        Directories $directories,
75
        Exec $exec,
76
        Flags $flags,
77
        Env $env,
78
        Streams $streams
79
    )
80
    {
81 25
        $this->runOpts = $runOpts;
82 25
        $this->directories = $directories;
83 25
        $this->exec = $exec;
84 25
        $this->flags = $flags;
85 25
        $this->env = $env;
86 25
        $this->streams = $streams;
87 25
    }
88
89
    /**
90
     * @param \Ktomk\Pipelines\File\Pipeline\Step $step
91
     *
92
     * @return null|int exist status of step script or null if the run operation failed
93
     */
94 24
    public function runStep(Step $step)
95
    {
96 24
        $dir = $this->directories->getProjectDirectory();
97 24
        $env = $this->env;
98 24
        $exec = $this->exec;
99 24
        $streams = $this->streams;
100
101 24
        $env->setPipelinesProjectPath($dir);
102
103 24
        $container = StepContainer::create($step, $exec);
104
105 24
        $name = $container->generateName(
106 24
            $this->runOpts->getPrefix(),
107 24
            $this->env->getValue('BITBUCKET_REPO_SLUG') ?: $this->directories->getName()
108
        );
109 24
        $env->setContainerName($name);
110
111 24
        $image = $step->getImage();
112
113
        # launch container
114 24
        $streams->out(sprintf(
115 24
            "\x1D+++ step #%d\n\n    name...........: %s\n    effective-image: %s\n    container......: %s\n",
116 24
            $step->getIndex() + 1,
117 24
            $step->getName() ? '"' . $step->getName() . '"' : '(unnamed)',
118 24
            $image->getName(),
119
            $name
120
        ));
121
122 24
        $id = $container->keepOrKill($this->flags->reuseContainer());
123
124 24
        $deployCopy = $this->flags->deployCopy();
125
126 24
        if (null === $id) {
127 22
            list($id, $status) = $this->runNewContainer($container, $dir, $deployCopy, $step);
128 21
            if (null === $id) {
129 3
                return $status;
130
            }
131
        }
132
133 20
        $streams->out(sprintf("    container-id...: %s\n\n", substr($id, 0, 12)));
134
135
        # TODO: different deployments, mount (default), mount-ro, copy
136 20
        if (null !== $result = $this->deployCopy($deployCopy, $id, $dir)) {
137 2
            return $result;
138
        }
139
140 18
        $status = StepScriptRunner::createRunStepScript($step, $streams, $exec, $name);
141
142 18
        $this->captureStepArtifacts($step, $deployCopy && 0 === $status, $id, $dir);
143
144 18
        $this->shutdownStepContainer($container, $status);
145
146 18
        return $status;
147
    }
148
149
    /**
150
     * method to wrap new to have a test-point
151
     *
152
     * @return Repository
153
     */
154 2
    public function getDockerBinaryRepository()
155
    {
156 2
        $repo = Repository::create($this->exec, $this->directories);
157 2
        $repo->resolve($this->runOpts->getBinaryPackage());
158
159 1
        return $repo;
160
    }
161
162
    /**
163
     * @param Step $step
164
     * @param bool $copy
165
     * @param string $id container id
166
     * @param string $dir to put artifacts in (project directory)
167
     *
168
     * @throws \RuntimeException
169
     */
170 18
    private function captureStepArtifacts(Step $step, $copy, $id, $dir)
171
    {
172
        # capturing artifacts is only supported for deploy copy
173 18
        if (!$copy) {
174 13
            return;
175
        }
176
177 5
        $artifacts = $step->getArtifacts();
178
179 5
        if (null === $artifacts) {
180 2
            return;
181
        }
182
183 3
        $exec = $this->exec;
184 3
        $streams = $this->streams;
185
186 3
        $streams->out("\x1D+++ copying artifacts from container...\n");
187
188 3
        $source = new ArtifactSource($exec, $id, $dir);
189
190 3
        $patterns = $artifacts->getPatterns();
191 3
        foreach ($patterns as $pattern) {
192 3
            $this->captureArtifactPattern($source, $pattern, $dir);
193
        }
194
195 3
        $streams('');
196 3
    }
197
198
    /**
199
     * @param ArtifactSource $source
200
     * @param string $pattern
201
     * @param string $dir
202
     *
203
     * @throws \RuntimeException
204
     * @see Runner::captureStepArtifacts()
205
     *
206
     */
207 3
    private function captureArtifactPattern(ArtifactSource $source, $pattern, $dir)
208
    {
209 3
        $exec = $this->exec;
210 3
        $streams = $this->streams;
211
212 3
        $id = $source->getId();
213 3
        $paths = $source->findByPattern($pattern);
214 3
        if (empty($paths)) {
215 1
            return;
216
        }
217
218 2
        $chunks = Lib::arrayChunkByStringLength($paths, 131072, 4);
219
220 2
        foreach ($chunks as $paths) {
221 2
            $docker = Lib::cmd('docker', array('exec', '-w', '/app', $id));
222 2
            $tar = Lib::cmd('tar', array('c', '-f', '-', $paths));
223 2
            $unTar = Lib::cmd('tar', array('x', '-f', '-', '-C', $dir));
224
225 2
            $command = $docker . ' ' . $tar . ' | ' . $unTar;
226 2
            $status = $exec->pass($command, array());
227
228 2
            if (0 !== $status) {
229 1
                $streams->err(sprintf(
230 1
                    "pipelines: Artifact failure: '%s' (%d, %d paths, %d bytes)\n",
231
                    $pattern,
232
                    $status,
233 1
                    count($paths),
234 1
                    strlen($command)
235
                ));
236
            }
237
        }
238 2
    }
239
240
    /**
241
     * @param bool $copy
242
     * @param string $id container id
243
     * @param string $dir directory to copy contents into container
244
     *
245
     * @throws \RuntimeException
246
     * @return null|int null if all clear, integer for exit status
247
     */
248 20
    private function deployCopy($copy, $id, $dir)
249
    {
250 20
        if (!$copy) {
251 13
            return null;
252
        }
253
254 7
        $streams = $this->streams;
255 7
        $exec = $this->exec;
256
257 7
        $streams->out("\x1D+++ copying files into container...\n");
258
259 7
        $tmpDir = LibTmp::tmpDir('pipelines-cp.');
260 7
        $this->temporaryDirectories[] = DestructibleString::rmDir($tmpDir);
261 7
        LibFs::symlink($dir, $tmpDir . '/app');
262 7
        $cd = Lib::cmd('cd', array($tmpDir . '/.'));
263 7
        $tar = Lib::cmd('tar', array('c', '-h', '-f', '-', '--no-recursion', 'app'));
264 7
        $dockerCp = Lib::cmd('docker ', array('cp', '-', $id . ':/.'));
265
        $status = $exec->pass("${cd} && echo 'app' | ${tar} | ${dockerCp}", array());
266
        LibFs::unlink($tmpDir . '/app');
267 7
        if (0 !== $status) {
268 1
            $streams->err('pipelines: deploy copy failure\n');
269
270 1
            return $status;
271
        }
272
273 6
        $cd = Lib::cmd('cd', array($dir . '/.'));
274 6
        $tar = Lib::cmd('tar', array('c', '-f', '-', '.'));
275 6
        $dockerCp = Lib::cmd('docker ', array('cp', '-', $id . ':/app'));
276 6
        $status = $exec->pass("${cd} && ${tar} | ${dockerCp}", array());
277 6
        if (0 !== $status) {
278 1
            $streams->err('pipelines: deploy copy failure\n');
279
280 1
            return $status;
281
        }
282
283 5
        $streams('');
284
285 5
        return null;
286
    }
287
288
    /**
289
     * @param Image $image
290
     *
291
     * @throws \RuntimeException
292
     * @throws \InvalidArgumentException
293
     */
294
    private function imageLogin(Image $image)
295
    {
296 22
        $login = new ImageLogin($this->exec, $this->env->getResolver());
297 22
        $login->byImage($image);
298 22
    }
299
300
    /**
301
     * @param StepContainer $container
302
     * @param string $dir
303
     * @param bool $copy
304
     * @param \Ktomk\Pipelines\File\Pipeline\Step $step
305
     *
306
     * @return array array(string|null $id, int $status)
307
     */
308
    private function runNewContainer(StepContainer $container, $dir, $copy, Step $step)
309
    {
310 22
        $env = $this->env;
311 22
        $streams = $this->streams;
312
313 22
        $image = $step->getImage();
314
315
        # process docker login if image demands so, but continue on failure
316 22
        $this->imageLogin($image);
317
318 22
        $mountDockerSock = $this->obtainDockerSocketMount();
319
320 22
        $mountDockerClient = $this->obtainDockerClientMount($step);
321
322 21
        $mountWorkingDirectory = $this->obtainWorkingDirMount($copy, $dir, $mountDockerSock);
323 21
        if ($mountWorkingDirectory && is_int($mountWorkingDirectory[1])) {
324 2
            return $mountWorkingDirectory;
325
        }
326
327 19
        list($status, $out, $err) = $container->run(
328
            array(
329 19
                '-i', '--name', $container->getName(),
330 19
                $env->getArgs('-e'),
331 19
                $env::createArgVarDefinitions('-e', $step->getEnv()),
332 19
                $mountWorkingDirectory, '-e', 'BITBUCKET_CLONE_DIR=/app',
333 19
                $mountDockerSock,
334 19
                $mountDockerClient,
335 19
                '--workdir', '/app', '--detach', '--entrypoint=/bin/sh', $image->getName(),
336
            )
337
        );
338 19
        if (0 !== $status) {
339 1
            $streams->out("    container-id...: *failure*\n\n");
340 1
            $streams->err("pipelines: setting up the container failed\n");
341 1
            $streams->err("${err}\n");
342 1
            $streams->out("${out}\n");
343 1
            $streams->out(sprintf("exit status: %d\n", $status));
344
345 1
            return array(null, $status);
346
        }
347 18
        $id = $container->getDisplayId();
348
349 18
        return array($id, $status);
350
    }
351
352
    private function obtainDockerClientMount(Step $step)
353
    {
354
        # 'docker.client.path'
355 22
        $path = '/usr/bin/docker';
356
357 22
        if (!$step->getServices()->has('docker')) {
358 19
            return array();
359
        }
360
361
        // prefer pip mount over package
362 3
        $hostPath = $this->pipHostConfigBind($path);
363 3
        if (null !== $hostPath) {
364 1
            return array('-v', sprintf('%s:%s:ro', $hostPath, $path));
365
        }
366
367 2
        $local = $this->getDockerBinaryRepository()->getBinaryPath();
368 1
        chmod($local, 0755);
369
370 1
        return array('-v', sprintf('%s:%s:ro', $local, $path));
371
    }
372
373
    /**
374
     * enable docker client inside docker by mounting docker socket
375
     *
376
     * @return array docker socket volume args for docker run, empty if not mounting
377
     */
378
    private function obtainDockerSocketMount()
379
    {
380 22
        $args = array();
381
382
        // FIXME give more controlling options, this is serious /!\
383 22
        if (!$this->flags->useDockerSocket()) {
384 1
            return $args;
385
        }
386
387 21
        $defaultSocketPath = $this->runOpts->getOption('docker.socket.path');
388 21
        $hostPathDockerSocket = $defaultSocketPath;
389
390
        // pipelines inside pipelines
391 21
        $hostPath = $this->pipHostConfigBind($defaultSocketPath);
392 21
        if (null !== $hostPath) {
393
            return array(
394 1
                '-v', sprintf('%s:%s', $hostPath, $defaultSocketPath),
395
            );
396
        }
397
398 20
        $dockerHost = $this->env->getInheritValue('DOCKER_HOST');
399 20
        if (null !== $dockerHost && 0 === strpos($dockerHost, 'unix://')) {
400 1
            $hostPathDockerSocket = LibFs::normalizePath(substr($dockerHost, 7));
401
        }
402
403 20
        $pathDockerSock = $defaultSocketPath;
404
405 20
        if (file_exists($hostPathDockerSocket)) {
406
            $args = array(
407 17
                '-v', sprintf('%s:%s', $hostPathDockerSocket, $pathDockerSock),
408
            );
409
        }
410
411 20
        return $args;
412
    }
413
414
    /**
415
     * @param bool $copy
416
     * @param string $dir
417
     * @param array $mountDockerSock
418
     *
419
     * @return array mount options or array(null, int $status) for error handling
420
     */
421
    private function obtainWorkingDirMount($copy, $dir, array $mountDockerSock)
422
    {
423 21
        if ($copy) {
424 7
            return array();
425
        }
426
427 14
        $parentName = $this->env->getValue('PIPELINES_PARENT_CONTAINER_NAME');
428 14
        $hostDeviceDir = $this->pipHostConfigBind($dir);
429 14
        $checkMount = $mountDockerSock && null !== $parentName;
430 14
        $deviceDir = $hostDeviceDir ?: $dir;
431 14
        if ($checkMount && '/app' === $dir && null === $hostDeviceDir) { // FIXME(tk): hard encoded /app
432 2
            $deviceDir = $this->env->getValue('PIPELINES_PROJECT_PATH');
433 2
            if ($deviceDir === $dir || null === $deviceDir) {
434 2
                $this->streams->err("pipelines: fatal: can not detect ${dir} mount point. preventing new container.\n");
435
436 2
                return array(null, 1);
437
            }
438
        }
439
440
        // FIXME(tk): Never mount anything not matching /home/[a-zA-Z][a-zA-Z0-9]*/[^.].*/...
441
        //   + do realpath checking
442
        //   + prevent dot path injections (logical fix first)
443 12
        return array('-v', "${deviceDir}:/app"); // FIXME(tk): hard encoded /app
444
    }
445
446
    /**
447
     * get host path from mount point if in pip level 2+
448
     *
449
     * @param mixed $mountPoint
450
     *
451
     * @return null|string
452
     */
453
    private function pipHostConfigBind($mountPoint)
454
    {
455
        // if there is a parent name, this is level 2+
456 22
        if (null === $pipName = $this->env->getValue('PIPELINES_PIP_CONTAINER_NAME')) {
457 19
            return null;
458
        }
459
460 3
        return Docker::create($this->exec)->hostConfigBind($pipName, $mountPoint);
461
    }
462
463
    /**
464
     * @param StepContainer $container
465
     * @param int $status
466
     */
467
    private function shutdownStepContainer(StepContainer $container, $status)
468
    {
469 18
        $flags = $this->flags;
470 18
        $id = $container->getDisplayId();
471
472
        # keep container on error
473 18
        if (0 !== $status && $flags->keepOnError()) {
474 2
            $this->streams->err(sprintf(
475 2
                "error, keeping container id %s\n",
476 2
                substr($id, 0, 12)
477
            ));
478
479 2
            return;
480
        }
481
482
        # keep or kill/remove container
483 16
        $container->killAndRemove($flags->killContainer(), $flags->removeContainer());
484
485 16
        if ($flags->keep()) {
486 1
            $this->streams->out(sprintf(
487 1
                "keeping container id %s\n",
488 1
                substr($id, 0, 12)
489
            ));
490
        }
491 16
    }
492
}
493