summaryrefslogtreecommitdiffstats
path: root/library/Icinga/Application/MigrationManager.php
diff options
context:
space:
mode:
Diffstat (limited to 'library/Icinga/Application/MigrationManager.php')
-rw-r--r--library/Icinga/Application/MigrationManager.php417
1 files changed, 417 insertions, 0 deletions
diff --git a/library/Icinga/Application/MigrationManager.php b/library/Icinga/Application/MigrationManager.php
new file mode 100644
index 0000000..9d32896
--- /dev/null
+++ b/library/Icinga/Application/MigrationManager.php
@@ -0,0 +1,417 @@
+<?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','USAGE'];
+ }
+
+ /**
+ * 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();
+
+ $isPgsql = $db->getAdapter() instanceof Sql\Adapter\Pgsql;
+ // PgSQL doesn't have SELECT privilege on a database level and granting the CREATE,CONNECT, and TEMPORARY
+ // privileges on a database doesn't permit a user to read data from a table. Hence, we have to grant the
+ // required database,schema and table privileges simultaneously.
+ if (! $isPgsql && $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();
+
+ $isPgsql = $conn->getAdapter() instanceof Sql\Adapter\Pgsql;
+ $privileges = $this->getRequiredDatabasePrivileges();
+ $dbPrivilegesGranted = $dbTool->checkPrivileges($privileges);
+ $tablePrivilegesGranted = $dbTool->checkPrivileges($privileges, $tables);
+ if (! $dbPrivilegesGranted && ($isPgsql || ! $tablePrivilegesGranted)) {
+ return false;
+ }
+
+ if ($isPgsql && ! $tablePrivilegesGranted) {
+ return false;
+ }
+
+ if ($canIssueGrants && ! $dbTool->isGrantable($privileges)) {
+ 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];
+ }
+}