Passed
Push — master ( 5e712b...61b36a )
by Tom
02:52
created

App::getRunPipeline()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 21
Code Lines 15

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 15
CRAP Score 4

Importance

Changes 0
Metric Value
cc 4
eloc 15
nc 4
nop 3
dl 0
loc 21
ccs 15
cts 15
cp 1
crap 4
rs 9.0534
c 0
b 0
f 0
1
<?php
2
3
/* this file is part of pipelines */
4
5
namespace Ktomk\Pipelines\Utility;
6
7
use Exception;
8
use Ktomk\Pipelines\Cli\Args;
9
use Ktomk\Pipelines\Cli\ArgsException;
10
use Ktomk\Pipelines\Cli\Exec;
11
use Ktomk\Pipelines\Cli\Streams;
12
use Ktomk\Pipelines\File;
13
use Ktomk\Pipelines\Pipeline;
14
use Ktomk\Pipelines\Runner;
15
use Ktomk\Pipelines\Runner\Env;
16
17
class App
18
{
19
    const BBPL_BASENAME = 'bitbucket-pipelines.yml';
20
21
    const VERSION = '@.@.@';
22
23
    /**
24
     * @var bool whether version has been shown or not
25
     * @see App::showVersion()
26
     */
27
    private $versionShown = false;
28
29
    /**
30
     * @var Args
31
     */
32
    private $arguments;
33
34
    /**
35
     * @var Streams
36
     */
37
    private $streams;
38
39
    /**
40
     * @var bool
41
     */
42
    private $verbose = true;
43
44
    /**
45
     * @var bool
46
     */
47
    private $debug = false;
48
49
    /**
50
     * @return App
51
     */
52 1
    static function create()
53
    {
54 1
        $streams = Streams::create();
55
56 1
        return new self($streams);
57
    }
58
59 38
    public function __construct(Streams $streams)
60
    {
61 38
        $this->streams = $streams;
62 38
    }
63
64 7
    private function showVersion()
65
    {
66 7
        if (!$this->versionShown) {
67 7
            $version = $this->translateSourceVersion(self::VERSION);
68 7
            $this->info(sprintf('pipelines version %s', $version));
69 7
            $this->versionShown = true;
70
        }
71
72 7
        return 0;
73
    }
74
75
    /**
76
     * obtain utility version for the source edition
77
     *
78
     * @param string $version
79
     * @return string
80
     */
81 7
    private function translateSourceVersion($version)
82
    {
83
        // version is build version
84 7
        if ('@.' . '@.@' !== $version) {
85
            return $version; // @codeCoverageIgnore
86
        }
87
88
        // as composer module
89 7
        $installedFile = __DIR__ . '/../../../../composer/installed.json';
90 7
        if (is_file($installedFile) && is_readable($installedFile)) {
91
            // @codeCoverageIgnoreStart
92
            $buffer = file_get_contents($installedFile);
93
            $struct = json_decode($buffer);
94
            foreach ((array)$struct as $package) {
95
                if (!isset($package->name) || $package->name !== 'ktomk/pipelines') {
96
                    continue;
97
                }
98
                if (!isset($package->version)) {
99
                    break;
100
                }
101
                return $package->version;
102
            }
103
            // @codeCoverageIgnoreEnd
104
        }
105
106
        // as git repository
107 7
        $buffer = exec(sprintf(
108 7
            'cd %s && echo $(git describe --tags --always --first-parent 2>/dev/null)$(git diff-index --quiet HEAD -- 2>/dev/null || echo +)',
109 7
            escapeshellarg(__DIR__)
110
        ));
111 7
        if ($buffer !== '+') {
112 7
            return $buffer;
113
        }
114
115
        return $version; // @codeCoverageIgnore
116
    }
117
118 7
    private function showUsage()
119
    {
120 7
        $this->streams->out(<<<EOD
121 7
usage: pipelines [<options>...] [--version | [-h | --help]]
122
       pipelines [-v | --verbose] [--working-dir <path>] [--keep]
123
                 [--prefix <prefix>] [--basename <basename>]
124
                 [--file <path>] [--dry-run] [--no-run] [--list]
125
                 [--deploy mount | copy ] [--show] [--images]
126
                 [--pipeline <id>] [--verbatim]
127
       pipelines [-v | --verbose] [--dry-run] [--docker-list]
128
                 [--docker-kill] [--docker-clean]
129
130
EOD
131
        );
132 7
    }
133
134 3
    private function showHelp()
135
    {
136 3
        $this->showUsage();
137 3
        $this->streams->out(<<<EOD
138
139
    -h, --help            show usage and help information
140
    -v, --verbose         show commands executed
141
    --version             show version information
142
143
Common options
144
    --basename <basename> set basename for pipelines file,
145
                          default is 'bitbucket-pipelines.yml'
146
    --deploy mount|copy   how files from the working directory
147
                          are placed into the pipeline container:
148
                          mount    (default) the working dir is
149
                                 mounted. fastest, no isolation
150
                          copy     working directory is copied
151
                                 into the container. slower,
152
                                 stronger isolation as the
153
                                 pipeline scripts can change
154
                                 all files without side-effects
155
                                 in the working directory
156
    --file <path>         path to the pipelines file, overrides
157
                          looking up the <basename> file from
158
                          the current working directory
159
    --keep                keep docker containers. default is to
160
                          kill and remove containers after each
161
                          pipeline step unless the pipeline
162
                          step failed. then the non-zero exit
163
                          status is given and an error message
164
                          showing the container id
165
    --pipeline <id>       run pipeline with <id>, see --list
166
    --verbatim            only give verbatim output of the
167
                          pipeline, no other information around
168
                          like which step currently executes
169
    --working-dir <path>  run as if pipelines was started in
170
                          <path>
171
172
Run control options
173
    --dry-run             do not invoke docker or run containers,
174
                          with --verbose shows the commands that
175
                          would have run w/o the --dry-run flag
176
    --no-run              do not run the pipeline
177
178
File information options
179
    --images              list all images in file, in order
180
                          of use, w/o duplicate names and exit
181
    --list                list pipeline <id>s in file and exit
182
    --show                show information about pipelines in
183
                          file and exit
184
185
Docker container maintenance options
186
      usage might leave containers on the system. either by
187
      interrupting a running pipeline step or by keeping the
188
      running containers (--keep).
189
190
      pipelines uses a prefix followed by '-' and a UUID for
191
      container names. the prefix is either 'pipelines' or the
192
      one set by --prefix <prefix>.
193
194
      three options are built-in to monitor and deal with
195
      leftovers. if one or more of these are given, the following
196
      operations are executed in the order from top to down:
197
198
    --docker-clean        remove (non-running) containers with
199
                          pipelines prefix
200
    --docker-kill         kills prefixed containers
201
    --docker-list         list prefixed containers
202
203
Less common options
204
    --debug               flag for trouble-shooting fatal errors
205
    --prefix <prefix>     use a different prefix for container
206
                          names, default is 'pipelines'
207
208
EOD
209
        );
210 3
        return 0;
211
    }
212
213
    /**
214
     * @param array $arguments including the utility name in the first argument
215
     * @return int 0-255
216
     */
217 37
    public function main(array $arguments)
218
    {
219 37
        $this->arguments = Args::create($arguments);
220 37
        $this->debug = $this->arguments->hasOption('debug');
221
222
        try {
223 37
            $status = $this->run();
224 4
        } catch (ArgsException $e) {
225 3
            $status = $e->getCode();
226 3
            $message = $e->getMessage();
227 3
            $this->error($message);
228 3
            $this->showUsage();
229 1
        } catch (File\ParseException $e) {
230 1
            $status = 2;
231 1
            $message = sprintf('fatal: pipelines file: %s', $e->getMessage());
232 1
            $this->error($message);
233
        } catch (Exception $e) { // @codeCoverageIgnoreStart
234
            // catch unexpected exceptions for user-friendly message
235
            $status = 2;
236
            $message = sprintf('fatal: %s', $e->getMessage());
237
            $this->error($message);
238
            // @codeCoverageIgnoreEnd
239
        }
240
241 37
        if (isset($e) && $this->debug) {
242 1
            for (; $e; $e = $e->getPrevious()) {
243 1
                $this->error('--------');
244 1
                $this->error(sprintf("class....: %s", get_class($e)));
245 1
                $this->error(sprintf("message..: %s", $e->getMessage()));
246 1
                $this->error(sprintf("code.....: %s", $e->getCode()));
247 1
                $this->error(sprintf("file.....: %s", $e->getFile()));
248 1
                $this->error(sprintf("line.....: %s", $e->getLine()));
249 1
                $this->error('backtrace:');
250 1
                $this->error($e->getTraceAsString());
251
            }
252 1
            $this->error('--------');
253
        }
254
255 37
        return $status;
256
    }
257
258
    /**
259
     * @return int|null
260
     * @throws ArgsException
261
     */
262 37
    public function run()
263
    {
264 37
        $args = $this->arguments;
265
266 37
        if ($this->verbose = $args->hasOption(array('v', 'verbose'))) {
267 4
            $this->showVersion();
268
        };
269
270
        # quickly handle version
271 37
        if ($args->hasOption('version')) {
272 3
            return $this->showVersion();
273
        }
274
275
        # quickly handle help
276 34
        if ($args->hasOption(array('h', 'help'))) {
277 3
            return $this->showHelp();
278
        }
279
280 31
        $prefix = $args->getOptionArgument('prefix', 'pipelines');
281 29
        if (!preg_match('~^[a-z]{3,}$~', $prefix)) {
282 1
            ArgsException::__(sprintf("Invalid prefix: '%s'", $prefix));
283
        }
284
285 28
        $debugPrinter = null;
286 28
        if ($this->verbose) {
287 3
            $debugPrinter = $this->streams;
288
        }
289 28
        $exec = new Exec($debugPrinter);
290
291 28
        if ($args->hasOption('dry-run')) {
292 9
            $exec->setActive(false);
293
        }
294
295
        if (
296
            null !== $status
297 28
                = DockerOptions::bind($args, $exec, $prefix, $this->streams)->run()
298
        ) {
299 2
            return $status;
300
        }
301
302
        /** @var bool $keep containers */
303 26
        $keep = $args->hasOption('keep');
304
305
        /** @var string $basename for bitbucket-pipelines.yml */
306 26
        $basename = $args->getOptionArgument('basename', self::BBPL_BASENAME);
307
        // FIXME: must actually be a basename to prevent accidental traversal
308 26
        if (!strlen($basename)) {
309 1
            $this->error('Empty basename');
310 1
            return 1;
311
        }
312
313 25
        if (false !== $buffer = $args->getOptionArgument('working-dir', false)) {
0 ignored issues
show
introduced by
The condition false !== $buffer = $arg...t('working-dir', false) can never be false.
Loading history...
314 1
            $result = chdir($buffer);
315 1
            if ($result === false) {
316 1
                $this->error(sprintf('fatal: change working directory to %s', $buffer));
317 1
                return 2;
318
            }
319
        }
320
321 24
        $workingDir = getcwd();
322 24
        if ($workingDir === false) {
0 ignored issues
show
introduced by
The condition $workingDir === false can never be true.
Loading history...
323
            // @codeCoverageIgnoreStart
324
            $this->error('fatal: obtain working directory');
325
            return 1;
326
            // @codeCoverageIgnoreEnd
327
        }
328
329
        /** @var string $file as bitbucket-pipelines.yml to process */
330 24
        $file = $args->getOptionArgument('file', $basename);
331 24
        if (!strlen($file)) {
332 1
            $this->error('File can not be empty');
333 1
            return 1;
334
        }
335 23
        if ($file !== $basename && $basename !== self::BBPL_BASENAME) {
336 1
            $this->verbose('info: --file overrides non-default --basename');
337
        }
338
339
        // TODO: obtain project dir information etc. from VCS, also traverse for basename file
340
        // $vcs = new Vcs();
341
342
        /** @var string $path full path as bitbucket-pipelines.yml to process */
343
        // FIXME: try a variant with PHP stream wrapper prefixes support
344 23
        $path = ('/' === $file[0])
345 1
            ? $file
346 23
            : $workingDir . '/' . $file;
347
348 23
        if (!is_file($path) && !is_readable($path)) {
349 2
            $this->error(sprintf('Not a readable file: %s', $file));
350 2
            return 1;
351
        }
352 21
        unset($file);
353
354 21
        $noRun = $args->hasOption('no-run');
355
356 21
        $deployMode = $args->getOptionArgument('deploy', 'mount');
357 21
        if (!in_array($deployMode, array('mount', 'copy'))) {
358 1
            $this->error(sprintf("Unknown deploy mode '%s'\n", $deployMode));
359 1
            return 1;
360
        }
361
362 20
        $this->verbose(sprintf("info: pipelines file is '%s'", $path));
363
364 20
        $pipelines = File::createFromFile($path);
365
366 20
        $fileOptions = FileOptions::bind($args, $this->streams, $pipelines);
367 20
        if (null !== $status = $fileOptions->run()) {
368 7
            return $status;
369
        }
370
371
        ###
372
373 13
        $reference = Runner\Reference::create(
374 13
            $args->getOptionArgument('trigger')
375
        );
376
377 13
        $env = Env::create($_SERVER);
378 13
        $env->addReference($reference);
379
380 13
        $pipelineId = $pipelines->searchIdByReference($reference) ?: 'default';
381
382 13
        $pipelineId = $args->getOptionArgument('pipeline', $pipelineId);
383
384
        // --verbatim show only errors for own runner actions, show everything from pipeline verbatim
385 13
        $streams = $this->streams;
386 13
        if ($args->hasOption('verbatim')) {
387 2
            $streams = new Streams();
388 2
            $streams->copyHandle($this->streams, 2);
389
        }
390
391 13
        if ($option = $args->getFirstRemainingOption()) {
392 1
            $this->error("Unknown option: $option");
393 1
            $this->showUsage();
394 1
            return 1;
395
        }
396
397
        ###
398
399 12
        $pipeline = $this->getRunPipeline($pipelines, $pipelineId, $fileOptions);
400 11
        if (!$pipeline instanceof Pipeline) {
401 2
            return $pipeline;
402
        }
403
404 9
        $flags = $this->getRunFlags($keep, $deployMode);
405
406 9
        $runner = new Runner($prefix, $workingDir, $exec, $flags, $env, $streams);
407 9
        if ($noRun) {
408 2
            $this->verbose('info: not running the pipeline per --no-run option');
409 2
            $status = 0;
410
        } else {
411 7
            $status = $runner->run($pipeline);
412
        }
413
414 9
        return $status;
415
    }
416
417
    /**
418
     * Obtain pipeline to run from file while handling error output
419
     *
420
     * @param File $pipelines
421
     * @param $pipelineId
422
     * @param FileOptions $fileOptions
423
     * @return int|Pipeline on success, integer on error as exit status
424
     */
425 12
    private function getRunPipeline(File $pipelines, $pipelineId, FileOptions $fileOptions) {
426 12
        $this->verbose(sprintf("info: running pipeline '%s'", $pipelineId));
427
428
        try {
429 12
            $pipeline = $pipelines->getById($pipelineId);
430 2
        } catch (File\ParseException $e) {
431 1
            $this->error(sprintf("error: pipeline id '%s'", $pipelineId));
432 1
            throw $e;
433 1
        } catch (\InvalidArgumentException $e) {
434 1
            $this->error(sprintf("Pipeline '%s' unavailable", $pipelineId));
435 1
            $this->info('Pipelines are:');
436 1
            $fileOptions->showPipelines($pipelines);
437 1
            return 1;
438
        }
439
440 10
        if (!$pipeline) {
441 1
            $this->error("error: no pipeline to run!");
442 1
            return 1;
443
        }
444
445 9
        return $pipeline;
446
    }
447
448 13
    private function error($message)
449
    {
450 13
        $this->streams->err(
451 13
            sprintf("%s\n", $message)
452
        );
453 13
    }
454
455 8
    private function info($message)
456
    {
457 8
        $this->streams->out(
458 8
            sprintf("%s\n", $message)
459
        );
460 8
    }
461
462 21
    private function verbose($message)
463
    {
464 21
        $this->verbose && $this->info($message);
0 ignored issues
show
Bug introduced by
Are you sure the usage of $this->info($message) targeting Ktomk\Pipelines\Utility\App::info() seems to always return null.

This check looks for function or method calls that always return null and whose return value is used.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
if ($a->getObject()) {

The method getObject() can return nothing but null, so it makes no sense to use the return value.

The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.

Loading history...
465 21
    }
466
467
    /**
468
     * @param $keep
469
     * @param $deployMode
470
     * @return bool|int
471
     */
472 9
    private function getRunFlags($keep, $deployMode)
473
    {
474 9
        $flags = Runner::FLAGS;
475 9
        if ($keep) {
476 2
            $flags &= ~(Runner::FLAG_DOCKER_KILL | Runner::FLAG_DOCKER_REMOVE);
477
        }
478
479 9
        if ($deployMode === 'copy') {
480 1
            $flags |= Runner::FLAG_DEPLOY_COPY;
481
        }
482 9
        return $flags;
483
    }
484
}
485