class InPlaceUpdate in Automatic Updates 8
Class to apply in-place updates.
Hierarchy
- class \Drupal\automatic_updates\Services\InPlaceUpdate implements UpdateInterface uses ProjectInfoTrait
Expanded class hierarchy of InPlaceUpdate
1 file declares its use of InPlaceUpdate
- InPlaceUpdateTest.php in tests/
src/ Build/ InPlaceUpdateTest.php
1 string reference to 'InPlaceUpdate'
1 service uses InPlaceUpdate
File
- src/
Services/ InPlaceUpdate.php, line 27
Namespace
Drupal\automatic_updates\ServicesView source
class InPlaceUpdate implements UpdateInterface {
use ProjectInfoTrait;
/**
* The manifest file that lists all file deletions.
*/
const DELETION_MANIFEST = 'DELETION_MANIFEST.txt';
/**
* The directory inside the archive for file additions and modifications.
*/
const ARCHIVE_DIRECTORY = 'files/';
/**
* The logger.
*
* @var \Psr\Log\LoggerInterface
*/
protected $logger;
/**
* The archive manager.
*
* @var \Drupal\Core\Archiver\ArchiverManager
*/
protected $archiveManager;
/**
* The config factory.
*
* @var \Drupal\Core\Config\ConfigFactoryInterface
*/
protected $configFactory;
/**
* The file system service.
*
* @var \Drupal\Core\File\FileSystemInterface
*/
protected $fileSystem;
/**
* The HTTP client service.
*
* @var \GuzzleHttp\Client
*/
protected $httpClient;
/**
* The root file path.
*
* @var string
*/
protected $rootPath;
/**
* The vendor file path.
*
* @var string
*/
protected $vendorPath;
/**
* The folder where files are backed up.
*
* @var string
*/
protected $backup;
/**
* The temporary extract directory.
*
* @var string
*/
protected $tempDirectory;
/**
* Constructs an InPlaceUpdate.
*
* @param \Psr\Log\LoggerInterface $logger
* The logger.
* @param \Drupal\Core\Archiver\ArchiverManager $archive_manager
* The archive manager.
* @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
* The config factory.
* @param \Drupal\Core\File\FileSystemInterface $file_system
* The filesystem service.
* @param \GuzzleHttp\ClientInterface $http_client
* The HTTP client service.
* @param string $app_root
* The app root.
*/
public function __construct(LoggerInterface $logger, ArchiverManager $archive_manager, ConfigFactoryInterface $config_factory, FileSystemInterface $file_system, ClientInterface $http_client, $app_root) {
$this->logger = $logger;
$this->archiveManager = $archive_manager;
$this->configFactory = $config_factory;
$this->fileSystem = $file_system;
$this->httpClient = $http_client;
$this->rootPath = (string) $app_root;
$this->vendorPath = $this->rootPath . DIRECTORY_SEPARATOR . 'vendor' . DIRECTORY_SEPARATOR;
$project_root = drupal_get_path('module', 'automatic_updates');
require_once $project_root . DIRECTORY_SEPARATOR . 'vendor' . DIRECTORY_SEPARATOR . 'autoload.php';
}
/**
* {@inheritdoc}
*/
public function update(UpdateMetadata $metadata) {
// Bail immediately on updates if error category checks fail.
/** @var \Drupal\automatic_updates\ReadinessChecker\ReadinessCheckerManagerInterface $readiness_check_manager */
$checker = \Drupal::service('automatic_updates.readiness_checker');
if ($checker
->run(ReadinessCheckerManagerInterface::ERROR)) {
return FALSE;
}
$success = FALSE;
if ($metadata
->getProjectName() === 'drupal') {
$project_root = $this->rootPath;
}
else {
$project_root = drupal_get_path($metadata
->getProjectType(), $metadata
->getProjectName());
}
if ($archive = $this
->getArchive($metadata)) {
$modified = $this
->checkModifiedFiles($metadata, $archive);
if (!$modified && $this
->backup($archive, $project_root)) {
$this->logger
->info('In place update has started.');
try {
$success = $this
->processUpdate($archive, $project_root);
$this->logger
->info('In place update has finished.');
} catch (\Throwable $throwable) {
$this->logger
->info('In place update failed.');
watchdog_exception($throwable);
} catch (\Exception $exception) {
$this->logger
->info('In place update failed.');
watchdog_exception($exception);
}
if ($success) {
$process = automatic_updates_console_command('updatedb:status');
if ($success && $process
->getOutput()) {
$this->logger
->info('Database update handling has started.');
$success = $this
->handleDatabaseUpdates();
$this->logger
->info('Database update handling has finished.');
}
}
if (!$success) {
$this->logger
->info('Rollback has started.');
$this
->rollback($project_root);
$this->logger
->info('Rollback has finished.');
}
if ($success) {
$this->logger
->info('Cache clear has started.');
$this
->cacheRebuild();
$this->logger
->info('Cache clear has finished.');
}
}
}
/** @var \Symfony\Component\EventDispatcher\EventDispatcherInterface $event_dispatcher */
$event_dispatcher = \Drupal::service('event_dispatcher');
$event = new PostUpdateEvent($metadata, $success);
$event_dispatcher
->dispatch(UpdateEvents::POST_UPDATE, $event);
return $success;
}
/**
* Get an archive with the quasi-patch contents.
*
* @param \Drupal\automatic_updates\UpdateMetadata $metadata
* The update metadata.
*
* @return \Drupal\Core\Archiver\ArchiverInterface|null
* The archive or NULL if download fails.
*
* @throws \SodiumException
*/
protected function getArchive(UpdateMetadata $metadata) {
$quasi_patch = $this
->getQuasiPatchFileName($metadata);
$url = $this
->buildUrl($metadata
->getProjectName(), $quasi_patch);
$temp_directory = FileSystem::getOsTemporaryDirectory() . DIRECTORY_SEPARATOR;
$destination = $this->fileSystem
->getDestinationFilename($temp_directory . $quasi_patch, FileSystemInterface::EXISTS_REPLACE);
$this
->doGetResource($url, $destination);
$csig_file = $quasi_patch . '.csig';
$csig_url = $this
->buildUrl($metadata
->getProjectName(), $csig_file);
$csig_destination = $this->fileSystem
->getDestinationFilename(FileSystem::getOsTemporaryDirectory() . DIRECTORY_SEPARATOR . $csig_file, FileSystemInterface::EXISTS_REPLACE);
$this
->doGetResource($csig_url, $csig_destination);
$csig = file_get_contents($csig_destination);
$this
->validateArchive($temp_directory, $csig);
return $this->archiveManager
->getInstance([
'filepath' => $destination,
]);
}
/**
* Check if files are modified before applying updates.
*
* @param \Drupal\automatic_updates\UpdateMetadata $metadata
* The update metadata.
* @param \Drupal\Core\Archiver\ArchiverInterface $archive
* The archive.
*
* @return bool
* Return TRUE if modified files exist, FALSE otherwise.
*/
protected function checkModifiedFiles(UpdateMetadata $metadata, ArchiverInterface $archive) {
if ($metadata
->getProjectType() === 'core') {
$metadata
->setProjectType('module');
}
$extensions = $this
->getInfos($metadata
->getProjectType());
/** @var \Drupal\automatic_updates\Services\ModifiedFilesInterface $modified_files */
$modified_files = \Drupal::service('automatic_updates.modified_files');
try {
$files = iterator_to_array($modified_files
->getModifiedFiles([
$extensions[$metadata
->getProjectName()],
]));
} catch (RequestException $exception) {
// While not strictly true that there are modified files, we can't be sure
// there aren't any. So assume the worst.
return TRUE;
}
$files = array_unique($files);
$archive_files = $archive
->listContents();
foreach ($archive_files as $index => &$archive_file) {
$skipped_files = [
self::DELETION_MANIFEST,
];
// Skip certain files and all directories.
if (in_array($archive_file, $skipped_files, TRUE) || substr($archive_file, -1) === '/') {
unset($archive_files[$index]);
continue;
}
$this
->stripFileDirectoryPath($archive_file);
}
unset($archive_file);
if ($intersection = array_intersect($files, $archive_files)) {
$this->logger
->error('Can not update because %count files are modified: %paths', [
'%count' => count($intersection),
'%paths' => implode(', ', $intersection),
]);
return TRUE;
}
return FALSE;
}
/**
* Perform retrieval of archive, with delay if archive is still being created.
*
* @param string $url
* The URL to retrieve.
* @param string $destination
* The destination to download the archive.
* @param null|int $delay
* The delay, defaults to NULL.
*/
protected function doGetResource($url, $destination, $delay = NULL) {
try {
$this->httpClient
->get($url, [
'sink' => $destination,
'delay' => $delay,
// Some of the core quasi-patch zip files are large, increase timeout.
'timeout' => 120,
]);
} catch (RequestException $exception) {
$response = $exception
->getResponse();
if ($response && $response
->getStatusCode() === 429) {
$delay = 1000 * (isset($response
->getHeader('Retry-After')[0]) ? $response
->getHeader('Retry-After')[0] : 10);
$this
->doGetResource($url, $destination, $delay);
}
else {
$this->logger
->error('Retrieval of "@url" failed with: @message', [
'@url' => $exception
->getRequest()
->getUri(),
'@message' => $exception
->getMessage(),
]);
throw $exception;
}
}
}
/**
* Process update.
*
* @param \Drupal\Core\Archiver\ArchiverInterface $archive
* The archive.
* @param string $project_root
* The project root directory.
*
* @return bool
* Return TRUE if update succeeds, FALSE otherwise.
*/
protected function processUpdate(ArchiverInterface $archive, $project_root) {
$archive
->extract($this
->getTempDirectory());
foreach ($this
->getFilesList($this
->getTempDirectory()) as $file) {
$file_real_path = $this
->getFileRealPath($file);
$file_path = substr($file_real_path, strlen($this
->getTempDirectory() . self::ARCHIVE_DIRECTORY));
$project_real_path = $this
->getProjectRealPath($file_path, $project_root);
try {
$directory = dirname($project_real_path);
$this->fileSystem
->prepareDirectory($directory, FileSystemInterface::CREATE_DIRECTORY);
$this->fileSystem
->copy($file_real_path, $project_real_path, FileSystemInterface::EXISTS_REPLACE);
$this->logger
->info('"@file" was updated.', [
'@file' => $project_real_path,
]);
} catch (FileException $exception) {
return FALSE;
}
}
foreach ($this
->getDeletions() as $deletion) {
try {
$file_deletion = $this
->getProjectRealPath($deletion, $project_root);
$this->fileSystem
->delete($file_deletion);
$this->logger
->info('"@file" was deleted.', [
'@file' => $file_deletion,
]);
} catch (FileException $exception) {
return FALSE;
}
}
return TRUE;
}
/**
* Validate the downloaded archive.
*
* @param string $directory
* The location of the downloaded archive.
* @param string $csig
* The CSIG contents.
*
* @throws \SodiumException
*/
protected function validateArchive($directory, $csig) {
$module_path = drupal_get_path('module', 'automatic_updates');
$key = file_get_contents($module_path . '/artifacts/keys/root.pub');
$verifier = new Verifier($key);
$files = $verifier
->verifyCsigMessage($csig);
$checksums = new ChecksumList($files, TRUE);
$failed_checksums = new FailedCheckumFilter($checksums, $directory);
if (iterator_count($failed_checksums)) {
throw new \RuntimeException('The downloaded files did not match what was expected.');
}
}
/**
* Backup before an update.
*
* @param \Drupal\Core\Archiver\ArchiverInterface $archive
* The archive.
* @param string $project_root
* The project root directory.
*
* @return bool
* Return TRUE if backup succeeds, FALSE otherwise.
*/
protected function backup(ArchiverInterface $archive, $project_root) {
$backup = $this->fileSystem
->createFilename('automatic_updates-backup', 'temporary://');
$this->fileSystem
->prepareDirectory($backup, FileSystemInterface::CREATE_DIRECTORY);
$this->backup = $this->fileSystem
->realpath($backup) . DIRECTORY_SEPARATOR;
if (!$this->backup) {
return FALSE;
}
foreach ($archive
->listContents() as $file) {
// Ignore files that aren't in the files directory.
if (!$this
->stripFileDirectoryPath($file)) {
continue;
}
$success = $this
->doBackup($file, $project_root);
if (!$success) {
return FALSE;
}
}
$archive
->extract($this
->getTempDirectory(), [
self::DELETION_MANIFEST,
]);
foreach ($this
->getDeletions() as $deletion) {
$success = $this
->doBackup($deletion, $project_root);
if (!$success) {
return FALSE;
}
}
return TRUE;
}
/**
* Remove the files directory path from files from the archive.
*
* @param string $file
* The file path.
*
* @return bool
* TRUE if path was removed, else FALSE.
*/
protected function stripFileDirectoryPath(&$file) {
if (strpos($file, self::ARCHIVE_DIRECTORY) === 0) {
$file = substr($file, 6);
return TRUE;
}
return FALSE;
}
/**
* Execute file backup.
*
* @param string $file
* The file to backup.
* @param string $project_root
* The project root directory.
*
* @return bool
* Return TRUE if backup succeeds, FALSE otherwise.
*/
protected function doBackup($file, $project_root) {
$directory = $this->backup . dirname($file);
if (!file_exists($directory) && !$this->fileSystem
->mkdir($directory, NULL, TRUE)) {
return FALSE;
}
$project_real_path = $this
->getProjectRealPath($file, $project_root);
if (file_exists($project_real_path) && !is_dir($project_real_path)) {
try {
$this->fileSystem
->copy($project_real_path, $this->backup . $file, FileSystemInterface::EXISTS_REPLACE);
$this->logger
->info('"@file" was backed up in preparation for an update.', [
'@file' => $project_real_path,
]);
} catch (FileException $exception) {
return FALSE;
}
}
return TRUE;
}
/**
* Rollback after a failed update.
*
* @param string $project_root
* The project root directory.
*/
protected function rollback($project_root) {
if (!$this->backup) {
return;
}
foreach ($this
->getFilesList($this
->getTempDirectory()) as $file) {
$file_real_path = $this
->getFileRealPath($file);
$file_path = substr($file_real_path, strlen($this
->getTempDirectory() . self::ARCHIVE_DIRECTORY));
$project_real_path = $this
->getProjectRealPath($file_path, $project_root);
try {
$this->fileSystem
->delete($project_real_path);
$this->logger
->info('"@file" was successfully removed during rollback.', [
'@file' => $project_real_path,
]);
} catch (FileException $exception) {
$this->logger
->error('"@file" failed removal on rollback.', [
'@file' => $project_real_path,
]);
}
}
foreach ($this
->getFilesList($this->backup) as $file) {
$this
->doRestore($file, $project_root);
}
}
/**
* Do restore.
*
* @param \SplFileInfo $file
* File to restore.
* @param string $project_root
* The project root directory.
*/
protected function doRestore(\SplFileInfo $file, $project_root) {
$file_real_path = $this
->getFileRealPath($file);
$file_path = substr($file_real_path, strlen($this->backup));
try {
$this->fileSystem
->copy($file_real_path, $this
->getProjectRealPath($file_path, $project_root), FileSystemInterface::EXISTS_REPLACE);
$this->logger
->info('"@file" was successfully restored.', [
'@file' => $file_path,
]);
} catch (FileException $exception) {
$this->logger
->error('"@file" failed restoration during rollback.', [
'@file' => $file_real_path,
]);
}
}
/**
* Provide a recursive list of files, excluding directories.
*
* @param string $directory
* The directory to recurse for files.
*
* @return \RecursiveIteratorIterator|\SplFileInfo[]
* The iterator of SplFileInfos.
*/
protected function getFilesList($directory) {
$filter = static function ($file, $file_name, $iterator) {
/** @var \SplFileInfo $file */
/** @var string $file_name */
/** @var \RecursiveDirectoryIterator $iterator */
if ($iterator
->hasChildren() && $file
->getFilename() !== '.git') {
return TRUE;
}
$skipped_files = [
self::DELETION_MANIFEST,
];
return $file
->isFile() && !in_array($file
->getFilename(), $skipped_files, TRUE);
};
$innerIterator = new \RecursiveDirectoryIterator($directory, \RecursiveDirectoryIterator::SKIP_DOTS);
return new \RecursiveIteratorIterator(new \RecursiveCallbackFilterIterator($innerIterator, $filter));
}
/**
* Build a project quasi-patch download URL.
*
* @param string $project_name
* The project name.
* @param string $file_name
* The file name.
*
* @return string
* The URL endpoint with for an extension.
*/
protected function buildUrl($project_name, $file_name) {
$uri = $this->configFactory
->get('automatic_updates.settings')
->get('download_uri');
return Url::fromUri("{$uri}/{$project_name}/{$file_name}")
->toString();
}
/**
* Get the quasi-patch file name.
*
* @param \Drupal\automatic_updates\UpdateMetadata $metadata
* The update metadata.
*
* @return string
* The quasi-patch file name.
*/
protected function getQuasiPatchFileName(UpdateMetadata $metadata) {
return "{$metadata->getProjectName()}-{$metadata->getFromVersion()}-to-{$metadata->getToVersion()}.zip";
}
/**
* Get file real path.
*
* @param \SplFileInfo $file
* The file to retrieve the real path.
*
* @return string
* The file real path.
*/
protected function getFileRealPath(\SplFileInfo $file) {
$real_path = $file
->getRealPath();
if (!$real_path) {
throw new FileException(sprintf('Could not get real path for "%s"', $file
->getFilename()));
}
return $real_path;
}
/**
* Get the real path of a file.
*
* @param string $file_path
* The file path.
* @param string $project_root
* The project root directory.
*
* @return string
* The real path of a file.
*/
protected function getProjectRealPath($file_path, $project_root) {
if (strpos($file_path, 'vendor' . DIRECTORY_SEPARATOR) === 0) {
return $this->vendorPath . substr($file_path, 7);
}
return rtrim($project_root, '/\\') . DIRECTORY_SEPARATOR . $file_path;
}
/**
* Provides the temporary extraction directory.
*
* @return string
* The temporary directory.
*/
protected function getTempDirectory() {
if (!$this->tempDirectory) {
$this->tempDirectory = $this->fileSystem
->createFilename('automatic_updates-update', FileSystem::getOsTemporaryDirectory());
$this->fileSystem
->prepareDirectory($this->tempDirectory, FileSystemInterface::CREATE_DIRECTORY);
$this->tempDirectory .= DIRECTORY_SEPARATOR;
}
return $this->tempDirectory;
}
/**
* Get an iterator of files to delete.
*
* @return \ArrayIterator
* Iterator of files to delete.
*/
protected function getDeletions() {
$deletions = [];
if (!file_exists($this
->getTempDirectory() . self::DELETION_MANIFEST)) {
return new \ArrayIterator($deletions);
}
$handle = fopen($this
->getTempDirectory() . self::DELETION_MANIFEST, 'r');
if ($handle) {
while (($deletion = fgets($handle)) !== FALSE) {
if ($result = trim($deletion)) {
$deletions[] = $result;
}
}
fclose($handle);
}
return new \ArrayIterator($deletions);
}
/**
* Clear cache on successful update.
*/
protected function cacheRebuild() {
if (function_exists('opcache_reset')) {
opcache_reset();
}
automatic_updates_console_command('cache:rebuild');
}
/**
* Handle database updates.
*
* @return bool
* TRUE if database updates were handled successfully. FALSE otherwise.
*
* @throws \Drupal\Component\Plugin\Exception\PluginException
*/
protected function handleDatabaseUpdates() {
$result = TRUE;
/** @var \Drupal\Component\Plugin\PluginManagerInterface $database_update_handler */
$database_update_handler = \Drupal::service('plugin.manager.database_update_handler');
foreach ($this->configFactory
->get('automatic_updates.settings')
->get('database_update_handling') as $plugin_id) {
$result = $result && $database_update_handler
->createInstance($plugin_id)
->execute();
}
return $result;
}
}
Members
Name | Modifiers | Type | Description | Overrides |
---|---|---|---|---|
InPlaceUpdate:: |
protected | property | The archive manager. | |
InPlaceUpdate:: |
protected | property | The folder where files are backed up. | |
InPlaceUpdate:: |
protected | property | The config factory. | |
InPlaceUpdate:: |
protected | property | The file system service. | |
InPlaceUpdate:: |
protected | property | The HTTP client service. | |
InPlaceUpdate:: |
protected | property | The logger. | |
InPlaceUpdate:: |
protected | property | The root file path. | |
InPlaceUpdate:: |
protected | property | The temporary extract directory. | |
InPlaceUpdate:: |
protected | property | The vendor file path. | |
InPlaceUpdate:: |
constant | The directory inside the archive for file additions and modifications. | ||
InPlaceUpdate:: |
protected | function | Backup before an update. | |
InPlaceUpdate:: |
protected | function | Build a project quasi-patch download URL. | |
InPlaceUpdate:: |
protected | function | Clear cache on successful update. | |
InPlaceUpdate:: |
protected | function | Check if files are modified before applying updates. | |
InPlaceUpdate:: |
constant | The manifest file that lists all file deletions. | ||
InPlaceUpdate:: |
protected | function | Execute file backup. | |
InPlaceUpdate:: |
protected | function | Perform retrieval of archive, with delay if archive is still being created. | |
InPlaceUpdate:: |
protected | function | Do restore. | |
InPlaceUpdate:: |
protected | function | Get an archive with the quasi-patch contents. | |
InPlaceUpdate:: |
protected | function | Get an iterator of files to delete. | |
InPlaceUpdate:: |
protected | function | Get file real path. | |
InPlaceUpdate:: |
protected | function | Provide a recursive list of files, excluding directories. | |
InPlaceUpdate:: |
protected | function | Get the real path of a file. | |
InPlaceUpdate:: |
protected | function | Get the quasi-patch file name. | |
InPlaceUpdate:: |
protected | function | Provides the temporary extraction directory. | |
InPlaceUpdate:: |
protected | function | Handle database updates. | |
InPlaceUpdate:: |
protected | function | Process update. | |
InPlaceUpdate:: |
protected | function | Rollback after a failed update. | |
InPlaceUpdate:: |
protected | function | Remove the files directory path from files from the archive. | |
InPlaceUpdate:: |
public | function |
Update a project to the next release. Overrides UpdateInterface:: |
|
InPlaceUpdate:: |
protected | function | Validate the downloaded archive. | |
InPlaceUpdate:: |
public | function | Constructs an InPlaceUpdate. | |
ProjectInfoTrait:: |
protected | function | Get the composer.json as a JSON array. | |
ProjectInfoTrait:: |
protected | function | Get extension list. | |
ProjectInfoTrait:: |
protected | function | Get the extension version. | |
ProjectInfoTrait:: |
protected | function | Returns an array of info files information of available extensions. | 1 |
ProjectInfoTrait:: |
protected | function | Get the extension's project name. | |
ProjectInfoTrait:: |
protected | function | Get string suffix. |