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

@ -54,12 +54,15 @@ CREATE TABLE `icingaweb_rememberme`(
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin ROW_FORMAT=DYNAMIC;
CREATE TABLE icingaweb_schema (
id int unsigned NOT NULL AUTO_INCREMENT,
version smallint unsigned NOT NULL,
timestamp int unsigned NOT NULL,
id int unsigned NOT NULL AUTO_INCREMENT,
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');