Provide migration hook (#5046)

resolves #5043
This commit is contained in:
Johannes Meyer 2023-09-19 14:46:44 +02:00 committed by GitHub
commit e4c9266da2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
33 changed files with 2227 additions and 39 deletions

View File

@ -3,6 +3,8 @@
namespace Icinga\Controllers;
use Icinga\Application\Hook\DbMigrationHook;
use Icinga\Application\MigrationManager;
use Icinga\Exception\IcingaException;
use Zend_Controller_Plugin_ErrorHandler;
use Icinga\Application\Icinga;
@ -91,6 +93,22 @@ class ErrorController extends ActionController
$this->getResponse()->setHttpResponseCode(403);
break;
default:
$mm = MigrationManager::instance();
$action = $this->getRequest()->getActionName();
$controller = $this->getRequest()->getControllerName();
if ($action !== 'hint' && $controller !== 'migrations' && $mm->hasMigrations($moduleName)) {
// The view renderer from IPL web doesn't render the HTML content set in the respective
// controller if the error_handler request param is set, as it doesn't support error
// rendering. Since this error handler isn't caused by the migrations controller, we can
// safely unset this.
$this->setParam('error_handler', null);
$this->forward('hint', 'migrations', 'default', [
DbMigrationHook::MIGRATION_PARAM => $moduleName
]);
return;
}
$this->getResponse()->setHttpResponseCode(500);
$module = $modules->hasLoaded($moduleName) ? $modules->getModule($moduleName) : null;
Logger::error("%s\n%s", $exception, IcingaException::getConfidentialTraceAsString($exception));

View 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();
}
}

View 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);
}
}
}

View File

@ -581,6 +581,9 @@ class RoleForm extends RepositoryForm
],
'application/sessions' => [
'description' => t('Allow to manage user sessions')
],
'application/migrations' => [
'description' => t('Allow to apply pending application migrations')
]
];

View File

@ -1,7 +1,10 @@
<?php
use Icinga\Application\MigrationManager;
use Icinga\Web\Navigation\Renderer\BadgeNavigationItemRenderer;
use ipl\Html\HtmlElement;
use ipl\Web\Widget\Icon;
use ipl\Web\Widget\StateBadge;
?>
<div class="controls">
@ -93,6 +96,31 @@ use ipl\Web\Widget\Icon;
</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>
<table class="name-value-table" data-base-target="_next">
<?php foreach ($libraries as $library): ?>

View File

@ -213,7 +213,7 @@ Create the database and add a new user as shown below for MySQL/MariaDB:
sudo mysql -p
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
mysql -p icingaweb2 < /usr/share/icingaweb2/schema/mysql.schema.sql

View File

@ -3,6 +3,17 @@
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.
## 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
**General**

View File

@ -6,6 +6,7 @@ namespace Icinga\Application;
use DirectoryIterator;
use ErrorException;
use Exception;
use Icinga\Application\ProvidedHook\DbMigration;
use ipl\I18n\GettextTranslator;
use ipl\I18n\StaticTranslator;
use LogicException;
@ -731,4 +732,16 @@ abstract class ApplicationBootstrap
$localedir = $this->getLocaleDir();
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;
}
}

View File

@ -48,7 +48,8 @@ class Cli extends ApplicationBootstrap
->setupModuleManager()
->setupUserBackendFactory()
->loadSetupModuleIfNecessary()
->setupFakeAuthentication();
->setupFakeAuthentication()
->registerApplicationHooks();
}
/**

View File

@ -75,7 +75,8 @@ class EmbeddedWeb extends ApplicationBootstrap
->setupTimezone()
->prepareFakeInternationalization()
->setupModuleManager()
->loadEnabledModules();
->loadEnabledModules()
->registerApplicationHooks();
}
/**

View File

@ -266,23 +266,21 @@ class Hook
*
* @return array
*/
public static function all($name)
public static function all($name): array
{
$name = self::normalizeHookName($name);
if (! self::has($name)) {
return array();
return [];
}
foreach (self::$hooks[$name] as $key => $hook) {
list($class, $alwaysRun) = $hook;
if ($alwaysRun || self::hasPermission($class)) {
if (self::createInstance($name, $key) === null) {
return array();
}
self::createInstance($name, $key);
}
}
return isset(self::$instances[$name]) ? self::$instances[$name] : array();
return self::$instances[$name] ?? [];
}
/**

View 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;
}
}

View 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];
}
}

View 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];
}
}

View 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());
}
}

View File

@ -104,7 +104,8 @@ class Web extends EmbeddedWeb
->setupUser()
->setupTimezone()
->setupInternationalization()
->setupFatalErrorHandling();
->setupFatalErrorHandling()
->registerApplicationHooks();
}
/**

View File

@ -22,7 +22,7 @@ trait Database
*
* @throws \Icinga\Exception\ConfigurationError
*/
protected function getDb()
protected function getDb(): Connection
{
if (! $this->hasDb()) {
throw new LogicException('Please check if a db instance exists at all');

View 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']));
}
}

View File

@ -6,7 +6,9 @@ namespace Icinga\Web\Navigation;
use Icinga\Application\Hook\HealthHook;
use Icinga\Application\Icinga;
use Icinga\Application\Logger;
use Icinga\Application\MigrationManager;
use Icinga\Authentication\Auth;
use Icinga\Web\Navigation\Renderer\BadgeNavigationItemRenderer;
use ipl\Html\Attributes;
use ipl\Html\BaseHtmlElement;
use ipl\Html\HtmlElement;
@ -14,6 +16,7 @@ use ipl\Html\Text;
use ipl\Web\Url;
use ipl\Web\Widget\Icon;
use ipl\Web\Widget\StateBadge;
use Throwable;
class ConfigMenu extends BaseHtmlElement
{
@ -47,6 +50,10 @@ class ConfigMenu extends BaseHtmlElement
'label' => t('Health'),
'url' => 'health',
],
'migrations' => [
'label' => t('Migrations'),
'url' => 'migrations',
],
'announcements' => [
'label' => t('Announcements'),
'url' => 'announcements'
@ -136,7 +143,7 @@ class ConfigMenu extends BaseHtmlElement
null,
[
new Icon('cog'),
$this->createHealthBadge(),
$this->createHealthBadge() ?? $this->createMigrationBadge(),
]
),
$this->createLevel2Menu()
@ -211,7 +218,7 @@ class ConfigMenu extends BaseHtmlElement
return false;
}
protected function createHealthBadge()
protected function createHealthBadge(): ?StateBadge
{
$stateBadge = null;
if ($this->getHealthCount() > 0) {
@ -222,6 +229,25 @@ class ConfigMenu extends BaseHtmlElement
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()
{
$level2Nav = HtmlElement::create(
@ -240,23 +266,26 @@ class ConfigMenu extends BaseHtmlElement
return null;
}
$healthBadge = null;
$stateBadge = null;
$class = null;
if ($key === 'health') {
$class = 'badge-nav-item';
$healthBadge = $this->createHealthBadge();
$stateBadge = $this->createHealthBadge();
} elseif ($key === 'migrations') {
$class = 'badge-nav-item';
$stateBadge = $this->createMigrationBadge();
}
$li = HtmlElement::create(
'li',
isset($item['atts']) ? $item['atts'] : [],
$item['atts'] ?? [],
[
HtmlElement::create(
'a',
Attributes::create(['href' => Url::fromPath($item['url'])]),
[
$item['label'],
isset($healthBadge) ? $healthBadge : ''
$stateBadge ?? ''
]
),
]

View File

@ -80,7 +80,8 @@ class StyleSheet
'css/icinga/modal.less',
'css/icinga/audit.less',
'css/icinga/health.less',
'css/icinga/php-diff.less'
'css/icinga/php-diff.less',
'css/icinga/pending-migration.less',
];
/**

View 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());
}
}

View 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.')));
}
}
}

View 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);
}
}
}

View File

@ -78,6 +78,11 @@ class WebWizard extends Wizard implements SetupWizard
'UPDATE',
'DELETE',
'EXECUTE',
'CREATE',
'CREATE VIEW',
'ALTER',
'DROP',
'INDEX',
'TEMPORARY', // PostgreSql
'CREATE TEMPORARY TABLES' // MySQL
);

View File

@ -13165,11 +13165,6 @@ parameters:
count: 1
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\\.$#"
count: 1

View File

@ -49,6 +49,8 @@ parameters:
count: 2
path: library/Icinga/Protocol/Ldap/LdapConnection.php
- '#Call to an undefined method ipl\\Sql\\Connection::exec\(\)#'
scanDirectories:
- vendor
@ -56,6 +58,7 @@ parameters:
- library/Icinga/Test
universalObjectCratesClasses:
- ipl\Orm\Model
- Icinga\Data\ConfigObject
- Icinga\Web\View
- Icinga\Module\Monitoring\Object\MonitoredObject

View File

@ -13,6 +13,20 @@
> * {
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 {

View 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;
}
}

View File

@ -320,14 +320,21 @@
let rowSelector = this.getRowSelector(collapsible);
if (!! rowSelector) {
let visibleRows = Number(collapsible.dataset.visibleRows);
if (isNaN(visibleRows)) {
visibleRows = this.defaultVisibleRows;
} else if (visibleRows === 0) {
let collapseAfter = Number(collapsible.dataset.collapseAfter)
if (isNaN(collapseAfter)) {
collapseAfter = Number(collapsible.dataset.visibleRows);
if (isNaN(collapseAfter)) {
collapseAfter = this.defaultVisibleRows;
}
collapseAfter *= 2;
}
if (collapseAfter === 0) {
return true;
}
return collapsible.querySelectorAll(rowSelector).length > visibleRows * 2;
return collapsible.querySelectorAll(rowSelector).length > collapseAfter;
} else {
let maxHeight = Number(collapsible.dataset.visibleHeight);
if (isNaN(maxHeight)) {

View 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);

View File

@ -55,11 +55,14 @@ CREATE TABLE `icingaweb_rememberme`(
CREATE TABLE icingaweb_schema (
id int unsigned NOT NULL AUTO_INCREMENT,
version smallint unsigned NOT NULL,
timestamp int unsigned NOT NULL,
version varchar(64) 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;
INSERT INTO icingaweb_schema (version, timestamp)
VALUES (6, UNIX_TIMESTAMP());
INSERT INTO icingaweb_schema (version, timestamp, success)
VALUES ('2.12.0', UNIX_TIMESTAMP() * 1000, 'y');

View 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);

View File

@ -118,13 +118,18 @@ ALTER TABLE ONLY "icingaweb_rememberme"
"id"
);
CREATE TYPE boolenum AS ENUM ('n', 'y');
CREATE TABLE "icingaweb_schema" (
"id" serial,
"version" smallint NOT NULL,
"timestamp" int NOT NULL,
"version" varchar(64) 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)
VALUES (6, extract(epoch from now()));
INSERT INTO icingaweb_schema (version, timestamp, success)
VALUES ('2.12.0', extract(epoch from now()) * 1000, 'y');