summaryrefslogtreecommitdiffstats
path: root/library/Icinga/Application/Hook/DbMigrationHook.php
diff options
context:
space:
mode:
Diffstat (limited to 'library/Icinga/Application/Hook/DbMigrationHook.php')
-rw-r--r--library/Icinga/Application/Hook/DbMigrationHook.php421
1 files changed, 421 insertions, 0 deletions
diff --git a/library/Icinga/Application/Hook/DbMigrationHook.php b/library/Icinga/Application/Hook/DbMigrationHook.php
new file mode 100644
index 0000000..f34bc0d
--- /dev/null
+++ b/library/Icinga/Application/Hook/DbMigrationHook.php
@@ -0,0 +1,421 @@
+<?php
+
+/* Icinga Web 2 | (c) 2023 Icinga GmbH | GPLv2+ */
+
+namespace Icinga\Application\Hook;
+
+use Countable;
+use DateTime;
+use DirectoryIterator;
+use Exception;
+use Icinga\Application\ClassLoader;
+use Icinga\Application\Hook\Common\DbMigrationStep;
+use Icinga\Application\Icinga;
+use Icinga\Application\Logger;
+use Icinga\Application\Modules\Module;
+use Icinga\Model\Schema;
+use Icinga\Web\Session;
+use ipl\I18n\Translation;
+use ipl\Orm\Query;
+use ipl\Sql\Adapter\Pgsql;
+use ipl\Sql\Connection;
+use ipl\Stdlib\Filter;
+use PDO;
+use SplFileInfo;
+use stdClass;
+
+/**
+ * Allows you to automatically perform database migrations.
+ *
+ * The version numbers of the sql migrations are determined by extracting the respective migration script names.
+ * It's required to place the sql migrate scripts below the respective following directories:
+ *
+ * `{IcingaApp,Module}::baseDir()/schema/{mysql,pgsql}-upgrades`
+ */
+abstract class DbMigrationHook implements Countable
+{
+ use Translation;
+
+ public const MYSQL_UPGRADE_DIR = 'schema/mysql-upgrades';
+
+ public const PGSQL_UPGRADE_DIR = 'schema/pgsql-upgrades';
+
+ /** @var string Fakes a module when this hook is implemented by the framework itself */
+ public const DEFAULT_MODULE = 'icingaweb2';
+
+ /** @var string Migration hook param name */
+ public const MIGRATION_PARAM = 'migration';
+
+ public const ALL_MIGRATIONS = 'all-migrations';
+
+ /** @var ?array<string, DbMigrationStep> All pending database migrations of this hook */
+ protected $migrations;
+
+ /** @var ?string The current version of this hook */
+ protected $version;
+
+ /**
+ * Get whether the specified table exists in the given database
+ *
+ * @param Connection $conn
+ * @param string $table
+ *
+ * @return bool
+ */
+ public static function tableExists(Connection $conn, string $table): bool
+ {
+ /** @var false|int $exists */
+ $exists = $conn->prepexec(
+ 'SELECT EXISTS(SELECT 1 FROM information_schema.tables WHERE table_name = ?) AS result',
+ $table
+ )->fetchColumn();
+
+ return (bool) $exists;
+ }
+
+ /**
+ * Get whether the specified column exists in the provided table
+ *
+ * @param Connection $conn
+ * @param string $table
+ * @param string $column
+ *
+ * @return ?string
+ */
+ public static function getColumnType(Connection $conn, string $table, string $column): ?string
+ {
+ $pdoStmt = $conn->prepexec(
+ sprintf(
+ 'SELECT %s AS column_type, %s AS column_length FROM information_schema.columns'
+ . ' WHERE table_name = ? AND column_name = ?',
+ $conn->getAdapter() instanceof Pgsql ? 'udt_name' : 'column_type',
+ $conn->getAdapter() instanceof Pgsql ? 'character_maximum_length' : 'NULL'
+ ),
+ [$table, $column]
+ );
+
+ /** @var false|stdClass $result */
+ $result = $pdoStmt->fetch(PDO::FETCH_OBJ);
+ if ($result === false) {
+ return null;
+ }
+
+ if ($result->column_length !== null) {
+ $result->column_type .= '(' . $result->column_length . ')';
+ }
+
+ return $result->column_type;
+ }
+
+ /**
+ * Get the mysql collation name of the given column of the specified table
+ *
+ * @param Connection $conn
+ * @param string $table
+ * @param string $column
+ *
+ * @return ?string
+ */
+ public static function getColumnCollation(Connection $conn, string $table, string $column): ?string
+ {
+ if ($conn->getAdapter() instanceof Pgsql) {
+ return null;
+ }
+
+ /** @var false|string $collation */
+ $collation = $conn->prepexec(
+ 'SELECT collation_name FROM information_schema.columns WHERE table_name = ? AND column_name = ?',
+ [$table, $column]
+ )->fetchColumn();
+
+ return ! $collation ? null : $collation;
+ }
+
+ /**
+ * Get statically provided descriptions of the individual migrate scripts
+ *
+ * @return string[]
+ */
+ abstract public function providedDescriptions(): array;
+
+ /**
+ * Get the full name of the component this hook is implemented by
+ *
+ * @return string
+ */
+ abstract public function getName(): string;
+
+ /**
+ * Get the current schema version of this migration hook
+ *
+ * @return string
+ */
+ abstract public function getVersion(): string;
+
+ /**
+ * Get a database connection
+ *
+ * @return Connection
+ */
+ abstract public function getDb(): Connection;
+
+ /**
+ * Get all the pending migrations of this hook
+ *
+ * @return DbMigrationStep[]
+ */
+ public function getMigrations(): array
+ {
+ if ($this->migrations === null) {
+ $this->migrations = [];
+
+ $this->load();
+ }
+
+ return $this->migrations ?? [];
+ }
+
+ /**
+ * Get the latest migrations limited by the given number
+ *
+ * @param int $limit
+ *
+ * @return DbMigrationStep[]
+ */
+ public function getLatestMigrations(int $limit): array
+ {
+ $migrations = $this->getMigrations();
+ if ($limit > 0) {
+ $migrations = array_slice($migrations, -$limit, null, true);
+ }
+
+ return array_reverse($migrations);
+ }
+
+ /**
+ * Apply all pending migrations of this hook
+ *
+ * @param ?Connection $conn Use the provided database connection to apply the migrations.
+ * Is only used to elevate database users with insufficient privileges.
+ *
+ * @return bool Whether the migration(s) have been successfully applied
+ */
+ final public function run(Connection $conn = null): bool
+ {
+ if (! $conn) {
+ $conn = $this->getDb();
+ }
+
+ foreach ($this->getMigrations() as $migration) {
+ try {
+ $migration->apply($conn);
+
+ $this->version = $migration->getVersion();
+ unset($this->migrations[$migration->getVersion()]);
+
+ $data = [
+ 'name' => $this->getName(),
+ 'version' => $migration->getVersion()
+ ];
+ AuditHook::logActivity(
+ 'migrations',
+ 'Migrated database schema of {{name}} to version {{version}}',
+ $data
+ );
+
+ $this->storeState($migration->getVersion(), null);
+ } catch (Exception $e) {
+ Logger::error(
+ "Failed to apply %s pending migration version %s \n%s",
+ $this->getName(),
+ $migration->getVersion(),
+ $e
+ );
+ Logger::debug($e->getTraceAsString());
+
+ static::insertFailedEntry(
+ $conn,
+ $migration->getVersion(),
+ $e->getMessage() . PHP_EOL . $e->getTraceAsString()
+ );
+
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Get whether this hook is implemented by a module
+ *
+ * @return bool
+ */
+ public function isModule(): bool
+ {
+ return ClassLoader::classBelongsToModule(static::class);
+ }
+
+ /**
+ * Get the name of the module this hook is implemented by
+ *
+ * @return string
+ */
+ public function getModuleName(): string
+ {
+ if (! $this->isModule()) {
+ return static::DEFAULT_MODULE;
+ }
+
+ return ClassLoader::extractModuleName(static::class);
+ }
+
+ /**
+ * Get the number of pending migrations of this hook
+ *
+ * @return int
+ */
+ public function count(): int
+ {
+ return count($this->getMigrations());
+ }
+
+ /**
+ * Get a schema version query
+ *
+ * @return Query
+ */
+ abstract protected function getSchemaQuery(): Query;
+
+ protected function load(): void
+ {
+ $upgradeDir = static::MYSQL_UPGRADE_DIR;
+ if ($this->getDb()->getAdapter() instanceof Pgsql) {
+ $upgradeDir = static::PGSQL_UPGRADE_DIR;
+ }
+
+ if (! $this->isModule()) {
+ $path = Icinga::app()->getBaseDir();
+ } else {
+ $path = Module::get($this->getModuleName())->getBaseDir();
+ }
+
+ $descriptions = $this->providedDescriptions();
+ $version = $this->getVersion();
+ /** @var SplFileInfo $file */
+ foreach (new DirectoryIterator($path . DIRECTORY_SEPARATOR . $upgradeDir) as $file) {
+ if (preg_match('/^(v)?([^_]+)(?:_(\w+))?\.sql$/', $file->getFilename(), $m, PREG_UNMATCHED_AS_NULL)) {
+ [$_, $_, $migrateVersion, $description] = array_pad($m, 4, null);
+ /** @var string $migrateVersion */
+ if ($migrateVersion && version_compare($migrateVersion, $version, '>')) {
+ $migration = new DbMigrationStep($migrateVersion, $file->getRealPath());
+ if (isset($descriptions[$migrateVersion])) {
+ $migration->setDescription($descriptions[$migrateVersion]);
+ } elseif ($description) {
+ $migration->setDescription(str_replace('_', ' ', $description));
+ }
+
+ $migration->setLastState($this->loadLastState($migrateVersion));
+
+ $this->migrations[$migrateVersion] = $migration;
+ }
+ }
+ }
+
+ if ($this->migrations) {
+ // Sort all the migrations by their version numbers in ascending order.
+ uksort($this->migrations, function ($a, $b) {
+ return version_compare($a, $b);
+ });
+ }
+ }
+
+ /**
+ * Insert failed migration entry into the database or to the session
+ *
+ * @param Connection $conn
+ * @param string $version
+ * @param string $reason
+ *
+ * @return $this
+ */
+ protected function insertFailedEntry(Connection $conn, string $version, string $reason): self
+ {
+ $schemaQuery = $this->getSchemaQuery()
+ ->filter(Filter::equal('version', $version));
+
+ if (! static::getColumnType($conn, $schemaQuery->getModel()->getTableName(), 'success')) {
+ $this->storeState($version, $reason);
+ } else {
+ /** @var Schema $schema */
+ $schema = $schemaQuery->first();
+ if ($schema) {
+ $conn->update($schema->getTableName(), [
+ 'timestamp' => (new DateTime())->getTimestamp() * 1000.0,
+ 'success' => 'n',
+ 'reason' => $reason
+ ], ['id = ?' => $schema->id]);
+ } else {
+ $conn->insert($schemaQuery->getModel()->getTableName(), [
+ 'version' => $version,
+ 'timestamp' => (new DateTime())->getTimestamp() * 1000.0,
+ 'success' => 'n',
+ 'reason' => $reason
+ ]);
+ }
+ }
+
+ return $this;
+ }
+
+ /**
+ * Store a failed state message in the session for the given version
+ *
+ * @param string $version
+ * @param ?string $reason
+ *
+ * @return $this
+ */
+ protected function storeState(string $version, ?string $reason): self
+ {
+ $session = Session::getSession()->getNamespace('migrations');
+ /** @var array<string, string> $states */
+ $states = $session->get($this->getModuleName(), []);
+ $states[$version] = $reason;
+
+ $session->set($this->getModuleName(), $states);
+
+ return $this;
+ }
+
+ /**
+ * Load last failed state from database/session for the given version
+ *
+ * @param string $version
+ *
+ * @return ?string
+ */
+ protected function loadLastState(string $version): ?string
+ {
+ $session = Session::getSession()->getNamespace('migrations');
+ /** @var array<string, string> $states */
+ $states = $session->get($this->getModuleName(), []);
+ if (! isset($states[$version])) {
+ $schemaQuery = $this->getSchemaQuery()
+ ->filter(Filter::equal('version', $version))
+ ->filter(Filter::all(Filter::equal('success', 'n')));
+
+ if (static::getColumnType($this->getDb(), $schemaQuery->getModel()->getTableName(), 'reason')) {
+ /** @var Schema $schema */
+ $schema = $schemaQuery->first();
+ if ($schema) {
+ return $schema->reason;
+ }
+ }
+
+ return null;
+ }
+
+ return $states[$version];
+ }
+}