Skip to content

Commit

Permalink
Provide migration hook (#5046)
Browse files Browse the repository at this point in the history
resolves #5043
  • Loading branch information
nilmerg committed Sep 19, 2023
2 parents ac369f9 + 9c6d930 commit e4c9266
Show file tree
Hide file tree
Showing 33 changed files with 2,227 additions and 39 deletions.
18 changes: 18 additions & 0 deletions application/controllers/ErrorController.php
Expand Up @@ -3,6 +3,8 @@

namespace Icinga\Controllers;

use Icinga\Application\Hook\DbMigrationHook;
use Icinga\Application\MigrationManager;
use Icinga\Exception\IcingaException;
use Zend_Controller_Plugin_ErrorHandler;
use Icinga\Application\Icinga;
Expand Down Expand Up @@ -91,6 +93,22 @@ public function errorAction()
$this->getResponse()->setHttpResponseCode(403);
break;
default:
$mm = MigrationManager::instance();
$action = $this->getRequest()->getActionName();
$controller = $this->getRequest()->getControllerName();
if ($action !== 'hint' && $controller !== 'migrations' && $mm->hasMigrations($moduleName)) {
// The view renderer from IPL web doesn't render the HTML content set in the respective
// controller if the error_handler request param is set, as it doesn't support error
// rendering. Since this error handler isn't caused by the migrations controller, we can
// safely unset this.
$this->setParam('error_handler', null);
$this->forward('hint', 'migrations', 'default', [
DbMigrationHook::MIGRATION_PARAM => $moduleName
]);

return;
}

$this->getResponse()->setHttpResponseCode(500);
$module = $modules->hasLoaded($moduleName) ? $modules->getModule($moduleName) : null;
Logger::error("%s\n%s", $exception, IcingaException::getConfidentialTraceAsString($exception));
Expand Down
249 changes: 249 additions & 0 deletions application/controllers/MigrationsController.php
@@ -0,0 +1,249 @@
<?php

/* Icinga Web 2 | (c) 2023 Icinga GmbH | GPLv2+ */

namespace Icinga\Controllers;

use Icinga\Application\Hook\DbMigrationHook;
use Icinga\Application\Icinga;
use Icinga\Application\MigrationManager;
use Icinga\Common\Database;
use Icinga\Exception\MissingParameterException;
use Icinga\Forms\MigrationForm;
use Icinga\Web\Notification;
use Icinga\Web\Widget\ItemList\MigrationList;
use Icinga\Web\Widget\Tabextension\OutputFormat;
use ipl\Html\Attributes;
use ipl\Html\FormElement\SubmitButtonElement;
use ipl\Html\HtmlElement;
use ipl\Html\Text;
use ipl\Web\Compat\CompatController;
use ipl\Web\Widget\ActionLink;

class MigrationsController extends CompatController
{
use Database;

public function init()
{
Icinga::app()->getModuleManager()->loadModule('setup');
}

public function indexAction(): void
{
$mm = MigrationManager::instance();

$this->getTabs()->extend(new OutputFormat(['csv']));
$this->addTitleTab($this->translate('Migrations'));

$canApply = $this->hasPermission('application/migrations');
if (! $canApply) {
$this->addControl(
new HtmlElement(
'div',
Attributes::create(['class' => 'migration-state-banner']),
new HtmlElement(
'span',
null,
Text::create(
$this->translate('You do not have the required permission to apply pending migrations.')
)
)
)
);
}

$migrateListForm = new MigrationForm();
$migrateListForm->setAttribute('id', $this->getRequest()->protectId('migration-form'));
$migrateListForm->setRenderDatabaseUserChange(! $mm->validateDatabasePrivileges());

if ($canApply && $mm->hasPendingMigrations()) {
$migrateAllButton = new SubmitButtonElement(sprintf('migrate-%s', DbMigrationHook::ALL_MIGRATIONS), [
'form' => $migrateListForm->getAttribute('id')->getValue(),
'label' => $this->translate('Migrate All'),
'title' => $this->translate('Migrate all pending migrations')
]);

// Is the first button, so will be cloned and that the visible
// button is outside the form doesn't matter for Web's JS
$migrateListForm->registerElement($migrateAllButton);

// Make sure it looks familiar, even if not inside a form
$migrateAllButton->setWrapper(new HtmlElement('div', Attributes::create(['class' => 'icinga-controls'])));

$this->controls->getAttributes()->add('class', 'default-layout');
$this->addControl($migrateAllButton);
}

$this->handleFormatRequest($mm->toArray());

$frameworkList = new MigrationList($mm->yieldMigrations(), $migrateListForm);
$frameworkListControl = new HtmlElement('div', Attributes::create(['class' => 'migration-list-control']));
$frameworkListControl->addHtml(new HtmlElement('h2', null, Text::create($this->translate('System'))));
$frameworkListControl->addHtml($frameworkList);

$moduleList = new MigrationList($mm->yieldMigrations(true), $migrateListForm);
$moduleListControl = new HtmlElement('div', Attributes::create(['class' => 'migration-list-control']));
$moduleListControl->addHtml(new HtmlElement('h2', null, Text::create($this->translate('Modules'))));
$moduleListControl->addHtml($moduleList);

$migrateListForm->addHtml($frameworkListControl, $moduleListControl);
if ($canApply && $mm->hasPendingMigrations()) {
$frameworkList->ensureAssembled();
$moduleList->ensureAssembled();

$this->handleMigrateRequest($migrateListForm);
}

$migrations = new HtmlElement('div', Attributes::create(['class' => 'migrations']));
$migrations->addHtml($migrateListForm);

$this->addContent($migrations);
}

public function hintAction(): void
{
// The forwarded request doesn't modify the original server query string, but adds the migration param to the
// request param instead. So, there is no way to access the migration param other than via the request instance.
/** @var ?string $module */
$module = $this->getRequest()->getParam(DbMigrationHook::MIGRATION_PARAM);
if ($module === null) {
throw new MissingParameterException(
$this->translate('Required parameter \'%s\' missing'),
DbMigrationHook::MIGRATION_PARAM
);
}

$mm = MigrationManager::instance();
if (! $mm->hasMigrations($module)) {
$this->httpNotFound(sprintf('There are no pending migrations matching the given name: %s', $module));
}

$migration = $mm->getMigration($module);
$this->addTitleTab($this->translate('Error'));
$this->addContent(
new HtmlElement(
'div',
Attributes::create(['class' => 'pending-migrations-hint']),
new HtmlElement('h2', null, Text::create($this->translate('Error!'))),
new HtmlElement(
'p',
null,
Text::create(sprintf($this->translate('%s has pending migrations.'), $migration->getName()))
),
new HtmlElement('p', null, Text::create($this->translate('Please apply the migrations first.'))),
new ActionLink($this->translate('View pending Migrations'), 'migrations')
)
);
}

public function migrationAction(): void
{
/** @var string $name */
$name = $this->params->getRequired(DbMigrationHook::MIGRATION_PARAM);

$this->addTitleTab($this->translate('Migration'));
$this->getTabs()->disableLegacyExtensions();
$this->controls->getAttributes()->add('class', 'default-layout');

$mm = MigrationManager::instance();
if (! $mm->hasMigrations($name)) {
$migrations = [];
} else {
$hook = $mm->getMigration($name);
$migrations = array_reverse($hook->getMigrations());
if (! $this->hasPermission('application/migrations')) {
$this->addControl(
new HtmlElement(
'div',
Attributes::create(['class' => 'migration-state-banner']),
new HtmlElement(
'span',
null,
Text::create(
$this->translate('You do not have the required permission to apply pending migrations.')
)
)
)
);
} else {
$this->addControl(
new HtmlElement(
'div',
Attributes::create(['class' => 'migration-controls']),
new HtmlElement('span', null, Text::create($hook->getName()))
)
);
}
}

$migrationWidget = new HtmlElement('div', Attributes::create(['class' => 'migrations']));
$migrationWidget->addHtml((new MigrationList($migrations))->setMinimal(false));
$this->addContent($migrationWidget);
}

public function handleMigrateRequest(MigrationForm $form): void
{
$this->assertPermission('application/migrations');

$form->on(MigrationForm::ON_SUCCESS, function (MigrationForm $form) {
$mm = MigrationManager::instance();

/** @var array<string, string> $elevatedPrivileges */
$elevatedPrivileges = $form->getValue('database_setup');
if ($elevatedPrivileges !== null && $elevatedPrivileges['grant_privileges'] === 'y') {
$mm->fixIcingaWebMysqlGrants($this->getDb(), $elevatedPrivileges);
}

$pressedButton = $form->getPressedSubmitElement();
if ($pressedButton) {
$name = substr($pressedButton->getName(), 8);
switch ($name) {
case DbMigrationHook::ALL_MIGRATIONS:
if ($mm->applyAll($elevatedPrivileges)) {
Notification::success($this->translate('Applied all migrations successfully'));
} else {
Notification::error(
$this->translate(
'Applied migrations successfully. Though, one or more migration hooks'
. ' failed to run. See logs for details'
)
);
}
break;
default:
$migration = $mm->getMigration($name);
if ($mm->apply($migration, $elevatedPrivileges)) {
Notification::success($this->translate('Applied pending migrations successfully'));
} else {
Notification::error(
$this->translate('Failed to apply pending migration(s). See logs for details')
);
}
}
}

$this->sendExtraUpdates(['#col2' => '__CLOSE__']);

$this->redirectNow('migrations');
})->handleRequest($this->getServerRequest());
}

/**
* Handle exports
*
* @param array<string, mixed> $data
*/
protected function handleFormatRequest(array $data): void
{
$formatJson = $this->params->get('format') === 'json';
if (! $formatJson && ! $this->getRequest()->isApiRequest()) {
return;
}

$this->getResponse()
->json()
->setSuccessData($data)
->sendResponse();
}
}

0 comments on commit e4c9266

Please sign in to comment.