Passed
Push — master ( d5c1f5...3b22a1 )
by Tom
05:09 queued 02:51
created

App::changeWorkingDir()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 10
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 7
CRAP Score 2

Importance

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