Passed
Push — master ( d8d9f2...800fb3 )
by Tom
02:30
created

StepRunner::shutdownServices()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 18
Code Lines 13

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 13
CRAP Score 2

Importance

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