stepExecutedListener = $stepExecutedListener; $this->kernel = $kernel; $this->customReferenceResolver = $customReferenceResolver; } /** * Set up the command. * * Define the name, options and help text. */ protected function configure() { parent::configure(); $this ->setDescription('Execute available migration definitions.') // nb: when adding options, remember to forward them to sub-commands executed in 'separate-process' mode ->addOption('admin-login', 'a', InputOption::VALUE_REQUIRED, "Login of admin account used whenever elevated privileges are needed (user id 14 used by default)") ->addOption('clear-cache', 'c', InputOption::VALUE_NONE, "Clear the cache after the command finishes") ->addOption('default-language', 'l', InputOption::VALUE_REQUIRED, "Default language code that will be used if no language is provided in migration steps") ->addOption('force', 'f', InputOption::VALUE_NONE, "Force (re)execution of migrations already DONE, SKIPPED or FAILED. Use with great care!") ->addOption('ignore-failures', 'i', InputOption::VALUE_NONE, "Keep executing migrations even if one fails") ->addOption('no-interaction', 'n', InputOption::VALUE_NONE, "Do not ask any interactive question") ->addOption('no-transactions', 'u', InputOption::VALUE_NONE, "Do not use a repository transaction to wrap each migration. Unsafe, but needed for legacy slot handlers") ->addOption('path', null, InputOption::VALUE_OPTIONAL | InputOption::VALUE_IS_ARRAY, "The directory or file to load the migration definitions from") ->addOption('separate-process', 'p', InputOption::VALUE_NONE, "Use a separate php process to run each migration. Safe if your migration leak memory. A tad slower") ->addOption('force-sigchild-enabled', null, InputOption::VALUE_NONE, "When using a separate php process to run each migration, tell Symfony that php was compiled with --enable-sigchild option") ->addOption('survive-disconnected-tty', null, InputOption::VALUE_NONE, "Keep on executing migrations even if the tty where output is written to gets removed. Useful if you run the command over an unstable ssh connection") ->addOption('set-reference', null, InputOption::VALUE_OPTIONAL | InputOption::VALUE_IS_ARRAY, "Inject references into the migrations. Format: --set-reference refname:value --set-reference ref2name:value2") ->addOption('child', null, InputOption::VALUE_NONE, "*DO NOT USE* Internal option for when forking separate processes") ->setHelp(<<kaliop:migration:migrate command loads and executes migrations: ./ezpublish/console kaliop:migration:migrate You can optionally specify the path to migration definitions with --path: ./ezpublish/console kaliop:migrations:migrate --path=/path/to/bundle/version_directory --path=/path/to/bundle/version_directory/single_migration_file Use -v and -vv options to get troubleshooting information on the execution of each step in the migration(s). EOT ); } /** * Execute the command. * * @param InputInterface $input * @param OutputInterface $output * @return int 0 if everything went fine, or an error code */ protected function execute(InputInterface $input, OutputInterface $output) { $start = microtime(true); $this->setOutput($output); $this->setVerbosity($output->getVerbosity()); if ($input->getOption('child') && $output->getVerbosity() <= OutputInterface::VERBOSITY_NORMAL) { $this->setVerbosity(self::VERBOSITY_CHILD); } $this->stepExecutedListener->setOutput($output); $migrationService = $this->getMigrationService(); $migrationService->setOutput($output); $force = $input->getOption('force'); $toExecute = $this->buildMigrationsList($input->getOption('path'), $migrationService, $force); if (!count($toExecute)) { $output->writeln('No migrations to execute'); return 0; } $this->printMigrationsList($toExecute, $input, $output); if (!$input->getOption('child')) { // ask user for confirmation to make changes if (!$this->askForConfirmation($input, $output)) { return 0; } } if ($input->getOption('separate-process')) { $prefix = array(); $executableFinder = new PhpExecutableFinder(); if (false !== $php = $executableFinder->find()) { $prefix[] = $php; } $builderArgs = array_merge($prefix, $this->createChildProcessArgs($input)); } // For cli scripts, this means: do not die if anyone yanks out our stdout. if ($input->getOption('survive-disconnected-tty')) { ignore_user_abort(true); } // allow forcing handling of sigchild. Useful on eg. Debian and Ubuntu if ($input->getOption('force-sigchild-enabled')) { Process::forceSigchildEnabled(true); } if ($input->getOption('set-reference') && !$input->getOption('separate-process')) { foreach($input->getOption('set-reference') as $refSpec) { $ref = explode(':', $refSpec, 2); if (count($ref) < 2 || $ref[0] === '') { throw new \Exception("Invalid reference specification: '$refSpec'"); } $this->customReferenceResolver->addReference($ref[0], $ref[1], true); } } $aborted = false; $executed = 0; $failed = 0; $skipped = 0; $total = count($toExecute); /** @var MigrationDefinition $migrationDefinition */ foreach ($toExecute as $name => $migrationDefinition) { // let's skip migrations that we know are invalid - user was warned and he decided to proceed anyway if ($migrationDefinition->status == MigrationDefinition::STATUS_INVALID) { $output->writeln("Skipping $name"); $skipped++; continue; } if ($this->verbosity >= OutputInterface::VERBOSITY_VERBOSE) { $this->writeln("Processing $name (from definition $migrationDefinition->path)"); } else { $this->writeln("Processing $name"); } if ($input->getOption('separate-process')) { try { $this->executeMigrationInSeparateProcess($migrationDefinition, $migrationService, $builderArgs); $executed++; } catch (\Exception $e) { $failed++; $errorMessage = $e->getMessage(); // we probably have already echoed the error message while the subprocess was executing, avoid repeating it if ($errorMessage != $this->subProcessErrorString) { if ($e instanceof AfterMigrationExecutionException) { $errorMessage = "Failure after migration end! Reason: " . $errorMessage; } else { $errorMessage = "Migration failed! Reason: " . $errorMessage; } $this->writeErrorln("\n$errorMessage"); } if (!$input->getOption('ignore-failures')) { $aborted = true; break; } } } else { try { $this->executeMigrationInProcess($migrationDefinition, $force, $migrationService, $input); $executed++; } catch (\Exception $e) { $failed++; $this->writeErrorln("\nMigration failed! Reason: " . $e->getMessage() . ""); if (!$input->getOption('ignore-failures')) { $aborted = true; break; } } } } $missed = $total - $executed - $failed - $skipped; if ($aborted) { if ($missed > 0) { $this->writeErrorln("\nMigration execution aborted"); } } else { // NB: as per the Sf doc at https://symfony.com/doc/2.7/console/calling_commands.html, the 'cache:clear' // command should be run 'at the end', as they change some class definitions if ($input->getOption('clear-cache')) { $command = $this->getApplication()->find('cache:clear'); $inputArray = new ArrayInput(array('command' => 'cache:clear')); $command->run($inputArray, $output); } } $this->writeln("\nExecuted $executed migrations, failed $failed, skipped $skipped" . ($missed ? ", missed $missed" : '')); $time = microtime(true) - $start; if ($input->getOption('separate-process')) { // in case of using subprocesses, we can not measure max memory used $this->writeln("Time taken: ".sprintf('%.3f', $time)." secs"); } else { $this->writeln("Time taken: ".sprintf('%.3f', $time)." secs, memory: ".sprintf('%.2f', (memory_get_peak_usage(true) / 1000000)). ' MB'); } return $failed; } /** * @param MigrationDefinition $migrationDefinition * @param bool $force * @param MigrationService $migrationService * @param InputInterface $input */ protected function executeMigrationInProcess($migrationDefinition, $force, $migrationService, $input) { $migrationService->executeMigration( $migrationDefinition, !$input->getOption('no-transactions'), $input->getOption('default-language'), $input->getOption('admin-login'), $force ); } /** * @param MigrationDefinition $migrationDefinition * @param MigrationService $migrationService * @param array $builderArgs * @param bool $feedback */ protected function executeMigrationInSeparateProcess($migrationDefinition, $migrationService, $builderArgs, $feedback = true) { $process = new Process(array_merge($builderArgs, array('--path=' . $migrationDefinition->path))); if ($feedback) { $this->writeln('Executing: ' . $process->getCommandLine() . '', OutputInterface::VERBOSITY_VERBOSE); } $this->subProcessErrorString = ''; // allow long migrations processes by default $process->setTimeout($this->subProcessTimeout); // and give immediate feedback to the user... // NB: if the subprocess writes to stderr then terminates with non-0 exit code, this will lead us to echoing the // error text twice, once here and once at the end of execution of this command. // In order to avoid that, since we can not know at this time what the subprocess exit code will be, we // do print the error text now, and compare it to what we gt at the end... $process->run( $feedback ? function($type, $buffer) { if ($type == 'err') { $this->subProcessErrorString .= $buffer; $this->writeErrorln($buffer, OutputInterface::VERBOSITY_QUIET, OutputInterface::OUTPUT_RAW); } else { // swallow output of child processes in quiet mode $this->writeLn($buffer, self::VERBOSITY_CHILD, OutputInterface::OUTPUT_RAW); } } : function($type, $buffer) { } ); if (!$process->isSuccessful()) { $errorOutput = $process->getErrorOutput(); /// @todo should we always add the exit code to the error message, even when $errorOutput is not null ? if ($errorOutput === '') { $errorOutput = "(separate process used to execute migration failed with no stderr output. Its exit code was: " . $process->getExitCode(); if ($process->getExitCode() == -1) { $errorOutput .= ". If you are using Debian or Ubuntu linux, please consider using the --force-sigchild-enabled option."; } $errorOutput .= ")"; } throw new \Exception($errorOutput); } // There are cases where the separate process dies halfway but does not return a non-zero code. // That's why we double-check here if the migration is still tagged as 'started'... /** @var Migration $migration */ $migration = $migrationService->getMigration($migrationDefinition->name); if (!$migration) { // q: shall we add the migration to the db as failed? In doubt, we let it become a ghost, disappeared without a trace... throw new \Exception("After the separate process charged to execute the migration finished, the migration can not be found in the database any more."); } else if ($migration->status == Migration::STATUS_STARTED) { $errorMsg = "The separate process charged to execute the migration left it in 'started' state. Most likely it died halfway through execution."; $migrationService->endMigration(New Migration( $migration->name, $migration->md5, $migration->path, $migration->executionDate, Migration::STATUS_FAILED, ($migration->executionError != '' ? ($errorMsg . ' ' . $migration->executionError) : $errorMsg) )); throw new \Exception($errorMsg); } } /** * @param string[] $paths * @param MigrationService $migrationService * @param bool $force when true, look not only for TODO migrations, but also DONE, SKIPPED, FAILED ones (we still omit STARTED and SUSPENDED ones) * @return MigrationDefinition[] * * @todo optimize. This does not scale well with many definitions or migrations */ protected function buildMigrationsList($paths, $migrationService, $force = false) { $migrationDefinitions = $migrationService->getMigrationsDefinitions($paths); $migrations = $migrationService->getMigrations(); $allowedStatuses = array(Migration::STATUS_TODO); if ($force) { $allowedStatuses = array_merge($allowedStatuses, array(Migration::STATUS_DONE, Migration::STATUS_FAILED, Migration::STATUS_SKIPPED)); } // filter away all migrations except 'to do' ones $toExecute = array(); foreach ($migrationDefinitions as $name => $migrationDefinition) { if (!isset($migrations[$name]) || (($migration = $migrations[$name]) && in_array($migration->status, $allowedStatuses))) { $toExecute[$name] = $migrationService->parseMigrationDefinition($migrationDefinition); } } // if user wants to execute 'all' migrations: look for those which are registered in the database even if not // found by the loader if (empty($paths)) { foreach ($migrations as $migration) { if (in_array($migration->status, $allowedStatuses) && !isset($toExecute[$migration->name])) { try { $migrationDefinitions = $migrationService->getMigrationsDefinitions(array($migration->path)); if (count($migrationDefinitions)) { // q: shall we raise a warning here if migrations found > 1? $migrationDefinition = $migrationDefinitions->reset(); $toExecute[$migration->name] = $migrationService->parseMigrationDefinition($migrationDefinition); } else { throw new \Exception("Migration definition not found at path '$migration->path'"); } } catch (\Exception $e) { $this->writeErrorln("Error while loading definition for migration '{$migration->name}' registered in the database, skipping it: " . $e->getMessage()); } } } } ksort($toExecute); return $toExecute; } /** * @param MigrationDefinition[] $toExecute * @param InputInterface $input * @param OutputInterface $output * * @todo use a more compact output when there are *many* migrations */ protected function printMigrationsList($toExecute, InputInterface $input, OutputInterface $output) { $data = array(); $i = 1; foreach ($toExecute as $name => $migrationDefinition) { $notes = ''; if ($migrationDefinition->status != MigrationDefinition::STATUS_PARSED) { $notes = '' . $migrationDefinition->parsingError . ''; } $data[] = array( $i++, $name, $notes ); } if (!$input->getOption('child')) { $table = new Table($output); $table ->setHeaders(array('#', 'Migration', 'Notes')) ->setRows($data); $table->render(); } $this->writeln(''); } protected function askForConfirmation(InputInterface $input, OutputInterface $output, $nonIteractiveOutput = "=============================================\n") { if ($input->isInteractive() && !$input->getOption('no-interaction')) { $dialog = $this->getHelperSet()->get('question'); if (!$dialog->ask( $input, $output, new ConfirmationQuestion('Careful, the database will be modified. Do you want to continue Y/N ?', false) ) ) { $output->writeln('Migration execution cancelled!'); return 0; } } else { if ($nonIteractiveOutput != '') { $this->writeln("$nonIteractiveOutput"); } } return 1; } /** * Returns the command-line arguments needed to execute a migration in a separate subprocess * (except path, which should be added after this call) * @param InputInterface $input * @return array * @todo check if it is a good idea to pass on the current verbosity */ protected function createChildProcessArgs(InputInterface $input) { $kernel = $this->kernel; // mandatory args and options $builderArgs = array( $this->getConsoleFile(), // sf console static::$defaultName, // name of sf command '--env=' . $kernel->getEnvironment(), // sf env '--child' ); // sf/ez env options if (!$kernel->isDebug()) { $builderArgs[] = '--no-debug'; } if ($input->getOption('siteaccess')) { $builderArgs[] = '--siteaccess='.$input->getOption('siteaccess'); } switch ($this->verbosity) { case OutputInterface::VERBOSITY_VERBOSE: $builderArgs[] = '-v'; break; case OutputInterface::VERBOSITY_VERY_VERBOSE: $builderArgs[] = '-vv'; break; case OutputInterface::VERBOSITY_DEBUG: $builderArgs[] = '-vvv'; break; } // 'optional' options // note: options 'clear-cache', 'ignore-failures', 'no-interaction', 'path', 'separate-process' and 'survive-disconnected-tty' we never propagate if ($input->getOption('admin-login')) { $builderArgs[] = '--admin-login=' . $input->getOption('admin-login'); } if ($input->getOption('default-language')) { $builderArgs[] = '--default-language=' . $input->getOption('default-language'); } if ($input->getOption('force')) { $builderArgs[] = '--force'; } // useful in case the subprocess has a migration step of type process/run if ($input->getOption('force-sigchild-enabled')) { $builderArgs[] = '--force-sigchild-enabled'; } if ($input->getOption('no-transactions')) { $builderArgs[] = '--no-transactions'; } if ($input->getOption('set-reference')) { foreach($input->getOption('set-reference') as $refSpec) { $builderArgs[] = '--set-reference=' . $refSpec; } } return $builderArgs; } /** * Returns the file-path of the symfony console in use, based on simple heuristics * @return string * @todo improve how we look for the console: we could fe. scan all of the files in the kernel dir, or look up the full process info based on its pid */ protected function getConsoleFile() { if (strpos($_SERVER['argv'][0], 'phpunit') !== false) { $projectDir = $this->kernel->getProjectDir(); //getRootDir(); if (is_file("$projectDir/bin/console")) { return "$projectDir/bin/console"; } throw new \Exception("Can not determine the name of the symfony console file in use for running as separate process"); } return $_SERVER['argv'][0]; // sf console } }