Commit a2e1e0d0 authored by David Mudrák's avatar David Mudrák
Browse files

MDL-49329 admin: Archive plugin code before removing it from dirroot

This should allow the admin to revert the upgrade of existing plugins,
such when the dependency chain leads to a dead-end. Additionally, we
archive (as a last-chance copy) the to-be-installed plugins when
cancelling their installation. This is mainly for developers who could
otherwise loose their code. For the same reason, plugins are being
archived upon uninstallation, too.
parent 4d7528f9
......@@ -148,13 +148,6 @@ if ($delete and $confirmed) {
'core_plugin_manager::get_plugin_info() returned not-null versiondb for the plugin to be deleted');
}
// Make sure the folder is removable.
if (!$pluginman->is_plugin_folder_removable($pluginfo->component)) {
throw new moodle_exception('err_removing_unremovable_folder', 'core_plugin', '',
array('plugin' => $pluginfo->component, 'rootdir' => $pluginfo->rootdir),
'plugin root folder is not removable as expected');
}
// Make sure the folder is within Moodle installation tree.
if (strpos($pluginfo->rootdir, $CFG->dirroot) !== 0) {
throw new moodle_exception('err_unexpected_plugin_rootdir', 'core_plugin', '',
......@@ -163,11 +156,8 @@ if ($delete and $confirmed) {
}
// So long, and thanks for all the bugs.
fulldelete($pluginfo->rootdir);
// Reset op code caches.
if (function_exists('opcache_reset')) {
opcache_reset();
}
$pluginman->remove_plugin_folder($pluginfo);
// We need to execute upgrade to make sure everything including caches is up to date.
redirect(new moodle_url('/admin/index.php'));
}
......
......@@ -1341,11 +1341,7 @@ class core_plugin_manager {
list($plugintype, $pluginname) = core_component::normalize_component($plugin->component);
$target = $this->get_plugintype_root($plugintype);
if (file_exists($target.'/'.$pluginname)) {
$current = $this->get_plugin_info($plugin->component);
if ($current->versiondb and $current->versiondb == $current->versiondisk) {
// TODO Archive existing version so that we can revert.
}
remove_dir($target.'/'.$pluginname);
$this->remove_plugin_folder($this->get_plugin_info($plugin->component));
}
if (!$this->unzip_plugin_file($zipfile, $target, $pluginname)) {
$silent or $this->mtrace(get_string('error'));
......@@ -1911,6 +1907,37 @@ class core_plugin_manager {
}
}
/**
* Remove the current plugin code from the dirroot.
*
* If removing the currently installed version (which happens during
* updates), we archive the code so that the upgrade can be cancelled.
*
* To prevent accidental data-loss, we also archive the existing plugin
* code if cancelling installation of it, so that the developer does not
* loose the only version of their work-in-progress.
*
* @param \core\plugininfo\base $plugin
*/
public function remove_plugin_folder(\core\plugininfo\base $plugin) {
if (!$this->is_plugin_folder_removable($plugin->component)) {
throw new moodle_exception('err_removing_unremovable_folder', 'core_plugin', '',
array('plugin' => $pluginfo->component, 'rootdir' => $pluginfo->rootdir),
'plugin root folder is not removable as expected');
}
if ($plugin->get_status() === self::PLUGIN_STATUS_UPTODATE or $plugin->get_status() === self::PLUGIN_STATUS_NEW) {
$this->archive_plugin_version($plugin);
}
remove_dir($plugin->rootdir);
clearstatcache();
if (function_exists('opcache_reset')) {
opcache_reset();
}
}
/**
* Can the installation of the new plugin be cancelled?
*
......@@ -1938,16 +1965,13 @@ class core_plugin_manager {
* upgrade happens.
*
* @param string $component
* @return bool
*/
public function cancel_plugin_installation($component) {
$plugin = $this->get_plugin_info($component);
if ($this->can_cancel_plugin_installation($plugin)) {
if ($this->archive_plugin_version($plugin)) {
return remove_dir($plugin->rootdir);
}
$this->remove_plugin_folder($plugin);
}
return false;
......@@ -1974,8 +1998,7 @@ class core_plugin_manager {
* @return bool
*/
public function archive_plugin_version(\core\plugininfo\base $plugin) {
// TODO use code_manager to do it.
return true;
return $this->get_code_manager()->archive_plugin_version($plugin->rootdir, $plugin->component, $plugin->versiondisk);
}
/**
......
......@@ -24,7 +24,11 @@
namespace core\update;
use core_component;
use coding_exception;
use SplFileInfo;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
defined('MOODLE_INTERNAL') || die();
......@@ -220,6 +224,146 @@ class code_manager {
return $files;
}
/**
* Make an archive backup of the existing plugin folder.
*
* @param string $folderpath full path to the plugin folder
* @param string $targetzip full path to the zip file to be created
* @return bool true if file created, false if not
*/
public function zip_plugin_folder($folderpath, $targetzip) {
if (file_exists($targetzip)) {
throw new coding_exception('Attempting to create already existing ZIP file', $targetzip);
}
if (!is_writable(dirname($targetzip))) {
throw new coding_exception('Target ZIP location not writable', dirname($targetzip));
}
if (!is_dir($folderpath)) {
throw new coding_exception('Attempting to ZIP non-existing source directory', $folderpath);
}
$files = $this->list_plugin_folder_files($folderpath);
$fp = get_file_packer('application/zip');
return $fp->archive_to_pathname($files, $targetzip, false);
}
/**
* Archive the current plugin on-disk version.
*
* @param string $folderpath full path to the plugin folder
* @param string $component
* @param int $version
* @param bool $overwrite overwrite existing archive if found
* @return bool
*/
public function archive_plugin_version($folderpath, $component, $version, $overwrite=false) {
if ($component !== clean_param($component, PARAM_SAFEDIR)) {
// This should never happen, but just in case.
throw new moodle_exception('unexpected_plugin_component_format', 'core_plugin', '', null, $component);
}
if ((string)$version !== clean_param((string)$version, PARAM_FILE)) {
// Prevent some nasty injections via $plugin->version tricks.
throw new moodle_exception('unexpected_plugin_version_format', 'core_plugin', '', null, $version);
}
if (empty($component) or empty($version)) {
return false;
}
if (!is_dir($folderpath)) {
return false;
}
$archzip = $this->temproot.'/archive/'.$component.'/'.$version.'.zip';
if (file_exists($archzip) and !$overwrite) {
return true;
}
$tmpzip = make_request_directory().'/'.$version.'.zip';
$zipped = $this->zip_plugin_folder($folderpath, $tmpzip);
if (!$zipped) {
return false;
}
// Assert that the file looks like a valid one.
list($expectedtype, $expectedname) = core_component::normalize_component($component);
$actualname = $this->get_plugin_zip_root_dir($tmpzip);
if ($actualname !== $expectedname) {
// This should not happen.
throw new moodle_exception('unexpected_archive_structure', 'core_plugin');
}
make_writable_directory(dirname($archzip));
return rename($tmpzip, $archzip);
}
/**
* Return the path to the ZIP file with the archive of the given plugin version.
*
* @param string $component
* @param int $version
* @return string|bool false if not found, full path otherwise
*/
public function get_archived_plugin_version($component, $version) {
if (empty($component) or empty($version)) {
return false;
}
$archzip = $this->temproot.'/archive/'.$component.'/'.$version.'.zip';
if (file_exists($archzip)) {
return $archzip;
}
return false;
}
/**
* Returns list of all files in the given directory.
*
* Given a path like /full/path/to/mod/workshop, it returns array like
*
* [workshop/] => /full/path/to/mod/workshop
* [workshop/lang/] => /full/path/to/mod/workshop/lang
* [workshop/lang/workshop.php] => /full/path/to/mod/workshop/lang/workshop.php
* ...
*
* Which mathes the format used by Moodle file packers.
*
* @param string $folderpath full path to the plugin directory
* @return array (string)relpath => (string)fullpath
*/
public function list_plugin_folder_files($folderpath) {
$folder = new RecursiveDirectoryIterator($folderpath);
$iterator = new RecursiveIteratorIterator($folder);
$folderpathinfo = new SplFileInfo($folderpath);
$strip = strlen($folderpathinfo->getPathInfo()->getRealPath()) + 1;
$files = array();
foreach ($iterator as $fileinfo) {
if ($fileinfo->getFilename() === '..') {
continue;
}
if (strpos($fileinfo->getRealPath(), $folderpathinfo->getRealPath() !== 0)) {
throw new moodle_exception('unexpected_filepath_mismatch', 'core_plugin');
}
$key = substr($fileinfo->getRealPath(), $strip);
if ($fileinfo->isDir() and substr($key, -1) !== '/') {
$key .= '/';
}
$files[$key] = $fileinfo->getRealPath();
}
return $files;
}
/**
* Detects the plugin's name from its ZIP file.
*
......@@ -267,6 +411,7 @@ class code_manager {
*/
protected function init_temp_directories() {
make_writable_directory($this->temproot.'/distfiles');
make_writable_directory($this->temproot.'/archive');
}
/**
......
......@@ -170,4 +170,60 @@ class core_update_code_manager_testcase extends advanced_testcase {
$this->assertEquals('bar', $codeman->get_plugin_zip_root_dir($zipfilepath));
}
public function test_list_plugin_folder_files() {
$fixtures = __DIR__.'/fixtures/update_validator/plugindir';
$codeman = new \core\update\testable_code_manager();
$files = $codeman->list_plugin_folder_files($fixtures.'/foobar');
$this->assertInternalType('array', $files);
$this->assertEquals(6, count($files));
$this->assertEquals($files['foobar/'], $fixtures.'/foobar');
$this->assertEquals($files['foobar/lang/en/local_foobar.php'], $fixtures.'/foobar/lang/en/local_foobar.php');
}
public function test_zip_plugin_folder() {
$fixtures = __DIR__.'/fixtures/update_validator/plugindir';
$storage = make_request_directory();
$codeman = new \core\update\testable_code_manager();
$codeman->zip_plugin_folder($fixtures.'/foobar', $storage.'/foobar.zip');
$this->assertTrue(file_exists($storage.'/foobar.zip'));
$fp = get_file_packer('application/zip');
$zipfiles = $fp->list_files($storage.'/foobar.zip');
$this->assertNotEmpty($zipfiles);
foreach ($zipfiles as $zipfile) {
if ($zipfile->is_directory) {
$this->assertTrue(is_dir($fixtures.'/'.$zipfile->pathname));
} else {
$this->assertTrue(file_exists($fixtures.'/'.$zipfile->pathname));
}
}
}
public function test_archiving_plugin_version() {
$fixtures = __DIR__.'/fixtures/update_validator/plugindir';
$codeman = new \core\update\testable_code_manager();
$this->assertFalse($codeman->archive_plugin_version($fixtures.'/foobar', 'local_foobar', 0));
$this->assertFalse($codeman->archive_plugin_version($fixtures.'/foobar', 'local_foobar', null));
$this->assertFalse($codeman->archive_plugin_version($fixtures.'/foobar', '', 2015100900));
$this->assertFalse($codeman->archive_plugin_version($fixtures.'/foobar-does-not-exist', 'local_foobar', 2013031900));
$this->assertFalse($codeman->get_archived_plugin_version('local_foobar', 2013031900));
$this->assertFalse($codeman->get_archived_plugin_version('mod_foobar', 2013031900));
$this->assertTrue($codeman->archive_plugin_version($fixtures.'/foobar', 'local_foobar', 2013031900, true));
$this->assertNotFalse($codeman->get_archived_plugin_version('local_foobar', 2013031900));
$this->assertTrue(file_exists($codeman->get_archived_plugin_version('local_foobar', 2013031900)));
$this->assertTrue(file_exists($codeman->get_archived_plugin_version('local_foobar', '2013031900')));
$this->assertFalse($codeman->get_archived_plugin_version('mod_foobar', 2013031900));
$this->assertFalse($codeman->get_archived_plugin_version('local_foobar', 2013031901));
$this->assertFalse($codeman->get_archived_plugin_version('', 2013031901));
$this->assertFalse($codeman->get_archived_plugin_version('local_foobar', ''));
$this->assertTrue($codeman->archive_plugin_version($fixtures.'/foobar', 'local_foobar', '2013031900'));
$this->assertTrue(file_exists($codeman->get_archived_plugin_version('local_foobar', 2013031900)));
}
}
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment