Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/web/lib/fog/authorization.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
65 changes: 65 additions & 0 deletions packages/web/lib/fog/openapi.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
114 changes: 114 additions & 0 deletions packages/web/lib/router/route.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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'],
Expand Down Expand Up @@ -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.
*
Expand Down
Loading