diff --git a/CHANGELOG.md b/CHANGELOG.md
index 90bc9d3cb518d0522bcd99b0a3b00dbe330f6be4..ba91000bc509cf2ad9e128eca77d48f26de17748 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -27,6 +27,7 @@ This is a changelog for Piwik platform developers. All changes for our HTTP API'
 
 ### Deprecations
 * `API` classes should no longer have a protected constructor. Classes with a protected constructor will generate a notice in the logs and should expose a public constructor instead.
+* Update classes should not declare static `getSql()` and `update()` methods anymore. It is still supported to use those, but developers should instead override the `Updates::getMigrationQueries()` and `Updates::doUpdate()` instance methods.
 
 ### New features
 * `API` classes can now use dependency injection in their constructor to inject other instances.
diff --git a/core/Columns/Updater.php b/core/Columns/Updater.php
index 4272f46f566c88ac161106515371068bac6153fb..e1345a2dc2bb80ef1e06657dcc0589002fedaf35 100644
--- a/core/Columns/Updater.php
+++ b/core/Columns/Updater.php
@@ -7,6 +7,7 @@
  *
  */
 namespace Piwik\Columns;
+
 use Piwik\Common;
 use Piwik\DbHelper;
 use Piwik\Plugin\Dimension\ActionDimension;
@@ -25,86 +26,78 @@ class Updater extends \Piwik\Updates
     private static $cacheId = 'AllDimensionModifyTime';
 
     /**
-     * @var Updater
+     * @var VisitDimension[]
      */
-    private static $updater;
+    public $visitDimensions;
 
     /**
-     * Return SQL to be executed in this update
-     *
-     * @return array(
-     *              'ALTER .... ' => '1234', // if the query fails, it will be ignored if the error code is 1234
-     *              'ALTER .... ' => false,  // if an error occurs, the update will stop and fail
-     *                                       // and user will have to manually run the query
-     *         )
+     * @var ActionDimension[]
      */
-    public static function getSql()
+    private $actionDimensions;
+
+    /**
+     * @var ConversionDimension[]
+     */
+    private $conversionDimensions;
+
+    /**
+     * @param VisitDimension[]|null $visitDimensions
+     * @param ActionDimension[]|null $actionDimensions
+     * @param ConversionDimension[]|null $conversionDimensions
+     */
+    public function __construct(array $visitDimensions = null, array $actionDimensions = null, array $conversionDimensions = null)
+    {
+        $this->visitDimensions = $visitDimensions === null ? VisitDimension::getAllDimensions() : $visitDimensions;
+        $this->actionDimensions = $actionDimensions === null ? ActionDimension::getAllDimensions() : $actionDimensions;
+        $this->conversionDimensions = $conversionDimensions === null ? ConversionDimension::getAllDimensions() : $conversionDimensions;
+    }
+
+    public function getMigrationQueries(PiwikUpdater $updater)
     {
         $sqls = array();
 
-        $changingColumns = self::getUpdates();
+        $changingColumns = $this->getUpdates($updater);
 
         foreach ($changingColumns as $table => $columns) {
             if (empty($columns) || !is_array($columns)) {
                 continue;
             }
 
-            $sqls["ALTER TABLE `" . Common::prefixTable($table) . "` " . implode(', ', $columns)] = false;
+            $sqls["ALTER TABLE `" . Common::prefixTable($table) . "` " . implode(', ', $columns)] = array('1091', '1060');
         }
 
         return $sqls;
     }
 
-    /**
-     * Incremental version update
-     */
-    public static function update()
-    {
-        foreach (self::getSql() as $sql => $errorCode) {
-            try {
-                Db::exec($sql);
-            } catch (\Exception $e) {
-                if (!Db::get()->isErrNo($e, '1091') && !Db::get()->isErrNo($e, '1060')) {
-                    PiwikUpdater::handleQueryError($e, $sql, false, __FILE__);
-                }
-            }
-        }
-    }
-
-    public static function setUpdater($updater)
+    public function doUpdate(PiwikUpdater $updater)
     {
-        self::$updater = $updater;
+        $updater->executeMigrationQueries(__FILE__, $this->getMigrationQueries($updater));
     }
 
-    private static function hasComponentNewVersion($component)
-    {
-        return empty(self::$updater) || self::$updater->hasNewVersion($component);
-    }
-
-    private static function getUpdates()
+    private function getUpdates(PiwikUpdater $updater)
     {
         $visitColumns      = DbHelper::getTableColumns(Common::prefixTable('log_visit'));
         $actionColumns     = DbHelper::getTableColumns(Common::prefixTable('log_link_visit_action'));
         $conversionColumns = DbHelper::getTableColumns(Common::prefixTable('log_conversion'));
 
-        $changingColumns = array();
+        $allUpdatesToRun = array();
 
-        foreach (self::getVisitDimensions() as $dimension) {
-            $updates         = self::getUpdatesForDimension($dimension, 'log_visit.', $visitColumns, $conversionColumns);
-            $changingColumns = self::mixinUpdates($changingColumns, $updates);
+        foreach ($this->visitDimensions as $dimension) {
+            $updates         = $this->getUpdatesForDimension($updater, $dimension, 'log_visit.', $visitColumns, $conversionColumns);
+            $allUpdatesToRun = $this->mixinUpdates($allUpdatesToRun, $updates);
         }
 
-        foreach (self::getActionDimensions() as $dimension) {
-            $updates         = self::getUpdatesForDimension($dimension, 'log_link_visit_action.', $actionColumns);
-            $changingColumns = self::mixinUpdates($changingColumns, $updates);
+        foreach ($this->actionDimensions as $dimension) {
+            $updates         = $this->getUpdatesForDimension($updater, $dimension, 'log_link_visit_action.', $actionColumns);
+            $allUpdatesToRun = $this->mixinUpdates($allUpdatesToRun, $updates);
         }
 
-        foreach (self::getConversionDimensions() as $dimension) {
-            $updates         = self::getUpdatesForDimension($dimension, 'log_conversion.', $conversionColumns);
-            $changingColumns = self::mixinUpdates($changingColumns, $updates);
+        foreach ($this->conversionDimensions as $dimension) {
+            $updates         = $this->getUpdatesForDimension($updater, $dimension, 'log_conversion.', $conversionColumns);
+            $allUpdatesToRun = $this->mixinUpdates($allUpdatesToRun, $updates);
         }
 
-        return $changingColumns;
+        return $allUpdatesToRun;
     }
 
     /**
@@ -114,12 +107,12 @@ class Updater extends \Piwik\Updates
      * @param array $conversionColumns
      * @return array
      */
-    private static function getUpdatesForDimension($dimension, $componentPrefix, $existingColumnsInDb, $conversionColumns = array())
+    private function getUpdatesForDimension(PiwikUpdater $updater, $dimension, $componentPrefix, $existingColumnsInDb, $conversionColumns = array())
     {
         $column = $dimension->getColumnName();
         $componentName = $componentPrefix . $column;
 
-        if (!self::hasComponentNewVersion($componentName)) {
+        if (!$updater->hasNewVersion($componentName)) {
             return array();
         }
 
@@ -136,22 +129,22 @@ class Updater extends \Piwik\Updates
         return $sqlUpdates;
     }
 
-    private static function mixinUpdates($changingColumns, $updatesFromDimension)
+    private function mixinUpdates($allUpdatesToRun, $updatesFromDimension)
     {
         if (!empty($updatesFromDimension)) {
             foreach ($updatesFromDimension as $table => $col) {
-                if (empty($changingColumns[$table])) {
-                    $changingColumns[$table] = $col;
+                if (empty($allUpdatesToRun[$table])) {
+                    $allUpdatesToRun[$table] = $col;
                 } else {
-                    $changingColumns[$table] = array_merge($changingColumns[$table], $col);
+                    $allUpdatesToRun[$table] = array_merge($allUpdatesToRun[$table], $col);
                 }
             }
         }
 
-        return $changingColumns;
+        return $allUpdatesToRun;
     }
 
-    public static function getAllVersions()
+    public function getAllVersions(PiwikUpdater $updater)
     {
         // to avoid having to load all dimensions on each request we check if there were any changes on the file system
         // can easily save > 100ms for each request
@@ -169,16 +162,16 @@ class Updater extends \Piwik\Updates
         $actionColumns     = DbHelper::getTableColumns(Common::prefixTable('log_link_visit_action'));
         $conversionColumns = DbHelper::getTableColumns(Common::prefixTable('log_conversion'));
 
-        foreach (self::getVisitDimensions() as $dimension) {
-            $versions = self::mixinVersions($dimension, 'log_visit.', $visitColumns, $versions);
+        foreach ($this->visitDimensions as $dimension) {
+            $versions = $this->mixinVersions($updater, $dimension, 'log_visit.', $visitColumns, $versions);
         }
 
-        foreach (self::getActionDimensions() as $dimension) {
-            $versions = self::mixinVersions($dimension, 'log_link_visit_action.', $actionColumns, $versions);
+        foreach ($this->actionDimensions as $dimension) {
+            $versions = $this->mixinVersions($updater, $dimension, 'log_link_visit_action.', $actionColumns, $versions);
         }
 
-        foreach (self::getConversionDimensions() as $dimension) {
-            $versions = self::mixinVersions($dimension, 'log_conversion.', $conversionColumns, $versions);
+        foreach ($this->conversionDimensions as $dimension) {
+            $versions = $this->mixinVersions($updater, $dimension, 'log_conversion.', $conversionColumns, $versions);
         }
 
         return $versions;
@@ -191,10 +184,11 @@ class Updater extends \Piwik\Updates
      * @param array $versions
      * @return array The modified versions array
      */
-    private static function mixinVersions($dimension, $componentPrefix, $columns, $versions)
+    private function mixinVersions(PiwikUpdater $updater, $dimension, $componentPrefix, $columns, $versions)
     {
         $columnName = $dimension->getColumnName();
 
+        // dimensions w/o columns do not need DB updates
         if (!$columnName || !$dimension->hasColumnType()) {
             return $versions;
         }
@@ -202,10 +196,15 @@ class Updater extends \Piwik\Updates
         $component = $componentPrefix . $columnName;
         $version   = $dimension->getVersion();
 
+        // if the column exists in the table, but has no associated version, and was one of the core columns
+        // that was moved when the dimension refactor took place, then:
+        // - set the installed version in the DB to the current code version
+        // - and do not check for updates since we just set the version to the latest
         if (array_key_exists($columnName, $columns)
-            && false === PiwikUpdater::getCurrentRecordedComponentVersion($component)
-            && self::wasDimensionMovedFromCoreToPlugin($component, $version)) {
-            PiwikUpdater::recordComponentSuccessfullyUpdated($component, $version);
+            && false === $updater->getCurrentComponentVersion($component)
+            && self::wasDimensionMovedFromCoreToPlugin($component, $version)
+        ) {
+            $updater->markComponentSuccessfullyUpdated($component, $version);
             return $versions;
         }
 
@@ -224,7 +223,10 @@ class Updater extends \Piwik\Updates
 
     public static function wasDimensionMovedFromCoreToPlugin($name, $version)
     {
-        $dimensions = array (
+        // maps names of core dimension columns that were part of the original dimension refactor with their
+        // initial "version" strings. The '1' that is sometimes appended to the end of the string (sometimes seen as
+        // NULL1) is from individual dimension "versioning" logic (eg, see VisitDimension::getVersion())
+        $initialCoreDimensionVersions = array (
             'log_visit.config_resolution' => 'VARCHAR(9) NOT NULL',
             'log_visit.config_device_brand' => 'VARCHAR( 100 ) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL',
             'log_visit.config_device_model' => 'VARCHAR( 100 ) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL',
@@ -285,14 +287,14 @@ class Updater extends \Piwik\Updates
             'log_conversion.revenue_tax' => 'float default NULL',
         );
 
-        if (!array_key_exists($name, $dimensions)) {
+        if (!array_key_exists($name, $initialCoreDimensionVersions)) {
             return false;
         }
 
-        return strtolower($dimensions[$name]) === strtolower($version);
+        return strtolower($initialCoreDimensionVersions[$name]) === strtolower($version);
     }
 
-    public static function onNoUpdateAvailable($versionsThatWereChecked)
+    public function onNoUpdateAvailable($versionsThatWereChecked)
     {
         if (!empty($versionsThatWereChecked)) {
             // invalidate cache only if there were actually file changes before, otherwise we write the cache on each
@@ -337,25 +339,4 @@ class Updater extends \Piwik\Updates
 
         return array();
     }
-
-    private static function getVisitDimensions()
-    {
-        return VisitDimension::getAllDimensions();
-    }
-
-    /**
-     * @return mixed|Dimension[]
-     */
-    private static function getActionDimensions()
-    {
-        return ActionDimension::getAllDimensions();
-    }
-
-    /**
-     * @return mixed|Dimension[]
-     */
-    private static function getConversionDimensions()
-    {
-        return ConversionDimension::getAllDimensions();
-    }
-}
+}
\ No newline at end of file
diff --git a/core/DbHelper.php b/core/DbHelper.php
index 0c5ed7905973ca7f37f325c962b45350bcf63858..fdc1597cf69be2b1ad4186b8088588c9ee245f3e 100644
--- a/core/DbHelper.php
+++ b/core/DbHelper.php
@@ -166,7 +166,7 @@ class DbHelper
     /**
      * Get the SQL to create a specific Piwik table
      *
-     * @param string $tableName
+     * @param string $tableName Unprefixed table name.
      * @return string  SQL
      */
     public static function getTableCreateSql($tableName)
diff --git a/core/FrontController.php b/core/FrontController.php
index 7c3740d93d7d0253e39ed216c91a50cbe0e7e24b..f9dd3259cb14dd420b26e03cb8c762903c5227f5 100644
--- a/core/FrontController.php
+++ b/core/FrontController.php
@@ -14,6 +14,7 @@ use Piwik\API\Request;
 use Piwik\API\ResponseBuilder;
 use Piwik\Container\StaticContainer;
 use Piwik\Exception\AuthenticationFailedException;
+use Piwik\Exception\DatabaseSchemaIsNewerThanCodebaseException;
 use Piwik\Http\ControllerResolver;
 use Piwik\Http\Router;
 use Piwik\Plugin\Report;
@@ -318,7 +319,7 @@ class FrontController extends Singleton
          */
         Piwik::postEvent('Request.dispatchCoreAndPluginUpdatesScreen');
 
-        Updater::throwIfPiwikVersionIsOlderThanDBSchema();
+        $this->throwIfPiwikVersionIsOlderThanDBSchema();
 
         \Piwik\Plugin\Manager::getInstance()->installLoadedPlugins();
 
@@ -556,4 +557,23 @@ class FrontController extends Singleton
         return $result;
     }
 
+    /**
+     * This method ensures that Piwik Platform cannot be running when using a NEWER database.
+     */
+    private function throwIfPiwikVersionIsOlderThanDBSchema()
+    {
+        $updater = new Updater();
+
+        $dbSchemaVersion = $updater->getCurrentComponentVersion('core');
+        $current = Version::VERSION;
+        if (-1 === version_compare($current, $dbSchemaVersion)) {
+            $messages = array(
+                Piwik::translate('General_ExceptionDatabaseVersionNewerThanCodebase', array($current, $dbSchemaVersion)),
+                Piwik::translate('General_ExceptionDatabaseVersionNewerThanCodebaseWait'),
+                // we cannot fill in the Super User emails as we are failing before Authentication was ready
+                Piwik::translate('General_ExceptionContactSupportGeneric', array('', ''))
+            );
+            throw new DatabaseSchemaIsNewerThanCodebaseException(implode(" ", $messages));
+        }
+    }
 }
diff --git a/core/Plugin/Manager.php b/core/Plugin/Manager.php
index fb11f0a6640e0fb5357abc403ec34a72b37c28dd..22eb0296d1e8f8340e79c108f0015f7eebe59851 100644
--- a/core/Plugin/Manager.php
+++ b/core/Plugin/Manager.php
@@ -1048,7 +1048,8 @@ class Manager extends Singleton
             $this->executePluginInstall($plugin);
             $pluginsInstalled[] = $pluginName;
             $this->updatePluginsInstalledConfig($pluginsInstalled);
-            Updater::recordComponentSuccessfullyUpdated($plugin->getPluginName(), $plugin->getVersion());
+            $updater = new Updater();
+            $updater->markComponentSuccessfullyUpdated($plugin->getPluginName(), $plugin->getVersion());
             $saveConfig = true;
         }
 
diff --git a/core/Updater.php b/core/Updater.php
index c9872d34817795d7b448b079e4341f4a2b9b990c..bccddff3f22084b647c9eb3c82d9d268a45d2cd0 100644
--- a/core/Updater.php
+++ b/core/Updater.php
@@ -7,8 +7,12 @@
  *
  */
 namespace Piwik;
+
 use Piwik\Columns\Updater as ColumnUpdater;
+use Piwik\Container\StaticContainer;
 use Piwik\Exception\DatabaseSchemaIsNewerThanCodebaseException;
+use Piwik\Updater\UpdateObserver;
+use Zend_Db_Exception;
 
 /**
  * Load and execute all relevant, incremental update scripts for Piwik core and plugins, and bump the component version numbers for completed updates.
@@ -19,41 +23,66 @@ class Updater
     const INDEX_CURRENT_VERSION = 0;
     const INDEX_NEW_VERSION = 1;
 
-    public $pathUpdateFileCore;
-    public $pathUpdateFilePlugins;
-    private $componentsToCheck = array();
+    private $pathUpdateFileCore;
+    private $pathUpdateFilePlugins;
     private $hasMajorDbUpdate = false;
     private $updatedClasses = array();
+    private $componentsWithNewVersion = array();
+    private $componentsWithUpdateFile = array();
+
+    /**
+     * @var UpdateObserver[]
+     */
+    private $updateObservers = array();
+
+    /**
+     * @var Columns\Updater
+     */
+    private $columnsUpdater;
+
+    /**
+     * Currently used Updater instance, set on construction. This instance is used to provide backwards
+     * compatibility w/ old code that may use the deprecated static methods in Updates.
+     *
+     * @var Updater
+     */
+    private static $activeInstance;
 
     /**
-     * Constructor
+     * Constructor.
+     *
+     * @param string|null $pathUpdateFileCore The path to core Update files.
+     * @param string|null $pathUpdateFilePlugins The path to plugin update files. Should contain a `'%s'` placeholder
+     *                                           for the plugin name.
+     * @param Columns\Updater|null $columnsUpdater The dimensions updater instance.
      */
-    public function __construct()
+    public function __construct($pathUpdateFileCore = null, $pathUpdateFilePlugins = null, Columns\Updater $columnsUpdater = null)
     {
-        $this->pathUpdateFileCore = PIWIK_INCLUDE_PATH . '/core/Updates/';
-        $this->pathUpdateFilePlugins = PIWIK_INCLUDE_PATH . '/plugins/%s/Updates/';
+        $this->pathUpdateFileCore = $pathUpdateFileCore ?: PIWIK_INCLUDE_PATH . '/core/Updates/';
+        $this->pathUpdateFilePlugins = $pathUpdateFilePlugins ?: PIWIK_INCLUDE_PATH . '/plugins/%s/Updates/';
+        $this->columnsUpdater = $columnsUpdater ?: new Columns\Updater();
 
-        ColumnUpdater::setUpdater($this);
+        self::$activeInstance = $this;
     }
 
     /**
-     * Add component to check
+     * Adds an UpdateObserver to the internal list of listeners.
      *
-     * @param string $name
-     * @param string $version
+     * @param UpdateObserver $listener
      */
-    public function addComponentToCheck($name, $version)
+    public function addUpdateObserver(UpdateObserver $listener)
     {
-        $this->componentsToCheck[$name] = $version;
+        $this->updateObservers[] = $listener;
     }
 
     /**
-     * Record version of successfully completed component update
+     * Marks a component as successfully updated to a specific version in the database. Sets an option
+     * that looks like `"version_$componentName"`.
      *
-     * @param string $name
-     * @param string $version
+     * @param string $name The component name. Eg, a plugin name, `'core'` or dimension column name.
+     * @param string $version The component version (should use semantic versioning).
      */
-    public static function recordComponentSuccessfullyUpdated($name, $version)
+    public function markComponentSuccessfullyUpdated($name, $version)
     {
         try {
             Option::set(self::getNameInOptionTable($name), $version, $autoLoad = 1);
@@ -63,12 +92,13 @@ class Updater
     }
 
     /**
-     * Retrieve the current version of a recorded component
-     * @param string $name
-     * @return false|string
+     * Returns the currently installed version of a Piwik component.
+     *
+     * @param string $name The component name. Eg, a plugin name, `'core'` or dimension column name.
+     * @return string A semantic version.
      * @throws \Exception
      */
-    public static function getCurrentRecordedComponentVersion($name)
+    public function getCurrentComponentVersion($name)
     {
         try {
             $currentVersion = Option::get(self::getNameInOptionTable($name));
@@ -86,44 +116,19 @@ class Updater
         return $currentVersion;
     }
 
-    /**
-     * Returns the flag name to use in the option table to record current schema version
-     * @param string $name
-     * @return string
-     */
-    private static function getNameInOptionTable($name)
-    {
-        return 'version_' . $name;
-    }
-
-
-    /**
-     * This method ensures that Piwik Platform cannot be running when using a NEWER database
-     */
-    public static function throwIfPiwikVersionIsOlderThanDBSchema()
-    {
-        $dbSchemaVersion = self::getCurrentRecordedComponentVersion('core');
-        $current = Version::VERSION;
-        if(-1 === version_compare($current, $dbSchemaVersion)) {
-            $messages = array(
-                Piwik::translate('General_ExceptionDatabaseVersionNewerThanCodebase', array($current, $dbSchemaVersion)),
-                Piwik::translate('General_ExceptionDatabaseVersionNewerThanCodebaseWait'),
-                // we cannot fill in the Super User emails as we are failing before Authentication was ready
-                Piwik::translate('General_ExceptionContactSupportGeneric', array('', ''))
-            );
-            throw new DatabaseSchemaIsNewerThanCodebaseException(implode(" ", $messages));
-        }
-    }
-
-
     /**
      * Returns a list of components (core | plugin) that need to run through the upgrade process.
      *
+     * @param string[] $componentsToCheck An array mapping component names to the latest locally available version.
+     *                                    If the version is later than the currently installed version, the component
+     *                                    must be upgraded.
+     *
+     *                                    Example: `array('core' => '2.11.0')`
      * @return array( componentName => array( file1 => version1, [...]), [...])
      */
-    public function getComponentsWithUpdateFile()
+    public function getComponentsWithUpdateFile($componentsToCheck)
     {
-        $this->componentsWithNewVersion = $this->getComponentsWithNewVersion();
+        $this->componentsWithNewVersion = $this->getComponentsWithNewVersion($componentsToCheck);
         $this->componentsWithUpdateFile = $this->loadComponentsWithUpdateFile();
         return $this->componentsWithUpdateFile;
     }
@@ -136,8 +141,7 @@ class Updater
      */
     public function hasNewVersion($componentName)
     {
-        return isset($this->componentsWithNewVersion) &&
-        isset($this->componentsWithNewVersion[$componentName]);
+        return isset($this->componentsWithNewVersion[$componentName]);
     }
 
     /**
@@ -177,7 +181,8 @@ class Updater
 
                 $classNames[] = $className;
 
-                $queriesForComponent = call_user_func(array($className, 'getSql'));
+                $update = StaticContainer::getContainer()->make($className);
+                $queriesForComponent = call_user_func(array($update, 'getMigrationQueries'), $this);
                 foreach ($queriesForComponent as $query => $error) {
                     $queries[] = $query . ';';
                 }
@@ -214,29 +219,45 @@ class Updater
     {
         $warningMessages = array();
 
+        $this->executeListenerHook('onComponentUpdateStarting', array($componentName));
+
         foreach ($this->componentsWithUpdateFile[$componentName] as $file => $fileVersion) {
             try {
                 require_once $file; // prefixed by PIWIK_INCLUDE_PATH
 
                 $className = $this->getUpdateClassName($componentName, $fileVersion);
-                if (!in_array($className, $this->updatedClasses) && class_exists($className, false)) {
-                    // update()
-                    call_user_func(array($className, 'update'));
+                if (!in_array($className, $this->updatedClasses)
+                    && class_exists($className, false)
+                ) {
+                    $this->executeListenerHook('onComponentUpdateFileStarting', array($componentName, $file, $className, $fileVersion));
+
+                    $this->executeSingleUpdateClass($className);
+
+                    $this->executeListenerHook('onComponentUpdateFileFinished', array($componentName, $file, $className, $fileVersion));
+
                     // makes sure to call Piwik\Columns\Updater only once as one call updates all dimensions at the same
                     // time for better performance
                     $this->updatedClasses[] = $className;
                 }
 
-                self::recordComponentSuccessfullyUpdated($componentName, $fileVersion);
+                $this->markComponentSuccessfullyUpdated($componentName, $fileVersion);
             } catch (UpdaterErrorException $e) {
+                $this->executeListenerHook('onError', array($componentName, $fileVersion, $e));
+
                 throw $e;
             } catch (\Exception $e) {
                 $warningMessages[] = $e->getMessage();
+
+                $this->executeListenerHook('onWarning', array($componentName, $fileVersion, $e));
             }
         }
 
-        // to debug, create core/Updates/X.php, update the core/Version.php, throw an Exception in the try, and comment the following line
-        self::recordComponentSuccessfullyUpdated($componentName, $this->componentsWithNewVersion[$componentName][self::INDEX_NEW_VERSION]);
+        // to debug, create core/Updates/X.php, update the core/Version.php, throw an Exception in the try, and comment the following lines
+        $updatedVersion = $this->componentsWithNewVersion[$componentName][self::INDEX_NEW_VERSION];
+        $this->markComponentSuccessfullyUpdated($componentName, $updatedVersion);
+
+        $this->executeListenerHook('onComponentUpdateFinished', array($componentName, $updatedVersion, $warningMessages));
+
         return $warningMessages;
     }
 
@@ -284,7 +305,7 @@ class Updater
                 uasort($componentsWithUpdateFile[$name], "version_compare");
             } else {
                 // there are no update file => nothing to do, update to the new version is successful
-                self::recordComponentSuccessfullyUpdated($name, $newVersion);
+                $this->markComponentSuccessfullyUpdated($name, $newVersion);
             }
         }
 
@@ -294,29 +315,34 @@ class Updater
     /**
      * Construct list of outdated components
      *
+     * @param string[] $componentsToCheck An array mapping component names to the latest locally available version.
+     *                                    If the version is later than the currently installed version, the component
+     *                                    must be upgraded.
+     *
+     *                                    Example: `array('core' => '2.11.0')`
      * @throws \Exception
      * @return array array( componentName => array( oldVersion, newVersion), [...])
      */
-    public function getComponentsWithNewVersion()
+    public function getComponentsWithNewVersion($componentsToCheck)
     {
         $componentsToUpdate = array();
 
         // we make sure core updates are processed before any plugin updates
-        if (isset($this->componentsToCheck['core'])) {
-            $coreVersions = $this->componentsToCheck['core'];
-            unset($this->componentsToCheck['core']);
-            $this->componentsToCheck = array_merge(array('core' => $coreVersions), $this->componentsToCheck);
+        if (isset($componentsToCheck['core'])) {
+            $coreVersions = $componentsToCheck['core'];
+            unset($componentsToCheck['core']);
+            $componentsToCheck = array_merge(array('core' => $coreVersions), $componentsToCheck);
         }
 
-        $recordedCoreVersion = self::getCurrentRecordedComponentVersion('core');
-        if ($recordedCoreVersion === false) {
+        $recordedCoreVersion = $this->getCurrentComponentVersion('core');
+        if (empty($recordedCoreVersion)) {
             // This should not happen
             $recordedCoreVersion = Version::VERSION;
-            self::recordComponentSuccessfullyUpdated('core', $recordedCoreVersion);
+            $this->markComponentSuccessfullyUpdated('core', $recordedCoreVersion);
         }
 
-        foreach ($this->componentsToCheck as $name => $version) {
-            $currentVersion = self::getCurrentRecordedComponentVersion($name);
+        foreach ($componentsToCheck as $name => $version) {
+            $currentVersion = $this->getCurrentComponentVersion($name);
 
             if (ColumnUpdater::isDimensionComponent($name)) {
                 $isComponentOutdated = $currentVersion !== $version;
@@ -332,9 +358,192 @@ class Updater
                 );
             }
         }
+
         return $componentsToUpdate;
     }
 
+    /**
+     * Updates multiple components, while capturing & returning errors and warnings.
+     *
+     * @param string[] $componentsWithUpdateFile Component names mapped with arrays of update files. Same structure
+     *                                           as the result of `getComponentsWithUpdateFile()`.
+     * @return array Information about the update process, including:
+     *
+     *               * **warnings**: The list of warnings that occurred during the update process.
+     *               * **errors**: The list of updater exceptions thrown during individual component updates.
+     *               * **coreError**: True if an exception was thrown while updating core.
+     *               * **deactivatedPlugins**: The list of plugins that were deactivated due to an error in the
+     *                                         update process.
+     */
+    public function updateComponents($componentsWithUpdateFile)
+    {
+        $warnings = array();
+        $errors   = array();
+        $deactivatedPlugins = array();
+        $coreError = false;
+
+        if (!empty($componentsWithUpdateFile)) {
+            $currentAccess      = Access::getInstance();
+            $hasSuperUserAccess = $currentAccess->hasSuperUserAccess();
+
+            if (!$hasSuperUserAccess) {
+                $currentAccess->setSuperUserAccess(true);
+            }
+
+            // if error in any core update, show message + help message + EXIT
+            // if errors in any plugins updates, show them on screen, disable plugins that errored + CONTINUE
+            // if warning in any core update or in any plugins update, show message + CONTINUE
+            // if no error or warning, success message + CONTINUE
+            foreach ($componentsWithUpdateFile as $name => $filenames) {
+                try {
+                    $warnings = array_merge($warnings, $this->update($name));
+                } catch (UpdaterErrorException $e) {
+                    $errors[] = $e->getMessage();
+                    if ($name == 'core') {
+                        $coreError = true;
+                        break;
+                    } elseif (\Piwik\Plugin\Manager::getInstance()->isPluginActivated($name)) {
+                        \Piwik\Plugin\Manager::getInstance()->deactivatePlugin($name);
+                        $deactivatedPlugins[] = $name;
+                    }
+                }
+            }
+
+            if (!$hasSuperUserAccess) {
+                $currentAccess->setSuperUserAccess(false);
+            }
+        }
+
+        Filesystem::deleteAllCacheOnUpdate();
+
+        $result = array(
+            'warnings'  => $warnings,
+            'errors'    => $errors,
+            'coreError' => $coreError,
+            'deactivatedPlugins' => $deactivatedPlugins
+        );
+
+        /**
+         * Triggered after Piwik has been updated.
+         */
+        Piwik::postEvent('CoreUpdater.update.end');
+
+        return $result;
+    }
+
+    /**
+     * Returns any updates that should occur for core and all plugins that are both loaded and
+     * installed. Also includes updates required for dimensions.
+     *
+     * @return string[]|null Returns the result of `getComponentsWithUpdateFile()`.
+     */
+    public function getComponentUpdates()
+    {
+        $componentsToCheck = array(
+            'core' => Version::VERSION
+        );
+
+        $manager = \Piwik\Plugin\Manager::getInstance();
+        $plugins = $manager->getLoadedPlugins();
+        foreach ($plugins as $pluginName => $plugin) {
+            if ($manager->isPluginInstalled($pluginName)) {
+                $componentsToCheck[$pluginName] = $plugin->getVersion();
+            }
+        }
+
+        $columnsVersions = $this->columnsUpdater->getAllVersions($this);
+        foreach ($columnsVersions as $component => $version) {
+            $componentsToCheck[$component] = $version;
+        }
+
+        $componentsWithUpdateFile = $this->getComponentsWithUpdateFile($componentsToCheck);
+
+        if (count($componentsWithUpdateFile) == 0) {
+            $this->columnsUpdater->onNoUpdateAvailable($columnsVersions);
+
+            if (!$this->hasNewVersion('core')) {
+                return null;
+            }
+        }
+
+        return $componentsWithUpdateFile;
+    }
+
+    /**
+     * Execute multiple migration queries from a single Update file.
+     *
+     * @param string $file The path to the Updates file.
+     * @param array $migrationQueries An array mapping SQL queries w/ one or more MySQL errors to ignore.
+     */
+    public function executeMigrationQueries($file, $migrationQueries)
+    {
+        foreach ($migrationQueries as $update => $ignoreError) {
+            $this->executeSingleMigrationQuery($update, $ignoreError, $file);
+        }
+    }
+
+    /**
+     * Execute a single migration query from an update file.
+     *
+     * @param string $migrationQuerySql The SQL to execute.
+     * @param int|int[]|null An optional error code or list of error codes to ignore.
+     * @param string $file The path to the Updates file.
+     */
+    public function executeSingleMigrationQuery($migrationQuerySql, $errorToIgnore, $file)
+    {
+        try {
+            $this->executeListenerHook('onStartExecutingMigrationQuery', array($file, $migrationQuerySql));
+
+            Db::exec($migrationQuerySql);
+        } catch (\Exception $e) {
+            $this->handleUpdateQueryError($e, $migrationQuerySql, $errorToIgnore, $file);
+        }
+
+        $this->executeListenerHook('onFinishedExecutingMigrationQuery', array($file, $migrationQuerySql));
+    }
+
+    /**
+     * Handle an update query error.
+     *
+     * @param \Exception $e The error that occurred.
+     * @param string $updateSql The SQL that was executed.
+     * @param int|int[]|null An optional error code or list of error codes to ignore.
+     * @param string $file The path to the Updates file.
+     * @throws \Exception
+     */
+    public function handleUpdateQueryError(\Exception $e, $updateSql, $errorToIgnore, $file)
+    {
+        if (($errorToIgnore === false)
+            || !self::isDbErrorOneOf($e, $errorToIgnore)
+        ) {
+            $message = $file . ":\nError trying to execute the query '" . $updateSql . "'.\nThe error was: " . $e->getMessage();
+            throw new UpdaterErrorException($message);
+        }
+    }
+
+    private function executeListenerHook($hookName, $arguments)
+    {
+        foreach ($this->updateObservers as $listener) {
+            call_user_func_array(array($listener, $hookName), $arguments);
+        }
+    }
+
+    private function executeSingleUpdateClass($className)
+    {
+        $update = StaticContainer::getContainer()->make($className);
+        try {
+            call_user_func(array($update, 'doUpdate'), $this);
+        } catch (\Exception $e) {
+            // if an Update file executes PHP statements directly, DB exceptions be handled by executeSingleMigrationQuery, so
+            // make sure to check for them here
+            if ($e instanceof Zend_Db_Exception) {
+                throw new UpdaterErrorException($e->getMessage(), $e->getCode(), $e);
+            } else {
+                throw $e;
+            }
+        }
+    }
+
     /**
      * Performs database update(s)
      *
@@ -344,9 +553,7 @@ class Updater
      */
     static function updateDatabase($file, $sqlarray)
     {
-        foreach ($sqlarray as $update => $ignoreError) {
-            self::executeMigrationQuery($update, $ignoreError, $file);
-        }
+        self::$activeInstance->executeMigrationQueries($file, $sqlarray);
     }
 
     /**
@@ -358,11 +565,7 @@ class Updater
      */
     public static function executeMigrationQuery($updateSql, $errorToIgnore, $file)
     {
-        try {
-            Db::exec($updateSql);
-        } catch (\Exception $e) {
-            self::handleQueryError($e, $updateSql, $errorToIgnore, $file);
-        }
+        self::$activeInstance->executeSingleMigrationQuery($updateSql, $errorToIgnore, $file);
     }
 
     /**
@@ -376,12 +579,29 @@ class Updater
      */
     public static function handleQueryError($e, $updateSql, $errorToIgnore, $file)
     {
-        if (($errorToIgnore === false)
-            || !self::isDbErrorOneOf($e, $errorToIgnore)
-        ) {
-            $message = $file . ":\nError trying to execute the query '" . $updateSql . "'.\nThe error was: " . $e->getMessage();
-            throw new UpdaterErrorException($message);
-        }
+        self::$activeInstance->handleQueryError($e, $updateSql, $errorToIgnore, $file);
+    }
+
+    /**
+     * Record version of successfully completed component update
+     *
+     * @param string $name
+     * @param string $version
+     */
+    public static function recordComponentSuccessfullyUpdated($name, $version)
+    {
+        self::$activeInstance->markComponentSuccessfullyUpdated($name, $version);
+    }
+
+    /**
+     * Retrieve the current version of a recorded component
+     * @param string $name
+     * @return false|string
+     * @throws \Exception
+     */
+    public static function getCurrentRecordedComponentVersion($name)
+    {
+        return self::$activeInstance->getCurrentComponentVersion($name);
     }
 
     /**
@@ -401,6 +621,16 @@ class Updater
         }
         return false;
     }
+
+    /**
+     * Returns the flag name to use in the option table to record current schema version
+     * @param string $name
+     * @return string
+     */
+    private static function getNameInOptionTable($name)
+    {
+        return 'version_' . $name;
+    }
 }
 
 /**
diff --git a/core/Updater/UpdateObserver.php b/core/Updater/UpdateObserver.php
new file mode 100644
index 0000000000000000000000000000000000000000..b8a91562f55cc33be9b20debfc2d34f8b1620494
--- /dev/null
+++ b/core/Updater/UpdateObserver.php
@@ -0,0 +1,114 @@
+<?php
+/**
+ * Piwik - free/libre analytics platform
+ *
+ * @link http://piwik.org
+ * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
+ */
+namespace Piwik\Updater;
+
+/**
+ * UpdateObservers can be used to inject logic into the component updating process. Derive
+ * from this base class and add an instance of the derived class to a Updater instance. When
+ * Updater::update() is called, the methods in the added UpdateListeners will be executed
+ * accordingly.
+ */
+abstract class UpdateObserver
+{
+    /**
+     * Executed when a component is about to be updated. At this point, no SQL queries or other
+     * updating logic has been executed.
+     *
+     * @param string $name The name of the component being updated.
+     */
+    public function onComponentUpdateStarting($name)
+    {
+        // empty
+    }
+
+    /**
+     * Executed after a component has been successfully updated.
+     *
+     * @param string $name The name of the component being updated.
+     * @param string $version The version of the component that was updated.
+     * @param string[] $warnings Any warnings that occurred during the component update process.
+     */
+    public function onComponentUpdateFinished($name, $version, $warnings)
+    {
+        // empty
+    }
+
+    /**
+     * Executed before the update method of an Updates class is executed.
+     *
+     * @param string $componentName The name of the component being updated.
+     * @param string $file The path to the Updates file being executed.
+     * @param string $updateClassName The name of the Update class that should exist within the Update file.
+     * @param string $version The version the Updates file belongs to.
+     */
+    public function onComponentUpdateFileStarting($componentName, $file, $updateClassName, $version)
+    {
+        // empty
+    }
+
+    /**
+     * Executed after the update method of an Updates class is successfully executed.
+     *
+     * @param string $componentName The name of the component being updated.
+     * @param string $file The path to the Updates file being executed.
+     * @param string $updateClassName The name of the Update class that should exist within the Update file.
+     * @param string $version The version the Updates file belongs to.
+     */
+    public function onComponentUpdateFileFinished($componentName, $file, $updateClassName, $version)
+    {
+        // empty
+    }
+
+    /**
+     * Executed before a migration query is executed.
+     *
+     * @param string $updateFile The path to the Updates file being executed.
+     * @param string $sql The SQL query that is about to be executed.
+     */
+    public function onStartExecutingMigrationQuery($updateFile, $sql)
+    {
+        // empty
+    }
+
+    /**
+     * Executed after a migration query is executed.
+     *
+     * @param string $updateFile The path to the Updates file being executed.
+     * @param string $sql The SQL query that has finished executing.
+     */
+    public function onFinishedExecutingMigrationQuery($updateFile, $sql)
+    {
+        // empty
+    }
+
+    /**
+     * Executed when a warning occurs during the update process. A warning occurs when an Updates file
+     * throws an exception that is not a UpdaterErrorException.
+     *
+     * @param string $componentName The name of the component being updated.
+     * @param string $version The version of the Updates file during which the warning occurred.
+     * @param \Exception $exception The exception that generated the warning.
+     */
+    public function onWarning($componentName, $version, \Exception $exception)
+    {
+        // empty
+    }
+
+    /**
+     * Executed when an error occurs during the update process. An error occurs when an Updates file
+     * throws a UpdaterErrorException.
+     *
+     * @param string $componentName The name of the component being updated.
+     * @param string $version The version of the Updates file during which the error occurred.
+     * @param \Exception $exception The exception that generated the error.
+     */
+    public function onError($componentName, $version, \Exception $exception)
+    {
+        // empty
+    }
+}
\ No newline at end of file
diff --git a/core/Updates.php b/core/Updates.php
index 85e7e589ab07b7dc5f823074c49551cfc989b8bc..2ef3e0836da08148bf2b53905b0a4638c3ee8597 100644
--- a/core/Updates.php
+++ b/core/Updates.php
@@ -15,6 +15,21 @@ namespace Piwik;
  */
 abstract class Updates
 {
+    /**
+     * @deprecated since v2.12.0 use getMigrationQueries() instead
+     */
+    static function getSql()
+    {
+        return array();
+    }
+
+    /**
+     * @deprecated since v2.12.0 use doUpdate() instead
+     */
+    static function update()
+    {
+    }
+
     /**
      * Return SQL to be executed in this update
      *
@@ -24,16 +39,17 @@ abstract class Updates
      *                                       // and user will have to manually run the query
      *         )
      */
-    static function getSql()
+    public function getMigrationQueries(Updater $updater)
     {
-        return array();
+        return static::getSql();
     }
 
     /**
      * Incremental version update
      */
-    static function update()
+    public function doUpdate(Updater $updater)
     {
+        static::update();
     }
 
     /**
@@ -45,7 +61,7 @@ abstract class Updates
      *
      * @return bool
      */
-    static function isMajorUpdate()
+    public static function isMajorUpdate()
     {
         return false;
     }
@@ -53,7 +69,7 @@ abstract class Updates
     /**
      * Helper method to enable maintenance mode during large updates
      */
-    static function enableMaintenanceMode()
+    public static function enableMaintenanceMode()
     {
         $config = Config::getInstance();
 
@@ -71,7 +87,7 @@ abstract class Updates
     /**
      * Helper method to disable maintenance mode after large updates
      */
-    static function disableMaintenanceMode()
+    public static function disableMaintenanceMode()
     {
         $config = Config::getInstance();
 
diff --git a/core/Updates/2.10.0-b5.php b/core/Updates/2.10.0-b5.php
index 1cbcb0e3532206dc303ba73c5c1ee6ac831809b9..51663a9d0a26e388880fed6264217c87af415ab6 100644
--- a/core/Updates/2.10.0-b5.php
+++ b/core/Updates/2.10.0-b5.php
@@ -42,6 +42,7 @@ use Piwik\Plugins\Dashboard\Model AS DashboardModel;
  */
 class Updates_2_10_0_b5 extends Updates
 {
+    public static $archiveBlobTables;
 
     static function getSql()
     {
@@ -120,21 +121,18 @@ class Updates_2_10_0_b5 extends Updates
      */
     public static function getAllArchiveBlobTables()
     {
-        static $archiveBlobTables;
-
-        if (empty($archiveBlobTables)) {
-
+        if (empty(self::$archiveBlobTables)) {
             $archiveTables = ArchiveTableCreator::getTablesArchivesInstalled();
 
-            $archiveBlobTables = array_filter($archiveTables, function($name) {
+            self::$archiveBlobTables = array_filter($archiveTables, function($name) {
                 return ArchiveTableCreator::getTypeFromTableName($name) == ArchiveTableCreator::BLOB_TABLE;
             });
 
             // sort tables so we have them in order of their date
-            rsort($archiveBlobTables);
+            rsort(self::$archiveBlobTables);
         }
 
-        return (array) $archiveBlobTables;
+        return (array) self::$archiveBlobTables;
     }
 
     /**
diff --git a/plugins/CoreUpdater/Commands/Update.php b/plugins/CoreUpdater/Commands/Update.php
index 9eb8309e45d491efe66b6df8e0b660a45a265542..0f5566e3946a944026dd0e931585a5bbb70a914c 100644
--- a/plugins/CoreUpdater/Commands/Update.php
+++ b/plugins/CoreUpdater/Commands/Update.php
@@ -8,22 +8,31 @@
  */
 namespace Piwik\Plugins\CoreUpdater\Commands;
 
-use Piwik\Container\StaticContainer;
+use Piwik\Version;
+use Piwik\Config;
+use Piwik\DbHelper;
 use Piwik\Filesystem;
+use Piwik\Piwik;
 use Piwik\Plugin\ConsoleCommand;
-use Piwik\Plugins\CoreUpdater\Controller;
+use Piwik\Plugins\CoreUpdater\Commands\Update\CliUpdateObserver;
 use Piwik\Plugins\CoreUpdater\NoUpdatesFoundException;
 use Piwik\Plugins\UserCountry\LocationProvider;
+use Piwik\Updater;
 use Symfony\Component\Console\Input\InputInterface;
 use Symfony\Component\Console\Input\InputOption;
 use Symfony\Component\Console\Output\OutputInterface;
 use Symfony\Component\Console\Question\ConfirmationQuestion;
 
 /**
- * @package CloudAdmin
+ * @package CoreUpdater
  */
 class Update extends ConsoleCommand
 {
+    /**
+     * @var string[]
+     */
+    private $migrationQueries;
+
     protected function configure()
     {
         $this->setName('core:update');
@@ -62,10 +71,6 @@ class Update extends ConsoleCommand
         } catch(NoUpdatesFoundException $e) {
             // Do not fail if no updates were found
             $this->writeSuccessMessage($output, array($e->getMessage()));
-        } catch (\Exception $e) {
-            // Fail in case of any other error during upgrade
-            $output->writeln("<error>" . $e->getMessage() . "</error>");
-            throw $e;
         }
     }
 
@@ -86,9 +91,245 @@ class Update extends ConsoleCommand
     {
         $this->checkAllRequiredOptionsAreNotEmpty($input);
 
-        $updateController = StaticContainer::get('Piwik\Plugins\CoreUpdater\Controller');
-        $content = $updateController->runUpdaterAndExit($doDryRun);
+        $updater = $this->makeUpdaterInstance($output);
+
+        $componentsWithUpdateFile = $updater->getComponentUpdates();
+        if (empty($componentsWithUpdateFile)) {
+            throw new NoUpdatesFoundException("Everything is already up to date.");
+        }
+
+        $output->writeln(array(
+            "",
+            "    *** " . Piwik::translate('CoreUpdater_UpdateTitle') . " ***"
+        ));
+
+        // handle case of existing database with no tables
+        if (!DbHelper::isInstalled()) {
+            $this->handleCoreError($output, Piwik::translate('CoreUpdater_EmptyDatabaseError', Config::getInstance()->database['dbname']));
+            return;
+        }
+
+        $output->writeln(array(
+            "",
+            "    " . Piwik::translate('CoreUpdater_DatabaseUpgradeRequired'),
+            "",
+            "    " . Piwik::translate('CoreUpdater_YourDatabaseIsOutOfDate')
+        ));
+
+        if ($this->isUpdatingCore($componentsWithUpdateFile)) {
+            $currentVersion = $this->getCurrentVersionForCore($updater);
+            $output->writeln(array(
+                "",
+                "    " . Piwik::translate('CoreUpdater_PiwikWillBeUpgradedFromVersionXToVersionY', array($currentVersion, Version::VERSION))
+            ));
+        }
+
+        $pluginsToUpdate = $this->getPluginsToUpdate($componentsWithUpdateFile);
+        if (!empty($pluginsToUpdate)) {
+            $output->writeln(array(
+                "",
+                "    " . Piwik::translate('CoreUpdater_TheFollowingPluginsWillBeUpgradedX', implode(', ', $pluginsToUpdate))
+            ));
+        }
+
+        $dimensionsToUpdate = $this->getDimensionsToUpdate($componentsWithUpdateFile);
+        if (!empty($dimensionsToUpdate)) {
+            $output->writeln(array(
+                "",
+                "    " . Piwik::translate('CoreUpdater_TheFollowingDimensionsWillBeUpgradedX', implode(', ', $dimensionsToUpdate))
+            ));
+        }
+
+        $output->writeln("");
+
+        if ($doDryRun) {
+            $this->doDryRun($updater, $output);
+        } else {
+            $this->doRealUpdate($updater, $componentsWithUpdateFile, $output);
+        }
+    }
+
+    private function doDryRun(Updater $updater, OutputInterface $output)
+    {
+        $migrationQueries = $this->getMigrationQueriesToExecute($updater);
+
+        $output->writeln(array("    *** Note: this is a Dry Run ***", ""));
+
+        foreach ($migrationQueries as $query) {
+            $output->writeln("    " . $query);
+        }
+
+        $output->writeln(array("", "    *** End of Dry Run ***", ""));
+    }
+
+    private function doRealUpdate(Updater $updater, $componentsWithUpdateFile, OutputInterface $output)
+    {
+        $output->writeln(array("    " . Piwik::translate('CoreUpdater_TheUpgradeProcessMayTakeAWhilePleaseBePatient'), ""));
+
+        $updaterResult = $updater->updateComponents($componentsWithUpdateFile);
+
+        if (@$updaterResult['coreError']) {
+            $this->handleCoreError($output, $updaterResult['errors'], $includeDiyHelp = true);
+            return;
+        }
+
+        if (!empty($updaterResult['warnings'])) {
+            $this->outputUpdaterWarnings($output, $updaterResult['warnings']);
+        }
+
+        if (!empty($updaterResult['errors'])) {
+            $this->outputUpdaterErrors($output, $updaterResult['errors'], $updaterResult['deactivatedPlugins']);
+        }
+
+        if (!empty($updaterResult['warnings'])
+            || !empty($updaterResult['errors'])
+        ) {
+            $output->writeln(array(
+                "    " . Piwik::translate('CoreUpdater_HelpMessageIntroductionWhenWarning'),
+                "",
+                "    * " . $this->getUpdateHelpMessage()
+            ));
+        }
+    }
+
+    private function handleCoreError(OutputInterface $output, $errors, $includeDiyHelp = false)
+    {
+        if (!is_array($errors)) {
+            $errors = array($errors);
+        }
+
+        $output->writeln(array(
+            "",
+            "    [X] " . Piwik::translate('CoreUpdater_CriticalErrorDuringTheUpgradeProcess'),
+            "",
+        ));
+
+        foreach ($errors as $errorMessage) {
+            $errorMessage = trim($errorMessage);
+            $errorMessage = str_replace("\n", "\n    ", $errorMessage);
+
+            $output->writeln("    * $errorMessage");
+        }
+
+        $output->writeln(array(
+            "",
+            "    " . Piwik::translate('CoreUpdater_HelpMessageIntroductionWhenError'),
+            "",
+            "    * " . $this->getUpdateHelpMessage()
+        ));
+
+        if ($includeDiyHelp) {
+            $output->writeln(array(
+                "",
+                "    " . Piwik::translate('CoreUpdater_ErrorDIYHelp'),
+                "",
+                "    * " . Piwik::translate('CoreUpdater_ErrorDIYHelp_1'),
+                "    * " . Piwik::translate('CoreUpdater_ErrorDIYHelp_2'),
+                "    * " . Piwik::translate('CoreUpdater_ErrorDIYHelp_3'),
+                "    * " . Piwik::translate('CoreUpdater_ErrorDIYHelp_4'),
+                "    * " . Piwik::translate('CoreUpdater_ErrorDIYHelp_5')
+            ));
+        }
+
+        throw new \RuntimeException("Piwik could not be updated! See above for more information.");
+    }
+
+    private function outputUpdaterWarnings(OutputInterface $output, $warnings)
+    {
+        $output->writeln(array(
+            "",
+            "    [!] " . Piwik::translate('CoreUpdater_WarningMessages'),
+            ""
+        ));
+
+        foreach ($warnings as $message) {
+            $output->writeln("    * $message");
+        }
+    }
+
+    private function outputUpdaterErrors(OutputInterface $output, $errors, $deactivatedPlugins)
+    {
+        $output->writeln(array(
+            "",
+            "    [X] " . Piwik::translate('CoreUpdater_ErrorDuringPluginsUpdates'),
+            ""
+        ));
+
+        foreach ($errors as $message) {
+            $output->writeln("    * $message");
+        }
+
+        if (!empty($deactivatedPlugins)) {
+            $output->writeln(array(
+                "",
+                "    [!] " . Piwik::translate('CoreUpdater_WeAutomaticallyDeactivatedTheFollowingPlugins', implode(', ', $deactivatedPlugins))
+            ));
+        }
+    }
+
+    private function getUpdateHelpMessage()
+    {
+        return Piwik::translate('CoreUpdater_HelpMessageContent', array('[',']',"\n    *"));
+    }
+
+    private function isUpdatingCore($componentsWithUpdateFile)
+    {
+        foreach ($componentsWithUpdateFile as $componentName => $updates) {
+            if ($componentName == 'core') {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private function getCurrentVersionForCore(Updater $updater)
+    {
+        $currentVersion = $updater->getCurrentComponentVersion('core');
+        if ($currentVersion === false) {
+            $currentVersion = "<= 0.2.9";
+        }
+        return $currentVersion;
+    }
+
+    private function getPluginsToUpdate($componentsWithUpdateFile)
+    {
+        $plugins = array();
+        foreach ($componentsWithUpdateFile as $componentName => $updates) {
+            if ($componentName !== 'core'
+                && 0 !== strpos($componentName, 'log_')
+            ) {
+                $plugins[] = $componentName;
+            }
+        }
+        return $plugins;
+    }
+
+    private function getDimensionsToUpdate($componentsWithUpdateFile)
+    {
+        $dimensions = array();
+        foreach ($componentsWithUpdateFile as $componentName => $updates) {
+            if (0 === strpos($componentName, 'log_')) {
+                $dimensions[] = $componentName;
+            }
+        }
+        return $dimensions;
+    }
+
+    private function getMigrationQueriesToExecute(Updater $updater)
+    {
+        if (empty($this->migrationQueries)) {
+            $this->migrationQueries = $updater->getSqlQueriesToExecute();
+        }
+        return $this->migrationQueries;
+    }
+
+    private function makeUpdaterInstance(OutputInterface $output)
+    {
+        $updater = new Updater();
+
+        $migrationQueryCount = count($this->getMigrationQueriesToExecute($updater));
+        $updater->addUpdateObserver(new CliUpdateObserver($output, $migrationQueryCount));
 
-        $output->writeln($content);
+        return $updater;
     }
 }
diff --git a/plugins/CoreUpdater/Commands/Update/CliUpdateObserver.php b/plugins/CoreUpdater/Commands/Update/CliUpdateObserver.php
new file mode 100644
index 0000000000000000000000000000000000000000..a8c95dc6047a56cdc42aa375d37bb99ba11dd76e
--- /dev/null
+++ b/plugins/CoreUpdater/Commands/Update/CliUpdateObserver.php
@@ -0,0 +1,54 @@
+<?php
+/**
+ * Piwik - free/libre analytics platform
+ *
+ * @link http://piwik.org
+ * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
+ *
+ */
+namespace Piwik\Plugins\CoreUpdater\Commands\Update;
+
+use Piwik\Updater\UpdateObserver;
+use Symfony\Component\Console\Output\OutputInterface;
+
+/**
+ * UpdateObserver used to output progress of an update initiated on the command line. Prints the currently
+ * executing query and the total number of queries to run.
+ *
+ * @package CoreUpdater
+ */
+class CliUpdateObserver extends UpdateObserver
+{
+    /**
+     * @var OutputInterface
+     */
+    private $output;
+
+    /**
+     * @var int
+     */
+    private $totalMigrationQueryCount;
+
+    /**
+     * @var int
+     */
+    private $currentMigrationQueryExecutionCount = 0;
+
+    public function __construct(OutputInterface $output, $totalMigrationQueryCount)
+    {
+        $this->output = $output;
+        $this->totalMigrationQueryCount = $totalMigrationQueryCount;
+    }
+
+    public function onStartExecutingMigrationQuery($updateFile, $sql)
+    {
+        $this->output->write("  Executing <comment>$sql</comment>... ");
+
+        ++$this->currentMigrationQueryExecutionCount;
+    }
+
+    public function onFinishedExecutingMigrationQuery($updateFile, $sql)
+    {
+        $this->output->writeln("Done. <info>[{$this->currentMigrationQueryExecutionCount} / {$this->totalMigrationQueryCount}]</info>");
+    }
+}
\ No newline at end of file
diff --git a/plugins/CoreUpdater/Controller.php b/plugins/CoreUpdater/Controller.php
index 49cb1080a26c784503e4f691a07a871a1036d018..d15dc231e6178337233078d85a97d3f367e82b4b 100644
--- a/plugins/CoreUpdater/Controller.php
+++ b/plugins/CoreUpdater/Controller.php
@@ -110,7 +110,7 @@ class Controller extends \Piwik\Plugin\Controller
         return $view->render();
     }
 
-    protected function redirectToDashboardWhenNoError($updater)
+    protected function redirectToDashboardWhenNoError(DbUpdater $updater)
     {
         if (count($updater->getSqlQueriesToExecute()) == 1
             && !$this->coreError
@@ -146,16 +146,15 @@ class Controller extends \Piwik\Plugin\Controller
     public function runUpdaterAndExit($doDryRun = null)
     {
         $updater = new DbUpdater();
-        $componentsWithUpdateFile = CoreUpdater::getComponentUpdates($updater);
+        $componentsWithUpdateFile = $updater->getComponentUpdates();
         if (empty($componentsWithUpdateFile)) {
             throw new NoUpdatesFoundException("Everything is already up to date.");
         }
 
         SettingsServer::setMaxExecutionTime(0);
 
-        $cli = Common::isPhpCliMode() ? '_cli' : '';
-        $welcomeTemplate = '@CoreUpdater/runUpdaterAndExit_welcome' . $cli;
-        $doneTemplate = '@CoreUpdater/runUpdaterAndExit_done' . $cli;
+        $welcomeTemplate = '@CoreUpdater/runUpdaterAndExit_welcome';
+        $doneTemplate = '@CoreUpdater/runUpdaterAndExit_done';
 
         $viewWelcome = new View($welcomeTemplate);
         $this->addCustomLogoInfo($viewWelcome);
@@ -176,21 +175,6 @@ class Controller extends \Piwik\Plugin\Controller
             return $viewWelcome->render();
         }
 
-        // CLI
-        if (Common::isPhpCliMode()) {
-            $this->doWelcomeUpdates($viewWelcome, $componentsWithUpdateFile);
-            $output = $viewWelcome->render();
-
-            // Proceed with upgrade in CLI only if user specifically asked for it, or if running console command
-            $isUpdateRequested = Common::isRunningConsoleCommand() || Piwik::getModule() == 'CoreUpdater';
-
-            if (!$this->coreError && $isUpdateRequested) {
-                $this->doExecuteUpdates($viewDone, $updater, $componentsWithUpdateFile);
-                $output .= $viewDone->render();
-            }
-            return $output;
-        }
-
         // Web
         if ($doExecuteUpdates) {
             $this->warningMessages = array();
@@ -255,9 +239,9 @@ class Controller extends \Piwik\Plugin\Controller
         $view->coreToUpdate = $coreToUpdate;
     }
 
-    private function doExecuteUpdates($view, $updater, $componentsWithUpdateFile)
+    private function doExecuteUpdates($view, DbUpdater $updater, $componentsWithUpdateFile)
     {
-        $result = CoreUpdater::updateComponents($updater, $componentsWithUpdateFile);
+        $result = $updater->updateComponents($componentsWithUpdateFile);
 
         $this->coreError       = $result['coreError'];
         $this->warningMessages = $result['warnings'];
diff --git a/plugins/CoreUpdater/CoreUpdater.php b/plugins/CoreUpdater/CoreUpdater.php
index f6f197db5df39de5d7cbaa50ebf3fe5510dc8ce5..eb5ce6c622e695efa31f6aefa7f00ef77a2cfe30 100644
--- a/plugins/CoreUpdater/CoreUpdater.php
+++ b/plugins/CoreUpdater/CoreUpdater.php
@@ -14,7 +14,6 @@ use Piwik\Common;
 use Piwik\Filesystem;
 use Piwik\FrontController;
 use Piwik\Piwik;
-use Piwik\Columns\Updater as ColumnsUpdater;
 use Piwik\UpdateCheck;
 use Piwik\Updater;
 use Piwik\UpdaterErrorException;
@@ -36,89 +35,20 @@ class CoreUpdater extends \Piwik\Plugin
         );
     }
 
+    /**
+     * @deprecated
+     */
     public static function updateComponents(Updater $updater, $componentsWithUpdateFile)
     {
-        $warnings = array();
-        $errors   = array();
-        $deactivatedPlugins = array();
-        $coreError = false;
-
-        if (!empty($componentsWithUpdateFile)) {
-            $currentAccess      = Access::getInstance();
-            $hasSuperUserAccess = $currentAccess->hasSuperUserAccess();
-
-            if (!$hasSuperUserAccess) {
-                $currentAccess->setSuperUserAccess(true);
-            }
-
-            // if error in any core update, show message + help message + EXIT
-            // if errors in any plugins updates, show them on screen, disable plugins that errored + CONTINUE
-            // if warning in any core update or in any plugins update, show message + CONTINUE
-            // if no error or warning, success message + CONTINUE
-            foreach ($componentsWithUpdateFile as $name => $filenames) {
-                try {
-                    $warnings = array_merge($warnings, $updater->update($name));
-                } catch (UpdaterErrorException $e) {
-                    $errors[] = $e->getMessage();
-                    if ($name == 'core') {
-                        $coreError = true;
-                        break;
-                    } elseif (\Piwik\Plugin\Manager::getInstance()->isPluginActivated($name)) {
-                        \Piwik\Plugin\Manager::getInstance()->deactivatePlugin($name);
-                        $deactivatedPlugins[] = $name;
-                    }
-                }
-            }
-
-            if (!$hasSuperUserAccess) {
-                $currentAccess->setSuperUserAccess(false);
-            }
-        }
-
-        Filesystem::deleteAllCacheOnUpdate();
-
-        $result = array(
-            'warnings'  => $warnings,
-            'errors'    => $errors,
-            'coreError' => $coreError,
-            'deactivatedPlugins' => $deactivatedPlugins
-        );
-
-        /**
-         * Triggered after Piwik has been updated.
-         */
-        Piwik::postEvent('CoreUpdater.update.end');
-
-        return $result;
+        return $updater->updateComponents($componentsWithUpdateFile);
     }
 
+    /**
+     * @deprecated
+     */
     public static function getComponentUpdates(Updater $updater)
     {
-        $updater->addComponentToCheck('core', Version::VERSION);
-        $manager = \Piwik\Plugin\Manager::getInstance();
-        $plugins = $manager->getLoadedPlugins();
-        foreach ($plugins as $pluginName => $plugin) {
-            if ($manager->isPluginInstalled($pluginName)) {
-                $updater->addComponentToCheck($pluginName, $plugin->getVersion());
-            }
-        }
-
-        $columnsVersions = ColumnsUpdater::getAllVersions();
-        foreach ($columnsVersions as $component => $version) {
-            $updater->addComponentToCheck($component, $version);
-        }
-
-        $componentsWithUpdateFile = $updater->getComponentsWithUpdateFile();
-
-        if (count($componentsWithUpdateFile) == 0) {
-            ColumnsUpdater::onNoUpdateAvailable($columnsVersions);
-
-            if (!$updater->hasNewVersion('core')) {
-                return null;
-            }
-        }
-
-        return $componentsWithUpdateFile;
+        return $updater->getComponentUpdates();
     }
 
     public function dispatch()
@@ -127,12 +57,11 @@ class CoreUpdater extends \Piwik\Plugin
         $action = Common::getRequestVar('action', '', 'string');
 
         $updater = new Updater();
-        $updater->addComponentToCheck('core', Version::VERSION);
-        $updates = $updater->getComponentsWithNewVersion();
+        $updates = $updater->getComponentsWithNewVersion(array('core' => Version::VERSION));
         if (!empty($updates)) {
             Filesystem::deleteAllCacheOnUpdate();
         }
-        if (self::getComponentUpdates($updater) !== null
+        if ($updater->getComponentUpdates() !== null
             && $module != 'CoreUpdater'
             // Proxy module is used to redirect users to piwik.org, should still work when Piwik must be updated
             && $module != 'Proxy'
diff --git a/plugins/CoreUpdater/templates/runUpdaterAndExit_done_cli.twig b/plugins/CoreUpdater/templates/runUpdaterAndExit_done_cli.twig
deleted file mode 100644
index bee8fadcbf12f8b9ce1a53d92f9b230e9dc91b2b..0000000000000000000000000000000000000000
--- a/plugins/CoreUpdater/templates/runUpdaterAndExit_done_cli.twig
+++ /dev/null
@@ -1,51 +0,0 @@
-{% autoescape false %}
-{% set helpMessage %}{{- 'CoreUpdater_HelpMessageContent'|translate('[',']',"\n\n    *") }}{% endset %}
-{% if coreError %}
-    [X] {{ 'CoreUpdater_CriticalErrorDuringTheUpgradeProcess'|translate }}
-
-    {%  for message in errorMessages %}
-    * {{  message }}
-    {%  endfor %}
-
-    {{ 'CoreUpdater_HelpMessageIntroductionWhenError'|translate }}
-
-    * {{ helpMessage }}
-
-    {{ 'CoreUpdater_ErrorDIYHelp'|translate }}
-    * {{ 'CoreUpdater_ErrorDIYHelp_1'|translate }}
-    * {{ 'CoreUpdater_ErrorDIYHelp_2'|translate }}
-    * {{ 'CoreUpdater_ErrorDIYHelp_3'|translate }}
-    * {{ 'CoreUpdater_ErrorDIYHelp_4'|translate }}
-    * {{ 'CoreUpdater_ErrorDIYHelp_5'|translate }}
-
-{% else %}
-{% if warningMessages|length > 0 %}
-    [!] {{ 'CoreUpdater_WarningMessages'|translate }}
-
-    {% for message in warningMessages -%}
-    * {{ message }}
-    {%- endfor %}
-{%- endif %}
-{% if errorMessages|length > 0 -%}
-
-    [X] {{ 'CoreUpdater_ErrorDuringPluginsUpdates'|translate }}
-
-    {% for message in errorMessages %}
-    * {{ message }}
-    {% endfor %}
-
-    {% if deactivatedPlugins|length > 0 -%}
-    {%  set listOfDeactivatedPlugins %}{{ deactivatedPlugins|implode(', ') }}{% endset %}
-
-    [!] {{ 'CoreUpdater_WeAutomaticallyDeactivatedTheFollowingPlugins'|translate(listOfDeactivatedPlugins) }}
-    {% endif %}
-{% endif %}
-{% if errorMessages|length > 0 or warningMessages|length > 0 %}
-    {{ 'CoreUpdater_HelpMessageIntroductionWhenWarning'|translate }}
-
-    * {{ helpMessage }}
-{% else %}
-    Done!
-{% endif %}
-{% endif %}
-{% endautoescape %}
diff --git a/plugins/CoreUpdater/templates/runUpdaterAndExit_welcome_cli.twig b/plugins/CoreUpdater/templates/runUpdaterAndExit_welcome_cli.twig
deleted file mode 100644
index 33c21a15e32fc7bfd9f6e349994b2bd2666cd309..0000000000000000000000000000000000000000
--- a/plugins/CoreUpdater/templates/runUpdaterAndExit_welcome_cli.twig
+++ /dev/null
@@ -1,53 +0,0 @@
-{% autoescape false %}
-{% set helpMessage %}
-{{- 'CoreUpdater_HelpMessageContent'|translate('[',']','\n\n    *') }}
-{% endset %}
-
-*** {{ 'CoreUpdater_UpdateTitle'|translate }} ***
-{% if coreError %}
-
-    [X] {{ 'CoreUpdater_CriticalErrorDuringTheUpgradeProcess'|translate }}
-
-    {% for message in errorMessages %}
-        {{- message }}
-    {%  endfor %}
-
-    {{ 'CoreUpdater_HelpMessageIntroductionWhenError'|translate }}
-
-    * {{ helpMessage }}
-
-{% elseif coreToUpdate or pluginNamesToUpdate|length > 0 or dimensionsToUpdate|length > 0 %}
-
-    {{ 'CoreUpdater_DatabaseUpgradeRequired'|translate }}
-
-    {{ 'CoreUpdater_YourDatabaseIsOutOfDate'|translate }}
-
-{% if coreToUpdate %}
-    {{ 'CoreUpdater_PiwikWillBeUpgradedFromVersionXToVersionY'|translate(current_piwik_version, new_piwik_version) }}
-{% endif %}
-
-{%- if pluginNamesToUpdate|length > 0 %}
-    {%- set listOfPlugins %}{{  pluginNamesToUpdate|implode(', ') }}{% endset %}
-    {{ 'CoreUpdater_TheFollowingPluginsWillBeUpgradedX'|translate( listOfPlugins) }}
-{%  endif %}
-
-{%- if dimensionsToUpdate|length > 0 %}
-    {%- set listOfDimensions %}{{  dimensionsToUpdate|implode(', ') }}{% endset %}
-    {{ 'CoreUpdater_TheFollowingDimensionsWillBeUpgradedX'|translate( listOfDimensions) }}
-{%  endif %}
-
-{# dry run #}
-{% if queries is defined and queries is not empty %}
-*** Note: this is a Dry Run ***
-
-    {% for query in queries %}{{ query|trim }}
-    {% endfor %}
-
-*** End of Dry Run ***
-{% else %}
-    {{ 'CoreUpdater_TheUpgradeProcessMayTakeAWhilePleaseBePatient'|translate }}
-{% endif %}
-
-{%  endif %}
-{% endautoescape %}
-
diff --git a/plugins/CoreUpdater/tests/Integration/Commands/UpdateTest.php b/plugins/CoreUpdater/tests/Integration/Commands/UpdateTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..a3c97d86d8a8f6bbed7922fd7065a4ea50712a2f
--- /dev/null
+++ b/plugins/CoreUpdater/tests/Integration/Commands/UpdateTest.php
@@ -0,0 +1,126 @@
+<?php
+/**
+ * Piwik - free/libre analytics platform
+ *
+ * @link http://piwik.org
+ * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
+ */
+namespace Piwik\Plugins\CoreUpdater\tests\Integration\Commands;
+
+use Piwik\Common;
+use Piwik\Config;
+use Piwik\DataAccess\ArchiveTableCreator;
+use Piwik\Date;
+use Piwik\Db;
+use Piwik\DbHelper;
+use Piwik\Option;
+use Piwik\Tests\Framework\TestCase\ConsoleCommandTestCase;
+use Piwik\Updates\Updates_2_10_0_b5;
+use Piwik\Version;
+use Symfony\Component\Console\Helper\QuestionHelper;
+
+require_once PIWIK_INCLUDE_PATH . '/core/Updates/2.10.0-b5.php';
+
+/**
+ * @group CoreUpdater
+ * @group CoreUpdater_Integration
+ */
+class UpdateTest extends ConsoleCommandTestCase
+{
+    const VERSION_TO_UPDATE_FROM = '2.9.0';
+    const EXPECTED_SQL_FROM_2_10 = "UPDATE report SET reports = REPLACE(reports, 'UserSettings_getBrowserVersion', 'DevicesDetection_getBrowserVersions');";
+
+    private $oldScriptName = null;
+
+    public function setUp()
+    {
+        parent::setUp();
+
+        Config::getInstance()->setTestEnvironment();
+        Option::set('version_core', self::VERSION_TO_UPDATE_FROM);
+
+        $this->oldScriptName = $_SERVER['SCRIPT_NAME'];
+        $_SERVER['SCRIPT_NAME'] = $_SERVER['SCRIPT_NAME'] . " console"; // update won't execute w/o this, see Common::isRunningConsoleCommand()
+
+        ArchiveTableCreator::clear();
+        DbHelper::getTablesInstalled($forceReload = true); // force reload so internal cache in Mysql.php is refreshed
+        Updates_2_10_0_b5::$archiveBlobTables = null;
+    }
+
+    public function tearDown()
+    {
+        $_SERVER['SCRIPT_NAME'] = $this->oldScriptName;
+
+        parent::tearDown();
+    }
+
+    public function test_UpdateCommand_SuccessfullyExecutesUpdate()
+    {
+        $result = $this->applicationTester->run(array(
+            'command' => 'core:update',
+            '--yes' => true
+        ));
+
+        $this->assertEquals(0, $result, $this->getCommandDisplayOutputErrorMessage());
+
+        $this->assertDryRunExecuted($this->applicationTester->getDisplay());
+
+        // make sure update went through
+        $this->assertEquals(Version::VERSION, Option::get('version_core'));
+    }
+
+    public function test_UpdateCommand_DoesntExecuteSql_WhenUserSaysNo()
+    {
+        /** @var QuestionHelper $dialog */
+        $dialog = $this->application->getHelperSet()->get('question');
+        $dialog->setInputStream($this->getInputStream("N\n"));
+
+        $result = $this->applicationTester->run(array(
+            'command' => 'core:update'
+        ));
+
+        $this->assertEquals(0, $result, $this->getCommandDisplayOutputErrorMessage());
+
+        $this->assertDryRunExecuted($this->applicationTester->getDisplay());
+
+        // make sure update did not go through
+        $this->assertEquals(self::VERSION_TO_UPDATE_FROM, Option::get('version_core'));
+    }
+
+    public function test_UpdateCommand_DoesNotExecuteUpdate_IfPiwikUpToDate()
+    {
+        Option::set('version_core', Version::VERSION);
+
+        $result = $this->applicationTester->run(array(
+            'command' => 'core:update',
+            '--yes' => true
+        ));
+
+        $this->assertEquals(0, $result, $this->getCommandDisplayOutputErrorMessage());
+
+        // check no update occurred
+        $this->assertContains("Everything is already up to date.", $this->applicationTester->getDisplay());
+        $this->assertEquals(Version::VERSION, Option::get('version_core'));
+    }
+
+    public function test_UpdateCommand_ReturnsCorrectExitCode_WhenErrorOccurs()
+    {
+        // create a blob table, then drop it manually so update 2.10.0-b10 will fail
+        $tableName = ArchiveTableCreator::getBlobTable(Date::factory('2015-01-01'));
+        Db::exec("DROP TABLE $tableName");
+
+        $result = $this->applicationTester->run(array(
+            'command' => 'core:update',
+            '--yes' => true
+        ));
+
+        $this->assertEquals(1, $result, $this->getCommandDisplayOutputErrorMessage());
+        $this->assertContains("Piwik could not be updated! See above for more information.", $this->applicationTester->getDisplay());
+    }
+
+    private function assertDryRunExecuted($output)
+    {
+        $this->assertContains("Note: this is a Dry Run", $output);
+        $this->assertContains(self::EXPECTED_SQL_FROM_2_10, $output);
+    }
+}
\ No newline at end of file
diff --git a/plugins/Installation/Controller.php b/plugins/Installation/Controller.php
index 7641624cb38014904cea89e6dde288a603ad8ba2..9976f4ad58ae64e9ef678cc9569766ddbde51242 100644
--- a/plugins/Installation/Controller.php
+++ b/plugins/Installation/Controller.php
@@ -720,12 +720,12 @@ class Controller extends \Piwik\Plugin\ControllerAdmin
 
         return Access::doAsSuperUser(function () {
             $updater = new Updater();
-            $componentsWithUpdateFile = CoreUpdater::getComponentUpdates($updater);
+            $componentsWithUpdateFile = $updater->getComponentUpdates();
 
             if (empty($componentsWithUpdateFile)) {
                 return false;
             }
-            $result = CoreUpdater::updateComponents($updater, $componentsWithUpdateFile);
+            $result = $updater->updateComponents($componentsWithUpdateFile);
             return $result;
         });
     }
diff --git a/tests/PHPUnit/Framework/Fixture.php b/tests/PHPUnit/Framework/Fixture.php
index 302d1192e2cc84a9b8955370e0370f3753e74775..9d2fb8a2d7db19c6e61b0d5527c06779583a6e55 100644
--- a/tests/PHPUnit/Framework/Fixture.php
+++ b/tests/PHPUnit/Framework/Fixture.php
@@ -867,12 +867,12 @@ class Fixture extends \PHPUnit_Framework_Assert
         }
 
         $updater = new Updater();
-        $componentsWithUpdateFile = CoreUpdater::getComponentUpdates($updater);
+        $componentsWithUpdateFile = $updater->getComponentUpdates();
         if (empty($componentsWithUpdateFile)) {
             return false;
         }
 
-        $result = CoreUpdater::updateComponents($updater, $componentsWithUpdateFile);
+        $result = $updater->updateComponents($componentsWithUpdateFile);
         if (!empty($result['coreError'])
             || !empty($result['warnings'])
             || !empty($result['errors'])
diff --git a/tests/PHPUnit/Framework/TestCase/ConsoleCommandTestCase.php b/tests/PHPUnit/Framework/TestCase/ConsoleCommandTestCase.php
index 3a8d33c7a5453582ba4db58a4d31c64c3f5578bd..2da07556e1eb3ba8ecf8aa1565f351bec8a9cef1 100644
--- a/tests/PHPUnit/Framework/TestCase/ConsoleCommandTestCase.php
+++ b/tests/PHPUnit/Framework/TestCase/ConsoleCommandTestCase.php
@@ -61,4 +61,13 @@ class ConsoleCommandTestCase extends SystemTestCase
     {
         return "Command did not behave as expected. Command output: " . $this->applicationTester->getDisplay();
     }
+
+    protected function getInputStream($input)
+    {
+        $stream = fopen('php://memory', 'r+', false);
+        fputs($stream, $input);
+        rewind($stream);
+
+        return $stream;
+    }
 }
\ No newline at end of file
diff --git a/tests/PHPUnit/Integration/Columns/UpdaterTest.php b/tests/PHPUnit/Integration/Columns/UpdaterTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..fe2a4caa898c486f0a304268e052591eb57ef1ce
--- /dev/null
+++ b/tests/PHPUnit/Integration/Columns/UpdaterTest.php
@@ -0,0 +1,285 @@
+<?php
+/**
+ * Piwik - free/libre analytics platform
+ *
+ * @link http://piwik.org
+ * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
+ */
+namespace Piwik\Plugins\Test\Columns;
+
+use Piwik\Columns\Updater as ColumnsUpdater;
+use Piwik\Common;
+use Piwik\Db;
+use Piwik\DbHelper;
+use Piwik\Plugin\Dimension\ActionDimension;
+use Piwik\Plugin\Dimension\ConversionDimension;
+use Piwik\Plugin\Dimension\VisitDimension;
+use Piwik\Tests\Framework\TestCase\IntegrationTestCase;
+use Piwik\Updater;
+
+// NOTE: we can't use PHPUnit mock framework since we have to set columnName/columnType. reflection will set it, but
+// for some reason, methods of base type don't see the set value.
+class MockVisitDimension extends VisitDimension
+{
+    public function __construct($columnName, $columnType)
+    {
+        $this->columnName = $columnName;
+        $this->columnType = $columnType;
+    }
+}
+
+class MockActionDimension extends ActionDimension
+{
+    public function __construct($columnName, $columnType)
+    {
+        $this->columnName = $columnName;
+        $this->columnType = $columnType;
+    }
+}
+
+class MockConversionDimension extends ConversionDimension
+{
+    public function __construct($columnName, $columnType)
+    {
+        $this->columnName = $columnName;
+        $this->columnType = $columnType;
+    }
+}
+
+/**
+ * @group Core
+ */
+class UpdaterTest extends IntegrationTestCase
+{
+    private $tableColumnsCache = array(); // for test performance
+
+    /**
+     * @var ColumnsUpdater
+     */
+    private $columnsUpdater;
+
+    public function setUp()
+    {
+        parent::setUp();
+
+        // recreate log_visit/log_link_visit_action/log_conversion tables w/o any dimensions
+        $tablesToRecreate = array('log_visit', 'log_link_visit_action', 'log_conversion');
+        foreach ($tablesToRecreate as $table) {
+            Db::exec("DROP TABLE `" . Common::prefixTable($table) . "`");
+
+            $tableCreateSql = DbHelper::getTableCreateSql($table);
+            Db::exec($tableCreateSql);
+        }
+
+        $visitDimensions = array(
+            $this->getMockVisitDimension("test_visit_col_1", "INTEGER(10) UNSIGNED NOT NULL"),
+            $this->getMockVisitDimension("test_visit_col_2", "VARCHAR(32) NOT NULL")
+        );
+
+        $actionDimensions = array(
+            $this->getMockActionDimension("test_action_col_1", "VARCHAR(32) NOT NULL"),
+            $this->getMockActionDimension("test_action_col_2", "INTEGER(10) UNSIGNED DEFAULT NULL")
+        );
+
+        $conversionDimensions = array(
+            $this->getMockConversionDimension("test_conv_col_1", "FLOAT DEFAULT NULL"),
+            $this->getMockConversionDimension("test_conv_col_2", "VARCHAR(32) NOT NULL")
+        );
+
+        $this->columnsUpdater = new ColumnsUpdater($visitDimensions, $actionDimensions, $conversionDimensions);
+
+        $this->tableColumnsCache = array();
+    }
+
+    public function test_getMigrationQueries_ReturnsCorrectQueries_IfDimensionIsNotInTable()
+    {
+        $updater = $this->getMockUpdater();
+        $actualMigrationQueries = $this->columnsUpdater->getMigrationQueries($updater);
+
+        $expectedMigrationQueries = array(
+            'ALTER TABLE `log_visit` ADD COLUMN `test_visit_col_1` INTEGER(10) UNSIGNED NOT NULL, ADD COLUMN `test_visit_col_2` VARCHAR(32) NOT NULL' => array('1091', '1060'),
+            'ALTER TABLE `log_link_visit_action` ADD COLUMN `test_action_col_1` VARCHAR(32) NOT NULL, ADD COLUMN `test_action_col_2` INTEGER(10) UNSIGNED DEFAULT NULL' => array('1091', '1060'),
+            'ALTER TABLE `log_conversion` ADD COLUMN `test_conv_col_1` FLOAT DEFAULT NULL, ADD COLUMN `test_conv_col_2` VARCHAR(32) NOT NULL' => array('1091', '1060'),
+        );
+        $this->assertEquals($expectedMigrationQueries, $actualMigrationQueries);
+    }
+
+    public function test_getMigrationQueries_ReturnsCorrectQueries_IfDimensionIsInTable_ButHasNewVersion()
+    {
+        $this->addDimensionsToTables();
+
+        $updater = $this->getMockUpdater();
+        $actualMigrationQueries = $this->columnsUpdater->getMigrationQueries($updater);
+
+        $expectedMigrationQueries = array(
+            'ALTER TABLE `log_visit` MODIFY COLUMN `test_visit_col_1` INTEGER(10) UNSIGNED NOT NULL, MODIFY COLUMN `test_visit_col_2` VARCHAR(32) NOT NULL' => array('1091', '1060'),
+            'ALTER TABLE `log_link_visit_action` MODIFY COLUMN `test_action_col_1` VARCHAR(32) NOT NULL, MODIFY COLUMN `test_action_col_2` INTEGER(10) UNSIGNED DEFAULT NULL' => array('1091', '1060'),
+            'ALTER TABLE `log_conversion` MODIFY COLUMN `test_conv_col_1` FLOAT DEFAULT NULL, MODIFY COLUMN `test_conv_col_2` VARCHAR(32) NOT NULL' => array('1091', '1060')
+        );
+        $this->assertEquals($expectedMigrationQueries, $actualMigrationQueries);
+    }
+
+    public function test_getMigrationQueries_ReturnsNoQueries_IfDimensionsAreInTable_ButHaveNoNewVersions()
+    {
+        $this->addDimensionsToTables();
+
+        $updater = $this->getMockUpdater($hasNewVersion = false);
+        $actualMigrationQueries = $this->columnsUpdater->getMigrationQueries($updater);
+
+        $this->assertEquals(array(), $actualMigrationQueries);
+    }
+
+    public function test_doUpdate_AddsDimensions_WhenDimensionsNotInTables()
+    {
+        $updater = $this->getMockUpdater();
+        $this->columnsUpdater->doUpdate($updater);
+
+        $this->assertDimensionsAddedToTables();
+    }
+
+    public function test_doUpdate_DoesNotError_WhenDimensionsAlreadyInTables()
+    {
+        $this->addDimensionsToTables();
+
+        $updater = $this->getMockUpdater();
+        $this->columnsUpdater->doUpdate($updater);
+
+        $this->assertDimensionsAddedToTables();
+    }
+
+    public function test_getAllVersions_ReturnsFileVersionsOfAllDimensions()
+    {
+        $updater = $this->getMockUpdater();
+        $actualVersions = $this->columnsUpdater->getAllVersions($updater);
+
+        $expectedVersions = array(
+            'log_visit.test_visit_col_1' => 'INTEGER(10) UNSIGNED NOT NULL',
+            'log_visit.test_visit_col_2' => 'VARCHAR(32) NOT NULL',
+            'log_link_visit_action.test_action_col_1' => 'VARCHAR(32) NOT NULL',
+            'log_link_visit_action.test_action_col_2' => 'INTEGER(10) UNSIGNED DEFAULT NULL',
+            'log_conversion.test_conv_col_1' => 'FLOAT DEFAULT NULL',
+            'log_conversion.test_conv_col_2' => 'VARCHAR(32) NOT NULL'
+        );
+        $this->assertEquals($actualVersions, $expectedVersions);
+    }
+
+    /**
+     * @dataProvider getCoreDimensionsForGetAllVersionsTest
+     */
+    public function test_getAllVersions_ReturnsNoVersions_ForCoreDimensions_ThatWereRefactored_AndHaveNoDbVersion($table, $columnName, $columnType)
+    {
+        $this->addDimensionsToTables();
+        $this->addDimensionToTable($table, $columnName, $columnType);
+
+        $updater = $this->getMockUpdater();
+        $actualVersions = $this->columnsUpdater->getAllVersions($updater);
+
+        $expectedVersions = array(
+            'log_visit.test_visit_col_1' => 'INTEGER(10) UNSIGNED NOT NULL',
+            'log_visit.test_visit_col_2' => 'VARCHAR(32) NOT NULL',
+            'log_link_visit_action.test_action_col_1' => 'VARCHAR(32) NOT NULL',
+            'log_link_visit_action.test_action_col_2' => 'INTEGER(10) UNSIGNED DEFAULT NULL',
+            'log_conversion.test_conv_col_1' => 'FLOAT DEFAULT NULL',
+            'log_conversion.test_conv_col_2' => 'VARCHAR(32) NOT NULL'
+        );
+        $this->assertEquals($actualVersions, $expectedVersions);
+    }
+
+    public function getCoreDimensionsForGetAllVersionsTest()
+    {
+        // only one test per table. otherwise test will be too slow (~2 mins for all).
+        return array(
+            array('log_visit', 'user_id', 'VARCHAR(200) NULL'),
+            array('log_link_visit_action', 'idaction_event_category', 'INTEGER(10) UNSIGNED DEFAULT NULL'),
+            array('log_conversion', 'revenue_tax', 'float default NULL')
+        );
+    }
+
+    private function getMockVisitDimension($columnName, $columnType)
+    {
+        return new MockVisitDimension($columnName, $columnType);
+    }
+
+    private function getMockActionDimension($columnName, $columnType)
+    {
+        return new MockActionDimension($columnName, $columnType);
+    }
+
+    private function getMockConversionDimension($columnName, $columnType)
+    {
+        return new MockConversionDimension($columnName, $columnType);
+    }
+
+    private function getMockUpdater($hasNewVersion = true)
+    {
+        $result = $this->getMock("Piwik\\Updater", array('hasNewVersion'));
+
+        $result->expects($this->any())->method('hasNewVersion')->will($this->returnCallback(function () use ($hasNewVersion) {
+            return $hasNewVersion;
+        }));
+
+        return $result;
+    }
+
+    private function assertDimensionsAddedToTables()
+    {
+        $this->assertTableHasColumn('log_visit', 'test_visit_col_1', 'int(10) unsigned', $allowNull = false);
+        $this->assertTableHasColumn('log_visit', 'test_visit_col_2', 'varchar(32)', $allowNull = false);
+
+        $this->assertTableHasColumn('log_link_visit_action', 'test_action_col_1', 'varchar(32)', $allowNull = false);
+        $this->assertTableHasColumn('log_link_visit_action', 'test_action_col_2', 'int(10) unsigned', $allowNull = true);
+
+        $this->assertTableHasColumn('log_conversion', 'test_conv_col_1', 'float', $allowNull = true);
+        $this->assertTableHasColumn('log_conversion', 'test_conv_col_2', 'varchar(32)', $allowNull = false);
+    }
+
+    private function assertTableHasColumn($table, $columnName, $columnType, $allowNull)
+    {
+        $column = $this->getTableColumnInfo($table, $columnName);
+
+        $this->assertNotNull($column, "Column '$columnName' does not exist in '$table'.");
+
+        $this->assertEquals(strtolower($columnType), strtolower($column['Type']));
+        if ($allowNull) {
+            $this->assertEquals("yes", strtolower($column['Null']));
+        } else {
+            $this->assertEquals("no", strtolower($column['Null']));
+        }
+    }
+
+    private function getTableColumns($table)
+    {
+        if (empty($this->tableColumnsCache[$table])) {
+            $this->tableColumnsCache[$table] = Db::fetchAll("SHOW COLUMNS IN `" . Common::prefixTable($table) . "`");
+        }
+        return $this->tableColumnsCache[$table];
+    }
+
+    private function getTableColumnInfo($table, $columnName)
+    {
+        $columns = $this->getTableColumns($table);
+        foreach ($columns as $row) {
+            if ($row['Field'] == $columnName) {
+                return $row;
+            }
+        }
+        return null;
+    }
+
+    private function addDimensionsToTables()
+    {
+        $this->addDimensionToTable('log_visit', 'test_visit_col_1', "INTEGER UNSIGNED NOT NULL");
+        $this->addDimensionToTable('log_visit', 'test_visit_col_2', "VARCHAR(32) NOT NULL");
+
+        $this->addDimensionToTable('log_link_visit_action', 'test_action_col_1', "VARCHAR(32) NOT NULL");
+        $this->addDimensionToTable('log_link_visit_action', 'test_action_col_2', "INTEGER(10) UNSIGNED DEFAULT NULL");
+
+        $this->addDimensionToTable('log_conversion', 'test_conv_col_1', "FLOAT DEFAULT NULL");
+        $this->addDimensionToTable('log_conversion', 'test_conv_col_2', "VARCHAR(32) NOT NULL");
+    }
+
+    private function addDimensionToTable($table, $column, $type)
+    {
+        Db::exec("ALTER TABLE `" . Common::prefixTable($table) . "` ADD COLUMN $column $type");
+    }
+}
\ No newline at end of file
diff --git a/tests/PHPUnit/Integration/UpdaterTest.php b/tests/PHPUnit/Integration/UpdaterTest.php
index c62210bde68f2642aba0ad9bfac86fd202f9e154..e49a54b08e20746ea8e15803121adb88035065bd 100644
--- a/tests/PHPUnit/Integration/UpdaterTest.php
+++ b/tests/PHPUnit/Integration/UpdaterTest.php
@@ -19,21 +19,17 @@ class UpdaterTest extends IntegrationTestCase
 {
     public function testUpdaterChecksCoreVersionAndDetectsUpdateFile()
     {
-        $updater = new Updater();
-        $updater->pathUpdateFileCore = PIWIK_INCLUDE_PATH . '/tests/resources/Updater/core/';
-        $updater->recordComponentSuccessfullyUpdated('core', '0.1');
-        $updater->addComponentToCheck('core', '0.3');
-        $componentsWithUpdateFile = $updater->getComponentsWithUpdateFile();
+        $updater = new Updater(PIWIK_INCLUDE_PATH . '/tests/resources/Updater/core/');
+        $updater->markComponentSuccessfullyUpdated('core', '0.1');
+        $componentsWithUpdateFile = $updater->getComponentsWithUpdateFile(array('core' => '0.3'));
         $this->assertEquals(1, count($componentsWithUpdateFile));
     }
 
     public function testUpdaterChecksGivenPluginVersionAndDetectsMultipleUpdateFileInOrder()
     {
-        $updater = new Updater();
-        $updater->pathUpdateFilePlugins = PIWIK_INCLUDE_PATH . '/tests/resources/Updater/%s/';
-        $updater->recordComponentSuccessfullyUpdated('testpluginUpdates', '0.1beta');
-        $updater->addComponentToCheck('testpluginUpdates', '0.1');
-        $componentsWithUpdateFile = $updater->getComponentsWithUpdateFile();
+        $updater = new Updater($pathToCoreUpdates = null, PIWIK_INCLUDE_PATH . '/tests/resources/Updater/%s/');
+        $updater->markComponentSuccessfullyUpdated('testpluginUpdates', '0.1beta');
+        $componentsWithUpdateFile = $updater->getComponentsWithUpdateFile(array('testpluginUpdates' => '0.1'));
 
         $this->assertEquals(1, count($componentsWithUpdateFile));
         $updateFiles = $componentsWithUpdateFile['testpluginUpdates'];
@@ -49,17 +45,18 @@ class UpdaterTest extends IntegrationTestCase
 
     public function testUpdaterChecksCoreAndPluginCheckThatCoreIsRanFirst()
     {
-        $updater = new Updater();
-        $updater->pathUpdateFilePlugins = PIWIK_INCLUDE_PATH . '/tests/resources/Updater/%s/';
-        $updater->pathUpdateFileCore = PIWIK_INCLUDE_PATH . '/tests/resources/Updater/core/';
-
-        $updater->recordComponentSuccessfullyUpdated('testpluginUpdates', '0.1beta');
-        $updater->addComponentToCheck('testpluginUpdates', '0.1');
+        $updater = new Updater(
+            PIWIK_INCLUDE_PATH . '/tests/resources/Updater/core/',
+            PIWIK_INCLUDE_PATH . '/tests/resources/Updater/%s/'
+        );
 
-        $updater->recordComponentSuccessfullyUpdated('core', '0.1');
-        $updater->addComponentToCheck('core', '0.3');
+        $updater->markComponentSuccessfullyUpdated('testpluginUpdates', '0.1beta');
+        $updater->markComponentSuccessfullyUpdated('core', '0.1');
 
-        $componentsWithUpdateFile = $updater->getComponentsWithUpdateFile();
+        $componentsWithUpdateFile = $updater->getComponentsWithUpdateFile(array(
+            'testpluginUpdates' => '0.1',
+            'core' => '0.3'
+        ));
         $this->assertEquals(2, count($componentsWithUpdateFile));
         reset($componentsWithUpdateFile);
         $this->assertEquals('core', key($componentsWithUpdateFile));