diff --git a/library/Icinga/Application/Hook/Common/DbMigration.php b/library/Icinga/Application/Hook/Common/DbMigration.php new file mode 100644 index 000000000..14219be58 --- /dev/null +++ b/library/Icinga/Application/Hook/Common/DbMigration.php @@ -0,0 +1,151 @@ +scriptPath = $scriptPath; + + $this->setVersion($version); + } + + /** + * Get the sql script version the queries are loaded from + * + * @return string + */ + public function getVersion(): string + { + return $this->version; + } + + /** + * Set the sql script version the queries are loaded from + * + * @param string $version + * + * @return $this + */ + public function setVersion(string $version): self + { + $this->version = $version; + + return $this; + } + + /** + * 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 DbMigration + */ + 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 + { + if (! $this->query) { + $statements = @file_get_contents($this->getScriptPath()); + if (! $statements) { + throw new RuntimeException(sprintf('Cannot load upgrade script %s', $this->getScriptPath())); + } + + 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); + } + + $this->query = $statements; + } + + if (empty($this->query)) { + throw new RuntimeException('Nothing to migrate'); + } + + $conn->exec($this->query); + + return $this; + } +} diff --git a/library/Icinga/Application/Hook/MigrationHook.php b/library/Icinga/Application/Hook/MigrationHook.php new file mode 100644 index 000000000..bbbc529cc --- /dev/null +++ b/library/Icinga/Application/Hook/MigrationHook.php @@ -0,0 +1,379 @@ + 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 stdClass $query */ + $query = $conn->prepexec( + 'SELECT EXISTS(SELECT 1 FROM information_schema.tables WHERE table_name = ?) AS result', + $table + )->fetch(PDO::FETCH_OBJ); + + return $query->result; + } + + /** + * 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 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 all the pending migrations of this hook + * + * @return DbMigration[] + */ + 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 DbMigration[] + */ + 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 + * + * @return bool Whether the migration(s) have been successfully applied + */ + public function run(): bool + { + $conn = $this->getDb(); + foreach ($this->getMigrations() as $migration) { + try { + $migration->apply($conn); + + $this->version = $migration->getVersion(); + unset($this->migrations[$migration->getVersion()]); + + Logger::error( + "Applied %s pending migration version %s successfully", + $this->getName(), + $migration->getVersion() + ); + + $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->getMessage() + ); + Logger::debug($e->getTraceAsString()); + + static::insertFailedEntry( + $conn, + $this->getSchemaQueryFor($migration->getVersion()), + $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 database connection + * + * @return Connection + */ + abstract protected function getDb(): Connection; + + /** + * Get a schema version query filtered by the given $version + * + * @param string $version + * + * @return Query + */ + abstract protected function getSchemaQueryFor(string $version): 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('/^(?:r|v)?((?:\d+\.){0,2}\d+)(?:_([\w+]+))?\.sql$/', $file->getFilename(), $m)) { + if (version_compare($m[1], $version, '>')) { + $migration = new DbMigration($m[1], $file->getRealPath()); + if (isset($descriptions[$migration->getVersion()])) { + $migration->setDescription($descriptions[$migration->getVersion()]); + } elseif (isset($m[2])) { + $migration->setDescription(str_replace('_', ' ', $m[2])); + } + + $migration->setLastState($this->loadLastState($migration->getVersion())); + + $this->migrations[$m[1]] = $migration; + } + } + } + + // Sort all the migrations by their version numbers in ascending order. + uksort($this->migrations, 'version_compare'); + } + + /** + * Insert failed migration entry into the database or to the session + * + * @param Connection $conn + * @param Query $schemaQuery + * @param string $version + * @param string $reason + * + * @return $this + */ + protected function insertFailedEntry(Connection $conn, Query $schemaQuery, string $version, string $reason): self + { + 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->getSchemaQueryFor($version); + $schemaQuery->setFilter(Filter::all(Filter::equal('success', 'n'))); + if (static::getColumnType($this->getDb(), $schemaQuery->getModel()->getTableName(), 'reason')) { + /** @var Schema $schema */ + $schema = $schemaQuery->first(); + if ($schema && version_compare($schema->version, $version, '==')) { + return $schema->reason; + } + } + + return null; + } + + return $states[$version]; + } +}