Passed
Push — test ( a68431...c65f5a )
by Tom
02:31
created

StepRunner::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 1

Importance

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