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)) { |
|
|
|
|
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) { |
|
|
|
|
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); |
|
|
|
|
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
|
|
|
|