418 lines
12 KiB
PHP
418 lines
12 KiB
PHP
<?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];
|
|
}
|
|
}
|