diff --git a/packages/web/lib/fog/authorization.class.php b/packages/web/lib/fog/authorization.class.php index 5d7ba448b1..7a47f7b0e4 100644 --- a/packages/web/lib/fog/authorization.class.php +++ b/packages/web/lib/fog/authorization.class.php @@ -222,6 +222,10 @@ class Authorization extends FOGBase 'pendingmacs' => 'host.view', 'snapinCreateWithFile' => 'snapin.create', 'uploadSnapinFiles' => 'snapin.create', + // The node the web Install button checks, rather than plugin.edit: + // installing runs a plugin's schema migrations, which is a + // different authority from editing its row. + 'pluginInstall' => 'plugin.install', 'settingsCacheView' => 'settings.view', 'settingsCacheFlush' => 'settings.edit', 'settingsCacheRefresh' => 'settings.edit' diff --git a/packages/web/lib/fog/openapi.class.php b/packages/web/lib/fog/openapi.class.php index 2cbb7020bb..352e5be27e 100644 --- a/packages/web/lib/fog/openapi.class.php +++ b/packages/web/lib/fog/openapi.class.php @@ -2009,10 +2009,75 @@ private static function _fixedPaths() '/storagegroup/{id}/uploadsnapinfiles' => [ 'parameters' => [self::_idParameter()], 'post' => self::_uploadSnapinFilesOp() + ], + '/plugin/{id}/install' => [ + 'parameters' => [self::_idParameter()], + 'post' => self::_pluginInstallOp() ] ]; } + /** + * POST /plugin/{id}/install. + * + * Written out here rather than falling out of the generic shapes for + * the same reason the upload routes are: it is an action, not CRUD on + * a row. The generic edit route cannot express it, and deliberately + * refuses to pretend it can -- plugins.installed and plugins.schema + * are server-owned, because both record what this operation DID. + * + * @return array + */ + private static function _pluginInstallOp() + { + return self::_op( + '', + 'pluginInstall', + _('Install a plugin'), + _('Activates the plugin, applies its schema migrations, and ' + . 'records the result -- the same three steps, in the same ' + . 'order, as the Install action in the web UI. Idempotent: ' + . 'migration steps are append-only and are resumed from the ' + . 'count already applied, so calling this on an installed ' + . 'plugin applies only steps it has not seen, which is what ' + . 'the UI calls Upgrade. Setting `installed` through the ' + . 'generic edit route instead is refused: that column ' + . 'records that this operation succeeded, and asserting it ' + . 'without running the migrations leaves the plugin\'s ' + . 'routes in this document while its tables do not exist.'), + [ + '204' => ['description' => _('The plugin is installed and ' + . 'its schema is up to date.')], + '400' => [ + 'description' => _('The server refuses to activate this ' + . 'plugin; the message says why.'), + 'content' => [ + 'application/json' => [ + 'schema' => ['$ref' => '#/components/schemas/Error'] + ] + ] + ], + '404' => [ + 'description' => _('No plugin with that id.'), + 'content' => [ + 'application/json' => [ + 'schema' => ['$ref' => '#/components/schemas/Error'] + ] + ] + ], + '500' => [ + 'description' => _('A migration step failed. The plugin ' + . 'is left activated and not marked installed.'), + 'content' => [ + 'application/json' => [ + 'schema' => ['$ref' => '#/components/schemas/Error'] + ] + ] + ] + ] + ); + } + /** * POST /snapin/createwithfile. * diff --git a/packages/web/lib/router/route.class.php b/packages/web/lib/router/route.class.php index 810c9436a7..9a64066c60 100644 --- a/packages/web/lib/router/route.class.php +++ b/packages/web/lib/router/route.class.php @@ -383,6 +383,36 @@ class Route extends FOGBase 'user' => [ 'token', ], + // Both record what the installer DID, and are written by it only + // after it succeeded. PluginManagementPage::installPost() sets + // state, then runs Plugin::installdb() to create the tables, and + // writes installed=1 last; Plugin::installdb() writes schema to + // the number of migration steps it applied. + // + // Accepting them over the generic edit route lets a client assert + // an install that never happened. Measured: PUT /plugin/{id}/edit + // with installed=1 on an uninstalled plugin registers the plugin's + // classes -- so its routes and schemas appear in GET + // system/openapi -- while no table is ever created, and every one + // of those routes then answers + // + // 406 SQLSTATE[42S02]: Base table or view not found + // + // A client generated from that document gets commands guaranteed + // to fail. It also hides from the repair path most admins would + // reach for: installPost() only calls installdb() for plugins + // filtered on installed IN ('', 0, '0'), so the Install button + // skips a row that already claims to be installed. upgradePost() + // is what fixes it, and nothing says so. + // + // state is deliberately NOT here. Activating is a column write and + // nothing else -- installPost() and the Activate action both just + // set state=1 -- so a client writing it does the whole job rather + // than half of it. + 'plugin' => [ + 'installed', + 'schema', + ], ]; /** * Memoized union of the list above and what plugins declare through @@ -1332,6 +1362,10 @@ protected static function defineRoutes() '/storagegroup/[i:id]/uploadsnapinfiles', [__CLASS__, 'uploadSnapinFiles'], 'uploadSnapinFiles' + )->post( + '/plugin/[i:id]/install', + [__CLASS__, 'pluginInstall'], + 'pluginInstall' )->post( "{$expandedw}/[create|new]?", [__CLASS__, 'create'], @@ -4455,6 +4489,86 @@ public static function uploadSnapinFiles($id) ); } } + /** + * Installs a plugin: activate, create its tables, then record it. + * + * POST /plugin/{id}/install. + * + * The same three steps PluginManagementPage::installPost() performs, + * in the same order, because the order is the contract: state first, + * then Plugin::installdb() to apply the plugin's schema migrations, + * and installed=1 LAST so the column only ever claims an install that + * actually happened. + * + * installdb() is called unconditionally rather than only for a plugin + * that is not yet installed. Migration steps are append-only and + * idempotent by contract (docs/PLUGIN_SCHEMA_MIGRATIONS.md) and + * Schema::applyUpdates() resumes from the stored count, so calling it + * on an installed plugin applies only steps it has not seen -- which + * is what the Upgrade action does. One route therefore installs, and + * brings a plugin whose code ships newer schema() steps up to date, + * without a drop and recreate. + * + * That also makes it the repair for a row whose installed flag was set + * without the tables ever being created. The web Install action cannot + * do it: installPost() filters on installed IN ('', 0, '0'), so it + * skips a plugin that already claims to be installed. + * + * @param int $id The plugin id. + * + * @return void + */ + public static function pluginInstall($id) + { + try { + $Plugin = self::getClass('Plugin', (int)$id); + if (!$Plugin->isValid()) { + self::sendResponse( + HTTPResponseCodes::HTTP_NOT_FOUND, + _('Plugin not found') + ); + return; + } + // Same gate the page applies. A blocked plugin is one the + // server has a reason to refuse -- a conflict with a core + // feature that replaced it, for instance -- and the API must + // not be the way around it. + $blockers = Plugin::activationBlockers([$Plugin->get('id')]); + if (count($blockers)) { + $reasons = []; + foreach ($blockers as $name => $reason) { + $reasons[] = sprintf('%s %s', $name, $reason); + } + self::sendResponse( + HTTPResponseCodes::HTTP_BAD_REQUEST, + implode('; ', $reasons) + ); + return; + } + $Plugin->set('state', 1)->save(); + if (!$Plugin->installdb()) { + self::sendResponse( + HTTPResponseCodes::HTTP_INTERNAL_SERVER_ERROR, + sprintf( + // translators: %s is the plugin name. + _('Failed to install %s'), + $Plugin->get('name') + ) + ); + return; + } + // Written here, by the server, having done the work. The + // generic edit route refuses this column for exactly that + // reason -- see Route::$serverOwnedFields. + $Plugin->set('installed', 1)->save(); + self::sendResponse(HTTPResponseCodes::HTTP_NO_CONTENT); + } catch (\Exception $e) { + self::sendResponse( + HTTPResponseCodes::HTTP_INTERNAL_SERVER_ERROR, + $e->getMessage() + ); + } + } /** * Cancels a task element. *