diff --git a/docs/deploycommand.md b/docs/deploycommand.md index a12c222eb1..bfce2abc8d 100644 --- a/docs/deploycommand.md +++ b/docs/deploycommand.md @@ -25,6 +25,36 @@ documentation][HOOK_update_N()] for more details. | [HOOK_post_update_NAME()](https://api.drupal.org/api/drupal/core!lib!Drupal!Core!Extension!module.api.php/function/hook_post_update_NAME) | Drupal | Runs *before* config is imported. | | [HOOK_deploy_NAME()](https://github.com/drush-ops/drush/tree/HEAD/drush.api.php) | Drush | Runs *after* config is imported. | +## Deploy Hook Commands + +Drush provides several commands to manage deploy hooks: + +| Command | Purpose | +| --- | --- | +| `deploy:hook` | Run pending deploy update hooks. | +| `deploy:hook-status` | Show the status of pending deploy hooks. | +| `deploy:mark-complete` | Skip all pending deploy hooks and mark them as complete. | +| `deploy:hook-list` | List all deployed hooks that have been run. | +| `deploy:hook-unset` | Remove a specific hook from the deployed hooks list. | +| `deploy:redeploy` | Re-run a specific deployed hook. | + +### Examples + +List all deployed hooks: +```shell +drush deploy:hook-list +``` + +Remove a specific hook from the deployed hooks list: +```shell +drush deploy:hook-unset hook_deploy_NAME +``` + +Re-run a specific deployed hook: +```shell +drush deploy:redeploy hook_deploy_NAME +``` + ## Configuration If you need to customize this command, you should use Drush configuration for the diff --git a/docs/module-schema.md b/docs/module-schema.md new file mode 100644 index 0000000000..8ba6f0968b --- /dev/null +++ b/docs/module-schema.md @@ -0,0 +1,46 @@ +# Module Schema Commands + +## Overview + +The module schema commands allow you to manage the schema version of Drupal modules. + +In Drupal, each module maintains a schema version number that is used to track which database updates have been applied. When a module is updated, it may include update hooks (`hook_update_N()`) that need to be run to update the database schema or data. Drupal's update system uses the stored schema version to determine which updates need to be applied. + +## Available Commands + +### module:schema-set (mss) + +Set the schema version for a module. + +```bash +drush module:schema-set +``` + +#### Arguments + +- `module`: The machine name of the module. +- `version`: The schema version to set. + +#### Examples + +```bash +# Set the schema version for system module to 8000 +drush module:schema-set system 8000 + +# Set the schema version for a custom module to 8001 +drush mss custom_module 8001 +``` + +#### Use Cases + +This command is useful in several scenarios: + +1. **Development**: When developing update hooks, you may need to reset the schema version to test your updates. +2. **Troubleshooting**: If an update fails and you need to re-run it, you can set the schema version to a previous value. +3. **Migration**: When migrating a site, you might need to adjust schema versions to match the expected state. +4. **Testing**: For testing update paths or simulating specific module states. + +#### Notes + +- The module must be installed and enabled for this command to work. +- Use with caution in production environments, as setting incorrect schema versions can lead to update hooks being skipped or run multiple times. \ No newline at end of file diff --git a/src/Commands/core/DeployHookCommands.php b/src/Commands/core/DeployHookCommands.php index a5ae21b0c9..9d3559e18e 100644 --- a/src/Commands/core/DeployHookCommands.php +++ b/src/Commands/core/DeployHookCommands.php @@ -20,6 +20,8 @@ use Drush\Log\SuccessInterface; use Psr\Log\LogLevel; +use function OpenTelemetry\Instrumentation\hook; + final class DeployHookCommands extends DrushCommands { use AutowireTrait; @@ -28,6 +30,10 @@ final class DeployHookCommands extends DrushCommands const HOOK = 'deploy:hook'; const BATCH_PROCESS = 'deploy:batch-process'; const MARK_COMPLETE = 'deploy:mark-complete'; + const HOOK_LIST = 'deploy:hook-list'; + const HOOK_UNSET = 'deploy:hook-unset'; + const UPDATE_TYPE = '_deploy_'; + const HOOK_REDEPLOY = 'deploy:redeploy'; public function __construct( private readonly SiteAliasManagerInterface $siteAliasManager @@ -119,38 +125,7 @@ public function run(): int throw new UserAbortException(); } - $success = true; - if (!$this->getConfig()->simulate()) { - $operations = []; - foreach ($pending as $function) { - $operations[] = ['\Drush\Commands\core\DeployHookCommands::updateDoOneDeployHook', [$function]]; - } - - $batch = [ - 'operations' => $operations, - 'title' => 'Updating', - 'init_message' => 'Starting deploy hooks', - 'error_message' => 'An unrecoverable error has occurred. You can find the error message below. It is advised to copy it to the clipboard for reference.', - 'finished' => [$this, 'updateFinished'], - ]; - batch_set($batch); - $result = drush_backend_batch_process(self::BATCH_PROCESS); - - $success = false; - if (!is_array($result)) { - $this->logger()->error(dt('Batch process did not return a result array. Returned: !type', ['!type' => gettype($result)])); - } elseif (!empty($result[0]['#abort'])) { - // Whenever an error occurs the batch process does not continue, so - // this array should only contain a single item, but we still output - // all available data for completeness. - $this->logger()->error(dt('Update aborted by: !process', [ - '!process' => implode(', ', $result[0]['#abort']), - ])); - } else { - $success = true; - } - } - + $success = $this->batchOperation($pending); $level = $success ? SuccessInterface::SUCCESS : LogLevel::ERROR; $this->logger()->log($level, dt('Finished performing deploy hooks.')); return $success ? self::EXIT_SUCCESS : self::EXIT_FAILURE; @@ -283,4 +258,201 @@ public function markComplete(): int $this->logger()->success(dt('Marked %count pending deploy hooks as complete.', ['%count' => count($pending)])); return self::EXIT_SUCCESS; } + + /** + * Prints information about deployed hooks. + * + * @return \Consolidation\OutputFormatters\StructuredData\RowsOfFields + */ + #[CLI\Command(name: self::HOOK_LIST)] + #[CLI\Usage(name: 'drush deploy:hook-list', description: 'Prints information about deployed hooks.')] + #[CLI\FieldLabels(labels: [ + 'module' => 'Module', + 'hook' => 'Hook', + ])] + #[CLI\DefaultTableFields(fields: ['module', 'hook'])] + #[CLI\FilterDefaultField(field: 'hook')] + #[CLI\Topics(topics: [DocsCommands::DEPLOY])] + #[CLI\Bootstrap(level: DrupalBootLevels::FULL)] + public function list(): RowsOfFields + { + $update_functions = $this->getDeployedHooks(); + + $updates = []; + foreach ($update_functions as $function) { + // Validate function name format. + if (!str_contains($function, self::UPDATE_TYPE)) { + $this->logger()->warning("Skipping invalid hook function: {function}", ['function' => $function]); + continue; + } + + // Split function name into extension and update. + [$extension, $update] = explode(self::UPDATE_TYPE, $function); + if (empty($extension) || empty($update)) { + $this->logger()->warning("Invalid hook function format: {function}", ['function' => $function]); + continue; + } + + // Store the update data. + $updates[$extension]['deployed'][$update] = true; + if (!isset($updates[$extension]['start'])) { + $updates[$extension]['start'] = $update; + } + } + $rows = []; + foreach ($updates as $module => $update_data) { + foreach ($update_data['deployed'] as $hook => $value) { + $rows[] = [ + 'module' => $module, + 'hook' => $hook, + ]; + } + } + return new RowsOfFields($rows); + } + + /** + * Unsets a hook from the deployed hooks list. + * + * @param string $hook_name The name of the hook to remove (e.g., hook_deploy_NAME) + * + * @return int Exit code + */ + #[CLI\Command(name: self::HOOK_UNSET)] + #[CLI\Argument(name: 'hook_name', description: 'The name of the hook to remove (e.g., hook_deploy_NAME)')] + #[CLI\Usage( + name: 'drush deploy:hook-unset hook_deploy_NAME', + description: 'Removes the specified hook from the deployed hooks list' + )] + #[CLI\Topics(topics: [DocsCommands::DEPLOY])] + #[CLI\Bootstrap(level: DrupalBootLevels::FULL)] + public function unset(string $hook_name): int + { + $deployed_hooks = $this->getDeployedHooks(); + // Check if the hook exists. + if (!in_array($hook_name, $deployed_hooks, true)) { + $this->logger()->warning("Hook {hook} not found in deployed hooks.", [ + 'hook' => $hook_name + ]); + return self::EXIT_SUCCESS; + } + // Remove the hook from the list. + $update_functions = array_filter($deployed_hooks, function ($function) use ($hook_name) { + return $function !== $hook_name; + }); + + // Update the deployed hook list. + \Drupal::service('keyvalue')->get('deploy_hook')->set('existing_updates', $update_functions); + $this->logger()->success(dt('Hook !hook_name removed from deployed hooks list.', [ + '!hook_name' => $hook_name + ])); + return self::EXIT_SUCCESS; + } + + /** + * Redeploys a hook. + * + * @param string $hook_name + * The name of the hook to redeploy (e.g., hook_deploy_NAME) + * + * @return int + * Exit code. + * @throws \Drush\Exceptions\UserAbortException + */ + #[CLI\Command(name: self::HOOK_REDEPLOY)] + #[CLI\Argument(name: 'hook_name', description: 'The name of the hook to redeploy (e.g., hook_deploy_NAME)')] + #[CLI\Usage( + name: 'drush deploy:redeploy hook_deploy_NAME', + description: 'Redeploys the specified hook' + )] + #[CLI\Version(version: '10.3')] + #[CLI\Topics(topics: [DocsCommands::DEPLOY])] + #[CLI\Bootstrap(level: DrupalBootLevels::FULL)] + public function redeploy(string $hook_name): int + { + $deployed_hooks = $this->getDeployedHooks(); + if (!in_array($hook_name, $deployed_hooks)) { + $this->logger()->success("Hook {$hook_name} not found in deployed hooks.", [ + 'hook' => $hook_name + ]); + return self::EXIT_SUCCESS; + } + + if (!$this->io()->confirm(dt('Do you wish to run the specified deployed hooks?'))) { + throw new UserAbortException(); + } + // Build and run the batch process. + $success = $this->batchOperation([$hook_name]); + $level = $success ? SuccessInterface::SUCCESS : LogLevel::ERROR; + $this->logger()->log($level, dt('Finished performing re-deploy hooks.')); + return $success ? self::EXIT_SUCCESS : self::EXIT_FAILURE; + } + + + /** + * Get all deployed hooks. + * + * @return array + */ + public function getDeployedHooks(): array + { + $store = \Drupal::service('keyvalue')->get('deploy_hook'); + return $store->get('existing_updates', []); + } + + /** + * Build the batch process to run the deployment hooks. + * + * @param array $hooks + * An array of function names to be executed as deploy hooks. + * + * @return bool + * TRUE if the batch process was started successfully, FALSE otherwise. + */ + public function batchOperation(array $hooks): bool + { + $success = true; + if (!$this->getConfig()->simulate()) { + $operations = []; + foreach ($hooks as $function) { + $operations[] + = [ + '\Drush\Commands\core\DeployHookCommands::updateDoOneDeployHook', + [$function] + ]; + } + + $batch = [ + 'operations' => $operations, + 'title' => 'Updating', + 'init_message' => 'Starting deploy hooks', + 'error_message' => 'An unrecoverable error has occurred. You can find the error message below. + It is advised to copy it to the clipboard for reference.', + 'finished' => [$this, 'updateFinished'], + ]; + batch_set($batch); + $result = drush_backend_batch_process(self::BATCH_PROCESS); + + $success = false; + if (!is_array($result)) { + $this->logger()->error( + dt( + 'Batch process did not return a result array. Returned: !type', + ['!type' => gettype($result)] + ) + ); + } elseif (!empty($result[0]['#abort'])) { + // Whenever an error occurs, the batch process does not continue, so + // this array should only contain a single item, but we still output + // all available data for completeness. + $this->logger()->error(dt('Update aborted by: !process', [ + '!process' => implode(', ', $result[0]['#abort']), + ])); + } else { + $success = true; + } + } + + return $success; + } } diff --git a/src/Commands/core/ModuleCommands.php b/src/Commands/core/ModuleCommands.php new file mode 100644 index 0000000000..e7e3950866 --- /dev/null +++ b/src/Commands/core/ModuleCommands.php @@ -0,0 +1,56 @@ +moduleExists($module)) { + throw new \Exception(dt('Module @module does not exist or is not installed.', ['@module' => $module])); + } + + // Set the schema version + try { + \Drupal::service('update.update_hook_registry')->setInstalledVersion($module, $version); + $this->logger()->success(dt('Schema version for module @module set to @version.', [ + '@module' => $module, + '@version' => $version, + ])); + } catch (\Exception $e) { + throw new \Exception(dt('Could not set schema version for module @module: @error', [ + '@module' => $module, + '@error' => $e->getMessage(), + ])); + } + } +} diff --git a/tests/functional/DeployHookTest.php b/tests/functional/DeployHookTest.php index 9ed4feed07..e0faee6ff2 100644 --- a/tests/functional/DeployHookTest.php +++ b/tests/functional/DeployHookTest.php @@ -115,4 +115,134 @@ public function testDeployHooksInModuleWithDeployInName() $this->assertStringContainsString('[notice] Performed: woot_deploy_deploy_function', $this->getErrorOutput()); $this->assertStringContainsString('[success] Finished performing deploy hooks.', $this->getErrorOutput()); } + + /** + * Test the deploy:hook-list command. + */ + public function testDeployHookList() + { + $this->setUpDrupal(1, true); + $options = [ + 'yes' => null, + ]; + $this->drush(PmCommands::INSTALL, ['woot'], $options); + + // Run deploy hooks to create some deployed hooks. + $this->drush(DeployHookCommands::HOOK, [], $options, null, null, self::EXIT_ERROR); + + // Set the drupal state so that the failing hook passes + $this->drush(StateCommands::SET, ['woot_deploy_pass', 'true'], [], null, null, self::EXIT_SUCCESS); + + // Run deploy hooks again to complete all hooks. + $this->drush(DeployHookCommands::HOOK, [], $options, null, null, self::EXIT_SUCCESS); + + // Test the hook-list command. + $options = [ + 'format' => 'json' + ]; + $this->drush(DeployHookCommands::HOOK_LIST, [], $options, null, null, self::EXIT_SUCCESS); + + $output = $this->getOutputFromJSON(); + $this->assertNotEmpty($output); + + // Check that the expected hooks are in the list + $found_hooks = [ + 'woot_deploy_a' => false, + 'woot_deploy_batch' => false, + 'woot_deploy_failing' => false, + ]; + + foreach ($output as $hook) { + if ($hook['module'] === 'woot' && $hook['hook'] === 'a') { + $found_hooks['woot_deploy_a'] = true; + } + if ($hook['module'] === 'woot' && $hook['hook'] === 'batch') { + $found_hooks['woot_deploy_batch'] = true; + } + if ($hook['module'] === 'woot' && $hook['hook'] === 'failing') { + $found_hooks['woot_deploy_failing'] = true; + } + } + + $this->assertTrue($found_hooks['woot_deploy_a'], 'Hook woot_deploy_a should be in the list'); + $this->assertTrue($found_hooks['woot_deploy_batch'], 'Hook woot_deploy_batch should be in the list'); + $this->assertTrue($found_hooks['woot_deploy_failing'], 'Hook woot_deploy_failing should be in the list'); + } + + /** + * Test the deploy:hook-unset command. + */ + public function testDeployHookUnset() + { + $this->setUpDrupal(1, true); + $options = [ + 'yes' => null, + ]; + $this->drush(PmCommands::INSTALL, ['woot'], $options); + + // Run deploy hooks to create some deployed hooks. + $this->drush(DeployHookCommands::HOOK, [], $options, null, null, self::EXIT_ERROR); + + // Set the drupal state so that the failing hook passes + $this->drush(StateCommands::SET, ['woot_deploy_pass', 'true'], [], null, null, self::EXIT_SUCCESS); + + // Run deploy hooks again to complete all hooks. + $this->drush(DeployHookCommands::HOOK, [], $options, null, null, self::EXIT_SUCCESS); + + // Test the hook-unset command. + $this->drush(DeployHookCommands::HOOK_UNSET, ['woot_deploy_a'], [], null, null, self::EXIT_SUCCESS); + $this->assertStringContainsString( + '[success] Hook woot_deploy_a removed from deployed hooks list.', + $this->getErrorOutput() + ); + + // Verify the hook is no longer in the list + $options = [ + 'format' => 'json' + ]; + $this->drush(DeployHookCommands::HOOK_LIST, [], $options, null, null, self::EXIT_SUCCESS); + + $output = $this->getOutputFromJSON(); + $found_hook_a = false; + + foreach ($output as $hook) { + if ($hook['module'] === 'woot' && $hook['hook'] === 'a') { + $found_hook_a = true; + break; + } + } + + $this->assertFalse($found_hook_a, 'Hook woot_deploy_a should not be in the list after unset'); + } + + /** + * Test the deploy:redeploy command. + */ + public function testDeployHookRedeploy() + { + $this->setUpDrupal(1, true); + $options = [ + 'yes' => null, + ]; + $this->drush(PmCommands::INSTALL, ['woot'], $options); + + // Run deploy hooks to create some deployed hooks. + $this->drush(DeployHookCommands::HOOK, [], $options, null, null, self::EXIT_ERROR); + + // Set the drupal state so that the failing hook passes + $this->drush(StateCommands::SET, ['woot_deploy_pass', 'true'], [], null, null, self::EXIT_SUCCESS); + + // Run deploy hooks again to complete all hooks. + $this->drush(DeployHookCommands::HOOK, [], $options, null, null, self::EXIT_SUCCESS); + + // Test the redeploy command. + $this->drush(DeployHookCommands::HOOK_REDEPLOY, ['woot_deploy_a'], $options, null, null, self::EXIT_SUCCESS); + + $this->assertStringContainsString('[notice] Deploy hook started: woot_deploy_a', $this->getErrorOutput()); + $this->assertStringContainsString( + '[notice] This is the update message from woot_deploy_a', + $this->getErrorOutput() + ); + $this->assertStringContainsString('[success] Finished performing re-deploy hooks.', $this->getErrorOutput()); + } } diff --git a/tests/functional/ModuleSchemaTest.php b/tests/functional/ModuleSchemaTest.php new file mode 100644 index 0000000000..e1e7060337 --- /dev/null +++ b/tests/functional/ModuleSchemaTest.php @@ -0,0 +1,69 @@ +setUpDrupal(1, true); + $options = [ + 'yes' => null, + ]; + + // Install a test module + $this->drush(PmCommands::INSTALL, ['drush_empty_module'], $options); + + // Set the schema version to 8001 + $this->drush(ModuleCommands::SCHEMA_SET, ['drush_empty_module', '8001'], $options); + + // Verify the schema version was set correctly + $this->drush( + PhpCommands::EVAL, + [ + 'echo \Drupal::service("update.update_hook_registry")->getInstalledVersion("drush_empty_module");' + ], + $options + ); + $this->assertEquals('8001', trim($this->getOutput())); + + // Set the schema version to a different value + $this->drush(ModuleCommands::SCHEMA_SET, ['drush_empty_module', '8005'], $options); + + // Verify the schema version was updated + $this->drush( + PhpCommands::EVAL, + ['echo \Drupal::service("update.update_hook_registry")->getInstalledVersion("drush_empty_module");'], + $options + ); + $this->assertEquals('8005', trim($this->getOutput())); + + // Test with a non-existent module + $this->drush( + ModuleCommands::SCHEMA_SET, + ['non_existent_module', '8001'], + $options, + null, + null, + self::EXIT_ERROR + ); + $this->assertStringContainsString( + 'Module non_existent_module does not exist or is not installed', + $this->getErrorOutput() + ); + } +}