diff --git a/application/controllers/ErrorController.php b/application/controllers/ErrorController.php index 580015b25..476b71f2e 100644 --- a/application/controllers/ErrorController.php +++ b/application/controllers/ErrorController.php @@ -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)); diff --git a/application/controllers/MigrationsController.php b/application/controllers/MigrationsController.php new file mode 100644 index 000000000..5229f066c --- /dev/null +++ b/application/controllers/MigrationsController.php @@ -0,0 +1,249 @@ +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 $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 $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(); + } +} diff --git a/application/forms/MigrationForm.php b/application/forms/MigrationForm.php new file mode 100644 index 000000000..c5d517f03 --- /dev/null +++ b/application/forms/MigrationForm.php @@ -0,0 +1,143 @@ + ['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 $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); + } + } +} diff --git a/application/forms/Security/RoleForm.php b/application/forms/Security/RoleForm.php index 3bddbf81b..58387f7aa 100644 --- a/application/forms/Security/RoleForm.php +++ b/application/forms/Security/RoleForm.php @@ -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') ] ]; diff --git a/application/views/scripts/about/index.phtml b/application/views/scripts/about/index.phtml index e80cd89c6..805e723e0 100644 --- a/application/views/scripts/about/index.phtml +++ b/application/views/scripts/about/index.phtml @@ -1,7 +1,10 @@
@@ -93,6 +96,31 @@ use ipl\Web\Widget\Icon;
+ hasPendingMigrations()): ?> +
+

translate('Pending Migrations') ?>

+ + getPendingMigrations() as $migration): ?> + + + + + +
escape($migration->getName()) ?>getMigrations()), + BadgeNavigationItemRenderer::STATE_PENDING + ); + ?>
+ qlink( + $this->translate('Show all'), + 'migrations', + null, + ['title' => $this->translate('Show all pending migrations')] + ) ?> +
+ +

translate('Loaded Libraries') ?>

diff --git a/doc/20-Advanced-Topics.md b/doc/20-Advanced-Topics.md index 6835a6f28..d88476847 100644 --- a/doc/20-Advanced-Topics.md +++ b/doc/20-Advanced-Topics.md @@ -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 diff --git a/doc/80-Upgrading.md b/doc/80-Upgrading.md index ee3602d82..c6f4b7b57 100644 --- a/doc/80-Upgrading.md +++ b/doc/80-Upgrading.md @@ -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** diff --git a/library/Icinga/Application/ApplicationBootstrap.php b/library/Icinga/Application/ApplicationBootstrap.php index 4417f2750..e484f6c03 100644 --- a/library/Icinga/Application/ApplicationBootstrap.php +++ b/library/Icinga/Application/ApplicationBootstrap.php @@ -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; + } } diff --git a/library/Icinga/Application/Cli.php b/library/Icinga/Application/Cli.php index 28109f94f..3b937382c 100644 --- a/library/Icinga/Application/Cli.php +++ b/library/Icinga/Application/Cli.php @@ -48,7 +48,8 @@ class Cli extends ApplicationBootstrap ->setupModuleManager() ->setupUserBackendFactory() ->loadSetupModuleIfNecessary() - ->setupFakeAuthentication(); + ->setupFakeAuthentication() + ->registerApplicationHooks(); } /** diff --git a/library/Icinga/Application/EmbeddedWeb.php b/library/Icinga/Application/EmbeddedWeb.php index 8d03e1133..9adb3a429 100644 --- a/library/Icinga/Application/EmbeddedWeb.php +++ b/library/Icinga/Application/EmbeddedWeb.php @@ -75,7 +75,8 @@ class EmbeddedWeb extends ApplicationBootstrap ->setupTimezone() ->prepareFakeInternationalization() ->setupModuleManager() - ->loadEnabledModules(); + ->loadEnabledModules() + ->registerApplicationHooks(); } /** diff --git a/library/Icinga/Application/Hook.php b/library/Icinga/Application/Hook.php index dcfd5dd84..9720c6ab6 100644 --- a/library/Icinga/Application/Hook.php +++ b/library/Icinga/Application/Hook.php @@ -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] ?? []; } /** diff --git a/library/Icinga/Application/Hook/Common/DbMigrationStep.php b/library/Icinga/Application/Hook/Common/DbMigrationStep.php new file mode 100644 index 000000000..54a113972 --- /dev/null +++ b/library/Icinga/Application/Hook/Common/DbMigrationStep.php @@ -0,0 +1,129 @@ +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; + } +} diff --git a/library/Icinga/Application/Hook/DbMigrationHook.php b/library/Icinga/Application/Hook/DbMigrationHook.php new file mode 100644 index 000000000..892bb6e97 --- /dev/null +++ b/library/Icinga/Application/Hook/DbMigrationHook.php @@ -0,0 +1,420 @@ + 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 $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 $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]; + } +} diff --git a/library/Icinga/Application/MigrationManager.php b/library/Icinga/Application/MigrationManager.php new file mode 100644 index 000000000..46e19909c --- /dev/null +++ b/library/Icinga/Application/MigrationManager.php @@ -0,0 +1,406 @@ + 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 + */ + 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 $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 $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 + */ + 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 $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 $elevateConfig + */ + public function fixIcingaWebMysqlGrants(Sql\Connection $db, array $elevateConfig): void + { + $wizardProperties = (new ReflectionClass(WebWizard::class)) + ->getDefaultProperties(); + /** @var array $privileges */ + $privileges = $wizardProperties['databaseUsagePrivileges']; + /** @var array $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 $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 $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 $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 + */ + 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]; + } +} diff --git a/library/Icinga/Application/ProvidedHook/DbMigration.php b/library/Icinga/Application/ProvidedHook/DbMigration.php new file mode 100644 index 000000000..899dbf6ac --- /dev/null +++ b/library/Icinga/Application/ProvidedHook/DbMigration.php @@ -0,0 +1,83 @@ +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()); + } +} diff --git a/library/Icinga/Application/Web.php b/library/Icinga/Application/Web.php index 268943ff8..934af0745 100644 --- a/library/Icinga/Application/Web.php +++ b/library/Icinga/Application/Web.php @@ -104,7 +104,8 @@ class Web extends EmbeddedWeb ->setupUser() ->setupTimezone() ->setupInternationalization() - ->setupFatalErrorHandling(); + ->setupFatalErrorHandling() + ->registerApplicationHooks(); } /** diff --git a/library/Icinga/Common/Database.php b/library/Icinga/Common/Database.php index 4c977653c..d54eb253b 100644 --- a/library/Icinga/Common/Database.php +++ b/library/Icinga/Common/Database.php @@ -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'); diff --git a/library/Icinga/Model/Schema.php b/library/Icinga/Model/Schema.php new file mode 100644 index 000000000..465cce084 --- /dev/null +++ b/library/Icinga/Model/Schema.php @@ -0,0 +1,49 @@ +add(new BoolCast(['success'])); + $behaviors->add(new MillisecondTimestamp(['timestamp'])); + } +} diff --git a/library/Icinga/Web/Navigation/ConfigMenu.php b/library/Icinga/Web/Navigation/ConfigMenu.php index ba541f721..583bf42bb 100644 --- a/library/Icinga/Web/Navigation/ConfigMenu.php +++ b/library/Icinga/Web/Navigation/ConfigMenu.php @@ -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 ?? '' ] ), ] diff --git a/library/Icinga/Web/StyleSheet.php b/library/Icinga/Web/StyleSheet.php index 9ca6d9af7..65cbb9705 100644 --- a/library/Icinga/Web/StyleSheet.php +++ b/library/Icinga/Web/StyleSheet.php @@ -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', ]; /** diff --git a/library/Icinga/Web/Widget/ItemList/MigrationFileListItem.php b/library/Icinga/Web/Widget/ItemList/MigrationFileListItem.php new file mode 100644 index 000000000..007a730e0 --- /dev/null +++ b/library/Icinga/Web/Widget/ItemList/MigrationFileListItem.php @@ -0,0 +1,92 @@ +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()); + } +} diff --git a/library/Icinga/Web/Widget/ItemList/MigrationList.php b/library/Icinga/Web/Widget/ItemList/MigrationList.php new file mode 100644 index 000000000..43699d3e5 --- /dev/null +++ b/library/Icinga/Web/Widget/ItemList/MigrationList.php @@ -0,0 +1,133 @@ + 'item-list']; + + /** @var Generator */ + 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|array $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.'))); + } + } +} diff --git a/library/Icinga/Web/Widget/ItemList/MigrationListItem.php b/library/Icinga/Web/Widget/ItemList/MigrationListItem.php new file mode 100644 index 000000000..284ce4c84 --- /dev/null +++ b/library/Icinga/Web/Widget/ItemList/MigrationListItem.php @@ -0,0 +1,151 @@ +migrateButton = $migrateButton; + + return $this; + } + + protected function assembleTitle(BaseHtmlElement $title): void + { + $title->addHtml( + FormattedString::create( + t('%s ', ''), + 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); + } + } +} diff --git a/modules/setup/library/Setup/WebWizard.php b/modules/setup/library/Setup/WebWizard.php index caf6871df..4fc0f2bde 100644 --- a/modules/setup/library/Setup/WebWizard.php +++ b/modules/setup/library/Setup/WebWizard.php @@ -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 ); diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 932886e9a..25632c315 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -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 diff --git a/phpstan.neon b/phpstan.neon index 43cecf9c4..9da27bcff 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -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 diff --git a/public/css/icinga/about.less b/public/css/icinga/about.less index 4ccaaf8d1..fe3a71b71 100644 --- a/public/css/icinga/about.less +++ b/public/css/icinga/about.less @@ -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 { diff --git a/public/css/icinga/pending-migration.less b/public/css/icinga/pending-migration.less new file mode 100644 index 000000000..bc70caa33 --- /dev/null +++ b/public/css/icinga/pending-migration.less @@ -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; + } +} diff --git a/public/js/icinga/behavior/collapsible.js b/public/js/icinga/behavior/collapsible.js index c0b5349e3..16f719506 100644 --- a/public/js/icinga/behavior/collapsible.js +++ b/public/js/icinga/behavior/collapsible.js @@ -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)) { diff --git a/schema/mysql-upgrades/2.12.0.sql b/schema/mysql-upgrades/2.12.0.sql new file mode 100644 index 000000000..f2630ac34 --- /dev/null +++ b/schema/mysql-upgrades/2.12.0.sql @@ -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); diff --git a/schema/mysql.schema.sql b/schema/mysql.schema.sql index 160061887..1eb71a8a9 100644 --- a/schema/mysql.schema.sql +++ b/schema/mysql.schema.sql @@ -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'); diff --git a/schema/pgsql-upgrades/2.12.0.sql b/schema/pgsql-upgrades/2.12.0.sql new file mode 100644 index 000000000..2a5818e5e --- /dev/null +++ b/schema/pgsql-upgrades/2.12.0.sql @@ -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); diff --git a/schema/pgsql.schema.sql b/schema/pgsql.schema.sql index 8bf4ca07f..3a5413bbb 100644 --- a/schema/pgsql.schema.sql +++ b/schema/pgsql.schema.sql @@ -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');