MDL-49329 admin: Archive plugin code before removing it from dirroot
authorDavid Mudrák <david@moodle.com>
Fri, 9 Oct 2015 11:44:57 +0000 (13:44 +0200)
committerDavid Mudrák <david@moodle.com>
Fri, 9 Oct 2015 12:33:47 +0000 (14:33 +0200)
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.

admin/plugins.php
lib/classes/plugin_manager.php
lib/classes/update/code_manager.php
lib/tests/update_code_manager_test.php

index ae255e2..9c9e2bc 100644 (file)
@@ -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'));
 }
index 222fbf3..8b094a6 100644 (file)
@@ -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);
     }
 
     /**
index 383890b..4b6f432 100644 (file)
 
 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');
     }
 
     /**
index ec9401f..6e1091a 100644 (file)
@@ -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)));
+
+    }
 }