SelfUpdateCommand::cleanupOldBackups()   A
last analyzed

Complexity

Conditions 2
Paths 2

Size

Total Lines 11
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 11
rs 9.4285
c 0
b 0
f 0
cc 2
eloc 7
nc 2
nop 1
1
<?php
2
3
/**
4
 * This file is part of tenside/core-bundle.
5
 *
6
 * (c) Christian Schiffler <[email protected]>
7
 *
8
 * For the full copyright and license information, please view the LICENSE
9
 * file that was distributed with this source code.
10
 *
11
 * This project is provided in good faith and hope to be usable by anyone.
12
 *
13
 * @package    tenside/core-bundle
14
 * @author     Christian Schiffler <[email protected]>
15
 * @copyright  2015 Christian Schiffler <[email protected]>
16
 * @license    https://github.com/tenside/core-bundle/blob/master/LICENSE MIT
17
 * @link       https://github.com/tenside/core-bundle
18
 * @filesource
19
 */
20
21
namespace Tenside\CoreBundle\Command;
22
23
use Composer\Command\BaseCommand;
24
use Composer\Downloader\FilesystemException;
25
use Composer\Factory;
26
use Composer\Util\Filesystem;
27
use Composer\Util\RemoteFilesystem;
28
use Symfony\Component\Console\Input\InputArgument;
29
use Symfony\Component\Console\Input\InputInterface;
30
use Symfony\Component\Console\Input\InputOption;
31
use Symfony\Component\Console\Output\OutputInterface;
32
use Symfony\Component\DependencyInjection\ContainerAwareInterface;
33
use Symfony\Component\DependencyInjection\ContainerAwareTrait;
34
use Symfony\Component\Finder\Finder;
35
use Tenside\Core\Tenside;
36
37
/**
38
 * Update the phar.
39
 *
40
 * This class is a heavily influenced (copied and modified) version of the composer self-update command
41
 *
42
 * @see Composer\Command\SelfUpdateCommand
43
 */
44
class SelfUpdateCommand extends BaseCommand implements ContainerAwareInterface
45
{
46
    use ContainerAwareTrait;
47
48
    /**
49
     * The remote file system to use.
50
     *
51
     * @var RemoteFilesystem
52
     */
53
    private $rfs;
54
55
    /**
56
     * Buffer the base url value.
57
     *
58
     * @var string
59
     */
60
    private $baseUrl;
61
62
    /**
63
     * Backup file name.
64
     */
65
    const OLD_INSTALL_EXT = '-old.phar';
66
67
    /**
68
     * {@inheritDoc}
69
     */
70
    protected function configure()
71
    {
72
        $this
73
            ->setName('tenside:self-update')
74
            ->setAliases(['tenside:selfupdate'])
75
            ->setDescription('Updates tenside.phar to the latest version.')
76
            ->setDefinition(array(
77
                new InputOption(
78
                    'rollback',
79
                    'r',
80
                    InputOption::VALUE_NONE,
81
                    'Revert to an older version'
82
                ),
83
                new InputOption(
84
                    'clean-backups',
85
                    null,
86
                    InputOption::VALUE_NONE,
87
                    'Delete old backups during an update. ' .
88
                    'This makes the current version of the only backup available after the update'
89
                ),
90
                new InputArgument('version', InputArgument::OPTIONAL, 'The version to update to'),
91
                new InputOption('no-progress', null, InputOption::VALUE_NONE, 'Do not output download progress.'),
92
            ))
93
            ->setHelp(
94
                'The <info>self-update</info> command checks the update server for newer
95
versions and if found, installs the latest.
96
97
<info>php tenside.phar self-update</info>
98
99
'
100
            );
101
    }
102
103
    /**
104
     * {@inheritDoc}
105
     *
106
     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
107
     * @SuppressWarnings(PHPMD.CyclomaticComplexity)
108
     * @SuppressWarnings(PHPMD.NPathComplexity)
109
     * @SuppressWarnings(PHPMD.ExcessiveMethodLength)
110
     *
111
     * @throws FilesystemException When the temporary directory or local file are not writable.
112
     */
113
    protected function execute(InputInterface $input, OutputInterface $output)
114
    {
115
        $originName    = $this->getOriginName();
116
        $config        = Factory::createConfig();
117
        $inputOutput   = $this->getIO();
118
        $this->rfs     = new RemoteFilesystem($inputOutput, $config);
119
        $cacheDir      = $config->get('cache-dir');
120
        $rollbackDir   = $config->get('home');
121
        $localFilename = $this->determineLocalFileName();
122
123
        // check if current dir is writable and if not try the cache dir from settings
124
        $tmpDir = is_writable(dirname($localFilename)) ? dirname($localFilename) : $cacheDir;
125
126
        // check for permissions in local filesystem before start connection process
127
        if (!is_writable($tmpDir)) {
128
            throw new FilesystemException(
129
                sprintf(
130
                    'Self update failed: the "%s" directory used to download the temp file could not be written',
131
                    $tmpDir
132
                )
133
            );
134
        }
135
        if (!is_writable($localFilename)) {
136
            throw new FilesystemException('Self update failed: "' . $localFilename . '" is not writable.');
137
        }
138
139
        if ($input->getOption('rollback')) {
140
            return $this->rollback($rollbackDir, $localFilename);
141
        }
142
143
        $latestVersion = $this->getLatestVersion();
144
        $updateVersion = $input->getArgument('version') ?: $latestVersion;
145
146
        if (preg_match('{^[0-9a-f]{40}$}', $updateVersion) && $updateVersion !== $latestVersion) {
147
            $inputOutput->writeError(
148
                '<error>You can not update to a specific SHA-1 as those phars are not available for download</error>'
149
            );
150
151
            return 1;
152
        }
153
154
        if (Tenside::VERSION === $updateVersion) {
155
            $inputOutput->writeError('<info>You are already using version ' . $updateVersion . '.</info>');
156
157
            return 0;
158
        }
159
160
        $tempFilename = $tmpDir . '/' . basename($localFilename, '.phar').'-temp.phar';
161
        $backupFile   = sprintf(
162
            '%s/%s-%s%s',
163
            $rollbackDir,
164
            strtr(Tenside::RELEASE_DATE, ' :', '_-'),
165
            preg_replace('{^([0-9a-f]{7})[0-9a-f]{33}$}', '$1', Tenside::VERSION),
166
            self::OLD_INSTALL_EXT
167
        );
168
169
        $inputOutput->writeError(sprintf('Updating to version <info>%s</info>.', $updateVersion));
170
        $remoteFilename = $this->determineRemoteFilename($updateVersion);
171
        $this->rfs->copy($originName, $remoteFilename, $tempFilename, !$input->getOption('no-progress'));
172
        if (!file_exists($tempFilename)) {
173
            $inputOutput->writeError(
174
                '<error>The download of the new version failed for an unexpected reason</error>'
175
            );
176
177
            return 1;
178
        }
179
180
        // remove saved installations of tenside
181
        if ($input->getOption('clean-backups')) {
182
            $this->cleanupOldBackups($rollbackDir);
183
        }
184
185
        if ($err = $this->setLocalPhar($localFilename, $tempFilename, $backupFile)) {
186
            $inputOutput->writeError('<error>The file is corrupted ('.$err->getMessage().').</error>');
187
            $inputOutput->writeError('<error>Please re-run the self-update command to try again.</error>');
188
189
            return 1;
190
        }
191
192
        if (file_exists($backupFile)) {
193
            $inputOutput->writeError(
194
                sprintf(
195
                    'Use <info>%s self-update --rollback</info> to return to version %s',
196
                    $localFilename,
197
                    Tenside::VERSION
198
                )
199
            );
200
201
            return 0;
202
        }
203
204
        $inputOutput->writeError(
205
            sprintf(
206
                '<warning>A backup of the current version could not be written to %s, ' .
207
                'no rollback possible</warning>',
208
                $backupFile
209
            )
210
        );
211
212
        return 0;
213
    }
214
215
    /**
216
     * Rollback to the previous version.
217
     *
218
     * @param string $rollbackDir   The directory where the rollback files are located.
219
     *
220
     * @param string $localFilename The file to rollback to.
221
     *
222
     * @return int
223
     *
224
     * @throws \UnexpectedValueException If the version could not be found.
225
     *
226
     * @throws FilesystemException       If the rollback directory is not writable.
227
     */
228
    protected function rollback($rollbackDir, $localFilename)
229
    {
230
        $rollbackVersion = $this->getLastBackupVersion($rollbackDir);
231
        if (!$rollbackVersion) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $rollbackVersion of type string|false is loosely compared to false; this is ambiguous if the string can be empty. You might want to explicitly use === false instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
232
            throw new \UnexpectedValueException(
233
                sprintf(
234
                    'Composer rollback failed: no installation to roll back to in "%s"',
235
                    $rollbackDir
236
                )
237
            );
238
        }
239
240
        if (!is_writable($rollbackDir)) {
241
            throw new FilesystemException(
242
                sprintf(
243
                    'Composer rollback failed: the "%s" dir could not be written to',
244
                    $rollbackDir
245
                )
246
            );
247
        }
248
249
        $old = $rollbackDir . '/' . $rollbackVersion . self::OLD_INSTALL_EXT;
250
251
        if (!is_file($old)) {
252
            throw new FilesystemException('Composer rollback failed: "'.$old.'" could not be found');
253
        }
254
        if (!is_readable($old)) {
255
            throw new FilesystemException('Composer rollback failed: "'.$old.'" could not be read');
256
        }
257
258
        $oldFile     = sprintf('%s/"%s"', $rollbackDir, $rollbackVersion, self::OLD_INSTALL_EXT);
259
        $inputOutput = $this->getIO();
260
        $inputOutput->writeError(sprintf('Rolling back to version <info>%s</info>.', $rollbackVersion));
261
        if ($err = $this->setLocalPhar($localFilename, $oldFile)) {
262
            $inputOutput->writeError(
263
                sprintf(
264
                    '<error>The backup file was corrupted (%s) and has been removed.</error>',
265
                    $err->getMessage()
266
                )
267
            );
268
269
            return 1;
270
        }
271
272
        return 0;
273
    }
274
275
    /**
276
     * Update the local file with the new file optionally creating a backup first.
277
     *
278
     * If an exception occurs that is not returned.
279
     *
280
     * @param string $localFilename The local filename.
281
     *
282
     * @param string $newFilename   The new file name.
283
     *
284
     * @param null   $backupTarget  The backup file name.
285
     *
286
     * @return \UnexpectedValueException|\PharException|null
287
     *
288
     * @throws \Exception For any other exception encountered aside from \UnexpectedValueException and \PharException.
289
     */
290
    protected function setLocalPhar($localFilename, $newFilename, $backupTarget = null)
291
    {
292
        try {
293
            // @codingStandardsIgnoreStart
294
            @chmod($newFilename, fileperms($localFilename));
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition here. This can introduce security issues, and is generally not recommended.

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
295
            // @codingStandardsIgnoreEnd
296
            if (!ini_get('phar.readonly')) {
297
                // test the phar validity
298
                $phar = new \Phar($newFilename);
299
                // free the variable to unlock the file
300
                unset($phar);
301
            }
302
303
            // copy current file into installations dir
304
            if ($backupTarget && file_exists($localFilename)) {
305
                // @codingStandardsIgnoreStart
306
                @copy($localFilename, $backupTarget);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition here. This can introduce security issues, and is generally not recommended.

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
307
                // @codingStandardsIgnoreEnd
308
            }
309
310
            rename($newFilename, $localFilename);
311
        } catch (\Exception $e) {
312
            if ($backupTarget) {
313
                // @codingStandardsIgnoreStart
314
                @unlink($newFilename);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition here. This can introduce security issues, and is generally not recommended.

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
315
                // @codingStandardsIgnoreEnd
316
            }
317
            if (!$e instanceof \UnexpectedValueException && !$e instanceof \PharException) {
318
                throw $e;
319
            }
320
321
            return $e;
322
        }
323
324
        return null;
325
    }
326
327
    /**
328
     * Retrieve most recent backup version.
329
     *
330
     * @param string $rollbackDir The directory where the rollback files are contained within.
331
     *
332
     * @return bool|string
333
     */
334
    protected function getLastBackupVersion($rollbackDir)
335
    {
336
        $finder = $this->getOldInstallationFinder($rollbackDir);
337
        $finder->sortByName();
338
        $files = iterator_to_array($finder);
339
340
        if (count($files)) {
341
            return basename(end($files), self::OLD_INSTALL_EXT);
342
        }
343
344
        return false;
345
    }
346
347
    /**
348
     * Create a finder instance that is capable of finding
349
     *
350
     * @param string $rollbackDir The directory where the rollback files are contained within.
351
     *
352
     * @return Finder
353
     */
354
    protected function getOldInstallationFinder($rollbackDir)
355
    {
356
        $finder = Finder::create()
357
            ->depth(0)
358
            ->files()
359
            ->name('*' . self::OLD_INSTALL_EXT)
360
            ->in($rollbackDir);
361
362
        return $finder;
363
    }
364
365
    /**
366
     * Clean the backup directory.
367
     *
368
     * @param string $rollbackDir The backup directory.
369
     *
370
     * @return void
371
     */
372
    protected function cleanupOldBackups($rollbackDir)
373
    {
374
        $finder = $this->getOldInstallationFinder($rollbackDir);
375
376
        $fileSystem = new Filesystem;
377
        foreach ($finder as $file) {
378
            $file = (string) $file;
379
            $this->getIO()->writeError('<info>Removing: ' . $file . '</info>');
380
            $fileSystem->remove($file);
381
        }
382
    }
383
384
    /**
385
     * Check online for the latest version available.
386
     *
387
     * @return string
388
     */
389
    protected function getLatestVersion()
390
    {
391
        $latestVersion = trim($this->rfs->getContents($this->getOriginName(), $this->getBaseUrl() . '/version', false));
392
393
        return $latestVersion;
394
    }
395
396
    /**
397
     * Retrieve the download url base to use for custom configuration.
398
     *
399
     * @return string
400
     */
401
    protected function getOriginName()
402
    {
403
        return $this->container->getParameter('tenside.self_update.origin_name');
404
    }
405
406
    /**
407
     * Retrieve the base url for obtaining the latest version and phar files.
408
     *
409
     * @return string
410
     */
411
    protected function getBaseUrl()
412
    {
413
        if (isset($this->baseUrl)) {
414
            return $this->baseUrl;
415
        }
416
417
        $this->baseUrl = $this->container->getParameter('tenside.self_update.base_url');
418
419
        if (false === strpos($this->baseUrl, '://')) {
420
            $this->baseUrl = (extension_loaded('openssl') ? 'https' : 'http') . '://' . $this->baseUrl;
421
        }
422
423
        return $this->baseUrl;
424
    }
425
426
    /**
427
     * Retrieve the phar name parameter.
428
     *
429
     * @return string
430
     */
431
    protected function getPharName()
432
    {
433
        return $this->container->getParameter('tenside.self_update.phar_name');
434
    }
435
436
    /**
437
     * Calculate the remote filename from a version.
438
     *
439
     * @param string $updateVersion The version to update to either an sha or a semver version.
440
     *
441
     * @return string
442
     */
443
    protected function determineRemoteFilename($updateVersion)
444
    {
445
        // If sha, download from root.
446
        if (preg_match('{^[0-9a-f]{40}$}', $updateVersion)) {
447
            return $this->getBaseUrl() . '/' . $this->getPharName();
448
        }
449
450
        // Download from sub directory otherwise.
451
        return sprintf(
452
            '%s/download/%s/%s',
453
            $this->getBaseUrl(),
454
            $updateVersion,
455
            $this->getPharName()
456
        );
457
    }
458
459
    /**
460
     * Determine the local file name of the current running phar.
461
     *
462
     * @return string
463
     *
464
     * @SuppressWarnings(PHPMD.Superglobals)
465
     * @SuppressWarnings(PHPMD.CamelCaseVariableName)
466
     */
467
    protected function determineLocalFileName()
468
    {
469
        // First: try to convert server argv 0 to a real path first (absolute path to a phar).
470
        if (false !== ($localFilename = realpath($_SERVER['argv'][0]))) {
471
            return $localFilename;
472
        }
473
474
        // Second: try the currently running phar file now.
475
        if ($localFilename = \Phar::running(false)) {
476
            return $localFilename;
477
        }
478
479
        // Fall back to server argv 0 (retaining relative path) and hope best.
480
        return $_SERVER['argv'][0];
481
    }
482
}
483