diff options
Diffstat (limited to '')
-rw-r--r-- | library/Icinga/Application/MigrationManager.php | 417 |
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]; + } +} |