Skip to content

Commit

Permalink
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
mudrd8mz committed Oct 9, 2015
1 parent 4d7528f commit a2e1e0d
Show file tree
Hide file tree
Showing 4 changed files with 237 additions and 23 deletions.
14 changes: 2 additions & 12 deletions admin/plugins.php
Expand Up @@ -148,13 +148,6 @@
'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', '',
Expand All @@ -163,11 +156,8 @@
}

// 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'));
}
Expand Down
45 changes: 34 additions & 11 deletions lib/classes/plugin_manager.php
Expand Up @@ -1341,11 +1341,7 @@ public function install_plugins(array $plugins, $confirmed, $silent) {
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'));
Expand Down Expand Up @@ -1911,6 +1907,37 @@ public static function standard_plugins_list($type) {
}
}

/**
* 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?
*
Expand Down Expand Up @@ -1938,16 +1965,13 @@ public function can_cancel_plugin_installation(\core\plugininfo\base $plugin) {
* 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;
Expand All @@ -1974,8 +1998,7 @@ public function cancel_all_plugin_installations() {
* @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);
}

/**
Expand Down
145 changes: 145 additions & 0 deletions lib/classes/update/code_manager.php
Expand Up @@ -24,7 +24,11 @@

namespace core\update;

use core_component;
use coding_exception;
use SplFileInfo;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;

defined('MOODLE_INTERNAL') || die();

Expand Down Expand Up @@ -220,6 +224,146 @@ public function unzip_plugin_file($zipfilepath, $targetdir, $rootdir = '') {
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.
*
Expand Down Expand Up @@ -267,6 +411,7 @@ public function get_plugin_zip_root_dir($zipfilepath) {
*/
protected function init_temp_directories() {
make_writable_directory($this->temproot.'/distfiles');
make_writable_directory($this->temproot.'/archive');
}

/**
Expand Down
56 changes: 56 additions & 0 deletions lib/tests/update_code_manager_test.php
Expand Up @@ -170,4 +170,60 @@ public function test_get_plugin_zip_root_dir() {
$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)));

}
}

0 comments on commit a2e1e0d

Please sign in to comment.