AbstractProvider   A
last analyzed

Complexity

Total Complexity 32

Size/Duplication

Total Lines 483
Duplicated Lines 0 %

Test Coverage

Coverage 0%

Importance

Changes 1
Bugs 0 Features 0
Metric Value
wmc 32
eloc 191
c 1
b 0
f 0
dl 0
loc 483
ccs 0
cts 130
cp 0
rs 9.84

16 Methods

Rating   Name   Duplication   Size   Complexity  
A scanConfiguration() 0 48 4
A __construct() 0 4 1
A client() 0 3 1
A getBranchLink() 0 10 1
B download() 0 70 6
A getLabel() 0 5 1
A getJSON() 0 20 3
A getRepositoryLink() 0 9 1
A processRefArray() 0 8 2
A getTagsAndBranches() 0 28 5
A encodeRepository() 0 3 1
A getShaLink() 0 10 1
A getConfiguration() 0 15 1
A get() 0 6 1
A handles() 0 5 1
A cleanBranchName() 0 53 2
1
<?php
2
3
namespace App\Provider;
4
5
use App\Builder;
6
use App\Facades\Log;
7
use App\Facades\Settings;
8
use App\Model\Deployment;
9
use App\Model\Project;
10
use Closure;
11
use Exception;
12
use GuzzleHttp\ClientInterface;
13
use GuzzleHttp\Exception\ClientException;
14
use Psr\Http\Message\ResponseInterface;
15
use Psr\Http\Message\StreamInterface;
16
use ReflectionClass;
17
use Ronanchilvers\Foundation\Config;
18
use Ronanchilvers\Utility\Str;
19
use RuntimeException;
20
use Symfony\Component\Process\Exception\ProcessFailedException;
21
use Symfony\Component\Process\Process;
22
use Symfony\Component\Yaml\Exception\ParseException;
23
use Symfony\Component\Yaml\Yaml;
24
25
/**
26
 * Base provider class
27
 *
28
 * @author Ronan Chilvers <[email protected]>
29
 */
30
abstract class AbstractProvider
31
{
32
    /**
33
     * @var ClientInterface
34
     */
35
    private $client;
36
37
    /**
38
     * @var string
39
     */
40
    protected $token;
41
42
    /**
43
     * @var array
44
     */
45
    protected $typesHandled = [];
46
47
    /**
48
     * @var string
49
     */
50
    protected $headUrl = null;
51
52
    /**
53
     * @var string
54
     */
55
    protected $branchAndTagUrl = null;
56
57
    /**
58
     * @var string
59
     */
60
    protected $commitUrl = null;
61
62
    /**
63
     * @var string
64
     */
65
    protected $downloadUrl = null;
66
67
    /**
68
     * @var string
69
     */
70
    protected $configUrl = null;
71
72
    /**
73
     * @var string
74
     */
75
    protected $repoUrl = null;
76
77
    /**
78
     * @var string
79
     */
80
    protected $branchUrl = null;
81
82
    /**
83
     * @var string
84
     */
85
    protected $shaUrl = null;
86
87
    /**
88
     * Class constructor
89
     *
90
     * @param string $token
91
     * @author Ronan Chilvers <[email protected]>
92
     */
93
    public function __construct(ClientInterface $client, string $token)
94
    {
95
        $this->client = $client;
96
        $this->token = $token;
97
    }
98
99
    /**
100
     * @see \App\Provider\ProviderInterface::handles()
101
     */
102
    public function handles(Project $project)
103
    {
104
        return in_array(
105
            $project->provider,
106
            $this->typesHandled
107
        );
108
    }
109
110
    /**
111
     * @author Ronan Chilvers <[email protected]>
112
     */
113
    public function getLabel()
114
    {
115
        $reflection = new ReflectionClass($this);
116
117
        return strtolower($reflection->getShortName());
118
    }
119
120
    /**
121
     * Get a repository link for a given repository
122
     *
123
     * @param string $repository
124
     * @return string
125
     * @author Ronan Chilvers <[email protected]>
126
     */
127
    public function getRepositoryLink(string $repository)
128
    {
129
        $params = [
130
            'repository' => $repository,
131
        ];
132
133
        return Str::moustaches(
134
            $this->repoUrl,
135
            $params
136
        );
137
    }
138
139
    /**
140
     * Get a link to a repository branch
141
     *
142
     * @param string $repository
143
     * @param string $branch
144
     * @return string
145
     * @author Ronan Chilvers <[email protected]>
146
     */
147
    public function getBranchLink(string $repository, string $branch)
148
    {
149
        $params = [
150
            'repository' => $repository,
151
            'branch'     => $branch,
152
        ];
153
154
        return Str::moustaches(
155
            $this->branchUrl,
156
            $params
157
        );
158
    }
159
160
    /**
161
     * Get a link for a given repository and sha
162
     *
163
     * @param string $repository
164
     * @param string $sha
165
     * @return string
166
     * @author Ronan Chilvers <[email protected]>
167
     */
168
    public function getShaLink(string $repository, string $sha)
169
    {
170
        $params = [
171
            'repository' => $repository,
172
            'sha'        => $sha,
173
        ];
174
175
        return Str::moustaches(
176
            $this->shaUrl,
177
            $params
178
        );
179
    }
180
181
    /**
182
     * {@inheritdoc}
183
     *
184
     * @return array
185
     * @author Ronan Chilvers <[email protected]>
186
     */
187
    public function getTagsAndBranches(string $repository)
188
    {
189
        $params = [
190
            'repository' => $this->encodeRepository($repository),
191
        ];
192
        $output = [];
193
194
        $url = Str::moustaches(
195
            $this->branchesUrl,
196
            $params
197
        );
198
        $branches = $this->getJSON($url);
199
        $branches = $this->processRefArray($branches);
200
        if (is_array($branches) && 0 < count($branches)) {
201
            $output['branch'] = $branches;
202
        }
203
204
        $url = Str::moustaches(
205
            $this->tagsUrl,
206
            $params
207
        );
208
        $tags = $this->getJSON($url);
209
        $tags = $this->processRefArray($tags);
210
        if (is_array($tags) && 0 < count($tags)) {
211
            $output['tag'] = $tags;
212
        }
213
214
        return $output;
215
    }
216
217
    /**
218
     * Process a ref arrays into simplified form
219
     *
220
     * @param array $data
221
     * @return array
222
     * @author Ronan Chilvers <[email protected]>
223
     */
224
    protected function processRefArray(array $data): array
225
    {
226
        $output = [];
227
        foreach ($data as $datum) {
228
            $output[$datum['name']] = $datum['name'];
229
        }
230
231
        return $output;
232
    }
233
234
    /**
235
     * @see \App\Provider\ProviderInterface::scanConfiguration()
236
     */
237
    public function scanConfiguration(Project $project, Deployment $deployment, Closure $closure = null)
238
    {
239
        try {
240
            $raw = $this->getConfiguration(
241
                $project,
242
                $deployment
243
            );
244
            $yaml = Yaml::parse($raw);
245
            if (is_null($yaml)) {
246
                $yaml = [];
247
            }
248
            $closure(
249
                'info',
250
                implode("\n", [
251
                    'YAML deployment configuration read successfully',
252
                    "YAML: " . $raw
253
                ])
254
            );
255
256
            return new Config($yaml);
257
        } catch (ClientException $ex) {
258
            $closure(
259
                'info',
260
                implode("\n", [
261
                    'No deployment configuration found - using defaults',
262
                    "Exception: " . $ex->getMessage(),
263
                ])
264
            );
265
            Log::error('No deployment configuration found - using defaults', [
266
                'project'   => $project->toArray(),
267
                'exception' => $ex,
268
            ]);
269
270
            return;
271
        } catch (ParseException $ex) {
272
            $closure(
273
                'error',
274
                implode("\n", [
275
                    'Unable to parse YAML deployment configuration',
276
                    "Exception: " . $ex->getMessage(),
277
                ])
278
            );
279
            Log::error('Unable to parse YAML deployment configuration', [
280
                'project'   => $project->toArray(),
281
                'exception' => $ex,
282
            ]);
283
284
            throw $ex;
285
        }
286
    }
287
288
    /**
289
     * Try to download the deploy.yaml file from the remote repository
290
     *
291
     * @param \App\Model\Project $project
292
     * @param \App\Model\Deployment $deployment
293
     * @author Ronan Chilvers <[email protected]>
294
     */
295
    protected function getConfiguration(Project $project, Deployment $deployment)
296
    {
297
        $repository = $this->encodeRepository($project->repository);
298
        $params = [
299
            'repository' => $repository,
300
            'sha'        => $deployment->sha,
301
        ];
302
        $url = Str::moustaches(
303
            $this->configUrl,
304
            $params
305
        );
306
        $data = $this->getJSON($url);
307
        $yaml = base64_decode($data['content']);
308
309
        return $yaml;
310
    }
311
312
    /**
313
     * @see \App\Provider\ProviderInterface::download()
314
     */
315
    public function download(Project $project, Deployment $deployment, $directory, Closure $closure = null)
316
    {
317
        $repository = $this->encodeRepository($project->repository);
318
        $params = [
319
            'repository' => $repository,
320
            'sha'        => $deployment->sha,
321
        ];
322
        $url = Str::moustaches(
323
            $this->downloadUrl,
324
            $params
325
        );
326
        $filename = tempnam('/tmp', Str::join('-', 'deploy', $project->id, $params['sha']));
327
        if (!$handle = fopen($filename, "w")) {
328
            $closure(
329
                'error',
330
                implode("\n", [
331
                    "Unable to open temporary download file: {$filename}"
332
                ])
333
            );
334
            throw new RuntimeException('Unable to open temporary file');
335
        }
336
        $this->get(
337
            $url,
338
            [
339
                'sink' => $handle,
340
            ]
341
        );
342
343
        // Make sure the deployment download directory exists
344
        if (!is_dir($directory)) {
345
            $mode = Settings::get('build.chmod.default_folder', Builder::MODE_DEFAULT);
346
            $closure('info', "Creating deployment directory: {$directory}");
347
            if (!mkdir($directory, $mode, true)) {
348
                $closure('error', "Failed to create deployment directory: {$directory}");
349
                throw new RuntimeException(
350
                    'Unable to create build directory at ' . $directory
351
                );
352
            }
353
        }
354
355
        // Decompress the archive into the download directory
356
        $tar     = Settings::get('binary.tar', '/bin/tar');
357
        $command = "{$tar} --strip-components=1 -xzf {$filename} -C {$directory}";
358
        $closure('info', "Unpacking codebase tarball: {$command}");
359
        $process = new Process(explode(' ', $command));
360
        $process->run();
361
        if (!$process->isSuccessful()) {
362
            $closure(
363
                'error',
364
                implode("\n", [
365
                    "Unpack failed: {$command}",
366
                    $process->getErrorOutput(),
367
                ])
368
            );
369
            throw new ProcessFailedException($process);
370
        }
371
372
        // Remove the downloaded archive
373
        if (!unlink($filename)) {
374
            $closure(
375
                'error',
376
                implode("\n", [
377
                    'Unable to remove tarball after unpacking',
378
                    $process->getOutput(),
379
                ])
380
            );
381
            throw new RuntimeException('Unable to remove local code archive');
382
        }
383
384
        return true;
385
    }
386
387
    /**
388
     * Send a GET request to a URL and get back a JSON array
389
     *
390
     * @return array
391
     * @throws RuntimeException
392
     * @author Ronan Chilvers <[email protected]>
393
     */
394
    protected function getJSON($url, array $options = []): array
395
    {
396
        $response = $this->get($url, $options);
397
        $content = $response->getBody()->getContents();
398
        Log::debug('Source control API response', [
399
            'provider' => get_called_class(),
400
            'body'     => $content,
401
        ]);
402
        if (empty($content)) {
403
            $content = '[]';
404
        }
405
        if (null === ($data = json_decode($content, true))) {
406
            throw new RuntimeException($this->getLabel() . ' : Invalid JSON response');
407
        }
408
        Log::debug('Source control JSON response', [
409
            'provider' => get_called_class(),
410
            'body'     => $content,
411
        ]);
412
413
        return $data;
414
    }
415
416
    /**
417
     * Send a GET request to a URL
418
     *
419
     * @return \Psr\Http\Message\ResponseInterface
420
     * @author Ronan Chilvers <[email protected]>
421
     */
422
    protected function get($url, array $options = []): ResponseInterface
423
    {
424
        return $this->client()->request(
425
            'GET',
426
            $url,
427
            $options
428
        );
429
    }
430
431
    /**
432
     * Get the HTTP client object
433
     *
434
     * @return \GuzzleHttp\ClientInterface
435
     * @author Ronan Chilvers <[email protected]>
436
     */
437
    protected function client(): ClientInterface
438
    {
439
        return $this->client;
440
    }
441
442
    /**
443
     * Encode a repository name
444
     *
445
     * @return string
446
     * @author Ronan Chilvers <[email protected]>
447
     */
448
    protected function encodeRepository($repository)
449
    {
450
        return $repository;
451
    }
452
453
    /**
454
     * Clean git branch name
455
     *
456
     * @param string $name
457
     * @return string
458
     * @author Ronan Chilvers <[email protected]>
459
     */
460
    protected function cleanBranchName(string $name): string
461
    {
462
        $callbacks = [];
463
        // Refs can't start with a hyphen
464
        $callbacks['/^[-]{1,}/'] = function ($match) {
465
            return '';
466
        };
467
        // No path component can start with a '.'
468
        $callbacks['/\/\./'] = function ($match) {
469
            return '/';
470
        };
471
        // No path component can end with '.lock'
472
        $callbacks['/\/([^\/]+)\.lock/'] = function ($match) {
473
            return '/' . $match[1];
474
        };
475
        // '..' is illegal anywhere
476
        $callbacks['/\.\./'] = function ($match) {
477
            return '';
478
        };
479
        // Control characters, space, tilde, caret, colon or backslash
480
        $callbacks['/[\x00-\x20\x7E\x7F\x5E\x3A\x3F\x2A\x5B\x5C]{1,}/'] = function ($match) {
481
            return '';
482
        };
483
        // Can't begin with a slash
484
        $callbacks['/^\//'] = function ($match) {
485
            return '';
486
        };
487
        // Can't end with a slash
488
        $callbacks['/\/$/'] = function ($match) {
489
            return '';
490
        };
491
        // Can't end with a dot
492
        $callbacks['/\.$/'] = function ($match) {
493
            return '';
494
        };
495
        // Can't contain '@{'
496
        $callbacks['/@{/'] = function ($match) {
497
            return '';
498
        };
499
        // Can't be just '@'
500
        $callbacks['/^@$/'] = function ($match) {
501
            return '-invalid-';
502
        };
503
        // Run regex, looping to make sure we don't create an illegal branch
504
        // while cleaning
505
        $previous = $name;
506
        $cleaned = preg_replace_callback_array($callbacks, $name);
507
        while ($cleaned != $previous) {
508
            $previous = $cleaned;
509
            $cleaned = preg_replace_callback_array($callbacks, $cleaned);
510
        }
511
512
        return $cleaned;
513
    }
514
}
515