Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Provide migration hook #5046

Merged
merged 48 commits into from
Sep 19, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
21bde13
Introduce database models required by migration hooks
yhabteab Jun 21, 2023
babc594
Introduce base `MigrationHook` class & helpers
yhabteab Jul 24, 2023
81c9e5c
Introduce `MigrationManager` class
yhabteab Aug 2, 2023
2daa144
Introduce `MigrationForm` class
yhabteab Jul 24, 2023
85b63dd
Introduce migration `ListItem` & `ItemList` classes
yhabteab Jul 24, 2023
faaebae
Forward failed requests for routes with pending migrations
yhabteab Jul 24, 2023
a9db85e
Introduce `application/migrations` permission
yhabteab Jul 24, 2023
1da5487
Introduce `MigrationsController` & add pending migrations list in abo…
yhabteab Jul 24, 2023
bc3c444
CSS: Adjust styles
flourish86 Jul 27, 2023
15792fb
Provide `DbMigration` hook & register when bootstrapping
yhabteab Aug 2, 2023
dec2468
Pending migrations CSS enhancement
yhabteab Aug 2, 2023
ce012dc
Hook: Don't abort loading remaining hooks due to one broken hook
yhabteab Aug 7, 2023
d186604
Allow to define row count after which a collapsible can be collapsed
yhabteab Aug 30, 2023
fb33a20
Defferentiate migrations with no provided descriptions
yhabteab Sep 11, 2023
192a21b
Don't use `strong` tag to highlight unselectable items
yhabteab Sep 11, 2023
fdadba5
Fix form with mulitple buttons doesn't recognize whether it's been su…
yhabteab Sep 11, 2023
ad02431
Add extra `class` to outer item lists & render subject header in the …
yhabteab Sep 11, 2023
a00f094
Add extra collapsible container around error section
yhabteab Sep 12, 2023
44897e4
CSS: Styling
flourish86 Sep 12, 2023
7e313c9
MigrationListItemMinimal: Customize markup for styling
flourish86 Sep 12, 2023
2944cea
Rename `getSchemaQueryFor()` & drop `$version` param
yhabteab Sep 12, 2023
4b2784f
Use `Icinga Web` as a component name
yhabteab Sep 12, 2023
13569a3
Check explicitly for `false` before raising an unknown error
yhabteab Sep 12, 2023
821a681
Use `EmptyState(Bar)` classes where applicable
yhabteab Sep 13, 2023
a167b6d
Rename migration list item classes
yhabteab Sep 13, 2023
73b1041
Fix phpstan claims & php code sniffer errors
yhabteab Sep 13, 2023
ac24c6d
Don't traverse schema query if the last successfully migrated version…
yhabteab Sep 13, 2023
80ac314
Schema: Update timestamp & set success of existing schema version entry
yhabteab Sep 13, 2023
ce2073d
Add `2.12` database upgrade docs
yhabteab Sep 13, 2023
12bc950
Don't raise unhandled exceptions in menu context
yhabteab Sep 14, 2023
2657f03
Allow to automatically fix missing grants & elevalte database users
yhabteab Sep 14, 2023
ce89d4a
Rename `Common\DbMigration` -> `DbMigrationStep`
yhabteab Sep 14, 2023
26cae8b
Rename `MigrationHook` -> `DbMigrationHook`
yhabteab Sep 14, 2023
864a783
Make sql schema files consistent
yhabteab Sep 14, 2023
fac3855
DbMigrationStep: Don't cache sql statements unnecessarily
yhabteab Sep 15, 2023
96a6321
DbMigration: Adjust usage of `Database::getDb()`
yhabteab Sep 15, 2023
dc738ec
`DbMigrationHook`: Adjust regex pattern & add missing argument docs
yhabteab Sep 15, 2023
6a43141
Don't use `IF (NOT) EXITS` SQL commands in upgrade scripts
yhabteab Sep 15, 2023
3f37233
CSS: Remove obsolete `icinga-form` styles & store max view width in a…
yhabteab Sep 15, 2023
2505e79
DbMigration: Check for mysql collation name whether to check 2.11 is …
yhabteab Sep 15, 2023
501ab81
docs: Add missing grants in MYSQL manual setup examples
yhabteab Sep 15, 2023
99e8a23
Don't render migrate button in detailed file list view
yhabteab Sep 15, 2023
d2ce60d
Always right align `control-label-group`
yhabteab Sep 15, 2023
47b214e
Use `PDO::fetchColumn()` where applicable
yhabteab Sep 15, 2023
167ff54
Enhance logging
yhabteab Sep 15, 2023
8a1c224
WebWizard: Grant permission for DDL statements by default
nilmerg Sep 15, 2023
4a8d171
migrations/index: Let the migrate all button submit the migration form
nilmerg Sep 15, 2023
9c6d930
MigrationManager: Also check table privileges
nilmerg Sep 15, 2023
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
18 changes: 18 additions & 0 deletions application/controllers/ErrorController.php
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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();
}
}
Loading