Passed
Branch test (244668)
by Tom
03:44
created

App::getRunPipeline()   B

Complexity

Conditions 4
Paths 4

Size

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