diff --git a/src/bundle/RepositoryInstaller/Command/UpgradePlatformCommand.php b/src/bundle/RepositoryInstaller/Command/UpgradePlatformCommand.php new file mode 100644 index 0000000000..1c4b231b81 --- /dev/null +++ b/src/bundle/RepositoryInstaller/Command/UpgradePlatformCommand.php @@ -0,0 +1,273 @@ +connection = $connection; + $this->installers = $installers; + $this->cachePool = $cachePool; + $this->environment = $environment; + $this->repositoryConfigurationProvider = $repositoryConfigurationProvider; + parent::__construct(); + } + + protected function configure() + { + $this->setName('ibexa:flavor-upgrade'); + $this->setAliases($this->getDeprecatedAliases()); + $this->addArgument( + 'type', + InputArgument::OPTIONAL, + 'The type of upgrade. Available options: ' . implode(', ', array_keys($this->installers)), + 'ibexa-content' + ); + $this->addOption( + 'skip-indexing', + null, + InputOption::VALUE_NONE, + 'Skip indexing (ezplaform:reindex)' + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $this->output = $output; + $this->checkPermissions(); + $this->checkParameters(); + $this->checkCreateDatabase($output); + + $type = $input->getArgument('type'); + $siteaccess = $input->getOption('siteaccess'); + $installer = $this->getInstaller($type); + if ($installer === false) { + $output->writeln( + "Unknown install type '$type', available options in currently installed Ibexa package: " . + implode(', ', array_keys($this->installers)) + ); + exit(self::EXIT_UNKNOWN_INSTALL_TYPE); + } + + $installer->setOutput($output); + + $installer->importSchema(); + $installer->importData(); + $this->cacheClear($output); + + if (!$input->getOption('skip-indexing')) { + $this->indexData($output, $siteaccess); + } + + return 0; + } + + private function checkPermissions() + { + // @todo should take var-dir etc. from composer config or fallback to flex directory scheme + if (!is_writable('public') && !is_writable('public/var')) { + $this->output->writeln('[public/ | public/var] is not writable'); + exit(self::EXIT_MISSING_PERMISSIONS); + } + } + + private function checkParameters() + { + // @todo doesn't make sense to check for parameters.yml in sf4 and flex + return; + $parametersFile = 'app/config/parameters.yml'; + if (!is_file($parametersFile)) { + $this->output->writeln("Required configuration file '$parametersFile' not found"); + exit(self::EXIT_PARAMETERS_NOT_FOUND); + } + } + + private function checkCreateDatabase(OutputInterface $output) + { + $output->writeln( + sprintf( + 'Creating database %s if it does not exist, using doctrine:database:create --if-not-exists', + $this->connection->getDatabase() + ) + ); + try { + $bufferedOutput = new BufferedOutput(); + $connectionName = $this->repositoryConfigurationProvider->getStorageConnectionName(); + $command = sprintf('doctrine:database:create --if-not-exists --connection=%s', $connectionName); + $this->executeCommand($bufferedOutput, $command); + $output->writeln($bufferedOutput->fetch()); + } catch (\RuntimeException $exception) { + $this->output->writeln( + sprintf( + "The configured database '%s' does not exist or cannot be created (%s).", + $this->connection->getDatabase(), + $exception->getMessage() + ) + ); + $this->output->writeln("Please check the database configuration in 'app/config/parameters.yml'"); + exit(self::EXIT_GENERAL_DATABASE_ERROR); + } + } + + /** + * Clear all content related cache (persistence cache). + * + * @param \Symfony\Component\Console\Output\OutputInterface $output + */ + private function cacheClear(OutputInterface $output) + { + $this->cachePool->clear(); + } + + /** + * Calls indexing commands. + * + * @todo This should not be needed once/if the Installer starts using API in the future. + * So temporary measure until it is not raw SQL based for the data itself (as opposed to the schema). + * This is done after cache clearing to make sure no cached data from before sql import is used. + * + * IMPORTANT: This is done using a command because config has change, so container and all services are different. + * + * @param \Symfony\Component\Console\Output\OutputInterface $output + * @param string|null $siteaccess + */ + private function indexData(OutputInterface $output, $siteaccess = null) + { + $output->writeln( + sprintf('Search engine re-indexing, executing command ibexa:reindex') + ); + + $command = 'ibexa:reindex'; + if ($siteaccess) { + $command .= sprintf(' --siteaccess=%s', $siteaccess); + } + + $this->executeCommand($output, $command); + } + + /** + * @param $type + * + * @return \Ibexa\Bundle\RepositoryInstaller\Installer\Installer + */ + private function getInstaller($type) + { + if (!isset($this->installers[$type])) { + return false; + } + + return $this->installers[$type]; + } + + /** + * Executes a Symfony command in separate process. + * + * Typically useful when configuration has changed, or you are outside of Symfony context (Composer commands). + * + * Based on {@see \Sensio\Bundle\DistributionBundle\Composer\ScriptHandler::executeCommand}. + * + * @param \Symfony\Component\Console\Output\OutputInterface $output + * @param string $cmd Ibexa command to execute, like 'ezplatform:solr_create_index' + * Escape any user provided arguments, like: 'assets:install '.escapeshellarg($webDir) + * @param int $timeout + */ + private function executeCommand(OutputInterface $output, $cmd, $timeout = 300) + { + $phpFinder = new PhpExecutableFinder(); + if (!$phpPath = $phpFinder->find(false)) { + throw new \RuntimeException('The php executable could not be found. Add it to your PATH environment variable and try again'); + } + + // We don't know which php arguments where used so we gather some to be on the safe side + $arguments = $phpFinder->findArguments(); + if (false !== ($ini = php_ini_loaded_file())) { + $arguments[] = '--php-ini=' . $ini; + } + + // Pass memory_limit in case this was specified as php argument, if not it will most likely be same as $ini. + if ($memoryLimit = ini_get('memory_limit')) { + $arguments[] = '-d memory_limit=' . $memoryLimit; + } + + $phpArgs = implode(' ', array_map('escapeshellarg', $arguments)); + $php = escapeshellarg($phpPath) . ($phpArgs ? ' ' . $phpArgs : ''); + + // Make sure to pass along relevant global Symfony options to console command + $console = escapeshellarg('bin/console'); + if ($output->getVerbosity() > OutputInterface::VERBOSITY_NORMAL) { + $console .= ' -' . str_repeat('v', $output->getVerbosity() - 1); + } + + if ($output->isDecorated()) { + $console .= ' --ansi'; + } + + $console .= ' --env=' . escapeshellarg($this->environment); + + $process = Process::fromShellCommandline( + implode(' ', [$php, $console, $cmd]), + null, + null, + null, + $timeout + ); + + $process->run(static function ($type, $buffer) use ($output) { $output->write($buffer, false); }); + if (!$process->getExitCode() === 1) { + throw new \RuntimeException(sprintf('An error occurred when executing the "%s" command.', escapeshellarg($cmd))); + } + } + + public function getDeprecatedAliases(): array + { + return ['ezplatform:upgrade']; + } +} + +class_alias(UpgradePlatformCommand::class, 'EzSystems\PlatformInstallerBundle\Command\UpgradePlatformCommand'); diff --git a/src/bundle/RepositoryInstaller/DependencyInjection/Compiler/UpgraderTagPass.php b/src/bundle/RepositoryInstaller/DependencyInjection/Compiler/UpgraderTagPass.php new file mode 100644 index 0000000000..2d2e85fd86 --- /dev/null +++ b/src/bundle/RepositoryInstaller/DependencyInjection/Compiler/UpgraderTagPass.php @@ -0,0 +1,52 @@ +hasDefinition(UpgradePlatformCommand::class)) { + return; + } + + $installCommandDef = $container->findDefinition(UpgradePlatformCommand::class); + $installers = []; + + foreach ($container->findTaggedServiceIds(self::INSTALLER_TAG) as $id => $tags) { + foreach ($tags as $tag) { + if (!isset($tag['type'])) { + throw new LogicException( + sprintf( + 'Service tag %s needs a "type" attribute to identify the installer. You need to provide a tag for %s.', + self::INSTALLER_TAG, + $id + ) + ); + } + + $installers[$tag['type']] = new Reference($id); + } + } + + $installCommandDef->replaceArgument('$installers', $installers); + } +} + +class_alias(UpgraderTagPass::class, 'EzSystems\PlatformInstallerBundle\DependencyInjection\Compiler\UpgraderTagPass'); diff --git a/src/bundle/RepositoryInstaller/IbexaRepositoryInstallerBundle.php b/src/bundle/RepositoryInstaller/IbexaRepositoryInstallerBundle.php index b5934d8080..cb419349a9 100644 --- a/src/bundle/RepositoryInstaller/IbexaRepositoryInstallerBundle.php +++ b/src/bundle/RepositoryInstaller/IbexaRepositoryInstallerBundle.php @@ -8,6 +8,7 @@ use Ibexa\Bundle\DoctrineSchema\DoctrineSchemaBundle; use Ibexa\Bundle\RepositoryInstaller\DependencyInjection\Compiler\InstallerTagPass; +use Ibexa\Bundle\RepositoryInstaller\DependencyInjection\Compiler\UpgraderTagPass; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Exception\RuntimeException; use Symfony\Component\HttpKernel\Bundle\Bundle; @@ -30,6 +31,7 @@ public function build(ContainerBuilder $container) parent::build($container); $container->addCompilerPass(new InstallerTagPass()); + $container->addCompilerPass(new UpgraderTagPass()); } } diff --git a/src/bundle/RepositoryInstaller/Installer/CoreUpgrader.php b/src/bundle/RepositoryInstaller/Installer/CoreUpgrader.php new file mode 100644 index 0000000000..a896fe99a5 --- /dev/null +++ b/src/bundle/RepositoryInstaller/Installer/CoreUpgrader.php @@ -0,0 +1,184 @@ +schemaImporter = $schemaImporter; + //$this->defaultTableOptions = $defaultTableOptions; + $this->previousType = $previousType; + $this->nextType = $nextType; + } + + /** + * Import Schema using schema.yaml files from new bundles + * + * @throws \Ibexa\Contracts\DoctrineSchema\Exception\InvalidConfigurationException + * @throws \Doctrine\DBAL\DBALException + */ + public function importSchema() + { + $schemaFiles = $this->findSchemaFiles(); + + $config = new SchemaConfig(); + $config->setDefaultTableOptions($this->defaultTableOptions); + + $schema = new Schema([], [], $config); + + $this->output->writeln( + sprintf( + 'Import %d schema files', + count($schemaFiles) + ) + ); + + foreach ($schemaFiles as $schemaFilePath) { + $this->schemaImporter->importFromFile($schemaFilePath, $schema); + } + + $databasePlatform = $this->db->getDatabasePlatform(); + $queries = array_map(function($query) { + return str_replace('CREATE TABLE', 'CREATE TABLE IF NOT EXISTS', $query); + }, $schema->toSql($databasePlatform)); + + $queriesCount = count($queries); + $this->output->writeln( + sprintf( + 'Executing %d queries on database %s (%s)', + $queriesCount, + $this->db->getDatabase(), + $databasePlatform->getName() + ) + ); + $progressBar = new ProgressBar($this->output); + $progressBar->start($queriesCount); + + foreach ($queries as $query) { + try { + @$this->db->exec($query); + } catch (DriverException $driverException) { + if (false === strpos($query, 'ALTER TABLE')) { + throw $driverException; + } + } + $progressBar->advance(1); + } + + $progressBar->finish(); + // go to the next line after ProgressBar::finish and add one more extra blank line for readability + $this->output->writeln(PHP_EOL); + // clear any leftover progress bar parts in the output buffer + $progressBar->clear(); + } + + /** + * @todo Have a nicer way to retrieve those schemas than this dirty hack + * + * @return string[] + */ + private function findSchemaFiles(): array { + $schemaFiles = []; + + $previousEditionPackage = str_replace('-', '/', $this->previousType); + $version=null; + foreach(json_decode(file_get_contents('vendor/composer/installed.json'), true)['packages'] as $package) { + if ($package['name'] === $previousEditionPackage) { + $version = $package['version']; + break; + } + } + if (is_null($version)) { + return $schemaFiles; //TODO: Throw a proper error instead + } + $nextEditionPackage = str_replace('-', '/', $this->nextType); + $nextPackageList = []; + foreach (json_decode(shell_exec("curl -s https://raw.githubusercontent.com/$nextEditionPackage/$version/composer.json"), true)['require'] as $requiredPackage=>$requiredVersion) { + if (0 === strpos($requiredPackage, 'ibexa/') && $requiredPackage !== $nextEditionPackage) { + $nextPackageList[] = $requiredPackage; + } + } + + foreach($nextPackageList as $package) { + foreach (['', 'storage/', 'storage/legacy/'] as $subDir) { + $schemaFile="vendor/$package/src/bundle/Resources/config/{$subDir}schema.yaml"; + if (file_exists($schemaFile)) { + $schemaFiles[] = $schemaFile; + } + } + } + + return $schemaFiles; + } + + public function importData() + { + // Already imported during previous install + //$this->runQueriesFromFile($this->getKernelSQLFileForDBMS('cleandata.sql')); + } + + /** + * @param \Doctrine\DBAL\Schema\Schema $newSchema + * @param \Doctrine\DBAL\Platforms\AbstractPlatform $databasePlatform + * + * @return string[] + */ + protected function getDropSqlStatementsForExistingSchema( + Schema $newSchema, + AbstractPlatform $databasePlatform + ): array { + $existingSchema = $this->db->getSchemaManager()->createSchema(); + $statements = []; + // reverse table order for clean-up (due to FKs) + $tables = array_reverse($newSchema->getTables()); + // cleanup pre-existing database + foreach ($tables as $table) { + if ($existingSchema->hasTable($table->getName())) { + $statements[] = $databasePlatform->getDropTableSQL($table); + } + } + + return $statements; + } + + /** + * Handle optional import of binary files to var folder. + */ + public function importBinaries() + { + } + + /** + * {@inheritdoc} + */ + public function createConfiguration() + { + } +} + +class_alias(CoreUpgrader::class, 'EzSystems\PlatformInstallerBundle\Installer\CoreUpgrader'); diff --git a/src/bundle/RepositoryInstaller/Resources/config/services.yml b/src/bundle/RepositoryInstaller/Resources/config/services.yml index 965404750b..4fa5472a65 100644 --- a/src/bundle/RepositoryInstaller/Resources/config/services.yml +++ b/src/bundle/RepositoryInstaller/Resources/config/services.yml @@ -16,6 +16,14 @@ services: tags: - { name: ibexa.installer, type: ibexa-oss } + Ibexa\Bundle\RepositoryInstaller\Installer\CoreUpgrader: + autowire: true + parent: Ibexa\Bundle\RepositoryInstaller\Installer\DbBasedInstaller + #arguments: + # $defaultTableOptions: [] # '%ibexa.schema.default_table_options%' + tags: + - { name: ibexa.upgrader, type: ibexa-oss } + Ibexa\Bundle\RepositoryInstaller\Command\InstallPlatformCommand: arguments: $connection: '@ibexa.persistence.connection' @@ -26,6 +34,16 @@ services: tags: - { name: console.command } + Ibexa\Bundle\RepositoryInstaller\Command\UpgradePlatformCommand: + arguments: + $connection: '@ibexa.persistence.connection' + $installers: [] + $cachePool: '@ibexa.cache_pool' + $environment: "%kernel.environment%" + $repositoryConfigurationProvider: '@Ibexa\Bundle\Core\ApiLoader\RepositoryConfigurationProvider' + tags: + - { name: console.command } + Ibexa\Bundle\RepositoryInstaller\Command\ValidatePasswordHashesCommand: arguments: $userStorage: '@Ibexa\Core\FieldType\User\UserStorage'