mirror of
https://github.com/Icinga/icingaweb2.git
synced 2025-07-27 07:44:04 +02:00
commit
e4c9266da2
@ -3,6 +3,8 @@
|
|||||||
|
|
||||||
namespace Icinga\Controllers;
|
namespace Icinga\Controllers;
|
||||||
|
|
||||||
|
use Icinga\Application\Hook\DbMigrationHook;
|
||||||
|
use Icinga\Application\MigrationManager;
|
||||||
use Icinga\Exception\IcingaException;
|
use Icinga\Exception\IcingaException;
|
||||||
use Zend_Controller_Plugin_ErrorHandler;
|
use Zend_Controller_Plugin_ErrorHandler;
|
||||||
use Icinga\Application\Icinga;
|
use Icinga\Application\Icinga;
|
||||||
@ -91,6 +93,22 @@ class ErrorController extends ActionController
|
|||||||
$this->getResponse()->setHttpResponseCode(403);
|
$this->getResponse()->setHttpResponseCode(403);
|
||||||
break;
|
break;
|
||||||
default:
|
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);
|
$this->getResponse()->setHttpResponseCode(500);
|
||||||
$module = $modules->hasLoaded($moduleName) ? $modules->getModule($moduleName) : null;
|
$module = $modules->hasLoaded($moduleName) ? $modules->getModule($moduleName) : null;
|
||||||
Logger::error("%s\n%s", $exception, IcingaException::getConfidentialTraceAsString($exception));
|
Logger::error("%s\n%s", $exception, IcingaException::getConfidentialTraceAsString($exception));
|
||||||
|
249
application/controllers/MigrationsController.php
Normal file
249
application/controllers/MigrationsController.php
Normal file
@ -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();
|
||||||
|
}
|
||||||
|
}
|
143
application/forms/MigrationForm.php
Normal file
143
application/forms/MigrationForm.php
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/* Icinga Web 2 | (c) 2023 Icinga GmbH | GPLv2+ */
|
||||||
|
|
||||||
|
namespace Icinga\Forms;
|
||||||
|
|
||||||
|
use Icinga\Application\MigrationManager;
|
||||||
|
use ipl\Html\Attributes;
|
||||||
|
use ipl\Html\FormElement\CheckboxElement;
|
||||||
|
use ipl\Html\FormElement\FieldsetElement;
|
||||||
|
use ipl\Html\HtmlElement;
|
||||||
|
use ipl\Html\Text;
|
||||||
|
use ipl\I18n\Translation;
|
||||||
|
use ipl\Validator\CallbackValidator;
|
||||||
|
use ipl\Web\Common\CsrfCounterMeasure;
|
||||||
|
use ipl\Web\Common\FormUid;
|
||||||
|
use ipl\Web\Compat\CompatForm;
|
||||||
|
use ipl\Web\FormDecorator\IcingaFormDecorator;
|
||||||
|
use PDOException;
|
||||||
|
|
||||||
|
class MigrationForm extends CompatForm
|
||||||
|
{
|
||||||
|
use CsrfCounterMeasure;
|
||||||
|
use FormUid;
|
||||||
|
use Translation;
|
||||||
|
|
||||||
|
protected $defaultAttributes = [
|
||||||
|
'class' => ['icinga-form', 'migration-form', 'icinga-controls'],
|
||||||
|
'name' => 'migration-form'
|
||||||
|
];
|
||||||
|
|
||||||
|
/** @var bool Whether to allow changing the current database user and password */
|
||||||
|
protected $renderDatabaseUserChange = false;
|
||||||
|
|
||||||
|
public function hasBeenSubmitted(): bool
|
||||||
|
{
|
||||||
|
if (! $this->hasBeenSent()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$pressedButton = $this->getPressedSubmitElement();
|
||||||
|
|
||||||
|
return $pressedButton && strpos($pressedButton->getName(), 'migrate-') !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setRenderDatabaseUserChange(bool $value = true): self
|
||||||
|
{
|
||||||
|
$this->renderDatabaseUserChange = $value;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasDefaultElementDecorator()
|
||||||
|
{
|
||||||
|
// The base implementation registers a decorator we don't want here
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function assemble(): void
|
||||||
|
{
|
||||||
|
$this->addHtml($this->createUidElement());
|
||||||
|
|
||||||
|
if ($this->renderDatabaseUserChange) {
|
||||||
|
$mm = MigrationManager::instance();
|
||||||
|
$newDbSetup = new FieldsetElement('database_setup', ['required' => true]);
|
||||||
|
$newDbSetup
|
||||||
|
->setDefaultElementDecorator(new IcingaFormDecorator())
|
||||||
|
->addElement('text', 'username', [
|
||||||
|
'required' => true,
|
||||||
|
'label' => $this->translate('Username'),
|
||||||
|
'description' => $this->translate(
|
||||||
|
'A user which is able to create and/or alter the database schema.'
|
||||||
|
)
|
||||||
|
])
|
||||||
|
->addElement('password', 'password', [
|
||||||
|
'required' => true,
|
||||||
|
'autocomplete' => 'new-password',
|
||||||
|
'label' => $this->translate('Password'),
|
||||||
|
'description' => $this->translate('The password for the database user defined above.'),
|
||||||
|
'validators' => [
|
||||||
|
new CallbackValidator(function ($_, CallbackValidator $validator) use ($mm, $newDbSetup): bool {
|
||||||
|
/** @var array<string, string> $values */
|
||||||
|
$values = $this->getValue('database_setup');
|
||||||
|
/** @var CheckboxElement $checkBox */
|
||||||
|
$checkBox = $newDbSetup->getElement('grant_privileges');
|
||||||
|
$canIssueGrants = $checkBox->isChecked();
|
||||||
|
$elevationConfig = [
|
||||||
|
'username' => $values['username'],
|
||||||
|
'password' => $values['password']
|
||||||
|
];
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (! $mm->validateDatabasePrivileges($elevationConfig, $canIssueGrants)) {
|
||||||
|
$validator->addMessage(sprintf(
|
||||||
|
$this->translate(
|
||||||
|
'The provided credentials cannot be used to execute "%s" SQL commands'
|
||||||
|
. ' and/or grant the missing privileges to other users.'
|
||||||
|
),
|
||||||
|
implode(' ,', $mm->getRequiredDatabasePrivileges())
|
||||||
|
));
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
$validator->addMessage($e->getMessage());
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
]
|
||||||
|
])
|
||||||
|
->addElement('checkbox', 'grant_privileges', [
|
||||||
|
'required' => false,
|
||||||
|
'label' => $this->translate('Grant Missing Privileges'),
|
||||||
|
'description' => $this->translate(
|
||||||
|
'Allows to automatically grant the required privileges to the database user specified'
|
||||||
|
. ' in the respective resource config. If you do not want to provide additional credentials'
|
||||||
|
. ' each time, you can enable this and Icinga Web will grant the active database user the'
|
||||||
|
. ' missing privileges.'
|
||||||
|
)
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->addHtml(
|
||||||
|
new HtmlElement(
|
||||||
|
'div',
|
||||||
|
Attributes::create(['class' => 'change-database-user-description']),
|
||||||
|
new HtmlElement('span', null, Text::create(sprintf(
|
||||||
|
$this->translate(
|
||||||
|
'It seems that the currently used database user does not have the required privileges to'
|
||||||
|
. ' execute the %s SQL commands. Please provide an alternative user'
|
||||||
|
. ' that has the appropriate credentials to resolve this issue.'
|
||||||
|
),
|
||||||
|
implode(', ', $mm->getRequiredDatabasePrivileges())
|
||||||
|
)))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->addElement($newDbSetup);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -581,6 +581,9 @@ class RoleForm extends RepositoryForm
|
|||||||
],
|
],
|
||||||
'application/sessions' => [
|
'application/sessions' => [
|
||||||
'description' => t('Allow to manage user sessions')
|
'description' => t('Allow to manage user sessions')
|
||||||
|
],
|
||||||
|
'application/migrations' => [
|
||||||
|
'description' => t('Allow to apply pending application migrations')
|
||||||
]
|
]
|
||||||
];
|
];
|
||||||
|
|
||||||
|
@ -1,7 +1,10 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use Icinga\Application\MigrationManager;
|
||||||
|
use Icinga\Web\Navigation\Renderer\BadgeNavigationItemRenderer;
|
||||||
use ipl\Html\HtmlElement;
|
use ipl\Html\HtmlElement;
|
||||||
use ipl\Web\Widget\Icon;
|
use ipl\Web\Widget\Icon;
|
||||||
|
use ipl\Web\Widget\StateBadge;
|
||||||
|
|
||||||
?>
|
?>
|
||||||
<div class="controls">
|
<div class="controls">
|
||||||
@ -93,6 +96,31 @@ use ipl\Web\Widget\Icon;
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<?php $mm = MigrationManager::instance(); if ($mm->hasPendingMigrations()): ?>
|
||||||
|
<div class="pending-migrations clearfix">
|
||||||
|
<h2><?= $this->translate('Pending Migrations') ?></h2>
|
||||||
|
<table class="name-value-table migrations">
|
||||||
|
<?php foreach ($mm->getPendingMigrations() as $migration): ?>
|
||||||
|
<tr>
|
||||||
|
<th><?= $this->escape($migration->getName()) ?></th>
|
||||||
|
<td><?=
|
||||||
|
new StateBadge(
|
||||||
|
count($migration->getMigrations()),
|
||||||
|
BadgeNavigationItemRenderer::STATE_PENDING
|
||||||
|
);
|
||||||
|
?></td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach ?>
|
||||||
|
</table>
|
||||||
|
<?= $this->qlink(
|
||||||
|
$this->translate('Show all'),
|
||||||
|
'migrations',
|
||||||
|
null,
|
||||||
|
['title' => $this->translate('Show all pending migrations')]
|
||||||
|
) ?>
|
||||||
|
</div>
|
||||||
|
<?php endif ?>
|
||||||
|
|
||||||
<h2><?= $this->translate('Loaded Libraries') ?></h2>
|
<h2><?= $this->translate('Loaded Libraries') ?></h2>
|
||||||
<table class="name-value-table" data-base-target="_next">
|
<table class="name-value-table" data-base-target="_next">
|
||||||
<?php foreach ($libraries as $library): ?>
|
<?php foreach ($libraries as $library): ?>
|
||||||
|
@ -213,7 +213,7 @@ Create the database and add a new user as shown below for MySQL/MariaDB:
|
|||||||
sudo mysql -p
|
sudo mysql -p
|
||||||
|
|
||||||
CREATE DATABASE icingaweb2;
|
CREATE DATABASE icingaweb2;
|
||||||
GRANT SELECT, INSERT, UPDATE, DELETE, DROP, CREATE VIEW, INDEX, EXECUTE ON icingaweb2.* TO 'icingaweb2'@'localhost' IDENTIFIED BY 'icingaweb2';
|
GRANT CREATE, SELECT, INSERT, UPDATE, DELETE, DROP, ALTER, CREATE VIEW, INDEX, EXECUTE ON icingaweb2.* TO 'icingaweb2'@'localhost' IDENTIFIED BY 'icingaweb2';
|
||||||
quit
|
quit
|
||||||
|
|
||||||
mysql -p icingaweb2 < /usr/share/icingaweb2/schema/mysql.schema.sql
|
mysql -p icingaweb2 < /usr/share/icingaweb2/schema/mysql.schema.sql
|
||||||
|
@ -3,6 +3,17 @@
|
|||||||
Specific version upgrades are described below. Please note that upgrades are incremental. An upgrade from
|
Specific version upgrades are described below. Please note that upgrades are incremental. An upgrade from
|
||||||
v2.6 to v2.8 requires to follow the instructions for v2.7 too.
|
v2.6 to v2.8 requires to follow the instructions for v2.7 too.
|
||||||
|
|
||||||
|
## Upgrading to Icinga Web 2.12.0
|
||||||
|
|
||||||
|
**Database Schema**
|
||||||
|
|
||||||
|
With the latest Icinga Web versions, you no longer need to manually import sql upgrade scripts. Icinga Web `>= 2.12`
|
||||||
|
offers you the possibility to perform such migrations in an easy way. You can find and apply all pending migrations
|
||||||
|
of your Icinga Web environment in the menu at `System -> Migrations`.
|
||||||
|
|
||||||
|
You can still apply the `2.12.0.sql` upgrade script manually, depending on your database vendor.
|
||||||
|
For package installations you can find this file in `/usr/share/icingaweb2/schema/*-upgrades/`.
|
||||||
|
|
||||||
## Upgrading to Icinga Web 2.11.x
|
## Upgrading to Icinga Web 2.11.x
|
||||||
|
|
||||||
**General**
|
**General**
|
||||||
|
@ -6,6 +6,7 @@ namespace Icinga\Application;
|
|||||||
use DirectoryIterator;
|
use DirectoryIterator;
|
||||||
use ErrorException;
|
use ErrorException;
|
||||||
use Exception;
|
use Exception;
|
||||||
|
use Icinga\Application\ProvidedHook\DbMigration;
|
||||||
use ipl\I18n\GettextTranslator;
|
use ipl\I18n\GettextTranslator;
|
||||||
use ipl\I18n\StaticTranslator;
|
use ipl\I18n\StaticTranslator;
|
||||||
use LogicException;
|
use LogicException;
|
||||||
@ -731,4 +732,16 @@ abstract class ApplicationBootstrap
|
|||||||
$localedir = $this->getLocaleDir();
|
$localedir = $this->getLocaleDir();
|
||||||
return $localedir !== false && file_exists($localedir) && is_dir($localedir);
|
return $localedir !== false && file_exists($localedir) && is_dir($localedir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register all hooks provided by the main application
|
||||||
|
*
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
protected function registerApplicationHooks(): self
|
||||||
|
{
|
||||||
|
Hook::register('DbMigration', DbMigration::class, DbMigration::class);
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -48,7 +48,8 @@ class Cli extends ApplicationBootstrap
|
|||||||
->setupModuleManager()
|
->setupModuleManager()
|
||||||
->setupUserBackendFactory()
|
->setupUserBackendFactory()
|
||||||
->loadSetupModuleIfNecessary()
|
->loadSetupModuleIfNecessary()
|
||||||
->setupFakeAuthentication();
|
->setupFakeAuthentication()
|
||||||
|
->registerApplicationHooks();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -75,7 +75,8 @@ class EmbeddedWeb extends ApplicationBootstrap
|
|||||||
->setupTimezone()
|
->setupTimezone()
|
||||||
->prepareFakeInternationalization()
|
->prepareFakeInternationalization()
|
||||||
->setupModuleManager()
|
->setupModuleManager()
|
||||||
->loadEnabledModules();
|
->loadEnabledModules()
|
||||||
|
->registerApplicationHooks();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -266,23 +266,21 @@ class Hook
|
|||||||
*
|
*
|
||||||
* @return array
|
* @return array
|
||||||
*/
|
*/
|
||||||
public static function all($name)
|
public static function all($name): array
|
||||||
{
|
{
|
||||||
$name = self::normalizeHookName($name);
|
$name = self::normalizeHookName($name);
|
||||||
if (! self::has($name)) {
|
if (! self::has($name)) {
|
||||||
return array();
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (self::$hooks[$name] as $key => $hook) {
|
foreach (self::$hooks[$name] as $key => $hook) {
|
||||||
list($class, $alwaysRun) = $hook;
|
list($class, $alwaysRun) = $hook;
|
||||||
if ($alwaysRun || self::hasPermission($class)) {
|
if ($alwaysRun || self::hasPermission($class)) {
|
||||||
if (self::createInstance($name, $key) === null) {
|
self::createInstance($name, $key);
|
||||||
return array();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return isset(self::$instances[$name]) ? self::$instances[$name] : array();
|
return self::$instances[$name] ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
129
library/Icinga/Application/Hook/Common/DbMigrationStep.php
Normal file
129
library/Icinga/Application/Hook/Common/DbMigrationStep.php
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/* Icinga Web 2 | (c) 2023 Icinga GmbH | GPLv2+ */
|
||||||
|
|
||||||
|
namespace Icinga\Application\Hook\Common;
|
||||||
|
|
||||||
|
use ipl\Sql\Connection;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
class DbMigrationStep
|
||||||
|
{
|
||||||
|
/** @var string The sql script version the queries are loaded from */
|
||||||
|
protected $version;
|
||||||
|
|
||||||
|
/** @var string */
|
||||||
|
protected $scriptPath;
|
||||||
|
|
||||||
|
/** @var ?string */
|
||||||
|
protected $description;
|
||||||
|
|
||||||
|
/** @var ?string */
|
||||||
|
protected $lastState;
|
||||||
|
|
||||||
|
public function __construct(string $version, string $scriptPath)
|
||||||
|
{
|
||||||
|
$this->scriptPath = $scriptPath;
|
||||||
|
$this->version = $version;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the sql script version the queries are loaded from
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public function getVersion(): string
|
||||||
|
{
|
||||||
|
return $this->version;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get upgrade script relative path name
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public function getScriptPath(): string
|
||||||
|
{
|
||||||
|
return $this->scriptPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the description of this database migration if any
|
||||||
|
*
|
||||||
|
* @return ?string
|
||||||
|
*/
|
||||||
|
public function getDescription(): ?string
|
||||||
|
{
|
||||||
|
return $this->description;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the description of this database migration
|
||||||
|
*
|
||||||
|
* @param ?string $description
|
||||||
|
*
|
||||||
|
* @return DbMigrationStep
|
||||||
|
*/
|
||||||
|
public function setDescription(?string $description): self
|
||||||
|
{
|
||||||
|
$this->description = $description;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the last error message of this hook if any
|
||||||
|
*
|
||||||
|
* @return ?string
|
||||||
|
*/
|
||||||
|
public function getLastState(): ?string
|
||||||
|
{
|
||||||
|
return $this->lastState;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the last error message
|
||||||
|
*
|
||||||
|
* @param ?string $message
|
||||||
|
*
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
public function setLastState(?string $message): self
|
||||||
|
{
|
||||||
|
$this->lastState = $message;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perform the sql migration
|
||||||
|
*
|
||||||
|
* @param Connection $conn
|
||||||
|
*
|
||||||
|
* @return $this
|
||||||
|
*
|
||||||
|
* @throws RuntimeException Throws an error in case of any database errors or when there is nothing to migrate
|
||||||
|
*/
|
||||||
|
public function apply(Connection $conn): self
|
||||||
|
{
|
||||||
|
$statements = @file_get_contents($this->getScriptPath());
|
||||||
|
if ($statements === false) {
|
||||||
|
throw new RuntimeException(sprintf('Cannot load upgrade script %s', $this->getScriptPath()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($statements)) {
|
||||||
|
throw new RuntimeException('Nothing to migrate');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('/\s*delimiter\s*(\S+)\s*$/im', $statements, $matches)) {
|
||||||
|
/** @var string $statements */
|
||||||
|
$statements = preg_replace('/\s*delimiter\s*(\S+)\s*$/im', '', $statements);
|
||||||
|
/** @var string $statements */
|
||||||
|
$statements = preg_replace('/' . preg_quote($matches[1], '/') . '$/m', ';', $statements);
|
||||||
|
}
|
||||||
|
|
||||||
|
$conn->exec($statements);
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
}
|
420
library/Icinga/Application/Hook/DbMigrationHook.php
Normal file
420
library/Icinga/Application/Hook/DbMigrationHook.php
Normal file
@ -0,0 +1,420 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/* Icinga Web 2 | (c) 2023 Icinga GmbH | GPLv2+ */
|
||||||
|
|
||||||
|
namespace Icinga\Application\Hook;
|
||||||
|
|
||||||
|
use Countable;
|
||||||
|
use DateTime;
|
||||||
|
use DirectoryIterator;
|
||||||
|
use Exception;
|
||||||
|
use Icinga\Application\ClassLoader;
|
||||||
|
use Icinga\Application\Hook\Common\DbMigrationStep;
|
||||||
|
use Icinga\Application\Icinga;
|
||||||
|
use Icinga\Application\Logger;
|
||||||
|
use Icinga\Application\Modules\Module;
|
||||||
|
use Icinga\Model\Schema;
|
||||||
|
use Icinga\Web\Session;
|
||||||
|
use ipl\I18n\Translation;
|
||||||
|
use ipl\Orm\Query;
|
||||||
|
use ipl\Sql\Adapter\Pgsql;
|
||||||
|
use ipl\Sql\Connection;
|
||||||
|
use ipl\Stdlib\Filter;
|
||||||
|
use PDO;
|
||||||
|
use SplFileInfo;
|
||||||
|
use stdClass;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allows you to automatically perform database migrations.
|
||||||
|
*
|
||||||
|
* The version numbers of the sql migrations are determined by extracting the respective migration script names.
|
||||||
|
* It's required to place the sql migrate scripts below the respective following directories:
|
||||||
|
*
|
||||||
|
* `{IcingaApp,Module}::baseDir()/schema/{mysql,pgsql}-upgrades`
|
||||||
|
*/
|
||||||
|
abstract class DbMigrationHook implements Countable
|
||||||
|
{
|
||||||
|
use Translation;
|
||||||
|
|
||||||
|
public const MYSQL_UPGRADE_DIR = 'schema/mysql-upgrades';
|
||||||
|
|
||||||
|
public const PGSQL_UPGRADE_DIR = 'schema/pgsql-upgrades';
|
||||||
|
|
||||||
|
/** @var string Fakes a module when this hook is implemented by the framework itself */
|
||||||
|
public const DEFAULT_MODULE = 'icingaweb2';
|
||||||
|
|
||||||
|
/** @var string Migration hook param name */
|
||||||
|
public const MIGRATION_PARAM = 'migration';
|
||||||
|
|
||||||
|
public const ALL_MIGRATIONS = 'all-migrations';
|
||||||
|
|
||||||
|
/** @var ?array<string, DbMigrationStep> All pending database migrations of this hook */
|
||||||
|
protected $migrations;
|
||||||
|
|
||||||
|
/** @var ?string The current version of this hook */
|
||||||
|
protected $version;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get whether the specified table exists in the given database
|
||||||
|
*
|
||||||
|
* @param Connection $conn
|
||||||
|
* @param string $table
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public static function tableExists(Connection $conn, string $table): bool
|
||||||
|
{
|
||||||
|
/** @var false|int $exists */
|
||||||
|
$exists = $conn->prepexec(
|
||||||
|
'SELECT EXISTS(SELECT 1 FROM information_schema.tables WHERE table_name = ?) AS result',
|
||||||
|
$table
|
||||||
|
)->fetchColumn();
|
||||||
|
|
||||||
|
return (bool) $exists;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get whether the specified column exists in the provided table
|
||||||
|
*
|
||||||
|
* @param Connection $conn
|
||||||
|
* @param string $table
|
||||||
|
* @param string $column
|
||||||
|
*
|
||||||
|
* @return ?string
|
||||||
|
*/
|
||||||
|
public static function getColumnType(Connection $conn, string $table, string $column): ?string
|
||||||
|
{
|
||||||
|
$pdoStmt = $conn->prepexec(
|
||||||
|
sprintf(
|
||||||
|
'SELECT %s AS column_type, %s AS column_length FROM information_schema.columns'
|
||||||
|
. ' WHERE table_name = ? AND column_name = ?',
|
||||||
|
$conn->getAdapter() instanceof Pgsql ? 'udt_name' : 'column_type',
|
||||||
|
$conn->getAdapter() instanceof Pgsql ? 'character_maximum_length' : 'NULL'
|
||||||
|
),
|
||||||
|
[$table, $column]
|
||||||
|
);
|
||||||
|
|
||||||
|
/** @var false|stdClass $result */
|
||||||
|
$result = $pdoStmt->fetch(PDO::FETCH_OBJ);
|
||||||
|
if ($result === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($result->column_length !== null) {
|
||||||
|
$result->column_type .= '(' . $result->column_length . ')';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result->column_type;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the mysql collation name of the given column of the specified table
|
||||||
|
*
|
||||||
|
* @param Connection $conn
|
||||||
|
* @param string $table
|
||||||
|
* @param string $column
|
||||||
|
*
|
||||||
|
* @return ?string
|
||||||
|
*/
|
||||||
|
public static function getColumnCollation(Connection $conn, string $table, string $column): ?string
|
||||||
|
{
|
||||||
|
if ($conn->getAdapter() instanceof Pgsql) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var false|string $collation */
|
||||||
|
$collation = $conn->prepexec(
|
||||||
|
'SELECT collation_name FROM information_schema.columns WHERE table_name = ? AND column_name = ?',
|
||||||
|
[$table, $column]
|
||||||
|
)->fetchColumn();
|
||||||
|
|
||||||
|
return ! $collation ? null : $collation;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get statically provided descriptions of the individual migrate scripts
|
||||||
|
*
|
||||||
|
* @return string[]
|
||||||
|
*/
|
||||||
|
abstract public function providedDescriptions(): array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the full name of the component this hook is implemented by
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
abstract public function getName(): string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current schema version of this migration hook
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
abstract public function getVersion(): string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a database connection
|
||||||
|
*
|
||||||
|
* @return Connection
|
||||||
|
*/
|
||||||
|
abstract public function getDb(): Connection;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all the pending migrations of this hook
|
||||||
|
*
|
||||||
|
* @return DbMigrationStep[]
|
||||||
|
*/
|
||||||
|
public function getMigrations(): array
|
||||||
|
{
|
||||||
|
if ($this->migrations === null) {
|
||||||
|
$this->migrations = [];
|
||||||
|
|
||||||
|
$this->load();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->migrations ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the latest migrations limited by the given number
|
||||||
|
*
|
||||||
|
* @param int $limit
|
||||||
|
*
|
||||||
|
* @return DbMigrationStep[]
|
||||||
|
*/
|
||||||
|
public function getLatestMigrations(int $limit): array
|
||||||
|
{
|
||||||
|
$migrations = $this->getMigrations();
|
||||||
|
if ($limit > 0) {
|
||||||
|
$migrations = array_slice($migrations, -$limit, null, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_reverse($migrations);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply all pending migrations of this hook
|
||||||
|
*
|
||||||
|
* @param ?Connection $conn Use the provided database connection to apply the migrations.
|
||||||
|
* Is only used to elevate database users with insufficient privileges.
|
||||||
|
*
|
||||||
|
* @return bool Whether the migration(s) have been successfully applied
|
||||||
|
*/
|
||||||
|
final public function run(Connection $conn = null): bool
|
||||||
|
{
|
||||||
|
if (! $conn) {
|
||||||
|
$conn = $this->getDb();
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($this->getMigrations() as $migration) {
|
||||||
|
try {
|
||||||
|
$migration->apply($conn);
|
||||||
|
|
||||||
|
$this->version = $migration->getVersion();
|
||||||
|
unset($this->migrations[$migration->getVersion()]);
|
||||||
|
|
||||||
|
$data = [
|
||||||
|
'name' => $this->getName(),
|
||||||
|
'version' => $migration->getVersion()
|
||||||
|
];
|
||||||
|
AuditHook::logActivity(
|
||||||
|
'migrations',
|
||||||
|
'Migrated database schema of {{name}} to version {{version}}',
|
||||||
|
$data
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->storeState($migration->getVersion(), null);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
Logger::error(
|
||||||
|
"Failed to apply %s pending migration version %s \n%s",
|
||||||
|
$this->getName(),
|
||||||
|
$migration->getVersion(),
|
||||||
|
$e
|
||||||
|
);
|
||||||
|
Logger::debug($e->getTraceAsString());
|
||||||
|
|
||||||
|
static::insertFailedEntry(
|
||||||
|
$conn,
|
||||||
|
$migration->getVersion(),
|
||||||
|
$e->getMessage() . PHP_EOL . $e->getTraceAsString()
|
||||||
|
);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get whether this hook is implemented by a module
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function isModule(): bool
|
||||||
|
{
|
||||||
|
return ClassLoader::classBelongsToModule(static::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the name of the module this hook is implemented by
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public function getModuleName(): string
|
||||||
|
{
|
||||||
|
if (! $this->isModule()) {
|
||||||
|
return static::DEFAULT_MODULE;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ClassLoader::extractModuleName(static::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the number of pending migrations of this hook
|
||||||
|
*
|
||||||
|
* @return int
|
||||||
|
*/
|
||||||
|
public function count(): int
|
||||||
|
{
|
||||||
|
return count($this->getMigrations());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a schema version query
|
||||||
|
*
|
||||||
|
* @return Query
|
||||||
|
*/
|
||||||
|
abstract protected function getSchemaQuery(): Query;
|
||||||
|
|
||||||
|
protected function load(): void
|
||||||
|
{
|
||||||
|
$upgradeDir = static::MYSQL_UPGRADE_DIR;
|
||||||
|
if ($this->getDb()->getAdapter() instanceof Pgsql) {
|
||||||
|
$upgradeDir = static::PGSQL_UPGRADE_DIR;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->isModule()) {
|
||||||
|
$path = Icinga::app()->getBaseDir();
|
||||||
|
} else {
|
||||||
|
$path = Module::get($this->getModuleName())->getBaseDir();
|
||||||
|
}
|
||||||
|
|
||||||
|
$descriptions = $this->providedDescriptions();
|
||||||
|
$version = $this->getVersion();
|
||||||
|
/** @var SplFileInfo $file */
|
||||||
|
foreach (new DirectoryIterator($path . DIRECTORY_SEPARATOR . $upgradeDir) as $file) {
|
||||||
|
if (preg_match('/^(v)?([^_]+)(?:_(\w+))?\.sql$/', $file->getFilename(), $m, PREG_UNMATCHED_AS_NULL)) {
|
||||||
|
[$_, $_, $migrateVersion, $description] = $m;
|
||||||
|
if ($migrateVersion && version_compare($migrateVersion, $version, '>')) {
|
||||||
|
$migration = new DbMigrationStep($migrateVersion, $file->getRealPath());
|
||||||
|
if (isset($descriptions[$migrateVersion])) {
|
||||||
|
$migration->setDescription($descriptions[$migrateVersion]);
|
||||||
|
} elseif ($description) {
|
||||||
|
$migration->setDescription(str_replace('_', ' ', $description));
|
||||||
|
}
|
||||||
|
|
||||||
|
$migration->setLastState($this->loadLastState($migrateVersion));
|
||||||
|
|
||||||
|
$this->migrations[$migrateVersion] = $migration;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->migrations) {
|
||||||
|
// Sort all the migrations by their version numbers in ascending order.
|
||||||
|
uksort($this->migrations, function ($a, $b) {
|
||||||
|
return version_compare($a, $b);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert failed migration entry into the database or to the session
|
||||||
|
*
|
||||||
|
* @param Connection $conn
|
||||||
|
* @param string $version
|
||||||
|
* @param string $reason
|
||||||
|
*
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
protected function insertFailedEntry(Connection $conn, string $version, string $reason): self
|
||||||
|
{
|
||||||
|
$schemaQuery = $this->getSchemaQuery()
|
||||||
|
->filter(Filter::equal('version', $version));
|
||||||
|
|
||||||
|
if (! static::getColumnType($conn, $schemaQuery->getModel()->getTableName(), 'success')) {
|
||||||
|
$this->storeState($version, $reason);
|
||||||
|
} else {
|
||||||
|
/** @var Schema $schema */
|
||||||
|
$schema = $schemaQuery->first();
|
||||||
|
if ($schema) {
|
||||||
|
$conn->update($schema->getTableName(), [
|
||||||
|
'timestamp' => (new DateTime())->getTimestamp() * 1000.0,
|
||||||
|
'success' => 'n',
|
||||||
|
'reason' => $reason
|
||||||
|
], ['id = ?' => $schema->id]);
|
||||||
|
} else {
|
||||||
|
$conn->insert($schemaQuery->getModel()->getTableName(), [
|
||||||
|
'version' => $version,
|
||||||
|
'timestamp' => (new DateTime())->getTimestamp() * 1000.0,
|
||||||
|
'success' => 'n',
|
||||||
|
'reason' => $reason
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store a failed state message in the session for the given version
|
||||||
|
*
|
||||||
|
* @param string $version
|
||||||
|
* @param ?string $reason
|
||||||
|
*
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
protected function storeState(string $version, ?string $reason): self
|
||||||
|
{
|
||||||
|
$session = Session::getSession()->getNamespace('migrations');
|
||||||
|
/** @var array<string, string> $states */
|
||||||
|
$states = $session->get($this->getModuleName(), []);
|
||||||
|
$states[$version] = $reason;
|
||||||
|
|
||||||
|
$session->set($this->getModuleName(), $states);
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load last failed state from database/session for the given version
|
||||||
|
*
|
||||||
|
* @param string $version
|
||||||
|
*
|
||||||
|
* @return ?string
|
||||||
|
*/
|
||||||
|
protected function loadLastState(string $version): ?string
|
||||||
|
{
|
||||||
|
$session = Session::getSession()->getNamespace('migrations');
|
||||||
|
/** @var array<string, string> $states */
|
||||||
|
$states = $session->get($this->getModuleName(), []);
|
||||||
|
if (! isset($states[$version])) {
|
||||||
|
$schemaQuery = $this->getSchemaQuery()
|
||||||
|
->filter(Filter::equal('version', $version))
|
||||||
|
->filter(Filter::all(Filter::equal('success', 'n')));
|
||||||
|
|
||||||
|
if (static::getColumnType($this->getDb(), $schemaQuery->getModel()->getTableName(), 'reason')) {
|
||||||
|
/** @var Schema $schema */
|
||||||
|
$schema = $schemaQuery->first();
|
||||||
|
if ($schema) {
|
||||||
|
return $schema->reason;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $states[$version];
|
||||||
|
}
|
||||||
|
}
|
406
library/Icinga/Application/MigrationManager.php
Normal file
406
library/Icinga/Application/MigrationManager.php
Normal file
@ -0,0 +1,406 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/* Icinga Web 2 | (c) 2023 Icinga GmbH | GPLv2+ */
|
||||||
|
|
||||||
|
namespace Icinga\Application;
|
||||||
|
|
||||||
|
use Countable;
|
||||||
|
use Generator;
|
||||||
|
use Icinga\Application\Hook\DbMigrationHook;
|
||||||
|
use Icinga\Exception\NotFoundError;
|
||||||
|
use Icinga\Module\Setup\Utils\DbTool;
|
||||||
|
use Icinga\Module\Setup\WebWizard;
|
||||||
|
use ipl\I18n\Translation;
|
||||||
|
use ipl\Sql;
|
||||||
|
use ReflectionClass;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migration manager allows you to manage all pending migrations in a structured way.
|
||||||
|
*/
|
||||||
|
final class MigrationManager implements Countable
|
||||||
|
{
|
||||||
|
use Translation;
|
||||||
|
|
||||||
|
/** @var array<string, DbMigrationHook> All pending migration hooks */
|
||||||
|
protected $pendingMigrations;
|
||||||
|
|
||||||
|
/** @var MigrationManager */
|
||||||
|
private static $instance;
|
||||||
|
|
||||||
|
private function __construct()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the instance of this manager
|
||||||
|
*
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
public static function instance(): self
|
||||||
|
{
|
||||||
|
if (self::$instance === null) {
|
||||||
|
self::$instance = new self();
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::$instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all pending migrations
|
||||||
|
*
|
||||||
|
* @return array<string, DbMigrationHook>
|
||||||
|
*/
|
||||||
|
public function getPendingMigrations(): array
|
||||||
|
{
|
||||||
|
if ($this->pendingMigrations === null) {
|
||||||
|
$this->load();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->pendingMigrations;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get whether there are any pending migrations
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function hasPendingMigrations(): bool
|
||||||
|
{
|
||||||
|
return $this->count() > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasMigrations(string $module): bool
|
||||||
|
{
|
||||||
|
if (! $this->hasPendingMigrations()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return isset($this->getPendingMigrations()[$module]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get pending migration matching the given module name
|
||||||
|
*
|
||||||
|
* @param string $module
|
||||||
|
*
|
||||||
|
* @return DbMigrationHook
|
||||||
|
*
|
||||||
|
* @throws NotFoundError When there are no pending migrations matching the given module name
|
||||||
|
*/
|
||||||
|
public function getMigration(string $module): DbMigrationHook
|
||||||
|
{
|
||||||
|
if (! $this->hasMigrations($module)) {
|
||||||
|
throw new NotFoundError('There are no pending migrations matching the given name: %s', $module);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->getPendingMigrations()[$module];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the number of all pending migrations
|
||||||
|
*
|
||||||
|
* @return int
|
||||||
|
*/
|
||||||
|
public function count(): int
|
||||||
|
{
|
||||||
|
return count($this->getPendingMigrations());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply all pending migrations matching the given migration module name
|
||||||
|
*
|
||||||
|
* @param string $module
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function applyByName(string $module): bool
|
||||||
|
{
|
||||||
|
$migration = $this->getMigration($module);
|
||||||
|
if ($migration->isModule() && $this->hasMigrations(DbMigrationHook::DEFAULT_MODULE)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->apply($migration);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply the given migration hook
|
||||||
|
*
|
||||||
|
* @param DbMigrationHook $hook
|
||||||
|
* @param ?array<string, string> $elevateConfig
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function apply(DbMigrationHook $hook, array $elevateConfig = null): bool
|
||||||
|
{
|
||||||
|
if ($hook->isModule() && $this->hasMigrations(DbMigrationHook::DEFAULT_MODULE)) {
|
||||||
|
Logger::error(
|
||||||
|
'Please apply the Icinga Web pending migration(s) first or apply all the migrations instead'
|
||||||
|
);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$conn = $hook->getDb();
|
||||||
|
if ($elevateConfig && ! $this->checkRequiredPrivileges($conn)) {
|
||||||
|
$conn = $this->elevateDatabaseConnection($conn, $elevateConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($hook->run($conn)) {
|
||||||
|
unset($this->pendingMigrations[$hook->getModuleName()]);
|
||||||
|
|
||||||
|
Logger::info('Applied pending %s migrations successfully', $hook->getName());
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply all pending modules/framework migrations
|
||||||
|
*
|
||||||
|
* @param ?array<string, string> $elevateConfig
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function applyAll(array $elevateConfig = null): bool
|
||||||
|
{
|
||||||
|
$default = DbMigrationHook::DEFAULT_MODULE;
|
||||||
|
if ($this->hasMigrations($default)) {
|
||||||
|
$migration = $this->getMigration($default);
|
||||||
|
if (! $this->apply($migration, $elevateConfig)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$succeeded = true;
|
||||||
|
foreach ($this->getPendingMigrations() as $migration) {
|
||||||
|
if (! $this->apply($migration, $elevateConfig) && $succeeded) {
|
||||||
|
$succeeded = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $succeeded;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Yield module and framework pending migrations separately
|
||||||
|
*
|
||||||
|
* @param bool $modules
|
||||||
|
*
|
||||||
|
* @return Generator<DbMigrationHook>
|
||||||
|
*/
|
||||||
|
public function yieldMigrations(bool $modules = false): Generator
|
||||||
|
{
|
||||||
|
foreach ($this->getPendingMigrations() as $migration) {
|
||||||
|
if ($modules === $migration->isModule()) {
|
||||||
|
yield $migration;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the required database privileges for database migrations
|
||||||
|
*
|
||||||
|
* @return string[]
|
||||||
|
*/
|
||||||
|
public function getRequiredDatabasePrivileges(): array
|
||||||
|
{
|
||||||
|
return ['CREATE','SELECT','INSERT','UPDATE','DELETE','DROP','ALTER','CREATE VIEW','INDEX','EXECUTE'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify whether all database users of all pending migrations do have the required SQL privileges
|
||||||
|
*
|
||||||
|
* @param ?array<string, string> $elevateConfig
|
||||||
|
* @param bool $canIssueGrant
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function validateDatabasePrivileges(array $elevateConfig = null, bool $canIssueGrant = false): bool
|
||||||
|
{
|
||||||
|
if (! $this->hasPendingMigrations()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($this->getPendingMigrations() as $migration) {
|
||||||
|
if (! $this->checkRequiredPrivileges($migration->getDb(), $elevateConfig, $canIssueGrant)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if there are missing grants for the Icinga Web database and fix them
|
||||||
|
*
|
||||||
|
* This fixes the following problems on existing installations:
|
||||||
|
* - Setups made by the wizard have no access to `icingaweb_schema`
|
||||||
|
* - Setups made by the wizard have no DDL grants
|
||||||
|
* - Setups done manually using the advanced documentation chapter have no DDL grants
|
||||||
|
*
|
||||||
|
* @param Sql\Connection $db
|
||||||
|
* @param array<string, string> $elevateConfig
|
||||||
|
*/
|
||||||
|
public function fixIcingaWebMysqlGrants(Sql\Connection $db, array $elevateConfig): void
|
||||||
|
{
|
||||||
|
$wizardProperties = (new ReflectionClass(WebWizard::class))
|
||||||
|
->getDefaultProperties();
|
||||||
|
/** @var array<int, string> $privileges */
|
||||||
|
$privileges = $wizardProperties['databaseUsagePrivileges'];
|
||||||
|
/** @var array<int, string> $tables */
|
||||||
|
$tables = $wizardProperties['databaseTables'];
|
||||||
|
|
||||||
|
$actualUsername = $db->getConfig()->username;
|
||||||
|
$db = $this->elevateDatabaseConnection($db, $elevateConfig);
|
||||||
|
$tool = $this->createDbTool($db);
|
||||||
|
$tool->connectToDb();
|
||||||
|
|
||||||
|
if ($tool->checkPrivileges(['SELECT'], [], $actualUsername)) {
|
||||||
|
// Checks only database level grants. If this succeeds, the grants were issued manually.
|
||||||
|
if (! $tool->checkPrivileges($privileges, [], $actualUsername) && $tool->isGrantable($privileges)) {
|
||||||
|
// Any missing grant is now granted on database level as well, not to mix things up
|
||||||
|
$tool->grantPrivileges($privileges, [], $actualUsername);
|
||||||
|
}
|
||||||
|
} elseif (! $tool->checkPrivileges($privileges, $tables, $actualUsername) && $tool->isGrantable($privileges)) {
|
||||||
|
// The above ensures that if this fails, we can safely apply table level grants, as it's
|
||||||
|
// very likely that the existing grants were issued by the setup wizard
|
||||||
|
$tool->grantPrivileges($privileges, $tables, $actualUsername);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create and return a DbTool instance
|
||||||
|
*
|
||||||
|
* @param Sql\Connection $db
|
||||||
|
*
|
||||||
|
* @return DbTool
|
||||||
|
*/
|
||||||
|
private function createDbTool(Sql\Connection $db): DbTool
|
||||||
|
{
|
||||||
|
$config = $db->getConfig();
|
||||||
|
|
||||||
|
return new DbTool(array_merge([
|
||||||
|
'db' => $config->db,
|
||||||
|
'host' => $config->host,
|
||||||
|
'port' => $config->port,
|
||||||
|
'dbname' => $config->dbname,
|
||||||
|
'username' => $config->username,
|
||||||
|
'password' => $config->password,
|
||||||
|
'charset' => $config->charset
|
||||||
|
], $db->getAdapter()->getOptions($config)));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function load(): void
|
||||||
|
{
|
||||||
|
$this->pendingMigrations = [];
|
||||||
|
|
||||||
|
/** @var DbMigrationHook $hook */
|
||||||
|
foreach (Hook::all('DbMigration') as $hook) {
|
||||||
|
if (empty($hook->getMigrations())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->pendingMigrations[$hook->getModuleName()] = $hook;
|
||||||
|
}
|
||||||
|
|
||||||
|
ksort($this->pendingMigrations);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check the required SQL privileges of the given connection
|
||||||
|
*
|
||||||
|
* @param Sql\Connection $conn
|
||||||
|
* @param ?array<string, string> $elevateConfig
|
||||||
|
* @param bool $canIssueGrants
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
protected function checkRequiredPrivileges(
|
||||||
|
Sql\Connection $conn,
|
||||||
|
array $elevateConfig = null,
|
||||||
|
bool $canIssueGrants = false
|
||||||
|
): bool {
|
||||||
|
if ($elevateConfig) {
|
||||||
|
$conn = $this->elevateDatabaseConnection($conn, $elevateConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
$wizardProperties = (new ReflectionClass(WebWizard::class))
|
||||||
|
->getDefaultProperties();
|
||||||
|
/** @var array<int, string> $tables */
|
||||||
|
$tables = $wizardProperties['databaseTables'];
|
||||||
|
|
||||||
|
$dbTool = $this->createDbTool($conn);
|
||||||
|
$dbTool->connectToDb();
|
||||||
|
if (! $dbTool->checkPrivileges($this->getRequiredDatabasePrivileges())
|
||||||
|
&& ! $dbTool->checkPrivileges($this->getRequiredDatabasePrivileges(), $tables)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($canIssueGrants && ! $dbTool->isGrantable($this->getRequiredDatabasePrivileges())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Override the database config of the given connection by the specified new config
|
||||||
|
*
|
||||||
|
* Overrides only the username and password of existing database connection.
|
||||||
|
*
|
||||||
|
* @param Sql\Connection $conn
|
||||||
|
* @param array<string, string> $elevateConfig
|
||||||
|
* @return Sql\Connection
|
||||||
|
*/
|
||||||
|
protected function elevateDatabaseConnection(Sql\Connection $conn, array $elevateConfig): Sql\Connection
|
||||||
|
{
|
||||||
|
$config = clone $conn->getConfig();
|
||||||
|
$config->username = $elevateConfig['username'];
|
||||||
|
$config->password = $elevateConfig['password'];
|
||||||
|
|
||||||
|
return new Sql\Connection($config);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all pending migrations as an array
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
$framework = [];
|
||||||
|
$serialize = function (DbMigrationHook $hook): array {
|
||||||
|
$serialized = [
|
||||||
|
'name' => $hook->getName(),
|
||||||
|
'module' => $hook->getModuleName(),
|
||||||
|
'isModule' => $hook->isModule(),
|
||||||
|
'migrated_version' => $hook->getVersion(),
|
||||||
|
'migrations' => []
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($hook->getMigrations() as $migration) {
|
||||||
|
$serialized['migrations'][$migration->getVersion()] = [
|
||||||
|
'path' => $migration->getScriptPath(),
|
||||||
|
'error' => $migration->getLastState()
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $serialized;
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach ($this->yieldMigrations() as $migration) {
|
||||||
|
$framework[] = $serialize($migration);
|
||||||
|
}
|
||||||
|
|
||||||
|
$modules = [];
|
||||||
|
foreach ($this->yieldMigrations(true) as $migration) {
|
||||||
|
$modules[] = $serialize($migration);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['System' => $framework, 'Modules' => $modules];
|
||||||
|
}
|
||||||
|
}
|
83
library/Icinga/Application/ProvidedHook/DbMigration.php
Normal file
83
library/Icinga/Application/ProvidedHook/DbMigration.php
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/* Icinga Web 2 | (c) 2023 Icinga GmbH | GPLv2+ */
|
||||||
|
|
||||||
|
namespace Icinga\Application\ProvidedHook;
|
||||||
|
|
||||||
|
use Icinga\Application\Hook\DbMigrationHook;
|
||||||
|
use Icinga\Common\Database;
|
||||||
|
use Icinga\Model\Schema;
|
||||||
|
use ipl\Orm\Query;
|
||||||
|
use ipl\Sql\Connection;
|
||||||
|
|
||||||
|
class DbMigration extends DbMigrationHook
|
||||||
|
{
|
||||||
|
use Database {
|
||||||
|
getDb as private getWebDb;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDb(): Connection
|
||||||
|
{
|
||||||
|
return $this->getWebDb();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getName(): string
|
||||||
|
{
|
||||||
|
return $this->translate('Icinga Web');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function providedDescriptions(): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getVersion(): string
|
||||||
|
{
|
||||||
|
if ($this->version === null) {
|
||||||
|
$conn = $this->getDb();
|
||||||
|
$schemaQuery = $this->getSchemaQuery()
|
||||||
|
->orderBy('id', SORT_DESC)
|
||||||
|
->limit(2);
|
||||||
|
|
||||||
|
if (static::getColumnType($conn, $schemaQuery->getModel()->getTableName(), 'success')) {
|
||||||
|
/** @var Schema $schema */
|
||||||
|
foreach ($schemaQuery as $schema) {
|
||||||
|
if ($schema->success) {
|
||||||
|
$this->version = $schema->version;
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->version) {
|
||||||
|
$this->version = '2.12.0';
|
||||||
|
}
|
||||||
|
} elseif (static::tableExists($conn, $schemaQuery->getModel()->getTableName())
|
||||||
|
|| static::getColumnCollation($conn, 'icingaweb_user_preference', 'username') === 'utf8mb4_unicode_ci'
|
||||||
|
) {
|
||||||
|
$this->version = '2.11.0';
|
||||||
|
} elseif (static::tableExists($conn, 'icingaweb_rememberme')) {
|
||||||
|
$randomIvType = static::getColumnType($conn, 'icingaweb_rememberme', 'random_iv');
|
||||||
|
if ($randomIvType === 'varchar(32)') {
|
||||||
|
$this->version = '2.9.1';
|
||||||
|
} else {
|
||||||
|
$this->version = '2.9.0';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$usernameType = static::getColumnType($conn, 'icingaweb_group_membership', 'username');
|
||||||
|
if ($usernameType === 'varchar(254)') {
|
||||||
|
$this->version = '2.5.0';
|
||||||
|
} else {
|
||||||
|
$this->version = '2.0.0';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->version;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getSchemaQuery(): Query
|
||||||
|
{
|
||||||
|
return Schema::on($this->getDb());
|
||||||
|
}
|
||||||
|
}
|
@ -104,7 +104,8 @@ class Web extends EmbeddedWeb
|
|||||||
->setupUser()
|
->setupUser()
|
||||||
->setupTimezone()
|
->setupTimezone()
|
||||||
->setupInternationalization()
|
->setupInternationalization()
|
||||||
->setupFatalErrorHandling();
|
->setupFatalErrorHandling()
|
||||||
|
->registerApplicationHooks();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -22,7 +22,7 @@ trait Database
|
|||||||
*
|
*
|
||||||
* @throws \Icinga\Exception\ConfigurationError
|
* @throws \Icinga\Exception\ConfigurationError
|
||||||
*/
|
*/
|
||||||
protected function getDb()
|
protected function getDb(): Connection
|
||||||
{
|
{
|
||||||
if (! $this->hasDb()) {
|
if (! $this->hasDb()) {
|
||||||
throw new LogicException('Please check if a db instance exists at all');
|
throw new LogicException('Please check if a db instance exists at all');
|
||||||
|
49
library/Icinga/Model/Schema.php
Normal file
49
library/Icinga/Model/Schema.php
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/* Icinga Web 2 | (c) 2023 Icinga GmbH | GPLv2+ */
|
||||||
|
|
||||||
|
namespace Icinga\Model;
|
||||||
|
|
||||||
|
use DateTime;
|
||||||
|
use ipl\Orm\Behavior\BoolCast;
|
||||||
|
use ipl\Orm\Behavior\MillisecondTimestamp;
|
||||||
|
use ipl\Orm\Behaviors;
|
||||||
|
use ipl\Orm\Model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A database model for Icinga Web schema version table
|
||||||
|
*
|
||||||
|
* @property int $id Unique identifier of the database schema entries
|
||||||
|
* @property string $version The current schema version of Icinga Web
|
||||||
|
* @property DateTime $timestamp The insert/modify time of the schema entry
|
||||||
|
* @property bool $success Whether the database migration of the current version was successful
|
||||||
|
* @property ?string $reason The reason why the database migration has failed
|
||||||
|
*/
|
||||||
|
class Schema extends Model
|
||||||
|
{
|
||||||
|
public function getTableName(): string
|
||||||
|
{
|
||||||
|
return 'icingaweb_schema';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getKeyName()
|
||||||
|
{
|
||||||
|
return 'id';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getColumns(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'version',
|
||||||
|
'timestamp',
|
||||||
|
'success',
|
||||||
|
'reason'
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createBehaviors(Behaviors $behaviors): void
|
||||||
|
{
|
||||||
|
$behaviors->add(new BoolCast(['success']));
|
||||||
|
$behaviors->add(new MillisecondTimestamp(['timestamp']));
|
||||||
|
}
|
||||||
|
}
|
@ -6,7 +6,9 @@ namespace Icinga\Web\Navigation;
|
|||||||
use Icinga\Application\Hook\HealthHook;
|
use Icinga\Application\Hook\HealthHook;
|
||||||
use Icinga\Application\Icinga;
|
use Icinga\Application\Icinga;
|
||||||
use Icinga\Application\Logger;
|
use Icinga\Application\Logger;
|
||||||
|
use Icinga\Application\MigrationManager;
|
||||||
use Icinga\Authentication\Auth;
|
use Icinga\Authentication\Auth;
|
||||||
|
use Icinga\Web\Navigation\Renderer\BadgeNavigationItemRenderer;
|
||||||
use ipl\Html\Attributes;
|
use ipl\Html\Attributes;
|
||||||
use ipl\Html\BaseHtmlElement;
|
use ipl\Html\BaseHtmlElement;
|
||||||
use ipl\Html\HtmlElement;
|
use ipl\Html\HtmlElement;
|
||||||
@ -14,6 +16,7 @@ use ipl\Html\Text;
|
|||||||
use ipl\Web\Url;
|
use ipl\Web\Url;
|
||||||
use ipl\Web\Widget\Icon;
|
use ipl\Web\Widget\Icon;
|
||||||
use ipl\Web\Widget\StateBadge;
|
use ipl\Web\Widget\StateBadge;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
class ConfigMenu extends BaseHtmlElement
|
class ConfigMenu extends BaseHtmlElement
|
||||||
{
|
{
|
||||||
@ -47,6 +50,10 @@ class ConfigMenu extends BaseHtmlElement
|
|||||||
'label' => t('Health'),
|
'label' => t('Health'),
|
||||||
'url' => 'health',
|
'url' => 'health',
|
||||||
],
|
],
|
||||||
|
'migrations' => [
|
||||||
|
'label' => t('Migrations'),
|
||||||
|
'url' => 'migrations',
|
||||||
|
],
|
||||||
'announcements' => [
|
'announcements' => [
|
||||||
'label' => t('Announcements'),
|
'label' => t('Announcements'),
|
||||||
'url' => 'announcements'
|
'url' => 'announcements'
|
||||||
@ -136,7 +143,7 @@ class ConfigMenu extends BaseHtmlElement
|
|||||||
null,
|
null,
|
||||||
[
|
[
|
||||||
new Icon('cog'),
|
new Icon('cog'),
|
||||||
$this->createHealthBadge(),
|
$this->createHealthBadge() ?? $this->createMigrationBadge(),
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
$this->createLevel2Menu()
|
$this->createLevel2Menu()
|
||||||
@ -211,7 +218,7 @@ class ConfigMenu extends BaseHtmlElement
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function createHealthBadge()
|
protected function createHealthBadge(): ?StateBadge
|
||||||
{
|
{
|
||||||
$stateBadge = null;
|
$stateBadge = null;
|
||||||
if ($this->getHealthCount() > 0) {
|
if ($this->getHealthCount() > 0) {
|
||||||
@ -222,6 +229,25 @@ class ConfigMenu extends BaseHtmlElement
|
|||||||
return $stateBadge;
|
return $stateBadge;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function createMigrationBadge(): ?StateBadge
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$mm = MigrationManager::instance();
|
||||||
|
$count = $mm->count();
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
Logger::error('Failed to load pending migrations: %s', $e);
|
||||||
|
$count = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stateBadge = null;
|
||||||
|
if ($count > 0) {
|
||||||
|
$stateBadge = new StateBadge($count, BadgeNavigationItemRenderer::STATE_PENDING);
|
||||||
|
$stateBadge->addAttributes(['class' => 'disabled']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $stateBadge;
|
||||||
|
}
|
||||||
|
|
||||||
protected function createLevel2Menu()
|
protected function createLevel2Menu()
|
||||||
{
|
{
|
||||||
$level2Nav = HtmlElement::create(
|
$level2Nav = HtmlElement::create(
|
||||||
@ -240,23 +266,26 @@ class ConfigMenu extends BaseHtmlElement
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$healthBadge = null;
|
$stateBadge = null;
|
||||||
$class = null;
|
$class = null;
|
||||||
if ($key === 'health') {
|
if ($key === 'health') {
|
||||||
$class = 'badge-nav-item';
|
$class = 'badge-nav-item';
|
||||||
$healthBadge = $this->createHealthBadge();
|
$stateBadge = $this->createHealthBadge();
|
||||||
|
} elseif ($key === 'migrations') {
|
||||||
|
$class = 'badge-nav-item';
|
||||||
|
$stateBadge = $this->createMigrationBadge();
|
||||||
}
|
}
|
||||||
|
|
||||||
$li = HtmlElement::create(
|
$li = HtmlElement::create(
|
||||||
'li',
|
'li',
|
||||||
isset($item['atts']) ? $item['atts'] : [],
|
$item['atts'] ?? [],
|
||||||
[
|
[
|
||||||
HtmlElement::create(
|
HtmlElement::create(
|
||||||
'a',
|
'a',
|
||||||
Attributes::create(['href' => Url::fromPath($item['url'])]),
|
Attributes::create(['href' => Url::fromPath($item['url'])]),
|
||||||
[
|
[
|
||||||
$item['label'],
|
$item['label'],
|
||||||
isset($healthBadge) ? $healthBadge : ''
|
$stateBadge ?? ''
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
@ -80,7 +80,8 @@ class StyleSheet
|
|||||||
'css/icinga/modal.less',
|
'css/icinga/modal.less',
|
||||||
'css/icinga/audit.less',
|
'css/icinga/audit.less',
|
||||||
'css/icinga/health.less',
|
'css/icinga/health.less',
|
||||||
'css/icinga/php-diff.less'
|
'css/icinga/php-diff.less',
|
||||||
|
'css/icinga/pending-migration.less',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
92
library/Icinga/Web/Widget/ItemList/MigrationFileListItem.php
Normal file
92
library/Icinga/Web/Widget/ItemList/MigrationFileListItem.php
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/* Icinga Web 2 | (c) 2023 Icinga GmbH | GPLv2+ */
|
||||||
|
|
||||||
|
namespace Icinga\Web\Widget\ItemList;
|
||||||
|
|
||||||
|
use Icinga\Application\Hook\Common\DbMigrationStep;
|
||||||
|
use ipl\Html\Attributes;
|
||||||
|
use ipl\Html\BaseHtmlElement;
|
||||||
|
use ipl\Html\Html;
|
||||||
|
use ipl\Html\HtmlElement;
|
||||||
|
use ipl\Html\HtmlString;
|
||||||
|
use ipl\Html\Text;
|
||||||
|
use ipl\I18n\Translation;
|
||||||
|
use ipl\Web\Common\BaseListItem;
|
||||||
|
use ipl\Web\Widget\EmptyState;
|
||||||
|
use ipl\Web\Widget\Icon;
|
||||||
|
|
||||||
|
class MigrationFileListItem extends BaseListItem
|
||||||
|
{
|
||||||
|
use Translation;
|
||||||
|
|
||||||
|
/** @var DbMigrationStep Just for type hint */
|
||||||
|
protected $item;
|
||||||
|
|
||||||
|
protected function assembleVisual(BaseHtmlElement $visual): void
|
||||||
|
{
|
||||||
|
if ($this->item->getLastState()) {
|
||||||
|
$visual->getAttributes()->add('class', 'upgrade-failed');
|
||||||
|
$visual->addHtml(new Icon('circle-xmark'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function assembleTitle(BaseHtmlElement $title): void
|
||||||
|
{
|
||||||
|
$scriptPath = $this->item->getScriptPath();
|
||||||
|
/** @var string $parentDirs */
|
||||||
|
$parentDirs = substr($scriptPath, (int) strpos($scriptPath, 'schema'));
|
||||||
|
$parentDirs = substr($parentDirs, 0, strrpos($parentDirs, '/') + 1);
|
||||||
|
|
||||||
|
$title->addHtml(
|
||||||
|
new HtmlElement('span', null, Text::create($parentDirs)),
|
||||||
|
new HtmlElement(
|
||||||
|
'span',
|
||||||
|
Attributes::create(['class' => 'version']),
|
||||||
|
Text::create($this->item->getVersion() . '.sql')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($this->item->getLastState()) {
|
||||||
|
$title->addHtml(
|
||||||
|
new HtmlElement(
|
||||||
|
'span',
|
||||||
|
Attributes::create(['class' => 'upgrade-failed']),
|
||||||
|
Text::create($this->translate('Upgrade failed'))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function assembleHeader(BaseHtmlElement $header): void
|
||||||
|
{
|
||||||
|
$header->addHtml($this->createTitle());
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function assembleCaption(BaseHtmlElement $caption): void
|
||||||
|
{
|
||||||
|
if ($this->item->getDescription()) {
|
||||||
|
$caption->addHtml(Text::create($this->item->getDescription()));
|
||||||
|
} else {
|
||||||
|
$caption->addHtml(new EmptyState(Text::create($this->translate('No description provided.'))));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function assembleFooter(BaseHtmlElement $footer): void
|
||||||
|
{
|
||||||
|
if ($this->item->getLastState()) {
|
||||||
|
$footer->addHtml(
|
||||||
|
new HtmlElement(
|
||||||
|
'section',
|
||||||
|
Attributes::create(['class' => 'caption']),
|
||||||
|
new HtmlElement('pre', null, new HtmlString(Html::escape($this->item->getLastState())))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function assembleMain(BaseHtmlElement $main): void
|
||||||
|
{
|
||||||
|
$main->addHtml($this->createHeader(), $this->createCaption());
|
||||||
|
}
|
||||||
|
}
|
133
library/Icinga/Web/Widget/ItemList/MigrationList.php
Normal file
133
library/Icinga/Web/Widget/ItemList/MigrationList.php
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/* Icinga Web 2 | (c) 2023 Icinga GmbH | GPLv2+ */
|
||||||
|
|
||||||
|
namespace Icinga\Web\Widget\ItemList;
|
||||||
|
|
||||||
|
use Generator;
|
||||||
|
use Icinga\Application\Hook\Common\DbMigrationStep;
|
||||||
|
use Icinga\Application\Hook\DbMigrationHook;
|
||||||
|
use Icinga\Application\MigrationManager;
|
||||||
|
use Icinga\Forms\MigrationForm;
|
||||||
|
use ipl\I18n\Translation;
|
||||||
|
use ipl\Web\Common\BaseItemList;
|
||||||
|
use ipl\Web\Widget\EmptyStateBar;
|
||||||
|
|
||||||
|
class MigrationList extends BaseItemList
|
||||||
|
{
|
||||||
|
use Translation;
|
||||||
|
|
||||||
|
protected $baseAttributes = ['class' => 'item-list'];
|
||||||
|
|
||||||
|
/** @var Generator<DbMigrationHook> */
|
||||||
|
protected $data;
|
||||||
|
|
||||||
|
/** @var ?MigrationForm */
|
||||||
|
protected $migrationForm;
|
||||||
|
|
||||||
|
/** @var bool Whether to render minimal migration list items */
|
||||||
|
protected $minimal = true;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new migration list
|
||||||
|
*
|
||||||
|
* @param Generator<DbMigrationHook>|array<DbMigrationStep|DbMigrationHook> $data
|
||||||
|
*
|
||||||
|
* @param ?MigrationForm $form
|
||||||
|
*/
|
||||||
|
public function __construct($data, MigrationForm $form = null)
|
||||||
|
{
|
||||||
|
parent::__construct($data);
|
||||||
|
|
||||||
|
$this->migrationForm = $form;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set whether to render minimal migration list items
|
||||||
|
*
|
||||||
|
* @param bool $minimal
|
||||||
|
*
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
public function setMinimal(bool $minimal): self
|
||||||
|
{
|
||||||
|
$this->minimal = $minimal;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get whether to render minimal migration list items
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function isMinimal(): bool
|
||||||
|
{
|
||||||
|
return $this->minimal;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getItemClass(): string
|
||||||
|
{
|
||||||
|
if ($this->isMinimal()) {
|
||||||
|
return MigrationListItem::class;
|
||||||
|
}
|
||||||
|
|
||||||
|
return MigrationFileListItem::class;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function assemble(): void
|
||||||
|
{
|
||||||
|
$itemClass = $this->getItemClass();
|
||||||
|
if (! $this->isMinimal()) {
|
||||||
|
$this->getAttributes()->add('class', 'file-list');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var DbMigrationHook $data */
|
||||||
|
foreach ($this->data as $data) {
|
||||||
|
/** @var MigrationFileListItem|MigrationListItem $item */
|
||||||
|
$item = new $itemClass($data, $this);
|
||||||
|
if ($item instanceof MigrationListItem && $this->migrationForm) {
|
||||||
|
$migrateButton = $this->migrationForm->createElement(
|
||||||
|
'submit',
|
||||||
|
sprintf('migrate-%s', $data->getModuleName()),
|
||||||
|
[
|
||||||
|
'required' => false,
|
||||||
|
'label' => $this->translate('Migrate'),
|
||||||
|
'title' => sprintf(
|
||||||
|
$this->translatePlural(
|
||||||
|
'Migrate %d pending migration',
|
||||||
|
'Migrate all %d pending migrations',
|
||||||
|
$data->count()
|
||||||
|
),
|
||||||
|
$data->count()
|
||||||
|
)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$mm = MigrationManager::instance();
|
||||||
|
if ($data->isModule() && $mm->hasMigrations(DbMigrationHook::DEFAULT_MODULE)) {
|
||||||
|
$migrateButton->getAttributes()
|
||||||
|
->set('disabled', true)
|
||||||
|
->set(
|
||||||
|
'title',
|
||||||
|
$this->translate(
|
||||||
|
'Please apply all the pending migrations of Icinga Web first or use the apply all'
|
||||||
|
. ' button instead.'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->migrationForm->registerElement($migrateButton);
|
||||||
|
|
||||||
|
$item->setMigrateButton($migrateButton);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->addHtml($item);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->isEmpty()) {
|
||||||
|
$this->setTag('div');
|
||||||
|
$this->addHtml(new EmptyStateBar(t('No items found.')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
151
library/Icinga/Web/Widget/ItemList/MigrationListItem.php
Normal file
151
library/Icinga/Web/Widget/ItemList/MigrationListItem.php
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/* Icinga Web 2 | (c) 2023 Icinga GmbH | GPLv2+ */
|
||||||
|
|
||||||
|
namespace Icinga\Web\Widget\ItemList;
|
||||||
|
|
||||||
|
use Icinga\Application\Hook\Common\DbMigrationStep;
|
||||||
|
use Icinga\Application\Hook\DbMigrationHook;
|
||||||
|
use ipl\Html\Attributes;
|
||||||
|
use ipl\Html\BaseHtmlElement;
|
||||||
|
use ipl\Html\Contract\FormElement;
|
||||||
|
use ipl\Html\FormattedString;
|
||||||
|
use ipl\Html\Html;
|
||||||
|
use ipl\Html\HtmlElement;
|
||||||
|
use ipl\Html\HtmlString;
|
||||||
|
use ipl\Html\Text;
|
||||||
|
use ipl\I18n\Translation;
|
||||||
|
use ipl\Web\Common\BaseListItem;
|
||||||
|
use ipl\Web\Url;
|
||||||
|
use ipl\Web\Widget\EmptyState;
|
||||||
|
use ipl\Web\Widget\Icon;
|
||||||
|
use ipl\Web\Widget\Link;
|
||||||
|
use LogicException;
|
||||||
|
|
||||||
|
class MigrationListItem extends BaseListItem
|
||||||
|
{
|
||||||
|
use Translation;
|
||||||
|
|
||||||
|
/** @var ?FormElement */
|
||||||
|
protected $migrateButton;
|
||||||
|
|
||||||
|
/** @var DbMigrationHook Just for type hint */
|
||||||
|
protected $item;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a migration form of this list item
|
||||||
|
*
|
||||||
|
* @param FormElement $migrateButton
|
||||||
|
*
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
public function setMigrateButton(FormElement $migrateButton): self
|
||||||
|
{
|
||||||
|
$this->migrateButton = $migrateButton;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function assembleTitle(BaseHtmlElement $title): void
|
||||||
|
{
|
||||||
|
$title->addHtml(
|
||||||
|
FormattedString::create(
|
||||||
|
t('%s ', '<name>'),
|
||||||
|
HtmlElement::create('span', ['class' => 'subject'], $this->item->getName())
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function assembleHeader(BaseHtmlElement $header): void
|
||||||
|
{
|
||||||
|
if ($this->migrateButton === null) {
|
||||||
|
throw new LogicException('Please set the migrate submit button beforehand');
|
||||||
|
}
|
||||||
|
|
||||||
|
$header->addHtml($this->createTitle());
|
||||||
|
$header->addHtml($this->migrateButton);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function assembleCaption(BaseHtmlElement $caption): void
|
||||||
|
{
|
||||||
|
$migrations = $this->item->getMigrations();
|
||||||
|
/** @var DbMigrationStep $migration */
|
||||||
|
$migration = array_shift($migrations);
|
||||||
|
if ($migration->getLastState()) {
|
||||||
|
if ($migration->getDescription()) {
|
||||||
|
$caption->addHtml(Text::create($migration->getDescription()));
|
||||||
|
} else {
|
||||||
|
$caption->addHtml(new EmptyState(Text::create($this->translate('No description provided.'))));
|
||||||
|
}
|
||||||
|
|
||||||
|
$scriptPath = $migration->getScriptPath();
|
||||||
|
/** @var string $parentDirs */
|
||||||
|
$parentDirs = substr($scriptPath, (int) strpos($scriptPath, 'schema'));
|
||||||
|
$parentDirs = substr($parentDirs, 0, strrpos($parentDirs, '/') + 1);
|
||||||
|
|
||||||
|
$title = new HtmlElement('div', Attributes::create(['class' => 'title']));
|
||||||
|
$title->addHtml(
|
||||||
|
new HtmlElement('span', null, Text::create($parentDirs)),
|
||||||
|
new HtmlElement(
|
||||||
|
'span',
|
||||||
|
Attributes::create(['class' => 'version']),
|
||||||
|
Text::create($migration->getVersion() . '.sql')
|
||||||
|
),
|
||||||
|
new HtmlElement(
|
||||||
|
'span',
|
||||||
|
Attributes::create(['class' => 'upgrade-failed']),
|
||||||
|
Text::create($this->translate('Upgrade failed'))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
$error = new HtmlElement('div', Attributes::create([
|
||||||
|
'class' => 'collapsible',
|
||||||
|
'data-visible-height' => '58',
|
||||||
|
]));
|
||||||
|
$error->addHtml(new HtmlElement('pre', null, new HtmlString(Html::escape($migration->getLastState()))));
|
||||||
|
|
||||||
|
$errorSection = new HtmlElement('div', Attributes::create(['class' => 'errors-section',]));
|
||||||
|
$errorSection->addHtml(
|
||||||
|
new HtmlElement('header', null, new Icon('circle-xmark', ['class' => 'status-icon']), $title),
|
||||||
|
$caption,
|
||||||
|
$error
|
||||||
|
);
|
||||||
|
|
||||||
|
$caption->prependWrapper($errorSection);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function assembleFooter(BaseHtmlElement $footer): void
|
||||||
|
{
|
||||||
|
$footer->addHtml((new MigrationList($this->item->getLatestMigrations(3)))->setMinimal(false));
|
||||||
|
if ($this->item->count() > 3) {
|
||||||
|
$footer->addHtml(
|
||||||
|
new Link(
|
||||||
|
sprintf($this->translate('Show all %d migrations'), $this->item->count()),
|
||||||
|
Url::fromPath(
|
||||||
|
'migrations/migration',
|
||||||
|
[DbMigrationHook::MIGRATION_PARAM => $this->item->getModuleName()]
|
||||||
|
),
|
||||||
|
[
|
||||||
|
'data-base-target' => '_next',
|
||||||
|
'class' => 'show-more'
|
||||||
|
]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function assembleMain(BaseHtmlElement $main): void
|
||||||
|
{
|
||||||
|
$main->addHtml($this->createHeader());
|
||||||
|
$caption = $this->createCaption();
|
||||||
|
if (! $caption->isEmpty()) {
|
||||||
|
$main->addHtml($caption);
|
||||||
|
}
|
||||||
|
|
||||||
|
$footer = $this->createFooter();
|
||||||
|
if ($footer) {
|
||||||
|
$main->addHtml($footer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -78,6 +78,11 @@ class WebWizard extends Wizard implements SetupWizard
|
|||||||
'UPDATE',
|
'UPDATE',
|
||||||
'DELETE',
|
'DELETE',
|
||||||
'EXECUTE',
|
'EXECUTE',
|
||||||
|
'CREATE',
|
||||||
|
'CREATE VIEW',
|
||||||
|
'ALTER',
|
||||||
|
'DROP',
|
||||||
|
'INDEX',
|
||||||
'TEMPORARY', // PostgreSql
|
'TEMPORARY', // PostgreSql
|
||||||
'CREATE TEMPORARY TABLES' // MySQL
|
'CREATE TEMPORARY TABLES' // MySQL
|
||||||
);
|
);
|
||||||
|
@ -13165,11 +13165,6 @@ parameters:
|
|||||||
count: 1
|
count: 1
|
||||||
path: library/Icinga/Web/Navigation/ConfigMenu.php
|
path: library/Icinga/Web/Navigation/ConfigMenu.php
|
||||||
|
|
||||||
-
|
|
||||||
message: "#^Method Icinga\\\\Web\\\\Navigation\\\\ConfigMenu\\:\\:createHealthBadge\\(\\) has no return type specified\\.$#"
|
|
||||||
count: 1
|
|
||||||
path: library/Icinga/Web/Navigation/ConfigMenu.php
|
|
||||||
|
|
||||||
-
|
-
|
||||||
message: "#^Method Icinga\\\\Web\\\\Navigation\\\\ConfigMenu\\:\\:createLevel2Menu\\(\\) has no return type specified\\.$#"
|
message: "#^Method Icinga\\\\Web\\\\Navigation\\\\ConfigMenu\\:\\:createLevel2Menu\\(\\) has no return type specified\\.$#"
|
||||||
count: 1
|
count: 1
|
||||||
|
@ -49,6 +49,8 @@ parameters:
|
|||||||
count: 2
|
count: 2
|
||||||
path: library/Icinga/Protocol/Ldap/LdapConnection.php
|
path: library/Icinga/Protocol/Ldap/LdapConnection.php
|
||||||
|
|
||||||
|
- '#Call to an undefined method ipl\\Sql\\Connection::exec\(\)#'
|
||||||
|
|
||||||
scanDirectories:
|
scanDirectories:
|
||||||
- vendor
|
- vendor
|
||||||
|
|
||||||
@ -56,6 +58,7 @@ parameters:
|
|||||||
- library/Icinga/Test
|
- library/Icinga/Test
|
||||||
|
|
||||||
universalObjectCratesClasses:
|
universalObjectCratesClasses:
|
||||||
|
- ipl\Orm\Model
|
||||||
- Icinga\Data\ConfigObject
|
- Icinga\Data\ConfigObject
|
||||||
- Icinga\Web\View
|
- Icinga\Web\View
|
||||||
- Icinga\Module\Monitoring\Object\MonitoredObject
|
- Icinga\Module\Monitoring\Object\MonitoredObject
|
||||||
|
@ -13,6 +13,20 @@
|
|||||||
> * {
|
> * {
|
||||||
margin-bottom: 2em;
|
margin-bottom: 2em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pending-migrations {
|
||||||
|
.name-value-table.migrations {
|
||||||
|
tr:not(:first-child):not(:last-child) {
|
||||||
|
border-top: 1px solid @gray-lighter;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
float: right;
|
||||||
|
margin-top: 1em;
|
||||||
|
color: @icinga-blue
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
|
173
public/css/icinga/pending-migration.less
Normal file
173
public/css/icinga/pending-migration.less
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
// Style
|
||||||
|
|
||||||
|
@visual-width: 1.5em;
|
||||||
|
@max-view-width: 50em;
|
||||||
|
|
||||||
|
.migration-state-banner, .change-database-user-description {
|
||||||
|
.rounded-corners();
|
||||||
|
|
||||||
|
border: 1px solid @gray-light;
|
||||||
|
color: @text-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.migrations {
|
||||||
|
a {
|
||||||
|
color: @icinga-blue;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-item {
|
||||||
|
.visual.upgrade-failed, span.upgrade-failed, .errors-section > header > i {
|
||||||
|
color: @state-critical;
|
||||||
|
}
|
||||||
|
|
||||||
|
span.version {
|
||||||
|
color: @text-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.migration-form {
|
||||||
|
input[type="submit"] {
|
||||||
|
line-height: 1.5;
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
color: @disabled-gray;
|
||||||
|
cursor: not-allowed;
|
||||||
|
background: none;
|
||||||
|
border-color: fade(@disabled-gray, 75)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Layout
|
||||||
|
|
||||||
|
#layout.twocols:not(.wide-layout) .migration-form fieldset .control-label-group {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.migration-state-banner, .change-database-user-description {
|
||||||
|
padding: 1em;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
&.change-database-user-description {
|
||||||
|
max-width: 50em;
|
||||||
|
padding: .5em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pending-migrations-hint {
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
> h2 {
|
||||||
|
font-size: 2em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.migration-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.migrations {
|
||||||
|
.migration-form fieldset {
|
||||||
|
max-width: @max-view-width;
|
||||||
|
}
|
||||||
|
|
||||||
|
.migration-list-control {
|
||||||
|
padding-bottom: 1em;
|
||||||
|
|
||||||
|
> .item-list {
|
||||||
|
max-width: @max-view-width;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-list:not(.file-list) > .list-item {
|
||||||
|
> .main {
|
||||||
|
border-top: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-item {
|
||||||
|
align-items: baseline;
|
||||||
|
|
||||||
|
.main {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: flex-start;
|
||||||
|
|
||||||
|
input {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title span.upgrade-failed {
|
||||||
|
margin: .5em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.caption, .errors-section pre {
|
||||||
|
margin-top: .25em;
|
||||||
|
height: auto;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.errors-section {
|
||||||
|
margin: 1em -.25em;
|
||||||
|
border: 1px solid @state-critical;
|
||||||
|
padding: .25em;
|
||||||
|
.rounded-corners(.5em);
|
||||||
|
|
||||||
|
.status-icon {
|
||||||
|
margin-top: .3em;
|
||||||
|
margin-left: -1.5em;
|
||||||
|
margin-right: .25em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.caption, header {
|
||||||
|
margin-left: 1.8em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
width: 100%;
|
||||||
|
padding-top: 0;
|
||||||
|
|
||||||
|
> * {
|
||||||
|
font-size: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-item:first-child .main {
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
margin-left: @visual-width;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-list.file-list {
|
||||||
|
.visual {
|
||||||
|
width: @visual-width;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main {
|
||||||
|
margin-left: @visual-width;
|
||||||
|
}
|
||||||
|
|
||||||
|
.visual + .main {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
}
|
@ -320,14 +320,21 @@
|
|||||||
|
|
||||||
let rowSelector = this.getRowSelector(collapsible);
|
let rowSelector = this.getRowSelector(collapsible);
|
||||||
if (!! rowSelector) {
|
if (!! rowSelector) {
|
||||||
let visibleRows = Number(collapsible.dataset.visibleRows);
|
let collapseAfter = Number(collapsible.dataset.collapseAfter)
|
||||||
if (isNaN(visibleRows)) {
|
if (isNaN(collapseAfter)) {
|
||||||
visibleRows = this.defaultVisibleRows;
|
collapseAfter = Number(collapsible.dataset.visibleRows);
|
||||||
} else if (visibleRows === 0) {
|
if (isNaN(collapseAfter)) {
|
||||||
|
collapseAfter = this.defaultVisibleRows;
|
||||||
|
}
|
||||||
|
|
||||||
|
collapseAfter *= 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (collapseAfter === 0) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return collapsible.querySelectorAll(rowSelector).length > visibleRows * 2;
|
return collapsible.querySelectorAll(rowSelector).length > collapseAfter;
|
||||||
} else {
|
} else {
|
||||||
let maxHeight = Number(collapsible.dataset.visibleHeight);
|
let maxHeight = Number(collapsible.dataset.visibleHeight);
|
||||||
if (isNaN(maxHeight)) {
|
if (isNaN(maxHeight)) {
|
||||||
|
11
schema/mysql-upgrades/2.12.0.sql
Normal file
11
schema/mysql-upgrades/2.12.0.sql
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
ALTER TABLE icingaweb_schema
|
||||||
|
MODIFY COLUMN timestamp bigint unsigned NOT NULL,
|
||||||
|
MODIFY COLUMN version varchar(64) NOT NULL,
|
||||||
|
ADD COLUMN success enum('n', 'y') DEFAULT NULL,
|
||||||
|
ADD COLUMN reason text DEFAULT NULL,
|
||||||
|
ADD CONSTRAINT idx_icingaweb_schema_version UNIQUE (version);
|
||||||
|
|
||||||
|
UPDATE icingaweb_schema SET timestamp = timestamp * 1000, success = 'y';
|
||||||
|
|
||||||
|
INSERT INTO icingaweb_schema (version, timestamp, success, reason)
|
||||||
|
VALUES('2.12.0', UNIX_TIMESTAMP() * 1000, 'y', NULL);
|
@ -55,11 +55,14 @@ CREATE TABLE `icingaweb_rememberme`(
|
|||||||
|
|
||||||
CREATE TABLE icingaweb_schema (
|
CREATE TABLE icingaweb_schema (
|
||||||
id int unsigned NOT NULL AUTO_INCREMENT,
|
id int unsigned NOT NULL AUTO_INCREMENT,
|
||||||
version smallint unsigned NOT NULL,
|
version varchar(64) NOT NULL,
|
||||||
timestamp int unsigned NOT NULL,
|
timestamp bigint unsigned NOT NULL,
|
||||||
|
success enum('n', 'y') DEFAULT NULL,
|
||||||
|
reason text DEFAULT NULL,
|
||||||
|
|
||||||
PRIMARY KEY (id)
|
PRIMARY KEY (id),
|
||||||
|
CONSTRAINT idx_icingaweb_schema_version UNIQUE (version)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin ROW_FORMAT=DYNAMIC;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin ROW_FORMAT=DYNAMIC;
|
||||||
|
|
||||||
INSERT INTO icingaweb_schema (version, timestamp)
|
INSERT INTO icingaweb_schema (version, timestamp, success)
|
||||||
VALUES (6, UNIX_TIMESTAMP());
|
VALUES ('2.12.0', UNIX_TIMESTAMP() * 1000, 'y');
|
||||||
|
13
schema/pgsql-upgrades/2.12.0.sql
Normal file
13
schema/pgsql-upgrades/2.12.0.sql
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
CREATE TYPE boolenum AS ENUM ('n', 'y');
|
||||||
|
|
||||||
|
ALTER TABLE icingaweb_schema
|
||||||
|
ALTER COLUMN timestamp TYPE bigint,
|
||||||
|
ALTER COLUMN version TYPE varchar(64),
|
||||||
|
ADD COLUMN success boolenum DEFAULT NULL,
|
||||||
|
ADD COLUMN reason text DEFAULT NULL,
|
||||||
|
ADD CONSTRAINT idx_icingaweb_schema_version UNIQUE (version);
|
||||||
|
|
||||||
|
UPDATE icingaweb_schema SET timestamp = timestamp * 1000, success = 'y';
|
||||||
|
|
||||||
|
INSERT INTO icingaweb_schema (version, timestamp, success, reason)
|
||||||
|
VALUES('2.12.0', EXTRACT(EPOCH FROM now()) * 1000, 'y', NULL);
|
@ -118,13 +118,18 @@ ALTER TABLE ONLY "icingaweb_rememberme"
|
|||||||
"id"
|
"id"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TYPE boolenum AS ENUM ('n', 'y');
|
||||||
|
|
||||||
CREATE TABLE "icingaweb_schema" (
|
CREATE TABLE "icingaweb_schema" (
|
||||||
"id" serial,
|
"id" serial,
|
||||||
"version" smallint NOT NULL,
|
"version" varchar(64) NOT NULL,
|
||||||
"timestamp" int NOT NULL,
|
"timestamp" bigint NOT NULL,
|
||||||
|
"success" boolenum DEFAULT NULL,
|
||||||
|
"reason" text DEFAULT NULL,
|
||||||
|
|
||||||
CONSTRAINT pk_icingaweb_schema PRIMARY KEY ("id")
|
CONSTRAINT pk_icingaweb_schema PRIMARY KEY ("id"),
|
||||||
|
CONSTRAINT idx_icingaweb_schema_version UNIQUE (version)
|
||||||
);
|
);
|
||||||
|
|
||||||
INSERT INTO icingaweb_schema (version, timestamp)
|
INSERT INTO icingaweb_schema (version, timestamp, success)
|
||||||
VALUES (6, extract(epoch from now()));
|
VALUES ('2.12.0', extract(epoch from now()) * 1000, 'y');
|
||||||
|
Loading…
x
Reference in New Issue
Block a user