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